@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
@@ -828,1164 +828,12 @@ async function toFastifyReply(reply: FastifyReply, response: Response): Promise<
828
828
  }
829
829
  `;
830
830
  }
831
- export function nextJsRouteHandlerTemplate() {
832
- return `import { createApp, type OidcProviderOptions } from './app';
833
-
834
- export type NextOidcProviderOptions = OidcProviderOptions;
835
- export type NextOidcRouteHandler = (request: Request) => Promise<Response>;
836
-
837
- export interface NextOidcRouteHandlers {
838
- GET: NextOidcRouteHandler;
839
- POST: NextOidcRouteHandler;
840
- OPTIONS: NextOidcRouteHandler;
841
- }
842
-
843
- export function createOidcRouteHandlers(options: NextOidcProviderOptions): NextOidcRouteHandlers {
844
- const oidc = createApp(options);
845
- const handle = (request: Request): Promise<Response> =>
846
- oidc.request(rebaseRequestOrigin(request, options.config?.issuer));
847
-
848
- return {
849
- GET: handle,
850
- POST: handle,
851
- OPTIONS: handle,
852
- };
853
- }
854
-
855
- // The advertised issuer is the source of truth for the OP's own origin
856
- // (OIDC Discovery 1.0 §3): rebasing here keeps every URL the OP derives from
857
- // the request — including the /login and /consent redirect Locations —
858
- // independent of the Host header the runtime saw (RFC 9700 §2.1).
859
- function rebaseRequestOrigin(request: Request, issuer: string | undefined): Request {
860
- if (!issuer) return request;
861
-
862
- const issuerUrl = new URL(issuer);
863
- const requestUrl = new URL(request.url);
864
- if (requestUrl.origin === issuerUrl.origin) return request;
865
-
866
- requestUrl.protocol = issuerUrl.protocol;
867
- requestUrl.host = issuerUrl.host;
868
- const init: RequestInit & { duplex?: 'half' } = {
869
- method: request.method,
870
- headers: request.headers,
871
- body: request.body,
872
- redirect: request.redirect,
873
- signal: request.signal,
874
- };
875
- if (request.body) {
876
- init.duplex = 'half';
877
- }
878
- return new Request(requestUrl, init);
879
- }
880
- `;
881
- }
882
- export function nextJsStorageBackendTemplate() {
883
- return `import { mkdirSync } from 'node:fs';
884
- import { dirname, resolve } from 'node:path';
885
- import { DatabaseSync } from 'node:sqlite';
886
- import {
887
- createJsonProviderStores,
888
- type JsonStoreBackend,
889
- type JsonStoreEntry,
890
- type ProviderStores,
891
- } from './store';
892
-
893
- declare const process: { env: Record<string, string | undefined> };
894
-
895
- interface StoredRow {
896
- key: string;
897
- value: string;
898
- expires_at: number | null;
899
- }
900
-
901
- class SqliteJsonStoreBackend implements JsonStoreBackend {
902
- private readonly database: DatabaseSync;
903
-
904
- constructor(path: string) {
905
- const databasePath = path === ':memory:' ? path : resolve(path);
906
- if (databasePath !== ':memory:') {
907
- mkdirSync(dirname(databasePath), { recursive: true });
908
- }
909
- this.database = new DatabaseSync(databasePath);
910
- // Concurrent processes opening the same file (e.g. Next.js build workers
911
- // collecting page data) race on the initial schema write; without a busy
912
- // timeout SQLite fails fast with "database is locked" instead of waiting.
913
- this.database.exec('PRAGMA busy_timeout = 5000');
914
- this.database.exec('PRAGMA journal_mode = WAL');
915
- this.database.exec(
916
- 'CREATE TABLE IF NOT EXISTS oidc_store (' +
917
- 'key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)',
918
- );
919
- }
920
-
921
- async get<T>(key: string): Promise<T | null> {
922
- const row = this.database
923
- .prepare('SELECT key, value, expires_at FROM oidc_store WHERE key = ?')
924
- .get(key) as unknown as StoredRow | undefined;
925
- if (!row) return null;
926
- if (row.expires_at !== null && row.expires_at <= Date.now()) {
927
- await this.delete(key);
928
- return null;
929
- }
930
- return JSON.parse(row.value) as T;
931
- }
932
-
933
- async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
934
- const expiresAt = ttlSeconds === undefined ? null : Date.now() + ttlSeconds * 1000;
935
- this.database.prepare(
936
- 'INSERT INTO oidc_store (key, value, expires_at) VALUES (?, ?, ?) ' +
937
- 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at',
938
- ).run(key, JSON.stringify(value), expiresAt);
939
- }
940
-
941
- async delete(key: string): Promise<void> {
942
- this.database.prepare('DELETE FROM oidc_store WHERE key = ?').run(key);
943
- }
944
-
945
- async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
946
- const rows = this.database
947
- .prepare(
948
- 'SELECT key, value, expires_at FROM oidc_store ' +
949
- 'WHERE key >= ? AND key < ? ORDER BY key',
950
- )
951
- .all(prefix, prefix + '\\uffff') as unknown as StoredRow[];
952
- const entries: Array<JsonStoreEntry<T>> = [];
953
- for (const row of rows) {
954
- if (row.expires_at !== null && row.expires_at <= Date.now()) {
955
- await this.delete(row.key);
956
- } else {
957
- entries.push({ key: row.key, value: JSON.parse(row.value) as T });
958
- }
959
- }
960
- return entries;
961
- }
962
- }
963
-
964
- interface UpstashResponse<T> {
965
- result?: T;
966
- error?: string;
967
- }
968
-
969
- class UpstashRedisJsonStoreBackend implements JsonStoreBackend {
970
- constructor(
971
- private readonly url: string,
972
- private readonly token: string,
973
- private readonly namespace = 'maronn-openid-connect:',
974
- ) {}
975
-
976
- async get<T>(key: string): Promise<T | null> {
977
- const value = await this.command<string | null>(['GET', this.fullKey(key)]);
978
- return value === null ? null : JSON.parse(value) as T;
979
- }
980
-
981
- async put<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
982
- const command: Array<string | number> = ['SET', this.fullKey(key), JSON.stringify(value)];
983
- if (ttlSeconds !== undefined) command.push('EX', ttlSeconds);
984
- await this.command<string>(command);
985
- }
986
-
987
- async delete(key: string): Promise<void> {
988
- await this.command<number>(['DEL', this.fullKey(key)]);
989
- }
990
-
991
- async list<T>(prefix: string): Promise<Array<JsonStoreEntry<T>>> {
992
- const keys: string[] = [];
993
- let cursor = '0';
994
- do {
995
- const result = await this.command<[string, string[]]>([
996
- 'SCAN',
997
- cursor,
998
- 'MATCH',
999
- this.fullKey(prefix) + '*',
1000
- 'COUNT',
1001
- 100,
1002
- ]);
1003
- cursor = String(result[0]);
1004
- keys.push(...result[1]);
1005
- } while (cursor !== '0');
1006
-
1007
- const entries: Array<JsonStoreEntry<T>> = [];
1008
- for (const fullKey of keys) {
1009
- const value = await this.command<string | null>(['GET', fullKey]);
1010
- if (value !== null) {
1011
- entries.push({
1012
- key: fullKey.slice(this.namespace.length),
1013
- value: JSON.parse(value) as T,
1014
- });
1015
- }
1016
- }
1017
- return entries;
1018
- }
1019
-
1020
- private fullKey(key: string): string {
1021
- return this.namespace + key;
1022
- }
1023
-
1024
- private async command<T>(command: Array<string | number>): Promise<T> {
1025
- const response = await fetch(this.url, {
1026
- method: 'POST',
1027
- headers: {
1028
- Authorization: 'Bearer ' + this.token,
1029
- 'Content-Type': 'application/json',
1030
- },
1031
- body: JSON.stringify(command),
1032
- cache: 'no-store',
1033
- });
1034
- const body = await response.json() as UpstashResponse<T>;
1035
- if (!response.ok || body.error || !('result' in body)) {
1036
- throw new Error(body.error ?? 'Upstash Redis request failed with HTTP ' + response.status);
1037
- }
1038
- return body.result as T;
1039
- }
1040
- }
1041
-
1042
- const storageRegistry = globalThis as typeof globalThis & {
1043
- __oidcNextJsProviderStores?: ProviderStores;
1044
- };
1045
-
1046
- export function createNextJsProviderStores(): ProviderStores {
1047
- return (storageRegistry.__oidcNextJsProviderStores ??= createStores());
1048
- }
1049
-
1050
- function createStores(): ProviderStores {
1051
- const redisUrl = readEnv('UPSTASH_REDIS_REST_URL');
1052
- const redisToken = readEnv('UPSTASH_REDIS_REST_TOKEN');
1053
- if (redisUrl && redisToken) {
1054
- return createJsonProviderStores(new UpstashRedisJsonStoreBackend(redisUrl, redisToken));
1055
- }
1056
- if (readEnv('VERCEL')) {
1057
- throw new Error(
1058
- 'UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required on Vercel',
1059
- );
1060
- }
1061
- const sqlitePath = readEnv('OIDC_SQLITE_PATH') ?? '.data/oidc.sqlite';
1062
- return createJsonProviderStores(new SqliteJsonStoreBackend(sqlitePath));
1063
- }
1064
-
1065
- function readEnv(name: string): string | undefined {
1066
- return process.env[name];
1067
- }
1068
- `;
1069
- }
1070
- export function nextJsRuntimeTemplate(corePkg, features = DEFAULT_FEATURES) {
1071
- const googleLoginConfigTypeImport = features.googleLogin ? 'type GoogleLoginConfig, ' : '';
1072
- const googleLoginRuntimeConfig = features.googleLogin
1073
- ? `
1074
- // EXTENSION (google-login): set GOOGLE_CLIENT_ID (the OAuth client ID from
1075
- // the Google Cloud console) to render the "Sign in with Google" button.
1076
- // Register <issuer>/login/google as an authorized redirect URI there.
1077
- // GOOGLE_HOSTED_DOMAIN optionally restricts sign-in to one Workspace domain.
1078
- googleLogin: readGoogleLoginConfig(),`
1079
- : '';
1080
- const googleLoginRuntimeReader = features.googleLogin
1081
- ? `
1082
- function readGoogleLoginConfig(): GoogleLoginConfig | undefined {
1083
- const clientId = readEnv('GOOGLE_CLIENT_ID');
1084
- if (!clientId) return undefined;
1085
- const hostedDomain = readEnv('GOOGLE_HOSTED_DOMAIN');
1086
- return hostedDomain ? { clientId, hostedDomain } : { clientId };
1087
- }
1088
- `
1089
- : '';
1090
- return `import {
1091
- createCachedSigningKeyProvider,
1092
- type AcrResolver,
1093
- type SigningKey,
1094
- type SigningKeyProvider,
1095
- } from '${corePkg}';
1096
- import { createInMemoryClientResolver, ${googleLoginConfigTypeImport}type RegisteredClient } from './config';
1097
- import { createOidcRouteHandlers } from './next';
1098
- import { createNextJsProviderStores } from './storage-backend';
1099
- import type { OidcProviderOptions } from './app';
1100
-
1101
- declare const process: { env: Record<string, string | undefined> } | undefined;
1102
-
1103
- const signingKeyProvider = createCachedSigningKeyProvider(
1104
- createEphemeralRs256KeyProvider(),
1105
- 60_000,
1106
- );
1107
- const providerStores = createNextJsProviderStores();
1108
-
1109
- // OIDC Core 1.0 §2 / §3.1.2.1: when a client requests an acr via \`acr_values\`
1110
- // (or \`claims.id_token.acr.values\`), echo the most-preferred requested value back
1111
- // as the ID Token \`acr\` claim. The OIDF oidcc-ensure-request-with-acr-values-succeeds
1112
- // module only requires that the returned acr is one of the requested values; without
1113
- // any resolver the OP omits acr and the module reports a SHOULD warning. This sample
1114
- // treats every requested acr as satisfiable — a real deployment must map this to its
1115
- // actual authentication context instead of echoing the request.
1116
- const sampleAcrResolver: AcrResolver = async ({ requestedAcrValues }) => {
1117
- if (!requestedAcrValues) return undefined;
1118
- const preferred = requestedAcrValues.split(' ').find((value) => value.length > 0);
1119
- if (!preferred) return undefined;
1120
- return { acr: preferred, amr: ['pwd'] };
1121
- };
1122
-
1123
- export function createOidcProviderOptions(): OidcProviderOptions {
1124
- const issuer = readEnv('OIDC_ISSUER') ?? readEnv('ISSUER') ?? 'http://localhost:3000';
1125
- const clients = readRegisteredClients();
1126
- const clientResolver = createInMemoryClientResolver(clients);
1127
-
1128
- return {
1129
- config: {
1130
- issuer,
1131
- accessTokenExpiresIn: 3600,
1132
- idTokenExpiresIn: 3600,
1133
- refreshTokenAbsoluteLifetime: 7776000,
1134
- accessTokenFormat: 'jwt',
1135
- authorizationCodeTtl: 300,
1136
- allowNonPkceAuthorizationCodeFlow:
1137
- readEnv('OIDC_ALLOW_NON_PKCE_AUTHORIZATION_CODE_FLOW') === '1',
1138
- // OIDC Core 1.0 §6.1 / RFC 9101: accepting unsigned (alg:none) Request Objects
1139
- // is a security relaxation used only for OIDF Basic OP conformance, where the
1140
- // request object modules are skipped unless the OP advertises 'none' in
1141
- // request_object_signing_alg_values_supported. Default off (signed-only).
1142
- allowUnsignedRequestObject:
1143
- readEnv('OIDC_ALLOW_UNSIGNED_REQUEST_OBJECT') === '1',
1144
- // Non-redirect authorization errors (unknown client_id, unregistered
1145
- // redirect_uri, fragment) are handed to a Next.js-native error page at
1146
- // /oidc-error, which renders them via the App Router error boundary
1147
- // (app/oidc-error/error.tsx) — consistent with login/consent being real
1148
- // pages rather than HTML strings from the route handler.
1149
- authorizationErrorRedirectPath: '/oidc-error',${googleLoginRuntimeConfig}
1150
- },
1151
- signingKeyProvider,
1152
- clientResolver,
1153
- tokenClientResolver: clientResolver,
1154
- storage: providerStores,
1155
- acrResolver: sampleAcrResolver,
1156
- corsOrigins: readEnv('OIDC_CORS_ORIGINS') ?? issuer,
1157
- };
1158
- }
1159
-
1160
- /**
1161
- * Built provider options. Exported so the login / consent Server Actions can
1162
- * reuse the same issuer and client resolver as the route handlers.
1163
- */
1164
- export const oidcProviderOptions = createOidcProviderOptions();
1165
-
1166
- export const oidcHandlers = createOidcRouteHandlers(oidcProviderOptions);
1167
-
1168
- function readRegisteredClients(): ReadonlyMap<string, RegisteredClient> {
1169
- const encoded = readEnv('OIDC_CLIENTS_JSON');
1170
- if (encoded) {
1171
- return parseRegisteredClients(encoded);
1172
- }
1173
-
1174
- const clientId = readEnv('OIDC_CLIENT_ID') ?? readEnv('CLIENT_ID') ?? 'example-client';
1175
- const clientSecret =
1176
- readEnv('OIDC_CLIENT_SECRET') ?? readEnv('CLIENT_SECRET') ?? 'example-secret';
1177
- const clientRedirectUri =
1178
- readEnv('OIDC_CLIENT_REDIRECT_URI') ??
1179
- readEnv('CLIENT_REDIRECT_URI') ??
1180
- 'http://localhost:3000/callback';
1181
-
1182
- const clients = new Map<string, RegisteredClient>([
1183
- [
1184
- clientId,
1185
- {
1186
- clientId,
1187
- clientSecret,
1188
- redirectUris: [clientRedirectUri],
1189
- clientType: 'confidential',
1190
- grantTypes: ['authorization_code'],
1191
- tokenEndpointAuthMethod: 'client_secret_post',
1192
- responseTypes: ['code'],
1193
- },
1194
- ],
1195
- ]);
1196
-
1197
- const resourceServerClientId =
1198
- readEnv('OIDC_RESOURCE_SERVER_CLIENT_ID') ?? readEnv('RESOURCE_SERVER_CLIENT_ID');
1199
- const resourceServerClientSecret =
1200
- readEnv('OIDC_RESOURCE_SERVER_CLIENT_SECRET') ?? readEnv('RESOURCE_SERVER_CLIENT_SECRET');
1201
- const resourceServerRedirectUri =
1202
- readEnv('OIDC_RESOURCE_SERVER_REDIRECT_URI') ??
1203
- readEnv('RESOURCE_SERVER_REDIRECT_URI') ??
1204
- 'http://localhost:3030/unused-callback';
1205
-
1206
- if (resourceServerClientId && resourceServerClientSecret) {
1207
- clients.set(resourceServerClientId, {
1208
- clientId: resourceServerClientId,
1209
- clientSecret: resourceServerClientSecret,
1210
- redirectUris: [resourceServerRedirectUri],
1211
- clientType: 'confidential',
1212
- grantTypes: ['authorization_code'],
1213
- tokenEndpointAuthMethod: 'client_secret_basic',
1214
- responseTypes: ['code'],
1215
- });
1216
- }
1217
-
1218
- return clients;
1219
- }
1220
-
1221
- function parseRegisteredClients(encoded: string): ReadonlyMap<string, RegisteredClient> {
1222
- const clients = JSON.parse(encoded) as RegisteredClient[];
1223
- return new Map(clients.map((client) => [client.clientId, client]));
1224
- }
1225
-
1226
- function readEnv(name: string): string | undefined {
1227
- if (typeof process === 'undefined') return undefined;
1228
- return process.env[name];
1229
- }
1230
- ${googleLoginRuntimeReader}
1231
- function createEphemeralRs256KeyProvider(): SigningKeyProvider {
1232
- const keyPromise = generateSigningKey();
1233
- return {
1234
- async getSigningKey(): Promise<SigningKey> {
1235
- return keyPromise;
1236
- },
1237
- async getSigningKeys(): Promise<SigningKey[]> {
1238
- return [await keyPromise];
1239
- },
1240
- };
1241
- }
1242
-
1243
- async function generateSigningKey(): Promise<SigningKey> {
1244
- const keyPair = await crypto.subtle.generateKey(
1245
- {
1246
- name: 'RSASSA-PKCS1-v1_5',
1247
- modulusLength: 2048,
1248
- publicExponent: new Uint8Array([1, 0, 1]),
1249
- hash: 'SHA-256',
1250
- },
1251
- true,
1252
- ['sign', 'verify'],
1253
- );
1254
- const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) as JsonWebKey & {
1255
- alg?: string;
1256
- use?: string;
1257
- kid?: string;
1258
- };
1259
- publicJwk.alg = 'RS256';
1260
- publicJwk.use = 'sig';
1261
- publicJwk.kid = readEnv('OIDC_SIGNING_KEY_ID') ?? 'nextjs-rs256-key';
1262
-
1263
- return {
1264
- privateKey: keyPair.privateKey,
1265
- publicJwk,
1266
- keyId: publicJwk.kid,
1267
- };
1268
- }
1269
- `;
1270
- }
1271
- export function nextJsEndpointRouteTemplate(importPath, methods) {
1272
- const exports = methods
1273
- .map((method) => `export const ${method} = oidcHandlers.${method};`)
1274
- .join('\n');
1275
- return `import { oidcHandlers } from '${importPath}';
1276
-
1277
- export const dynamic = 'force-dynamic';
1278
- export const runtime = 'nodejs';
1279
-
1280
- ${exports}
1281
- `;
1282
- }
1283
- export function nextJsLoginPageTemplate(corePkg, features = DEFAULT_FEATURES) {
1284
- const bindingImports = features.transactionBinding
1285
- ? `import { cookies } from 'next/headers';
1286
- import { getAuthTransaction, validateTransactionBinding } from '${corePkg}';`
1287
- : `import { getAuthTransaction } from '${corePkg}';`;
1288
- const bindingStoreImport = features.transactionBinding
1289
- ? `import {
1290
- defaultProviderStores,
1291
- TRANSACTION_BINDING_COOKIE_PREFIX,
1292
- } from '../_oidc-provider/store';`
1293
- : `import { defaultProviderStores } from '../_oidc-provider/store';`;
1294
- const bindingHelper = features.transactionBinding
1295
- ? `
1296
- /**
1297
- * Is this request coming from the User-Agent that started the transaction?
1298
- * The authorization endpoint handed that browser a secret in an HttpOnly cookie
1299
- * named per transaction; only its hash is stored (OIDC Core 1.0 Section 3.1.2.3
1300
- * / 3.1.2.4). See buildTransactionBindingCookie() in _oidc-provider/store.ts.
1301
- */
1302
- async function isBoundToThisBrowser(
1303
- transaction: Awaited<ReturnType<typeof getAuthTransaction>>,
1304
- transactionId: string,
1305
- ): Promise<boolean> {
1306
- const cookieStore = await cookies();
1307
- const bindingSecret = cookieStore.get(TRANSACTION_BINDING_COOKIE_PREFIX + transactionId)?.value;
1308
- try {
1309
- await validateTransactionBinding(transaction, bindingSecret);
1310
- return true;
1311
- } catch {
1312
- return false;
1313
- }
1314
- }
1315
- `
1316
- : '';
1317
- const bindingCheck = features.transactionBinding
1318
- ? `
1319
- // OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4: this form embeds csrf_token, so only
1320
- // the User-Agent that started the transaction may render it. transaction_id
1321
- // alone is not proof — it rides in the URL and can leak. See store.ts.
1322
- if (!(await isBoundToThisBrowser(transaction, transactionId))) {
1323
- return (
1324
- <main>
1325
- <h1>Login</h1>
1326
- <p role="alert">This authorization transaction was not started by this browser.</p>
1327
- </main>
1328
- );
1329
- }
1330
- `
1331
- : '';
1332
- const googleLoginImports = features.googleLogin
1333
- ? `
1334
- import Script from 'next/script';
1335
- import { issueGoogleLoginNonce } from '${GOOGLE_LOGIN_PACKAGE}';
1336
- import {
1337
- buildGoogleSignInAttributes,
1338
- GOOGLE_GSI_CLIENT_SCRIPT_URL,
1339
- } from '${GOOGLE_LOGIN_PACKAGE}/sign-in';`
1340
- : '';
1341
- const loginPageStores = features.googleLogin
1342
- ? `const { transactionStore, googleLoginNonceStore } =
1343
- oidcProviderOptions.storage ?? defaultProviderStores;`
1344
- : `const transactionStore =
1345
- (oidcProviderOptions.storage ?? defaultProviderStores).transactionStore;`;
1346
- const googleSignInRender = features.googleLogin
1347
- ? `
1348
- // EXTENSION (google-login): the GIS configuration (g_id_onload attributes),
1349
- // built only when config.googleLogin is set. Each render issues a fresh nonce
1350
- // bound to this transaction; Google echoes it in the ID token, which is how
1351
- // login/google/route.ts finds the transaction. The JSX below owns the markup.
1352
- const googleLogin = oidcProviderOptions.config?.googleLogin;
1353
- const googleSignIn = googleLogin
1354
- ? buildGoogleSignInAttributes({
1355
- clientId: googleLogin.clientId,
1356
- // Must equal an authorized redirect URI of the Google OAuth client.
1357
- loginUri: new URL(
1358
- '/login/google',
1359
- oidcProviderOptions.config?.issuer ?? 'http://localhost:3000',
1360
- ).toString(),
1361
- nonce: await issueGoogleLoginNonce({
1362
- transactionId,
1363
- expiresAt: transaction.expiresAt,
1364
- store: googleLoginNonceStore,
1365
- }),
1366
- loginHint: transaction.loginHint,
1367
- hostedDomain:
1368
- typeof googleLogin.hostedDomain === 'string' ? googleLogin.hostedDomain : undefined,
1369
- })
1370
- : undefined;
1371
- `
1372
- : '';
1373
- const googleSignInJsx = features.googleLogin
1374
- ? `
1375
- {/*
1376
- EXTENSION (google-login): the three elements GIS needs for redirect mode.
1377
- googleSignIn holds the g_id_onload attributes (data-ux_mode="redirect",
1378
- data-login_uri, data-nonce, ...) and spreads straight onto the element;
1379
- GIS replaces .g_id_signin with the button — style it through the GIS
1380
- button attributes (data-theme, data-size, data-text, ...).
1381
- */}
1382
- {googleSignIn ? (
1383
- <section aria-label="Sign in with Google">
1384
- <Script src={GOOGLE_GSI_CLIENT_SCRIPT_URL} strategy="afterInteractive" />
1385
- <div {...googleSignIn} />
1386
- <div className="g_id_signin" data-type="standard" />
1387
- </section>
1388
- ) : null}`
1389
- : '';
1390
- return `${bindingImports}
1391
- import { oidcProviderOptions } from '../_oidc-provider/runtime';
1392
- ${bindingStoreImport}${googleLoginImports}
1393
- import { loginAction } from './actions';
1394
-
1395
- ${loginPageStores}
1396
-
1397
- // Authorization redirects here with a per-request transaction_id, so the page
1398
- // must always render dynamically (never statically cached).
1399
- export const dynamic = 'force-dynamic';
1400
-
1401
- interface LoginPageProps {
1402
- searchParams: Promise<{
1403
- transaction_id?: string;
1404
- error?: string;
1405
- remaining?: string;
1406
- }>;
1407
- }
1408
- ${bindingHelper}
1409
- /**
1410
- * Login page (React Server Component).
1411
- *
1412
- * This is intentionally a real Next.js \`page.tsx\` so you can customize the UI
1413
- * with JSX, components, CSS modules, and the rest of the React/Next.js
1414
- * ecosystem. The form posts to a Server Action (./actions.ts) that runs the
1415
- * OpenID Connect login logic on the server.
1416
- */
1417
- export default async function LoginPage({ searchParams }: LoginPageProps) {
1418
- const { transaction_id: transactionId, error, remaining } = await searchParams;
1419
-
1420
- if (!transactionId) {
1421
- return (
1422
- <main>
1423
- <h1>Login</h1>
1424
- <p>Missing transaction_id</p>
1425
- </main>
1426
- );
1427
- }
1428
-
1429
- // Rate limit reached: handleLoginFailure() locked further attempts.
1430
- if (error === 'too_many_attempts') {
1431
- return (
1432
- <main>
1433
- <h1>Login</h1>
1434
- <p role="alert">Too many login attempts</p>
1435
- </main>
1436
- );
1437
- }
1438
-
1439
- const transaction = await getAuthTransaction(transactionId, transactionStore);
1440
- ${bindingCheck}${googleSignInRender}
1441
- const errorMessage =
1442
- error === 'invalid_credentials'
1443
- ? \`Invalid credentials\${remaining ? \`. Attempts remaining: \${remaining}\` : ''}\`
1444
- : null;
1445
-
1446
- return (
1447
- <main>
1448
- <h1>Login</h1>
1449
- {errorMessage ? (
1450
- <p role="alert" style={{ color: 'red' }}>
1451
- {errorMessage}
1452
- </p>
1453
- ) : null}
1454
- <form action={loginAction}>
1455
- <input type="hidden" name="transaction_id" value={transactionId} />
1456
- <input type="hidden" name="csrf_token" value={transaction.csrfToken} />
1457
- <div>
1458
- <label htmlFor="username">Username:</label>
1459
- <input type="text" id="username" name="username" required />
1460
- </div>
1461
- <div>
1462
- <label htmlFor="password">Password:</label>
1463
- <input type="password" id="password" name="password" required />
1464
- </div>
1465
- <button type="submit">Login</button>
1466
- </form>${googleSignInJsx}
1467
- </main>
1468
- );
1469
- }
1470
- `;
1471
- }
1472
- export function nextJsAuthorizationErrorPageTemplate() {
1473
- return `// The Authorization Endpoint 303-redirects non-redirect errors here (see
1474
- // runtime.ts authorizationErrorRedirectPath), so this page must always render
1475
- // dynamically and never be statically cached.
1476
- export const dynamic = 'force-dynamic';
1477
-
1478
- interface OidcErrorPageProps {
1479
- searchParams: Promise<{ error?: string; error_description?: string }>;
1480
- }
1481
-
1482
- /**
1483
- * Authorization error page (OIDC Core 1.0 §3.1.2.2).
1484
- *
1485
- * The Authorization Endpoint cannot redirect certain errors (unknown client_id,
1486
- * unregistered redirect_uri, redirect_uri with a fragment) back to the client,
1487
- * so it sends the browser here instead. This Server Component intentionally
1488
- * throws so the sibling App Router error boundary (\`error.tsx\`) renders the UI —
1489
- * the idiomatic Next.js way to surface errors, consistent with login / consent
1490
- * being real pages rather than HTML strings from a route handler. \`error.tsx\`
1491
- * reads error / error_description from the URL, so the thrown Error only needs to
1492
- * activate the boundary.
1493
- */
1494
- export default async function OidcErrorPage({ searchParams }: OidcErrorPageProps) {
1495
- const { error } = await searchParams;
1496
- throw new Error(\`Authorization error: \${error ?? 'invalid_request'}\`);
1497
- }
1498
- `;
1499
- }
1500
- export function nextJsAuthorizationErrorBoundaryTemplate() {
1501
- return `'use client';
1502
-
1503
- import { useSearchParams } from 'next/navigation';
1504
-
1505
- /**
1506
- * App Router error boundary for the authorization error page.
1507
- *
1508
- * OIDC Core 1.0 §3.1.2.2: the Authorization Endpoint 303-redirects non-redirect
1509
- * errors to /oidc-error, whose \`page.tsx\` throws to trigger this boundary. We read
1510
- * the OAuth error / error_description from the URL — not from the thrown Error,
1511
- * whose message is stripped in production builds — and render them as React text
1512
- * so the values are safely escaped. Customize this UI with JSX as needed.
1513
- */
1514
- export default function OidcAuthorizationError() {
1515
- const searchParams = useSearchParams();
1516
- const error = searchParams.get('error') ?? 'invalid_request';
1517
- const errorDescription = searchParams.get('error_description');
1518
-
1519
- return (
1520
- <main>
1521
- <h1>Error</h1>
1522
- <p>{error}</p>
1523
- {errorDescription ? <p>{errorDescription}</p> : null}
1524
- </main>
1525
- );
1526
- }
1527
- `;
1528
- }
1529
- export function nextJsLoginActionTemplate(corePkg, features = DEFAULT_FEATURES) {
1530
- const bindingCoreImport = features.transactionBinding
1531
- ? `
1532
- validateTransactionBinding,`
1533
- : '';
1534
- const bindingStoreImport = features.transactionBinding
1535
- ? `import {
1536
- defaultProviderStores,
1537
- SESSION_COOKIE_NAME,
1538
- TRANSACTION_BINDING_COOKIE_PREFIX,
1539
- } from '../_oidc-provider/store';`
1540
- : `import { defaultProviderStores, SESSION_COOKIE_NAME } from '../_oidc-provider/store';`;
1541
- const bindingCheck = features.transactionBinding
1542
- ? ` // OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4: checked before validateCsrfToken —
1543
- // the CSRF token only proves the value came from the form, and that form is
1544
- // reachable by anyone holding transaction_id. This proves it is the same
1545
- // browser. Throws AuthTransactionError, surfaced by the App Router error
1546
- // boundary rather than redirected to the client. See _oidc-provider/store.ts.
1547
- const bindingCookieStore = await cookies();
1548
- await validateTransactionBinding(
1549
- transaction,
1550
- bindingCookieStore.get(TRANSACTION_BINDING_COOKIE_PREFIX + transactionId)?.value,
1551
- );
1552
- `
1553
- : '';
1554
- return `'use server';
1555
-
1556
- import { redirect } from 'next/navigation';
1557
- import { cookies } from 'next/headers';
1558
- import {
1559
- getAuthTransaction,
1560
- validateCsrfToken,${bindingCoreImport}
1561
- handleLoginFailure,
1562
- generateRandomString,
1563
- } from '${corePkg}';
1564
- import { oidcProviderOptions } from '../_oidc-provider/runtime';
1565
- ${bindingStoreImport}
1566
-
1567
- const {
1568
- transactionStore,
1569
- authSessionStore,
1570
- browserSessionStore,
1571
- userStore,
1572
- } = oidcProviderOptions.storage ?? defaultProviderStores;
1573
-
1574
- /**
1575
- * Login Server Action.
1576
- *
1577
- * Mirrors the framework-neutral login route, but runs as a Next.js Server
1578
- * Action so the UI can stay a plain React \`page.tsx\`. On failure it redirects
1579
- * back to the login page with an error so the page can re-render the message.
1580
- */
1581
- export async function loginAction(formData: FormData): Promise<void> {
1582
- const transactionId = String(formData.get('transaction_id') ?? '');
1583
- const csrfToken = String(formData.get('csrf_token') ?? '');
1584
- const username = String(formData.get('username') ?? '');
1585
- const password = String(formData.get('password') ?? '');
1586
-
1587
- const transaction = await getAuthTransaction(transactionId, transactionStore);
1588
- ${bindingCheck} validateCsrfToken(transaction, csrfToken);
1589
-
1590
- const user = await userStore.authenticate(username, password);
1591
- if (!user) {
1592
- const failureResult = await handleLoginFailure(
1593
- transactionId,
1594
- transaction,
1595
- transactionStore,
1596
- );
1597
- if (!failureResult.canRetry) {
1598
- redirect(
1599
- \`/login?transaction_id=\${encodeURIComponent(transactionId)}&error=too_many_attempts\`,
1600
- );
1601
- }
1602
- const remaining = failureResult.maxAttempts - failureResult.failedAttempts;
1603
- redirect(
1604
- \`/login?transaction_id=\${encodeURIComponent(transactionId)}&error=invalid_credentials&remaining=\${remaining}\`,
1605
- );
1606
- }
1607
-
1608
- const cookieStore = await cookies();
1609
-
1610
- // prompt=login / select_account requires fresh authentication: discard any
1611
- // existing transaction handoff AND browser session.
1612
- // OIDC Core 1.0 Section 3.1.2.1 — prompt is a space-delimited list.
1613
- const loginPromptValues = transaction.prompt?.trim().split(/\\s+/).filter(Boolean) ?? [];
1614
- if (loginPromptValues.includes('login') || loginPromptValues.includes('select_account')) {
1615
- await authSessionStore.delete(transactionId);
1616
- const existingSessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value;
1617
- if (existingSessionId) await browserSessionStore.delete(existingSessionId);
1618
- }
1619
-
1620
- const authTime = Math.floor(Date.now() / 1000);
1621
-
1622
- // Establish a persistent browser (OP) session so SSO / prompt=none / max_age
1623
- // work on subsequent authorization requests (OIDC Core 1.0 Section 3.1.2.3).
1624
- // Cookie attributes match buildSessionCookie() in store.ts so the
1625
- // sessionResolver can read it back.
1626
- const sessionId = await generateRandomString(32);
1627
- await browserSessionStore.set(sessionId, { subject: user.sub, authTime });
1628
-
1629
- // Store authenticated subject for the consent step (per-transaction handoff).
1630
- // sessionId も渡すのは online refresh token のため。consent 経由で発行する認可
1631
- // コードにこのセッションを引き継ぎ、ログアウトで使えなくなる RT を作る。
1632
- await authSessionStore.set(transactionId, {
1633
- subject: user.sub,
1634
- authTime,
1635
- sessionId,
1636
- });
1637
- cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
1638
- httpOnly: true,
1639
- secure: true,
1640
- sameSite: 'lax',
1641
- path: '/',
1642
- });
1643
-
1644
- redirect(\`/consent?transaction_id=\${encodeURIComponent(transactionId)}\`);
1645
- }
1646
- `;
1647
- }
1648
- export function nextJsConsentPageTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
1649
- const customScopesDeclared = scopes.length > 0;
1650
- const customScopeImport = customScopesDeclared
1651
- ? `
1652
- import { resolveGrantableScopes } from '../_oidc-provider/scopes';`
1653
- : '';
1654
- const consentPageScopes = customScopesDeclared
1655
- ? ` // The subject comes from the auth session that the login step stored for this
1656
- // transaction; without one there is nothing to apply the scope policy to, so
1657
- // the request is shown as-is and the Server Action stops on the same missing
1658
- // session.
1659
- const consentSession = await authSessionStore.get(transactionId);
1660
- const requestedScopes = transaction.scope.split(' ').filter(Boolean);
1661
- const scopes = consentSession
1662
- ? await resolveGrantableScopes(requestedScopes, consentSession.subject)
1663
- : requestedScopes;`
1664
- : ` const scopes = transaction.scope.split(' ').filter(Boolean);`;
1665
- const consentPageStores = customScopesDeclared
1666
- ? `
1667
- const authSessionStore =
1668
- (oidcProviderOptions.storage ?? defaultProviderStores).authSessionStore;`
1669
- : '';
1670
- const bindingImports = features.transactionBinding
1671
- ? `import { cookies } from 'next/headers';
1672
- import { getAuthTransaction, validateTransactionBinding } from '${corePkg}';`
1673
- : `import { getAuthTransaction } from '${corePkg}';`;
1674
- const bindingStoreImport = features.transactionBinding
1675
- ? `import {
1676
- defaultProviderStores,
1677
- TRANSACTION_BINDING_COOKIE_PREFIX,
1678
- } from '../_oidc-provider/store';`
1679
- : `import { defaultProviderStores } from '../_oidc-provider/store';`;
1680
- const bindingHelper = features.transactionBinding
1681
- ? `
1682
- /**
1683
- * Is this request coming from the User-Agent that started the transaction?
1684
- * The authorization endpoint handed that browser a secret in an HttpOnly cookie
1685
- * named per transaction; only its hash is stored (OIDC Core 1.0 Section 3.1.2.3
1686
- * / 3.1.2.4). See buildTransactionBindingCookie() in _oidc-provider/store.ts.
1687
- */
1688
- async function isBoundToThisBrowser(
1689
- transaction: Awaited<ReturnType<typeof getAuthTransaction>>,
1690
- transactionId: string,
1691
- ): Promise<boolean> {
1692
- const cookieStore = await cookies();
1693
- const bindingSecret = cookieStore.get(TRANSACTION_BINDING_COOKIE_PREFIX + transactionId)?.value;
1694
- try {
1695
- await validateTransactionBinding(transaction, bindingSecret);
1696
- return true;
1697
- } catch {
1698
- return false;
1699
- }
1700
- }
1701
- `
1702
- : '';
1703
- const bindingCheck = features.transactionBinding
1704
- ? `
1705
- // OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4: this form embeds csrf_token and its
1706
- // submission mints the authorization code, so only the User-Agent that started
1707
- // the transaction may render it. See _oidc-provider/store.ts.
1708
- if (!(await isBoundToThisBrowser(transaction, transactionId))) {
1709
- return (
1710
- <main>
1711
- <h1>Authorize Application</h1>
1712
- <p role="alert">This authorization transaction was not started by this browser.</p>
1713
- </main>
1714
- );
1715
- }
1716
-
1717
- `
1718
- : '';
1719
- return `${bindingImports}
1720
- import { oidcProviderOptions } from '../_oidc-provider/runtime';
1721
- ${bindingStoreImport}
1722
- import { consentAction } from './actions';${customScopeImport}
1723
-
1724
- const transactionStore =
1725
- (oidcProviderOptions.storage ?? defaultProviderStores).transactionStore;${consentPageStores}
1726
-
1727
- export const dynamic = 'force-dynamic';
1728
-
1729
- interface ConsentPageProps {
1730
- searchParams: Promise<{ transaction_id?: string }>;
1731
- }
1732
- ${bindingHelper}
1733
- /**
1734
- * Consent page (React Server Component).
1735
- *
1736
- * A real Next.js \`page.tsx\` so the consent UI can be customized with JSX and
1737
- * React components. The form posts to a Server Action (./actions.ts).
1738
- */
1739
- export default async function ConsentPage({ searchParams }: ConsentPageProps) {
1740
- const { transaction_id: transactionId } = await searchParams;
1741
-
1742
- if (!transactionId) {
1743
- return (
1744
- <main>
1745
- <h1>Authorize Application</h1>
1746
- <p>Missing transaction_id</p>
1747
- </main>
1748
- );
1749
- }
1750
-
1751
- const transaction = await getAuthTransaction(transactionId, transactionStore);
1752
- ${bindingCheck}${consentPageScopes}
1753
-
1754
- return (
1755
- <main>
1756
- <h1>Authorize Application</h1>
1757
- <p>
1758
- Client <strong>{transaction.clientId}</strong> is requesting access to the
1759
- following scopes:
1760
- </p>
1761
- <ul>
1762
- {scopes.map((scope) => (
1763
- <li key={scope}>{scope}</li>
1764
- ))}
1765
- </ul>
1766
- {/*
1767
- The submit buttons carry the authorization decision (OIDC Core 1.0
1768
- Section 3.1.2.4). consentAction accepts exactly two values — 'approve'
1769
- and 'deny' — and rejects everything else, so customizing this markup must
1770
- keep both button values as they are: renaming 'approve' makes every
1771
- approval fail with an error page. See ./actions.ts.
1772
- */}
1773
- <form action={consentAction}>
1774
- <input type="hidden" name="transaction_id" value={transactionId} />
1775
- <input type="hidden" name="csrf_token" value={transaction.csrfToken} />
1776
- <button type="submit" name="action" value="approve">
1777
- Approve
1778
- </button>
1779
- <button type="submit" name="action" value="deny">
1780
- Deny
1781
- </button>
1782
- </form>
1783
- </main>
1784
- );
1785
- }
1786
- `;
1787
- }
1788
- export function nextJsConsentActionTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
1789
- const customScopesDeclared = scopes.length > 0;
1790
- const customScopeImport = customScopesDeclared
1791
- ? `
1792
- import { resolveGrantableScopes } from '../_oidc-provider/scopes';`
1793
- : '';
1794
- const consentGrantedScope = customScopesDeclared
1795
- ? `
1796
- // Apply the scope policy (resolveGrantableScopes in _oidc-provider/scopes.ts —
1797
- // the place to write per-user filtering). A dropped scope narrows the grant
1798
- // rather than failing the request: RFC 6749 §3.3 lets the authorization server
1799
- // issue a narrower scope, and the token response reports what was granted.
1800
- const grantedScope = await resolveGrantableScopes(
1801
- transaction.scope.split(' ').filter(Boolean),
1802
- session.subject,
1803
- );`
1804
- : ` const grantedScope = transaction.scope.split(' ').filter(Boolean);`;
1805
- const bindingCookiesImport = features.transactionBinding
1806
- ? `
1807
- import { cookies } from 'next/headers';`
1808
- : '';
1809
- const bindingCoreImport = features.transactionBinding
1810
- ? `
1811
- validateTransactionBinding,`
1812
- : '';
1813
- const bindingStoreImport = features.transactionBinding
1814
- ? `import {
1815
- defaultProviderStores,
1816
- TRANSACTION_BINDING_COOKIE_PREFIX,
1817
- } from '../_oidc-provider/store';`
1818
- : `import { defaultProviderStores } from '../_oidc-provider/store';`;
1819
- const bindingCheck = features.transactionBinding
1820
- ? ` // OIDC Core 1.0 Section 3.1.2.3 / 3.1.2.4: checked before validateCsrfToken and
1821
- // before any decision is acted on — this step mints the authorization code, so
1822
- // an unbound caller must reach it neither to approve nor to deny. Throws
1823
- // AuthTransactionError, surfaced by the App Router error boundary rather than
1824
- // redirected to the client. See _oidc-provider/store.ts.
1825
- const cookieStore = await cookies();
1826
- const bindingCookieName = TRANSACTION_BINDING_COOKIE_PREFIX + transactionId;
1827
- await validateTransactionBinding(
1828
- transaction,
1829
- cookieStore.get(bindingCookieName)?.value,
1830
- );
1831
- `
1832
- : '';
1833
- const clearBindingCookie = features.transactionBinding
1834
- ? ` // The transaction is over; drop its binding cookie so the browser does not
1835
- // keep one cookie per finished flow.
1836
- cookieStore.delete(bindingCookieName);
1837
- `
1838
- : '';
1839
- const clearBindingCookieOnSuccess = features.transactionBinding
1840
- ? ` // The transaction is over; drop its binding cookie so the browser does not
1841
- // keep one cookie per finished flow.
1842
- cookieStore.delete(bindingCookieName);
1843
-
1844
- `
1845
- : '';
1846
- return `'use server';
1847
-
1848
- import { redirect } from 'next/navigation';${bindingCookiesImport}
1849
- import {
1850
- getAuthTransaction,
1851
- validateCsrfToken,${bindingCoreImport}
1852
- completeAuthTransaction,
1853
- createAuthorizationCode,
1854
- } from '${corePkg}';
1855
- import { oidcProviderOptions } from '../_oidc-provider/runtime';
1856
- import { createStoreResolvers } from '../_oidc-provider/resolvers';
1857
- ${bindingStoreImport}${customScopeImport}
1858
-
1859
- const providerStores = oidcProviderOptions.storage ?? defaultProviderStores;
1860
- const { transactionStore, authCodeStore, authSessionStore } = providerStores;
1861
- const { consentResolver } = createStoreResolvers(providerStores);
1862
-
1863
- /**
1864
- * Consent Server Action.
1865
- *
1866
- * Mirrors the framework-neutral consent route. Reuses the same issuer / client
1867
- * resolver as the route handlers via oidcProviderOptions so the issued code and
1868
- * recorded consent stay consistent with the rest of the provider.
1869
- */
1870
- export async function consentAction(formData: FormData): Promise<void> {
1871
- const transactionId = String(formData.get('transaction_id') ?? '');
1872
- const csrfToken = String(formData.get('csrf_token') ?? '');
1873
- const action = String(formData.get('action') ?? '');
1874
-
1875
- const transaction = await getAuthTransaction(transactionId, transactionStore);
1876
- ${bindingCheck} validateCsrfToken(transaction, csrfToken);
1877
-
1878
- // RFC 9207 §2: include the issuer identifier on every authorization response.
1879
- const issuer = oidcProviderOptions.config?.issuer ?? '';
1880
-
1881
- if (action === 'deny') {
1882
- const denyUrl = new URL(transaction.redirectUri);
1883
- denyUrl.searchParams.set('error', 'access_denied');
1884
- if (transaction.state) {
1885
- denyUrl.searchParams.set('state', transaction.state);
1886
- }
1887
- denyUrl.searchParams.set('iss', issuer);
1888
- await transactionStore.delete('auth_txn:' + transactionId);
1889
- await authSessionStore.delete(transactionId);
1890
- ${clearBindingCookie} redirect(denyUrl.toString());
1891
- }
1892
-
1893
- // OIDC Core 1.0 Section 3.1.2.4: "the Authorization Server MUST obtain an
1894
- // authorization decision before releasing information to the Relying Party."
1895
- // This action mints the authorization code, so it detects the affirmative
1896
- // decision on an allowlist just like the route handlers: a missing, empty or
1897
- // unknown 'action' means no decision was obtained and must not approve.
1898
- //
1899
- // 'approve' is the decision value this provider accepts, and it MUST stay in
1900
- // sync with the Approve button in consent/page.tsx.
1901
- //
1902
- // Section 3.1.2.6: access_denied means the End-User denied the request, which
1903
- // is not the same as no decision at all — an unrecognized value therefore goes
1904
- // to the OP's own error page instead of back to the client.
1905
- if (action !== 'approve') {
1906
- redirect(
1907
- '/oidc-error?error=invalid_request&error_description=' +
1908
- encodeURIComponent('Invalid consent decision. Please use the Approve or Deny button.'),
1909
- );
1910
- }
1911
-
1912
- const session = await authSessionStore.get(transactionId);
1913
- if (!session) {
1914
- redirect(\`/login?transaction_id=\${encodeURIComponent(transactionId)}\`);
1915
- }
1916
-
1917
- const responseParams = await completeAuthTransaction(
1918
- transactionId,
1919
- transaction,
1920
- transactionStore,
1921
- );
1922
-
1923
- // transaction.scope は認可リクエスト検証時に applyOfflineAccessPolicy を通した後の値。
1924
- // offline_access の可否(OIDC Core 1.0 §11 の prompt=consent と、クライアント登録
1925
- // grant_types に refresh_token があるか)はそこで確定しているので再フィルタしない。
1926
- ${consentGrantedScope}
1927
-
1928
- // OIDC Core 1.0 Section 3.1.3.1: TTL is configurable via ProviderConfig.
1929
- const authCodeData = await createAuthorizationCode({
1930
- authorizationResponse: { ...responseParams, scope: grantedScope },
1931
- subject: session.subject,
1932
- authTime: session.authTime,
1933
- // online refresh token をこのログインセッションへ束縛する(login フローが
1934
- // authSessionStore へ載せた値)。ログアウトすれば RT も使えなくなる。
1935
- sessionId: session.sessionId,
1936
- ttlSeconds: oidcProviderOptions.config?.authorizationCodeTtl,
1937
- });
1938
- await authCodeStore.set(authCodeData.code, authCodeData);
1939
-
1940
- // Record consent so a later prompt=none request can confirm it without UI
1941
- // (OIDC Core 1.0 Section 3.1.2.4).
1942
- await consentResolver.recordConsent?.(
1943
- session.subject,
1944
- transaction.clientId,
1945
- grantedScope,
1946
- );
1947
-
1948
- await authSessionStore.delete(transactionId);
1949
-
1950
- ${clearBindingCookieOnSuccess} const successUrl = new URL(responseParams.redirectUri);
1951
- successUrl.searchParams.set('code', authCodeData.code);
1952
- if (responseParams.state) {
1953
- successUrl.searchParams.set('state', responseParams.state);
1954
- }
1955
- successUrl.searchParams.set('iss', issuer);
1956
- redirect(successUrl.toString());
1957
- }
1958
- `;
1959
- }
1960
- export function webConformanceTestTemplate(corePkg, errorPageMode = 'html', features = DEFAULT_FEATURES, includeNodeAdapterContract = false, jarmConsentResponseMode = 'jwt', scopes = []) {
1961
- const usesRedirect = errorPageMode === 'redirect';
831
+ export function webConformanceTestTemplate(corePkg, features = DEFAULT_FEATURES, scopes = []) {
1962
832
  const customScopeConformanceImport = scopes.length > 0
1963
833
  ? `
1964
834
  import { RESTRICTED_SCOPE_SUBJECTS } from './scopes.js';`
1965
835
  : '';
1966
- const createAppConfig = usesRedirect
1967
- ? `
1968
- config: { authorizationErrorRedirectPath: '/oidc-error' },`
1969
- : '';
1970
- const nonRedirectErrorTest = usesRedirect
1971
- ? ` // OIDC Core 1.0 §3.1.2.2: an unregistered redirect_uri MUST NOT be redirected
1972
- // to. This Next.js provider sets config.authorizationErrorRedirectPath, so the
1973
- // OP hands the error to a framework-native error page (app/oidc-error, rendered
1974
- // via Next.js error.tsx) instead of returning HTML from the route handler. The
1975
- // browser is 303-redirected to the OP's OWN error page (never the attacker's
1976
- // unregistered redirect_uri). That error page responds 200, so the 400 status
1977
- // is intentionally traded for an idiomatic Next.js error UI.
1978
- it('should 303-redirect browser callers to the OP error page for an unregistered redirect_uri', async () => {
1979
- const res = await app.request(unregisteredAuthorizeUrl);
1980
-
1981
- expect(res.status).toBe(303);
1982
- // Pinned exactly so the redirect target stays the OP's own error page and
1983
- // never leaks to the unregistered (attacker-controlled) redirect_uri.
1984
- expect(res.headers.get('Location')).toBe(
1985
- '/oidc-error?error=invalid_request&error_description=redirect_uri+not+registered',
1986
- );
1987
- });`
1988
- : ` // OIDC Core 1.0 §3.1.2.2: an unregistered redirect_uri MUST NOT be redirected
836
+ const nonRedirectErrorTest = ` // OIDC Core 1.0 §3.1.2.2: an unregistered redirect_uri MUST NOT be redirected
1989
837
  // to. Browser callers receive an HTML error page (HTTP 400) so the OIDF
1990
838
  // Conformance Suite (oidcc-ensure-registered-redirect-uri) can screenshot it.
1991
839
  it('should render an HTML error page (not redirect) for an unregistered redirect_uri', async () => {
@@ -2014,11 +862,8 @@ import { RESTRICTED_SCOPE_SUBJECTS } from './scopes.js';`
2014
862
  const exportPublicJwkImport = features.requestObject || features.idJag
2015
863
  ? `import { exportPublicJwk } from '${corePkg}';\n`
2016
864
  : '';
2017
- const nodeAdapterImport = includeNodeAdapterContract
2018
- ? `import { writeWebResponse } from './node-adapter.js';\n`
2019
- : '';
2020
- const nodeAdapterContract = includeNodeAdapterContract
2021
- ? `
865
+ const nodeAdapterImport = `import { writeWebResponse } from './node-adapter.js';\n`;
866
+ const nodeAdapterContract = `
2022
867
  describe('Node response adapter', () => {
2023
868
  it('should preserve each Set-Cookie value as a separate outgoing header', async () => {
2024
869
  const headers = new Map<string, string | string[]>();
@@ -2065,8 +910,7 @@ import { RESTRICTED_SCOPE_SUBJECTS } from './scopes.js';`
2065
910
  expect(endedBody).toBe('ok');
2066
911
  });
2067
912
  });
