mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
235 lines
6.3 KiB
JavaScript
235 lines
6.3 KiB
JavaScript
import crypto from "node:crypto";
|
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
import { getSettings } from "@/lib/localDb";
|
|
|
|
export const OIDC_COOKIE_NAMES = {
|
|
state: "oidc_state",
|
|
nonce: "oidc_nonce",
|
|
verifier: "oidc_code_verifier",
|
|
};
|
|
|
|
const DEFAULT_SCOPES = "openid profile email";
|
|
const DEFAULT_LOGIN_LABEL = "Sign in with OIDC";
|
|
|
|
function trimTrailingSlashes(value) {
|
|
return (value || "").trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function normalizeScopes(value) {
|
|
return (value || DEFAULT_SCOPES).trim() || DEFAULT_SCOPES;
|
|
}
|
|
|
|
export function getPublicOrigin(request) {
|
|
const configuredBaseUrl =
|
|
process.env.BASE_URL ||
|
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
|
"";
|
|
|
|
if (configuredBaseUrl) {
|
|
return trimTrailingSlashes(configuredBaseUrl);
|
|
}
|
|
|
|
const forwardedProto = request?.headers?.get?.("x-forwarded-proto") || "";
|
|
const forwardedHost = request?.headers?.get?.("x-forwarded-host") || "";
|
|
const host = forwardedHost || request?.headers?.get?.("host") || "";
|
|
if (host) {
|
|
const protocol = (forwardedProto || new URL(request.url).protocol || "http:").replace(/:$/, "");
|
|
return `${protocol}://${host}`.replace(/\/+$/, "");
|
|
}
|
|
|
|
return trimTrailingSlashes(new URL(request.url).origin);
|
|
}
|
|
|
|
export function isOidcConfigured(settings) {
|
|
return !!(
|
|
trimTrailingSlashes(settings?.oidcIssuerUrl) &&
|
|
(settings?.oidcClientId || "").trim() &&
|
|
(settings?.oidcClientSecret || "").trim()
|
|
);
|
|
}
|
|
|
|
export async function getOidcRuntimeConfig() {
|
|
const settings = await getSettings();
|
|
if (!["oidc", "both"].includes(settings.authMode) || !isOidcConfigured(settings)) return null;
|
|
|
|
const issuerUrl = trimTrailingSlashes(settings.oidcIssuerUrl);
|
|
return {
|
|
issuerUrl,
|
|
clientId: settings.oidcClientId.trim(),
|
|
clientSecret: settings.oidcClientSecret.trim(),
|
|
scopes: normalizeScopes(settings.oidcScopes),
|
|
loginLabel: (settings.oidcLoginLabel || DEFAULT_LOGIN_LABEL).trim() || DEFAULT_LOGIN_LABEL,
|
|
};
|
|
}
|
|
|
|
export async function fetchOidcDiscovery(issuerUrl) {
|
|
const discoveryUrl = `${trimTrailingSlashes(issuerUrl)}/.well-known/openid-configuration`;
|
|
const res = await fetch(discoveryUrl, { cache: "no-store" });
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to load OIDC discovery document from ${discoveryUrl}`);
|
|
}
|
|
return await res.json();
|
|
}
|
|
|
|
export function createPkcePair() {
|
|
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
return { verifier, challenge };
|
|
}
|
|
|
|
export function createOidcState() {
|
|
return crypto.randomBytes(16).toString("base64url");
|
|
}
|
|
|
|
export function createOidcNonce() {
|
|
return crypto.randomBytes(16).toString("base64url");
|
|
}
|
|
|
|
export function buildOidcAuthorizationUrl({
|
|
authorizationEndpoint,
|
|
clientId,
|
|
redirectUri,
|
|
scopes = DEFAULT_SCOPES,
|
|
state,
|
|
nonce,
|
|
codeChallenge,
|
|
}) {
|
|
const url = new URL(authorizationEndpoint);
|
|
url.searchParams.set("response_type", "code");
|
|
url.searchParams.set("client_id", clientId);
|
|
url.searchParams.set("redirect_uri", redirectUri);
|
|
url.searchParams.set("scope", normalizeScopes(scopes));
|
|
url.searchParams.set("state", state);
|
|
url.searchParams.set("nonce", nonce);
|
|
url.searchParams.set("code_challenge", codeChallenge);
|
|
url.searchParams.set("code_challenge_method", "S256");
|
|
return url.toString();
|
|
}
|
|
|
|
export async function exchangeOidcCode({
|
|
tokenEndpoint,
|
|
clientId,
|
|
clientSecret,
|
|
code,
|
|
redirectUri,
|
|
codeVerifier,
|
|
}) {
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: clientId,
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
code_verifier: codeVerifier,
|
|
});
|
|
|
|
if (clientSecret) {
|
|
body.set("client_secret", clientSecret);
|
|
}
|
|
|
|
const res = await fetch(tokenEndpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`;
|
|
throw new Error(message);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
export async function probeOidcClientSecret({
|
|
tokenEndpoint,
|
|
clientId,
|
|
clientSecret,
|
|
redirectUri,
|
|
}) {
|
|
if (!clientSecret) {
|
|
return {
|
|
tested: false,
|
|
valid: null,
|
|
message: "No client secret was provided, so secret validation was skipped.",
|
|
};
|
|
}
|
|
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
code: "__oidc_test_invalid_code__",
|
|
redirect_uri: redirectUri,
|
|
code_verifier: "__oidc_test_invalid_verifier__",
|
|
});
|
|
|
|
const res = await fetch(tokenEndpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
const error = (data?.error || "").toLowerCase();
|
|
const errorDescription = data?.error_description || data?.error || "";
|
|
|
|
if (res.ok) {
|
|
return {
|
|
tested: true,
|
|
valid: true,
|
|
message: "Client secret was accepted by the token endpoint.",
|
|
raw: data,
|
|
};
|
|
}
|
|
|
|
if (error === "invalid_client" || error === "unauthorized_client" || /client.*(invalid|failed|mismatch)/i.test(errorDescription)) {
|
|
return {
|
|
tested: true,
|
|
valid: false,
|
|
message: errorDescription || "Client secret is not valid.",
|
|
raw: data,
|
|
};
|
|
}
|
|
|
|
if (error === "invalid_grant" || error === "invalid_code" || /grant|code/i.test(errorDescription)) {
|
|
return {
|
|
tested: true,
|
|
valid: true,
|
|
message: "Client secret was accepted; the token exchange failed only because the test authorization code is invalid.",
|
|
raw: data,
|
|
};
|
|
}
|
|
|
|
return {
|
|
tested: true,
|
|
valid: null,
|
|
message: errorDescription || `Token endpoint responded with ${res.status}`,
|
|
raw: data,
|
|
};
|
|
}
|
|
|
|
export async function verifyOidcIdToken({
|
|
idToken,
|
|
issuer,
|
|
audience,
|
|
jwksUri,
|
|
nonce,
|
|
}) {
|
|
const jwks = createRemoteJWKSet(new URL(jwksUri));
|
|
const { payload } = await jwtVerify(idToken, jwks, {
|
|
issuer,
|
|
audience,
|
|
nonce,
|
|
});
|
|
return payload;
|
|
}
|
|
|
|
export function pickOidcDisplayName(payload = {}) {
|
|
return payload.preferred_username || payload.email || payload.name || payload.given_name || payload.sub || "OIDC user";
|
|
}
|
|
|
|
export function pickOidcEmail(payload = {}) {
|
|
return payload.email || "";
|
|
}
|