@maronn-openid-connect/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_FEATURES } from '../../features.js';
2
2
  export const EXPERIMENTAL_PACKAGE = '@maronn-openid-connect/experimental';
3
+ export const GOOGLE_LOGIN_PACKAGE = '@maronn-openid-connect/google-login';
3
4
  function singleQuoted(value) {
4
5
  return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
5
6
  }
@@ -23,14 +24,21 @@ function oidcMethodGuardTemplate(features) {
23
24
  '/ciba/login': ['POST'],
24
25
  '/ciba/approve': ['POST'],\n`
25
26
  : '';
27
+ const logoutMethods = features.rpInitiatedLogout
28
+ ? ` '/logout': ['GET', 'POST'],
29
+ '/logout/approve': ['POST'],\n`
30
+ : '';
31
+ const googleLoginMethods = features.googleLogin
32
+ ? ` '/login/google': ['POST'],\n`
33
+ : '';
26
34
  return `const OIDC_ENDPOINT_METHODS: Readonly<Record<string, readonly string[]>> = {
27
35
  '/authorize': ['GET', 'POST'],
28
36
  '/token': ['POST'],
29
37
  '/userinfo': ['GET', 'POST'],
30
- ${introspectionMethod}${revocationMethod}${parMethod}${deviceMethods}${cibaMethods} '/.well-known/jwks.json': ['GET'],
38
+ ${introspectionMethod}${revocationMethod}${parMethod}${deviceMethods}${cibaMethods}${logoutMethods} '/.well-known/jwks.json': ['GET'],
31
39
  '/.well-known/openid-configuration': ['GET'],
32
40
  '/login': ['GET', 'POST'],
33
- '/consent': ['GET', 'POST'],
41
+ ${googleLoginMethods} '/consent': ['GET', 'POST'],
34
42
  };
35
43
 
36
44
  async function enforceOidcEndpointMethod(c: any, next: () => Promise<void>): Promise<Response | void> {
@@ -85,14 +93,14 @@ export function appTemplate(_corePkg, features = DEFAULT_FEATURES) {
85
93
  : '';
86
94
  const deviceImport = features.deviceAuthorizationGrant
87
95
  ? `import { deviceAuthorizationApp } from './routes/device-authorization.js';
88
- import { deviceApp } from './routes/device.js';\n`
96
+ import { devicePage } from './pages/device.js';\n`
89
97
  : '';
90
98
  const deviceCors = features.deviceAuthorizationGrant
91
99
  ? ` app.use('/device_authorization', protectedCors);\n`
92
100
  : '';
93
101
  const deviceMount = features.deviceAuthorizationGrant
94
102
  ? ` app.route('/device_authorization', deviceAuthorizationApp);
95
- app.route('/device', deviceApp);\n`
103
+ app.route('/device', devicePage);\n`
96
104
  : '';
97
105
  const deviceStorageContext = features.deviceAuthorizationGrant
98
106
  ? ` c.set('deviceAuthorizationStore', deviceAuthorizationStore);\n`
@@ -102,14 +110,14 @@ import { deviceApp } from './routes/device.js';\n`
102
110
  : '';
103
111
  const cibaImport = features.ciba
104
112
  ? `import { backchannelAuthenticationApp } from './routes/backchannel-authentication.js';
105
- import { cibaApp } from './routes/ciba-verification.js';\n`
113
+ import { cibaPage } from './pages/ciba.js';\n`
106
114
  : '';
107
115
  const cibaCors = features.ciba
108
116
  ? ` app.use('/backchannel_authentication', protectedCors);\n`
109
117
  : '';
110
118
  const cibaMount = features.ciba
111
119
  ? ` app.route('/backchannel_authentication', backchannelAuthenticationApp);
112
- app.route('/ciba', cibaApp);\n`
120
+ app.route('/ciba', cibaPage);\n`
113
121
  : '';
114
122
  const cibaStorageContext = features.ciba
115
123
  ? ` c.set('cibaAuthenticationRequestStore', cibaAuthenticationRequestStore);
@@ -132,6 +140,44 @@ import { cibaApp } from './routes/ciba-verification.js';\n`
132
140
  cibaUserResolver?: (
133
141
  loginHint: string,
134
142
  ) => Promise<{ subject: string } | null> | { subject: string } | null;
143
+ `
144
+ : '';
145
+ const logoutImport = features.rpInitiatedLogout
146
+ ? `import { logoutPage } from './pages/logout.js';\n`
147
+ : '';
148
+ const logoutMount = features.rpInitiatedLogout
149
+ ? ` app.route('/logout', logoutPage);\n`
150
+ : '';
151
+ const googleLoginImport = features.googleLogin
152
+ ? `import {
153
+ getDefaultGoogleIdTokenVerifier,
154
+ type GoogleAccountResolver,
155
+ type GoogleIdTokenPayload,
156
+ type GoogleIdTokenVerifier,
157
+ } from '${GOOGLE_LOGIN_PACKAGE}';\n`
158
+ : '';
159
+ const googleLoginStorageContext = features.googleLogin
160
+ ? ` c.set('googleLoginNonceStore', stores.googleLoginNonceStore);
161
+ c.set('googleIdTokenVerifier', options.googleIdTokenVerifier ?? getDefaultGoogleIdTokenVerifier());
162
+ c.set('googleAccountResolver', options.googleAccountResolver ?? {
163
+ resolveSubject: async (account: GoogleIdTokenPayload) =>
164
+ (await stores.userStore.linkGoogleAccount(account)).sub,
165
+ });\n`
166
+ : '';
167
+ const googleLoginOptionsFields = features.googleLogin
168
+ ? ` /**
169
+ * EXTENSION (google-login): verifier for the ID token Google posts to
170
+ * /login/google. Defaults to google-auth-library (OAuth2Client.verifyIdToken)
171
+ * with a process-wide certificate cache; inject a custom one for tests or a
172
+ * proxied environment.
173
+ */
174
+ googleIdTokenVerifier?: GoogleIdTokenVerifier;
175
+ /**
176
+ * EXTENSION (google-login): map a verified Google account to the OP subject.
177
+ * Defaults to just-in-time provisioning through the user store
178
+ * (userStore.linkGoogleAccount), keyed by the Google \`sub\`.
179
+ */
180
+ googleAccountResolver?: GoogleAccountResolver;
135
181
  `
136
182
  : '';
137
183
  const refreshStorageContext = features.refreshToken
@@ -148,13 +194,13 @@ import { cibaApp } from './routes/ciba-verification.js';\n`
148
194
  const methodGuard = oidcMethodGuardTemplate(features);
149
195
  return `import { Hono } from 'hono';
150
196
  import { cors } from 'hono/cors';
151
- import { authorizeApp } from './routes/authorize.js';
197
+ import { authorizePage } from './pages/authorize.js';
152
198
  import { tokenApp } from './routes/token.js';
153
199
  import { userinfoApp } from './routes/userinfo.js';
154
- ${introspectionImport}${revocationImport}${parImport}${deviceImport}${cibaImport}import { jwksApp } from './routes/jwks.js';
200
+ ${introspectionImport}${revocationImport}${parImport}${deviceImport}${cibaImport}${logoutImport}import { jwksApp } from './routes/jwks.js';
155
201
  import { discoveryApp } from './routes/discovery.js';
156
- import { loginApp } from './routes/login.js';
157
- import { consentApp } from './routes/consent.js';
202
+ import { loginPage } from './pages/login.js';
203
+ import { consentPage } from './pages/consent.js';
158
204
  import {
159
205
  createInMemoryClientResolver,
160
206
  createProviderConfig,
@@ -169,7 +215,7 @@ ${parStoreImport}${deviceStoreImport}${cibaStoreImport} type ProviderStores,
169
215
  type ProviderStoresFactory,
170
216
  } from './store.js';
171
217
  import { createViews, type Views } from './views.js';
172
- import {
218
+ ${googleLoginImport}import {
173
219
  assertHasRs256Key,
174
220
  assertKeyStrength,
175
221
  assertKidStrategyConsistent,
@@ -231,7 +277,7 @@ export interface CreateAppOptions {
231
277
  * Override only when hints are signed by a different key set.
232
278
  */
233
279
  jwksProvider?: () => Promise<JwkSet> | JwkSet;
234
- ${cibaOptionsField} corsOrigins?: CorsOrigins;
280
+ ${cibaOptionsField}${googleLoginOptionsFields} corsOrigins?: CorsOrigins;
235
281
  }
236
282
 
237
283
  export function validateSigningKeySet(
@@ -328,7 +374,7 @@ ${introspectionCors}${revocationCors}${parCors}${deviceCors}${cibaCors} app.use
328
374
  c.set('authCodeResolver', storeResolvers.authorizationCodeResolver);
329
375
  c.set('accessTokenResolver', storeResolvers.accessTokenResolver);
330
376
  c.set('userClaimsResolver', storeResolvers.userClaimsResolver);
331
- ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext}${parStorageContext}${deviceStorageContext}${cibaStorageContext}
377
+ ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext}${parStorageContext}${deviceStorageContext}${cibaStorageContext}${googleLoginStorageContext}
332
378
  // P1: default cookie-based session + consent resolvers so prompt=none /
333
379
  // max_age / SSO work out of the box (OIDC Core 1.0 Section 3.1.2.1 / 3.1.2.3).
334
380
  c.set('sessionResolver', options.sessionResolver ?? storeResolvers.sessionResolver);
@@ -346,13 +392,15 @@ ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext
346
392
  await next();
347
393
  });
348
394
 
349
- app.route('/authorize', authorizeApp);
395
+ // Browser-facing surfaces are mounted from pages/: every GET and POST of a
396
+ // screen lives there, and the logic they call is in routes/.
397
+ app.route('/authorize', authorizePage);
350
398
  app.route('/token', tokenApp);
351
399
  app.route('/userinfo', userinfoApp);
352
- ${introspectionMount}${revocationMount}${parMount}${deviceMount}${cibaMount} app.route('/.well-known/jwks.json', jwksApp);
400
+ ${introspectionMount}${revocationMount}${parMount}${deviceMount}${cibaMount}${logoutMount} app.route('/.well-known/jwks.json', jwksApp);
353
401
  app.route('/.well-known/openid-configuration', discoveryApp);
354
- app.route('/login', loginApp);
355
- app.route('/consent', consentApp);
402
+ app.route('/login', loginPage);
403
+ app.route('/consent', consentPage);
356
404
 
357
405
  return app;
358
406
  }
@@ -584,13 +632,49 @@ ${exampleClientExchangeComment}${exampleClientIdJagComment}${exampleClientCibaCo
584
632
  `
585
633
  : `${noRefreshGrantComment}${exampleClientExchangeComment}${exampleClientIdJagComment}${exampleClientCibaComment} grantTypes: [${exampleClientGrantTypes}],
586
634
  `;
635
+ const googleLoginConfigTypes = features.googleLogin
636
+ ? `
637
+ /**
638
+ * EXTENSION (google-login): Sign in with Google (Google Identity Services,
639
+ * redirect mode) as a login method. See @maronn-openid-connect/google-login.
640
+ */
641
+ export interface GoogleLoginConfig {
642
+ /**
643
+ * OAuth 2.0 client ID (type: Web application) from the Google Cloud console.
644
+ * The ID token's \`aud\` must equal it. Register \`<issuer>/login/google\` as an
645
+ * authorized redirect URI of this client, and the login page origin as an
646
+ * authorized JavaScript origin.
647
+ */
648
+ clientId: string;
649
+ /**
650
+ * Optional: only accept Google Workspace accounts of these hosted domains
651
+ * (\`hd\` claim). A personal Google account has no \`hd\` and is rejected.
652
+ */
653
+ hostedDomain?: string | string[];
654
+ /**
655
+ * Optional: reject accounts whose email Google has not verified
656
+ * (\`email_verified !== true\`). Off by default; users are keyed by the Google
657
+ * \`sub\`, never by email, so an unverified email cannot hijack another user.
658
+ */
659
+ requireVerifiedEmail?: boolean;
660
+ }
661
+ `
662
+ : '';
663
+ const googleLoginConfigField = features.googleLogin
664
+ ? ` /**
665
+ * EXTENSION (google-login): Sign in with Google. Leave undefined to render no
666
+ * Google button; the login page then only offers the username / password form.
667
+ */
668
+ googleLogin?: GoogleLoginConfig;
669
+ `
670
+ : '';
587
671
  return `import type {
588
672
  ClientInfo,
589
673
  ClientResolver,
590
674
  TokenClientInfo,
591
675
  TokenClientResolver,
592
676
  } from '${corePkg}';
593
-
677
+ ${googleLoginConfigTypes}
594
678
  export interface ProviderConfig {
595
679
  issuer: string;
596
680
  accessTokenExpiresIn: number;
@@ -627,7 +711,7 @@ ${allowUnsignedField} /**
627
711
  * 有無に関わらず常に 400 の OAuth error JSON を返す。
628
712
  */
629
713
  authorizationErrorRedirectPath?: string;
630
- }
714
+ ${googleLoginConfigField}}
631
715
 
632
716
  /**
633
717
  * Optional defaults for quick local testing.
@@ -1129,6 +1213,247 @@ export const cibaAuthenticationRequestStore: CibaAuthenticationRequestStore =
1129
1213
  export const cibaLoginTransactionStore: CibaLoginTransactionStore =
1130
1214
  (cibaStoreRegistry.__oidcCibaLoginTransactionStore ??=
1131
1215
  createInMemoryCibaLoginTransactionStore());
1216
+ `
1217
+ : '';
1218
+ const googleLoginStoreTypeImport = features.googleLogin
1219
+ ? `
1220
+ import type {
1221
+ GoogleIdTokenPayload,
1222
+ GoogleLoginNonceRecord,
1223
+ GoogleLoginNonceStore,
1224
+ } from '${GOOGLE_LOGIN_PACKAGE}';`
1225
+ : '';
1226
+ const googleUsersField = features.googleLogin
1227
+ ? ` // EXTENSION (google-login): users provisioned from a verified Google account,
1228
+ // keyed by their OP subject ('google:' + Google sub). They have no password.
1229
+ private googleUsers = new Map<string, UserClaims>();
1230
+
1231
+ `
1232
+ : '';
1233
+ const userStoreGoogleFallback = features.googleLogin ? 'this.googleUsers.get(sub)' : 'undefined';
1234
+ const userStoreGoogleMethods = features.googleLogin
1235
+ ? `
1236
+ /**
1237
+ * EXTENSION (google-login): create or refresh the OP user for a verified Google
1238
+ * account (just-in-time provisioning) and return its claims. The subject is
1239
+ * 'google:' + the Google sub — never the email, which a Google account can
1240
+ * change — so the same person always maps to the same OP user.
1241
+ */
1242
+ linkGoogleAccount(account: GoogleIdTokenPayload): UserClaims {
1243
+ const claims = googleAccountToClaims(account);
1244
+ this.googleUsers.set(claims.sub, claims);
1245
+ return claims;
1246
+ }
1247
+ `
1248
+ : '';
1249
+ const googleLoginStoreImplementation = features.googleLogin
1250
+ ? `/**
1251
+ * EXTENSION (google-login): OP subject prefix for users provisioned from Google.
1252
+ */
1253
+ export const GOOGLE_SUBJECT_PREFIX = 'google:';
1254
+
1255
+ /**
1256
+ * EXTENSION (google-login): the OP user record derived from a verified Google
1257
+ * ID token. Only the profile / email claims Google supplied are copied, so the
1258
+ * UserInfo endpoint returns exactly what Google asserted about the account.
1259
+ */
1260
+ export function googleAccountToClaims(account: GoogleIdTokenPayload): UserClaims {
1261
+ const claims: UserClaims = { sub: GOOGLE_SUBJECT_PREFIX + account.sub };
1262
+ if (account.name !== undefined) claims.name = account.name;
1263
+ if (account.given_name !== undefined) claims.given_name = account.given_name;
1264
+ if (account.family_name !== undefined) claims.family_name = account.family_name;
1265
+ if (account.picture !== undefined) claims.picture = account.picture;
1266
+ if (account.locale !== undefined) claims.locale = account.locale;
1267
+ if (account.email !== undefined) claims.email = account.email;
1268
+ if (account.email_verified !== undefined) claims.email_verified = account.email_verified;
1269
+ return claims;
1270
+ }
1271
+
1272
+ /**
1273
+ * EXTENSION (google-login): in-memory store for the nonce that binds a
1274
+ * "Sign in with Google" click to the authorization transaction it started from
1275
+ * (see @maronn-openid-connect/google-login). An entry lives as long as its
1276
+ * transaction and is consumed on first use by the callback.
1277
+ */
1278
+ export class InMemoryGoogleLoginNonceStore implements GoogleLoginNonceStore {
1279
+ private records = new Map<string, { value: GoogleLoginNonceRecord; expiresAt: number }>();
1280
+
1281
+ async get(key: string): Promise<GoogleLoginNonceRecord | null> {
1282
+ const entry = this.records.get(key);
1283
+ if (!entry) return null;
1284
+ if (Date.now() > entry.expiresAt) {
1285
+ this.records.delete(key);
1286
+ return null;
1287
+ }
1288
+ return entry.value;
1289
+ }
1290
+
1291
+ async put(key: string, value: GoogleLoginNonceRecord, ttlSeconds: number): Promise<void> {
1292
+ this.records.set(key, { value, expiresAt: Date.now() + (ttlSeconds * 1000) });
1293
+ }
1294
+
1295
+ async delete(key: string): Promise<void> {
1296
+ this.records.delete(key);
1297
+ }
1298
+ }
1299
+
1300
+ `
1301
+ : '';
1302
+ const userStorageGoogleMember = features.googleLogin
1303
+ ? ` /** EXTENSION (google-login): provision / refresh the user for a verified Google account. */
1304
+ linkGoogleAccount(account: GoogleIdTokenPayload): Awaitable<UserClaims>;
1305
+ `
1306
+ : '';
1307
+ const providerStoresGoogleMember = features.googleLogin
1308
+ ? ` /** EXTENSION (google-login): nonce -> transaction binding for the Google callback. */
1309
+ googleLoginNonceStore: GoogleLoginNonceStore;
1310
+ `
1311
+ : '';
1312
+ const googleLoginPrefixes = features.googleLogin
1313
+ ? `
1314
+ const GOOGLE_USER_PREFIX = 'google-user:';
1315
+ const GOOGLE_LOGIN_NONCE_PREFIX = 'google-login-nonce:';`
1316
+ : '';
1317
+ const jsonUserStoreGoogleFallback = features.googleLogin ? 'this.findGoogleUser(sub)' : 'undefined';
1318
+ const jsonUserStoreGoogleMethods = features.googleLogin
1319
+ ? `
1320
+ /**
1321
+ * EXTENSION (google-login): provision / refresh the user for a verified Google
1322
+ * account under its own key prefix, so it never collides with a password user.
1323
+ */
1324
+ async linkGoogleAccount(account: GoogleIdTokenPayload): Promise<UserClaims> {
1325
+ const claims = googleAccountToClaims(account);
1326
+ await this.backend.put(GOOGLE_USER_PREFIX + claims.sub, claims);
1327
+ return claims;
1328
+ }
1329
+
1330
+ private async findGoogleUser(sub: string): Promise<UserClaims | undefined> {
1331
+ return (await this.backend.get<UserClaims>(GOOGLE_USER_PREFIX + sub)) ?? undefined;
1332
+ }
1333
+ `
1334
+ : '';
1335
+ const jsonGoogleNonceStore = features.googleLogin
1336
+ ? `class JsonGoogleLoginNonceStore implements GoogleLoginNonceStore {
1337
+ constructor(private readonly backend: JsonStoreBackend) {}
1338
+
1339
+ async get(key: string): Promise<GoogleLoginNonceRecord | null> {
1340
+ return this.backend.get<GoogleLoginNonceRecord>(GOOGLE_LOGIN_NONCE_PREFIX + key);
1341
+ }
1342
+
1343
+ async put(key: string, value: GoogleLoginNonceRecord, ttlSeconds: number): Promise<void> {
1344
+ await this.backend.put(GOOGLE_LOGIN_NONCE_PREFIX + key, value, ttlSeconds);
1345
+ }
1346
+
1347
+ async delete(key: string): Promise<void> {
1348
+ await this.backend.delete(GOOGLE_LOGIN_NONCE_PREFIX + key);
1349
+ }
1350
+ }
1351
+
1352
+ `
1353
+ : '';
1354
+ const jsonStoresGoogleEntry = features.googleLogin
1355
+ ? ` googleLoginNonceStore: new JsonGoogleLoginNonceStore(backend),
1356
+ `
1357
+ : '';
1358
+ const defaultStoresGoogleEntry = features.googleLogin
1359
+ ? ` googleLoginNonceStore: new InMemoryGoogleLoginNonceStore(),
1360
+ `
1361
+ : '';
1362
+ const googleLoginStoreExport = features.googleLogin
1363
+ ? `
1364
+ export const googleLoginNonceStore = defaultProviderStores.googleLoginNonceStore;`
1365
+ : '';
1366
+ const rpInitiatedLogoutHelpers = features.rpInitiatedLogout
1367
+ ? `
1368
+ /**
1369
+ * EXPERIMENTAL — RP-Initiated Logout confirmation cookie
1370
+ * (RP-Initiated Logout 1.0 §2).
1371
+ *
1372
+ * Rendering the logout confirmation screen mints a fresh secret and hands it
1373
+ * to that one browser twice: in this HttpOnly cookie and in the form's hidden
1374
+ * csrf_token. POST /logout/approve runs only when both come back carrying the
1375
+ * same secret. An attacker can collect a valid pair in their own browser, but
1376
+ * cannot set this cookie in the victim's browser, so a forged cross-site POST
1377
+ * fails the comparison (and SameSite=Lax drops the cookie from a cross-site
1378
+ * POST to begin with). Neither half alone is ever accepted — the same model
1379
+ * as the device verification binding cookie above.
1380
+ *
1381
+ * The cookie also carries the OP-computed post-logout redirect target
1382
+ * (base64url of the exact registered URL, or empty when there is none), so
1383
+ * the redirect decision survives the confirmation round-trip inside an
1384
+ * HttpOnly channel instead of a tamperable hidden form field — and the
1385
+ * id_token_hint itself is never echoed into the page.
1386
+ */
1387
+ export const LOGOUT_CONFIRMATION_COOKIE = 'oidc_logout_confirm';
1388
+
1389
+ /** What one rendered confirmation screen carries across to its approve POST. */
1390
+ export interface LogoutConfirmation {
1391
+ /** Secret pairing the HttpOnly cookie with the form's hidden csrf_token. */
1392
+ csrfSecret: string;
1393
+ /** Registered redirect URL resolved at render time, or null for the completed page. */
1394
+ redirectTo: string | null;
1395
+ }
1396
+
1397
+ export function buildLogoutConfirmationCookie(confirmation: LogoutConfirmation): string {
1398
+ const bytes = new TextEncoder().encode(confirmation.redirectTo ?? '');
1399
+ const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
1400
+ const encodedRedirect = btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
1401
+ return (
1402
+ LOGOUT_CONFIRMATION_COOKIE + '=' + confirmation.csrfSecret + '.' + encodedRedirect +
1403
+ // 10 minutes: enough to read the screen and click, short enough that an
1404
+ // abandoned confirmation does not leave a long-lived pre-auth cookie.
1405
+ '; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600'
1406
+ );
1407
+ }
1408
+
1409
+ /** Clear the confirmation cookie once the approve POST consumed it. */
1410
+ export function buildClearedLogoutConfirmationCookie(): string {
1411
+ return LOGOUT_CONFIRMATION_COOKIE + '=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0';
1412
+ }
1413
+
1414
+ /**
1415
+ * Parse the confirmation cookie back. Returns null when the cookie is absent
1416
+ * or malformed in any way, which the approve POST answers with 400 and,
1417
+ * crucially, without deleting anything.
1418
+ */
1419
+ export function parseLogoutConfirmation(cookieHeader: string | null): LogoutConfirmation | null {
1420
+ if (!cookieHeader) return null;
1421
+ let value: string | null = null;
1422
+ for (const part of cookieHeader.split(';')) {
1423
+ const trimmed = part.trim();
1424
+ const eq = trimmed.indexOf('=');
1425
+ if (eq === -1) continue;
1426
+ if (trimmed.slice(0, eq) === LOGOUT_CONFIRMATION_COOKIE) {
1427
+ value = trimmed.slice(eq + 1);
1428
+ break;
1429
+ }
1430
+ }
1431
+ if (value === null) return null;
1432
+ const dot = value.indexOf('.');
1433
+ if (dot === -1) return null;
1434
+ const csrfSecret = value.slice(0, dot);
1435
+ if (csrfSecret === '') return null;
1436
+ const encodedRedirect = value.slice(dot + 1);
1437
+ if (encodedRedirect === '') return { csrfSecret, redirectTo: null };
1438
+ if (!/^[A-Za-z0-9_-]+$/.test(encodedRedirect)) return null;
1439
+ try {
1440
+ const base64 = encodedRedirect.replace(/-/g, '+').replace(/_/g, '/');
1441
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
1442
+ const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
1443
+ return { csrfSecret, redirectTo: new TextDecoder().decode(bytes) };
1444
+ } catch {
1445
+ return null;
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Build the Set-Cookie value that removes the browser session cookie. The
1451
+ * logout routes pair it with browserSessionStore.delete(): the store entry
1452
+ * and the cookie go away together (RP-Initiated Logout 1.0 §2).
1453
+ */
1454
+ export function buildClearedSessionCookie(): string {
1455
+ return SESSION_COOKIE_NAME + '=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0';
1456
+ }
1132
1457
  `
1133
1458
  : '';
1134
1459
  return `import type {
@@ -1138,7 +1463,7 @@ export const cibaLoginTransactionStore: CibaLoginTransactionStore =
1138
1463
  AccessTokenInfo,
1139
1464
  RefreshTokenInfo,
1140
1465
  UserClaims,
1141
- } from '${corePkg}';${parStoreTypeImport}${deviceStoreTypeImport}${cibaStoreTypeImport}
1466
+ } from '${corePkg}';${parStoreTypeImport}${deviceStoreTypeImport}${cibaStoreTypeImport}${googleLoginStoreTypeImport}
1142
1467
 
1143
1468
  /**
1144
1469
  * In-memory Authorization Transaction Store.
@@ -1400,7 +1725,7 @@ export function parseSessionId(cookieHeader: string | null): string | undefined
1400
1725
  export function buildSessionCookie(sessionId: string): string {
1401
1726
  return SESSION_COOKIE_NAME + '=' + sessionId + '; HttpOnly; Secure; SameSite=Lax; Path=/';
1402
1727
  }
1403
- ${transactionBindingHelpers}
1728
+ ${transactionBindingHelpers}${rpInitiatedLogoutHelpers}
1404
1729
  /**
1405
1730
  * In-memory consent store. Records that a user granted a set of scopes to a
1406
1731
  * client so prompt=none can confirm consent without showing UI
@@ -1459,7 +1784,7 @@ export class ConsentStore {
1459
1784
  export class UserStore {
1460
1785
  private users = new Map<string, UserClaims & { password: string }>();
1461
1786
 
1462
- constructor() {
1787
+ ${googleUsersField} constructor() {
1463
1788
  // Example user for development.
1464
1789
  // Carries the standard claims for every scope advertised in Discovery
1465
1790
  // (profile / email / address / phone — OIDC Core 1.0 §5.4) so the OIDF
@@ -1524,13 +1849,13 @@ export class UserStore {
1524
1849
 
1525
1850
  getClaims(sub: string): UserClaims | undefined {
1526
1851
  const user = this.users.get(sub);
1527
- if (!user) return undefined;
1852
+ if (!user) return ${userStoreGoogleFallback};
1528
1853
  const { password: _, ...claims } = user;
1529
1854
  return claims;
1530
1855
  }
1531
- }
1856
+ ${userStoreGoogleMethods}}
1532
1857
 
1533
- export type Awaitable<T> = T | Promise<T>;
1858
+ ${googleLoginStoreImplementation}export type Awaitable<T> = T | Promise<T>;
1534
1859
 
1535
1860
  export interface JsonStoreEntry<T> {
1536
1861
  key: string;
@@ -1598,7 +1923,7 @@ export interface UserStorage {
1598
1923
  password: string,
1599
1924
  ): Awaitable<(UserClaims & { password: string }) | undefined>;
1600
1925
  getClaims(sub: string): Awaitable<UserClaims | undefined>;
1601
- }
1926
+ ${userStorageGoogleMember}}
1602
1927
 
1603
1928
  export interface ProviderStores {
1604
1929
  transactionStore: AuthTransactionStore;
@@ -1609,7 +1934,7 @@ export interface ProviderStores {
1609
1934
  browserSessionStore: BrowserSessionStorage;
1610
1935
  consentStore: ConsentStorage;
1611
1936
  userStore: UserStorage;
1612
- }
1937
+ ${providerStoresGoogleMember}}
1613
1938
 
1614
1939
  export type ProviderStoresFactory = (
1615
1940
  context: any,
@@ -1622,7 +1947,7 @@ const REFRESH_TOKEN_PREFIX = 'refresh-token:';
1622
1947
  const AUTH_SESSION_PREFIX = 'auth-session:';
1623
1948
  const BROWSER_SESSION_PREFIX = 'browser-session:';
1624
1949
  const CONSENT_PREFIX = 'consent:';
1625
- const USER_PREFIX = 'user:';
1950
+ const USER_PREFIX = 'user:';${googleLoginPrefixes}
1626
1951
 
1627
1952
  class JsonTransactionStore implements AuthTransactionStore {
1628
1953
  constructor(private readonly backend: JsonStoreBackend) {}
@@ -1835,7 +2160,7 @@ class JsonUserStore implements UserStorage {
1835
2160
 
1836
2161
  async getClaims(sub: string): Promise<UserClaims | undefined> {
1837
2162
  const user = await this.findOrSeed(sub);
1838
- if (!user) return undefined;
2163
+ if (!user) return ${jsonUserStoreGoogleFallback};
1839
2164
  const { password: _, ...claims } = user;
1840
2165
  return claims;
1841
2166
  }
@@ -1849,9 +2174,9 @@ class JsonUserStore implements UserStorage {
1849
2174
  await this.backend.put(key, fixture);
1850
2175
  return fixture;
1851
2176
  }
1852
- }
2177
+ ${jsonUserStoreGoogleMethods}}
1853
2178
 
1854
- /** Create all OP stores over one deployment-native JSON backend. */
2179
+ ${jsonGoogleNonceStore}/** Create all OP stores over one deployment-native JSON backend. */
1855
2180
  export function createJsonProviderStores(backend: JsonStoreBackend): ProviderStores {
1856
2181
  return {
1857
2182
  transactionStore: new JsonTransactionStore(backend),
@@ -1862,7 +2187,7 @@ export function createJsonProviderStores(backend: JsonStoreBackend): ProviderSto
1862
2187
  browserSessionStore: new JsonBrowserSessionStore(backend),
1863
2188
  consentStore: new JsonConsentStore(backend),
1864
2189
  userStore: new JsonUserStore(backend),
1865
- };
2190
+ ${jsonStoresGoogleEntry} };
1866
2191
  }
1867
2192
 
1868
2193
  function epochSeconds(): number {
@@ -1946,7 +2271,7 @@ export const defaultProviderStores = (storeRegistry.__oidcProviderStores ??= {
1946
2271
  browserSessionStore: new BrowserSessionStore(),
1947
2272
  consentStore: new ConsentStore(),
1948
2273
  userStore: new UserStore(),
1949
- });
2274
+ ${defaultStoresGoogleEntry}});
1950
2275
 
