@sentropic/auth-hono 0.2.1 → 0.4.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.
- package/README.md +168 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/authorize-handler.d.ts +13 -0
- package/dist/oauth/authorize-handler.d.ts.map +1 -0
- package/dist/oauth/authorize-handler.js +143 -0
- package/dist/oauth/authorize-handler.js.map +1 -0
- package/dist/oauth/consent-decision-handler.d.ts +11 -0
- package/dist/oauth/consent-decision-handler.d.ts.map +1 -0
- package/dist/oauth/consent-decision-handler.js +58 -0
- package/dist/oauth/consent-decision-handler.js.map +1 -0
- package/dist/oauth/crypto-utils.d.ts +3 -0
- package/dist/oauth/crypto-utils.d.ts.map +1 -0
- package/dist/oauth/crypto-utils.js +13 -0
- package/dist/oauth/crypto-utils.js.map +1 -0
- package/dist/oauth/dpop.d.ts +18 -0
- package/dist/oauth/dpop.d.ts.map +1 -0
- package/dist/oauth/dpop.js +54 -0
- package/dist/oauth/dpop.js.map +1 -0
- package/dist/oauth/http-utils.d.ts +6 -0
- package/dist/oauth/http-utils.d.ts.map +1 -0
- package/dist/oauth/http-utils.js +27 -0
- package/dist/oauth/http-utils.js.map +1 -0
- package/dist/oauth/introspect-handler.d.ts +8 -0
- package/dist/oauth/introspect-handler.d.ts.map +1 -0
- package/dist/oauth/introspect-handler.js +63 -0
- package/dist/oauth/introspect-handler.js.map +1 -0
- package/dist/oauth/jwks-service.d.ts +25 -0
- package/dist/oauth/jwks-service.d.ts.map +1 -0
- package/dist/oauth/jwks-service.js +61 -0
- package/dist/oauth/jwks-service.js.map +1 -0
- package/dist/oauth/revoke-handler.d.ts +8 -0
- package/dist/oauth/revoke-handler.d.ts.map +1 -0
- package/dist/oauth/revoke-handler.js +55 -0
- package/dist/oauth/revoke-handler.js.map +1 -0
- package/dist/oauth/router.d.ts +8 -0
- package/dist/oauth/router.d.ts.map +1 -0
- package/dist/oauth/router.js +30 -0
- package/dist/oauth/router.js.map +1 -0
- package/dist/oauth/service-auth-middleware.d.ts +30 -0
- package/dist/oauth/service-auth-middleware.d.ts.map +1 -0
- package/dist/oauth/service-auth-middleware.js +170 -0
- package/dist/oauth/service-auth-middleware.js.map +1 -0
- package/dist/oauth/session-resolver.d.ts +9 -0
- package/dist/oauth/session-resolver.d.ts.map +1 -0
- package/dist/oauth/session-resolver.js +28 -0
- package/dist/oauth/session-resolver.js.map +1 -0
- package/dist/oauth/state-codec.d.ts +25 -0
- package/dist/oauth/state-codec.d.ts.map +1 -0
- package/dist/oauth/state-codec.js +60 -0
- package/dist/oauth/state-codec.js.map +1 -0
- package/dist/oauth/state-store-types.d.ts +100 -0
- package/dist/oauth/state-store-types.d.ts.map +1 -0
- package/dist/oauth/state-store-types.js +2 -0
- package/dist/oauth/state-store-types.js.map +1 -0
- package/dist/oauth/token-handler.d.ts +12 -0
- package/dist/oauth/token-handler.d.ts.map +1 -0
- package/dist/oauth/token-handler.js +294 -0
- package/dist/oauth/token-handler.js.map +1 -0
- package/dist/oauth/userinfo-handler.d.ts +9 -0
- package/dist/oauth/userinfo-handler.d.ts.map +1 -0
- package/dist/oauth/userinfo-handler.js +93 -0
- package/dist/oauth/userinfo-handler.js.map +1 -0
- package/dist/oauth/wellknown-handler.d.ts +9 -0
- package/dist/oauth/wellknown-handler.d.ts.map +1 -0
- package/dist/oauth/wellknown-handler.js +37 -0
- package/dist/oauth/wellknown-handler.js.map +1 -0
- package/dist/ports.d.ts +4 -0
- package/dist/ports.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/contracts.ts +2 -0
- package/src/index.ts +16 -0
- package/src/oauth/authorize-handler.ts +201 -0
- package/src/oauth/consent-decision-handler.ts +93 -0
- package/src/oauth/crypto-utils.ts +14 -0
- package/src/oauth/dpop.ts +93 -0
- package/src/oauth/http-utils.ts +58 -0
- package/src/oauth/introspect-handler.ts +88 -0
- package/src/oauth/jwks-service.ts +103 -0
- package/src/oauth/revoke-handler.ts +70 -0
- package/src/oauth/router.ts +42 -0
- package/src/oauth/service-auth-middleware.ts +250 -0
- package/src/oauth/session-resolver.ts +48 -0
- package/src/oauth/state-codec.ts +98 -0
- package/src/oauth/state-store-types.ts +109 -0
- package/src/oauth/token-handler.ts +423 -0
- package/src/oauth/userinfo-handler.ts +129 -0
- package/src/oauth/wellknown-handler.ts +52 -0
- package/src/ports.ts +17 -0
|
@@ -0,0 +1,423 @@
|
|
|
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, ServiceClientRecord, TokenMeta } from './state-store-types.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SERVICE_ACCESS_TOKEN_TTL_SECONDS = 900;
|
|
11
|
+
|
|
12
|
+
export interface OAuthTokenHandlerOptions {
|
|
13
|
+
accessTokenTtlSeconds?: number;
|
|
14
|
+
dpopIatSkewSeconds?: number;
|
|
15
|
+
idTokenTtlSeconds?: number;
|
|
16
|
+
issuer: string;
|
|
17
|
+
ports: AuthHonoPorts;
|
|
18
|
+
serviceAccessTokenTtlSeconds?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ClientAuthentication {
|
|
22
|
+
client: OauthClientRecord;
|
|
23
|
+
secret?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ServiceClientAuthentication {
|
|
27
|
+
client: ServiceClientRecord;
|
|
28
|
+
secret: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const createOAuthTokenHandler =
|
|
32
|
+
(options: OAuthTokenHandlerOptions) =>
|
|
33
|
+
async (c: Context): Promise<Response> => {
|
|
34
|
+
const form = new URLSearchParams(await c.req.text());
|
|
35
|
+
const grantType = form.get('grant_type');
|
|
36
|
+
if (grantType === 'client_credentials') {
|
|
37
|
+
return handleClientCredentials(c, form, options);
|
|
38
|
+
}
|
|
39
|
+
if (grantType !== 'authorization_code') {
|
|
40
|
+
return oauthJsonError(
|
|
41
|
+
c,
|
|
42
|
+
400,
|
|
43
|
+
'unsupported_grant_type',
|
|
44
|
+
'Only authorization_code and client_credentials grants are supported.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const auth = await authenticateClient(c, form, options.ports);
|
|
49
|
+
if (auth instanceof Response) return auth;
|
|
50
|
+
|
|
51
|
+
const codePayload = await options.ports.oauthStateStore.consumeAuthCode(form.get('code') ?? '');
|
|
52
|
+
if (!codePayload || codePayload.clientId !== auth.client.clientId) {
|
|
53
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'Authorization code is invalid or already used.');
|
|
54
|
+
}
|
|
55
|
+
if (form.get('redirect_uri') !== codePayload.redirectUri) {
|
|
56
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'redirect_uri does not match the authorization request.');
|
|
57
|
+
}
|
|
58
|
+
if ((await sha256Base64url(form.get('code_verifier') ?? '')) !== codePayload.codeChallenge) {
|
|
59
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'PKCE verification failed.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const dpopJkt = await resolveDpopJkt(c, options, auth.client, codePayload);
|
|
63
|
+
if (dpopJkt instanceof Response) return dpopJkt;
|
|
64
|
+
|
|
65
|
+
const user = await options.ports.users.findById(codePayload.userId);
|
|
66
|
+
if (!user) return oauthJsonError(c, 400, 'invalid_grant', 'Authorization code user is invalid.');
|
|
67
|
+
|
|
68
|
+
const tokens = await issueTokens(options, auth.client, codePayload, user, dpopJkt);
|
|
69
|
+
return c.json(tokens);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const authenticateClient = async (
|
|
73
|
+
c: Context,
|
|
74
|
+
form: URLSearchParams,
|
|
75
|
+
ports: AuthHonoPorts
|
|
76
|
+
): Promise<ClientAuthentication | Response> => {
|
|
77
|
+
const credentials = parseClientCredentials(c.req.header('authorization'), form);
|
|
78
|
+
if (!credentials.clientId) {
|
|
79
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const client = await ports.oauthStateStore.findClient(credentials.clientId);
|
|
83
|
+
if (!client) return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
84
|
+
|
|
85
|
+
if (client.tokenEndpointAuthMethod === 'none') {
|
|
86
|
+
return { client };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!credentials.secret || !client.clientSecretHash) {
|
|
90
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client secret is required.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const secretHash = await ports.tokens.hashSecret(credentials.secret);
|
|
94
|
+
if (secretHash !== client.clientSecretHash) {
|
|
95
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { client, secret: credentials.secret };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const parseClientCredentials = (
|
|
102
|
+
authorization: string | undefined,
|
|
103
|
+
form: URLSearchParams
|
|
104
|
+
): { clientId: string | null; secret?: string } => {
|
|
105
|
+
if (authorization?.startsWith('Basic ')) {
|
|
106
|
+
const decoded = atob(authorization.slice('Basic '.length));
|
|
107
|
+
const separator = decoded.indexOf(':');
|
|
108
|
+
return {
|
|
109
|
+
clientId: separator >= 0 ? decoded.slice(0, separator) : decoded,
|
|
110
|
+
secret: separator >= 0 ? decoded.slice(separator + 1) : '',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
clientId: form.get('client_id'),
|
|
116
|
+
secret: form.get('client_secret') ?? undefined,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const resolveDpopJkt = async (
|
|
121
|
+
c: Context,
|
|
122
|
+
options: OAuthTokenHandlerOptions,
|
|
123
|
+
client: OauthClientRecord,
|
|
124
|
+
codePayload: AuthCodePayload
|
|
125
|
+
): Promise<string | null | Response> => {
|
|
126
|
+
if (!client.dpopBoundAccessTokens) return null;
|
|
127
|
+
|
|
128
|
+
const proof = c.req.header('dpop');
|
|
129
|
+
if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const verified = await verifyOAuthDpopProof({
|
|
133
|
+
htm: 'POST',
|
|
134
|
+
htu: c.req.url,
|
|
135
|
+
iatSkewSeconds: options.dpopIatSkewSeconds,
|
|
136
|
+
ports: options.ports,
|
|
137
|
+
proof,
|
|
138
|
+
});
|
|
139
|
+
if (codePayload.dpopJkt && codePayload.dpopJkt !== verified.jkt) {
|
|
140
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'DPoP key does not match the authorization code.');
|
|
141
|
+
}
|
|
142
|
+
return verified.jkt;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof OAuthDpopProofError) {
|
|
145
|
+
return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleClientCredentials = async (
|
|
152
|
+
c: Context,
|
|
153
|
+
form: URLSearchParams,
|
|
154
|
+
options: OAuthTokenHandlerOptions
|
|
155
|
+
): Promise<Response> => {
|
|
156
|
+
const findServiceClient = options.ports.oauthStateStore.findServiceClient;
|
|
157
|
+
if (!findServiceClient) {
|
|
158
|
+
return oauthJsonError(c, 400, 'unsupported_grant_type', 'The client_credentials grant is not supported.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const auth = await authenticateServiceClient(c, form, options.ports, findServiceClient);
|
|
162
|
+
if (auth instanceof Response) return auth;
|
|
163
|
+
|
|
164
|
+
const scope = resolveServiceScope(c, form, auth.client);
|
|
165
|
+
if (scope instanceof Response) return scope;
|
|
166
|
+
|
|
167
|
+
const resource = resolveResourceIndicator(c, form, auth.client);
|
|
168
|
+
if (resource instanceof Response) return resource;
|
|
169
|
+
|
|
170
|
+
const dpopJkt = await resolveServiceDpopJkt(c, options, auth.client);
|
|
171
|
+
if (dpopJkt instanceof Response) return dpopJkt;
|
|
172
|
+
|
|
173
|
+
const tokens = await issueServiceToken(options, auth.client, scope, resource, dpopJkt);
|
|
174
|
+
return c.json(tokens);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const authenticateServiceClient = async (
|
|
178
|
+
c: Context,
|
|
179
|
+
form: URLSearchParams,
|
|
180
|
+
ports: AuthHonoPorts,
|
|
181
|
+
findServiceClient: NonNullable<AuthHonoPorts['oauthStateStore']['findServiceClient']>
|
|
182
|
+
): Promise<ServiceClientAuthentication | Response> => {
|
|
183
|
+
const credentials = parseClientCredentials(c.req.header('authorization'), form);
|
|
184
|
+
if (!credentials.clientId || !credentials.secret) {
|
|
185
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const client = await findServiceClient(credentials.clientId);
|
|
189
|
+
if (!client) return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
190
|
+
|
|
191
|
+
const secretHash = await ports.tokens.hashSecret(credentials.secret);
|
|
192
|
+
if (secretHash !== client.clientSecretHash) {
|
|
193
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { client, secret: credentials.secret };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const resolveServiceScope = (
|
|
200
|
+
c: Context,
|
|
201
|
+
form: URLSearchParams,
|
|
202
|
+
client: ServiceClientRecord
|
|
203
|
+
): string | Response => {
|
|
204
|
+
const requested = (form.get('scope') ?? '').split(/\s+/).filter(Boolean);
|
|
205
|
+
if (requested.length === 0) {
|
|
206
|
+
return client.allowedScopes.join(' ');
|
|
207
|
+
}
|
|
208
|
+
const allowed = new Set(client.allowedScopes);
|
|
209
|
+
const unauthorized = requested.filter((scope) => !allowed.has(scope));
|
|
210
|
+
if (unauthorized.length > 0) {
|
|
211
|
+
return oauthJsonError(c, 400, 'invalid_scope', `Scope not allowed: ${unauthorized.join(' ')}.`);
|
|
212
|
+
}
|
|
213
|
+
return requested.join(' ');
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const resolveResourceIndicator = (
|
|
217
|
+
c: Context,
|
|
218
|
+
form: URLSearchParams,
|
|
219
|
+
client: ServiceClientRecord
|
|
220
|
+
): string | Response => {
|
|
221
|
+
const requested = form.get('resource');
|
|
222
|
+
const indicators = client.resourceIndicators;
|
|
223
|
+
|
|
224
|
+
if (requested) {
|
|
225
|
+
if (!indicators.includes(requested)) {
|
|
226
|
+
return oauthJsonError(c, 400, 'invalid_target', 'Requested resource is not allowed for this client.');
|
|
227
|
+
}
|
|
228
|
+
return requested;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (indicators.length === 1) {
|
|
232
|
+
return indicators[0];
|
|
233
|
+
}
|
|
234
|
+
if (indicators.length === 0) {
|
|
235
|
+
return oauthJsonError(c, 400, 'invalid_target', 'A resource indicator is required for this client.');
|
|
236
|
+
}
|
|
237
|
+
return oauthJsonError(c, 400, 'invalid_target', 'A resource indicator must be specified when multiple are allowed.');
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const resolveServiceDpopJkt = async (
|
|
241
|
+
c: Context,
|
|
242
|
+
options: OAuthTokenHandlerOptions,
|
|
243
|
+
client: ServiceClientRecord
|
|
244
|
+
): Promise<string | null | Response> => {
|
|
245
|
+
if (!client.dpopBoundAccessTokens) return null;
|
|
246
|
+
|
|
247
|
+
const proof = c.req.header('dpop');
|
|
248
|
+
if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const verified = await verifyOAuthDpopProof({
|
|
252
|
+
htm: 'POST',
|
|
253
|
+
htu: c.req.url,
|
|
254
|
+
iatSkewSeconds: options.dpopIatSkewSeconds,
|
|
255
|
+
ports: options.ports,
|
|
256
|
+
proof,
|
|
257
|
+
});
|
|
258
|
+
return verified.jkt;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof OAuthDpopProofError) {
|
|
261
|
+
return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const issueServiceToken = async (
|
|
268
|
+
options: OAuthTokenHandlerOptions,
|
|
269
|
+
client: ServiceClientRecord,
|
|
270
|
+
scope: string,
|
|
271
|
+
resource: string,
|
|
272
|
+
dpopJkt: string | null
|
|
273
|
+
) => {
|
|
274
|
+
const ttlSeconds = options.serviceAccessTokenTtlSeconds ?? DEFAULT_SERVICE_ACCESS_TOKEN_TTL_SECONDS;
|
|
275
|
+
const now = options.ports.clock.now();
|
|
276
|
+
const expiresAt = options.ports.clock.addSeconds(now, ttlSeconds);
|
|
277
|
+
const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
|
|
278
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
279
|
+
const accessJti = options.ports.random.uuid();
|
|
280
|
+
const accessToken = await jwks.signJwt(
|
|
281
|
+
{
|
|
282
|
+
client_id: client.clientId,
|
|
283
|
+
...(cnf ? { cnf } : {}),
|
|
284
|
+
scope,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
audience: resource,
|
|
288
|
+
expiresAt,
|
|
289
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
290
|
+
jti: accessJti,
|
|
291
|
+
subject: client.clientId,
|
|
292
|
+
type: 'JWT',
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Service tokens are stateless (BR39d-D5): no saveTokenMeta, no oauth_tokens row.
|
|
297
|
+
return {
|
|
298
|
+
access_token: accessToken,
|
|
299
|
+
expires_in: ttlSeconds,
|
|
300
|
+
scope,
|
|
301
|
+
token_type: dpopJkt ? 'DPoP' : 'Bearer',
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const issueTokens = async (
|
|
306
|
+
options: OAuthTokenHandlerOptions,
|
|
307
|
+
client: OauthClientRecord,
|
|
308
|
+
codePayload: AuthCodePayload,
|
|
309
|
+
user: AuthHonoUserRecord,
|
|
310
|
+
dpopJkt: string | null
|
|
311
|
+
) => {
|
|
312
|
+
const accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 3600;
|
|
313
|
+
const idTokenTtlSeconds = options.idTokenTtlSeconds ?? 3600;
|
|
314
|
+
const now = options.ports.clock.now();
|
|
315
|
+
const accessExpiresAt = options.ports.clock.addSeconds(now, accessTokenTtlSeconds);
|
|
316
|
+
const idExpiresAt = options.ports.clock.addSeconds(now, idTokenTtlSeconds);
|
|
317
|
+
const scopes = codePayload.scope.split(/\s+/).filter(Boolean);
|
|
318
|
+
const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
|
|
319
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
320
|
+
const accessJti = options.ports.random.uuid();
|
|
321
|
+
const accessAudience = `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`;
|
|
322
|
+
const accessToken = await jwks.signJwt(
|
|
323
|
+
{
|
|
324
|
+
acr: codePayload.acr,
|
|
325
|
+
auth_time: toEpochSeconds(codePayload.authTime),
|
|
326
|
+
client_id: client.clientId,
|
|
327
|
+
...(cnf ? { cnf } : {}),
|
|
328
|
+
scope: codePayload.scope,
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
audience: accessAudience,
|
|
332
|
+
expiresAt: accessExpiresAt,
|
|
333
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
334
|
+
jti: accessJti,
|
|
335
|
+
subject: codePayload.userId,
|
|
336
|
+
type: 'JWT',
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await options.ports.oauthStateStore.saveTokenMeta(
|
|
341
|
+
accessJti,
|
|
342
|
+
tokenMeta({
|
|
343
|
+
audience: accessAudience,
|
|
344
|
+
client,
|
|
345
|
+
codePayload,
|
|
346
|
+
dpopJkt,
|
|
347
|
+
expiresAt: accessExpiresAt,
|
|
348
|
+
jti: accessJti,
|
|
349
|
+
tokenType: 'access_token',
|
|
350
|
+
}),
|
|
351
|
+
accessTokenTtlSeconds
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const response: Record<string, unknown> = {
|
|
355
|
+
access_token: accessToken,
|
|
356
|
+
expires_in: accessTokenTtlSeconds,
|
|
357
|
+
scope: codePayload.scope,
|
|
358
|
+
token_type: dpopJkt ? 'DPoP' : 'Bearer',
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
if (scopes.includes('openid')) {
|
|
362
|
+
const idJti = options.ports.random.uuid();
|
|
363
|
+
const idToken = await jwks.signJwt(
|
|
364
|
+
{
|
|
365
|
+
acr: codePayload.acr,
|
|
366
|
+
auth_time: toEpochSeconds(codePayload.authTime),
|
|
367
|
+
...(cnf ? { cnf } : {}),
|
|
368
|
+
...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
|
|
369
|
+
...(scopes.includes('profile') ? { name: user.displayName } : {}),
|
|
370
|
+
...(codePayload.nonce ? { nonce: codePayload.nonce } : {}),
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
audience: client.clientId,
|
|
374
|
+
expiresAt: idExpiresAt,
|
|
375
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
376
|
+
jti: idJti,
|
|
377
|
+
subject: codePayload.userId,
|
|
378
|
+
type: 'JWT',
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
response.id_token = idToken;
|
|
382
|
+
await options.ports.oauthStateStore.saveTokenMeta(
|
|
383
|
+
idJti,
|
|
384
|
+
tokenMeta({
|
|
385
|
+
audience: client.clientId,
|
|
386
|
+
client,
|
|
387
|
+
codePayload,
|
|
388
|
+
dpopJkt,
|
|
389
|
+
expiresAt: idExpiresAt,
|
|
390
|
+
jti: idJti,
|
|
391
|
+
tokenType: 'id_token',
|
|
392
|
+
}),
|
|
393
|
+
idTokenTtlSeconds
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return response;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const tokenMeta = (input: {
|
|
401
|
+
audience: string;
|
|
402
|
+
client: OauthClientRecord;
|
|
403
|
+
codePayload: AuthCodePayload;
|
|
404
|
+
dpopJkt: string | null;
|
|
405
|
+
expiresAt: Date;
|
|
406
|
+
jti: string;
|
|
407
|
+
tokenType: 'access_token' | 'id_token';
|
|
408
|
+
}): TokenMeta => ({
|
|
409
|
+
audience: input.audience,
|
|
410
|
+
clientId: input.client.clientId,
|
|
411
|
+
createdAt: input.codePayload.createdAt,
|
|
412
|
+
dpopJkt: input.dpopJkt,
|
|
413
|
+
expiresAt: input.expiresAt,
|
|
414
|
+
jti: input.jti,
|
|
415
|
+
scope: input.codePayload.scope,
|
|
416
|
+
tenantId: input.codePayload.tenantId,
|
|
417
|
+
tokenType: input.tokenType,
|
|
418
|
+
userId: input.codePayload.userId,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const toEpochSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
|
|
422
|
+
|
|
423
|
+
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { JWTPayload } 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
|
+
import { createJwksService } from './jwks-service.js';
|
|
8
|
+
import type { TokenMeta } from './state-store-types.js';
|
|
9
|
+
|
|
10
|
+
export interface OAuthUserInfoHandlerOptions {
|
|
11
|
+
dpopIatSkewSeconds?: number;
|
|
12
|
+
issuer: string;
|
|
13
|
+
ports: AuthHonoPorts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createOAuthUserInfoHandler =
|
|
17
|
+
(options: OAuthUserInfoHandlerOptions) =>
|
|
18
|
+
async (c: Context): Promise<Response> => {
|
|
19
|
+
const authorization = parseAccessToken(c.req.header('authorization'));
|
|
20
|
+
if (!authorization) return unauthorized(c, 'Access token is required.');
|
|
21
|
+
|
|
22
|
+
const payload = await verifyAccessToken(c, options, authorization.token);
|
|
23
|
+
if (payload instanceof Response) return payload;
|
|
24
|
+
|
|
25
|
+
const meta = await resolveActiveTokenMeta(c, options.ports, payload);
|
|
26
|
+
if (meta instanceof Response) return meta;
|
|
27
|
+
if (meta.dpopJkt) {
|
|
28
|
+
const dpop = await verifyBoundDpop(c, options, authorization, meta);
|
|
29
|
+
if (dpop instanceof Response) return dpop;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const scopes = meta.scope.split(/\s+/).filter(Boolean);
|
|
33
|
+
if (scopes.some((scope) => !['openid', 'profile', 'email'].includes(scope))) {
|
|
34
|
+
return unauthorized(c, 'Access token contains unsupported scopes.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const user = await options.ports.users.findById(meta.userId);
|
|
38
|
+
if (!user) return unauthorized(c, 'Access token user is invalid.');
|
|
39
|
+
|
|
40
|
+
return c.json({
|
|
41
|
+
sub: user.id,
|
|
42
|
+
...(scopes.includes('profile') ? { name: user.displayName } : {}),
|
|
43
|
+
...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const verifyAccessToken = async (
|
|
48
|
+
c: Context,
|
|
49
|
+
options: OAuthUserInfoHandlerOptions,
|
|
50
|
+
token: string
|
|
51
|
+
): Promise<JWTPayload | Response> => {
|
|
52
|
+
try {
|
|
53
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
54
|
+
const result = await jwks.verifyJwt(token, {
|
|
55
|
+
audience: `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`,
|
|
56
|
+
currentDate: options.ports.clock.now(),
|
|
57
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
58
|
+
});
|
|
59
|
+
return result.payload;
|
|
60
|
+
} catch {
|
|
61
|
+
return unauthorized(c, 'Access token is invalid.');
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const resolveActiveTokenMeta = async (
|
|
66
|
+
c: Context,
|
|
67
|
+
ports: AuthHonoPorts,
|
|
68
|
+
payload: JWTPayload
|
|
69
|
+
): Promise<TokenMeta | Response> => {
|
|
70
|
+
const jti = payload.jti;
|
|
71
|
+
if (!jti) return unauthorized(c, 'Access token jti is missing.');
|
|
72
|
+
|
|
73
|
+
const meta = await ports.oauthStateStore.findTokenMeta(jti);
|
|
74
|
+
if (
|
|
75
|
+
!meta ||
|
|
76
|
+
meta.tokenType !== 'access_token' ||
|
|
77
|
+
meta.expiresAt <= ports.clock.now() ||
|
|
78
|
+
(await ports.oauthStateStore.isTokenRevoked(jti))
|
|
79
|
+
) {
|
|
80
|
+
return unauthorized(c, 'Access token is inactive.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return meta;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const verifyBoundDpop = async (
|
|
87
|
+
c: Context,
|
|
88
|
+
options: OAuthUserInfoHandlerOptions,
|
|
89
|
+
authorization: { scheme: 'Bearer' | 'DPoP'; token: string },
|
|
90
|
+
meta: TokenMeta
|
|
91
|
+
): Promise<null | Response> => {
|
|
92
|
+
const proof = c.req.header('dpop');
|
|
93
|
+
if (authorization.scheme !== 'DPoP' || !proof) {
|
|
94
|
+
return unauthorized(c, 'DPoP proof is required for this access token.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const verified = await verifyOAuthDpopProof({
|
|
99
|
+
accessToken: authorization.token,
|
|
100
|
+
htm: c.req.method,
|
|
101
|
+
htu: c.req.url,
|
|
102
|
+
iatSkewSeconds: options.dpopIatSkewSeconds,
|
|
103
|
+
ports: options.ports,
|
|
104
|
+
proof,
|
|
105
|
+
});
|
|
106
|
+
if (verified.jkt !== meta.dpopJkt) {
|
|
107
|
+
return unauthorized(c, 'DPoP proof key does not match the access token.');
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof OAuthDpopProofError) {
|
|
112
|
+
return unauthorized(c, error.message);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const parseAccessToken = (
|
|
119
|
+
authorization: string | undefined
|
|
120
|
+
): { scheme: 'Bearer' | 'DPoP'; token: string } | null => {
|
|
121
|
+
const [scheme, token, extra] = authorization?.split(/\s+/) ?? [];
|
|
122
|
+
if (extra || !token || (scheme !== 'Bearer' && scheme !== 'DPoP')) return null;
|
|
123
|
+
return { scheme, token };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const unauthorized = (c: Context, message: string): Response =>
|
|
127
|
+
oauthJsonError(c, 401, 'invalid_token', message);
|
|
128
|
+
|
|
129
|
+
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
|
|
3
|
+
import type { AuthHonoPorts } from '../ports.js';
|
|
4
|
+
import { createJwksService } from './jwks-service.js';
|
|
5
|
+
|
|
6
|
+
export interface CreateWellKnownRouterOptions {
|
|
7
|
+
issuer: string;
|
|
8
|
+
oauthPathPrefix?: string;
|
|
9
|
+
ports: AuthHonoPorts;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const createWellKnownRouter = (options: CreateWellKnownRouterOptions): Hono => {
|
|
13
|
+
const router = new Hono();
|
|
14
|
+
const issuer = trimTrailingSlash(options.issuer);
|
|
15
|
+
const oauthPrefix = normalizePathPrefix(options.oauthPathPrefix ?? '/api/v1/auth/oauth');
|
|
16
|
+
|
|
17
|
+
router.get('/openid-configuration', (c) =>
|
|
18
|
+
c.json({
|
|
19
|
+
authorization_endpoint: `${issuer}${oauthPrefix}/authorize`,
|
|
20
|
+
claims_supported: ['sub', 'aud', 'iss', 'exp', 'iat', 'nonce', 'auth_time', 'acr', 'email', 'email_verified', 'name'],
|
|
21
|
+
code_challenge_methods_supported: ['S256'],
|
|
22
|
+
dpop_signing_alg_values_supported: ['EdDSA'],
|
|
23
|
+
grant_types_supported: ['authorization_code', 'client_credentials'],
|
|
24
|
+
id_token_signing_alg_values_supported: ['EdDSA'],
|
|
25
|
+
introspection_endpoint: `${issuer}${oauthPrefix}/introspect`,
|
|
26
|
+
issuer,
|
|
27
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
28
|
+
response_types_supported: ['code'],
|
|
29
|
+
revocation_endpoint: `${issuer}${oauthPrefix}/revoke`,
|
|
30
|
+
scopes_supported: ['openid', 'profile', 'email'],
|
|
31
|
+
subject_types_supported: ['public'],
|
|
32
|
+
token_endpoint: `${issuer}${oauthPrefix}/token`,
|
|
33
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
34
|
+
userinfo_endpoint: `${issuer}${oauthPrefix}/userinfo`,
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
router.get('/jwks.json', async (c) => {
|
|
39
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
40
|
+
c.header('Cache-Control', 'public, max-age=300');
|
|
41
|
+
return c.json(await jwks.getPublicJwks());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return router;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/u, '');
|
|
48
|
+
|
|
49
|
+
const normalizePathPrefix = (value: string): string => {
|
|
50
|
+
const trimmed = value.replace(/^\/+|\/+$/gu, '');
|
|
51
|
+
return trimmed ? `/${trimmed}` : '';
|
|
52
|
+
};
|
package/src/ports.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import type { JwksPort, OauthStateStorePort } from './oauth/state-store-types.js';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
AuthCodePayload,
|
|
5
|
+
DpopProofRecord,
|
|
6
|
+
JwksKeyRecord,
|
|
7
|
+
JwksPort,
|
|
8
|
+
JwksPublicJwk,
|
|
9
|
+
OauthClientRecord,
|
|
10
|
+
OauthStateStorePort,
|
|
11
|
+
OauthTokenType,
|
|
12
|
+
ServiceClientRecord,
|
|
13
|
+
TokenMeta,
|
|
14
|
+
} from './oauth/state-store-types.js';
|
|
15
|
+
|
|
1
16
|
export type AuthHonoAccountStatus =
|
|
2
17
|
| 'active'
|
|
3
18
|
| 'pending_admin_approval'
|
|
@@ -286,4 +301,6 @@ export interface AuthHonoPorts {
|
|
|
286
301
|
clock: AuthHonoClockPort;
|
|
287
302
|
random: AuthHonoRandomPort;
|
|
288
303
|
accountPolicy: AuthHonoAccountPolicyPort;
|
|
304
|
+
oauthStateStore: OauthStateStorePort;
|
|
305
|
+
jwks: JwksPort;
|
|
289
306
|
}
|