@sentropic/auth-hono 0.4.0 → 0.7.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 (51) hide show
  1. package/README.md +34 -0
  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 +1 -0
  7. package/dist/oauth/authorize-handler.d.ts.map +1 -1
  8. package/dist/oauth/authorize-handler.js +85 -7
  9. package/dist/oauth/authorize-handler.js.map +1 -1
  10. package/dist/oauth/consent-decision-handler.d.ts.map +1 -1
  11. package/dist/oauth/consent-decision-handler.js +7 -18
  12. package/dist/oauth/consent-decision-handler.js.map +1 -1
  13. package/dist/oauth/dpop.d.ts +7 -4
  14. package/dist/oauth/dpop.d.ts.map +1 -1
  15. package/dist/oauth/dpop.js +23 -44
  16. package/dist/oauth/dpop.js.map +1 -1
  17. package/dist/oauth/issue-authorized-code.d.ts +15 -0
  18. package/dist/oauth/issue-authorized-code.d.ts.map +1 -0
  19. package/dist/oauth/issue-authorized-code.js +29 -0
  20. package/dist/oauth/issue-authorized-code.js.map +1 -0
  21. package/dist/oauth/jwks-service.d.ts.map +1 -1
  22. package/dist/oauth/jwks-service.js +6 -8
  23. package/dist/oauth/jwks-service.js.map +1 -1
  24. package/dist/oauth/service-auth-middleware.d.ts.map +1 -1
  25. package/dist/oauth/service-auth-middleware.js +46 -64
  26. package/dist/oauth/service-auth-middleware.js.map +1 -1
  27. package/dist/oauth/state-codec.d.ts +2 -0
  28. package/dist/oauth/state-codec.d.ts.map +1 -1
  29. package/dist/oauth/state-codec.js.map +1 -1
  30. package/dist/oauth/state-store-types.d.ts +12 -0
  31. package/dist/oauth/state-store-types.d.ts.map +1 -1
  32. package/dist/oauth/token-handler.d.ts.map +1 -1
  33. package/dist/oauth/token-handler.js +38 -1
  34. package/dist/oauth/token-handler.js.map +1 -1
  35. package/dist/oauth/wellknown-handler.js +1 -1
  36. package/dist/oauth/wellknown-handler.js.map +1 -1
  37. package/dist/ports.d.ts +32 -0
  38. package/dist/ports.d.ts.map +1 -1
  39. package/package.json +4 -1
  40. package/src/index.ts +11 -0
  41. package/src/oauth/authorize-handler.ts +101 -8
  42. package/src/oauth/consent-decision-handler.ts +11 -25
  43. package/src/oauth/dpop.ts +30 -67
  44. package/src/oauth/issue-authorized-code.ts +50 -0
  45. package/src/oauth/jwks-service.ts +5 -9
  46. package/src/oauth/service-auth-middleware.ts +59 -80
  47. package/src/oauth/state-codec.ts +2 -0
  48. package/src/oauth/state-store-types.ts +12 -0
  49. package/src/oauth/token-handler.ts +44 -1
  50. package/src/oauth/wellknown-handler.ts +1 -1
  51. package/src/ports.ts +35 -0
@@ -59,6 +59,9 @@ export const createOAuthTokenHandler =
59
59
  return oauthJsonError(c, 400, 'invalid_grant', 'PKCE verification failed.');
60
60
  }
61
61
 
62
+ const resourceError = validateTokenResource(c, form, codePayload);
63
+ if (resourceError) return resourceError;
64
+
62
65
  const dpopJkt = await resolveDpopJkt(c, options, auth.client, codePayload);
63
66
  if (dpopJkt instanceof Response) return dpopJkt;
64
67
 
@@ -117,6 +120,30 @@ const parseClientCredentials = (
117
120
  };
118
121
  };
119
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
+
120
147
  const resolveDpopJkt = async (
121
148
  c: Context,
122
149
  options: OAuthTokenHandlerOptions,
@@ -316,15 +343,30 @@ const issueTokens = async (
316
343
  const idExpiresAt = options.ports.clock.addSeconds(now, idTokenTtlSeconds);
317
344
  const scopes = codePayload.scope.split(/\s+/).filter(Boolean);
318
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
+
319
356
  const jwks = createJwksService({ clock: options.ports.clock, jwksPort: options.ports.jwks });
320
357
  const accessJti = options.ports.random.uuid();
321
- 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`;
322
363
  const accessToken = await jwks.signJwt(
323
364
  {
324
365
  acr: codePayload.acr,
325
366
  auth_time: toEpochSeconds(codePayload.authTime),
326
367
  client_id: client.clientId,
327
368
  ...(cnf ? { cnf } : {}),
369
+ ...(boundTenantId ? { tid: boundTenantId } : {}),
328
370
  scope: codePayload.scope,
329
371
  },
330
372
  {
@@ -368,6 +410,7 @@ const issueTokens = async (
368
410
  ...(scopes.includes('email') ? { email: user.email, email_verified: user.emailVerified } : {}),
369
411
  ...(scopes.includes('profile') ? { name: user.displayName } : {}),
370
412
  ...(codePayload.nonce ? { nonce: codePayload.nonce } : {}),
413
+ ...(boundTenantId ? { tid: boundTenantId } : {}),
371
414
  },
372
415
  {
373
416
  audience: client.clientId,
@@ -17,7 +17,7 @@ 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
23
  grant_types_supported: ['authorization_code', 'client_credentials'],
package/src/ports.ts CHANGED
@@ -287,6 +287,37 @@ export interface AuthHonoAccountPolicyPort {
287
287
  afterUserCreated?(user: AuthHonoUserRecord): Promise<void> | void;
288
288
  }
289
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
+
303
+ /**
304
+ * Consent persistence. OPTIONAL — when absent, auth-hono keeps the legacy behavior of
305
+ * always re-showing the consent screen on every `/authorize`. When present, an approved
306
+ * grant per exact `(userId, clientId)` lets the authorize handler skip consent and issue
307
+ * the auth code directly, provided the stored grant's scopes are a SUPERSET of the
308
+ * requested scopes (scope-escalation re-consents) and `prompt !== 'consent'`.
309
+ */
310
+ export interface AuthHonoConsentGrant {
311
+ scopes: string[];
312
+ }
313
+
314
+ export interface AuthHonoConsentStorePort {
315
+ /** The user's currently granted scopes for this client, or `null` if no grant exists. */
316
+ getGrant(userId: string, clientId: string): Promise<AuthHonoConsentGrant | null>;
317
+ /** Upsert the grant for `(userId, clientId)`, unioning `scopes` with any prior grant. */
318
+ saveGrant(userId: string, clientId: string, scopes: string[]): Promise<void>;
319
+ }
320
+
290
321
  export interface AuthHonoPorts {
291
322
  users: AuthHonoUserPort;
292
323
  credentials: AuthHonoCredentialPort;
@@ -303,4 +334,8 @@ export interface AuthHonoPorts {
303
334
  accountPolicy: AuthHonoAccountPolicyPort;
304
335
  oauthStateStore: OauthStateStorePort;
305
336
  jwks: JwksPort;
337
+ /** BR-39e tenancy spine (optional; legacy behavior when absent). */
338
+ tenant?: AuthHonoTenantPort;
339
+ /** Consent persistence (optional; always-consent legacy behavior when absent). */
340
+ consentStore?: AuthHonoConsentStorePort;
306
341
  }