1951
2276
  export const transactionStore = defaultProviderStores.transactionStore;
1952
2277
  export const authCodeStore = defaultProviderStores.authCodeStore;
@@ -1955,7 +2280,7 @@ export const refreshTokenStore = defaultProviderStores.refreshTokenStore;
1955
2280
  export const authSessionStore = defaultProviderStores.authSessionStore;
1956
2281
  export const browserSessionStore = defaultProviderStores.browserSessionStore;
1957
2282
  export const consentStore = defaultProviderStores.consentStore;
1958
- export const userStore = defaultProviderStores.userStore;
2283
+ export const userStore = defaultProviderStores.userStore;${googleLoginStoreExport}
1959
2284
  ${parStoreImplementation}${deviceStoreImplementation}${cibaStoreImplementation}`;
1960
2285
  }
1961
2286
  export function resolversTemplate(corePkg, features = DEFAULT_FEATURES) {
@@ -2276,23 +2601,9 @@ import { findUnsupportedScopes, resolveGrantableScopes } from '../scopes.js';`
2276
2601
  `
2277
2602
  : ` const transaction = createAuthTransaction(validatedRequest, csrfToken);
2278
2603
  `;
2279
- const bindingCookieOnConsentRedirect = features.transactionBinding
2280
- ? ` // Hand the binding secret to this browser before the interactive steps.
2281
- // Only paths that continue in the browser get the cookie; paths that
2282
- // redirect straight back to the client never needed one.
2283
- c.header(
2284
- 'Set-Cookie',
2285
- buildTransactionBindingCookie(transactionId, bindingSecret, transactionTtlSeconds),
2286
- );
2287
- `
2288
- : '';
2289
- const bindingCookieOnLoginRedirect = features.transactionBinding
2290
- ? ` c.header(
2291
- 'Set-Cookie',
2292
- buildTransactionBindingCookie(transactionId, bindingSecret, transactionTtlSeconds),
2293
- );
2294
- `
2295
- : '';
2604
+ const browserStepCookies = features.transactionBinding
2605
+ ? '[buildTransactionBindingCookie(transactionId, bindingSecret, transactionTtlSeconds)]'
2606
+ : '[]';
2296
2607
  const requestObjectImports = features.requestObject
2297
2608
  ? `
2298
2609
  resolveRequestObjectParams,
@@ -2375,27 +2686,7 @@ import { parStore as defaultParStore } from '../store.js';`
2375
2686
  // non-redirect path as AuthorizationError below. Every failure kind
2376
2687
  // (unknown / used / expired / wrong client) returns the identical code and
2377
2688
  // description so the response cannot be used as an existence oracle.
2378
- const acceptsJson = (c.req.header('Accept') ?? '').includes('application/json');
2379
- if (acceptsJson) {
2380
- return c.json({ error: error.code, error_description: error.errorDescription }, 400);
2381
- }
2382
- const parErrorPagePath = c.get('config').authorizationErrorRedirectPath;
2383
- if (parErrorPagePath && parErrorPagePath.startsWith('/') && !parErrorPagePath.startsWith('//')) {
2384
- const parErrorParams = new URLSearchParams({
2385
- error: error.code,
2386
- error_description: error.errorDescription,
2387
- });
2388
- return c.redirect(\`\${parErrorPagePath}?\${parErrorParams.toString()}\`, 303);
2389
- }
2390
- const parViews = c.get('views') ?? defaultViews;
2391
- return renderView(
2392
- parViews.errorPage({
2393
- error: error.code,
2394
- errorDescription: error.errorDescription,
2395
- statusCode: 400,
2396
- }),
2397
- { status: 400 },
2398
- );
2689
+ return { kind: 'error', error: error.code, errorDescription: error.errorDescription };
2399
2690
  }
2400
2691
  `
2401
2692
  : '';
