feat(oobe): add conditional auth provider bootstrap step (GRO-392)
Backend: - GET /api/setup/status now returns showAuthProviderStep, authConfigExists, and authEnvVarsSet to inform the frontend whether to show the step - POST /api/setup/auth-provider: unauthenticated endpoint for first-time auth provider configuration during OOBE; guarded by needsSetup check (returns 403 after setup completes); encrypts clientSecret before storing Frontend: - SetupWizard fetches /api/setup/status on mount to determine if the auth provider step is needed (fresh install with no DB config and no OIDC env vars) - When needed, inserts the Auth Provider step after Welcome, before Business Name; includes full form with Test Connection button - Endpoint is POST /api/admin/auth-provider/test for connection testing Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, staff, businessSettings } from "@groombook/db";
|
||||
import { eq, getDb, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
@@ -17,7 +18,26 @@ setupRouter.get("/status", async (c) => {
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
return c.json({ needsSetup: !superUser });
|
||||
// Check if DB already has an auth provider config
|
||||
const [dbAuthConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
// Check if OIDC env vars are set (bootstrap mode)
|
||||
const oidcIssuer = process.env.OIDC_ISSUER;
|
||||
const oidcClientId = process.env.OIDC_CLIENT_ID;
|
||||
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const authEnvVarsSet = !!(oidcIssuer && oidcClientId && oidcClientSecret);
|
||||
|
||||
return c.json({
|
||||
needsSetup: !superUser,
|
||||
// Show auth provider bootstrap step when: fresh install (no super user) AND no DB config AND no env vars
|
||||
showAuthProviderStep: !superUser && !dbAuthConfig && !authEnvVarsSet,
|
||||
authConfigExists: !!dbAuthConfig,
|
||||
authEnvVarsSet,
|
||||
});
|
||||
});
|
||||
|
||||
const setupSchema = z.object({
|
||||
@@ -76,4 +96,86 @@ setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
|
||||
}
|
||||
|
||||
return c.json({ ok: true, staff: result.staff }, 201);
|
||||
});
|
||||
|
||||
// ─── Auth Provider Bootstrap ──────────────────────────────────────────────────
|
||||
|
||||
const authProviderBootstrapSchema = z.object({
|
||||
providerId: z.string().min(1).max(100),
|
||||
displayName: z.string().min(1).max(200),
|
||||
issuerUrl: z.string().url(),
|
||||
internalBaseUrl: z.string().url().nullable().optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.string().default("openid profile email"),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/setup/auth-provider
|
||||
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchema), async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
const [superUser] = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
.where(eq(staff.isSuperUser, true))
|
||||
.limit(1);
|
||||
|
||||
if (superUser) {
|
||||
// Setup already completed — lock this endpoint permanently
|
||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
||||
}
|
||||
|
||||
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
||||
const [existingConfig] = await db
|
||||
.select({ id: authProviderConfig.id })
|
||||
.from(authProviderConfig)
|
||||
.where(eq(authProviderConfig.enabled, true))
|
||||
.limit(1);
|
||||
|
||||
if (existingConfig) {
|
||||
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
|
||||
// Encrypt clientSecret before storing
|
||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||
|
||||
const [row] = await db
|
||||
.insert(authProviderConfig)
|
||||
.values({
|
||||
providerId: body.providerId,
|
||||
displayName: body.displayName,
|
||||
issuerUrl: body.issuerUrl,
|
||||
internalBaseUrl: body.internalBaseUrl ?? null,
|
||||
clientId: body.clientId,
|
||||
clientSecret: encryptedSecret,
|
||||
scopes: body.scopes,
|
||||
enabled: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
displayName: row.displayName,
|
||||
issuerUrl: row.issuerUrl,
|
||||
internalBaseUrl: row.internalBaseUrl,
|
||||
clientId: row.clientId,
|
||||
scopes: row.scopes,
|
||||
enabled: row.enabled,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}, 201);
|
||||
});
|
||||
Reference in New Issue
Block a user