@sentropic/auth-hono 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +115 -1
  2. package/dist/contracts.d.ts +1 -1
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +15 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +15 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/oauth/authorize-handler.d.ts +13 -0
  11. package/dist/oauth/authorize-handler.d.ts.map +1 -0
  12. package/dist/oauth/authorize-handler.js +143 -0
  13. package/dist/oauth/authorize-handler.js.map +1 -0
  14. package/dist/oauth/consent-decision-handler.d.ts +11 -0
  15. package/dist/oauth/consent-decision-handler.d.ts.map +1 -0
  16. package/dist/oauth/consent-decision-handler.js +58 -0
  17. package/dist/oauth/consent-decision-handler.js.map +1 -0
  18. package/dist/oauth/crypto-utils.d.ts +3 -0
  19. package/dist/oauth/crypto-utils.d.ts.map +1 -0
  20. package/dist/oauth/crypto-utils.js +13 -0
  21. package/dist/oauth/crypto-utils.js.map +1 -0
  22. package/dist/oauth/dpop.d.ts +18 -0
  23. package/dist/oauth/dpop.d.ts.map +1 -0
  24. package/dist/oauth/dpop.js +54 -0
  25. package/dist/oauth/dpop.js.map +1 -0
  26. package/dist/oauth/http-utils.d.ts +6 -0
  27. package/dist/oauth/http-utils.d.ts.map +1 -0
  28. package/dist/oauth/http-utils.js +27 -0
  29. package/dist/oauth/http-utils.js.map +1 -0
  30. package/dist/oauth/introspect-handler.d.ts +8 -0
  31. package/dist/oauth/introspect-handler.d.ts.map +1 -0
  32. package/dist/oauth/introspect-handler.js +63 -0
  33. package/dist/oauth/introspect-handler.js.map +1 -0
  34. package/dist/oauth/jwks-service.d.ts +25 -0
  35. package/dist/oauth/jwks-service.d.ts.map +1 -0
  36. package/dist/oauth/jwks-service.js +61 -0
  37. package/dist/oauth/jwks-service.js.map +1 -0
  38. package/dist/oauth/revoke-handler.d.ts +8 -0
  39. package/dist/oauth/revoke-handler.d.ts.map +1 -0
  40. package/dist/oauth/revoke-handler.js +55 -0
  41. package/dist/oauth/revoke-handler.js.map +1 -0
  42. package/dist/oauth/router.d.ts +8 -0
  43. package/dist/oauth/router.d.ts.map +1 -0
  44. package/dist/oauth/router.js +30 -0
  45. package/dist/oauth/router.js.map +1 -0
  46. package/dist/oauth/session-resolver.d.ts +9 -0
  47. package/dist/oauth/session-resolver.d.ts.map +1 -0
  48. package/dist/oauth/session-resolver.js +28 -0
  49. package/dist/oauth/session-resolver.js.map +1 -0
  50. package/dist/oauth/state-codec.d.ts +25 -0
  51. package/dist/oauth/state-codec.d.ts.map +1 -0
  52. package/dist/oauth/state-codec.js +60 -0
  53. package/dist/oauth/state-codec.js.map +1 -0
  54. package/dist/oauth/state-store-types.d.ts +86 -0
  55. package/dist/oauth/state-store-types.d.ts.map +1 -0
  56. package/dist/oauth/state-store-types.js +2 -0
  57. package/dist/oauth/state-store-types.js.map +1 -0
  58. package/dist/oauth/token-handler.d.ts +11 -0
  59. package/dist/oauth/token-handler.d.ts.map +1 -0
  60. package/dist/oauth/token-handler.js +176 -0
  61. package/dist/oauth/token-handler.js.map +1 -0
  62. package/dist/oauth/userinfo-handler.d.ts +9 -0
  63. package/dist/oauth/userinfo-handler.d.ts.map +1 -0
  64. package/dist/oauth/userinfo-handler.js +93 -0
  65. package/dist/oauth/userinfo-handler.js.map +1 -0
  66. package/dist/oauth/wellknown-handler.d.ts +9 -0
  67. package/dist/oauth/wellknown-handler.d.ts.map +1 -0
  68. package/dist/oauth/wellknown-handler.js +37 -0
  69. package/dist/oauth/wellknown-handler.js.map +1 -0
  70. package/dist/ports.d.ts +4 -0
  71. package/dist/ports.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/contracts.ts +2 -0
  74. package/src/index.ts +15 -0
  75. package/src/oauth/authorize-handler.ts +201 -0
  76. package/src/oauth/consent-decision-handler.ts +93 -0
  77. package/src/oauth/crypto-utils.ts +14 -0
  78. package/src/oauth/dpop.ts +93 -0
  79. package/src/oauth/http-utils.ts +58 -0
  80. package/src/oauth/introspect-handler.ts +88 -0
  81. package/src/oauth/jwks-service.ts +103 -0
  82. package/src/oauth/revoke-handler.ts +70 -0
  83. package/src/oauth/router.ts +42 -0
  84. package/src/oauth/session-resolver.ts +48 -0
  85. package/src/oauth/state-codec.ts +98 -0
  86. package/src/oauth/state-store-types.ts +94 -0
  87. package/src/oauth/token-handler.ts +252 -0
  88. package/src/oauth/userinfo-handler.ts +129 -0
  89. package/src/oauth/wellknown-handler.ts +52 -0
  90. package/src/ports.ts +16 -0
