@maronn-openid-connect/cli 0.7.0 → 0.8.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 +83 -7
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +18 -9
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +7 -6
- package/dist/frameworks/hono/pages.d.ts.map +1 -1
- package/dist/frameworks/hono/pages.js +62 -44
- package/dist/frameworks/hono/pages.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +19 -6
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +439 -304
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/hono/views.d.ts +3 -0
- package/dist/frameworks/hono/views.d.ts.map +1 -0
- package/dist/frameworks/hono/views.js +458 -0
- package/dist/frameworks/hono/views.js.map +1 -0
- package/dist/frameworks/nextjs/authorize.d.ts +3 -0
- package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
- package/dist/frameworks/nextjs/authorize.js +757 -0
- package/dist/frameworks/nextjs/authorize.js.map +1 -0
- package/dist/frameworks/nextjs/ciba.d.ts +8 -0
- package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
- package/dist/frameworks/nextjs/ciba.js +591 -0
- package/dist/frameworks/nextjs/ciba.js.map +1 -0
- package/dist/frameworks/nextjs/conformance.d.ts +3 -0
- package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
- package/dist/frameworks/nextjs/conformance.js +2447 -0
- package/dist/frameworks/nextjs/conformance.js.map +1 -0
- package/dist/frameworks/nextjs/device.d.ts +8 -0
- package/dist/frameworks/nextjs/device.d.ts.map +1 -0
- package/dist/frameworks/nextjs/device.js +662 -0
- package/dist/frameworks/nextjs/device.js.map +1 -0
- package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
- package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
- package/dist/frameworks/nextjs/endpoints.js +673 -0
- package/dist/frameworks/nextjs/endpoints.js.map +1 -0
- package/dist/frameworks/nextjs/index.d.ts.map +1 -1
- package/dist/frameworks/nextjs/index.js +135 -2
- package/dist/frameworks/nextjs/index.js.map +1 -1
- package/dist/frameworks/nextjs/interaction.d.ts +13 -0
- package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
- package/dist/frameworks/nextjs/interaction.js +864 -0
- package/dist/frameworks/nextjs/interaction.js.map +1 -0
- package/dist/frameworks/nextjs/logout.d.ts +5 -0
- package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
- package/dist/frameworks/nextjs/logout.js +271 -0
- package/dist/frameworks/nextjs/logout.js.map +1 -0
- package/dist/frameworks/nextjs/metadata.d.ts +6 -0
- package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
- package/dist/frameworks/nextjs/metadata.js +339 -0
- package/dist/frameworks/nextjs/metadata.js.map +1 -0
- package/dist/frameworks/nextjs/provider.d.ts +6 -0
- package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
- package/dist/frameworks/nextjs/provider.js +797 -0
- package/dist/frameworks/nextjs/provider.js.map +1 -0
- package/dist/frameworks/nextjs/token.d.ts +7 -0
- package/dist/frameworks/nextjs/token.d.ts.map +1 -0
- package/dist/frameworks/nextjs/token.js +1337 -0
- package/dist/frameworks/nextjs/token.js.map +1 -0
- package/dist/frameworks/web-standard/templates.d.ts +1 -13
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +12 -1296
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,2447 @@
|
|
|
1
|
+
import { DEFAULT_FEATURES } from '../../features.js';
|
|
2
|
+
import { nextJsSupportedGrantTypes, nextJsSupportedScopes } from './metadata.js';
|
|
3
|
+
export function nextJsConformanceTestTemplate(features = DEFAULT_FEATURES, scopes = []) {
|
|
4
|
+
return [
|
|
5
|
+
harnessSection(features, scopes),
|
|
6
|
+
discoverySection(features, scopes),
|
|
7
|
+
authorizationEndpointSection(features),
|
|
8
|
+
internalRedirectOriginSection(),
|
|
9
|
+
loginSection(),
|
|
10
|
+
consentSection(),
|
|
11
|
+
transactionBindingSection(features),
|
|
12
|
+
tokenEndpointSection(features),
|
|
13
|
+
userInfoSection(),
|
|
14
|
+
sessionSection(),
|
|
15
|
+
features.introspection ? introspectionSection() : '',
|
|
16
|
+
features.revocation ? revocationSection() : '',
|
|
17
|
+
scopes.length > 0 ? customScopesSection(scopes) : '',
|
|
18
|
+
features.par ? parSection() : '',
|
|
19
|
+
features.jarm ? jarmSection() : '',
|
|
20
|
+
features.tokenExchange ? tokenExchangeSection() : '',
|
|
21
|
+
features.idJag ? idJagSection() : '',
|
|
22
|
+
features.deviceAuthorizationGrant ? deviceAuthorizationSection() : '',
|
|
23
|
+
features.ciba ? cibaSection() : '',
|
|
24
|
+
features.jwtIntrospectionResponse && features.introspection
|
|
25
|
+
? jwtIntrospectionResponseSection()
|
|
26
|
+
: '',
|
|
27
|
+
features.rpInitiatedLogout ? rpInitiatedLogoutSection() : '',
|
|
28
|
+
features.googleLogin ? googleLoginSection() : '',
|
|
29
|
+
]
|
|
30
|
+
.filter((section) => section !== '')
|
|
31
|
+
.join('\n');
|
|
32
|
+
}
|
|
33
|
+
function harnessSection(features, scopes) {
|
|
34
|
+
const grantTypes = nextJsSupportedGrantTypes(features)
|
|
35
|
+
.map((grantType) => ` '${grantType}',`)
|
|
36
|
+
.join('\n');
|
|
37
|
+
const extraClients = [
|
|
38
|
+
...(features.deviceAuthorizationGrant
|
|
39
|
+
? [` // RFC 8628 §3.4: a second device-grant client, to prove a device_code is
|
|
40
|
+
// refused when another client presents it.
|
|
41
|
+
{
|
|
42
|
+
clientId: 'conformance-device-client',
|
|
43
|
+
clientSecret: 'conformance-device-secret',
|
|
44
|
+
redirectUris: [redirectUri],
|
|
45
|
+
clientType: 'confidential',
|
|
46
|
+
grantTypes: ['urn:ietf:params:oauth:grant-type:device_code'],
|
|
47
|
+
tokenEndpointAuthMethod: 'client_secret_post',
|
|
48
|
+
responseTypes: ['code'],
|
|
49
|
+
},`]
|
|
50
|
+
: []),
|
|
51
|
+
...(features.ciba
|
|
52
|
+
? [` // CIBA Core 1.0 §11: a second CIBA client, to prove an auth_req_id is
|
|
53
|
+
// refused when another client presents it.
|
|
54
|
+
{
|
|
55
|
+
clientId: 'conformance-ciba-client',
|
|
56
|
+
clientSecret: 'conformance-ciba-secret',
|
|
57
|
+
redirectUris: [redirectUri],
|
|
58
|
+
clientType: 'confidential',
|
|
59
|
+
grantTypes: ['urn:openid:params:grant-type:ciba'],
|
|
60
|
+
tokenEndpointAuthMethod: 'client_secret_post',
|
|
61
|
+
responseTypes: ['code'],
|
|
62
|
+
},`]
|
|
63
|
+
: []),
|
|
64
|
+
]
|
|
65
|
+
.map((client) => '\n' + client)
|
|
66
|
+
.join('');
|
|
67
|
+
const googleEnvNames = features.googleLogin
|
|
68
|
+
? `
|
|
69
|
+
'GOOGLE_CLIENT_ID',
|
|
70
|
+
'GOOGLE_HOSTED_DOMAIN',`
|
|
71
|
+
: '';
|
|
72
|
+
const googleEnv = features.googleLogin
|
|
73
|
+
? `
|
|
74
|
+
// EXTENSION (google-login): configures config.googleLogin, so the login page
|
|
75
|
+
// renders the Google button and /login/google accepts callbacks.
|
|
76
|
+
process.env.GOOGLE_CLIENT_ID = 'conformance.apps.googleusercontent.com';`
|
|
77
|
+
: '';
|
|
78
|
+
const googleMock = features.googleLogin
|
|
79
|
+
? `
|
|
80
|
+
// EXTENSION (google-login): stands in for google-auth-library, which would
|
|
81
|
+
// fetch Google's keys. The "ID token" a test posts is the JSON payload Google
|
|
82
|
+
// would have signed, so every test decides exactly what Google asserts.
|
|
83
|
+
vi.mock('@maronn-openid-connect/google-login', async (importOriginal) => ({
|
|
84
|
+
...(await importOriginal<typeof import('@maronn-openid-connect/google-login')>()),
|
|
85
|
+
getDefaultGoogleIdTokenVerifier: () => ({
|
|
86
|
+
verify: async (idToken: string) => JSON.parse(idToken),
|
|
87
|
+
}),
|
|
88
|
+
}));
|
|
89
|
+
`
|
|
90
|
+
: '';
|
|
91
|
+
const routeImports = [
|
|
92
|
+
...(features.introspection ? [`import * as introspect from '../introspect/route';`] : []),
|
|
93
|
+
...(features.revocation ? [`import * as revoke from '../revoke/route';`] : []),
|
|
94
|
+
...(features.par
|
|
95
|
+
? [`import * as par from '../par/route';`, `import { parConfig } from '../par/config';`]
|
|
96
|
+
: []),
|
|
97
|
+
...(features.idJag ? [`import { idJagConfig } from '../token/id-jag';`] : []),
|
|
98
|
+
...(features.deviceAuthorizationGrant
|
|
99
|
+
? [
|
|
100
|
+
`import * as deviceAuthorization from '../device_authorization/route';`,
|
|
101
|
+
`import * as device from '../device/route';`,
|
|
102
|
+
`import * as deviceLogin from '../device/login/route';`,
|
|
103
|
+
`import * as deviceApprove from '../device/approve/route';`,
|
|
104
|
+
]
|
|
105
|
+
: []),
|
|
106
|
+
...(features.ciba
|
|
107
|
+
? [
|
|
108
|
+
`import * as backchannelAuthentication from '../backchannel_authentication/route';`,
|
|
109
|
+
`import * as ciba from '../ciba/route';`,
|
|
110
|
+
`import * as cibaLogin from '../ciba/login/route';`,
|
|
111
|
+
`import * as cibaApprove from '../ciba/approve/route';`,
|
|
112
|
+
]
|
|
113
|
+
: []),
|
|
114
|
+
...(features.rpInitiatedLogout
|
|
115
|
+
? [
|
|
116
|
+
`import * as logout from '../logout/route';`,
|
|
117
|
+
`import * as logoutApprove from '../logout/approve/route';`,
|
|
118
|
+
`import { rpInitiatedLogoutConfig } from '../logout/config';`,
|
|
119
|
+
]
|
|
120
|
+
: []),
|
|
121
|
+
...(features.googleLogin ? [`import * as googleLogin from '../login/google/route';`] : []),
|
|
122
|
+
];
|
|
123
|
+
const extraRouteImports = routeImports.length > 0 ? '\n' + routeImports.join('\n') : '';
|
|
124
|
+
const scopesImport = scopes.length > 0 ? `\nimport { RESTRICTED_SCOPE_SUBJECTS } from './scopes';` : '';
|
|
125
|
+
return `/**
|
|
126
|
+
* Contract test for the generated OpenID Provider.
|
|
127
|
+
*
|
|
128
|
+
* It drives the OP the way Next.js does — Route Handlers with a NextRequest,
|
|
129
|
+
* pages (React Server Components) rendered to HTML, Server Actions with the
|
|
130
|
+
* submitted FormData — and pins what the specifications require of each
|
|
131
|
+
* answer. No server is started: run it with \`vitest run\`.
|
|
132
|
+
*
|
|
133
|
+
* Next.js provides some functions only inside a real request, so they are
|
|
134
|
+
* replaced here:
|
|
135
|
+
* - cookies() (next/headers) reads and writes the cookie jar of the simulated
|
|
136
|
+
* browser making the call — the same jar its Route Handler requests send and
|
|
137
|
+
* update, so pages, Server Actions and Route Handlers see one browser.
|
|
138
|
+
* - redirect() and notFound() (next/navigation) throw a signal, which the
|
|
139
|
+
* harness turns into what Next.js would answer: a redirect, or a 404.
|
|
140
|
+
*
|
|
141
|
+
* provider.ts reads its configuration from the environment on first import, so
|
|
142
|
+
* the environment is pinned in vi.hoisted(), before any import runs: a fixed
|
|
143
|
+
* issuer, the clients this test registers and an in-memory SQLite store.
|
|
144
|
+
*/
|
|
145
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
146
|
+
import { createElement, type ReactElement } from 'react';
|
|
147
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
148
|
+
import { NextRequest } from 'next/server';
|
|
149
|
+
|
|
150
|
+
const { ISSUER, REDIRECT_URI, harness } = vi.hoisted(() => {
|
|
151
|
+
const issuer = 'http://localhost:3000';
|
|
152
|
+
const redirectUri = 'https://rp.example.com/callback';
|
|
153
|
+
const clients = [
|
|
154
|
+
{
|
|
155
|
+
clientId: 'conformance-client',
|
|
156
|
+
clientSecret: 'conformance-secret',
|
|
157
|
+
redirectUris: [redirectUri],
|
|
158
|
+
clientType: 'confidential',
|
|
159
|
+
// Registered for every grant this OP supports.
|
|
160
|
+
grantTypes: [
|
|
161
|
+
${grantTypes}
|
|
162
|
+
],
|
|
163
|
+
tokenEndpointAuthMethod: 'client_secret_post',
|
|
164
|
+
responseTypes: ['code'],
|
|
165
|
+
},
|
|
166
|
+
// RFC 6749 §2.3.1: a client that authenticates with HTTP Basic.
|
|
167
|
+
{
|
|
168
|
+
clientId: 'conformance-basic-client',
|
|
169
|
+
clientSecret: 'conformance-basic-secret',
|
|
170
|
+
redirectUris: [redirectUri],
|
|
171
|
+
clientType: 'confidential',
|
|
172
|
+
grantTypes: ['authorization_code'],
|
|
173
|
+
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
174
|
+
responseTypes: ['code'],
|
|
175
|
+
},
|
|
176
|
+
// OAuth 2.1 §2.1: a public client, which has no secret.
|
|
177
|
+
{
|
|
178
|
+
clientId: 'conformance-public-client',
|
|
179
|
+
redirectUris: [redirectUri],
|
|
180
|
+
clientType: 'public',
|
|
181
|
+
grantTypes: ['authorization_code'],
|
|
182
|
+
tokenEndpointAuthMethod: 'none',
|
|
183
|
+
responseTypes: ['code'],
|
|
184
|
+
},${extraClients}
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
process.env.OIDC_ISSUER = issuer;
|
|
188
|
+
process.env.OIDC_CLIENTS_JSON = JSON.stringify(clients);
|
|
189
|
+
process.env.OIDC_SIGNING_KEY_ID = 'conformance-key';
|
|
190
|
+
process.env.OIDC_SQLITE_PATH = ':memory:';
|
|
191
|
+
for (const name of [
|
|
192
|
+
'ISSUER',
|
|
193
|
+
'OIDC_CORS_ORIGINS',
|
|
194
|
+
'OIDC_ALLOW_NON_PKCE_AUTHORIZATION_CODE_FLOW',
|
|
195
|
+
'OIDC_ALLOW_UNSIGNED_REQUEST_OBJECT',
|
|
196
|
+
'UPSTASH_REDIS_REST_URL',
|
|
197
|
+
'UPSTASH_REDIS_REST_TOKEN',
|
|
198
|
+
'VERCEL',${googleEnvNames}
|
|
199
|
+
]) {
|
|
200
|
+
delete process.env[name];
|
|
201
|
+
}${googleEnv}
|
|
202
|
+
|
|
203
|
+
/** What redirect() throws: the location the browser is sent to. */
|
|
204
|
+
class RedirectSignal extends Error {
|
|
205
|
+
constructor(readonly location: string) {
|
|
206
|
+
super('NEXT_REDIRECT ' + location);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What notFound() throws: Next.js answers with the not-found screen (404). */
|
|
211
|
+
class NotFoundSignal extends Error {
|
|
212
|
+
constructor() {
|
|
213
|
+
super('NEXT_HTTP_ERROR_FALLBACK;404');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ISSUER: issuer,
|
|
219
|
+
REDIRECT_URI: redirectUri,
|
|
220
|
+
harness: {
|
|
221
|
+
RedirectSignal,
|
|
222
|
+
NotFoundSignal,
|
|
223
|
+
/** The cookie jar of the browser whose call is being handled. */
|
|
224
|
+
jar: new Map<string, string>(),
|
|
225
|
+
/** Every cookies().set() call, with the attributes it asked for. */
|
|
226
|
+
cookieWrites: [] as Array<{ name: string; value: string; options: Record<string, unknown> }>,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
vi.mock('next/headers', () => ({
|
|
232
|
+
cookies: async () => ({
|
|
233
|
+
get: (name: string) => {
|
|
234
|
+
const value = harness.jar.get(name);
|
|
235
|
+
return value === undefined ? undefined : { name, value };
|
|
236
|
+
},
|
|
237
|
+
set: (name: string, value: string, options: Record<string, unknown> = {}) => {
|
|
238
|
+
harness.jar.set(name, value);
|
|
239
|
+
harness.cookieWrites.push({ name, value, options });
|
|
240
|
+
},
|
|
241
|
+
delete: (name: string) => {
|
|
242
|
+
harness.jar.delete(name);
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
vi.mock('next/navigation', () => ({
|
|
248
|
+
redirect: (location: string): never => {
|
|
249
|
+
throw new harness.RedirectSignal(location);
|
|
250
|
+
},
|
|
251
|
+
notFound: (): never => {
|
|
252
|
+
throw new harness.NotFoundSignal();
|
|
253
|
+
},
|
|
254
|
+
}));
|
|
255
|
+
${googleMock}
|
|
256
|
+
import * as discovery from '../.well-known/openid-configuration/route';
|
|
257
|
+
import * as jwks from '../.well-known/jwks.json/route';
|
|
258
|
+
import * as authorize from '../authorize/route';
|
|
259
|
+
import LoginPage from '../login/page';
|
|
260
|
+
import { loginAction } from '../login/actions';
|
|
261
|
+
import LoginNotFound from '../login/not-found';
|
|
262
|
+
import LoginError from '../login/error';
|
|
263
|
+
import ConsentPage from '../consent/page';
|
|
264
|
+
import { consentAction } from '../consent/actions';
|
|
265
|
+
import ConsentNotFound from '../consent/not-found';
|
|
266
|
+
import ConsentError from '../consent/error';
|
|
267
|
+
import OidcErrorPage from '../oidc-error/page';
|
|
268
|
+
import * as token from '../token/route';
|
|
269
|
+
import * as userinfo from '../userinfo/route';${extraRouteImports}
|
|
270
|
+
import { config, resolvers, stores } from './provider';${scopesImport}
|
|
271
|
+
|
|
272
|
+
type RouteHandler = (request: NextRequest) => Response | Promise<Response>;
|
|
273
|
+
type RequestOptions = { method?: string; headers?: HeadersInit; body?: BodyInit };
|
|
274
|
+
type Page<Query> = (props: { searchParams: Promise<Query> }) => Promise<ReactElement>;
|
|
275
|
+
type ServerAction = (formData: FormData) => Promise<void>;
|
|
276
|
+
|
|
277
|
+
/** What the browser gets from a page or a Server Action. */
|
|
278
|
+
interface Outcome {
|
|
279
|
+
status: number;
|
|
280
|
+
/** Where redirect() sent the browser. */
|
|
281
|
+
location?: string;
|
|
282
|
+
/** The rendered page. */
|
|
283
|
+
html?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One User-Agent. Its cookie jar is what Route Handlers receive in the Cookie
|
|
288
|
+
* header and what cookies() reads inside pages and Server Actions; Set-Cookie
|
|
289
|
+
* answers and cookies().set() both write back to it.
|
|
290
|
+
*/
|
|
291
|
+
class Browser {
|
|
292
|
+
readonly cookies = new Map<string, string>();
|
|
293
|
+
|
|
294
|
+
/** Send a request to a Route Handler. */
|
|
295
|
+
async request(handler: RouteHandler, url: string, options: RequestOptions = {}): Promise<Response> {
|
|
296
|
+
const headers = new Headers(options.headers);
|
|
297
|
+
if (this.cookies.size > 0) {
|
|
298
|
+
headers.set(
|
|
299
|
+
'Cookie',
|
|
300
|
+
[...this.cookies].map(([name, value]) => name + '=' + value).join('; '),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
harness.jar = this.cookies;
|
|
304
|
+
let response: Response;
|
|
305
|
+
try {
|
|
306
|
+
response = await handler(
|
|
307
|
+
new NextRequest(new URL(url, ISSUER), { method: options.method, headers, body: options.body }),
|
|
308
|
+
);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
response = answerSignal(error);
|
|
311
|
+
}
|
|
312
|
+
for (const setCookie of response.headers.getSetCookie()) {
|
|
313
|
+
this.storeCookie(setCookie);
|
|
314
|
+
}
|
|
315
|
+
return response;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** POST a form to a Route Handler. */
|
|
319
|
+
post(handler: RouteHandler, url: string, fields: Record<string, string>, headers?: HeadersInit): Promise<Response> {
|
|
320
|
+
return this.request(handler, url, { method: 'POST', headers, body: new URLSearchParams(fields) });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Open a page: its HTML, or where it redirects, or a 404 from notFound(). */
|
|
324
|
+
async open<Query>(page: Page<Query>, query: Query): Promise<Outcome> {
|
|
325
|
+
harness.jar = this.cookies;
|
|
326
|
+
try {
|
|
327
|
+
return { status: 200, html: renderToStaticMarkup(await page({ searchParams: Promise.resolve(query) })) };
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return outcomeOf(error, 307);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Render a page as this browser would see it. */
|
|
334
|
+
async render<Query>(page: Page<Query>, query: Query): Promise<string> {
|
|
335
|
+
return (await this.open(page, query)).html ?? '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Submit a form to a Server Action: where it redirects the browser (Next.js
|
|
340
|
+
* answers a Server Action's redirect with 303), or a 404 from notFound().
|
|
341
|
+
*/
|
|
342
|
+
async submit(action: ServerAction, fields: Record<string, string>): Promise<Outcome> {
|
|
343
|
+
const formData = new FormData();
|
|
344
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
345
|
+
formData.set(name, value);
|
|
346
|
+
}
|
|
347
|
+
harness.jar = this.cookies;
|
|
348
|
+
try {
|
|
349
|
+
await action(formData);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return outcomeOf(error, 303);
|
|
352
|
+
}
|
|
353
|
+
throw new Error('The Server Action returned without redirecting');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private storeCookie(setCookie: string): void {
|
|
357
|
+
const [pair = '', ...attributes] = setCookie.split(';');
|
|
358
|
+
const separator = pair.indexOf('=');
|
|
359
|
+
const name = pair.slice(0, separator).trim();
|
|
360
|
+
const expired = attributes.some((attribute) => attribute.trim().toLowerCase() === 'max-age=0');
|
|
361
|
+
if (expired) {
|
|
362
|
+
this.cookies.delete(name);
|
|
363
|
+
} else {
|
|
364
|
+
this.cookies.set(name, pair.slice(separator + 1).trim());
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** What Next.js answers when a page or a Server Action calls redirect() or notFound(). */
|
|
370
|
+
function outcomeOf(error: unknown, redirectStatus: number): Outcome {
|
|
371
|
+
if (error instanceof harness.RedirectSignal) return { status: redirectStatus, location: error.location };
|
|
372
|
+
if (error instanceof harness.NotFoundSignal) return { status: 404 };
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** What Next.js answers when a Route Handler calls redirect() or notFound(). */
|
|
377
|
+
function answerSignal(error: unknown): Response {
|
|
378
|
+
if (error instanceof harness.RedirectSignal) {
|
|
379
|
+
return new Response(null, { status: 307, headers: { Location: error.location } });
|
|
380
|
+
}
|
|
381
|
+
if (error instanceof harness.NotFoundSignal) return new Response(null, { status: 404 });
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** RFC 7636 Appendix B: the example code_verifier and its S256 code_challenge. */
|
|
386
|
+
const CODE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
387
|
+
const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
388
|
+
|
|
389
|
+
/** An authorization request of conformance-client; undefined drops a parameter. */
|
|
390
|
+
function authorizationRequest(
|
|
391
|
+
overrides: Record<string, string | undefined> = {},
|
|
392
|
+
): Record<string, string> {
|
|
393
|
+
const params: Record<string, string | undefined> = {
|
|
394
|
+
response_type: 'code',
|
|
395
|
+
client_id: 'conformance-client',
|
|
396
|
+
redirect_uri: REDIRECT_URI,
|
|
397
|
+
scope: 'openid profile',
|
|
398
|
+
state: 'conformance-state',
|
|
399
|
+
nonce: 'conformance-nonce',
|
|
400
|
+
code_challenge: CODE_CHALLENGE,
|
|
401
|
+
code_challenge_method: 'S256',
|
|
402
|
+
...overrides,
|
|
403
|
+
};
|
|
404
|
+
return Object.fromEntries(
|
|
405
|
+
Object.entries(params).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function authorizeUrl(params: Record<string, string>, origin = ISSUER): string {
|
|
410
|
+
return origin + '/authorize?' + new URLSearchParams(params).toString();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function locationOf(response: Response): string {
|
|
414
|
+
return response.headers.get('Location') ?? '';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function transactionIdOf(location: string): string {
|
|
418
|
+
return new URL(location, ISSUER).searchParams.get('transaction_id') ?? '';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function csrfTokenOf(html: string): string {
|
|
422
|
+
return html.match(/name="csrf_token" value="([^"]+)"/)?.[1] ?? '';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Start an authorization request; resolves to its transaction_id. */
|
|
426
|
+
async function startAuthorization(
|
|
427
|
+
browser: Browser,
|
|
428
|
+
params: Record<string, string> = authorizationRequest(),
|
|
429
|
+
): Promise<string> {
|
|
430
|
+
return transactionIdOf(locationOf(await browser.request(authorize.GET, authorizeUrl(params))));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Sign in on the login page of a transaction; resolves to where the browser goes next. */
|
|
434
|
+
async function logIn(browser: Browser, transactionId: string, username = 'testuser'): Promise<string> {
|
|
435
|
+
const html = await browser.render(LoginPage, { transaction_id: transactionId });
|
|
436
|
+
const outcome = await browser.submit(loginAction, {
|
|
437
|
+
transaction_id: transactionId,
|
|
438
|
+
csrf_token: csrfTokenOf(html),
|
|
439
|
+
username,
|
|
440
|
+
password: 'password',
|
|
441
|
+
});
|
|
442
|
+
return outcome.location ?? '';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Submit a consent decision from the consent page; undefined omits the action field. */
|
|
446
|
+
async function decide(browser: Browser, transactionId: string, action: string | undefined): Promise<string> {
|
|
447
|
+
const html = await browser.render(ConsentPage, { transaction_id: transactionId });
|
|
448
|
+
const fields: Record<string, string> = { transaction_id: transactionId, csrf_token: csrfTokenOf(html) };
|
|
449
|
+
const outcome = await browser.submit(consentAction, action === undefined ? fields : { ...fields, action });
|
|
450
|
+
return outcome.location ?? '';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* authorize → login → consent, the way a browser walks it; resolves to the
|
|
455
|
+
* authorization response the browser lands on. Pure data collection: it
|
|
456
|
+
* neither asserts nor branches, so every check stays in the it() blocks.
|
|
457
|
+
*/
|
|
458
|
+
async function signIn(
|
|
459
|
+
browser: Browser,
|
|
460
|
+
params: Record<string, string> = authorizationRequest(),
|
|
461
|
+
username = 'testuser',
|
|
462
|
+
): Promise<URL> {
|
|
463
|
+
const transactionId = await startAuthorization(browser, params);
|
|
464
|
+
await logIn(browser, transactionId, username);
|
|
465
|
+
return new URL(await decide(browser, transactionId, 'approve'), ISSUER);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** POST to the token endpoint as conformance-client (client_secret_post). */
|
|
469
|
+
function tokenRequest(params: Record<string, string>): Promise<Response> {
|
|
470
|
+
return new Browser().post(token.POST, '/token', {
|
|
471
|
+
client_id: 'conformance-client',
|
|
472
|
+
client_secret: 'conformance-secret',
|
|
473
|
+
...params,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function exchangeCode(code: string): Promise<Response> {
|
|
478
|
+
return tokenRequest({
|
|
479
|
+
grant_type: 'authorization_code',
|
|
480
|
+
code,
|
|
481
|
+
redirect_uri: REDIRECT_URI,
|
|
482
|
+
code_verifier: CODE_VERIFIER,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
interface TokenResponse {
|
|
487
|
+
access_token: string;
|
|
488
|
+
token_type: string;
|
|
489
|
+
expires_in: number;
|
|
490
|
+
id_token: string;
|
|
491
|
+
refresh_token?: string;
|
|
492
|
+
scope: string;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Sign in and redeem the code; resolves to the token response. */
|
|
496
|
+
async function issueTokens(
|
|
497
|
+
browser = new Browser(),
|
|
498
|
+
params: Record<string, string> = authorizationRequest(),
|
|
499
|
+
): Promise<TokenResponse> {
|
|
500
|
+
const callback = await signIn(browser, params);
|
|
501
|
+
const response = await exchangeCode(callback.searchParams.get('code') ?? '');
|
|
502
|
+
return (await response.json()) as TokenResponse;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function userInfoRequest(accessToken: string): Promise<Response> {
|
|
506
|
+
return new Browser().request(userinfo.GET, '/userinfo', {
|
|
507
|
+
headers: { Authorization: 'Bearer ' + accessToken },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function basicAuthorization(clientId: string, clientSecret: string): string {
|
|
512
|
+
// RFC 6749 §2.3.1: both values are form-urlencoded before base64.
|
|
513
|
+
return 'Basic ' + btoa(encodeURIComponent(clientId) + ':' + encodeURIComponent(clientSecret));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function decodeJwt(jwt: string): { header: Record<string, unknown>; payload: Record<string, unknown> } {
|
|
517
|
+
const [header = '', payload = ''] = jwt.split('.');
|
|
518
|
+
return { header: decodeSegment(header), payload: decodeSegment(payload) };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function decodeSegment(segment: string): Record<string, unknown> {
|
|
522
|
+
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Verify an RS256 JWS with the key the JWKS endpoint publishes under its kid. */
|
|
526
|
+
async function verifiesWithPublishedKey(jwt: string): Promise<boolean> {
|
|
527
|
+
const [header = '', payload = '', signature = ''] = jwt.split('.');
|
|
528
|
+
const response = await new Browser().request(jwks.GET, '/.well-known/jwks.json');
|
|
529
|
+
const { keys } = (await response.json()) as { keys: Array<JsonWebKey & { kid?: string }> };
|
|
530
|
+
const jwk = keys.find((key) => key.kid === decodeSegment(header)['kid']);
|
|
531
|
+
if (!jwk) return false;
|
|
532
|
+
const key = await crypto.subtle.importKey(
|
|
533
|
+
'jwk',
|
|
534
|
+
jwk,
|
|
535
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
536
|
+
false,
|
|
537
|
+
['verify'],
|
|
538
|
+
);
|
|
539
|
+
return crypto.subtle.verify(
|
|
540
|
+
'RSASSA-PKCS1-v1_5',
|
|
541
|
+
key,
|
|
542
|
+
Buffer.from(signature, 'base64url'),
|
|
543
|
+
new TextEncoder().encode(header + '.' + payload),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
afterEach(() => {
|
|
548
|
+
vi.restoreAllMocks();
|
|
549
|
+
config.issuer = ISSUER;
|
|
550
|
+
});
|
|
551
|
+
`;
|
|
552
|
+
}
|
|
553
|
+
function discoverySection(features, scopes) {
|
|
554
|
+
const endpoints = [
|
|
555
|
+
` issuer: ISSUER,`,
|
|
556
|
+
` authorization_endpoint: ISSUER + '/authorize',`,
|
|
557
|
+
` token_endpoint: ISSUER + '/token',`,
|
|
558
|
+
` userinfo_endpoint: ISSUER + '/userinfo',`,
|
|
559
|
+
` jwks_uri: ISSUER + '/.well-known/jwks.json',`,
|
|
560
|
+
...(features.introspection ? [` introspection_endpoint: ISSUER + '/introspect',`] : []),
|
|
561
|
+
...(features.revocation ? [` revocation_endpoint: ISSUER + '/revoke',`] : []),
|
|
562
|
+
...(features.par ? [` pushed_authorization_request_endpoint: ISSUER + '/par',`] : []),
|
|
563
|
+
...(features.deviceAuthorizationGrant
|
|
564
|
+
? [` device_authorization_endpoint: ISSUER + '/device_authorization',`]
|
|
565
|
+
: []),
|
|
566
|
+
...(features.ciba
|
|
567
|
+
? [` backchannel_authentication_endpoint: ISSUER + '/backchannel_authentication',`]
|
|
568
|
+
: []),
|
|
569
|
+
...(features.rpInitiatedLogout ? [` end_session_endpoint: ISSUER + '/logout',`] : []),
|
|
570
|
+
].join('\n');
|
|
571
|
+
const list = (values) => values.map((value) => `'${value}'`).join(', ');
|
|
572
|
+
const responseModes = features.jarm ? `'query', 'query.jwt', 'jwt'` : `'query'`;
|
|
573
|
+
const absentMembers = [
|
|
574
|
+
...(features.introspection ? [] : ['introspection_endpoint']),
|
|
575
|
+
...(features.revocation ? [] : ['revocation_endpoint']),
|
|
576
|
+
...(features.par ? [] : ['pushed_authorization_request_endpoint']),
|
|
577
|
+
...(features.deviceAuthorizationGrant ? [] : ['device_authorization_endpoint']),
|
|
578
|
+
...(features.ciba
|
|
579
|
+
? []
|
|
580
|
+
: ['backchannel_authentication_endpoint', 'backchannel_token_delivery_modes_supported']),
|
|
581
|
+
...(features.rpInitiatedLogout ? [] : ['end_session_endpoint']),
|
|
582
|
+
...(features.jarm ? [] : ['authorization_signing_alg_values_supported']),
|
|
583
|
+
...(features.idJag
|
|
584
|
+
? []
|
|
585
|
+
: ['identity_chaining_requested_token_types_supported', 'authorization_grant_profiles_supported']),
|
|
586
|
+
...(features.introspection && features.jwtIntrospectionResponse
|
|
587
|
+
? []
|
|
588
|
+
: ['introspection_signing_alg_values_supported']),
|
|
589
|
+
];
|
|
590
|
+
const absentMembersTest = absentMembers.length > 0
|
|
591
|
+
? `
|
|
592
|
+
it('should advertise nothing of the features this OP was generated without', async () => {
|
|
593
|
+
const { metadata } = await providerMetadata();
|
|
594
|
+
const absent = [
|
|
595
|
+
${absentMembers.map((name) => ` '${name}',`).join('\n')}
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
expect(absent.filter((name) => name in metadata)).toEqual([]);
|
|
599
|
+
});
|
|
600
|
+
`
|
|
601
|
+
: '';
|
|
602
|
+
return `describe('Discovery (OIDC Discovery 1.0 §3 / §4)', () => {
|
|
603
|
+
async function providerMetadata(): Promise<{ response: Response; metadata: Record<string, unknown> }> {
|
|
604
|
+
const response = await new Browser().request(discovery.GET, '/.well-known/openid-configuration');
|
|
605
|
+
return { response, metadata: (await response.json()) as Record<string, unknown> };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
it('should publish every endpoint on the issuer', async () => {
|
|
609
|
+
const { response, metadata } = await providerMetadata();
|
|
610
|
+
|
|
611
|
+
expect(response.status).toBe(200);
|
|
612
|
+
expect(metadata).toMatchObject({
|
|
613
|
+
${endpoints}
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should advertise exactly what this OP supports', async () => {
|
|
618
|
+
const { metadata } = await providerMetadata();
|
|
619
|
+
|
|
620
|
+
expect(metadata).toMatchObject({
|
|
621
|
+
response_types_supported: ['code'],
|
|
622
|
+
response_modes_supported: [${responseModes}],
|
|
623
|
+
grant_types_supported: [${list(nextJsSupportedGrantTypes(features))}],
|
|
624
|
+
scopes_supported: [${list(nextJsSupportedScopes(features, scopes))}],
|
|
625
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
626
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
627
|
+
code_challenge_methods_supported: ['S256'],
|
|
628
|
+
authorization_response_iss_parameter_supported: true,
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
${absentMembersTest}
|
|
632
|
+
it('should let any origin read and cache the metadata', async () => {
|
|
633
|
+
const { response } = await providerMetadata();
|
|
634
|
+
|
|
635
|
+
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
|
636
|
+
expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('should publish the signing key without its private members (RFC 7517 §4)', async () => {
|
|
640
|
+
const response = await new Browser().request(jwks.GET, '/.well-known/jwks.json');
|
|
641
|
+
const { keys } = (await response.json()) as { keys: Array<Record<string, unknown>> };
|
|
642
|
+
|
|
643
|
+
expect(response.status).toBe(200);
|
|
644
|
+
expect(keys.map((key) => [key.kid, key.kty, key.alg, key.use, key.d])).toEqual([
|
|
645
|
+
['conformance-key', 'RSA', 'RS256', 'sig', undefined],
|
|
646
|
+
]);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
`;
|
|
650
|
+
}
|
|
651
|
+
function authorizationEndpointSection(features) {
|
|
652
|
+
const pkceTests = features.pkce
|
|
653
|
+
? `
|
|
654
|
+
it('should require PKCE from every client (OAuth 2.1 §4.1.1)', async () => {
|
|
655
|
+
const response = await new Browser().request(
|
|
656
|
+
authorize.GET,
|
|
657
|
+
authorizeUrl(authorizationRequest({ code_challenge: undefined, code_challenge_method: undefined })),
|
|
658
|
+
);
|
|
659
|
+
const location = new URL(locationOf(response));
|
|
660
|
+
|
|
661
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
662
|
+
expect(location.searchParams.get('error')).toBe('invalid_request');
|
|
663
|
+
expect(location.searchParams.get('error_description')).toBe('Missing required parameter: code_challenge');
|
|
664
|
+
});
|
|
665
|
+
`
|
|
666
|
+
: `
|
|
667
|
+
it('should let a confidential client omit PKCE (OIDF Basic OP compatibility)', async () => {
|
|
668
|
+
const response = await new Browser().request(
|
|
669
|
+
authorize.GET,
|
|
670
|
+
authorizeUrl(authorizationRequest({ code_challenge: undefined, code_challenge_method: undefined })),
|
|
671
|
+
);
|
|
672
|
+
const location = new URL(locationOf(response));
|
|
673
|
+
|
|
674
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/login');
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('should still require PKCE from a public client (OAuth 2.1 §4.1.1)', async () => {
|
|
678
|
+
const response = await new Browser().request(
|
|
679
|
+
authorize.GET,
|
|
680
|
+
authorizeUrl(
|
|
681
|
+
authorizationRequest({
|
|
682
|
+
client_id: 'conformance-public-client',
|
|
683
|
+
code_challenge: undefined,
|
|
684
|
+
code_challenge_method: undefined,
|
|
685
|
+
}),
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
const location = new URL(locationOf(response));
|
|
689
|
+
|
|
690
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
691
|
+
expect(location.searchParams.get('error')).toBe('invalid_request');
|
|
692
|
+
});
|
|
693
|
+
`;
|
|
694
|
+
const requestObjectTest = features.requestObject
|
|
695
|
+
? `
|
|
696
|
+
it('should refuse an unsigned Request Object (OIDC Core 1.0 §6.1)', async () => {
|
|
697
|
+
const unsigned =
|
|
698
|
+
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url') +
|
|
699
|
+
'.' +
|
|
700
|
+
Buffer.from(JSON.stringify(authorizationRequest())).toString('base64url') +
|
|
701
|
+
'.';
|
|
702
|
+
const response = await new Browser().request(
|
|
703
|
+
authorize.GET,
|
|
704
|
+
authorizeUrl(authorizationRequest({ request: unsigned })),
|
|
705
|
+
);
|
|
706
|
+
const location = new URL(locationOf(response));
|
|
707
|
+
|
|
708
|
+
expect(response.status).toBe(303);
|
|
709
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/oidc-error');
|
|
710
|
+
expect(location.searchParams.get('error')).toBe('invalid_request_object');
|
|
711
|
+
});
|
|
712
|
+
`
|
|
713
|
+
: `
|
|
714
|
+
it('should refuse the request parameter (OIDC Core 1.0 §6.3)', async () => {
|
|
715
|
+
const response = await new Browser().request(
|
|
716
|
+
authorize.GET,
|
|
717
|
+
authorizeUrl(authorizationRequest({ request: 'eyJhbGciOiJub25lIn0.e30.' })),
|
|
718
|
+
);
|
|
719
|
+
const location = new URL(locationOf(response));
|
|
720
|
+
|
|
721
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
722
|
+
expect(location.searchParams.get('error')).toBe('request_not_supported');
|
|
723
|
+
});
|
|
724
|
+
`;
|
|
725
|
+
return `describe('Authorization Endpoint (OIDC Core 1.0 §3.1.2)', () => {
|
|
726
|
+
it('should send the browser to the login page on the issuer with a new transaction', async () => {
|
|
727
|
+
const response = await new Browser().request(authorize.GET, authorizeUrl(authorizationRequest()));
|
|
728
|
+
const location = new URL(locationOf(response));
|
|
729
|
+
|
|
730
|
+
expect(response.status).toBe(302);
|
|
731
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/login');
|
|
732
|
+
expect(location.searchParams.get('transaction_id')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('should accept the request as a form POST (OIDC Core 1.0 §3.1.2.1)', async () => {
|
|
736
|
+
const response = await new Browser().post(authorize.POST, '/authorize', authorizationRequest());
|
|
737
|
+
const location = new URL(locationOf(response));
|
|
738
|
+
|
|
739
|
+
expect(response.status).toBe(302);
|
|
740
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/login');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('should reject a POST body that is not form-encoded', async () => {
|
|
744
|
+
const response = await new Browser().request(authorize.POST, '/authorize', {
|
|
745
|
+
method: 'POST',
|
|
746
|
+
headers: { 'Content-Type': 'application/json' },
|
|
747
|
+
body: JSON.stringify(authorizationRequest()),
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
expect(response.status).toBe(400);
|
|
751
|
+
expect(await response.json()).toEqual({
|
|
752
|
+
error: 'invalid_request',
|
|
753
|
+
error_description: 'Authorization POST requests must use application/x-www-form-urlencoded',
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('should reject a repeated parameter without redirecting (RFC 6749 §3.1)', async () => {
|
|
758
|
+
const response = await new Browser().request(
|
|
759
|
+
authorize.GET,
|
|
760
|
+
authorizeUrl(authorizationRequest()) + '&scope=openid',
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
expect(response.status).toBe(400);
|
|
764
|
+
expect(await response.json()).toEqual({
|
|
765
|
+
error: 'invalid_request',
|
|
766
|
+
error_description: 'Parameter "scope" must not be repeated',
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('should send an unregistered redirect_uri to the OP error page instead of redirecting (OIDC Core 1.0 §3.1.2.2)', async () => {
|
|
771
|
+
const response = await new Browser().request(
|
|
772
|
+
authorize.GET,
|
|
773
|
+
authorizeUrl(authorizationRequest({ redirect_uri: 'https://attacker.example/callback' })),
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
expect(response.status).toBe(303);
|
|
777
|
+
expect(locationOf(response)).toBe(
|
|
778
|
+
ISSUER + '/oidc-error?error=invalid_request&error_description=redirect_uri+not+registered',
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('should answer the same error as JSON to a caller that accepts JSON', async () => {
|
|
783
|
+
const response = await new Browser().request(
|
|
784
|
+
authorize.GET,
|
|
785
|
+
authorizeUrl(authorizationRequest({ redirect_uri: 'https://attacker.example/callback' })),
|
|
786
|
+
{ headers: { Accept: 'application/json' } },
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
expect(response.status).toBe(400);
|
|
790
|
+
expect(await response.json()).toEqual({
|
|
791
|
+
error: 'invalid_request',
|
|
792
|
+
error_description: 'redirect_uri not registered',
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('should render the error page with the error escaped', async () => {
|
|
797
|
+
const html = await new Browser().render(OidcErrorPage, {
|
|
798
|
+
error: 'invalid_request',
|
|
799
|
+
error_description: '<script>alert(1)</script>',
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
expect(html).toBe(
|
|
803
|
+
'<main><h1>Error</h1><p>invalid_request</p><p><script>alert(1)</script></p></main>',
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should return a redirectable error to the client with state and iss (RFC 9207 §2)', async () => {
|
|
808
|
+
const response = await new Browser().request(
|
|
809
|
+
authorize.GET,
|
|
810
|
+
authorizeUrl(authorizationRequest({ response_type: 'token' })),
|
|
811
|
+
);
|
|
812
|
+
const location = new URL(locationOf(response));
|
|
813
|
+
|
|
814
|
+
expect(response.status).toBe(302);
|
|
815
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
816
|
+
expect(Object.fromEntries(location.searchParams)).toEqual({
|
|
817
|
+
error: 'unsupported_response_type',
|
|
818
|
+
error_description: 'Unsupported response_type: token',
|
|
819
|
+
state: 'conformance-state',
|
|
820
|
+
iss: ISSUER,
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
${pkceTests}${requestObjectTest}});
|
|
824
|
+
`;
|
|
825
|
+
}
|
|
826
|
+
function internalRedirectOriginSection() {
|
|
827
|
+
return `describe('Internal redirect origin (OIDC Discovery 1.0 §3 / RFC 9700 §2.1)', () => {
|
|
828
|
+
it('should ignore the Host header when building the login redirect Location', async () => {
|
|
829
|
+
// Some platforms build request.url from the Host header, so an
|
|
830
|
+
// attacker-chosen Host arrives as an attacker-origin URL. Both are sent;
|
|
831
|
+
// neither may decide where transaction_id lands.
|
|
832
|
+
const response = await new Browser().request(
|
|
833
|
+
authorize.GET,
|
|
834
|
+
authorizeUrl(authorizationRequest(), 'http://attacker.example'),
|
|
835
|
+
{ headers: { Host: 'attacker.example' } },
|
|
836
|
+
);
|
|
837
|
+
const location = new URL(locationOf(response));
|
|
838
|
+
|
|
839
|
+
expect(response.status).toBe(302);
|
|
840
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/login');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('should keep the login redirect Location on the issuer origin for a subpath issuer', async () => {
|
|
844
|
+
// '/login' is an absolute path, so a subpath issuer contributes only its
|
|
845
|
+
// origin. Mounting the generated routes under a subpath (basePath) is a
|
|
846
|
+
// separate concern this provider does not handle.
|
|
847
|
+
config.issuer = 'https://op.example.com/op';
|
|
848
|
+
const response = await new Browser().request(authorize.GET, authorizeUrl(authorizationRequest()));
|
|
849
|
+
const location = new URL(locationOf(response));
|
|
850
|
+
|
|
851
|
+
expect(location.origin + location.pathname).toBe('https://op.example.com/login');
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('should continue from login to consent on a path of this app', async () => {
|
|
855
|
+
// Server Actions redirect within the app, which Next.js resolves against
|
|
856
|
+
// the page the browser is on — never against a request header.
|
|
857
|
+
const browser = new Browser();
|
|
858
|
+
const transactionId = await startAuthorization(browser);
|
|
859
|
+
|
|
860
|
+
expect(await logIn(browser, transactionId)).toBe('/consent?transaction_id=' + transactionId);
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
function loginSection() {
|
|
866
|
+
return `describe('Login page and loginAction (OIDC Core 1.0 §3.1.2.3)', () => {
|
|
867
|
+
it('should render the login form for the transaction', async () => {
|
|
868
|
+
const browser = new Browser();
|
|
869
|
+
const transactionId = await startAuthorization(browser);
|
|
870
|
+
const html = await browser.render(LoginPage, { transaction_id: transactionId });
|
|
871
|
+
|
|
872
|
+
expect(html).toContain('<input type="hidden" name="transaction_id" value="' + transactionId + '"/>');
|
|
873
|
+
expect(csrfTokenOf(html)).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('should pre-fill the username from login_hint (OIDC Core 1.0 §3.1.2.1)', async () => {
|
|
877
|
+
const browser = new Browser();
|
|
878
|
+
const transactionId = await startAuthorization(browser, authorizationRequest({ login_hint: 'testuser' }));
|
|
879
|
+
const html = await browser.render(LoginPage, { transaction_id: transactionId });
|
|
880
|
+
|
|
881
|
+
expect(html).toContain('<input type="text" id="username" required="" name="username" value="testuser"/>');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should start the OP session in an HttpOnly, Secure, SameSite=Lax cookie', async () => {
|
|
885
|
+
const browser = new Browser();
|
|
886
|
+
const transactionId = await startAuthorization(browser);
|
|
887
|
+
await logIn(browser, transactionId);
|
|
888
|
+
const sessionCookie = harness.cookieWrites.filter((write) => write.name === 'session_id').at(-1);
|
|
889
|
+
|
|
890
|
+
expect(sessionCookie?.value).toBe(browser.cookies.get('session_id'));
|
|
891
|
+
expect(sessionCookie?.options).toEqual({ httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it('should send a failed login back to the form with the attempts remaining', async () => {
|
|
895
|
+
const browser = new Browser();
|
|
896
|
+
const transactionId = await startAuthorization(browser);
|
|
897
|
+
const html = await browser.render(LoginPage, { transaction_id: transactionId });
|
|
898
|
+
const outcome = await browser.submit(loginAction, {
|
|
899
|
+
transaction_id: transactionId,
|
|
900
|
+
csrf_token: csrfTokenOf(html),
|
|
901
|
+
username: 'testuser',
|
|
902
|
+
password: 'wrong-password',
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(outcome).toEqual({
|
|
906
|
+
status: 303,
|
|
907
|
+
location: '/login?transaction_id=' + transactionId + '&error=invalid_credentials&remaining=4',
|
|
908
|
+
});
|
|
909
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('should render the failed login message', async () => {
|
|
913
|
+
const browser = new Browser();
|
|
914
|
+
const transactionId = await startAuthorization(browser);
|
|
915
|
+
const html = await browser.render(LoginPage, {
|
|
916
|
+
transaction_id: transactionId,
|
|
917
|
+
error: 'invalid_credentials',
|
|
918
|
+
remaining: '4',
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
expect(html).toContain('<p role="alert" style="color:red">Invalid credentials. Attempts remaining: 4</p>');
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('should end the transaction on the OP error page after too many failed attempts', async () => {
|
|
925
|
+
const browser = new Browser();
|
|
926
|
+
const transactionId = await startAuthorization(browser);
|
|
927
|
+
const csrfToken = csrfTokenOf(await browser.render(LoginPage, { transaction_id: transactionId }));
|
|
928
|
+
const outcomes: Outcome[] = [];
|
|
929
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
930
|
+
outcomes.push(
|
|
931
|
+
await browser.submit(loginAction, {
|
|
932
|
+
transaction_id: transactionId,
|
|
933
|
+
csrf_token: csrfToken,
|
|
934
|
+
username: 'testuser',
|
|
935
|
+
password: 'wrong-password',
|
|
936
|
+
}),
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
expect(outcomes.at(-1)).toEqual({
|
|
941
|
+
status: 303,
|
|
942
|
+
location:
|
|
943
|
+
'/oidc-error?error=max_attempts_exceeded&error_description=' +
|
|
944
|
+
'Too+many+login+attempts.+Start+again+from+the+application.',
|
|
945
|
+
});
|
|
946
|
+
expect(await browser.open(LoginPage, { transaction_id: transactionId })).toEqual({ status: 404 });
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('should send a submission with a wrong csrf_token to the OP error page', async () => {
|
|
950
|
+
const browser = new Browser();
|
|
951
|
+
const transactionId = await startAuthorization(browser);
|
|
952
|
+
const outcome = await browser.submit(loginAction, {
|
|
953
|
+
transaction_id: transactionId,
|
|
954
|
+
csrf_token: 'forged',
|
|
955
|
+
username: 'testuser',
|
|
956
|
+
password: 'password',
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
expect(outcome).toEqual({
|
|
960
|
+
status: 303,
|
|
961
|
+
location: '/oidc-error?error=invalid_csrf_token&error_description=Invalid+CSRF+token.',
|
|
962
|
+
});
|
|
963
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
describe('Error screens (Next.js not-found.js / error.js)', () => {
|
|
967
|
+
it('should answer 404 with not-found.tsx for an unknown transaction', async () => {
|
|
968
|
+
expect(await new Browser().open(LoginPage, { transaction_id: 'unknown-transaction' })).toEqual({ status: 404 });
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('should answer 404 when transaction_id is missing', async () => {
|
|
972
|
+
expect(await new Browser().open(LoginPage, {})).toEqual({ status: 404 });
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('should answer 404 to a login submitted for an unknown transaction', async () => {
|
|
976
|
+
const outcome = await new Browser().submit(loginAction, {
|
|
977
|
+
transaction_id: 'unknown-transaction',
|
|
978
|
+
csrf_token: 'unknown',
|
|
979
|
+
username: 'testuser',
|
|
980
|
+
password: 'password',
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
expect(outcome).toEqual({ status: 404 });
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it('should tell the End-User to start again from the application (not-found.tsx)', () => {
|
|
987
|
+
expect(renderToStaticMarkup(createElement(LoginNotFound))).toBe(
|
|
988
|
+
'<main><h1>Error</h1><p>transaction_not_found</p>' +
|
|
989
|
+
'<p>This sign-in request was not found or has expired. Start again from the application.</p></main>',
|
|
990
|
+
);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it('should show the digest of an unexpected error but never its message (error.tsx)', () => {
|
|
994
|
+
const error = Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:6379'), { digest: '2938471023' });
|
|
995
|
+
const html = renderToStaticMarkup(createElement(LoginError, { error, retry: () => undefined }));
|
|
996
|
+
|
|
997
|
+
expect(html).toBe(
|
|
998
|
+
'<main><h1>Error</h1><p>server_error</p><p>Reference: 2938471023</p>' +
|
|
999
|
+
'<button type="button">Try again</button></main>',
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
`;
|
|
1005
|
+
}
|
|
1006
|
+
function consentSection() {
|
|
1007
|
+
return `describe('Consent page and consentAction (OIDC Core 1.0 §3.1.2.4)', () => {
|
|
1008
|
+
it('should show the client and the requested scopes', async () => {
|
|
1009
|
+
const browser = new Browser();
|
|
1010
|
+
const transactionId = await startAuthorization(browser);
|
|
1011
|
+
await logIn(browser, transactionId);
|
|
1012
|
+
const html = await browser.render(ConsentPage, { transaction_id: transactionId });
|
|
1013
|
+
|
|
1014
|
+
expect(html).toContain('<strong>conformance-client</strong>');
|
|
1015
|
+
expect(html).toContain('<ul><li>openid</li><li>profile</li></ul>');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('should send the browser back to login when the transaction has no signed-in user', async () => {
|
|
1019
|
+
const browser = new Browser();
|
|
1020
|
+
const transactionId = await startAuthorization(browser);
|
|
1021
|
+
|
|
1022
|
+
expect(await decide(browser, transactionId, 'approve')).toBe('/login?transaction_id=' + transactionId);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('should send a decision with a wrong csrf_token to the OP error page', async () => {
|
|
1026
|
+
const browser = new Browser();
|
|
1027
|
+
const transactionId = await startAuthorization(browser);
|
|
1028
|
+
await logIn(browser, transactionId);
|
|
1029
|
+
const outcome = await browser.submit(consentAction, {
|
|
1030
|
+
transaction_id: transactionId,
|
|
1031
|
+
csrf_token: 'forged',
|
|
1032
|
+
action: 'approve',
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
expect(outcome).toEqual({
|
|
1036
|
+
status: 303,
|
|
1037
|
+
location: '/oidc-error?error=invalid_csrf_token&error_description=Invalid+CSRF+token.',
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
describe('Error screens (Next.js not-found.js / error.js)', () => {
|
|
1042
|
+
it('should answer 404 with not-found.tsx for an unknown transaction', async () => {
|
|
1043
|
+
expect(await new Browser().open(ConsentPage, { transaction_id: 'unknown-transaction' })).toEqual({ status: 404 });
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('should answer 404 to a decision submitted for an unknown transaction', async () => {
|
|
1047
|
+
const outcome = await new Browser().submit(consentAction, {
|
|
1048
|
+
transaction_id: 'unknown-transaction',
|
|
1049
|
+
csrf_token: 'unknown',
|
|
1050
|
+
action: 'approve',
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
expect(outcome).toEqual({ status: 404 });
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('should tell the End-User to start again from the application (not-found.tsx)', () => {
|
|
1057
|
+
expect(renderToStaticMarkup(createElement(ConsentNotFound))).toBe(
|
|
1058
|
+
'<main><h1>Error</h1><p>transaction_not_found</p>' +
|
|
1059
|
+
'<p>This sign-in request was not found or has expired. Start again from the application.</p></main>',
|
|
1060
|
+
);
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it('should show the digest of an unexpected error but never its message (error.tsx)', () => {
|
|
1064
|
+
const error = Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:6379'), { digest: '2938471023' });
|
|
1065
|
+
const html = renderToStaticMarkup(createElement(ConsentError, { error, retry: () => undefined }));
|
|
1066
|
+
|
|
1067
|
+
expect(html).toBe(
|
|
1068
|
+
'<main><h1>Error</h1><p>server_error</p><p>Reference: 2938471023</p>' +
|
|
1069
|
+
'<button type="button">Try again</button></main>',
|
|
1070
|
+
);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
describe('Consent decision value (OIDC Core 1.0 §3.1.2.4)', () => {
|
|
1075
|
+
const invalidDecision =
|
|
1076
|
+
'/oidc-error?error=invalid_request&error_description=' +
|
|
1077
|
+
'Invalid+consent+decision.+Please+use+the+Approve+or+Deny+button.';
|
|
1078
|
+
|
|
1079
|
+
async function signedInTransaction(browser: Browser): Promise<string> {
|
|
1080
|
+
const transactionId = await startAuthorization(browser);
|
|
1081
|
+
await logIn(browser, transactionId);
|
|
1082
|
+
return transactionId;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
it('should issue an authorization code when the consent form sends action=approve', async () => {
|
|
1086
|
+
const browser = new Browser();
|
|
1087
|
+
const location = new URL(await decide(browser, await signedInTransaction(browser), 'approve'));
|
|
1088
|
+
|
|
1089
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
1090
|
+
expect([...location.searchParams.keys()]).toEqual(['code', 'state', 'iss']);
|
|
1091
|
+
expect(location.searchParams.get('state')).toBe('conformance-state');
|
|
1092
|
+
expect(location.searchParams.get('iss')).toBe(ISSUER);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('should redirect with error=access_denied when the consent form sends action=deny', async () => {
|
|
1096
|
+
const browser = new Browser();
|
|
1097
|
+
const transactionId = await signedInTransaction(browser);
|
|
1098
|
+
const location = new URL(await decide(browser, transactionId, 'deny'));
|
|
1099
|
+
|
|
1100
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
1101
|
+
expect(Object.fromEntries(location.searchParams)).toEqual({
|
|
1102
|
+
error: 'access_denied',
|
|
1103
|
+
state: 'conformance-state',
|
|
1104
|
+
iss: ISSUER,
|
|
1105
|
+
});
|
|
1106
|
+
expect(await stores.transactionStore.get('auth_txn:' + transactionId)).toBe(null);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('should not issue an authorization code when the consent form omits the action parameter', async () => {
|
|
1110
|
+
const browser = new Browser();
|
|
1111
|
+
|
|
1112
|
+
expect(await decide(browser, await signedInTransaction(browser), undefined)).toBe(invalidDecision);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('should not issue an authorization code when the consent form sends an empty action value', async () => {
|
|
1116
|
+
const browser = new Browser();
|
|
1117
|
+
|
|
1118
|
+
expect(await decide(browser, await signedInTransaction(browser), '')).toBe(invalidDecision);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
it('should not issue an authorization code when the consent form sends an unknown action value', async () => {
|
|
1122
|
+
const browser = new Browser();
|
|
1123
|
+
|
|
1124
|
+
expect(await decide(browser, await signedInTransaction(browser), 'allow')).toBe(invalidDecision);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('should not record consent via recordConsent when the action value is unrecognized', async () => {
|
|
1128
|
+
const recordConsent = vi.spyOn(resolvers.consentResolver, 'recordConsent');
|
|
1129
|
+
const browser = new Browser();
|
|
1130
|
+
await decide(browser, await signedInTransaction(browser), 'allow');
|
|
1131
|
+
|
|
1132
|
+
expect(recordConsent).not.toHaveBeenCalled();
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('should keep the transaction open after an unrecognized action value', async () => {
|
|
1136
|
+
const browser = new Browser();
|
|
1137
|
+
const transactionId = await signedInTransaction(browser);
|
|
1138
|
+
await decide(browser, transactionId, 'allow');
|
|
1139
|
+
const location = new URL(await decide(browser, transactionId, 'approve'));
|
|
1140
|
+
|
|
1141
|
+
expect(location.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
`;
|
|
1146
|
+
}
|
|
1147
|
+
function transactionBindingSection(features) {
|
|
1148
|
+
if (!features.transactionBinding) {
|
|
1149
|
+
return `describe('Auth transaction User-Agent binding (disabled by default)', () => {
|
|
1150
|
+
it('should let another browser holding the transaction_id continue the transaction', async () => {
|
|
1151
|
+
// Generate with --enable transaction-binding to refuse this: without it,
|
|
1152
|
+
// transaction_id alone is enough to drive login and consent.
|
|
1153
|
+
const transactionId = await startAuthorization(new Browser());
|
|
1154
|
+
|
|
1155
|
+
expect(await logIn(new Browser(), transactionId)).toBe('/consent?transaction_id=' + transactionId);
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
`;
|
|
1159
|
+
}
|
|
1160
|
+
return `describe('Auth transaction User-Agent binding (OIDC Core 1.0 §3.1.2.3 / §3.1.2.4)', () => {
|
|
1161
|
+
it('should hand the binding secret to the browser in an HttpOnly cookie scoped to the transaction', async () => {
|
|
1162
|
+
const response = await new Browser().request(authorize.GET, authorizeUrl(authorizationRequest()));
|
|
1163
|
+
const transactionId = transactionIdOf(locationOf(response));
|
|
1164
|
+
|
|
1165
|
+
expect(response.headers.getSetCookie()).toEqual([
|
|
1166
|
+
expect.stringMatching(
|
|
1167
|
+
new RegExp('^oidc_txn_' + transactionId + '=[A-Za-z0-9_-]{43}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600$'),
|
|
1168
|
+
),
|
|
1169
|
+
]);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
const notThisBrowser =
|
|
1173
|
+
'/oidc-error?error=invalid_transaction_binding&error_description=' +
|
|
1174
|
+
'This+authorization+transaction+was+not+started+by+this+browser.';
|
|
1175
|
+
|
|
1176
|
+
it('should send another browser holding the transaction_id from the login page to the OP error page', async () => {
|
|
1177
|
+
const transactionId = await startAuthorization(new Browser());
|
|
1178
|
+
|
|
1179
|
+
expect(await new Browser().open(LoginPage, { transaction_id: transactionId })).toEqual({
|
|
1180
|
+
status: 307,
|
|
1181
|
+
location: notThisBrowser,
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
it('should refuse a login submitted by another browser holding the transaction_id', async () => {
|
|
1186
|
+
const browser = new Browser();
|
|
1187
|
+
const transactionId = await startAuthorization(browser);
|
|
1188
|
+
const csrfToken = csrfTokenOf(await browser.render(LoginPage, { transaction_id: transactionId }));
|
|
1189
|
+
const attacker = new Browser();
|
|
1190
|
+
const outcome = await attacker.submit(loginAction, {
|
|
1191
|
+
transaction_id: transactionId,
|
|
1192
|
+
csrf_token: csrfToken,
|
|
1193
|
+
username: 'testuser',
|
|
1194
|
+
password: 'password',
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
expect(outcome).toEqual({ status: 303, location: notThisBrowser });
|
|
1198
|
+
expect(attacker.cookies.has('session_id')).toBe(false);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it('should send another browser holding the transaction_id from the consent page to the OP error page', async () => {
|
|
1202
|
+
const browser = new Browser();
|
|
1203
|
+
const transactionId = await startAuthorization(browser);
|
|
1204
|
+
await logIn(browser, transactionId);
|
|
1205
|
+
|
|
1206
|
+
expect(await new Browser().open(ConsentPage, { transaction_id: transactionId })).toEqual({
|
|
1207
|
+
status: 307,
|
|
1208
|
+
location: notThisBrowser,
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it('should refuse a consent decision from another browser holding the transaction_id', async () => {
|
|
1213
|
+
const browser = new Browser();
|
|
1214
|
+
const transactionId = await startAuthorization(browser);
|
|
1215
|
+
await logIn(browser, transactionId);
|
|
1216
|
+
const csrfToken = csrfTokenOf(await browser.render(ConsentPage, { transaction_id: transactionId }));
|
|
1217
|
+
const outcome = await new Browser().submit(consentAction, {
|
|
1218
|
+
transaction_id: transactionId,
|
|
1219
|
+
csrf_token: csrfToken,
|
|
1220
|
+
action: 'approve',
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
expect(outcome).toEqual({ status: 303, location: notThisBrowser });
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('should drop the binding cookie once the decision is made', async () => {
|
|
1227
|
+
const browser = new Browser();
|
|
1228
|
+
const transactionId = await startAuthorization(browser);
|
|
1229
|
+
await logIn(browser, transactionId);
|
|
1230
|
+
await decide(browser, transactionId, 'approve');
|
|
1231
|
+
|
|
1232
|
+
expect(browser.cookies.has('oidc_txn_' + transactionId)).toBe(false);
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
`;
|
|
1236
|
+
}
|
|
1237
|
+
function tokenEndpointSection(features) {
|
|
1238
|
+
const refreshTests = features.refreshToken
|
|
1239
|
+
? `
|
|
1240
|
+
it('should rotate the refresh token and revoke the family on reuse (OAuth 2.1 §4.3.1)', async () => {
|
|
1241
|
+
const tokens = await issueTokens();
|
|
1242
|
+
const refresh = (refreshToken: string) =>
|
|
1243
|
+
tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken });
|
|
1244
|
+
const rotated = (await (await refresh(tokens.refresh_token ?? '')).json()) as TokenResponse;
|
|
1245
|
+
const reuse = await refresh(tokens.refresh_token ?? '');
|
|
1246
|
+
const afterReuse = await refresh(rotated.refresh_token ?? '');
|
|
1247
|
+
|
|
1248
|
+
expect(rotated.refresh_token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
1249
|
+
expect(rotated.refresh_token).not.toBe(tokens.refresh_token);
|
|
1250
|
+
expect(reuse.status).toBe(400);
|
|
1251
|
+
expect(((await reuse.json()) as { error: string }).error).toBe('invalid_grant');
|
|
1252
|
+
expect(afterReuse.status).toBe(400);
|
|
1253
|
+
});
|
|
1254
|
+
`
|
|
1255
|
+
: `
|
|
1256
|
+
it('should not issue a refresh token', async () => {
|
|
1257
|
+
const tokens = await issueTokens();
|
|
1258
|
+
|
|
1259
|
+
expect(tokens.refresh_token).toBe(undefined);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('should refuse the refresh_token grant', async () => {
|
|
1263
|
+
const response = await tokenRequest({ grant_type: 'refresh_token', refresh_token: 'unused' });
|
|
1264
|
+
|
|
1265
|
+
expect(response.status).toBe(400);
|
|
1266
|
+
expect(((await response.json()) as { error: string }).error).toBe('unsupported_grant_type');
|
|
1267
|
+
});
|
|
1268
|
+
`;
|
|
1269
|
+
const disabledGrants = [
|
|
1270
|
+
...(features.tokenExchange || features.idJag
|
|
1271
|
+
? []
|
|
1272
|
+
: ['urn:ietf:params:oauth:grant-type:token-exchange']),
|
|
1273
|
+
...(features.deviceAuthorizationGrant ? [] : ['urn:ietf:params:oauth:grant-type:device_code']),
|
|
1274
|
+
...(features.ciba ? [] : ['urn:openid:params:grant-type:ciba']),
|
|
1275
|
+
];
|
|
1276
|
+
const disabledGrantsTest = disabledGrants.length > 0
|
|
1277
|
+
? `
|
|
1278
|
+
it('should answer unsupported_grant_type for the grants of features this OP was generated without (RFC 6749 §5.2)', async () => {
|
|
1279
|
+
const grantTypes = [
|
|
1280
|
+
${disabledGrants.map((grantType) => ` '${grantType}',`).join('\n')}
|
|
1281
|
+
];
|
|
1282
|
+
const answers = await Promise.all(
|
|
1283
|
+
grantTypes.map(async (grantType) => {
|
|
1284
|
+
const response = await tokenRequest({ grant_type: grantType });
|
|
1285
|
+
return [response.status, ((await response.json()) as { error: string }).error];
|
|
1286
|
+
}),
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
expect(answers).toEqual(grantTypes.map(() => [400, 'unsupported_grant_type']));
|
|
1290
|
+
});
|
|
1291
|
+
`
|
|
1292
|
+
: '';
|
|
1293
|
+
return `describe('Token Endpoint (OIDC Core 1.0 §3.1.3)', () => {
|
|
1294
|
+
it('should exchange the code for tokens that must not be cached', async () => {
|
|
1295
|
+
const callback = await signIn(new Browser());
|
|
1296
|
+
const response = await exchangeCode(callback.searchParams.get('code') ?? '');
|
|
1297
|
+
const body = (await response.json()) as TokenResponse;
|
|
1298
|
+
|
|
1299
|
+
expect(response.status).toBe(200);
|
|
1300
|
+
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
|
1301
|
+
expect(response.headers.get('Pragma')).toBe('no-cache');
|
|
1302
|
+
expect(body).toMatchObject({ token_type: 'Bearer', expires_in: 3600, scope: 'openid profile' });
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it('should issue an ID Token for the signed-in user (OIDC Core 1.0 §2)', async () => {
|
|
1306
|
+
const tokens = await issueTokens();
|
|
1307
|
+
|
|
1308
|
+
expect(decodeJwt(tokens.id_token).payload).toMatchObject({
|
|
1309
|
+
iss: ISSUER,
|
|
1310
|
+
sub: 'testuser',
|
|
1311
|
+
aud: 'conformance-client',
|
|
1312
|
+
nonce: 'conformance-nonce',
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
it('should sign the ID Token with the key the JWKS endpoint publishes', async () => {
|
|
1317
|
+
const tokens = await issueTokens();
|
|
1318
|
+
|
|
1319
|
+
expect(decodeJwt(tokens.id_token).header).toMatchObject({ alg: 'RS256', kid: 'conformance-key' });
|
|
1320
|
+
expect(await verifiesWithPublishedKey(tokens.id_token)).toBe(true);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
it('should reject a body that is not form-encoded (RFC 6749 §4.1.3)', async () => {
|
|
1324
|
+
const response = await new Browser().request(token.POST, '/token', {
|
|
1325
|
+
method: 'POST',
|
|
1326
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1327
|
+
body: JSON.stringify({ grant_type: 'authorization_code' }),
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
expect(response.status).toBe(400);
|
|
1331
|
+
expect(await response.json()).toEqual({
|
|
1332
|
+
error: 'invalid_request',
|
|
1333
|
+
error_description: 'Token requests must use application/x-www-form-urlencoded',
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it('should reject a repeated parameter (RFC 6749 §3.2)', async () => {
|
|
1338
|
+
const response = await new Browser().request(token.POST, '/token', {
|
|
1339
|
+
method: 'POST',
|
|
1340
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1341
|
+
body: 'grant_type=authorization_code&code=a&code=b',
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
expect(response.status).toBe(400);
|
|
1345
|
+
expect(await response.json()).toEqual({
|
|
1346
|
+
error: 'invalid_request',
|
|
1347
|
+
error_description: 'Parameter "code" must not be repeated',
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it('should answer a wrong client_secret with 401 invalid_client (RFC 6749 §5.2)', async () => {
|
|
1352
|
+
const response = await tokenRequest({
|
|
1353
|
+
grant_type: 'authorization_code',
|
|
1354
|
+
code: 'unused',
|
|
1355
|
+
client_secret: 'wrong-secret',
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
expect(response.status).toBe(401);
|
|
1359
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_client');
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
it('should challenge a failed HTTP Basic authentication with WWW-Authenticate (RFC 6749 §5.2)', async () => {
|
|
1363
|
+
const response = await new Browser().post(
|
|
1364
|
+
token.POST,
|
|
1365
|
+
'/token',
|
|
1366
|
+
{ grant_type: 'authorization_code', code: 'unused' },
|
|
1367
|
+
{ Authorization: basicAuthorization('conformance-basic-client', 'wrong-secret') },
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
expect(response.status).toBe(401);
|
|
1371
|
+
expect(response.headers.get('WWW-Authenticate')).toBe('Basic realm="Client Authentication"');
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('should authenticate a client_secret_basic client from the Authorization header', async () => {
|
|
1375
|
+
const callback = await signIn(new Browser(), authorizationRequest({ client_id: 'conformance-basic-client' }));
|
|
1376
|
+
const response = await new Browser().post(
|
|
1377
|
+
token.POST,
|
|
1378
|
+
'/token',
|
|
1379
|
+
{
|
|
1380
|
+
grant_type: 'authorization_code',
|
|
1381
|
+
code: callback.searchParams.get('code') ?? '',
|
|
1382
|
+
redirect_uri: REDIRECT_URI,
|
|
1383
|
+
code_verifier: CODE_VERIFIER,
|
|
1384
|
+
},
|
|
1385
|
+
{ Authorization: basicAuthorization('conformance-basic-client', 'conformance-basic-secret') },
|
|
1386
|
+
);
|
|
1387
|
+
|
|
1388
|
+
expect(response.status).toBe(200);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
it('should refuse a code_verifier that does not match the code_challenge (RFC 7636 §4.6)', async () => {
|
|
1392
|
+
const callback = await signIn(new Browser());
|
|
1393
|
+
const response = await tokenRequest({
|
|
1394
|
+
grant_type: 'authorization_code',
|
|
1395
|
+
code: callback.searchParams.get('code') ?? '',
|
|
1396
|
+
redirect_uri: REDIRECT_URI,
|
|
1397
|
+
code_verifier: 'wrong-verifier-wrong-verifier-wrong-verifier-wrong',
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
expect(response.status).toBe(400);
|
|
1401
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_grant');
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
it('should refuse a reused code and revoke the tokens issued from it (OAuth 2.1 §4.1.2)', async () => {
|
|
1405
|
+
const callback = await signIn(new Browser());
|
|
1406
|
+
const code = callback.searchParams.get('code') ?? '';
|
|
1407
|
+
const tokens = (await (await exchangeCode(code)).json()) as TokenResponse;
|
|
1408
|
+
const reuse = await exchangeCode(code);
|
|
1409
|
+
const userInfo = await userInfoRequest(tokens.access_token);
|
|
1410
|
+
|
|
1411
|
+
expect(reuse.status).toBe(400);
|
|
1412
|
+
expect(((await reuse.json()) as { error: string }).error).toBe('invalid_grant');
|
|
1413
|
+
expect(userInfo.status).toBe(401);
|
|
1414
|
+
});
|
|
1415
|
+
${refreshTests}${disabledGrantsTest}
|
|
1416
|
+
it('should answer a CORS preflight for the configured origin', async () => {
|
|
1417
|
+
const response = await new Browser().request(token.OPTIONS, '/token', {
|
|
1418
|
+
method: 'OPTIONS',
|
|
1419
|
+
headers: { Origin: ISSUER, 'Access-Control-Request-Method': 'POST' },
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
expect(response.status).toBe(204);
|
|
1423
|
+
expect(Object.fromEntries(response.headers)).toEqual({
|
|
1424
|
+
'access-control-allow-origin': ISSUER,
|
|
1425
|
+
'access-control-allow-methods': 'POST,GET,OPTIONS',
|
|
1426
|
+
'access-control-allow-headers': 'Authorization,Content-Type',
|
|
1427
|
+
'access-control-max-age': '600',
|
|
1428
|
+
vary: 'Origin',
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
`;
|
|
1433
|
+
}
|
|
1434
|
+
function userInfoSection() {
|
|
1435
|
+
return `describe('UserInfo Endpoint (OIDC Core 1.0 §5.3)', () => {
|
|
1436
|
+
it('should return the claims of the granted scopes (OIDC Core 1.0 §5.4)', async () => {
|
|
1437
|
+
const tokens = await issueTokens();
|
|
1438
|
+
const response = await userInfoRequest(tokens.access_token);
|
|
1439
|
+
|
|
1440
|
+
expect(response.status).toBe(200);
|
|
1441
|
+
expect(await response.json()).toEqual({
|
|
1442
|
+
sub: 'testuser',
|
|
1443
|
+
name: 'Test User',
|
|
1444
|
+
family_name: 'User',
|
|
1445
|
+
given_name: 'Test',
|
|
1446
|
+
middle_name: 'Q',
|
|
1447
|
+
nickname: 'testy',
|
|
1448
|
+
preferred_username: 'testuser',
|
|
1449
|
+
profile: 'https://op.example.com/users/testuser',
|
|
1450
|
+
picture: 'https://op.example.com/users/testuser/avatar.png',
|
|
1451
|
+
website: 'https://testuser.example.com',
|
|
1452
|
+
gender: 'unspecified',
|
|
1453
|
+
birthdate: '1990-01-01',
|
|
1454
|
+
zoneinfo: 'Asia/Tokyo',
|
|
1455
|
+
locale: 'en-US',
|
|
1456
|
+
updated_at: 1700000000,
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it('should accept the access token in a form-encoded POST body (RFC 6750 §2.2)', async () => {
|
|
1461
|
+
const tokens = await issueTokens();
|
|
1462
|
+
const response = await new Browser().post(userinfo.POST, '/userinfo', { access_token: tokens.access_token });
|
|
1463
|
+
|
|
1464
|
+
expect(response.status).toBe(200);
|
|
1465
|
+
expect(((await response.json()) as { sub: string }).sub).toBe('testuser');
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
it('should challenge a request without an access token (RFC 6750 §3)', async () => {
|
|
1469
|
+
const response = await new Browser().request(userinfo.GET, '/userinfo');
|
|
1470
|
+
|
|
1471
|
+
expect(response.status).toBe(401);
|
|
1472
|
+
expect(response.headers.get('WWW-Authenticate')).toBe('Bearer realm="UserInfo"');
|
|
1473
|
+
expect(await response.json()).toEqual({ error: 'invalid_token', error_description: 'Access token is required' });
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('should reject an access token this OP did not issue', async () => {
|
|
1477
|
+
const response = await userInfoRequest('unknown-access-token');
|
|
1478
|
+
|
|
1479
|
+
expect(response.status).toBe(401);
|
|
1480
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_token');
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
`;
|
|
1484
|
+
}
|
|
1485
|
+
function sessionSection() {
|
|
1486
|
+
return `describe('OP session (OIDC Core 1.0 §3.1.2.1 / §3.1.2.3)', () => {
|
|
1487
|
+
it('should reuse the session and the consent already given (single sign-on)', async () => {
|
|
1488
|
+
const browser = new Browser();
|
|
1489
|
+
await signIn(browser);
|
|
1490
|
+
const response = await browser.request(authorize.GET, authorizeUrl(authorizationRequest()));
|
|
1491
|
+
const location = new URL(locationOf(response));
|
|
1492
|
+
|
|
1493
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
1494
|
+
expect(location.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it('should show the consent page again for prompt=consent', async () => {
|
|
1498
|
+
const browser = new Browser();
|
|
1499
|
+
await signIn(browser);
|
|
1500
|
+
const response = await browser.request(authorize.GET, authorizeUrl(authorizationRequest({ prompt: 'consent' })));
|
|
1501
|
+
const location = new URL(locationOf(response));
|
|
1502
|
+
|
|
1503
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/consent');
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it('should ask for a new login for prompt=login even with a session', async () => {
|
|
1507
|
+
const browser = new Browser();
|
|
1508
|
+
await signIn(browser);
|
|
1509
|
+
const response = await browser.request(authorize.GET, authorizeUrl(authorizationRequest({ prompt: 'login' })));
|
|
1510
|
+
const location = new URL(locationOf(response));
|
|
1511
|
+
|
|
1512
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/login');
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
it('should answer prompt=none without a session with login_required', async () => {
|
|
1516
|
+
const response = await new Browser().request(authorize.GET, authorizeUrl(authorizationRequest({ prompt: 'none' })));
|
|
1517
|
+
const location = new URL(locationOf(response));
|
|
1518
|
+
|
|
1519
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
1520
|
+
expect(location.searchParams.get('error')).toBe('login_required');
|
|
1521
|
+
expect(location.searchParams.get('state')).toBe('conformance-state');
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it('should issue a code for prompt=none with a session and consent', async () => {
|
|
1525
|
+
const browser = new Browser();
|
|
1526
|
+
await signIn(browser);
|
|
1527
|
+
const response = await browser.request(authorize.GET, authorizeUrl(authorizationRequest({ prompt: 'none' })));
|
|
1528
|
+
const location = new URL(locationOf(response));
|
|
1529
|
+
|
|
1530
|
+
expect(location.origin + location.pathname).toBe(REDIRECT_URI);
|
|
1531
|
+
expect(location.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
`;
|
|
1535
|
+
}
|
|
1536
|
+
function introspectionSection() {
|
|
1537
|
+
return `describe('Token Introspection (RFC 7662)', () => {
|
|
1538
|
+
function introspectToken(tokenValue: string, clientSecret = 'conformance-secret'): Promise<Response> {
|
|
1539
|
+
return new Browser().post(introspect.POST, '/introspect', {
|
|
1540
|
+
token: tokenValue,
|
|
1541
|
+
client_id: 'conformance-client',
|
|
1542
|
+
client_secret: clientSecret,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
it('should describe an active access token (RFC 7662 §2.2)', async () => {
|
|
1547
|
+
const tokens = await issueTokens();
|
|
1548
|
+
const response = await introspectToken(tokens.access_token);
|
|
1549
|
+
|
|
1550
|
+
expect(response.status).toBe(200);
|
|
1551
|
+
expect(await response.json()).toMatchObject({
|
|
1552
|
+
active: true,
|
|
1553
|
+
client_id: 'conformance-client',
|
|
1554
|
+
sub: 'testuser',
|
|
1555
|
+
scope: 'openid profile',
|
|
1556
|
+
iss: ISSUER,
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it('should answer active=false for a token this OP did not issue', async () => {
|
|
1561
|
+
const response = await introspectToken('unknown-token');
|
|
1562
|
+
|
|
1563
|
+
expect(await response.json()).toEqual({ active: false });
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it('should require client authentication (RFC 7662 §2.1)', async () => {
|
|
1567
|
+
const response = await introspectToken('unknown-token', 'wrong-secret');
|
|
1568
|
+
|
|
1569
|
+
expect(response.status).toBe(401);
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
`;
|
|
1573
|
+
}
|
|
1574
|
+
function revocationSection() {
|
|
1575
|
+
return `describe('Token Revocation (RFC 7009)', () => {
|
|
1576
|
+
function revokeToken(tokenValue: string): Promise<Response> {
|
|
1577
|
+
return new Browser().post(revoke.POST, '/revoke', {
|
|
1578
|
+
token: tokenValue,
|
|
1579
|
+
client_id: 'conformance-client',
|
|
1580
|
+
client_secret: 'conformance-secret',
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
it('should revoke an access token so it is no longer accepted (RFC 7009 §2.1)', async () => {
|
|
1585
|
+
const tokens = await issueTokens();
|
|
1586
|
+
const response = await revokeToken(tokens.access_token);
|
|
1587
|
+
const userInfo = await userInfoRequest(tokens.access_token);
|
|
1588
|
+
|
|
1589
|
+
expect(response.status).toBe(200);
|
|
1590
|
+
expect(userInfo.status).toBe(401);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it('should answer 200 for a token this OP did not issue (RFC 7009 §2.2)', async () => {
|
|
1594
|
+
const response = await revokeToken('unknown-token');
|
|
1595
|
+
|
|
1596
|
+
expect(response.status).toBe(200);
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
`;
|
|
1600
|
+
}
|
|
1601
|
+
function customScopesSection(scopes) {
|
|
1602
|
+
const scope = scopes[0] ?? '';
|
|
1603
|
+
return `describe('Custom scopes (scopes.ts)', () => {
|
|
1604
|
+
afterEach(() => {
|
|
1605
|
+
delete RESTRICTED_SCOPE_SUBJECTS['${scope}'];
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
it('should grant a declared custom scope', async () => {
|
|
1609
|
+
const tokens = await issueTokens(new Browser(), authorizationRequest({ scope: 'openid ${scope}' }));
|
|
1610
|
+
|
|
1611
|
+
expect(tokens.scope).toBe('openid ${scope}');
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it('should reject a scope that was not declared (RFC 6749 §3.3)', async () => {
|
|
1615
|
+
const response = await new Browser().request(
|
|
1616
|
+
authorize.GET,
|
|
1617
|
+
authorizeUrl(authorizationRequest({ scope: 'openid undeclared.scope' })),
|
|
1618
|
+
);
|
|
1619
|
+
const location = new URL(locationOf(response));
|
|
1620
|
+
|
|
1621
|
+
expect(location.searchParams.get('error')).toBe('invalid_scope');
|
|
1622
|
+
expect(location.searchParams.get('error_description')).toBe('Unsupported scope: undeclared.scope');
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it('should drop a scope the End-User may not be granted (resolveGrantableScopes)', async () => {
|
|
1626
|
+
RESTRICTED_SCOPE_SUBJECTS['${scope}'] = ['otheruser'];
|
|
1627
|
+
const tokens = await issueTokens(new Browser(), authorizationRequest({ scope: 'openid ${scope}' }));
|
|
1628
|
+
|
|
1629
|
+
expect(tokens.scope).toBe('openid');
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
`;
|
|
1633
|
+
}
|
|
1634
|
+
function parSection() {
|
|
1635
|
+
return `describe('Pushed Authorization Requests (RFC 9126)', () => {
|
|
1636
|
+
function pushAuthorizationRequest(clientSecret = 'conformance-secret'): Promise<Response> {
|
|
1637
|
+
return new Browser().post(par.POST, '/par', {
|
|
1638
|
+
...authorizationRequest(),
|
|
1639
|
+
client_secret: clientSecret,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
async function requestUri(): Promise<string> {
|
|
1644
|
+
return ((await (await pushAuthorizationRequest()).json()) as { request_uri: string }).request_uri;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
it('should answer 201 with a request_uri that must not be cached (RFC 9126 §2.2)', async () => {
|
|
1648
|
+
const response = await pushAuthorizationRequest();
|
|
1649
|
+
const body = (await response.json()) as { request_uri: string; expires_in: number };
|
|
1650
|
+
|
|
1651
|
+
expect(response.status).toBe(201);
|
|
1652
|
+
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store');
|
|
1653
|
+
expect(body.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:[A-Za-z0-9_-]+$/);
|
|
1654
|
+
expect(body.expires_in).toBe(60);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it('should complete the flow from the pushed request (RFC 9126 §4)', async () => {
|
|
1658
|
+
const callback = await signIn(new Browser(), { client_id: 'conformance-client', request_uri: await requestUri() });
|
|
1659
|
+
const response = await exchangeCode(callback.searchParams.get('code') ?? '');
|
|
1660
|
+
|
|
1661
|
+
expect(callback.searchParams.get('state')).toBe('conformance-state');
|
|
1662
|
+
expect(response.status).toBe(200);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
it('should refuse a used request_uri without redirecting (RFC 9126 §7.3)', async () => {
|
|
1666
|
+
const uri = await requestUri();
|
|
1667
|
+
const params = { client_id: 'conformance-client', request_uri: uri };
|
|
1668
|
+
await new Browser().request(authorize.GET, authorizeUrl(params));
|
|
1669
|
+
const replay = await new Browser().request(authorize.GET, authorizeUrl(params));
|
|
1670
|
+
const location = new URL(locationOf(replay));
|
|
1671
|
+
|
|
1672
|
+
expect(replay.status).toBe(303);
|
|
1673
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/oidc-error');
|
|
1674
|
+
expect(location.searchParams.get('error')).toBe('invalid_request_uri');
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
it('should require client authentication (RFC 9126 §2.1)', async () => {
|
|
1678
|
+
const response = await pushAuthorizationRequest('wrong-secret');
|
|
1679
|
+
|
|
1680
|
+
expect(response.status).toBe(401);
|
|
1681
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_client');
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
it('should refuse a request_uri inside a pushed request (RFC 9126 §2.1)', async () => {
|
|
1685
|
+
const response = await new Browser().post(par.POST, '/par', {
|
|
1686
|
+
...authorizationRequest(),
|
|
1687
|
+
client_secret: 'conformance-secret',
|
|
1688
|
+
request_uri: 'urn:ietf:params:oauth:request_uri:pushed-elsewhere',
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
expect(response.status).toBe(400);
|
|
1692
|
+
expect(await response.json()).toEqual({
|
|
1693
|
+
error: 'invalid_request',
|
|
1694
|
+
error_description: 'request_uri MUST NOT be included in a pushed authorization request',
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it('should validate the request like the authorization endpoint before storing it (RFC 9126 §2.1)', async () => {
|
|
1699
|
+
const response = await new Browser().post(par.POST, '/par', {
|
|
1700
|
+
...authorizationRequest({ redirect_uri: 'https://attacker.example/callback' }),
|
|
1701
|
+
client_secret: 'conformance-secret',
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
expect(response.status).toBe(400);
|
|
1705
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_request');
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
it('should refuse a request_uri presented with another client_id (RFC 9126 §4)', async () => {
|
|
1709
|
+
const response = await new Browser().request(
|
|
1710
|
+
authorize.GET,
|
|
1711
|
+
authorizeUrl({ client_id: 'conformance-basic-client', request_uri: await requestUri() }),
|
|
1712
|
+
);
|
|
1713
|
+
const location = new URL(locationOf(response));
|
|
1714
|
+
|
|
1715
|
+
expect(response.status).toBe(303);
|
|
1716
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/oidc-error');
|
|
1717
|
+
expect(location.searchParams.get('error')).toBe('invalid_request_uri');
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
describe('Required pushed requests (RFC 9126 §5)', () => {
|
|
1721
|
+
afterEach(() => {
|
|
1722
|
+
parConfig.requirePushedAuthorizationRequests = false;
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it('should refuse an authorization request that was not pushed', async () => {
|
|
1726
|
+
parConfig.requirePushedAuthorizationRequests = true;
|
|
1727
|
+
const response = await new Browser().request(authorize.GET, authorizeUrl(authorizationRequest()));
|
|
1728
|
+
const location = new URL(locationOf(response));
|
|
1729
|
+
|
|
1730
|
+
expect(location.origin + location.pathname).toBe(ISSUER + '/oidc-error');
|
|
1731
|
+
expect(location.searchParams.get('error')).toBe('invalid_request');
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
it('should advertise require_pushed_authorization_requests in discovery', async () => {
|
|
1735
|
+
parConfig.requirePushedAuthorizationRequests = true;
|
|
1736
|
+
const response = await new Browser().request(discovery.GET, '/.well-known/openid-configuration');
|
|
1737
|
+
|
|
1738
|
+
expect(((await response.json()) as Record<string, unknown>).require_pushed_authorization_requests).toBe(true);
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
`;
|
|
1743
|
+
}
|
|
1744
|
+
function jarmSection() {
|
|
1745
|
+
return `describe('JWT Secured Authorization Response Mode (JARM)', () => {
|
|
1746
|
+
it('should return the authorization response as one signed JWT (JARM §2.3.1)', async () => {
|
|
1747
|
+
const callback = await signIn(new Browser(), authorizationRequest({ response_mode: 'query.jwt' }));
|
|
1748
|
+
const response = callback.searchParams.get('response') ?? '';
|
|
1749
|
+
|
|
1750
|
+
expect([...callback.searchParams.keys()]).toEqual(['response']);
|
|
1751
|
+
expect(decodeJwt(response).payload).toMatchObject({
|
|
1752
|
+
iss: ISSUER,
|
|
1753
|
+
aud: 'conformance-client',
|
|
1754
|
+
state: 'conformance-state',
|
|
1755
|
+
});
|
|
1756
|
+
expect(await verifiesWithPublishedKey(response)).toBe(true);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
it('should return a denial as a signed error JWT (JARM §2.1)', async () => {
|
|
1760
|
+
const browser = new Browser();
|
|
1761
|
+
const transactionId = await startAuthorization(browser, authorizationRequest({ response_mode: 'query.jwt' }));
|
|
1762
|
+
await logIn(browser, transactionId);
|
|
1763
|
+
const callback = new URL(await decide(browser, transactionId, 'deny'));
|
|
1764
|
+
|
|
1765
|
+
expect(decodeJwt(callback.searchParams.get('response') ?? '').payload).toMatchObject({
|
|
1766
|
+
iss: ISSUER,
|
|
1767
|
+
aud: 'conformance-client',
|
|
1768
|
+
error: 'access_denied',
|
|
1769
|
+
state: 'conformance-state',
|
|
1770
|
+
});
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it('should answer the single sign-on fast path with a signed JWT whose code is redeemable', async () => {
|
|
1774
|
+
// This path answers inside the authorize Route Handler, not the consent
|
|
1775
|
+
// Server Action, so both places that sign are covered.
|
|
1776
|
+
const browser = new Browser();
|
|
1777
|
+
await signIn(browser);
|
|
1778
|
+
const response = await browser.request(
|
|
1779
|
+
authorize.GET,
|
|
1780
|
+
authorizeUrl(authorizationRequest({ response_mode: 'query.jwt' })),
|
|
1781
|
+
);
|
|
1782
|
+
const location = new URL(locationOf(response));
|
|
1783
|
+
const jwt = location.searchParams.get('response') ?? '';
|
|
1784
|
+
const tokenResponse = await exchangeCode(String(decodeJwt(jwt).payload.code ?? ''));
|
|
1785
|
+
|
|
1786
|
+
expect([...location.searchParams.keys()]).toEqual(['response']);
|
|
1787
|
+
expect(await verifiesWithPublishedKey(jwt)).toBe(true);
|
|
1788
|
+
expect(tokenResponse.status).toBe(200);
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
it('should answer prompt=none without a session with a signed error JWT', async () => {
|
|
1792
|
+
const response = await new Browser().request(
|
|
1793
|
+
authorize.GET,
|
|
1794
|
+
authorizeUrl(authorizationRequest({ response_mode: 'query.jwt', prompt: 'none' })),
|
|
1795
|
+
);
|
|
1796
|
+
const jwt = new URL(locationOf(response)).searchParams.get('response') ?? '';
|
|
1797
|
+
|
|
1798
|
+
expect(decodeJwt(jwt).payload).toMatchObject({
|
|
1799
|
+
iss: ISSUER,
|
|
1800
|
+
aud: 'conformance-client',
|
|
1801
|
+
error: 'login_required',
|
|
1802
|
+
state: 'conformance-state',
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
it('should accept the jwt shorthand for query.jwt (JARM §2.3.4)', async () => {
|
|
1807
|
+
const callback = await signIn(new Browser(), authorizationRequest({ response_mode: 'jwt' }));
|
|
1808
|
+
|
|
1809
|
+
expect([...callback.searchParams.keys()]).toEqual(['response']);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it('should advertise the response modes and the signing algorithm (JARM §4)', async () => {
|
|
1813
|
+
const response = await new Browser().request(discovery.GET, '/.well-known/openid-configuration');
|
|
1814
|
+
|
|
1815
|
+
expect(await response.json()).toMatchObject({
|
|
1816
|
+
response_modes_supported: ['query', 'query.jwt', 'jwt'],
|
|
1817
|
+
authorization_signing_alg_values_supported: ['RS256'],
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
it('should refuse fragment.jwt with a plain query error (JARM §2.3.2)', async () => {
|
|
1822
|
+
const response = await new Browser().request(
|
|
1823
|
+
authorize.GET,
|
|
1824
|
+
authorizeUrl(authorizationRequest({ response_mode: 'fragment.jwt' })),
|
|
1825
|
+
);
|
|
1826
|
+
const location = new URL(locationOf(response));
|
|
1827
|
+
|
|
1828
|
+
expect(Object.fromEntries(location.searchParams)).toEqual({
|
|
1829
|
+
error: 'invalid_request',
|
|
1830
|
+
error_description: 'response_mode fragment.jwt is not supported',
|
|
1831
|
+
state: 'conformance-state',
|
|
1832
|
+
iss: ISSUER,
|
|
1833
|
+
});
|
|
1834
|
+
});
|
|
1835
|
+
});
|
|
1836
|
+
`;
|
|
1837
|
+
}
|
|
1838
|
+
function tokenExchangeSection() {
|
|
1839
|
+
return `describe('Token Exchange (RFC 8693)', () => {
|
|
1840
|
+
const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
|
1841
|
+
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
|
1842
|
+
|
|
1843
|
+
it('should exchange an access token for one of narrower scope (RFC 8693 §2.2)', async () => {
|
|
1844
|
+
const tokens = await issueTokens();
|
|
1845
|
+
const response = await tokenRequest({
|
|
1846
|
+
grant_type: TOKEN_EXCHANGE,
|
|
1847
|
+
subject_token: tokens.access_token,
|
|
1848
|
+
subject_token_type: ACCESS_TOKEN_TYPE,
|
|
1849
|
+
scope: 'openid',
|
|
1850
|
+
});
|
|
1851
|
+
const body = (await response.json()) as TokenResponse & { issued_token_type: string };
|
|
1852
|
+
|
|
1853
|
+
expect(response.status).toBe(200);
|
|
1854
|
+
expect(body).toMatchObject({ issued_token_type: ACCESS_TOKEN_TYPE, token_type: 'Bearer', scope: 'openid' });
|
|
1855
|
+
expect(decodeJwt(body.access_token).payload).toMatchObject({ sub: 'testuser', scope: 'openid' });
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
it('should reject a subject_token this OP did not issue (RFC 8693 §2.2.2)', async () => {
|
|
1859
|
+
const response = await tokenRequest({
|
|
1860
|
+
grant_type: TOKEN_EXCHANGE,
|
|
1861
|
+
subject_token: 'unknown-token',
|
|
1862
|
+
subject_token_type: ACCESS_TOKEN_TYPE,
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
expect(response.status).toBe(400);
|
|
1866
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_request');
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
`;
|
|
1870
|
+
}
|
|
1871
|
+
function idJagSection() {
|
|
1872
|
+
return `describe('Identity Assertion JWT Authorization Grant (ID-JAG)', () => {
|
|
1873
|
+
const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
|
1874
|
+
const ID_JAG_TYPE = 'urn:ietf:params:oauth:token-type:id-jag';
|
|
1875
|
+
const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token';
|
|
1876
|
+
const RESOURCE_AS = 'https://as.example.net';
|
|
1877
|
+
|
|
1878
|
+
afterEach(() => {
|
|
1879
|
+
idJagConfig.allowedAudiences = [];
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
function requestIdJag(idToken: string): Promise<Response> {
|
|
1883
|
+
return tokenRequest({
|
|
1884
|
+
grant_type: TOKEN_EXCHANGE,
|
|
1885
|
+
requested_token_type: ID_JAG_TYPE,
|
|
1886
|
+
subject_token: idToken,
|
|
1887
|
+
subject_token_type: ID_TOKEN_TYPE,
|
|
1888
|
+
audience: RESOURCE_AS,
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
it('should issue a signed ID-JAG for an allowed resource authorization server (draft §4.3)', async () => {
|
|
1893
|
+
idJagConfig.allowedAudiences = [RESOURCE_AS];
|
|
1894
|
+
const tokens = await issueTokens();
|
|
1895
|
+
const response = await requestIdJag(tokens.id_token);
|
|
1896
|
+
const body = (await response.json()) as { access_token: string; issued_token_type: string; token_type: string };
|
|
1897
|
+
|
|
1898
|
+
expect(response.status).toBe(200);
|
|
1899
|
+
expect(body).toMatchObject({ issued_token_type: ID_JAG_TYPE, token_type: 'N_A' });
|
|
1900
|
+
expect(decodeJwt(body.access_token).header.typ).toBe('oauth-id-jag+jwt');
|
|
1901
|
+
expect(decodeJwt(body.access_token).payload).toMatchObject({
|
|
1902
|
+
iss: ISSUER,
|
|
1903
|
+
sub: 'testuser',
|
|
1904
|
+
aud: RESOURCE_AS,
|
|
1905
|
+
client_id: 'conformance-client',
|
|
1906
|
+
});
|
|
1907
|
+
expect(await verifiesWithPublishedKey(body.access_token)).toBe(true);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it('should refuse an audience that is not allowed', async () => {
|
|
1911
|
+
const tokens = await issueTokens();
|
|
1912
|
+
const response = await requestIdJag(tokens.id_token);
|
|
1913
|
+
|
|
1914
|
+
expect(response.status).toBe(400);
|
|
1915
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_target');
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it('should refuse to redeem an ID-JAG at the identity provider that issued it (draft §9.3)', async () => {
|
|
1919
|
+
idJagConfig.allowedAudiences = [RESOURCE_AS];
|
|
1920
|
+
const tokens = await issueTokens();
|
|
1921
|
+
const issued = (await (await requestIdJag(tokens.id_token)).json()) as { access_token: string };
|
|
1922
|
+
const response = await tokenRequest({
|
|
1923
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
1924
|
+
assertion: issued.access_token,
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
expect(response.status).toBe(400);
|
|
1928
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_grant');
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it('should advertise the identity chaining metadata', async () => {
|
|
1932
|
+
const response = await new Browser().request(discovery.GET, '/.well-known/openid-configuration');
|
|
1933
|
+
|
|
1934
|
+
expect(await response.json()).toMatchObject({
|
|
1935
|
+
identity_chaining_requested_token_types_supported: [ID_JAG_TYPE],
|
|
1936
|
+
authorization_grant_profiles_supported: ['urn:ietf:params:oauth:grant-profile:id-jag'],
|
|
1937
|
+
});
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
`;
|
|
1941
|
+
}
|
|
1942
|
+
function deviceAuthorizationSection() {
|
|
1943
|
+
return `describe('Device Authorization Grant (RFC 8628)', () => {
|
|
1944
|
+
const DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
1945
|
+
|
|
1946
|
+
interface DeviceAuthorizationResponse {
|
|
1947
|
+
device_code: string;
|
|
1948
|
+
user_code: string;
|
|
1949
|
+
verification_uri: string;
|
|
1950
|
+
verification_uri_complete: string;
|
|
1951
|
+
expires_in: number;
|
|
1952
|
+
interval: number;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
async function startDeviceAuthorization(): Promise<DeviceAuthorizationResponse> {
|
|
1956
|
+
const response = await new Browser().post(deviceAuthorization.POST, '/device_authorization', {
|
|
1957
|
+
client_id: 'conformance-client',
|
|
1958
|
+
client_secret: 'conformance-secret',
|
|
1959
|
+
scope: 'openid profile',
|
|
1960
|
+
});
|
|
1961
|
+
return (await response.json()) as DeviceAuthorizationResponse;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
function poll(deviceCode: string, clientId = 'conformance-client', clientSecret = 'conformance-secret'): Promise<Response> {
|
|
1965
|
+
return new Browser().post(token.POST, '/token', {
|
|
1966
|
+
grant_type: DEVICE_CODE,
|
|
1967
|
+
device_code: deviceCode,
|
|
1968
|
+
client_id: clientId,
|
|
1969
|
+
client_secret: clientSecret,
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/** The verification steps on the second device; resolves to the final screen. */
|
|
1974
|
+
async function approveOnSecondDevice(browser: Browser, userCode: string): Promise<Response> {
|
|
1975
|
+
const loginScreen = await (await browser.post(device.POST, '/device', { user_code: userCode })).text();
|
|
1976
|
+
const approvalScreen = await (
|
|
1977
|
+
await browser.post(deviceLogin.POST, '/device/login', {
|
|
1978
|
+
user_code: userCode,
|
|
1979
|
+
csrf_token: csrfTokenOf(loginScreen),
|
|
1980
|
+
username: 'testuser',
|
|
1981
|
+
password: 'password',
|
|
1982
|
+
})
|
|
1983
|
+
).text();
|
|
1984
|
+
return browser.post(deviceApprove.POST, '/device/approve', {
|
|
1985
|
+
user_code: userCode,
|
|
1986
|
+
csrf_token: csrfTokenOf(approvalScreen),
|
|
1987
|
+
decision: 'approve',
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
it('should answer the device authorization request (RFC 8628 §3.2)', async () => {
|
|
1992
|
+
const body = await startDeviceAuthorization();
|
|
1993
|
+
|
|
1994
|
+
expect(body).toMatchObject({
|
|
1995
|
+
verification_uri: ISSUER + '/device',
|
|
1996
|
+
expires_in: 600,
|
|
1997
|
+
interval: 5,
|
|
1998
|
+
});
|
|
1999
|
+
expect(body.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/);
|
|
2000
|
+
expect(body.verification_uri_complete).toBe(ISSUER + '/device?user_code=' + encodeURIComponent(body.user_code));
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it('should answer authorization_pending until the user decides (RFC 8628 §3.5)', async () => {
|
|
2004
|
+
const response = await poll((await startDeviceAuthorization()).device_code);
|
|
2005
|
+
|
|
2006
|
+
expect(response.status).toBe(400);
|
|
2007
|
+
expect(((await response.json()) as { error: string }).error).toBe('authorization_pending');
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it('should issue tokens after the user approves on a second device', async () => {
|
|
2011
|
+
const { device_code: deviceCode, user_code: userCode } = await startDeviceAuthorization();
|
|
2012
|
+
const completed = await approveOnSecondDevice(new Browser(), userCode);
|
|
2013
|
+
const response = await poll(deviceCode);
|
|
2014
|
+
const tokens = (await response.json()) as TokenResponse;
|
|
2015
|
+
|
|
2016
|
+
expect(completed.status).toBe(200);
|
|
2017
|
+
expect(response.status).toBe(200);
|
|
2018
|
+
expect(tokens).toMatchObject({ token_type: 'Bearer', scope: 'openid profile' });
|
|
2019
|
+
expect(decodeJwt(tokens.id_token).payload).toMatchObject({ sub: 'testuser', aud: 'conformance-client' });
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
it('should refuse the sign-in step without the browser binding cookie (RFC 8628 §5.4)', async () => {
|
|
2023
|
+
const { user_code: userCode } = await startDeviceAuthorization();
|
|
2024
|
+
const loginScreen = await (await new Browser().post(device.POST, '/device', { user_code: userCode })).text();
|
|
2025
|
+
const response = await new Browser().post(deviceLogin.POST, '/device/login', {
|
|
2026
|
+
user_code: userCode,
|
|
2027
|
+
csrf_token: csrfTokenOf(loginScreen),
|
|
2028
|
+
username: 'testuser',
|
|
2029
|
+
password: 'password',
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
expect(response.status).toBe(403);
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
it('should refuse a device_code presented by another client (RFC 8628 §3.4)', async () => {
|
|
2036
|
+
const { device_code: deviceCode, user_code: userCode } = await startDeviceAuthorization();
|
|
2037
|
+
await approveOnSecondDevice(new Browser(), userCode);
|
|
2038
|
+
const response = await poll(deviceCode, 'conformance-device-client', 'conformance-device-secret');
|
|
2039
|
+
|
|
2040
|
+
expect(response.status).toBe(400);
|
|
2041
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_grant');
|
|
2042
|
+
});
|
|
2043
|
+
});
|
|
2044
|
+
`;
|
|
2045
|
+
}
|
|
2046
|
+
function cibaSection() {
|
|
2047
|
+
return `describe('CIBA (CIBA Core 1.0, poll mode)', () => {
|
|
2048
|
+
const CIBA = 'urn:openid:params:grant-type:ciba';
|
|
2049
|
+
|
|
2050
|
+
async function requestAuthentication(loginHint = 'testuser'): Promise<Response> {
|
|
2051
|
+
return new Browser().post(backchannelAuthentication.POST, '/backchannel_authentication', {
|
|
2052
|
+
client_id: 'conformance-client',
|
|
2053
|
+
client_secret: 'conformance-secret',
|
|
2054
|
+
scope: 'openid profile',
|
|
2055
|
+
login_hint: loginHint,
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
async function authReqId(): Promise<string> {
|
|
2060
|
+
return ((await (await requestAuthentication()).json()) as { auth_req_id: string }).auth_req_id;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function poll(id: string, clientId = 'conformance-client', clientSecret = 'conformance-secret'): Promise<Response> {
|
|
2064
|
+
return new Browser().post(token.POST, '/token', {
|
|
2065
|
+
grant_type: CIBA,
|
|
2066
|
+
auth_req_id: id,
|
|
2067
|
+
client_id: clientId,
|
|
2068
|
+
client_secret: clientSecret,
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/** The user signs in at /ciba and approves the request id; resolves to the final screen. */
|
|
2073
|
+
async function approveOnOwnBrowser(browser: Browser, id: string): Promise<Response> {
|
|
2074
|
+
const loginScreen = await (await browser.request(ciba.GET, '/ciba')).text();
|
|
2075
|
+
const pendingRequests = await (
|
|
2076
|
+
await browser.post(cibaLogin.POST, '/ciba/login', {
|
|
2077
|
+
login_transaction_id: loginScreen.match(/name="login_transaction_id" value="([^"]+)"/)?.[1] ?? '',
|
|
2078
|
+
csrf_token: csrfTokenOf(loginScreen),
|
|
2079
|
+
username: 'testuser',
|
|
2080
|
+
password: 'password',
|
|
2081
|
+
})
|
|
2082
|
+
).text();
|
|
2083
|
+
// Every pending request of the user is listed, each in its own form.
|
|
2084
|
+
const form =
|
|
2085
|
+
pendingRequests.split('<form').find((html) => html.includes('name="auth_req_id" value="' + id + '"')) ?? '';
|
|
2086
|
+
return browser.post(cibaApprove.POST, '/ciba/approve', {
|
|
2087
|
+
auth_req_id: id,
|
|
2088
|
+
csrf_token: csrfTokenOf(form),
|
|
2089
|
+
decision: 'approve',
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
it('should accept a request for a known user (CIBA Core 1.0 §7.3)', async () => {
|
|
2094
|
+
const response = await requestAuthentication();
|
|
2095
|
+
const body = (await response.json()) as { auth_req_id: string; expires_in: number; interval: number };
|
|
2096
|
+
|
|
2097
|
+
expect(response.status).toBe(200);
|
|
2098
|
+
expect(body).toMatchObject({ expires_in: 120, interval: 5 });
|
|
2099
|
+
expect(body.auth_req_id).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
it('should reject a login_hint that names no user (CIBA Core 1.0 §13)', async () => {
|
|
2103
|
+
const response = await requestAuthentication('nobody');
|
|
2104
|
+
|
|
2105
|
+
expect(response.status).toBe(400);
|
|
2106
|
+
expect(((await response.json()) as { error: string }).error).toBe('unknown_user_id');
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
it('should answer authorization_pending until the user decides (CIBA Core 1.0 §11)', async () => {
|
|
2110
|
+
const response = await poll(await authReqId());
|
|
2111
|
+
|
|
2112
|
+
expect(response.status).toBe(400);
|
|
2113
|
+
expect(((await response.json()) as { error: string }).error).toBe('authorization_pending');
|
|
2114
|
+
});
|
|
2115
|
+
|
|
2116
|
+
it('should issue tokens after the user approves on their own browser', async () => {
|
|
2117
|
+
const id = await authReqId();
|
|
2118
|
+
const completed = await approveOnOwnBrowser(new Browser(), id);
|
|
2119
|
+
const response = await poll(id);
|
|
2120
|
+
const tokens = (await response.json()) as TokenResponse;
|
|
2121
|
+
|
|
2122
|
+
expect(completed.status).toBe(200);
|
|
2123
|
+
expect(response.status).toBe(200);
|
|
2124
|
+
expect(decodeJwt(tokens.id_token).payload).toMatchObject({ sub: 'testuser', aud: 'conformance-client' });
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
it('should refuse the sign-in form without the browser binding cookie', async () => {
|
|
2128
|
+
const loginScreen = await (await new Browser().request(ciba.GET, '/ciba')).text();
|
|
2129
|
+
const response = await new Browser().post(cibaLogin.POST, '/ciba/login', {
|
|
2130
|
+
login_transaction_id: loginScreen.match(/name="login_transaction_id" value="([^"]+)"/)?.[1] ?? '',
|
|
2131
|
+
csrf_token: csrfTokenOf(loginScreen),
|
|
2132
|
+
username: 'testuser',
|
|
2133
|
+
password: 'password',
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
expect(response.status).toBe(403);
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
it('should refuse an auth_req_id presented by another client (CIBA Core 1.0 §11)', async () => {
|
|
2140
|
+
const response = await poll(await authReqId(), 'conformance-ciba-client', 'conformance-ciba-secret');
|
|
2141
|
+
|
|
2142
|
+
expect(response.status).toBe(400);
|
|
2143
|
+
expect(((await response.json()) as { error: string }).error).toBe('invalid_grant');
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
`;
|
|
2147
|
+
}
|
|
2148
|
+
function jwtIntrospectionResponseSection() {
|
|
2149
|
+
return `describe('JWT Response for Token Introspection (RFC 9701)', () => {
|
|
2150
|
+
it('should answer the issuing client with a signed introspection JWT (RFC 9701 §5)', async () => {
|
|
2151
|
+
const tokens = await issueTokens();
|
|
2152
|
+
const response = await new Browser().post(
|
|
2153
|
+
introspect.POST,
|
|
2154
|
+
'/introspect',
|
|
2155
|
+
{ token: tokens.access_token, client_id: 'conformance-client', client_secret: 'conformance-secret' },
|
|
2156
|
+
{ Accept: 'application/token-introspection+jwt' },
|
|
2157
|
+
);
|
|
2158
|
+
const jwt = await response.text();
|
|
2159
|
+
|
|
2160
|
+
expect(response.headers.get('Content-Type')).toBe('application/token-introspection+jwt');
|
|
2161
|
+
expect(decodeJwt(jwt).header.typ).toBe('token-introspection+jwt');
|
|
2162
|
+
expect(decodeJwt(jwt).payload).toMatchObject({
|
|
2163
|
+
iss: ISSUER,
|
|
2164
|
+
aud: 'conformance-client',
|
|
2165
|
+
token_introspection: { active: true, sub: 'testuser', client_id: 'conformance-client' },
|
|
2166
|
+
});
|
|
2167
|
+
expect(await verifiesWithPublishedKey(jwt)).toBe(true);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it('should disclose nothing about the token to a caller that is not its audience (RFC 9701 §5)', async () => {
|
|
2171
|
+
const tokens = await issueTokens();
|
|
2172
|
+
const response = await new Browser().post(
|
|
2173
|
+
introspect.POST,
|
|
2174
|
+
'/introspect',
|
|
2175
|
+
{ token: tokens.access_token },
|
|
2176
|
+
{
|
|
2177
|
+
Accept: 'application/token-introspection+jwt',
|
|
2178
|
+
Authorization: basicAuthorization('conformance-basic-client', 'conformance-basic-secret'),
|
|
2179
|
+
},
|
|
2180
|
+
);
|
|
2181
|
+
const jwt = await response.text();
|
|
2182
|
+
|
|
2183
|
+
expect(decodeJwt(jwt).payload).toMatchObject({ aud: 'conformance-basic-client' });
|
|
2184
|
+
expect(decodeJwt(jwt).payload.token_introspection).toEqual({ active: false });
|
|
2185
|
+
});
|
|
2186
|
+
|
|
2187
|
+
it('should not answer an unauthenticated request with a signed response (RFC 9701 §5)', async () => {
|
|
2188
|
+
const tokens = await issueTokens();
|
|
2189
|
+
const response = await new Browser().post(
|
|
2190
|
+
introspect.POST,
|
|
2191
|
+
'/introspect',
|
|
2192
|
+
{ token: tokens.access_token },
|
|
2193
|
+
{ Accept: 'application/token-introspection+jwt' },
|
|
2194
|
+
);
|
|
2195
|
+
|
|
2196
|
+
expect(response.status).toBe(401);
|
|
2197
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
it('should advertise the introspection signing algorithm', async () => {
|
|
2201
|
+
const response = await new Browser().request(discovery.GET, '/.well-known/openid-configuration');
|
|
2202
|
+
|
|
2203
|
+
expect(((await response.json()) as Record<string, unknown>).introspection_signing_alg_values_supported).toEqual([
|
|
2204
|
+
'RS256',
|
|
2205
|
+
]);
|
|
2206
|
+
});
|
|
2207
|
+
});
|
|
2208
|
+
`;
|
|
2209
|
+
}
|
|
2210
|
+
function rpInitiatedLogoutSection() {
|
|
2211
|
+
return `describe('RP-Initiated Logout 1.0', () => {
|
|
2212
|
+
const POST_LOGOUT_REDIRECT_URI = 'https://rp.example.com/logged-out';
|
|
2213
|
+
|
|
2214
|
+
afterEach(() => {
|
|
2215
|
+
rpInitiatedLogoutConfig.postLogoutRedirectUris = {};
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it('should end the session and return to the registered URI for a valid id_token_hint (§2 / §3)', async () => {
|
|
2219
|
+
rpInitiatedLogoutConfig.postLogoutRedirectUris = { 'conformance-client': [POST_LOGOUT_REDIRECT_URI] };
|
|
2220
|
+
const browser = new Browser();
|
|
2221
|
+
const tokens = await issueTokens(browser);
|
|
2222
|
+
const response = await browser.request(
|
|
2223
|
+
logout.GET,
|
|
2224
|
+
'/logout?' +
|
|
2225
|
+
new URLSearchParams({
|
|
2226
|
+
id_token_hint: tokens.id_token,
|
|
2227
|
+
post_logout_redirect_uri: POST_LOGOUT_REDIRECT_URI,
|
|
2228
|
+
state: 'logout-state',
|
|
2229
|
+
}).toString(),
|
|
2230
|
+
);
|
|
2231
|
+
const silent = await browser.request(authorize.GET, authorizeUrl(authorizationRequest({ prompt: 'none' })));
|
|
2232
|
+
|
|
2233
|
+
expect(response.status).toBe(302);
|
|
2234
|
+
expect(locationOf(response)).toBe(POST_LOGOUT_REDIRECT_URI + '?state=logout-state');
|
|
2235
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2236
|
+
expect(new URL(locationOf(silent)).searchParams.get('error')).toBe('login_required');
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
it('should ask for confirmation when no id_token_hint is presented (§2)', async () => {
|
|
2240
|
+
const browser = new Browser();
|
|
2241
|
+
await signIn(browser);
|
|
2242
|
+
const confirmation = await browser.request(logout.GET, '/logout');
|
|
2243
|
+
const response = await browser.post(logoutApprove.POST, '/logout/approve', {
|
|
2244
|
+
csrf_token: csrfTokenOf(await confirmation.text()),
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
expect(confirmation.status).toBe(200);
|
|
2248
|
+
expect(response.status).toBe(200);
|
|
2249
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
it('should accept the logout request as a form POST (§2)', async () => {
|
|
2253
|
+
rpInitiatedLogoutConfig.postLogoutRedirectUris = { 'conformance-client': [POST_LOGOUT_REDIRECT_URI] };
|
|
2254
|
+
const browser = new Browser();
|
|
2255
|
+
const tokens = await issueTokens(browser);
|
|
2256
|
+
const response = await browser.post(logout.POST, '/logout', {
|
|
2257
|
+
id_token_hint: tokens.id_token,
|
|
2258
|
+
post_logout_redirect_uri: POST_LOGOUT_REDIRECT_URI,
|
|
2259
|
+
state: 'logout-state',
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
expect(response.status).toBe(302);
|
|
2263
|
+
expect(locationOf(response)).toBe(POST_LOGOUT_REDIRECT_URI + '?state=logout-state');
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
it('should not redirect to a post_logout_redirect_uri that is not registered (§3)', async () => {
|
|
2267
|
+
const browser = new Browser();
|
|
2268
|
+
const tokens = await issueTokens(browser);
|
|
2269
|
+
const response = await browser.request(
|
|
2270
|
+
logout.GET,
|
|
2271
|
+
'/logout?' +
|
|
2272
|
+
new URLSearchParams({
|
|
2273
|
+
id_token_hint: tokens.id_token,
|
|
2274
|
+
post_logout_redirect_uri: 'https://attacker.example/logged-out',
|
|
2275
|
+
}).toString(),
|
|
2276
|
+
);
|
|
2277
|
+
|
|
2278
|
+
expect(response.status).toBe(200);
|
|
2279
|
+
expect(response.headers.get('Location')).toBe(null);
|
|
2280
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
it('should refuse an approval from a browser without the confirmation cookie', async () => {
|
|
2284
|
+
const browser = new Browser();
|
|
2285
|
+
await signIn(browser);
|
|
2286
|
+
const confirmation = await (await browser.request(logout.GET, '/logout')).text();
|
|
2287
|
+
const response = await new Browser().post(logoutApprove.POST, '/logout/approve', {
|
|
2288
|
+
csrf_token: csrfTokenOf(confirmation),
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
expect(response.status).toBe(400);
|
|
2292
|
+
expect(browser.cookies.has('session_id')).toBe(true);
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
it('should refuse a confirmation without the matching csrf_token', async () => {
|
|
2296
|
+
const browser = new Browser();
|
|
2297
|
+
await signIn(browser);
|
|
2298
|
+
await browser.request(logout.GET, '/logout');
|
|
2299
|
+
const response = await browser.post(logoutApprove.POST, '/logout/approve', { csrf_token: 'forged' });
|
|
2300
|
+
|
|
2301
|
+
expect(response.status).toBe(400);
|
|
2302
|
+
expect(browser.cookies.has('session_id')).toBe(true);
|
|
2303
|
+
});
|
|
2304
|
+
});
|
|
2305
|
+
`;
|
|
2306
|
+
}
|
|
2307
|
+
function googleLoginSection() {
|
|
2308
|
+
return `describe('Sign in with Google (redirect mode)', () => {
|
|
2309
|
+
const GOOGLE_CLIENT_ID = 'conformance.apps.googleusercontent.com';
|
|
2310
|
+
const configuredGoogleLogin = config.googleLogin;
|
|
2311
|
+
|
|
2312
|
+
afterEach(() => {
|
|
2313
|
+
config.googleLogin = configuredGoogleLogin;
|
|
2314
|
+
});
|
|
2315
|
+
|
|
2316
|
+
/** Start a transaction and render its login page, which issues the nonce. */
|
|
2317
|
+
async function loginPage(browser: Browser): Promise<{ transactionId: string; html: string }> {
|
|
2318
|
+
const transactionId = await startAuthorization(browser);
|
|
2319
|
+
return { transactionId, html: await browser.render(LoginPage, { transaction_id: transactionId }) };
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/** The ID token payload Google would assert for this login page's nonce. */
|
|
2323
|
+
function googleIdToken(loginPageHtml: string, claims: Record<string, unknown> = {}): string {
|
|
2324
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2325
|
+
return JSON.stringify({
|
|
2326
|
+
iss: 'https://accounts.google.com',
|
|
2327
|
+
aud: GOOGLE_CLIENT_ID,
|
|
2328
|
+
sub: '1234567890',
|
|
2329
|
+
email: 'conformance@example.com',
|
|
2330
|
+
email_verified: true,
|
|
2331
|
+
nonce: loginPageHtml.match(/data-nonce="([^"]+)"/)?.[1] ?? '',
|
|
2332
|
+
iat: now,
|
|
2333
|
+
exp: now + 300,
|
|
2334
|
+
...claims,
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* POST the callback the way Google Identity Services does: it sets the
|
|
2340
|
+
* g_csrf_token cookie and posts the same value (double-submit).
|
|
2341
|
+
*/
|
|
2342
|
+
function postCallback(browser: Browser, credential: string, postedCsrfToken = 'double-submit'): Promise<Response> {
|
|
2343
|
+
browser.cookies.set('g_csrf_token', 'double-submit');
|
|
2344
|
+
return browser.post(googleLogin.POST, '/login/google', { credential, g_csrf_token: postedCsrfToken });
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
it('should render the Google button with the login_uri of this OP', async () => {
|
|
2348
|
+
const { html } = await loginPage(new Browser());
|
|
2349
|
+
|
|
2350
|
+
expect(html).toContain('data-login_uri="' + ISSUER + '/login/google"');
|
|
2351
|
+
expect(html).toContain('data-client_id="' + GOOGLE_CLIENT_ID + '"');
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
it('should sign in with the Google account and continue to consent', async () => {
|
|
2355
|
+
const browser = new Browser();
|
|
2356
|
+
const { transactionId, html } = await loginPage(browser);
|
|
2357
|
+
const response = await postCallback(browser, googleIdToken(html));
|
|
2358
|
+
const callback = new URL(await decide(browser, transactionId, 'approve'));
|
|
2359
|
+
const tokens = (await (await exchangeCode(callback.searchParams.get('code') ?? '')).json()) as TokenResponse;
|
|
2360
|
+
|
|
2361
|
+
expect(response.status).toBe(302);
|
|
2362
|
+
expect(locationOf(response)).toBe(ISSUER + '/consent?transaction_id=' + transactionId);
|
|
2363
|
+
expect(decodeJwt(tokens.id_token).payload.sub).toBe('google:1234567890');
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
it('should send a callback without the double-submit cookie to the OP error page', async () => {
|
|
2367
|
+
const browser = new Browser();
|
|
2368
|
+
const { html } = await loginPage(browser);
|
|
2369
|
+
const response = await browser.post(googleLogin.POST, '/login/google', {
|
|
2370
|
+
credential: googleIdToken(html),
|
|
2371
|
+
g_csrf_token: 'double-submit',
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
expect(response.status).toBe(303);
|
|
2375
|
+
expect(locationOf(response)).toBe(
|
|
2376
|
+
ISSUER + '/oidc-error?error=csrf_token_missing_in_cookie&error_description=No+CSRF+token+in+Cookie.',
|
|
2377
|
+
);
|
|
2378
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
it('should send a g_csrf_token that does not match its cookie to the OP error page', async () => {
|
|
2382
|
+
const browser = new Browser();
|
|
2383
|
+
const { html } = await loginPage(browser);
|
|
2384
|
+
const response = await postCallback(browser, googleIdToken(html), 'forged');
|
|
2385
|
+
|
|
2386
|
+
expect(response.status).toBe(303);
|
|
2387
|
+
expect(locationOf(response)).toBe(
|
|
2388
|
+
ISSUER + '/oidc-error?error=csrf_token_mismatch&error_description=Failed+to+verify+double+submit+cookie.',
|
|
2389
|
+
);
|
|
2390
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
it('should send a replayed ID token, whose nonce is single use, to the OP error page', async () => {
|
|
2394
|
+
const browser = new Browser();
|
|
2395
|
+
const { html } = await loginPage(browser);
|
|
2396
|
+
const credential = googleIdToken(html);
|
|
2397
|
+
await postCallback(browser, credential);
|
|
2398
|
+
const replay = await postCallback(new Browser(), credential);
|
|
2399
|
+
|
|
2400
|
+
expect(replay.status).toBe(303);
|
|
2401
|
+
expect(locationOf(replay)).toBe(
|
|
2402
|
+
ISSUER +
|
|
2403
|
+
'/oidc-error?error=login_nonce_not_found&error_description=' +
|
|
2404
|
+
'Google+login+attempt+not+found.+The+login+page+may+have+expired+or+the+credential+was+already+used.',
|
|
2405
|
+
);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
it('should send an account outside the configured hosted domain to the OP error page', async () => {
|
|
2409
|
+
config.googleLogin = { clientId: GOOGLE_CLIENT_ID, hostedDomain: 'example.com' };
|
|
2410
|
+
const browser = new Browser();
|
|
2411
|
+
const { html } = await loginPage(browser);
|
|
2412
|
+
const response = await postCallback(browser, googleIdToken(html, { hd: 'other.example' }));
|
|
2413
|
+
|
|
2414
|
+
expect(response.status).toBe(303);
|
|
2415
|
+
expect(locationOf(response)).toBe(
|
|
2416
|
+
ISSUER +
|
|
2417
|
+
'/oidc-error?error=invalid_hosted_domain&error_description=' +
|
|
2418
|
+
'ID+token+hd+does+not+match+the+allowed+hosted+domain',
|
|
2419
|
+
);
|
|
2420
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
it('should send a callback whose transaction has expired to the OP error page', async () => {
|
|
2424
|
+
const browser = new Browser();
|
|
2425
|
+
const { transactionId, html } = await loginPage(browser);
|
|
2426
|
+
await stores.transactionStore.delete('auth_txn:' + transactionId);
|
|
2427
|
+
const response = await postCallback(browser, googleIdToken(html));
|
|
2428
|
+
|
|
2429
|
+
expect(response.status).toBe(303);
|
|
2430
|
+
expect(locationOf(response)).toBe(
|
|
2431
|
+
ISSUER +
|
|
2432
|
+
'/oidc-error?error=transaction_not_found&error_description=' +
|
|
2433
|
+
'Auth+transaction+not+found.+The+session+may+have+expired.',
|
|
2434
|
+
);
|
|
2435
|
+
expect(browser.cookies.has('session_id')).toBe(false);
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
it('should answer 404 while Google login is not configured', async () => {
|
|
2439
|
+
config.googleLogin = undefined;
|
|
2440
|
+
const response = await postCallback(new Browser(), '{}');
|
|
2441
|
+
|
|
2442
|
+
expect(response.status).toBe(404);
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
`;
|
|
2446
|
+
}
|
|
2447
|
+
//# sourceMappingURL=conformance.js.map
|