auth-core-idp.ts797 lines · main
1/**
2 * Briven Auth as OIDC / OAuth2 provider (SuperTokens-class surface).
3 *
4 * Public:
5 * GET /v1/auth-core/oidc/.well-known/openid-configuration
6 * GET /v1/auth-core/oidc/jwks.json
7 * GET /v1/auth-core/oidc/authorize
8 * POST /v1/auth-core/oidc/token
9 * GET /v1/auth-core/oidc/userinfo
10 * POST /v1/auth-core/oidc/revoke
11 * POST /v1/auth-core/oidc/introspect
12 * GET|POST /v1/auth-core/oidc/end_session
13 * GET /v1/auth-core/oidc/challenge/:id
14 * POST /v1/auth-core/oidc/consent
15 *
16 * Dashboard (project admin):
17 * GET|POST /v1/auth-core/projects/:projectId/oidc/clients
18 * POST /v1/auth-core/projects/:projectId/oidc/clients/:clientId/rotate-secret
19 * DELETE /v1/auth-core/projects/:projectId/oidc/clients/:clientId (soft revoke)
20 * DELETE /v1/auth-core/projects/:projectId/oidc/clients/:clientId?hard=1 (purge row)
21 */
22
23import { Hono } from 'hono';
24
25import { requireAuthCoreProject } from '../middleware/auth-core-guard.js';
26import { BRIVEN_ENGINE_ID } from '../services/auth-core/engine.js';
27import {
28 createOidcClient,
29 deleteOidcClient,
30 getOidcClientByClientId,
31 listOidcClients,
32 revokeOidcClient,
33 rotateOidcClientSecret,
34} from '../services/auth-core/idp-clients.js';
35import { getOidcJwks } from '../services/auth-core/idp-signing.js';
36import {
37 attachUserToAuthRequest,
38 buildUserInfo,
39 createAuthRequest,
40 denyAuthRequest,
41 discoveryDocument,
42 exchangeAuthorizationCode,
43 exchangeRefreshToken,
44 getAuthRequest,
45 hasConsent,
46 introspectToken,
47 issueAuthCodeAndRedirect,
48 oidcIssuer,
49 revokeToken,
50 webOrigin,
51} from '../services/auth-core/idp-flow.js';
52import { verifyAuthCoreSession } from '../services/auth-core/session.js';
53import type { AppEnv } from '../types/app-env.js';
54import type { User } from '../middleware/session.js';
55
56export const authCoreIdpRouter = new Hono<AppEnv>();
57
58// ─── Discovery + JWKS ────────────────────────────────────────────────
59
60authCoreIdpRouter.get(
61 '/v1/auth-core/oidc/.well-known/openid-configuration',
62 (c) => c.json(discoveryDocument()),
63);
64
65authCoreIdpRouter.get('/v1/auth-core/oidc/jwks.json', async (c) => {
66 try {
67 return c.json(await getOidcJwks());
68 } catch (err) {
69 return c.json(
70 {
71 engine: BRIVEN_ENGINE_ID,
72 error: 'server_error',
73 error_description: err instanceof Error ? err.message : String(err),
74 },
75 500,
76 );
77 }
78});
79
80// ─── Authorize ───────────────────────────────────────────────────────
81
82authCoreIdpRouter.get('/v1/auth-core/oidc/authorize', async (c) => {
83 const clientId = c.req.query('client_id') ?? '';
84 const redirectUri = c.req.query('redirect_uri') ?? '';
85 const responseType = c.req.query('response_type') ?? '';
86 const scope = c.req.query('scope') ?? 'openid';
87 const state = c.req.query('state') ?? null;
88 const nonce = c.req.query('nonce') ?? null;
89 const codeChallenge = c.req.query('code_challenge') ?? null;
90 const codeChallengeMethod = c.req.query('code_challenge_method') ?? null;
91
92 if (responseType !== 'code') {
93 return c.json(
94 {
95 error: 'unsupported_response_type',
96 error_description: 'only response_type=code is supported',
97 engine: BRIVEN_ENGINE_ID,
98 },
99 400,
100 );
101 }
102
103 const client = await getOidcClientByClientId(clientId);
104 if (!client || client.revokedAt) {
105 return c.json(
106 {
107 error: 'invalid_client',
108 error_description: 'unknown client_id',
109 engine: BRIVEN_ENGINE_ID,
110 },
111 400,
112 );
113 }
114
115 let authReq;
116 try {
117 authReq = await createAuthRequest({
118 client,
119 redirectUri,
120 scope,
121 state,
122 nonce,
123 codeChallenge,
124 codeChallengeMethod,
125 });
126 } catch (err) {
127 const msg = err instanceof Error ? err.message : String(err);
128 if (msg === 'invalid_redirect_uri') {
129 return c.json(
130 {
131 error: 'invalid_request',
132 error_description: 'redirect_uri not registered for this client',
133 engine: BRIVEN_ENGINE_ID,
134 },
135 400,
136 );
137 }
138 return c.json(
139 {
140 error: 'invalid_request',
141 error_description: msg,
142 engine: BRIVEN_ENGINE_ID,
143 },
144 400,
145 );
146 }
147
148 // If already logged in + consented, short-circuit to code
149 const session = await verifyAuthCoreSession({
150 url: c.req.url,
151 method: c.req.method,
152 headers: c.req.raw.headers,
153 cookieHeader: c.req.header('cookie'),
154 });
155
156 if (session.ok) {
157 const userId = session.session.getUserId();
158 await attachUserToAuthRequest(authReq.id, userId);
159 const consented = await hasConsent(userId, client.clientId, authReq.scope);
160 if (consented) {
161 const { redirectUrl } = await issueAuthCodeAndRedirect(authReq.id, userId);
162 return c.redirect(redirectUrl, 302);
163 }
164 const consentUrl = `${webOrigin()}/auth/${encodeURIComponent(client.projectId)}/oauth/consent?challenge=${encodeURIComponent(authReq.id)}`;
165 return c.redirect(consentUrl, 302);
166 }
167
168 // Not logged in → hosted login (password + links to OTP/magic), then consent.
169 // Hosted UI uses briven-engine FDI so sAccessToken is set for consent.
170 const afterLogin = `${webOrigin()}/auth/${encodeURIComponent(client.projectId)}/oauth/consent?challenge=${encodeURIComponent(authReq.id)}`;
171 const loginUrl = `${webOrigin()}/auth/${encodeURIComponent(client.projectId)}/sign-in?callbackURL=${encodeURIComponent(afterLogin)}`;
172 return c.redirect(loginUrl, 302);
173});
174
175// ─── Challenge (for consent UI) ──────────────────────────────────────
176
177authCoreIdpRouter.get('/v1/auth-core/oidc/challenge/:id', async (c) => {
178 const id = c.req.param('id');
179 const req = await getAuthRequest(id);
180 if (!req) {
181 return c.json(
182 {
183 engine: BRIVEN_ENGINE_ID,
184 error: 'invalid_request',
185 error_description: 'challenge expired or unknown',
186 },
187 404,
188 );
189 }
190 const client = await getOidcClientByClientId(req.clientId);
191 if (!client) {
192 return c.json(
193 { engine: BRIVEN_ENGINE_ID, error: 'invalid_client' },
194 400,
195 );
196 }
197 return c.json({
198 engine: BRIVEN_ENGINE_ID,
199 challenge: req.id,
200 projectId: req.projectId,
201 scope: req.scope,
202 scopes: req.scope.split(/\s+/),
203 client: {
204 clientId: client.clientId,
205 name: client.name,
206 logoUrl: client.logoUrl,
207 },
208 });
209});
210
211// ─── Consent accept / deny ───────────────────────────────────────────
212
213authCoreIdpRouter.post('/v1/auth-core/oidc/consent', async (c) => {
214 let body: { challenge?: string; decision?: 'allow' | 'deny' } = {};
215 try {
216 body = await c.req.json();
217 } catch {
218 body = {};
219 }
220 const challenge = body.challenge?.trim() ?? '';
221 const decision = body.decision === 'deny' ? 'deny' : 'allow';
222 if (!challenge) {
223 return c.json(
224 {
225 engine: BRIVEN_ENGINE_ID,
226 error: 'invalid_request',
227 error_description: 'challenge required',
228 },
229 400,
230 );
231 }
232
233 const session = await verifyAuthCoreSession({
234 url: c.req.url,
235 method: c.req.method,
236 headers: c.req.raw.headers,
237 cookieHeader: c.req.header('cookie'),
238 });
239 if (!session.ok) {
240 return c.json(
241 {
242 engine: BRIVEN_ENGINE_ID,
243 error: 'login_required',
244 error_description: 'sign in before consenting',
245 },
246 401,
247 );
248 }
249 const userId = session.session.getUserId();
250
251 try {
252 if (decision === 'deny') {
253 const { redirectUrl } = await denyAuthRequest(challenge);
254 return c.json({ engine: BRIVEN_ENGINE_ID, redirectUrl });
255 }
256 await attachUserToAuthRequest(challenge, userId);
257 const { redirectUrl } = await issueAuthCodeAndRedirect(challenge, userId);
258 return c.json({ engine: BRIVEN_ENGINE_ID, redirectUrl });
259 } catch (err) {
260 return c.json(
261 {
262 engine: BRIVEN_ENGINE_ID,
263 error: 'server_error',
264 error_description: err instanceof Error ? err.message : String(err),
265 },
266 400,
267 );
268 }
269});
270
271// ─── Token ───────────────────────────────────────────────────────────
272
273async function parseTokenBody(c: {
274 req: {
275 header: (n: string) => string | undefined;
276 parseBody: () => Promise<Record<string, unknown>>;
277 json: () => Promise<Record<string, unknown>>;
278 };
279}): Promise<{
280 grant_type: string;
281 code?: string;
282 redirect_uri?: string;
283 client_id?: string;
284 client_secret?: string;
285 code_verifier?: string;
286 refresh_token?: string;
287}> {
288 let clientId = '';
289 let clientSecret = '';
290 const auth = c.req.header('authorization');
291 if (auth?.startsWith('Basic ')) {
292 try {
293 const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
294 const i = decoded.indexOf(':');
295 if (i > 0) {
296 clientId = decoded.slice(0, i);
297 clientSecret = decoded.slice(i + 1);
298 }
299 } catch {
300 /* ignore */
301 }
302 }
303
304 const ct = c.req.header('content-type') ?? '';
305 let body: Record<string, unknown> = {};
306 if (ct.includes('application/x-www-form-urlencoded')) {
307 body = (await c.req.parseBody()) as Record<string, unknown>;
308 } else {
309 try {
310 body = await c.req.json();
311 } catch {
312 body = {};
313 }
314 }
315
316 return {
317 grant_type: String(body.grant_type ?? ''),
318 code: body.code != null ? String(body.code) : undefined,
319 redirect_uri:
320 body.redirect_uri != null ? String(body.redirect_uri) : undefined,
321 client_id: clientId || (body.client_id != null ? String(body.client_id) : undefined),
322 client_secret:
323 clientSecret ||
324 (body.client_secret != null ? String(body.client_secret) : undefined),
325 code_verifier:
326 body.code_verifier != null ? String(body.code_verifier) : undefined,
327 refresh_token:
328 body.refresh_token != null ? String(body.refresh_token) : undefined,
329 };
330}
331
332authCoreIdpRouter.post('/v1/auth-core/oidc/token', async (c) => {
333 const body = await parseTokenBody(c);
334 if (!body.client_id) {
335 return c.json(
336 {
337 error: 'invalid_client',
338 error_description: 'client_id required',
339 engine: BRIVEN_ENGINE_ID,
340 },
341 401,
342 );
343 }
344
345 if (body.grant_type === 'authorization_code') {
346 if (!body.code || !body.redirect_uri) {
347 return c.json(
348 {
349 error: 'invalid_request',
350 error_description: 'code and redirect_uri required',
351 engine: BRIVEN_ENGINE_ID,
352 },
353 400,
354 );
355 }
356 const result = await exchangeAuthorizationCode({
357 code: body.code,
358 redirectUri: body.redirect_uri,
359 clientId: body.client_id,
360 clientSecret: body.client_secret,
361 codeVerifier: body.code_verifier,
362 });
363 if (!result.ok) {
364 return c.json(
365 {
366 error: result.error,
367 error_description: result.error_description,
368 engine: BRIVEN_ENGINE_ID,
369 },
370 400,
371 );
372 }
373 return c.json({
374 access_token: result.access_token,
375 id_token: result.id_token,
376 refresh_token: result.refresh_token,
377 token_type: result.token_type,
378 expires_in: result.expires_in,
379 scope: result.scope,
380 engine: BRIVEN_ENGINE_ID,
381 });
382 }
383
384 if (body.grant_type === 'refresh_token') {
385 if (!body.refresh_token) {
386 return c.json(
387 {
388 error: 'invalid_request',
389 error_description: 'refresh_token required',
390 engine: BRIVEN_ENGINE_ID,
391 },
392 400,
393 );
394 }
395 const result = await exchangeRefreshToken({
396 refreshToken: body.refresh_token,
397 clientId: body.client_id,
398 clientSecret: body.client_secret,
399 });
400 if (!result.ok) {
401 return c.json(
402 {
403 error: result.error,
404 error_description: result.error_description,
405 engine: BRIVEN_ENGINE_ID,
406 },
407 400,
408 );
409 }
410 return c.json({
411 access_token: result.access_token,
412 id_token: result.id_token,
413 refresh_token: result.refresh_token,
414 token_type: result.token_type,
415 expires_in: result.expires_in,
416 scope: result.scope,
417 engine: BRIVEN_ENGINE_ID,
418 });
419 }
420
421 return c.json(
422 {
423 error: 'unsupported_grant_type',
424 error_description: 'authorization_code and refresh_token only',
425 engine: BRIVEN_ENGINE_ID,
426 },
427 400,
428 );
429});
430
431// ─── UserInfo ────────────────────────────────────────────────────────
432
433authCoreIdpRouter.get('/v1/auth-core/oidc/userinfo', async (c) => {
434 const auth = c.req.header('authorization') ?? '';
435 const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
436 if (!token) {
437 return c.json({ error: 'invalid_token', engine: BRIVEN_ENGINE_ID }, 401);
438 }
439 const result = await buildUserInfo(token);
440 if (!result.ok) {
441 return c.json({ error: result.error, engine: BRIVEN_ENGINE_ID }, result.status as 401);
442 }
443 return c.json(result.body);
444});
445
446authCoreIdpRouter.post('/v1/auth-core/oidc/userinfo', async (c) => {
447 const auth = c.req.header('authorization') ?? '';
448 let token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
449 if (!token) {
450 const ct = c.req.header('content-type') ?? '';
451 if (ct.includes('application/x-www-form-urlencoded')) {
452 const form = await c.req.parseBody();
453 token = String(form.access_token ?? '');
454 }
455 }
456 if (!token) {
457 return c.json({ error: 'invalid_token', engine: BRIVEN_ENGINE_ID }, 401);
458 }
459 const result = await buildUserInfo(token);
460 if (!result.ok) {
461 return c.json({ error: result.error, engine: BRIVEN_ENGINE_ID }, result.status as 401);
462 }
463 return c.json(result.body);
464});
465
466// ─── Revoke / introspect ─────────────────────────────────────────────
467
468async function parseClientAuthAndToken(c: {
469 req: {
470 header: (n: string) => string | undefined;
471 parseBody: () => Promise<Record<string, unknown>>;
472 json: () => Promise<Record<string, unknown>>;
473 };
474}): Promise<{ clientId: string; clientSecret: string; token: string }> {
475 let clientId = '';
476 let clientSecret = '';
477 const auth = c.req.header('authorization');
478 if (auth?.startsWith('Basic ')) {
479 try {
480 const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
481 const i = decoded.indexOf(':');
482 if (i > 0) {
483 clientId = decoded.slice(0, i);
484 clientSecret = decoded.slice(i + 1);
485 }
486 } catch {
487 /* ignore */
488 }
489 }
490 let token = '';
491 const ct = c.req.header('content-type') ?? '';
492 try {
493 if (ct.includes('application/x-www-form-urlencoded')) {
494 const form = await c.req.parseBody();
495 token = String(form.token ?? '');
496 if (!clientId) clientId = String(form.client_id ?? '');
497 if (!clientSecret) clientSecret = String(form.client_secret ?? '');
498 } else {
499 const body = (await c.req.json()) as {
500 token?: string;
501 client_id?: string;
502 client_secret?: string;
503 };
504 token = body.token ?? '';
505 if (!clientId) clientId = body.client_id ?? '';
506 if (!clientSecret) clientSecret = body.client_secret ?? '';
507 }
508 } catch {
509 /* empty */
510 }
511 return { clientId, clientSecret, token };
512}
513
514authCoreIdpRouter.post('/v1/auth-core/oidc/revoke', async (c) => {
515 const { clientId, clientSecret, token } = await parseClientAuthAndToken(c);
516 if (clientId && token) {
517 await revokeToken({ token, clientId, clientSecret });
518 }
519 // RFC 7009: 200 even if token unknown
520 return c.json({ engine: BRIVEN_ENGINE_ID });
521});
522
523authCoreIdpRouter.post('/v1/auth-core/oidc/introspect', async (c) => {
524 const { clientId, clientSecret, token } = await parseClientAuthAndToken(c);
525 if (!clientId || !token) {
526 return c.json({ active: false, engine: BRIVEN_ENGINE_ID });
527 }
528 const result = await introspectToken({
529 token,
530 clientId,
531 clientSecret,
532 });
533 return c.json({ ...result, engine: BRIVEN_ENGINE_ID });
534});
535
536// ─── End session (logout) ────────────────────────────────────────────
537
538authCoreIdpRouter.get('/v1/auth-core/oidc/end_session', async (c) => {
539 const postLogout = c.req.query('post_logout_redirect_uri');
540 const state = c.req.query('state');
541 // Clear engine session cookies best-effort
542 c.header(
543 'Set-Cookie',
544 'sAccessToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
545 { append: true },
546 );
547 c.header(
548 'Set-Cookie',
549 'sRefreshToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
550 { append: true },
551 );
552 if (postLogout) {
553 try {
554 const u = new URL(postLogout);
555 if (state) u.searchParams.set('state', state);
556 return c.redirect(u.toString(), 302);
557 } catch {
558 /* fall through */
559 }
560 }
561 return c.html(
562 `<!doctype html><html><body style="font-family:monospace;padding:2rem">
563 <p>signed out of briven auth</p>
564 <p>engine: briven-engine</p>
565 </body></html>`,
566 );
567});
568
569authCoreIdpRouter.post('/v1/auth-core/oidc/end_session', async (c) => {
570 // Same behaviour as GET (form_post style clients)
571 const url = new URL(c.req.url);
572 let postLogout = url.searchParams.get('post_logout_redirect_uri');
573 let state = url.searchParams.get('state');
574 try {
575 const ct = c.req.header('content-type') ?? '';
576 if (ct.includes('application/x-www-form-urlencoded')) {
577 const form = await c.req.parseBody();
578 if (!postLogout) postLogout = String(form.post_logout_redirect_uri ?? '') || null;
579 if (!state) state = String(form.state ?? '') || null;
580 }
581 } catch {
582 /* ignore */
583 }
584 c.header(
585 'Set-Cookie',
586 'sAccessToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
587 { append: true },
588 );
589 c.header(
590 'Set-Cookie',
591 'sRefreshToken=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0',
592 { append: true },
593 );
594 if (postLogout) {
595 try {
596 const u = new URL(postLogout);
597 if (state) u.searchParams.set('state', state);
598 return c.redirect(u.toString(), 302);
599 } catch {
600 /* fall through */
601 }
602 }
603 return c.html(
604 `<!doctype html><html><body style="font-family:monospace;padding:2rem">
605 <p>signed out of briven auth</p>
606 <p>engine: briven-engine</p>
607 </body></html>`,
608 );
609});
610
611// ─── Client admin (dashboard) ────────────────────────────────────────
612
613authCoreIdpRouter.use(
614 '/v1/auth-core/projects/:projectId/oidc/clients',
615 ...requireAuthCoreProject('admin'),
616);
617authCoreIdpRouter.use(
618 '/v1/auth-core/projects/:projectId/oidc/clients/*',
619 ...requireAuthCoreProject('admin'),
620);
621
622authCoreIdpRouter.get(
623 '/v1/auth-core/projects/:projectId/oidc/clients',
624 async (c) => {
625 const projectId = c.req.param('projectId');
626 const includeRevoked =
627 c.req.query('includeRevoked') === '1' ||
628 c.req.query('includeRevoked') === 'true';
629 try {
630 const clients = await listOidcClients(projectId, { includeRevoked });
631 return c.json({
632 engine: BRIVEN_ENGINE_ID,
633 projectId,
634 issuer: oidcIssuer(),
635 discovery: `${oidcIssuer()}/.well-known/openid-configuration`,
636 clients: clients.map((cl) => ({
637 id: cl.id,
638 clientId: cl.clientId,
639 name: cl.name,
640 logoUrl: cl.logoUrl,
641 isPublic: cl.isPublic,
642 redirectUris: cl.redirectUris,
643 scopes: cl.scopes,
644 hint: cl.secretSuffix ? `…${cl.secretSuffix}` : null,
645 revokedAt: cl.revokedAt,
646 createdAt: cl.createdAt,
647 })),
648 });
649 } catch (err) {
650 return c.json(
651 {
652 engine: BRIVEN_ENGINE_ID,
653 code: 'list_failed',
654 message: err instanceof Error ? err.message : String(err),
655 },
656 500,
657 );
658 }
659 },
660);
661
662authCoreIdpRouter.post(
663 '/v1/auth-core/projects/:projectId/oidc/clients',
664 async (c) => {
665 const projectId = c.req.param('projectId');
666 let body: {
667 name?: string;
668 redirectUris?: string[];
669 logoUrl?: string;
670 isPublic?: boolean;
671 postLogoutUris?: string[];
672 } = {};
673 try {
674 body = await c.req.json();
675 } catch {
676 body = {};
677 }
678 const user = c.get('user') as User | null;
679 try {
680 const created = await createOidcClient({
681 projectId,
682 name: body.name ?? '',
683 redirectUris: body.redirectUris ?? [],
684 logoUrl: body.logoUrl,
685 isPublic: body.isPublic,
686 postLogoutUris: body.postLogoutUris,
687 createdBy: user?.id ?? null,
688 });
689 return c.json({
690 engine: BRIVEN_ENGINE_ID,
691 projectId,
692 issuer: oidcIssuer(),
693 client: {
694 id: created.client.id,
695 clientId: created.client.clientId,
696 name: created.client.name,
697 logoUrl: created.client.logoUrl,
698 isPublic: created.client.isPublic,
699 redirectUris: created.client.redirectUris,
700 /** Shown once for confidential clients */
701 clientSecret: created.clientSecret,
702 },
703 note: created.clientSecret
704 ? 'Copy client_secret now — it is not shown again.'
705 : 'Public client — use PKCE (S256); no client_secret.',
706 });
707 } catch (err) {
708 return c.json(
709 {
710 engine: BRIVEN_ENGINE_ID,
711 code: 'create_failed',
712 message: err instanceof Error ? err.message : String(err),
713 },
714 400,
715 );
716 }
717 },
718);
719
720/** Rotate confidential client secret — old secret dies immediately. */
721authCoreIdpRouter.post(
722 '/v1/auth-core/projects/:projectId/oidc/clients/:clientId/rotate-secret',
723 async (c) => {
724 const projectId = c.req.param('projectId');
725 const clientId = c.req.param('clientId');
726 try {
727 const rotated = await rotateOidcClientSecret(projectId, clientId);
728 return c.json({
729 engine: BRIVEN_ENGINE_ID,
730 ok: true,
731 projectId,
732 client: {
733 clientId: rotated.client.clientId,
734 name: rotated.client.name,
735 hint: rotated.client.secretSuffix
736 ? `…${rotated.client.secretSuffix}`
737 : null,
738 clientSecret: rotated.clientSecret,
739 },
740 note: 'Copy client_secret now — the previous secret no longer works. Live refresh tokens for this app were revoked.',
741 });
742 } catch (err) {
743 return c.json(
744 {
745 engine: BRIVEN_ENGINE_ID,
746 code: 'rotate_failed',
747 message: err instanceof Error ? err.message : String(err),
748 },
749 400,
750 );
751 }
752 },
753);
754
755/**
756 * DELETE without hard=1 → soft revoke (secret wiped, tokens killed).
757 * DELETE ?hard=1 → permanent remove (leftover apps).
758 */
759authCoreIdpRouter.delete(
760 '/v1/auth-core/projects/:projectId/oidc/clients/:clientId',
761 async (c) => {
762 const projectId = c.req.param('projectId');
763 const clientId = c.req.param('clientId');
764 const hard =
765 c.req.query('hard') === '1' || c.req.query('hard') === 'true';
766 try {
767 if (hard) {
768 await deleteOidcClient(projectId, clientId, { force: true });
769 return c.json({
770 engine: BRIVEN_ENGINE_ID,
771 ok: true,
772 projectId,
773 clientId,
774 deleted: true,
775 });
776 }
777 await revokeOidcClient(projectId, clientId);
778 return c.json({
779 engine: BRIVEN_ENGINE_ID,
780 ok: true,
781 projectId,
782 clientId,
783 revoked: true,
784 note: 'Client secret wiped; refresh tokens revoked. Use ?hard=1 to delete the leftover row.',
785 });
786 } catch (err) {
787 return c.json(
788 {
789 engine: BRIVEN_ENGINE_ID,
790 code: hard ? 'delete_failed' : 'revoke_failed',
791 message: err instanceof Error ? err.message : String(err),
792 },
793 404,
794 );
795 }
796 },
797);