Skip to content
File

Blob: src/worker/api/admin/clients.ts

typescript186 lines
1import { eq } from "drizzle-orm";
2 
3import { isNonEmptyString, isStringArray, readJsonBody, requiredParam } from "@/worker/api/request";
4import { oauthAccessTokens, oauthRefreshTokens } from "@/worker/db/schema";
5import type { AppContext } from "@/worker/hono";
6import { HttpError, secretJsonResponse } from "@/worker/http";
7import { isSafeHttpUrl } from "@/worker/services/url";
8 
9interface CreateClientBody {
10 name?: unknown;
11 redirectUris?: unknown;
12 scopes?: unknown;
13 /**
14 * Skip the OAuth consent screen for this client. Use only for first-party
15 * internal RPs (anvil, bland, the test-client) โ€” never for third-party
16 * clients where the user must explicitly grant scope.
17 */
18 skipConsent?: unknown;
19 /**
20 * Public homepage of the app. Stored as the OIDC `client_uri`; rendered
21 * on the consent screen and the connected-apps page.
22 */
23 uri?: unknown;
24}
25 
26interface UpdateClientBody {
27 name?: unknown;
28 skipConsent?: unknown;
29 uri?: unknown;
30}
31 
32export const handleListClients = async (c: AppContext): Promise<Response> => {
33 const result = await c.var.auth.api.getOAuthClients({ headers: c.req.raw.headers });
34 return c.json(result);
35};
36 
37export const handleCreateClient = async (c: AppContext): Promise<Response> => {
38 const logger = c.var.log.child({ component: "admin.clients" });
39 const body = await readJsonBody<CreateClientBody>(c);
40 if (!isNonEmptyString(body.name) || !isStringArray(body.redirectUris) || body.redirectUris.length === 0) {
41 throw new HttpError(400, "invalid_body", "name and redirectUris[] are required.");
42 }
43 const scopes = isStringArray(body.scopes) ? body.scopes : ["openid", "email", "profile"];
44 
45 // Client homepage URLs must use HTTP(S), with HTTP limited to loopback.
46 let clientUri: string | null = null;
47 if (isNonEmptyString(body.uri)) {
48 const trimmed = body.uri.trim();
49 if (!isSafeHttpUrl(trimmed)) {
50 throw new HttpError(400, "invalid_uri", "uri must be an absolute https URL (http allowed only on loopback).");
51 }
52 clientUri = trimmed;
53 }
54 
55 const created = await c.var.auth.api.adminCreateOAuthClient({
56 body: {
57 client_name: body.name,
58 redirect_uris: body.redirectUris,
59 scope: scopes.join(" "),
60 token_endpoint_auth_method: "client_secret_basic",
61 // Better Auth 1.7 requires native redirect policy for HTTP loopback
62 // callbacks. Client authentication remains confidential via Basic.
63 application_type: body.redirectUris.some((uri) => /^http:\/\//i.test(uri)) ? "native" : "web",
64 grant_types: ["authorization_code"],
65 response_types: ["code"],
66 ...(clientUri ? { client_uri: clientUri } : {}),
67 ...(body.skipConsent === true ? { skip_consent: true } : {}),
68 },
69 headers: c.req.raw.headers,
70 });
71 
72 logger.info("oauth_client_created", {
73 clientId: created.client_id,
74 byUserId: c.var.session?.user.id,
75 redirectUriCount: body.redirectUris.length,
76 scopeCount: scopes.length,
77 skipConsent: body.skipConsent === true,
78 hasClientUri: clientUri !== null,
79 });
80 
81 // The plugin returns the plaintext client_secret exactly once.
82 return secretJsonResponse(created, 201);
83};
84 
85// Scoped to display fields (name, skipConsent, client_uri). redirect_uris,
86// grant types, metadata, and secret rotation keep their own audit/handling
87// surface (rotation runs the token-cleanup batch; this PATCH does not).
88export const handleUpdateClient = async (c: AppContext): Promise<Response> => {
89 const logger = c.var.log.child({ component: "admin.clients" });
90 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." });
91 const body = await readJsonBody<UpdateClientBody>(c);
92 
93 const update: { client_name?: string; skip_consent?: boolean; client_uri?: string } = {};
94 const changed: string[] = [];
95 if (body.name !== undefined) {
96 if (!isNonEmptyString(body.name)) {
97 throw new HttpError(400, "invalid_name", "name must be a non-empty string.");
98 }
99 update.client_name = body.name;
100 changed.push("name");
101 }
102 if (body.skipConsent !== undefined) {
103 if (typeof body.skipConsent !== "boolean") {
104 throw new HttpError(400, "invalid_skip_consent", "skipConsent must be a boolean.");
105 }
106 update.skip_consent = body.skipConsent;
107 changed.push("skipConsent");
108 }
109 if (body.uri !== undefined) {
110 if (typeof body.uri !== "string") {
111 throw new HttpError(400, "invalid_uri", "uri must be a string.");
112 }
113 const trimmed = body.uri.trim();
114 if (trimmed.length === 0) {
115 // Better Auth's PATCH schema is `client_uri: z.string().optional()`
116 // and rejects null, so a cleared field is stored as an empty string.
117 // Consumers gate on truthiness or `isSafeHttpUrl`, both of which
118 // treat "" the same as a missing value.
119 update.client_uri = "";
120 } else if (!isSafeHttpUrl(trimmed)) {
121 throw new HttpError(400, "invalid_uri", "uri must be an absolute https URL (http allowed only on loopback).");
122 } else {
123 update.client_uri = trimmed;
124 }
125 changed.push("uri");
126 }
127 if (changed.length === 0) {
128 throw new HttpError(400, "invalid_body", "At least one of name, skipConsent, or uri is required.");
129 }
130 
131 const result = await c.var.auth.api.adminUpdateOAuthClient({
132 body: { client_id: id, update },
133 headers: c.req.raw.headers,
134 });
135 
136 logger.info("oauth_client_updated", {
137 clientId: id,
138 byUserId: c.var.session?.user.id,
139 fields: changed,
140 });
141 return c.json(result);
142};
143 
144export const handleRotateClientSecret = async (c: AppContext): Promise<Response> => {
145 const logger = c.var.log.child({ component: "admin.clients" });
146 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." });
147 // Rotation is a kill-switch: existing access/refresh tokens for this
148 // client must not survive the new secret. Validation at /userinfo and
149 // /introspect only consults `oauth_access_tokens.token`, not the
150 // client secret โ€” so deleting tokens is the actual revocation step.
151 // Run cleanup BEFORE rotateClientSecret so a partial-failure window
152 // never leaves the new secret committed alongside live old tokens.
153 // If rotate throws after cleanup, the admin retries: both halves are
154 // idempotent (cleanup is a no-op when rows are already gone, rotate
155 // replaces a still-current secret). The brief window between cleanup
156 // and rotate has the old secret still valid, but with no usable
157 // tokens to validate against โ€” and a leaked-secret threat model is
158 // exactly what the next call closes.
159 const batchResult = await c.var.db.batch([
160 c.var.db.delete(oauthAccessTokens).where(eq(oauthAccessTokens.clientId, id)),
161 c.var.db.delete(oauthRefreshTokens).where(eq(oauthRefreshTokens.clientId, id)),
162 ]);
163 const result = await c.var.auth.api.rotateClientSecret({
164 body: { client_id: id },
165 headers: c.req.raw.headers,
166 });
167 logger.info("oauth_client_secret_rotated", {
168 clientId: id,
169 byUserId: c.var.session?.user.id,
170 accessTokensDeleted: batchResult[0]?.meta?.changes ?? null,
171 refreshTokensDeleted: batchResult[1]?.meta?.changes ?? null,
172 });
173 return secretJsonResponse(result);
174};
175 
176export const handleDeleteClient = async (c: AppContext): Promise<Response> => {
177 const logger = c.var.log.child({ component: "admin.clients" });
178 const id = requiredParam(c, "id", { status: 400, code: "invalid_id", message: "Client id required." });
179 await c.var.auth.api.deleteOAuthClient({
180 body: { client_id: id },
181 headers: c.req.raw.headers,
182 });
183 logger.info("oauth_client_deleted", { clientId: id, byUserId: c.var.session?.user.id });
184 return c.body(null, 204);
185};