@sentropic/auth-hono 0.3.0 → 0.6.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 +55 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/authorize-handler.d.ts.map +1 -1
- package/dist/oauth/authorize-handler.js +46 -1
- package/dist/oauth/authorize-handler.js.map +1 -1
- package/dist/oauth/consent-decision-handler.d.ts.map +1 -1
- package/dist/oauth/consent-decision-handler.js +1 -0
- package/dist/oauth/consent-decision-handler.js.map +1 -1
- package/dist/oauth/dpop.d.ts +7 -4
- package/dist/oauth/dpop.d.ts.map +1 -1
- package/dist/oauth/dpop.js +23 -44
- package/dist/oauth/dpop.js.map +1 -1
- package/dist/oauth/jwks-service.d.ts.map +1 -1
- package/dist/oauth/jwks-service.js +6 -8
- package/dist/oauth/jwks-service.js.map +1 -1
- 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 +152 -0
- package/dist/oauth/service-auth-middleware.js.map +1 -0
- package/dist/oauth/state-codec.d.ts +2 -0
- package/dist/oauth/state-codec.d.ts.map +1 -1
- package/dist/oauth/state-codec.js.map +1 -1
- package/dist/oauth/state-store-types.d.ts +26 -0
- package/dist/oauth/state-store-types.d.ts.map +1 -1
- package/dist/oauth/token-handler.d.ts +1 -0
- package/dist/oauth/token-handler.d.ts.map +1 -1
- package/dist/oauth/token-handler.js +158 -3
- package/dist/oauth/token-handler.js.map +1 -1
- package/dist/oauth/wellknown-handler.js +3 -3
- package/dist/oauth/wellknown-handler.js.map +1 -1
- package/dist/ports.d.ts +15 -1
- package/dist/ports.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +11 -0
- package/src/oauth/authorize-handler.ts +55 -1
- package/src/oauth/consent-decision-handler.ts +1 -0
- package/src/oauth/dpop.ts +30 -67
- package/src/oauth/jwks-service.ts +5 -9
- package/src/oauth/service-auth-middleware.ts +229 -0
- package/src/oauth/state-codec.ts +2 -0
- package/src/oauth/state-store-types.ts +27 -0
- package/src/oauth/token-handler.ts +218 -4
- package/src/oauth/wellknown-handler.ts +3 -3
- package/src/ports.ts +16 -0
|
@@ -5,7 +5,9 @@ import { createJwksService } from './jwks-service.js';
|
|
|
5
5
|
import { oauthJsonError } from './http-utils.js';
|
|
6
6
|
import { sha256Base64url } from './crypto-utils.js';
|
|
7
7
|
import { OAuthDpopProofError, verifyOAuthDpopProof } from './dpop.js';
|
|
8
|
-
import type { AuthCodePayload, OauthClientRecord, TokenMeta } from './state-store-types.js';
|
|
8
|
+
import type { AuthCodePayload, OauthClientRecord, ServiceClientRecord, TokenMeta } from './state-store-types.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_SERVICE_ACCESS_TOKEN_TTL_SECONDS = 900;
|
|
9
11
|
|
|
10
12
|
export interface OAuthTokenHandlerOptions {
|
|
11
13
|
accessTokenTtlSeconds?: number;
|
|
@@ -13,6 +15,7 @@ export interface OAuthTokenHandlerOptions {
|
|
|
13
15
|
idTokenTtlSeconds?: number;
|
|
14
16
|
issuer: string;
|
|
15
17
|
ports: AuthHonoPorts;
|
|
18
|
+
serviceAccessTokenTtlSeconds?: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
interface ClientAuthentication {
|
|
@@ -20,12 +23,26 @@ interface ClientAuthentication {
|
|
|
20
23
|
secret?: string;
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
interface ServiceClientAuthentication {
|
|
27
|
+
client: ServiceClientRecord;
|
|
28
|
+
secret: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
export const createOAuthTokenHandler =
|
|
24
32
|
(options: OAuthTokenHandlerOptions) =>
|
|
25
33
|
async (c: Context): Promise<Response> => {
|
|
26
34
|
const form = new URLSearchParams(await c.req.text());
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
);
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
const auth = await authenticateClient(c, form, options.ports);
|
|
@@ -42,6 +59,9 @@ export const createOAuthTokenHandler =
|
|
|
42
59
|
return oauthJsonError(c, 400, 'invalid_grant', 'PKCE verification failed.');
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
const resourceError = validateTokenResource(c, form, codePayload);
|
|
63
|
+
if (resourceError) return resourceError;
|
|
64
|
+
|
|
45
65
|
const dpopJkt = await resolveDpopJkt(c, options, auth.client, codePayload);
|
|
46
66
|
if (dpopJkt instanceof Response) return dpopJkt;
|
|
47
67
|
|
|
@@ -100,6 +120,30 @@ const parseClientCredentials = (
|
|
|
100
120
|
};
|
|
101
121
|
};
|
|
102
122
|
|
|
123
|
+
/**
|
|
124
|
+
* RFC 8707 resource validation on the token leg of the `authorization_code` flow (BR-39l Lot 2).
|
|
125
|
+
* - C1 single-aud: more than one `resource` value ⇒ `invalid_target`.
|
|
126
|
+
* - C3 sealing: if the client re-sends `resource` on the token request (the MCP draft requires it
|
|
127
|
+
* on both legs), it MUST equal the value sealed at authorize time, else `invalid_grant`
|
|
128
|
+
* (audience downgrade/upgrade rejection). The `aud` is derived ONLY from the sealed value.
|
|
129
|
+
* No `resource` on the token leg is accepted (the sealed value still governs `aud`).
|
|
130
|
+
*/
|
|
131
|
+
const validateTokenResource = (
|
|
132
|
+
c: Context,
|
|
133
|
+
form: URLSearchParams,
|
|
134
|
+
codePayload: AuthCodePayload
|
|
135
|
+
): Response | null => {
|
|
136
|
+
const requested = form.getAll('resource').filter((value) => value.length > 0);
|
|
137
|
+
if (requested.length === 0) return null;
|
|
138
|
+
if (requested.length > 1) {
|
|
139
|
+
return oauthJsonError(c, 400, 'invalid_target', 'Only a single resource indicator is supported.');
|
|
140
|
+
}
|
|
141
|
+
if (requested[0] !== (codePayload.resource ?? null)) {
|
|
142
|
+
return oauthJsonError(c, 400, 'invalid_grant', 'resource does not match the authorization request.');
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
|
|
103
147
|
const resolveDpopJkt = async (
|
|
104
148
|
c: Context,
|
|
105
149
|
options: OAuthTokenHandlerOptions,
|
|
@@ -131,6 +175,160 @@ const resolveDpopJkt = async (
|
|
|
131
175
|
}
|
|
132
176
|
};
|
|
133
177
|
|
|
178
|
+
const handleClientCredentials = async (
|
|
179
|
+
c: Context,
|
|
180
|
+
form: URLSearchParams,
|
|
181
|
+
options: OAuthTokenHandlerOptions
|
|
182
|
+
): Promise<Response> => {
|
|
183
|
+
const findServiceClient = options.ports.oauthStateStore.findServiceClient;
|
|
184
|
+
if (!findServiceClient) {
|
|
185
|
+
return oauthJsonError(c, 400, 'unsupported_grant_type', 'The client_credentials grant is not supported.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const auth = await authenticateServiceClient(c, form, options.ports, findServiceClient);
|
|
189
|
+
if (auth instanceof Response) return auth;
|
|
190
|
+
|
|
191
|
+
const scope = resolveServiceScope(c, form, auth.client);
|
|
192
|
+
if (scope instanceof Response) return scope;
|
|
193
|
+
|
|
194
|
+
const resource = resolveResourceIndicator(c, form, auth.client);
|
|
195
|
+
if (resource instanceof Response) return resource;
|
|
196
|
+
|
|
197
|
+
const dpopJkt = await resolveServiceDpopJkt(c, options, auth.client);
|
|
198
|
+
if (dpopJkt instanceof Response) return dpopJkt;
|
|
199
|
+
|
|
200
|
+
const tokens = await issueServiceToken(options, auth.client, scope, resource, dpopJkt);
|
|
201
|
+
return c.json(tokens);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const authenticateServiceClient = async (
|
|
205
|
+
c: Context,
|
|
206
|
+
form: URLSearchParams,
|
|
207
|
+
ports: AuthHonoPorts,
|
|
208
|
+
findServiceClient: NonNullable<AuthHonoPorts['oauthStateStore']['findServiceClient']>
|
|
209
|
+
): Promise<ServiceClientAuthentication | Response> => {
|
|
210
|
+
const credentials = parseClientCredentials(c.req.header('authorization'), form);
|
|
211
|
+
if (!credentials.clientId || !credentials.secret) {
|
|
212
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication is required.');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const client = await findServiceClient(credentials.clientId);
|
|
216
|
+
if (!client) return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
217
|
+
|
|
218
|
+
const secretHash = await ports.tokens.hashSecret(credentials.secret);
|
|
219
|
+
if (secretHash !== client.clientSecretHash) {
|
|
220
|
+
return oauthJsonError(c, 401, 'invalid_client', 'Client authentication failed.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { client, secret: credentials.secret };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const resolveServiceScope = (
|
|
227
|
+
c: Context,
|
|
228
|
+
form: URLSearchParams,
|
|
229
|
+
client: ServiceClientRecord
|
|
230
|
+
): string | Response => {
|
|
231
|
+
const requested = (form.get('scope') ?? '').split(/\s+/).filter(Boolean);
|
|
232
|
+
if (requested.length === 0) {
|
|
233
|
+
return client.allowedScopes.join(' ');
|
|
234
|
+
}
|
|
235
|
+
const allowed = new Set(client.allowedScopes);
|
|
236
|
+
const unauthorized = requested.filter((scope) => !allowed.has(scope));
|
|
237
|
+
if (unauthorized.length > 0) {
|
|
238
|
+
return oauthJsonError(c, 400, 'invalid_scope', `Scope not allowed: ${unauthorized.join(' ')}.`);
|
|
239
|
+
}
|
|
240
|
+
return requested.join(' ');
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const resolveResourceIndicator = (
|
|
244
|
+
c: Context,
|
|
245
|
+
form: URLSearchParams,
|
|
246
|
+
client: ServiceClientRecord
|
|
247
|
+
): string | Response => {
|
|
248
|
+
const requested = form.get('resource');
|
|
249
|
+
const indicators = client.resourceIndicators;
|
|
250
|
+
|
|
251
|
+
if (requested) {
|
|
252
|
+
if (!indicators.includes(requested)) {
|
|
253
|
+
return oauthJsonError(c, 400, 'invalid_target', 'Requested resource is not allowed for this client.');
|
|
254
|
+
}
|
|
255
|
+
return requested;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (indicators.length === 1) {
|
|
259
|
+
return indicators[0];
|
|
260
|
+
}
|
|
261
|
+
if (indicators.length === 0) {
|
|
262
|
+
return oauthJsonError(c, 400, 'invalid_target', 'A resource indicator is required for this client.');
|
|
263
|
+
}
|
|
264
|
+
return oauthJsonError(c, 400, 'invalid_target', 'A resource indicator must be specified when multiple are allowed.');
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const resolveServiceDpopJkt = async (
|
|
268
|
+
c: Context,
|
|
269
|
+
options: OAuthTokenHandlerOptions,
|
|
270
|
+
client: ServiceClientRecord
|
|
271
|
+
): Promise<string | null | Response> => {
|
|
272
|
+
if (!client.dpopBoundAccessTokens) return null;
|
|
273
|
+
|
|
274
|
+
const proof = c.req.header('dpop');
|
|
275
|
+
if (!proof) return oauthJsonError(c, 400, 'invalid_dpop_proof', 'DPoP proof is required.');
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const verified = await verifyOAuthDpopProof({
|
|
279
|
+
htm: 'POST',
|
|
280
|
+
htu: c.req.url,
|
|
281
|
+
iatSkewSeconds: options.dpopIatSkewSeconds,
|
|
282
|
+
ports: options.ports,
|
|
283
|
+
proof,
|
|
284
|
+
});
|
|
285
|
+
return verified.jkt;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error instanceof OAuthDpopProofError) {
|
|
288
|
+
return oauthJsonError(c, 400, 'invalid_dpop_proof', error.message);
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const issueServiceToken = async (
|
|
295
|
+
options: OAuthTokenHandlerOptions,
|
|
296
|
+
client: ServiceClientRecord,
|
|
297
|
+
scope: string,
|
|
298
|
+
resource: string,
|
|
299
|
+
dpopJkt: string | null
|
|
300
|
+
) => {
|
|
301
|
+
const ttlSeconds = options.serviceAccessTokenTtlSeconds ?? DEFAULT_SERVICE_ACCESS_TOKEN_TTL_SECONDS;
|
|
302
|
+
const now = options.ports.clock.now();
|
|
303
|
+
const expiresAt = options.ports.clock.addSeconds(now, ttlSeconds);
|
|
304
|
+
const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
|
|
305
|
+
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
306
|
+
const accessJti = options.ports.random.uuid();
|
|
307
|
+
const accessToken = await jwks.signJwt(
|
|
308
|
+
{
|
|
309
|
+
client_id: client.clientId,
|
|
310
|
+
...(cnf ? { cnf } : {}),
|
|
311
|
+
scope,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
audience: resource,
|
|
315
|
+
expiresAt,
|
|
316
|
+
issuer: trimTrailingSlash(options.issuer),
|
|
317
|
+
jti: accessJti,
|
|
318
|
+
subject: client.clientId,
|
|
319
|
+
type: 'JWT',
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// Service tokens are stateless (BR39d-D5): no saveTokenMeta, no oauth_tokens row.
|
|
324
|
+
return {
|
|
325
|
+
access_token: accessToken,
|
|
326
|
+
expires_in: ttlSeconds,
|
|
327
|
+
scope,
|
|
328
|
+
token_type: dpopJkt ? 'DPoP' : 'Bearer',
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
|
|
134
332
|
const issueTokens = async (
|
|
135
333
|
options: OAuthTokenHandlerOptions,
|
|
136
334
|
client: OauthClientRecord,
|
|
@@ -145,15 +343,30 @@ const issueTokens = async (
|
|
|
145
343
|
const idExpiresAt = options.ports.clock.addSeconds(now, idTokenTtlSeconds);
|
|
146
344
|
const scopes = codePayload.scope.split(/\s+/).filter(Boolean);
|
|
147
345
|
const cnf = dpopJkt ? { jkt: dpopJkt } : undefined;
|
|
346
|
+
|
|
347
|
+
// BR-39e: bind the tenant claim to a STILL-`approved` membership at token time (lifecycle
|
|
348
|
+
// gate). If the membership was suspended/revoked between authorize and token exchange, drop
|
|
349
|
+
// the claim so no tenant-scoped token is issued for a non-member.
|
|
350
|
+
let boundTenantId: string | null = codePayload.tenantId;
|
|
351
|
+
if (boundTenantId && options.ports.tenant) {
|
|
352
|
+
const stillApproved = await options.ports.tenant.isApprovedMember(codePayload.userId, boundTenantId);
|
|
353
|
+
if (!stillApproved) boundTenantId = null;
|
|
354
|
+
}
|
|
355
|
+
|
|
148
356
|
const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
|
|
149
357
|
const accessJti = options.ports.random.uuid();
|
|
150
|
-
|
|
358
|
+
// BR-39l Lot 2 (C3/C4): variable RFC 8707 audience. The access-token `aud` is the resource
|
|
359
|
+
// sealed at authorize time (single string), or the userinfo URL by default — byte-identical to
|
|
360
|
+
// auth-hono 0.5.0 when no `resource` was requested. The id_token `aud` (client_id) is untouched.
|
|
361
|
+
const accessAudience =
|
|
362
|
+
codePayload.resource ?? `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`;
|
|
151
363
|
const accessToken = await jwks.signJwt(
|
|
152
364
|
{
|
|
153
365
|
acr: codePayload.acr,
|
|
154
366
|
auth_time: toEpochSeconds(codePayload.authTime),
|
|
155
367
|
client_id: client.clientId,
|
|
156
368
|
...(cnf ? { cnf } : {}),
|
|
369
|
+
...(boundTenantId ? { tid: boundTenantId } : {}),
|
|
157
370
|
scope: codePayload.scope,
|
|
158
371
|
},
|
|
159
372
|
{
|
|
@@ -197,6 +410,7 @@ const issueTokens = async (
|
|
|
197
410
|
...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
|
|
198
411
|
...(scopes.includes('profile') ? { name: user.displayName } : {}),
|
|
199
412
|
...(codePayload.nonce ? { nonce: codePayload.nonce } : {}),
|
|
413
|
+
...(boundTenantId ? { tid: boundTenantId } : {}),
|
|
200
414
|
},
|
|
201
415
|
{
|
|
202
416
|
audience: client.clientId,
|
|
@@ -17,10 +17,10 @@ export const createWellKnownRouter = (options: CreateWellKnownRouterOptions): Ho
|
|
|
17
17
|
router.get('/openid-configuration', (c) =>
|
|
18
18
|
c.json({
|
|
19
19
|
authorization_endpoint: `${issuer}${oauthPrefix}/authorize`,
|
|
20
|
-
claims_supported: ['sub', 'aud', 'iss', 'exp', 'iat', 'nonce', 'auth_time', 'acr', 'email', 'email_verified', 'name'],
|
|
20
|
+
claims_supported: ['sub', 'aud', 'iss', 'exp', 'iat', 'nonce', 'auth_time', 'acr', 'email', 'email_verified', 'name', 'tid'],
|
|
21
21
|
code_challenge_methods_supported: ['S256'],
|
|
22
22
|
dpop_signing_alg_values_supported: ['EdDSA'],
|
|
23
|
-
grant_types_supported: ['authorization_code'],
|
|
23
|
+
grant_types_supported: ['authorization_code', 'client_credentials'],
|
|
24
24
|
id_token_signing_alg_values_supported: ['EdDSA'],
|
|
25
25
|
introspection_endpoint: `${issuer}${oauthPrefix}/introspect`,
|
|
26
26
|
issuer,
|
|
@@ -30,7 +30,7 @@ export const createWellKnownRouter = (options: CreateWellKnownRouterOptions): Ho
|
|
|
30
30
|
scopes_supported: ['openid', 'profile', 'email'],
|
|
31
31
|
subject_types_supported: ['public'],
|
|
32
32
|
token_endpoint: `${issuer}${oauthPrefix}/token`,
|
|
33
|
-
token_endpoint_auth_methods_supported: ['client_secret_basic', 'none'],
|
|
33
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
34
34
|
userinfo_endpoint: `${issuer}${oauthPrefix}/userinfo`,
|
|
35
35
|
})
|
|
36
36
|
);
|
package/src/ports.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
OauthClientRecord,
|
|
10
10
|
OauthStateStorePort,
|
|
11
11
|
OauthTokenType,
|
|
12
|
+
ServiceClientRecord,
|
|
12
13
|
TokenMeta,
|
|
13
14
|
} from './oauth/state-store-types.js';
|
|
14
15
|
|
|
@@ -286,6 +287,19 @@ export interface AuthHonoAccountPolicyPort {
|
|
|
286
287
|
afterUserCreated?(user: AuthHonoUserRecord): Promise<void> | void;
|
|
287
288
|
}
|
|
288
289
|
|
|
290
|
+
/**
|
|
291
|
+
* BR-39e: tenancy spine. OPTIONAL — when absent, auth-hono keeps the legacy behavior of
|
|
292
|
+
* sourcing the auth-code `tenantId` from the client. When present, the tenant claim (`tid`)
|
|
293
|
+
* is derived ONLY from a VALIDATED `approved` membership (never a raw request parameter),
|
|
294
|
+
* and re-checked at token time (lifecycle gate).
|
|
295
|
+
*/
|
|
296
|
+
export interface AuthHonoTenantPort {
|
|
297
|
+
/** Tenant ids the user is an `approved` member of, for selection + claim derivation. */
|
|
298
|
+
listApprovedTenantIds(userId: string): Promise<string[]>;
|
|
299
|
+
/** True iff (userId, tenantId) is currently an `approved` membership (binding re-check). */
|
|
300
|
+
isApprovedMember(userId: string, tenantId: string): Promise<boolean>;
|
|
301
|
+
}
|
|
302
|
+
|
|
289
303
|
export interface AuthHonoPorts {
|
|
290
304
|
users: AuthHonoUserPort;
|
|
291
305
|
credentials: AuthHonoCredentialPort;
|
|
@@ -302,4 +316,6 @@ export interface AuthHonoPorts {
|
|
|
302
316
|
accountPolicy: AuthHonoAccountPolicyPort;
|
|
303
317
|
oauthStateStore: OauthStateStorePort;
|
|
304
318
|
jwks: JwksPort;
|
|
319
|
+
/** BR-39e tenancy spine (optional; legacy behavior when absent). */
|
|
320
|
+
tenant?: AuthHonoTenantPort;
|
|
305
321
|
}
|