@maronn-openid-connect/cli 0.3.0 → 0.4.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.
@@ -92,7 +92,8 @@ import { deviceApp } from './routes/device.js';\n`
92
92
  ? ` deviceAuthorizationStore,\n`
93
93
  : '';
94
94
  const refreshStorageContext = features.refreshToken
95
- ? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);\n`
95
+ ? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);
96
+ c.set('authenticationSessionResolver', storeResolvers.authenticationSessionResolver);\n`
96
97
  : '';
97
98
  const introspectionStorageContext = features.introspection
98
99
  ? ` c.set('introspectionAccessTokenResolver', storeResolvers.introspectionAccessTokenResolver);
@@ -333,11 +334,33 @@ export function configTemplate(corePkg, features = DEFAULT_FEATURES) {
333
334
  * 設定例: 90 日 = 7776000。
334
335
  */
335
336
  refreshTokenAbsoluteLifetime: number;
337
+ /**
338
+ * online refresh token(\`offline_access\` が付与されていない grant にも発行する
339
+ * Refresh Token)を有効にするか。
340
+ *
341
+ * OIDC Core 1.0 §11 は \`offline_access\` を「End-User が居ない(not logged in)ときにも
342
+ * 使える Refresh Token」と定義したうえで、Refresh Token の利用がその用途に限られない
343
+ * ことを明示している("The use of Refresh Tokens is not exclusive to the
344
+ * \`offline_access\` use case. The Authorization Server MAY grant Refresh Tokens in
345
+ * other contexts that are beyond the scope of this specification.")。本 OP はその
346
+ * 「other contexts」を online refresh token として実装する。
347
+ *
348
+ * - \`true\`(既定): \`grant_types\` に \`refresh_token\` を登録したクライアントには、
349
+ * \`offline_access\` が無くても Refresh Token を発行する。ただし発行元のログイン
350
+ * セッションへ束縛され、セッションが終われば \`invalid_grant\` になる。ブラウザ
351
+ * セッションを持たない経路(device authorization grant)では発行しない。
352
+ * - \`false\`: Refresh Token は \`offline_access\` が付与された grant にだけ発行する。
353
+ * ログアウトしても使い続けられる offline refresh token だけになる。
354
+ */
355
+ onlineRefreshTokenEnabled: boolean;
336
356
  `
337
357
  : '';
338
358
  const refreshTokenLifetimeDefault = features.refreshToken
339
359
  ? ` // OAuth 2.1 §6.1: refresh token は initial issuance から 90 日(7776000 秒)で必ず失効する。
340
360
  refreshTokenAbsoluteLifetime: 7776000,
361
+ // OIDC Core 1.0 §11: offline_access 無しの Refresh Token(online refresh token)も
362
+ // 発行する。ログインセッションに束縛されるため、ログアウトすると使えなくなる。
363
+ onlineRefreshTokenEnabled: true,
341
364
  `
342
365
  : '';
343
366
  const allowNonPkceDefault = features.pkce
@@ -367,12 +390,22 @@ export function configTemplate(corePkg, features = DEFAULT_FEATURES) {
367
390
  const exampleClientGrantTypes = [
368
391
  `'authorization_code'`,
369
392
  ...(features.refreshToken ? [`'refresh_token'`] : []),
370
- ...(features.tokenExchange ? [`'urn:ietf:params:oauth:grant-type:token-exchange'`] : []),
393
+ ...(features.tokenExchange || features.idJag
394
+ ? [`'urn:ietf:params:oauth:grant-type:token-exchange'`]
395
+ : []),
396
+ ...(features.idJag ? [`'urn:ietf:params:oauth:grant-type:jwt-bearer'`] : []),
371
397
  ].join(', ');
372
398
  const exampleClientExchangeComment = features.tokenExchange
373
399
  ? ` // EXPERIMENTAL (RFC 8693): registering the token-exchange URN is what lets
374
400
  // this confidential client exchange its access tokens. Remove it to forbid
375
401
  // exchanges for this client; public clients are rejected either way.
402
+ `
403
+ : '';
404
+ const exampleClientIdJagComment = features.idJag
405
+ ? ` // EXPERIMENTAL (ID-JAG draft §4.3 / §4.4): the token-exchange URN lets this
406
+ // confidential client request an ID-JAG for a trusted resource authorization
407
+ // server, and the jwt-bearer URN lets it redeem an ID-JAG issued by a trusted
408
+ // identity provider. Remove either to forbid that half of Cross-App Access.
376
409
  `
377
410
  : '';
378
411
  const noRefreshGrantComment = features.tokenExchange
@@ -383,12 +416,14 @@ export function configTemplate(corePkg, features = DEFAULT_FEATURES) {
383
416
  // grant is disabled in this generated provider, so only authorization_code is registered.
384
417
  `;
385
418
  const exampleClientGrantFields = features.refreshToken
386
- ? ` offlineAccessAllowed: true,
387
- // RFC 7591 §2: grant_types default is ["authorization_code"]. This client uses
388
- // offline_access (refresh tokens), so it must explicitly register refresh_token.
389
- ${exampleClientExchangeComment} grantTypes: [${exampleClientGrantTypes}],
419
+ ? ` // RFC 7591 §2: grant_types default is ["authorization_code"]. Registering
420
+ // refresh_token is the single switch that lets this client receive refresh
421
+ // tokens at all: an online refresh token (bound to the login session) on every
422
+ // authorization, and an offline one (usable after logout) when offline_access
423
+ // is granted per OIDC Core 1.0 §11. Remove it and neither is issued.
424
+ ${exampleClientExchangeComment}${exampleClientIdJagComment} grantTypes: [${exampleClientGrantTypes}],
390
425
  `
391
- : `${noRefreshGrantComment}${exampleClientExchangeComment} grantTypes: [${exampleClientGrantTypes}],
426
+ : `${noRefreshGrantComment}${exampleClientExchangeComment}${exampleClientIdJagComment} grantTypes: [${exampleClientGrantTypes}],
392
427
  `;
393
428
  return `import type {
394
429
  ClientInfo,
@@ -459,9 +494,14 @@ export function createProviderConfig(
459
494
  }
460
495
 
461
496
  /**
462
- * Extended client info with offline_access permission.
463
- * offlineAccessAllowed: controls whether the client may request refresh tokens
464
- * via the offline_access scope (OAuth 2.1 / OIDC offline_access).
497
+ * Extended client info for this provider.
498
+ *
499
+ * Whether a client may receive refresh tokens is decided by the standard
500
+ * \`grantTypes\` registration metadata (RFC 7591 §2 / OIDC Dynamic Client
501
+ * Registration 1.0 §2) it already carries through TokenClientInfo — there is no
502
+ * separate provider-specific switch. \`grantTypes\` containing \`refresh_token\`
503
+ * gates both refresh token flavors; OIDC Core 1.0 §11 (prompt=consent) decides
504
+ * which flavor the authorization produces.
465
505
  *
466
506
  * userinfoSignedResponseAlg: when set, the UserInfo endpoint returns a signed JWT
467
507
  * with content-type \`application/jwt\` (OIDC Core 1.0 Section 5.3.2 — client metadata
@@ -478,7 +518,6 @@ export function createProviderConfig(
478
518
  * rejected as a server configuration error.
479
519
  */
480
520
  export type RegisteredClient = ClientInfo & TokenClientInfo & {
481
- offlineAccessAllowed?: boolean;
482
521
  userinfoSignedResponseAlg?: 'RS256' | 'ES256';
483
522
  idTokenSignedResponseAlg?: 'RS256' | 'ES256';
484
523
  };
@@ -1018,6 +1057,12 @@ export class RefreshTokenStore {
1018
1057
  export interface AuthSessionInfo {
1019
1058
  subject: string;
1020
1059
  authTime: number;
1060
+ /**
1061
+ * このログインで確立(または再利用)したブラウザセッションの識別子。
1062
+ * consent 画面を経て発行する認可コードへ引き継ぎ、online refresh token を
1063
+ * そのセッションへ束縛するために使う。
1064
+ */
1065
+ sessionId?: string;
1021
1066
  }
1022
1067
 
1023
1068
  export class AuthSessionStore {
@@ -1085,8 +1130,8 @@ export function parseSessionId(cookieHeader: string | null): string | undefined
1085
1130
 
1086
1131
  /**
1087
1132
  * Build the Set-Cookie value for the browser session.
1088
- * Attributes per study-material/http-security-headers-and-tls.md:
1089
- * HttpOnly (no JS access), Secure (HTTPS only), SameSite=Lax. SameSite=Strict
1133
+ * Security attributes: HttpOnly (no JS access), Secure (HTTPS only),
1134
+ * SameSite=Lax. SameSite=Strict
1090
1135
  * would drop the cookie on the cross-site authorization redirect return and
1091
1136
  * break the flow, so Lax is required.
1092
1137
  */
@@ -1655,6 +1700,8 @@ export function resolversTemplate(corePkg, features = DEFAULT_FEATURES) {
1655
1700
  const refreshTypeImports = features.refreshToken
1656
1701
  ? ` RefreshTokenResolver,
1657
1702
  RefreshTokenInfo,
1703
+ AuthenticationSessionResolver,
1704
+ AuthenticationSessionInfo,
1658
1705
  `
1659
1706
  : '';
1660
1707
  const introspectionTypeImports = features.introspection
@@ -1682,10 +1729,31 @@ export function resolversTemplate(corePkg, features = DEFAULT_FEATURES) {
1682
1729
 
1683
1730
  `
1684
1731
  : '';
1685
- const refreshReturnField = features.refreshToken ? ` refreshTokenResolver,
1686
- ` : '';
1732
+ const refreshReturnField = features.refreshToken
1733
+ ? ` refreshTokenResolver,
1734
+ authenticationSessionResolver,
1735
+ `
1736
+ : '';
1687
1737
  const refreshExport = features.refreshToken
1688
1738
  ? `export const refreshTokenResolver = defaultStoreResolvers.refreshTokenResolver;
1739
+ export const authenticationSessionResolver =
1740
+ defaultStoreResolvers.authenticationSessionResolver;
1741
+ `
1742
+ : '';
1743
+ const authenticationSessionResolverBlock = features.refreshToken
1744
+ ? ` // online refresh token の束縛先セッションを sessionId から引く。sessionResolver は
1745
+ // Cookie を持つブラウザリクエストから引く入口で、トークンエンドポイントには End-User の
1746
+ // Cookie が届かないため、保存された sessionId から直接引くこちらが要る。
1747
+ // 終了したセッションでは必ず null を返すこと。返し続けると online refresh token が
1748
+ // ログアウト後も使えてしまう。
1749
+ const authenticationSessionResolver: AuthenticationSessionResolver = {
1750
+ async findSession(sessionId: string): Promise<AuthenticationSessionInfo | null> {
1751
+ const session = await browserSessionStore.get(sessionId);
1752
+ if (!session) return null;
1753
+ return { subject: session.subject, authTime: session.authTime };
1754
+ },
1755
+ };
1756
+
1689
1757
  `
1690
1758
  : '';
1691
1759
  const introspectionResolversBlock = features.introspection
@@ -1820,11 +1888,13 @@ ${introspectionResolversBlock}${revocationResolversBlock} const sessionResolver
1820
1888
  if (!sessionId) return null;
1821
1889
  const session = await browserSessionStore.get(sessionId);
1822
1890
  if (!session) return null;
1823
- return { subject: session.subject, authTime: session.authTime };
1891
+ // sessionId まで返すのは online refresh token のため。認可コードへ引き継ぎ、
1892
+ // トークンエンドポイントが Refresh Token をこのセッションへ束縛する。
1893
+ return { subject: session.subject, authTime: session.authTime, sessionId };
1824
1894
  },
1825
1895
  };
1826
1896
 
1827
- const revokeConsentAndTokens = async (subject: string, clientId: string): Promise<void> => {
1897
+ ${authenticationSessionResolverBlock} const revokeConsentAndTokens = async (subject: string, clientId: string): Promise<void> => {
1828
1898
  const grantIds = await consentStore.revoke(subject, clientId);
1829
1899
  for (const grantId of grantIds) {
1830
1900
  await authorizationCodeResolver.revokeTokensByGrantId?.(grantId);
@@ -2283,18 +2353,20 @@ function buildErrorRedirect(
2283
2353
  ? `jarmResponse ? { ...transaction, jarmResponseMode: 'query.jwt' } : transaction,`
2284
2354
  : `transaction,`;
2285
2355
  const offlineAccessStep = features.refreshToken
2286
- ? ` // OIDC Core 1.0 §11: offline_access requires prompt=consent (or another
2287
- // granting condition). The default policy drops offline_access from scope
2288
- // unless prompt=consent is present. To inject your own grant policy (e.g.
2289
- // honor a previously recorded user consent), pass a callback:
2290
- // scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt,
2356
+ ? ` // offline_access 2 つの独立した条件を両方満たしたときだけ残る。
2357
+ // - OIDC Core 1.0 §11: エンドユーザーの同意(prompt=consent)
2358
+ // - RFC 7591 §2: クライアント登録の grant_types refresh_token があること
2359
+ // (既定は ["authorization_code"])。無いまま offline_access を通すと、発行した
2360
+ // Refresh Token unauthorized_client で拒否されるだけの死んだ資格情報になる。
2361
+ // 独自の許可条件を差し込むならコールバックを渡す(client も受け取れる):
2362
+ // scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt, client,
2291
2363
  // (req, { promptValues }) => promptValues.includes('consent') || hasStoredConsent(req));
2292
- scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt);
2364
+ scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt, client);
2293
2365
  `
2294
2366
  : ` // The refresh_token feature is disabled in this generated provider:
2295
2367
  // the callback always returns false, so offline_access is never granted
2296
2368
  // (OIDC Core 1.0 §11 requires ignoring the request in that case).
2297
- scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt, () => false);
2369
+ scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt, client, () => false);
2298
2370
  `;
2299
2371
  return `import { Hono } from 'hono';
2300
2372
  import {
@@ -2616,12 +2688,11 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2616
2688
  return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'Session exceeds the requested max_age; re-authentication required', issuer));
2617
2689
  }
2618
2690
 
2619
- // Filter offline_access if the client does not allow it
2620
- const clientConfig = await clientResolver.findClient(transaction.clientId);
2621
- const grantedScope = transaction.scope.split(' ').filter((s: string) => {
2622
- if (s === 'offline_access' && !clientConfig?.offlineAccessAllowed) return false;
2623
- return Boolean(s);
2624
- });
2691
+ // transaction.scope は認可リクエスト検証時に applyOfflineAccessPolicy を通した
2692
+ // 後の値。offline_access の可否(OIDC Core 1.0 §11 の prompt=consent と、
2693
+ // クライアント登録 grant_types refresh_token があるか)はそこで判定済みなので、
2694
+ // ここで再フィルタしない。
2695
+ const grantedScope = transaction.scope.split(' ').filter(Boolean);
2625
2696
 
2626
2697
  // Generate authorization code via core helper
2627
2698
  const responseParams = await completeAuthTransaction(
@@ -2633,6 +2704,9 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2633
2704
  authorizationResponse: { ...responseParams, scope: grantedScope },
2634
2705
  subject: session.subject,
2635
2706
  authTime: session.authTime,
2707
+ // online refresh token をこのログインセッションへ束縛するために引き継ぐ。
2708
+ // セッションが終われば、その RT は invalid_grant になる。
2709
+ sessionId: session.sessionId,
2636
2710
  // OIDC Core 1.0 §3.1.3.1: TTL は ProviderConfig から設定可能(既定 300 秒)。
2637
2711
  ttlSeconds: config.authorizationCodeTtl,
2638
2712
  });
@@ -2685,12 +2759,9 @@ ${promptNoneSuccessRedirect}
2685
2759
  ));
2686
2760
 
2687
2761
  if (consentAlreadyGranted) {
2688
- // Filter offline_access if the client does not allow it
2689
- const clientConfig = await clientResolver.findClient(transaction.clientId);
2690
- const grantedScope = transaction.scope.split(' ').filter((s: string) => {
2691
- if (s === 'offline_access' && !clientConfig?.offlineAccessAllowed) return false;
2692
- return Boolean(s);
2693
- });
2762
+ // transaction.scope applyOfflineAccessPolicy 通過後の値(prompt=consent
2763
+ // クライアントの grant_types offline_access の可否は判定済み)。再フィルタしない。
2764
+ const grantedScope = transaction.scope.split(' ').filter(Boolean);
2694
2765
 
2695
2766
  const responseParams = await completeAuthTransaction(
2696
2767
  transactionId,
@@ -2701,6 +2772,8 @@ ${promptNoneSuccessRedirect}
2701
2772
  authorizationResponse: { ...responseParams, scope: grantedScope },
2702
2773
  subject: existingSession.subject,
2703
2774
  authTime: existingSession.authTime,
2775
+ // online refresh token を、この SSO で再利用したログインセッションへ束縛する。
2776
+ sessionId: existingSession.sessionId,
2704
2777
  // OIDC Core 1.0 §3.1.3.1: TTL は ProviderConfig から設定可能(既定 300 秒)。
2705
2778
  ttlSeconds: config.authorizationCodeTtl,
2706
2779
  });
@@ -2718,8 +2791,19 @@ ${ssoSuccessRedirect}
2718
2791
  await authSessionStore.set(transactionId, {
2719
2792
  subject: existingSession.subject,
2720
2793
  authTime: existingSession.authTime,
2794
+ // consent 画面を経由しても online refresh token の束縛先を見失わないよう、
2795
+ // login → consent の受け渡しに sessionId も載せる。
2796
+ sessionId: existingSession.sessionId,
2721
2797
  });
2722
- ${bindingCookieOnConsentRedirect} const consentUrl = new URL('/consent', c.req.url);
2798
+ ${bindingCookieOnConsentRedirect} // Internal redirects (/login, /consent) are built on config.issuer, never
2799
+ // on the request URL: some runtimes derive the request URL from the Host
2800
+ // header, which would let the sender pick the redirect origin and receive
2801
+ // transaction_id there (RFC 9700 §2.1: redirect only to trusted URIs).
2802
+ // OIDC Discovery 1.0 §3 makes the advertised issuer the source of truth
2803
+ // for URLs that point at the OP itself. A subpath issuer contributes only
2804
+ // its origin here ('/consent' is an absolute path) — subpath mounting is
2805
+ // not supported by the generated routes.
2806
+ const consentUrl = new URL('/consent', config.issuer);
2723
2807
  consentUrl.searchParams.set('transaction_id', transactionId);
2724
2808
  return c.redirect(consentUrl.toString());
2725
2809
  }
@@ -2727,7 +2811,9 @@ ${bindingCookieOnConsentRedirect} const consentUrl = new URL('/consent'
2727
2811
  }
2728
2812
 
2729
2813
  // Redirect to login page (prompt=login forces re-authentication; handled in login route)
2730
- ${bindingCookieOnLoginRedirect} const loginUrl = new URL('/login', c.req.url);
2814
+ ${bindingCookieOnLoginRedirect} // config.issuer, not the request URL, decides the redirect origin — see the
2815
+ // /consent redirect above (OIDC Discovery 1.0 §3 / RFC 9700 §2.1).
2816
+ const loginUrl = new URL('/login', config.issuer);
2731
2817
  loginUrl.searchParams.set('transaction_id', transactionId);
2732
2818
  return c.redirect(loginUrl.toString());