2068
- `
2069
- : '';
913
+ `;
2070
914
  const responseModesSupportedExpectation = features.jarm
2071
915
  ? ` // OAuth 2.0 Multiple Response Type Encoding Practices §2 + JARM §4: the
2072
916
  // code flow returns the authorization response via query, and this OP was
@@ -2140,7 +984,7 @@ ${requestObjectConformanceBeforeAll(features)}
2140
984
  app = createApp({
2141
985
  signingKeyProvider,
2142
986
  clientResolver: createInMemoryClientResolver(testClients),
2143
- acrResolver: async () => ({ acr: 'urn:example:loa:2', amr: ['pwd', 'otp'] }),${createAppConfig}
987
+ acrResolver: async () => ({ acr: 'urn:example:loa:2', amr: ['pwd', 'otp'] }),
2144
988
  });
2145
989
  });
2146
990
 
@@ -2521,11 +1365,10 @@ ${nonRedirectErrorTest}
2521
1365
  });
2522
1366
  });
2523
1367
  });
2524
- ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${internalRedirectOriginConformanceBlock()}${endpointBehaviorConformanceBlock(features)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features, errorPageMode)}${onlineRefreshTokenConformanceBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${idJagConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${cibaConformanceBlock(features)}${jarmConformanceBlock(features, jarmConsentResponseMode)}${jwtIntrospectionResponseConformanceBlock(features)}${rpInitiatedLogoutConformanceBlock(features)}${googleLoginConformanceBlock(features)}${consentDecisionConformanceBlock()}${customScopeConformanceBlock(scopes)}});
1368
+ ${transactionBindingConformanceBlock(features)}${customViewConformanceTestBlock()}${internalRedirectOriginConformanceBlock()}${endpointBehaviorConformanceBlock(features)}${idTokenHintConformanceBlock()}${consentWithdrawalConformanceBlock(features)}${reuseFlowConformanceTestBlock(features)}${onlineRefreshTokenConformanceBlock(features)}${revocationDisabledConformanceBlock(features)}${tokenEndpointAuthMethodsConformanceBlock()}${pkceDisabledConformanceBlock(features)}${parConformanceBlock(features)}${tokenExchangeConformanceBlock(features)}${idJagConformanceBlock(features)}${deviceAuthorizationConformanceBlock(features)}${cibaConformanceBlock(features)}${jarmConformanceBlock(features)}${jwtIntrospectionResponseConformanceBlock(features)}${rpInitiatedLogoutConformanceBlock(features)}${googleLoginConformanceBlock(features)}${consentDecisionConformanceBlock()}${customScopeConformanceBlock(scopes)}});
2525
1369
  `;
