@maronn-openid-connect/cli 0.0.1 → 0.1.1
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 +3 -0
- package/dist/features.d.ts.map +1 -1
- package/dist/features.js +12 -0
- package/dist/features.js.map +1 -1
- package/dist/frameworks/hono/index.js +2 -2
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +3 -2
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +778 -83
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/web-standard/templates.d.ts +4 -4
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +192 -30
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
|
@@ -495,6 +495,83 @@ export function createInMemoryClientResolver(
|
|
|
495
495
|
`;
|
|
496
496
|
}
|
|
497
497
|
export function storeTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
498
|
+
const transactionBindingHelpers = features.transactionBinding
|
|
499
|
+
? `
|
|
500
|
+
/**
|
|
501
|
+
* Auth transaction binding cookie - OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4.
|
|
502
|
+
*
|
|
503
|
+
* Why this exists: transaction_id travels in the URL, so it can leak through
|
|
504
|
+
* browser history, access logs or a shared screen. Without a second factor the
|
|
505
|
+
* OP cannot tell the browser that started the authorization request from anyone
|
|
506
|
+
* who merely knows that id, and that lets a third party read csrf_token off the
|
|
507
|
+
* consent page and finish the flow. Worse, an attacker can start a flow with
|
|
508
|
+
* their OWN client, lure the victim to /login?transaction_id=<attacker's> and
|
|
509
|
+
* have the victim's authorization code delivered to the attacker's client - a
|
|
510
|
+
* case the RP's state check cannot catch. Binding the transaction to a secret
|
|
511
|
+
* this browser holds in an HttpOnly cookie is the OP-side defense.
|
|
512
|
+
*
|
|
513
|
+
* The cookie name embeds the transaction id so two tabs can run two
|
|
514
|
+
* authorization flows at once without overwriting each other's secret. The
|
|
515
|
+
* cookie carries the raw secret; only its SHA-256 hash is stored on the
|
|
516
|
+
* transaction, so leaking the transaction store does not yield a usable cookie.
|
|
517
|
+
*/
|
|
518
|
+
export const TRANSACTION_BINDING_COOKIE_PREFIX = 'oidc_txn_';
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Build the Set-Cookie value binding a transaction to this browser.
|
|
522
|
+
* Same attributes as the session cookie: HttpOnly (no JS access), Secure
|
|
523
|
+
* (HTTPS only; http://localhost is treated as trustworthy by browsers) and
|
|
524
|
+
* SameSite=Lax, because SameSite=Strict would drop the cookie on the cross-site
|
|
525
|
+
* navigation that starts the flow. Max-Age matches the transaction TTL so
|
|
526
|
+
* abandoned flows do not leave cookies behind. When the OP is always served
|
|
527
|
+
* over HTTPS, prefixing the name with '__Host-' is recommended.
|
|
528
|
+
*/
|
|
529
|
+
export function buildTransactionBindingCookie(
|
|
530
|
+
transactionId: string,
|
|
531
|
+
bindingSecret: string,
|
|
532
|
+
ttlSeconds: number,
|
|
533
|
+
): string {
|
|
534
|
+
return (
|
|
535
|
+
TRANSACTION_BINDING_COOKIE_PREFIX + transactionId + '=' + bindingSecret +
|
|
536
|
+
'; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=' + String(ttlSeconds)
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Build the Set-Cookie value that clears a transaction binding cookie once the
|
|
542
|
+
* transaction is finished (code issued or access denied), so the browser does
|
|
543
|
+
* not accumulate one cookie per completed flow.
|
|
544
|
+
*/
|
|
545
|
+
export function buildClearedTransactionBindingCookie(transactionId: string): string {
|
|
546
|
+
return (
|
|
547
|
+
TRANSACTION_BINDING_COOKIE_PREFIX + transactionId +
|
|
548
|
+
'=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0'
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Extract the binding secret for one transaction from a Cookie request header.
|
|
554
|
+
* Returns undefined when the header is missing or this transaction's cookie is
|
|
555
|
+
* absent, which validateTransactionBinding() rejects.
|
|
556
|
+
*/
|
|
557
|
+
export function parseTransactionBindingSecret(
|
|
558
|
+
cookieHeader: string | null,
|
|
559
|
+
transactionId: string,
|
|
560
|
+
): string | undefined {
|
|
561
|
+
if (!cookieHeader) return undefined;
|
|
562
|
+
const name = TRANSACTION_BINDING_COOKIE_PREFIX + transactionId;
|
|
563
|
+
for (const part of cookieHeader.split(';')) {
|
|
564
|
+
const trimmed = part.trim();
|
|
565
|
+
const eq = trimmed.indexOf('=');
|
|
566
|
+
if (eq === -1) continue;
|
|
567
|
+
if (trimmed.slice(0, eq) === name) {
|
|
568
|
+
return trimmed.slice(eq + 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
`
|
|
574
|
+
: '';
|
|
498
575
|
const parStoreTypeImport = features.par
|
|
499
576
|
? `
|
|
500
577
|
import type {
|
|
@@ -823,7 +900,7 @@ export function parseSessionId(cookieHeader: string | null): string | undefined
|
|
|
823
900
|
export function buildSessionCookie(sessionId: string): string {
|
|
824
901
|
return SESSION_COOKIE_NAME + '=' + sessionId + '; HttpOnly; Secure; SameSite=Lax; Path=/';
|
|
825
902
|
}
|
|
826
|
-
|
|
903
|
+
${transactionBindingHelpers}
|
|
827
904
|
/**
|
|
828
905
|
* In-memory consent store. Records that a user granted a set of scopes to a
|
|
829
906
|
* client so prompt=none can confirm consent without showing UI
|
|
@@ -1600,6 +1677,44 @@ export async function revokeConsentAndTokens(subject: string, clientId: string):
|
|
|
1600
1677
|
`;
|
|
1601
1678
|
}
|
|
1602
1679
|
export function authorizeRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
1680
|
+
const bindingCoreImport = features.transactionBinding
|
|
1681
|
+
? `
|
|
1682
|
+
computeTransactionBindingHash,`
|
|
1683
|
+
: '';
|
|
1684
|
+
const bindingStoreImport = features.transactionBinding
|
|
1685
|
+
? `
|
|
1686
|
+
buildTransactionBindingCookie,`
|
|
1687
|
+
: '';
|
|
1688
|
+
const bindingSecretStep = features.transactionBinding
|
|
1689
|
+
? ` // OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4: the End-User who authenticates and
|
|
1690
|
+
// consents must be the one behind THIS User-Agent. transaction_id alone cannot
|
|
1691
|
+
// prove that (it rides in the URL and can leak), so a secret is handed to this
|
|
1692
|
+
// browser in an HttpOnly cookie and only its hash is kept on the transaction.
|
|
1693
|
+
// See buildTransactionBindingCookie() in store.ts for the threat this closes.
|
|
1694
|
+
const bindingSecret = await generateRandomString(32);
|
|
1695
|
+
const transaction = createAuthTransaction(validatedRequest, csrfToken, {
|
|
1696
|
+
bindingHash: await computeTransactionBindingHash(bindingSecret),
|
|
1697
|
+
});
|
|
1698
|
+
`
|
|
1699
|
+
: ` const transaction = createAuthTransaction(validatedRequest, csrfToken);
|
|
1700
|
+
`;
|
|
1701
|
+
const bindingCookieOnConsentRedirect = features.transactionBinding
|
|
1702
|
+
? ` // Hand the binding secret to this browser before the interactive steps.
|
|
1703
|
+
// Only paths that continue in the browser get the cookie; paths that
|
|
1704
|
+
// redirect straight back to the client never needed one.
|
|
1705
|
+
c.header(
|
|
1706
|
+
'Set-Cookie',
|
|
1707
|
+
buildTransactionBindingCookie(transactionId, bindingSecret, transactionTtlSeconds),
|
|
1708
|
+
);
|
|
1709
|
+
`
|
|
1710
|
+
: '';
|
|
1711
|
+
const bindingCookieOnLoginRedirect = features.transactionBinding
|
|
1712
|
+
? ` c.header(
|
|
1713
|
+
'Set-Cookie',
|
|
1714
|
+
buildTransactionBindingCookie(transactionId, bindingSecret, transactionTtlSeconds),
|
|
1715
|
+
);
|
|
1716
|
+
`
|
|
1717
|
+
: '';
|
|
1603
1718
|
const requestObjectImports = features.requestObject
|
|
1604
1719
|
? `
|
|
1605
1720
|
resolveRequestObjectParams,
|
|
@@ -1736,7 +1851,7 @@ import {
|
|
|
1736
1851
|
parseAudienceParameter,
|
|
1737
1852
|
parseClaimsRequestParameter,
|
|
1738
1853
|
validateIdTokenHint,
|
|
1739
|
-
createAuthTransaction
|
|
1854
|
+
createAuthTransaction,${bindingCoreImport}
|
|
1740
1855
|
createAuthorizationCode,
|
|
1741
1856
|
completeAuthTransaction,
|
|
1742
1857
|
generateRandomString,
|
|
@@ -1754,7 +1869,7 @@ import { clientResolver as defaultClientResolver } from '../resolvers.js';
|
|
|
1754
1869
|
import {
|
|
1755
1870
|
transactionStore as defaultTransactionStore,
|
|
1756
1871
|
authCodeStore as defaultAuthCodeStore,
|
|
1757
|
-
authSessionStore as defaultAuthSessionStore
|
|
1872
|
+
authSessionStore as defaultAuthSessionStore,${bindingStoreImport}
|
|
1758
1873
|
} from '../store.js';
|
|
1759
1874
|
import { defaultViews, renderView } from '../views.js';${parImports}
|
|
1760
1875
|
|
|
@@ -1958,14 +2073,14 @@ ${offlineAccessStep}
|
|
|
1958
2073
|
|
|
1959
2074
|
// Create authentication transaction
|
|
1960
2075
|
const csrfToken = await generateRandomString(32);
|
|
1961
|
-
const
|
|
1962
|
-
const transactionId = await generateRandomString(32);
|
|
2076
|
+
${bindingSecretStep} const transactionId = await generateRandomString(32);
|
|
1963
2077
|
|
|
1964
2078
|
// Store transaction
|
|
2079
|
+
const transactionTtlSeconds = 10 * 60; // 10 minutes TTL
|
|
1965
2080
|
await transactionStore.put(
|
|
1966
2081
|
'auth_txn:' + transactionId,
|
|
1967
2082
|
transaction,
|
|
1968
|
-
|
|
2083
|
+
transactionTtlSeconds,
|
|
1969
2084
|
);
|
|
1970
2085
|
|
|
1971
2086
|
// OIDC Core 1.0 Section 3.1.2.1: prompt is a space-delimited list
|
|
@@ -2179,7 +2294,7 @@ ${offlineAccessStep}
|
|
|
2179
2294
|
subject: existingSession.subject,
|
|
2180
2295
|
authTime: existingSession.authTime,
|
|
2181
2296
|
});
|
|
2182
|
-
const consentUrl = new URL('/consent', c.req.url);
|
|
2297
|
+
${bindingCookieOnConsentRedirect} const consentUrl = new URL('/consent', c.req.url);
|
|
2183
2298
|
consentUrl.searchParams.set('transaction_id', transactionId);
|
|
2184
2299
|
return c.redirect(consentUrl.toString());
|
|
2185
2300
|
}
|
|
@@ -2187,7 +2302,7 @@ ${offlineAccessStep}
|
|
|
2187
2302
|
}
|
|
2188
2303
|
|
|
2189
2304
|
// Redirect to login page (prompt=login forces re-authentication; handled in login route)
|
|
2190
|
-
const loginUrl = new URL('/login', c.req.url);
|
|
2305
|
+
${bindingCookieOnLoginRedirect} const loginUrl = new URL('/login', c.req.url);
|
|
2191
2306
|
loginUrl.searchParams.set('transaction_id', transactionId);
|
|
2192
2307
|
return c.redirect(loginUrl.toString());
|
|
2193
2308
|
} catch (error) {
|
|
@@ -3713,11 +3828,83 @@ ${rfc8414Comment}${introspectionMetadata}${revocationMetadata} });
|
|
|
3713
3828
|
});
|
|
3714
3829
|
`;
|
|
3715
3830
|
}
|
|
3716
|
-
export function loginRouteTemplate(corePkg) {
|
|
3831
|
+
export function loginRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
3832
|
+
const bindingImports = features.transactionBinding
|
|
3833
|
+
? `
|
|
3834
|
+
validateTransactionBinding,
|
|
3835
|
+
AuthTransactionError,
|
|
3836
|
+
type AuthTransaction,`
|
|
3837
|
+
: '';
|
|
3838
|
+
const bindingStoreImport = features.transactionBinding
|
|
3839
|
+
? `
|
|
3840
|
+
parseTransactionBindingSecret,`
|
|
3841
|
+
: '';
|
|
3842
|
+
const bindingGuard = features.transactionBinding
|
|
3843
|
+
? `
|
|
3844
|
+
/**
|
|
3845
|
+
* Enforce that this step comes from the User-Agent that started the transaction
|
|
3846
|
+
* (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns an error Response to send
|
|
3847
|
+
* back, or undefined when the binding holds.
|
|
3848
|
+
*
|
|
3849
|
+
* The failure is rendered by the OP itself and never redirected to the client's
|
|
3850
|
+
* redirect_uri: at this point we cannot tell whose transaction this is, so
|
|
3851
|
+
* answering the client would leak that a transaction exists — and, in the
|
|
3852
|
+
* lured-victim case, would hand the attacker's client a code for the victim.
|
|
3853
|
+
* See buildTransactionBindingCookie() in store.ts for the full threat model.
|
|
3854
|
+
*/
|
|
3855
|
+
async function rejectUnboundTransaction(
|
|
3856
|
+
transaction: AuthTransaction,
|
|
3857
|
+
transactionId: string,
|
|
3858
|
+
cookieHeader: string | null,
|
|
3859
|
+
views: typeof defaultViews,
|
|
3860
|
+
): Promise<Response | undefined> {
|
|
3861
|
+
try {
|
|
3862
|
+
await validateTransactionBinding(
|
|
3863
|
+
transaction,
|
|
3864
|
+
parseTransactionBindingSecret(cookieHeader, transactionId),
|
|
3865
|
+
);
|
|
3866
|
+
return undefined;
|
|
3867
|
+
} catch (error) {
|
|
3868
|
+
if (!(error instanceof AuthTransactionError)) throw error;
|
|
3869
|
+
return renderView(views.errorPage({
|
|
3870
|
+
error: error.message,
|
|
3871
|
+
statusCode: error.httpStatusCode,
|
|
3872
|
+
}), { status: error.httpStatusCode });
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
`
|
|
3876
|
+
: '';
|
|
3877
|
+
const bindingCheckBeforeLoginForm = features.transactionBinding
|
|
3878
|
+
? `
|
|
3879
|
+
// Checked BEFORE rendering: the login page embeds csrf_token, so anyone who
|
|
3880
|
+
// could load this page with a leaked transaction_id would obtain the token
|
|
3881
|
+
// that the POST handlers validate.
|
|
3882
|
+
const bindingError = await rejectUnboundTransaction(
|
|
3883
|
+
transaction,
|
|
3884
|
+
transactionId,
|
|
3885
|
+
c.req.header('Cookie') ?? null,
|
|
3886
|
+
views,
|
|
3887
|
+
);
|
|
3888
|
+
if (bindingError) return bindingError;
|
|
3889
|
+
`
|
|
3890
|
+
: '';
|
|
3891
|
+
const bindingCheckBeforeLoginCsrf = features.transactionBinding
|
|
3892
|
+
? ` // Checked before validateCsrfToken: the CSRF token only proves the request
|
|
3893
|
+
// carries a value from the form, and that form is reachable by anyone holding
|
|
3894
|
+
// transaction_id. The binding proves it is the same browser.
|
|
3895
|
+
const bindingError = await rejectUnboundTransaction(
|
|
3896
|
+
transaction,
|
|
3897
|
+
transactionId,
|
|
3898
|
+
c.req.header('Cookie') ?? null,
|
|
3899
|
+
views,
|
|
3900
|
+
);
|
|
3901
|
+
if (bindingError) return bindingError;
|
|
3902
|
+
`
|
|
3903
|
+
: '';
|
|
3717
3904
|
return `import { Hono } from 'hono';
|
|
3718
3905
|
import {
|
|
3719
3906
|
getAuthTransaction,
|
|
3720
|
-
validateCsrfToken
|
|
3907
|
+
validateCsrfToken,${bindingImports}
|
|
3721
3908
|
handleLoginFailure,
|
|
3722
3909
|
generateRandomString,
|
|
3723
3910
|
} from '${corePkg}';
|
|
@@ -3726,13 +3913,13 @@ import {
|
|
|
3726
3913
|
authSessionStore as defaultAuthSessionStore,
|
|
3727
3914
|
browserSessionStore as defaultBrowserSessionStore,
|
|
3728
3915
|
buildSessionCookie,
|
|
3729
|
-
parseSessionId
|
|
3916
|
+
parseSessionId,${bindingStoreImport}
|
|
3730
3917
|
userStore,
|
|
3731
3918
|
} from '../store.js';
|
|
3732
3919
|
import { defaultViews, renderView } from '../views.js';
|
|
3733
3920
|
|
|
3734
3921
|
export const loginApp = new Hono<{ Variables: Record<string, any> }>();
|
|
3735
|
-
|
|
3922
|
+
${bindingGuard}
|
|
3736
3923
|
/**
|
|
3737
3924
|
* Login Page - GET
|
|
3738
3925
|
* Displays the login form for user authentication.
|
|
@@ -3746,7 +3933,7 @@ loginApp.get('/', async (c) => {
|
|
|
3746
3933
|
const views = c.get('views') ?? defaultViews;
|
|
3747
3934
|
const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
|
|
3748
3935
|
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
3749
|
-
|
|
3936
|
+
${bindingCheckBeforeLoginForm}
|
|
3750
3937
|
return renderView(views.loginPage({
|
|
3751
3938
|
transactionId,
|
|
3752
3939
|
csrfToken: transaction.csrfToken,
|
|
@@ -3775,7 +3962,7 @@ loginApp.post('/', async (c) => {
|
|
|
3775
3962
|
((u: string, p: string) => userStore.authenticate(u, p));
|
|
3776
3963
|
|
|
3777
3964
|
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
3778
|
-
validateCsrfToken(transaction, csrfToken);
|
|
3965
|
+
${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
|
|
3779
3966
|
|
|
3780
3967
|
// Authenticate user
|
|
3781
3968
|
const user = await authenticateUser(username, password);
|
|
@@ -3832,11 +4019,96 @@ loginApp.post('/', async (c) => {
|
|
|
3832
4019
|
});
|
|
3833
4020
|
`;
|
|
3834
4021
|
}
|
|
3835
|
-
export function consentRouteTemplate(corePkg) {
|
|
4022
|
+
export function consentRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
4023
|
+
const bindingImports = features.transactionBinding
|
|
4024
|
+
? `
|
|
4025
|
+
validateTransactionBinding,
|
|
4026
|
+
AuthTransactionError,
|
|
4027
|
+
type AuthTransaction,`
|
|
4028
|
+
: '';
|
|
4029
|
+
const bindingStoreImport = features.transactionBinding
|
|
4030
|
+
? `
|
|
4031
|
+
buildClearedTransactionBindingCookie,
|
|
4032
|
+
parseTransactionBindingSecret,`
|
|
4033
|
+
: '';
|
|
4034
|
+
const bindingGuard = features.transactionBinding
|
|
4035
|
+
? `
|
|
4036
|
+
/**
|
|
4037
|
+
* Enforce that this step comes from the User-Agent that started the transaction
|
|
4038
|
+
* (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns an error Response to send
|
|
4039
|
+
* back, or undefined when the binding holds.
|
|
4040
|
+
*
|
|
4041
|
+
* The failure is rendered by the OP itself and never redirected to the client's
|
|
4042
|
+
* redirect_uri: without a verified owner, answering the client would let an
|
|
4043
|
+
* attacker who lured a victim into their own transaction collect a code for the
|
|
4044
|
+
* victim's identity. See buildTransactionBindingCookie() in store.ts.
|
|
4045
|
+
*/
|
|
4046
|
+
async function rejectUnboundTransaction(
|
|
4047
|
+
transaction: AuthTransaction,
|
|
4048
|
+
transactionId: string,
|
|
4049
|
+
cookieHeader: string | null,
|
|
4050
|
+
views: typeof defaultViews,
|
|
4051
|
+
): Promise<Response | undefined> {
|
|
4052
|
+
try {
|
|
4053
|
+
await validateTransactionBinding(
|
|
4054
|
+
transaction,
|
|
4055
|
+
parseTransactionBindingSecret(cookieHeader, transactionId),
|
|
4056
|
+
);
|
|
4057
|
+
return undefined;
|
|
4058
|
+
} catch (error) {
|
|
4059
|
+
if (!(error instanceof AuthTransactionError)) throw error;
|
|
4060
|
+
return renderView(views.errorPage({
|
|
4061
|
+
error: error.message,
|
|
4062
|
+
statusCode: error.httpStatusCode,
|
|
4063
|
+
}), { status: error.httpStatusCode });
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
`
|
|
4067
|
+
: '';
|
|
4068
|
+
const bindingCheckBeforeConsentForm = features.transactionBinding
|
|
4069
|
+
? `
|
|
4070
|
+
// Checked BEFORE rendering: the consent page embeds csrf_token, so a third
|
|
4071
|
+
// party holding a leaked transaction_id must not be able to read it here and
|
|
4072
|
+
// then complete POST /consent on the End-User's behalf.
|
|
4073
|
+
const bindingError = await rejectUnboundTransaction(
|
|
4074
|
+
transaction,
|
|
4075
|
+
transactionId,
|
|
4076
|
+
c.req.header('Cookie') ?? null,
|
|
4077
|
+
views,
|
|
4078
|
+
);
|
|
4079
|
+
if (bindingError) return bindingError;
|
|
4080
|
+
`
|
|
4081
|
+
: '';
|
|
4082
|
+
const bindingCheckBeforeConsentCsrf = features.transactionBinding
|
|
4083
|
+
? ` // Checked before validateCsrfToken and before any decision is acted on: this
|
|
4084
|
+
// is the step that mints the authorization code, so an unbound caller must not
|
|
4085
|
+
// reach it — neither to approve nor to deny on the End-User's behalf.
|
|
4086
|
+
const bindingError = await rejectUnboundTransaction(
|
|
4087
|
+
transaction,
|
|
4088
|
+
transactionId,
|
|
4089
|
+
c.req.header('Cookie') ?? null,
|
|
4090
|
+
views,
|
|
4091
|
+
);
|
|
4092
|
+
if (bindingError) return bindingError;
|
|
4093
|
+
`
|
|
4094
|
+
: '';
|
|
4095
|
+
const clearBindingCookieOnDeny = features.transactionBinding
|
|
4096
|
+
? ` // The transaction is over; drop its binding cookie so the browser does not
|
|
4097
|
+
// keep one cookie per finished flow.
|
|
4098
|
+
c.header('Set-Cookie', buildClearedTransactionBindingCookie(transactionId));
|
|
4099
|
+
`
|
|
4100
|
+
: '';
|
|
4101
|
+
const clearBindingCookieOnSuccess = features.transactionBinding
|
|
4102
|
+
? ` // The transaction is over; drop its binding cookie so the browser does not
|
|
4103
|
+
// keep one cookie per finished flow.
|
|
4104
|
+
c.header('Set-Cookie', buildClearedTransactionBindingCookie(transactionId));
|
|
4105
|
+
|
|
4106
|
+
`
|
|
4107
|
+
: '';
|
|
3836
4108
|
return `import { Hono } from 'hono';
|
|
3837
4109
|
import {
|
|
3838
4110
|
getAuthTransaction,
|
|
3839
|
-
validateCsrfToken
|
|
4111
|
+
validateCsrfToken,${bindingImports}
|
|
3840
4112
|
completeAuthTransaction,
|
|
3841
4113
|
createAuthorizationCode,
|
|
3842
4114
|
} from '${corePkg}';
|
|
@@ -3847,12 +4119,12 @@ import {
|
|
|
3847
4119
|
import {
|
|
3848
4120
|
transactionStore as defaultTransactionStore,
|
|
3849
4121
|
authCodeStore as defaultAuthCodeStore,
|
|
3850
|
-
authSessionStore as defaultAuthSessionStore
|
|
4122
|
+
authSessionStore as defaultAuthSessionStore,${bindingStoreImport}
|
|
3851
4123
|
} from '../store.js';
|
|
3852
4124
|
import { defaultViews, renderView } from '../views.js';
|
|
3853
4125
|
|
|
3854
4126
|
export const consentApp = new Hono<{ Variables: Record<string, any> }>();
|
|
3855
|
-
|
|
4127
|
+
${bindingGuard}
|
|
3856
4128
|
/**
|
|
3857
4129
|
* Consent Page - GET
|
|
3858
4130
|
* Displays the consent form for scope authorization.
|
|
@@ -3866,7 +4138,7 @@ consentApp.get('/', async (c) => {
|
|
|
3866
4138
|
const views = c.get('views') ?? defaultViews;
|
|
3867
4139
|
const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
|
|
3868
4140
|
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
3869
|
-
|
|
4141
|
+
${bindingCheckBeforeConsentForm}
|
|
3870
4142
|
return renderView(views.consentPage({
|
|
3871
4143
|
transactionId,
|
|
3872
4144
|
csrfToken: transaction.csrfToken,
|
|
@@ -3892,7 +4164,7 @@ consentApp.post('/', async (c) => {
|
|
|
3892
4164
|
const clientResolver = c.get('clientResolver') ?? defaultClientResolver;
|
|
3893
4165
|
|
|
3894
4166
|
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
3895
|
-
validateCsrfToken(transaction, csrfToken);
|
|
4167
|
+
${bindingCheckBeforeConsentCsrf} validateCsrfToken(transaction, csrfToken);
|
|
3896
4168
|
|
|
3897
4169
|
// RFC 9207 §2: include the issuer identifier on every authorization response
|
|
3898
4170
|
// (success and error) so clients can pin the issuer that produced the response.
|
|
@@ -3908,7 +4180,7 @@ consentApp.post('/', async (c) => {
|
|
|
3908
4180
|
redirectUrl.searchParams.set('iss', issuer);
|
|
3909
4181
|
await transactionStore.delete('auth_txn:' + transactionId);
|
|
3910
4182
|
await authSessionStore.delete(transactionId);
|
|
3911
|
-
return c.redirect(redirectUrl.toString());
|
|
4183
|
+
${clearBindingCookieOnDeny} return c.redirect(redirectUrl.toString());
|
|
3912
4184
|
}
|
|
3913
4185
|
|
|
3914
4186
|
const session = await authSessionStore.get(transactionId);
|
|
@@ -3958,7 +4230,7 @@ consentApp.post('/', async (c) => {
|
|
|
3958
4230
|
|
|
3959
4231
|
await authSessionStore.delete(transactionId);
|
|
3960
4232
|
|
|
3961
|
-
// Redirect back to client with authorization code
|
|
4233
|
+
${clearBindingCookieOnSuccess} // Redirect back to client with authorization code
|
|
3962
4234
|
const redirectUrl = new URL(responseParams.redirectUri);
|
|
3963
4235
|
redirectUrl.searchParams.set('code', authCodeData.code);
|
|
3964
4236
|
if (responseParams.state) {
|
|
@@ -4865,10 +5137,11 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
4865
5137
|
const PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
4866
5138
|
const PKCE_CHALLENGE_S256 = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
4867
5139
|
|
|
4868
|
-
// The
|
|
4869
|
-
//
|
|
4870
|
-
//
|
|
4871
|
-
//
|
|
5140
|
+
// The flow carries forward whatever cookie /authorize set, like a browser
|
|
5141
|
+
// would, so it passes with or without --enable transaction-binding. These
|
|
5142
|
+
// helpers only fetch and parse: they make no assertions and contain no
|
|
5143
|
+
// branching, so every check stays in the it() blocks as an expect(). Test code
|
|
5144
|
+
// carries no logic that could drift from the OP's behavior.
|
|
4872
5145
|
function relativeFrom(location: string | null): string {
|
|
4873
5146
|
const url = new URL(location ?? '', 'http://localhost');
|
|
4874
5147
|
return url.pathname + url.search;
|
|
@@ -4920,13 +5193,18 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
4920
5193
|
|
|
4921
5194
|
const authorizeRes = await app.request(authorizeUrl);
|
|
4922
5195
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
5196
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
5197
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
5198
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
5199
|
+
// it, so the same flow works in both builds.
|
|
5200
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
4923
5201
|
const transactionId =
|
|
4924
5202
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
4925
5203
|
|
|
4926
|
-
const loginGet = await app.request(loginPath);
|
|
5204
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
4927
5205
|
const loginRes = await app.request('/login', {
|
|
4928
5206
|
method: 'POST',
|
|
4929
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5207
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
4930
5208
|
body: new URLSearchParams({
|
|
4931
5209
|
transaction_id: transactionId,
|
|
4932
5210
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -4936,10 +5214,10 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
4936
5214
|
});
|
|
4937
5215
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
4938
5216
|
|
|
4939
|
-
const consentGet = await app.request(consentPath);
|
|
5217
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
4940
5218
|
const consentRes = await app.request('/consent', {
|
|
4941
5219
|
method: 'POST',
|
|
4942
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5220
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
4943
5221
|
body: new URLSearchParams({
|
|
4944
5222
|
transaction_id: transactionId,
|
|
4945
5223
|
csrf_token: csrfFrom(await consentGet.text()),
|
|
@@ -5027,10 +5305,11 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
5027
5305
|
const PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
5028
5306
|
const PKCE_CHALLENGE_S256 = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
5029
5307
|
|
|
5030
|
-
// The
|
|
5031
|
-
//
|
|
5032
|
-
//
|
|
5033
|
-
//
|
|
5308
|
+
// The flow carries forward whatever cookie /authorize set, like a browser
|
|
5309
|
+
// would, so it passes with or without --enable transaction-binding. These
|
|
5310
|
+
// helpers only fetch and parse: they make no assertions and contain no
|
|
5311
|
+
// branching, so every check stays in the it() blocks as an expect(). Test code
|
|
5312
|
+
// carries no logic that could drift from the OP's behavior.
|
|
5034
5313
|
function relativeFrom(location: string | null): string {
|
|
5035
5314
|
const url = new URL(location ?? '', 'http://localhost');
|
|
5036
5315
|
return url.pathname + url.search;
|
|
@@ -5082,13 +5361,18 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
5082
5361
|
|
|
5083
5362
|
const authorizeRes = await app.request(authorizeUrl);
|
|
5084
5363
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
5364
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
5365
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
5366
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
5367
|
+
// it, so the same flow works in both builds.
|
|
5368
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
5085
5369
|
const transactionId =
|
|
5086
5370
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
5087
5371
|
|
|
5088
|
-
const loginGet = await app.request(loginPath);
|
|
5372
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
5089
5373
|
const loginRes = await app.request('/login', {
|
|
5090
5374
|
method: 'POST',
|
|
5091
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5375
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
5092
5376
|
body: new URLSearchParams({
|
|
5093
5377
|
transaction_id: transactionId,
|
|
5094
5378
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -5098,10 +5382,10 @@ function reuseCascadeConformanceBlock(features) {
|
|
|
5098
5382
|
});
|
|
5099
5383
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
5100
5384
|
|
|
5101
|
-
const consentGet = await app.request(consentPath);
|
|
5385
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
5102
5386
|
const consentRes = await app.request('/consent', {
|
|
5103
5387
|
method: 'POST',
|
|
5104
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5388
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
5105
5389
|
body: new URLSearchParams({
|
|
5106
5390
|
transaction_id: transactionId,
|
|
5107
5391
|
csrf_token: csrfFrom(await consentGet.text()),
|
|
@@ -5425,6 +5709,355 @@ function requestObjectValueConformanceBlock(features) {
|
|
|
5425
5709
|
});
|
|
5426
5710
|
`;
|
|
5427
5711
|
}
|
|
5712
|
+
export function transactionBindingConformanceBlock(features = DEFAULT_FEATURES) {
|
|
5713
|
+
if (!features.transactionBinding)
|
|
5714
|
+
return transactionBindingDisabledConformanceBlock();
|
|
5715
|
+
return `
|
|
5716
|
+
describe('Auth transaction User-Agent binding', () => {
|
|
5717
|
+
const BINDING_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
5718
|
+
|
|
5719
|
+
// Pure fetch + parse helpers: no assertions and no branching, so the contract
|
|
5720
|
+
// stays visible in the it() blocks.
|
|
5721
|
+
function bindingRelativeFrom(location: string | null): string {
|
|
5722
|
+
const url = new URL(location ?? '', 'http://localhost');
|
|
5723
|
+
return url.pathname + url.search;
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5726
|
+
function bindingCsrfFrom(html: string): string {
|
|
5727
|
+
return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5730
|
+
// Start one authorization request and return everything a browser would hold
|
|
5731
|
+
// after it: where the OP sent us, the transaction id, and the binding cookie.
|
|
5732
|
+
async function startFlow(state: string): Promise<{
|
|
5733
|
+
loginPath: string;
|
|
5734
|
+
transactionId: string;
|
|
5735
|
+
cookie: string;
|
|
5736
|
+
}> {
|
|
5737
|
+
const res = await app.request(
|
|
5738
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
5739
|
+
'&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
|
|
5740
|
+
'&scope=openid&state=' + state + '&prompt=consent' +
|
|
5741
|
+
'&code_challenge=' + BINDING_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
5742
|
+
);
|
|
5743
|
+
const loginPath = bindingRelativeFrom(res.headers.get('Location'));
|
|
5744
|
+
return {
|
|
5745
|
+
loginPath,
|
|
5746
|
+
transactionId:
|
|
5747
|
+
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '',
|
|
5748
|
+
cookie: (res.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '',
|
|
5749
|
+
};
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
// Log in and reach the consent page as the browser that owns the transaction.
|
|
5753
|
+
async function loginAndReachConsent(flow: {
|
|
5754
|
+
loginPath: string;
|
|
5755
|
+
transactionId: string;
|
|
5756
|
+
cookie: string;
|
|
5757
|
+
}): Promise<{ consentPath: string; consentCsrf: string }> {
|
|
5758
|
+
const loginGet = await app.request(flow.loginPath, {
|
|
5759
|
+
headers: { Cookie: flow.cookie },
|
|
5760
|
+
});
|
|
5761
|
+
const loginRes = await app.request('/login', {
|
|
5762
|
+
method: 'POST',
|
|
5763
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: flow.cookie },
|
|
5764
|
+
body: new URLSearchParams({
|
|
5765
|
+
transaction_id: flow.transactionId,
|
|
5766
|
+
csrf_token: bindingCsrfFrom(await loginGet.text()),
|
|
5767
|
+
username: 'testuser',
|
|
5768
|
+
password: 'password',
|
|
5769
|
+
}).toString(),
|
|
5770
|
+
});
|
|
5771
|
+
const consentPath = bindingRelativeFrom(loginRes.headers.get('Location'));
|
|
5772
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: flow.cookie } });
|
|
5773
|
+
return { consentPath, consentCsrf: bindingCsrfFrom(await consentGet.text()) };
|
|
5774
|
+
}
|
|
5775
|
+
|
|
5776
|
+
// The authorization endpoint issues the binding secret; without it there is
|
|
5777
|
+
// nothing to check the later steps against.
|
|
5778
|
+
it('should set a transaction binding cookie on the redirect to the login page', async () => {
|
|
5779
|
+
const res = await app.request(
|
|
5780
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
5781
|
+
'&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
|
|
5782
|
+
'&scope=openid&state=binding-set&prompt=consent' +
|
|
5783
|
+
'&code_challenge=' + BINDING_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
5784
|
+
);
|
|
5785
|
+
const transactionId =
|
|
5786
|
+
new URL(bindingRelativeFrom(res.headers.get('Location')), 'http://localhost')
|
|
5787
|
+
.searchParams.get('transaction_id') ?? '';
|
|
5788
|
+
const setCookie = res.headers.get('Set-Cookie') ?? '';
|
|
5789
|
+
|
|
5790
|
+
expect(res.status).toBe(302);
|
|
5791
|
+
// Named per transaction so two tabs can run two flows at once, and marked
|
|
5792
|
+
// HttpOnly/Secure/SameSite=Lax like the session cookie.
|
|
5793
|
+
expect(setCookie.startsWith('oidc_txn_' + transactionId + '=')).toBe(true);
|
|
5794
|
+
expect(setCookie.endsWith('; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600')).toBe(true);
|
|
5795
|
+
});
|
|
5796
|
+
|
|
5797
|
+
// The csrf_token lives in this HTML. If a leaked transaction_id were enough to
|
|
5798
|
+
// fetch it, the CSRF defense would reduce to the secrecy of a URL parameter.
|
|
5799
|
+
it('should not expose the csrf token for GET /login without the transaction binding cookie', async () => {
|
|
5800
|
+
const flow = await startFlow('binding-login-get');
|
|
5801
|
+
|
|
5802
|
+
const res = await app.request(flow.loginPath);
|
|
5803
|
+
const body = await res.text();
|
|
5804
|
+
|
|
5805
|
+
expect(res.status).toBe(400);
|
|
5806
|
+
expect(body.includes('csrf_token')).toBe(false);
|
|
5807
|
+
});
|
|
5808
|
+
|
|
5809
|
+
it('should return 400 for GET /consent without the transaction binding cookie', async () => {
|
|
5810
|
+
const flow = await startFlow('binding-consent-get');
|
|
5811
|
+
await loginAndReachConsent(flow);
|
|
5812
|
+
|
|
5813
|
+
const res = await app.request('/consent?transaction_id=' + flow.transactionId);
|
|
5814
|
+
const body = await res.text();
|
|
5815
|
+
|
|
5816
|
+
expect(res.status).toBe(400);
|
|
5817
|
+
expect(body.includes('csrf_token')).toBe(false);
|
|
5818
|
+
});
|
|
5819
|
+
|
|
5820
|
+
it('should reject POST /login without the transaction binding cookie', async () => {
|
|
5821
|
+
const flow = await startFlow('binding-login-post');
|
|
5822
|
+
const loginGet = await app.request(flow.loginPath, { headers: { Cookie: flow.cookie } });
|
|
5823
|
+
const csrf = bindingCsrfFrom(await loginGet.text());
|
|
5824
|
+
|
|
5825
|
+
const res = await app.request('/login', {
|
|
5826
|
+
method: 'POST',
|
|
5827
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5828
|
+
body: new URLSearchParams({
|
|
5829
|
+
transaction_id: flow.transactionId,
|
|
5830
|
+
csrf_token: csrf,
|
|
5831
|
+
username: 'testuser',
|
|
5832
|
+
password: 'password',
|
|
5833
|
+
}).toString(),
|
|
5834
|
+
});
|
|
5835
|
+
|
|
5836
|
+
// Stopped by the OP itself (400), never redirected onward to the client.
|
|
5837
|
+
expect(res.status).toBe(400);
|
|
5838
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
5839
|
+
});
|
|
5840
|
+
|
|
5841
|
+
// The core threat: someone holding transaction_id and a valid csrf_token
|
|
5842
|
+
// (both readable from a shared screen or a browser history entry) must still
|
|
5843
|
+
// not be able to complete the grant.
|
|
5844
|
+
it('should not issue an authorization code for POST /consent without the transaction binding cookie', async () => {
|
|
5845
|
+
const flow = await startFlow('binding-consent-post');
|
|
5846
|
+
const consent = await loginAndReachConsent(flow);
|
|
5847
|
+
|
|
5848
|
+
const res = await app.request('/consent', {
|
|
5849
|
+
method: 'POST',
|
|
5850
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5851
|
+
body: new URLSearchParams({
|
|
5852
|
+
transaction_id: flow.transactionId,
|
|
5853
|
+
csrf_token: consent.consentCsrf,
|
|
5854
|
+
action: 'approve',
|
|
5855
|
+
}).toString(),
|
|
5856
|
+
});
|
|
5857
|
+
|
|
5858
|
+
expect(res.status).toBe(400);
|
|
5859
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
5860
|
+
});
|
|
5861
|
+
|
|
5862
|
+
// The lured-victim case: the attacker starts their own transaction, so their
|
|
5863
|
+
// cookie is a perfectly valid binding cookie — just not for THIS transaction.
|
|
5864
|
+
it('should not issue an authorization code for POST /consent with another transactions binding cookie', async () => {
|
|
5865
|
+
const victim = await startFlow('binding-victim');
|
|
5866
|
+
const consent = await loginAndReachConsent(victim);
|
|
5867
|
+
const attacker = await startFlow('binding-attacker');
|
|
5868
|
+
|
|
5869
|
+
const res = await app.request('/consent', {
|
|
5870
|
+
method: 'POST',
|
|
5871
|
+
headers: {
|
|
5872
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
5873
|
+
Cookie: attacker.cookie,
|
|
5874
|
+
},
|
|
5875
|
+
body: new URLSearchParams({
|
|
5876
|
+
transaction_id: victim.transactionId,
|
|
5877
|
+
csrf_token: consent.consentCsrf,
|
|
5878
|
+
action: 'approve',
|
|
5879
|
+
}).toString(),
|
|
5880
|
+
});
|
|
5881
|
+
|
|
5882
|
+
expect(res.status).toBe(400);
|
|
5883
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
5884
|
+
});
|
|
5885
|
+
|
|
5886
|
+
it('should reject POST /consent action=deny without the transaction binding cookie', async () => {
|
|
5887
|
+
const flow = await startFlow('binding-deny');
|
|
5888
|
+
const consent = await loginAndReachConsent(flow);
|
|
5889
|
+
|
|
5890
|
+
const res = await app.request('/consent', {
|
|
5891
|
+
method: 'POST',
|
|
5892
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
5893
|
+
body: new URLSearchParams({
|
|
5894
|
+
transaction_id: flow.transactionId,
|
|
5895
|
+
csrf_token: consent.consentCsrf,
|
|
5896
|
+
action: 'deny',
|
|
5897
|
+
}).toString(),
|
|
5898
|
+
});
|
|
5899
|
+
|
|
5900
|
+
expect(res.status).toBe(400);
|
|
5901
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
5902
|
+
});
|
|
5903
|
+
|
|
5904
|
+
// Regression guard: the binding must not break the flow it protects.
|
|
5905
|
+
it('should issue an authorization code for the normal flow with a valid binding cookie', async () => {
|
|
5906
|
+
const flow = await startFlow('binding-happy');
|
|
5907
|
+
const consent = await loginAndReachConsent(flow);
|
|
5908
|
+
|
|
5909
|
+
const res = await app.request('/consent', {
|
|
5910
|
+
method: 'POST',
|
|
5911
|
+
headers: {
|
|
5912
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
5913
|
+
Cookie: flow.cookie,
|
|
5914
|
+
},
|
|
5915
|
+
body: new URLSearchParams({
|
|
5916
|
+
transaction_id: flow.transactionId,
|
|
5917
|
+
csrf_token: consent.consentCsrf,
|
|
5918
|
+
action: 'approve',
|
|
5919
|
+
}).toString(),
|
|
5920
|
+
});
|
|
5921
|
+
const callback = new URL(res.headers.get('Location') ?? '', 'http://localhost');
|
|
5922
|
+
|
|
5923
|
+
expect(res.status).toBe(302);
|
|
5924
|
+
expect(callback.searchParams.get('state')).toBe('binding-happy');
|
|
5925
|
+
expect((callback.searchParams.get('code') ?? '').length).toBe(43);
|
|
5926
|
+
// The finished transaction's cookie is cleared so it cannot pile up.
|
|
5927
|
+
expect(res.headers.get('Set-Cookie')).toBe(
|
|
5928
|
+
'oidc_txn_' + flow.transactionId + '=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0',
|
|
5929
|
+
);
|
|
5930
|
+
});
|
|
5931
|
+
|
|
5932
|
+
// Two tabs, two clients, at the same time: the cookie is named per
|
|
5933
|
+
// transaction, so neither flow overwrites the other's secret.
|
|
5934
|
+
it('should complete two concurrent authorization flows in the same browser', async () => {
|
|
5935
|
+
const first = await startFlow('binding-tab-one');
|
|
5936
|
+
const second = await startFlow('binding-tab-two');
|
|
5937
|
+
const bothCookies = first.cookie + '; ' + second.cookie;
|
|
5938
|
+
|
|
5939
|
+
const firstConsent = await loginAndReachConsent({ ...first, cookie: bothCookies });
|
|
5940
|
+
const secondConsent = await loginAndReachConsent({ ...second, cookie: bothCookies });
|
|
5941
|
+
|
|
5942
|
+
const firstRes = await app.request('/consent', {
|
|
5943
|
+
method: 'POST',
|
|
5944
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bothCookies },
|
|
5945
|
+
body: new URLSearchParams({
|
|
5946
|
+
transaction_id: first.transactionId,
|
|
5947
|
+
csrf_token: firstConsent.consentCsrf,
|
|
5948
|
+
action: 'approve',
|
|
5949
|
+
}).toString(),
|
|
5950
|
+
});
|
|
5951
|
+
const secondRes = await app.request('/consent', {
|
|
5952
|
+
method: 'POST',
|
|
5953
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bothCookies },
|
|
5954
|
+
body: new URLSearchParams({
|
|
5955
|
+
transaction_id: second.transactionId,
|
|
5956
|
+
csrf_token: secondConsent.consentCsrf,
|
|
5957
|
+
action: 'approve',
|
|
5958
|
+
}).toString(),
|
|
5959
|
+
});
|
|
5960
|
+
const firstCallback = new URL(firstRes.headers.get('Location') ?? '', 'http://localhost');
|
|
5961
|
+
const secondCallback = new URL(secondRes.headers.get('Location') ?? '', 'http://localhost');
|
|
5962
|
+
|
|
5963
|
+
expect(firstCallback.searchParams.get('state')).toBe('binding-tab-one');
|
|
5964
|
+
expect(secondCallback.searchParams.get('state')).toBe('binding-tab-two');
|
|
5965
|
+
expect((firstCallback.searchParams.get('code') ?? '').length).toBe(43);
|
|
5966
|
+
expect((secondCallback.searchParams.get('code') ?? '').length).toBe(43);
|
|
5967
|
+
});
|
|
5968
|
+
});
|
|
5969
|
+
`;
|
|
5970
|
+
}
|
|
5971
|
+
function transactionBindingDisabledConformanceBlock() {
|
|
5972
|
+
return `
|
|
5973
|
+
describe('Auth transaction User-Agent binding (disabled by default)', () => {
|
|
5974
|
+
const NO_BINDING_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
5975
|
+
|
|
5976
|
+
function noBindingRelativeFrom(location: string | null): string {
|
|
5977
|
+
const url = new URL(location ?? '', 'http://localhost');
|
|
5978
|
+
return url.pathname + url.search;
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
function noBindingCsrfFrom(html: string): string {
|
|
5982
|
+
return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
|
|
5983
|
+
}
|
|
5984
|
+
|
|
5985
|
+
// Drive the whole flow WITHOUT ever sending a Cookie header, exactly as a
|
|
5986
|
+
// curl session would. No assertions or branching in here.
|
|
5987
|
+
async function flowWithoutCookies(state: string): Promise<{
|
|
5988
|
+
authorizeSetCookie: string | null;
|
|
5989
|
+
loginFormStatus: number;
|
|
5990
|
+
consentFormStatus: number;
|
|
5991
|
+
consentFormHasCsrf: boolean;
|
|
5992
|
+
callbackCode: string;
|
|
5993
|
+
callbackState: string | null;
|
|
5994
|
+
}> {
|
|
5995
|
+
const authorizeRes = await app.request(
|
|
5996
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
5997
|
+
'&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
|
|
5998
|
+
'&scope=openid&state=' + state + '&prompt=consent' +
|
|
5999
|
+
'&code_challenge=' + NO_BINDING_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
6000
|
+
);
|
|
6001
|
+
const loginPath = noBindingRelativeFrom(authorizeRes.headers.get('Location'));
|
|
6002
|
+
const transactionId =
|
|
6003
|
+
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6004
|
+
|
|
6005
|
+
const loginGet = await app.request(loginPath);
|
|
6006
|
+
const loginRes = await app.request('/login', {
|
|
6007
|
+
method: 'POST',
|
|
6008
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6009
|
+
body: new URLSearchParams({
|
|
6010
|
+
transaction_id: transactionId,
|
|
6011
|
+
csrf_token: noBindingCsrfFrom(await loginGet.text()),
|
|
6012
|
+
username: 'testuser',
|
|
6013
|
+
password: 'password',
|
|
6014
|
+
}).toString(),
|
|
6015
|
+
});
|
|
6016
|
+
|
|
6017
|
+
const consentPath = noBindingRelativeFrom(loginRes.headers.get('Location'));
|
|
6018
|
+
const consentGet = await app.request(consentPath);
|
|
6019
|
+
const consentHtml = await consentGet.text();
|
|
6020
|
+
const consentRes = await app.request('/consent', {
|
|
6021
|
+
method: 'POST',
|
|
6022
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6023
|
+
body: new URLSearchParams({
|
|
6024
|
+
transaction_id: transactionId,
|
|
6025
|
+
csrf_token: noBindingCsrfFrom(consentHtml),
|
|
6026
|
+
action: 'approve',
|
|
6027
|
+
}).toString(),
|
|
6028
|
+
});
|
|
6029
|
+
const callback = new URL(consentRes.headers.get('Location') ?? '', 'http://localhost');
|
|
6030
|
+
|
|
6031
|
+
return {
|
|
6032
|
+
authorizeSetCookie: authorizeRes.headers.get('Set-Cookie'),
|
|
6033
|
+
loginFormStatus: loginGet.status,
|
|
6034
|
+
consentFormStatus: consentGet.status,
|
|
6035
|
+
consentFormHasCsrf: noBindingCsrfFrom(consentHtml).length > 0,
|
|
6036
|
+
callbackCode: callback.searchParams.get('code') ?? '',
|
|
6037
|
+
callbackState: callback.searchParams.get('state'),
|
|
6038
|
+
};
|
|
6039
|
+
}
|
|
6040
|
+
|
|
6041
|
+
it('should not set any binding cookie on the redirect to the login page', async () => {
|
|
6042
|
+
const flow = await flowWithoutCookies('no-binding-cookie');
|
|
6043
|
+
|
|
6044
|
+
expect(flow.authorizeSetCookie).toBe(null);
|
|
6045
|
+
});
|
|
6046
|
+
|
|
6047
|
+
// The whole point of leaving this off by default: transaction_id alone is
|
|
6048
|
+
// enough to walk the flow, so the OP can be explored by hand.
|
|
6049
|
+
it('should complete the whole flow without sending a single cookie', async () => {
|
|
6050
|
+
const flow = await flowWithoutCookies('no-binding-flow');
|
|
6051
|
+
|
|
6052
|
+
expect(flow.loginFormStatus).toBe(200);
|
|
6053
|
+
expect(flow.consentFormStatus).toBe(200);
|
|
6054
|
+
expect(flow.consentFormHasCsrf).toBe(true);
|
|
6055
|
+
expect(flow.callbackState).toBe('no-binding-flow');
|
|
6056
|
+
expect(flow.callbackCode.length).toBe(43);
|
|
6057
|
+
});
|
|
6058
|
+
});
|
|
6059
|
+
`;
|
|
6060
|
+
}
|
|
5428
6061
|
export function customViewConformanceTestBlock() {
|
|
5429
6062
|
return `
|
|
5430
6063
|
describe('custom view rendering (ViewResult / renderView)', () => {
|
|
@@ -5475,8 +6108,13 @@ export function customViewConformanceTestBlock() {
|
|
|
5475
6108
|
'&code_challenge=' + PKCE_CHALLENGE_S256 + '&code_challenge_method=S256';
|
|
5476
6109
|
const authorizeRes = await app.request(authorizeUrl);
|
|
5477
6110
|
const loginUrl = new URL(authorizeRes.headers.get('Location') ?? '', 'http://localhost');
|
|
6111
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6112
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6113
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6114
|
+
// it, so the same flow works in both builds.
|
|
6115
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
5478
6116
|
|
|
5479
|
-
const res = await app.request(loginUrl.pathname + loginUrl.search);
|
|
6117
|
+
const res = await app.request(loginUrl.pathname + loginUrl.search, { headers: { Cookie: bindingCookie } });
|
|
5480
6118
|
|
|
5481
6119
|
// The login body carries a dynamic transaction_id / csrf_token, so the
|
|
5482
6120
|
// status + content type pin that renderView delivered a text/html Response
|
|
@@ -5542,13 +6180,18 @@ async function conformanceAuthorizationCode(scope: string): Promise<string> {
|
|
|
5542
6180
|
'&code_challenge=' + CONFORMANCE_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
5543
6181
|
);
|
|
5544
6182
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
6183
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6184
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6185
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6186
|
+
// it, so the same flow works in both builds.
|
|
6187
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
5545
6188
|
const transactionId =
|
|
5546
6189
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
5547
6190
|
|
|
5548
|
-
const loginGet = await app.request(loginPath);
|
|
6191
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
5549
6192
|
const loginRes = await app.request('/login', {
|
|
5550
6193
|
method: 'POST',
|
|
5551
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6194
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
5552
6195
|
body: new URLSearchParams({
|
|
5553
6196
|
transaction_id: transactionId,
|
|
5554
6197
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -5558,10 +6201,10 @@ async function conformanceAuthorizationCode(scope: string): Promise<string> {
|
|
|
5558
6201
|
});
|
|
5559
6202
|
|
|
5560
6203
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
5561
|
-
const consentGet = await app.request(consentPath);
|
|
6204
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
5562
6205
|
const consentRes = await app.request('/consent', {
|
|
5563
6206
|
method: 'POST',
|
|
5564
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6207
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
5565
6208
|
body: new URLSearchParams({
|
|
5566
6209
|
transaction_id: transactionId,
|
|
5567
6210
|
csrf_token: csrfFrom(await consentGet.text()),
|
|
@@ -6023,14 +6666,19 @@ export function pkceDisabledConformanceBlock(features) {
|
|
|
6023
6666
|
);
|
|
6024
6667
|
expect(authorizeRes.status).toBe(302);
|
|
6025
6668
|
const loginPath = relativePathFrom(authorizeRes.headers.get('Location'));
|
|
6669
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6670
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6671
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6672
|
+
// it, so the same flow works in both builds.
|
|
6673
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6026
6674
|
expect(loginPath.startsWith('/login?')).toBe(true);
|
|
6027
6675
|
const transactionId =
|
|
6028
6676
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6029
6677
|
|
|
6030
|
-
const loginGet = await app.request(loginPath);
|
|
6678
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6031
6679
|
const loginRes = await app.request('/login', {
|
|
6032
6680
|
method: 'POST',
|
|
6033
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6681
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6034
6682
|
body: new URLSearchParams({
|
|
6035
6683
|
transaction_id: transactionId,
|
|
6036
6684
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6042,10 +6690,10 @@ export function pkceDisabledConformanceBlock(features) {
|
|
|
6042
6690
|
const consentPath = relativePathFrom(loginRes.headers.get('Location'));
|
|
6043
6691
|
expect(consentPath.startsWith('/consent?')).toBe(true);
|
|
6044
6692
|
|
|
6045
|
-
const consentGet = await app.request(consentPath);
|
|
6693
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6046
6694
|
const consentRes = await app.request('/consent', {
|
|
6047
6695
|
method: 'POST',
|
|
6048
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6696
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6049
6697
|
body: new URLSearchParams({
|
|
6050
6698
|
transaction_id: transactionId,
|
|
6051
6699
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6099,12 +6747,17 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6099
6747
|
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256',
|
|
6100
6748
|
);
|
|
6101
6749
|
const loginPath = relativeLocation(authorizeRes.headers.get('Location'));
|
|
6750
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6751
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6752
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6753
|
+
// it, so the same flow works in both builds.
|
|
6754
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6102
6755
|
const transactionId =
|
|
6103
6756
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6104
|
-
const loginGet = await app.request(loginPath);
|
|
6757
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6105
6758
|
const loginRes = await app.request('/login', {
|
|
6106
6759
|
method: 'POST',
|
|
6107
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6760
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6108
6761
|
body: new URLSearchParams({
|
|
6109
6762
|
transaction_id: transactionId,
|
|
6110
6763
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6113,10 +6766,10 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6113
6766
|
}).toString(),
|
|
6114
6767
|
});
|
|
6115
6768
|
const consentPath = relativeLocation(loginRes.headers.get('Location'));
|
|
6116
|
-
const consentGet = await app.request(consentPath);
|
|
6769
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6117
6770
|
const consentRes = await app.request('/consent', {
|
|
6118
6771
|
method: 'POST',
|
|
6119
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6772
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6120
6773
|
body: new URLSearchParams({
|
|
6121
6774
|
transaction_id: transactionId,
|
|
6122
6775
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6162,12 +6815,17 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6162
6815
|
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256',
|
|
6163
6816
|
);
|
|
6164
6817
|
const loginPath = relativeLocation(authorizeRes.headers.get('Location'));
|
|
6818
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6819
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6820
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6821
|
+
// it, so the same flow works in both builds.
|
|
6822
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6165
6823
|
const transactionId =
|
|
6166
6824
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6167
|
-
const loginGet = await app.request(loginPath);
|
|
6825
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6168
6826
|
const loginRes = await app.request('/login', {
|
|
6169
6827
|
method: 'POST',
|
|
6170
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6828
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6171
6829
|
body: new URLSearchParams({
|
|
6172
6830
|
transaction_id: transactionId,
|
|
6173
6831
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6176,10 +6834,10 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6176
6834
|
}).toString(),
|
|
6177
6835
|
});
|
|
6178
6836
|
const consentPath = relativeLocation(loginRes.headers.get('Location'));
|
|
6179
|
-
const consentGet = await app.request(consentPath);
|
|
6837
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6180
6838
|
const consentRes = await app.request('/consent', {
|
|
6181
6839
|
method: 'POST',
|
|
6182
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6840
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6183
6841
|
body: new URLSearchParams({
|
|
6184
6842
|
transaction_id: transactionId,
|
|
6185
6843
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6223,12 +6881,17 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6223
6881
|
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256',
|
|
6224
6882
|
);
|
|
6225
6883
|
const loginPath = relativeLocation(authorizeRes.headers.get('Location'));
|
|
6884
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
6885
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
6886
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
6887
|
+
// it, so the same flow works in both builds.
|
|
6888
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6226
6889
|
const transactionId =
|
|
6227
6890
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6228
|
-
const loginGet = await app.request(loginPath);
|
|
6891
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6229
6892
|
const loginRes = await app.request('/login', {
|
|
6230
6893
|
method: 'POST',
|
|
6231
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6894
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6232
6895
|
body: new URLSearchParams({
|
|
6233
6896
|
transaction_id: transactionId,
|
|
6234
6897
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6237,10 +6900,10 @@ export function tokenEndpointAuthMethodsConformanceBlock() {
|
|
|
6237
6900
|
}).toString(),
|
|
6238
6901
|
});
|
|
6239
6902
|
const consentPath = relativeLocation(loginRes.headers.get('Location'));
|
|
6240
|
-
const consentGet = await app.request(consentPath);
|
|
6903
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6241
6904
|
const consentRes = await app.request('/consent', {
|
|
6242
6905
|
method: 'POST',
|
|
6243
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
6906
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6244
6907
|
body: new URLSearchParams({
|
|
6245
6908
|
transaction_id: transactionId,
|
|
6246
6909
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6391,11 +7054,16 @@ ${corsPreflightTest}
|
|
|
6391
7054
|
);
|
|
6392
7055
|
expect(authorizeRes.status).toBe(302);
|
|
6393
7056
|
const loginUrl = new URL(authorizeRes.headers.get('Location') ?? '', 'http://localhost');
|
|
7057
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
7058
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
7059
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
7060
|
+
// it, so the same flow works in both builds.
|
|
7061
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6394
7062
|
const transactionId = loginUrl.searchParams.get('transaction_id') ?? '';
|
|
6395
|
-
const loginGet = await app.request(loginUrl.pathname + loginUrl.search);
|
|
7063
|
+
const loginGet = await app.request(loginUrl.pathname + loginUrl.search, { headers: { Cookie: bindingCookie } });
|
|
6396
7064
|
const loginRes = await app.request('/login', {
|
|
6397
7065
|
method: 'POST',
|
|
6398
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7066
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6399
7067
|
body: new URLSearchParams({
|
|
6400
7068
|
transaction_id: transactionId,
|
|
6401
7069
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6405,10 +7073,12 @@ ${corsPreflightTest}
|
|
|
6405
7073
|
});
|
|
6406
7074
|
expect(loginRes.status).toBe(302);
|
|
6407
7075
|
const consentUrl = new URL(loginRes.headers.get('Location') ?? '', 'http://localhost');
|
|
6408
|
-
const consentGet = await app.request(consentUrl.pathname + consentUrl.search
|
|
7076
|
+
const consentGet = await app.request(consentUrl.pathname + consentUrl.search, {
|
|
7077
|
+
headers: { Cookie: bindingCookie },
|
|
7078
|
+
});
|
|
6409
7079
|
const denyRes = await app.request('/consent', {
|
|
6410
7080
|
method: 'POST',
|
|
6411
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7081
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6412
7082
|
body: new URLSearchParams({
|
|
6413
7083
|
transaction_id: transactionId,
|
|
6414
7084
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6503,12 +7173,17 @@ export function idTokenHintConformanceBlock() {
|
|
|
6503
7173
|
'&code_challenge=' + HINT_PKCE_CHALLENGE + '&code_challenge_method=S256',
|
|
6504
7174
|
);
|
|
6505
7175
|
const loginPath = hintRelativeLocation(authorizeRes.headers.get('Location'));
|
|
7176
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
7177
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
7178
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
7179
|
+
// it, so the same flow works in both builds.
|
|
7180
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6506
7181
|
const transactionId =
|
|
6507
7182
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6508
|
-
const loginGet = await app.request(loginPath);
|
|
7183
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6509
7184
|
const loginRes = await app.request('/login', {
|
|
6510
7185
|
method: 'POST',
|
|
6511
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7186
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6512
7187
|
body: new URLSearchParams({
|
|
6513
7188
|
transaction_id: transactionId,
|
|
6514
7189
|
csrf_token: hintCsrfToken(await loginGet.text()),
|
|
@@ -6518,10 +7193,10 @@ export function idTokenHintConformanceBlock() {
|
|
|
6518
7193
|
});
|
|
6519
7194
|
hintSessionCookie = loginRes.headers.get('Set-Cookie') ?? '';
|
|
6520
7195
|
const consentPath = hintRelativeLocation(loginRes.headers.get('Location'));
|
|
6521
|
-
const consentGet = await app.request(consentPath);
|
|
7196
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6522
7197
|
await app.request('/consent', {
|
|
6523
7198
|
method: 'POST',
|
|
6524
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7199
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6525
7200
|
body: new URLSearchParams({
|
|
6526
7201
|
transaction_id: transactionId,
|
|
6527
7202
|
csrf_token: hintCsrfToken(await consentGet.text()),
|
|
@@ -6720,13 +7395,18 @@ export function consentWithdrawalConformanceBlock(features) {
|
|
|
6720
7395
|
);
|
|
6721
7396
|
expect(authorizeRes.status).toBe(302);
|
|
6722
7397
|
const loginPath = relativeLocation(authorizeRes.headers.get('Location'));
|
|
7398
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
7399
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
7400
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
7401
|
+
// it, so the same flow works in both builds.
|
|
7402
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6723
7403
|
const transactionId =
|
|
6724
7404
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6725
7405
|
|
|
6726
|
-
const loginGet = await app.request(loginPath);
|
|
7406
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6727
7407
|
const loginRes = await app.request('/login', {
|
|
6728
7408
|
method: 'POST',
|
|
6729
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7409
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6730
7410
|
body: new URLSearchParams({
|
|
6731
7411
|
transaction_id: transactionId,
|
|
6732
7412
|
csrf_token: csrfTokenFrom(await loginGet.text()),
|
|
@@ -6737,10 +7417,10 @@ export function consentWithdrawalConformanceBlock(features) {
|
|
|
6737
7417
|
expect(loginRes.status).toBe(302);
|
|
6738
7418
|
const sessionCookie = loginRes.headers.get('Set-Cookie') ?? '';
|
|
6739
7419
|
const consentPath = relativeLocation(loginRes.headers.get('Location'));
|
|
6740
|
-
const consentGet = await app.request(consentPath);
|
|
7420
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6741
7421
|
const consentRes = await app.request('/consent', {
|
|
6742
7422
|
method: 'POST',
|
|
6743
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7423
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6744
7424
|
body: new URLSearchParams({
|
|
6745
7425
|
transaction_id: transactionId,
|
|
6746
7426
|
csrf_token: csrfTokenFrom(await consentGet.text()),
|
|
@@ -6919,13 +7599,18 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
6919
7599
|
|
|
6920
7600
|
const authorizeRes = await app.request(authorizeUrl);
|
|
6921
7601
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
7602
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
7603
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
7604
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
7605
|
+
// it, so the same flow works in both builds.
|
|
7606
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
6922
7607
|
const transactionId =
|
|
6923
7608
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
6924
7609
|
|
|
6925
|
-
const loginGet = await app.request(loginPath);
|
|
7610
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
6926
7611
|
const loginRes = await app.request('/login', {
|
|
6927
7612
|
method: 'POST',
|
|
6928
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7613
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6929
7614
|
body: new URLSearchParams({
|
|
6930
7615
|
transaction_id: transactionId,
|
|
6931
7616
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -6935,10 +7620,10 @@ export function tokenExchangeConformanceBlock(features) {
|
|
|
6935
7620
|
});
|
|
6936
7621
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
6937
7622
|
|
|
6938
|
-
const consentGet = await app.request(consentPath);
|
|
7623
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
6939
7624
|
const consentRes = await app.request('/consent', {
|
|
6940
7625
|
method: 'POST',
|
|
6941
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7626
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
6942
7627
|
body: new URLSearchParams({
|
|
6943
7628
|
transaction_id: transactionId,
|
|
6944
7629
|
csrf_token: csrfFrom(await consentGet.text()),
|
|
@@ -7578,12 +8263,17 @@ export function parConformanceBlock(features) {
|
|
|
7578
8263
|
'/authorize?client_id=c-conf&request_uri=' + encodeURIComponent(requestUri),
|
|
7579
8264
|
);
|
|
7580
8265
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
8266
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
8267
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
8268
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
8269
|
+
// it, so the same flow works in both builds.
|
|
8270
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
7581
8271
|
const transactionId =
|
|
7582
8272
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
7583
|
-
const loginGet = await app.request(loginPath);
|
|
8273
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
7584
8274
|
const loginRes = await app.request('/login', {
|
|
7585
8275
|
method: 'POST',
|
|
7586
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
8276
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
7587
8277
|
body: new URLSearchParams({
|
|
7588
8278
|
transaction_id: transactionId,
|
|
7589
8279
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -7592,10 +8282,10 @@ export function parConformanceBlock(features) {
|
|
|
7592
8282
|
}).toString(),
|
|
7593
8283
|
});
|
|
7594
8284
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
7595
|
-
const consentGet = await app.request(consentPath);
|
|
8285
|
+
const consentGet = await app.request(consentPath, { headers: { Cookie: bindingCookie } });
|
|
7596
8286
|
const consentRes = await app.request('/consent', {
|
|
7597
8287
|
method: 'POST',
|
|
7598
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
8288
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
7599
8289
|
body: new URLSearchParams({
|
|
7600
8290
|
transaction_id: transactionId,
|
|
7601
8291
|
csrf_token: csrfFrom(await consentGet.text()),
|
|
@@ -7639,12 +8329,17 @@ export function parConformanceBlock(features) {
|
|
|
7639
8329
|
encodeURIComponent(requestUri),
|
|
7640
8330
|
);
|
|
7641
8331
|
const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
|
|
8332
|
+
// Carry forward whatever cookie /authorize set, exactly as a browser would.
|
|
8333
|
+
// With --enable transaction-binding this is the per-transaction binding
|
|
8334
|
+
// secret the later steps require; without it this is '' and the OP ignores
|
|
8335
|
+
// it, so the same flow works in both builds.
|
|
8336
|
+
const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
|
|
7642
8337
|
const transactionId =
|
|
7643
8338
|
new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
|
|
7644
|
-
const loginGet = await app.request(loginPath);
|
|
8339
|
+
const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
|
|
7645
8340
|
const loginRes = await app.request('/login', {
|
|
7646
8341
|
method: 'POST',
|
|
7647
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
8342
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
|
|
7648
8343
|
body: new URLSearchParams({
|
|
7649
8344
|
transaction_id: transactionId,
|
|
7650
8345
|
csrf_token: csrfFrom(await loginGet.text()),
|
|
@@ -7653,7 +8348,7 @@ export function parConformanceBlock(features) {
|
|
|
7653
8348
|
}).toString(),
|
|
7654
8349
|
});
|
|
7655
8350
|
const consentPath = relativeFrom(loginRes.headers.get('Location'));
|
|
7656
|
-
const consentHtml = await (await app.request(consentPath)).text();
|
|
8351
|
+
const consentHtml = await (await app.request(consentPath, { headers: { Cookie: bindingCookie } })).text();
|
|
7657
8352
|
|
|
7658
8353
|
expect(authorizeRes.status).toBe(302);
|
|
7659
8354
|
// The consent screen lists the pushed scope, not the tampered one.
|
|
@@ -8309,7 +9004,7 @@ ${introspectionConformanceBlock(features)}
|
|
|
8309
9004
|
});
|
|
8310
9005
|
});
|
|
8311
9006
|
});
|
|
8312
|
-
${customViewConformanceTestBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}});
|
|
9007
|
+
${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}});
|
|
8313
9008
|
`;
|
|
8314
9009
|
}
|
|
8315
9010
|
//# sourceMappingURL=templates.js.map
|