@maronn-openid-connect/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -7
- package/dist/frameworks/hono/index.d.ts.map +1 -1
- package/dist/frameworks/hono/index.js +18 -9
- package/dist/frameworks/hono/index.js.map +1 -1
- package/dist/frameworks/hono/pages.d.ts +7 -6
- package/dist/frameworks/hono/pages.d.ts.map +1 -1
- package/dist/frameworks/hono/pages.js +62 -44
- package/dist/frameworks/hono/pages.js.map +1 -1
- package/dist/frameworks/hono/templates.d.ts +19 -6
- package/dist/frameworks/hono/templates.d.ts.map +1 -1
- package/dist/frameworks/hono/templates.js +439 -304
- package/dist/frameworks/hono/templates.js.map +1 -1
- package/dist/frameworks/hono/views.d.ts +3 -0
- package/dist/frameworks/hono/views.d.ts.map +1 -0
- package/dist/frameworks/hono/views.js +458 -0
- package/dist/frameworks/hono/views.js.map +1 -0
- package/dist/frameworks/nextjs/authorize.d.ts +3 -0
- package/dist/frameworks/nextjs/authorize.d.ts.map +1 -0
- package/dist/frameworks/nextjs/authorize.js +757 -0
- package/dist/frameworks/nextjs/authorize.js.map +1 -0
- package/dist/frameworks/nextjs/ciba.d.ts +8 -0
- package/dist/frameworks/nextjs/ciba.d.ts.map +1 -0
- package/dist/frameworks/nextjs/ciba.js +591 -0
- package/dist/frameworks/nextjs/ciba.js.map +1 -0
- package/dist/frameworks/nextjs/conformance.d.ts +3 -0
- package/dist/frameworks/nextjs/conformance.d.ts.map +1 -0
- package/dist/frameworks/nextjs/conformance.js +2447 -0
- package/dist/frameworks/nextjs/conformance.js.map +1 -0
- package/dist/frameworks/nextjs/device.d.ts +8 -0
- package/dist/frameworks/nextjs/device.d.ts.map +1 -0
- package/dist/frameworks/nextjs/device.js +662 -0
- package/dist/frameworks/nextjs/device.js.map +1 -0
- package/dist/frameworks/nextjs/endpoints.d.ts +7 -0
- package/dist/frameworks/nextjs/endpoints.d.ts.map +1 -0
- package/dist/frameworks/nextjs/endpoints.js +673 -0
- package/dist/frameworks/nextjs/endpoints.js.map +1 -0
- package/dist/frameworks/nextjs/index.d.ts.map +1 -1
- package/dist/frameworks/nextjs/index.js +135 -2
- package/dist/frameworks/nextjs/index.js.map +1 -1
- package/dist/frameworks/nextjs/interaction.d.ts +13 -0
- package/dist/frameworks/nextjs/interaction.d.ts.map +1 -0
- package/dist/frameworks/nextjs/interaction.js +864 -0
- package/dist/frameworks/nextjs/interaction.js.map +1 -0
- package/dist/frameworks/nextjs/logout.d.ts +5 -0
- package/dist/frameworks/nextjs/logout.d.ts.map +1 -0
- package/dist/frameworks/nextjs/logout.js +271 -0
- package/dist/frameworks/nextjs/logout.js.map +1 -0
- package/dist/frameworks/nextjs/metadata.d.ts +6 -0
- package/dist/frameworks/nextjs/metadata.d.ts.map +1 -0
- package/dist/frameworks/nextjs/metadata.js +339 -0
- package/dist/frameworks/nextjs/metadata.js.map +1 -0
- package/dist/frameworks/nextjs/provider.d.ts +6 -0
- package/dist/frameworks/nextjs/provider.d.ts.map +1 -0
- package/dist/frameworks/nextjs/provider.js +797 -0
- package/dist/frameworks/nextjs/provider.js.map +1 -0
- package/dist/frameworks/nextjs/token.d.ts +7 -0
- package/dist/frameworks/nextjs/token.d.ts.map +1 -0
- package/dist/frameworks/nextjs/token.js +1337 -0
- package/dist/frameworks/nextjs/token.js.map +1 -0
- package/dist/frameworks/web-standard/templates.d.ts +1 -13
- package/dist/frameworks/web-standard/templates.d.ts.map +1 -1
- package/dist/frameworks/web-standard/templates.js +12 -1296
- package/dist/frameworks/web-standard/templates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -828,1164 +828,12 @@ async function toFastifyReply(reply: FastifyReply, response: Response): Promise<
|
|
|
828
828
|
}
|
|
829
829
|
`;
|
|
830
830
|
}
|
|
831
|
-
export function
|
|
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
|
|
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 =
|
|
2018
|
-
|
|
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'] })
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
1445
|
+
{ path: 'routes/consent.ts', content: toWebRouteTemplate(consentRouteTemplate(corePkg, features, scopes)) },
|
|
2603
1446
|
{
|
|
2604
1447
|
path: 'conformance.test.ts',
|
|
2605
|
-
content: webConformanceTestTemplate(corePkg,
|
|
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,
|
|
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
|