2526
1370
  }
2527
- function webCoreGeneratedFiles(corePkg, errorPageMode = 'html', features = DEFAULT_FEATURES, includeNodeAdapterContract = false, jarmConsentResponseMode = 'jwt', scopes = []) {
2528
- const consentFeatures = jarmConsentResponseMode === 'plain' ? { ...features, jarm: false } : features;
1371
+ function webCoreGeneratedFiles(corePkg, features = DEFAULT_FEATURES, scopes = []) {
2529
1372
  return [
2530
1373
  { path: 'app.ts', content: webAppTemplate(corePkg, features) },
2531
1374
  { path: 'web-router.ts', content: webRouterTemplate() },
@@ -2539,7 +1382,7 @@ function webCoreGeneratedFiles(corePkg, errorPageMode = 'html', features = DEFAU
2539
1382
  },
2540
1383
  {
2541
1384
  path: 'resolvers.ts',
2542
- content: resolversTemplate(corePkg, features).replace('through Hono context', 'through the generated request context'),
1385
+ content: resolversTemplate(corePkg, features, 'Project integrations should inject a D1/KV/env-backed resolver through the generated request context.'),
2543
1386
  },
2544
1387
  { path: 'views.ts', content: viewsTemplate(features) },
2545
1388
  { path: 'pages/respond.ts', content: respondTemplate() },
@@ -2599,145 +1442,18 @@ function webCoreGeneratedFiles(corePkg, errorPageMode = 'html', features = DEFAU
2599
1442
  { path: 'routes/jwks.ts', content: toWebRouteTemplate(jwksRouteTemplate(corePkg)) },
2600
1443
  { path: 'routes/discovery.ts', content: toWebRouteTemplate(discoveryRouteTemplate(corePkg, features, scopes)) },
2601
1444
  { path: 'routes/login.ts', content: toWebRouteTemplate(loginRouteTemplate(corePkg, features)) },
2602
- { path: 'routes/consent.ts', content: toWebRouteTemplate(consentRouteTemplate(corePkg, consentFeatures, scopes)) },
1445
+ { path: 'routes/consent.ts', content: toWebRouteTemplate(consentRouteTemplate(corePkg, features, scopes)) },
2603
1446
  {
2604
1447
  path: 'conformance.test.ts',
2605
- content: webConformanceTestTemplate(corePkg, errorPageMode, features, includeNodeAdapterContract, jarmConsentResponseMode, scopes),
1448
+ content: webConformanceTestTemplate(corePkg, features, scopes),
2606
1449
  },
2607
1450
  ];
2608
1451
  }
2609
- function toNextJsModuleImports(content) {
2610
- return content.replaceAll(/(from\s+['"](?:\.{1,2}\/[^'"]+))\.js(['"])/g, '$1$2');
2611
- }
2612
1452
  export function webGeneratedFiles(corePkg, applyTemplate, features = DEFAULT_FEATURES, scopes = []) {
2613
1453
  return [
2614
- ...webCoreGeneratedFiles(corePkg, 'html', features, true, 'jwt', scopes),
1454
+ ...webCoreGeneratedFiles(corePkg, features, scopes),
2615
1455
  { path: 'apply.ts', content: applyTemplate },
2616
1456
  { path: 'node-adapter.ts', content: nodeAdapterTemplate() },
2617
1457
  ];
2618
1458
  }
2619
- export function nextJsGeneratedFiles(corePkg, features = DEFAULT_FEATURES, scopes = []) {
2620
- const internalFiles = webCoreGeneratedFiles(corePkg, 'redirect', features, false, 'plain', scopes).map((file) => ({
2621
- path: `_oidc-provider/${file.path}`,
2622
- content: toNextJsModuleImports(file.content),
2623
- }));
2624
- return [
2625
- ...internalFiles,
2626
- { path: '_oidc-provider/next.ts', content: nextJsRouteHandlerTemplate() },
2627
- { path: '_oidc-provider/storage-backend.ts', content: nextJsStorageBackendTemplate() },
2628
- { path: '_oidc-provider/runtime.ts', content: nextJsRuntimeTemplate(corePkg, features) },
2629
- {
2630
- path: 'authorize/route.ts',
2631
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', [
2632
- 'GET',
2633
- 'POST',
2634
- 'OPTIONS',
2635
- ]),
2636
- },
2637
- {
2638
- path: 'token/route.ts',
2639
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2640
- },
2641
- {
2642
- path: 'userinfo/route.ts',
2643
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', [
2644
- 'GET',
2645
- 'POST',
2646
- 'OPTIONS',
2647
- ]),
2648
- },
2649
- ...(features.introspection
2650
- ? [
2651
- {
2652
- path: 'introspect/route.ts',
2653
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2654
- },
2655
- ]
2656
- : []),
2657
- ...(features.revocation
2658
- ? [
2659
- {
2660
- path: 'revoke/route.ts',
2661
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2662
- },
2663
- ]
2664
- : []),
2665
- ...(features.par
2666
- ? [
2667
- {
2668
- path: 'par/route.ts',
2669
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2670
- },
2671
- ]
2672
- : []),
2673
- ...(features.deviceAuthorizationGrant
2674
- ? [
2675
- {
2676
- path: 'device_authorization/route.ts',
2677
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2678
- },
2679
- {
2680
- path: 'device/route.ts',
2681
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['GET', 'POST']),
2682
- },
2683
- {
2684
- path: 'device/login/route.ts',
2685
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', ['POST']),
2686
- },
2687
- {
2688
- path: 'device/approve/route.ts',
2689
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', ['POST']),
2690
- },
2691
- ]
2692
- : []),
2693
- ...(features.ciba
2694
- ? [
2695
- {
2696
- path: 'backchannel_authentication/route.ts',
2697
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['POST', 'OPTIONS']),
2698
- },
2699
- {
2700
- path: 'ciba/route.ts',
2701
- content: nextJsEndpointRouteTemplate('../_oidc-provider/runtime', ['GET']),
2702
- },
2703
- {
2704
- path: 'ciba/login/route.ts',
2705
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', ['POST']),
2706
- },
2707
- {
2708
- path: 'ciba/approve/route.ts',
2709
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', ['POST']),
2710
- },
2711
- ]
2712
- : []),
2713
- {
2714
- path: '.well-known/jwks.json/route.ts',
2715
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', [
2716
- 'GET',
2717
- 'OPTIONS',
2718
- ]),
2719
- },
2720
- {
2721
- path: '.well-known/openid-configuration/route.ts',
2722
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', [
2723
- 'GET',
2724
- 'OPTIONS',
2725
- ]),
2726
- },
2727
- { path: 'login/page.tsx', content: nextJsLoginPageTemplate(corePkg, features) },
2728
- { path: 'login/actions.ts', content: nextJsLoginActionTemplate(corePkg, features) },
2729
- ...(features.googleLogin
2730
- ? [
2731
- {
2732
- path: 'login/google/route.ts',
2733
- content: nextJsEndpointRouteTemplate('../../_oidc-provider/runtime', ['POST']),
2734
- },
2735
- ]
2736
- : []),
2737
- { path: 'consent/page.tsx', content: nextJsConsentPageTemplate(corePkg, features, scopes) },
2738
- { path: 'consent/actions.ts', content: nextJsConsentActionTemplate(corePkg, features, scopes) },
2739
- { path: 'oidc-error/page.tsx', content: nextJsAuthorizationErrorPageTemplate() },
2740
- { path: 'oidc-error/error.tsx', content: nextJsAuthorizationErrorBoundaryTemplate() },
2741
- ];
2742
- }
2743
1459
  //# sourceMappingURL=templates.js.map