@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.
- package/README.md +55 -5
- package/dist/features.d.ts +5 -1
- package/dist/features.d.ts.map +1 -1
- package/dist/features.js +17 -2
- package/dist/features.js.map +1 -1
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +17 -1
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +10 -0
- package/dist/frameworks/hono/pages.d.ts.map +1 -0
- package/dist/frameworks/hono/pages.js +806 -0
- package/dist/frameworks/hono/pages.js.map +1 -0
- package/dist/frameworks/hono/templates.d.ts +6 -1
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +2307 -712
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/web-standard/templates.d.ts +1 -1
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +179 -30
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 {
|
|
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',
|
|
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 {
|
|
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',
|
|
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 {
|
|
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 {
|
|
157
|
-
import {
|
|
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
|
-
|
|
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',
|
|
355
|
-
app.route('/consent',
|
|
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
|
|
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
|
|
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
|
|
2280
|
-
?
|
|
2281
|
-
|
|
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
|
-
|
|
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
|
|
2600
|
-
|
|
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
|
|
2905
|
+
return { kind: 'authorization_response', location: redirectUrl.toString() };`;
|
|
2614
2906
|
const ssoSuccessRedirect = features.jarm
|
|
2615
|
-
? ` return
|
|
2616
|
-
|
|
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
|
|
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
|
|
2641
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
2787
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3114
|
-
//
|
|
3115
|
-
|
|
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
|
-
//
|
|
3129
|
-
|
|
3130
|
-
//
|
|
3131
|
-
|
|
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.
|
|
3140
|
-
//
|
|
3141
|
-
//
|
|
3142
|
-
//
|
|
3143
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
3598
|
-
*
|
|
3599
|
-
*
|
|
3600
|
-
*
|
|
3601
|
-
*
|
|
3602
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3710
|
-
* cookie this
|
|
3978
|
+
* first answer that may carry a csrf_token. Everything downstream requires the
|
|
3979
|
+
* cookie this outcome sets.
|
|
3711
3980
|
*/
|
|
3712
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
4009
|
+
cookies: [cookie],
|
|
4010
|
+
};
|
|
3744
4011
|
}
|
|
3745
4012
|
|
|
3746
|
-
return
|
|
4013
|
+
return {
|
|
4014
|
+
kind: 'login',
|
|
3747
4015
|
userCode: record.userCodeDisplay,
|
|
3748
4016
|
csrfToken,
|
|
3749
|
-
|
|
3750
|
-
}
|
|
4017
|
+
cookies: [cookie],
|
|
4018
|
+
};
|
|
4019
|
+
}
|
|
3751
4020
|
|
|
3752
4021
|
/**
|
|
3753
|
-
* Device login
|
|
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
|
-
|
|
3761
|
-
const
|
|
3762
|
-
|
|
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
|
|
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
|
|
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
|
|
3804
|
-
error: 'Too many login attempts',
|
|
3805
|
-
statusCode: 429,
|
|
3806
|
-
}), { status: 429 });
|
|
4067
|
+
return { kind: 'locked_out' };
|
|
3807
4068
|
}
|
|
3808
|
-
return
|
|
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
|
-
//
|
|
3821
|
-
//
|
|
3822
|
-
|
|
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
|
-
|
|
3828
|
-
|
|
3829
|
-
}
|
|
4089
|
+
cookies: [buildSessionCookie(sessionId)],
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
3830
4092
|
|
|
3831
4093
|
/**
|
|
3832
|
-
* Approve or deny
|
|
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
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4152
|
+
return verificationFailure(error);
|
|
3901
4153
|
}
|
|
3902
|
-
}
|
|
4154
|
+
}
|
|
3903
4155
|
`;
|
|
3904
4156
|
}
|
|
3905
|
-
export function
|
|
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
|
|
3934
|
-
*
|
|
3935
|
-
*
|
|
3936
|
-
* This
|
|
3937
|
-
* It is backed by
|
|
3938
|
-
*
|
|
3939
|
-
* without pinning the
|
|
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
|
-
? `
|
|
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
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
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
|
|
4251
|
-
function
|
|
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
|
|
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
|
-
*
|
|
4268
|
-
*
|
|
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
|
|
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
|
|
4767
|
+
* Listing / login form (GET /ciba)
|
|
4280
4768
|
*
|
|
4281
|
-
* With an OP session:
|
|
4282
|
-
*
|
|
4283
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
4790
|
+
return {
|
|
4791
|
+
kind: 'login',
|
|
4304
4792
|
loginTransactionId: record.id,
|
|
4305
4793
|
csrfToken: record.csrfToken,
|
|
4306
|
-
|
|
4307
|
-
}
|
|
4794
|
+
cookies: [cookie],
|
|
4795
|
+
};
|
|
4796
|
+
}
|
|
4308
4797
|
|
|
4309
4798
|
/**
|
|
4310
|
-
* Sign in
|
|
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
|
-
|
|
4318
|
-
const
|
|
4319
|
-
|
|
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
|
|
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
|
|
4360
|
-
error: 'Too many login attempts',
|
|
4361
|
-
statusCode: 429,
|
|
4362
|
-
}), { status: 429 });
|
|
4843
|
+
return { kind: 'locked_out' };
|
|
4363
4844
|
}
|
|
4364
|
-
return
|
|
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
|
|
4381
|
-
// binding (it is single-use and would otherwise linger until Max-Age).
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
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
|
|
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
|
-
|
|
4397
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4459
|
-
approved: false,
|
|
4460
|
-
clientId: record?.clientId ?? '',
|
|
4461
|
-
}));
|
|
4925
|
+
return { kind: 'completed', approved: false, clientId: record?.clientId ?? '' };
|
|
4462
4926
|
} catch (error) {
|
|
4463
|
-
return
|
|
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
|
|
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
|
|
6719
|
-
*
|
|
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
|
|
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
|
-
|
|
6731
|
-
views: typeof defaultViews,
|
|
6732
|
-
): Promise<Response | undefined> {
|
|
7199
|
+
): Promise<LoginError | undefined> {
|
|
6733
7200
|
try {
|
|
6734
7201
|
await validateTransactionBinding(
|
|
6735
7202
|
transaction,
|
|
6736
|
-
parseTransactionBindingSecret(
|
|
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
|
|
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
|
|
6752
|
-
// could load
|
|
6753
|
-
// that
|
|
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
|
-
|
|
7230
|
+
const googleLoginImports = features.googleLogin
|
|
7231
|
+
? `
|
|
6777
7232
|
import {
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
7233
|
+
handleGoogleLoginRedirect,
|
|
7234
|
+
issueGoogleLoginNonce,
|
|
7235
|
+
resolveGoogleLoginSubject,
|
|
7236
|
+
GoogleLoginError,
|
|
7237
|
+
type GoogleIdTokenPayload,
|
|
7238
|
+
} from '${GOOGLE_LOGIN_PACKAGE}';
|
|
6783
7239
|
import {
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
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
|
-
*
|
|
6798
|
-
*
|
|
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
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
const
|
|
6807
|
-
|
|
6808
|
-
const
|
|
6809
|
-
|
|
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
|
-
|
|
6813
|
-
|
|
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
|
-
*
|
|
6820
|
-
*
|
|
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
|
-
|
|
6823
|
-
const
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
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
|
|
6864
|
-
//
|
|
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
|
|
6876
|
-
//
|
|
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
|
|
6880
|
-
|
|
7402
|
+
await browserSessionStore.set(sessionId, { subject, authTime });
|
|
7403
|
+
await authSessionStore.set(transactionId, { subject, authTime, sessionId });
|
|
6881
7404
|
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
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
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
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
|
-
? ` //
|
|
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
|
|
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
|
|
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
|
|
6952
|
-
*
|
|
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
|
|
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
|
-
|
|
6963
|
-
views: typeof defaultViews,
|
|
6964
|
-
): Promise<Response | undefined> {
|
|
7624
|
+
): Promise<ConsentError | undefined> {
|
|
6965
7625
|
try {
|
|
6966
7626
|
await validateTransactionBinding(
|
|
6967
7627
|
transaction,
|
|
6968
|
-
parseTransactionBindingSecret(
|
|
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
|
|
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
|
|
6984
|
-
// party holding a leaked transaction_id must not be able to read
|
|
6985
|
-
// then complete
|
|
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
|
|
7009
|
-
?
|
|
7010
|
-
|
|
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
|
|
7046
|
-
* (jarmResponseMode). This
|
|
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
|
|
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
|
-
|
|
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
|
|
7125
|
-
|
|
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
|
-
|
|
7779
|
+
return { kind: 'authorization_response', location: redirectUrl.toString(), cookies: ${finishedCookies} };
|
|
7141
7780
|
}`;
|
|
7142
|
-
const
|
|
7143
|
-
?
|
|
7144
|
-
return
|
|
7145
|
-
|
|
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
|
-
|
|
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
|
|
7158
|
-
return
|
|
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,${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
7179
|
-
*
|
|
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
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
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
|
|
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
|
-
*
|
|
7201
|
-
* Processes the consent decision.
|
|
7888
|
+
* POST /consent: record the decision and build the authorization response.
|
|
7202
7889
|
*/
|
|
7203
|
-
|
|
7204
|
-
const
|
|
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
|
-
${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
${
|
|
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 {
|
|
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',
|
|
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 {
|
|
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',
|
|
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 {
|
|
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 {
|
|
7402
|
-
import {
|
|
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
|
-
|
|
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',
|
|
7651
|
-
app.route('/consent',
|
|
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 —
|
|
7763
|
-
*
|
|
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
|
|
8342
|
-
*
|
|
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
|
-
|
|
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
|
|
9645
|
-
// page is delivered as a text/html Response through
|
|
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
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
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' }
|
|
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
|