@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
@@ -4,9 +4,11 @@ import type { AuthHonoPorts } from '../ports.js';
4
4
  import type { OauthClientRecord } from './state-store-types.js';
5
5
  import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
6
6
  import { appendParams, oauthJsonError, redirectWithOAuthError } from './http-utils.js';
7
+ import { issueAuthorizedCode } from './issue-authorized-code.js';
7
8
  import { resolveOAuthAcr, resolveOAuthSession } from './session-resolver.js';
8
9
 
9
10
  export interface OAuthAuthorizeHandlerOptions {
11
+ authorizationCodeTtlSeconds?: number;
10
12
  consentUrl: string;
11
13
  issuer: string;
12
14
  loginUrl: string;
@@ -21,6 +23,7 @@ interface ValidatedAuthorizeRequest {
21
23
  dpopJkt: string | null;
22
24
  nonce: string | null;
23
25
  redirectUri: string;
26
+ resource: string | null;
24
27
  scope: string;
25
28
  state: string | null;
26
29
  }
@@ -48,19 +51,56 @@ export const createOAuthAuthorizeHandler =
48
51
  return c.redirect(appendParams(options.loginUrl, { continue: continuation }, c.req.url), 302);
49
52
  }
50
53
 
51
- if (prompt === 'none') {
52
- return redirectWithOAuthError(validation.redirectUri, 'consent_required', validation.state, c.req.url);
53
- }
54
-
55
- const sealedState = await sealContinuation(c, options, validation, {
54
+ const consentState: Pick<OAuthContinuationState, 'acr' | 'authTime' | 'userId'> = {
56
55
  acr: resolveOAuthAcr(session.sessionRecord),
57
56
  authTime: session.sessionRecord.createdAt.toISOString(),
58
57
  userId: session.user.id,
59
- });
58
+ };
59
+
60
+ // Consent persistence (optional): skip the consent screen and issue the code directly
61
+ // when a stored grant for the exact (user, client) covers every requested scope.
62
+ // `prompt=consent` ALWAYS forces the screen; coverage is a strict set-superset check,
63
+ // so any requested scope absent from the grant re-shows consent (scope-escalation guard).
64
+ const skipConsent =
65
+ prompt !== 'consent' &&
66
+ (await hasCoveringGrant(options.ports, session.user.id, validation.client.clientId, validation.scope));
60
67
 
61
- return c.redirect(appendParams(options.consentUrl, { state: sealedState }, c.req.url), 302);
68
+ if (prompt === 'none') {
69
+ if (!skipConsent) {
70
+ return redirectWithOAuthError(validation.redirectUri, 'consent_required', validation.state, c.req.url);
71
+ }
72
+ } else if (!skipConsent) {
73
+ const sealedState = await sealContinuation(c, options, validation, consentState);
74
+ return c.redirect(appendParams(options.consentUrl, { state: sealedState }, c.req.url), 302);
75
+ }
76
+
77
+ const sealedState = await sealContinuation(c, options, validation, consentState);
78
+ const payload = await options.stateCodec.unseal(sealedState);
79
+ if (!payload) {
80
+ return oauthJsonError(c, 400, 'invalid_request', 'OAuth continuation is invalid.');
81
+ }
82
+ return issueAuthorizedCode(c, options, payload);
62
83
  };
63
84
 
