@sentropic/auth-hono 0.4.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 (43) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/oauth/authorize-handler.d.ts.map +1 -1
  5. package/dist/oauth/authorize-handler.js +46 -1
  6. package/dist/oauth/authorize-handler.js.map +1 -1
  7. package/dist/oauth/consent-decision-handler.d.ts.map +1 -1
  8. package/dist/oauth/consent-decision-handler.js +1 -0
  9. package/dist/oauth/consent-decision-handler.js.map +1 -1
  10. package/dist/oauth/dpop.d.ts +7 -4
  11. package/dist/oauth/dpop.d.ts.map +1 -1
  12. package/dist/oauth/dpop.js +23 -44
  13. package/dist/oauth/dpop.js.map +1 -1
  14. package/dist/oauth/jwks-service.d.ts.map +1 -1
  15. package/dist/oauth/jwks-service.js +6 -8
  16. package/dist/oauth/jwks-service.js.map +1 -1
  17. package/dist/oauth/service-auth-middleware.d.ts.map +1 -1
  18. package/dist/oauth/service-auth-middleware.js +46 -64
  19. package/dist/oauth/service-auth-middleware.js.map +1 -1
  20. package/dist/oauth/state-codec.d.ts +2 -0
  21. package/dist/oauth/state-codec.d.ts.map +1 -1
  22. package/dist/oauth/state-codec.js.map +1 -1
  23. package/dist/oauth/state-store-types.d.ts +12 -0
  24. package/dist/oauth/state-store-types.d.ts.map +1 -1
  25. package/dist/oauth/token-handler.d.ts.map +1 -1
  26. package/dist/oauth/token-handler.js +38 -1
  27. package/dist/oauth/token-handler.js.map +1 -1
  28. package/dist/oauth/wellknown-handler.js +1 -1
  29. package/dist/oauth/wellknown-handler.js.map +1 -1
  30. package/dist/ports.d.ts +14 -0
  31. package/dist/ports.d.ts.map +1 -1
  32. package/package.json +4 -1
  33. package/src/index.ts +10 -0
  34. package/src/oauth/authorize-handler.ts +55 -1
  35. package/src/oauth/consent-decision-handler.ts +1 -0
  36. package/src/oauth/dpop.ts +30 -67
  37. package/src/oauth/jwks-service.ts +5 -9
  38. package/src/oauth/service-auth-middleware.ts +59 -80
  39. package/src/oauth/state-codec.ts +2 -0
  40. package/src/oauth/state-store-types.ts +12 -0
  41. package/src/oauth/token-handler.ts +44 -1
  42. package/src/oauth/wellknown-handler.ts +1 -1
  43. package/src/ports.ts +15 -0
@@ -1,15 +1,21 @@
1
+ // COMPAT WRAPPER (architect verdict E2/F8). The CANONICAL home of this RS middleware is now
2
+ // `@sentropic/mcp-auth/hono` (`createRequireServiceAuth`). auth-hono keeps this signature-stable
3
+ // wrapper — same behavior, sharing the SAME verification core (`@sentropic/oauth-verify`), no
4
+ // fourth copy of verify code — for ≥1 minor so pinned RPs are not forced to bump; it is dropped
5
+ // at auth-hono 1.0. The wrapper builds on oauth-verify primitives directly (NOT on mcp-auth) to
6
+ // respect the dependency DAG (auth-hono and mcp-auth never import each other).
1
7
  import {
2
- calculateJwkThumbprint,
3
- decodeProtectedHeader,
4
- importJWK,
5
- jwtVerify,
6
- type JWK,
7
- type JWTPayload,
8
- } from 'jose';
8
+ DpopVerifyError,
9
+ fromJwksPort,
10
+ parseScopes,
11
+ TokenVerifyError,
12
+ verifyAccessToken,
13
+ verifyDpopProof,
14
+ type AccessTokenClaims,
15
+ } from '@sentropic/oauth-verify';
9
16
  import type { Context, MiddlewareHandler } from 'hono';
10
17
 
11
18
  import type { AuthHonoClockPort } from '../ports.js';
12
- import { sha256Base64url } from './crypto-utils.js';
13
19
  import type { JwksPort, OauthStateStorePort } from './state-store-types.js';
14
20
 
