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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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 });
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user