2733
2819
  } catch (error) {
@@ -3348,8 +3434,7 @@ deviceApp.post('/login', async (c) => {
3348
3434
  if (!user) {
3349
3435
  // Per-record throttling only. An attacker holding a device-grant client can
3350
3436
  // mint unlimited records, so the aggregate password-guess budget is the same
3351
- // as the one on /login — subject-scoped throttling is tracked separately in
3352
- // tasks/p2-login-attempt-throttling-subject-scope.md.
3437
+ // as the one on /login. Subject-scoped throttling is a separate concern.
3353
3438
  const failure = await recordDeviceLoginFailure(
3354
3439
  record,
3355
3440
  deviceStore,
@@ -3494,7 +3579,8 @@ assertJarmLifetimeSeconds(jarmConfig.jarmResponseLifetimeSeconds);
3494
3579
  export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3495
3580
  const refreshResolverImport = features.refreshToken
3496
3581
  ? `
3497
- refreshTokenResolver as defaultRefreshTokenResolver,`
3582
+ refreshTokenResolver as defaultRefreshTokenResolver,
3583
+ authenticationSessionResolver as defaultAuthenticationSessionResolver,`
3498
3584
  : '';
3499
3585
  const refreshStoreImport = features.refreshToken
3500
3586
  ? `
@@ -3503,6 +3589,10 @@ export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3503
3589
  const refreshResolverConst = features.refreshToken
3504
3590
  ? ` const refreshTokenResolver =
3505
3591
  c.get('refreshTokenResolver') ?? defaultRefreshTokenResolver;
3592
+ // online refresh token の束縛先セッションを sessionId から引く。差し替えると
3593
+ // 「セッションが生きているか」の判定そのものを差し替えられる。
3594
+ const authenticationSessionResolver =
3595
+ c.get('authenticationSessionResolver') ?? defaultAuthenticationSessionResolver;
3506
3596
  `
3507
3597
  : '';
3508
3598
  const refreshStoreConst = features.refreshToken
@@ -3543,6 +3633,12 @@ export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3543
3633
  // to enable it, or remove this step if your experiment has no idle lifetime.
3544
3634
  validateRefreshTokenIdleTimeout(refreshTokenInfo, undefined);
3545
3635
 
3636
+ // online refresh token(sessionId を持つ RT)は、束縛先のログインセッションが
3637
+ // 生きている間だけ使える。ログアウト・別ユーザーでの再ログインでセッションが
3638
+ // 消えれば invalid_grant になる。offline_access が付与された RT は sessionId を
3639
+ // 持たないため、このステップを素通りしてログアウト後も使い続けられる。
3640
+ await validateRefreshTokenSession(refreshTokenInfo, authenticationSessionResolver);
3641
+
3546
3642
  // RFC 6749 §6: requested scope may only narrow the original grant.
3547
3643
  const effectiveScope = validateRefreshTokenScope(
3548
3644
  params.scope,
@@ -3630,10 +3726,21 @@ export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3630
3726
  validateRefreshTokenExpiration,
3631
3727
  validateRefreshTokenIdleTimeout,
3632
3728
  validateRefreshTokenScope,
3729
+ validateRefreshTokenSession,
3730
+ clientAllowsRefreshTokenGrant,
3633
3731
  buildValidatedRefreshTokenRequest,`
3634
3732
  : '';
3635
3733
  const grantHasOfflineAccessBlock = features.refreshToken
3636
- ? ` // RFC 6749 §6 / OIDC Core 1.0 §11: refresh 時の scope 縮小は当該リクエストの access token /
3734
+ ? ` // --- Refresh Token を発行するかの判定 -------------------------------------
3735
+ //
3736
+ // RFC 7591 §2 / OIDC Dynamic Client Registration 1.0 §2: grant_types の既定は
3737
+ // ["authorization_code"]。refresh_token を登録していないクライアントへ RT を渡しても、
3738
+ // 次に grant_type=refresh_token を出した瞬間 validateClientGrantType が
3739
+ // unauthorized_client で拒否する。一度も使えない長期資格情報を保存させるだけなので
3740
+ // (RFC 9700 §4.14)、登録が無ければ発行しない。
3741
+ const clientAllowsRefreshGrant = clientAllowsRefreshTokenGrant(tokenClient);
3742
+
3743
+ // RFC 6749 §6 / OIDC Core 1.0 §11: refresh 時の scope 縮小は当該リクエストの access token /
3637
3744
  // ID Token の権限縮小として扱い、refresh token rotation の可否とは切り離す。rotation 可否は
3638
3745
  // 「元の grant が offline_access を持っていたか」で判断する。
3639
3746
  // - authorization_code grant: 今回付与された scope に offline_access があるか。
@@ -3641,14 +3748,33 @@ export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3641
3748
  // (validatedRequest.hadOfflineAccess)。縮小後 scope から offline_access を落としても
3642
3749
  // 元 grant の権限は失われないため rotation を継続する。
3643
3750
  const grantHasOfflineAccess =
3644
- validatedRequest.grantType === 'refresh_token'
3751
+ clientAllowsRefreshGrant &&
3752
+ (validatedRequest.grantType === 'refresh_token'
3645
3753
  ? validatedRequest.hadOfflineAccess
3646
- : validatedRequest.scope.includes('offline_access');
3754
+ : validatedRequest.scope.includes('offline_access'));
3755
+
3756
+ // online refresh token の束縛先セッション。
3757
+ // OIDC Core 1.0 §11 は offline_access を「End-User が居ない(not logged in)ときにも
3758
+ // 使える Refresh Token」と定義したうえで、Refresh Token の利用がその用途に限られない
3759
+ // ことも明示している("The Authorization Server MAY grant Refresh Tokens in other
3760
+ // contexts")。この OP はその other contexts を online refresh token として実装し、
3761
+ // ログインセッションへ束縛する。offline_access がある grant は束縛しない。
3762
+ // - authorization_code grant: 認可コードが持つ sessionId(ログイン時に確立したもの)。
3763
+ // - refresh_token grant: 元 RT の束縛をそのまま引き継ぎ、rotation で外れないようにする。
3764
+ const boundSessionId = grantHasOfflineAccess ? undefined : validatedRequest.sessionId;
3765
+
3766
+ // 束縛先が分からなければ online refresh token は発行しない。ブラウザセッションを
3767
+ // 持たない経路(device authorization grant)が該当する。ログアウトで止まる保証を
3768
+ // 付けられない RT を配らないための fail-closed。
3769
+ const issueRefreshToken =
3770
+ clientAllowsRefreshGrant &&
3771
+ (grantHasOfflineAccess ||
3772
+ (config.onlineRefreshTokenEnabled && boundSessionId !== undefined));
3647
3773
 
3648
3774
  `
3649
3775
  : '';
3650
3776
  const refreshTokenValueExpression = features.refreshToken
3651
- ? `grantHasOfflineAccess ? generateRandomString(32) : undefined`
3777
+ ? `issueRefreshToken ? generateRandomString(32) : undefined`
3652
3778
  : `undefined /* the refresh_token feature is disabled: never issue one */`;
3653
3779
  const randomStringImport = features.refreshToken
3654
3780
  ? `
@@ -3707,6 +3833,9 @@ export function tokenRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
3707
3833
  acr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.acr : resolvedAcr,
3708
3834
  amr: validatedRequest.grantType === 'refresh_token' ? validatedRequest.amr : resolvedAmr,
3709
3835
  azp: validatedRequest.grantType === 'refresh_token' ? validatedRequest.azp : undefined,
3836
+ // online refresh token の束縛。undefined なら offline refresh token として
3837
+ // セッションから独立し、ログアウト後も使える。
3838
+ sessionId: boundSessionId,
3710
3839
  });
3711
3840
  }
3712
3841
 
@@ -4089,6 +4218,349 @@ ${deviceRefreshTokenBlock}
4089
4218
  error.statusCode,
4090
4219
  );
4091
4220
  }
4221
+ `
4222
+ : '';
4223
+ const idJagTokenExchangeConstImport = features.idJag && !features.tokenExchange
4224
+ ? `
4225
+ ID_JAG_TOKEN_TYPE,
4226
+ TOKEN_EXCHANGE_GRANT_TYPE,`
4227
+ : '';
4228
+ const idJagImports = features.idJag
4229
+ ? `
4230
+ import {
4231
+ IdJagError,
4232
+ JWT_BEARER_GRANT_TYPE,${idJagTokenExchangeConstImport}
4233
+ TOKEN_TYPE_ID_TOKEN,
4234
+ matchesIdJagIssuanceRequest,
4235
+ processIdJagIssuanceRequest,
4236
+ processIdJagRedemptionRequest,
4237
+ resolveIdJagActor,
4238
+ type IdJagAccessTokenInfo,
4239
+ type IdJagActorTokenResolver,
4240
+ type IdJagTrustedIdentityProvider,
4241
+ } from '${EXPERIMENTAL_PACKAGE}/id-jag';
4242
+ import type { JwkSet } from '${corePkg}';`
4243
+ : '';
4244
+ const idJagRefreshConfigDoc = features.idJag && features.refreshToken
4245
+ ? `
4246
+ * - allowRefreshTokenSubjects: whether a refresh token this OP issued may stand
4247
+ * in for the ID Token as the subject_token (draft §4.3 MAY), so a client can
4248
+ * request a fresh ID-JAG after its ID Token expired without a new SSO round
4249
+ * trip. Validated exactly like the standard refresh_token grant (rotation
4250
+ * reuse revokes the token family; online tokens require the login session to
4251
+ * be alive); the refresh token is NOT consumed. Grants without the openid
4252
+ * scope are refused — their refresh token replaces no identity assertion.`
4253
+ : '';
4254
+ const idJagRefreshConfigField = features.idJag && features.refreshToken
4255
+ ? `
4256
+ allowRefreshTokenSubjects: true,`
4257
+ : '';
4258
+ const idJagConfigBlock = features.idJag
4259
+ ? `
4260
+ /**
4261
+ * EXPERIMENTAL — Cross-App Access (XAA) / ID-JAG settings
4262
+ * (draft-ietf-oauth-identity-assertion-authz-grant-04).
4263
+ *
4264
+ * Issuing side (this OP as the IdP, draft §4.3):
4265
+ * - allowedAudiences: resource authorization server issuers this IdP may issue
4266
+ * an ID-JAG for. Empty by default (fail safe): every issuance request is
4267
+ * rejected with invalid_target until you list the peer AS issuers here.
4268
+ * Adding an entry grants that cross-app connection on behalf of every user —
4269
+ * there is no per-user consent screen in this flow.
4270
+ * - idJagLifetimeSeconds: ID-JAG lifetime. Keep it short (draft example: 300);
4271
+ * clients are expected to request a fresh one instead of holding it.
4272
+ * - allowedScopes: optional cap on the scopes an ID-JAG may carry. undefined
4273
+ * passes the requested scopes through (the resource AS applies its own
4274
+ * policy again on redemption).${idJagRefreshConfigDoc}
4275
+ * - allowActorTokens: whether an actor_token (identifying who acts on the
4276
+ * subject's behalf) is accepted and recorded as the ID-JAG's act claim
4277
+ * (RFC 8693 §4.1). The draft defines no normative actor processing (§9.7
4278
+ * sketches extensions), so this is an opt-in extension and defaults to
4279
+ * false — an actor_token is rejected until you flip it, whatever else is
4280
+ * configured. Every token type identifier RFC 8693 §3 defines is accepted
4281
+ * the same way; the type alone decides nothing.
4282
+ * - actorTokenResolver: validates the actor_token's CONTENT (signature,
4283
+ * revocation, whose token it is) — for every accepted type, this OP's own
4284
+ * ID Tokens included. The library only checks the request structure and the
4285
+ * shape of what you return. Return the act value ({ sub, act? }) for a valid
4286
+ * token, null for an invalid one (answered with a fixed invalid_request), or
4287
+ * throw IdJagError to pick the response yourself. The default below handles
4288
+ * ID Tokens this OP issued to the authenticated client; extend or replace it
4289
+ * to cover the other types. Clearing it rejects every actor_token.
4290
+ *
4291
+ * Consuming side (this OP as the resource authorization server, draft §4.4):
4292
+ * - trustedIdentityProviders: the IdPs whose ID-JAGs are accepted on the
4293
+ * jwt-bearer grant. Empty by default (fail safe). Keys come from the inline
4294
+ * \`jwks\` when present, otherwise from \`jwksUri\` (fetched and cached below).
4295
+ * Never derive the key source from the assertion itself.
4296
+ */
4297
+ const defaultIdJagActorTokenResolver: IdJagActorTokenResolver = async ({
4298
+ actorToken,
4299
+ actorTokenType,
4300
+ clientId,
4301
+ issuer,
4302
+ jwks,
4303
+ }) =>
4304
+ actorTokenType === TOKEN_TYPE_ID_TOKEN
4305
+ ? resolveIdJagActor({ actorToken, issuer, clientId, jwks })
4306
+ : null;
4307
+
4308
+ export const idJagConfig = {
4309
+ allowedAudiences: [] as string[],
4310
+ idJagLifetimeSeconds: 300,
4311
+ allowedScopes: undefined as string[] | undefined,${idJagRefreshConfigField}
4312
+ allowActorTokens: false,
4313
+ actorTokenResolver: defaultIdJagActorTokenResolver as IdJagActorTokenResolver | undefined,
4314
+ trustedIdentityProviders: [] as Array<{ issuer: string; jwksUri?: string; jwks?: JwkSet }>,
4315
+ };
4316
+
4317
+ /**
4318
+ * EXPERIMENTAL — jwks_uri cache for trusted identity providers.
4319
+ *
4320
+ * A fetched JWKS is reused for 300 seconds, so a signing-key rotation at the
4321
+ * IdP can take up to that long to be picked up (a verification that fails
4322
+ * within the window is answered as an untrusted assertion). The fetch target
4323
+ * comes exclusively from the static idJagConfig above — never from request or
4324
+ * assertion content — which is what keeps this endpoint SSRF-free.
4325
+ */
4326
+ const idJagJwksCache = new Map<string, { jwks: JwkSet; expiresAt: number }>();
4327
+ const ID_JAG_JWKS_CACHE_TTL_MS = 300_000;
4328
+
4329
+ async function resolveTrustedIdentityProviders(): Promise<IdJagTrustedIdentityProvider[]> {
4330
+ const resolved: IdJagTrustedIdentityProvider[] = [];
4331
+ for (const entry of idJagConfig.trustedIdentityProviders) {
4332
+ if (entry.jwks !== undefined) {
4333
+ resolved.push({ issuer: entry.issuer, jwks: entry.jwks });
4334
+ continue;
4335
+ }
4336
+ if (entry.jwksUri === undefined) {
4337
+ // An entry with neither jwks nor jwksUri can never verify anything; skip
4338
+ // it so the assertion is answered with the fixed untrusted description.
4339
+ continue;
4340
+ }
4341
+ const cached = idJagJwksCache.get(entry.jwksUri);
4342
+ if (cached !== undefined && cached.expiresAt > Date.now()) {
4343
+ resolved.push({ issuer: entry.issuer, jwks: cached.jwks });
4344
+ continue;
4345
+ }
4346
+ // A failed fetch propagates: the generic catch turns it into server_error,
4347
+ // which is honest — the assertion was never evaluated, so invalid_grant
4348
+ // would wrongly blame the client for an outage on this side.
4349
+ const response = await fetch(entry.jwksUri);
4350
+ if (!response.ok) {
4351
+ throw new Error(\`Fetching the JWKS of trusted IdP \${entry.issuer} failed with status \${response.status}\`);
4352
+ }
4353
+ const jwks = (await response.json()) as JwkSet;
4354
+ idJagJwksCache.set(entry.jwksUri, { jwks, expiresAt: Date.now() + ID_JAG_JWKS_CACHE_TTL_MS });
4355
+ resolved.push({ issuer: entry.issuer, jwks });
4356
+ }
4357
+ return resolved;
4358
+ }
4359
+ `
4360
+ : '';
4361
+ const idJagRefreshSubjectArgs = features.idJag && features.refreshToken
4362
+ ? `
4363
+ ...(idJagConfig.allowRefreshTokenSubjects
4364
+ ? { refreshTokenResolver, authenticationSessionResolver }
4365
+ : {}),`
4366
+ : '';
4367
+ const idJagTokenExchangeFallbackStep = features.idJag && !features.tokenExchange
4368
+ ? `
4369
+ // Generated without --enable token-exchange: the exchange grant exists here
4370
+ // only to issue ID-JAGs, so any other requested_token_type is answered with
4371
+ // a pointer instead of falling through to unsupported_grant_type (discovery
4372
+ // does advertise the exchange grant in this build).
4373
+ if (params.grant_type === TOKEN_EXCHANGE_GRANT_TYPE) {
4374
+ c.header('Cache-Control', 'no-store');
4375
+ c.header('Pragma', 'no-cache');
4376
+ return c.json(
4377
+ {
4378
+ error: 'invalid_request',
4379
+ error_description: \`This authorization server only supports requested_token_type \${ID_JAG_TOKEN_TYPE} for token exchange\`,
4380
+ },
4381
+ 400,
4382
+ );
4383
+ }
4384
+ `
4385
+ : '';
4386
+ const idJagIssuanceDispatchStep = features.idJag
4387
+ ? `
4388
+ // --- EXPERIMENTAL: ID-JAG issuance (Cross-App Access, draft §4.3) ------
4389
+ // A token-exchange request whose requested_token_type is the ID-JAG URN.
4390
+ // Dispatched right after client authentication and BEFORE the plain
4391
+ // token-exchange branch (same grant_type URN) and core's
4392
+ // validateGrantTypeSupported. The subject_token must be an ID Token this OP
4393
+ // issued to the authenticated client; the result is a signed grant JWT for
4394
+ // the resource authorization server named by \`audience\` — not an access
4395
+ // token (the response carries token_type N_A).
4396
+ //
4397
+ // Backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may change
4398
+ // in a breaking way between releases. The underlying specification is an
4399
+ // IETF draft (-04) and may itself change. Do not build production code on
4400
+ // this without pinning versions.
4401
+ if (matchesIdJagIssuanceRequest(params)) {
4402
+ const idJagIssuanceConfig = c.get('config');
4403
+ // The ID-JAG is signed with a registered RS256 key so the peer AS can
4404
+ // verify it against this OP's JWKS endpoint (same key-selection contract
4405
+ // as JARM: RS256 is pinned, the active key may be a different alg).
4406
+ const idJagSigningKeys = (c.get('signingKeys') as SigningKey[] | undefined) ?? [];
4407
+ let idJagSigningKey: SigningKey;
4408
+ try {
4409
+ idJagSigningKey = selectSigningKeyByAlg(idJagSigningKeys, 'RS256');
4410
+ } catch {
4411
+ c.header('Cache-Control', 'no-store');
4412
+ c.header('Pragma', 'no-cache');
4413
+ return c.json(
4414
+ { error: 'server_error', error_description: 'No RS256 signing key registered for ID-JAG issuance' },
4415
+ 500,
4416
+ );
4417
+ }
4418
+ // The subject_token is verified against the same JWKS that id_token_hint
4419
+ // uses (the OP's own ID Token signing keys) — draft §4.3.3 requires the
4420
+ // assertion's audience to be the authenticated client, which
4421
+ // processIdJagIssuanceRequest checks.
4422
+ const idJagJwks = await c.get('jwksProvider')();
4423
+
4424
+ const idJagIssuanceResponse = await processIdJagIssuanceRequest({
4425
+ params,
4426
+ client: tokenClient,
4427
+ issuer: idJagIssuanceConfig.issuer,
4428
+ jwks: idJagJwks,
4429
+ signingKey: idJagSigningKey,
4430
+ allowedAudiences: idJagConfig.allowedAudiences,
4431
+ allowedScopes: idJagConfig.allowedScopes,
4432
+ lifetimeSeconds: idJagConfig.idJagLifetimeSeconds,
4433
+ // Extension (draft §9.7): when enabled, an actor_token is recorded as
4434
+ // the ID-JAG's act claim. Every accepted token type goes through the
4435
+ // same resolver, which owns the content validation.
4436
+ allowActorTokens: idJagConfig.allowActorTokens,
4437
+ ...(idJagConfig.actorTokenResolver === undefined
4438
+ ? {}
4439
+ : { actorTokenResolver: idJagConfig.actorTokenResolver }),${idJagRefreshSubjectArgs}
4440
+ });
4441
+
4442
+ // RFC 6749 §5.1: token responses MUST NOT be cached. The ID-JAG itself is
4443
+ // not persisted — it is a self-contained signed grant the peer AS
4444
+ // verifies by signature and exp.
4445
+ c.header('Cache-Control', 'no-store');
4446
+ c.header('Pragma', 'no-cache');
4447
+ return c.json(idJagIssuanceResponse);
4448
+ }
4449
+ ${idJagTokenExchangeFallbackStep}`
4450
+ : '';
4451
+ const idJagRedemptionDispatchStep = features.idJag
4452
+ ? `
4453
+ // --- EXPERIMENTAL: ID-JAG redemption (Cross-App Access, draft §4.4) ----
4454
+ // The jwt-bearer grant (RFC 7523 §2.1). The assertion must be an ID-JAG
4455
+ // (typ oauth-id-jag+jwt) issued by one of idJagConfig.trustedIdentityProviders
4456
+ // for THIS issuer and for the authenticated client. This OP then issues its
4457
+ // own access token — the IdP never mints tokens for this AS.
4458
+ //
4459
+ // No ID Token is issued (this is not an OIDC authentication flow: the
4460
+ // openid scope only grants UserInfo access) and no refresh token is issued
4461
+ // (draft §4.4.3 SHOULD NOT — re-presenting the still-valid ID-JAG replaces
4462
+ // the refresh token).
4463
+ if (params.grant_type === JWT_BEARER_GRANT_TYPE) {
4464
+ const idJagRedemptionConfig = c.get('config');
4465
+ const idJagIdentityProviders = await resolveTrustedIdentityProviders();
4466
+
4467
+ const idJagGrant = await processIdJagRedemptionRequest({
4468
+ params,
4469
+ client: tokenClient,
4470
+ issuer: idJagRedemptionConfig.issuer,
4471
+ identityProviders: idJagIdentityProviders,
4472
+ configuredExpiresIn: idJagRedemptionConfig.accessTokenExpiresIn,
4473
+ });
4474
+
4475
+ // config / privateKey / keyId are bound further down for the standard
4476
+ // grants. This branch reads them on its own so the generated output is
4477
+ // unchanged when the feature is off; it returns, so nothing runs twice.
4478
+ const idJagTokenIssuer: AccessTokenIssuer =
4479
+ idJagRedemptionConfig.accessTokenFormat === 'opaque'
4480
+ ? createOpaqueAccessTokenIssuer()
4481
+ : createJwtAccessTokenIssuer();
4482
+
4483
+ // Same aud policy as the standard token route: the UserInfo endpoint
4484
+ // stays a permanent member (RFC 9068 §3); the ID-JAG's resource claim
4485
+ // (RFC 8707) contributes the requested resources.
4486
+ const idJagAudience = buildAccessTokenAudience({
4487
+ userInfoEndpoint: \`\${idJagRedemptionConfig.issuer}/userinfo\`,
4488
+ requested: idJagGrant.requestedResources,
4489
+ issuer: idJagRedemptionConfig.issuer,
4490
+ });
4491
+
4492
+ const idJagIssuedAt = Math.floor(Date.now() / 1000);
4493
+ const idJagAccessTokenPayload = buildAccessTokenPayload({
4494
+ issuer: idJagRedemptionConfig.issuer,
4495
+ subject: idJagGrant.subject,
4496
+ clientId: idJagGrant.clientId,
4497
+ scope: idJagGrant.scope,
4498
+ audience: idJagAudience,
4499
+ expiresIn: idJagGrant.expiresIn,
4500
+ issuedAt: idJagIssuedAt,
4501
+ });
4502
+ const idJagAccessToken = await idJagTokenIssuer.issue({
4503
+ payload: {
4504
+ ...idJagAccessTokenPayload,
4505
+ // RFC 8693 §4.1: an act claim carried by the ID-JAG is preserved on
4506
+ // the issued access token, so downstream services still see WHO acts
4507
+ // on the subject's behalf (dropping it would silently turn the
4508
+ // delegation into impersonation).
4509
+ ...(idJagGrant.actor === undefined ? {} : { act: idJagGrant.actor }),
4510
+ },
4511
+ privateKey: c.get('privateKey'),
4512
+ keyId: c.get('keyId'),
4513
+ });
4514
+
4515
+ const idJagAccessTokenMetadata: IdJagAccessTokenInfo = {
4516
+ // draft §4.4.1: the ID-JAG's sub is used as the local subject directly
4517
+ // (subject resolution by identical sub; JIT provisioning is out of scope).
4518
+ sub: idJagGrant.subject,
4519
+ clientId: idJagGrant.clientId,
4520
+ scope: idJagGrant.scope,
4521
+ expiresAt: idJagIssuedAt + idJagGrant.expiresIn,
4522
+ // Each redemption is its own grant: revoking one issued token must not
4523
+ // affect tokens from other redemptions of the same (re-presentable)
4524
+ // ID-JAG, so the payload's own jti doubles as the grant id.
4525
+ grantId: idJagAccessTokenPayload.jti,
4526
+ iat: idJagIssuedAt,
4527
+ nbf: idJagIssuedAt,
4528
+ audience: idJagAudience,
4529
+ issuer: idJagRedemptionConfig.issuer,
4530
+ jti: idJagAccessTokenPayload.jti,
4531
+ // The actor record is persisted too, so opaque-token introspection and
4532
+ // store-based tooling can surface it just like the JWT claim.
4533
+ ...(idJagGrant.actor === undefined ? {} : { act: idJagGrant.actor }),
4534
+ };
4535
+ await accessTokenStore.set(idJagAccessToken, idJagAccessTokenMetadata);
4536
+
4537
+ // RFC 6749 §5.1: token responses MUST NOT be cached.
4538
+ c.header('Cache-Control', 'no-store');
4539
+ c.header('Pragma', 'no-cache');
4540
+ return c.json({
4541
+ access_token: idJagAccessToken,
4542
+ token_type: 'Bearer' as const,
4543
+ expires_in: idJagGrant.expiresIn,
4544
+ scope: idJagGrant.scope.join(' '),
4545
+ });
4546
+ }
4547
+ `
4548
+ : '';
4549
+ const idJagCatchBranch = features.idJag
4550
+ ? ` if (error instanceof IdJagError) {
4551
+ // ID-JAG errors use the RFC 6749 §5.2 shape and are always 400 — a 401
4552
+ // can only come from client authentication, which runs before both
4553
+ // branches and throws core's TokenError. Issuance failures map to
4554
+ // invalid_request / invalid_target / invalid_scope / unauthorized_client
4555
+ // (RFC 8693 §2.2.2); assertion failures on redemption map to
4556
+ // invalid_grant (RFC 7521 §4.1).
4557
+ c.header('Cache-Control', 'no-store');
4558
+ c.header('Pragma', 'no-cache');
4559
+ return c.json(
4560
+ { error: error.code, error_description: error.errorDescription },
4561
+ error.statusCode,
4562
+ );
4563
+ }
4092
4564
  `
4093
4565
  : '';
4094
4566
  return `import { Hono } from 'hono';
@@ -4132,8 +4604,8 @@ import {
4132
4604
  accessTokenStore as defaultAccessTokenStore,
4133
4605
  authCodeStore as defaultAuthCodeStore,${refreshStoreImport}
4134
4606
  } from '../store.js';
4135
- import type { RegisteredClient } from '../config.js';${tokenExchangeImports}${deviceGrantImports}
4136
- ${tokenExchangeConfigBlock}
4607
+ import type { RegisteredClient } from '../config.js';${tokenExchangeImports}${idJagImports}${deviceGrantImports}
4608
+ ${tokenExchangeConfigBlock}${idJagConfigBlock}
4137
4609
  export const tokenApp = new Hono<{ Variables: Record<string, any> }>();
4138
4610
 
4139
4611
  /**
@@ -4243,7 +4715,7 @@ ${refreshStoreConst}
4243
4715
  await verifyClientSecret(tokenClient, presentedCredentials.clientSecret);
4244
4716
 
4245
4717
  const authenticatedClientId = presentedCredentials.clientId;
4246
- ${tokenExchangeDispatchStep}${deviceCodeDispatchStep}
4718
+ ${idJagIssuanceDispatchStep}${idJagRedemptionDispatchStep}${tokenExchangeDispatchStep}${deviceCodeDispatchStep}
4247
4719
  // --- Token request validation pipeline --------------------------------
4248
4720
  // Each step below is an independent core function, called in the same order
4249
4721
  // as core's validateTokenRequest(). Delete a call to drop that validation,
@@ -4478,7 +4950,7 @@ ${refreshTokenPersistenceBlock} c.header('Cache-Control', 'no-store');
4478
4950
  c.header('Pragma', 'no-cache');
4479
4951
  return c.json(tokenResponse);
4480
4952
  } catch (error) {
4481
- ${tokenExchangeCatchBranch}${deviceGrantCatchBranch} if (error instanceof TokenError) {
4953
+ ${idJagCatchBranch}${tokenExchangeCatchBranch}${deviceGrantCatchBranch} if (error instanceof TokenError) {
4482
4954
  const status = error.statusCode as 400 | 401;
4483
4955
  // RFC 6750 Section 3 / OAuth 2.1 Section 5.2: 401 responses include WWW-Authenticate
4484
4956
  if (error.wwwAuthenticate) {
@@ -4841,7 +5313,10 @@ export function discoveryRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
4841
5313
  const supportedGrantTypes = [
4842
5314
  `'authorization_code'`,
4843
5315
  ...(features.refreshToken ? [`'refresh_token'`] : []),
4844
- ...(features.tokenExchange ? [`'urn:ietf:params:oauth:grant-type:token-exchange'`] : []),
5316
+ ...(features.tokenExchange || features.idJag
5317
+ ? [`'urn:ietf:params:oauth:grant-type:token-exchange'`]
5318
+ : []),
5319
+ ...(features.idJag ? [`'urn:ietf:params:oauth:grant-type:jwt-bearer'`] : []),
4845
5320
  ...(features.deviceAuthorizationGrant
4846
5321
  ? [`'urn:ietf:params:oauth:grant-type:device_code'`]
4847
5322
  : []),
@@ -4927,6 +5402,16 @@ import { parConfig } from './par.js';`
4927
5402
  // EXPERIMENTAL — RFC 8628 §4 metadata.
4928
5403
  device_authorization_endpoint: \`\${issuer}/device_authorization\`,`
4929
5404
  : '';
5405
+ const idJagDiscoveryMetadata = features.idJag
5406
+ ? `
5407
+ // EXPERIMENTAL — ID-JAG draft §7.1: this OP can issue an ID-JAG via token
5408
+ // exchange (identity-chaining requested token type).
5409
+ identity_chaining_requested_token_types_supported: ['urn:ietf:params:oauth:token-type:id-jag'],
5410
+ // EXPERIMENTAL — ID-JAG draft §7.2: this OP can process the ID-JAG grant
5411
+ // profile on the jwt-bearer grant. Which issuers are actually trusted is
5412
+ // local policy and is not disclosed here (draft §9.4).
5413
+ authorization_grant_profiles_supported: ['urn:ietf:params:oauth:grant-profile:id-jag'],`
5414
+ : '';
4930
5415
  return `import { Hono } from 'hono';
4931
5416
  import { buildProviderMetadata, getJwaAlgorithm, type SigningKey } from '${corePkg}';
4932
5417
  import { defaultProviderConfig } from '../config.js';${parDiscoveryImport}
@@ -5039,7 +5524,7 @@ ${rfc8414Comment}${introspectionMetadata}${revocationMetadata} });
5039
5524
  // not in OIDC Discovery, so it is added separately.
5040
5525
  return c.json({
5041
5526
  ...metadata,
5042
- code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${jarmDiscoveryMetadata}
5527
+ code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${jarmDiscoveryMetadata}${idJagDiscoveryMetadata}
5043
5528
  });
5044
5529
  });
5045
5530
  `;
@@ -5132,6 +5617,7 @@ import {
5132
5617
  parseSessionId,${bindingStoreImport}
5133
5618
  userStore,
5134
5619
  } from '../store.js';
5620
+ import { defaultProviderConfig } from '../config.js';
5135
5621
  import { defaultViews, renderView } from '../views.js';
5136
5622
 
5137
5623
  export const loginApp = new Hono<{ Variables: Record<string, any> }>();
@@ -5215,12 +5701,6 @@ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
5215
5701
 
5216
5702
  const authTime = Math.floor(Date.now() / 1000);
5217
5703
 
5218
- // Store authenticated subject for the consent step (per-transaction handoff).
5219
- await authSessionStore.set(transactionId, {
5220
- subject: user.sub,
5221
- authTime,
5222
- });
5223
-
5224
5704
  // Establish a persistent browser (OP) session and set the session cookie so
5225
5705
  // SSO / prompt=none / max_age work on subsequent authorization requests
5226
5706
  // (OIDC Core 1.0 Section 3.1.2.3).
@@ -5228,8 +5708,21 @@ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
5228
5708
  await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
5229
5709
  c.header('Set-Cookie', buildSessionCookie(sessionId));
5230
5710
 
5231
- // Redirect to consent page
5232
- const consentUrl = new URL('/consent', c.req.url);
5711
+ // Store authenticated subject for the consent step (per-transaction handoff).
5712
+ // sessionId も渡すのは online refresh token のため。consent 経由で発行する認可
5713
+ // コードにこのセッションを引き継ぎ、ログアウトで使えなくなる RT を作る。
5714
+ await authSessionStore.set(transactionId, {
5715
+ subject: user.sub,
5716
+ authTime,
5717
+ sessionId,
5718
+ });
5719
+
5720
+ // Redirect to consent page. config.issuer, not the request URL, decides the
5721
+ // redirect origin: some runtimes derive the request URL from the Host header,
5722
+ // which would let the sender pick where transaction_id lands (OIDC Discovery
5723
+ // 1.0 §3 / RFC 9700 §2.1).
5724
+ const config = c.get('config') ?? defaultProviderConfig;
5725
+ const consentUrl = new URL('/consent', config.issuer);
5233
5726
  consentUrl.searchParams.set('transaction_id', transactionId);
5234
5727
  return c.redirect(consentUrl.toString());
5235
5728
  });
@@ -5466,7 +5959,6 @@ import {
5466
5959
  createAuthorizationCode,${jarmConsentCoreImports}
5467
5960
  } from '${corePkg}';
5468
5961
  import {
5469
- clientResolver as defaultClientResolver,
5470
5962
  consentResolver as defaultConsentResolver,
5471
5963
  } from '../resolvers.js';
5472
5964
  import {
@@ -5514,7 +6006,6 @@ consentApp.post('/', async (c) => {
5514
6006
  const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
5515
6007
  const authCodeStore = c.get('authCodeStore') ?? defaultAuthCodeStore;
5516
6008
  const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
5517
- const clientResolver = c.get('clientResolver') ?? defaultClientResolver;
5518
6009
 
5519
6010
  const transaction = await getAuthTransaction(transactionId, transactionStore);
5520
6011
  ${bindingCheckBeforeConsentCsrf} validateCsrfToken(transaction, csrfToken);
@@ -5561,12 +6052,10 @@ ${consentDenyRedirect}
5561
6052
  transactionStore,
5562
6053
  );
5563
6054
 
5564
- // Filter offline_access if the client does not allow it
5565
- const clientConfig = await clientResolver.findClient(transaction.clientId);
5566
- const grantedScope = transaction.scope.split(' ').filter((s) => {
5567
- if (s === 'offline_access' && !clientConfig?.offlineAccessAllowed) return false;
5568
- return Boolean(s);
5569
- });
6055
+ // transaction.scope は認可リクエスト検証時に applyOfflineAccessPolicy を通した後の値。
6056
+ // offline_access の可否(OIDC Core 1.0 §11 の prompt=consent と、クライアント登録
6057
+ // grant_types refresh_token があるか)はそこで確定しているので再フィルタしない。
6058
+ const grantedScope = transaction.scope.split(' ').filter(Boolean);
5570
6059
 
5571
6060
  // Generate authorization code via core helper
5572
6061
  // OIDC Core 1.0 Section 3.1.3.1: TTL is configurable via ProviderConfig
@@ -5575,6 +6064,9 @@ ${consentDenyRedirect}
5575
6064
  authorizationResponse: { ...responseParams, scope: grantedScope },
5576
6065
  subject: session.subject,
5577
6066
  authTime: session.authTime,
6067
+ // online refresh token をこのログインセッションへ束縛する(login route が
6068
+ // authSessionStore へ載せた値)。ログアウトすれば RT も使えなくなる。
6069
+ sessionId: session.sessionId,
5578
6070
  ttlSeconds: config.authorizationCodeTtl,
5579
6071
  });
5580
6072
  await authCodeStore.set(authCodeData.code, authCodeData);
@@ -5650,7 +6142,8 @@ import { deviceApp } from './routes/device.js';\n`
5650
6142
  ? ` deviceAuthorizationStore,\n`
5651
6143
  : '';
5652
6144
  const refreshStorageContext = features.refreshToken
5653
- ? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);\n`
6145
+ ? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);
6146
+ c.set('authenticationSessionResolver', storeResolvers.authenticationSessionResolver);\n`
5654
6147
  : '';
5655
6148
  const introspectionStorageContext = features.introspection
5656
6149
  ? ` c.set('introspectionAccessTokenResolver', storeResolvers.introspectionAccessTokenResolver);
@@ -7695,7 +8188,6 @@ function deviceAuthorizationConformanceClients(features) {
7695
8188
  responseTypes: ['code'],
7696
8189
  grantTypes: ${deviceGrantTypes},
7697
8190
  tokenEndpointAuthMethod: 'client_secret_post',
7698
- offlineAccessAllowed: true,
7699
8191
  }],
7700
8192
  ['c-device-other', {
7701
8193
  clientId: 'c-device-other',
@@ -7746,6 +8238,46 @@ function tokenExchangeConformanceClients(features) {
7746
8238
  }],
7747
8239
  `;
7748
8240
  }
8241
+ function idJagConformanceClients(features) {
8242
+ if (!features.idJag)
8243
+ return '';
8244
+ const idJagClientGrantTypes = features.refreshToken
8245
+ ? `['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:token-exchange', 'urn:ietf:params:oauth:grant-type:jwt-bearer']`
8246
+ : `['authorization_code', 'urn:ietf:params:oauth:grant-type:token-exchange', 'urn:ietf:params:oauth:grant-type:jwt-bearer']`;
8247
+ return ` // EXPERIMENTAL (ID-JAG draft): the Cross-App Access fixtures. c-idjag plays
8248
+ // the requesting app for both halves (issuance via token exchange, redemption
8249
+ // via jwt-bearer); c-idjag-other holds the jwt-bearer grant so the
8250
+ // client-continuity contract (draft §4.4.1) can present someone else's
8251
+ // ID-JAG; the public fixture pins that registering the URNs does not lift the
8252
+ // confidential-client requirement.
8253
+ ['c-idjag', {
8254
+ clientId: 'c-idjag',
8255
+ clientSecret: 's',
8256
+ redirectUris: [REDIRECT_URI],
8257
+ clientType: 'confidential' as const,
8258
+ responseTypes: ['code'],
8259
+ grantTypes: ${idJagClientGrantTypes},
8260
+ tokenEndpointAuthMethod: 'client_secret_post',
8261
+ }],
8262
+ ['c-idjag-other', {
8263
+ clientId: 'c-idjag-other',
8264
+ clientSecret: 's',
8265
+ redirectUris: [REDIRECT_URI],
8266
+ clientType: 'confidential' as const,
8267
+ responseTypes: ['code'],
8268
+ grantTypes: ['urn:ietf:params:oauth:grant-type:jwt-bearer'],
8269
+ tokenEndpointAuthMethod: 'client_secret_post',
8270
+ }],
8271
+ ['c-public-idjag', {
8272
+ clientId: 'c-public-idjag',
8273
+ redirectUris: [REDIRECT_URI],
8274
+ clientType: 'public' as const,
8275
+ responseTypes: ['code'],
8276
+ grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
8277
+ tokenEndpointAuthMethod: 'none',
8278
+ }],
8279
+ `;
8280
+ }
7749
8281
  export function authorizationCodeConformanceHelper(features) {
7750
8282
  if (!features.introspection)
7751
8283
  return '';
@@ -7845,12 +8377,13 @@ export function conformanceTestClientsBlock(features) {
7845
8377
  grantTypes: ['authorization_code'],
7846
8378
  tokenEndpointAuthMethod: 'client_secret_basic',
7847
8379
  }],
7848
- ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}]);
8380
+ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}${idJagConformanceClients(features)}]);
7849
8381
  `;
7850
8382
  }
7851
8383
  return `const testClients = new Map<string, RegisteredClient>([
7852
- // offlineAccessAllowed + refresh_token grant so the reuse-cascade tests can drive
7853
- // the full code/refresh flow and observe revocation across the grant.
8384
+ // RFC 7591 §2: registering the refresh_token grant is what makes this client
8385
+ // eligible for refresh tokens at all, so the reuse-cascade tests can drive the
8386
+ // full code/refresh flow and observe revocation across the grant.
7854
8387
  ['c-conf', {
7855
8388
  clientId: 'c-conf',
7856
8389
  clientSecret: 's',
@@ -7859,7 +8392,6 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
7859
8392
  responseTypes: ['code'],
7860
8393
  grantTypes: ['authorization_code', 'refresh_token'],
7861
8394
  tokenEndpointAuthMethod: 'client_secret_post',
7862
- offlineAccessAllowed: true,
7863
8395
  }],
7864
8396
  ['c-public', {
7865
8397
  clientId: 'c-public',
@@ -7868,7 +8400,6 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
7868
8400
  responseTypes: ['code'],
7869
8401
  grantTypes: ['authorization_code', 'refresh_token'],
7870
8402
  tokenEndpointAuthMethod: 'none',
7871
- offlineAccessAllowed: true,
7872
8403
  }],
7873
8404
  // A confidential client registered for client_secret_basic so the conformance
7874
8405
  // suite can drive Authorization: Basic authentication (RFC 6749 §2.3.1).
@@ -7880,9 +8411,20 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
7880
8411
  responseTypes: ['code'],
7881
8412
  grantTypes: ['authorization_code', 'refresh_token'],
7882
8413
  tokenEndpointAuthMethod: 'client_secret_basic',
7883
- offlineAccessAllowed: true,
7884
8414
  }],
7885
- ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}]);
8415
+ // RFC 7591 §2 の既定(grant_types = ["authorization_code"])そのままのクライアント。
8416
+ // Refresh Token を一切受け取れないこと、offline_access が付与 scope から落ちることを
8417
+ // 契約として固定するために置く。
8418
+ ['c-conf-no-refresh', {
8419
+ clientId: 'c-conf-no-refresh',
8420
+ clientSecret: 's',
8421
+ redirectUris: [REDIRECT_URI],
8422
+ clientType: 'confidential' as const,
8423
+ responseTypes: ['code'],
8424
+ grantTypes: ['authorization_code'],
8425
+ tokenEndpointAuthMethod: 'client_secret_post',
8426
+ }],
8427
+ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}${idJagConformanceClients(features)}]);
7886
8428
  `;
7887
8429
  }
7888
8430
  export function scopesSupportedConformanceTest(features) {
@@ -8531,6 +9073,148 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
8531
9073
 
8532
9074
  `;
8533
9075
  }
9076
+ export function internalRedirectOriginConformanceBlock() {
9077
+ return `
9078
+ describe('Internal redirect origin (OIDC Discovery 1.0 §3 / RFC 9700 §2.1)', () => {
9079
+ // RFC 7636 Appendix B example PKCE challenge.
9080
+ const REDIRECT_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
9081
+
9082
+ function issuerAuthorizeUrl(origin: string, overrides: Record<string, string> = {}): string {
9083
+ return origin + '/authorize?' + new URLSearchParams({
9084
+ response_type: 'code',
9085
+ client_id: 'c-conf',
9086
+ redirect_uri: REDIRECT_URI,
9087
+ scope: 'openid',
9088
+ state: 'redirect-origin',
9089
+ code_challenge: REDIRECT_PKCE_CHALLENGE,
9090
+ code_challenge_method: 'S256',
9091
+ ...overrides,
9092
+ }).toString();
9093
+ }
9094
+
9095
+ function redirectOriginCsrf(html: string): string {
9096
+ return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
9097
+ }
9098
+
9099
+ function redirectOriginCookie(res: Response): string {
9100
+ return (res.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
9101
+ }
9102
+
9103
+ // Drives authorize -> login POST from an attacker origin and returns each
9104
+ // Location plus the session cookie login handed out. The transaction cookie
9105
+ // is carried forward exactly as a browser would, so this works with or
9106
+ // without --enable transaction-binding. Pure fetch-and-parse: every check
9107
+ // stays in the it() blocks as an expect().
9108
+ async function loginFromOrigin(origin: string): Promise<{
9109
+ loginRedirect: string;
9110
+ consentRedirect: string;
9111
+ sessionCookie: string;
9112
+ }> {
9113
+ const authorizeRes = await app.request(issuerAuthorizeUrl(origin), {
9114
+ headers: { Host: 'attacker.example' },
9115
+ });
9116
+ const loginRedirect = authorizeRes.headers.get('Location') ?? '';
9117
+ const bindingCookie = redirectOriginCookie(authorizeRes);
9118
+ const loginUrl = new URL(loginRedirect, 'http://localhost');
9119
+ const transactionId = loginUrl.searchParams.get('transaction_id') ?? '';
9120
+
9121
+ const loginGet = await app.request(origin + loginUrl.pathname + loginUrl.search, {
9122
+ headers: { Cookie: bindingCookie },
9123
+ });
9124
+ const loginRes = await app.request(origin + '/login', {
9125
+ method: 'POST',
9126
+ headers: {
9127
+ 'Content-Type': 'application/x-www-form-urlencoded',
9128
+ Cookie: bindingCookie,
9129
+ Host: 'attacker.example',
9130
+ },
9131
+ body: new URLSearchParams({
9132
+ transaction_id: transactionId,
9133
+ csrf_token: redirectOriginCsrf(await loginGet.text()),
9134
+ username: 'testuser',
9135
+ password: 'password',
9136
+ }).toString(),
9137
+ });
9138
+
9139
+ return {
9140
+ loginRedirect,
9141
+ consentRedirect: loginRes.headers.get('Location') ?? '',
9142
+ sessionCookie: redirectOriginCookie(loginRes),
9143
+ };
9144
+ }
9145
+
9146
+ it('should build the login redirect Location on the configured issuer origin', async () => {
9147
+ const res = await app.request(issuerAuthorizeUrl('http://localhost:3000'));
9148
+ const location = new URL(res.headers.get('Location') ?? '');
9149
+
9150
+ expect(res.status).toBe(302);
9151
+ expect(location.origin).toBe('http://localhost:3000');
9152
+ expect(location.pathname).toBe('/login');
9153
+ expect(location.searchParams.has('transaction_id')).toBe(true);
9154
+ });
9155
+
9156
+ it('should ignore the Host header when building the login redirect Location', async () => {
9157
+ // Runtimes such as @hono/node-server build the request URL from the Host
9158
+ // header, so an attacker-controlled Host arrives here as an attacker-origin
9159
+ // request URL. Both are sent; neither may reach the Location.
9160
+ const res = await app.request(issuerAuthorizeUrl('http://attacker.example'), {
9161
+ headers: { Host: 'attacker.example' },
9162
+ });
9163
+ const location = new URL(res.headers.get('Location') ?? '');
9164
+
9165
+ expect(res.status).toBe(302);
9166
+ expect(location.origin).toBe('http://localhost:3000');
9167
+ expect(location.pathname).toBe('/login');
9168
+ });
9169
+
9170
+ it('should build the consent redirect Location on the configured issuer origin', async () => {
9171
+ // SSO path: an established OP session makes /authorize redirect straight
9172
+ // to /consent (OIDC Core 1.0 §3.1.2.3). prompt=consent forces the consent
9173
+ // screen (OIDC Core 1.0 §3.1.2.1), so this stays on the /consent redirect
9174
+ // even when another test already recorded a consent grant in the shared
9175
+ // store. The attacker origin on this second request must not leak into
9176
+ // that Location either.
9177
+ const first = await loginFromOrigin('http://attacker.example');
9178
+ const res = await app.request(
9179
+ issuerAuthorizeUrl('http://attacker.example', { prompt: 'consent' }),
9180
+ { headers: { Cookie: first.sessionCookie, Host: 'attacker.example' } },
9181
+ );
9182
+ const location = new URL(res.headers.get('Location') ?? '');
9183
+
9184
+ expect(res.status).toBe(302);
9185
+ expect(location.origin).toBe('http://localhost:3000');
9186
+ expect(location.pathname).toBe('/consent');
9187
+ });
9188
+
9189
+ it('should build the consent redirect Location on the configured issuer origin after login', async () => {
9190
+ const flow = await loginFromOrigin('http://attacker.example');
9191
+ const location = new URL(flow.consentRedirect);
9192
+
9193
+ expect(new URL(flow.loginRedirect).origin).toBe('http://localhost:3000');
9194
+ expect(location.origin).toBe('http://localhost:3000');
9195
+ expect(location.pathname).toBe('/consent');
9196
+ });
9197
+
9198
+ it('should keep the login redirect Location on the issuer origin for a subpath issuer', async () => {
9199
+ // '/login' is an absolute path, so a subpath issuer contributes only its
9200
+ // origin — the same result the express/fastify/nextjs adapters produce
9201
+ // when they rebase request URLs onto the issuer. Subpath mounting of the
9202
+ // generated routes is a separate, unsupported concern.
9203
+ const subpathApp = createApp({
9204
+ signingKeyProvider,
9205
+ clientResolver: createInMemoryClientResolver(testClients),
9206
+ config: { issuer: 'https://op.example.com/op' },
9207
+ });
9208
+ const res = await subpathApp.request(issuerAuthorizeUrl('https://op.example.com'));
9209
+ const location = new URL(res.headers.get('Location') ?? '');
9210
+
9211
+ expect(res.status).toBe(302);
9212
+ expect(location.origin).toBe('https://op.example.com');
9213
+ expect(location.pathname).toBe('/login');
9214
+ });
9215
+ });
9216
+ `;
9217
+ }
8534
9218
  export function endpointBehaviorConformanceBlock(features, includeHonoApplyParity = false) {
8535
9219
  const introspectionMethodTest = features.introspection
8536
9220
  ? `
@@ -9104,9 +9788,44 @@ export function consentWithdrawalConformanceBlock(features) {
9104
9788
  });
9105
9789
  `;
9106
9790
  }
9107
- export function persistentStorageConformanceBlock() {
9108
- return ` describe('Persistent storage contract', () => {
9109
- it('should share state across provider store instances backed by the same backend', async () => {
9791
+ export function onlineRefreshTokenConformanceStoreImport(features) {
9792
+ return features.refreshToken ? ' parseSessionId,' : '';
9793
+ }
9794
+ export function onlineRefreshTokenConformanceBlock(features) {
9795
+ if (!features.refreshToken)
9796
+ return '';
9797
+ return `
9798
+ // OIDC Core 1.0 §11 は offline_access を「End-User が居ない(not logged in)ときにも
9799
+ // 使える Refresh Token を要求する scope」と定義し、Refresh Token の利用がその用途に
9800
+ // 限られないことも明示している("The use of Refresh Tokens is not exclusive to the
9801
+ // offline_access use case. The Authorization Server MAY grant Refresh Tokens in other
9802
+ // contexts that are beyond the scope of this specification.")。
9803
+ //
9804
+ // この生成 OP はその other contexts を online refresh token として実装する。何が
9805
+ // 発行されるかは次の 2 つで決まる。
9806
+ //
9807
+ // | grant_types に refresh_token | offline_access の付与 | 発行される Refresh Token |
9808
+ // |---|---|---|
9809
+ // | 無し | - | 発行しない(使えない長期資格情報を配らない)|
9810
+ // | 有り | 無し | online: ログインセッションに束縛。セッションが終われば invalid_grant |
9811
+ // | 有り | 有り | offline: セッション非依存。ログアウト後も使える |
9812
+ describe('Online and offline refresh tokens (OIDC Core 1.0 §11)', () => {
9813
+ // RFC 7636 Appendix B example PKCE pair (verifier -> its S256 challenge).
9814
+ const PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
9815
+ const PKCE_CHALLENGE_S256 = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
9816
+
9817
+ function relativeFrom(location: string | null): string {
9818
+ const url = new URL(location ?? '', 'http://localhost');
9819
+ return url.pathname + url.search;
9820
+ }
9821
+
9822
+ function csrfFrom(html: string): string {
9823
+ return /name="csrf_token" value="([^"]+)"/.exec(html)?.[1] ?? '';
9824
+ }
9825
+
9826
+ // 各テストが自分だけのストアを持つ provider を作る。ブラウザセッションを直接消せる
9827
+ // ので、「ログアウトしたら online refresh token が止まる」を実フロー越しに固定できる。
9828
+ function createIsolatedProvider() {
9110
9829
  const values = new Map<string, unknown>();
9111
9830
  const backend: JsonStoreBackend = {
9112
9831
  async get<T>(key: string): Promise<T | null> {
@@ -9124,10 +9843,273 @@ export function persistentStorageConformanceBlock() {
9124
9843
  .map(([key, value]) => ({ key, value: value as T }));
9125
9844
  },
9126
9845
  };
9127
- const writerStores = createJsonProviderStores(backend);
9128
- await writerStores.authSessionStore.set('persistent-transaction', {
9129
- subject: 'testuser',
9130
- authTime: 1700000000,
9846
+ const stores = createJsonProviderStores(backend);
9847
+ const provider = createApp({
9848
+ signingKeyProvider,
9849
+ clientResolver: createInMemoryClientResolver(testClients),
9850
+ storage: stores,
9851
+ });
9852
+ return { provider, stores };
9853
+ }
9854
+
9855
+ // authorize -> login -> consent を実際に往復し、認可コードと、そのログインで確立した
9856
+ // セッション id を返す。sessionId はログアウトを再現するために使う。
9857
+ async function authorize(
9858
+ provider: ReturnType<typeof createApp>,
9859
+ options: { clientId: string; scope: string; prompt?: string },
9860
+ ): Promise<{ code: string; sessionId: string }> {
9861
+ const authorizeUrl =
9862
+ '/authorize?response_type=code&client_id=' + options.clientId +
9863
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
9864
+ '&scope=' + encodeURIComponent(options.scope) +
9865
+ '&state=online-rt' +
9866
+ (options.prompt === undefined ? '' : '&prompt=' + options.prompt) +
9867
+ '&code_challenge=' + PKCE_CHALLENGE_S256 + '&code_challenge_method=S256';
9868
+
9869
+ const authorizeRes = await provider.request(authorizeUrl);
9870
+ const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
9871
+ // Carry forward whatever cookie /authorize set, exactly as a browser would
9872
+ // (the per-transaction binding secret when that feature is enabled).
9873
+ const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
9874
+ const transactionId =
9875
+ new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
9876
+
9877
+ const loginGet = await provider.request(loginPath, { headers: { Cookie: bindingCookie } });
9878
+ const loginRes = await provider.request('/login', {
9879
+ method: 'POST',
9880
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
9881
+ body: new URLSearchParams({
9882
+ transaction_id: transactionId,
9883
+ csrf_token: csrfFrom(await loginGet.text()),
9884
+ username: 'testuser',
9885
+ password: 'password',
9886
+ }).toString(),
9887
+ });
9888
+ // /login sets exactly one cookie: the browser (OP) session. Its value is the
9889
+ // session an online refresh token gets bound to.
9890
+ const sessionId = parseSessionId(loginRes.headers.get('Set-Cookie')) ?? '';
9891
+
9892
+ const consentPath = relativeFrom(loginRes.headers.get('Location'));
9893
+ const consentGet = await provider.request(consentPath, { headers: { Cookie: bindingCookie } });
9894
+ const consentRes = await provider.request('/consent', {
9895
+ method: 'POST',
9896
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
9897
+ body: new URLSearchParams({
9898
+ transaction_id: transactionId,
9899
+ csrf_token: csrfFrom(await consentGet.text()),
9900
+ action: 'approve',
9901
+ }).toString(),
9902
+ });
9903
+ const callback = new URL(consentRes.headers.get('Location') ?? '', 'http://localhost');
9904
+
9905
+ return { code: callback.searchParams.get('code') ?? '', sessionId };
9906
+ }
9907
+
9908
+ async function exchangeCode(
9909
+ provider: ReturnType<typeof createApp>,
9910
+ clientId: string,
9911
+ code: string,
9912
+ ): Promise<Response> {
9913
+ return provider.request('/token', {
9914
+ method: 'POST',
9915
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9916
+ body: new URLSearchParams({
9917
+ grant_type: 'authorization_code',
9918
+ code,
9919
+ redirect_uri: REDIRECT_URI,
9920
+ code_verifier: PKCE_VERIFIER,
9921
+ client_id: clientId,
9922
+ client_secret: 's',
9923
+ }).toString(),
9924
+ });
9925
+ }
9926
+
9927
+ async function refresh(
9928
+ provider: ReturnType<typeof createApp>,
9929
+ clientId: string,
9930
+ refreshToken: string,
9931
+ ): Promise<Response> {
9932
+ return provider.request('/token', {
9933
+ method: 'POST',
9934
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9935
+ body: new URLSearchParams({
9936
+ grant_type: 'refresh_token',
9937
+ refresh_token: refreshToken,
9938
+ client_id: clientId,
9939
+ client_secret: 's',
9940
+ }).toString(),
9941
+ });
9942
+ }
9943
+
9944
+ it('should issue a refresh token without offline_access when the client registers the refresh_token grant', async () => {
9945
+ const { provider } = createIsolatedProvider();
9946
+ const { code } = await authorize(provider, { clientId: 'c-conf', scope: 'openid' });
9947
+
9948
+ const res = await exchangeCode(provider, 'c-conf', code);
9949
+ const body = await res.json();
9950
+
9951
+ expect(res.status).toBe(200);
9952
+ expect(typeof body.refresh_token).toBe('string');
9953
+ // offline_access は要求していないので付与 scope にも入らない。
9954
+ expect(body.scope).toBe('openid');
9955
+ });
9956
+
9957
+ it('should keep the online refresh token usable while the login session is alive', async () => {
9958
+ const { provider } = createIsolatedProvider();
9959
+ const { code } = await authorize(provider, { clientId: 'c-conf', scope: 'openid' });
9960
+ const issued = await (await exchangeCode(provider, 'c-conf', code)).json();
9961
+
9962
+ const res = await refresh(provider, 'c-conf', issued.refresh_token as string);
9963
+ const body = await res.json();
9964
+
9965
+ expect(res.status).toBe(200);
9966
+ expect(body.scope).toBe('openid');
9967
+ });
9968
+
9969
+ it('should reject the online refresh token after the login session ended', async () => {
9970
+ const { provider, stores } = createIsolatedProvider();
9971
+ const { code, sessionId } = await authorize(provider, { clientId: 'c-conf', scope: 'openid' });
9972
+ const issued = await (await exchangeCode(provider, 'c-conf', code)).json();
9973
+
9974
+ // ログアウト相当: ブラウザ (OP) セッションを終了させる。
9975
+ await stores.browserSessionStore.delete(sessionId);
9976
+
9977
+ const res = await refresh(provider, 'c-conf', issued.refresh_token as string);
9978
+ const body = await res.json();
9979
+
9980
+ expect(res.status).toBe(400);
9981
+ expect(body.error).toBe('invalid_grant');
9982
+ });
9983
+
9984
+ it('should keep the online refresh token bound to the session across rotation', async () => {
9985
+ const { provider, stores } = createIsolatedProvider();
9986
+ const { code, sessionId } = await authorize(provider, { clientId: 'c-conf', scope: 'openid' });
9987
+ const issued = await (await exchangeCode(provider, 'c-conf', code)).json();
9988
+
9989
+ // 1 回ローテーションしても束縛は外れない(外れると 1 リフレッシュで offline 化する)。
9990
+ const rotated = await (await refresh(provider, 'c-conf', issued.refresh_token as string)).json();
9991
+ await stores.browserSessionStore.delete(sessionId);
9992
+
9993
+ const res = await refresh(provider, 'c-conf', rotated.refresh_token as string);
9994
+ const body = await res.json();
9995
+
9996
+ expect(res.status).toBe(400);
9997
+ expect(body.error).toBe('invalid_grant');
9998
+ });
9999
+
10000
+ it('should keep the offline refresh token usable after the login session ended', async () => {
10001
+ const { provider, stores } = createIsolatedProvider();
10002
+ // OIDC Core 1.0 §11: offline_access needs prompt=consent.
10003
+ const { code, sessionId } = await authorize(provider, {
10004
+ clientId: 'c-conf',
10005
+ scope: 'openid offline_access',
10006
+ prompt: 'consent',
10007
+ });
10008
+ const issued = await (await exchangeCode(provider, 'c-conf', code)).json();
10009
+
10010
+ await stores.browserSessionStore.delete(sessionId);
10011
+
10012
+ const res = await refresh(provider, 'c-conf', issued.refresh_token as string);
10013
+ const body = await res.json();
10014
+
10015
+ expect(res.status).toBe(200);
10016
+ expect(body.scope).toBe('openid offline_access');
10017
+ });
10018
+
10019
+ it('should not issue a refresh token to a client that does not register the refresh_token grant', async () => {
10020
+ // RFC 7591 §2: grant_types の既定は ["authorization_code"]。発行しても
10021
+ // unauthorized_client で拒否されるだけの Refresh Token は配らない。
10022
+ const { provider } = createIsolatedProvider();
10023
+ const { code } = await authorize(provider, { clientId: 'c-conf-no-refresh', scope: 'openid' });
10024
+
10025
+ const res = await exchangeCode(provider, 'c-conf-no-refresh', code);
10026
+ const body = await res.json();
10027
+
10028
+ expect(res.status).toBe(200);
10029
+ expect(body.refresh_token).toBe(undefined);
10030
+ });
10031
+
10032
+ it('should drop offline_access for a client that does not register the refresh_token grant', async () => {
10033
+ const { provider } = createIsolatedProvider();
10034
+ const { code } = await authorize(provider, {
10035
+ clientId: 'c-conf-no-refresh',
10036
+ scope: 'openid offline_access',
10037
+ prompt: 'consent',
10038
+ });
10039
+
10040
+ const res = await exchangeCode(provider, 'c-conf-no-refresh', code);
10041
+ const body = await res.json();
10042
+
10043
+ expect(res.status).toBe(200);
10044
+ expect(body.scope).toBe('openid');
10045
+ expect(body.refresh_token).toBe(undefined);
10046
+ });
10047
+
10048
+ it('should issue only offline refresh tokens when onlineRefreshTokenEnabled is false', async () => {
10049
+ const values = new Map<string, unknown>();
10050
+ const backend: JsonStoreBackend = {
10051
+ async get<T>(key: string): Promise<T | null> {
10052
+ return (values.get(key) as T | undefined) ?? null;
10053
+ },
10054
+ async put<T>(key: string, value: T): Promise<void> {
10055
+ values.set(key, value);
10056
+ },
10057
+ async delete(key: string): Promise<void> {
10058
+ values.delete(key);
10059
+ },
10060
+ async list<T>(prefix: string): Promise<Array<{ key: string; value: T }>> {
10061
+ return [...values.entries()]
10062
+ .filter(([key]) => key.startsWith(prefix))
10063
+ .map(([key, value]) => ({ key, value: value as T }));
10064
+ },
10065
+ };
10066
+ const provider = createApp({
10067
+ signingKeyProvider,
10068
+ clientResolver: createInMemoryClientResolver(testClients),
10069
+ storage: createJsonProviderStores(backend),
10070
+ config: { onlineRefreshTokenEnabled: false },
10071
+ });
10072
+
10073
+ const online = await authorize(provider, { clientId: 'c-conf', scope: 'openid' });
10074
+ const onlineBody = await (await exchangeCode(provider, 'c-conf', online.code)).json();
10075
+ expect(onlineBody.refresh_token).toBe(undefined);
10076
+
10077
+ const offline = await authorize(provider, {
10078
+ clientId: 'c-conf',
10079
+ scope: 'openid offline_access',
10080
+ prompt: 'consent',
10081
+ });
10082
+ const offlineBody = await (await exchangeCode(provider, 'c-conf', offline.code)).json();
10083
+ expect(typeof offlineBody.refresh_token).toBe('string');
10084
+ });
10085
+ });
10086
+
10087
+ `;
10088
+ }
10089
+ export function persistentStorageConformanceBlock() {
10090
+ return ` describe('Persistent storage contract', () => {
10091
+ it('should share state across provider store instances backed by the same backend', async () => {
10092
+ const values = new Map<string, unknown>();
10093
+ const backend: JsonStoreBackend = {
10094
+ async get<T>(key: string): Promise<T | null> {
10095
+ return (values.get(key) as T | undefined) ?? null;
10096
+ },
10097
+ async put<T>(key: string, value: T): Promise<void> {
10098
+ values.set(key, value);
10099
+ },
10100
+ async delete(key: string): Promise<void> {
10101
+ values.delete(key);
10102
+ },
10103
+ async list<T>(prefix: string): Promise<Array<{ key: string; value: T }>> {
10104
+ return [...values.entries()]
10105
+ .filter(([key]) => key.startsWith(prefix))
10106
+ .map(([key, value]) => ({ key, value: value as T }));
10107
+ },
10108
+ };
10109
+ const writerStores = createJsonProviderStores(backend);
10110
+ await writerStores.authSessionStore.set('persistent-transaction', {
10111
+ subject: 'testuser',
10112
+ authTime: 1700000000,
9131
10113
  });
9132
10114
 
9133
10115
  const readerStores = createJsonProviderStores(backend);
@@ -9532,302 +10514,1271 @@ export function tokenExchangeConformanceBlock(features) {
9532
10514
  actor_token_type: ACCESS_TOKEN_TYPE,
9533
10515
  });
9534
10516
 
9535
- expect(res.status).toBe(400);
9536
- expect(await res.json()).toEqual({
9537
- error: 'invalid_request',
9538
- error_description: 'actor_token_type must not be present without actor_token',
9539
- });
10517
+ expect(res.status).toBe(400);
10518
+ expect(await res.json()).toEqual({
10519
+ error: 'invalid_request',
10520
+ error_description: 'actor_token_type must not be present without actor_token',
10521
+ });
10522
+ });
10523
+
10524
+ it('should reject an unsupported actor_token_type with invalid_request', async () => {
10525
+ const subjectToken = await subjectTokenFor('openid');
10526
+ const res = await exchangeRequest({
10527
+ subject_token: subjectToken,
10528
+ actor_token: subjectToken,
10529
+ actor_token_type: 'urn:ietf:params:oauth:token-type:id_token',
10530
+ });
10531
+
10532
+ expect(res.status).toBe(400);
10533
+ expect(await res.json()).toEqual({
10534
+ error: 'invalid_request',
10535
+ error_description:
10536
+ 'Unsupported actor_token_type. Only urn:ietf:params:oauth:token-type:access_token is supported.',
10537
+ });
10538
+ });
10539
+
10540
+ // The actor_token failure description is fixed for the same oracle-
10541
+ // elimination reason as the subject_token one.
10542
+ it('should reject an unknown actor_token with the fixed description', async () => {
10543
+ const subjectToken = await subjectTokenFor('openid');
10544
+ const res = await exchangeRequest({
10545
+ subject_token: subjectToken,
10546
+ actor_token: 'not-a-real-token',
10547
+ actor_token_type: ACCESS_TOKEN_TYPE,
10548
+ });
10549
+
10550
+ expect(res.status).toBe(400);
10551
+ expect(await res.json()).toEqual({
10552
+ error: 'invalid_request',
10553
+ error_description: ACTOR_INVALID_DESCRIPTION,
10554
+ });
10555
+ });
10556
+
10557
+ // RFC 8693 §2.1: resource MUST be an absolute URI without a fragment.
10558
+ it('should reject a relative resource with invalid_request', async () => {
10559
+ const subjectToken = await subjectTokenFor('openid');
10560
+ const res = await exchangeRequest({ subject_token: subjectToken, resource: '/api' });
10561
+
10562
+ expect(res.status).toBe(400);
10563
+ expect(await res.json()).toEqual({
10564
+ error: 'invalid_request',
10565
+ error_description: 'resource must be an absolute URI without a fragment component',
10566
+ });
10567
+ });
10568
+
10569
+ it('should reject a resource carrying a fragment with invalid_request', async () => {
10570
+ const subjectToken = await subjectTokenFor('openid');
10571
+ const res = await exchangeRequest({
10572
+ subject_token: subjectToken,
10573
+ resource: 'https://api.example.com/x#frag',
10574
+ });
10575
+
10576
+ expect(res.status).toBe(400);
10577
+ expect(await res.json()).toEqual({
10578
+ error: 'invalid_request',
10579
+ error_description: 'resource must be an absolute URI without a fragment component',
10580
+ });
10581
+ });
10582
+
10583
+ // RFC 6749 §3.2: repeated token endpoint parameters are refused, which is
10584
+ // why this OP supports only a single audience / resource value.
10585
+ it('should reject a repeated resource parameter', async () => {
10586
+ const subjectToken = await subjectTokenFor('openid');
10587
+ const res = await app.request('/token', {
10588
+ method: 'POST',
10589
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
10590
+ body:
10591
+ 'client_id=c-exchange&client_secret=s&grant_type=' +
10592
+ encodeURIComponent(EXCHANGE_GRANT_TYPE) +
10593
+ '&subject_token=' + encodeURIComponent(subjectToken) +
10594
+ '&subject_token_type=' + encodeURIComponent(ACCESS_TOKEN_TYPE) +
10595
+ '&resource=https%3A%2F%2Fa.example.com&resource=https%3A%2F%2Fb.example.com',
10596
+ });
10597
+
10598
+ expect(res.status).toBe(400);
10599
+ expect(await res.json()).toEqual({
10600
+ error: 'invalid_request',
10601
+ error_description: 'Parameter "resource" must not be repeated',
10602
+ });
10603
+ });
10604
+
10605
+ // RFC 8693 §2.2.2 sends invalid subject tokens to invalid_request, NOT to
10606
+ // invalid_grant as the authorization_code / refresh_token grants would.
10607
+ it('should reject an unknown subject_token with invalid_request', async () => {
10608
+ const res = await exchangeRequest({ subject_token: 'not-a-real-token' });
10609
+
10610
+ expect(res.status).toBe(400);
10611
+ expect(await res.json()).toEqual({
10612
+ error: 'invalid_request',
10613
+ error_description: SUBJECT_INVALID_DESCRIPTION,
10614
+ });
10615
+ });
10616
+
10617
+ it('should report a revoked subject_token exactly like an unknown one', async () => {
10618
+ const subjectToken = await subjectTokenFor('openid');
10619
+ await app.request('/revoke', {
10620
+ method: 'POST',
10621
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
10622
+ body: new URLSearchParams({
10623
+ client_id: 'c-exchange',
10624
+ client_secret: 's',
10625
+ token: subjectToken,
10626
+ }).toString(),
10627
+ });
10628
+ const revoked = await exchangeRequest({ subject_token: subjectToken });
10629
+ const unknown = await exchangeRequest({ subject_token: 'not-a-real-token' });
10630
+
10631
+ expect(revoked.status).toBe(400);
10632
+ expect(await revoked.json()).toEqual(await unknown.json());
10633
+ });
10634
+ });
10635
+
10636
+ describe('Scope narrowing', () => {
10637
+ it('should reject a scope that exceeds the subject token scope', async () => {
10638
+ const subjectToken = await subjectTokenFor('openid');
10639
+ const res = await exchangeRequest({ subject_token: subjectToken, scope: 'openid profile' });
10640
+
10641
+ expect(res.status).toBe(400);
10642
+ expect(await res.json()).toEqual({
10643
+ error: 'invalid_scope',
10644
+ error_description: 'The requested scope exceeds the scope of the subject_token',
10645
+ });
10646
+ });
10647
+
10648
+ it('should grant exactly the requested subset', async () => {
10649
+ const subjectToken = await subjectTokenFor('openid profile email');
10650
+ const res = await exchangeRequest({ subject_token: subjectToken, scope: 'email' });
10651
+
10652
+ expect(res.status).toBe(200);
10653
+ expect((await res.json()).scope).toBe('email');
10654
+ });
10655
+ });
10656
+
10657
+ describe('Delegation (RFC 8693 §4.1)', () => {
10658
+ // sub stays the subject; the actor appears only in the act claim.
10659
+ it('should record the actor in the act claim of the issued token', async () => {
10660
+ const subjectToken = await subjectTokenFor('openid profile');
10661
+ const actorToken = await actorTokenFor('openid');
10662
+ const res = await exchangeRequest({
10663
+ subject_token: subjectToken,
10664
+ actor_token: actorToken,
10665
+ actor_token_type: ACCESS_TOKEN_TYPE,
10666
+ });
10667
+ const body = await res.json();
10668
+ const payload = decodeJwtPayload(body.access_token as string);
10669
+
10670
+ expect(res.status).toBe(200);
10671
+ expect(payload.sub).toBe('testuser');
10672
+ expect(payload.act).toEqual({ sub: 'otheruser' });
10673
+ });
10674
+
10675
+ it('should not add an act claim to an impersonation exchange', async () => {
10676
+ const subjectToken = await subjectTokenFor('openid');
10677
+ const body = await (await exchangeRequest({ subject_token: subjectToken })).json();
10678
+ const payload = decodeJwtPayload(body.access_token as string);
10679
+
10680
+ expect(payload.act).toBe(undefined);
10681
+ });
10682
+
10683
+ // RFC 8693 §4.1: exchanging a delegated token again pushes the prior
10684
+ // actor one level down; the outermost act names the current actor.
10685
+ it('should nest the prior actor when a delegated token is exchanged again', async () => {
10686
+ const subjectToken = await subjectTokenFor('openid');
10687
+ const firstActor = await actorTokenFor('openid');
10688
+ const delegated = (await (
10689
+ await exchangeRequest({
10690
+ subject_token: subjectToken,
10691
+ actor_token: firstActor,
10692
+ actor_token_type: ACCESS_TOKEN_TYPE,
10693
+ })
10694
+ ).json()).access_token as string;
10695
+ const secondActor = await actorTokenFor('openid');
10696
+ const res = await exchangeRequest({
10697
+ subject_token: delegated,
10698
+ actor_token: secondActor,
10699
+ actor_token_type: ACCESS_TOKEN_TYPE,
10700
+ });
10701
+ const payload = decodeJwtPayload((await res.json()).access_token as string);
10702
+
10703
+ expect(res.status).toBe(200);
10704
+ expect(payload.act).toEqual({ sub: 'otheruser', act: { sub: 'otheruser' } });
10705
+ });
10706
+
10707
+ // A delegated token is an ordinary access token of the subject: the
10708
+ // UserInfo endpoint answers for the subject, not the actor.
10709
+ it('should answer UserInfo for the subject of a delegated token', async () => {
10710
+ const subjectToken = await subjectTokenFor('openid profile');
10711
+ const actorToken = await actorTokenFor('openid');
10712
+ const delegated = (await (
10713
+ await exchangeRequest({
10714
+ subject_token: subjectToken,
10715
+ actor_token: actorToken,
10716
+ actor_token_type: ACCESS_TOKEN_TYPE,
10717
+ })
10718
+ ).json()).access_token as string;
10719
+ const res = await app.request('/userinfo', {
10720
+ headers: { Authorization: 'Bearer ' + delegated },
10721
+ });
10722
+
10723
+ expect(res.status).toBe(200);
10724
+ expect((await res.json()).sub).toBe('testuser');
10725
+ });
10726
+ });
10727
+
10728
+ describe('Target policy (allowedTargets)', () => {
10729
+ // The generated default is an empty list, so any named target is refused
10730
+ // until the operator opts in. The list is restored after each test.
10731
+ it('should reject an audience that is not in allowedTargets', async () => {
10732
+ const subjectToken = await subjectTokenFor('openid');
10733
+ const res = await exchangeRequest({
10734
+ subject_token: subjectToken,
10735
+ audience: 'https://internal.example.com',
10736
+ });
10737
+
10738
+ expect(res.status).toBe(400);
10739
+ expect(await res.json()).toEqual({
10740
+ error: 'invalid_target',
10741
+ error_description: TARGET_REJECTED_DESCRIPTION,
10742
+ });
10743
+ });
10744
+
10745
+ it('should reject a resource that is not in allowedTargets', async () => {
10746
+ const subjectToken = await subjectTokenFor('openid');
10747
+ const res = await exchangeRequest({
10748
+ subject_token: subjectToken,
10749
+ resource: 'https://internal.example.com/api',
10750
+ });
10751
+
10752
+ expect(res.status).toBe(400);
10753
+ expect(await res.json()).toEqual({
10754
+ error: 'invalid_target',
10755
+ error_description: TARGET_REJECTED_DESCRIPTION,
10756
+ });
10757
+ });
10758
+
10759
+ it('should issue a token for an allowed audience', async () => {
10760
+ const subjectToken = await subjectTokenFor('openid');
10761
+ tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
10762
+ const res = await exchangeRequest({
10763
+ subject_token: subjectToken,
10764
+ audience: 'https://internal.example.com',
10765
+ });
10766
+ const body = await res.json();
10767
+ tokenExchangeConfig.allowedTargets = [];
10768
+
10769
+ expect(res.status).toBe(200);
10770
+ expect(body.token_type).toBe('Bearer');
10771
+ });
10772
+
10773
+ // The UserInfo endpoint stays a permanent aud member (RFC 9068 §3), so an
10774
+ // exchanged token keeps working against this OP as well as the new target.
10775
+ it('should add the allowed audience alongside the UserInfo endpoint', async () => {
10776
+ const subjectToken = await subjectTokenFor('openid');
10777
+ tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
10778
+ const exchanged = (await (
10779
+ await exchangeRequest({
10780
+ subject_token: subjectToken,
10781
+ audience: 'https://internal.example.com',
10782
+ })
10783
+ ).json()).access_token as string;
10784
+ tokenExchangeConfig.allowedTargets = [];
10785
+ const introspection = await (
10786
+ await app.request('/introspect', {
10787
+ method: 'POST',
10788
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
10789
+ body: new URLSearchParams({
10790
+ client_id: 'c-exchange',
10791
+ client_secret: 's',
10792
+ token: exchanged,
10793
+ }).toString(),
10794
+ })
10795
+ ).json();
10796
+
10797
+ expect(introspection.aud).toEqual([
10798
+ 'http://localhost:3000/userinfo',
10799
+ 'https://internal.example.com',
10800
+ ]);
10801
+ });
10802
+ });
10803
+
10804
+ describe('Discovery', () => {
10805
+ it('should advertise the exchange grant in grant_types_supported', async () => {
10806
+ const metadata = await (await app.request('/.well-known/openid-configuration')).json();
10807
+
10808
+ expect(metadata.grant_types_supported.includes(EXCHANGE_GRANT_TYPE)).toBe(true);
10809
+ });
10810
+ });
10811
+ });
10812
+
10813
+ `;
10814
+ }
10815
+ export function idJagConformanceBlock(features) {
10816
+ if (!features.idJag)
10817
+ return '';
10818
+ const unsupportedSubjectTypeMessage = features.refreshToken
10819
+ ? 'Unsupported subject_token_type for ID-JAG issuance. Only urn:ietf:params:oauth:token-type:id_token or urn:ietf:params:oauth:token-type:refresh_token is supported.'
10820
+ : 'Unsupported subject_token_type for ID-JAG issuance. Only urn:ietf:params:oauth:token-type:id_token is supported.';
10821
+ const refreshSubjectContract = features.refreshToken
10822
+ ? `
10823
+ // draft §4.3 MAY: a refresh token of this OP may stand in for the ID Token.
10824
+ it('should issue an ID-JAG from a refresh token subject', async () => {
10825
+ const tokens = await xaaCodeFlowTokens('c-idjag');
10826
+ const res = await withIssuanceAudience(() =>
10827
+ issuanceRequest({
10828
+ subject_token: tokens.refresh_token,
10829
+ subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token',
10830
+ }),
10831
+ );
10832
+ const body = (await res.json()) as Record<string, unknown>;
10833
+
10834
+ expect(res.status).toBe(200);
10835
+ expect(body.token_type).toBe('N_A');
10836
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
10837
+ // The subject claims come from the refresh token's stored grant context.
10838
+ expect(claims.iss).toBe(XAA_OWN_ISSUER);
10839
+ expect(claims.sub).toBe('testuser');
10840
+ expect(claims.aud).toBe(XAA_PEER_AS_ISSUER);
10841
+ expect(typeof claims.auth_time).toBe('number');
10842
+ });
10843
+
10844
+ it('should not consume the refresh token when issuing an ID-JAG', async () => {
10845
+ // The exchange is not the refresh grant: no rotation happens, so the
10846
+ // same refresh token mints a second ID-JAG (draft §4.4.3's renewal path).
10847
+ const tokens = await xaaCodeFlowTokens('c-idjag');
10848
+ const first = await withIssuanceAudience(() =>
10849
+ issuanceRequest({
10850
+ subject_token: tokens.refresh_token,
10851
+ subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token',
10852
+ }),
10853
+ );
10854
+ const second = await withIssuanceAudience(() =>
10855
+ issuanceRequest({
10856
+ subject_token: tokens.refresh_token,
10857
+ subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token',
10858
+ }),
10859
+ );
10860
+
10861
+ expect(first.status).toBe(200);
10862
+ expect(second.status).toBe(200);
10863
+ });
10864
+
10865
+ it('should reject a rotated refresh token subject with the fixed description', async () => {
10866
+ // OAuth 2.1 §4.3.1: presenting a rotated-out token is validated exactly
10867
+ // like the standard refresh grant would.
10868
+ const tokens = await xaaCodeFlowTokens('c-idjag');
10869
+ await postXaaToken({
10870
+ client_id: 'c-idjag',
10871
+ client_secret: 's',
10872
+ grant_type: 'refresh_token',
10873
+ refresh_token: tokens.refresh_token,
10874
+ });
10875
+
10876
+ const res = await withIssuanceAudience(() =>
10877
+ issuanceRequest({
10878
+ subject_token: tokens.refresh_token,
10879
+ subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token',
10880
+ }),
10881
+ );
10882
+
10883
+ expect(res.status).toBe(400);
10884
+ expect(await res.json()).toEqual({
10885
+ error: 'invalid_request',
10886
+ error_description: XAA_SUBJECT_INVALID_DESCRIPTION,
10887
+ });
10888
+ });
10889
+
10890
+ it('should reject a refresh token subject while allowRefreshTokenSubjects is off', async () => {
10891
+ const tokens = await xaaCodeFlowTokens('c-idjag');
10892
+ idJagConfig.allowRefreshTokenSubjects = false;
10893
+ try {
10894
+ const res = await withIssuanceAudience(() =>
10895
+ issuanceRequest({
10896
+ subject_token: tokens.refresh_token,
10897
+ subject_token_type: 'urn:ietf:params:oauth:token-type:refresh_token',
10898
+ }),
10899
+ );
10900
+
10901
+ expect(res.status).toBe(400);
10902
+ expect(await res.json()).toEqual({
10903
+ error: 'invalid_request',
10904
+ error_description:
10905
+ 'Unsupported subject_token_type for ID-JAG issuance. Only urn:ietf:params:oauth:token-type:id_token is supported.',
10906
+ });
10907
+ } finally {
10908
+ idJagConfig.allowRefreshTokenSubjects = true;
10909
+ }
10910
+ });
10911
+ `
10912
+ : '';
10913
+ const introspectionContract = features.introspection
10914
+ ? `
10915
+ it('should report a redeemed access token active with the ID-JAG subject and client', async () => {
10916
+ const assertion = await mintExternalIdJag({});
10917
+ const redeemed = await withTrustedIdp(() => redeemRequest({ assertion }));
10918
+ const redeemedBody = (await redeemed.json()) as Record<string, string>;
10919
+
10920
+ const res = await postXaaToken({}, '/introspect', {
10921
+ token: redeemedBody.access_token,
10922
+ client_id: 'c-idjag',
10923
+ client_secret: 's',
10924
+ });
10925
+ const body = (await res.json()) as Record<string, unknown>;
10926
+
10927
+ expect(res.status).toBe(200);
10928
+ expect(body.active).toBe(true);
10929
+ // draft §4.4.1: the ID-JAG sub becomes the local subject directly, and
10930
+ // the token is bound to the client that redeemed the grant.
10931
+ expect(body.sub).toBe('testuser');
10932
+ expect(body.client_id).toBe('c-idjag');
10933
+ expect(body.scope).toBe('openid profile');
10934
+ });
10935
+ `
10936
+ : '';
10937
+ return `
10938
+ // EXPERIMENTAL — Cross-App Access / ID-JAG
10939
+ // (draft-ietf-oauth-identity-assertion-authz-grant-04). Generated because this
10940
+ // provider was created with --enable id-jag. These tests pin the contract the
10941
+ // repository guarantees for both halves of XAA: issuing an ID-JAG on the
10942
+ // token-exchange grant (draft §4.3) and redeeming one on the jwt-bearer grant
10943
+ // (draft §4.4). Change the behavior and they fail, which is how a customized
10944
+ // OP learns it drifted.
10945
+ describe('Cross-App Access / ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant)', () => {
10946
+ // RFC 7636 Appendix B example PKCE pair (verifier -> its S256 challenge).
10947
+ const XAA_PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
10948
+ const XAA_PKCE_CHALLENGE_S256 = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
10949
+ const XAA_EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
10950
+ const XAA_JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
10951
+ const XAA_ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag';
10952
+ const XAA_ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
10953
+ // This OP's own issuer (createApp above runs on the default config).
10954
+ const XAA_OWN_ISSUER = 'http://localhost:3000';
10955
+ // The peer resource authorization server ID-JAGs are issued for.
10956
+ const XAA_PEER_AS_ISSUER = 'https://peer-as.conformance.example';
10957
+ // The fake external IdP whose signed ID-JAGs this OP redeems.
10958
+ const XAA_TRUSTED_IDP_ISSUER = 'https://trusted-idp.conformance.example';
10959
+ // Every unusable subject_token is rejected with this one description, and
10960
+ // an untrusted issuer is indistinguishable from a broken signature, so the
10961
+ // responses cannot be used as an existence / trust-list oracle.
10962
+ const XAA_SUBJECT_INVALID_DESCRIPTION = 'The provided subject_token is not valid';
10963
+ const XAA_ASSERTION_UNTRUSTED_DESCRIPTION =
10964
+ 'The assertion issuer is not trusted or the assertion signature is invalid';
10965
+
10966
+ let externalIdpPrivateKey: CryptoKey;
10967
+ let externalIdpJwk: Awaited<ReturnType<typeof exportPublicJwk>>;
10968
+
10969
+ beforeAll(async () => {
10970
+ // The fake external IdP: its public JWK is trust-listed inline by the
10971
+ // tests below, so no jwks_uri fetch happens inside this suite.
10972
+ const externalIdpKeyPair = await crypto.subtle.generateKey(
10973
+ { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
10974
+ true,
10975
+ ['sign', 'verify'],
10976
+ );
10977
+ externalIdpPrivateKey = externalIdpKeyPair.privateKey;
10978
+ externalIdpJwk = await exportPublicJwk(externalIdpKeyPair.publicKey, 'external-idp-key');
10979
+ });
10980
+
10981
+ // Pure helpers: they fetch, sign and parse only. Every assertion lives in an it().
10982
+ function xaaRelativeFrom(location: string | null): string {
10983
+ const url = new URL(location ?? '', 'http://localhost');
10984
+ return url.pathname + url.search;
10985
+ }
10986
+
10987
+ function xaaCsrfFrom(html: string): string {
10988
+ return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
10989
+ }
10990
+
10991
+ function xaaB64Url(bytes: Uint8Array): string {
10992
+ let binary = '';
10993
+ for (const byte of bytes) binary += String.fromCharCode(byte);
10994
+ return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
10995
+ }
10996
+
10997
+ function xaaB64UrlJson(value: Record<string, unknown>): string {
10998
+ return xaaB64Url(new TextEncoder().encode(JSON.stringify(value)));
10999
+ }
11000
+
11001
+ function xaaDecodeJwtSegment(segment: string): Record<string, unknown> {
11002
+ const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
11003
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
11004
+ return JSON.parse(atob(padded)) as Record<string, unknown>;
11005
+ }
11006
+
11007
+ function postXaaToken(
11008
+ fields: Record<string, string>,
11009
+ path = '/token',
11010
+ base: Record<string, string> = {},
11011
+ ): Promise<Response> {
11012
+ return app.request(path, {
11013
+ method: 'POST',
11014
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
11015
+ body: new URLSearchParams({ ...base, ...fields }).toString(),
11016
+ });
11017
+ }
11018
+
11019
+ // Drive authorize -> login -> consent over HTTP and hand back the code. No
11020
+ // assertions and no branching here: the flow contract lives in the it()s.
11021
+ async function xaaAuthorizeFlow(
11022
+ clientId: string,
11023
+ scope: string,
11024
+ username = 'testuser',
11025
+ ): Promise<string> {
11026
+ const authorizeUrl =
11027
+ '/authorize?response_type=code&client_id=' + clientId +
11028
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
11029
+ '&scope=' + encodeURIComponent(scope) +
11030
+ '&state=xaa-state&nonce=xaa-nonce' +
11031
+ '&code_challenge=' + XAA_PKCE_CHALLENGE_S256 + '&code_challenge_method=S256';
11032
+
11033
+ const authorizeRes = await app.request(authorizeUrl);
11034
+ const loginPath = xaaRelativeFrom(authorizeRes.headers.get('Location'));
11035
+ // Carry forward whatever cookie /authorize set, exactly as a browser would
11036
+ // (with --enable transaction-binding it is the binding secret).
11037
+ const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
11038
+ const transactionId =
11039
+ new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
11040
+
11041
+ const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
11042
+ const loginRes = await app.request('/login', {
11043
+ method: 'POST',
11044
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
11045
+ body: new URLSearchParams({
11046
+ transaction_id: transactionId,
11047
+ csrf_token: xaaCsrfFrom(await loginGet.text()),
11048
+ username,
11049
+ password: 'password',
11050
+ }).toString(),
11051
+ });
11052
+ const consentPath = xaaRelativeFrom(loginRes.headers.get('Location'));
11053
+
11054
+ const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
11055
+ const consentRes = await app.request('/consent', {
11056
+ method: 'POST',
11057
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
11058
+ body: new URLSearchParams({
11059
+ transaction_id: transactionId,
11060
+ csrf_token: xaaCsrfFrom(await consentGet.text()),
11061
+ action: 'approve',
11062
+ }).toString(),
11063
+ });
11064
+ const callback = new URL(consentRes.headers.get('Location') ?? '', 'http://localhost');
11065
+ return callback.searchParams.get('code') ?? '';
11066
+ }
11067
+
11068
+ // The identity assertion the issuance half consumes: an ID Token from the
11069
+ // ordinary Authorization Code Flow of the given client.
11070
+ async function xaaCodeFlowTokens(
11071
+ clientId: string,
11072
+ username = 'testuser',
11073
+ ): Promise<Record<string, string>> {
11074
+ const code = await xaaAuthorizeFlow(clientId, 'openid profile', username);
11075
+ const res = await postXaaToken({
11076
+ client_id: clientId,
11077
+ client_secret: 's',
11078
+ grant_type: 'authorization_code',
11079
+ code,
11080
+ redirect_uri: REDIRECT_URI,
11081
+ code_verifier: XAA_PKCE_VERIFIER,
11082
+ });
11083
+ return (await res.json()) as Record<string, string>;
11084
+ }
11085
+
11086
+ function issuanceRequest(overrides: Record<string, string> = {}): Promise<Response> {
11087
+ return postXaaToken(overrides, '/token', {
11088
+ client_id: 'c-idjag',
11089
+ client_secret: 's',
11090
+ grant_type: XAA_EXCHANGE_GRANT_TYPE,
11091
+ requested_token_type: XAA_ID_JAG_TOKEN_TYPE,
11092
+ subject_token_type: XAA_ID_TOKEN_TYPE,
11093
+ audience: XAA_PEER_AS_ISSUER,
11094
+ scope: 'openid profile',
11095
+ });
11096
+ }
11097
+
11098
+ function redeemRequest(
11099
+ overrides: Record<string, string> = {},
11100
+ clientId = 'c-idjag',
11101
+ ): Promise<Response> {
11102
+ return postXaaToken(overrides, '/token', {
11103
+ client_id: clientId,
11104
+ client_secret: 's',
11105
+ grant_type: XAA_JWT_BEARER_GRANT_TYPE,
11106
+ });
11107
+ }
11108
+
11109
+ // Sign an ID-JAG as the fake external IdP. An override set to undefined
11110
+ // removes the member (JSON.stringify drops undefined values).
11111
+ async function mintExternalIdJag(
11112
+ claims: Record<string, unknown>,
11113
+ header: Record<string, unknown> = {},
11114
+ ): Promise<string> {
11115
+ const nowSeconds = Math.floor(Date.now() / 1000);
11116
+ const encodedHeader = xaaB64UrlJson({
11117
+ alg: 'RS256',
11118
+ typ: 'oauth-id-jag+jwt',
11119
+ kid: 'external-idp-key',
11120
+ ...header,
11121
+ });
11122
+ const encodedPayload = xaaB64UrlJson({
11123
+ iss: XAA_TRUSTED_IDP_ISSUER,
11124
+ sub: 'testuser',
11125
+ aud: XAA_OWN_ISSUER,
11126
+ client_id: 'c-idjag',
11127
+ jti: 'conformance-jag',
11128
+ exp: nowSeconds + 300,
11129
+ iat: nowSeconds,
11130
+ scope: 'openid profile offline_access',
11131
+ ...claims,
11132
+ });
11133
+ const signingInput = encodedHeader + '.' + encodedPayload;
11134
+ const signature = await crypto.subtle.sign(
11135
+ 'RSASSA-PKCS1-v1_5',
11136
+ externalIdpPrivateKey,
11137
+ new TextEncoder().encode(signingInput),
11138
+ );
11139
+ return signingInput + '.' + xaaB64Url(new Uint8Array(signature));
11140
+ }
11141
+
11142
+ // Config helpers: flip the generated allow lists for one call and always
11143
+ // restore them, so the fail-safe empty defaults stay pinned by other tests.
11144
+ async function withIssuanceAudience<T>(fn: () => Promise<T>): Promise<T> {
11145
+ idJagConfig.allowedAudiences = [XAA_PEER_AS_ISSUER];
11146
+ try {
11147
+ return await fn();
11148
+ } finally {
11149
+ idJagConfig.allowedAudiences = [];
11150
+ }
11151
+ }
11152
+
11153
+ async function withTrustedIdp<T>(fn: () => Promise<T>): Promise<T> {
11154
+ idJagConfig.trustedIdentityProviders = [
11155
+ { issuer: XAA_TRUSTED_IDP_ISSUER, jwks: { keys: [externalIdpJwk] } },
11156
+ ];
11157
+ try {
11158
+ return await fn();
11159
+ } finally {
11160
+ idJagConfig.trustedIdentityProviders = [];
11161
+ }
11162
+ }
11163
+
11164
+ describe('ID-JAG issuance (draft §4.3)', () => {
11165
+ it('should issue an ID-JAG with the §3.1 claims and the §4.3.4 response members', async () => {
11166
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11167
+ const res = await withIssuanceAudience(() =>
11168
+ issuanceRequest({ subject_token: idToken }),
11169
+ );
11170
+ const body = (await res.json()) as Record<string, unknown>;
11171
+
11172
+ expect(res.status).toBe(200);
11173
+ expect(res.headers.get('Cache-Control')).toBe('no-store');
11174
+ expect(res.headers.get('Pragma')).toBe('no-cache');
11175
+ expect(Object.keys(body).sort()).toEqual([
11176
+ 'access_token',
11177
+ 'expires_in',
11178
+ 'issued_token_type',
11179
+ 'scope',
11180
+ 'token_type',
11181
+ ]);
11182
+ expect(body.issued_token_type).toBe(XAA_ID_JAG_TOKEN_TYPE);
11183
+ // draft §4.3.4: the issued grant is NOT an access token.
11184
+ expect(body.token_type).toBe('N_A');
11185
+ expect(body.expires_in).toBe(300);
11186
+ expect(body.scope).toBe('openid profile');
11187
+
11188
+ const segments = String(body.access_token).split('.');
11189
+ const header = xaaDecodeJwtSegment(segments[0] ?? '');
11190
+ const claims = xaaDecodeJwtSegment(segments[1] ?? '');
11191
+ // draft §3.1 / RFC 8725 §3.11: explicit typing, RS256, published kid.
11192
+ expect(header.typ).toBe('oauth-id-jag+jwt');
11193
+ expect(header.alg).toBe('RS256');
11194
+ expect(header.kid).toBe('test-key');
11195
+ expect(claims.iss).toBe(XAA_OWN_ISSUER);
11196
+ expect(claims.sub).toBe('testuser');
11197
+ expect(claims.aud).toBe(XAA_PEER_AS_ISSUER);
11198
+ expect(claims.client_id).toBe('c-idjag');
11199
+ expect(claims.scope).toBe('openid profile');
11200
+ expect(typeof claims.jti).toBe('string');
11201
+ expect((claims.exp as number) - (claims.iat as number)).toBe(300);
11202
+ });
11203
+
11204
+ it('should omit the scope claim and return an empty scope when none is requested', async () => {
11205
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11206
+ const res = await withIssuanceAudience(() =>
11207
+ issuanceRequest({ subject_token: idToken, scope: '' }),
11208
+ );
11209
+ const body = (await res.json()) as Record<string, unknown>;
11210
+
11211
+ expect(res.status).toBe(200);
11212
+ expect(body.scope).toBe('');
11213
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11214
+ expect('scope' in claims).toBe(false);
11215
+ });
11216
+
11217
+ it('should reject an audience outside the allow list with invalid_target', async () => {
11218
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11219
+ // The generated default allow list is empty (fail safe), so the same
11220
+ // audience that succeeds above is rejected without the config flip.
11221
+ const res = await issuanceRequest({ subject_token: idToken });
11222
+
11223
+ expect(res.status).toBe(400);
11224
+ expect(await res.json()).toEqual({
11225
+ error: 'invalid_target',
11226
+ error_description: 'The requested audience is not allowed for ID-JAG issuance',
11227
+ });
11228
+ });
11229
+
11230
+ it('should reject this issuer itself as audience with invalid_target', async () => {
11231
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11232
+ // draft §9.3: cross-domain only — even an allow-listed own issuer is refused.
11233
+ idJagConfig.allowedAudiences = [XAA_OWN_ISSUER];
11234
+ try {
11235
+ const res = await issuanceRequest({ subject_token: idToken, audience: XAA_OWN_ISSUER });
11236
+
11237
+ expect(res.status).toBe(400);
11238
+ expect(await res.json()).toEqual({
11239
+ error: 'invalid_target',
11240
+ error_description:
11241
+ 'The requested audience must belong to a different trust domain than this authorization server',
11242
+ });
11243
+ } finally {
11244
+ idJagConfig.allowedAudiences = [];
11245
+ }
11246
+ });
11247
+
11248
+ it('should reject an ID Token issued to another client with the fixed description', async () => {
11249
+ // draft §4.3.3: the assertion audience must be the authenticated client.
11250
+ const foreignIdToken = (await xaaCodeFlowTokens('c-conf')).id_token;
11251
+ const res = await withIssuanceAudience(() =>
11252
+ issuanceRequest({ subject_token: foreignIdToken }),
11253
+ );
11254
+
11255
+ expect(res.status).toBe(400);
11256
+ expect(await res.json()).toEqual({
11257
+ error: 'invalid_request',
11258
+ error_description: XAA_SUBJECT_INVALID_DESCRIPTION,
11259
+ });
11260
+ });
11261
+
11262
+ it('should reject an access token presented as the subject with the same fixed description', async () => {
11263
+ const accessToken = (await xaaCodeFlowTokens('c-idjag')).access_token;
11264
+ const res = await withIssuanceAudience(() =>
11265
+ issuanceRequest({ subject_token: accessToken }),
11266
+ );
11267
+
11268
+ expect(res.status).toBe(400);
11269
+ expect(await res.json()).toEqual({
11270
+ error: 'invalid_request',
11271
+ error_description: XAA_SUBJECT_INVALID_DESCRIPTION,
11272
+ });
11273
+ });
11274
+
11275
+ it('should reject a saml2 subject_token_type with invalid_request', async () => {
11276
+ const res = await issuanceRequest({
11277
+ subject_token: 'unused',
11278
+ subject_token_type: 'urn:ietf:params:oauth:token-type:saml2',
11279
+ });
11280
+
11281
+ expect(res.status).toBe(400);
11282
+ expect(await res.json()).toEqual({
11283
+ error: 'invalid_request',
11284
+ error_description: '${unsupportedSubjectTypeMessage}',
11285
+ });
11286
+ });
11287
+
11288
+ it('should reject an actor_token with invalid_request', async () => {
11289
+ const res = await issuanceRequest({
11290
+ subject_token: 'unused',
11291
+ actor_token: 'unused',
11292
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11293
+ });
11294
+
11295
+ expect(res.status).toBe(400);
11296
+ expect(await res.json()).toEqual({
11297
+ error: 'invalid_request',
11298
+ error_description: 'actor_token is not supported for ID-JAG issuance',
11299
+ });
11300
+ });
11301
+
11302
+ it('should reject a client without the token-exchange grant with unauthorized_client', async () => {
11303
+ // c-conf authenticates fine (client_secret_post) but never registered
11304
+ // the exchange URN.
11305
+ const res = await issuanceRequest({
11306
+ subject_token: 'unused',
11307
+ client_id: 'c-conf',
11308
+ });
11309
+
11310
+ expect(res.status).toBe(400);
11311
+ expect(((await res.json()) as Record<string, unknown>).error).toBe('unauthorized_client');
11312
+ });
11313
+
11314
+ it('should reject a public client with unauthorized_client', async () => {
11315
+ const res = await postXaaToken({
11316
+ client_id: 'c-public-idjag',
11317
+ grant_type: XAA_EXCHANGE_GRANT_TYPE,
11318
+ requested_token_type: XAA_ID_JAG_TOKEN_TYPE,
11319
+ subject_token: 'unused',
11320
+ subject_token_type: XAA_ID_TOKEN_TYPE,
11321
+ audience: XAA_PEER_AS_ISSUER,
11322
+ });
11323
+
11324
+ expect(res.status).toBe(400);
11325
+ expect(await res.json()).toEqual({
11326
+ error: 'unauthorized_client',
11327
+ error_description: 'Public clients are not allowed to request an ID-JAG',
11328
+ });
11329
+ });
11330
+
11331
+ it('should cap the issued scopes at idJagConfig.allowedScopes with invalid_scope', async () => {
11332
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11333
+ idJagConfig.allowedScopes = ['openid'];
11334
+ try {
11335
+ const res = await withIssuanceAudience(() =>
11336
+ issuanceRequest({ subject_token: idToken, scope: 'openid profile' }),
11337
+ );
11338
+
11339
+ expect(res.status).toBe(400);
11340
+ expect(await res.json()).toEqual({
11341
+ error: 'invalid_scope',
11342
+ error_description: 'The requested scope exceeds the scopes allowed for ID-JAG issuance',
11343
+ });
11344
+ } finally {
11345
+ idJagConfig.allowedScopes = undefined;
11346
+ }
11347
+ });
11348
+ ${refreshSubjectContract}
11349
+ // Extension (draft §9.7): actor tokens are an explicit opt-in; the
11350
+ // generated default keeps them off.
11351
+ it('should record the actor in the act claim when actor tokens are enabled', async () => {
11352
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11353
+ const actorIdToken = (await xaaCodeFlowTokens('c-idjag', 'otheruser')).id_token;
11354
+ idJagConfig.allowActorTokens = true;
11355
+ try {
11356
+ const res = await withIssuanceAudience(() =>
11357
+ issuanceRequest({
11358
+ subject_token: subjectIdToken,
11359
+ actor_token: actorIdToken,
11360
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11361
+ }),
11362
+ );
11363
+ const body = (await res.json()) as Record<string, unknown>;
11364
+
11365
+ expect(res.status).toBe(200);
11366
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11367
+ // RFC 8693 §4.1: sub stays the resource owner; the actor appears only in act.
11368
+ expect(claims.sub).toBe('testuser');
11369
+ expect(claims.act).toEqual({ sub: 'otheruser' });
11370
+ } finally {
11371
+ idJagConfig.allowActorTokens = false;
11372
+ }
11373
+ });
11374
+
11375
+ it('should reject an actor ID Token issued to another client with the fixed description', async () => {
11376
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11377
+ const foreignActorToken = (await xaaCodeFlowTokens('c-conf')).id_token;
11378
+ idJagConfig.allowActorTokens = true;
11379
+ try {
11380
+ const res = await withIssuanceAudience(() =>
11381
+ issuanceRequest({
11382
+ subject_token: subjectIdToken,
11383
+ actor_token: foreignActorToken,
11384
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11385
+ }),
11386
+ );
11387
+
11388
+ expect(res.status).toBe(400);
11389
+ expect(await res.json()).toEqual({
11390
+ error: 'invalid_request',
11391
+ error_description: 'The provided actor_token is not valid',
11392
+ });
11393
+ } finally {
11394
+ idJagConfig.allowActorTokens = false;
11395
+ }
11396
+ });
11397
+
11398
+ // Every token type identifier RFC 8693 §3 defines is accepted the same
11399
+ // way; idJagConfig.actorTokenResolver decides what is valid. The
11400
+ // generated default resolves this OP's own ID Tokens and nothing else.
11401
+ it('should reject an actor token type the configured resolver does not accept', async () => {
11402
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11403
+ idJagConfig.allowActorTokens = true;
11404
+ try {
11405
+ const res = await withIssuanceAudience(() =>
11406
+ issuanceRequest({
11407
+ subject_token: subjectIdToken,
11408
+ actor_token: 'opaque-actor-token',
11409
+ actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
11410
+ }),
11411
+ );
11412
+
11413
+ expect(res.status).toBe(400);
11414
+ expect(await res.json()).toEqual({
11415
+ error: 'invalid_request',
11416
+ error_description: 'The provided actor_token is not valid',
11417
+ });
11418
+ } finally {
11419
+ idJagConfig.allowActorTokens = false;
11420
+ }
11421
+ });
11422
+
11423
+ it('should reject an actor_token_type outside the registered identifiers', async () => {
11424
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11425
+ idJagConfig.allowActorTokens = true;
11426
+ try {
11427
+ const res = await withIssuanceAudience(() =>
11428
+ issuanceRequest({
11429
+ subject_token: subjectIdToken,
11430
+ actor_token: 'opaque-actor-token',
11431
+ actor_token_type: 'urn:example:token-type:badge',
11432
+ }),
11433
+ );
11434
+
11435
+ expect(res.status).toBe(400);
11436
+ expect(await res.json()).toEqual({
11437
+ error: 'invalid_request',
11438
+ error_description:
11439
+ 'Unsupported actor_token_type for ID-JAG issuance. Supported values are urn:ietf:params:oauth:token-type:access_token, urn:ietf:params:oauth:token-type:refresh_token, urn:ietf:params:oauth:token-type:id_token, urn:ietf:params:oauth:token-type:jwt, urn:ietf:params:oauth:token-type:saml1, urn:ietf:params:oauth:token-type:saml2.',
11440
+ });
11441
+ } finally {
11442
+ idJagConfig.allowActorTokens = false;
11443
+ }
11444
+ });
11445
+
11446
+ it('should record the act chain resolved by the deployment actor token resolver', async () => {
11447
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11448
+ const defaultResolver = idJagConfig.actorTokenResolver;
11449
+ idJagConfig.allowActorTokens = true;
11450
+ idJagConfig.actorTokenResolver = async ({ actorToken, actorTokenType, clientId }) =>
11451
+ actorTokenType === 'urn:ietf:params:oauth:token-type:access_token' &&
11452
+ actorToken === 'badge-7' &&
11453
+ clientId === 'c-idjag'
11454
+ ? { sub: 'badge-actor', act: { sub: 'upstream-actor' } }
11455
+ : null;
11456
+ try {
11457
+ const res = await withIssuanceAudience(() =>
11458
+ issuanceRequest({
11459
+ subject_token: subjectIdToken,
11460
+ actor_token: 'badge-7',
11461
+ actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
11462
+ }),
11463
+ );
11464
+ const body = (await res.json()) as Record<string, unknown>;
11465
+
11466
+ expect(res.status).toBe(200);
11467
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11468
+ // The subject stays the resource owner; the resolver's chain lands in act.
11469
+ expect(claims.sub).toBe('testuser');
11470
+ expect(claims.act).toEqual({ sub: 'badge-actor', act: { sub: 'upstream-actor' } });
11471
+ } finally {
11472
+ idJagConfig.allowActorTokens = false;
11473
+ idJagConfig.actorTokenResolver = defaultResolver;
11474
+ }
11475
+ });
11476
+
11477
+ it('should answer a null from the actor token resolver with the fixed description', async () => {
11478
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11479
+ const actorIdToken = (await xaaCodeFlowTokens('c-idjag', 'otheruser')).id_token;
11480
+ const defaultResolver = idJagConfig.actorTokenResolver;
11481
+ idJagConfig.allowActorTokens = true;
11482
+ idJagConfig.actorTokenResolver = async () => null;
11483
+ try {
11484
+ const res = await withIssuanceAudience(() =>
11485
+ issuanceRequest({
11486
+ subject_token: subjectIdToken,
11487
+ actor_token: actorIdToken,
11488
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11489
+ }),
11490
+ );
11491
+
11492
+ expect(res.status).toBe(400);
11493
+ expect(await res.json()).toEqual({
11494
+ error: 'invalid_request',
11495
+ error_description: 'The provided actor_token is not valid',
11496
+ });
11497
+ } finally {
11498
+ idJagConfig.allowActorTokens = false;
11499
+ idJagConfig.actorTokenResolver = defaultResolver;
11500
+ }
11501
+ });
11502
+
11503
+ // The resolver owns every type, ID Tokens included — there is no
11504
+ // separate built-in lane the deployment cannot reach.
11505
+ it('should route id_token actors through the configured resolver as well', async () => {
11506
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11507
+ const seenTypes: string[] = [];
11508
+ const defaultResolver = idJagConfig.actorTokenResolver;
11509
+ idJagConfig.allowActorTokens = true;
11510
+ idJagConfig.actorTokenResolver = async ({ actorTokenType }) => {
11511
+ seenTypes.push(actorTokenType);
11512
+ return { sub: 'resolver-decided' };
11513
+ };
11514
+ try {
11515
+ const res = await withIssuanceAudience(() =>
11516
+ issuanceRequest({
11517
+ subject_token: subjectIdToken,
11518
+ actor_token: 'opaque-actor-token',
11519
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11520
+ }),
11521
+ );
11522
+ const body = (await res.json()) as Record<string, unknown>;
11523
+
11524
+ expect(res.status).toBe(200);
11525
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11526
+ expect(claims.act).toEqual({ sub: 'resolver-decided' });
11527
+ expect(seenTypes).toEqual([XAA_ID_TOKEN_TYPE]);
11528
+ } finally {
11529
+ idJagConfig.allowActorTokens = false;
11530
+ idJagConfig.actorTokenResolver = defaultResolver;
11531
+ }
11532
+ });
11533
+
11534
+ it('should reject every actor token once the resolver is cleared', async () => {
11535
+ const subjectIdToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11536
+ const actorIdToken = (await xaaCodeFlowTokens('c-idjag', 'otheruser')).id_token;
11537
+ const defaultResolver = idJagConfig.actorTokenResolver;
11538
+ idJagConfig.allowActorTokens = true;
11539
+ idJagConfig.actorTokenResolver = undefined;
11540
+ try {
11541
+ const res = await withIssuanceAudience(() =>
11542
+ issuanceRequest({
11543
+ subject_token: subjectIdToken,
11544
+ actor_token: actorIdToken,
11545
+ actor_token_type: XAA_ID_TOKEN_TYPE,
11546
+ }),
11547
+ );
11548
+
11549
+ expect(res.status).toBe(400);
11550
+ expect(await res.json()).toEqual({
11551
+ error: 'invalid_request',
11552
+ error_description: 'The provided actor_token is not valid',
11553
+ });
11554
+ } finally {
11555
+ idJagConfig.allowActorTokens = false;
11556
+ idJagConfig.actorTokenResolver = defaultResolver;
11557
+ }
11558
+ });
11559
+ });
11560
+
11561
+ describe('ID-JAG redemption (draft §4.4)', () => {
11562
+ it('should redeem a trusted ID-JAG for an access token of this AS', async () => {
11563
+ const assertion = await mintExternalIdJag({});
11564
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
11565
+ const body = (await res.json()) as Record<string, unknown>;
11566
+
11567
+ expect(res.status).toBe(200);
11568
+ expect(res.headers.get('Cache-Control')).toBe('no-store');
11569
+ expect(res.headers.get('Pragma')).toBe('no-cache');
11570
+ // draft §4.4.2 / §4.4.3: a plain token response — no refresh_token (the
11571
+ // re-presentable ID-JAG replaces it) and no id_token (this is not an
11572
+ // OIDC authentication flow).
11573
+ expect(Object.keys(body).sort()).toEqual([
11574
+ 'access_token',
11575
+ 'expires_in',
11576
+ 'scope',
11577
+ 'token_type',
11578
+ ]);
11579
+ expect(body.token_type).toBe('Bearer');
11580
+ expect(body.expires_in).toBe(3600);
11581
+ // offline_access is always dropped: no refresh token is ever issued here.
11582
+ expect(body.scope).toBe('openid profile');
11583
+
11584
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11585
+ // The access token is this AS's own (draft §1: the IdP never mints
11586
+ // tokens for the resource AS), for the ID-JAG's subject and client.
11587
+ expect(claims.iss).toBe(XAA_OWN_ISSUER);
11588
+ expect(claims.sub).toBe('testuser');
11589
+ expect(claims.client_id).toBe('c-idjag');
9540
11590
  });
9541
11591
 
9542
- it('should reject an unsupported actor_token_type with invalid_request', async () => {
9543
- const subjectToken = await subjectTokenFor('openid');
9544
- const res = await exchangeRequest({
9545
- subject_token: subjectToken,
9546
- actor_token: subjectToken,
9547
- actor_token_type: 'urn:ietf:params:oauth:token-type:id_token',
9548
- });
11592
+ it('should let the redeemed access token pass the UserInfo endpoint', async () => {
11593
+ const assertion = await mintExternalIdJag({});
11594
+ const redeemed = await withTrustedIdp(() => redeemRequest({ assertion }));
11595
+ const accessToken = ((await redeemed.json()) as Record<string, string>).access_token;
9549
11596
 
9550
- expect(res.status).toBe(400);
9551
- expect(await res.json()).toEqual({
9552
- error: 'invalid_request',
9553
- error_description:
9554
- 'Unsupported actor_token_type. Only urn:ietf:params:oauth:token-type:access_token is supported.',
11597
+ const res = await app.request('/userinfo', {
11598
+ headers: { Authorization: 'Bearer ' + accessToken },
9555
11599
  });
11600
+ const body = (await res.json()) as Record<string, unknown>;
11601
+
11602
+ expect(res.status).toBe(200);
11603
+ expect(body.sub).toBe('testuser');
9556
11604
  });
11605
+ ${introspectionContract}
11606
+ it('should accept the same ID-JAG again while it is valid', async () => {
11607
+ // draft §4.4.3: re-presenting the still-valid grant replaces the refresh
11608
+ // token, so a second redemption MUST succeed (no jti replay store).
11609
+ const assertion = await mintExternalIdJag({});
11610
+ const first = await withTrustedIdp(() => redeemRequest({ assertion }));
11611
+ const second = await withTrustedIdp(() => redeemRequest({ assertion }));
9557
11612
 
9558
- // The actor_token failure description is fixed for the same oracle-
9559
- // elimination reason as the subject_token one.
9560
- it('should reject an unknown actor_token with the fixed description', async () => {
9561
- const subjectToken = await subjectTokenFor('openid');
9562
- const res = await exchangeRequest({
9563
- subject_token: subjectToken,
9564
- actor_token: 'not-a-real-token',
9565
- actor_token_type: ACCESS_TOKEN_TYPE,
9566
- });
11613
+ expect(first.status).toBe(200);
11614
+ expect(second.status).toBe(200);
11615
+ });
9567
11616
 
9568
- expect(res.status).toBe(400);
9569
- expect(await res.json()).toEqual({
9570
- error: 'invalid_request',
9571
- error_description: ACTOR_INVALID_DESCRIPTION,
9572
- });
11617
+ it('should answer an untrusted issuer and a broken signature identically', async () => {
11618
+ const untrusted = await mintExternalIdJag({ iss: 'https://unknown-idp.example.org' });
11619
+ const [h, p] = (await mintExternalIdJag({})).split('.');
11620
+ const tampered = h + '.' + p + '.AAAA';
11621
+
11622
+ const untrustedRes = await withTrustedIdp(() => redeemRequest({ assertion: untrusted }));
11623
+ const tamperedRes = await withTrustedIdp(() => redeemRequest({ assertion: tampered }));
11624
+ const expected = {
11625
+ error: 'invalid_grant',
11626
+ error_description: XAA_ASSERTION_UNTRUSTED_DESCRIPTION,
11627
+ };
11628
+
11629
+ expect(untrustedRes.status).toBe(400);
11630
+ expect(tamperedRes.status).toBe(400);
11631
+ expect(await untrustedRes.json()).toEqual(expected);
11632
+ expect(await tamperedRes.json()).toEqual(expected);
9573
11633
  });
9574
11634
 
9575
- // RFC 8693 §2.1: resource MUST be an absolute URI without a fragment.
9576
- it('should reject a relative resource with invalid_request', async () => {
9577
- const subjectToken = await subjectTokenFor('openid');
9578
- const res = await exchangeRequest({ subject_token: subjectToken, resource: '/api' });
11635
+ it('should reject every assertion when no identity provider is trusted', async () => {
11636
+ // The generated default trust list is empty (fail safe).
11637
+ const assertion = await mintExternalIdJag({});
11638
+ const res = await redeemRequest({ assertion });
9579
11639
 
9580
11640
  expect(res.status).toBe(400);
9581
11641
  expect(await res.json()).toEqual({
9582
- error: 'invalid_request',
9583
- error_description: 'resource must be an absolute URI without a fragment component',
11642
+ error: 'invalid_grant',
11643
+ error_description: XAA_ASSERTION_UNTRUSTED_DESCRIPTION,
9584
11644
  });
9585
11645
  });
9586
11646
 
9587
- it('should reject a resource carrying a fragment with invalid_request', async () => {
9588
- const subjectToken = await subjectTokenFor('openid');
9589
- const res = await exchangeRequest({
9590
- subject_token: subjectToken,
9591
- resource: 'https://api.example.com/x#frag',
9592
- });
11647
+ it('should reject an ID-JAG addressed to another authorization server with invalid_grant', async () => {
11648
+ const assertion = await mintExternalIdJag({ aud: 'https://other-as.example.org' });
11649
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
9593
11650
 
9594
11651
  expect(res.status).toBe(400);
9595
11652
  expect(await res.json()).toEqual({
9596
- error: 'invalid_request',
9597
- error_description: 'resource must be an absolute URI without a fragment component',
11653
+ error: 'invalid_grant',
11654
+ error_description: 'The assertion audience does not match this authorization server',
9598
11655
  });
9599
11656
  });
9600
11657
 
9601
- // RFC 6749 §3.2: repeated token endpoint parameters are refused, which is
9602
- // why this OP supports only a single audience / resource value.
9603
- it('should reject a repeated resource parameter', async () => {
9604
- const subjectToken = await subjectTokenFor('openid');
9605
- const res = await app.request('/token', {
9606
- method: 'POST',
9607
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9608
- body:
9609
- 'client_id=c-exchange&client_secret=s&grant_type=' +
9610
- encodeURIComponent(EXCHANGE_GRANT_TYPE) +
9611
- '&subject_token=' + encodeURIComponent(subjectToken) +
9612
- '&subject_token_type=' + encodeURIComponent(ACCESS_TOKEN_TYPE) +
9613
- '&resource=https%3A%2F%2Fa.example.com&resource=https%3A%2F%2Fb.example.com',
9614
- });
11658
+ it('should reject an ID-JAG bound to another client with invalid_grant', async () => {
11659
+ // draft §4.4.1 client continuity: c-idjag-other authenticates correctly
11660
+ // but presents a grant that names c-idjag.
11661
+ const assertion = await mintExternalIdJag({});
11662
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }, 'c-idjag-other'));
9615
11663
 
9616
11664
  expect(res.status).toBe(400);
9617
11665
  expect(await res.json()).toEqual({
9618
- error: 'invalid_request',
9619
- error_description: 'Parameter "resource" must not be repeated',
11666
+ error: 'invalid_grant',
11667
+ error_description: 'The assertion client_id does not match the authenticated client',
9620
11668
  });
9621
11669
  });
9622
11670
 
9623
- // RFC 8693 §2.2.2 sends invalid subject tokens to invalid_request, NOT to
9624
- // invalid_grant as the authorization_code / refresh_token grants would.
9625
- it('should reject an unknown subject_token with invalid_request', async () => {
9626
- const res = await exchangeRequest({ subject_token: 'not-a-real-token' });
11671
+ it('should reject a JWT without the ID-JAG typ with invalid_grant', async () => {
11672
+ // RFC 8725 §3.11 explicit typing: an ID Token (typ JWT) can never be
11673
+ // redeemed as an ID-JAG even with otherwise plausible claims.
11674
+ const assertion = await mintExternalIdJag({}, { typ: 'JWT' });
11675
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
9627
11676
 
9628
11677
  expect(res.status).toBe(400);
9629
11678
  expect(await res.json()).toEqual({
9630
- error: 'invalid_request',
9631
- error_description: SUBJECT_INVALID_DESCRIPTION,
9632
- });
9633
- });
9634
-
9635
- it('should report a revoked subject_token exactly like an unknown one', async () => {
9636
- const subjectToken = await subjectTokenFor('openid');
9637
- await app.request('/revoke', {
9638
- method: 'POST',
9639
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9640
- body: new URLSearchParams({
9641
- client_id: 'c-exchange',
9642
- client_secret: 's',
9643
- token: subjectToken,
9644
- }).toString(),
11679
+ error: 'invalid_grant',
11680
+ error_description: 'The assertion typ must be oauth-id-jag+jwt',
9645
11681
  });
9646
- const revoked = await exchangeRequest({ subject_token: subjectToken });
9647
- const unknown = await exchangeRequest({ subject_token: 'not-a-real-token' });
9648
-
9649
- expect(revoked.status).toBe(400);
9650
- expect(await revoked.json()).toEqual(await unknown.json());
9651
11682
  });
9652
- });
9653
11683
 
9654
- describe('Scope narrowing', () => {
9655
- it('should reject a scope that exceeds the subject token scope', async () => {
9656
- const subjectToken = await subjectTokenFor('openid');
9657
- const res = await exchangeRequest({ subject_token: subjectToken, scope: 'openid profile' });
11684
+ it('should reject an expired ID-JAG with invalid_grant', async () => {
11685
+ const nowSeconds = Math.floor(Date.now() / 1000);
11686
+ const assertion = await mintExternalIdJag({ exp: nowSeconds - 120, iat: nowSeconds - 400 });
11687
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
9658
11688
 
9659
11689
  expect(res.status).toBe(400);
9660
11690
  expect(await res.json()).toEqual({
9661
- error: 'invalid_scope',
9662
- error_description: 'The requested scope exceeds the scope of the subject_token',
11691
+ error: 'invalid_grant',
11692
+ error_description: 'The assertion has expired',
9663
11693
  });
9664
11694
  });
9665
11695
 
9666
- it('should grant exactly the requested subset', async () => {
9667
- const subjectToken = await subjectTokenFor('openid profile email');
9668
- const res = await exchangeRequest({ subject_token: subjectToken, scope: 'email' });
11696
+ it('should reject a client without the jwt-bearer grant with unauthorized_client', async () => {
11697
+ // c-conf authenticates fine (client_secret_post) but never registered
11698
+ // the jwt-bearer URN.
11699
+ const res = await redeemRequest({ assertion: 'unused', client_id: 'c-conf' });
9669
11700
 
9670
- expect(res.status).toBe(200);
9671
- expect((await res.json()).scope).toBe('email');
11701
+ expect(res.status).toBe(400);
11702
+ expect(((await res.json()) as Record<string, unknown>).error).toBe('unauthorized_client');
9672
11703
  });
9673
- });
9674
11704
 
9675
- describe('Delegation (RFC 8693 §4.1)', () => {
9676
- // sub stays the subject; the actor appears only in the act claim.
9677
- it('should record the actor in the act claim of the issued token', async () => {
9678
- const subjectToken = await subjectTokenFor('openid profile');
9679
- const actorToken = await actorTokenFor('openid');
9680
- const res = await exchangeRequest({
9681
- subject_token: subjectToken,
9682
- actor_token: actorToken,
9683
- actor_token_type: ACCESS_TOKEN_TYPE,
11705
+ it('should reject a public client with unauthorized_client', async () => {
11706
+ const res = await postXaaToken({
11707
+ client_id: 'c-public-idjag',
11708
+ grant_type: XAA_JWT_BEARER_GRANT_TYPE,
11709
+ assertion: 'unused',
9684
11710
  });
9685
- const body = await res.json();
9686
- const payload = decodeJwtPayload(body.access_token as string);
9687
-
9688
- expect(res.status).toBe(200);
9689
- expect(payload.sub).toBe('testuser');
9690
- expect(payload.act).toEqual({ sub: 'otheruser' });
9691
- });
9692
-
9693
- it('should not add an act claim to an impersonation exchange', async () => {
9694
- const subjectToken = await subjectTokenFor('openid');
9695
- const body = await (await exchangeRequest({ subject_token: subjectToken })).json();
9696
- const payload = decodeJwtPayload(body.access_token as string);
9697
-
9698
- expect(payload.act).toBe(undefined);
9699
- });
9700
11711
 
9701
- // RFC 8693 §4.1: exchanging a delegated token again pushes the prior
9702
- // actor one level down; the outermost act names the current actor.
9703
- it('should nest the prior actor when a delegated token is exchanged again', async () => {
9704
- const subjectToken = await subjectTokenFor('openid');
9705
- const firstActor = await actorTokenFor('openid');
9706
- const delegated = (await (
9707
- await exchangeRequest({
9708
- subject_token: subjectToken,
9709
- actor_token: firstActor,
9710
- actor_token_type: ACCESS_TOKEN_TYPE,
9711
- })
9712
- ).json()).access_token as string;
9713
- const secondActor = await actorTokenFor('openid');
9714
- const res = await exchangeRequest({
9715
- subject_token: delegated,
9716
- actor_token: secondActor,
9717
- actor_token_type: ACCESS_TOKEN_TYPE,
11712
+ expect(res.status).toBe(400);
11713
+ expect(await res.json()).toEqual({
11714
+ error: 'unauthorized_client',
11715
+ error_description: 'Public clients are not allowed to use the jwt-bearer grant type',
9718
11716
  });
9719
- const payload = decodeJwtPayload((await res.json()).access_token as string);
9720
-
9721
- expect(res.status).toBe(200);
9722
- expect(payload.act).toEqual({ sub: 'otheruser', act: { sub: 'otheruser' } });
9723
11717
  });
9724
11718
 
9725
- // A delegated token is an ordinary access token of the subject: the
9726
- // UserInfo endpoint answers for the subject, not the actor.
9727
- it('should answer UserInfo for the subject of a delegated token', async () => {
9728
- const subjectToken = await subjectTokenFor('openid profile');
9729
- const actorToken = await actorTokenFor('openid');
9730
- const delegated = (await (
9731
- await exchangeRequest({
9732
- subject_token: subjectToken,
9733
- actor_token: actorToken,
9734
- actor_token_type: ACCESS_TOKEN_TYPE,
9735
- })
9736
- ).json()).access_token as string;
9737
- const res = await app.request('/userinfo', {
9738
- headers: { Authorization: 'Bearer ' + delegated },
9739
- });
11719
+ it('should preserve the act claim of an actor-bearing ID-JAG on the issued access token', async () => {
11720
+ // RFC 8693 §4.1: the actor record survives the redemption, on the JWT
11721
+ // and in the store alike dropping it would hide who actually acts.
11722
+ const assertion = await mintExternalIdJag({ act: { sub: 'external-actor' } });
11723
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
11724
+ const body = (await res.json()) as Record<string, unknown>;
9740
11725
 
9741
11726
  expect(res.status).toBe(200);
9742
- expect((await res.json()).sub).toBe('testuser');
11727
+ const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
11728
+ expect(claims.sub).toBe('testuser');
11729
+ expect(claims.act).toEqual({ sub: 'external-actor' });
9743
11730
  });
9744
- });
9745
11731
 
9746
- describe('Target policy (allowedTargets)', () => {
9747
- // The generated default is an empty list, so any named target is refused
9748
- // until the operator opts in. The list is restored after each test.
9749
- it('should reject an audience that is not in allowedTargets', async () => {
9750
- const subjectToken = await subjectTokenFor('openid');
9751
- const res = await exchangeRequest({
9752
- subject_token: subjectToken,
9753
- audience: 'https://internal.example.com',
9754
- });
11732
+ it('should reject a malformed act claim with invalid_grant', async () => {
11733
+ const assertion = await mintExternalIdJag({ act: { role: 'admin' } });
11734
+ const res = await withTrustedIdp(() => redeemRequest({ assertion }));
9755
11735
 
9756
11736
  expect(res.status).toBe(400);
9757
11737
  expect(await res.json()).toEqual({
9758
- error: 'invalid_target',
9759
- error_description: TARGET_REJECTED_DESCRIPTION,
11738
+ error: 'invalid_grant',
11739
+ error_description: 'The assertion act claim is malformed',
9760
11740
  });
9761
11741
  });
9762
11742
 
9763
- it('should reject a resource that is not in allowedTargets', async () => {
9764
- const subjectToken = await subjectTokenFor('openid');
9765
- const res = await exchangeRequest({
9766
- subject_token: subjectToken,
9767
- resource: 'https://internal.example.com/api',
9768
- });
11743
+ it('should refuse to redeem an ID-JAG this authorization server issued itself', async () => {
11744
+ // draft §9.3: the full chain — a real ID-JAG issued by this OP (for the
11745
+ // peer AS) must not be exchangeable for this OP's own access token,
11746
+ // whatever the trust list says.
11747
+ const idToken = (await xaaCodeFlowTokens('c-idjag')).id_token;
11748
+ const issued = await withIssuanceAudience(() =>
11749
+ issuanceRequest({ subject_token: idToken }),
11750
+ );
11751
+ const selfIssuedJag = ((await issued.json()) as Record<string, string>).access_token;
11752
+
11753
+ const res = await withTrustedIdp(() => redeemRequest({ assertion: selfIssuedJag }));
9769
11754
 
9770
11755
  expect(res.status).toBe(400);
9771
11756
  expect(await res.json()).toEqual({
9772
- error: 'invalid_target',
9773
- error_description: TARGET_REJECTED_DESCRIPTION,
9774
- });
9775
- });
9776
-
9777
- it('should issue a token for an allowed audience', async () => {
9778
- const subjectToken = await subjectTokenFor('openid');
9779
- tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
9780
- const res = await exchangeRequest({
9781
- subject_token: subjectToken,
9782
- audience: 'https://internal.example.com',
11757
+ error: 'invalid_grant',
11758
+ error_description: 'An assertion issued by this authorization server cannot be redeemed here',
9783
11759
  });
9784
- const body = await res.json();
9785
- tokenExchangeConfig.allowedTargets = [];
9786
-
9787
- expect(res.status).toBe(200);
9788
- expect(body.token_type).toBe('Bearer');
9789
- });
9790
-
9791
- // The UserInfo endpoint stays a permanent aud member (RFC 9068 §3), so an
9792
- // exchanged token keeps working against this OP as well as the new target.
9793
- it('should add the allowed audience alongside the UserInfo endpoint', async () => {
9794
- const subjectToken = await subjectTokenFor('openid');
9795
- tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
9796
- const exchanged = (await (
9797
- await exchangeRequest({
9798
- subject_token: subjectToken,
9799
- audience: 'https://internal.example.com',
9800
- })
9801
- ).json()).access_token as string;
9802
- tokenExchangeConfig.allowedTargets = [];
9803
- const introspection = await (
9804
- await app.request('/introspect', {
9805
- method: 'POST',
9806
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9807
- body: new URLSearchParams({
9808
- client_id: 'c-exchange',
9809
- client_secret: 's',
9810
- token: exchanged,
9811
- }).toString(),
9812
- })
9813
- ).json();
9814
-
9815
- expect(introspection.aud).toEqual([
9816
- 'http://localhost:3000/userinfo',
9817
- 'https://internal.example.com',
9818
- ]);
9819
11760
  });
9820
11761
  });
9821
11762
 
9822
- describe('Discovery', () => {
9823
- it('should advertise the exchange grant in grant_types_supported', async () => {
9824
- const metadata = await (await app.request('/.well-known/openid-configuration')).json();
9825
-
9826
- expect(metadata.grant_types_supported.includes(EXCHANGE_GRANT_TYPE)).toBe(true);
11763
+ describe('Discovery advertisement (draft §7)', () => {
11764
+ it('should advertise both XAA grant types and the profile metadata', async () => {
11765
+ const res = await app.request('/.well-known/openid-configuration');
11766
+ const metadata = (await res.json()) as Record<string, unknown>;
11767
+ const grantTypes = metadata.grant_types_supported as string[];
11768
+
11769
+ expect(grantTypes.includes(XAA_EXCHANGE_GRANT_TYPE)).toBe(true);
11770
+ expect(grantTypes.includes(XAA_JWT_BEARER_GRANT_TYPE)).toBe(true);
11771
+ // draft §7.1 / §7.2: profile support only — the trusted-IdP list and the
11772
+ // audience allow list are deliberately NOT disclosed (draft §9.4).
11773
+ expect(metadata.identity_chaining_requested_token_types_supported).toEqual([
11774
+ 'urn:ietf:params:oauth:token-type:id-jag',
11775
+ ]);
11776
+ expect(metadata.authorization_grant_profiles_supported).toEqual([
11777
+ 'urn:ietf:params:oauth:grant-profile:id-jag',
11778
+ ]);
9827
11779
  });
9828
11780
  });
9829
11781
  });
9830
-
9831
11782
  `;
9832
11783
  }
9833
11784
  export function parConformanceBlock(features) {
@@ -11689,7 +13640,7 @@ export function consentDecisionConformanceBlock() {
11689
13640
  `;
11690
13641
  }
11691
13642
  export function conformanceTestTemplate(corePkg, features = DEFAULT_FEATURES) {
11692
- const exportPublicJwkImport = features.requestObject
13643
+ const exportPublicJwkImport = features.requestObject || features.idJag
11693
13644
  ? `import { exportPublicJwk } from '${corePkg}';\n`
11694
13645
  : '';
11695
13646
  const responseModesSupportedExpectation = features.jarm
@@ -11711,16 +13662,20 @@ import { parConfig } from './routes/par.js';`
11711
13662
  ? `
11712
13663
  import { tokenExchangeConfig } from './routes/token.js';`
11713
13664
  : '';
13665
+ const idJagConformanceImports = features.idJag
13666
+ ? `
13667
+ import { idJagConfig } from './routes/token.js';`
13668
+ : '';
11714
13669
  return `import { describe, it, expect, beforeAll } from 'vitest';
11715
13670
  import type { SigningKeyProvider, SigningKey } from '${corePkg}';
11716
13671
  import { Hono } from 'hono';
11717
13672
  ${exportPublicJwkImport}import { createApp, validateSigningKeySet } from './app.js';
11718
13673
  import { applyOidc } from './apply.js';
11719
13674
  import { createInMemoryClientResolver, type RegisteredClient } from './config.js';
11720
- import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores, refreshTokenStore, transactionStore, type JsonStoreBackend } from './store.js';
13675
+ import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores,${onlineRefreshTokenConformanceStoreImport(features)} refreshTokenStore, transactionStore, type JsonStoreBackend } from './store.js';
11721
13676
  import { consentResolver } from './resolvers.js';
11722
13677
  import { defaultViews } from './views.js';
11723
- import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}
13678
+ import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}${idJagConformanceImports}
11724
13679
 
11725
13680
  /**
11726
13681
  * HTTP conformance smoke tests for the generated OpenID Connect Provider.
@@ -12199,7 +14154,7 @@ ${introspectionConformanceBlock(features)}
12199
14154
  });
12200
14155
  });
12201
14156
  });
12202
- ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${jarmConformanceBlock(features)}${consentDecisionConformanceBlock()}});
14157
+ ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${internalRedirectOriginConformanceBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${onlineRefreshTokenConformanceBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${idJagConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${jarmConformanceBlock(features)}${consentDecisionConformanceBlock()}});
12203
14158
  `;
12204
14159
  }
12205
14160
  //# sourceMappingURL=templates.js.map