15
21
  /**
@@ -62,7 +68,7 @@ export const createRequireServiceAuth = (
62
68
  return async (c, next) => {
63
69
  try {
64
70
  const { scheme, token } = parseAuthorization(c.req.header('authorization'));
65
- const payload = await verifyAccessToken(token, options.ports, issuer, options.resource);
71
+ const payload = await verifyServiceAccessToken(token, options.ports, issuer, options.resource);
66
72
  const scopes = parseScopes(payload.scope);
67
73
  assertScopes(scopes, requiredScopes);
68
74
 
@@ -98,44 +104,33 @@ const parseAuthorization = (header: string | undefined): { scheme: 'Bearer' | 'D
98
104
  throw new ServiceAuthError(401, 'invalid_token', 'Unsupported authorization scheme.');
99
105
  };
100
106
 
101
- const verifyAccessToken = async (
107
+ /**
108
+ * RS-side access-token verification. Delegates to `@sentropic/oauth-verify`'s shared
109
+ * `verifyAccessToken` over an in-process JWKS key source, mapping any failure onto the
110
+ * RFC 6750 `invalid_token` 401 the middleware emits.
111
+ */
112
+ const verifyServiceAccessToken = async (
102
113
  token: string,
103
114
  ports: ServiceAuthPorts,
104
115
  issuer: string,
105
116
  resource: string
106
- ): Promise<JWTPayload & { scope?: unknown; client_id?: unknown; cnf?: { jkt?: string } }> => {
107
- let kid: string | undefined;
117
+ ): Promise<AccessTokenClaims> => {
108
118
  try {
109
- kid = decodeProtectedHeader(token).kid;
110
- } catch {
111
- throw new ServiceAuthError(401, 'invalid_token', 'Access token header is invalid.');
112
- }
113
- if (!kid) {
114
- throw new ServiceAuthError(401, 'invalid_token', 'Access token is missing a key id.');
115
- }
116
-
117
- const key = await ports.jwks.findKeyByKid(kid);
118
- if (!key) {
119
- throw new ServiceAuthError(401, 'invalid_token', 'Access token signing key is unknown.');
120
- }
121
-
122
- const publicKey = await importJWK(key.publicJwk, key.alg);
123
- const currentDate = ports.clock.now();
124
- try {
125
- const { payload } = await jwtVerify(token, publicKey, {
119
+ return await verifyAccessToken({
126
120
  audience: resource,
127
- currentDate,
128
121
  issuer,
122
+ keySource: fromJwksPort(ports.jwks),
123
+ now: ports.clock.now(),
124
+ token,
129
125
  });
130
- return payload;
131
- } catch {
132
- throw new ServiceAuthError(401, 'invalid_token', 'Access token is invalid, expired, or has the wrong audience.');
126
+ } catch (error) {
127
+ if (error instanceof TokenVerifyError) {
128
+ throw new ServiceAuthError(401, 'invalid_token', 'Access token is invalid, expired, or has the wrong audience.');
129
+ }
130
+ throw error;
133
131
  }
134
132
  };
135
133
 
136
- const parseScopes = (scope: unknown): string[] =>
137
- typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [];
138
-
139
134
  const assertScopes = (scopes: string[], requiredScopes: string[]): void => {
140
135
  const granted = new Set(scopes);
141
136
  const missing = requiredScopes.filter((scope) => !granted.has(scope));
@@ -146,7 +141,7 @@ const assertScopes = (scopes: string[], requiredScopes: string[]): void => {
146
141
 
147
142
  const enforceDpop = async (
148
143
  c: Context,
149
- payload: { cnf?: { jkt?: string } },
144
+ payload: { cnf?: { jkt: string } },
150
145
  accessToken: string,
151
146
  scheme: 'Bearer' | 'DPoP',
152
147
  options: CreateRequireServiceAuthOptions
@@ -188,53 +183,37 @@ interface VerifyServiceDpopProofOptions {
188
183
  proof: string;
189
184
  }
190
185
 
186
+ /**
187
+ * RS-side DPoP proof verification. Delegates to `@sentropic/oauth-verify`'s shared
188
+ * `verifyDpopProof`, wiring the optional RS replay port and remapping failures onto the
189
+ * RFC 9449 `invalid_dpop_proof` 401. The `jkt`↔`cnf.jkt` binding is enforced by the caller
190
+ * (`enforceDpop`) AFTER replay recording, preserving the original consume-then-compare order.
191
+ */
191
192
  const verifyServiceDpopProof = async (options: VerifyServiceDpopProofOptions): Promise<string> => {
192
- const header = decodeProtectedHeader(options.proof);
193
- const publicJwk = header.jwk as JWK | undefined;
194
- if (!publicJwk || !header.alg || header.typ !== 'dpop+jwt') {
195
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP proof header is invalid.', 'DPoP');
196
- }
197
-
198
- const key = await importJWK(publicJwk, header.alg);
199
- let payload: JWTPayload;
193
+ const iatSkewSec = options.iatSkewSeconds ?? 60;
200
194
  try {
201
- ({ payload } = await jwtVerify(options.proof, key));
202
- } catch {
203
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP proof signature is invalid.', 'DPoP');
204
- }
205
-
206
- const skew = options.iatSkewSeconds ?? 60;
207
- if (payload.htm !== options.htm.toUpperCase()) {
208
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP htm claim does not match the request method.', 'DPoP');
209
- }
210
- if (payload.htu !== options.htu) {
211
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP htu claim does not match the request URL.', 'DPoP');
212
- }
213
- if (!payload.jti || typeof payload.jti !== 'string') {
214
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP jti claim is required.', 'DPoP');
215
- }
216
- if (typeof payload.iat !== 'number') {
217
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP iat claim is required.', 'DPoP');
218
- }
219
- const nowSeconds = Math.floor(options.ports.clock.now().getTime() / 1000);
220
- if (Math.abs(payload.iat - nowSeconds) > skew) {
221
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP iat claim is outside the allowed skew.', 'DPoP');
222
- }
223
-
224
- // RFC 9449 §4.3: bind the proof to the access token (BR39d-D7).
225
- if (payload.ath !== (await sha256Base64url(options.accessToken))) {
226
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP ath claim does not match the access token.', 'DPoP');
227
- }
228
-
229
- if (options.ports.dpopReplay) {
230
- const expiresAt = options.ports.clock.addSeconds(options.ports.clock.now(), skew);
231
- const recorded = await options.ports.dpopReplay.recordDpopJti(payload.jti, expiresAt);
232
- if (!recorded) {
233
- throw new ServiceAuthError(401, 'invalid_dpop_proof', 'DPoP proof jti was already used.', 'DPoP');
195
+ const { jkt } = await verifyDpopProof({
196
+ accessToken: options.accessToken,
197
+ htm: options.htm,
198
+ htu: options.htu,
199
+ iatSkewSec,
200
+ now: options.ports.clock.now(),
201
+ proof: options.proof,
202
+ replay: options.ports.dpopReplay
203
+ ? (jti) =>
204
+ options.ports.dpopReplay!.recordDpopJti(
205
+ jti,
206
+ options.ports.clock.addSeconds(options.ports.clock.now(), iatSkewSec)
207
+ )
208
+ : undefined,
209
+ });
210
+ return jkt;
211
+ } catch (error) {
212
+ if (error instanceof DpopVerifyError) {
213
+ throw new ServiceAuthError(401, 'invalid_dpop_proof', error.message, 'DPoP');
234
214
  }
215
+ throw error;
235
216
  }
236
-
237
- return calculateJwkThumbprint(publicJwk);
238
217
  };
239
218
 
240
219
  const serviceAuthErrorResponse = (c: Context, error: ServiceAuthError): Response => {
@@ -9,6 +9,8 @@ export interface OAuthContinuationState {
9
9
  expiresAt: string;
10
10
  nonce: string | null;
11
11
  redirectUri: string;
12
+ /** RFC 8707 resource sealed at authorize time (BR-39l Lot 2); carried authorize → consent → code. */
13
+ resource?: string | null;
12
14
  scope: string;
13
15
  state: string | null;
14
16
  tenantId: string | null;
@@ -16,6 +16,12 @@ export interface OauthClientRecord {
16
16
  requirePkce: boolean;
17
17
  tenantId: string | null;
18
18
  ownerUserId: string | null;
19
+ /**
20
+ * RFC 8707 resource-indicator allowlist for the `authorization_code` flow (BR-39l Lot 2).
21
+ * Additive, default-deny: an empty/absent allowlist means the client may NOT request any
22
+ * `resource` (any value ⇒ `invalid_target`). Mirrors `ServiceClientRecord.resourceIndicators`.
23
+ */
24
+ resourceIndicators?: string[];
19
25
  createdAt: Date;
20
26
  updatedAt: Date;
21
27
  }
@@ -30,6 +36,12 @@ export interface AuthCodePayload {
30
36
  codeChallengeMethod: 'S256';
31
37
  dpopJkt: string | null;
32
38
  nonce: string | null;
39
+ /**
40
+ * RFC 8707 resource sealed at authorize time (BR-39l Lot 2). When present, it becomes the
41
+ * access-token `aud`; the token-leg `resource` (if sent) MUST equal it. Absent ⇒ default-aud
42
+ * (userinfo URL), byte-identical to auth-hono 0.5.0.
43
+ */
44
+ resource?: string | null;
33
45
  acr: string;
34
46
  authTime: Date;
35
47
  expiresAt: Date;
@@ -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,19 @@ 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
+
290
303
  export interface AuthHonoPorts {
291
304
  users: AuthHonoUserPort;
292
305
  credentials: AuthHonoCredentialPort;
@@ -303,4 +316,6 @@ export interface AuthHonoPorts {
303
316
  accountPolicy: AuthHonoAccountPolicyPort;
304
317
  oauthStateStore: OauthStateStorePort;
305
318
  jwks: JwksPort;
319
+ /** BR-39e tenancy spine (optional; legacy behavior when absent). */
320
+ tenant?: AuthHonoTenantPort;
306
321
  }