@maronn-openid-connect/cli 0.2.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 +5 -0
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +2561 -250
- 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 +67 -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
|
|
|
@@ -3729,6 +3858,7 @@ import {
|
|
|
3729
3858
|
TokenExchangeError,
|
|
3730
3859
|
buildTokenExchangeResponse,
|
|
3731
3860
|
processTokenExchangeRequest,
|
|
3861
|
+
type ExchangedAccessTokenInfo,
|
|
3732
3862
|
} from '${EXPERIMENTAL_PACKAGE}/token-exchange';`
|
|
3733
3863
|
: '';
|
|
3734
3864
|
const tokenExchangeConfigBlock = features.tokenExchange
|
|
@@ -3804,14 +3934,20 @@ export const tokenExchangeConfig = {
|
|
|
3804
3934
|
issuedAt: exchangeIssuedAt,
|
|
3805
3935
|
});
|
|
3806
3936
|
const exchangedToken = await exchangeIssuer.issue({
|
|
3807
|
-
payload:
|
|
3937
|
+
payload: {
|
|
3938
|
+
...exchangePayload,
|
|
3939
|
+
// RFC 8693 §4.1: a delegation exchange records the current actor in
|
|
3940
|
+
// the act claim (chains already nested by processTokenExchangeRequest).
|
|
3941
|
+
// Impersonation exchanges carry no act claim.
|
|
3942
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
3943
|
+
},
|
|
3808
3944
|
privateKey: c.get('privateKey'),
|
|
3809
3945
|
keyId: c.get('keyId'),
|
|
3810
3946
|
});
|
|
3811
3947
|
|
|
3812
|
-
|
|
3813
|
-
// RFC 8693 §1.1:
|
|
3814
|
-
//
|
|
3948
|
+
const exchangeMetadata: ExchangedAccessTokenInfo = {
|
|
3949
|
+
// RFC 8693 §1.1: the exchanged token acts as the same subject, but is
|
|
3950
|
+
// bound to the client that requested the exchange.
|
|
3815
3951
|
sub: grant.subject,
|
|
3816
3952
|
clientId: grant.clientId,
|
|
3817
3953
|
scope: grant.scope,
|
|
@@ -3827,10 +3963,14 @@ export const tokenExchangeConfig = {
|
|
|
3827
3963
|
// so it is a distinct store record even when it is exchanged twice from
|
|
3828
3964
|
// the same subject_token within one second.
|
|
3829
3965
|
jti: exchangePayload.jti,
|
|
3966
|
+
// Persisting act lets a later exchange that presents THIS token as its
|
|
3967
|
+
// subject_token pick up the chain (RFC 8693 §4.1 nesting).
|
|
3968
|
+
...(grant.actor === undefined ? {} : { act: grant.actor }),
|
|
3830
3969
|
// The subject token's stored claims parameter (OIDC Core 1.0 §5.5) is
|
|
3831
3970
|
// deliberately NOT inherited: an exchanged token yields scope-based
|
|
3832
3971
|
// claims only at the UserInfo endpoint.
|
|
3833
|
-
}
|
|
3972
|
+
};
|
|
3973
|
+
await accessTokenStore.set(exchangedToken, exchangeMetadata);
|
|
3834
3974
|
|
|
3835
3975
|
// RFC 6749 §5.1: token responses MUST NOT be cached.
|
|
3836
3976
|
c.header('Cache-Control', 'no-store');
|
|
@@ -4078,6 +4218,349 @@ ${deviceRefreshTokenBlock}
|
|
|
4078
4218
|
error.statusCode,
|
|
4079
4219
|
);
|
|
4080
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
|
+
}
|
|
4081
4564
|
`
|
|
4082
4565
|
: '';
|
|
4083
4566
|
return `import { Hono } from 'hono';
|
|
@@ -4121,8 +4604,8 @@ import {
|
|
|
4121
4604
|
accessTokenStore as defaultAccessTokenStore,
|
|
4122
4605
|
authCodeStore as defaultAuthCodeStore,${refreshStoreImport}
|
|
4123
4606
|
} from '../store.js';
|
|
4124
|
-
import type { RegisteredClient } from '../config.js';${tokenExchangeImports}${deviceGrantImports}
|
|
4125
|
-
${tokenExchangeConfigBlock}
|
|
4607
|
+
import type { RegisteredClient } from '../config.js';${tokenExchangeImports}${idJagImports}${deviceGrantImports}
|
|
4608
|
+
${tokenExchangeConfigBlock}${idJagConfigBlock}
|
|
4126
4609
|
export const tokenApp = new Hono<{ Variables: Record<string, any> }>();
|
|
4127
4610
|
|
|
4128
4611
|
/**
|
|
@@ -4232,7 +4715,7 @@ ${refreshStoreConst}
|
|
|
4232
4715
|
await verifyClientSecret(tokenClient, presentedCredentials.clientSecret);
|
|
4233
4716
|
|
|
4234
4717
|
const authenticatedClientId = presentedCredentials.clientId;
|
|
4235
|
-
${tokenExchangeDispatchStep}${deviceCodeDispatchStep}
|
|
4718
|
+
${idJagIssuanceDispatchStep}${idJagRedemptionDispatchStep}${tokenExchangeDispatchStep}${deviceCodeDispatchStep}
|
|
4236
4719
|
// --- Token request validation pipeline --------------------------------
|
|
4237
4720
|
// Each step below is an independent core function, called in the same order
|
|
4238
4721
|
// as core's validateTokenRequest(). Delete a call to drop that validation,
|
|
@@ -4467,7 +4950,7 @@ ${refreshTokenPersistenceBlock} c.header('Cache-Control', 'no-store');
|
|
|
4467
4950
|
c.header('Pragma', 'no-cache');
|
|
4468
4951
|
return c.json(tokenResponse);
|
|
4469
4952
|
} catch (error) {
|
|
4470
|
-
${tokenExchangeCatchBranch}${deviceGrantCatchBranch} if (error instanceof TokenError) {
|
|
4953
|
+
${idJagCatchBranch}${tokenExchangeCatchBranch}${deviceGrantCatchBranch} if (error instanceof TokenError) {
|
|
4471
4954
|
const status = error.statusCode as 400 | 401;
|
|
4472
4955
|
// RFC 6750 Section 3 / OAuth 2.1 Section 5.2: 401 responses include WWW-Authenticate
|
|
4473
4956
|
if (error.wwwAuthenticate) {
|
|
@@ -4830,7 +5313,10 @@ export function discoveryRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
|
4830
5313
|
const supportedGrantTypes = [
|
|
4831
5314
|
`'authorization_code'`,
|
|
4832
5315
|
...(features.refreshToken ? [`'refresh_token'`] : []),
|
|
4833
|
-
...(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'`] : []),
|
|
4834
5320
|
...(features.deviceAuthorizationGrant
|
|
4835
5321
|
? [`'urn:ietf:params:oauth:grant-type:device_code'`]
|
|
4836
5322
|
: []),
|
|
@@ -4916,6 +5402,16 @@ import { parConfig } from './par.js';`
|
|
|
4916
5402
|
// EXPERIMENTAL — RFC 8628 §4 metadata.
|
|
4917
5403
|
device_authorization_endpoint: \`\${issuer}/device_authorization\`,`
|
|
4918
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
|
+
: '';
|
|
4919
5415
|
return `import { Hono } from 'hono';
|
|
4920
5416
|
import { buildProviderMetadata, getJwaAlgorithm, type SigningKey } from '${corePkg}';
|
|
4921
5417
|
import { defaultProviderConfig } from '../config.js';${parDiscoveryImport}
|
|
@@ -5028,7 +5524,7 @@ ${rfc8414Comment}${introspectionMetadata}${revocationMetadata} });
|
|
|
5028
5524
|
// not in OIDC Discovery, so it is added separately.
|
|
5029
5525
|
return c.json({
|
|
5030
5526
|
...metadata,
|
|
5031
|
-
code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${jarmDiscoveryMetadata}
|
|
5527
|
+
code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${jarmDiscoveryMetadata}${idJagDiscoveryMetadata}
|
|
5032
5528
|
});
|
|
5033
5529
|
});
|
|
5034
5530
|
`;
|
|
@@ -5121,6 +5617,7 @@ import {
|
|
|
5121
5617
|
parseSessionId,${bindingStoreImport}
|
|
5122
5618
|
userStore,
|
|
5123
5619
|
} from '../store.js';
|
|
5620
|
+
import { defaultProviderConfig } from '../config.js';
|
|
5124
5621
|
import { defaultViews, renderView } from '../views.js';
|
|
5125
5622
|
|
|
5126
5623
|
export const loginApp = new Hono<{ Variables: Record<string, any> }>();
|
|
@@ -5204,12 +5701,6 @@ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
|
|
|
5204
5701
|
|
|
5205
5702
|
const authTime = Math.floor(Date.now() / 1000);
|
|
5206
5703
|
|
|
5207
|
-
// Store authenticated subject for the consent step (per-transaction handoff).
|
|
5208
|
-
await authSessionStore.set(transactionId, {
|
|
5209
|
-
subject: user.sub,
|
|
5210
|
-
authTime,
|
|
5211
|
-
});
|
|
5212
|
-
|
|
5213
5704
|
// Establish a persistent browser (OP) session and set the session cookie so
|
|
5214
5705
|
// SSO / prompt=none / max_age work on subsequent authorization requests
|
|
5215
5706
|
// (OIDC Core 1.0 Section 3.1.2.3).
|
|
@@ -5217,8 +5708,21 @@ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
|
|
|
5217
5708
|
await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
|
|
5218
5709
|
c.header('Set-Cookie', buildSessionCookie(sessionId));
|
|
5219
5710
|
|
|
5220
|
-
//
|
|
5221
|
-
|
|
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);
|
|
5222
5726
|
consentUrl.searchParams.set('transaction_id', transactionId);
|
|
5223
5727
|
return c.redirect(consentUrl.toString());
|
|
5224
5728
|
});
|
|
@@ -5455,7 +5959,6 @@ import {
|
|
|
5455
5959
|
createAuthorizationCode,${jarmConsentCoreImports}
|
|
5456
5960
|
} from '${corePkg}';
|
|
5457
5961
|
import {
|
|
5458
|
-
clientResolver as defaultClientResolver,
|
|
5459
5962
|
consentResolver as defaultConsentResolver,
|
|
5460
5963
|
} from '../resolvers.js';
|
|
5461
5964
|
import {
|
|
@@ -5503,7 +6006,6 @@ consentApp.post('/', async (c) => {
|
|
|
5503
6006
|
const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
|
|
5504
6007
|
const authCodeStore = c.get('authCodeStore') ?? defaultAuthCodeStore;
|
|
5505
6008
|
const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
|
|
5506
|
-
const clientResolver = c.get('clientResolver') ?? defaultClientResolver;
|
|
5507
6009
|
|
|
5508
6010
|
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
5509
6011
|
${bindingCheckBeforeConsentCsrf} validateCsrfToken(transaction, csrfToken);
|
|
@@ -5515,6 +6017,27 @@ ${bindingCheckBeforeConsentCsrf} validateCsrfToken(transaction, csrfToken);
|
|
|
5515
6017
|
|
|
5516
6018
|
${consentDenyRedirect}
|
|
5517
6019
|
|
|
6020
|
+
// OIDC Core 1.0 Section 3.1.2.4: "the Authorization Server MUST obtain an
|
|
6021
|
+
// authorization decision before releasing information to the Relying Party."
|
|
6022
|
+
// The affirmative decision is therefore detected on an allowlist: a missing,
|
|
6023
|
+
// empty or unknown 'action' means no decision was obtained, so it must not
|
|
6024
|
+
// approve. Deciding by "not deny" would approve every unexpected value instead.
|
|
6025
|
+
//
|
|
6026
|
+
// 'approve' is the decision value this provider accepts, and it MUST stay in
|
|
6027
|
+
// sync with the Approve button in views.ts consentPage(). Changing it here
|
|
6028
|
+
// without changing the button (or the other way round) makes every approval
|
|
6029
|
+
// fail with the 400 below.
|
|
6030
|
+
//
|
|
6031
|
+
// Section 3.1.2.6: access_denied means the End-User denied the request, which
|
|
6032
|
+
// is not the same as no decision at all — an unrecognized value stops here on
|
|
6033
|
+
// the OP's own error page instead of being redirected back to the client.
|
|
6034
|
+
if (action !== 'approve') {
|
|
6035
|
+
return renderView(views.errorPage({
|
|
6036
|
+
error: 'Invalid consent decision. Please use the Approve or Deny button.',
|
|
6037
|
+
statusCode: 400,
|
|
6038
|
+
}), { status: 400 });
|
|
6039
|
+
}
|
|
6040
|
+
|
|
5518
6041
|
const session = await authSessionStore.get(transactionId);
|
|
5519
6042
|
if (!session) {
|
|
5520
6043
|
return renderView(views.errorPage({
|
|
@@ -5529,12 +6052,10 @@ ${consentDenyRedirect}
|
|
|
5529
6052
|
transactionStore,
|
|
5530
6053
|
);
|
|
5531
6054
|
|
|
5532
|
-
//
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
return Boolean(s);
|
|
5537
|
-
});
|
|
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);
|
|
5538
6059
|
|
|
5539
6060
|
// Generate authorization code via core helper
|
|
5540
6061
|
// OIDC Core 1.0 Section 3.1.3.1: TTL is configurable via ProviderConfig
|
|
@@ -5543,6 +6064,9 @@ ${consentDenyRedirect}
|
|
|
5543
6064
|
authorizationResponse: { ...responseParams, scope: grantedScope },
|
|
5544
6065
|
subject: session.subject,
|
|
5545
6066
|
authTime: session.authTime,
|
|
6067
|
+
// online refresh token をこのログインセッションへ束縛する(login route が
|
|
6068
|
+
// authSessionStore へ載せた値)。ログアウトすれば RT も使えなくなる。
|
|
6069
|
+
sessionId: session.sessionId,
|
|
5546
6070
|
ttlSeconds: config.authorizationCodeTtl,
|
|
5547
6071
|
});
|
|
5548
6072
|
await authCodeStore.set(authCodeData.code, authCodeData);
|
|
@@ -5618,7 +6142,8 @@ import { deviceApp } from './routes/device.js';\n`
|
|
|
5618
6142
|
? ` deviceAuthorizationStore,\n`
|
|
5619
6143
|
: '';
|
|
5620
6144
|
const refreshStorageContext = features.refreshToken
|
|
5621
|
-
? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver)
|
|
6145
|
+
? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);
|
|
6146
|
+
c.set('authenticationSessionResolver', storeResolvers.authenticationSessionResolver);\n`
|
|
5622
6147
|
: '';
|
|
5623
6148
|
const introspectionStorageContext = features.introspection
|
|
5624
6149
|
? ` c.set('introspectionAccessTokenResolver', storeResolvers.introspectionAccessTokenResolver);
|
|
@@ -6494,6 +7019,12 @@ function defaultLoginPage(params: LoginPageParams): string {
|
|
|
6494
7019
|
</html>\`;
|
|
6495
7020
|
}
|
|
6496
7021
|
|
|
7022
|
+
// The submit buttons below carry the authorization decision (OIDC Core 1.0
|
|
7023
|
+
// Section 3.1.2.4). The consent handler accepts exactly two values — 'approve'
|
|
7024
|
+
// and 'deny' — and rejects everything else with 400, so customizing this markup
|
|
7025
|
+
// must keep both button values as they are: renaming 'approve' makes every
|
|
7026
|
+
// approval fail, and renaming 'deny' makes the Deny button rejected as well.
|
|
7027
|
+
// See routes/consent.ts (Next.js: consent/page.tsx and consent/actions.ts).
|
|
6497
7028
|
function defaultConsentPage(params: ConsentPageParams): string {
|
|
6498
7029
|
// Every string interpolated into HTML is escaped, including values that are
|
|
6499
7030
|
// server-generated by the default stores: users may replace stores/views.
|
|
@@ -7657,7 +8188,6 @@ function deviceAuthorizationConformanceClients(features) {
|
|
|
7657
8188
|
responseTypes: ['code'],
|
|
7658
8189
|
grantTypes: ${deviceGrantTypes},
|
|
7659
8190
|
tokenEndpointAuthMethod: 'client_secret_post',
|
|
7660
|
-
offlineAccessAllowed: true,
|
|
7661
8191
|
}],
|
|
7662
8192
|
['c-device-other', {
|
|
7663
8193
|
clientId: 'c-device-other',
|
|
@@ -7708,6 +8238,46 @@ function tokenExchangeConformanceClients(features) {
|
|
|
7708
8238
|
}],
|
|
7709
8239
|
`;
|
|
7710
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
|
+
}
|
|
7711
8281
|
export function authorizationCodeConformanceHelper(features) {
|
|
7712
8282
|
if (!features.introspection)
|
|
7713
8283
|
return '';
|
|
@@ -7807,12 +8377,13 @@ export function conformanceTestClientsBlock(features) {
|
|
|
7807
8377
|
grantTypes: ['authorization_code'],
|
|
7808
8378
|
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
7809
8379
|
}],
|
|
7810
|
-
${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}]);
|
|
8380
|
+
${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClients(features)}${idJagConformanceClients(features)}]);
|
|
7811
8381
|
`;
|
|
7812
8382
|
}
|
|
7813
8383
|
return `const testClients = new Map<string, RegisteredClient>([
|
|
7814
|
-
//
|
|
7815
|
-
//
|
|
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.
|
|
7816
8387
|
['c-conf', {
|
|
7817
8388
|
clientId: 'c-conf',
|
|
7818
8389
|
clientSecret: 's',
|
|
@@ -7821,7 +8392,6 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
|
|
|
7821
8392
|
responseTypes: ['code'],
|
|
7822
8393
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
7823
8394
|
tokenEndpointAuthMethod: 'client_secret_post',
|
|
7824
|
-
offlineAccessAllowed: true,
|
|
7825
8395
|
}],
|
|
7826
8396
|
['c-public', {
|
|
7827
8397
|
clientId: 'c-public',
|
|
@@ -7830,7 +8400,6 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
|
|
|
7830
8400
|
responseTypes: ['code'],
|
|
7831
8401
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
7832
8402
|
tokenEndpointAuthMethod: 'none',
|
|
7833
|
-
offlineAccessAllowed: true,
|
|
7834
8403
|
}],
|
|
7835
8404
|
// A confidential client registered for client_secret_basic so the conformance
|
|
7836
8405
|
// suite can drive Authorization: Basic authentication (RFC 6749 §2.3.1).
|
|
@@ -7842,9 +8411,20 @@ ${tokenExchangeConformanceClients(features)}${deviceAuthorizationConformanceClie
|
|
|
7842
8411
|
responseTypes: ['code'],
|
|
7843
8412
|
grantTypes: ['authorization_code', 'refresh_token'],
|
|
7844
8413
|
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
7845
|
-
offlineAccessAllowed: true,
|
|
7846
8414
|
}],
|
|
7847
|
-
|
|
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)}]);
|
|
7848
8428
|
`;
|
|
7849
8429
|
}
|
|
7850
8430
|
export function scopesSupportedConformanceTest(features) {
|
|
@@ -8493,6 +9073,148 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
8493
9073
|
|
|
8494
9074
|
`;
|
|
8495
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
|
+
}
|
|
8496
9218
|
export function endpointBehaviorConformanceBlock(features, includeHonoApplyParity = false) {
|
|
8497
9219
|
const introspectionMethodTest = features.introspection
|
|
8498
9220
|
? `
|
|
@@ -9066,23 +9788,321 @@ export function consentWithdrawalConformanceBlock(features) {
|
|
|
9066
9788
|
});
|
|
9067
9789
|
`;
|
|
9068
9790
|
}
|
|
9069
|
-
export function
|
|
9070
|
-
return
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
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() {
|
|
9829
|
+
const values = new Map<string, unknown>();
|
|
9830
|
+
const backend: JsonStoreBackend = {
|
|
9831
|
+
async get<T>(key: string): Promise<T | null> {
|
|
9832
|
+
return (values.get(key) as T | undefined) ?? null;
|
|
9833
|
+
},
|
|
9834
|
+
async put<T>(key: string, value: T): Promise<void> {
|
|
9835
|
+
values.set(key, value);
|
|
9836
|
+
},
|
|
9837
|
+
async delete(key: string): Promise<void> {
|
|
9838
|
+
values.delete(key);
|
|
9839
|
+
},
|
|
9840
|
+
async list<T>(prefix: string): Promise<Array<{ key: string; value: T }>> {
|
|
9841
|
+
return [...values.entries()]
|
|
9842
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
9843
|
+
.map(([key, value]) => ({ key, value: value as T }));
|
|
9844
|
+
},
|
|
9845
|
+
};
|
|
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))
|
|
9086
10106
|
.map(([key, value]) => ({ key, value: value as T }));
|
|
9087
10107
|
},
|
|
9088
10108
|
};
|
|
@@ -9117,9 +10137,11 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9117
10137
|
const PKCE_CHALLENGE_S256 = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
9118
10138
|
const EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
|
9119
10139
|
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
|
9120
|
-
// The exchange rejects every kind of unusable subject_token
|
|
9121
|
-
// description so the response cannot be used as an existence
|
|
10140
|
+
// The exchange rejects every kind of unusable subject_token / actor_token
|
|
10141
|
+
// with one description each, so the response cannot be used as an existence
|
|
10142
|
+
// oracle.
|
|
9122
10143
|
const SUBJECT_INVALID_DESCRIPTION = 'The provided subject_token is not valid';
|
|
10144
|
+
const ACTOR_INVALID_DESCRIPTION = 'The provided actor_token is not valid';
|
|
9123
10145
|
const TARGET_REJECTED_DESCRIPTION =
|
|
9124
10146
|
'The requested target is not allowed for token exchange';
|
|
9125
10147
|
|
|
@@ -9151,9 +10173,24 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9151
10173
|
});
|
|
9152
10174
|
}
|
|
9153
10175
|
|
|
10176
|
+
// Decode a JWT access token's payload (base64url, RFC 7515 §2) so the act
|
|
10177
|
+
// claim of a delegated token can be pinned. The generated default issues
|
|
10178
|
+
// JWT access tokens (config.accessTokenFormat: 'jwt').
|
|
10179
|
+
function decodeJwtPayload(token: string): Record<string, unknown> {
|
|
10180
|
+
const segment = token.split('.')[1] ?? '';
|
|
10181
|
+
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
10182
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
10183
|
+
return JSON.parse(atob(padded)) as Record<string, unknown>;
|
|
10184
|
+
}
|
|
10185
|
+
|
|
9154
10186
|
// Drive authorize -> login -> consent over HTTP and hand back the code. No
|
|
9155
10187
|
// assertions and no branching here: the flow contract lives in the it()s.
|
|
9156
|
-
async function authorizeFlow(
|
|
10188
|
+
async function authorizeFlow(
|
|
10189
|
+
clientId: string,
|
|
10190
|
+
scope: string,
|
|
10191
|
+
claims?: string,
|
|
10192
|
+
username = 'testuser',
|
|
10193
|
+
): Promise<string> {
|
|
9157
10194
|
const authorizeUrl =
|
|
9158
10195
|
'/authorize?response_type=code&client_id=' + clientId +
|
|
9159
10196
|
'&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
|
|
@@ -9179,7 +10216,7 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9179
10216
|
body: new URLSearchParams({
|
|
9180
10217
|
transaction_id: transactionId,
|
|
9181
10218
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
9182
|
-
username
|
|
10219
|
+
username,
|
|
9183
10220
|
password: 'password',
|
|
9184
10221
|
}).toString(),
|
|
9185
10222
|
});
|
|
@@ -9204,8 +10241,9 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9204
10241
|
scope: string,
|
|
9205
10242
|
clientId = 'c-exchange',
|
|
9206
10243
|
claims?: string,
|
|
10244
|
+
username = 'testuser',
|
|
9207
10245
|
): Promise<string> {
|
|
9208
|
-
const code = await authorizeFlow(clientId, scope, claims);
|
|
10246
|
+
const code = await authorizeFlow(clientId, scope, claims, username);
|
|
9209
10247
|
const res = await postToken({
|
|
9210
10248
|
client_id: clientId,
|
|
9211
10249
|
...(clientId === 'c-public-exchange' ? {} : { client_secret: 's' }),
|
|
@@ -9217,6 +10255,13 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9217
10255
|
return ((await res.json()) as Record<string, string>).access_token;
|
|
9218
10256
|
}
|
|
9219
10257
|
|
|
10258
|
+
// An actor_token with a sub distinct from the subject: the second seeded
|
|
10259
|
+
// user runs the same flow, so delegation tests can tell subject and actor
|
|
10260
|
+
// apart in the act claim.
|
|
10261
|
+
function actorTokenFor(scope: string): Promise<string> {
|
|
10262
|
+
return subjectTokenFor(scope, 'c-exchange', undefined, 'otheruser');
|
|
10263
|
+
}
|
|
10264
|
+
|
|
9220
10265
|
describe('Successful exchange', () => {
|
|
9221
10266
|
it('should return every RFC 8693 §2.2.1 response member for a scope-narrowing exchange', async () => {
|
|
9222
10267
|
const subjectToken = await subjectTokenFor('openid profile email');
|
|
@@ -9446,208 +10491,1294 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
9446
10491
|
});
|
|
9447
10492
|
});
|
|
9448
10493
|
|
|
9449
|
-
//
|
|
9450
|
-
it('should reject
|
|
9451
|
-
const subjectToken = await subjectTokenFor('openid');
|
|
9452
|
-
const res = await exchangeRequest({
|
|
9453
|
-
subject_token: subjectToken,
|
|
9454
|
-
actor_token: subjectToken,
|
|
9455
|
-
|
|
9456
|
-
|
|
10494
|
+
// RFC 8693 §2.1: actor_token_type is REQUIRED when actor_token is present.
|
|
10495
|
+
it('should reject actor_token without actor_token_type', async () => {
|
|
10496
|
+
const subjectToken = await subjectTokenFor('openid');
|
|
10497
|
+
const res = await exchangeRequest({
|
|
10498
|
+
subject_token: subjectToken,
|
|
10499
|
+
actor_token: subjectToken,
|
|
10500
|
+
});
|
|
10501
|
+
|
|
10502
|
+
expect(res.status).toBe(400);
|
|
10503
|
+
expect(await res.json()).toEqual({
|
|
10504
|
+
error: 'invalid_request',
|
|
10505
|
+
error_description: 'actor_token_type is required when actor_token is present',
|
|
10506
|
+
});
|
|
10507
|
+
});
|
|
10508
|
+
|
|
10509
|
+
// RFC 8693 §2.1: actor_token_type MUST NOT be included without actor_token.
|
|
10510
|
+
it('should reject actor_token_type without actor_token', async () => {
|
|
10511
|
+
const subjectToken = await subjectTokenFor('openid');
|
|
10512
|
+
const res = await exchangeRequest({
|
|
10513
|
+
subject_token: subjectToken,
|
|
10514
|
+
actor_token_type: ACCESS_TOKEN_TYPE,
|
|
10515
|
+
});
|
|
10516
|
+
|
|
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');
|
|
11590
|
+
});
|
|
11591
|
+
|
|
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;
|
|
11596
|
+
|
|
11597
|
+
const res = await app.request('/userinfo', {
|
|
11598
|
+
headers: { Authorization: 'Bearer ' + accessToken },
|
|
11599
|
+
});
|
|
11600
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
11601
|
+
|
|
11602
|
+
expect(res.status).toBe(200);
|
|
11603
|
+
expect(body.sub).toBe('testuser');
|
|
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 }));
|
|
11612
|
+
|
|
11613
|
+
expect(first.status).toBe(200);
|
|
11614
|
+
expect(second.status).toBe(200);
|
|
11615
|
+
});
|
|
11616
|
+
|
|
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);
|
|
11633
|
+
});
|
|
11634
|
+
|
|
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 });
|
|
9457
11639
|
|
|
9458
11640
|
expect(res.status).toBe(400);
|
|
9459
11641
|
expect(await res.json()).toEqual({
|
|
9460
|
-
error: '
|
|
9461
|
-
error_description:
|
|
9462
|
-
'Delegation is not supported: actor_token and actor_token_type must not be present.',
|
|
11642
|
+
error: 'invalid_grant',
|
|
11643
|
+
error_description: XAA_ASSERTION_UNTRUSTED_DESCRIPTION,
|
|
9463
11644
|
});
|
|
9464
11645
|
});
|
|
9465
11646
|
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
const
|
|
9469
|
-
const res = await exchangeRequest({ subject_token: subjectToken, resource: '/api' });
|
|
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 }));
|
|
9470
11650
|
|
|
9471
11651
|
expect(res.status).toBe(400);
|
|
9472
11652
|
expect(await res.json()).toEqual({
|
|
9473
|
-
error: '
|
|
9474
|
-
error_description: '
|
|
11653
|
+
error: 'invalid_grant',
|
|
11654
|
+
error_description: 'The assertion audience does not match this authorization server',
|
|
9475
11655
|
});
|
|
9476
11656
|
});
|
|
9477
11657
|
|
|
9478
|
-
it('should reject
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
});
|
|
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'));
|
|
9484
11663
|
|
|
9485
11664
|
expect(res.status).toBe(400);
|
|
9486
11665
|
expect(await res.json()).toEqual({
|
|
9487
|
-
error: '
|
|
9488
|
-
error_description: '
|
|
11666
|
+
error: 'invalid_grant',
|
|
11667
|
+
error_description: 'The assertion client_id does not match the authenticated client',
|
|
9489
11668
|
});
|
|
9490
11669
|
});
|
|
9491
11670
|
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
const
|
|
9496
|
-
const res = await
|
|
9497
|
-
method: 'POST',
|
|
9498
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
9499
|
-
body:
|
|
9500
|
-
'client_id=c-exchange&client_secret=s&grant_type=' +
|
|
9501
|
-
encodeURIComponent(EXCHANGE_GRANT_TYPE) +
|
|
9502
|
-
'&subject_token=' + encodeURIComponent(subjectToken) +
|
|
9503
|
-
'&subject_token_type=' + encodeURIComponent(ACCESS_TOKEN_TYPE) +
|
|
9504
|
-
'&resource=https%3A%2F%2Fa.example.com&resource=https%3A%2F%2Fb.example.com',
|
|
9505
|
-
});
|
|
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 }));
|
|
9506
11676
|
|
|
9507
11677
|
expect(res.status).toBe(400);
|
|
9508
11678
|
expect(await res.json()).toEqual({
|
|
9509
|
-
error: '
|
|
9510
|
-
error_description: '
|
|
11679
|
+
error: 'invalid_grant',
|
|
11680
|
+
error_description: 'The assertion typ must be oauth-id-jag+jwt',
|
|
9511
11681
|
});
|
|
9512
11682
|
});
|
|
9513
11683
|
|
|
9514
|
-
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
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 }));
|
|
9518
11688
|
|
|
9519
11689
|
expect(res.status).toBe(400);
|
|
9520
11690
|
expect(await res.json()).toEqual({
|
|
9521
|
-
error: '
|
|
9522
|
-
error_description:
|
|
11691
|
+
error: 'invalid_grant',
|
|
11692
|
+
error_description: 'The assertion has expired',
|
|
9523
11693
|
});
|
|
9524
11694
|
});
|
|
9525
11695
|
|
|
9526
|
-
it('should
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
9531
|
-
body: new URLSearchParams({
|
|
9532
|
-
client_id: 'c-exchange',
|
|
9533
|
-
client_secret: 's',
|
|
9534
|
-
token: subjectToken,
|
|
9535
|
-
}).toString(),
|
|
9536
|
-
});
|
|
9537
|
-
const revoked = await exchangeRequest({ subject_token: subjectToken });
|
|
9538
|
-
const unknown = await exchangeRequest({ subject_token: 'not-a-real-token' });
|
|
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' });
|
|
9539
11700
|
|
|
9540
|
-
expect(
|
|
9541
|
-
expect(await
|
|
11701
|
+
expect(res.status).toBe(400);
|
|
11702
|
+
expect(((await res.json()) as Record<string, unknown>).error).toBe('unauthorized_client');
|
|
9542
11703
|
});
|
|
9543
|
-
});
|
|
9544
11704
|
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
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',
|
|
11710
|
+
});
|
|
9549
11711
|
|
|
9550
11712
|
expect(res.status).toBe(400);
|
|
9551
11713
|
expect(await res.json()).toEqual({
|
|
9552
|
-
error: '
|
|
9553
|
-
error_description: '
|
|
11714
|
+
error: 'unauthorized_client',
|
|
11715
|
+
error_description: 'Public clients are not allowed to use the jwt-bearer grant type',
|
|
9554
11716
|
});
|
|
9555
11717
|
});
|
|
9556
11718
|
|
|
9557
|
-
it('should
|
|
9558
|
-
|
|
9559
|
-
|
|
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>;
|
|
9560
11725
|
|
|
9561
11726
|
expect(res.status).toBe(200);
|
|
9562
|
-
|
|
11727
|
+
const claims = xaaDecodeJwtSegment(String(body.access_token).split('.')[1] ?? '');
|
|
11728
|
+
expect(claims.sub).toBe('testuser');
|
|
11729
|
+
expect(claims.act).toEqual({ sub: 'external-actor' });
|
|
9563
11730
|
});
|
|
9564
|
-
});
|
|
9565
11731
|
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
it('should reject an audience that is not in allowedTargets', async () => {
|
|
9570
|
-
const subjectToken = await subjectTokenFor('openid');
|
|
9571
|
-
const res = await exchangeRequest({
|
|
9572
|
-
subject_token: subjectToken,
|
|
9573
|
-
audience: 'https://internal.example.com',
|
|
9574
|
-
});
|
|
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 }));
|
|
9575
11735
|
|
|
9576
11736
|
expect(res.status).toBe(400);
|
|
9577
11737
|
expect(await res.json()).toEqual({
|
|
9578
|
-
error: '
|
|
9579
|
-
error_description:
|
|
11738
|
+
error: 'invalid_grant',
|
|
11739
|
+
error_description: 'The assertion act claim is malformed',
|
|
9580
11740
|
});
|
|
9581
11741
|
});
|
|
9582
11742
|
|
|
9583
|
-
it('should
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
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 }));
|
|
9589
11754
|
|
|
9590
11755
|
expect(res.status).toBe(400);
|
|
9591
11756
|
expect(await res.json()).toEqual({
|
|
9592
|
-
error: '
|
|
9593
|
-
error_description:
|
|
9594
|
-
});
|
|
9595
|
-
});
|
|
9596
|
-
|
|
9597
|
-
it('should issue a token for an allowed audience', async () => {
|
|
9598
|
-
const subjectToken = await subjectTokenFor('openid');
|
|
9599
|
-
tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
|
|
9600
|
-
const res = await exchangeRequest({
|
|
9601
|
-
subject_token: subjectToken,
|
|
9602
|
-
audience: 'https://internal.example.com',
|
|
11757
|
+
error: 'invalid_grant',
|
|
11758
|
+
error_description: 'An assertion issued by this authorization server cannot be redeemed here',
|
|
9603
11759
|
});
|
|
9604
|
-
const body = await res.json();
|
|
9605
|
-
tokenExchangeConfig.allowedTargets = [];
|
|
9606
|
-
|
|
9607
|
-
expect(res.status).toBe(200);
|
|
9608
|
-
expect(body.token_type).toBe('Bearer');
|
|
9609
|
-
});
|
|
9610
|
-
|
|
9611
|
-
// The UserInfo endpoint stays a permanent aud member (RFC 9068 §3), so an
|
|
9612
|
-
// exchanged token keeps working against this OP as well as the new target.
|
|
9613
|
-
it('should add the allowed audience alongside the UserInfo endpoint', async () => {
|
|
9614
|
-
const subjectToken = await subjectTokenFor('openid');
|
|
9615
|
-
tokenExchangeConfig.allowedTargets = ['https://internal.example.com'];
|
|
9616
|
-
const exchanged = (await (
|
|
9617
|
-
await exchangeRequest({
|
|
9618
|
-
subject_token: subjectToken,
|
|
9619
|
-
audience: 'https://internal.example.com',
|
|
9620
|
-
})
|
|
9621
|
-
).json()).access_token as string;
|
|
9622
|
-
tokenExchangeConfig.allowedTargets = [];
|
|
9623
|
-
const introspection = await (
|
|
9624
|
-
await app.request('/introspect', {
|
|
9625
|
-
method: 'POST',
|
|
9626
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
9627
|
-
body: new URLSearchParams({
|
|
9628
|
-
client_id: 'c-exchange',
|
|
9629
|
-
client_secret: 's',
|
|
9630
|
-
token: exchanged,
|
|
9631
|
-
}).toString(),
|
|
9632
|
-
})
|
|
9633
|
-
).json();
|
|
9634
|
-
|
|
9635
|
-
expect(introspection.aud).toEqual([
|
|
9636
|
-
'http://localhost:3000/userinfo',
|
|
9637
|
-
'https://internal.example.com',
|
|
9638
|
-
]);
|
|
9639
11760
|
});
|
|
9640
11761
|
});
|
|
9641
11762
|
|
|
9642
|
-
describe('Discovery', () => {
|
|
9643
|
-
it('should advertise
|
|
9644
|
-
const
|
|
9645
|
-
|
|
9646
|
-
|
|
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
|
+
]);
|
|
9647
11779
|
});
|
|
9648
11780
|
});
|
|
9649
11781
|
});
|
|
9650
|
-
|
|
9651
11782
|
`;
|
|
9652
11783
|
}
|
|
9653
11784
|
export function parConformanceBlock(features) {
|
|
@@ -11332,8 +13463,184 @@ ${jarmAuthorizeRouteResponsesComment} it('should answer the SSO fast path w
|
|
|
11332
13463
|
});
|
|
11333
13464
|
`;
|
|
11334
13465
|
}
|
|
13466
|
+
export function consentDecisionConformanceBlock() {
|
|
13467
|
+
return `
|
|
13468
|
+
describe('Consent decision value (OIDC Core 1.0 §3.1.2.4)', () => {
|
|
13469
|
+
const DECISION_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
13470
|
+
|
|
13471
|
+
// Pure fetch + parse helpers: no assertions and no branching, so the contract
|
|
13472
|
+
// stays visible in the it() blocks.
|
|
13473
|
+
function decisionRelativeFrom(location: string | null): string {
|
|
13474
|
+
const url = new URL(location ?? '', 'http://localhost');
|
|
13475
|
+
return url.pathname + url.search;
|
|
13476
|
+
}
|
|
13477
|
+
|
|
13478
|
+
function decisionCsrfFrom(html: string): string {
|
|
13479
|
+
return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
|
|
13480
|
+
}
|
|
13481
|
+
|
|
13482
|
+
// Drives authorize -> login -> GET /consent and returns everything the browser
|
|
13483
|
+
// holds at the consent screen, so each test only differs in the posted action.
|
|
13484
|
+
async function reachConsent(state: string): Promise<{
|
|
13485
|
+
transactionId: string;
|
|
13486
|
+
csrfToken: string;
|
|
13487
|
+
cookie: string;
|
|
13488
|
+
}> {
|
|
13489
|
+
const authorizeRes = await app.request(
|
|
13490
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
13491
|
+
'&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
|
|
13492
|
+
'&scope=openid&state=' + state + '&prompt=consent' +
|
|
13493
|
+
'&code_challenge=' + DECISION_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
13494
|
+
);
|
|
13495
|
+
const loginPath = decisionRelativeFrom(authorizeRes.headers.get('Location'));
|
|
13496
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
13497
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
13498
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
13499
|
+
// it, so the same flow works in both builds.
|
|
13500
|
+
const cookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
13501
|
+
const transactionId =
|
|
13502
|
+
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
13503
|
+
|
|
13504
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: cookie } });
|
|
13505
|
+
const loginRes = await app.request('/login', {
|
|
13506
|
+
method: 'POST',
|
|
13507
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
|
|
13508
|
+
body: new URLSearchParams({
|
|
13509
|
+
transaction_id: transactionId,
|
|
13510
|
+
csrf_token: decisionCsrfFrom(await loginGet.text()),
|
|
13511
|
+
username: 'testuser',
|
|
13512
|
+
password: 'password',
|
|
13513
|
+
}).toString(),
|
|
13514
|
+
});
|
|
13515
|
+
const consentPath = decisionRelativeFrom(loginRes.headers.get('Location'));
|
|
13516
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: cookie } });
|
|
13517
|
+
|
|
13518
|
+
return { transactionId, csrfToken: decisionCsrfFrom(await consentGet.text()), cookie };
|
|
13519
|
+
}
|
|
13520
|
+
|
|
13521
|
+
// The body is passed in whole so a test can leave 'action' out entirely
|
|
13522
|
+
// without this helper branching on it.
|
|
13523
|
+
function postConsent(cookie: string, body: Record<string, string>): Promise<Response> {
|
|
13524
|
+
return app.request('/consent', {
|
|
13525
|
+
method: 'POST',
|
|
13526
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
|
|
13527
|
+
body: new URLSearchParams(body).toString(),
|
|
13528
|
+
});
|
|
13529
|
+
}
|
|
13530
|
+
|
|
13531
|
+
// A form rebuilt by a script or a test harness carries no submit-button value.
|
|
13532
|
+
it('should not issue an authorization code when the consent POST omits the action parameter', async () => {
|
|
13533
|
+
const flow = await reachConsent('decision-omitted');
|
|
13534
|
+
|
|
13535
|
+
const res = await postConsent(flow.cookie, {
|
|
13536
|
+
transaction_id: flow.transactionId,
|
|
13537
|
+
csrf_token: flow.csrfToken,
|
|
13538
|
+
});
|
|
13539
|
+
|
|
13540
|
+
expect(res.status).toBe(400);
|
|
13541
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
13542
|
+
});
|
|
13543
|
+
|
|
13544
|
+
it('should not issue an authorization code when the consent POST sends an empty action value', async () => {
|
|
13545
|
+
const flow = await reachConsent('decision-empty');
|
|
13546
|
+
|
|
13547
|
+
const res = await postConsent(flow.cookie, {
|
|
13548
|
+
transaction_id: flow.transactionId,
|
|
13549
|
+
csrf_token: flow.csrfToken,
|
|
13550
|
+
action: '',
|
|
13551
|
+
});
|
|
13552
|
+
|
|
13553
|
+
expect(res.status).toBe(400);
|
|
13554
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
13555
|
+
});
|
|
13556
|
+
|
|
13557
|
+
// The realistic regression: the Approve button is renamed in views.ts, so the
|
|
13558
|
+
// handler receives a value it never agreed to accept.
|
|
13559
|
+
it('should not issue an authorization code when the consent POST sends an unknown action value', async () => {
|
|
13560
|
+
const flow = await reachConsent('decision-unknown');
|
|
13561
|
+
|
|
13562
|
+
const res = await postConsent(flow.cookie, {
|
|
13563
|
+
transaction_id: flow.transactionId,
|
|
13564
|
+
csrf_token: flow.csrfToken,
|
|
13565
|
+
action: 'allow',
|
|
13566
|
+
});
|
|
13567
|
+
|
|
13568
|
+
expect(res.status).toBe(400);
|
|
13569
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
13570
|
+
});
|
|
13571
|
+
|
|
13572
|
+
// OIDC Core 1.0 §3.1.2.6: access_denied means the End-User denied the request.
|
|
13573
|
+
// "No decision was obtained" is a different outcome, so it stops at the OP with
|
|
13574
|
+
// its own error page instead of being redirected to the client.
|
|
13575
|
+
it('should return 400 for a consent POST with an unrecognized action value', async () => {
|
|
13576
|
+
const flow = await reachConsent('decision-400');
|
|
13577
|
+
|
|
13578
|
+
const res = await postConsent(flow.cookie, {
|
|
13579
|
+
transaction_id: flow.transactionId,
|
|
13580
|
+
csrf_token: flow.csrfToken,
|
|
13581
|
+
action: 'accept',
|
|
13582
|
+
});
|
|
13583
|
+
const body = await res.text();
|
|
13584
|
+
|
|
13585
|
+
expect(res.status).toBe(400);
|
|
13586
|
+
expect(body.includes('Invalid consent decision. Please use the Approve or Deny button.')).toBe(true);
|
|
13587
|
+
expect(body.includes('access_denied')).toBe(false);
|
|
13588
|
+
});
|
|
13589
|
+
|
|
13590
|
+
it('should issue an authorization code when the consent POST sends action=approve', async () => {
|
|
13591
|
+
const flow = await reachConsent('decision-approve');
|
|
13592
|
+
|
|
13593
|
+
const res = await postConsent(flow.cookie, {
|
|
13594
|
+
transaction_id: flow.transactionId,
|
|
13595
|
+
csrf_token: flow.csrfToken,
|
|
13596
|
+
action: 'approve',
|
|
13597
|
+
});
|
|
13598
|
+
const callback = new URL(res.headers.get('Location') ?? '', 'http://localhost');
|
|
13599
|
+
|
|
13600
|
+
expect(res.status).toBe(302);
|
|
13601
|
+
expect(callback.origin + callback.pathname).toBe(REDIRECT_URI);
|
|
13602
|
+
expect(callback.searchParams.get('state')).toBe('decision-approve');
|
|
13603
|
+
expect(callback.searchParams.get('error')).toBe(null);
|
|
13604
|
+
expect((callback.searchParams.get('code') ?? '').length > 0).toBe(true);
|
|
13605
|
+
});
|
|
13606
|
+
|
|
13607
|
+
it('should redirect with error=access_denied when the consent POST sends action=deny', async () => {
|
|
13608
|
+
const flow = await reachConsent('decision-deny');
|
|
13609
|
+
|
|
13610
|
+
const res = await postConsent(flow.cookie, {
|
|
13611
|
+
transaction_id: flow.transactionId,
|
|
13612
|
+
csrf_token: flow.csrfToken,
|
|
13613
|
+
action: 'deny',
|
|
13614
|
+
});
|
|
13615
|
+
const callback = new URL(res.headers.get('Location') ?? '', 'http://localhost');
|
|
13616
|
+
|
|
13617
|
+
expect(res.status).toBe(302);
|
|
13618
|
+
expect(callback.origin + callback.pathname).toBe(REDIRECT_URI);
|
|
13619
|
+
expect(callback.searchParams.get('error')).toBe('access_denied');
|
|
13620
|
+
expect(callback.searchParams.get('state')).toBe('decision-deny');
|
|
13621
|
+
expect(callback.searchParams.get('code')).toBe(null);
|
|
13622
|
+
});
|
|
13623
|
+
|
|
13624
|
+
// Consent must not be persisted either: a recorded consent would let a later
|
|
13625
|
+
// prompt=none request succeed without the End-User ever having approved.
|
|
13626
|
+
it('should not record consent via recordConsent when the action value is unrecognized', async () => {
|
|
13627
|
+
await consentResolver.revokeConsent?.('testuser', 'c-conf');
|
|
13628
|
+
const flow = await reachConsent('decision-no-record');
|
|
13629
|
+
|
|
13630
|
+
const res = await postConsent(flow.cookie, {
|
|
13631
|
+
transaction_id: flow.transactionId,
|
|
13632
|
+
csrf_token: flow.csrfToken,
|
|
13633
|
+
action: 'approved',
|
|
13634
|
+
});
|
|
13635
|
+
|
|
13636
|
+
expect(res.status).toBe(400);
|
|
13637
|
+
expect(consentStore.hasConsent('testuser', 'c-conf', ['openid'])).toBe(false);
|
|
13638
|
+
});
|
|
13639
|
+
});
|
|
13640
|
+
`;
|
|
13641
|
+
}
|
|
11335
13642
|
export function conformanceTestTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
11336
|
-
const exportPublicJwkImport = features.requestObject
|
|
13643
|
+
const exportPublicJwkImport = features.requestObject || features.idJag
|
|
11337
13644
|
? `import { exportPublicJwk } from '${corePkg}';\n`
|
|
11338
13645
|
: '';
|
|
11339
13646
|
const responseModesSupportedExpectation = features.jarm
|
|
@@ -11355,16 +13662,20 @@ import { parConfig } from './routes/par.js';`
|
|
|
11355
13662
|
? `
|
|
11356
13663
|
import { tokenExchangeConfig } from './routes/token.js';`
|
|
11357
13664
|
: '';
|
|
13665
|
+
const idJagConformanceImports = features.idJag
|
|
13666
|
+
? `
|
|
13667
|
+
import { idJagConfig } from './routes/token.js';`
|
|
13668
|
+
: '';
|
|
11358
13669
|
return `import { describe, it, expect, beforeAll } from 'vitest';
|
|
11359
13670
|
import type { SigningKeyProvider, SigningKey } from '${corePkg}';
|
|
11360
13671
|
import { Hono } from 'hono';
|
|
11361
13672
|
${exportPublicJwkImport}import { createApp, validateSigningKeySet } from './app.js';
|
|
11362
13673
|
import { applyOidc } from './apply.js';
|
|
11363
13674
|
import { createInMemoryClientResolver, type RegisteredClient } from './config.js';
|
|
11364
|
-
import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores
|
|
13675
|
+
import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores,${onlineRefreshTokenConformanceStoreImport(features)} refreshTokenStore, transactionStore, type JsonStoreBackend } from './store.js';
|
|
11365
13676
|
import { consentResolver } from './resolvers.js';
|
|
11366
13677
|
import { defaultViews } from './views.js';
|
|
11367
|
-
import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}
|
|
13678
|
+
import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}${idJagConformanceImports}
|
|
11368
13679
|
|
|
11369
13680
|
/**
|
|
11370
13681
|
* HTTP conformance smoke tests for the generated OpenID Connect Provider.
|
|
@@ -11843,7 +14154,7 @@ ${introspectionConformanceBlock(features)}
|
|
|
11843
14154
|
});
|
|
11844
14155
|
});
|
|
11845
14156
|
});
|
|
11846
|
-
${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${jarmConformanceBlock(features)}});
|
|
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()}});
|
|
11847
14158
|
`;
|
|
11848
14159
|
}
|
|
11849
14160
|
//# sourceMappingURL=templates.js.map
|