@maronn-openid-connect/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +83 -7
  2. package/dist/frameworks/hono/index.d.ts.map +1 -1
  3. package/dist/frameworks/hono/index.js +18 -9
  4. package/dist/frameworks/hono/index.js.map +1 -1
  5. package/dist/frameworks/hono/pages.d.ts +7 -6
  6. package/dist/frameworks/hono/pages.d.ts.map +1 -1
  7. package/dist/frameworks/hono/pages.js +62 -44
  8. package/dist/frameworks/hono/pages.js.map +1 -1
  9. package/dist/frameworks/hono/templates.d.ts +19 -6
  10. package/dist/frameworks/hono/templates.d.ts.map +1 -1
  11. package/dist/frameworks/hono/templates.js +439 -304
  12. package/dist/frameworks/hono/templates.js.map +1 -1
  13. package/dist/frameworks/hono/views.d.ts +3 -0
  14. package/dist/frameworks/hono/views.d.ts.map +1 -0
  15. package/dist/frameworks/hono/views.js +458 -0
  16. package/dist/frameworks/hono/views.js.map +1 -0
  17. package/dist/frameworks/nextjs/authorize.d.ts +3 -0
  18. package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
  19. package/dist/frameworks/nextjs/authorize.js +757 -0
  20. package/dist/frameworks/nextjs/authorize.js.map +1 -0
  21. package/dist/frameworks/nextjs/ciba.d.ts +8 -0
  22. package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
  23. package/dist/frameworks/nextjs/ciba.js +591 -0
  24. package/dist/frameworks/nextjs/ciba.js.map +1 -0
  25. package/dist/frameworks/nextjs/conformance.d.ts +3 -0
  26. package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
  27. package/dist/frameworks/nextjs/conformance.js +2447 -0
  28. package/dist/frameworks/nextjs/conformance.js.map +1 -0
  29. package/dist/frameworks/nextjs/device.d.ts +8 -0
  30. package/dist/frameworks/nextjs/device.d.ts.map +1 -0
  31. package/dist/frameworks/nextjs/device.js +662 -0
  32. package/dist/frameworks/nextjs/device.js.map +1 -0
  33. package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
  34. package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
  35. package/dist/frameworks/nextjs/endpoints.js +673 -0
  36. package/dist/frameworks/nextjs/endpoints.js.map +1 -0
  37. package/dist/frameworks/nextjs/index.d.ts.map +1 -1
  38. package/dist/frameworks/nextjs/index.js +135 -2
  39. package/dist/frameworks/nextjs/index.js.map +1 -1
  40. package/dist/frameworks/nextjs/interaction.d.ts +13 -0
  41. package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
  42. package/dist/frameworks/nextjs/interaction.js +864 -0
  43. package/dist/frameworks/nextjs/interaction.js.map +1 -0
  44. package/dist/frameworks/nextjs/logout.d.ts +5 -0
  45. package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
  46. package/dist/frameworks/nextjs/logout.js +271 -0
  47. package/dist/frameworks/nextjs/logout.js.map +1 -0
  48. package/dist/frameworks/nextjs/metadata.d.ts +6 -0
  49. package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
  50. package/dist/frameworks/nextjs/metadata.js +339 -0
  51. package/dist/frameworks/nextjs/metadata.js.map +1 -0
  52. package/dist/frameworks/nextjs/provider.d.ts +6 -0
  53. package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
  54. package/dist/frameworks/nextjs/provider.js +797 -0
  55. package/dist/frameworks/nextjs/provider.js.map +1 -0
  56. package/dist/frameworks/nextjs/token.d.ts +7 -0
  57. package/dist/frameworks/nextjs/token.d.ts.map +1 -0
  58. package/dist/frameworks/nextjs/token.js +1337 -0
  59. package/dist/frameworks/nextjs/token.js.map +1 -0
  60. package/dist/frameworks/web-standard/templates.d.ts +1 -13
  61. package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
  62. package/dist/frameworks/web-standard/templates.js +12 -1296
  63. package/dist/frameworks/web-standard/templates.js.map +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +38 -8
  66. package/dist/index.js.map +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,797 @@
