@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.
Files changed (47) hide show
  1. package/README.md +55 -2
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/oauth/authorize-handler.d.ts.map +1 -1
  7. package/dist/oauth/authorize-handler.js +46 -1
  8. package/dist/oauth/authorize-handler.js.map +1 -1
  9. package/dist/oauth/consent-decision-handler.d.ts.map +1 -1
  10. package/dist/oauth/consent-decision-handler.js +1 -0
  11. package/dist/oauth/consent-decision-handler.js.map +1 -1
  12. package/dist/oauth/dpop.d.ts +7 -4
  13. package/dist/oauth/dpop.d.ts.map +1 -1
  14. package/dist/oauth/dpop.js +23 -44
  15. package/dist/oauth/dpop.js.map +1 -1
  16. package/dist/oauth/jwks-service.d.ts.map +1 -1
  17. package/dist/oauth/jwks-service.js +6 -8
  18. package/dist/oauth/jwks-service.js.map +1 -1
  19. package/dist/oauth/service-auth-middleware.d.ts +30 -0
  20. package/dist/oauth/service-auth-middleware.d.ts.map +1 -0
  21. package/dist/oauth/service-auth-middleware.js +152 -0
  22. package/dist/oauth/service-auth-middleware.js.map +1 -0
  23. package/dist/oauth/state-codec.d.ts +2 -0
  24. package/dist/oauth/state-codec.d.ts.map +1 -1
  25. package/dist/oauth/state-codec.js.map +1 -1
  26. package/dist/oauth/state-store-types.d.ts +26 -0
  27. package/dist/oauth/state-store-types.d.ts.map +1 -1
  28. package/dist/oauth/token-handler.d.ts +1 -0
  29. package/dist/oauth/token-handler.d.ts.map +1 -1
  30. package/dist/oauth/token-handler.js +158 -3
  31. package/dist/oauth/token-handler.js.map +1 -1
  32. package/dist/oauth/wellknown-handler.js +3 -3
  33. package/dist/oauth/wellknown-handler.js.map +1 -1
  34. package/dist/ports.d.ts +15 -1
  35. package/dist/ports.d.ts.map +1 -1
  36. package/package.json +4 -1
  37. package/src/index.ts +11 -0
  38. package/src/oauth/authorize-handler.ts +55 -1
  39. package/src/oauth/consent-decision-handler.ts +1 -0
  40. package/src/oauth/dpop.ts +30 -67
  41. package/src/oauth/jwks-service.ts +5 -9
  42. package/src/oauth/service-auth-middleware.ts +229 -0
  43. package/src/oauth/state-codec.ts +2 -0
  44. package/src/oauth/state-store-types.ts +27 -0
  45. package/src/oauth/token-handler.ts +218 -4
  46. package/src/oauth/wellknown-handler.ts +3 -3
  47. 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
- if (form.get('grant_type') !== 'authorization_code') {
28
- return oauthJsonError(c, 400, 'unsupported_grant_type', 'Only authorization_code grant is supported.');
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
- const accessAudience = `${trimTrailingSlash(options.issuer)}/api/v1/auth/oauth/userinfo`;
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
  }