abac9dfe6c
- 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>
180 lines
5.3 KiB
TypeScript
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 });
|
|
}
|
|
);
|