@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.
- package/dist/features.d.ts +2 -1
- package/dist/features.d.ts.map +1 -1
- package/dist/features.js +3 -0
- package/dist/features.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +4 -0
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +2271 -316
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +41 -23
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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)
|
|
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
|
|
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
|
-
? `
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
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
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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
|
-
*
|
|
1089
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
? ` //
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
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
|
-
//
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
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
|
-
//
|
|
2689
|
-
|
|
2690
|
-
const grantedScope = transaction.scope.split(' ').filter(
|
|
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}
|
|
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}
|
|
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
|
|
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
|
-
? ` //
|
|
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
|
-
|
|
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
|
-
? `
|
|
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
|
|
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
|
-
//
|
|
5232
|
-
|
|
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
|
-
//
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
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)
|
|
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
|
-
//
|
|
7853
|
-
//
|
|
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
|
-
|
|
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
|
|
9108
|
-
return
|
|
9109
|
-
|
|
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
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
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
|
|
9543
|
-
const
|
|
9544
|
-
const
|
|
9545
|
-
|
|
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
|
-
|
|
9551
|
-
|
|
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
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
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
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
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
|
-
|
|
9576
|
-
|
|
9577
|
-
const
|
|
9578
|
-
const res = await
|
|
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: '
|
|
9583
|
-
error_description:
|
|
11642
|
+
error: 'invalid_grant',
|
|
11643
|
+
error_description: XAA_ASSERTION_UNTRUSTED_DESCRIPTION,
|
|
9584
11644
|
});
|
|
9585
11645
|
});
|
|
9586
11646
|
|
|
9587
|
-
it('should reject
|
|
9588
|
-
const
|
|
9589
|
-
const res = await
|
|
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: '
|
|
9597
|
-
error_description: '
|
|
11653
|
+
error: 'invalid_grant',
|
|
11654
|
+
error_description: 'The assertion audience does not match this authorization server',
|
|
9598
11655
|
});
|
|
9599
11656
|
});
|
|
9600
11657
|
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
const
|
|
9605
|
-
const res = await
|
|
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: '
|
|
9619
|
-
error_description: '
|
|
11666
|
+
error: 'invalid_grant',
|
|
11667
|
+
error_description: 'The assertion client_id does not match the authenticated client',
|
|
9620
11668
|
});
|
|
9621
11669
|
});
|
|
9622
11670
|
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
const
|
|
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: '
|
|
9631
|
-
error_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
|
-
|
|
9655
|
-
|
|
9656
|
-
const
|
|
9657
|
-
const res = await
|
|
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: '
|
|
9662
|
-
error_description: 'The
|
|
11691
|
+
error: 'invalid_grant',
|
|
11692
|
+
error_description: 'The assertion has expired',
|
|
9663
11693
|
});
|
|
9664
11694
|
});
|
|
9665
11695
|
|
|
9666
|
-
it('should
|
|
9667
|
-
|
|
9668
|
-
|
|
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(
|
|
9671
|
-
expect((await res.json()).
|
|
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
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
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
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9704
|
-
|
|
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
|
-
|
|
9726
|
-
|
|
9727
|
-
|
|
9728
|
-
const
|
|
9729
|
-
const
|
|
9730
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
9747
|
-
|
|
9748
|
-
|
|
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: '
|
|
9759
|
-
error_description:
|
|
11738
|
+
error: 'invalid_grant',
|
|
11739
|
+
error_description: 'The assertion act claim is malformed',
|
|
9760
11740
|
});
|
|
9761
11741
|
});
|
|
9762
11742
|
|
|
9763
|
-
it('should
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
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: '
|
|
9773
|
-
error_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
|
|
9824
|
-
const
|
|
9825
|
-
|
|
9826
|
-
|
|
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
|
|
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
|