@@ -2596,37 +2887,39 @@ function buildErrorRedirect(
2596
2887
  return url.toString();
2597
2888
  }`;
2598
2889
  const promptNoneSuccessRedirect = features.jarm
2599
- ? ` return c.redirect(
2600
- await buildSuccessRedirect(
2890
+ ? ` return {
2891
+ kind: 'authorization_response',
2892
+ location: await buildSuccessRedirect(
2601
2893
  jarmResponse,
2602
2894
  transaction.redirectUri,
2603
2895
  authCodeData.code,
2604
2896
  transaction.state,
2605
2897
  issuer,
2606
2898
  ),
2607
- );`
2899
+ };`
2608
2900
  : ` const redirectUrl = new URL(transaction.redirectUri);
2609
2901
  redirectUrl.searchParams.set('code', authCodeData.code);
2610
2902
  if (transaction.state) redirectUrl.searchParams.set('state', transaction.state);
2611
2903
  // RFC 9207 §2: include iss in success responses too.
2612
2904
  redirectUrl.searchParams.set('iss', issuer);
2613
- return c.redirect(redirectUrl.toString());`;
2905
+ return { kind: 'authorization_response', location: redirectUrl.toString() };`;
2614
2906
  const ssoSuccessRedirect = features.jarm
2615
- ? ` return c.redirect(
2616
- await buildSuccessRedirect(
2907
+ ? ` return {
2908
+ kind: 'authorization_response',
2909
+ location: await buildSuccessRedirect(
2617
2910
  jarmResponse,
2618
2911
  transaction.redirectUri,
2619
2912
  authCodeData.code,
2620
2913
  transaction.state,
2621
2914
  issuer,
2622
2915
  ),
2623
- );`
2916
+ };`
2624
2917
  : ` const redirectUrl = new URL(transaction.redirectUri);
2625
2918
  redirectUrl.searchParams.set('code', authCodeData.code);
2626
2919
  if (transaction.state) redirectUrl.searchParams.set('state', transaction.state);
2627
2920
  // RFC 9207 §2: include iss in success responses.
2628
2921
  redirectUrl.searchParams.set('iss', issuer);
2629
- return c.redirect(redirectUrl.toString());`;
2922
+ return { kind: 'authorization_response', location: redirectUrl.toString() };`;
2630
2923
  const catchErrorRedirect = features.jarm
2631
2924
  ? ` if (error.redirectUri) {
2632
2925
  // RFC 9207 §2: include iss on error redirects so the client can
@@ -2637,8 +2930,9 @@ function buildErrorRedirect(
2637
2930
  // signed JWT and no plain parameter is added. jarmResponse is undefined
2638
2931
  // for errors thrown before response_mode was interpreted (unknown
2639
2932
  // client, unsupported JWT mode), which is why those stay plain.
2640
- return c.redirect(
2641
- await buildErrorRedirect(
2933
+ return {
2934
+ kind: 'authorization_response',
2935
+ location: await buildErrorRedirect(
2642
2936
  jarmResponse,
2643
2937
  error.redirectUri,
2644
2938
  error.error,
@@ -2646,7 +2940,7 @@ function buildErrorRedirect(
2646
2940
  error.errorDescription,
2647
2941
  c.get('config').issuer,
2648
2942
  ),
2649
- );
2943
+ };
2650
2944
  }`
2651
2945
  : ` if (error.redirectUri) {
2652
2946
  const redirectUrl = new URL(error.redirectUri);
@@ -2662,7 +2956,7 @@ function buildErrorRedirect(
2662
2956
  // middleware; reread it here because the early-bound issuer is
2663
2957
  // scoped to the try block.
2664
2958
  redirectUrl.searchParams.set('iss', c.get('config').issuer);
2665
- return c.redirect(redirectUrl.toString());
2959
+ return { kind: 'authorization_response', location: redirectUrl.toString() };
2666
2960
  }`;
2667
2961
  const jarmTransactionPutArg = features.jarm
2668
2962
  ? `jarmResponse ? { ...transaction, jarmResponseMode: 'query.jwt' } : transaction,`
@@ -2683,7 +2977,16 @@ function buildErrorRedirect(
2683
2977
  // (OIDC Core 1.0 §11 requires ignoring the request in that case).
2684
2978
  scope = await applyOfflineAccessPolicy(scope, effectiveParams, prompt, client, () => false);
2685
2979
  `;
2686
- return `import { Hono } from 'hono';
2980
+ return `/**
2981
+ * Authorization endpoint (API layer: logic only).
2982
+ *
2983
+ * processAuthorizationRequest() runs the whole OIDC Core 1.0 §3.1.2 pipeline —
2984
+ * request validation, the transaction, id_token_hint, prompt=none, SSO — and
2985
+ * reports what the endpoint decided as an outcome: the authorization response
2986
+ * URL for the client, "continue on the login / consent screen", or an error
2987
+ * that must stay on the OP. It never builds a Response; pages/authorize.ts,
2988
+ * which owns GET|POST /authorize, turns the outcome into HTTP.
2989
+ */
2687
2990
  import {
2688
2991
  resolveClientForAuthorization,
2689
2992
  validateRegisteredRedirectUris,${requestObjectImports}
@@ -2718,10 +3021,25 @@ import {
2718
3021
  transactionStore as defaultTransactionStore,
2719
3022
  authCodeStore as defaultAuthCodeStore,
2720
3023
  authSessionStore as defaultAuthSessionStore,${bindingStoreImport}
2721
- } from '../store.js';
2722
- import { defaultViews, renderView } from '../views.js';${parImports}${jarmImports}${customScopeImports}
3024
+ } from '../store.js';${parImports}${jarmImports}${customScopeImports}
2723
3025
 
2724
- export const authorizeApp = new Hono<{ Variables: Record<string, any> }>();
3026
+ /** What the authorization endpoint decided; pages/authorize.ts turns it into HTTP. */
3027
+ export type AuthorizationOutcome =
3028
+ /** Malformed transport: OAuth error JSON (400), no transaction exists yet. */
3029
+ | { kind: 'bad_request'; error: string; errorDescription: string }
3030
+ /**
3031
+ * The authorization response for the client — code, redirectable error or
3032
+ * (EXPERIMENTAL JARM) signed response JWT — ready in the URL.
3033
+ */
3034
+ | { kind: 'authorization_response'; location: string }
3035
+ /** Interactive authentication is needed: continue on the login screen. */
3036
+ | { kind: 'login'; transactionId: string; cookies: string[] }
3037
+ /** The End-User is signed in but consent is needed: continue on the consent screen. */
3038
+ | { kind: 'consent'; transactionId: string; cookies: string[] }
3039
+ /** OIDC Core 1.0 §3.1.2.2: the error cannot be redirected and stays on the OP. */
3040
+ | { kind: 'error'; error: string; errorDescription?: string }
3041
+ /** An unexpected failure: OAuth error JSON (500). */
3042
+ | { kind: 'server_error' };
2725
3043
 
2726
3044
  /**
2727
3045
  * Narrows raw query-string params to the typed AuthorizationRequestParams.
@@ -2783,25 +3101,25 @@ async function parseAuthorizationRequestParams(
2783
3101
  }
2784
3102
 
2785
3103
  /**
2786
- * Authorization Endpoint handler shared by GET and POST.
2787
- * OIDC Core 1.0 Section 3.1.2
3104
+ * Process one authorization request (OIDC Core 1.0 Section 3.1.2). Shared by
3105
+ * GET and POST /authorize (pages/authorize.ts).
2788
3106
  */
2789
- const handleAuthorizationRequest = async (c: any) => {
3107
+ export async function processAuthorizationRequest(c: any): Promise<AuthorizationOutcome> {
2790
3108
  const parsed = await parseAuthorizationRequestParams(c);
2791
3109
 
2792
3110
  if (parsed === null) {
2793
- return c.json({ error: 'invalid_request', error_description: 'Authorization POST requests must use application/x-www-form-urlencoded' }, 400);
3111
+ return { kind: 'bad_request', error: 'invalid_request', errorDescription: 'Authorization POST requests must use application/x-www-form-urlencoded' };
2794
3112
  }
2795
3113
 
2796
3114
  // OIDC Core 1.0 §3.1.2.1 / RFC 6749 §3.1: request parameters MUST NOT be repeated.
2797
3115
  if (parsed.duplicateKey !== undefined) {
2798
- return c.json({ error: 'invalid_request', error_description: \`Parameter "\${parsed.duplicateKey}" must not be repeated\` }, 400);
3116
+ return { kind: 'bad_request', error: 'invalid_request', errorDescription: \`Parameter "\${parsed.duplicateKey}" must not be repeated\` };
2799
3117
  }
2800
3118
 
2801
3119
  const rawParams = parsed.params;
2802
3120
 
2803
3121
  if (!isAuthorizationRequestParams(rawParams)) {
2804
- return c.json({ error: 'invalid_request', error_description: 'Missing required parameter: client_id' }, 400);
3122
+ return { kind: 'bad_request', error: 'invalid_request', errorDescription: 'Missing required parameter: client_id' };
2805
3123
  }
2806
3124
 
2807
3125
  ${parParamsBinding}${jarmResponseBinding}
@@ -2911,7 +3229,7 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2911
3229
  // prompt=none must not be combined with other values (OIDC Core 1.0 Section 3.1.2.1)
2912
3230
  if (promptValues.includes('none') && promptValues.length > 1) {
2913
3231
  await transactionStore.delete('auth_txn:' + transactionId);
2914
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'invalid_request', transaction.state, 'prompt=none must not be combined with other prompt values', issuer));
3232
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'invalid_request', transaction.state, 'prompt=none must not be combined with other prompt values', issuer) };
2915
3233
  }
2916
3234
 
2917
3235
  // OIDC Core 1.0 §3.1.2.1: the id_token_hint rule ("if the End-User identified
@@ -2927,7 +3245,7 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2927
3245
  if (!jwksProvider) {
2928
3246
  // jwksProvider 未提供では hint を検証できない → login_required で拒否
2929
3247
  await transactionStore.delete('auth_txn:' + transactionId);
2930
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'jwksProvider is not configured; cannot verify id_token_hint', issuer));
3248
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'jwksProvider is not configured; cannot verify id_token_hint', issuer) };
2931
3249
  }
2932
3250
  try {
2933
3251
  const jwks = await jwksProvider();
@@ -2940,7 +3258,7 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2940
3258
  } catch (hintError) {
2941
3259
  await transactionStore.delete('auth_txn:' + transactionId);
2942
3260
  const code = hintError instanceof IdTokenHintError ? hintError.error : 'login_required';
2943
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, code, transaction.state, hintError instanceof Error && hintError.message ? hintError.message : 'id_token_hint verification failed', issuer));
3261
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, code, transaction.state, hintError instanceof Error && hintError.message ? hintError.message : 'id_token_hint verification failed', issuer) };
2944
3262
  }
2945
3263
  }
2946
3264
 
@@ -2953,14 +3271,14 @@ ${bindingSecretStep} const transactionId = await generateRandomString(32);
2953
3271
  // No sessionResolver configured → cannot verify session → login_required
2954
3272
  if (!sessionResolver) {
2955
3273
  await transactionStore.delete('auth_txn:' + transactionId);
2956
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'sessionResolver is not configured; cannot satisfy prompt=none', issuer));
3274
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'sessionResolver is not configured; cannot satisfy prompt=none', issuer) };
2957
3275
  }
2958
3276
 
2959
3277
  // No consentResolver configured → cannot confirm consent → consent_required
2960
3278
  // (OIDC Core 1.0 Section 3.1.2.1: prompt=none must not display consent screen)
2961
3279
  if (!consentResolver) {
2962
3280
  await transactionStore.delete('auth_txn:' + transactionId);
2963
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'consent_required', transaction.state, 'consentResolver is not configured; cannot satisfy prompt=none', issuer));
3281
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'consent_required', transaction.state, 'consentResolver is not configured; cannot satisfy prompt=none', issuer) };
2964
3282
  }
2965
3283
 
2966
3284
  let session;
@@ -2987,20 +3305,20 @@ ${promptNoneUserScopeStep}
2987
3305
  } catch (promptError) {
2988
3306
  await transactionStore.delete('auth_txn:' + transactionId);
2989
3307
  if (promptError instanceof AuthorizationError) {
2990
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, promptError.error, transaction.state, promptError.errorDescription, issuer));
3308
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, promptError.error, transaction.state, promptError.errorDescription, issuer) };
2991
3309
  }
2992
3310
  const serverDescription =
2993
3311
  promptError instanceof Error && promptError.message
2994
3312
  ? promptError.message
2995
3313
  : 'Unexpected error while evaluating prompt=none';
2996
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'server_error', transaction.state, serverDescription, issuer));
3314
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'server_error', transaction.state, serverDescription, issuer) };
2997
3315
  }
2998
3316
 
2999
3317
  // Check max_age: if session is too old, prompt=none cannot trigger re-authentication
3000
3318
  // OIDC Core 1.0 Section 3.1.2.1
3001
3319
  if (transaction.maxAge !== undefined && requiresReauthentication(transaction.maxAge, session.authTime)) {
3002
3320
  await transactionStore.delete('auth_txn:' + transactionId);
3003
- return c.redirect(${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'Session exceeds the requested max_age; re-authentication required', issuer));
3321
+ return { kind: 'authorization_response', location: ${jarmAwait}buildErrorRedirect(${jarmErrorArg}transaction.redirectUri, 'login_required', transaction.state, 'Session exceeds the requested max_age; re-authentication required', issuer) };
3004
3322
  }
3005
3323
 
3006
3324
  // transaction.scope は認可リクエスト検証時に applyOfflineAccessPolicy を通した
@@ -3110,75 +3428,31 @@ ${ssoSuccessRedirect}
3110
3428
  // login → consent の受け渡しに sessionId も載せる。
3111
3429
  sessionId: existingSession.sessionId,
3112
3430
  });
3113
- ${bindingCookieOnConsentRedirect} // Internal redirects (/login, /consent) are built on config.issuer, never
3114
- // on the request URL: some runtimes derive the request URL from the Host
3115
- // header, which would let the sender pick the redirect origin and receive
3116
- // transaction_id there (RFC 9700 §2.1: redirect only to trusted URIs).
3117
- // OIDC Discovery 1.0 §3 makes the advertised issuer the source of truth
3118
- // for URLs that point at the OP itself. A subpath issuer contributes only
3119
- // its origin here ('/consent' is an absolute path) — subpath mounting is
3120
- // not supported by the generated routes.
3121
- const consentUrl = new URL('/consent', config.issuer);
3122
- consentUrl.searchParams.set('transaction_id', transactionId);
3123
- return c.redirect(consentUrl.toString());
3431
+ // Continue on the consent screen (pages/consent.ts); the binding
3432
+ // cookie, when enabled, travels with this answer.
3433
+ return { kind: 'consent', transactionId, cookies: ${browserStepCookies} };
3124
3434
  }
3125
3435
  }
3126
3436
  }
3127
3437
 
3128
- // Redirect to login page (prompt=login forces re-authentication; handled in login route)
3129
- ${bindingCookieOnLoginRedirect} // config.issuer, not the request URL, decides the redirect origin — see the
3130
- // /consent redirect above (OIDC Discovery 1.0 §3 / RFC 9700 §2.1).
3131
- const loginUrl = new URL('/login', config.issuer);
3132
- loginUrl.searchParams.set('transaction_id', transactionId);
3133
- return c.redirect(loginUrl.toString());
3438
+ // Interactive authentication: continue on the login screen (pages/login.ts;
3439
+ // prompt=login forces re-authentication there). The binding cookie, when
3440
+ // enabled, travels with this answer.
3441
+ return { kind: 'login', transactionId, cookies: ${browserStepCookies} };
3134
3442
  } catch (error) {
3135
3443
  ${parCatchBranch} if (error instanceof AuthorizationError) {
3136
3444
  ${catchErrorRedirect}
3137
3445
  // OIDC Core 1.0 §3.1.2.2: errors that cannot be redirected (unknown
3138
3446
  // client_id, unregistered redirect_uri, redirect_uri with a fragment) MUST
3139
- // NOT redirect to the supplied redirect_uri. Browser callers get an HTML
3140
- // error page (so the OIDF Conformance Suite can submit a screenshot for
3141
- // oidcc-ensure-registered-redirect-uri); programmatic callers that ask for
3142
- // JSON via the Accept header still receive the OAuth error JSON.
3143
- const acceptsJson = (c.req.header('Accept') ?? '').includes('application/json');
3144
- if (acceptsJson) {
3145
- return c.json({ error: error.error, error_description: error.errorDescription }, 400);
3146
- }
3147
- // OP 内部のエラーページパスが設定されている場合(Next.js sample のように
3148
- // error.tsx などの framework-native なエラー画面へ委ねたいケース)は、HTML を
3149
- // 直接返さず 303 でそのパスへ遷移する。未登録 redirect_uri へは決して飛ばさず、
3150
- // OP 自身のパスにのみ遷移する。遷移先ページは 200 を返すため元の HTTP 400 は
3151
- // 失われるが、ブラウザにエラー画面を見せる(OIDF の screenshot 要件)目的は満たす。
3152
- // error / error_description は URLSearchParams でエンコードして渡す。
3153
- // 安全性のため遷移先は OP 内部の root-relative path('/' 始まりかつ
3154
- // protocol-relative '//host' でない)に限定する。絶対 URL や '//host' を
3155
- // 設定された場合は open redirect 化を防ぐため redirect せず、安全側の
3156
- // HTML error page にフォールバックする。
3157
- const errorPagePath = c.get('config').authorizationErrorRedirectPath;
3158
- if (errorPagePath && errorPagePath.startsWith('/') && !errorPagePath.startsWith('//')) {
3159
- const params = new URLSearchParams({ error: error.error });
3160
- if (error.errorDescription) {
3161
- params.set('error_description', error.errorDescription);
3162
- }
3163
- return c.redirect(\`\${errorPagePath}?\${params.toString()}\`, 303);
3164
- }
3165
- const views = c.get('views') ?? defaultViews;
3166
- return renderView(
3167
- views.errorPage({
3168
- error: error.error,
3169
- errorDescription: error.errorDescription,
3170
- statusCode: 400,
3171
- }),
3172
- { status: 400 },
3173
- );
3447
+ // NOT redirect to the supplied redirect_uri. They stay on the OP:
3448
+ // pages/authorize.ts answers programmatic callers (Accept:
3449
+ // application/json) with the OAuth error JSON and browsers with the OP's
3450
+ // own error page.
3451
+ return { kind: 'error', error: error.error, errorDescription: error.errorDescription };
3174
3452
  }
3175
- return c.json({ error: 'server_error' }, 500);
3453
+ return { kind: 'server_error' };
3176
3454
  }
3177
- };
3178
-
3179
- // OIDC Core 1.0 Section 3.1.2.1: Authorization Endpoint must support both GET and POST.
3180
- authorizeApp.get('/', handleAuthorizationRequest);
3181
- authorizeApp.post('/', handleAuthorizationRequest);
3455
+ }
3182
3456
  `;
3183
3457
  }
3184
3458
  export function parRouteTemplate(corePkg) {
@@ -3578,9 +3852,9 @@ import { resolveGrantableScopes } from '../scopes.js';`
3578
3852
  : '';
3579
3853
  return `/**
3580
3854
  * EXPERIMENTAL — OAuth 2.0 Device Authorization Grant, verification UI
3581
- * (RFC 8628 §3.3).
3855
+ * (RFC 8628 §3.3), API layer: logic only.
3582
3856
  *
3583
- * This route was generated because the OP was created with
3857
+ * This module was generated because the OP was created with
3584
3858
  * \`--enable device-authorization-grant\`. It is backed by
3585
3859
  * ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may change in a breaking
3586
3860
  * way between releases. Do not build production code on it without pinning the
@@ -3590,22 +3864,25 @@ import { resolveGrantableScopes } from '../scopes.js';`
3590
3864
  * device is showing, signs in, and approves or denies. The device learns the
3591
3865
  * outcome only by polling the token endpoint — there is no push channel.
3592
3866
  *
3593
- * ## Why every POST here demands a binding cookie
3867
+ * The three functions below are the three state-changing steps of that UI.
3868
+ * None of them builds a Response: each returns an outcome (which screen comes
3869
+ * next, with which cookies), and pages/device.ts — which also owns GET /device
3870
+ * and the POST routes — turns it into HTTP.
3871
+ *
3872
+ * ## Why every step here demands a binding cookie
3594
3873
  *
3595
3874
  * The user_code is known to whoever started the flow, and that party can be the
3596
3875
  * attacker. A CSRF token stored on the record is therefore not a defense: the
3597
- * attacker can fetch a valid one by POSTing /device with their own code. What
3598
- * stops both consent coercion (a forged /device/approve that ships the victim's
3599
- * tokens to the attacker's device) and login CSRF (a forged /device/login that
3600
- * plants the attacker's session in the victim's browser) is the binding cookie
3601
- * minted below — see buildDeviceBindingCookie() in store.ts for the full model.
3602
- * The hidden csrf_token is kept as defense in depth, never as the only check.
3876
+ * attacker can fetch a valid one by submitting their own code. What stops both
3877
+ * consent coercion (a forged approval that ships the victim's tokens to the
3878
+ * attacker's device) and login CSRF (a forged sign-in that plants the
3879
+ * attacker's session in the victim's browser) is the binding cookie minted
3880
+ * below — see buildDeviceBindingCookie() in store.ts for the full model. The
3881
+ * hidden csrf_token is kept as defense in depth, never as the only check.
3603
3882
  */
3604
- import { Hono } from 'hono';
3605
3883
  import {
3606
3884
  DeviceAuthorizationError,
3607
3885
  DeviceVerificationError,
3608
- INVALID_USER_CODE_MESSAGE,
3609
3886
  approveDeviceAuthorization,
3610
3887
  denyDeviceAuthorization,
3611
3888
  findPendingRecordByUserCode,
@@ -3625,26 +3902,51 @@ import {
3625
3902
  parseSessionId,
3626
3903
  userStore,
3627
3904
  } from '../store.js';
3628
- import { defaultViews, renderView } from '../views.js';
3629
3905
  import { deviceAuthorizationConfig } from './device-authorization.js';${customScopeImport}
3630
3906
 
3631
- export const deviceApp = new Hono<{ Variables: Record<string, any> }>();
3907
+ /** What a verification step decided; pages/device.ts turns it into the next screen. */
3908
+ export type DeviceOutcome =
3909
+ /**
3910
+ * The code did not match. RFC 8628 §5.1: unknown, expired and already-used
3911
+ * codes share one reason-free answer so the UI cannot reveal which codes exist.
3912
+ */
3913
+ | { kind: 'invalid_user_code'; userCode: string }
3914
+ /** A binding or CSRF failure the OP shows on its own error page. */
3915
+ | { kind: 'error'; error: string; statusCode: number }
3916
+ /** The decision step needs an OP session this browser does not have (401). */
3917
+ | { kind: 'session_required' }
3918
+ /** recordDeviceLoginFailure() denied the record: no further attempt is accepted (429). */
3919
+ | { kind: 'locked_out' }
3920
+ /** Show the sign-in form; cookies carries the binding its submission needs. */
3921
+ | { kind: 'login'; userCode: string; csrfToken: string; cookies: string[] }
3922
+ /** Wrong credentials: show the sign-in form again with the attempts left. */
3923
+ | { kind: 'invalid_credentials'; userCode: string; csrfToken: string; remainingAttempts: number }
3924
+ /** Show the approve / deny screen; cookies carries the binding and/or the new OP session. */
3925
+ | {
3926
+ kind: 'approval';
3927
+ userCode: string;
3928
+ csrfToken: string;
3929
+ clientId: string;
3930
+ scopes: string[];
3931
+ cookies: string[];
3932
+ }
3933
+ /** The decision is recorded; cookies clears the binding. */
3934
+ | { kind: 'completed'; approved: boolean; clientId: string; cookies: string[] };
3632
3935
 
3633
- /**
3634
- * Attach a Set-Cookie to a Response a view already produced.
3635
- *
3636
- * renderView() builds its own Response, so headers staged on the framework
3637
- * context never reach it. Rebuilding the Response is the framework-neutral way
3638
- * to add the cookie without making views cookie-aware.
3639
- */
3640
- function withCookie(response: Response, cookie: string): Response {
3641
- const headers = new Headers(response.headers);
3642
- headers.append('Set-Cookie', cookie);
3643
- return new Response(response.body, {
3644
- status: response.status,
3645
- statusText: response.statusText,
3646
- headers,
3647
- });
3936
+ /** The fields of the sign-in form. */
3937
+ export interface DeviceLoginSubmission {
3938
+ userCode: string;
3939
+ csrfToken: string;
3940
+ username: string;
3941
+ password: string;
3942
+ }
3943
+
3944
+ /** The fields of the approve / deny form. */
3945
+ export interface DeviceDecisionSubmission {
3946
+ userCode: string;
3947
+ csrfToken: string;
3948
+ /** 'approve' or anything else (treated as deny). */
3949
+ decision: string;
3648
3950
  }
3649
3951
 
3650
3952
  /**
@@ -3657,69 +3959,32 @@ function remainingTtlSeconds(record: DeviceAuthorizationRecord): number {
3657
3959
  return Math.max(0, Math.ceil((record.expiresAt.getTime() - Date.now()) / 1000));
3658
3960
  }
3659
3961
 
3660
- /**
3661
- * Re-render the code entry form with the single, reason-free failure message.
3662
- *
3663
- * RFC 8628 §5.1: unknown, expired and already-used codes must be
3664
- * indistinguishable, otherwise the response itself confirms which codes exist.
3665
- */
3666
- function renderInvalidUserCode(views: typeof defaultViews, userCode: string): Response {
3667
- return renderView(
3668
- views.deviceVerificationPage({ userCode, error: INVALID_USER_CODE_MESSAGE }),
3669
- { status: 400 },
3670
- );
3671
- }
3672
-
3673
- /** Map a verification failure to its error page; anything else is re-thrown. */
3674
- function renderVerificationError(views: typeof defaultViews, error: unknown): Response {
3962
+ /** Map a verification failure to the error to show; anything else is re-thrown. */
3963
+ function verificationFailure(error: unknown): DeviceOutcome {
3675
3964
  if (error instanceof DeviceVerificationError) {
3676
- return renderView(
3677
- views.errorPage({ error: error.message, statusCode: error.statusCode }),
3678
- { status: error.statusCode },
3679
- );
3965
+ return { kind: 'error', error: error.message, statusCode: error.statusCode };
3680
3966
  }
3681
3967
  if (error instanceof DeviceAuthorizationError) {
3682
- return renderView(
3683
- views.errorPage({ error: error.errorDescription, statusCode: 400 }),
3684
- { status: 400 },
3685
- );
3968
+ return { kind: 'error', error: error.errorDescription, statusCode: 400 };
3686
3969
  }
3687
3970
  throw error;
3688
3971
  }
3689
3972
 
3690
3973
  /**
3691
- * User code entry form - GET
3692
- * RFC 8628 §3.3 / §3.3.1
3693
- *
3694
- * Unauthenticated and side-effect free. A user_code in the query string
3695
- * (verification_uri_complete) only pre-fills the field: nothing is looked up or
3696
- * mutated until the form is submitted, so following the complete URI never
3697
- * consumes or reveals anything.
3698
- */
3699
- deviceApp.get('/', (c) => {
3700
- const views = c.get('views') ?? defaultViews;
3701
- return renderView(views.deviceVerificationPage({ userCode: c.req.query('user_code') ?? '' }));
3702
- });
3703
-
3704
- /**
3705
- * User code submission - POST
3974
+ * User code submission (POST /device)
3706
3975
  * RFC 8628 §3.3
3707
3976
  *
3708
3977
  * On a match this is where the browser binding is minted, so this is also the
3709
- * first response that may carry a csrf_token. Everything downstream requires the
3710
- * cookie this response sets.
3978
+ * first answer that may carry a csrf_token. Everything downstream requires the
3979
+ * cookie this outcome sets.
3711
3980
  */
3712
- deviceApp.post('/', async (c) => {
3713
- const body = await c.req.parseBody();
3714
- const submittedUserCode = String(body['user_code'] ?? '');
3715
-
3716
- const views = c.get('views') ?? defaultViews;
3981
+ export async function submitDeviceUserCode(c: any, submittedUserCode: string): Promise<DeviceOutcome> {
3717
3982
  const deviceStore = c.get('deviceAuthorizationStore');
3718
3983
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
3719
3984
 
3720
3985
  const record = await findPendingRecordByUserCode(submittedUserCode, deviceStore);
3721
3986
  if (!record) {
3722
- return renderInvalidUserCode(views, submittedUserCode);
3987
+ return { kind: 'invalid_user_code', userCode: submittedUserCode };
3723
3988
  }
3724
3989
 
3725
3990
  // Rotate the binding secret and the csrf token together. A second browser
@@ -3735,36 +4000,35 @@ deviceApp.post('/', async (c) => {
3735
4000
  const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
3736
4001
  const session = sessionId ? await browserSessionStore.get(sessionId) : undefined;
3737
4002
  if (session) {
3738
- return withCookie(renderView(views.deviceApprovalPage({
4003
+ return {
4004
+ kind: 'approval',
3739
4005
  userCode: record.userCodeDisplay,
3740
4006
  csrfToken,
3741
4007
  clientId: record.clientId,
3742
4008
  scopes: ${approvalPageScopes('session.subject')},
3743
- })), cookie);
4009
+ cookies: [cookie],
4010
+ };
3744
4011
  }
3745
4012
 
3746
- return withCookie(renderView(views.deviceLoginPage({
4013
+ return {
4014
+ kind: 'login',
3747
4015
  userCode: record.userCodeDisplay,
3748
4016
  csrfToken,
3749
- })), cookie);
3750
- });
4017
+ cookies: [cookie],
4018
+ };
4019
+ }
3751
4020
 
3752
4021
  /**
3753
- * Device login - POST
4022
+ * Device login (POST /device/login)
3754
4023
  * RFC 8628 §3.3
3755
4024
  *
3756
4025
  * Binding first, then CSRF, then credentials: the binding is what proves this is
3757
4026
  * the browser that submitted the user_code, and it must gate the step that would
3758
4027
  * otherwise let a forged POST establish an OP session in the victim's browser.
3759
4028
  */
3760
- deviceApp.post('/login', async (c) => {
3761
- const body = await c.req.parseBody();
3762
- const submittedUserCode = String(body['user_code'] ?? '');
3763
- const csrfToken = String(body['csrf_token'] ?? '');
3764
- const username = String(body['username'] ?? '');
3765
- const password = String(body['password'] ?? '');
3766
-
3767
- const views = c.get('views') ?? defaultViews;
4029
+ export async function submitDeviceLogin(c: any, input: DeviceLoginSubmission): Promise<DeviceOutcome> {
4030
+ const { userCode: submittedUserCode, csrfToken, username, password } = input;
4031
+
3768
4032
  const deviceStore = c.get('deviceAuthorizationStore');
3769
4033
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
3770
4034
  const authenticateUser =
@@ -3773,7 +4037,7 @@ deviceApp.post('/login', async (c) => {
3773
4037
 
3774
4038
  const record = await findPendingRecordByUserCode(submittedUserCode, deviceStore);
3775
4039
  if (!record) {
3776
- return renderInvalidUserCode(views, submittedUserCode);
4040
+ return { kind: 'invalid_user_code', userCode: submittedUserCode };
3777
4041
  }
3778
4042
 
3779
4043
  try {
@@ -3783,7 +4047,7 @@ deviceApp.post('/login', async (c) => {
3783
4047
  );
3784
4048
  validateVerificationCsrfToken(record, csrfToken);
3785
4049
  } catch (error) {
3786
- return renderVerificationError(views, error);
4050
+ return verificationFailure(error);
3787
4051
  }
3788
4052
 
3789
4053
  // Swap point: replace this with your own credential check (LDAP, WebAuthn, an
@@ -3800,64 +4064,58 @@ deviceApp.post('/login', async (c) => {
3800
4064
  );
3801
4065
  if (!failure.canRetry) {
3802
4066
  // The record is now denied: the device gets access_denied on its next poll.
3803
- return renderView(views.errorPage({
3804
- error: 'Too many login attempts',
3805
- statusCode: 429,
3806
- }), { status: 429 });
4067
+ return { kind: 'locked_out' };
3807
4068
  }
3808
- return renderView(views.deviceLoginPage({
4069
+ return {
4070
+ kind: 'invalid_credentials',
3809
4071
  userCode: record.userCodeDisplay,
3810
4072
  csrfToken,
3811
- error: 'Invalid credentials',
3812
4073
  remainingAttempts: failure.remainingAttempts,
3813
- }));
4074
+ };
3814
4075
  }
3815
4076
 
3816
4077
  const authTime = Math.floor(Date.now() / 1000);
3817
4078
  const sessionId = generateRandomString(32);
3818
4079
  await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
3819
4080
 
3820
- // Two cookies on one response: the new OP session, and the binding cookie the
3821
- // approval POST will have to present again.
3822
- const withSession = withCookie(renderView(views.deviceApprovalPage({
4081
+ // The new OP session travels with the approval screen; the approval step
4082
+ // will have to present the binding cookie again as well.
4083
+ return {
4084
+ kind: 'approval',
3823
4085
  userCode: record.userCodeDisplay,
3824
4086
  csrfToken,
3825
4087
  clientId: record.clientId,
3826
4088
  scopes: ${approvalPageScopes('user.sub')},
3827
- })), buildSessionCookie(sessionId));
3828
- return withSession;
3829
- });
4089
+ cookies: [buildSessionCookie(sessionId)],
4090
+ };
4091
+ }
3830
4092
 
3831
4093
  /**
3832
- * Approve or deny - POST
4094
+ * Approve or deny (POST /device/approve)
3833
4095
  * RFC 8628 §3.3
3834
4096
  *
3835
4097
  * The only state-changing step of the UI, so it demands all three: an OP
3836
4098
  * session, the binding cookie, and the csrf_token.
3837
4099
  */
3838
- deviceApp.post('/approve', async (c) => {
3839
- const body = await c.req.parseBody();
3840
- const submittedUserCode = String(body['user_code'] ?? '');
3841
- const csrfToken = String(body['csrf_token'] ?? '');
3842
- const decision = String(body['decision'] ?? '');
4100
+ export async function submitDeviceDecision(
4101
+ c: any,
4102
+ input: DeviceDecisionSubmission,
4103
+ ): Promise<DeviceOutcome> {
4104
+ const { userCode: submittedUserCode, csrfToken, decision } = input;
3843
4105
 
3844
- const views = c.get('views') ?? defaultViews;
3845
4106
  const deviceStore = c.get('deviceAuthorizationStore');
3846
4107
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
3847
4108
  const consentResolver = c.get('consentResolver');
3848
4109
 
3849
4110
  const record = await findPendingRecordByUserCode(submittedUserCode, deviceStore);
3850
4111
  if (!record) {
3851
- return renderInvalidUserCode(views, submittedUserCode);
4112
+ return { kind: 'invalid_user_code', userCode: submittedUserCode };
3852
4113
  }
3853
4114
 
3854
4115
  const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
3855
4116
  const session = sessionId ? await browserSessionStore.get(sessionId) : undefined;
3856
4117
  if (!session) {
3857
- return renderView(views.errorPage({
3858
- error: 'Sign in again to approve this device',
3859
- statusCode: 401,
3860
- }), { status: 401 });
4118
+ return { kind: 'session_required' };
3861
4119
  }
3862
4120
 
3863
4121
  const clearCookie = buildClearedDeviceBindingCookie(record.userCode);
@@ -3885,58 +4143,258 @@ ${approveNarrowStep} // Record the consent the same way /consent does, so a
3885
4143
  approved.approvedScope ?? approved.scope,
3886
4144
  );
3887
4145
  await consentResolver?.recordGrant?.(approved.subject, approved.clientId, approved.grantId);
3888
- return withCookie(renderView(views.deviceCompletedPage({
3889
- approved: true,
3890
- clientId: approved.clientId,
3891
- })), clearCookie);
4146
+ return { kind: 'completed', approved: true, clientId: approved.clientId, cookies: [clearCookie] };
3892
4147
  }
3893
4148
 
3894
4149
  await denyDeviceAuthorization({ record, store: deviceStore, csrfToken });
3895
- return withCookie(renderView(views.deviceCompletedPage({
3896
- approved: false,
3897
- clientId: record.clientId,
3898
- })), clearCookie);
4150
+ return { kind: 'completed', approved: false, clientId: record.clientId, cookies: [clearCookie] };
3899
4151
  } catch (error) {
3900
- return renderVerificationError(views, error);
4152
+ return verificationFailure(error);
3901
4153
  }
3902
- });
4154
+ }
3903
4155
  `;
3904
4156
  }
3905
- export function backchannelAuthenticationRouteTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
3906
- const refreshTokenFeatureEnabled = features.refreshToken ? 'true' : 'false';
3907
- const customScopesDeclared = scopes.length > 0;
3908
- const customScopeImport = customScopesDeclared
3909
- ? `
3910
- import { findUnsupportedScopes } from '../scopes.js';`
3911
- : '';
3912
- const customScopeStep = customScopesDeclared
3913
- ? ` // RFC 6749 §3.3: reject a scope this OP never declared, the same allow list
3914
- // /authorize uses (scopes.ts). Checked before the pipeline so a request for
3915
- // an unknown scope never reaches the store. offline_access is left out of
3916
- // the check: the pipeline applies its own policy and ignores it when it
3917
- // cannot be granted (OIDC Core 1.0 §11), which must not become an error.
3918
- const unsupportedScopes = findUnsupportedScopes(
3919
- (params['scope'] ?? '')
3920
- .split(' ')
3921
- .filter((scope) => scope.length > 0 && scope !== 'offline_access'),
3922
- );
3923
- if (unsupportedScopes.length > 0) {
3924
- throw new BackchannelAuthenticationError(
3925
- 'invalid_scope',
3926
- 'Unsupported scope: ' + unsupportedScopes.join(' '),
3927
- );
3928
- }
3929
-
3930
- `
3931
- : '';
4157
+ export function endSessionRouteTemplate(corePkg) {
3932
4158
  return `/**
3933
- * EXPERIMENTAL — OpenID Connect Client-Initiated Backchannel Authentication
3934
- * (CIBA Core 1.0), poll mode.
3935
- *
3936
- * This route was generated because the OP was created with \`--enable ciba\`.
3937
- * It is backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may
3938
- * change in a breaking way between releases. Do not build production code on it
3939
- * without pinning the version.
4159
+ * EXPERIMENTAL — OpenID Connect RP-Initiated Logout 1.0, end_session_endpoint
4160
+ * — API layer: logic only.
4161
+ *
4162
+ * This module was generated because the OP was created with
4163
+ * \`--enable rp-initiated-logout\`. It is backed by
4164
+ * ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may change in a breaking
4165
+ * way between releases. Do not build production code on it without pinning the
4166
+ * version.
4167
+ *
4168
+ * The RP sends the user agent to /logout (GET or POST, §2 MUST) to end the OP
4169
+ * browser session. A request whose id_token_hint verifies against this OP's
4170
+ * keys AND matches the current session's End-User logs out immediately; every
4171
+ * other request — no hint, an invalid or expired hint, a client_id that
4172
+ * mismatches the hint audience, no session, another user's session — falls to
4173
+ * one shared confirmation screen (§2 MUST; §7: an unauthenticated logout link
4174
+ * would otherwise be a denial-of-service primitive). The failure reason is
4175
+ * never disclosed anywhere: a reason would turn this endpoint into an oracle
4176
+ * for session state.
4177
+ *
4178
+ * Neither function below builds a Response: each returns an outcome (which
4179
+ * screen or redirect comes next, with which cookies), and pages/logout.ts —
4180
+ * which owns the GET and POST routes — turns it into HTTP.
4181
+ *
4182
+ * ## Why the confirmation approve step demands a cookie + token pair
4183
+ *
4184
+ * The approve POST ends a session, so a forged cross-site POST must not drive
4185
+ * it. When the confirmation screen is shown the OP mints a fresh secret and
4186
+ * hands it to that one browser twice: in an HttpOnly cookie and in the form's
4187
+ * hidden csrf_token. approveLogout() runs only when both come back equal. An
4188
+ * attacker can obtain a valid pair in their own browser but cannot plant that
4189
+ * cookie into the victim's, so the forged POST fails the comparison — the
4190
+ * same model as the device verification binding cookie (see store.ts).
4191
+ *
4192
+ * The cookie also carries the OP-computed post-logout redirect target, so the
4193
+ * confirmation flow never round-trips the id_token_hint (or any redirect
4194
+ * parameter) through the HTML page: the only value the form submits back is
4195
+ * the csrf_token itself.
4196
+ */
4197
+ import {
4198
+ decideLogoutFlow,
4199
+ extractIdTokenHintAudience,
4200
+ parseEndSessionRequest,
4201
+ resolvePostLogoutRedirect,
4202
+ } from '${EXPERIMENTAL_PACKAGE}/rp-initiated-logout';
4203
+ import { IdTokenHintError, generateRandomString, validateIdTokenHint } from '${corePkg}';
4204
+ import {
4205
+ browserSessionStore as defaultBrowserSessionStore,
4206
+ buildClearedLogoutConfirmationCookie,
4207
+ buildClearedSessionCookie,
4208
+ buildLogoutConfirmationCookie,
4209
+ parseLogoutConfirmation,
4210
+ parseSessionId,
4211
+ } from '../store.js';
4212
+ import { defaultProviderConfig } from '../config.js';
4213
+
4214
+ /**
4215
+ * EXPERIMENTAL — settings for RP-Initiated Logout.
4216
+ *
4217
+ * postLogoutRedirectUris is the registry §3 checks against: client_id → the
4218
+ * exact post_logout_redirect_uri values that client registered (a registry of
4219
+ * its own — the authorize redirect_uris are NOT reused). A requested URI is
4220
+ * used only on an exact string match for the client the id_token_hint
4221
+ * verified for; everything else falls back to the completed page
4222
+ * (fail-closed). The default is empty, so no logout redirect happens until
4223
+ * you register one here.
4224
+ */
4225
+ export const rpInitiatedLogoutConfig = {
4226
+ postLogoutRedirectUris: {} as Record<string, string[]>,
4227
+ };
4228
+
4229
+ /** What a logout step decided; pages/logout.ts turns it into HTTP. */
4230
+ export type LogoutOutcome =
4231
+ /** Forged, replayed or expired confirmation: nothing was deleted (400). */
4232
+ | { kind: 'invalid_confirmation' }
4233
+ /** §2 MUST: ask first. cookies pairs the HttpOnly secret with the form's csrf_token. */
4234
+ | { kind: 'confirmation'; csrfToken: string; cookies: string[] }
4235
+ /** Logged out; §3: return to the registered post_logout_redirect_uri (state appended). */
4236
+ | { kind: 'redirect'; location: string; cookies: string[] }
4237
+ /** Logged out; no registered redirect applied, so show the completed screen. */
4238
+ | { kind: 'completed'; cookies: string[] };
4239
+
4240
+ /**
4241
+ * Interpret one end_session request (§2). GET and POST share this function —
4242
+ * they differ only in where the parameters come from.
4243
+ */
4244
+ export async function processEndSessionRequest(c: any, params: URLSearchParams): Promise<LogoutOutcome> {
4245
+ const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
4246
+ const config = c.get('config') ?? defaultProviderConfig;
4247
+ const request = parseEndSessionRequest(params);
4248
+ // logout_hint and ui_locales are accepted but unused (OPTIONAL, §2): the
4249
+ // hint is not read past parsing and is never logged — it can identify the
4250
+ // End-User. The same goes for the id_token_hint value itself.
4251
+
4252
+ // §2: verify the hint (signature / iss / aud / exp) against the same key
4253
+ // set id_token_hint uses elsewhere (context jwksProvider). The expected
4254
+ // audience is the client_id parameter when present, otherwise it is
4255
+ // extracted — unverified — from the hint payload; trust comes from
4256
+ // validateIdTokenHint afterwards.
4257
+ let verifiedHint: { sub: string; [key: string]: unknown } | null = null;
4258
+ let expectedAudience: string | null = null;
4259
+ if (request.idTokenHint !== undefined) {
4260
+ expectedAudience = request.clientId ?? extractIdTokenHintAudience(request.idTokenHint);
4261
+ if (expectedAudience !== null) {
4262
+ try {
4263
+ const jwks = await c.get('jwksProvider')();
4264
+ verifiedHint = await validateIdTokenHint(request.idTokenHint, {
4265
+ expectedIss: config.issuer,
4266
+ expectedAud: expectedAudience,
4267
+ jwks,
4268
+ });
4269
+ } catch (error) {
4270
+ // An expired, tampered or foreign hint is not an error to report — it
4271
+ // just fails to prove logout authority, so the request falls to the
4272
+ // confirmation path (§2 MUST) with no reason disclosed. Anything that
4273
+ // is not a hint-validation failure (e.g. the JWKS provider itself
4274
+ // failing) is rethrown: masking an outage as \"invalid hint\" would
4275
+ // silently degrade every logout into a confirmation.
4276
+ if (!(error instanceof IdTokenHintError)) throw error;
4277
+ verifiedHint = null;
4278
+ }
4279
+ }
4280
+ }
4281
+
4282
+ const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
4283
+ const session = sessionId ? await browserSessionStore.get(sessionId) : undefined;
4284
+
4285
+ const decision = decideLogoutFlow({
4286
+ verifiedHint,
4287
+ expectedAudience,
4288
+ clientIdParam: request.clientId,
4289
+ sessionSubject: session ? session.subject : null,
4290
+ });
4291
+
4292
+ // §3: redirect only to the verified client's exactly-matching registered
4293
+ // URI, with state appended. Resolved before the branch because the
4294
+ // confirmation flow honors the same result after approval — the redirect
4295
+ // condition is the hint and the exact match, not which path the logout took.
4296
+ const redirectTo = resolvePostLogoutRedirect({
4297
+ postLogoutRedirectUri: request.postLogoutRedirectUri,
4298
+ state: request.state,
4299
+ verifiedClientId: decision.verifiedClientId,
4300
+ registeredUris:
4301
+ decision.verifiedClientId === null
4302
+ ? []
4303
+ : rpInitiatedLogoutConfig.postLogoutRedirectUris[decision.verifiedClientId] ?? [],
4304
+ });
4305
+
4306
+ if (decision.requiresConfirmation) {
4307
+ // §2 MUST. Nothing is deleted here, and the screen's wording never varies
4308
+ // with session state. The minted secret pairs the HttpOnly cookie with the
4309
+ // form's hidden csrf_token; the redirect target rides inside the cookie.
4310
+ const csrfSecret = generateRandomString(32);
4311
+ return {
4312
+ kind: 'confirmation',
4313
+ csrfToken: csrfSecret,
4314
+ cookies: [buildLogoutConfirmationCookie({ csrfSecret, redirectTo })],
4315
+ };
4316
+ }
4317
+
4318
+ // Immediate logout: a valid hint for the current session's End-User (§2).
4319
+ // Delete the store entry and expire the cookie together.
4320
+ if (sessionId) {
4321
+ await browserSessionStore.delete(sessionId);
4322
+ }
4323
+ const cookies = [buildClearedSessionCookie()];
4324
+ if (redirectTo !== null) {
4325
+ return { kind: 'redirect', location: redirectTo, cookies };
4326
+ }
4327
+ return { kind: 'completed', cookies };
4328
+ }
4329
+
4330
+ /**
4331
+ * Confirmation approve (POST /logout/approve)
4332
+ *
4333
+ * Runs only for the browser that saw the confirmation screen: the HttpOnly
4334
+ * cookie and the hidden csrf_token must present the same secret (neither
4335
+ * alone is accepted). On success the session is deleted and the redirect
4336
+ * decision computed when the screen was shown — carried in the cookie, never
4337
+ * in the form — is honored (§3).
4338
+ */
4339
+ export async function approveLogout(c: any, csrfToken: string): Promise<LogoutOutcome> {
4340
+ const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
4341
+
4342
+ const confirmation = parseLogoutConfirmation(c.req.header('Cookie') ?? null);
4343
+ if (confirmation === null || csrfToken === '' || confirmation.csrfSecret !== csrfToken) {
4344
+ // Forged, replayed or expired confirmation: delete nothing.
4345
+ return { kind: 'invalid_confirmation' };
4346
+ }
4347
+
4348
+ // The End-User explicitly approved (§2). When the session is already gone
4349
+ // there is nothing to delete and the answer is the same either way — the
4350
+ // confirmation flow is not an oracle for whether a session existed.
4351
+ const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
4352
+ if (sessionId) {
4353
+ await browserSessionStore.delete(sessionId);
4354
+ }
4355
+ const cookies = [buildClearedSessionCookie(), buildClearedLogoutConfirmationCookie()];
4356
+ if (confirmation.redirectTo !== null) {
4357
+ return { kind: 'redirect', location: confirmation.redirectTo, cookies };
4358
+ }
4359
+ return { kind: 'completed', cookies };
4360
+ }
4361
+ `;
4362
+ }
4363
+ export function backchannelAuthenticationRouteTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
4364
+ const refreshTokenFeatureEnabled = features.refreshToken ? 'true' : 'false';
4365
+ const customScopesDeclared = scopes.length > 0;
4366
+ const customScopeImport = customScopesDeclared
4367
+ ? `
4368
+ import { findUnsupportedScopes } from '../scopes.js';`
4369
+ : '';
4370
+ const customScopeStep = customScopesDeclared
4371
+ ? ` // RFC 6749 §3.3: reject a scope this OP never declared, the same allow list
4372
+ // /authorize uses (scopes.ts). Checked before the pipeline so a request for
4373
+ // an unknown scope never reaches the store. offline_access is left out of
4374
+ // the check: the pipeline applies its own policy and ignores it when it
4375
+ // cannot be granted (OIDC Core 1.0 §11), which must not become an error.
4376
+ const unsupportedScopes = findUnsupportedScopes(
4377
+ (params['scope'] ?? '')
4378
+ .split(' ')
4379
+ .filter((scope) => scope.length > 0 && scope !== 'offline_access'),
4380
+ );
4381
+ if (unsupportedScopes.length > 0) {
4382
+ throw new BackchannelAuthenticationError(
4383
+ 'invalid_scope',
4384
+ 'Unsupported scope: ' + unsupportedScopes.join(' '),
4385
+ );
4386
+ }
4387
+
4388
+ `
4389
+ : '';
4390
+ return `/**
4391
+ * EXPERIMENTAL — OpenID Connect Client-Initiated Backchannel Authentication
4392
+ * (CIBA Core 1.0), poll mode.
4393
+ *
4394
+ * This route was generated because the OP was created with \`--enable ciba\`.
4395
+ * It is backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may
4396
+ * change in a breaking way between releases. Do not build production code on it
4397
+ * without pinning the version.
3940
4398
  *
3941
4399
  * The consumption device (a call-center console, a kiosk, a smart speaker
3942
4400
  * backend) POSTs here — back channel, client-authenticated — with a login_hint
@@ -4133,7 +4591,7 @@ export function cibaVerificationRouteTemplate(corePkg, scopes = []) {
4133
4591
  import { resolveGrantableScopes } from '../scopes.js';`
4134
4592
  : '';
4135
4593
  const pendingRequestRows = customScopesDeclared
4136
- ? ` const requests = await Promise.all(
4594
+ ? ` return Promise.all(
4137
4595
  pending.map(async (record) => ({
4138
4596
  authReqId: record.authReqId,
4139
4597
  clientId: record.clientId,
@@ -4143,17 +4601,14 @@ import { resolveGrantableScopes } from '../scopes.js';`
4143
4601
  expiresInSeconds: remainingSeconds(record.expiresAt),
4144
4602
  csrfToken: record.csrfToken ?? '',
4145
4603
  })),
4146
- );
4147
- return renderView(views.cibaPendingRequestsPage({ requests }));`
4148
- : ` return renderView(views.cibaPendingRequestsPage({
4149
- requests: pending.map((record) => ({
4150
- authReqId: record.authReqId,
4151
- clientId: record.clientId,
4152
- scopes: record.scope,
4153
- bindingMessage: record.bindingMessage,
4154
- expiresInSeconds: remainingSeconds(record.expiresAt),
4155
- csrfToken: record.csrfToken ?? '',
4156
- })),
4604
+ );`
4605
+ : ` return pending.map((record) => ({
4606
+ authReqId: record.authReqId,
4607
+ clientId: record.clientId,
4608
+ scopes: record.scope,
4609
+ bindingMessage: record.bindingMessage,
4610
+ expiresInSeconds: remainingSeconds(record.expiresAt),
4611
+ csrfToken: record.csrfToken ?? '',
4157
4612
  }));`;
4158
4613
  const approveNarrowStep = customScopesDeclared
4159
4614
  ? ` // Apply the scope policy to what was approved. approveCibaRequest()
@@ -4171,9 +4626,9 @@ import { resolveGrantableScopes } from '../scopes.js';`
4171
4626
  : '';
4172
4627
  return `/**
4173
4628
  * EXPERIMENTAL — OpenID Connect Client-Initiated Backchannel Authentication
4174
- * (CIBA Core 1.0), authentication device UI.
4629
+ * (CIBA Core 1.0), authentication device UI — API layer: logic only.
4175
4630
  *
4176
- * This route was generated because the OP was created with \`--enable ciba\`.
4631
+ * This module was generated because the OP was created with \`--enable ciba\`.
4177
4632
  * It is backed by ${EXPERIMENTAL_PACKAGE}, whose API is NOT stable: it may
4178
4633
  * change in a breaking way between releases. Do not build production code on it
4179
4634
  * without pinning the version.
@@ -4186,6 +4641,11 @@ import { resolveGrantableScopes } from '../scopes.js';`
4186
4641
  * outcome only by polling the token endpoint — there is no push channel in
4187
4642
  * poll mode.
4188
4643
  *
4644
+ * The three functions below are the three steps of that UI. None of them
4645
+ * builds a Response: each returns an outcome (which screen comes next, with
4646
+ * which cookies), and pages/ciba.ts — which owns the GET and POST routes —
4647
+ * turns it into HTTP.
4648
+ *
4189
4649
  * ## Why the login form demands a binding cookie
4190
4650
  *
4191
4651
  * A successful login establishes an OP session, whose reach goes beyond CIBA
@@ -4203,7 +4663,6 @@ import { resolveGrantableScopes } from '../scopes.js';`
4203
4663
  * ever rendered on the session-gated listing. Knowing an auth_req_id gives an
4204
4664
  * attacker no step to forge.
4205
4665
  */
4206
- import { Hono } from 'hono';
4207
4666
  import {
4208
4667
  CibaVerificationError,
4209
4668
  approveCibaRequest,
@@ -4225,35 +4684,66 @@ import {
4225
4684
  parseSessionId,
4226
4685
  userStore,
4227
4686
  } from '../store.js';
4228
- import { defaultViews, renderView } from '../views.js';
4229
4687
  import { cibaConfig } from './backchannel-authentication.js';${customScopeImport}
4230
4688
 
4231
- export const cibaApp = new Hono<{ Variables: Record<string, any> }>();
4689
+ /** One pending backchannel authentication request, as the approval screen shows it. */
4690
+ export interface CibaPendingRequest {
4691
+ /** auth_req_id; posted back by the decision form. */
4692
+ authReqId: string;
4693
+ clientId: string;
4694
+ /** Scopes this End-User is asked to grant (already narrowed by the scope policy). */
4695
+ scopes: string[];
4696
+ /** CIBA Core 1.0 §7.1 binding_message, client-supplied: escape before rendering. */
4697
+ bindingMessage?: string;
4698
+ expiresInSeconds: number;
4699
+ /** Per-record CSRF token; posted back by the decision form. */
4700
+ csrfToken: string;
4701
+ }
4702
+
4703
+ /** What a step of the UI decided; pages/ciba.ts turns it into the next screen. */
4704
+ export type CibaOutcome =
4705
+ /** A binding or CSRF failure the OP shows on its own error page. */
4706
+ | { kind: 'error'; error: string; statusCode: number }
4707
+ /** The decision step needs an OP session this browser does not have (401). */
4708
+ | { kind: 'session_required' }
4709
+ /** decision was neither approve nor deny (400). */
4710
+ | { kind: 'invalid_decision' }
4711
+ /** recordCibaLoginFailure() discarded the login transaction: no further attempt (429). */
4712
+ | { kind: 'locked_out' }
4713
+ /** Show the sign-in form; cookies carries the binding its submission needs. */
4714
+ | { kind: 'login'; loginTransactionId: string; csrfToken: string; cookies: string[] }
4715
+ /** Wrong credentials: show the sign-in form again with the attempts left. */
4716
+ | {
4717
+ kind: 'invalid_credentials';
4718
+ loginTransactionId: string;
4719
+ csrfToken: string;
4720
+ remainingAttempts: number;
4721
+ }
4722
+ /** Show the signed-in user's pending requests; cookies carries a new OP session, if any. */
4723
+ | { kind: 'pending_requests'; requests: CibaPendingRequest[]; cookies: string[] }
4724
+ /** The decision is recorded. */
4725
+ | { kind: 'completed'; approved: boolean; clientId: string };
4232
4726
 
4233
- /**
4234
- * Attach a Set-Cookie to a Response a view already produced.
4235
- *
4236
- * renderView() builds its own Response, so headers staged on the framework
4237
- * context never reach it. Rebuilding the Response is the framework-neutral way
4238
- * to add the cookie without making views cookie-aware.
4239
- */
4240
- function withCookie(response: Response, cookie: string): Response {
4241
- const headers = new Headers(response.headers);
4242
- headers.append('Set-Cookie', cookie);
4243
- return new Response(response.body, {
4244
- status: response.status,
4245
- statusText: response.statusText,
4246
- headers,
4247
- });
4727
+ /** The fields of the sign-in form. */
4728
+ export interface CibaLoginSubmission {
4729
+ loginTransactionId: string;
4730
+ csrfToken: string;
4731
+ username: string;
4732
+ password: string;
4733
+ }
4734
+
4735
+ /** The fields of the approve / deny form. */
4736
+ export interface CibaDecisionSubmission {
4737
+ authReqId: string;
4738
+ csrfToken: string;
4739
+ /** 'approve' or 'deny'. */
4740
+ decision: string;
4248
4741
  }
4249
4742
 
4250
- /** Map a verification failure to its error page; anything else is re-thrown. */
4251
- function renderVerificationError(views: typeof defaultViews, error: unknown): Response {
4743
+ /** Map a verification failure to the error to show; anything else is re-thrown. */
4744
+ function verificationFailure(error: unknown): CibaOutcome {
4252
4745
  if (error instanceof CibaVerificationError) {
4253
- return renderView(
4254
- views.errorPage({ error: error.message, statusCode: error.statusCode }),
4255
- { status: error.statusCode },
4256
- );
4746
+ return { kind: 'error', error: error.message, statusCode: error.statusCode };
4257
4747
  }
4258
4748
  throw error;
4259
4749
  }
@@ -4264,26 +4754,23 @@ function remainingSeconds(expiresAt: Date): number {
4264
4754
  }
4265
4755
 
4266
4756
  /**
4267
- * Render the session subject's pending requests with freshly rotated CSRF
4268
- * tokens (the only place those tokens are ever exposed, and it is
4269
- * session-gated).
4757
+ * The session subject's pending requests with freshly rotated CSRF tokens (the
4758
+ * only place those tokens are ever exposed, and it is session-gated).
4270
4759
  */
4271
- async function renderPendingRequests(c: any, subject: string): Promise<Response> {
4272
- const views = c.get('views') ?? defaultViews;
4760
+ async function listPendingRequests(c: any, subject: string): Promise<CibaPendingRequest[]> {
4273
4761
  const cibaStore = c.get('cibaAuthenticationRequestStore') ?? defaultCibaAuthenticationRequestStore;
4274
4762
  const pending = await listPendingCibaRequests({ subject, store: cibaStore });
4275
4763
  ${pendingRequestRows}
4276
4764
  }
4277
4765
 
4278
4766
  /**
4279
- * Listing / login form - GET
4767
+ * Listing / login form (GET /ciba)
4280
4768
  *
4281
- * With an OP session: list the pending requests addressed to the signed-in
4282
- * user. Without one: mint a login transaction and show the sign-in form, with
4283
- * the binding cookie this response sets.
4769
+ * With an OP session: the pending requests addressed to the signed-in user.
4770
+ * Without one: mint a login transaction and describe the sign-in form, with the
4771
+ * binding cookie its submission needs.
4284
4772
  */
4285
- cibaApp.get('/', async (c) => {
4286
- const views = c.get('views') ?? defaultViews;
4773
+ export async function prepareCibaDevice(c: any): Promise<CibaOutcome> {
4287
4774
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
4288
4775
  const loginTransactionStore =
4289
4776
  c.get('cibaLoginTransactionStore') ?? defaultCibaLoginTransactionStore;
@@ -4291,7 +4778,7 @@ cibaApp.get('/', async (c) => {
4291
4778
  const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
4292
4779
  const session = sessionId ? await browserSessionStore.get(sessionId) : undefined;
4293
4780
  if (session) {
4294
- return renderPendingRequests(c, session.subject);
4781
+ return { kind: 'pending_requests', requests: await listPendingRequests(c, session.subject), cookies: [] };
4295
4782
  }
4296
4783
 
4297
4784
  const { record, bindingSecret } = await createCibaLoginTransaction(loginTransactionStore);
@@ -4300,28 +4787,25 @@ cibaApp.get('/', async (c) => {
4300
4787
  bindingSecret,
4301
4788
  remainingSeconds(record.expiresAt),
4302
4789
  );
4303
- return withCookie(renderView(views.cibaLoginPage({
4790
+ return {
4791
+ kind: 'login',
4304
4792
  loginTransactionId: record.id,
4305
4793
  csrfToken: record.csrfToken,
4306
- })), cookie);
4307
- });
4794
+ cookies: [cookie],
4795
+ };
4796
+ }
4308
4797
 
4309
4798
  /**
4310
- * Sign in - POST
4799
+ * Sign in (POST /ciba/login)
4311
4800
  *
4312
4801
  * Binding first, then CSRF, then credentials: the binding is what proves this
4313
4802
  * is the browser the login form was issued to, and it must gate the step that
4314
4803
  * would otherwise let a forged POST establish an OP session in the victim's
4315
4804
  * browser.
4316
4805
  */
4317
- cibaApp.post('/login', async (c) => {
4318
- const body = await c.req.parseBody();
4319
- const transactionId = String(body['login_transaction_id'] ?? '');
4320
- const csrfToken = String(body['csrf_token'] ?? '');
4321
- const username = String(body['username'] ?? '');
4322
- const password = String(body['password'] ?? '');
4323
-
4324
- const views = c.get('views') ?? defaultViews;
4806
+ export async function submitCibaLogin(c: any, input: CibaLoginSubmission): Promise<CibaOutcome> {
4807
+ const { loginTransactionId: transactionId, csrfToken, username, password } = input;
4808
+
4325
4809
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
4326
4810
  const loginTransactionStore =
4327
4811
  c.get('cibaLoginTransactionStore') ?? defaultCibaLoginTransactionStore;
@@ -4338,7 +4822,7 @@ cibaApp.post('/login', async (c) => {
4338
4822
  store: loginTransactionStore,
4339
4823
  });
4340
4824
  } catch (error) {
4341
- return renderVerificationError(views, error);
4825
+ return verificationFailure(error);
4342
4826
  }
4343
4827
 
4344
4828
  // Swap point: replace this with your own credential check (LDAP, WebAuthn, an
@@ -4356,17 +4840,14 @@ cibaApp.post('/login', async (c) => {
4356
4840
  );
4357
4841
  if (!failure.canRetry) {
4358
4842
  // The transaction is gone: this form cannot be retried at all.
4359
- return renderView(views.errorPage({
4360
- error: 'Too many login attempts',
4361
- statusCode: 429,
4362
- }), { status: 429 });
4843
+ return { kind: 'locked_out' };
4363
4844
  }
4364
- return renderView(views.cibaLoginPage({
4845
+ return {
4846
+ kind: 'invalid_credentials',
4365
4847
  loginTransactionId: transaction.id,
4366
4848
  csrfToken: transaction.csrfToken,
4367
- error: 'Invalid credentials',
4368
4849
  remainingAttempts: failure.remainingAttempts,
4369
- }));
4850
+ };
4370
4851
  }
4371
4852
 
4372
4853
  // The transaction is single-use: a successful login consumes it, and the
@@ -4377,29 +4858,25 @@ cibaApp.post('/login', async (c) => {
4377
4858
  const sessionId = generateRandomString(32);
4378
4859
  await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
4379
4860
 
4380
- // Two cookies on one response: the new OP session, and the cleared login
4381
- // binding (it is single-use and would otherwise linger until Max-Age).
4382
- const listing = await renderPendingRequests(c, user.sub);
4383
- return withCookie(
4384
- withCookie(listing, buildSessionCookie(sessionId)),
4385
- buildClearedCibaLoginBindingCookie(transaction.id),
4386
- );
4387
- });
4861
+ // Two cookies travel with the listing: the new OP session, and the cleared
4862
+ // login binding (it is single-use and would otherwise linger until Max-Age).
4863
+ return {
4864
+ kind: 'pending_requests',
4865
+ requests: await listPendingRequests(c, user.sub),
4866
+ cookies: [buildSessionCookie(sessionId), buildClearedCibaLoginBindingCookie(transaction.id)],
4867
+ };
4868
+ }
4388
4869
 
4389
4870
  /**
4390
- * Approve or deny - POST
4871
+ * Approve or deny (POST /ciba/approve)
4391
4872
  *
4392
4873
  * The only state-changing step of the UI. It demands an OP session whose
4393
4874
  * subject owns the record, plus the per-record csrf_token from the
4394
4875
  * session-gated listing.
4395
4876
  */
4396
- cibaApp.post('/approve', async (c) => {
4397
- const body = await c.req.parseBody();
4398
- const authReqId = String(body['auth_req_id'] ?? '');
4399
- const csrfToken = String(body['csrf_token'] ?? '');
4400
- const decision = String(body['decision'] ?? '');
4877
+ export async function submitCibaDecision(c: any, input: CibaDecisionSubmission): Promise<CibaOutcome> {
4878
+ const { authReqId, csrfToken, decision } = input;
4401
4879
 
4402
- const views = c.get('views') ?? defaultViews;
4403
4880
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
4404
4881
  const cibaStore = c.get('cibaAuthenticationRequestStore') ?? defaultCibaAuthenticationRequestStore;
4405
4882
  const consentResolver = c.get('consentResolver');
@@ -4407,18 +4884,11 @@ cibaApp.post('/approve', async (c) => {
4407
4884
  const sessionId = parseSessionId(c.req.header('Cookie') ?? null);
4408
4885
  const session = sessionId ? await browserSessionStore.get(sessionId) : undefined;
4409
4886
  if (!session) {
4410
- return renderView(views.errorPage({
4411
- error: 'Sign in again to review this request',
4412
- statusCode: 401,
4413
- }), { status: 401 });
4887
+ return { kind: 'session_required' };
4414
4888
  }
4415
4889
 
4416
4890
  if (decision !== 'approve' && decision !== 'deny') {
4417
- return renderView(views.errorPage({
4418
- error: 'invalid_request',
4419
- errorDescription: 'decision must be approve or deny',
4420
- statusCode: 400,
4421
- }), { status: 400 });
4891
+ return { kind: 'invalid_decision' };
4422
4892
  }
4423
4893
 
4424
4894
  try {
@@ -4442,10 +4912,7 @@ ${approveNarrowStep} // Record the consent the same way /consent does, so a
4442
4912
  approved.approvedScope ?? approved.scope,
4443
4913
  );
4444
4914
  await consentResolver?.recordGrant?.(approved.subject, approved.clientId, approved.grantId);
4445
- return renderView(views.cibaCompletedPage({
4446
- approved: true,
4447
- clientId: approved.clientId,
4448
- }));
4915
+ return { kind: 'completed', approved: true, clientId: approved.clientId };
4449
4916
  }
4450
4917
 
4451
4918
  const record = await cibaStore.findByAuthReqId(authReqId);
@@ -4455,14 +4922,11 @@ ${approveNarrowStep} // Record the consent the same way /consent does, so a
4455
4922
  csrfToken,
4456
4923
  store: cibaStore,
4457
4924
  });
4458
- return renderView(views.cibaCompletedPage({
4459
- approved: false,
4460
- clientId: record?.clientId ?? '',
4461
- }));
4925
+ return { kind: 'completed', approved: false, clientId: record?.clientId ?? '' };
4462
4926
  } catch (error) {
4463
- return renderVerificationError(views, error);
4927
+ return verificationFailure(error);
4464
4928
  }
4465
- });
4929
+ }
4466
4930
  `;
4467
4931
  }
4468
4932
  export function jarmConfigTemplate() {
@@ -6583,6 +7047,11 @@ import { parConfig } from './par.js';`
6583
7047
  // introspection_signed_response_alg), so exactly one alg is advertised.
6584
7048
  introspection_signing_alg_values_supported: ['RS256'],`
6585
7049
  : '';
7050
+ const rpInitiatedLogoutDiscoveryMetadata = features.rpInitiatedLogout
7051
+ ? `
7052
+ // EXPERIMENTAL — RP-Initiated Logout 1.0 §2.1 metadata.
7053
+ end_session_endpoint: \`\${issuer}/logout\`,`
7054
+ : '';
6586
7055
  return `import { Hono } from 'hono';
6587
7056
  import { buildProviderMetadata, getJwaAlgorithm, type SigningKey } from '${corePkg}';
6588
7057
  import { defaultProviderConfig } from '../config.js';${parDiscoveryImport}${customScopeImport}
@@ -6695,17 +7164,16 @@ ${rfc8414Comment}${introspectionMetadata}${revocationMetadata} });
6695
7164
  // not in OIDC Discovery, so it is added separately.
6696
7165
  return c.json({
6697
7166
  ...metadata,
6698
- code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${cibaDiscoveryMetadata}${jarmDiscoveryMetadata}${idJagDiscoveryMetadata}${jwtIntrospectionResponseDiscoveryMetadata}
7167
+ code_challenge_methods_supported: ['S256'],${parDiscoveryMetadata}${deviceDiscoveryMetadata}${cibaDiscoveryMetadata}${jarmDiscoveryMetadata}${idJagDiscoveryMetadata}${jwtIntrospectionResponseDiscoveryMetadata}${rpInitiatedLogoutDiscoveryMetadata}
6699
7168
  });
6700
7169
  });
6701
7170
  `;
6702
7171
  }
6703
7172
  export function loginRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
6704
- const bindingImports = features.transactionBinding
7173
+ const bindingCoreImports = features.transactionBinding
6705
7174
  ? `
6706
7175
  validateTransactionBinding,
6707
- AuthTransactionError,
6708
- type AuthTransaction,`
7176
+ AuthTransactionError,`
6709
7177
  : '';
6710
7178
  const bindingStoreImport = features.transactionBinding
6711
7179
  ? `
@@ -6715,48 +7183,39 @@ export function loginRouteTemplate(corePkg, features = DEFAULT_FEATURES) {
6715
7183
  ? `
6716
7184
  /**
6717
7185
  * Enforce that this step comes from the User-Agent that started the transaction
6718
- * (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns an error Response to send
6719
- * back, or undefined when the binding holds.
7186
+ * (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns the error to show, or
7187
+ * undefined when the binding holds.
6720
7188
  *
6721
- * The failure is rendered by the OP itself and never redirected to the client's
7189
+ * The failure is shown by the OP itself and never redirected to the client's
6722
7190
  * redirect_uri: at this point we cannot tell whose transaction this is, so
6723
7191
  * answering the client would leak that a transaction exists — and, in the
6724
7192
  * lured-victim case, would hand the attacker's client a code for the victim.
6725
7193
  * See buildTransactionBindingCookie() in store.ts for the full threat model.
6726
7194
  */
6727
7195
  async function rejectUnboundTransaction(
7196
+ c: any,
6728
7197
  transaction: AuthTransaction,
6729
7198
  transactionId: string,
6730
- cookieHeader: string | null,
6731
- views: typeof defaultViews,
6732
- ): Promise<Response | undefined> {
7199
+ ): Promise<LoginError | undefined> {
6733
7200
  try {
6734
7201
  await validateTransactionBinding(
6735
7202
  transaction,
6736
- parseTransactionBindingSecret(cookieHeader, transactionId),
7203
+ parseTransactionBindingSecret(c.req.header('Cookie') ?? null, transactionId),
6737
7204
  );
6738
7205
  return undefined;
6739
7206
  } catch (error) {
6740
7207
  if (!(error instanceof AuthTransactionError)) throw error;
6741
- return renderView(views.errorPage({
6742
- error: error.message,
6743
- statusCode: error.httpStatusCode,
6744
- }), { status: error.httpStatusCode });
7208
+ return { kind: 'error', error: error.message, statusCode: error.httpStatusCode };
6745
7209
  }
6746
7210
  }
6747
7211
  `
6748
7212
  : '';
6749
7213
  const bindingCheckBeforeLoginForm = features.transactionBinding
6750
7214
  ? `
6751
- // Checked BEFORE rendering: the login page embeds csrf_token, so anyone who
6752
- // could load this page with a leaked transaction_id would obtain the token
6753
- // that the POST handlers validate.
6754
- const bindingError = await rejectUnboundTransaction(
6755
- transaction,
6756
- transactionId,
6757
- c.req.header('Cookie') ?? null,
6758
- views,
6759
- );
7215
+ // Checked BEFORE the form is described: the login page embeds csrf_token, so
7216
+ // anyone who could load it with a leaked transaction_id would obtain the
7217
+ // token that submitLogin() validates.
7218
+ const bindingError = await rejectUnboundTransaction(c, transaction, transactionId);
6760
7219
  if (bindingError) return bindingError;
6761
7220
  `
6762
7221
  : '';
@@ -6764,105 +7223,170 @@ async function rejectUnboundTransaction(
6764
7223
  ? ` // Checked before validateCsrfToken: the CSRF token only proves the request
6765
7224
  // carries a value from the form, and that form is reachable by anyone holding
6766
7225
  // transaction_id. The binding proves it is the same browser.
6767
- const bindingError = await rejectUnboundTransaction(
6768
- transaction,
6769
- transactionId,
6770
- c.req.header('Cookie') ?? null,
6771
- views,
6772
- );
7226
+ const bindingError = await rejectUnboundTransaction(c, transaction, transactionId);
6773
7227
  if (bindingError) return bindingError;
6774
7228
  `
6775
7229
  : '';
6776
- return `import { Hono } from 'hono';
7230
+ const googleLoginImports = features.googleLogin
7231
+ ? `
6777
7232
  import {
6778
- getAuthTransaction,
6779
- validateCsrfToken,${bindingImports}
6780
- handleLoginFailure,
6781
- generateRandomString,
6782
- } from '${corePkg}';
7233
+ handleGoogleLoginRedirect,
7234
+ issueGoogleLoginNonce,
7235
+ resolveGoogleLoginSubject,
7236
+ GoogleLoginError,
7237
+ type GoogleIdTokenPayload,
7238
+ } from '${GOOGLE_LOGIN_PACKAGE}';
6783
7239
  import {
6784
- transactionStore as defaultTransactionStore,
6785
- authSessionStore as defaultAuthSessionStore,
6786
- browserSessionStore as defaultBrowserSessionStore,
6787
- buildSessionCookie,
6788
- parseSessionId,${bindingStoreImport}
6789
- userStore,
6790
- } from '../store.js';
6791
- import { defaultProviderConfig } from '../config.js';
6792
- import { defaultViews, renderView } from '../views.js';
6793
-
6794
- export const loginApp = new Hono<{ Variables: Record<string, any> }>();
6795
- ${bindingGuard}
7240
+ buildGoogleSignInAttributes,
7241
+ type GoogleSignInAttributes,
7242
+ } from '${GOOGLE_LOGIN_PACKAGE}/sign-in';`
7243
+ : '';
7244
+ const googleStoreImport = features.googleLogin
7245
+ ? `
7246
+ googleLoginNonceStore as defaultGoogleLoginNonceStore,`
7247
+ : '';
7248
+ const googleConfigImport = features.googleLogin
7249
+ ? `
7250
+ import { defaultProviderConfig, type GoogleLoginConfig } from '../config.js';`
7251
+ : '';
7252
+ const googleScreenField = features.googleLogin
7253
+ ? `
7254
+ /**
7255
+ * EXTENSION (google-login): the GIS configuration (g_id_onload attributes) of
7256
+ * the "Sign in with Google" button; undefined until config.googleLogin is set.
7257
+ */
7258
+ googleSignIn?: GoogleSignInAttributes;`
7259
+ : '';
7260
+ const googleScreenValue = features.googleLogin
7261
+ ? `
7262
+ googleSignIn: await buildGoogleSignIn(c, transactionId, transaction),`
7263
+ : '';
7264
+ const screenContextParam = features.googleLogin ? ' c: any,\n' : '';
7265
+ const screenContextArg = features.googleLogin ? 'c, ' : '';
7266
+ const googleLoginHelpers = features.googleLogin
7267
+ ? `
6796
7268
  /**
6797
- * Login Page - GET
6798
- * Displays the login form for user authentication.
7269
+ * EXTENSION (google-login): build the GIS configuration (the g_id_onload
7270
+ * attributes) for this transaction, or undefined when config.googleLogin is not
7271
+ * set. Rendering is the view's job (views.ts): the package generates no UI.
7272
+ * Every description of the form issues a fresh nonce bound to the transaction:
7273
+ * Google echoes it in the ID token, which is how completeGoogleLogin() finds
7274
+ * its way back to this authorization request (the redirect-mode POST carries
7275
+ * nothing else).
6799
7276
  */
6800
- loginApp.get('/', async (c) => {
6801
- const transactionId = c.req.query('transaction_id');
6802
- if (!transactionId) {
6803
- return c.text('Missing transaction_id', 400);
6804
- }
6805
-
6806
- const views = c.get('views') ?? defaultViews;
6807
- const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
6808
- const transaction = await getAuthTransaction(transactionId, transactionStore);
6809
- ${bindingCheckBeforeLoginForm}
6810
- return renderView(views.loginPage({
7277
+ async function buildGoogleSignIn(
7278
+ c: any,
7279
+ transactionId: string,
7280
+ transaction: AuthTransaction,
7281
+ ): Promise<GoogleSignInAttributes | undefined> {
7282
+ const config = c.get('config') ?? defaultProviderConfig;
7283
+ const googleLogin: GoogleLoginConfig | undefined = config.googleLogin;
7284
+ if (!googleLogin) return undefined;
7285
+ const nonceStore = c.get('googleLoginNonceStore') ?? defaultGoogleLoginNonceStore;
7286
+ const nonce = await issueGoogleLoginNonce({
6811
7287
  transactionId,
6812
- csrfToken: transaction.csrfToken,
6813
- // OIDC Core 1.0 §3.1.2.1: pre-fill the login form with login_hint (RECOMMENDED).
7288
+ expiresAt: transaction.expiresAt,
7289
+ store: nonceStore,
7290
+ });
7291
+ return buildGoogleSignInAttributes({
7292
+ clientId: googleLogin.clientId,
7293
+ // Must equal an authorized redirect URI of the Google OAuth client. Built on
7294
+ // config.issuer for the same reason as the /consent redirect (RFC 9700 §2.1).
7295
+ loginUri: new URL('/login/google', config.issuer).toString(),
7296
+ nonce,
7297
+ // OIDC Core 1.0 §3.1.2.1: pass login_hint on so Google can preselect the account.
6814
7298
  loginHint: transaction.loginHint,
6815
- }));
6816
- });
7299
+ hostedDomain: typeof googleLogin.hostedDomain === 'string' ? googleLogin.hostedDomain : undefined,
7300
+ });
7301
+ }
6817
7302
 
6818
7303
  /**
6819
- * Login Handler - POST
6820
- * Processes the login form submission.
7304
+ * EXTENSION (google-login): run the callback checks and map the Google account
7305
+ * to an OP subject. Returns the error to show on failure so a failed Google
7306
+ * callback is never redirected to a client — until the nonce is verified the
7307
+ * OP cannot tell whose transaction this is.
7308
+ */
7309
+ async function verifyGoogleLoginCallback(
7310
+ c: any,
7311
+ googleLogin: GoogleLoginConfig,
7312
+ ): Promise<{ transactionId: string; subject: string } | LoginError> {
7313
+ const nonceStore = c.get('googleLoginNonceStore') ?? defaultGoogleLoginNonceStore;
7314
+ const verifier = c.get('googleIdTokenVerifier');
7315
+ const accountResolver = c.get('googleAccountResolver') ?? {
7316
+ resolveSubject: async (account: GoogleIdTokenPayload) =>
7317
+ (await userStore.linkGoogleAccount(account)).sub,
7318
+ };
7319
+ try {
7320
+ // Double Submit Cookie -> google-auth-library verification -> nonce lookup,
7321
+ // in the order Google's server-side verification guide prescribes.
7322
+ const login = await handleGoogleLoginRedirect({
7323
+ params: await c.req.parseBody(),
7324
+ cookieHeader: c.req.header('Cookie') ?? null,
7325
+ clientId: googleLogin.clientId,
7326
+ verifier,
7327
+ nonceStore,
7328
+ hostedDomain: googleLogin.hostedDomain,
7329
+ requireVerifiedEmail: googleLogin.requireVerifiedEmail,
7330
+ });
7331
+ const subject = await resolveGoogleLoginSubject(login.account, accountResolver);
7332
+ return { transactionId: login.transactionId, subject };
7333
+ } catch (error) {
7334
+ if (!(error instanceof GoogleLoginError)) throw error;
7335
+ return {
7336
+ kind: 'error',
7337
+ error: error.code,
7338
+ errorDescription: error.message,
7339
+ statusCode: error.httpStatusCode,
7340
+ };
7341
+ }
7342
+ }
7343
+ `
7344
+ : '';
7345
+ const googleBindingNote = features.transactionBinding
7346
+ ? `
7347
+ *
7348
+ * Transaction binding is deliberately NOT checked here: Google's POST is a
7349
+ * cross-site navigation, so the browser withholds the SameSite=Lax binding
7350
+ * cookie. The single-use nonce stands in for it — it was issued to the login
7351
+ * page, which only the bound browser could fetch.`
7352
+ : '';
7353
+ const googleLoginOutcome = features.googleLogin
7354
+ ? `
7355
+ /** What the Google login callback decided; pages/login.ts turns it into HTTP. */
7356
+ export type GoogleLoginOutcome =
7357
+ | LoginError
7358
+ /** config.googleLogin is not set: the callback does not exist (404). */
7359
+ | { kind: 'not_configured' }
7360
+ /** Signed in: the OP session cookie(s) to set, then continue to the consent step. */
7361
+ | { kind: 'authenticated'; transactionId: string; cookies: string[] };
7362
+ `
7363
+ : '';
7364
+ const googleLoginFunction = features.googleLogin
7365
+ ? `
7366
+ /**
7367
+ * EXTENSION (google-login) — the Google login callback (login_uri), POST /login/google.
7368
+ *
7369
+ * Sign in with Google (redirect mode) posts the ID token here once the user
7370
+ * picks an account. After the callback checks, this continues exactly like a
7371
+ * successful password login: same session cookie, same consent hand-off.${googleBindingNote}
6821
7372
  */
6822
- loginApp.post('/', async (c) => {
6823
- const body = await c.req.parseBody();
6824
- const transactionId = String(body['transaction_id'] ?? '');
6825
- const csrfToken = String(body['csrf_token'] ?? '');
6826
- const username = String(body['username'] ?? '');
6827
- const password = String(body['password'] ?? '');
6828
-
6829
- const views = c.get('views') ?? defaultViews;
7373
+ export async function completeGoogleLogin(c: any): Promise<GoogleLoginOutcome> {
7374
+ const config = c.get('config') ?? defaultProviderConfig;
7375
+ if (!config.googleLogin) {
7376
+ return { kind: 'not_configured' };
7377
+ }
7378
+
7379
+ const verified = await verifyGoogleLoginCallback(c, config.googleLogin);
7380
+ if ('kind' in verified) return verified;
7381
+ const { transactionId, subject } = verified;
7382
+
6830
7383
  const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
6831
7384
  const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
6832
7385
  const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
6833
- const authenticateUser =
6834
- c.get('authenticateUser') ??
6835
- ((u: string, p: string) => userStore.authenticate(u, p));
6836
-
6837
7386
  const transaction = await getAuthTransaction(transactionId, transactionStore);
6838
- ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
6839
-
6840
- // Authenticate user
6841
- const user = await authenticateUser(username, password);
6842
- if (!user) {
6843
- const failureResult = await handleLoginFailure(
6844
- transactionId,
6845
- transaction,
6846
- transactionStore,
6847
- );
6848
- if (!failureResult.canRetry) {
6849
- return renderView(views.errorPage({
6850
- error: 'Too many login attempts',
6851
- statusCode: 429,
6852
- }), { status: 429 });
6853
- }
6854
- return renderView(views.loginPage({
6855
- transactionId,
6856
- csrfToken: transaction.csrfToken,
6857
- error: 'Invalid credentials',
6858
- remainingAttempts: failureResult.maxAttempts - failureResult.failedAttempts,
6859
- loginHint: transaction.loginHint,
6860
- }));
6861
- }
6862
7387
 
6863
- // prompt=login (and prompt=select_account in Phase 1) requires fresh
6864
- // authentication: discard any existing transaction handoff AND browser session.
6865
- // OIDC Core 1.0 Section 3.1.2.1 — prompt is a space-delimited list, use includes()
7388
+ // prompt=login / select_account requires fresh authentication: discard any
7389
+ // existing transaction handoff AND browser session (OIDC Core 1.0 Section 3.1.2.1).
6866
7390
  const loginPromptValues = transaction.prompt?.trim().split(/\\s+/).filter(Boolean) ?? [];
6867
7391
  if (loginPromptValues.includes('login') || loginPromptValues.includes('select_account')) {
6868
7392
  await authSessionStore.delete(transactionId);
@@ -6872,32 +7396,169 @@ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
6872
7396
 
6873
7397
  const authTime = Math.floor(Date.now() / 1000);
6874
7398
 
6875
- // Establish a persistent browser (OP) session and set the session cookie so
6876
- // SSO / prompt=none / max_age work on subsequent authorization requests
6877
- // (OIDC Core 1.0 Section 3.1.2.3).
7399
+ // Establish the browser (OP) session and the per-transaction handoff exactly
7400
+ // as the password login does (OIDC Core 1.0 Section 3.1.2.3).
6878
7401
  const sessionId = await generateRandomString(32);
6879
- await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
6880
- c.header('Set-Cookie', buildSessionCookie(sessionId));
7402
+ await browserSessionStore.set(sessionId, { subject, authTime });
7403
+ await authSessionStore.set(transactionId, { subject, authTime, sessionId });
6881
7404
 
6882
- // Store authenticated subject for the consent step (per-transaction handoff).
6883
- // sessionId も渡すのは online refresh token のため。consent 経由で発行する認可
6884
- // コードにこのセッションを引き継ぎ、ログアウトで使えなくなる RT を作る。
6885
- await authSessionStore.set(transactionId, {
6886
- subject: user.sub,
6887
- authTime,
6888
- sessionId,
6889
- });
7405
+ return { kind: 'authenticated', transactionId, cookies: [buildSessionCookie(sessionId)] };
7406
+ }
7407
+ `
7408
+ : '';
7409
+ return `/**
7410
+ * Login step (API layer: logic only).
7411
+ *
7412
+ * Everything the login screen has to decide lives here as plain functions:
7413
+ * loading the transaction, the User-Agent binding, the credential check, the
7414
+ * lockout, the OP session cookie and the hand-off to the consent step. None of
7415
+ * them builds a Response — each returns an outcome, and pages/login.ts turns
7416
+ * that outcome into a screen or a redirect. The UI can therefore be changed
7417
+ * without touching this file.
7418
+ */
7419
+ import {
7420
+ getAuthTransaction,
7421
+ validateCsrfToken,${bindingCoreImports}
7422
+ handleLoginFailure,
7423
+ generateRandomString,
7424
+ type AuthTransaction,
7425
+ } from '${corePkg}';${googleLoginImports}
7426
+ import {
7427
+ transactionStore as defaultTransactionStore,
7428
+ authSessionStore as defaultAuthSessionStore,
7429
+ browserSessionStore as defaultBrowserSessionStore,
7430
+ buildSessionCookie,
7431
+ parseSessionId,${bindingStoreImport}${googleStoreImport}
7432
+ userStore,
7433
+ } from '../store.js';${googleConfigImport}
6890
7434
 
6891
- // Redirect to consent page. config.issuer, not the request URL, decides the
6892
- // redirect origin: some runtimes derive the request URL from the Host header,
6893
- // which would let the sender pick where transaction_id lands (OIDC Discovery
6894
- // 1.0 §3 / RFC 9700 §2.1).
6895
- const config = c.get('config') ?? defaultProviderConfig;
6896
- const consentUrl = new URL('/consent', config.issuer);
6897
- consentUrl.searchParams.set('transaction_id', transactionId);
6898
- return c.redirect(consentUrl.toString());
6899
- });
6900
- `;
7435
+ /** What the login form needs: prepared for GET /login and again after a failed attempt. */
7436
+ export interface LoginScreen {
7437
+ kind: 'screen';
7438
+ transactionId: string;
7439
+ /** Must be posted back as the csrf_token field. */
7440
+ csrfToken: string;
7441
+ /**
7442
+ * OIDC Core 1.0 §3.1.2.1 login_hint: untrusted external value the OP MAY use
7443
+ * to pre-fill the login form.
7444
+ */
7445
+ loginHint?: string;${googleScreenField}
7446
+ }
7447
+
7448
+ /** A failure the OP shows on its own error page (never redirected to the client). */
7449
+ export interface LoginError {
7450
+ kind: 'error';
7451
+ error: string;
7452
+ errorDescription?: string;
7453
+ statusCode: number;
7454
+ }
7455
+
7456
+ /** What POST /login decided; pages/login.ts turns it into HTTP. */
7457
+ export type LoginOutcome =
7458
+ | LoginError
7459
+ /** handleLoginFailure() locked the transaction: no further attempt is accepted (429). */
7460
+ | { kind: 'locked_out' }
7461
+ /** Wrong credentials: show the form again with the attempts left. */
7462
+ | { kind: 'invalid_credentials'; screen: LoginScreen; remainingAttempts: number }
7463
+ /** Signed in: the OP session cookie(s) to set, then continue to the consent step. */
7464
+ | { kind: 'authenticated'; transactionId: string; cookies: string[] };
7465
+ ${googleLoginOutcome}
7466
+ /** The fields of the login form. */
7467
+ export interface LoginSubmission {
7468
+ transactionId: string;
7469
+ csrfToken: string;
7470
+ username: string;
7471
+ password: string;
7472
+ }
7473
+ ${bindingGuard}${googleLoginHelpers}
7474
+ /** Describe the form for a transaction (the shared part of GET and a failed POST). */
7475
+ async function describeLoginScreen(
7476
+ ${screenContextParam} transactionId: string,
7477
+ transaction: AuthTransaction,
7478
+ ): Promise<LoginScreen> {
7479
+ return {
7480
+ kind: 'screen',
7481
+ transactionId,
7482
+ csrfToken: transaction.csrfToken,
7483
+ loginHint: transaction.loginHint,${googleScreenValue}
7484
+ };
7485
+ }
7486
+
7487
+ /**
7488
+ * GET /login: load the transaction and describe the form, or the error to show
7489
+ * instead when this browser may not see it.
7490
+ */
7491
+ export async function prepareLogin(c: any, transactionId: string): Promise<LoginScreen | LoginError> {
7492
+ const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
7493
+ const transaction = await getAuthTransaction(transactionId, transactionStore);
7494
+ ${bindingCheckBeforeLoginForm}
7495
+ return describeLoginScreen(${screenContextArg}transactionId, transaction);
7496
+ }
7497
+
7498
+ /**
7499
+ * POST /login: check the credentials and, on success, establish the OP session.
7500
+ */
7501
+ export async function submitLogin(c: any, input: LoginSubmission): Promise<LoginOutcome> {
7502
+ const { transactionId, csrfToken, username, password } = input;
7503
+
7504
+ const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
7505
+ const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
7506
+ const browserSessionStore = c.get('browserSessionStore') ?? defaultBrowserSessionStore;
7507
+ const authenticateUser =
7508
+ c.get('authenticateUser') ??
7509
+ ((u: string, p: string) => userStore.authenticate(u, p));
7510
+
7511
+ const transaction = await getAuthTransaction(transactionId, transactionStore);
7512
+ ${bindingCheckBeforeLoginCsrf} validateCsrfToken(transaction, csrfToken);
7513
+
7514
+ // Authenticate user
7515
+ const user = await authenticateUser(username, password);
7516
+ if (!user) {
7517
+ const failureResult = await handleLoginFailure(
7518
+ transactionId,
7519
+ transaction,
7520
+ transactionStore,
7521
+ );
7522
+ if (!failureResult.canRetry) {
7523
+ return { kind: 'locked_out' };
7524
+ }
7525
+ return {
7526
+ kind: 'invalid_credentials',
7527
+ screen: await describeLoginScreen(${screenContextArg}transactionId, transaction),
7528
+ remainingAttempts: failureResult.maxAttempts - failureResult.failedAttempts,
7529
+ };
7530
+ }
7531
+
7532
+ // prompt=login (and prompt=select_account in Phase 1) requires fresh
7533
+ // authentication: discard any existing transaction handoff AND browser session.
7534
+ // OIDC Core 1.0 Section 3.1.2.1 — prompt is a space-delimited list, use includes()
7535
+ const loginPromptValues = transaction.prompt?.trim().split(/\\s+/).filter(Boolean) ?? [];
7536
+ if (loginPromptValues.includes('login') || loginPromptValues.includes('select_account')) {
7537
+ await authSessionStore.delete(transactionId);
7538
+ const existingSessionId = parseSessionId(c.req.header('Cookie') ?? null);
7539
+ if (existingSessionId) await browserSessionStore.delete(existingSessionId);
7540
+ }
7541
+
7542
+ const authTime = Math.floor(Date.now() / 1000);
7543
+
7544
+ // Establish a persistent browser (OP) session; its cookie travels with the
7545
+ // answer so SSO / prompt=none / max_age work on subsequent authorization
7546
+ // requests (OIDC Core 1.0 Section 3.1.2.3).
7547
+ const sessionId = await generateRandomString(32);
7548
+ await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
7549
+
7550
+ // Store authenticated subject for the consent step (per-transaction handoff).
7551
+ // sessionId も渡すのは online refresh token のため。consent 経由で発行する認可
7552
+ // コードにこのセッションを引き継ぎ、ログアウトで使えなくなる RT を作る。
7553
+ await authSessionStore.set(transactionId, {
7554
+ subject: user.sub,
7555
+ authTime,
7556
+ sessionId,
7557
+ });
7558
+
7559
+ return { kind: 'authenticated', transactionId, cookies: [buildSessionCookie(sessionId)] };
7560
+ }
7561
+ ${googleLoginFunction}`;
6901
7562
  }
6902
7563
  export function consentRouteTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
6903
7564
  const customScopesDeclared = scopes.length > 0;
@@ -6906,10 +7567,10 @@ export function consentRouteTemplate(corePkg, features = DEFAULT_FEATURES, scope
6906
7567
  import { resolveGrantableScopes } from '../scopes.js';`
6907
7568
  : '';
6908
7569
  const consentGetScopeResolution = customScopesDeclared
6909
- ? ` // Display only what THIS End-User can actually grant. The subject comes from
6910
- // the auth session that /login (or the SSO fast path) stored for this
7570
+ ? ` // Describe only what THIS End-User can actually grant. The subject comes
7571
+ // from the auth session that /login (or the SSO fast path) stored for this
6911
7572
  // transaction; without one there is nothing to apply the policy to, so the
6912
- // request is shown as-is and POST /consent stops on the same missing session.
7573
+ // request is shown as-is and submitConsent() stops on the same missing session.
6913
7574
  const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
6914
7575
  const consentSession = await authSessionStore.get(transactionId);
6915
7576
  const requestedScopes = transaction.scope.split(' ').filter(Boolean);
@@ -6933,7 +7594,7 @@ import { resolveGrantableScopes } from '../scopes.js';`
6933
7594
  session.subject,
6934
7595
  );`
6935
7596
  : ` const grantedScope = transaction.scope.split(' ').filter(Boolean);`;
6936
- const bindingImports = features.transactionBinding
7597
+ const bindingCoreImports = features.transactionBinding
6937
7598
  ? `
6938
7599
  validateTransactionBinding,
6939
7600
  AuthTransactionError,
@@ -6948,47 +7609,38 @@ import { resolveGrantableScopes } from '../scopes.js';`
6948
7609
  ? `
6949
7610
  /**
6950
7611
  * Enforce that this step comes from the User-Agent that started the transaction
6951
- * (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns an error Response to send
6952
- * back, or undefined when the binding holds.
7612
+ * (OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4). Returns the error to show, or
7613
+ * undefined when the binding holds.
6953
7614
  *
6954
- * The failure is rendered by the OP itself and never redirected to the client's
7615
+ * The failure is shown by the OP itself and never redirected to the client's
6955
7616
  * redirect_uri: without a verified owner, answering the client would let an
6956
7617
  * attacker who lured a victim into their own transaction collect a code for the
6957
7618
  * victim's identity. See buildTransactionBindingCookie() in store.ts.
6958
7619
  */
6959
7620
  async function rejectUnboundTransaction(
7621
+ c: any,
6960
7622
  transaction: AuthTransaction,
6961
7623
  transactionId: string,
6962
- cookieHeader: string | null,
6963
- views: typeof defaultViews,
6964
- ): Promise<Response | undefined> {
7624
+ ): Promise<ConsentError | undefined> {
6965
7625
  try {
6966
7626
  await validateTransactionBinding(
6967
7627
  transaction,
6968
- parseTransactionBindingSecret(cookieHeader, transactionId),
7628
+ parseTransactionBindingSecret(c.req.header('Cookie') ?? null, transactionId),
6969
7629
  );
6970
7630
  return undefined;
6971
7631
  } catch (error) {
6972
7632
  if (!(error instanceof AuthTransactionError)) throw error;
6973
- return renderView(views.errorPage({
6974
- error: error.message,
6975
- statusCode: error.httpStatusCode,
6976
- }), { status: error.httpStatusCode });
7633
+ return { kind: 'error', error: error.message, statusCode: error.httpStatusCode };
6977
7634
  }
6978
7635
  }
6979
7636
  `
6980
7637
  : '';
6981
7638
  const bindingCheckBeforeConsentForm = features.transactionBinding
6982
7639
  ? `
6983
- // Checked BEFORE rendering: the consent page embeds csrf_token, so a third
6984
- // party holding a leaked transaction_id must not be able to read it here and
6985
- // then complete POST /consent on the End-User's behalf.
6986
- const bindingError = await rejectUnboundTransaction(
6987
- transaction,
6988
- transactionId,
6989
- c.req.header('Cookie') ?? null,
6990
- views,
6991
- );
7640
+ // Checked BEFORE the form is described: the consent page embeds csrf_token,
7641
+ // so a third party holding a leaked transaction_id must not be able to read
7642
+ // it and then complete the consent step on the End-User's behalf.
7643
+ const bindingError = await rejectUnboundTransaction(c, transaction, transactionId);
6992
7644
  if (bindingError) return bindingError;
6993
7645
  `
6994
7646
  : '';
@@ -6996,28 +7648,13 @@ async function rejectUnboundTransaction(
6996
7648
  ? ` // Checked before validateCsrfToken and before any decision is acted on: this
6997
7649
  // is the step that mints the authorization code, so an unbound caller must not
6998
7650
  // reach it — neither to approve nor to deny on the End-User's behalf.
6999
- const bindingError = await rejectUnboundTransaction(
7000
- transaction,
7001
- transactionId,
7002
- c.req.header('Cookie') ?? null,
7003
- views,
7004
- );
7651
+ const bindingError = await rejectUnboundTransaction(c, transaction, transactionId);
7005
7652
  if (bindingError) return bindingError;
7006
7653
  `
7007
7654
  : '';
7008
- const clearBindingCookieOnDeny = features.transactionBinding
7009
- ? ` // The transaction is over; drop its binding cookie so the browser does not
7010
- // keep one cookie per finished flow.
7011
- c.header('Set-Cookie', buildClearedTransactionBindingCookie(transactionId));
7012
- `
7013
- : '';
7014
- const clearBindingCookieOnSuccess = features.transactionBinding
7015
- ? ` // The transaction is over; drop its binding cookie so the browser does not
7016
- // keep one cookie per finished flow.
7017
- c.header('Set-Cookie', buildClearedTransactionBindingCookie(transactionId));
7018
-
7019
- `
7020
- : '';
7655
+ const finishedCookies = features.transactionBinding
7656
+ ? '[buildClearedTransactionBindingCookie(transactionId)]'
7657
+ : '[]';
7021
7658
  const jarmConsentImports = features.jarm
7022
7659
  ? `
7023
7660
  import {
@@ -7042,8 +7679,8 @@ import { jarmConfig } from './jarm.js';`
7042
7679
  /**
7043
7680
  * EXPERIMENTAL — JARM (JWT Secured Authorization Response Mode).
7044
7681
  *
7045
- * The authorize route recorded the requested response mode on the transaction
7046
- * (jarmResponseMode). This route only ever sees the transaction it read back
7682
+ * The authorize step recorded the requested response mode on the transaction
7683
+ * (jarmResponseMode). This step only ever sees the transaction it read back
7047
7684
  * from the store, so the auth transaction store MUST persist fields it does not
7048
7685
  * know about — otherwise a client that asked for a JWT response silently gets a
7049
7686
  * plain query response instead. conformance.test.ts pins that round trip.
@@ -7114,19 +7751,21 @@ async function buildConsentRedirect(
7114
7751
  }
7115
7752
  `
7116
7753
  : '';
7117
- const consentDenyRedirect = features.jarm
7754
+ const consentDenyResponse = features.jarm
7118
7755
  ? ` if (action === 'deny') {
7119
7756
  await transactionStore.delete('auth_txn:' + transactionId);
7120
7757
  await authSessionStore.delete(transactionId);
7121
- ${clearBindingCookieOnDeny} // EXPERIMENTAL (JARM §2.1): a request that asked for response_mode=query.jwt
7758
+ // EXPERIMENTAL (JARM §2.1): a request that asked for response_mode=query.jwt
7122
7759
  // gets its error as a signed JWT too, so the client can verify that the OP
7123
7760
  // it trusts is the one that denied the request.
7124
- return c.redirect(
7125
- await buildConsentRedirect(resolveJarmResponse(c, transaction), transaction.redirectUri, {
7761
+ return {
7762
+ kind: 'authorization_response',
7763
+ location: await buildConsentRedirect(resolveJarmResponse(c, transaction), transaction.redirectUri, {
7126
7764
  error: 'access_denied',
7127
7765
  state: transaction.state,
7128
7766
  }, issuer),
7129
- );
7767
+ cookies: ${finishedCookies},
7768
+ };
7130
7769
  }`
7131
7770
  : ` if (action === 'deny') {
7132
7771
  const redirectUrl = new URL(transaction.redirectUri);
@@ -7137,28 +7776,40 @@ ${clearBindingCookieOnDeny} // EXPERIMENTAL (JARM §2.1): a request that aske
7137
7776
  redirectUrl.searchParams.set('iss', issuer);
7138
7777
  await transactionStore.delete('auth_txn:' + transactionId);
7139
7778
  await authSessionStore.delete(transactionId);
7140
- ${clearBindingCookieOnDeny} return c.redirect(redirectUrl.toString());
7779
+ return { kind: 'authorization_response', location: redirectUrl.toString(), cookies: ${finishedCookies} };
7141
7780
  }`;
7142
- const consentSuccessRedirect = features.jarm
7143
- ? `${clearBindingCookieOnSuccess} // Redirect back to client with authorization code
7144
- return c.redirect(
7145
- await buildConsentRedirect(resolveJarmResponse(c, transaction), responseParams.redirectUri, {
7781
+ const consentSuccessResponse = features.jarm
7782
+ ? ` // Back to the client with the authorization code
7783
+ return {
7784
+ kind: 'authorization_response',
7785
+ location: await buildConsentRedirect(resolveJarmResponse(c, transaction), responseParams.redirectUri, {
7146
7786
  code: authCodeData.code,
7147
7787
  state: responseParams.state,
7148
7788
  }, issuer),
7149
- );`
7150
- : `${clearBindingCookieOnSuccess} // Redirect back to client with authorization code
7789
+ cookies: ${finishedCookies},
7790
+ };`
7791
+ : ` // Back to the client with the authorization code
7151
7792
  const redirectUrl = new URL(responseParams.redirectUri);
7152
7793
  redirectUrl.searchParams.set('code', authCodeData.code);
7153
7794
  if (responseParams.state) {
7154
7795
  redirectUrl.searchParams.set('state', responseParams.state);
7155
7796
  }
7156
7797
  redirectUrl.searchParams.set('iss', issuer);
7157
- return c.redirect(redirectUrl.toString());`;
7158
- return `import { Hono } from 'hono';
7798
+ return { kind: 'authorization_response', location: redirectUrl.toString(), cookies: ${finishedCookies} };`;
7799
+ return `/**
7800
+ * Consent step (API layer: logic only).
7801
+ *
7802
+ * Everything the consent screen has to decide lives here as plain functions:
7803
+ * loading the transaction, the User-Agent binding, the scope policy, the
7804
+ * authorization decision, the authorization code, the consent record and the
7805
+ * authorization response URL (RFC 6749 §4.1.2 / RFC 9207 iss / JARM). None of
7806
+ * them builds a Response — each returns an outcome, and pages/consent.ts turns
7807
+ * that outcome into a screen or a redirect. The UI can therefore be changed
7808
+ * without touching this file.
7809
+ */
7159
7810
  import {
7160
7811
  getAuthTransaction,
7161
- validateCsrfToken,${bindingImports}
7812
+ validateCsrfToken,${bindingCoreImports}
7162
7813
  completeAuthTransaction,
7163
7814
  createAuthorizationCode,${jarmConsentCoreImports}
7164
7815
  } from '${corePkg}';
@@ -7169,44 +7820,76 @@ import {
7169
7820
  transactionStore as defaultTransactionStore,
7170
7821
  authCodeStore as defaultAuthCodeStore,
7171
7822
  authSessionStore as defaultAuthSessionStore,${bindingStoreImport}
7172
- } from '../store.js';
7173
- import { defaultViews, renderView } from '../views.js';${jarmConsentImports}${customScopeImports}
7823
+ } from '../store.js';${jarmConsentImports}${customScopeImports}
7824
+
7825
+ /** What the consent form needs, prepared for GET /consent. */
7826
+ export interface ConsentScreen {
7827
+ kind: 'screen';
7828
+ transactionId: string;
7829
+ /** Must be posted back as the csrf_token field. */
7830
+ csrfToken: string;
7831
+ /** Scopes this End-User is asked to grant (already narrowed by the scope policy). */
7832
+ scopes: string[];
7833
+ /** Client requesting the authorization. */
7834
+ clientId: string;
7835
+ }
7836
+
7837
+ /** A failure the OP shows on its own error page (never redirected to the client). */
7838
+ export interface ConsentError {
7839
+ kind: 'error';
7840
+ error: string;
7841
+ errorDescription?: string;
7842
+ statusCode: number;
7843
+ }
7174
7844
 
7175
- export const consentApp = new Hono<{ Variables: Record<string, any> }>();
7845
+ /** What POST /consent decided; pages/consent.ts turns it into HTTP. */
7846
+ export type ConsentOutcome =
7847
+ | ConsentError
7848
+ /**
7849
+ * OIDC Core 1.0 Section 3.1.2.4: no decision was obtained — action was
7850
+ * missing, empty or unknown. Not access_denied (Section 3.1.2.6), so the
7851
+ * browser stays on the OP (400).
7852
+ */
7853
+ | { kind: 'invalid_decision' }
7854
+ /** No authenticated subject for this transaction: the login step was skipped or expired (400). */
7855
+ | { kind: 'session_missing' }
7856
+ /** Approved or denied: the authorization response for the client, ready in the URL. */
7857
+ | { kind: 'authorization_response'; location: string; cookies: string[] };
7858
+
7859
+ /** The fields of the consent form. */
7860
+ export interface ConsentSubmission {
7861
+ transactionId: string;
7862
+ csrfToken: string;
7863
+ /** 'approve' or 'deny' — the submit button values of the consent view. */
7864
+ action: string;
7865
+ }
7176
7866
  ${bindingGuard}${jarmConsentHelpers}
7177
7867
  /**
7178
- * Consent Page - GET
7179
- * Displays the consent form for scope authorization.
7868
+ * GET /consent: load the transaction and describe the form, or the error to
7869
+ * show instead when this browser may not see it.
7180
7870
  */
7181
- consentApp.get('/', async (c) => {
7182
- const transactionId = c.req.query('transaction_id');
7183
- if (!transactionId) {
7184
- return c.text('Missing transaction_id', 400);
7185
- }
7186
-
7187
- const views = c.get('views') ?? defaultViews;
7871
+ export async function prepareConsent(
7872
+ c: any,
7873
+ transactionId: string,
7874
+ ): Promise<ConsentScreen | ConsentError> {
7188
7875
  const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
7189
7876
  const transaction = await getAuthTransaction(transactionId, transactionStore);
7190
7877
  ${bindingCheckBeforeConsentForm}
7191
- ${consentGetScopeResolution} return renderView(views.consentPage({
7878
+ ${consentGetScopeResolution} return {
7879
+ kind: 'screen',
7192
7880
  transactionId,
7193
7881
  csrfToken: transaction.csrfToken,
7194
7882
  scopes: ${consentDisplayScopes},
7195
7883
  clientId: transaction.clientId,
7196
- }));
7197
- });
7884
+ };
7885
+ }
7198
7886
 
7199
7887
  /**
7200
- * Consent Handler - POST
7201
- * Processes the consent decision.
7888
+ * POST /consent: record the decision and build the authorization response.
7202
7889
  */
7203
- consentApp.post('/', async (c) => {
7204
- const body = await c.req.parseBody();
7205
- const transactionId = String(body['transaction_id'] ?? '');
7206
- const csrfToken = String(body['csrf_token'] ?? '');
7207
- const action = String(body['action'] ?? '');
7890
+ export async function submitConsent(c: any, input: ConsentSubmission): Promise<ConsentOutcome> {
7891
+ const { transactionId, csrfToken, action } = input;
7208
7892
 
7209
- const views = c.get('views') ?? defaultViews;
7210
7893
  const transactionStore = c.get('transactionStore') ?? defaultTransactionStore;
7211
7894
  const authCodeStore = c.get('authCodeStore') ?? defaultAuthCodeStore;
7212
7895
  const authSessionStore = c.get('authSessionStore') ?? defaultAuthSessionStore;
@@ -7219,7 +7902,7 @@ ${bindingCheckBeforeConsentCsrf} validateCsrfToken(transaction, csrfToken);
7219
7902
  const config = c.get('config');
7220
7903
  const issuer = config.issuer;
7221
7904
 
7222
- ${consentDenyRedirect}
7905
+ ${consentDenyResponse}
7223
7906
 
7224
7907
  // OIDC Core 1.0 Section 3.1.2.4: "the Authorization Server MUST obtain an
7225
7908
  // authorization decision before releasing information to the Relying Party."
@@ -7230,24 +7913,18 @@ ${consentDenyRedirect}
7230
7913
  // 'approve' is the decision value this provider accepts, and it MUST stay in
7231
7914
  // sync with the Approve button in views.ts consentPage(). Changing it here
7232
7915
  // without changing the button (or the other way round) makes every approval
7233
- // fail with the 400 below.
7916
+ // fail with the 400 pages/consent.ts shows for this outcome.
7234
7917
  //
7235
7918
  // Section 3.1.2.6: access_denied means the End-User denied the request, which
7236
7919
  // is not the same as no decision at all — an unrecognized value stops here on
7237
7920
  // the OP's own error page instead of being redirected back to the client.
7238
7921
  if (action !== 'approve') {
7239
- return renderView(views.errorPage({
7240
- error: 'Invalid consent decision. Please use the Approve or Deny button.',
7241
- statusCode: 400,
7242
- }), { status: 400 });
7922
+ return { kind: 'invalid_decision' };
7243
7923
  }
7244
7924
 
7245
7925
  const session = await authSessionStore.get(transactionId);
7246
7926
  if (!session) {
7247
- return renderView(views.errorPage({
7248
- error: 'Authentication session not found. Please restart login.',
7249
- statusCode: 400,
7250
- }), { status: 400 });
7927
+ return { kind: 'session_missing' };
7251
7928
  }
7252
7929
 
7253
7930
  const responseParams = await completeAuthTransaction(
@@ -7268,7 +7945,7 @@ ${consentGrantedScope}
7268
7945
  authorizationResponse: { ...responseParams, scope: grantedScope },
7269
7946
  subject: session.subject,
7270
7947
  authTime: session.authTime,
7271
- // online refresh token をこのログインセッションへ束縛する(login route が
7948
+ // online refresh token をこのログインセッションへ束縛する(login step が
7272
7949
  // authSessionStore へ載せた値)。ログアウトすれば RT も使えなくなる。
7273
7950
  sessionId: session.sessionId,
7274
7951
  ttlSeconds: config.authorizationCodeTtl,
@@ -7290,8 +7967,8 @@ ${consentGrantedScope}
7290
7967
 
7291
7968
  await authSessionStore.delete(transactionId);
7292
7969
 
7293
- ${consentSuccessRedirect}
7294
- });
7970
+ ${consentSuccessResponse}
7971
+ }
7295
7972
  `;
7296
7973
  }
7297
7974
  export function applyTemplate(_corePkg, features = DEFAULT_FEATURES) {
@@ -7330,14 +8007,14 @@ export function applyTemplate(_corePkg, features = DEFAULT_FEATURES) {
7330
8007
  : '';
7331
8008
  const deviceImport = features.deviceAuthorizationGrant
7332
8009
  ? `import { deviceAuthorizationApp } from './routes/device-authorization.js';
7333
- import { deviceApp } from './routes/device.js';\n`
8010
+ import { devicePage } from './pages/device.js';\n`
7334
8011
  : '';
7335
8012
  const deviceCors = features.deviceAuthorizationGrant
7336
8013
  ? ` app.use('/device_authorization', protectedCors);\n`
7337
8014
  : '';
7338
8015
  const deviceMount = features.deviceAuthorizationGrant
7339
8016
  ? ` app.route('/device_authorization', deviceAuthorizationApp);
7340
- app.route('/device', deviceApp);\n`
8017
+ app.route('/device', devicePage);\n`
7341
8018
  : '';
7342
8019
  const deviceStorageContext = features.deviceAuthorizationGrant
7343
8020
  ? ` c.set('deviceAuthorizationStore', deviceAuthorizationStore);\n`
@@ -7347,14 +8024,14 @@ import { deviceApp } from './routes/device.js';\n`
7347
8024
  : '';
7348
8025
  const cibaImport = features.ciba
7349
8026
  ? `import { backchannelAuthenticationApp } from './routes/backchannel-authentication.js';
7350
- import { cibaApp } from './routes/ciba-verification.js';\n`
8027
+ import { cibaPage } from './pages/ciba.js';\n`
7351
8028
  : '';
7352
8029
  const cibaCors = features.ciba
7353
8030
  ? ` app.use('/backchannel_authentication', protectedCors);\n`
7354
8031
  : '';
7355
8032
  const cibaMount = features.ciba
7356
8033
  ? ` app.route('/backchannel_authentication', backchannelAuthenticationApp);
7357
- app.route('/ciba', cibaApp);\n`
8034
+ app.route('/ciba', cibaPage);\n`
7358
8035
  : '';
7359
8036
  const cibaStorageContext = features.ciba
7360
8037
  ? ` c.set('cibaAuthenticationRequestStore', cibaAuthenticationRequestStore);
@@ -7377,6 +8054,44 @@ import { cibaApp } from './routes/ciba-verification.js';\n`
7377
8054
  cibaUserResolver?: (
7378
8055
  loginHint: string,
7379
8056
  ) => Promise<{ subject: string } | null> | { subject: string } | null;
8057
+ `
8058
+ : '';
8059
+ const logoutImport = features.rpInitiatedLogout
8060
+ ? `import { logoutPage } from './pages/logout.js';\n`
8061
+ : '';
8062
+ const logoutMount = features.rpInitiatedLogout
8063
+ ? ` app.route('/logout', logoutPage);\n`
8064
+ : '';
8065
+ const googleLoginImport = features.googleLogin
8066
+ ? `import {
8067
+ getDefaultGoogleIdTokenVerifier,
8068
+ type GoogleAccountResolver,
8069
+ type GoogleIdTokenPayload,
8070
+ type GoogleIdTokenVerifier,
8071
+ } from '${GOOGLE_LOGIN_PACKAGE}';\n`
8072
+ : '';
8073
+ const googleLoginStorageContext = features.googleLogin
8074
+ ? ` c.set('googleLoginNonceStore', stores.googleLoginNonceStore);
8075
+ c.set('googleIdTokenVerifier', options.googleIdTokenVerifier ?? getDefaultGoogleIdTokenVerifier());
8076
+ c.set('googleAccountResolver', options.googleAccountResolver ?? {
8077
+ resolveSubject: async (account: GoogleIdTokenPayload) =>
8078
+ (await stores.userStore.linkGoogleAccount(account)).sub,
8079
+ });\n`
8080
+ : '';
8081
+ const googleLoginOptionsFields = features.googleLogin
8082
+ ? ` /**
8083
+ * EXTENSION (google-login): verifier for the ID token Google posts to
8084
+ * /login/google. Defaults to google-auth-library (OAuth2Client.verifyIdToken)
8085
+ * with a process-wide certificate cache; inject a custom one for tests or a
8086
+ * proxied environment.
8087
+ */
8088
+ googleIdTokenVerifier?: GoogleIdTokenVerifier;
8089
+ /**
8090
+ * EXTENSION (google-login): map a verified Google account to the OP subject.
8091
+ * Defaults to just-in-time provisioning through the user store
8092
+ * (userStore.linkGoogleAccount), keyed by the Google \`sub\`.
8093
+ */
8094
+ googleAccountResolver?: GoogleAccountResolver;
7380
8095
  `
7381
8096
  : '';
7382
8097
  const refreshStorageContext = features.refreshToken
@@ -7393,13 +8108,13 @@ import { cibaApp } from './routes/ciba-verification.js';\n`
7393
8108
  const methodGuard = oidcMethodGuardTemplate(features);
7394
8109
  return `import type { Hono } from 'hono';
7395
8110
  import { cors } from 'hono/cors';
7396
- import { authorizeApp } from './routes/authorize.js';
8111
+ import { authorizePage } from './pages/authorize.js';
7397
8112
  import { tokenApp } from './routes/token.js';
7398
8113
  import { userinfoApp } from './routes/userinfo.js';
7399
- ${introspectionImport}${revocationImport}${parImport}${deviceImport}${cibaImport}import { jwksApp } from './routes/jwks.js';
8114
+ ${introspectionImport}${revocationImport}${parImport}${deviceImport}${cibaImport}${logoutImport}import { jwksApp } from './routes/jwks.js';
7400
8115
  import { discoveryApp } from './routes/discovery.js';
7401
- import { loginApp } from './routes/login.js';
7402
- import { consentApp } from './routes/consent.js';
8116
+ import { loginPage } from './pages/login.js';
8117
+ import { consentPage } from './pages/consent.js';
7403
8118
  import {
7404
8119
  createInMemoryClientResolver,
7405
8120
  createProviderConfig,
@@ -7414,7 +8129,7 @@ ${parStoreImport}${deviceStoreImport}${cibaStoreImport} type ProviderStores,
7414
8129
  type ProviderStoresFactory,
7415
8130
  } from './store.js';
7416
8131
  import { createViews, type Views } from './views.js';
7417
- import {
8132
+ ${googleLoginImport}import {
7418
8133
  assertHasRs256Key,
7419
8134
  assertKeyStrength,
7420
8135
  assertKidStrategyConsistent,
@@ -7501,7 +8216,7 @@ export interface ApplyOidcOptions {
7501
8216
  * Token / UserInfo / Introspection / Revocation エンドポイントに適用される。
7502
8217
  * Discovery / JWKS は仕様上常に '*' 固定 (OIDC Discovery / RFC 8414 で公開資産扱い)。
7503
8218
  */
7504
- ${cibaOptionsField} corsOrigins?: CorsOrigins;
8219
+ ${cibaOptionsField}${googleLoginOptionsFields} corsOrigins?: CorsOrigins;
7505
8220
  /**
7506
8221
  * Custom UI for the login / consent / error pages.
7507
8222
  * Provide any subset; omitted pages fall back to the default views.
@@ -7624,7 +8339,7 @@ ${introspectionCors}${revocationCors}${parCors}${deviceCors}${cibaCors} app.use
7624
8339
  c.set('authCodeResolver', storeResolvers.authorizationCodeResolver);
7625
8340
  c.set('accessTokenResolver', storeResolvers.accessTokenResolver);
7626
8341
  c.set('userClaimsResolver', storeResolvers.userClaimsResolver);
7627
- ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext}${parStorageContext}${deviceStorageContext}${cibaStorageContext}
8342
+ ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext}${parStorageContext}${deviceStorageContext}${cibaStorageContext}${googleLoginStorageContext}
7628
8343
  // T-015: acr / amr resolver (optional; undefined preserves T-009 hold behavior).
7629
8344
  if (options.acrResolver) {
7630
8345
  c.set('acrResolver', options.acrResolver);
@@ -7642,13 +8357,15 @@ ${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext
7642
8357
  await next();
7643
8358
  });
7644
8359
 
7645
- app.route('/authorize', authorizeApp);
8360
+ // Browser-facing surfaces are mounted from pages/: every GET and POST of a
8361
+ // screen lives there, and the logic they call is in routes/.
8362
+ app.route('/authorize', authorizePage);
7646
8363
  app.route('/token', tokenApp);
7647
8364
  app.route('/userinfo', userinfoApp);
7648
- ${introspectionMount}${revocationMount}${parMount}${deviceMount}${cibaMount} app.route('/.well-known/jwks.json', jwksApp);
8365
+ ${introspectionMount}${revocationMount}${parMount}${deviceMount}${cibaMount}${logoutMount} app.route('/.well-known/jwks.json', jwksApp);
7649
8366
  app.route('/.well-known/openid-configuration', discoveryApp);
7650
- app.route('/login', loginApp);
7651
- app.route('/consent', consentApp);
8367
+ app.route('/login', loginPage);
8368
+ app.route('/consent', consentPage);
7652
8369
  }
7653
8370
 
7654
8371
  async function resolveProviderStores(
@@ -7735,6 +8452,7 @@ import {
7735
8452
  verifyClientSecret,
7736
8453
  requireIntrospectionToken,
7737
8454
  requireIntrospectionClient,
8455
+ requireConfidentialIntrospectionCaller,
7738
8456
  resolveIntrospectionToken,
7739
8457
  isIntrospectionTokenActive,
7740
8458
  buildIntrospectionResponse,
@@ -7759,8 +8477,10 @@ function isFormUrlEncoded(contentType: string): boolean {
7759
8477
  * Token Introspection Endpoint
7760
8478
  * RFC 7662 Section 2
7761
8479
  *
7762
- * Confidential client only — public clients are out of scope for this template.
7763
- * Response is always cache-busting per RFC 7662 Section 2.2.
8480
+ * Confidential client only — a caller registered with
8481
+ * token_endpoint_auth_method 'none' is rejected with invalid_client
8482
+ * (RFC 7662 §2.1 / RFC 9701 §5), because a public client_id alone is not
8483
+ * authentication. Response is always cache-busting per RFC 7662 Section 2.2.
7764
8484
  */
7765
8485
  introspectionApp.post('/', async (c) => {
7766
8486
  c.header('Cache-Control', 'no-store');
@@ -7802,6 +8522,13 @@ introspectionApp.post('/', async (c) => {
7802
8522
  );
7803
8523
  validateClientAuthMethod(introspectingClient, presentedCredentials);
7804
8524
  await verifyClientSecret(introspectingClient, presentedCredentials.clientSecret);
8525
+ // RFC 7662 §2.1 / RFC 9701 §5: the caller must be an authenticated
8526
+ // confidential client. A client registered with token_endpoint_auth_method
8527
+ // 'none' passes the pipeline above by presenting its client_id alone —
8528
+ // public information — so treating it as authenticated would let anyone
8529
+ // scan tokens. Revocation deliberately has no such step: RFC 7009 §2.1
8530
+ // lets a public client revoke its own tokens.
8531
+ requireConfidentialIntrospectionCaller(introspectingClient);
7805
8532
  const authenticatedClientId = presentedCredentials.clientId;
7806
8533
 
7807
8534
  // --- Introspection pipeline ---------------------------------------------
@@ -8335,11 +9062,114 @@ function defaultCibaCompletedPage(params: CibaCompletedPageParams): string {
8335
9062
  cibaCompletedPage: defaultCibaCompletedPage,
8336
9063
  `
8337
9064
  : '';
9065
+ const rpInitiatedLogoutParamTypes = features.rpInitiatedLogout
9066
+ ? `
9067
+ export interface LogoutConfirmationPageParams {
9068
+ /** CSRF token (must be included as hidden form field of the approve POST) */
9069
+ csrfToken: string;
9070
+ }
9071
+
9072
+ /**
9073
+ * Parameters of the logged-out page. Deliberately empty: the completed screen
9074
+ * shows no End-User or client identifier (whoever sees the screen learns
9075
+ * nothing), and its wording never depends on whether anything was actually
9076
+ * deleted — varying it would make the page a session-existence oracle.
9077
+ */
9078
+ export interface LogoutCompletedPageParams {}
9079
+ `
9080
+ : '';
9081
+ const rpInitiatedLogoutViewsMembers = features.rpInitiatedLogout
9082
+ ? ` /** EXPERIMENTAL (RP-Initiated Logout 1.0 §2): render the logout confirmation screen */
9083
+ logoutConfirmationPage(params: LogoutConfirmationPageParams): ViewResult;
9084
+ /** EXPERIMENTAL (RP-Initiated Logout 1.0): render the logged-out screen */
9085
+ logoutCompletedPage(params: LogoutCompletedPageParams): ViewResult;
9086
+ `
9087
+ : '';
9088
+ const rpInitiatedLogoutDefaultViews = features.rpInitiatedLogout
9089
+ ? `// RP-Initiated Logout 1.0 §2: the wording is fixed for every path into this
9090
+ // screen (no hint, an invalid or expired hint, another user's session, no
9091
+ // session at all), so the page cannot be used as an oracle for session state
9092
+ // or for why the hint failed.
9093
+ function defaultLogoutConfirmationPage(params: LogoutConfirmationPageParams): string {
9094
+ return \`<!DOCTYPE html>
9095
+ <html>
9096
+ <head><title>Log out</title></head>
9097
+ <body>
9098
+ <h1>Log out</h1>
9099
+ <p>Do you want to log out of the OpenID Provider?</p>
9100
+ <p>If you did not request this, close this page.</p>
9101
+ <form method="POST" action="/logout/approve">
9102
+ <input type="hidden" name="csrf_token" value="\${escapeHtml(params.csrfToken)}" />
9103
+ <button type="submit">Log out</button>
9104
+ </form>
9105
+ </body>
9106
+ </html>\`;
9107
+ }
9108
+
9109
+ function defaultLogoutCompletedPage(_params: LogoutCompletedPageParams): string {
9110
+ return \`<!DOCTYPE html>
9111
+ <html>
9112
+ <head><title>Logged out</title></head>
9113
+ <body>
9114
+ <h1>Logged out</h1>
9115
+ <p>You have been logged out.</p>
9116
+ <p>You can close this page.</p>
9117
+ </body>
9118
+ </html>\`;
9119
+ }
9120
+
9121
+ `
9122
+ : '';
9123
+ const rpInitiatedLogoutDefaultViewsEntries = features.rpInitiatedLogout
9124
+ ? ` logoutConfirmationPage: defaultLogoutConfirmationPage,
9125
+ logoutCompletedPage: defaultLogoutCompletedPage,
9126
+ `
9127
+ : '';
9128
+ const googleViewsImport = features.googleLogin
9129
+ ? `// EXTENSION (google-login): the GIS configuration type and the helper that
9130
+ // serializes it into this string template. The package generates no UI; the
9131
+ // three elements GIS needs are written out in defaultLoginPage below.
9132
+ import {
9133
+ googleSignInAttributesToHtml,
9134
+ GOOGLE_GSI_CLIENT_SCRIPT_URL,
9135
+ type GoogleSignInAttributes,
9136
+ } from '${GOOGLE_LOGIN_PACKAGE}/sign-in';
9137
+
9138
+ `
9139
+ : '';
9140
+ const googleLoginPageParam = features.googleLogin
9141
+ ? ` /**
9142
+ * EXTENSION (google-login): GIS configuration for "Sign in with Google"
9143
+ * (redirect mode) — the g_id_onload attributes built by
9144
+ * buildGoogleSignInAttributes(): client ID, data-ux_mode="redirect", the
9145
+ * login_uri Google posts the ID token to, and the nonce bound to this
9146
+ * transaction. The view owns the markup (see defaultLoginPage). Undefined
9147
+ * when Google login is not configured; only the password form is shown then.
9148
+ */
9149
+ googleSignIn?: GoogleSignInAttributes;
9150
+ `
9151
+ : '';
9152
+ const googleSignInSnippet = features.googleLogin
9153
+ ? `
9154
+ // EXTENSION (google-login): the three elements GIS needs for redirect mode —
9155
+ // its client script, #g_id_onload carrying the configuration (attribute
9156
+ // values escaped by googleSignInAttributesToHtml), and .g_id_signin, which
9157
+ // GIS replaces with the button. Style the button through the GIS button
9158
+ // attributes (data-theme, data-size, data-text, ...) on .g_id_signin.
9159
+ const googleSignInHtml = params.googleSignIn
9160
+ ? \` <hr />\\n <section aria-label="Sign in with Google">\\n <script src="\${GOOGLE_GSI_CLIENT_SCRIPT_URL}" async></script>\\n <div \${googleSignInAttributesToHtml(params.googleSignIn)}></div>\\n <div class="g_id_signin" data-type="standard"></div>\\n </section>\\n\`
9161
+ : '';
9162
+ `
9163
+ : '';
9164
+ const googleSignInPlaceholder = features.googleLogin ? '${googleSignInHtml}' : '';
8338
9165
  return `/**
8339
9166
  * UI Views for OpenID Connect Provider.
8340
9167
  *
8341
- * This file contains all user-facing HTML rendering.
8342
- * Customize these functions to match your application's design.
9168
+ * This file contains the default HTML of every user-facing screen. The screen
9169
+ * routes in pages/ deliver these views (pages/login.ts renders loginPage, and so
9170
+ * on); the logic in routes/ never renders anything — it returns outcomes the
9171
+ * pages turn into HTTP. Customize these functions to match your application's
9172
+ * design, or change how a screen is delivered in its pages/ module.
8343
9173
  *
8344
9174
  * Each function receives typed parameters and returns a ViewResult: either an
8345
9175
  * HTML string (wrapped into a text/html Response by renderView) or a
@@ -8348,7 +9178,7 @@ function defaultCibaCompletedPage(params: CibaCompletedPageParams): string {
8348
9178
  * rendering, or UI framework of your choice.
8349
9179
  */
8350
9180
 
8351
- // ============================================================
9181
+ ${googleViewsImport}// ============================================================
8352
9182
  // View Parameter Types
8353
9183
  // ============================================================
8354
9184
 
@@ -8367,7 +9197,7 @@ export interface LoginPageParams {
8367
9197
  * HTML-attribute escaped before rendering since it is unauthenticated input.
8368
9198
  */
8369
9199
  loginHint?: string;
8370
- }
9200
+ ${googleLoginPageParam}}
8371
9201
 
8372
9202
  export interface ConsentPageParams {
8373
9203
  /** Transaction ID for the auth flow */
@@ -8388,7 +9218,7 @@ export interface ErrorPageParams {
8388
9218
  /** HTTP status code */
8389
9219
  statusCode: number;
8390
9220
  }
8391
- ${deviceParamTypes}${cibaParamTypes}
9221
+ ${deviceParamTypes}${cibaParamTypes}${rpInitiatedLogoutParamTypes}
8392
9222
  // ============================================================
8393
9223
  // Views Interface
8394
9224
  // ============================================================
@@ -8407,7 +9237,7 @@ export interface Views {
8407
9237
  consentPage(params: ConsentPageParams): ViewResult;
8408
9238
  /** Render a generic error page */
8409
9239
  errorPage(params: ErrorPageParams): ViewResult;
8410
- ${deviceViewsMembers}${cibaViewsMembers}}
9240
+ ${deviceViewsMembers}${cibaViewsMembers}${rpInitiatedLogoutViewsMembers}}
8411
9241
 
8412
9242
  /** Options applied when renderView wraps an HTML string into a Response. */
8413
9243
  export interface RenderViewInit {
@@ -8462,7 +9292,7 @@ function defaultLoginPage(params: LoginPageParams): string {
8462
9292
  : ''
8463
9293
  }</p>\`
8464
9294
  : '';
8465
-
9295
+ ${googleSignInSnippet}
8466
9296
  return \`<!DOCTYPE html>
8467
9297
  <html>
8468
9298
  <head><title>Login</title></head>
@@ -8482,7 +9312,7 @@ function defaultLoginPage(params: LoginPageParams): string {
8482
9312
  </div>
8483
9313
  <button type="submit">Login</button>
8484
9314
  </form>
8485
- </body>
9315
+ ${googleSignInPlaceholder}</body>
8486
9316
  </html>\`;
8487
9317
  }
8488
9318
 
@@ -8537,7 +9367,7 @@ function defaultErrorPage(params: ErrorPageParams): string {
8537
9367
  </html>\`;
8538
9368
  }
8539
9369
 
8540
- ${deviceDefaultViews}${cibaDefaultViews}/**
9370
+ ${deviceDefaultViews}${cibaDefaultViews}${rpInitiatedLogoutDefaultViews}/**
8541
9371
  * Default Views used when no custom views are injected.
8542
9372
  * These render minimal, unstyled HTML so the flow works out of the box.
8543
9373
  */
@@ -8545,7 +9375,7 @@ export const defaultViews: Views = {
8545
9375
  loginPage: defaultLoginPage,
8546
9376
  consentPage: defaultConsentPage,
8547
9377
  errorPage: defaultErrorPage,
8548
- ${deviceDefaultViewsEntries}${cibaDefaultViewsEntries}};
9378
+ ${deviceDefaultViewsEntries}${cibaDefaultViewsEntries}${rpInitiatedLogoutDefaultViewsEntries}};
8549
9379
 
8550
9380
  /**
8551
9381
  * Build a Views instance, overriding any subset of the default views with your
@@ -8634,9 +9464,9 @@ export function requestObjectConformanceBeforeAll(features = DEFAULT_FEATURES) {
8634
9464
  );
8635
9465
  `;
8636
9466
  }
8637
- export function reuseFlowConformanceTestBlock(features = DEFAULT_FEATURES) {
9467
+ export function reuseFlowConformanceTestBlock(features = DEFAULT_FEATURES, errorPageMode = 'html') {
8638
9468
  return (reuseCascadeConformanceBlock(features) +
8639
- requestObjectValueConformanceBlock(features));
9469
+ requestObjectValueConformanceBlock(features, errorPageMode));
8640
9470
  }
8641
9471
  function reuseCascadeConformanceBlock(features) {
8642
9472
  if (!features.refreshToken) {
@@ -9109,7 +9939,39 @@ function reuseCascadeConformanceBlock(features) {
9109
9939
  });
9110
9940
  `;
9111
9941
  }
9112
- function requestObjectValueConformanceBlock(features) {
9942
+ function requestObjectValueConformanceBlock(features, errorPageMode = 'html') {
9943
+ const brokenRequestObjectExpectation = errorPageMode === 'redirect'
9944
+ ? ` // OIDC Core 1.0 §6.3: invalid_request_object (not the generic
9945
+ // invalid_request). This provider sets authorizationErrorRedirectPath, so
9946
+ // the browser is 303-redirected to the OP's OWN error page — never to the
9947
+ // redirect_uri of the broken Request Object.
9948
+ expect(res.status).toBe(303);
9949
+ expect(res.headers.get('Location')).toBe(
9950
+ '/oidc-error?error=invalid_request_object&error_description=request+object+is+not+a+JWS+compact+serialization',
9951
+ );`
9952
+ : ` // OIDC Core 1.0 §6.3: the request parameter contains an invalid Request
9953
+ // Object, so the OP reports invalid_request_object (not the generic
9954
+ // invalid_request). A redirect_uri carried inside a broken Request Object
9955
+ // cannot be trusted, so the error stays on the OP: HTTP 400, no redirect,
9956
+ // no state echo. Pinned to the default error page so a change in either
9957
+ // the error code or the non-redirect behavior is caught exactly.
9958
+ expect(res.status).toBe(400);
9959
+ expect(res.headers.get('Location')).toBe(null);
9960
+ expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
9961
+ const body = await res.text();
9962
+ expect(body).toBe(
9963
+ [
9964
+ '<!DOCTYPE html>',
9965
+ '<html>',
9966
+ '<head><title>Error</title></head>',
9967
+ '<body>',
9968
+ ' <h1>Error</h1>',
9969
+ ' <p>invalid_request_object</p>',
9970
+ ' <p>request object is not a JWS compact serialization</p>',
9971
+ '</body>',
9972
+ '</html>',
9973
+ ].join('\\n'),
9974
+ );`;
9113
9975
  if (!features.requestObject) {
9114
9976
  return `
9115
9977
  // OIDC Core 1.0 §6.3: the request parameter (Request Object by value) is disabled
@@ -9213,29 +10075,7 @@ function requestObjectValueConformanceBlock(features) {
9213
10075
  '&code_challenge=' + PKCE_CHALLENGE_S256 + '&code_challenge_method=S256';
9214
10076
  const res = await app.request(url);
9215
10077
 
9216
- // OIDC Core 1.0 §6.3: the request parameter contains an invalid Request
9217
- // Object, so the OP reports invalid_request_object (not the generic
9218
- // invalid_request). A redirect_uri carried inside a broken Request Object
9219
- // cannot be trusted, so the error stays on the OP: HTTP 400, no redirect,
9220
- // no state echo. Pinned to the default error page so a change in either
9221
- // the error code or the non-redirect behavior is caught exactly.
9222
- expect(res.status).toBe(400);
9223
- expect(res.headers.get('Location')).toBe(null);
9224
- expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
9225
- const body = await res.text();
9226
- expect(body).toBe(
9227
- [
9228
- '<!DOCTYPE html>',
9229
- '<html>',
9230
- '<head><title>Error</title></head>',
9231
- '<body>',
9232
- ' <h1>Error</h1>',
9233
- ' <p>invalid_request_object</p>',
9234
- ' <p>request object is not a JWS compact serialization</p>',
9235
- '</body>',
9236
- '</html>',
9237
- ].join('\\n'),
9238
- );
10078
+ ${brokenRequestObjectExpectation}
9239
10079
  });
9240
10080
 
9241
10081
  it('should reject the request_uri parameter with a request_uri_not_supported redirect', async () => {
@@ -9641,8 +10481,9 @@ export function customViewConformanceTestBlock() {
9641
10481
  expect(await res.text()).toBe('<h1>custom-view-response</h1>');
9642
10482
  });
9643
10483
 
9644
- // End-to-end: the login route returns its view via renderView, so the login
9645
- // page is delivered as a text/html Response through the framework at runtime.
10484
+ // End-to-end: the login page (pages/login.ts) returns its view via
10485
+ // renderView, so the login page is delivered as a text/html Response through
10486
+ // the framework at runtime.
9646
10487
  it('should deliver the login page through renderView as a text/html Response', async () => {
9647
10488
  // RFC 7636 Appendix B example challenge so authorize is accepted and mints a
9648
10489
  // transaction (302 -> /login); the verifier is never needed here.
@@ -10147,11 +10988,29 @@ export function jwtIntrospectionResponseConformanceBlock(features) {
10147
10988
  expect(res.headers.get('Content-Type')).toBe('application/json');
10148
10989
  expect(await res.json()).toMatchObject({ error: 'invalid_client' });
10149
10990
  });
10150
- });
10151
-
10152
- describe('Provider metadata (RFC 9701 §7)', () => {
10153
- it('should advertise introspection_signing_alg_values_supported as exactly RS256', async () => {
10154
- const metadata = await (await app.request('/.well-known/openid-configuration')).json();
10991
+
10992
+ // RFC 9701 §5: an unauthenticated request must be refused, and a public
10993
+ // client_id alone is not authentication. Without this rejection the
10994
+ // signed assertion would vouch for a caller identity that was never
10995
+ // verified.
10996
+ it('should reject a public client introspection request even when it asks for the JWT response', async () => {
10997
+ const res = await introspectWith(
10998
+ { client_id: 'c-public', token: 'rfc9701-active' },
10999
+ INTROSPECTION_JWT_MEDIA_TYPE,
11000
+ );
11001
+
11002
+ expect(res.status).toBe(401);
11003
+ expect(res.headers.get('Content-Type')).toBe('application/json');
11004
+ expect(await res.json()).toEqual({
11005
+ error: 'invalid_client',
11006
+ error_description: 'Introspection requires an authenticated confidential client',
11007
+ });
11008
+ });
11009
+ });
11010
+
11011
+ describe('Provider metadata (RFC 9701 §7)', () => {
11012
+ it('should advertise introspection_signing_alg_values_supported as exactly RS256', async () => {
11013
+ const metadata = await (await app.request('/.well-known/openid-configuration')).json();
10155
11014
 
10156
11015
  expect(metadata.introspection_signing_alg_values_supported).toEqual(['RS256']);
10157
11016
  });
@@ -10580,6 +11439,35 @@ export function introspectionConformanceBlock(features) {
10580
11439
  expect(body.active).toBe(true);
10581
11440
  expect(body.jti).toBe(accessTokenJti);
10582
11441
  });
11442
+
11443
+ // RFC 7662 §2.1: the introspection caller must be authorized, and a public
11444
+ // client's client_id is public information, so presenting it alone is not
11445
+ // client authentication. The route rejects the caller before any token
11446
+ // lookup, while revocation keeps accepting the same client (RFC 7009 §2.1).
11447
+ it('should reject an introspection request that presents only a public client_id', async () => {
11448
+ const now = Math.floor(Date.now() / 1000);
11449
+ accessTokenStore.set('public-introspect-token', {
11450
+ sub: 'testuser',
11451
+ clientId: 'c-public',
11452
+ scope: ['openid'],
11453
+ expiresAt: now + 3600,
11454
+ });
11455
+ const res = await app.request('/introspect', {
11456
+ method: 'POST',
11457
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
11458
+ body: new URLSearchParams({
11459
+ client_id: 'c-public',
11460
+ token: 'public-introspect-token',
11461
+ }).toString(),
11462
+ });
11463
+
11464
+ expect(res.status).toBe(401);
11465
+ expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="Client Authentication"');
11466
+ expect(await res.json()).toEqual({
11467
+ error: 'invalid_client',
11468
+ error_description: 'Introspection requires an authenticated confidential client',
11469
+ });
11470
+ });
10583
11471
  });
10584
11472
  `;
10585
11473
  }
@@ -11187,6 +12075,11 @@ export function endpointBehaviorConformanceBlock(features, includeHonoApplyParit
11187
12075
  { path: '/device/login', method: 'GET', allow: 'POST' },
11188
12076
  { path: '/device/approve', method: 'GET', allow: 'POST' },`
11189
12077
  : '';
12078
+ const logoutMethodTests = features.rpInitiatedLogout
12079
+ ? `
12080
+ { path: '/logout', method: 'PUT', allow: 'GET, POST' },
12081
+ { path: '/logout/approve', method: 'GET', allow: 'POST' },`
12082
+ : '';
11190
12083
  const corsPreflightTest = includeHonoApplyParity
11191
12084
  ? ` it('should give createApp and applyOidc the same CORS preflight behavior', async () => {
11192
12085
  const responses = await Promise.all(
@@ -11237,7 +12130,10 @@ export function endpointBehaviorConformanceBlock(features, includeHonoApplyParit
11237
12130
  it('should return 405 and an exact Allow header for unsupported endpoint methods', async () => {
11238
12131
  const cases = [
11239
12132
  { path: '/token', method: 'GET', allow: 'POST' },
11240
- { path: '/userinfo', method: 'PUT', allow: 'GET, POST' },${introspectionMethodTest}${revocationMethodTest}${deviceMethodTests}
12133
+ { path: '/userinfo', method: 'PUT', allow: 'GET, POST' },
12134
+ // The browser-facing screens (pages/) enforce their method lists too.
12135
+ { path: '/login', method: 'PUT', allow: 'GET, POST' },
12136
+ { path: '/consent', method: 'PUT', allow: 'GET, POST' },${introspectionMethodTest}${revocationMethodTest}${deviceMethodTests}${logoutMethodTests}
11241
12137
  { path: '/.well-known/openid-configuration', method: 'POST', allow: 'GET' },
11242
12138
  { path: '/.well-known/jwks.json', method: 'POST', allow: 'GET' },
11243
12139
  ];
@@ -16488,6 +17384,699 @@ export function consentDecisionConformanceBlock() {
16488
17384
  });
16489
17385
  `;
16490
17386
  }
17387
+ export function googleLoginConformanceImportsBlock(features) {
17388
+ if (!features.googleLogin)
17389
+ return '';
17390
+ return `
17391
+ import {
17392
+ GoogleLoginError,
17393
+ GoogleLoginErrorCode,
17394
+ type GoogleIdTokenPayload,
17395
+ type GoogleIdTokenVerifier,
17396
+ } from '${GOOGLE_LOGIN_PACKAGE}';`;
17397
+ }
17398
+ export function googleLoginConformanceBlock(features) {
17399
+ if (!features.googleLogin)
17400
+ return '';
17401
+ return `
17402
+ // EXTENSION — Sign in with Google (Google Identity Services, redirect mode).
17403
+ // Generated because this provider was created with --enable google-login.
17404
+ // google-auth-library is replaced by a stand-in verifier, so these tests never
17405
+ // reach Google: they pin the OP-side contract around the callback.
17406
+ describe('Google login (Sign in with Google, redirect mode)', () => {
17407
+ // RFC 7636 Appendix B example PKCE pair (verifier -> its S256 challenge).
17408
+ const GOOGLE_PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
17409
+ const GOOGLE_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
17410
+ const GOOGLE_CLIENT_ID = 'conformance-google-client.apps.googleusercontent.com';
17411
+ const GOOGLE_CSRF = 'conformance-g-csrf-token';
17412
+ const GOOGLE_SUB = '10769150350006150715113082367';
17413
+
17414
+ // Stand-in for google-auth-library: a credential is the JSON payload wrapped
17415
+ // as 'fake:<base64>'; anything else is refused like a bad signature would be.
17416
+ const verifiedClientIds: Array<string | readonly string[]> = [];
17417
+ const fakeGoogleVerifier: GoogleIdTokenVerifier = {
17418
+ async verify(idToken, clientId) {
17419
+ verifiedClientIds.push(clientId);
17420
+ if (!idToken.startsWith('fake:')) {
17421
+ throw new GoogleLoginError(GoogleLoginErrorCode.InvalidIdToken, 'Invalid token signature');
17422
+ }
17423
+ return JSON.parse(atob(idToken.slice('fake:'.length))) as GoogleIdTokenPayload;
17424
+ },
17425
+ };
17426
+
17427
+ function googleCredential(payload: Record<string, unknown>): string {
17428
+ return 'fake:' + btoa(JSON.stringify(payload));
17429
+ }
17430
+
17431
+ function googleAccount(nonce: string, overrides: Record<string, unknown> = {}): Record<string, unknown> {
17432
+ const now = Math.floor(Date.now() / 1000);
17433
+ return {
17434
+ iss: 'https://accounts.google.com',
17435
+ aud: GOOGLE_CLIENT_ID,
17436
+ sub: GOOGLE_SUB,
17437
+ email: 'jsmith@example.com',
17438
+ email_verified: true,
17439
+ name: 'John Smith',
17440
+ given_name: 'John',
17441
+ family_name: 'Smith',
17442
+ picture: 'https://lh3.googleusercontent.com/a/photo',
17443
+ iat: now,
17444
+ exp: now + 3600,
17445
+ nonce,
17446
+ ...overrides,
17447
+ };
17448
+ }
17449
+
17450
+ // Pure fetch + parse helpers: no assertions and no branching, so the
17451
+ // contract stays visible in the it() blocks.
17452
+ function googleRelativeFrom(location: string | null): string {
17453
+ const url = new URL(location ?? '', 'http://localhost');
17454
+ return url.pathname + url.search;
17455
+ }
17456
+
17457
+ function googleCsrfFrom(html: string): string {
17458
+ return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
17459
+ }
17460
+
17461
+ function googleNonceFrom(html: string): string {
17462
+ return html.match(/data-nonce="([^"]+)"/)?.[1] ?? '';
17463
+ }
17464
+
17465
+ function createGoogleApp(
17466
+ googleLogin: GoogleLoginConfig = { clientId: GOOGLE_CLIENT_ID },
17467
+ ): ReturnType<typeof createApp> {
17468
+ return createApp({
17469
+ signingKeyProvider,
17470
+ clientResolver: createInMemoryClientResolver(testClients),
17471
+ config: { googleLogin },
17472
+ googleIdTokenVerifier: fakeGoogleVerifier,
17473
+ });
17474
+ }
17475
+
17476
+ // Start an authorization request on the given app and fetch its login page.
17477
+ async function startGoogleFlow(
17478
+ targetApp: ReturnType<typeof createApp>,
17479
+ state: string,
17480
+ scope = 'openid',
17481
+ ): Promise<{ transactionId: string; loginHtml: string; nonce: string }> {
17482
+ const authorizeRes = await targetApp.request(
17483
+ '/authorize?response_type=code&client_id=c-conf' +
17484
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
17485
+ '&scope=' + encodeURIComponent(scope) + '&state=' + state + '&prompt=consent' +
17486
+ '&code_challenge=' + GOOGLE_PKCE_CHALLENGE + '&code_challenge_method=S256',
17487
+ );
17488
+ const loginPath = googleRelativeFrom(authorizeRes.headers.get('Location'));
17489
+ const loginRes = await targetApp.request(loginPath);
17490
+ const loginHtml = await loginRes.text();
17491
+ return {
17492
+ transactionId:
17493
+ new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '',
17494
+ loginHtml,
17495
+ nonce: googleNonceFrom(loginHtml),
17496
+ };
17497
+ }
17498
+
17499
+ // POST the redirect-mode callback exactly as the browser would after Google's
17500
+ // account chooser: credential + g_csrf_token in the body, g_csrf_token cookie.
17501
+ function googleCallback(
17502
+ targetApp: ReturnType<typeof createApp>,
17503
+ credential: string,
17504
+ init: { bodyCsrf?: string; cookie?: string | null } = {},
17505
+ ): Promise<Response> {
17506
+ const headers: Record<string, string> = {
17507
+ 'Content-Type': 'application/x-www-form-urlencoded',
17508
+ };
17509
+ if (init.cookie !== null) {
17510
+ headers.Cookie = init.cookie ?? 'g_csrf_token=' + GOOGLE_CSRF;
17511
+ }
17512
+ return targetApp.request('/login/google', {
17513
+ method: 'POST',
17514
+ headers,
17515
+ body: new URLSearchParams({
17516
+ credential,
17517
+ g_csrf_token: init.bodyCsrf ?? GOOGLE_CSRF,
17518
+ select_by: 'btn',
17519
+ }).toString(),
17520
+ });
17521
+ }
17522
+
17523
+ let googleApp: ReturnType<typeof createApp>;
17524
+
17525
+ beforeAll(() => {
17526
+ googleApp = createGoogleApp();
17527
+ });
17528
+
17529
+ // GIS HTML API, redirect mode: the g_id_onload element carries the client
17530
+ // ID, data-ux_mode="redirect", the login_uri Google posts to, and the nonce
17531
+ // that ties the click to this authorization transaction.
17532
+ it('should render the Sign in with Google button in redirect mode on the login page', async () => {
17533
+ const flow = await startGoogleFlow(googleApp, 'google-button');
17534
+
17535
+ expect(flow.loginHtml.includes('<script src="https://accounts.google.com/gsi/client" async></script>')).toBe(true);
17536
+ expect(flow.loginHtml.includes(
17537
+ '<div id="g_id_onload" data-client_id="' + GOOGLE_CLIENT_ID + '" data-ux_mode="redirect"' +
17538
+ ' data-login_uri="http://localhost:3000/login/google" data-nonce="' + flow.nonce + '"></div>',
17539
+ )).toBe(true);
17540
+ expect(flow.nonce.length).toBe(43);
17541
+ // The password form stays available next to the button.
17542
+ expect(flow.loginHtml.includes('name="password"')).toBe(true);
17543
+ });
17544
+
17545
+ it('should issue a fresh nonce for every login page render', async () => {
17546
+ const first = await startGoogleFlow(googleApp, 'google-nonce-1');
17547
+ const second = await startGoogleFlow(googleApp, 'google-nonce-2');
17548
+
17549
+ expect(first.nonce === second.nonce).toBe(false);
17550
+ });
17551
+
17552
+ it('should not render the button when Google login is not configured', async () => {
17553
+ const flow = await startGoogleFlow(app, 'google-unconfigured');
17554
+
17555
+ expect(flow.loginHtml.includes('g_id_onload')).toBe(false);
17556
+ expect(flow.nonce).toBe('');
17557
+ });
17558
+
17559
+ it('should answer 404 on the Google callback when Google login is not configured', async () => {
17560
+ const res = await googleCallback(app, googleCredential(googleAccount('unused')));
17561
+
17562
+ expect(res.status).toBe(404);
17563
+ expect(res.headers.get('Location')).toBe(null);
17564
+ });
17565
+
17566
+ // Google's server-side guide: the double-submit cookie is checked before the
17567
+ // credential is even looked at, in the order cookie -> body -> mismatch.
17568
+ it('should reject the callback without the g_csrf_token cookie', async () => {
17569
+ const flow = await startGoogleFlow(googleApp, 'google-no-cookie');
17570
+
17571
+ const res = await googleCallback(googleApp, googleCredential(googleAccount(flow.nonce)), { cookie: null });
17572
+
17573
+ expect(res.status).toBe(400);
17574
+ expect(res.headers.get('Location')).toBe(null);
17575
+ expect((await res.text()).includes('csrf_token_missing_in_cookie')).toBe(true);
17576
+ });
17577
+
17578
+ it('should reject the callback whose g_csrf_token cookie and body differ', async () => {
17579
+ const flow = await startGoogleFlow(googleApp, 'google-csrf-mismatch');
17580
+
17581
+ const res = await googleCallback(googleApp, googleCredential(googleAccount(flow.nonce)), { bodyCsrf: 'other' });
17582
+
17583
+ expect(res.status).toBe(400);
17584
+ expect((await res.text()).includes('csrf_token_mismatch')).toBe(true);
17585
+ });
17586
+
17587
+ it('should reject a credential the verifier does not accept', async () => {
17588
+ await startGoogleFlow(googleApp, 'google-bad-credential');
17589
+
17590
+ const res = await googleCallback(googleApp, 'not-a-google-id-token');
17591
+
17592
+ expect(res.status).toBe(401);
17593
+ expect(res.headers.get('Location')).toBe(null);
17594
+ expect((await res.text()).includes('invalid_id_token')).toBe(true);
17595
+ });
17596
+
17597
+ it('should hand the configured client ID to the verifier as the expected audience', async () => {
17598
+ const flow = await startGoogleFlow(googleApp, 'google-audience');
17599
+ verifiedClientIds.length = 0;
17600
+
17601
+ await googleCallback(googleApp, googleCredential(googleAccount(flow.nonce)));
17602
+
17603
+ expect(verifiedClientIds).toEqual([GOOGLE_CLIENT_ID]);
17604
+ });
17605
+
17606
+ it('should reject a credential whose nonce was not issued for a login page', async () => {
17607
+ await startGoogleFlow(googleApp, 'google-forged-nonce');
17608
+
17609
+ const res = await googleCallback(googleApp, googleCredential(googleAccount('forged-nonce')));
17610
+
17611
+ expect(res.status).toBe(400);
17612
+ expect((await res.text()).includes('login_nonce_not_found')).toBe(true);
17613
+ });
17614
+
17615
+ it('should reject a credential without a nonce', async () => {
17616
+ await startGoogleFlow(googleApp, 'google-missing-nonce');
17617
+
17618
+ const res = await googleCallback(googleApp, googleCredential(googleAccount('', { nonce: undefined })));
17619
+
17620
+ expect(res.status).toBe(400);
17621
+ expect((await res.text()).includes('invalid_nonce')).toBe(true);
17622
+ });
17623
+
17624
+ it('should establish the OP session and continue to consent for a valid credential', async () => {
17625
+ const flow = await startGoogleFlow(googleApp, 'google-happy');
17626
+
17627
+ const res = await googleCallback(googleApp, googleCredential(googleAccount(flow.nonce)));
17628
+ const setCookie = res.headers.get('Set-Cookie') ?? '';
17629
+
17630
+ expect(res.status).toBe(302);
17631
+ expect(res.headers.get('Location')).toBe(
17632
+ 'http://localhost:3000/consent?transaction_id=' + flow.transactionId,
17633
+ );
17634
+ expect(setCookie.startsWith('session_id=')).toBe(true);
17635
+ expect(setCookie.endsWith('; HttpOnly; Secure; SameSite=Lax; Path=/')).toBe(true);
17636
+ });
17637
+
17638
+ // The nonce is single use: the same credential cannot start a second session.
17639
+ it('should reject a replay of an already used credential', async () => {
17640
+ const flow = await startGoogleFlow(googleApp, 'google-replay');
17641
+ const credential = googleCredential(googleAccount(flow.nonce));
17642
+ await googleCallback(googleApp, credential);
17643
+
17644
+ const res = await googleCallback(googleApp, credential);
17645
+
17646
+ expect(res.status).toBe(400);
17647
+ expect((await res.text()).includes('login_nonce_not_found')).toBe(true);
17648
+ });
17649
+
17650
+ // A callback that fails before the credential is verified must not burn the
17651
+ // nonce, or a forged POST could lock the user out of their own login page.
17652
+ it('should keep the nonce usable after a callback that failed before verification', async () => {
17653
+ const flow = await startGoogleFlow(googleApp, 'google-nonce-kept');
17654
+ const credential = googleCredential(googleAccount(flow.nonce));
17655
+ await googleCallback(googleApp, credential, { cookie: null });
17656
+
17657
+ const res = await googleCallback(googleApp, credential);
17658
+
17659
+ expect(res.status).toBe(302);
17660
+ });
17661
+
17662
+ it('should issue tokens for the Google user and return the Google profile from UserInfo', async () => {
17663
+ const flow = await startGoogleFlow(googleApp, 'google-tokens', 'openid profile email');
17664
+ const callbackRes = await googleCallback(googleApp, googleCredential(googleAccount(flow.nonce)));
17665
+ const consentPath = googleRelativeFrom(callbackRes.headers.get('Location'));
17666
+ const consentGet = await googleApp.request(consentPath);
17667
+ const consentRes = await googleApp.request('/consent', {
17668
+ method: 'POST',
17669
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
17670
+ body: new URLSearchParams({
17671
+ transaction_id: flow.transactionId,
17672
+ csrf_token: googleCsrfFrom(await consentGet.text()),
17673
+ action: 'approve',
17674
+ }).toString(),
17675
+ });
17676
+ const code =
17677
+ new URL(consentRes.headers.get('Location') ?? '', 'http://localhost').searchParams.get('code') ?? '';
17678
+ const tokenRes = await googleApp.request('/token', {
17679
+ method: 'POST',
17680
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
17681
+ body: new URLSearchParams({
17682
+ grant_type: 'authorization_code',
17683
+ code,
17684
+ redirect_uri: REDIRECT_URI,
17685
+ client_id: 'c-conf',
17686
+ client_secret: 's',
17687
+ code_verifier: GOOGLE_PKCE_VERIFIER,
17688
+ }).toString(),
17689
+ });
17690
+ const tokens = await tokenRes.json();
17691
+ const userinfoRes = await googleApp.request('/userinfo', {
17692
+ headers: { Authorization: 'Bearer ' + tokens.access_token },
17693
+ });
17694
+
17695
+ expect(tokenRes.status).toBe(200);
17696
+ // Users provisioned from Google are keyed by the Google sub, never the email.
17697
+ expect(idTokenPayload(tokens.id_token as string).sub).toBe('google:' + GOOGLE_SUB);
17698
+ expect(userinfoRes.status).toBe(200);
17699
+ expect(await userinfoRes.json()).toEqual({
17700
+ sub: 'google:' + GOOGLE_SUB,
17701
+ name: 'John Smith',
17702
+ given_name: 'John',
17703
+ family_name: 'Smith',
17704
+ picture: 'https://lh3.googleusercontent.com/a/photo',
17705
+ email: 'jsmith@example.com',
17706
+ email_verified: true,
17707
+ });
17708
+ });
17709
+
17710
+ it('should keep the password login working next to the Google button', async () => {
17711
+ const flow = await startGoogleFlow(googleApp, 'google-password');
17712
+
17713
+ const res = await googleApp.request('/login', {
17714
+ method: 'POST',
17715
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
17716
+ body: new URLSearchParams({
17717
+ transaction_id: flow.transactionId,
17718
+ csrf_token: googleCsrfFrom(flow.loginHtml),
17719
+ username: 'testuser',
17720
+ password: 'password',
17721
+ }).toString(),
17722
+ });
17723
+
17724
+ expect(res.status).toBe(302);
17725
+ expect(res.headers.get('Location')).toBe(
17726
+ 'http://localhost:3000/consent?transaction_id=' + flow.transactionId,
17727
+ );
17728
+ });
17729
+
17730
+ it('should reject a Google account outside the allowed hosted domain', async () => {
17731
+ const workspaceApp = createGoogleApp({ clientId: GOOGLE_CLIENT_ID, hostedDomain: 'example.com' });
17732
+ const flow = await startGoogleFlow(workspaceApp, 'google-hd');
17733
+
17734
+ const res = await googleCallback(
17735
+ workspaceApp,
17736
+ googleCredential(googleAccount(flow.nonce, { hd: 'other.example' })),
17737
+ );
17738
+
17739
+ expect(res.status).toBe(403);
17740
+ expect((await res.text()).includes('invalid_hosted_domain')).toBe(true);
17741
+ });
17742
+
17743
+ it('should accept a Google account of the allowed hosted domain', async () => {
17744
+ const workspaceApp = createGoogleApp({ clientId: GOOGLE_CLIENT_ID, hostedDomain: 'example.com' });
17745
+ const flow = await startGoogleFlow(workspaceApp, 'google-hd-ok');
17746
+
17747
+ const res = await googleCallback(
17748
+ workspaceApp,
17749
+ googleCredential(googleAccount(flow.nonce, { hd: 'example.com' })),
17750
+ );
17751
+
17752
+ expect(res.status).toBe(302);
17753
+ });
17754
+
17755
+ it('should reject an unverified email when requireVerifiedEmail is set', async () => {
17756
+ const strictApp = createGoogleApp({ clientId: GOOGLE_CLIENT_ID, requireVerifiedEmail: true });
17757
+ const flow = await startGoogleFlow(strictApp, 'google-unverified');
17758
+
17759
+ const res = await googleCallback(
17760
+ strictApp,
17761
+ googleCredential(googleAccount(flow.nonce, { email_verified: false })),
17762
+ );
17763
+
17764
+ expect(res.status).toBe(403);
17765
+ expect((await res.text()).includes('email_not_verified')).toBe(true);
17766
+ });
17767
+
17768
+ // The persistent (JsonStoreBackend) stores carry the same contract as the
17769
+ // in-memory ones: the nonce is stored and consumed there, and the Google
17770
+ // user is provisioned under its own key prefix.
17771
+ it('should provision the Google user through the JSON store backend as well', async () => {
17772
+ const values = new Map<string, unknown>();
17773
+ const backend: JsonStoreBackend = {
17774
+ async get<T>(key: string): Promise<T | null> {
17775
+ return (values.get(key) as T | undefined) ?? null;
17776
+ },
17777
+ async put<T>(key: string, value: T): Promise<void> {
17778
+ values.set(key, value);
17779
+ },
17780
+ async delete(key: string): Promise<void> {
17781
+ values.delete(key);
17782
+ },
17783
+ async list<T>(prefix: string): Promise<Array<{ key: string; value: T }>> {
17784
+ return [...values.entries()]
17785
+ .filter(([key]) => key.startsWith(prefix))
17786
+ .map(([key, value]) => ({ key, value: value as T }));
17787
+ },
17788
+ };
17789
+ const jsonApp = createApp({
17790
+ signingKeyProvider,
17791
+ clientResolver: createInMemoryClientResolver(testClients),
17792
+ config: { googleLogin: { clientId: GOOGLE_CLIENT_ID } },
17793
+ googleIdTokenVerifier: fakeGoogleVerifier,
17794
+ storage: createJsonProviderStores(backend),
17795
+ });
17796
+ const flow = await startGoogleFlow(jsonApp, 'google-json-store');
17797
+
17798
+ const res = await googleCallback(jsonApp, googleCredential(googleAccount(flow.nonce)));
17799
+ const readerStores = createJsonProviderStores(backend);
17800
+
17801
+ expect(res.status).toBe(302);
17802
+ expect(await readerStores.userStore.getClaims('google:' + GOOGLE_SUB)).toEqual({
17803
+ sub: 'google:' + GOOGLE_SUB,
17804
+ name: 'John Smith',
17805
+ given_name: 'John',
17806
+ family_name: 'Smith',
17807
+ picture: 'https://lh3.googleusercontent.com/a/photo',
17808
+ email: 'jsmith@example.com',
17809
+ email_verified: true,
17810
+ });
17811
+ // The nonce record was consumed on first use.
17812
+ expect([...values.keys()].filter((key) => key.startsWith('google-login-nonce:'))).toEqual([]);
17813
+ });
17814
+ });
17815
+ `;
17816
+ }
17817
+ export function rpInitiatedLogoutConformanceBlock(features) {
17818
+ if (!features.rpInitiatedLogout)
17819
+ return '';
17820
+ return `
17821
+ // EXPERIMENTAL — OpenID Connect RP-Initiated Logout 1.0. Generated because
17822
+ // this provider was created with --enable rp-initiated-logout. These tests
17823
+ // pin the contract the repository guarantees for the generated
17824
+ // end_session_endpoint: change the behavior and they fail, which is how a
17825
+ // customized OP learns it drifted.
17826
+ describe('RP-Initiated Logout (RP-Initiated Logout 1.0)', () => {
17827
+ // RFC 7636 Appendix B example PKCE pair (verifier -> its S256 challenge).
17828
+ const LOGOUT_PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
17829
+ const LOGOUT_PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
17830
+ // A logout-specific registered return URI — deliberately NOT the authorize
17831
+ // REDIRECT_URI, because §3 defines post_logout_redirect_uris as its own
17832
+ // registry.
17833
+ const POST_LOGOUT_URI = 'http://localhost:3000/logged-out';
17834
+ const CLEARED_SESSION_COOKIE =
17835
+ 'session_id=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0';
17836
+
17837
+ // The generated settings object is the §3 registry; register the test
17838
+ // client's return URI once for every test in this block.
17839
+ rpInitiatedLogoutConfig.postLogoutRedirectUris = { 'c-conf': [POST_LOGOUT_URI] };
17840
+
17841
+ // Pure helpers: they fetch and parse only. Every assertion lives in an it().
17842
+ function relativeFrom(location: string | null): string {
17843
+ const url = new URL(location ?? '', 'http://localhost');
17844
+ return url.pathname + url.search;
17845
+ }
17846
+
17847
+ function csrfFrom(html: string): string {
17848
+ return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
17849
+ }
17850
+
17851
+ // Drive authorize -> login -> consent -> token and hand back the browser
17852
+ // session cookie plus the ID Token (the id_token_hint of the logout tests).
17853
+ async function loginSession(): Promise<{ idToken: string; sessionCookie: string }> {
17854
+ const authorizeRes = await app.request(
17855
+ '/authorize?response_type=code&client_id=c-conf' +
17856
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
17857
+ '&scope=openid&state=logout-flow&nonce=logout-nonce&prompt=consent' +
17858
+ '&code_challenge=' + LOGOUT_PKCE_CHALLENGE + '&code_challenge_method=S256',
17859
+ );
17860
+ const loginPath = relativeFrom(authorizeRes.headers.get('Location'));
17861
+ const bindingCookie = (authorizeRes.headers.get('Set-Cookie') ?? '').split(';')[0] ?? '';
17862
+ const transactionId =
17863
+ new URL(loginPath, 'http://localhost').searchParams.get('transaction_id') ?? '';
17864
+
17865
+ const loginGet = await app.request(loginPath, { headers: { Cookie: bindingCookie } });
17866
+ const loginRes = await app.request('/login', {
17867
+ method: 'POST',
17868
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: bindingCookie },
17869
+ body: new URLSearchParams({
17870
+ transaction_id: transactionId,
17871
+ csrf_token: csrfFrom(await loginGet.text()),
17872
+ username: 'testuser',
17873
+ password: 'password',
17874
+ }).toString(),
17875
+ });
17876
+ const sessionCookie =
17877
+ (loginRes.headers.get('Set-Cookie') ?? '').match(/session_id=[^;,]+/)?.[0] ?? '';
17878
+ const cookies = bindingCookie ? bindingCookie + '; ' + sessionCookie : sessionCookie;
17879
+
17880
+ const consentPath = relativeFrom(loginRes.headers.get('Location'));
17881
+ const consentGet = await app.request(consentPath, { headers: { Cookie: cookies } });
17882
+ const consentRes = await app.request('/consent', {
17883
+ method: 'POST',
17884
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookies },
17885
+ body: new URLSearchParams({
17886
+ transaction_id: transactionId,
17887
+ csrf_token: csrfFrom(await consentGet.text()),
17888
+ action: 'approve',
17889
+ }).toString(),
17890
+ });
17891
+ const code =
17892
+ new URL(consentRes.headers.get('Location') ?? '', 'http://localhost')
17893
+ .searchParams.get('code') ?? '';
17894
+
17895
+ const tokenRes = await app.request('/token', {
17896
+ method: 'POST',
17897
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
17898
+ body: new URLSearchParams({
17899
+ grant_type: 'authorization_code',
17900
+ code,
17901
+ redirect_uri: REDIRECT_URI,
17902
+ client_id: 'c-conf',
17903
+ client_secret: 's',
17904
+ code_verifier: LOGOUT_PKCE_VERIFIER,
17905
+ }).toString(),
17906
+ });
17907
+ const idToken = ((await tokenRes.json()) as { id_token?: string }).id_token ?? '';
17908
+ return { idToken, sessionCookie };
17909
+ }
17910
+
17911
+ // prompt=none is the observable session probe (OIDC Core 1.0 §3.1.2.1): a
17912
+ // live session answers with a code, a dead one with error=login_required.
17913
+ async function promptNoneProbe(
17914
+ sessionCookie: string,
17915
+ ): Promise<{ error: string | null; hasCode: boolean }> {
17916
+ const res = await app.request(
17917
+ '/authorize?response_type=code&client_id=c-conf' +
17918
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) +
17919
+ '&scope=openid&state=logout-probe&prompt=none' +
17920
+ '&code_challenge=' + LOGOUT_PKCE_CHALLENGE + '&code_challenge_method=S256',
17921
+ { headers: { Cookie: sessionCookie } },
17922
+ );
17923
+ const callback = new URL(res.headers.get('Location') ?? '', 'http://localhost');
17924
+ return {
17925
+ error: callback.searchParams.get('error'),
17926
+ hasCode: callback.searchParams.get('code') !== null,
17927
+ };
17928
+ }
17929
+
17930
+ it('should advertise end_session_endpoint in discovery metadata', async () => {
17931
+ const res = await app.request('/.well-known/openid-configuration');
17932
+
17933
+ expect(res.status).toBe(200);
17934
+ const metadata = await res.json();
17935
+ expect(metadata.end_session_endpoint).toBe('http://localhost:3000/logout');
17936
+ });
17937
+
17938
+ // §2 + §3: a valid hint for the current session logs out at once, the
17939
+ // session cookie and store entry are destroyed together, and the browser
17940
+ // returns to the registered URI with state appended.
17941
+ it('should log out immediately and redirect with state for a valid hint and registered URI', async () => {
17942
+ const { idToken, sessionCookie } = await loginSession();
17943
+
17944
+ const res = await app.request(
17945
+ '/logout?id_token_hint=' + encodeURIComponent(idToken) +
17946
+ '&post_logout_redirect_uri=' + encodeURIComponent(POST_LOGOUT_URI) +
17947
+ '&state=af0ifjsldkj',
17948
+ { headers: { Cookie: sessionCookie } },
17949
+ );
17950
+
17951
+ expect(res.status).toBe(302);
17952
+ expect(res.headers.get('Location')).toBe(POST_LOGOUT_URI + '?state=af0ifjsldkj');
17953
+ expect(res.headers.get('Set-Cookie')).toBe(CLEARED_SESSION_COOKIE);
17954
+ expect(await promptNoneProbe(sessionCookie)).toEqual({
17955
+ error: 'login_required',
17956
+ hasCode: false,
17957
+ });
17958
+ });
17959
+
17960
+ // §2 MUST: the end_session_endpoint accepts POST with a form body exactly
17961
+ // like GET with a query.
17962
+ it('should accept the logout request as a form POST', async () => {
17963
+ const { idToken, sessionCookie } = await loginSession();
17964
+
17965
+ const res = await app.request('/logout', {
17966
+ method: 'POST',
17967
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: sessionCookie },
17968
+ body: new URLSearchParams({
17969
+ id_token_hint: idToken,
17970
+ post_logout_redirect_uri: POST_LOGOUT_URI,
17971
+ state: 'post-body-state',
17972
+ }).toString(),
17973
+ });
17974
+
17975
+ expect(res.status).toBe(302);
17976
+ expect(res.headers.get('Location')).toBe(POST_LOGOUT_URI + '?state=post-body-state');
17977
+ });
17978
+
17979
+ // §2 MUST + §7: without a valid hint nothing is deleted — the screen asks
17980
+ // first, so a planted <img src="/logout"> cannot end the victim's session.
17981
+ it('should show the confirmation screen and keep the session when the hint is absent', async () => {
17982
+ const { sessionCookie } = await loginSession();
17983
+
17984
+ const res = await app.request('/logout', { headers: { Cookie: sessionCookie } });
17985
+
17986
+ expect(res.status).toBe(200);
17987
+ expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
17988
+ const html = await res.text();
17989
+ expect(html.includes('action="/logout/approve"')).toBe(true);
17990
+ expect((res.headers.get('Set-Cookie') ?? '').startsWith('oidc_logout_confirm=')).toBe(true);
17991
+ expect(await promptNoneProbe(sessionCookie)).toEqual({ error: null, hasCode: true });
17992
+ });
17993
+
17994
+ it('should delete the session when the confirmation is approved with the paired cookie and token', async () => {
17995
+ const { sessionCookie } = await loginSession();
17996
+ const confirmRes = await app.request('/logout', { headers: { Cookie: sessionCookie } });
17997
+ const confirmCookie =
17998
+ (confirmRes.headers.get('Set-Cookie') ?? '').match(/oidc_logout_confirm=[^;,]+/)?.[0] ?? '';
17999
+ const csrfToken = csrfFrom(await confirmRes.text());
18000
+
18001
+ const res = await app.request('/logout/approve', {
18002
+ method: 'POST',
18003
+ headers: {
18004
+ 'Content-Type': 'application/x-www-form-urlencoded',
18005
+ Cookie: sessionCookie + '; ' + confirmCookie,
18006
+ },
18007
+ body: new URLSearchParams({ csrf_token: csrfToken }).toString(),
18008
+ });
18009
+
18010
+ expect(res.status).toBe(200);
18011
+ expect((res.headers.get('Set-Cookie') ?? '').includes(CLEARED_SESSION_COOKIE)).toBe(true);
18012
+ expect(await promptNoneProbe(sessionCookie)).toEqual({
18013
+ error: 'login_required',
18014
+ hasCode: false,
18015
+ });
18016
+ });
18017
+
18018
+ // The hidden token alone is not a defense: without the HttpOnly cookie the
18019
+ // approve POST must refuse to delete anything (forged cross-site POST).
18020
+ it('should reject an approve POST without the confirmation cookie and keep the session', async () => {
18021
+ const { sessionCookie } = await loginSession();
18022
+ const confirmRes = await app.request('/logout', { headers: { Cookie: sessionCookie } });
18023
+ const csrfToken = csrfFrom(await confirmRes.text());
18024
+
18025
+ const res = await app.request('/logout/approve', {
18026
+ method: 'POST',
18027
+ headers: {
18028
+ 'Content-Type': 'application/x-www-form-urlencoded',
18029
+ Cookie: sessionCookie,
18030
+ },
18031
+ body: new URLSearchParams({ csrf_token: csrfToken }).toString(),
18032
+ });
18033
+
18034
+ expect(res.status).toBe(400);
18035
+ expect(await promptNoneProbe(sessionCookie)).toEqual({ error: null, hasCode: true });
18036
+ });
18037
+
18038
+ // §3 MUST NOT: an unregistered URI is never redirected to. The logout
18039
+ // itself still happens (the hint was valid) — the browser just stays on
18040
+ // the completed page and state is not echoed anywhere.
18041
+ it('should show the completed page instead of redirecting to an unregistered URI', async () => {
18042
+ const { idToken, sessionCookie } = await loginSession();
18043
+
18044
+ const res = await app.request(
18045
+ '/logout?id_token_hint=' + encodeURIComponent(idToken) +
18046
+ '&post_logout_redirect_uri=' + encodeURIComponent('https://attacker.example/out') +
18047
+ '&state=leak-probe',
18048
+ { headers: { Cookie: sessionCookie } },
18049
+ );
18050
+
18051
+ expect(res.status).toBe(200);
18052
+ expect(res.headers.get('Location')).toBe(null);
18053
+ expect((await res.text()).includes('leak-probe')).toBe(false);
18054
+ expect(await promptNoneProbe(sessionCookie)).toEqual({
18055
+ error: 'login_required',
18056
+ hasCode: false,
18057
+ });
18058
+ });
18059
+
18060
+ // §2 MUST: a client_id parameter that mismatches the hint audience voids
18061
+ // the hint — confirmation screen, session intact, no redirect.
18062
+ it('should fall back to the confirmation screen when client_id mismatches the hint audience', async () => {
18063
+ const { idToken, sessionCookie } = await loginSession();
18064
+
18065
+ const res = await app.request(
18066
+ '/logout?id_token_hint=' + encodeURIComponent(idToken) +
18067
+ '&client_id=c-public' +
18068
+ '&post_logout_redirect_uri=' + encodeURIComponent(POST_LOGOUT_URI),
18069
+ { headers: { Cookie: sessionCookie } },
18070
+ );
18071
+
18072
+ expect(res.status).toBe(200);
18073
+ expect(res.headers.get('Location')).toBe(null);
18074
+ expect((await res.text()).includes('action="/logout/approve"')).toBe(true);
18075
+ expect(await promptNoneProbe(sessionCookie)).toEqual({ error: null, hasCode: true });
18076
+ });
18077
+ });
18078
+ `;
18079
+ }
16491
18080
  export function conformanceTestTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
16492
18081
  const customScopeConformanceImport = scopes.length > 0
16493
18082
  ? `
@@ -16523,19 +18112,25 @@ import { idJagConfig } from './routes/token.js';`
16523
18112
  ? `
16524
18113
  import { cibaAuthenticationRequestStore } from './store.js';`
16525
18114
  : '';
18115
+ const rpInitiatedLogoutConformanceImports = features.rpInitiatedLogout
18116
+ ? `
18117
+ import { rpInitiatedLogoutConfig } from './routes/logout.js';`
18118
+ : '';
16526
18119
  const vitestNames = features.ciba
16527
18120
  ? 'describe, it, expect, beforeAll, afterEach'
16528
18121
  : 'describe, it, expect, beforeAll';
18122
+ const googleLoginConformanceImports = googleLoginConformanceImportsBlock(features);
18123
+ const googleLoginConfigTypeImport = features.googleLogin ? ', type GoogleLoginConfig' : '';
16529
18124
  return `import { ${vitestNames} } from 'vitest';
16530
18125
  import type { SigningKeyProvider, SigningKey } from '${corePkg}';
16531
18126
  import { Hono } from 'hono';
16532
18127
  ${exportPublicJwkImport}import { createApp, validateSigningKeySet } from './app.js';
16533
18128
  import { applyOidc } from './apply.js';
16534
- import { createInMemoryClientResolver, type RegisteredClient } from './config.js';
18129
+ import { createInMemoryClientResolver, type RegisteredClient${googleLoginConfigTypeImport} } from './config.js';
16535
18130
  import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores,${onlineRefreshTokenConformanceStoreImport(features)} refreshTokenStore, transactionStore, type JsonStoreBackend } from './store.js';
16536
18131
  import { consentResolver } from './resolvers.js';
16537
18132
  import { defaultViews } from './views.js';
16538
- import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}${idJagConformanceImports}${cibaConformanceImports}${customScopeConformanceImport}
18133
+ import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}${idJagConformanceImports}${cibaConformanceImports}${rpInitiatedLogoutConformanceImports}${googleLoginConformanceImports}${customScopeConformanceImport}
16539
18134
 
16540
18135
  /**
16541
18136
  * HTTP conformance smoke tests for the generated OpenID Connect Provider.
@@ -17014,7 +18609,7 @@ ${introspectionConformanceBlock(features)}
17014
18609
  });
17015
18610
  });
17016
18611
  });
17017
- ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${internalRedirectOriginConformanceBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${onlineRefreshTokenConformanceBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${idJagConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${cibaConformanceBlock(features)}${jarmConformanceBlock(features)}${jwtIntrospectionResponseConformanceBlock(features)}${consentDecisionConformanceBlock()}${customScopeConformanceBlock(scopes)}});
18612
+ ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${internalRedirectOriginConformanceBlock()}${endpointBehaviorConformanceBlock(features, true)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${onlineRefreshTokenConformanceBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${idJagConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${cibaConformanceBlock(features)}${jarmConformanceBlock(features)}${jwtIntrospectionResponseConformanceBlock(features)}${rpInitiatedLogoutConformanceBlock(features)}${googleLoginConformanceBlock(features)}${consentDecisionConformanceBlock()}${customScopeConformanceBlock(scopes)}});
17018
18613
  `;
17019
18614
  }
17020
18615
  //# sourceMappingURL=templates.js.map