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