Add authenticated login and SSO protection
This commit is contained in:
119
lib/auth/config.ts
Normal file
119
lib/auth/config.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export const SESSION_COOKIE_NAME = "zhinian_session";
|
||||
export const AUTH_STATE_COOKIE_NAME = "zhinian_auth_state";
|
||||
|
||||
export type AuthRuntimeConfig = {
|
||||
required: boolean;
|
||||
configured: boolean;
|
||||
missing: string[];
|
||||
authBaseUrl?: string;
|
||||
authorizeUrl?: string;
|
||||
tokenUrl?: string;
|
||||
jwksUrl?: string;
|
||||
logoutUrl?: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scope: string;
|
||||
issuer: string;
|
||||
sessionSecret?: string;
|
||||
clockSkewSeconds: number;
|
||||
};
|
||||
|
||||
export function getAuthRuntimeConfig(): AuthRuntimeConfig {
|
||||
const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE"));
|
||||
const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "customPC";
|
||||
const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET");
|
||||
const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
|
||||
const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com";
|
||||
const sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET");
|
||||
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
|
||||
const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true;
|
||||
const hasAnyAuthConfig = Boolean(authBaseUrl || clientSecret || sessionSecret);
|
||||
const required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(authBaseUrl));
|
||||
const wantsConfiguration = required || hasAnyAuthConfig;
|
||||
const missing: string[] = [];
|
||||
|
||||
if (wantsConfiguration && !authBaseUrl) missing.push("ZHINIAN_AUTH_BASE_URL");
|
||||
if (wantsConfiguration && !clientSecret) missing.push("ZHINIAN_AUTH_CLIENT_SECRET");
|
||||
if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET");
|
||||
|
||||
return {
|
||||
required,
|
||||
configured: wantsConfiguration && missing.length === 0,
|
||||
missing,
|
||||
authBaseUrl,
|
||||
authorizeUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_AUTHORIZE_URL", "/oauth2/authorize"),
|
||||
tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"),
|
||||
jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"),
|
||||
logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"),
|
||||
clientId,
|
||||
clientSecret,
|
||||
scope,
|
||||
issuer,
|
||||
sessionSecret,
|
||||
clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60
|
||||
};
|
||||
}
|
||||
|
||||
export function safeNextPath(value: string | null | undefined, fallback = "/create"): string {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
|
||||
try {
|
||||
const parsed = new URL(value, "http://zhinian.local");
|
||||
if (parsed.origin !== "http://zhinian.local") return fallback;
|
||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
if (path.startsWith("/api/auth") || path.startsWith("/auth/login")) return fallback;
|
||||
return path;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function authConfigSummary(config = getAuthRuntimeConfig()) {
|
||||
if (!config.required) return "disabled";
|
||||
return config.configured ? "configured" : "missing";
|
||||
}
|
||||
|
||||
export function shouldUseSecureAuthCookie(requestUrl?: string): boolean {
|
||||
const explicit = boolEnv("ZHINIAN_AUTH_COOKIE_SECURE");
|
||||
if (explicit !== undefined) return explicit;
|
||||
const configured = envValue("NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL");
|
||||
const candidate = configured || requestUrl;
|
||||
if (!candidate) return false;
|
||||
try {
|
||||
return new URL(candidate).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function endpointUrl(authBaseUrl: string | undefined, overrideKey: string, path: string) {
|
||||
const override = envValue(overrideKey);
|
||||
if (override) return override.replace(/\/$/, "");
|
||||
return authBaseUrl ? `${authBaseUrl}${path}` : undefined;
|
||||
}
|
||||
|
||||
function envValue(...names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function boolEnv(name: string): boolean | undefined {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
if (!value || value === "auto") return undefined;
|
||||
if (["1", "true", "yes", "on"].includes(value)) return true;
|
||||
if (["0", "false", "no", "off"].includes(value)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberEnv(name: string): number | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value?: string) {
|
||||
return value?.replace(/\/$/, "");
|
||||
}
|
||||
99
lib/auth/session.ts
Normal file
99
lib/auth/session.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
subject: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
clientId: string;
|
||||
tenantId?: string;
|
||||
authorities: string[];
|
||||
scope: string[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
version: 1;
|
||||
user: AuthUser;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export async function createSignedJsonValue(value: unknown, secret: string): Promise<string> {
|
||||
const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value)));
|
||||
const signature = await signPayload(payload, secret);
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export async function parseSignedJsonValue<T>(value: string | undefined, secret: string): Promise<T | null> {
|
||||
if (!value) return null;
|
||||
const [payload, signature, extra] = value.split(".");
|
||||
if (!payload || !signature || extra !== undefined) return null;
|
||||
const expected = await signPayload(payload, secret);
|
||||
if (!constantTimeEqual(signature, expected)) return null;
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(base64UrlToBytes(payload))) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionCookieValue(session: AuthSession, secret: string): Promise<string> {
|
||||
return createSignedJsonValue(session, secret);
|
||||
}
|
||||
|
||||
export async function parseSessionCookieValue(
|
||||
value: string | undefined,
|
||||
secret: string,
|
||||
nowSeconds = Math.floor(Date.now() / 1000)
|
||||
): Promise<AuthSession | null> {
|
||||
const session = await parseSignedJsonValue<AuthSession>(value, secret);
|
||||
if (!session || session.version !== 1) return null;
|
||||
if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null;
|
||||
if (session.expiresAt <= nowSeconds) return null;
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [],
|
||||
scope: Array.isArray(session.user.scope) ? session.user.scope : []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function signPayload(payload: string, secret: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
textEncoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
|
||||
return bytesToBase64Url(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
function constantTimeEqual(left: string, right: string): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
let diff = 0;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.slice(index, index + 0x8000));
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
Reference in New Issue
Block a user