@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.
@@ -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 transaction = createAuthTransaction(validatedRequest, csrfToken);
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
- 10 * 60, // 10 minutes TTL
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 login -> consent handoff is keyed by transaction_id (not a cookie), so the
4869
- // flow needs no cookie jar. These helpers only fetch and parse: they make no
4870
- // assertions and contain no branching, so every check stays in the it() blocks as
4871
- // an expect(). Test code carries no logic that could drift from the OP's behavior.
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 login -> consent handoff is keyed by transaction_id (not a cookie), so the
5031
- // flow needs no cookie jar. These helpers only fetch and parse: they make no
5032
- // assertions and contain no branching, so every check stays in the it() blocks as
5033
- // an expect(). Test code carries no logic that could drift from the OP's behavior.
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