1
+ import { DEFAULT_FEATURES } from '../../features.js';
2
+ export function nextJsProviderTemplate(corePkg, features = DEFAULT_FEATURES) {
3
+ const refreshTokenConfig = features.refreshToken
4
+ ? `
5
+ refreshTokenAbsoluteLifetime: 7776000,`
6
+ : '';
7
+ const pkceConfig = features.pkce
8
+ ? `
9
+ // OIDF Basic OP static-client compatibility (OAuth 2.1 makes PKCE mandatory):
10
+ // set OIDC_ALLOW_NON_PKCE_AUTHORIZATION_CODE_FLOW=1 only for conformance runs.
11
+ allowNonPkceAuthorizationCodeFlow:
12
+ process.env.OIDC_ALLOW_NON_PKCE_AUTHORIZATION_CODE_FLOW === '1',`
13
+ : '';
14
+ const requestObjectConfig = features.requestObject
15
+ ? `
16
+ // OIDC Core 1.0 §6.1 / RFC 9101: accepting unsigned (alg:none) Request Objects
17
+ // is a security relaxation used only for OIDF Basic OP conformance, where the
18
+ // request object modules are skipped unless the OP advertises 'none' in
19
+ // request_object_signing_alg_values_supported. Default off (signed-only).
20
+ allowUnsignedRequestObject: process.env.OIDC_ALLOW_UNSIGNED_REQUEST_OBJECT === '1',`
21
+ : '';
22
+ const googleLoginConfig = features.googleLogin
23
+ ? `
24
+ // EXTENSION (google-login): set GOOGLE_CLIENT_ID (the OAuth client ID from the
25
+ // Google Cloud console) to render the "Sign in with Google" button, and
26
+ // register <issuer>/login/google as an authorized redirect URI there.
27
+ // GOOGLE_HOSTED_DOMAIN optionally restricts sign-in to one Workspace domain.
28
+ googleLogin: readGoogleLoginConfig(),`
29
+ : '';
30
+ const googleLoginReader = features.googleLogin
31
+ ? `
32
+ function readGoogleLoginConfig(): GoogleLoginConfig | undefined {
33
+ const clientId = process.env.GOOGLE_CLIENT_ID;
34
+ if (!clientId) return undefined;
35
+ const hostedDomain = process.env.GOOGLE_HOSTED_DOMAIN;
36
+ return hostedDomain ? { clientId, hostedDomain } : { clientId };
37
+ }
38
+ `
39
+ : '';
40
+ const googleLoginConfigTypeImport = features.googleLogin ? '\n type GoogleLoginConfig,' : '';
41
+ const experimentalStores = [
42
+ ...(features.par
43
+ ? [` /** EXPERIMENTAL (RFC 9126): pushed authorization requests. */
44
+ parStore,`]
45
+ : []),
46
+ ...(features.deviceAuthorizationGrant
47
+ ? [` /** EXPERIMENTAL (RFC 8628): device authorizations. */
48
+ deviceAuthorizationStore,`]
49
+ : []),
50
+ ...(features.ciba
51
+ ? [` /** EXPERIMENTAL (CIBA Core 1.0): backchannel authentication requests. */
52
+ cibaAuthenticationRequestStore,
53
+ /** EXPERIMENTAL (CIBA Core 1.0): sign-in forms of the authentication device UI. */
54
+ cibaLoginTransactionStore,`]
55
+ : []),
56
+ ];
57
+ const experimentalStoreExport = experimentalStores.length > 0
58
+ ? `
59
+ // EXPERIMENTAL feature stores. They are in-memory (store.ts), so pending
60
+ // requests do not survive a restart and are not shared between instances —
61
+ // replace them with persistent stores before relying on these flows.
62
+ export {
63
+ ${experimentalStores.join('\n')}
64
+ } from './store';
65
+ `
66
+ : '';
67
+ const cibaUserResolver = features.ciba
68
+ ? `
69
+ /**
70
+ * EXPERIMENTAL (CIBA Core 1.0 §7.1): resolve a login_hint to the End-User the
71
+ * backchannel authentication request is for. The default treats the hint as a
72
+ * username of the user store; resolve email addresses, phone numbers or your
73
+ * own identifiers here instead. Return null when no user matches.
74
+ */
75
+ export async function resolveCibaUser(loginHint: string): Promise<{ subject: string } | null> {
76
+ const claims = await stores.userStore.getClaims(loginHint);
77
+ return claims ? { subject: claims.sub } : null;
78
+ }
79
+ `
80
+ : '';
81
+ return `/**
82
+ * The OpenID Provider's configuration and the dependencies its endpoints share.
83
+ *
84
+ * Every Route Handler, page and Server Action imports what it needs from here:
85
+ * the provider config, the registered clients, the stores, the resolvers built
86
+ * over them and the signing keys. This is the file to edit when wiring the OP
87
+ * into a project — read clients from a database, load fixed signing keys, swap
88
+ * the storage backend, decide acr / amr.
89
+ *
90
+ * Everything below is created once per server process. Next.js bundles Route
91
+ * Handlers apart from pages and Server Actions, so anything that has to be the
92
+ * same instance for both (the stores, the signing key) is kept on globalThis.
93
+ */
94
+ import {
95
+ assertHasRs256Key,
96
+ assertKeyStrength,
97
+ assertKidStrategyConsistent,
98
+ createCachedSigningKeyProvider,
99
+ createJwtAccessTokenIssuer,
100
+ createOpaqueAccessTokenIssuer,
101
+ getRegisteredSigningKeys,
102
+ selectSigningKeyByAlg,
103
+ signingKeysToJwkSet,
104
+ type AccessTokenIssuer,
105
+ type AcrResolver,
106
+ type JwkSet,
107
+ type SigningKey,
108
+ type SigningKeyProvider,
109
+ } from '${corePkg}';
110
+ import {
111
+ createInMemoryClientResolver,
112
+ createProviderConfig,${googleLoginConfigTypeImport}
113
+ type ProviderConfig,
114
+ type RegisteredClient,
115
+ } from './config';
116
+ import { createStoreResolvers } from './resolvers';
117
+ import { createNextJsProviderStores } from './storage-backend';
118
+
119
+ /**
120
+ * Provider configuration. OIDC_ISSUER must be the exact public URL of this app:
121
+ * it is the iss of every token, and the origin every URL the OP builds for
122
+ * itself is based on (OIDC Discovery 1.0 §3).
123
+ */
124
+ export const config: ProviderConfig = createProviderConfig({
125
+ issuer: process.env.OIDC_ISSUER ?? process.env.ISSUER ?? 'http://localhost:3000',
126
+ accessTokenExpiresIn: 3600,
127
+ idTokenExpiresIn: 3600,${refreshTokenConfig}
128
+ accessTokenFormat: 'jwt',
129
+ authorizationCodeTtl: 300,${pkceConfig}${requestObjectConfig}${googleLoginConfig}
130
+ });
131
+
132
+ /**
133
+ * Registered clients: OIDC_CLIENTS_JSON (a JSON array of RegisteredClient), or a
134
+ * single confidential client from OIDC_CLIENT_ID / OIDC_CLIENT_SECRET /
135
+ * OIDC_CLIENT_REDIRECT_URI. Replace this with a database-backed ClientResolver
136
+ * in a real project.
137
+ */
138
+ export const clientResolver = createInMemoryClientResolver(readRegisteredClients());
139
+
140
+ /**
141
+ * Persistent provider stores: Upstash Redis on Vercel, node:sqlite locally
142
+ * (storage-backend.ts).
143
+ */
144
+ export const stores = createNextJsProviderStores();
145
+ ${experimentalStoreExport}
146
+ /** The resolvers core reads authorization codes, tokens, sessions and consent through. */
147
+ export const resolvers = createStoreResolvers(stores);
148
+
149
+ /**
150
+ * How access tokens are minted (config.accessTokenFormat): self-contained JWTs
151
+ * (RFC 9068) by default, or opaque strings resource servers check through
152
+ * introspection — the better fit when a token must be revocable at once.
153
+ */
154
+ export const accessTokenIssuer: AccessTokenIssuer =
155
+ config.accessTokenFormat === 'opaque'
156
+ ? createOpaqueAccessTokenIssuer()
157
+ : createJwtAccessTokenIssuer();
158
+
159
+ /**
160
+ * Origins allowed to call the back-channel endpoints from a browser (CORS):
161
+ * OIDC_CORS_ORIGINS, or the issuer itself. Discovery and JWKS are public.
162
+ */
163
+ export const corsOrigins: string | readonly string[] = process.env.OIDC_CORS_ORIGINS ?? config.issuer;
164
+
165
+ /**
166
+ * acr / amr of the ID Token (OIDC Core 1.0 §2 / §3.1.2.1).
167
+ *
168
+ * This sample echoes the most-preferred requested acr_values entry back, which
169
+ * is what the OIDF oidcc-ensure-request-with-acr-values-succeeds module checks.
170
+ * A real deployment must map this to its actual authentication context instead
171
+ * of trusting the request.
172
+ */
173
+ export const acrResolver: AcrResolver = async ({ requestedAcrValues }) => {
174
+ if (!requestedAcrValues) return undefined;
175
+ const preferred = requestedAcrValues.split(' ').find((value) => value.length > 0);
176
+ if (!preferred) return undefined;
177
+ return { acr: preferred, amr: ['pwd'] };
178
+ };
179
+ ${cibaUserResolver}
180
+ // --- Signing keys -------------------------------------------------------------
181
+ //
182
+ // The key below is generated when the server starts, so issued tokens stop
183
+ // verifying after a restart and separate instances never share a key. Load
184
+ // fixed keys (an environment secret, a KMS, a JWKS file) for anything beyond
185
+ // local testing.
186
+
187
+ const signingKeyRegistry = globalThis as typeof globalThis & {
188
+ __oidcSigningKeyProvider?: SigningKeyProvider;
189
+ };
190
+
191
+ /**
192
+ * General-purpose signing keys: access tokens, and every other JWT the OP signs
193
+ * besides ID Tokens and UserInfo responses. Kept on globalThis so a Server
194
+ * Action signs with the same key the JWKS Route Handler publishes.
195
+ */
196
+ const signingKeyProvider: SigningKeyProvider = (signingKeyRegistry.__oidcSigningKeyProvider ??=
197
+ createCachedSigningKeyProvider(createEphemeralRs256KeyProvider(), 60_000));
198
+
199
+ /** ID Token signing keys. Point this at another provider to sign ID Tokens with dedicated keys. */
200
+ const idTokenSigningKeyProvider: SigningKeyProvider = signingKeyProvider;
201
+
202
+ /** UserInfo response signing keys (OIDC Core 1.0 §5.3.2, userinfo_signed_response_alg). */
203
+ const userinfoSigningKeyProvider: SigningKeyProvider = signingKeyProvider;
204
+
205
+ /** The key new signatures use, and every key still published for verification. */
206
+ export interface SigningKeySet {
207
+ active: SigningKey;
208
+ /** Rotated-out keys stay registered until the tokens signed with them expire. */
209
+ registered: SigningKey[];
210
+ }
211
+
212
+ export interface ProviderSigningKeys {
213
+ general: SigningKeySet;
214
+ idToken: SigningKeySet;
215
+ userinfo: SigningKeySet;
216
+ }
217
+
218
+ /**
219
+ * Load every signing key set and refuse one the OP must not sign with: weak
220
+ * keys, ambiguous kids, or ID Token keys without RS256 (OIDC Core 1.0 §15.1).
221
+ * Endpoints answer 503 when this throws.
222
+ */
223
+ export async function loadSigningKeys(): Promise<ProviderSigningKeys> {
224
+ const keys = {
225
+ general: await loadSigningKeySet(signingKeyProvider),
226
+ idToken: await loadSigningKeySet(idTokenSigningKeyProvider),
227
+ userinfo: await loadSigningKeySet(userinfoSigningKeyProvider),
228
+ };
229
+ validateSigningKeySet(keys.general.registered);
230
+ validateSigningKeySet(keys.idToken.registered, true);
231
+ validateSigningKeySet(keys.userinfo.registered);
232
+ return keys;
233
+ }
234
+
235
+ /**
236
+ * The JWKS an id_token_hint is verified against (OIDC Core 1.0 §3.1.2.2): this
237
+ * OP's own ID Token keys, so any ID Token it issued is accepted as a hint.
238
+ */
239
+ export function idTokenHintJwks(keys: ProviderSigningKeys): Promise<JwkSet> {
240
+ return signingKeysToJwkSet(keys.idToken.registered);
241
+ }
242
+
243
+ /**
244
+ * The key a client's ID Tokens are signed with: the registered ID Token key
245
+ * whose alg matches its id_token_signed_response_alg (OIDC Dynamic Client
246
+ * Registration 1.0 §2), RS256 when it registered none. Never simply the active
247
+ * key — that would hand an ES256 client an RS256 ID Token and hash at_hash with
248
+ * the wrong algorithm. undefined when no registered key has that alg, which is
249
+ * a server configuration error.
250
+ */
251
+ export function selectIdTokenSigningKey(
252
+ keys: ProviderSigningKeys,
253
+ alg: string | undefined,
254
+ ): SigningKey | undefined {
255
+ try {
256
+ return selectSigningKeyByAlg(keys.idToken.registered, alg);
257
+ } catch {
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ export function validateSigningKeySet(
263
+ keys: readonly SigningKey[],
264
+ requireRs256 = false,
265
+ ): void {
266
+ assertKeyStrength(keys);
267
+ assertKidStrategyConsistent(keys);
268
+ if (requireRs256) {
269
+ assertHasRs256Key(keys.map((key) => key.privateKey));
270
+ }
271
+ }
272
+
273
+ async function loadSigningKeySet(provider: SigningKeyProvider): Promise<SigningKeySet> {
274
+ return {
275
+ active: await provider.getSigningKey(),
276
+ registered: await getRegisteredSigningKeys(provider),
277
+ };
278
+ }
279
+
280
+ function createEphemeralRs256KeyProvider(): SigningKeyProvider {
281
+ const keyPromise = generateSigningKey();
282
+ return {
283
+ async getSigningKey(): Promise<SigningKey> {
284
+ return keyPromise;
285
+ },
286
+ async getSigningKeys(): Promise<SigningKey[]> {
287
+ return [await keyPromise];
288
+ },
289
+ };
290
+ }
291
+
292
+ async function generateSigningKey(): Promise<SigningKey> {
293
+ const keyPair = await crypto.subtle.generateKey(
294
+ {
295
+ name: 'RSASSA-PKCS1-v1_5',
296
+ modulusLength: 2048,
297
+ publicExponent: new Uint8Array([1, 0, 1]),
298
+ hash: 'SHA-256',
299
+ },
300
+ true,
301
+ ['sign', 'verify'],
302
+ );
303
+ const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) as JsonWebKey & {
304
+ alg?: string;
305
+ use?: string;
306
+ kid?: string;
307
+ };
308
+ publicJwk.alg = 'RS256';
309
+ publicJwk.use = 'sig';
310
+ publicJwk.kid = process.env.OIDC_SIGNING_KEY_ID ?? 'nextjs-rs256-key';
311
+
312
+ return {
313
+ privateKey: keyPair.privateKey,
314
+ publicJwk,
315
+ keyId: publicJwk.kid,
316
+ };
317
+ }
318
+
319
+ // --- Registered clients ---------------------------------------------------------
320
+
321
+ function readRegisteredClients(): ReadonlyMap<string, RegisteredClient> {
322
+ const encoded = process.env.OIDC_CLIENTS_JSON;
323
+ if (encoded) {
324
+ const clients = JSON.parse(encoded) as RegisteredClient[];
325
+ return new Map(clients.map((client) => [client.clientId, client]));
326
+ }
327
+
328
+ const clientId = process.env.OIDC_CLIENT_ID ?? process.env.CLIENT_ID ?? 'example-client';
329
+ const clientSecret =
330
+ process.env.OIDC_CLIENT_SECRET ?? process.env.CLIENT_SECRET ?? 'example-secret';
331
+ const clientRedirectUri =
332
+ process.env.OIDC_CLIENT_REDIRECT_URI ??
333
+ process.env.CLIENT_REDIRECT_URI ??
334
+ 'http://localhost:3000/callback';
335
+
336
+ const clients = new Map<string, RegisteredClient>([
337
+ [
338
+ clientId,
339
+ {
340
+ clientId,
341
+ clientSecret,
342
+ redirectUris: [clientRedirectUri],
343
+ clientType: 'confidential',
344
+ grantTypes: ['authorization_code'],
345
+ tokenEndpointAuthMethod: 'client_secret_post',
346
+ responseTypes: ['code'],
347
+ },
348
+ ],
349
+ ]);
350
+
351
+ const resourceServerClientId =
352
+ process.env.OIDC_RESOURCE_SERVER_CLIENT_ID ?? process.env.RESOURCE_SERVER_CLIENT_ID;
353
+ const resourceServerClientSecret =
354
+ process.env.OIDC_RESOURCE_SERVER_CLIENT_SECRET ?? process.env.RESOURCE_SERVER_CLIENT_SECRET;
355
+ const resourceServerRedirectUri =
356
+ process.env.OIDC_RESOURCE_SERVER_REDIRECT_URI ??
357
+ process.env.RESOURCE_SERVER_REDIRECT_URI ??
358
+ 'http://localhost:3030/unused-callback';
359
+
360
+ if (resourceServerClientId && resourceServerClientSecret) {
361
+ clients.set(resourceServerClientId, {
362
+ clientId: resourceServerClientId,
363
+ clientSecret: resourceServerClientSecret,
364
+ redirectUris: [resourceServerRedirectUri],
365
+ clientType: 'confidential',
366
+ grantTypes: ['authorization_code'],
367
+ tokenEndpointAuthMethod: 'client_secret_basic',
368
+ responseTypes: ['code'],
369
+ });
370
+ }
371
+
372
+ return clients;
373
+ }
374
+ ${googleLoginReader}`;
375
+ }
376
+ export function nextJsStorageBackendTemplate() {
377
+ return `import { mkdirSync } from 'node:fs';
378
+ import { dirname, resolve } from 'node:path';
379
+ import { DatabaseSync } from 'node:sqlite';
380
+ import {
381
+ createJsonProviderStores,
382
+ type JsonStoreBackend,
383
+ type JsonStoreEntry,
384
+ type ProviderStores,
385
+ } from './store';
386
+
387
+ interface StoredRow {
388
+ key: string;
389
+ value: string;
390
+ expires_at: number | null;
391
+ }
392
+
393
+ class SqliteJsonStoreBackend implements JsonStoreBackend {
394
+ private readonly database: DatabaseSync;
395
+
396
+ constructor(path: string) {
397
+ const databasePath = path === ':memory:' ? path : resolve(path);
398
+ if (databasePath !== ':memory:') {
399
+ mkdirSync(dirname(databasePath), { recursive: true });
400
+ }
401
+ this.database = new DatabaseSync(databasePath);
402
+ // Concurrent processes opening the same file (e.g. Next.js build workers
403
+ // collecting page data) race on the initial schema write; without a busy
404
+ // timeout SQLite fails fast with "database is locked" instead of waiting.
405
+ this.database.exec('PRAGMA busy_timeout = 5000');
406
+ this.database.exec('PRAGMA journal_mode = WAL');
407
+ this.database.exec(
408
+ 'CREATE TABLE IF NOT EXISTS oidc_store (' +
409
+ 'key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)',
410
+ );
411
+ }
412
+
413
+ async get<T>(key: string): Promise<T | null> {
414
+ const row = this.database
415
+ .prepare('SELECT key, value, expires_at FROM oidc_store WHERE key = ?')
416
+ .get(key) as unknown as StoredRow | undefined;
417
+ if (!row) return null;
418
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
419
+ await this.delete(key);
420
+ return null;
421
+ }
422
+ return JSON.parse(row.value) as T;
423
+ }
424
+
425
+ async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
426
+ const expiresAt = ttlSeconds === undefined ? null : Date.now() + ttlSeconds * 1000;
427
+ this.database.prepare(
428
+ 'INSERT INTO oidc_store (key, value, expires_at) VALUES (?, ?, ?) ' +
429
+ 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at',
430
+ ).run(key, JSON.stringify(value), expiresAt);
431
+ }
432
+
433
+ async delete(key: string): Promise<void> {
434
+ this.database.prepare('DELETE FROM oidc_store WHERE key = ?').run(key);
435
+ }
436
+
437
+ async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
438
+ const rows = this.database
439
+ .prepare(
440
+ 'SELECT key, value, expires_at FROM oidc_store ' +
441
+ 'WHERE key >= ? AND key < ? ORDER BY key',
442
+ )
443
+ .all(prefix, prefix + '\\uffff') as unknown as StoredRow[];
444
+ const entries: Array<JsonStoreEntry<T>> = [];
445
+ for (const row of rows) {
446
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
447
+ await this.delete(row.key);
448
+ } else {
449
+ entries.push({ key: row.key, value: JSON.parse(row.value) as T });
450
+ }
451
+ }
452
+ return entries;
453
+ }
454
+ }
455
+
456
+ interface UpstashResponse<T> {
457
+ result?: T;
458
+ error?: string;
459
+ }
460
+
461
+ class UpstashRedisJsonStoreBackend implements JsonStoreBackend {
462
+ constructor(
463
+ private readonly url: string,
464
+ private readonly token: string,
465
+ private readonly namespace = 'maronn-openid-connect:',
466
+ ) {}
467
+
468
+ async get<T>(key: string): Promise<T | null> {
469
+ const value = await this.command<string | null>(['GET', this.fullKey(key)]);
470
+ return value === null ? null : JSON.parse(value) as T;
471
+ }
472
+
473
+ async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
474
+ const command: Array<string | number> = ['SET', this.fullKey(key), JSON.stringify(value)];
475
+ if (ttlSeconds !== undefined) command.push('EX', ttlSeconds);
476
+ await this.command<string>(command);
477
+ }
478
+
479
+ async delete(key: string): Promise<void> {
480
+ await this.command<number>(['DEL', this.fullKey(key)]);
481
+ }
482
+
483
+ async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
484
+ const keys: string[] = [];
485
+ let cursor = '0';
486
+ do {
487
+ const result = await this.command<[string, string[]]>([
488
+ 'SCAN',
489
+ cursor,
490
+ 'MATCH',
491
+ this.fullKey(prefix) + '*',
492
+ 'COUNT',
493
+ 100,
494
+ ]);
495
+ cursor = String(result[0]);
496
+ keys.push(...result[1]);
497
+ } while (cursor !== '0');
498
+
499
+ const entries: Array<JsonStoreEntry<T>> = [];
500
+ for (const fullKey of keys) {
501
+ const value = await this.command<string | null>(['GET', fullKey]);
502
+ if (value !== null) {
503
+ entries.push({
504
+ key: fullKey.slice(this.namespace.length),
505
+ value: JSON.parse(value) as T,
506
+ });
507
+ }
508
+ }
509
+ return entries;
510
+ }
511
+
512
+ private fullKey(key: string): string {
513
+ return this.namespace + key;
514
+ }
515
+
516
+ private async command<T>(command: Array<string | number>): Promise<T> {
517
+ const response = await fetch(this.url, {
518
+ method: 'POST',
519
+ headers: {
520
+ Authorization: 'Bearer ' + this.token,
521
+ 'Content-Type': 'application/json',
522
+ },
523
+ body: JSON.stringify(command),
524
+ cache: 'no-store',
525
+ });
526
+ const body = await response.json() as UpstashResponse<T>;
527
+ if (!response.ok || body.error || !('result' in body)) {
528
+ throw new Error(body.error ?? 'Upstash Redis request failed with HTTP ' + response.status);
529
+ }
530
+ return body.result as T;
531
+ }
532
+ }
533
+
534
+ const storageRegistry = globalThis as typeof globalThis & {
535
+ __oidcNextJsProviderStores?: ProviderStores;
536
+ };
537
+
538
+ /**
539
+ * The provider stores, created once per process. Kept on globalThis because
540
+ * Next.js instantiates Route Handlers and Server Actions in separate module
541
+ * layers: without it, a transaction written by /authorize would be invisible
542
+ * to the login page. It also survives dev-mode hot reloads.
543
+ */
544
+ export function createNextJsProviderStores(): ProviderStores {
545
+ return (storageRegistry.__oidcNextJsProviderStores ??= createStores());
546
+ }
547
+
548
+ function createStores(): ProviderStores {
549
+ const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
550
+ const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
551
+ if (redisUrl && redisToken) {
552
+ return createJsonProviderStores(new UpstashRedisJsonStoreBackend(redisUrl, redisToken));
553
+ }
554
+ if (process.env.VERCEL) {
555
+ throw new Error(
556
+ 'UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required on Vercel',
557
+ );
558
+ }
559
+ const sqlitePath = process.env.OIDC_SQLITE_PATH ?? '.data/oidc.sqlite';
560
+ return createJsonProviderStores(new SqliteJsonStoreBackend(sqlitePath));
561
+ }
562
+ `;
563
+ }
564
+ export function nextJsHttpTemplate() {
565
+ return `/**
566
+ * Request and response helpers shared by the OP's Route Handlers and Server
567
+ * Actions.
568
+ *
569
+ * - CORS: the back-channel endpoints may be called from client applications in
570
+ * the browser; discovery and JWKS are public. Each Route Handler exports an
571
+ * OPTIONS handler (corsPreflight) and wraps its answers with withCors.
572
+ * - Responses that carry credentials are never cached (RFC 6749 §5.1 / §5.2).
573
+ * - OAuth request parameters must not be repeated (RFC 6749 §3.1 / §3.2), which
574
+ * Object.fromEntries(searchParams) would hide by keeping only the last value.
575
+ * - An error that must not reach the client ends on the OP's error page
576
+ * (app/oidc-error): errorPagePath() / redirectToErrorPage().
577
+ */
578
+ import { NextResponse } from 'next/server';
579
+ import { config, corsOrigins } from './provider';
580
+
581
+ export interface CorsPolicy {
582
+ origins: string | readonly string[];
583
+ allowMethods: readonly string[];
584
+ allowHeaders: readonly string[];
585
+ maxAge: number;
586
+ }
587
+
588
+ /**
589
+ * Endpoints client applications call directly (token, userinfo, introspect,
590
+ * revoke, ...): only the origins configured in provider.ts.
591
+ */
592
+ export const clientCors: CorsPolicy = {
593
+ origins: corsOrigins,
594
+ allowMethods: ['POST', 'GET', 'OPTIONS'],
595
+ allowHeaders: ['Authorization', 'Content-Type'],
596
+ maxAge: 600,
597
+ };
598
+
599
+ /** Public metadata (discovery, JWKS): readable from any origin. */
600
+ export const publicCors: CorsPolicy = {
601
+ origins: '*',
602
+ allowMethods: ['GET', 'OPTIONS'],
603
+ allowHeaders: ['Content-Type'],
604
+ maxAge: 600,
605
+ };
606
+
607
+ /** Answer a CORS preflight request. */
608
+ export function corsPreflight(request: Request, policy: CorsPolicy): Response {
609
+ return new Response(null, { status: 204, headers: corsHeaders(request, policy) });
610
+ }
611
+
612
+ /** Add the CORS headers of policy to a response. */
613
+ export function withCors(request: Request, policy: CorsPolicy, response: Response): Response {
614
+ const headers = new Headers(response.headers);
615
+ corsHeaders(request, policy).forEach((value, name) => headers.set(name, value));
616
+ return new Response(response.body, {
617
+ status: response.status,
618
+ statusText: response.statusText,
619
+ headers,
620
+ });
621
+ }
622
+
623
+ function corsHeaders(request: Request, policy: CorsPolicy): Headers {
624
+ const headers = new Headers();
625
+ const origin = allowedOrigin(request.headers.get('Origin'), policy.origins);
626
+ if (origin) {
627
+ headers.set('Access-Control-Allow-Origin', origin);
628
+ }
629
+ headers.set('Vary', 'Origin');
630
+ headers.set('Access-Control-Allow-Methods', policy.allowMethods.join(','));
631
+ headers.set('Access-Control-Allow-Headers', policy.allowHeaders.join(','));
632
+ headers.set('Access-Control-Max-Age', String(policy.maxAge));
633
+ return headers;
634
+ }
635
+
636
+ function allowedOrigin(
637
+ requestOrigin: string | null,
638
+ allowed: string | readonly string[],
639
+ ): string | undefined {
640
+ if (typeof allowed === 'string') return allowed;
641
+ if (requestOrigin && allowed.includes(requestOrigin)) return requestOrigin;
642
+ return undefined;
643
+ }
644
+
645
+ /**
646
+ * A JSON response that must never be stored: token, introspection and the
647
+ * other credential-bearing responses (RFC 6749 §5.1 / §5.2).
648
+ */
649
+ export function noStoreJson(body: unknown, init: ResponseInit = {}): Response {
650
+ const headers = new Headers(init.headers);
651
+ headers.set('Cache-Control', 'no-store');
652
+ headers.set('Pragma', 'no-cache');
653
+ return Response.json(body, { ...init, headers });
654
+ }
655
+
656
+ /** An OAuth error response (RFC 6749 §5.2), never cached. */
657
+ export function oauthError(
658
+ error: string,
659
+ errorDescription: string | undefined,
660
+ status = 400,
661
+ headers?: HeadersInit,
662
+ ): Response {
663
+ return noStoreJson({ error, error_description: errorDescription }, { status, headers });
664
+ }
665
+
666
+ /**
667
+ * The signing keys could not be loaded or failed validation (a key provider
668
+ * outage, or a key set the OP must not sign with).
669
+ */
670
+ export function signingKeysUnavailable(): Response {
671
+ return Response.json(
672
+ { error: 'server_error', error_description: 'Failed to load signing key' },
673
+ { status: 503 },
674
+ );
675
+ }
676
+
677
+ /**
678
+ * Is the request body application/x-www-form-urlencoded? Media types are
679
+ * case-insensitive and may carry parameters such as "; charset=UTF-8"
680
+ * (RFC 9110 §8.3.1).
681
+ */
682
+ export function isFormUrlEncoded(request: Request): boolean {
683
+ const [mediaType = ''] = (request.headers.get('Content-Type') ?? '').toLowerCase().split(';');
684
+ return mediaType.trim() === 'application/x-www-form-urlencoded';
685
+ }
686
+
687
+ /** Request parameters, and the first name that was repeated (if any). */
688
+ export interface UniqueParams {
689
+ params: Record<string, string>;
690
+ duplicateKey?: string;
691
+ }
692
+
693
+ /** Collect parameters, stopping at the first repeated name (RFC 6749 §3.1 / §3.2). */
694
+ export function uniqueParams(searchParams: URLSearchParams): UniqueParams {
695
+ const params: Record<string, string> = {};
696
+ const seen = new Set<string>();
697
+ for (const [key, value] of searchParams) {
698
+ if (seen.has(key)) {
699
+ return { params, duplicateKey: key };
700
+ }
701
+ seen.add(key);
702
+ params[key] = value;
703
+ }
704
+ return { params };
705
+ }
706
+
707
+ /**
708
+ * The fields of a form POST (urlencoded or multipart). A body that is neither
709
+ * yields no fields, so every field reads as missing.
710
+ */
711
+ export async function readFormFields(request: Request): Promise<FormData> {
712
+ try {
713
+ return await request.formData();
714
+ } catch {
715
+ return new FormData();
716
+ }
717
+ }
718
+
719
+ /**
720
+ * The OP's error page (app/oidc-error) for an error that must stay on the OP,
721
+ * as a path of this app with the error in the query. Pages and Server Actions
722
+ * redirect() to it.
723
+ */
724
+ export function errorPagePath(error: string, errorDescription?: string): string {
725
+ const query = new URLSearchParams({ error });
726
+ if (errorDescription) query.set('error_description', errorDescription);
727
+ return '/oidc-error?' + query.toString();
728
+ }
729
+
730
+ /**
731
+ * Send the browser to the OP's error page from a Route Handler. 303, so the
732
+ * page is fetched with GET even after a POST; built on config.issuer, never on
733
+ * the request URL (OIDC Discovery 1.0 §3).
734
+ */
735
+ export function redirectToErrorPage(error: string, errorDescription?: string): Response {
736
+ return NextResponse.redirect(new URL(errorPagePath(error, errorDescription), config.issuer), 303);
737
+ }
738
+ `;
739
+ }
740
+ export function nextJsHtmlTemplate() {
741
+ return `/**
742
+ * HTML responses for the screens that are served by Route Handlers instead of
743
+ * React pages: the device verification UI, the CIBA authentication device UI
744
+ * and the RP-Initiated Logout screens.
745
+ *
746
+ * Those screens set a cookie on the very response that renders them (a browser
747
+ * binding, or a confirmation secret), and their failures carry a status code
748
+ * (403, 429, ...) that the security model and its tests rely on. A Server
749
+ * Component can do neither, and Next.js does not render React from a Route
750
+ * Handler, so these stay plain HTML responses. Every other screen of the OP is
751
+ * a React page (login, consent, oidc-error).
752
+ */
753
+
754
+ /** Escape a value for HTML text and double-quoted attribute values. */
755
+ export function escapeHtml(value: string): string {
756
+ return value
757
+ .replace(/&/g, '&amp;')
758
+ .replace(/</g, '&lt;')
759
+ .replace(/>/g, '&gt;')
760
+ .replace(/"/g, '&quot;')
761
+ .replace(/'/g, '&#39;');
762
+ }
763
+
764
+ /** An HTML page, with the Set-Cookie values the step that rendered it decided on. */
765
+ export function htmlResponse(
766
+ html: string,
767
+ init: { status?: number; cookies?: readonly string[] } = {},
768
+ ): Response {
769
+ const headers = new Headers({ 'Content-Type': 'text/html; charset=UTF-8' });
770
+ for (const cookie of init.cookies ?? []) {
771
+ headers.append('Set-Cookie', cookie);
772
+ }
773
+ return new Response(html, { status: init.status ?? 200, headers });
774
+ }
775
+
776
+ /**
777
+ * The OP's own error page. statusCode is the HTTP status and nothing else, so
778
+ * the page never claims a different outcome than the response (a 429 lockout
779
+ * answers 429, a 403 binding failure 403).
780
+ */
781
+ export function errorPage(error: string, statusCode: number, errorDescription?: string): Response {
782
+ const descriptionHtml = errorDescription ? \` <p>\${escapeHtml(errorDescription)}</p>\\n\` : '';
783
+ return htmlResponse(
784
+ \`<!DOCTYPE html>
785
+ <html>
786
+ <head><title>Error</title></head>
787
+ <body>
788
+ <h1>Error</h1>
789
+ <p>\${escapeHtml(error)}</p>
790
+ \${descriptionHtml}</body>
791
+ </html>\`,
792
+ { status: statusCode },
793
+ );
794
+ }
795
+ `;
796
+ }
797
+ //# sourceMappingURL=provider.js.map