@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/dist/features.d.ts +19 -0
  4. package/dist/features.d.ts.map +1 -0
  5. package/dist/features.js +64 -0
  6. package/dist/features.js.map +1 -0
  7. package/dist/frameworks/express/index.d.ts +7 -0
  8. package/dist/frameworks/express/index.d.ts.map +1 -0
  9. package/dist/frameworks/express/index.js +9 -0
  10. package/dist/frameworks/express/index.js.map +1 -0
  11. package/dist/frameworks/fastify/index.d.ts +7 -0
  12. package/dist/frameworks/fastify/index.d.ts.map +1 -0
  13. package/dist/frameworks/fastify/index.js +9 -0
  14. package/dist/frameworks/fastify/index.js.map +1 -0
  15. package/dist/frameworks/hono/index.d.ts +7 -0
  16. package/dist/frameworks/hono/index.d.ts.map +1 -0
  17. package/dist/frameworks/hono/index.js +36 -0
  18. package/dist/frameworks/hono/index.js.map +1 -0
  19. package/dist/frameworks/hono/templates.d.ts +37 -0
  20. package/dist/frameworks/hono/templates.d.ts.map +1 -0
  21. package/dist/frameworks/hono/templates.js +8315 -0
  22. package/dist/frameworks/hono/templates.js.map +1 -0
  23. package/dist/frameworks/index.d.ts +5 -0
  24. package/dist/frameworks/index.d.ts.map +1 -0
  25. package/dist/frameworks/index.js +19 -0
  26. package/dist/frameworks/index.js.map +1 -0
  27. package/dist/frameworks/nextjs/index.d.ts +7 -0
  28. package/dist/frameworks/nextjs/index.d.ts.map +1 -0
  29. package/dist/frameworks/nextjs/index.js +9 -0
  30. package/dist/frameworks/nextjs/index.js.map +1 -0
  31. package/dist/frameworks/types.d.ts +16 -0
  32. package/dist/frameworks/types.d.ts.map +1 -0
  33. package/dist/frameworks/types.js +2 -0
  34. package/dist/frameworks/types.js.map +1 -0
  35. package/dist/frameworks/web-standard/templates.d.ts +21 -0
  36. package/dist/frameworks/web-standard/templates.d.ts.map +1 -0
  37. package/dist/frameworks/web-standard/templates.js +2191 -0
  38. package/dist/frameworks/web-standard/templates.js.map +1 -0
  39. package/dist/generator.d.ts +16 -0
  40. package/dist/generator.d.ts.map +1 -0
  41. package/dist/generator.js +15 -0
  42. package/dist/generator.js.map +1 -0
  43. package/dist/index.d.ts +3 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +242 -0
  46. package/dist/index.js.map +1 -0
  47. 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('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;')).toBe(true);
1707
+ expect(loginHtml.includes('&lt;img src=x onerror=alert(1)&gt;')).toBe(true);
1708
+ expect(consentHtml.includes('<script>')).toBe(false);
1709
+ expect(consentHtml.includes('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;')).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