@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,201 @@
1
+ import type { Context } from 'hono';
2
+
3
+ import type { AuthHonoPorts } from '../ports.js';
4
+ import type { OauthClientRecord } from './state-store-types.js';
5
+ import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
6
+ import { appendParams, oauthJsonError, redirectWithOAuthError } from './http-utils.js';
7
+ import { resolveOAuthAcr, resolveOAuthSession } from './session-resolver.js';
8
+
9
+ export interface OAuthAuthorizeHandlerOptions {
10
+ consentUrl: string;
11
+ issuer: string;
12
+ loginUrl: string;
13
+ ports: AuthHonoPorts;
14
+ stateCodec: OAuthContinuationCodec;
15
+ stateTtlSeconds?: number;
16
+ }
17
+
18
+ interface ValidatedAuthorizeRequest {
19
+ client: OauthClientRecord;
20
+ codeChallenge: string;
21
+ dpopJkt: string | null;
22
+ nonce: string | null;
23
+ redirectUri: string;
24
+ scope: string;
25
+ state: string | null;
26
+ }
27
+
28
+ export const createOAuthAuthorizeHandler =
29
+ (options: OAuthAuthorizeHandlerOptions) =>
30
+ async (c: Context): Promise<Response> => {
31
+ const continuation = c.req.query('continue');
32
+ if (continuation) {
33
+ return resumeLoginContinuation(c, options, continuation);
34
+ }
35
+
36
+ const validation = await validateAuthorizeRequest(c, options.ports);
37
+ if (validation instanceof Response) return validation;
38
+
39
+ const prompt = c.req.query('prompt') ?? '';
40
+ const session = await resolveOAuthSession(c.req.raw, options.ports);
41
+
42
+ if (!session || prompt === 'login') {
43
+ if (prompt === 'none') {
44
+ return redirectWithOAuthError(validation.redirectUri, 'login_required', validation.state, c.req.url);
45
+ }
46
+
47
+ const continuation = await sealContinuation(c, options, validation);
48
+ return c.redirect(appendParams(options.loginUrl, { continue: continuation }, c.req.url), 302);
49
+ }
50
+
51
+ if (prompt === 'none') {
52
+ return redirectWithOAuthError(validation.redirectUri, 'consent_required', validation.state, c.req.url);
53
+ }
54
+
55
+ const sealedState = await sealContinuation(c, options, validation, {
56
+ acr: resolveOAuthAcr(session.sessionRecord),
57
+ authTime: session.sessionRecord.createdAt.toISOString(),
58
+ userId: session.user.id,
59
+ });
60
+
61
+ return c.redirect(appendParams(options.consentUrl, { state: sealedState }, c.req.url), 302);
62
+ };
63
+
64
+ const resumeLoginContinuation = async (
65
+ c: Context,
66
+ options: OAuthAuthorizeHandlerOptions,
67
+ continuation: string
68
+ ): Promise<Response> => {
69
+ const payload = await options.stateCodec.unseal(continuation);
70
+ const now = options.ports.clock.now();
71
+ if (!payload || payload.userId || payload.codeChallengeMethod !== 'S256' || new Date(payload.expiresAt) <= now) {
72
+ return oauthJsonError(c, 400, 'invalid_request', 'OAuth continuation is invalid or expired.');
73
+ }
74
+
75
+ const client = await options.ports.oauthStateStore.findClient(payload.clientId);
76
+ if (!client) return oauthJsonError(c, 400, 'invalid_request', 'Unknown OAuth client.');
77
+
78
+ const redirectError = validateRedirectUri(client, payload.redirectUri);
79
+ if (redirectError) return oauthJsonError(c, 400, 'invalid_request', redirectError);
80
+
81
+ const scopeResult = validateScope(payload.scope, client, payload.redirectUri, payload.state, c.req.url);
82
+ if (scopeResult instanceof Response) return scopeResult;
83
+
84
+ const session = await resolveOAuthSession(c.req.raw, options.ports);
85
+ if (!session) {
86
+ return c.redirect(appendParams(options.loginUrl, { continue: continuation }, c.req.url), 302);
87
+ }
88
+
89
+ const expiresAt = options.ports.clock.addSeconds(now, options.stateTtlSeconds ?? 10 * 60);
90
+ const sealedState = await options.stateCodec.seal({
91
+ ...payload,
92
+ acr: resolveOAuthAcr(session.sessionRecord),
93
+ authTime: session.sessionRecord.createdAt.toISOString(),
94
+ createdAt: now.toISOString(),
95
+ expiresAt: expiresAt.toISOString(),
96
+ scope: scopeResult,
97
+ userId: session.user.id,
98
+ });
99
+
100
+ return c.redirect(appendParams(options.consentUrl, { state: sealedState }, c.req.url), 302);
101
+ };
102
+
103
+ const validateAuthorizeRequest = async (
104
+ c: Context,
105
+ ports: AuthHonoPorts
106
+ ): Promise<ValidatedAuthorizeRequest | Response> => {
107
+ const clientId = c.req.query('client_id');
108
+ const client = clientId ? await ports.oauthStateStore.findClient(clientId) : null;
109
+ if (!client) {
110
+ return oauthJsonError(c, 400, 'invalid_request', 'Unknown OAuth client.');
111
+ }
112
+
113
+ const redirectUri = c.req.query('redirect_uri') ?? '';
114
+ const redirectError = validateRedirectUri(client, redirectUri);
115
+ if (redirectError) {
116
+ return oauthJsonError(c, 400, 'invalid_request', redirectError);
117
+ }
118
+
119
+ const state = c.req.query('state') ?? null;
120
+ if (c.req.query('response_type') !== 'code') {
121
+ return redirectWithOAuthError(redirectUri, 'unsupported_response_type', state, c.req.url);
122
+ }
123
+
124
+ const codeChallenge = c.req.query('code_challenge') ?? '';
125
+ if (!codeChallenge || c.req.query('code_challenge_method') !== 'S256') {
126
+ return redirectWithOAuthError(redirectUri, 'invalid_request', state, c.req.url);
127
+ }
128
+
129
+ const scopeResult = validateScope(c.req.query('scope') ?? '', client, redirectUri, state, c.req.url);
130
+ if (scopeResult instanceof Response) return scopeResult;
131
+
132
+ return {
133
+ client,
134
+ codeChallenge,
135
+ dpopJkt: c.req.query('dpop_jkt') ?? null,
136
+ nonce: c.req.query('nonce') ?? null,
137
+ redirectUri,
138
+ scope: scopeResult,
139
+ state,
140
+ };
141
+ };
142
+
143
+ const validateRedirectUri = (client: OauthClientRecord, redirectUri: string): string | null => {
144
+ if (!client.redirectUris.includes(redirectUri)) return 'redirect_uri is not registered for this client.';
145
+
146
+ let parsed: URL;
147
+ try {
148
+ parsed = new URL(redirectUri);
149
+ } catch {
150
+ return 'redirect_uri must be an absolute URI.';
151
+ }
152
+
153
+ if (parsed.hash) return 'redirect_uri must not contain a fragment.';
154
+ if (parsed.username || parsed.password) return 'redirect_uri must not contain credentials.';
155
+ if (parsed.protocol === 'https:') return null;
156
+ if (parsed.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(parsed.hostname)) return null;
157
+ return 'redirect_uri must use https except for localhost development callbacks.';
158
+ };
159
+
160
+ const validateScope = (
161
+ scope: string,
162
+ client: OauthClientRecord,
163
+ redirectUri: string,
164
+ state: string | null,
165
+ baseUrl: string
166
+ ): string | Response => {
167
+ const requestedScopes = scope.split(/\s+/).filter(Boolean);
168
+ if (requestedScopes.includes('offline_access')) {
169
+ return redirectWithOAuthError(redirectUri, 'invalid_scope', state, baseUrl);
170
+ }
171
+ if (requestedScopes.some((requestedScope) => !client.allowedScopes.includes(requestedScope))) {
172
+ return redirectWithOAuthError(redirectUri, 'invalid_scope', state, baseUrl);
173
+ }
174
+ return requestedScopes.join(' ');
175
+ };
176
+
177
+ const sealContinuation = async (
178
+ c: Context,
179
+ options: OAuthAuthorizeHandlerOptions,
180
+ request: ValidatedAuthorizeRequest,
181
+ session?: Pick<OAuthContinuationState, 'acr' | 'authTime' | 'userId'>
182
+ ): Promise<string> => {
183
+ const now = options.ports.clock.now();
184
+ const expiresAt = options.ports.clock.addSeconds(now, options.stateTtlSeconds ?? 10 * 60);
185
+ return options.stateCodec.seal({
186
+ acr: session?.acr,
187
+ authTime: session?.authTime,
188
+ clientId: request.client.clientId,
189
+ codeChallenge: request.codeChallenge,
190
+ codeChallengeMethod: 'S256',
191
+ createdAt: now.toISOString(),
192
+ dpopJkt: request.dpopJkt,
193
+ expiresAt: expiresAt.toISOString(),
194
+ nonce: request.nonce,
195
+ redirectUri: request.redirectUri,
196
+ scope: request.scope,
197
+ state: request.state,
198
+ tenantId: request.client.tenantId,
199
+ userId: session?.userId,
200
+ });
201
+ };
@@ -0,0 +1,93 @@
1
+ import type { Context } from 'hono';
2
+
3
+ import type { AuthHonoPorts } from '../ports.js';
4
+ import { appendParams, oauthJsonError, redirectOrJson } from './http-utils.js';
5
+ import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
6
+ import { resolveOAuthSession } from './session-resolver.js';
7
+
8
+ export interface OAuthConsentHandlerOptions {
9
+ authorizationCodeTtlSeconds?: number;
10
+ ports: AuthHonoPorts;
11
+ stateCodec: OAuthContinuationCodec;
12
+ }
13
+
14
+ export const createOAuthConsentDetailsHandler =
15
+ (options: OAuthConsentHandlerOptions) =>
16
+ async (c: Context): Promise<Response> => {
17
+ const state = c.req.query('state') ?? '';
18
+ const payload = await validateConsentState(c, options, state);
19
+ if (payload instanceof Response) return payload;
20
+
21
+ const client = await options.ports.oauthStateStore.findClient(payload.clientId);
22
+ if (!client) return oauthJsonError(c, 400, 'invalid_request', 'Unknown OAuth client.');
23
+
24
+ return c.json({
25
+ clientName: client.name,
26
+ redirectUri: payload.redirectUri,
27
+ scopes: payload.scope.split(/\s+/).filter(Boolean),
28
+ });
29
+ };
30
+
31
+ export const createOAuthConsentDecisionHandler =
32
+ (options: OAuthConsentHandlerOptions) =>
33
+ async (c: Context): Promise<Response> => {
34
+ const body = await c.req.json<{ decision?: string; state?: string }>().catch(() => null);
35
+ if (!body?.state || !['approve', 'deny'].includes(body.decision ?? '')) {
36
+ return oauthJsonError(c, 400, 'invalid_request', 'Consent decision and state are required.');
37
+ }
38
+
39
+ const payload = await validateConsentState(c, options, body.state);
40
+ if (payload instanceof Response) return payload;
41
+
42
+ if (body.decision === 'deny') {
43
+ return redirectOrJson(
44
+ c,
45
+ appendParams(payload.redirectUri, { error: 'access_denied', state: payload.state }, c.req.url)
46
+ );
47
+ }
48
+
49
+ const code = options.ports.random.token(32);
50
+ const now = options.ports.clock.now();
51
+ await options.ports.oauthStateStore.saveAuthCode(
52
+ code,
53
+ {
54
+ acr: payload.acr ?? 'urn:sentropic:loa:bearer',
55
+ authTime: new Date(payload.authTime ?? now.toISOString()),
56
+ clientId: payload.clientId,
57
+ codeChallenge: payload.codeChallenge,
58
+ codeChallengeMethod: 'S256',
59
+ createdAt: now,
60
+ dpopJkt: payload.dpopJkt,
61
+ expiresAt: options.ports.clock.addSeconds(now, options.authorizationCodeTtlSeconds ?? 60),
62
+ nonce: payload.nonce,
63
+ redirectUri: payload.redirectUri,
64
+ scope: payload.scope,
65
+ tenantId: payload.tenantId,
66
+ userId: payload.userId ?? '',
67
+ },
68
+ options.authorizationCodeTtlSeconds ?? 60
69
+ );
70
+
71
+ return redirectOrJson(
72
+ c,
73
+ appendParams(payload.redirectUri, { code, state: payload.state }, c.req.url)
74
+ );
75
+ };
76
+
77
+ const validateConsentState = async (
78
+ c: Context,
79
+ options: OAuthConsentHandlerOptions,
80
+ sealedState: string
81
+ ): Promise<OAuthContinuationState | Response> => {
82
+ const payload = await options.stateCodec.unseal(sealedState);
83
+ if (!payload || !payload.userId || new Date(payload.expiresAt) <= options.ports.clock.now()) {
84
+ return oauthJsonError(c, 400, 'invalid_request', 'OAuth consent state is invalid or expired.');
85
+ }
86
+
87
+ const session = await resolveOAuthSession(c.req.raw, options.ports);
88
+ if (!session || session.user.id !== payload.userId) {
89
+ return oauthJsonError(c, 401, 'login_required', 'A valid user session is required.');
90
+ }
91
+
92
+ return payload;
93
+ };
@@ -0,0 +1,14 @@
1
+ const textEncoder = new TextEncoder();
2
+
3
+ export const sha256Base64url = async (value: string): Promise<string> => {
4
+ const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(value));
5
+ return base64urlEncode(new Uint8Array(digest));
6
+ };
7
+
8
+ export const base64urlEncode = (bytes: Uint8Array): string => {
9
+ let binary = '';
10
+ for (const byte of bytes) {
11
+ binary += String.fromCharCode(byte);
12
+ }
13
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
14
+ };
@@ -0,0 +1,93 @@
1
+ import {
2
+ calculateJwkThumbprint,
3
+ decodeProtectedHeader,
4
+ importJWK,
5
+ jwtVerify,
6
+ type JWK,
7
+ type JWTPayload,
8
+ } from 'jose';
9
+
10
+ import type { AuthHonoPorts } from '../ports.js';
11
+ import { sha256Base64url } from './crypto-utils.js';
12
+
13
+ export interface VerifyDpopProofOptions {
14
+ accessToken?: string;
15
+ htm: string;
16
+ htu: string;
17
+ iatSkewSeconds?: number;
18
+ ports: AuthHonoPorts;
19
+ proof: string;
20
+ }
21
+
22
+ export interface VerifiedDpopProof {
23
+ jkt: string;
24
+ jti: string;
25
+ }
26
+
27
+ export class OAuthDpopProofError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'OAuthDpopProofError';
31
+ }
32
+ }
33
+
34
+ export const verifyOAuthDpopProof = async (
35
+ options: VerifyDpopProofOptions
36
+ ): Promise<VerifiedDpopProof> => {
37
+ const header = decodeProtectedHeader(options.proof);
38
+ const publicJwk = header.jwk as JWK | undefined;
39
+ if (!publicJwk || !header.alg || header.typ !== 'dpop+jwt') {
40
+ throw new OAuthDpopProofError('DPoP proof header is invalid.');
41
+ }
42
+
43
+ const key = await importJWK(publicJwk, header.alg);
44
+ const { payload } = await jwtVerify(options.proof, key);
45
+ await validateDpopPayload(payload, options);
46
+
47
+ const expiresAt = options.ports.clock.addSeconds(
48
+ options.ports.clock.now(),
49
+ options.iatSkewSeconds ?? 60
50
+ );
51
+ const recorded = await options.ports.oauthStateStore.recordDpopJti(String(payload.jti), expiresAt);
52
+ if (!recorded) {
53
+ throw new OAuthDpopProofError('DPoP proof jti was already used.');
54
+ }
55
+
56
+ return {
57
+ jkt: await calculateJwkThumbprint(publicJwk),
58
+ jti: String(payload.jti),
59
+ };
60
+ };
61
+
62
+ const validateDpopPayload = async (
63
+ payload: JWTPayload,
64
+ options: VerifyDpopProofOptions
65
+ ): Promise<void> => {
66
+ if (payload.htm !== options.htm.toUpperCase()) {
67
+ throw new OAuthDpopProofError('DPoP htm claim does not match the request method.');
68
+ }
69
+ if (payload.htu !== options.htu) {
70
+ throw new OAuthDpopProofError('DPoP htu claim does not match the request URL.');
71
+ }
72
+ if (!payload.jti || typeof payload.jti !== 'string') {
73
+ throw new OAuthDpopProofError('DPoP jti claim is required.');
74
+ }
75
+ if (typeof payload.iat !== 'number') {
76
+ throw new OAuthDpopProofError('DPoP iat claim is required.');
77
+ }
78
+
79
+ const nowSeconds = Math.floor(options.ports.clock.now().getTime() / 1000);
80
+ if (Math.abs(payload.iat - nowSeconds) > (options.iatSkewSeconds ?? 60)) {
81
+ throw new OAuthDpopProofError('DPoP iat claim is outside the allowed skew.');
82
+ }
83
+
84
+ if (options.accessToken) {
85
+ await validateAth(payload, options.accessToken);
86
+ }
87
+ };
88
+
89
+ const validateAth = async (payload: JWTPayload, accessToken: string): Promise<void> => {
90
+ if (payload.ath !== (await sha256Base64url(accessToken))) {
91
+ throw new OAuthDpopProofError('DPoP ath claim does not match the access token.');
92
+ }
93
+ };
@@ -0,0 +1,58 @@
1
+ import type { Context } from 'hono';
2
+
3
+ export const appendParams = (
4
+ target: string,
5
+ params: Record<string, string | null | undefined>,
6
+ baseUrl: string
7
+ ): string => {
8
+ const url = new URL(target, baseUrl);
9
+ for (const [key, value] of Object.entries(params)) {
10
+ if (value !== null && value !== undefined) {
11
+ url.searchParams.set(key, value);
12
+ }
13
+ }
14
+ return url.toString();
15
+ };
16
+
17
+ export const oauthJsonError = (
18
+ c: Context,
19
+ status: 400 | 401,
20
+ code: string,
21
+ message: string
22
+ ): Response =>
23
+ c.json(
24
+ {
25
+ error: {
26
+ code,
27
+ message,
28
+ },
29
+ },
30
+ status
31
+ );
32
+
33
+ export const redirectWithOAuthError = (
34
+ redirectUri: string,
35
+ code: string,
36
+ state: string | null,
37
+ baseUrl: string
38
+ ): Response =>
39
+ Response.redirect(
40
+ appendParams(
41
+ redirectUri,
42
+ {
43
+ error: code,
44
+ state,
45
+ },
46
+ baseUrl
47
+ ),
48
+ 302
49
+ );
50
+
51
+ export const redirectOrJson = (c: Context, redirectTo: string): Response => {
52
+ const accept = c.req.header('accept') ?? '';
53
+ if (accept.includes('application/json')) {
54
+ return c.json({ redirectTo });
55
+ }
56
+
57
+ return c.redirect(redirectTo, 302);
58
+ };
@@ -0,0 +1,88 @@
1
+ import type { Context } from 'hono';
2
+ import type { JWTPayload } from 'jose';
3
+
4
+ import type { AuthHonoPorts } from '../ports.js';
5
+ import { oauthJsonError } from './http-utils.js';
6
+ import { createJwksService } from './jwks-service.js';
7
+
8
+ export interface OAuthIntrospectHandlerOptions {
9
+ issuer: string;
10
+ ports: AuthHonoPorts;
11
+ }
12
+
13
+ export const createOAuthIntrospectHandler =
14
+ (options: OAuthIntrospectHandlerOptions) =>
15
+ async (c: Context): Promise<Response> => {
16
+ const authenticated = await authenticateIntrospectionClient(c, options.ports);
17
+ if (authenticated instanceof Response) return authenticated;
18
+
19
+ const form = new URLSearchParams(await c.req.text());
20
+ const token = form.get('token');
21
+ if (!token) return c.json({ active: false });
22
+
23
+ const payload = await verifyToken(options, token);
24
+ if (!payload?.jti) return c.json({ active: false });
25
+
26
+ const meta = await options.ports.oauthStateStore.findTokenMeta(payload.jti);
27
+ if (
28
+ !meta ||
29
+ meta.expiresAt <= options.ports.clock.now() ||
30
+ (await options.ports.oauthStateStore.isTokenRevoked(payload.jti))
31
+ ) {
32
+ return c.json({ active: false });
33
+ }
34
+
35
+ return c.json({
36
+ active: true,
37
+ aud: meta.audience,
38
+ client_id: meta.clientId,
39
+ ...(meta.dpopJkt ? { cnf: { jkt: meta.dpopJkt } } : {}),
40
+ exp: toEpochSeconds(meta.expiresAt),
41
+ iat: typeof payload.iat === 'number' ? payload.iat : undefined,
42
+ jti: meta.jti,
43
+ scope: meta.scope,
44
+ sub: meta.userId,
45
+ token_type: meta.tokenType,
46
+ });
47
+ };
48
+
49
+ const authenticateIntrospectionClient = async (
50
+ c: Context,
51
+ ports: AuthHonoPorts
52
+ ): Promise<true | Response> => {
53
+ const authorization = c.req.header('authorization');
54
+ if (!authorization?.startsWith('Basic ')) {
55
+ return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
56
+ }
57
+
58
+ const decoded = atob(authorization.slice('Basic '.length));
59
+ const separator = decoded.indexOf(':');
60
+ const clientId = separator >= 0 ? decoded.slice(0, separator) : decoded;
61
+ const secret = separator >= 0 ? decoded.slice(separator + 1) : '';
62
+ const client = await ports.oauthStateStore.findClient(clientId);
63
+ if (!client?.clientSecretHash || (await ports.tokens.hashSecret(secret)) !== client.clientSecretHash) {
64
+ return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
65
+ }
66
+
67
+ return true;
68
+ };
69
+
70
+ const verifyToken = async (
71
+ options: OAuthIntrospectHandlerOptions,
72
+ token: string
73
+ ): Promise<JWTPayload | null> => {
74
+ try {
75
+ const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
76
+ const result = await jwks.verifyJwt(token, {
77
+ currentDate: options.ports.clock.now(),
78
+ issuer: trimTrailingSlash(options.issuer),
79
+ });
80
+ return result.payload;
81
+ } catch {
82
+ return null;
83
+ }
84
+ };
85
+
86
+ const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
87
+
88
+ const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');
@@ -0,0 +1,103 @@
1
+ import {
2
+ decodeProtectedHeader,
3
+ importJWK,
4
+ jwtVerify,
5
+ SignJWT,
6
+ type JWTVerifyOptions,
7
+ type JWTVerifyResult,
8
+ type JWTPayload,
9
+ } from 'jose';
10
+
11
+ import type { AuthHonoClockPort } from '../ports.js';
12
+ import type { JwksPort } from './state-store-types.js';
13
+
14
+ export interface CreateJwksServiceOptions {
15
+ clock: AuthHonoClockPort;
16
+ jwksPort: JwksPort;
17
+ }
18
+
19
+ export interface JwksSignOptions {
20
+ audience?: string | string[];
21
+ expiresAt?: Date;
22
+ issuer?: string;
23
+ jti?: string;
24
+ subject?: string;
25
+ type?: string;
26
+ }
27
+
28
+ export interface PublicJwks {
29
+ keys: Array<Record<string, unknown>>;
30
+ }
31
+
32
+ export interface JwksService {
33
+ getPublicJwks(): Promise<PublicJwks>;
34
+ signJwt(payload: JWTPayload, options?: JwksSignOptions): Promise<string>;
35
+ verifyJwt<T extends JWTPayload = JWTPayload>(
36
+ jwt: string,
37
+ options?: JWTVerifyOptions
38
+ ): Promise<JWTVerifyResult<T>>;
39
+ }
40
+
41
+ export const createJwksService = ({ clock, jwksPort }: CreateJwksServiceOptions): JwksService => ({
42
+ async getPublicJwks() {
43
+ const keys = await jwksPort.listPublicKeys();
44
+
45
+ return {
46
+ keys: keys.map((key) => {
47
+ const { d: _privateExponent, ...publicJwk } = key.publicJwk;
48
+ return {
49
+ ...publicJwk,
50
+ alg: key.alg,
51
+ crv: key.crv,
52
+ kid: key.kid,
53
+ kty: publicJwk.kty,
54
+ status: key.active ? 'active' : 'rotated',
55
+ use: 'sig',
56
+ };
57
+ }),
58
+ };
59
+ },
60
+
61
+ async signJwt(payload, options = {}) {
62
+ const activeKey = await jwksPort.getActiveKey();
63
+ if (!activeKey) {
64
+ throw new Error('No active JWKS signing key is configured.');
65
+ }
66
+ if (!activeKey.privateKey) {
67
+ throw new Error(`Active JWKS signing key ${activeKey.kid} has no private key material.`);
68
+ }
69
+
70
+ let jwt = new SignJWT(payload).setProtectedHeader({
71
+ alg: activeKey.alg,
72
+ kid: activeKey.kid,
73
+ ...(options.type ? { typ: options.type } : {}),
74
+ });
75
+
76
+ jwt = jwt.setIssuedAt(toEpochSeconds(clock.now()));
77
+ if (options.audience) jwt = jwt.setAudience(options.audience);
78
+ if (options.expiresAt) jwt = jwt.setExpirationTime(toEpochSeconds(options.expiresAt));
79
+ if (options.issuer) jwt = jwt.setIssuer(options.issuer);
80
+ if (options.jti) jwt = jwt.setJti(options.jti);
81
+ if (options.subject) jwt = jwt.setSubject(options.subject);
82
+
83
+ return jwt.sign(activeKey.privateKey);
84
+ },
85
+
86
+ async verifyJwt(jwt, options = {}) {
87
+ const protectedHeader = decodeProtectedHeader(jwt);
88
+ const kid = protectedHeader.kid;
89
+ if (!kid) {
90
+ throw new Error('JWT protected header is missing kid.');
91
+ }
92
+
93
+ const key = await jwksPort.findKeyByKid(kid);
94
+ if (!key) {
95
+ throw new Error(`Unknown JWKS kid: ${kid}`);
96
+ }
97
+
98
+ const publicKey = await importJWK(key.publicJwk, key.alg);
99
+ return jwtVerify(jwt, publicKey, options);
100
+ },
101
+ });
102
+
103
+ const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);