@@ -0,0 +1,70 @@
1
+ import type { Context } from 'hono';
2
+ import { decodeJwt } from 'jose';
3
+
4
+ import type { AuthHonoPorts } from '../ports.js';
5
+ import { OAuthDpopProofError, verifyOAuthDpopProof } from './dpop.js';
6
+ import { oauthJsonError } from './http-utils.js';
7
+
8
+ export interface OAuthRevokeHandlerOptions {
9
+ dpopIatSkewSeconds?: number;
10
+ ports: AuthHonoPorts;
11
+ }
12
+
13
+ export const createOAuthRevokeHandler =
14
+ (options: OAuthRevokeHandlerOptions) =>
15
+ async (c: Context): Promise<Response> => {
16
+ const form = new URLSearchParams(await c.req.text());
17
+ const token = form.get('token');
18
+ if (!token) return oauthJsonError(c, 400, 'invalid_request', 'token is required.');
19
+
20
+ const jti = decodeTokenJti(token);
21
+ if (!jti) return c.json({ success: true });
22
+
23
+ const meta = await options.ports.oauthStateStore.findTokenMeta(jti);
24
+ if (meta?.dpopJkt) {
25
+ const dpop = await validateRevokeDpop(c, options, token, meta.dpopJkt);
26
+ if (dpop instanceof Response) return dpop;
27
+ }
28
+
29
+ await options.ports.oauthStateStore.revokeToken(jti);
30
+ return c.json({ success: true });
31
+ };
32
+
33
+ const validateRevokeDpop = async (
34
+ c: Context,
35
+ options: OAuthRevokeHandlerOptions,
36
+ accessToken: string,
37
+ expectedJkt: string
38
+ ): Promise<null | Response> => {
39
+ const proof = c.req.header('dpop');
40
+ if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
41
+
42
+ try {
43
+ const verified = await verifyOAuthDpopProof({
44
+ accessToken,
45
+ htm: 'POST',
46
+ htu: c.req.url,
47
+ iatSkewSeconds: options.dpopIatSkewSeconds,
48
+ ports: options.ports,
49
+ proof,
50
+ });
51
+ if (verified.jkt !== expectedJkt) {
52
+ return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof key does not match the token.');
53
+ }
54
+ return null;
55
+ } catch (error) {
56
+ if (error instanceof OAuthDpopProofError) {
57
+ return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
58
+ }
59
+ throw error;
60
+ }
61
+ };
62
+
63
+ const decodeTokenJti = (token: string): string | null => {
64
+ try {
65
+ const payload = decodeJwt(token);
66
+ return typeof payload.jti === 'string' ? payload.jti : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ };
@@ -0,0 +1,42 @@
1
+ import { Hono } from 'hono';
2
+
3
+ import { createOAuthAuthorizeHandler, type OAuthAuthorizeHandlerOptions } from './authorize-handler.js';
4
+ import {
5
+ createOAuthConsentDecisionHandler,
6
+ createOAuthConsentDetailsHandler,
7
+ } from './consent-decision-handler.js';
8
+ import { createOAuthIntrospectHandler } from './introspect-handler.js';
9
+ import { createOAuthRevokeHandler } from './revoke-handler.js';
10
+ import { createOAuthTokenHandler } from './token-handler.js';
11
+ import { createOAuthUserInfoHandler } from './userinfo-handler.js';
12
+
13
+ export interface CreateOAuthRouterOptions extends OAuthAuthorizeHandlerOptions {
14
+ authorizationCodeTtlSeconds?: number;
15
+ routePrefix?: string;
16
+ }
17
+
18
+ export const createOAuthRouter = (options: CreateOAuthRouterOptions): Hono => {
19
+ const router = new Hono();
20
+ const prefix = normalizeRoutePrefix(options.routePrefix ?? '/oauth');
21
+
22
+ router.get(joinRoutePath(prefix, '/authorize'), createOAuthAuthorizeHandler(options));
23
+ router.get(joinRoutePath(prefix, '/consent'), createOAuthConsentDetailsHandler(options));
24
+ router.post(joinRoutePath(prefix, '/consent/decision'), createOAuthConsentDecisionHandler(options));
25
+ router.post(joinRoutePath(prefix, '/token'), createOAuthTokenHandler(options));
26
+ router.get(joinRoutePath(prefix, '/userinfo'), createOAuthUserInfoHandler(options));
27
+ router.post(joinRoutePath(prefix, '/userinfo'), createOAuthUserInfoHandler(options));
28
+ router.post(joinRoutePath(prefix, '/revoke'), createOAuthRevokeHandler(options));
29
+ router.post(joinRoutePath(prefix, '/introspect'), createOAuthIntrospectHandler(options));
30
+
31
+ return router;
32
+ };
33
+
34
+ const normalizeRoutePrefix = (prefix: string): string => {
35
+ if (!prefix || prefix === '/') return '';
36
+ return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
37
+ };
38
+
39
+ const joinRoutePath = (prefix: string, path: string): string => {
40
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
41
+ return `${prefix}${normalizedPath}`;
42
+ };
@@ -0,0 +1,48 @@
1
+ import type {
2
+ AuthHonoPorts,
3
+ AuthHonoSessionClaims,
4
+ AuthHonoSessionRecord,
5
+ AuthHonoUserRecord,
6
+ } from '../ports.js';
7
+
8
+ export interface OAuthResolvedSession {
9
+ claims: AuthHonoSessionClaims;
10
+ sessionRecord: AuthHonoSessionRecord;
11
+ user: AuthHonoUserRecord;
12
+ }
13
+
14
+ export const resolveOAuthSession = async (
15
+ request: Request,
16
+ ports: AuthHonoPorts
17
+ ): Promise<OAuthResolvedSession | null> => {
18
+ const token = ports.cookies.readSessionToken(request);
19
+ if (!token) return null;
20
+
21
+ const claims = await ports.tokens.verifySessionToken(token);
22
+ if (!claims) return null;
23
+
24
+ const tokenHash = await ports.tokens.hashSecret(token);
25
+ const sessionRecord = await ports.sessions.findByTokenHash(tokenHash);
26
+ const now = ports.clock.now();
27
+ if (
28
+ !sessionRecord ||
29
+ sessionRecord.id !== claims.sessionId ||
30
+ sessionRecord.userId !== claims.userId ||
31
+ sessionRecord.revokedAt ||
32
+ sessionRecord.expiresAt <= now
33
+ ) {
34
+ return null;
35
+ }
36
+
37
+ const user = await ports.users.findById(claims.userId);
38
+ if (!user) return null;
39
+
40
+ const decision = await ports.accountPolicy.canAuthenticate(user, now);
41
+ if (!decision.allowed) return null;
42
+
43
+ await ports.sessions.touch(sessionRecord.id, now);
44
+ return { claims, sessionRecord, user };
45
+ };
46
+
47
+ export const resolveOAuthAcr = (session: AuthHonoSessionRecord): string =>
48
+ session.mfaVerified ? 'urn:sentropic:loa:passkey-fresh' : 'urn:sentropic:loa:bearer';
@@ -0,0 +1,98 @@
1
+ export interface OAuthContinuationState {
2
+ acr?: string;
3
+ authTime?: string;
4
+ clientId: string;
5
+ codeChallenge: string;
6
+ codeChallengeMethod: 'S256';
7
+ createdAt: string;
8
+ dpopJkt: string | null;
9
+ expiresAt: string;
10
+ nonce: string | null;
11
+ redirectUri: string;
12
+ scope: string;
13
+ state: string | null;
14
+ tenantId: string | null;
15
+ userId?: string;
16
+ }
17
+
18
+ export interface OAuthContinuationCodec {
19
+ seal(payload: OAuthContinuationState): Promise<string> | string;
20
+ unseal(token: string): Promise<OAuthContinuationState | null> | OAuthContinuationState | null;
21
+ }
22
+
23
+ export interface CreateOAuthHmacStateCodecOptions {
24
+ secret: string;
25
+ }
26
+
27
+ export const createOAuthHmacStateCodec = ({
28
+ secret,
29
+ }: CreateOAuthHmacStateCodecOptions): OAuthContinuationCodec => {
30
+ if (!secret) {
31
+ throw new Error('OAuth state codec secret is required.');
32
+ }
33
+
34
+ return {
35
+ async seal(payload) {
36
+ const body = base64urlEncode(textEncoder.encode(JSON.stringify(payload)));
37
+ return `${body}.${await sign(body, secret)}`;
38
+ },
39
+
40
+ async unseal(token) {
41
+ const [body, signature, extra] = token.split('.');
42
+ if (!body || !signature || extra !== undefined) return null;
43
+
44
+ const expected = await sign(body, secret);
45
+ const actualBytes = base64urlDecode(signature);
46
+ const expectedBytes = base64urlDecode(expected);
47
+ if (!timingSafeEqual(actualBytes, expectedBytes)) return null;
48
+
49
+ try {
50
+ return JSON.parse(textDecoder.decode(base64urlDecode(body))) as OAuthContinuationState;
51
+ } catch {
52
+ return null;
53
+ }
54
+ },
55
+ };
56
+ };
57
+
58
+ const textEncoder = new TextEncoder();
59
+ const textDecoder = new TextDecoder();
60
+
61
+ const sign = async (body: string, secret: string): Promise<string> => {
62
+ const key = await crypto.subtle.importKey(
63
+ 'raw',
64
+ textEncoder.encode(secret),
65
+ { hash: 'SHA-256', name: 'HMAC' },
66
+ false,
67
+ ['sign']
68
+ );
69
+ const signature = await crypto.subtle.sign('HMAC', key, textEncoder.encode(body));
70
+ return base64urlEncode(new Uint8Array(signature));
71
+ };
72
+
73
+ const timingSafeEqual = (actual: Uint8Array, expected: Uint8Array): boolean => {
74
+ if (actual.byteLength !== expected.byteLength) return false;
75
+ let diff = 0;
76
+ for (let index = 0; index < actual.byteLength; index += 1) {
77
+ diff |= actual[index] ^ expected[index];
78
+ }
79
+ return diff === 0;
80
+ };
81
+
82
+ const base64urlEncode = (bytes: Uint8Array): string => {
83
+ let binary = '';
84
+ for (const byte of bytes) {
85
+ binary += String.fromCharCode(byte);
86
+ }
87
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
88
+ };
89
+
90
+ const base64urlDecode = (value: string): Uint8Array => {
91
+ const base64 = value.replaceAll('-', '+').replaceAll('_', '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
92
+ const binary = atob(base64);
93
+ const bytes = new Uint8Array(binary.length);
94
+ for (let index = 0; index < binary.length; index += 1) {
95
+ bytes[index] = binary.charCodeAt(index);
96
+ }
97
+ return bytes;
98
+ };
@@ -0,0 +1,94 @@
1
+ import type { JWK, KeyLike } from 'jose';
2
+
3
+ export type OauthTokenType = 'access_token' | 'id_token';
4
+
5
+ export interface OauthClientRecord {
6
+ id: string;
7
+ clientId: string;
8
+ clientSecretHash: string | null;
9
+ name: string;
10
+ redirectUris: string[];
11
+ allowedScopes: string[];
12
+ grantTypes: string[];
13
+ responseTypes: string[];
14
+ tokenEndpointAuthMethod: 'client_secret_basic' | 'none' | (string & {});
15
+ dpopBoundAccessTokens: boolean;
16
+ requirePkce: boolean;
17
+ tenantId: string | null;
18
+ ownerUserId: string | null;
19
+ createdAt: Date;
20
+ updatedAt: Date;
21
+ }
22
+
23
+ export interface AuthCodePayload {
24
+ clientId: string;
25
+ userId: string;
26
+ tenantId: string | null;
27
+ redirectUri: string;
28
+ scope: string;
29
+ codeChallenge: string;
30
+ codeChallengeMethod: 'S256';
31
+ dpopJkt: string | null;
32
+ nonce: string | null;
33
+ acr: string;
34
+ authTime: Date;
35
+ expiresAt: Date;
36
+ createdAt: Date;
37
+ }
38
+
39
+ export interface TokenMeta {
40
+ jti: string;
41
+ tokenType: OauthTokenType;
42
+ clientId: string;
43
+ userId: string;
44
+ tenantId: string | null;
45
+ scope: string;
46
+ audience: string;
47
+ dpopJkt: string | null;
48
+ expiresAt: Date;
49
+ createdAt: Date;
50
+ }
51
+
52
+ export interface DpopProofRecord {
53
+ jti: string;
54
+ expiresAt: Date;
55
+ createdAt: Date;
56
+ }
57
+
58
+ export interface OauthStateStorePort {
59
+ findClient(clientId: string): Promise<OauthClientRecord | null>;
60
+ saveAuthCode(code: string, payload: AuthCodePayload, ttlSec: number): Promise<void>;
61
+ consumeAuthCode(code: string): Promise<AuthCodePayload | null>;
62
+ saveTokenMeta(jti: string, meta: TokenMeta, ttlSec: number): Promise<void>;
63
+ findTokenMeta(jti: string): Promise<TokenMeta | null>;
64
+ revokeToken(jti: string): Promise<boolean>;
65
+ isTokenRevoked(jti: string): Promise<boolean>;
66
+ recordDpopJti(jti: string, expiresAt: Date): Promise<boolean>;
67
+ purgeExpired(): Promise<number>;
68
+ }
69
+
70
+ export type JwksPublicJwk = JWK & {
71
+ alg?: 'EdDSA' | (string & {});
72
+ crv: 'Ed25519' | (string & {});
73
+ kid?: string;
74
+ kty: 'OKP' | (string & {});
75
+ use?: 'sig' | (string & {});
76
+ x: string;
77
+ };
78
+
79
+ export interface JwksKeyRecord {
80
+ kid: string;
81
+ alg: 'EdDSA' | (string & {});
82
+ crv: 'Ed25519' | (string & {});
83
+ publicJwk: JwksPublicJwk;
84
+ privateKey?: KeyLike | Uint8Array;
85
+ active: boolean;
86
+ createdAt: Date;
87
+ rotatedAt: Date | null;
88
+ }
89
+
90
+ export interface JwksPort {
91
+ getActiveKey(): Promise<JwksKeyRecord | null>;
92
+ findKeyByKid(kid: string): Promise<JwksKeyRecord | null>;
93
+ listPublicKeys(): Promise<JwksKeyRecord[]>;
94
+ }
@@ -0,0 +1,252 @@
1
+ import type { Context } from 'hono';
2
+
3
+ import type { AuthHonoPorts, AuthHonoUserRecord } from '../ports.js';
4
+ import { createJwksService } from './jwks-service.js';
5
+ import { oauthJsonError } from './http-utils.js';
6
+ import { sha256Base64url } from './crypto-utils.js';
7
+ import { OAuthDpopProofError, verifyOAuthDpopProof } from './dpop.js';
8
+ import type { AuthCodePayload, OauthClientRecord, TokenMeta } from './state-store-types.js';
9
+
10
+ export interface OAuthTokenHandlerOptions {
11
+ accessTokenTtlSeconds?: number;
12
+ dpopIatSkewSeconds?: number;
13
+ idTokenTtlSeconds?: number;
14
+ issuer: string;
15
+ ports: AuthHonoPorts;
16
+ }
17
+
18
+ interface ClientAuthentication {
19
+ client: OauthClientRecord;
20
+ secret?: string;
21
+ }
22
+
23
+ export const createOAuthTokenHandler =
24
+ (options: OAuthTokenHandlerOptions) =>
25
+ async (c: Context): Promise<Response> => {
26
+ const form = new URLSearchParams(await c.req.text());
27
+ if (form.get('grant_type') !== 'authorization_code') {
28
+ return oauthJsonError(c, 400, 'unsupported_grant_type', 'Only authorization_code grant is supported.');
29
+ }
30
+
31
+ const auth = await authenticateClient(c, form, options.ports);
32
+ if (auth instanceof Response) return auth;
33
+
34
+ const codePayload = await options.ports.oauthStateStore.consumeAuthCode(form.get('code') ?? '');
35
+ if (!codePayload || codePayload.clientId !== auth.client.clientId) {
36
+ return oauthJsonError(c, 400, 'invalid_grant', 'Authorization code is invalid or already used.');
37
+ }
38
+ if (form.get('redirect_uri') !== codePayload.redirectUri) {
39
+ return oauthJsonError(c, 400, 'invalid_grant', 'redirect_uri does not match the authorization request.');
40
+ }
41
+ if ((await sha256Base64url(form.get('code_verifier') ?? '')) !== codePayload.codeChallenge) {
42
+ return oauthJsonError(c, 400, 'invalid_grant', 'PKCE verification failed.');
43
+ }
44
+
45
+ const dpopJkt = await resolveDpopJkt(c, options, auth.client, codePayload);
46
+ if (dpopJkt instanceof Response) return dpopJkt;
47
+
48
+ const user = await options.ports.users.findById(codePayload.userId);
49
+ if (!user) return oauthJsonError(c, 400, 'invalid_grant', 'Authorization code user is invalid.');
50
+
51
+ const tokens = await issueTokens(options, auth.client, codePayload, user, dpopJkt);
52
+ return c.json(tokens);
53
+ };
54
+
55
+ const authenticateClient = async (
56
+ c: Context,
57
+ form: URLSearchParams,
58
+ ports: AuthHonoPorts
59
+ ): Promise<ClientAuthentication | Response> => {
60
+ const credentials = parseClientCredentials(c.req.header('authorization'), form);
61
+ if (!credentials.clientId) {
62
+ return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
63
+ }
64
+
65
+ const client = await ports.oauthStateStore.findClient(credentials.clientId);
66
+ if (!client) return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
67
+
68
+ if (client.tokenEndpointAuthMethod === 'none') {
69
+ return { client };
70
+ }
71
+
72
+ if (!credentials.secret || !client.clientSecretHash) {
73
+ return oauthJsonError(c, 401, 'invalid_client', 'Client secret is required.');
74
+ }
75
+
76
+ const secretHash = await ports.tokens.hashSecret(credentials.secret);
77
+ if (secretHash !== client.clientSecretHash) {
78
+ return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
79
+ }
80
+
81
+ return { client, secret: credentials.secret };
82
+ };
83
+
84
+ const parseClientCredentials = (
85
+ authorization: string | undefined,
86
+ form: URLSearchParams
87
+ ): { clientId: string | null; secret?: string } => {
88
+ if (authorization?.startsWith('Basic ')) {
89
+ const decoded = atob(authorization.slice('Basic '.length));
90
+ const separator = decoded.indexOf(':');
91
+ return {
92
+ clientId: separator >= 0 ? decoded.slice(0, separator) : decoded,
93
+ secret: separator >= 0 ? decoded.slice(separator + 1) : '',
94
+ };
95
+ }
96
+
97
+ return {
98
+ clientId: form.get('client_id'),
99
+ secret: form.get('client_secret') ?? undefined,
100
+ };
101
+ };
102
+
103
+ const resolveDpopJkt = async (
104
+ c: Context,
105
+ options: OAuthTokenHandlerOptions,
106
+ client: OauthClientRecord,
107
+ codePayload: AuthCodePayload
108
+ ): Promise<string | null | Response> => {
109
+ if (!client.dpopBoundAccessTokens) return null;
110
+
111
+ const proof = c.req.header('dpop');
112
+ if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
113
+
114
+ try {
115
+ const verified = await verifyOAuthDpopProof({
116
+ htm: 'POST',
117
+ htu: c.req.url,
118
+ iatSkewSeconds: options.dpopIatSkewSeconds,
119
+ ports: options.ports,
120
+ proof,
121
+ });
122
+ if (codePayload.dpopJkt && codePayload.dpopJkt !== verified.jkt) {
123
+ return oauthJsonError(c, 400, 'invalid_grant', 'DPoP key does not match the authorization code.');
124
+ }
125
+ return verified.jkt;
126
+ } catch (error) {
127
+ if (error instanceof OAuthDpopProofError) {
128
+ return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
129
+ }
130
+ throw error;
131
+ }
132
+ };
133
+
134
+ const issueTokens = async (
135
+ options: OAuthTokenHandlerOptions,
136
+ client: OauthClientRecord,
137
+ codePayload: AuthCodePayload,
138
+ user: AuthHonoUserRecord,
139
+ dpopJkt: string | null
140
+ ) => {
141
+ const accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 3600;
142
+ const idTokenTtlSeconds = options.idTokenTtlSeconds ?? 3600;
143
+ const now = options.ports.clock.now();
144
+ const accessExpiresAt = options.ports.clock.addSeconds(now, accessTokenTtlSeconds);
145
+ const idExpiresAt = options.ports.clock.addSeconds(now, idTokenTtlSeconds);
146
+ const scopes = codePayload.scope.split(/\s+/).filter(Boolean);
147
+ const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
148
+ const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
149
+ const accessJti = options.ports.random.uuid();
150
+ const accessAudience = `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`;
151
+ const accessToken = await jwks.signJwt(
152
+ {
153
+ acr: codePayload.acr,
154
+ auth_time: toEpochSeconds(codePayload.authTime),
155
+ client_id: client.clientId,
156
+ ...(cnf ? { cnf } : {}),
157
+ scope: codePayload.scope,
158
+ },
159
+ {
160
+ audience: accessAudience,
161
+ expiresAt: accessExpiresAt,
162
+ issuer: trimTrailingSlash(options.issuer),
163
+ jti: accessJti,
164
+ subject: codePayload.userId,
165
+ type: 'JWT',
166
+ }
167
+ );
168
+
169
+ await options.ports.oauthStateStore.saveTokenMeta(
170
+ accessJti,
171
+ tokenMeta({
172
+ audience: accessAudience,
173
+ client,
174
+ codePayload,
175
+ dpopJkt,
176
+ expiresAt: accessExpiresAt,
177
+ jti: accessJti,
178
+ tokenType: 'access_token',
179
+ }),
180
+ accessTokenTtlSeconds
181
+ );
182
+
183
+ const response: Record<string, unknown> = {
184
+ access_token: accessToken,
185
+ expires_in: accessTokenTtlSeconds,
186
+ scope: codePayload.scope,
187
+ token_type: dpopJkt ? 'DPoP' : 'Bearer',
188
+ };
189
+
190
+ if (scopes.includes('openid')) {
191
+ const idJti = options.ports.random.uuid();
192
+ const idToken = await jwks.signJwt(
193
+ {
194
+ acr: codePayload.acr,
195
+ auth_time: toEpochSeconds(codePayload.authTime),
196
+ ...(cnf ? { cnf } : {}),
197
+ ...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
198
+ ...(scopes.includes('profile') ? { name: user.displayName } : {}),
199
+ ...(codePayload.nonce ? { nonce: codePayload.nonce } : {}),
200
+ },
201
+ {
202
+ audience: client.clientId,
203
+ expiresAt: idExpiresAt,
204
+ issuer: trimTrailingSlash(options.issuer),
205
+ jti: idJti,
206
+ subject: codePayload.userId,
207
+ type: 'JWT',
208
+ }
209
+ );
210
+ response.id_token = idToken;
211
+ await options.ports.oauthStateStore.saveTokenMeta(
212
+ idJti,
213
+ tokenMeta({
214
+ audience: client.clientId,
215
+ client,
216
+ codePayload,
217
+ dpopJkt,
218
+ expiresAt: idExpiresAt,
219
+ jti: idJti,
220
+ tokenType: 'id_token',
221
+ }),
222
+ idTokenTtlSeconds
223
+ );
224
+ }
225
+
226
+ return response;
227
+ };
228
+
229
+ const tokenMeta = (input: {
230
+ audience: string;
231
+ client: OauthClientRecord;
232
+ codePayload: AuthCodePayload;
233
+ dpopJkt: string | null;
234
+ expiresAt: Date;
235
+ jti: string;
236
+ tokenType: 'access_token' | 'id_token';
237
+ }): TokenMeta => ({
238
+ audience: input.audience,
239
+ clientId: input.client.clientId,
240
+ createdAt: input.codePayload.createdAt,
241
+ dpopJkt: input.dpopJkt,
242
+ expiresAt: input.expiresAt,
243
+ jti: input.jti,
244
+ scope: input.codePayload.scope,
245
+ tenantId: input.codePayload.tenantId,
246
+ tokenType: input.tokenType,
247
+ userId: input.codePayload.userId,
248
+ });
249
+
250
+ const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
251
+
252
+ const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');