import { eq } from "drizzle-orm"; import { isNonEmptyString, isStringArray, readJsonBody, requiredParam } from "@/worker/api/request"; import { oauthAccessTokens, oauthRefreshTokens } from "@/worker/db/schema"; import type { AppContext } from "@/worker/hono"; import { HttpError, secretJsonResponse } from "@/worker/http"; import { isSafeHttpUrl } from "@/worker/services/url"; interface CreateClientBody { name?: unknown; redirectUris?: unknown; scopes?: unknown; /** * Skip the OAuth consent screen for this client. Use only for first-party * internal RPs (anvil, bland, the test-client) — never for third-party * clients where the user must explicitly grant scope. */ skipConsent?: unknown; /** * Public homepage of the app. Stored as the OIDC `client_uri`; rendered * on the consent screen and the connected-apps page. */ uri?: unknown; } interface UpdateClientBody { name?: unknown; skipConsent?: unknown; uri?: unknown; } export const handleListClients = async (c: AppContext): Promise => { const result = await c.var.auth.api.getOAuthClients({ headers: c.req.raw.headers }); return c.json(result); }; export const handleCreateClient = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "admin.clients" }); const body = await readJsonBody(c); if (!isNonEmptyString(body.name) || !isStringArray(body.redirectUris) || body.redirectUris.length === 0) { throw new HttpError(400, "invalid_body", "name and redirectUris[] are required."); } const scopes = isStringArray(body.scopes) ? body.scopes : ["openid", "email", "profile"]; // Client homepage URLs must use HTTP(S), with HTTP limited to loopback. let clientUri: string | null = null; if (isNonEmptyString(body.uri)) { const trimmed = body.uri.trim(); if (!isSafeHttpUrl(trimmed)) { throw new HttpError(400, "invalid_uri", "uri must be an absolute https URL (http allowed only on loopback)."); } clientUri = trimmed; } const created = await c.var.auth.api.adminCreateOAuthClient({ body: { client_name: body.name, redirect_uris: body.redirectUris, scope: scopes.join(" "), token_endpoint_auth_method: "client_secret_basic", // Better Auth 1.7 requires native redirect policy for HTTP loopback // callbacks. Client authentication remains confidential via Basic. application_type: body.redirectUris.some((uri) => /^http:\/\//i.test(uri)) ? "native" : "web", grant_types: ["authorization_code"], response_types: ["code"], ...(clientUri ? { client_uri: clientUri } : {}), ...(body.skipConsent === true ? { skip_consent: true } : {}), }, headers: c.req.raw.headers, }); logger.info("oauth_client_created", { clientId: created.client_id, byUserId: c.var.session?.user.id, redirectUriCount: body.redirectUris.length, scopeCount: scopes.length, skipConsent: body.skipConsent === true, hasClientUri: clientUri !== null, }); // The plugin returns the plaintext client_secret exactly once. return secretJsonResponse(created, 201); }; // Scoped to display fields (name, skipConsent, client_uri). redirect_uris, // grant types, metadata, and secret rotation keep their own audit/handling // surface (rotation runs the token-cleanup batch; this PATCH does not). export const handleUpdateClient = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "admin.clients" }); const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." }); const body = await readJsonBody(c); const update: { client_name?: string; skip_consent?: boolean; client_uri?: string } = {}; const changed: string[] = []; if (body.name !== undefined) { if (!isNonEmptyString(body.name)) { throw new HttpError(400, "invalid_name", "name must be a non-empty string."); } update.client_name = body.name; changed.push("name"); } if (body.skipConsent !== undefined) { if (typeof body.skipConsent !== "boolean") { throw new HttpError(400, "invalid_skip_consent", "skipConsent must be a boolean."); } update.skip_consent = body.skipConsent; changed.push("skipConsent"); } if (body.uri !== undefined) { if (typeof body.uri !== "string") { throw new HttpError(400, "invalid_uri", "uri must be a string."); } const trimmed = body.uri.trim(); if (trimmed.length === 0) { // Better Auth's PATCH schema is `client_uri: z.string().optional()` // and rejects null, so a cleared field is stored as an empty string. // Consumers gate on truthiness or `isSafeHttpUrl`, both of which // treat "" the same as a missing value. update.client_uri = ""; } else if (!isSafeHttpUrl(trimmed)) { throw new HttpError(400, "invalid_uri", "uri must be an absolute https URL (http allowed only on loopback)."); } else { update.client_uri = trimmed; } changed.push("uri"); } if (changed.length === 0) { throw new HttpError(400, "invalid_body", "At least one of name, skipConsent, or uri is required."); } const result = await c.var.auth.api.adminUpdateOAuthClient({ body: { client_id: id, update }, headers: c.req.raw.headers, }); logger.info("oauth_client_updated", { clientId: id, byUserId: c.var.session?.user.id, fields: changed, }); return c.json(result); }; export const handleRotateClientSecret = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "admin.clients" }); const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." }); // Rotation is a kill-switch: existing access/refresh tokens for this // client must not survive the new secret. Validation at /userinfo and // /introspect only consults `oauth_access_tokens.token`, not the // client secret — so deleting tokens is the actual revocation step. // Run cleanup BEFORE rotateClientSecret so a partial-failure window // never leaves the new secret committed alongside live old tokens. // If rotate throws after cleanup, the admin retries: both halves are // idempotent (cleanup is a no-op when rows are already gone, rotate // replaces a still-current secret). The brief window between cleanup // and rotate has the old secret still valid, but with no usable // tokens to validate against — and a leaked-secret threat model is // exactly what the next call closes. const batchResult = await c.var.db.batch([ c.var.db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.clientId, id)), c.var.db.delete(oauthRefreshTokens).where(eq(oauthRefreshTokens.clientId, id)), ]); const result = await c.var.auth.api.rotateClientSecret({ body: { client_id: id }, headers: c.req.raw.headers, }); logger.info("oauth_client_secret_rotated", { clientId: id, byUserId: c.var.session?.user.id, accessTokensDeleted: batchResult[0]?.meta?.changes ?? null, refreshTokensDeleted: batchResult[1]?.meta?.changes ?? null, }); return secretJsonResponse(result); }; export const handleDeleteClient = async (c: AppContext): Promise => { const logger = c.var.log.child({ component: "admin.clients" }); const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." }); await c.var.auth.api.deleteOAuthClient({ body: { client_id: id }, headers: c.req.raw.headers, }); logger.info("oauth_client_deleted", { clientId: id, byUserId: c.var.session?.user.id }); return c.body(null, 204); };