Files
api/src/routes/authProvider.ts
T
Chris Farhood abac9dfe6c Extract groombook/api from monorepo with CI workflow
- Add source code from apps/api
- Add packages/db and packages/types workspace dependencies
- Add GitHub Actions CI workflow (lint, typecheck, test, docker)
- Generate pnpm-lock.yaml
- Add .gitignore

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-11 01:26:56 +00:00

180 lines
5.3 KiB
TypeScript

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, authProviderConfig, encryptSecret } from "@groombook/db";
import { requireSuperUser } from "../middleware/rbac.js";
import { reinitAuth } from "../lib/auth.js";
export const authProviderRouter = new Hono();
const REDACTED = "••••••••";
const putAuthProviderSchema = 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"),
});
/** Minimal schema for the test endpoint — only issuer/internal URLs are needed for OIDC discovery. */
const authProviderTestSchema = z.object({
issuerUrl: z.string().url(),
internalBaseUrl: z.string().url().nullable().optional(),
});
/**
* GET /api/admin/auth-provider
* Returns the current provider config with clientSecret redacted.
* Returns 404 if no provider is configured.
*/
authProviderRouter.get(
"/",
requireSuperUser(),
async (c) => {
const db = getDb();
const [row] = await db
.select()
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
if (!row) {
return c.json({ error: "No auth provider configured" }, 404);
}
// Return with secret redacted
return c.json({
id: row.id,
providerId: row.providerId,
displayName: row.displayName,
issuerUrl: row.issuerUrl,
internalBaseUrl: row.internalBaseUrl,
clientId: row.clientId,
clientSecret: REDACTED,
scopes: row.scopes,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
);
/**
* PUT /api/admin/auth-provider
* Creates or replaces the auth provider config.
* The clientSecret is encrypted before storage.
*/
authProviderRouter.put(
"/",
requireSuperUser(),
zValidator("json", putAuthProviderSchema),
async (c) => {
const db = getDb();
const body = c.req.valid("json");
let encryptedSecret: string;
try {
encryptedSecret = encryptSecret(body.clientSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: `Failed to encrypt client secret: ${message}` }, 500);
}
// Upsert: delete existing rows then insert atomically
let row: typeof authProviderConfig.$inferSelect | undefined;
try {
[row] = await db.transaction(async (tx) => {
await tx.delete(authProviderConfig);
return tx.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();
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: `Failed to persist auth provider config: ${message}` }, 500);
}
if (!row) return c.json({ error: "Failed to create auth provider config" }, 500);
try {
await reinitAuth();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
}
return c.json({
id: row.id,
providerId: row.providerId,
displayName: row.displayName,
issuerUrl: row.issuerUrl,
internalBaseUrl: row.internalBaseUrl,
clientId: row.clientId,
clientSecret: REDACTED,
scopes: row.scopes,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
);
/**
* POST /api/admin/auth-provider/test
* Validates the provider config by hitting the OIDC discovery endpoint.
* Returns {ok: true, metadata} on success or {ok: false, error: string} on failure.
*/
authProviderRouter.post(
"/test",
requireSuperUser(),
zValidator("json", authProviderTestSchema),
async (c) => {
const body = c.req.valid("json");
const discoveryUrl = `${body.issuerUrl.replace(/\/$/, "")}/.well-known/openid-configuration`;
try {
const res = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
return c.json({ ok: false, error: `Discovery endpoint returned ${res.status}` });
}
const metadata = await res.json() as Record<string, unknown>;
return c.json({ ok: true, metadata });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ ok: false, error: message });
}
}
);
/**
* DELETE /api/admin/auth-provider
* Removes the auth provider config from the DB.
* After this, auth falls back to OIDC_* env vars.
*/
authProviderRouter.delete(
"/",
requireSuperUser(),
async (c) => {
const db = getDb();
await db.delete(authProviderConfig);
try {
await reinitAuth();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: `Failed to reinitialize auth: ${message}` }, 500);
}
return c.json({ ok: true });
}
);