85
+ /**
86
+ * True iff `consentStore` is wired AND a stored grant for `(userId, clientId)` covers every
87
+ * requested scope (stored ⊇ requested). No store ⇒ false (legacy always-consent). The
88
+ * superset check is the scope-escalation invariant: a single uncovered scope forces consent.
89
+ */
90
+ const hasCoveringGrant = async (
91
+ ports: AuthHonoPorts,
92
+ userId: string,
93
+ clientId: string,
94
+ requestedScope: string
95
+ ): Promise<boolean> => {
96
+ if (!ports.consentStore) return false;
97
+ const grant = await ports.consentStore.getGrant(userId, clientId);
98
+ if (!grant) return false;
99
+ const granted = new Set(grant.scopes);
100
+ const requested = requestedScope.split(/\s+/).filter(Boolean);
101
+ return requested.every((scope) => granted.has(scope));
102
+ };
103
+
64
104
  const resumeLoginContinuation = async (
65
105
  c: Context,
66
106
  options: OAuthAuthorizeHandlerOptions,
@@ -129,12 +169,16 @@ const validateAuthorizeRequest = async (
129
169
  const scopeResult = validateScope(c.req.query('scope') ?? '', client, redirectUri, state, c.req.url);
130
170
  if (scopeResult instanceof Response) return scopeResult;
131
171
 
172
+ const resourceResult = validateResource(c.req.queries('resource'), client, redirectUri, state, c.req.url);
173
+ if (resourceResult instanceof Response) return resourceResult;
174
+
132
175
  return {
133
176
  client,
134
177
  codeChallenge,
135
178
  dpopJkt: c.req.query('dpop_jkt') ?? null,
136
179
  nonce: c.req.query('nonce') ?? null,
137
180
  redirectUri,
181
+ resource: resourceResult,
138
182
  scope: scopeResult,
139
183
  state,
140
184
  };
@@ -174,6 +218,34 @@ const validateScope = (
174
218
  return requestedScopes.join(' ');
175
219
  };
176
220
 
221
+ /**
222
+ * RFC 8707 resource indicator validation on the `authorization_code` flow (BR-39l Lot 2).
223
+ * - C1 single-aud: more than one `resource` value ⇒ `invalid_target` (no multi-audience tokens).
224
+ * - C2 default-deny allowlist: a requested `resource` must be in `client.resourceIndicators`,
225
+ * else `invalid_target`. No `resource` ⇒ `null` (default-aud = userinfo, byte-identical to 0.5.0).
226
+ * The validated value is sealed into the continuation and becomes the access-token `aud`.
227
+ */
228
+ const validateResource = (
229
+ resources: string[] | undefined,
230
+ client: OauthClientRecord,
231
+ redirectUri: string,
232
+ state: string | null,
233
+ baseUrl: string
234
+ ): string | null | Response => {
235
+ const requested = (resources ?? []).filter((value) => value.length > 0);
236
+ if (requested.length === 0) return null;
237
+ if (requested.length > 1) {
238
+ return redirectWithOAuthError(redirectUri, 'invalid_target', state, baseUrl);
239
+ }
240
+
241
+ const value = requested[0];
242
+ const allowlist = client.resourceIndicators ?? [];
243
+ if (!allowlist.includes(value)) {
244
+ return redirectWithOAuthError(redirectUri, 'invalid_target', state, baseUrl);
245
+ }
246
+ return value;
247
+ };
248
+
177
249
  const sealContinuation = async (
178
250
  c: Context,
179
251
  options: OAuthAuthorizeHandlerOptions,
@@ -182,6 +254,26 @@ const sealContinuation = async (
182
254
  ): Promise<string> => {
183
255
  const now = options.ports.clock.now();
184
256
  const expiresAt = options.ports.clock.addSeconds(now, options.stateTtlSeconds ?? 10 * 60);
257
+
258
+ // BR-39e: derive the tenant bound to this auth code from the user's VALIDATED membership,
259
+ // never from the raw client/param. Legacy behavior (client tenant) when no tenancy spine is
260
+ // wired. An explicit `?tenant=` selection is honored ONLY if it is an approved membership.
261
+ let tenantId: string | null = request.client.tenantId;
262
+ if (options.ports.tenant) {
263
+ tenantId = null;
264
+ if (session?.userId) {
265
+ const approved = await options.ports.tenant.listApprovedTenantIds(session.userId);
266
+ const requested = c.req.query('tenant') ?? null;
267
+ if (requested) {
268
+ tenantId = approved.includes(requested) ? requested : null;
269
+ } else if (approved.length === 1) {
270
+ tenantId = approved[0];
271
+ }
272
+ // 0 or >1 approved tenants without a valid explicit selection → no tenant claim
273
+ // (a multi-tenant selection screen is deferred; the RP may re-request with ?tenant=).
274
+ }
275
+ }
276
+
185
277
  return options.stateCodec.seal({
186
278
  acr: session?.acr,
187
279
  authTime: session?.authTime,
@@ -193,9 +285,10 @@ const sealContinuation = async (
193
285
  expiresAt: expiresAt.toISOString(),
194
286
  nonce: request.nonce,
195
287
  redirectUri: request.redirectUri,
288
+ resource: request.resource,
196
289
  scope: request.scope,
197
290
  state: request.state,
198
- tenantId: request.client.tenantId,
291
+ tenantId,
199
292
  userId: session?.userId,
200
293
  });
201
294
  };
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
2
2
 
3
3
  import type { AuthHonoPorts } from '../ports.js';
4
4
  import { appendParams, oauthJsonError, redirectOrJson } from './http-utils.js';
5
+ import { issueAuthorizedCode } from './issue-authorized-code.js';
5
6
  import type { OAuthContinuationCodec, OAuthContinuationState } from './state-codec.js';
6
7
  import { resolveOAuthSession } from './session-resolver.js';
7
8
 
@@ -46,32 +47,17 @@ export const createOAuthConsentDecisionHandler =
46
47
  );
47
48
  }
48
49
 
49
- const code = options.ports.random.token(32);
50
- const now = options.ports.clock.now();
51
- await options.ports.oauthStateStore.saveAuthCode(
52
- code,
53
- {
54
- acr: payload.acr ?? 'urn:sentropic:loa:bearer',
55
- authTime: new Date(payload.authTime ?? now.toISOString()),
56
- clientId: payload.clientId,
57
- codeChallenge: payload.codeChallenge,
58
- codeChallengeMethod: 'S256',
59
- createdAt: now,
60
- dpopJkt: payload.dpopJkt,
61
- expiresAt: options.ports.clock.addSeconds(now, options.authorizationCodeTtlSeconds ?? 60),
62
- nonce: payload.nonce,
63
- redirectUri: payload.redirectUri,
64
- scope: payload.scope,
65
- tenantId: payload.tenantId,
66
- userId: payload.userId ?? '',
67
- },
68
- options.authorizationCodeTtlSeconds ?? 60
69
- );
50
+ // Persist the grant so subsequent authorize requests for a covered scope set skip consent.
51
+ // Approve-only: a deny never records a grant. Absent consentStore ⇒ legacy (no persistence).
52
+ if (options.ports.consentStore && payload.userId) {
53
+ await options.ports.consentStore.saveGrant(
54
+ payload.userId,
55
+ payload.clientId,
56
+ payload.scope.split(/\s+/).filter(Boolean)
57
+ );
58
+ }
70
59
 
71
- return redirectOrJson(
72
- c,
73
- appendParams(payload.redirectUri, { code, state: payload.state }, c.req.url)
74
- );
60
+ return issueAuthorizedCode(c, options, payload);
75
61
  };
76
62
 
77
63
  const validateConsentState = async (
package/src/oauth/dpop.ts CHANGED
@@ -1,14 +1,10 @@
1
1
  import {
2
- calculateJwkThumbprint,
3
- decodeProtectedHeader,
4
- importJWK,
5
- jwtVerify,
6
- type JWK,
7
- type JWTPayload,
8
- } from 'jose';
2
+ DpopVerifyError,
3
+ verifyDpopProof,
4
+ type VerifiedDpop,
5
+ } from '@sentropic/oauth-verify';
9
6
 
10
7
  import type { AuthHonoPorts } from '../ports.js';
11
- import { sha256Base64url } from './crypto-utils.js';
12
8
 
13
9
  export interface VerifyDpopProofOptions {
14
10
  accessToken?: string;
@@ -19,10 +15,7 @@ export interface VerifyDpopProofOptions {
19
15
  proof: string;
20
16
  }
21
17
 
22
- export interface VerifiedDpopProof {
23
- jkt: string;
24
- jti: string;
25
- }
18
+ export type VerifiedDpopProof = VerifiedDpop;
26
19
 
27
20
  export class OAuthDpopProofError extends Error {
28
21
  constructor(message: string) {
@@ -31,63 +24,33 @@ export class OAuthDpopProofError extends Error {
31
24
  }
32
25
  }
33
26
 
27
+ /**
28
+ * AS-side DPoP proof verification. Thin adapter over `@sentropic/oauth-verify`'s shared
29
+ * `verifyDpopProof`: it binds the IdP's clock + replay store and re-maps verification
30
+ * failures onto `OAuthDpopProofError` for the OAuth handlers (token/userinfo/revoke).
31
+ */
34
32
  export const verifyOAuthDpopProof = async (
35
33
  options: VerifyDpopProofOptions
36
34
  ): Promise<VerifiedDpopProof> => {
37
- const header = decodeProtectedHeader(options.proof);
38
- const publicJwk = header.jwk as JWK | undefined;
39
- if (!publicJwk || !header.alg || header.typ !== 'dpop+jwt') {
40
- throw new OAuthDpopProofError('DPoP proof header is invalid.');
41
- }
42
-
43
- const key = await importJWK(publicJwk, header.alg);
44
- const { payload } = await jwtVerify(options.proof, key);
45
- await validateDpopPayload(payload, options);
46
-
47
- const expiresAt = options.ports.clock.addSeconds(
48
- options.ports.clock.now(),
49
- options.iatSkewSeconds ?? 60
50
- );
51
- const recorded = await options.ports.oauthStateStore.recordDpopJti(String(payload.jti), expiresAt);
52
- if (!recorded) {
53
- throw new OAuthDpopProofError('DPoP proof jti was already used.');
54
- }
55
-
56
- return {
57
- jkt: await calculateJwkThumbprint(publicJwk),
58
- jti: String(payload.jti),
59
- };
60
- };
61
-
62
- const validateDpopPayload = async (
63
- payload: JWTPayload,
64
- options: VerifyDpopProofOptions
65
- ): Promise<void> => {
66
- if (payload.htm !== options.htm.toUpperCase()) {
67
- throw new OAuthDpopProofError('DPoP htm claim does not match the request method.');
68
- }
69
- if (payload.htu !== options.htu) {
70
- throw new OAuthDpopProofError('DPoP htu claim does not match the request URL.');
71
- }
72
- if (!payload.jti || typeof payload.jti !== 'string') {
73
- throw new OAuthDpopProofError('DPoP jti claim is required.');
74
- }
75
- if (typeof payload.iat !== 'number') {
76
- throw new OAuthDpopProofError('DPoP iat claim is required.');
77
- }
78
-
79
- const nowSeconds = Math.floor(options.ports.clock.now().getTime() / 1000);
80
- if (Math.abs(payload.iat - nowSeconds) > (options.iatSkewSeconds ?? 60)) {
81
- throw new OAuthDpopProofError('DPoP iat claim is outside the allowed skew.');
82
- }
83
-
84
- if (options.accessToken) {
85
- await validateAth(payload, options.accessToken);
86
- }
87
- };
88
-
89
- const validateAth = async (payload: JWTPayload, accessToken: string): Promise<void> => {
90
- if (payload.ath !== (await sha256Base64url(accessToken))) {
91
- throw new OAuthDpopProofError('DPoP ath claim does not match the access token.');
35
+ const iatSkewSec = options.iatSkewSeconds ?? 60;
36
+ try {
37
+ return await verifyDpopProof({
38
+ accessToken: options.accessToken,
39
+ htm: options.htm,
40
+ htu: options.htu,
41
+ iatSkewSec,
42
+ now: options.ports.clock.now(),
43
+ proof: options.proof,
44
+ replay: (jti) =>
45
+ options.ports.oauthStateStore.recordDpopJti(
46
+ jti,
47
+ options.ports.clock.addSeconds(options.ports.clock.now(), iatSkewSec)
48
+ ),
49
+ });
50
+ } catch (error) {
51
+ if (error instanceof DpopVerifyError) {
52
+ throw new OAuthDpopProofError(error.message);
53
+ }
54
+ throw error;
92
55
  }
93
56
  };
@@ -0,0 +1,50 @@
1
+ import type { Context } from 'hono';
2
+
3
+ import type { AuthHonoPorts } from '../ports.js';
4
+ import { appendParams, redirectOrJson } from './http-utils.js';
5
+ import type { OAuthContinuationState } from './state-codec.js';
6
+
7
+ export interface IssueAuthorizedCodeOptions {
8
+ authorizationCodeTtlSeconds?: number;
9
+ ports: AuthHonoPorts;
10
+ }
11
+
12
+ /**
13
+ * Single source of truth for issuing an authorization code: mint a single-use code,
14
+ * persist its sealed payload, and redirect (or JSON) back to the RP `redirect_uri` with
15
+ * `code` + `state`. Called by BOTH the consent-approve path and the authorize skip-path
16
+ * (FL-1: never duplicate the seal / single-use-code / redirect logic).
17
+ */
18
+ export const issueAuthorizedCode = async (
19
+ c: Context,
20
+ options: IssueAuthorizedCodeOptions,
21
+ payload: OAuthContinuationState
22
+ ): Promise<Response> => {
23
+ const code = options.ports.random.token(32);
24
+ const now = options.ports.clock.now();
25
+ await options.ports.oauthStateStore.saveAuthCode(
26
+ code,
27
+ {
28
+ acr: payload.acr ?? 'urn:sentropic:loa:bearer',
29
+ authTime: new Date(payload.authTime ?? now.toISOString()),
30
+ clientId: payload.clientId,
31
+ codeChallenge: payload.codeChallenge,
32
+ codeChallengeMethod: 'S256',
33
+ createdAt: now,
34
+ dpopJkt: payload.dpopJkt,
35
+ expiresAt: options.ports.clock.addSeconds(now, options.authorizationCodeTtlSeconds ?? 60),
36
+ nonce: payload.nonce,
37
+ redirectUri: payload.redirectUri,
38
+ resource: payload.resource ?? null,
39
+ scope: payload.scope,
40
+ tenantId: payload.tenantId,
41
+ userId: payload.userId ?? '',
42
+ },
43
+ options.authorizationCodeTtlSeconds ?? 60
44
+ );
45
+
46
+ return redirectOrJson(
47
+ c,
48
+ appendParams(payload.redirectUri, { code, state: payload.state }, c.req.url)
49
+ );
50
+ };
@@ -1,6 +1,6 @@
1
+ import { fromJwksPort } from '@sentropic/oauth-verify';
1
2
  import {
2
3
  decodeProtectedHeader,
3
- importJWK,
4
4
  jwtVerify,
5
5
  SignJWT,
6
6
  type JWTVerifyOptions,
@@ -85,17 +85,13 @@ export const createJwksService = ({ clock, jwksPort }: CreateJwksServiceOptions)
85
85
 
86
86
  async verifyJwt(jwt, options = {}) {
87
87
  const protectedHeader = decodeProtectedHeader(jwt);
88
- const kid = protectedHeader.kid;
89
- if (!kid) {
88
+ if (!protectedHeader.kid) {
90
89
  throw new Error('JWT protected header is missing kid.');
91
90
  }
92
91
 
93
- const key = await jwksPort.findKeyByKid(kid);
94
- if (!key) {
95
- throw new Error(`Unknown JWKS kid: ${kid}`);
96
- }
97
-
98
- const publicKey = await importJWK(key.publicJwk, key.alg);
92
+ // Key resolution is shared with @sentropic/oauth-verify (single verify core); the
93
+ // AS-side claim assertions (iss/aud/currentDate) stay here via jose JWTVerifyOptions.
94
+ const publicKey = await fromJwksPort(jwksPort).resolveKey(protectedHeader);
99
95
  return jwtVerify(jwt, publicKey, options);
100
96
  },
101
97
  });
@@ -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;