@maronn-openid-connect/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/features.d.ts +19 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +64 -0
- package/dist/features.js.map +1 -0
- package/dist/frameworks/express/index.d.ts +7 -0
- package/dist/frameworks/express/index.d.ts.map +1 -0
- package/dist/frameworks/express/index.js +9 -0
- package/dist/frameworks/express/index.js.map +1 -0
- package/dist/frameworks/fastify/index.d.ts +7 -0
- package/dist/frameworks/fastify/index.d.ts.map +1 -0
- package/dist/frameworks/fastify/index.js +9 -0
- package/dist/frameworks/fastify/index.js.map +1 -0
- package/dist/frameworks/hono/index.d.ts +7 -0
- package/dist/frameworks/hono/index.d.ts.map +1 -0
- package/dist/frameworks/hono/index.js +36 -0
- package/dist/frameworks/hono/index.js.map +1 -0
- package/dist/frameworks/hono/templates.d.ts +37 -0
- package/dist/frameworks/hono/templates.d.ts.map +1 -0
- package/dist/frameworks/hono/templates.js +8315 -0
- package/dist/frameworks/hono/templates.js.map +1 -0
- package/dist/frameworks/index.d.ts +5 -0
- package/dist/frameworks/index.d.ts.map +1 -0
- package/dist/frameworks/index.js +19 -0
- package/dist/frameworks/index.js.map +1 -0
- package/dist/frameworks/nextjs/index.d.ts +7 -0
- package/dist/frameworks/nextjs/index.d.ts.map +1 -0
- package/dist/frameworks/nextjs/index.js +9 -0
- package/dist/frameworks/nextjs/index.js.map +1 -0
- package/dist/frameworks/types.d.ts +16 -0
- package/dist/frameworks/types.d.ts.map +1 -0
- package/dist/frameworks/types.js +2 -0
- package/dist/frameworks/types.js.map +1 -0
- package/dist/frameworks/web-standard/templates.d.ts +21 -0
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -0
- package/dist/frameworks/web-standard/templates.js +2191 -0
- package/dist/frameworks/web-standard/templates.js.map +1 -0
- package/dist/generator.d.ts +16 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +15 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +242 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
|
@@ -0,0 +1,2191 @@
|
|
|
1
|
+
import { DEFAULT_FEATURES } from '../../features.js';
|
|
2
|
+
import { authorizeRouteTemplate, configTemplate, conformanceTestClientsBlock, consentWithdrawalConformanceBlock, consentRouteTemplate, customViewConformanceTestBlock, discoveryRouteTemplate, endpointBehaviorConformanceBlock, featureDisabledDiscoveryConformanceTests, idTokenHintConformanceBlock, introspectionConformanceBlock, introspectionRouteTemplate, jwksRouteTemplate, loginRouteTemplate, parRouteTemplate, parConformanceBlock, tokenExchangeConformanceBlock, pkceDisabledConformanceBlock, persistentStorageConformanceBlock, requestObjectConformanceBeforeAll, requestObjectConformanceModuleSetup, resolversTemplate, reuseFlowConformanceTestBlock, revocationDisabledConformanceBlock, revocationRouteTemplate, scopesSupportedConformanceTest, storeTemplate, tokenEndpointAuthMethodsConformanceBlock, tokenRouteTemplate, userinfoRouteTemplate, viewsTemplate, } from '../hono/templates.js';
|
|
3
|
+
function toWebRouteTemplate(content) {
|
|
4
|
+
return content
|
|
5
|
+
.replace("import { Hono } from 'hono';", "import { WebRouter } from '../web-router.js';")
|
|
6
|
+
.replaceAll('new Hono<{ Variables: Record<string, any> }>()', 'new WebRouter()');
|
|
7
|
+
}
|
|
8
|
+
export function webRouterTemplate() {
|
|
9
|
+
return `export type WebHandler = (c: WebContext) => Response | Promise<Response>;
|
|
10
|
+
export type WebMiddleware = (
|
|
11
|
+
c: WebContext,
|
|
12
|
+
next: () => Promise<Response>,
|
|
13
|
+
) => Response | void | Promise<Response | void>;
|
|
14
|
+
|
|
15
|
+
interface Route {
|
|
16
|
+
method: string;
|
|
17
|
+
path: string;
|
|
18
|
+
handler: WebHandler;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MiddlewareEntry {
|
|
22
|
+
path: string;
|
|
23
|
+
handler: WebMiddleware;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MountEntry {
|
|
27
|
+
prefix: string;
|
|
28
|
+
router: WebRouter;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class WebRequest {
|
|
32
|
+
constructor(readonly raw: Request) {}
|
|
33
|
+
|
|
34
|
+
get method(): string {
|
|
35
|
+
return this.raw.method;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get url(): string {
|
|
39
|
+
return this.raw.url;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
header(name: string): string | undefined {
|
|
43
|
+
return this.raw.headers.get(name) ?? undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
query(name: string): string | undefined {
|
|
47
|
+
return new URL(this.raw.url).searchParams.get(name) ?? undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
text(): Promise<string> {
|
|
51
|
+
return this.raw.text();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async parseBody(): Promise<Record<string, string | File>> {
|
|
55
|
+
const contentType = this.raw.headers.get('Content-Type') ?? '';
|
|
56
|
+
const mediaType = contentType.toLowerCase().split(';')[0]?.trim() ?? '';
|
|
57
|
+
|
|
58
|
+
if (mediaType === 'application/x-www-form-urlencoded') {
|
|
59
|
+
const params = new URLSearchParams(await this.raw.text());
|
|
60
|
+
return Object.fromEntries(params);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mediaType === 'multipart/form-data') {
|
|
64
|
+
const formData = await this.raw.formData();
|
|
65
|
+
const body: Record<string, string | File> = {};
|
|
66
|
+
for (const [key, value] of formData.entries()) {
|
|
67
|
+
body[key] = value;
|
|
68
|
+
}
|
|
69
|
+
return body;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class WebContext {
|
|
77
|
+
readonly req: WebRequest;
|
|
78
|
+
private readonly variables = new Map<string, unknown>();
|
|
79
|
+
private readonly responseHeaders = new Headers();
|
|
80
|
+
|
|
81
|
+
constructor(request: Request) {
|
|
82
|
+
this.req = new WebRequest(request);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
set(key: string, value: unknown): void {
|
|
86
|
+
this.variables.set(key, value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mirrors Hono's loose context variable API so generated route templates can
|
|
90
|
+
// stay framework-neutral without forcing every c.get() call to cast.
|
|
91
|
+
get(key: string): any {
|
|
92
|
+
return this.variables.get(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
header(name: string, value: string): void {
|
|
96
|
+
this.responseHeaders.set(name, value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
json(data: unknown, status = 200): Response {
|
|
100
|
+
const headers = this.headersForResponse();
|
|
101
|
+
if (!headers.has('Content-Type')) {
|
|
102
|
+
headers.set('Content-Type', 'application/json');
|
|
103
|
+
}
|
|
104
|
+
return new Response(JSON.stringify(data), { status, headers });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
text(data: string, status = 200): Response {
|
|
108
|
+
const headers = this.headersForResponse();
|
|
109
|
+
if (!headers.has('Content-Type')) {
|
|
110
|
+
headers.set('Content-Type', 'text/plain; charset=UTF-8');
|
|
111
|
+
}
|
|
112
|
+
return new Response(data, { status, headers });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
html(data: string, status = 200): Response {
|
|
116
|
+
const headers = this.headersForResponse();
|
|
117
|
+
if (!headers.has('Content-Type')) {
|
|
118
|
+
headers.set('Content-Type', 'text/html; charset=UTF-8');
|
|
119
|
+
}
|
|
120
|
+
return new Response(data, { status, headers });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
body(data: BodyInit | null, status = 200): Response {
|
|
124
|
+
return new Response(data, { status, headers: this.headersForResponse() });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
redirect(url: string, status = 302): Response {
|
|
128
|
+
const headers = this.headersForResponse();
|
|
129
|
+
headers.set('Location', url);
|
|
130
|
+
return new Response(null, { status, headers });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private headersForResponse(): Headers {
|
|
134
|
+
return new Headers(this.responseHeaders);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class WebRouter {
|
|
139
|
+
private readonly routes: Route[] = [];
|
|
140
|
+
private readonly middleware: MiddlewareEntry[] = [];
|
|
141
|
+
private readonly mounts: MountEntry[] = [];
|
|
142
|
+
|
|
143
|
+
use(path: string, handler: WebMiddleware): void {
|
|
144
|
+
this.middleware.push({ path, handler });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
route(prefix: string, router: WebRouter): void {
|
|
148
|
+
this.mounts.push({ prefix: normalizeMount(prefix), router });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get(path: string, handler: WebHandler): void {
|
|
152
|
+
this.addRoute('GET', path, handler);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
post(path: string, handler: WebHandler): void {
|
|
156
|
+
this.addRoute('POST', path, handler);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
160
|
+
const request = input instanceof Request
|
|
161
|
+
? input
|
|
162
|
+
: new Request(resolveRequestInput(input), init);
|
|
163
|
+
return this.fetch(request);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fetch(request: Request): Promise<Response> {
|
|
167
|
+
const context = new WebContext(request);
|
|
168
|
+
const path = new URL(request.url).pathname;
|
|
169
|
+
return this.dispatch(context, normalizePath(path));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private addRoute(method: string, path: string, handler: WebHandler): void {
|
|
173
|
+
this.routes.push({ method, path: normalizePath(path), handler });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async dispatch(context: WebContext, path: string): Promise<Response> {
|
|
177
|
+
const middleware = this.middleware.filter((entry) =>
|
|
178
|
+
entry.path === '*' || pathMatches(entry.path, path),
|
|
179
|
+
);
|
|
180
|
+
let index = -1;
|
|
181
|
+
|
|
182
|
+
const run = async (): Promise<Response> => {
|
|
183
|
+
index += 1;
|
|
184
|
+
const entry = middleware[index];
|
|
185
|
+
if (!entry) {
|
|
186
|
+
return this.dispatchRoute(context, path);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let nextResponse: Response | undefined;
|
|
190
|
+
const result = await entry.handler(context, async () => {
|
|
191
|
+
nextResponse = await run();
|
|
192
|
+
return nextResponse;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (result instanceof Response) {
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
if (nextResponse) {
|
|
199
|
+
return nextResponse;
|
|
200
|
+
}
|
|
201
|
+
return new Response(null, { status: 204 });
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return run();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private dispatchRoute(context: WebContext, path: string): Promise<Response> {
|
|
208
|
+
for (const mount of this.mounts) {
|
|
209
|
+
const childPath = childPathForMount(path, mount.prefix);
|
|
210
|
+
if (childPath !== undefined) {
|
|
211
|
+
return mount.router.dispatch(context, childPath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const route = this.routes.find(
|
|
216
|
+
(candidate) =>
|
|
217
|
+
candidate.method === context.req.method &&
|
|
218
|
+
candidate.path === path,
|
|
219
|
+
);
|
|
220
|
+
if (route) {
|
|
221
|
+
return Promise.resolve(route.handler(context));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// RFC 9110 §9.1: general-purpose servers MUST support HEAD wherever GET is
|
|
225
|
+
// supported. RFC 9110 §9.3.2: HEAD shares GET semantics but MUST NOT return a
|
|
226
|
+
// body. Serve HEAD from the GET handler with the body stripped.
|
|
227
|
+
if (context.req.method === 'HEAD') {
|
|
228
|
+
const getRoute = this.routes.find(
|
|
229
|
+
(candidate) => candidate.method === 'GET' && candidate.path === path,
|
|
230
|
+
);
|
|
231
|
+
if (getRoute) {
|
|
232
|
+
return Promise.resolve(getRoute.handler(context)).then(
|
|
233
|
+
(response) =>
|
|
234
|
+
new Response(null, {
|
|
235
|
+
status: response.status,
|
|
236
|
+
statusText: response.statusText,
|
|
237
|
+
headers: response.headers,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const allowedMethods = this.routes
|
|
244
|
+
.filter((candidate) => candidate.path === path)
|
|
245
|
+
.map((candidate) => candidate.method);
|
|
246
|
+
if (allowedMethods.length > 0) {
|
|
247
|
+
return Promise.resolve(new Response(null, { status: 405, headers: { Allow: allowedMethods.join(', ') } }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return Promise.resolve(new Response('Not Found', { status: 404 }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function resolveRequestInput(input: RequestInfo | URL): RequestInfo | URL {
|
|
255
|
+
if (typeof input === 'string' && input.startsWith('/')) {
|
|
256
|
+
return new URL(input, 'http://localhost');
|
|
257
|
+
}
|
|
258
|
+
return input;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeMount(prefix: string): string {
|
|
262
|
+
const normalized = normalizePath(prefix);
|
|
263
|
+
return normalized === '/' ? '' : normalized;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizePath(path: string): string {
|
|
267
|
+
if (path === '') return '/';
|
|
268
|
+
return path.startsWith('/') ? path : '/' + path;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pathMatches(pattern: string, path: string): boolean {
|
|
272
|
+
const normalized = normalizeMount(pattern);
|
|
273
|
+
if (normalized === '') return true;
|
|
274
|
+
return path === normalized || path.startsWith(normalized + '/');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function childPathForMount(path: string, prefix: string): string | undefined {
|
|
278
|
+
if (path === prefix) return '/';
|
|
279
|
+
if (path.startsWith(prefix + '/')) {
|
|
280
|
+
const childPath = path.slice(prefix.length);
|
|
281
|
+
return childPath === '' ? '/' : childPath;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
export function nodeAdapterTemplate() {
|
|
288
|
+
return `import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
289
|
+
import { Readable } from 'node:stream';
|
|
290
|
+
|
|
291
|
+
export function toWebRequest(
|
|
292
|
+
incoming: IncomingMessage & { originalUrl?: string },
|
|
293
|
+
baseUrl = 'http://localhost',
|
|
294
|
+
bodyOverride?: BodyInit | null,
|
|
295
|
+
): Request {
|
|
296
|
+
const path = incoming.originalUrl ?? incoming.url ?? '/';
|
|
297
|
+
const url = new URL(path, baseUrl);
|
|
298
|
+
const headers = new Headers();
|
|
299
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
for (const item of value) headers.append(name, item);
|
|
302
|
+
} else if (value !== undefined) {
|
|
303
|
+
headers.set(name, value);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const method = incoming.method ?? 'GET';
|
|
308
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
309
|
+
const init: RequestInit & { duplex?: 'half' } = {
|
|
310
|
+
method,
|
|
311
|
+
headers,
|
|
312
|
+
};
|
|
313
|
+
if (hasBody) {
|
|
314
|
+
if (bodyOverride !== undefined) {
|
|
315
|
+
init.body = bodyOverride;
|
|
316
|
+
} else {
|
|
317
|
+
init.body = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;
|
|
318
|
+
init.duplex = 'half';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return new Request(url, init);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function writeWebResponse(
|
|
325
|
+
outgoing: ServerResponse,
|
|
326
|
+
response: Response,
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
outgoing.statusCode = response.status;
|
|
329
|
+
const setCookies = response.headers.getSetCookie();
|
|
330
|
+
if (setCookies.length > 0) {
|
|
331
|
+
outgoing.setHeader('Set-Cookie', setCookies);
|
|
332
|
+
}
|
|
333
|
+
response.headers.forEach((value, name) => {
|
|
334
|
+
if (name.toLowerCase() === 'set-cookie') return;
|
|
335
|
+
outgoing.setHeader(name, value);
|
|
336
|
+
});
|
|
337
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
338
|
+
outgoing.end(body);
|
|
339
|
+
}
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
export function webAppTemplate(corePkg, features = DEFAULT_FEATURES) {
|
|
343
|
+
const introspectionImport = features.introspection
|
|
344
|
+
? `import { introspectionApp } from './routes/introspection.js';\n`
|
|
345
|
+
: '';
|
|
346
|
+
const revocationImport = features.revocation
|
|
347
|
+
? `import { revocationApp } from './routes/revocation.js';\n`
|
|
348
|
+
: '';
|
|
349
|
+
const introspectionCors = features.introspection
|
|
350
|
+
? ` app.use('/introspect', protectedCors);\n`
|
|
351
|
+
: '';
|
|
352
|
+
const revocationCors = features.revocation
|
|
353
|
+
? ` app.use('/revoke', protectedCors);\n`
|
|
354
|
+
: '';
|
|
355
|
+
const introspectionMount = features.introspection
|
|
356
|
+
? ` app.route('/introspect', introspectionApp);\n`
|
|
357
|
+
: '';
|
|
358
|
+
const revocationMount = features.revocation
|
|
359
|
+
? ` app.route('/revoke', revocationApp);\n`
|
|
360
|
+
: '';
|
|
361
|
+
const parImport = features.par
|
|
362
|
+
? `import { parApp } from './routes/par.js';\n`
|
|
363
|
+
: '';
|
|
364
|
+
const parCors = features.par
|
|
365
|
+
? ` app.use('/par', protectedCors);\n`
|
|
366
|
+
: '';
|
|
367
|
+
const parMount = features.par
|
|
368
|
+
? ` app.route('/par', parApp);\n`
|
|
369
|
+
: '';
|
|
370
|
+
const parStorageContext = features.par
|
|
371
|
+
? ` c.set('parStore', parStore);\n`
|
|
372
|
+
: '';
|
|
373
|
+
const parStoreImport = features.par
|
|
374
|
+
? ` parStore,\n`
|
|
375
|
+
: '';
|
|
376
|
+
const refreshStorageContext = features.refreshToken
|
|
377
|
+
? ` c.set('refreshTokenResolver', storeResolvers.refreshTokenResolver);\n`
|
|
378
|
+
: '';
|
|
379
|
+
const introspectionStorageContext = features.introspection
|
|
380
|
+
? ` c.set('introspectionAccessTokenResolver', storeResolvers.introspectionAccessTokenResolver);
|
|
381
|
+
c.set('introspectionRefreshTokenResolver', storeResolvers.introspectionRefreshTokenResolver);\n`
|
|
382
|
+
: '';
|
|
383
|
+
const revocationStorageContext = features.revocation
|
|
384
|
+
? ` c.set('revocationResolvers', storeResolvers.revocationResolvers);\n`
|
|
385
|
+
: '';
|
|
386
|
+
return `import { WebRouter, type WebMiddleware } from './web-router.js';
|
|
387
|
+
import { authorizeApp } from './routes/authorize.js';
|
|
388
|
+
import { tokenApp } from './routes/token.js';
|
|
389
|
+
import { userinfoApp } from './routes/userinfo.js';
|
|
390
|
+
${introspectionImport}${revocationImport}${parImport}import { jwksApp } from './routes/jwks.js';
|
|
391
|
+
import { discoveryApp } from './routes/discovery.js';
|
|
392
|
+
import { loginApp } from './routes/login.js';
|
|
393
|
+
import { consentApp } from './routes/consent.js';
|
|
394
|
+
import {
|
|
395
|
+
createInMemoryClientResolver,
|
|
396
|
+
createProviderConfig,
|
|
397
|
+
type ProviderConfig,
|
|
398
|
+
} from './config.js';
|
|
399
|
+
import {
|
|
400
|
+
createStoreResolvers,
|
|
401
|
+
} from './resolvers.js';
|
|
402
|
+
import {
|
|
403
|
+
defaultProviderStores,
|
|
404
|
+
${parStoreImport} type ProviderStores,
|
|
405
|
+
} from './store.js';
|
|
406
|
+
import { createViews, type Views } from './views.js';
|
|
407
|
+
import {
|
|
408
|
+
assertHasRs256Key,
|
|
409
|
+
assertKeyStrength,
|
|
410
|
+
assertKidStrategyConsistent,
|
|
411
|
+
getRegisteredSigningKeys,
|
|
412
|
+
signingKeysToJwkSet,
|
|
413
|
+
} from '${corePkg}';
|
|
414
|
+
import type {
|
|
415
|
+
SigningKey,
|
|
416
|
+
SigningKeyProvider,
|
|
417
|
+
ClientResolver,
|
|
418
|
+
TokenClientResolver,
|
|
419
|
+
AcrResolver,
|
|
420
|
+
JwkSet,
|
|
421
|
+
SessionResolver,
|
|
422
|
+
ConsentResolver,
|
|
423
|
+
} from '${corePkg}';
|
|
424
|
+
|
|
425
|
+
export type CorsOrigins = string | string[];
|
|
426
|
+
|
|
427
|
+
export interface OidcProviderOptions {
|
|
428
|
+
config?: Partial<ProviderConfig>;
|
|
429
|
+
signingKeyProvider: SigningKeyProvider;
|
|
430
|
+
idTokenSigningKeyProvider?: SigningKeyProvider;
|
|
431
|
+
userinfoSigningKeyProvider?: SigningKeyProvider;
|
|
432
|
+
clientResolver?: ClientResolver;
|
|
433
|
+
tokenClientResolver?: TokenClientResolver;
|
|
434
|
+
sessionResolver?: SessionResolver;
|
|
435
|
+
consentResolver?: ConsentResolver;
|
|
436
|
+
/** Persistent stores shared by Route Handlers and Server Actions. */
|
|
437
|
+
storage?: ProviderStores;
|
|
438
|
+
acrResolver?: AcrResolver;
|
|
439
|
+
jwksProvider?: () => Promise<JwkSet> | JwkSet;
|
|
440
|
+
corsOrigins?: CorsOrigins;
|
|
441
|
+
/**
|
|
442
|
+
* Custom UI for the login / consent / error pages.
|
|
443
|
+
* Provide any subset; omitted pages fall back to the default views.
|
|
444
|
+
* Inject your own UI here instead of editing views.ts.
|
|
445
|
+
*/
|
|
446
|
+
views?: Partial<Views>;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function validateSigningKeySet(
|
|
450
|
+
keys: readonly SigningKey[],
|
|
451
|
+
requireRs256 = false,
|
|
452
|
+
): void {
|
|
453
|
+
assertKeyStrength(keys);
|
|
454
|
+
assertKidStrategyConsistent(keys);
|
|
455
|
+
if (requireRs256) {
|
|
456
|
+
assertHasRs256Key(keys.map((key) => key.privateKey));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function createApp(options: OidcProviderOptions): WebRouter {
|
|
461
|
+
const app = new WebRouter();
|
|
462
|
+
|
|
463
|
+
const corsOrigins = options.corsOrigins ?? '*';
|
|
464
|
+
const protectedCors = createCorsMiddleware({
|
|
465
|
+
origins: corsOrigins,
|
|
466
|
+
allowMethods: ['POST', 'GET', 'OPTIONS'],
|
|
467
|
+
allowHeaders: ['Authorization', 'Content-Type'],
|
|
468
|
+
maxAge: 600,
|
|
469
|
+
});
|
|
470
|
+
const publicCors = createCorsMiddleware({
|
|
471
|
+
origins: '*',
|
|
472
|
+
allowMethods: ['GET', 'OPTIONS'],
|
|
473
|
+
allowHeaders: ['Content-Type'],
|
|
474
|
+
maxAge: 600,
|
|
475
|
+
});
|
|
476
|
+
app.use('/token', protectedCors);
|
|
477
|
+
app.use('/userinfo', protectedCors);
|
|
478
|
+
${introspectionCors}${revocationCors}${parCors} app.use('/.well-known/openid-configuration', publicCors);
|
|
479
|
+
app.use('/.well-known/jwks.json', publicCors);
|
|
480
|
+
|
|
481
|
+
app.use('*', async (c, next) => {
|
|
482
|
+
let signingKey;
|
|
483
|
+
let idTokenSigningKey;
|
|
484
|
+
let userinfoSigningKey;
|
|
485
|
+
let signingKeys;
|
|
486
|
+
let idTokenSigningKeys;
|
|
487
|
+
let userinfoSigningKeys;
|
|
488
|
+
try {
|
|
489
|
+
signingKey = await options.signingKeyProvider.getSigningKey();
|
|
490
|
+
signingKeys = await getRegisteredSigningKeys(options.signingKeyProvider);
|
|
491
|
+
const idProvider = options.idTokenSigningKeyProvider ?? options.signingKeyProvider;
|
|
492
|
+
idTokenSigningKey = await idProvider.getSigningKey();
|
|
493
|
+
idTokenSigningKeys = await getRegisteredSigningKeys(idProvider);
|
|
494
|
+
const uiProvider = options.userinfoSigningKeyProvider ?? options.signingKeyProvider;
|
|
495
|
+
userinfoSigningKey = await uiProvider.getSigningKey();
|
|
496
|
+
userinfoSigningKeys = await getRegisteredSigningKeys(uiProvider);
|
|
497
|
+
validateSigningKeySet(signingKeys);
|
|
498
|
+
validateSigningKeySet(idTokenSigningKeys, true);
|
|
499
|
+
validateSigningKeySet(userinfoSigningKeys);
|
|
500
|
+
} catch {
|
|
501
|
+
return c.json({ error: 'server_error', error_description: 'Failed to load signing key' }, 503);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const { privateKey, publicJwk, keyId } = signingKey;
|
|
505
|
+
const clientResolver =
|
|
506
|
+
options.clientResolver ?? createInMemoryClientResolver();
|
|
507
|
+
const stores = options.storage ?? defaultProviderStores;
|
|
508
|
+
const storeResolvers = createStoreResolvers(stores);
|
|
509
|
+
|
|
510
|
+
c.set('privateKey', privateKey);
|
|
511
|
+
c.set('publicJwk', publicJwk);
|
|
512
|
+
c.set('keyId', keyId);
|
|
513
|
+
c.set('idTokenPrivateKey', idTokenSigningKey.privateKey);
|
|
514
|
+
c.set('idTokenPublicJwk', idTokenSigningKey.publicJwk);
|
|
515
|
+
c.set('idTokenKeyId', idTokenSigningKey.keyId);
|
|
516
|
+
c.set('userinfoPrivateKey', userinfoSigningKey.privateKey);
|
|
517
|
+
c.set('userinfoPublicJwk', userinfoSigningKey.publicJwk);
|
|
518
|
+
c.set('userinfoKeyId', userinfoSigningKey.keyId);
|
|
519
|
+
c.set('signingKeys', signingKeys);
|
|
520
|
+
c.set('idTokenSigningKeys', idTokenSigningKeys);
|
|
521
|
+
c.set('userinfoSigningKeys', userinfoSigningKeys);
|
|
522
|
+
c.set('config', createProviderConfig(options.config));
|
|
523
|
+
c.set('clientResolver', clientResolver);
|
|
524
|
+
c.set('tokenClientResolver', options.tokenClientResolver ?? clientResolver);
|
|
525
|
+
c.set('transactionStore', stores.transactionStore);
|
|
526
|
+
c.set('authCodeStore', stores.authCodeStore);
|
|
527
|
+
c.set('accessTokenStore', stores.accessTokenStore);
|
|
528
|
+
c.set('refreshTokenStore', stores.refreshTokenStore);
|
|
529
|
+
c.set('authSessionStore', stores.authSessionStore);
|
|
530
|
+
c.set('browserSessionStore', stores.browserSessionStore);
|
|
531
|
+
c.set('authenticateUser', (username: string, password: string) =>
|
|
532
|
+
stores.userStore.authenticate(username, password));
|
|
533
|
+
c.set('authCodeResolver', storeResolvers.authorizationCodeResolver);
|
|
534
|
+
c.set('accessTokenResolver', storeResolvers.accessTokenResolver);
|
|
535
|
+
c.set('userClaimsResolver', storeResolvers.userClaimsResolver);
|
|
536
|
+
${refreshStorageContext}${introspectionStorageContext}${revocationStorageContext}${parStorageContext}
|
|
537
|
+
if (options.acrResolver) {
|
|
538
|
+
c.set('acrResolver', options.acrResolver);
|
|
539
|
+
}
|
|
540
|
+
// P1: id_token_hint 検証用 JWKS プロバイダ。未指定なら OP 自身の ID Token
|
|
541
|
+
// 署名鍵セットを既定として使い、OP が発行した ID Token を hint として検証できる
|
|
542
|
+
// ようにする(OIDC Core 1.0 §3.1.2.2)。明示指定があれば優先。
|
|
543
|
+
c.set('jwksProvider', options.jwksProvider ?? (() => signingKeysToJwkSet(idTokenSigningKeys)));
|
|
544
|
+
c.set('sessionResolver', options.sessionResolver ?? storeResolvers.sessionResolver);
|
|
545
|
+
c.set('consentResolver', options.consentResolver ?? storeResolvers.consentResolver);
|
|
546
|
+
// Inject custom UI (login / consent / error) merged over the defaults.
|
|
547
|
+
c.set('views', createViews(options.views));
|
|
548
|
+
await next();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
app.route('/authorize', authorizeApp);
|
|
552
|
+
app.route('/token', tokenApp);
|
|
553
|
+
app.route('/userinfo', userinfoApp);
|
|
554
|
+
${introspectionMount}${revocationMount}${parMount} app.route('/.well-known/jwks.json', jwksApp);
|
|
555
|
+
app.route('/.well-known/openid-configuration', discoveryApp);
|
|
556
|
+
app.route('/login', loginApp);
|
|
557
|
+
app.route('/consent', consentApp);
|
|
558
|
+
|
|
559
|
+
return app;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
interface CorsOptions {
|
|
563
|
+
origins: CorsOrigins;
|
|
564
|
+
allowMethods: string[];
|
|
565
|
+
allowHeaders: string[];
|
|
566
|
+
maxAge: number;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function createCorsMiddleware(options: CorsOptions): WebMiddleware {
|
|
570
|
+
return async (c, next) => {
|
|
571
|
+
const origin = resolveCorsOrigin(c.req.raw.headers.get('Origin'), options.origins);
|
|
572
|
+
if (origin) {
|
|
573
|
+
c.header('Access-Control-Allow-Origin', origin);
|
|
574
|
+
}
|
|
575
|
+
c.header('Vary', 'Origin');
|
|
576
|
+
c.header('Access-Control-Allow-Methods', options.allowMethods.join(','));
|
|
577
|
+
c.header('Access-Control-Allow-Headers', options.allowHeaders.join(','));
|
|
578
|
+
c.header('Access-Control-Max-Age', String(options.maxAge));
|
|
579
|
+
|
|
580
|
+
if (c.req.method === 'OPTIONS') {
|
|
581
|
+
return c.body(null, 204);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
await next();
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function resolveCorsOrigin(requestOrigin: string | null, allowed: CorsOrigins): string | undefined {
|
|
589
|
+
if (allowed === '*') return '*';
|
|
590
|
+
if (typeof allowed === 'string') return allowed;
|
|
591
|
+
if (requestOrigin && allowed.includes(requestOrigin)) return requestOrigin;
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
596
|
+
export function expressApplyTemplate(features = DEFAULT_FEATURES) {
|
|
597
|
+
const introspectionEndpoint = features.introspection
|
|
598
|
+
? ` '/introspect',\n`
|
|
599
|
+
: '';
|
|
600
|
+
const revocationEndpoint = features.revocation
|
|
601
|
+
? ` '/revoke',\n`
|
|
602
|
+
: '';
|
|
603
|
+
const parEndpoint = features.par
|
|
604
|
+
? ` '/par',\n`
|
|
605
|
+
: '';
|
|
606
|
+
return `import type { Express } from 'express';
|
|
607
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
608
|
+
import { createApp, type OidcProviderOptions } from './app.js';
|
|
609
|
+
import { toWebRequest, writeWebResponse } from './node-adapter.js';
|
|
610
|
+
|
|
611
|
+
export type ApplyOidcOptions = OidcProviderOptions;
|
|
612
|
+
|
|
613
|
+
const OIDC_ENDPOINTS = [
|
|
614
|
+
'/authorize',
|
|
615
|
+
'/token',
|
|
616
|
+
'/userinfo',
|
|
617
|
+
${introspectionEndpoint}${revocationEndpoint}${parEndpoint} '/.well-known/jwks.json',
|
|
618
|
+
'/.well-known/openid-configuration',
|
|
619
|
+
'/login',
|
|
620
|
+
'/consent',
|
|
621
|
+
] as const;
|
|
622
|
+
|
|
623
|
+
export function applyOidc(app: Express, options: ApplyOidcOptions): void {
|
|
624
|
+
const oidc = createApp(options);
|
|
625
|
+
const baseUrl = options.config?.issuer ?? 'http://localhost';
|
|
626
|
+
|
|
627
|
+
for (const endpoint of OIDC_ENDPOINTS) {
|
|
628
|
+
app.use(endpoint, async (req: Request, res: Response, next: NextFunction) => {
|
|
629
|
+
try {
|
|
630
|
+
const response = await oidc.request(toWebRequest(req, baseUrl));
|
|
631
|
+
await writeWebResponse(res, response);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
next(error);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
export function fastifyApplyTemplate(features = DEFAULT_FEATURES) {
|
|
641
|
+
const introspectionRoute = features.introspection
|
|
642
|
+
? ` app.route({ method: ['POST', 'OPTIONS'], url: '/introspect', handler: handle });\n`
|
|
643
|
+
: '';
|
|
644
|
+
const revocationRoute = features.revocation
|
|
645
|
+
? ` app.route({ method: ['POST', 'OPTIONS'], url: '/revoke', handler: handle });\n`
|
|
646
|
+
: '';
|
|
647
|
+
const parRoute = features.par
|
|
648
|
+
? ` app.route({ method: ['POST', 'OPTIONS'], url: '/par', handler: handle });\n`
|
|
649
|
+
: '';
|
|
650
|
+
return `import type { FastifyInstance } from 'fastify';
|
|
651
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
652
|
+
import { createApp, type OidcProviderOptions } from './app.js';
|
|
653
|
+
import { toWebRequest } from './node-adapter.js';
|
|
654
|
+
|
|
655
|
+
export type ApplyOidcOptions = OidcProviderOptions;
|
|
656
|
+
|
|
657
|
+
export async function applyOidc(app: FastifyInstance, options: ApplyOidcOptions): Promise<void> {
|
|
658
|
+
const oidc = createApp(options);
|
|
659
|
+
const baseUrl = options.config?.issuer ?? 'http://localhost';
|
|
660
|
+
|
|
661
|
+
if (!app.hasContentTypeParser('application/x-www-form-urlencoded')) {
|
|
662
|
+
app.addContentTypeParser(
|
|
663
|
+
'application/x-www-form-urlencoded',
|
|
664
|
+
{ parseAs: 'buffer' },
|
|
665
|
+
(_request, body, done) => {
|
|
666
|
+
done(null, body);
|
|
667
|
+
},
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
672
|
+
const body = Buffer.isBuffer(request.body)
|
|
673
|
+
? request.body.buffer.slice(
|
|
674
|
+
request.body.byteOffset,
|
|
675
|
+
request.body.byteOffset + request.body.byteLength,
|
|
676
|
+
) as ArrayBuffer
|
|
677
|
+
: undefined;
|
|
678
|
+
const response = await oidc.request(toWebRequest(request.raw, baseUrl, body));
|
|
679
|
+
await toFastifyReply(reply, response);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
app.route({ method: ['GET', 'POST', 'OPTIONS'], url: '/authorize', handler: handle });
|
|
683
|
+
app.route({ method: ['POST', 'OPTIONS'], url: '/token', handler: handle });
|
|
684
|
+
app.route({ method: ['GET', 'POST', 'OPTIONS'], url: '/userinfo', handler: handle });
|
|
685
|
+
${introspectionRoute}${revocationRoute}${parRoute} app.route({ method: ['GET', 'OPTIONS'], url: '/.well-known/jwks.json', handler: handle });
|
|
686
|
+
app.route({ method: ['GET', 'OPTIONS'], url: '/.well-known/openid-configuration', handler: handle });
|
|
687
|
+
app.route({ method: ['GET', 'POST'], url: '/login', handler: handle });
|
|
688
|
+
app.route({ method: ['GET', 'POST'], url: '/consent', handler: handle });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function toFastifyReply(reply: FastifyReply, response: Response): Promise<void> {
|
|
692
|
+
reply.status(response.status);
|
|
693
|
+
const setCookies = response.headers.getSetCookie();
|
|
694
|
+
if (setCookies.length > 0) {
|
|
695
|
+
reply.header('Set-Cookie', setCookies);
|
|
696
|
+
}
|
|
697
|
+
response.headers.forEach((value, name) => {
|
|
698
|
+
if (name.toLowerCase() === 'set-cookie') return;
|
|
699
|
+
reply.header(name, value);
|
|
700
|
+
});
|
|
701
|
+
reply.send(Buffer.from(await response.arrayBuffer()));
|
|
702
|
+
}
|
|
703
|
+
`;
|
|
704
|
+
}
|
|
705
|
+
export function nextJsRouteHandlerTemplate() {
|
|
706
|
+
return `import { createApp, type OidcProviderOptions } from './app';
|
|
707
|
+
|
|
708
|
+
export type NextOidcProviderOptions = OidcProviderOptions;
|
|
709
|
+
export type NextOidcRouteHandler = (request: Request) => Promise<Response>;
|
|
710
|
+
|
|
711
|
+
export interface NextOidcRouteHandlers {
|
|
712
|
+
GET: NextOidcRouteHandler;
|
|
713
|
+
POST: NextOidcRouteHandler;
|
|
714
|
+
OPTIONS: NextOidcRouteHandler;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function createOidcRouteHandlers(options: NextOidcProviderOptions): NextOidcRouteHandlers {
|
|
718
|
+
const oidc = createApp(options);
|
|
719
|
+
const handle = (request: Request): Promise<Response> =>
|
|
720
|
+
oidc.request(rebaseRequestOrigin(request, options.config?.issuer));
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
GET: handle,
|
|
724
|
+
POST: handle,
|
|
725
|
+
OPTIONS: handle,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function rebaseRequestOrigin(request: Request, issuer: string | undefined): Request {
|
|
730
|
+
if (!issuer) return request;
|
|
731
|
+
|
|
732
|
+
const issuerUrl = new URL(issuer);
|
|
733
|
+
const requestUrl = new URL(request.url);
|
|
734
|
+
if (requestUrl.origin === issuerUrl.origin) return request;
|
|
735
|
+
|
|
736
|
+
requestUrl.protocol = issuerUrl.protocol;
|
|
737
|
+
requestUrl.host = issuerUrl.host;
|
|
738
|
+
const init: RequestInit & { duplex?: 'half' } = {
|
|
739
|
+
method: request.method,
|
|
740
|
+
headers: request.headers,
|
|
741
|
+
body: request.body,
|
|
742
|
+
redirect: request.redirect,
|
|
743
|
+
signal: request.signal,
|
|
744
|
+
};
|
|
745
|
+
if (request.body) {
|
|
746
|
+
init.duplex = 'half';
|
|
747
|
+
}
|
|
748
|
+
return new Request(requestUrl, init);
|
|
749
|
+
}
|
|
750
|
+
`;
|
|
751
|
+
}
|
|
752
|
+
export function nextJsStorageBackendTemplate() {
|
|
753
|
+
return `import { mkdirSync } from 'node:fs';
|
|
754
|
+
import { dirname, resolve } from 'node:path';
|
|
755
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
756
|
+
import {
|
|
757
|
+
createJsonProviderStores,
|
|
758
|
+
type JsonStoreBackend,
|
|
759
|
+
type JsonStoreEntry,
|
|
760
|
+
type ProviderStores,
|
|
761
|
+
} from './store';
|
|
762
|
+
|
|
763
|
+
declare const process: { env: Record<string, string | undefined> };
|
|
764
|
+
|
|
765
|
+
interface StoredRow {
|
|
766
|
+
key: string;
|
|
767
|
+
value: string;
|
|
768
|
+
expires_at: number | null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
class SqliteJsonStoreBackend implements JsonStoreBackend {
|
|
772
|
+
private readonly database: DatabaseSync;
|
|
773
|
+
|
|
774
|
+
constructor(path: string) {
|
|
775
|
+
const databasePath = path === ':memory:' ? path : resolve(path);
|
|
776
|
+
if (databasePath !== ':memory:') {
|
|
777
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
778
|
+
}
|
|
779
|
+
this.database = new DatabaseSync(databasePath);
|
|
780
|
+
// Concurrent processes opening the same file (e.g. Next.js build workers
|
|
781
|
+
// collecting page data) race on the initial schema write; without a busy
|
|
782
|
+
// timeout SQLite fails fast with "database is locked" instead of waiting.
|
|
783
|
+
this.database.exec('PRAGMA busy_timeout = 5000');
|
|
784
|
+
this.database.exec('PRAGMA journal_mode = WAL');
|
|
785
|
+
this.database.exec(
|
|
786
|
+
'CREATE TABLE IF NOT EXISTS oidc_store (' +
|
|
787
|
+
'key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)',
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async get<T>(key: string): Promise<T | null> {
|
|
792
|
+
const row = this.database
|
|
793
|
+
.prepare('SELECT key, value, expires_at FROM oidc_store WHERE key = ?')
|
|
794
|
+
.get(key) as unknown as StoredRow | undefined;
|
|
795
|
+
if (!row) return null;
|
|
796
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
797
|
+
await this.delete(key);
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
return JSON.parse(row.value) as T;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
|
804
|
+
const expiresAt = ttlSeconds === undefined ? null : Date.now() + ttlSeconds * 1000;
|
|
805
|
+
this.database.prepare(
|
|
806
|
+
'INSERT INTO oidc_store (key, value, expires_at) VALUES (?, ?, ?) ' +
|
|
807
|
+
'ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at',
|
|
808
|
+
).run(key, JSON.stringify(value), expiresAt);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async delete(key: string): Promise<void> {
|
|
812
|
+
this.database.prepare('DELETE FROM oidc_store WHERE key = ?').run(key);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
|
|
816
|
+
const rows = this.database
|
|
817
|
+
.prepare(
|
|
818
|
+
'SELECT key, value, expires_at FROM oidc_store ' +
|
|
819
|
+
'WHERE key >= ? AND key < ? ORDER BY key',
|
|
820
|
+
)
|
|
821
|
+
.all(prefix, prefix + '\\uffff') as unknown as StoredRow[];
|
|
822
|
+
const entries: Array<JsonStoreEntry<T>> = [];
|
|
823
|
+
for (const row of rows) {
|
|
824
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
825
|
+
await this.delete(row.key);
|
|
826
|
+
} else {
|
|
827
|
+
entries.push({ key: row.key, value: JSON.parse(row.value) as T });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return entries;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
interface UpstashResponse<T> {
|
|
835
|
+
result?: T;
|
|
836
|
+
error?: string;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
class UpstashRedisJsonStoreBackend implements JsonStoreBackend {
|
|
840
|
+
constructor(
|
|
841
|
+
private readonly url: string,
|
|
842
|
+
private readonly token: string,
|
|
843
|
+
private readonly namespace = 'maronn-oidc:',
|
|
844
|
+
) {}
|
|
845
|
+
|
|
846
|
+
async get<T>(key: string): Promise<T | null> {
|
|
847
|
+
const value = await this.command<string | null>(['GET', this.fullKey(key)]);
|
|
848
|
+
return value === null ? null : JSON.parse(value) as T;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
|
852
|
+
const command: Array<string | number> = ['SET', this.fullKey(key), JSON.stringify(value)];
|
|
853
|
+
if (ttlSeconds !== undefined) command.push('EX', ttlSeconds);
|
|
854
|
+
await this.command<string>(command);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async delete(key: string): Promise<void> {
|
|
858
|
+
await this.command<number>(['DEL', this.fullKey(key)]);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
|
|
862
|
+
const keys: string[] = [];
|
|
863
|
+
let cursor = '0';
|
|
864
|
+
do {
|
|
865
|
+
const result = await this.command<[string, string[]]>([
|
|
866
|
+
'SCAN',
|
|
867
|
+
cursor,
|
|
868
|
+
'MATCH',
|
|
869
|
+
this.fullKey(prefix) + '*',
|
|
870
|
+
'COUNT',
|
|
871
|
+
100,
|
|
872
|
+
]);
|
|
873
|
+
cursor = String(result[0]);
|
|
874
|
+
keys.push(...result[1]);
|
|
875
|
+
} while (cursor !== '0');
|
|
876
|
+
|
|
877
|
+
const entries: Array<JsonStoreEntry<T>> = [];
|
|
878
|
+
for (const fullKey of keys) {
|
|
879
|
+
const value = await this.command<string | null>(['GET', fullKey]);
|
|
880
|
+
if (value !== null) {
|
|
881
|
+
entries.push({
|
|
882
|
+
key: fullKey.slice(this.namespace.length),
|
|
883
|
+
value: JSON.parse(value) as T,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return entries;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private fullKey(key: string): string {
|
|
891
|
+
return this.namespace + key;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private async command<T>(command: Array<string | number>): Promise<T> {
|
|
895
|
+
const response = await fetch(this.url, {
|
|
896
|
+
method: 'POST',
|
|
897
|
+
headers: {
|
|
898
|
+
Authorization: 'Bearer ' + this.token,
|
|
899
|
+
'Content-Type': 'application/json',
|
|
900
|
+
},
|
|
901
|
+
body: JSON.stringify(command),
|
|
902
|
+
cache: 'no-store',
|
|
903
|
+
});
|
|
904
|
+
const body = await response.json() as UpstashResponse<T>;
|
|
905
|
+
if (!response.ok || body.error || !('result' in body)) {
|
|
906
|
+
throw new Error(body.error ?? 'Upstash Redis request failed with HTTP ' + response.status);
|
|
907
|
+
}
|
|
908
|
+
return body.result as T;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const storageRegistry = globalThis as typeof globalThis & {
|
|
913
|
+
__oidcNextJsProviderStores?: ProviderStores;
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
export function createNextJsProviderStores(): ProviderStores {
|
|
917
|
+
return (storageRegistry.__oidcNextJsProviderStores ??= createStores());
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function createStores(): ProviderStores {
|
|
921
|
+
const redisUrl = readEnv('UPSTASH_REDIS_REST_URL');
|
|
922
|
+
const redisToken = readEnv('UPSTASH_REDIS_REST_TOKEN');
|
|
923
|
+
if (redisUrl && redisToken) {
|
|
924
|
+
return createJsonProviderStores(new UpstashRedisJsonStoreBackend(redisUrl, redisToken));
|
|
925
|
+
}
|
|
926
|
+
if (readEnv('VERCEL')) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
'UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required on Vercel',
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
const sqlitePath = readEnv('OIDC_SQLITE_PATH') ?? '.data/oidc.sqlite';
|
|
932
|
+
return createJsonProviderStores(new SqliteJsonStoreBackend(sqlitePath));
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function readEnv(name: string): string | undefined {
|
|
936
|
+
return process.env[name];
|
|
937
|
+
}
|
|
938
|
+
`;
|
|
939
|
+
}
|
|
940
|
+
export function nextJsRuntimeTemplate(corePkg) {
|
|
941
|
+
return `import {
|
|
942
|
+
createCachedSigningKeyProvider,
|
|
943
|
+
type AcrResolver,
|
|
944
|
+
type SigningKey,
|
|
945
|
+
type SigningKeyProvider,
|
|
946
|
+
} from '${corePkg}';
|
|
947
|
+
import { createInMemoryClientResolver, type RegisteredClient } from './config';
|
|
948
|
+
import { createOidcRouteHandlers } from './next';
|
|
949
|
+
import { createNextJsProviderStores } from './storage-backend';
|
|
950
|
+
import type { OidcProviderOptions } from './app';
|
|
951
|
+
|
|
952
|
+
declare const process: { env: Record<string, string | undefined> } | undefined;
|
|
953
|
+
|
|
954
|
+
const signingKeyProvider = createCachedSigningKeyProvider(
|
|
955
|
+
createEphemeralRs256KeyProvider(),
|
|
956
|
+
60_000,
|
|
957
|
+
);
|
|
958
|
+
const providerStores = createNextJsProviderStores();
|
|
959
|
+
|
|
960
|
+
// OIDC Core 1.0 §2 / §3.1.2.1: when a client requests an acr via \`acr_values\`
|
|
961
|
+
// (or \`claims.id_token.acr.values\`), echo the most-preferred requested value back
|
|
962
|
+
// as the ID Token \`acr\` claim. The OIDF oidcc-ensure-request-with-acr-values-succeeds
|
|
963
|
+
// module only requires that the returned acr is one of the requested values; without
|
|
964
|
+
// any resolver the OP omits acr and the module reports a SHOULD warning. This sample
|
|
965
|
+
// treats every requested acr as satisfiable — a real deployment must map this to its
|
|
966
|
+
// actual authentication context instead of echoing the request.
|
|
967
|
+
const sampleAcrResolver: AcrResolver = async ({ requestedAcrValues }) => {
|
|
968
|
+
if (!requestedAcrValues) return undefined;
|
|
969
|
+
const preferred = requestedAcrValues.split(' ').find((value) => value.length > 0);
|
|
970
|
+
if (!preferred) return undefined;
|
|
971
|
+
return { acr: preferred, amr: ['pwd'] };
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
export function createOidcProviderOptions(): OidcProviderOptions {
|
|
975
|
+
const issuer = readEnv('OIDC_ISSUER') ?? readEnv('ISSUER') ?? 'http://localhost:3000';
|
|
976
|
+
const clients = readRegisteredClients();
|
|
977
|
+
const clientResolver = createInMemoryClientResolver(clients);
|
|
978
|
+
|
|
979
|
+
return {
|
|
980
|
+
config: {
|
|
981
|
+
issuer,
|
|
982
|
+
accessTokenExpiresIn: 3600,
|
|
983
|
+
idTokenExpiresIn: 3600,
|
|
984
|
+
refreshTokenAbsoluteLifetime: 7776000,
|
|
985
|
+
accessTokenFormat: 'jwt',
|
|
986
|
+
authorizationCodeTtl: 300,
|
|
987
|
+
allowNonPkceAuthorizationCodeFlow:
|
|
988
|
+
readEnv('OIDC_ALLOW_NON_PKCE_AUTHORIZATION_CODE_FLOW') === '1',
|
|
989
|
+
// OIDC Core 1.0 §6.1 / RFC 9101: accepting unsigned (alg:none) Request Objects
|
|
990
|
+
// is a security relaxation used only for OIDF Basic OP conformance, where the
|
|
991
|
+
// request object modules are skipped unless the OP advertises 'none' in
|
|
992
|
+
// request_object_signing_alg_values_supported. Default off (signed-only).
|
|
993
|
+
allowUnsignedRequestObject:
|
|
994
|
+
readEnv('OIDC_ALLOW_UNSIGNED_REQUEST_OBJECT') === '1',
|
|
995
|
+
// Non-redirect authorization errors (unknown client_id, unregistered
|
|
996
|
+
// redirect_uri, fragment) are handed to a Next.js-native error page at
|
|
997
|
+
// /oidc-error, which renders them via the App Router error boundary
|
|
998
|
+
// (app/oidc-error/error.tsx) — consistent with login/consent being real
|
|
999
|
+
// pages rather than HTML strings from the route handler.
|
|
1000
|
+
authorizationErrorRedirectPath: '/oidc-error',
|
|
1001
|
+
},
|
|
1002
|
+
signingKeyProvider,
|
|
1003
|
+
clientResolver,
|
|
1004
|
+
tokenClientResolver: clientResolver,
|
|
1005
|
+
storage: providerStores,
|
|
1006
|
+
acrResolver: sampleAcrResolver,
|
|
1007
|
+
corsOrigins: readEnv('OIDC_CORS_ORIGINS') ?? issuer,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Built provider options. Exported so the login / consent Server Actions can
|
|
1013
|
+
* reuse the same issuer and client resolver as the route handlers.
|
|
1014
|
+
*/
|
|
1015
|
+
export const oidcProviderOptions = createOidcProviderOptions();
|
|
1016
|
+
|
|
1017
|
+
export const oidcHandlers = createOidcRouteHandlers(oidcProviderOptions);
|
|
1018
|
+
|
|
1019
|
+
function readRegisteredClients(): ReadonlyMap<string, RegisteredClient> {
|
|
1020
|
+
const encoded = readEnv('OIDC_CLIENTS_JSON');
|
|
1021
|
+
if (encoded) {
|
|
1022
|
+
return parseRegisteredClients(encoded);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const clientId = readEnv('OIDC_CLIENT_ID') ?? readEnv('CLIENT_ID') ?? 'example-client';
|
|
1026
|
+
const clientSecret =
|
|
1027
|
+
readEnv('OIDC_CLIENT_SECRET') ?? readEnv('CLIENT_SECRET') ?? 'example-secret';
|
|
1028
|
+
const clientRedirectUri =
|
|
1029
|
+
readEnv('OIDC_CLIENT_REDIRECT_URI') ??
|
|
1030
|
+
readEnv('CLIENT_REDIRECT_URI') ??
|
|
1031
|
+
'http://localhost:3000/callback';
|
|
1032
|
+
|
|
1033
|
+
const clients = new Map<string, RegisteredClient>([
|
|
1034
|
+
[
|
|
1035
|
+
clientId,
|
|
1036
|
+
{
|
|
1037
|
+
clientId,
|
|
1038
|
+
clientSecret,
|
|
1039
|
+
redirectUris: [clientRedirectUri],
|
|
1040
|
+
clientType: 'confidential',
|
|
1041
|
+
grantTypes: ['authorization_code'],
|
|
1042
|
+
tokenEndpointAuthMethod: 'client_secret_post',
|
|
1043
|
+
responseTypes: ['code'],
|
|
1044
|
+
},
|
|
1045
|
+
],
|
|
1046
|
+
]);
|
|
1047
|
+
|
|
1048
|
+
const resourceServerClientId =
|
|
1049
|
+
readEnv('OIDC_RESOURCE_SERVER_CLIENT_ID') ?? readEnv('RESOURCE_SERVER_CLIENT_ID');
|
|
1050
|
+
const resourceServerClientSecret =
|
|
1051
|
+
readEnv('OIDC_RESOURCE_SERVER_CLIENT_SECRET') ?? readEnv('RESOURCE_SERVER_CLIENT_SECRET');
|
|
1052
|
+
const resourceServerRedirectUri =
|
|
1053
|
+
readEnv('OIDC_RESOURCE_SERVER_REDIRECT_URI') ??
|
|
1054
|
+
readEnv('RESOURCE_SERVER_REDIRECT_URI') ??
|
|
1055
|
+
'http://localhost:3030/unused-callback';
|
|
1056
|
+
|
|
1057
|
+
if (resourceServerClientId && resourceServerClientSecret) {
|
|
1058
|
+
clients.set(resourceServerClientId, {
|
|
1059
|
+
clientId: resourceServerClientId,
|
|
1060
|
+
clientSecret: resourceServerClientSecret,
|
|
1061
|
+
redirectUris: [resourceServerRedirectUri],
|
|
1062
|
+
clientType: 'confidential',
|
|
1063
|
+
grantTypes: ['authorization_code'],
|
|
1064
|
+
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
1065
|
+
responseTypes: ['code'],
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return clients;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function parseRegisteredClients(encoded: string): ReadonlyMap<string, RegisteredClient> {
|
|
1073
|
+
const clients = JSON.parse(encoded) as RegisteredClient[];
|
|
1074
|
+
return new Map(clients.map((client) => [client.clientId, client]));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function readEnv(name: string): string | undefined {
|
|
1078
|
+
if (typeof process === 'undefined') return undefined;
|
|
1079
|
+
return process.env[name];
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function createEphemeralRs256KeyProvider(): SigningKeyProvider {
|
|
1083
|
+
const keyPromise = generateSigningKey();
|
|
1084
|
+
return {
|
|
1085
|
+
async getSigningKey(): Promise<SigningKey> {
|
|
1086
|
+
return keyPromise;
|
|
1087
|
+
},
|
|
1088
|
+
async getSigningKeys(): Promise<SigningKey[]> {
|
|
1089
|
+
return [await keyPromise];
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function generateSigningKey(): Promise<SigningKey> {
|
|
1095
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
1096
|
+
{
|
|
1097
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
1098
|
+
modulusLength: 2048,
|
|
1099
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
1100
|
+
hash: 'SHA-256',
|
|
1101
|
+
},
|
|
1102
|
+
true,
|
|
1103
|
+
['sign', 'verify'],
|
|
1104
|
+
);
|
|
1105
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) as JsonWebKey & {
|
|
1106
|
+
alg?: string;
|
|
1107
|
+
use?: string;
|
|
1108
|
+
kid?: string;
|
|
1109
|
+
};
|
|
1110
|
+
publicJwk.alg = 'RS256';
|
|
1111
|
+
publicJwk.use = 'sig';
|
|
1112
|
+
publicJwk.kid = readEnv('OIDC_SIGNING_KEY_ID') ?? 'nextjs-rs256-key';
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
privateKey: keyPair.privateKey,
|
|
1116
|
+
publicJwk,
|
|
1117
|
+
keyId: publicJwk.kid,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
`;
|
|
1121
|
+
}
|
|
1122
|
+
export function nextJsEndpointRouteTemplate(importPath, methods) {
|
|
1123
|
+
const exports = methods
|
|
1124
|
+
.map((method) => `export const ${method} = oidcHandlers.${method};`)
|
|
1125
|
+
.join('\n');
|
|
1126
|
+
return `import { oidcHandlers } from '${importPath}';
|
|
1127
|
+
|
|
1128
|
+
export const dynamic = 'force-dynamic';
|
|
1129
|
+
export const runtime = 'nodejs';
|
|
1130
|
+
|
|
1131
|
+
${exports}
|
|
1132
|
+
`;
|
|
1133
|
+
}
|
|
1134
|
+
export function nextJsLoginPageTemplate(corePkg) {
|
|
1135
|
+
return `import { getAuthTransaction } from '${corePkg}';
|
|
1136
|
+
import { oidcProviderOptions } from '../_oidc-provider/runtime';
|
|
1137
|
+
import { defaultProviderStores } from '../_oidc-provider/store';
|
|
1138
|
+
import { loginAction } from './actions';
|
|
1139
|
+
|
|
1140
|
+
const transactionStore =
|
|
1141
|
+
(oidcProviderOptions.storage ?? defaultProviderStores).transactionStore;
|
|
1142
|
+
|
|
1143
|
+
// Authorization redirects here with a per-request transaction_id, so the page
|
|
1144
|
+
// must always render dynamically (never statically cached).
|
|
1145
|
+
export const dynamic = 'force-dynamic';
|
|
1146
|
+
|
|
1147
|
+
interface LoginPageProps {
|
|
1148
|
+
searchParams: Promise<{
|
|
1149
|
+
transaction_id?: string;
|
|
1150
|
+
error?: string;
|
|
1151
|
+
remaining?: string;
|
|
1152
|
+
}>;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Login page (React Server Component).
|
|
1157
|
+
*
|
|
1158
|
+
* This is intentionally a real Next.js \`page.tsx\` so you can customize the UI
|
|
1159
|
+
* with JSX, components, CSS modules, and the rest of the React/Next.js
|
|
1160
|
+
* ecosystem. The form posts to a Server Action (./actions.ts) that runs the
|
|
1161
|
+
* OpenID Connect login logic on the server.
|
|
1162
|
+
*/
|
|
1163
|
+
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|
1164
|
+
const { transaction_id: transactionId, error, remaining } = await searchParams;
|
|
1165
|
+
|
|
1166
|
+
if (!transactionId) {
|
|
1167
|
+
return (
|
|
1168
|
+
<main>
|
|
1169
|
+
<h1>Login</h1>
|
|
1170
|
+
<p>Missing transaction_id</p>
|
|
1171
|
+
</main>
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Rate limit reached: handleLoginFailure() locked further attempts.
|
|
1176
|
+
if (error === 'too_many_attempts') {
|
|
1177
|
+
return (
|
|
1178
|
+
<main>
|
|
1179
|
+
<h1>Login</h1>
|
|
1180
|
+
<p role="alert">Too many login attempts</p>
|
|
1181
|
+
</main>
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
1186
|
+
|
|
1187
|
+
const errorMessage =
|
|
1188
|
+
error === 'invalid_credentials'
|
|
1189
|
+
? \`Invalid credentials\${remaining ? \`. Attempts remaining: \${remaining}\` : ''}\`
|
|
1190
|
+
: null;
|
|
1191
|
+
|
|
1192
|
+
return (
|
|
1193
|
+
<main>
|
|
1194
|
+
<h1>Login</h1>
|
|
1195
|
+
{errorMessage ? (
|
|
1196
|
+
<p role="alert" style={{ color: 'red' }}>
|
|
1197
|
+
{errorMessage}
|
|
1198
|
+
</p>
|
|
1199
|
+
) : null}
|
|
1200
|
+
<form action={loginAction}>
|
|
1201
|
+
<input type="hidden" name="transaction_id" value={transactionId} />
|
|
1202
|
+
<input type="hidden" name="csrf_token" value={transaction.csrfToken} />
|
|
1203
|
+
<div>
|
|
1204
|
+
<label htmlFor="username">Username:</label>
|
|
1205
|
+
<input type="text" id="username" name="username" required />
|
|
1206
|
+
</div>
|
|
1207
|
+
<div>
|
|
1208
|
+
<label htmlFor="password">Password:</label>
|
|
1209
|
+
<input type="password" id="password" name="password" required />
|
|
1210
|
+
</div>
|
|
1211
|
+
<button type="submit">Login</button>
|
|
1212
|
+
</form>
|
|
1213
|
+
</main>
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
`;
|
|
1217
|
+
}
|
|
1218
|
+
export function nextJsAuthorizationErrorPageTemplate() {
|
|
1219
|
+
return `// The Authorization Endpoint 303-redirects non-redirect errors here (see
|
|
1220
|
+
// runtime.ts authorizationErrorRedirectPath), so this page must always render
|
|
1221
|
+
// dynamically and never be statically cached.
|
|
1222
|
+
export const dynamic = 'force-dynamic';
|
|
1223
|
+
|
|
1224
|
+
interface OidcErrorPageProps {
|
|
1225
|
+
searchParams: Promise<{ error?: string; error_description?: string }>;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Authorization error page (OIDC Core 1.0 §3.1.2.2).
|
|
1230
|
+
*
|
|
1231
|
+
* The Authorization Endpoint cannot redirect certain errors (unknown client_id,
|
|
1232
|
+
* unregistered redirect_uri, redirect_uri with a fragment) back to the client,
|
|
1233
|
+
* so it sends the browser here instead. This Server Component intentionally
|
|
1234
|
+
* throws so the sibling App Router error boundary (\`error.tsx\`) renders the UI —
|
|
1235
|
+
* the idiomatic Next.js way to surface errors, consistent with login / consent
|
|
1236
|
+
* being real pages rather than HTML strings from a route handler. \`error.tsx\`
|
|
1237
|
+
* reads error / error_description from the URL, so the thrown Error only needs to
|
|
1238
|
+
* activate the boundary.
|
|
1239
|
+
*/
|
|
1240
|
+
export default async function OidcErrorPage({ searchParams }: OidcErrorPageProps) {
|
|
1241
|
+
const { error } = await searchParams;
|
|
1242
|
+
throw new Error(\`Authorization error: \${error ?? 'invalid_request'}\`);
|
|
1243
|
+
}
|
|
1244
|
+
`;
|
|
1245
|
+
}
|
|
1246
|
+
export function nextJsAuthorizationErrorBoundaryTemplate() {
|
|
1247
|
+
return `'use client';
|
|
1248
|
+
|
|
1249
|
+
import { useSearchParams } from 'next/navigation';
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* App Router error boundary for the authorization error page.
|
|
1253
|
+
*
|
|
1254
|
+
* OIDC Core 1.0 §3.1.2.2: the Authorization Endpoint 303-redirects non-redirect
|
|
1255
|
+
* errors to /oidc-error, whose \`page.tsx\` throws to trigger this boundary. We read
|
|
1256
|
+
* the OAuth error / error_description from the URL — not from the thrown Error,
|
|
1257
|
+
* whose message is stripped in production builds — and render them as React text
|
|
1258
|
+
* so the values are safely escaped. Customize this UI with JSX as needed.
|
|
1259
|
+
*/
|
|
1260
|
+
export default function OidcAuthorizationError() {
|
|
1261
|
+
const searchParams = useSearchParams();
|
|
1262
|
+
const error = searchParams.get('error') ?? 'invalid_request';
|
|
1263
|
+
const errorDescription = searchParams.get('error_description');
|
|
1264
|
+
|
|
1265
|
+
return (
|
|
1266
|
+
<main>
|
|
1267
|
+
<h1>Error</h1>
|
|
1268
|
+
<p>{error}</p>
|
|
1269
|
+
{errorDescription ? <p>{errorDescription}</p> : null}
|
|
1270
|
+
</main>
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
`;
|
|
1274
|
+
}
|
|
1275
|
+
export function nextJsLoginActionTemplate(corePkg) {
|
|
1276
|
+
return `'use server';
|
|
1277
|
+
|
|
1278
|
+
import { redirect } from 'next/navigation';
|
|
1279
|
+
import { cookies } from 'next/headers';
|
|
1280
|
+
import {
|
|
1281
|
+
getAuthTransaction,
|
|
1282
|
+
validateCsrfToken,
|
|
1283
|
+
handleLoginFailure,
|
|
1284
|
+
generateRandomString,
|
|
1285
|
+
} from '${corePkg}';
|
|
1286
|
+
import { oidcProviderOptions } from '../_oidc-provider/runtime';
|
|
1287
|
+
import { defaultProviderStores, SESSION_COOKIE_NAME } from '../_oidc-provider/store';
|
|
1288
|
+
|
|
1289
|
+
const {
|
|
1290
|
+
transactionStore,
|
|
1291
|
+
authSessionStore,
|
|
1292
|
+
browserSessionStore,
|
|
1293
|
+
userStore,
|
|
1294
|
+
} = oidcProviderOptions.storage ?? defaultProviderStores;
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Login Server Action.
|
|
1298
|
+
*
|
|
1299
|
+
* Mirrors the framework-neutral login route, but runs as a Next.js Server
|
|
1300
|
+
* Action so the UI can stay a plain React \`page.tsx\`. On failure it redirects
|
|
1301
|
+
* back to the login page with an error so the page can re-render the message.
|
|
1302
|
+
*/
|
|
1303
|
+
export async function loginAction(formData: FormData): Promise<void> {
|
|
1304
|
+
const transactionId = String(formData.get('transaction_id') ?? '');
|
|
1305
|
+
const csrfToken = String(formData.get('csrf_token') ?? '');
|
|
1306
|
+
const username = String(formData.get('username') ?? '');
|
|
1307
|
+
const password = String(formData.get('password') ?? '');
|
|
1308
|
+
|
|
1309
|
+
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
1310
|
+
validateCsrfToken(transaction, csrfToken);
|
|
1311
|
+
|
|
1312
|
+
const user = await userStore.authenticate(username, password);
|
|
1313
|
+
if (!user) {
|
|
1314
|
+
const failureResult = await handleLoginFailure(
|
|
1315
|
+
transactionId,
|
|
1316
|
+
transaction,
|
|
1317
|
+
transactionStore,
|
|
1318
|
+
);
|
|
1319
|
+
if (!failureResult.canRetry) {
|
|
1320
|
+
redirect(
|
|
1321
|
+
\`/login?transaction_id=\${encodeURIComponent(transactionId)}&error=too_many_attempts\`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
const remaining = failureResult.maxAttempts - failureResult.failedAttempts;
|
|
1325
|
+
redirect(
|
|
1326
|
+
\`/login?transaction_id=\${encodeURIComponent(transactionId)}&error=invalid_credentials&remaining=\${remaining}\`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const cookieStore = await cookies();
|
|
1331
|
+
|
|
1332
|
+
// prompt=login / select_account requires fresh authentication: discard any
|
|
1333
|
+
// existing transaction handoff AND browser session.
|
|
1334
|
+
// OIDC Core 1.0 Section 3.1.2.1 — prompt is a space-delimited list.
|
|
1335
|
+
const loginPromptValues = transaction.prompt?.trim().split(/\\s+/).filter(Boolean) ?? [];
|
|
1336
|
+
if (loginPromptValues.includes('login') || loginPromptValues.includes('select_account')) {
|
|
1337
|
+
await authSessionStore.delete(transactionId);
|
|
1338
|
+
const existingSessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
|
1339
|
+
if (existingSessionId) await browserSessionStore.delete(existingSessionId);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const authTime = Math.floor(Date.now() / 1000);
|
|
1343
|
+
|
|
1344
|
+
// Store authenticated subject for the consent step (per-transaction handoff).
|
|
1345
|
+
await authSessionStore.set(transactionId, {
|
|
1346
|
+
subject: user.sub,
|
|
1347
|
+
authTime,
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// Establish a persistent browser (OP) session so SSO / prompt=none / max_age
|
|
1351
|
+
// work on subsequent authorization requests (OIDC Core 1.0 Section 3.1.2.3).
|
|
1352
|
+
// Cookie attributes match buildSessionCookie() in store.ts so the
|
|
1353
|
+
// sessionResolver can read it back.
|
|
1354
|
+
const sessionId = await generateRandomString(32);
|
|
1355
|
+
await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
|
|
1356
|
+
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
|
|
1357
|
+
httpOnly: true,
|
|
1358
|
+
secure: true,
|
|
1359
|
+
sameSite: 'lax',
|
|
1360
|
+
path: '/',
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
redirect(\`/consent?transaction_id=\${encodeURIComponent(transactionId)}\`);
|
|
1364
|
+
}
|
|
1365
|
+
`;
|
|
1366
|
+
}
|
|
1367
|
+
export function nextJsConsentPageTemplate(corePkg) {
|
|
1368
|
+
return `import { getAuthTransaction } from '${corePkg}';
|
|
1369
|
+
import { oidcProviderOptions } from '../_oidc-provider/runtime';
|
|
1370
|
+
import { defaultProviderStores } from '../_oidc-provider/store';
|
|
1371
|
+
import { consentAction } from './actions';
|
|
1372
|
+
|
|
1373
|
+
const transactionStore =
|
|
1374
|
+
(oidcProviderOptions.storage ?? defaultProviderStores).transactionStore;
|
|
1375
|
+
|
|
1376
|
+
export const dynamic = 'force-dynamic';
|
|
1377
|
+
|
|
1378
|
+
interface ConsentPageProps {
|
|
1379
|
+
searchParams: Promise<{ transaction_id?: string }>;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Consent page (React Server Component).
|
|
1384
|
+
*
|
|
1385
|
+
* A real Next.js \`page.tsx\` so the consent UI can be customized with JSX and
|
|
1386
|
+
* React components. The form posts to a Server Action (./actions.ts).
|
|
1387
|
+
*/
|
|
1388
|
+
export default async function ConsentPage({ searchParams }: ConsentPageProps) {
|
|
1389
|
+
const { transaction_id: transactionId } = await searchParams;
|
|
1390
|
+
|
|
1391
|
+
if (!transactionId) {
|
|
1392
|
+
return (
|
|
1393
|
+
<main>
|
|
1394
|
+
<h1>Authorize Application</h1>
|
|
1395
|
+
<p>Missing transaction_id</p>
|
|
1396
|
+
</main>
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
1401
|
+
const scopes = transaction.scope.split(' ').filter(Boolean);
|
|
1402
|
+
|
|
1403
|
+
return (
|
|
1404
|
+
<main>
|
|
1405
|
+
<h1>Authorize Application</h1>
|
|
1406
|
+
<p>
|
|
1407
|
+
Client <strong>{transaction.clientId}</strong> is requesting access to the
|
|
1408
|
+
following scopes:
|
|
1409
|
+
</p>
|
|
1410
|
+
<ul>
|
|
1411
|
+
{scopes.map((scope) => (
|
|
1412
|
+
<li key={scope}>{scope}</li>
|
|
1413
|
+
))}
|
|
1414
|
+
</ul>
|
|
1415
|
+
<form action={consentAction}>
|
|
1416
|
+
<input type="hidden" name="transaction_id" value={transactionId} />
|
|
1417
|
+
<input type="hidden" name="csrf_token" value={transaction.csrfToken} />
|
|
1418
|
+
<button type="submit" name="action" value="approve">
|
|
1419
|
+
Approve
|
|
1420
|
+
</button>
|
|
1421
|
+
<button type="submit" name="action" value="deny">
|
|
1422
|
+
Deny
|
|
1423
|
+
</button>
|
|
1424
|
+
</form>
|
|
1425
|
+
</main>
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
`;
|
|
1429
|
+
}
|
|
1430
|
+
export function nextJsConsentActionTemplate(corePkg) {
|
|
1431
|
+
return `'use server';
|
|
1432
|
+
|
|
1433
|
+
import { redirect } from 'next/navigation';
|
|
1434
|
+
import {
|
|
1435
|
+
getAuthTransaction,
|
|
1436
|
+
validateCsrfToken,
|
|
1437
|
+
completeAuthTransaction,
|
|
1438
|
+
createAuthorizationCode,
|
|
1439
|
+
} from '${corePkg}';
|
|
1440
|
+
import { oidcProviderOptions } from '../_oidc-provider/runtime';
|
|
1441
|
+
import { createStoreResolvers } from '../_oidc-provider/resolvers';
|
|
1442
|
+
import type { RegisteredClient } from '../_oidc-provider/config';
|
|
1443
|
+
import { defaultProviderStores } from '../_oidc-provider/store';
|
|
1444
|
+
|
|
1445
|
+
const providerStores = oidcProviderOptions.storage ?? defaultProviderStores;
|
|
1446
|
+
const { transactionStore, authCodeStore, authSessionStore } = providerStores;
|
|
1447
|
+
const { consentResolver } = createStoreResolvers(providerStores);
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Consent Server Action.
|
|
1451
|
+
*
|
|
1452
|
+
* Mirrors the framework-neutral consent route. Reuses the same issuer / client
|
|
1453
|
+
* resolver as the route handlers via oidcProviderOptions so the issued code and
|
|
1454
|
+
* recorded consent stay consistent with the rest of the provider.
|
|
1455
|
+
*/
|
|
1456
|
+
export async function consentAction(formData: FormData): Promise<void> {
|
|
1457
|
+
const transactionId = String(formData.get('transaction_id') ?? '');
|
|
1458
|
+
const csrfToken = String(formData.get('csrf_token') ?? '');
|
|
1459
|
+
const action = String(formData.get('action') ?? '');
|
|
1460
|
+
|
|
1461
|
+
const transaction = await getAuthTransaction(transactionId, transactionStore);
|
|
1462
|
+
validateCsrfToken(transaction, csrfToken);
|
|
1463
|
+
|
|
1464
|
+
// RFC 9207 §2: include the issuer identifier on every authorization response.
|
|
1465
|
+
const issuer = oidcProviderOptions.config?.issuer ?? '';
|
|
1466
|
+
|
|
1467
|
+
if (action === 'deny') {
|
|
1468
|
+
const denyUrl = new URL(transaction.redirectUri);
|
|
1469
|
+
denyUrl.searchParams.set('error', 'access_denied');
|
|
1470
|
+
if (transaction.state) {
|
|
1471
|
+
denyUrl.searchParams.set('state', transaction.state);
|
|
1472
|
+
}
|
|
1473
|
+
denyUrl.searchParams.set('iss', issuer);
|
|
1474
|
+
await transactionStore.delete('auth_txn:' + transactionId);
|
|
1475
|
+
await authSessionStore.delete(transactionId);
|
|
1476
|
+
redirect(denyUrl.toString());
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const session = await authSessionStore.get(transactionId);
|
|
1480
|
+
if (!session) {
|
|
1481
|
+
redirect(\`/login?transaction_id=\${encodeURIComponent(transactionId)}\`);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const responseParams = await completeAuthTransaction(
|
|
1485
|
+
transactionId,
|
|
1486
|
+
transaction,
|
|
1487
|
+
transactionStore,
|
|
1488
|
+
);
|
|
1489
|
+
|
|
1490
|
+
// Filter offline_access if the client does not allow it.
|
|
1491
|
+
// findClient() is typed as ClientResolver here, so narrow back to the
|
|
1492
|
+
// registered-client shape that carries offlineAccessAllowed.
|
|
1493
|
+
const clientConfig = (await oidcProviderOptions.clientResolver?.findClient(
|
|
1494
|
+
transaction.clientId,
|
|
1495
|
+
)) as RegisteredClient | null | undefined;
|
|
1496
|
+
const grantedScope = transaction.scope.split(' ').filter((s) => {
|
|
1497
|
+
if (s === 'offline_access' && !clientConfig?.offlineAccessAllowed) return false;
|
|
1498
|
+
return Boolean(s);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
// OIDC Core 1.0 Section 3.1.3.1: TTL is configurable via ProviderConfig.
|
|
1502
|
+
const authCodeData = await createAuthorizationCode({
|
|
1503
|
+
authorizationResponse: { ...responseParams, scope: grantedScope },
|
|
1504
|
+
subject: session.subject,
|
|
1505
|
+
authTime: session.authTime,
|
|
1506
|
+
ttlSeconds: oidcProviderOptions.config?.authorizationCodeTtl,
|
|
1507
|
+
});
|
|
1508
|
+
await authCodeStore.set(authCodeData.code, authCodeData);
|
|
1509
|
+
|
|
1510
|
+
// Record consent so a later prompt=none request can confirm it without UI
|
|
1511
|
+
// (OIDC Core 1.0 Section 3.1.2.4).
|
|
1512
|
+
await consentResolver.recordConsent?.(
|
|
1513
|
+
session.subject,
|
|
1514
|
+
transaction.clientId,
|
|
1515
|
+
grantedScope,
|
|
1516
|
+
);
|
|
1517
|
+
|
|
1518
|
+
await authSessionStore.delete(transactionId);
|
|
1519
|
+
|
|
1520
|
+
const successUrl = new URL(responseParams.redirectUri);
|
|
1521
|
+
successUrl.searchParams.set('code', authCodeData.code);
|
|
1522
|
+
if (responseParams.state) {
|
|
1523
|
+
successUrl.searchParams.set('state', responseParams.state);
|
|
1524
|
+
}
|
|
1525
|
+
successUrl.searchParams.set('iss', issuer);
|
|
1526
|
+
redirect(successUrl.toString());
|
|
1527
|
+
}
|
|
1528
|
+
`;
|
|
1529
|
+
}
|
|
1530
|
+
export function webConformanceTestTemplate(corePkg, errorPageMode = 'html', features = DEFAULT_FEATURES, includeNodeAdapterContract = false) {
|
|
1531
|
+
const usesRedirect = errorPageMode === 'redirect';
|
|
1532
|
+
const createAppConfig = usesRedirect
|
|
1533
|
+
? `
|
|
1534
|
+
config: { authorizationErrorRedirectPath: '/oidc-error' },`
|
|
1535
|
+
: '';
|
|
1536
|
+
const nonRedirectErrorTest = usesRedirect
|
|
1537
|
+
? ` // OIDC Core 1.0 §3.1.2.2: an unregistered redirect_uri MUST NOT be redirected
|
|
1538
|
+
// to. This Next.js provider sets config.authorizationErrorRedirectPath, so the
|
|
1539
|
+
// OP hands the error to a framework-native error page (app/oidc-error, rendered
|
|
1540
|
+
// via Next.js error.tsx) instead of returning HTML from the route handler. The
|
|
1541
|
+
// browser is 303-redirected to the OP's OWN error page (never the attacker's
|
|
1542
|
+
// unregistered redirect_uri). That error page responds 200, so the 400 status
|
|
1543
|
+
// is intentionally traded for an idiomatic Next.js error UI.
|
|
1544
|
+
it('should 303-redirect browser callers to the OP error page for an unregistered redirect_uri', async () => {
|
|
1545
|
+
const res = await app.request(unregisteredAuthorizeUrl);
|
|
1546
|
+
|
|
1547
|
+
expect(res.status).toBe(303);
|
|
1548
|
+
// Pinned exactly so the redirect target stays the OP's own error page and
|
|
1549
|
+
// never leaks to the unregistered (attacker-controlled) redirect_uri.
|
|
1550
|
+
expect(res.headers.get('Location')).toBe(
|
|
1551
|
+
'/oidc-error?error=invalid_request&error_description=redirect_uri+not+registered',
|
|
1552
|
+
);
|
|
1553
|
+
});`
|
|
1554
|
+
: ` // OIDC Core 1.0 §3.1.2.2: an unregistered redirect_uri MUST NOT be redirected
|
|
1555
|
+
// to. Browser callers receive an HTML error page (HTTP 400) so the OIDF
|
|
1556
|
+
// Conformance Suite (oidcc-ensure-registered-redirect-uri) can screenshot it.
|
|
1557
|
+
it('should render an HTML error page (not redirect) for an unregistered redirect_uri', async () => {
|
|
1558
|
+
const res = await app.request(unregisteredAuthorizeUrl);
|
|
1559
|
+
|
|
1560
|
+
expect(res.status).toBe(400);
|
|
1561
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
1562
|
+
expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
1563
|
+
const body = await res.text();
|
|
1564
|
+
// Pinned to the default error page so a regression in the rendered markup
|
|
1565
|
+
// (or a missing error_description) is caught exactly.
|
|
1566
|
+
expect(body).toBe(
|
|
1567
|
+
[
|
|
1568
|
+
'<!DOCTYPE html>',
|
|
1569
|
+
'<html>',
|
|
1570
|
+
'<head><title>Error</title></head>',
|
|
1571
|
+
'<body>',
|
|
1572
|
+
' <h1>Error</h1>',
|
|
1573
|
+
' <p>invalid_request</p>',
|
|
1574
|
+
' <p>redirect_uri not registered</p>',
|
|
1575
|
+
'</body>',
|
|
1576
|
+
'</html>',
|
|
1577
|
+
].join('\\n'),
|
|
1578
|
+
);
|
|
1579
|
+
});`;
|
|
1580
|
+
const exportPublicJwkImport = features.requestObject
|
|
1581
|
+
? `import { exportPublicJwk } from '${corePkg}';\n`
|
|
1582
|
+
: '';
|
|
1583
|
+
const nodeAdapterImport = includeNodeAdapterContract
|
|
1584
|
+
? `import { writeWebResponse } from './node-adapter.js';\n`
|
|
1585
|
+
: '';
|
|
1586
|
+
const nodeAdapterContract = includeNodeAdapterContract
|
|
1587
|
+
? `
|
|
1588
|
+
describe('Node response adapter', () => {
|
|
1589
|
+
it('should preserve each Set-Cookie value as a separate outgoing header', async () => {
|
|
1590
|
+
const headers = new Map<string, string | string[]>();
|
|
1591
|
+
let endedBody = '';
|
|
1592
|
+
const outgoing = {
|
|
1593
|
+
statusCode: 0,
|
|
1594
|
+
setHeader(name: string, value: string | string[]): void {
|
|
1595
|
+
headers.set(name, value);
|
|
1596
|
+
},
|
|
1597
|
+
end(body: Uint8Array): void {
|
|
1598
|
+
endedBody = new TextDecoder().decode(body);
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
const responseHeaders = new Headers();
|
|
1602
|
+
responseHeaders.append('Set-Cookie', 'session=one; Path=/');
|
|
1603
|
+
responseHeaders.append('Set-Cookie', 'csrf=two; Path=/');
|
|
1604
|
+
|
|
1605
|
+
await writeWebResponse(outgoing as never, new Response('ok', { headers: responseHeaders }));
|
|
1606
|
+
|
|
1607
|
+
expect(outgoing.statusCode).toBe(200);
|
|
1608
|
+
expect(headers.get('Set-Cookie')).toEqual(['session=one; Path=/', 'csrf=two; Path=/']);
|
|
1609
|
+
expect(endedBody).toBe('ok');
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it('should preserve a single Set-Cookie value', async () => {
|
|
1613
|
+
const headers = new Map<string, string | string[]>();
|
|
1614
|
+
let endedBody = '';
|
|
1615
|
+
const outgoing = {
|
|
1616
|
+
statusCode: 0,
|
|
1617
|
+
setHeader(name: string, value: string | string[]): void {
|
|
1618
|
+
headers.set(name, value);
|
|
1619
|
+
},
|
|
1620
|
+
end(body: Uint8Array): void {
|
|
1621
|
+
endedBody = new TextDecoder().decode(body);
|
|
1622
|
+
},
|
|
1623
|
+
};
|
|
1624
|
+
const responseHeaders = new Headers();
|
|
1625
|
+
responseHeaders.append('Set-Cookie', 'session=one; Path=/');
|
|
1626
|
+
|
|
1627
|
+
await writeWebResponse(outgoing as never, new Response('ok', { headers: responseHeaders }));
|
|
1628
|
+
|
|
1629
|
+
expect(outgoing.statusCode).toBe(200);
|
|
1630
|
+
expect(headers.get('Set-Cookie')).toEqual(['session=one; Path=/']);
|
|
1631
|
+
expect(endedBody).toBe('ok');
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
`
|
|
1635
|
+
: '';
|
|
1636
|
+
const parConformanceImports = features.par
|
|
1637
|
+
? `
|
|
1638
|
+
import { parStore } from './store.js';
|
|
1639
|
+
import { parConfig } from './routes/par.js';`
|
|
1640
|
+
: '';
|
|
1641
|
+
const tokenExchangeConformanceImports = features.tokenExchange
|
|
1642
|
+
? `
|
|
1643
|
+
import { tokenExchangeConfig } from './routes/token.js';`
|
|
1644
|
+
: '';
|
|
1645
|
+
return `import { describe, it, expect, beforeAll } from 'vitest';
|
|
1646
|
+
import type { SigningKeyProvider, SigningKey } from '${corePkg}';
|
|
1647
|
+
${exportPublicJwkImport}import { createApp, validateSigningKeySet } from './app.js';
|
|
1648
|
+
import { createInMemoryClientResolver, type RegisteredClient } from './config.js';
|
|
1649
|
+
import { accessTokenStore, authSessionStore, consentStore, createJsonProviderStores, refreshTokenStore, transactionStore, type JsonStoreBackend } from './store.js';
|
|
1650
|
+
import { consentResolver } from './resolvers.js';
|
|
1651
|
+
import { defaultViews } from './views.js';
|
|
1652
|
+
import { renderView } from './views.js';${parConformanceImports}${tokenExchangeConformanceImports}
|
|
1653
|
+
${nodeAdapterImport}
|
|
1654
|
+
|
|
1655
|
+
const REDIRECT_URI = 'http://localhost:3000/callback';
|
|
1656
|
+
|
|
1657
|
+
function idTokenPayload(idToken: string): Record<string, unknown> {
|
|
1658
|
+
const payload = idToken.split('.')[1] ?? '';
|
|
1659
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(payload.replace(/-/g, '+').replace(/_/g, '/')), (char) => char.charCodeAt(0))));
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
${conformanceTestClientsBlock(features)}${requestObjectConformanceModuleSetup(features)}
|
|
1663
|
+
let app: ReturnType<typeof createApp>;
|
|
1664
|
+
let signingKeyProvider: SigningKeyProvider;
|
|
1665
|
+
|
|
1666
|
+
beforeAll(async () => {
|
|
1667
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
1668
|
+
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
1669
|
+
true,
|
|
1670
|
+
['sign', 'verify'],
|
|
1671
|
+
);
|
|
1672
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
1673
|
+
signingKeyProvider = {
|
|
1674
|
+
async getSigningKey(): Promise<SigningKey> {
|
|
1675
|
+
return { privateKey: keyPair.privateKey, publicJwk, keyId: 'test-key' };
|
|
1676
|
+
},
|
|
1677
|
+
};
|
|
1678
|
+
${requestObjectConformanceBeforeAll(features)}
|
|
1679
|
+
app = createApp({
|
|
1680
|
+
signingKeyProvider,
|
|
1681
|
+
clientResolver: createInMemoryClientResolver(testClients),
|
|
1682
|
+
acrResolver: async () => ({ acr: 'urn:example:loa:2', amr: ['pwd', 'otp'] }),${createAppConfig}
|
|
1683
|
+
});
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
describe('generated provider HTTP conformance', () => {
|
|
1687
|
+
${persistentStorageConformanceBlock()}
|
|
1688
|
+
${nodeAdapterContract}
|
|
1689
|
+
describe('Generated view rendering', () => {
|
|
1690
|
+
it('should HTML-escape every login and consent value', () => {
|
|
1691
|
+
const hostile = '\"><script>alert(1)</script>';
|
|
1692
|
+
const loginHtml = String(defaultViews.loginPage({
|
|
1693
|
+
transactionId: hostile,
|
|
1694
|
+
csrfToken: hostile,
|
|
1695
|
+
error: '<img src=x onerror=alert(1)>',
|
|
1696
|
+
}));
|
|
1697
|
+
const consentHtml = String(defaultViews.consentPage({
|
|
1698
|
+
transactionId: hostile,
|
|
1699
|
+
csrfToken: hostile,
|
|
1700
|
+
scopes: ['openid'],
|
|
1701
|
+
clientId: 'client',
|
|
1702
|
+
}));
|
|
1703
|
+
|
|
1704
|
+
expect(loginHtml.includes('<script>')).toBe(false);
|
|
1705
|
+
expect(loginHtml.includes('<img src=x onerror=alert(1)>')).toBe(false);
|
|
1706
|
+
expect(loginHtml.includes('"><script>alert(1)</script>')).toBe(true);
|
|
1707
|
+
expect(loginHtml.includes('<img src=x onerror=alert(1)>')).toBe(true);
|
|
1708
|
+
expect(consentHtml.includes('<script>')).toBe(false);
|
|
1709
|
+
expect(consentHtml.includes('"><script>alert(1)</script>')).toBe(true);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
it('should preserve a custom Response returned by a view', () => {
|
|
1713
|
+
const customResponse = new Response('custom view', {
|
|
1714
|
+
status: 202,
|
|
1715
|
+
headers: { 'X-View-Renderer': 'custom' },
|
|
1716
|
+
});
|
|
1717
|
+
const rendered = renderView(customResponse, { status: 400 });
|
|
1718
|
+
|
|
1719
|
+
expect(rendered).toBe(customResponse);
|
|
1720
|
+
expect(rendered.status).toBe(202);
|
|
1721
|
+
expect(rendered.headers.get('X-View-Renderer')).toBe('custom');
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
it('should render a custom HTML string returned by the error view', async () => {
|
|
1725
|
+
const customHtml = '<!DOCTYPE html><p>custom authorization error</p>';
|
|
1726
|
+
const customApp = createApp({
|
|
1727
|
+
signingKeyProvider,
|
|
1728
|
+
clientResolver: createInMemoryClientResolver(testClients),
|
|
1729
|
+
views: { errorPage: () => customHtml },
|
|
1730
|
+
});
|
|
1731
|
+
const res = await customApp.request(
|
|
1732
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
1733
|
+
'&redirect_uri=' + encodeURIComponent('http://attacker.example/cb') +
|
|
1734
|
+
'&scope=openid&state=custom-view' +
|
|
1735
|
+
'&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256',
|
|
1736
|
+
);
|
|
1737
|
+
|
|
1738
|
+
expect(res.status).toBe(400);
|
|
1739
|
+
expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8');
|
|
1740
|
+
expect(await res.text()).toBe(customHtml);
|
|
1741
|
+
});
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
describe('Generated signing-key validation', () => {
|
|
1745
|
+
it('should reject an RSA signing key below 2048 bits', () => {
|
|
1746
|
+
const weakKey: SigningKey = {
|
|
1747
|
+
privateKey: {} as CryptoKey,
|
|
1748
|
+
publicJwk: { kty: 'RSA', n: '_'.repeat(170) + '8', e: 'AQAB' },
|
|
1749
|
+
keyId: 'weak-key',
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
expect(() => validateSigningKeySet([weakKey])).toThrow(
|
|
1753
|
+
'Signing key "weak-key" has a 1024-bit RSA modulus; minimum allowed is 2048 bits (NIST SP 800-131A Rev.2)',
|
|
1754
|
+
);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
it('should reject weak signing keys through the generated Web app', async () => {
|
|
1758
|
+
const weakKey: SigningKey = {
|
|
1759
|
+
privateKey: {} as CryptoKey,
|
|
1760
|
+
publicJwk: { kty: 'RSA', n: '_'.repeat(170) + '8', e: 'AQAB' },
|
|
1761
|
+
keyId: 'weak-runtime-key',
|
|
1762
|
+
};
|
|
1763
|
+
const weakProvider: SigningKeyProvider = {
|
|
1764
|
+
async getSigningKey(): Promise<SigningKey> {
|
|
1765
|
+
return weakKey;
|
|
1766
|
+
},
|
|
1767
|
+
async getSigningKeys(): Promise<SigningKey[]> {
|
|
1768
|
+
return [weakKey];
|
|
1769
|
+
},
|
|
1770
|
+
};
|
|
1771
|
+
const weakApp = createApp({ signingKeyProvider: weakProvider });
|
|
1772
|
+
const res = await weakApp.request('/.well-known/openid-configuration');
|
|
1773
|
+
|
|
1774
|
+
expect(res.status).toBe(503);
|
|
1775
|
+
expect(await res.json()).toEqual({
|
|
1776
|
+
error: 'server_error',
|
|
1777
|
+
error_description: 'Failed to load signing key',
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
it('should reject an empty kid in a multiple-key set', () => {
|
|
1782
|
+
const keyWithoutKid: SigningKey = {
|
|
1783
|
+
privateKey: {} as CryptoKey,
|
|
1784
|
+
publicJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
|
|
1785
|
+
keyId: '',
|
|
1786
|
+
};
|
|
1787
|
+
const keyWithKid: SigningKey = {
|
|
1788
|
+
privateKey: {} as CryptoKey,
|
|
1789
|
+
publicJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
|
|
1790
|
+
keyId: 'second-key',
|
|
1791
|
+
};
|
|
1792
|
+
|
|
1793
|
+
expect(() => validateSigningKeySet([keyWithoutKid, keyWithKid])).toThrow(
|
|
1794
|
+
'Multiple signing keys are published but a key has an empty kid (RFC 7517 §4.5)',
|
|
1795
|
+
);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it('should reject duplicate kid values in a multiple-key set', () => {
|
|
1799
|
+
const key: SigningKey = {
|
|
1800
|
+
privateKey: {} as CryptoKey,
|
|
1801
|
+
publicJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' },
|
|
1802
|
+
keyId: 'duplicate-key',
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
expect(() => validateSigningKeySet([key, key])).toThrow(
|
|
1806
|
+
'Duplicate kid in signing key set: duplicate-key (RFC 7517 §4.5)',
|
|
1807
|
+
);
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
describe('Discovery Endpoint', () => {
|
|
1812
|
+
it('should return the required OIDC provider metadata fields', async () => {
|
|
1813
|
+
const res = await app.request('/.well-known/openid-configuration');
|
|
1814
|
+
|
|
1815
|
+
expect(res.status).toBe(200);
|
|
1816
|
+
const metadata = await res.json();
|
|
1817
|
+
expect(metadata).toMatchObject({
|
|
1818
|
+
issuer: 'http://localhost:3000',
|
|
1819
|
+
authorization_endpoint: 'http://localhost:3000/authorize',
|
|
1820
|
+
token_endpoint: 'http://localhost:3000/token',
|
|
1821
|
+
jwks_uri: 'http://localhost:3000/.well-known/jwks.json',
|
|
1822
|
+
userinfo_endpoint: 'http://localhost:3000/userinfo',
|
|
1823
|
+
response_types_supported: ['code'],
|
|
1824
|
+
// OAuth 2.0 Multiple Response Type Encoding Practices §2: the code flow
|
|
1825
|
+
// returns the authorization response via query, so the OP advertises
|
|
1826
|
+
// response_modes_supported as exactly ['query'].
|
|
1827
|
+
response_modes_supported: ['query'],
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
${scopesSupportedConformanceTest(features)}
|
|
1832
|
+
// OIDC Core 1.0 §2 / §3.1.3.6 + Discovery 1.0 §3: claims_supported advertises
|
|
1833
|
+
// the claims the OP can supply, including the ID Token protocol claims
|
|
1834
|
+
// (auth_time/nonce/acr/amr/azp/at_hash). The full list is pinned so dropping
|
|
1835
|
+
// any claim fails the contract. c_hash is excluded (Hybrid is not implemented).
|
|
1836
|
+
it('should advertise the issuable claims in claims_supported', async () => {
|
|
1837
|
+
const res = await app.request('/.well-known/openid-configuration');
|
|
1838
|
+
|
|
1839
|
+
expect(res.status).toBe(200);
|
|
1840
|
+
const metadata = await res.json();
|
|
1841
|
+
expect(metadata.claims_supported).toEqual([
|
|
1842
|
+
'sub',
|
|
1843
|
+
'iss',
|
|
1844
|
+
'aud',
|
|
1845
|
+
'exp',
|
|
1846
|
+
'iat',
|
|
1847
|
+
'auth_time',
|
|
1848
|
+
'nonce',
|
|
1849
|
+
'acr',
|
|
1850
|
+
'amr',
|
|
1851
|
+
'azp',
|
|
1852
|
+
'at_hash',
|
|
1853
|
+
'name',
|
|
1854
|
+
'family_name',
|
|
1855
|
+
'given_name',
|
|
1856
|
+
'middle_name',
|
|
1857
|
+
'nickname',
|
|
1858
|
+
'preferred_username',
|
|
1859
|
+
'profile',
|
|
1860
|
+
'picture',
|
|
1861
|
+
'website',
|
|
1862
|
+
'gender',
|
|
1863
|
+
'birthdate',
|
|
1864
|
+
'zoneinfo',
|
|
1865
|
+
'locale',
|
|
1866
|
+
'updated_at',
|
|
1867
|
+
'email',
|
|
1868
|
+
'email_verified',
|
|
1869
|
+
'address',
|
|
1870
|
+
'phone_number',
|
|
1871
|
+
'phone_number_verified',
|
|
1872
|
+
]);
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
// OIDC Discovery 1.0 §3 / Core 1.0 §5.5: claims_parameter_supported defaults
|
|
1876
|
+
// to false when omitted, which makes spec-compliant RPs skip the (implemented)
|
|
1877
|
+
// 'claims' request parameter. It is pinned to true so a regression is caught.
|
|
1878
|
+
it('should advertise claims_parameter_supported as true', async () => {
|
|
1879
|
+
const res = await app.request('/.well-known/openid-configuration');
|
|
1880
|
+
|
|
1881
|
+
expect(res.status).toBe(200);
|
|
1882
|
+
const metadata = await res.json();
|
|
1883
|
+
expect(metadata.claims_parameter_supported).toBe(true);
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
it('should advertise the exact supported token endpoint authentication methods', async () => {
|
|
1887
|
+
const res = await app.request('/.well-known/openid-configuration');
|
|
1888
|
+
|
|
1889
|
+
expect(res.status).toBe(200);
|
|
1890
|
+
const metadata = await res.json();
|
|
1891
|
+
expect(metadata.token_endpoint_auth_methods_supported).toEqual([
|
|
1892
|
+
'client_secret_basic',
|
|
1893
|
+
'client_secret_post',
|
|
1894
|
+
'none',
|
|
1895
|
+
]);
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
// RFC 8414 §3.2 / RFC 9111 §5.2: Discovery metadata is cacheable. The
|
|
1899
|
+
// endpoint advertises a 3600s freshness lifetime so client libraries reuse
|
|
1900
|
+
// the metadata deterministically, matching the JWKS endpoint (jwks.ts).
|
|
1901
|
+
it('should return Cache-Control public, max-age=3600 on discovery response', async () => {
|
|
1902
|
+
const res = await app.request('/.well-known/openid-configuration');
|
|
1903
|
+
|
|
1904
|
+
expect(res.status).toBe(200);
|
|
1905
|
+
expect(res.headers.get('Cache-Control')).toBe('public, max-age=3600');
|
|
1906
|
+
});
|
|
1907
|
+
${featureDisabledDiscoveryConformanceTests(features)} });
|
|
1908
|
+
|
|
1909
|
+
describe('Token Endpoint error response', () => {
|
|
1910
|
+
it('should return Cache-Control no-store and an OAuth error JSON', async () => {
|
|
1911
|
+
const res = await app.request('/token', {
|
|
1912
|
+
method: 'POST',
|
|
1913
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1914
|
+
body: new URLSearchParams({ scope: 'openid' }).toString(),
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
expect(res.status).toBe(400);
|
|
1918
|
+
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
|
1919
|
+
expect(await res.json()).toEqual({
|
|
1920
|
+
error: 'invalid_request',
|
|
1921
|
+
error_description: 'Missing required parameter: grant_type',
|
|
1922
|
+
});
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
describe('UserInfo Endpoint', () => {
|
|
1927
|
+
it('should return 401 with a WWW-Authenticate Bearer challenge for an invalid token', async () => {
|
|
1928
|
+
const res = await app.request('/userinfo', {
|
|
1929
|
+
headers: { Authorization: 'Bearer this-token-does-not-exist' },
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
expect(res.status).toBe(401);
|
|
1933
|
+
expect(res.headers.get('WWW-Authenticate')).toBe(
|
|
1934
|
+
'Bearer realm="UserInfo", error="invalid_token", error_description="Access token is invalid"',
|
|
1935
|
+
);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it('should return only the UserInfo realm when no access token is provided', async () => {
|
|
1939
|
+
const res = await app.request('/userinfo');
|
|
1940
|
+
|
|
1941
|
+
expect(res.status).toBe(401);
|
|
1942
|
+
expect(res.headers.get('WWW-Authenticate')).toBe('Bearer realm="UserInfo"');
|
|
1943
|
+
expect(await res.json()).toEqual({
|
|
1944
|
+
error: 'invalid_token',
|
|
1945
|
+
error_description: 'Access token is required',
|
|
1946
|
+
});
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
// RFC 9068 §4: the generated OP passes its UserInfo endpoint URL to
|
|
1950
|
+
// validateUserInfoAudience, so aud validation is on by default for both JWT and opaque
|
|
1951
|
+
// tokens. Flow-issued tokens always carry the UserInfo endpoint in aud, so these inject
|
|
1952
|
+
// tokens with an explicit aud to exercise the accept/reject wiring end-to-end.
|
|
1953
|
+
describe('Access Token Audience Validation (RFC 9068 §4)', () => {
|
|
1954
|
+
const USERINFO_AUD = 'http://localhost:3000/userinfo';
|
|
1955
|
+
|
|
1956
|
+
it('should return 200 for a token whose aud includes the UserInfo endpoint', async () => {
|
|
1957
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1958
|
+
accessTokenStore.set('conf-aud-ok', {
|
|
1959
|
+
sub: 'testuser',
|
|
1960
|
+
clientId: 'c-conf',
|
|
1961
|
+
scope: ['openid'],
|
|
1962
|
+
expiresAt: now + 3600,
|
|
1963
|
+
audience: [USERINFO_AUD, 'https://api.example.com'],
|
|
1964
|
+
issuer: 'http://localhost:3000',
|
|
1965
|
+
});
|
|
1966
|
+
const res = await app.request('/userinfo', {
|
|
1967
|
+
headers: { Authorization: 'Bearer conf-aud-ok' },
|
|
1968
|
+
});
|
|
1969
|
+
expect(res.status).toBe(200);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
it('should accept every supported UserInfo form media type spelling', async () => {
|
|
1973
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1974
|
+
accessTokenStore.set('conf-post-ok', {
|
|
1975
|
+
sub: 'testuser',
|
|
1976
|
+
clientId: 'c-conf',
|
|
1977
|
+
scope: ['openid'],
|
|
1978
|
+
expiresAt: now + 3600,
|
|
1979
|
+
audience: [USERINFO_AUD],
|
|
1980
|
+
issuer: 'http://localhost:3000',
|
|
1981
|
+
});
|
|
1982
|
+
const contentTypes = [
|
|
1983
|
+
'application/x-www-form-urlencoded',
|
|
1984
|
+
'Application/X-WWW-Form-Urlencoded',
|
|
1985
|
+
'application/x-www-form-urlencoded; charset=utf-8',
|
|
1986
|
+
];
|
|
1987
|
+
const responses = await Promise.all(
|
|
1988
|
+
contentTypes.map(async (contentType) => {
|
|
1989
|
+
const res = await app.request('/userinfo', {
|
|
1990
|
+
method: 'POST',
|
|
1991
|
+
headers: { 'Content-Type': contentType },
|
|
1992
|
+
body: new URLSearchParams({ access_token: 'conf-post-ok' }).toString(),
|
|
1993
|
+
});
|
|
1994
|
+
return { status: res.status, body: await res.json() };
|
|
1995
|
+
}),
|
|
1996
|
+
);
|
|
1997
|
+
|
|
1998
|
+
expect(responses).toEqual([
|
|
1999
|
+
{ status: 200, body: { sub: 'testuser' } },
|
|
2000
|
+
{ status: 200, body: { sub: 'testuser' } },
|
|
2001
|
+
{ status: 200, body: { sub: 'testuser' } },
|
|
2002
|
+
]);
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
it('should return 401 for a token whose aud excludes the UserInfo endpoint', async () => {
|
|
2006
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2007
|
+
accessTokenStore.set('conf-aud-ng', {
|
|
2008
|
+
sub: 'testuser',
|
|
2009
|
+
clientId: 'c-conf',
|
|
2010
|
+
scope: ['openid'],
|
|
2011
|
+
expiresAt: now + 3600,
|
|
2012
|
+
audience: ['https://api.example.com'],
|
|
2013
|
+
issuer: 'http://localhost:3000',
|
|
2014
|
+
});
|
|
2015
|
+
const res = await app.request('/userinfo', {
|
|
2016
|
+
headers: { Authorization: 'Bearer conf-aud-ng' },
|
|
2017
|
+
});
|
|
2018
|
+
expect(res.status).toBe(401);
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it('should return 401 for a token with no stored aud (no opaque escape hatch)', async () => {
|
|
2022
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2023
|
+
accessTokenStore.set('conf-aud-missing', {
|
|
2024
|
+
sub: 'testuser',
|
|
2025
|
+
clientId: 'c-conf',
|
|
2026
|
+
scope: ['openid'],
|
|
2027
|
+
expiresAt: now + 3600,
|
|
2028
|
+
issuer: 'http://localhost:3000',
|
|
2029
|
+
});
|
|
2030
|
+
const res = await app.request('/userinfo', {
|
|
2031
|
+
headers: { Authorization: 'Bearer conf-aud-missing' },
|
|
2032
|
+
});
|
|
2033
|
+
expect(res.status).toBe(401);
|
|
2034
|
+
});
|
|
2035
|
+
});
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
${introspectionConformanceBlock(features)}
|
|
2039
|
+
describe('Authorization Endpoint non-redirect errors', () => {
|
|
2040
|
+
// A valid S256 challenge so the request is rejected solely on redirect_uri,
|
|
2041
|
+
// not on a missing PKCE parameter.
|
|
2042
|
+
const PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
2043
|
+
const unregisteredAuthorizeUrl =
|
|
2044
|
+
'/authorize?response_type=code&client_id=c-conf' +
|
|
2045
|
+
'&redirect_uri=' + encodeURIComponent('http://attacker.example/cb') +
|
|
2046
|
+
'&scope=openid&state=abc' +
|
|
2047
|
+
'&code_challenge=' + PKCE_CHALLENGE + '&code_challenge_method=S256';
|
|
2048
|
+
|
|
2049
|
+
${nonRedirectErrorTest}
|
|
2050
|
+
|
|
2051
|
+
// Programmatic callers that explicitly ask for JSON still receive the OAuth
|
|
2052
|
+
// error JSON instead of the HTML page.
|
|
2053
|
+
it('should return OAuth error JSON when the caller requests application/json', async () => {
|
|
2054
|
+
const res = await app.request(unregisteredAuthorizeUrl, {
|
|
2055
|
+
headers: { Accept: 'application/json' },
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
expect(res.status).toBe(400);
|
|
2059
|
+
expect(res.headers.get('Location')).toBe(null);
|
|
2060
|
+
expect(await res.json()).toEqual({
|
|
2061
|
+
error: 'invalid_request',
|
|
2062
|
+
error_description: 'redirect_uri not registered',
|
|
2063
|
+
});
|
|
2064
|
+
});
|
|
2065
|
+
});
|
|
2066
|
+
${customViewConformanceTestBlock()}${endpointBehaviorConformanceBlock(features)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}});
|
|
2067
|
+
`;
|
|
2068
|
+
}
|
|
2069
|
+
function webCoreGeneratedFiles(corePkg, errorPageMode = 'html', features = DEFAULT_FEATURES, includeNodeAdapterContract = false) {
|
|
2070
|
+
return [
|
|
2071
|
+
{ path: 'app.ts', content: webAppTemplate(corePkg, features) },
|
|
2072
|
+
{ path: 'web-router.ts', content: webRouterTemplate() },
|
|
2073
|
+
{ path: 'config.ts', content: configTemplate(corePkg, features) },
|
|
2074
|
+
{
|
|
2075
|
+
path: 'store.ts',
|
|
2076
|
+
content: storeTemplate(corePkg, features),
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
path: 'resolvers.ts',
|
|
2080
|
+
content: resolversTemplate(corePkg, features).replace('through Hono context', 'through the generated request context'),
|
|
2081
|
+
},
|
|
2082
|
+
{ path: 'views.ts', content: viewsTemplate() },
|
|
2083
|
+
{ path: 'routes/authorize.ts', content: toWebRouteTemplate(authorizeRouteTemplate(corePkg, features)) },
|
|
2084
|
+
{ path: 'routes/token.ts', content: toWebRouteTemplate(tokenRouteTemplate(corePkg, features)) },
|
|
2085
|
+
{ path: 'routes/userinfo.ts', content: toWebRouteTemplate(userinfoRouteTemplate(corePkg)) },
|
|
2086
|
+
...(features.introspection
|
|
2087
|
+
? [{ path: 'routes/introspection.ts', content: toWebRouteTemplate(introspectionRouteTemplate(corePkg)) }]
|
|
2088
|
+
: []),
|
|
2089
|
+
...(features.revocation
|
|
2090
|
+
? [{ path: 'routes/revocation.ts', content: toWebRouteTemplate(revocationRouteTemplate(corePkg)) }]
|
|
2091
|
+
: []),
|
|
2092
|
+
...(features.par
|
|
2093
|
+
? [{ path: 'routes/par.ts', content: toWebRouteTemplate(parRouteTemplate(corePkg)) }]
|
|
2094
|
+
: []),
|
|
2095
|
+
{ path: 'routes/jwks.ts', content: toWebRouteTemplate(jwksRouteTemplate(corePkg)) },
|
|
2096
|
+
{ path: 'routes/discovery.ts', content: toWebRouteTemplate(discoveryRouteTemplate(corePkg, features)) },
|
|
2097
|
+
{ path: 'routes/login.ts', content: toWebRouteTemplate(loginRouteTemplate(corePkg)) },
|
|
2098
|
+
{ path: 'routes/consent.ts', content: toWebRouteTemplate(consentRouteTemplate(corePkg)) },
|
|
2099
|
+
{
|
|
2100
|
+
path: 'conformance.test.ts',
|
|
2101
|
+
content: webConformanceTestTemplate(corePkg, errorPageMode, features, includeNodeAdapterContract),
|
|
2102
|
+
},
|
|
2103
|
+
];
|
|
2104
|
+
}
|
|
2105
|
+
function toNextJsModuleImports(content) {
|
|
2106
|
+
return content.replaceAll(/(from\s+['"](?:\.{1,2}\/[^'"]+))\.js(['"])/g, '$1$2');
|
|
2107
|
+
}
|
|
2108
|
+
export function webGeneratedFiles(corePkg, applyTemplate, features = DEFAULT_FEATURES) {
|
|
2109
|
+
return [
|
|
2110
|
+
...webCoreGeneratedFiles(corePkg, 'html', features, true),
|
|
2111
|
+
{ path: 'apply.ts', content: applyTemplate },
|
|
2112
|
+
{ path: 'node-adapter.ts', content: nodeAdapterTemplate() },
|
|
2113
|
+
];
|
|
2114
|
+
}
|
|
2115
|
+
export function nextJsGeneratedFiles(corePkg, features = DEFAULT_FEATURES) {
|
|
2116
|
+
const internalFiles = webCoreGeneratedFiles(corePkg, 'redirect', features).map((file) => ({
|
|
2117
|
+
path: `_oidc-provider/${file.path}`,
|
|
2118
|
+
content: toNextJsModuleImports(file.content),
|
|
2119
|
+
}));
|
|
2120
|
+
return [
|
|
2121
|
+
...internalFiles,
|
|
2122
|
+
{ path: '_oidc-provider/next.ts', content: nextJsRouteHandlerTemplate() },
|
|
2123
|
+
{ path: '_oidc-provider/storage-backend.ts', content: nextJsStorageBackendTemplate() },
|
|
2124
|
+
{ path: '_oidc-provider/runtime.ts', content: nextJsRuntimeTemplate(corePkg) },
|
|
2125
|
+
{
|
|
2126
|
+
path: 'authorize/route.ts',
|
|
2127
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', [
|
|
2128
|
+
'GET',
|
|
2129
|
+
'POST',
|
|
2130
|
+
'OPTIONS',
|
|
2131
|
+
]),
|
|
2132
|
+
},
|
|
2133
|
+
{
|
|
2134
|
+
path: 'token/route.ts',
|
|
2135
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
|
|
2136
|
+
},
|
|
2137
|
+
{
|
|
2138
|
+
path: 'userinfo/route.ts',
|
|
2139
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', [
|
|
2140
|
+
'GET',
|
|
2141
|
+
'POST',
|
|
2142
|
+
'OPTIONS',
|
|
2143
|
+
]),
|
|
2144
|
+
},
|
|
2145
|
+
...(features.introspection
|
|
2146
|
+
? [
|
|
2147
|
+
{
|
|
2148
|
+
path: 'introspect/route.ts',
|
|
2149
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
|
|
2150
|
+
},
|
|
2151
|
+
]
|
|
2152
|
+
: []),
|
|
2153
|
+
...(features.revocation
|
|
2154
|
+
? [
|
|
2155
|
+
{
|
|
2156
|
+
path: 'revoke/route.ts',
|
|
2157
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
|
|
2158
|
+
},
|
|
2159
|
+
]
|
|
2160
|
+
: []),
|
|
2161
|
+
...(features.par
|
|
2162
|
+
? [
|
|
2163
|
+
{
|
|
2164
|
+
path: 'par/route.ts',
|
|
2165
|
+
content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
|
|
2166
|
+
},
|
|
2167
|
+
]
|
|
2168
|
+
: []),
|
|
2169
|
+
{
|
|
2170
|
+
path: '.well-known/jwks.json/route.ts',
|
|
2171
|
+
content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', [
|
|
2172
|
+
'GET',
|
|
2173
|
+
'OPTIONS',
|
|
2174
|
+
]),
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
path: '.well-known/openid-configuration/route.ts',
|
|
2178
|
+
content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', [
|
|
2179
|
+
'GET',
|
|
2180
|
+
'OPTIONS',
|
|
2181
|
+
]),
|
|
2182
|
+
},
|
|
2183
|
+
{ path: 'login/page.tsx', content: nextJsLoginPageTemplate(corePkg) },
|
|
2184
|
+
{ path: 'login/actions.ts', content: nextJsLoginActionTemplate(corePkg) },
|
|
2185
|
+
{ path: 'consent/page.tsx', content: nextJsConsentPageTemplate(corePkg) },
|
|
2186
|
+
{ path: 'consent/actions.ts', content: nextJsConsentActionTemplate(corePkg) },
|
|
2187
|
+
{ path: 'oidc-error/page.tsx', content: nextJsAuthorizationErrorPageTemplate() },
|
|
2188
|
+
{ path: 'oidc-error/error.tsx', content: nextJsAuthorizationErrorBoundaryTemplate() },
|
|
2189
|
+
];
|
|
2190
|
+
}
|
|
2191
|
+
//# sourceMappingURL=templates.js.map
|