import { NextResponse } from "next/server"; import { AUTH_STATE_COOKIE_NAME, SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath, shouldUseSecureAuthCookie, type AuthRuntimeConfig } from "@/lib/auth/config"; import { createSessionCookieValue, createSignedJsonValue, parseSignedJsonValue } from "@/lib/auth/session"; import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; import { requestOrigin } from "@/lib/server/runtime"; export type AuthStateCookie = { state: string; next: string; createdAt: number; }; type TokenResponse = { access_token?: string; token_type?: string; expires_in?: number; refresh_token?: string; scope?: string; [key: string]: unknown; }; export class OAuthLoginError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = "OAuthLoginError"; this.status = status; } } export function authRedirectUri(request: Request): string { return new URL("/api/auth/callback", requestOrigin(request)).toString(); } export async function createAuthorizeRedirect(request: Request): Promise { const config = requireConfiguredAuth(); const requestUrl = new URL(request.url); const state = crypto.randomUUID(); const next = safeNextPath(requestUrl.searchParams.get("next")); const authorizeUrl = new URL(config.authorizeUrl || ""); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("client_id", config.clientId); authorizeUrl.searchParams.set("redirect_uri", authRedirectUri(request)); authorizeUrl.searchParams.set("scope", config.scope); authorizeUrl.searchParams.set("state", state); const response = NextResponse.redirect(authorizeUrl); response.cookies.set(AUTH_STATE_COOKIE_NAME, await createSignedJsonValue({ state, next, createdAt: Math.floor(Date.now() / 1000) } satisfies AuthStateCookie, config.sessionSecret || ""), { httpOnly: true, sameSite: "lax", secure: shouldUseSecureAuthCookie(request.url), path: "/", maxAge: 10 * 60 }); return response; } export async function completeAuthorizationCallback(request: Request): Promise { const config = requireConfiguredAuth(); const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) throw new OAuthLoginError("授权回调缺少 code 或 state。"); const stateCookie = await parseSignedJsonValue( getCookieValue(request, AUTH_STATE_COOKIE_NAME), config.sessionSecret || "" ); const now = Math.floor(Date.now() / 1000); if (!stateCookie || stateCookie.state !== state || now - stateCookie.createdAt > 10 * 60) { throw new OAuthLoginError("登录状态已失效,请重新登录。"); } const token = await exchangeAuthorizationCode(code, authRedirectUri(request), config); if (!token.access_token) throw new OAuthLoginError("认证中心没有返回 access_token。"); const claims = await verifyAuthJwt(token.access_token, config); const session = createSessionFromClaims(claims, config, token.expires_in); const response = NextResponse.redirect(new URL(stateCookie.next, request.url)); response.cookies.set( SESSION_COOKIE_NAME, await createSessionCookieValue(session, config.sessionSecret || ""), { httpOnly: true, sameSite: "lax", secure: shouldUseSecureAuthCookie(request.url), path: "/", expires: new Date(session.expiresAt * 1000) } ); response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url)); return response; } export function clearAuthCookies(request: Request, redirectTo = "/auth/login?loggedOut=1"): NextResponse { const response = NextResponse.redirect(new URL(redirectTo, request.url)); response.cookies.set(SESSION_COOKIE_NAME, "", clearCookieOptions(request.url)); response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url)); return response; } export function redirectToLoginWithError(request: Request, error: string): NextResponse { const loginUrl = new URL("/auth/login", request.url); loginUrl.searchParams.set("error", error); return NextResponse.redirect(loginUrl); } async function exchangeAuthorizationCode( code: string, redirectUri: string, config: AuthRuntimeConfig ): Promise { if (!config.tokenUrl || !config.clientSecret) throw new OAuthLoginError("认证 token endpoint 未配置。", 500); const body = new URLSearchParams(); body.set("grant_type", "authorization_code"); body.set("code", code); body.set("redirect_uri", redirectUri); const response = await fetch(config.tokenUrl, { method: "POST", headers: { Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded" }, body }); const payload = await response.json().catch(() => ({})) as TokenResponse & { msg?: string; error_description?: string; error?: string }; if (!response.ok) { throw new OAuthLoginError(payload.error_description || payload.msg || payload.error || "授权码换取 token 失败。", response.status); } return payload; } function requireConfiguredAuth(): AuthRuntimeConfig { const config = getAuthRuntimeConfig(); if (!config.configured) { throw new OAuthLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500); } return config; } function getCookieValue(request: Request, name: string): string | undefined { const cookie = request.headers.get("cookie") || ""; const prefix = `${name}=`; return cookie.split(/;\s*/).find((entry) => entry.startsWith(prefix))?.slice(prefix.length); } function clearCookieOptions(requestUrl: string) { return { httpOnly: true, sameSite: "lax" as const, secure: shouldUseSecureAuthCookie(requestUrl), path: "/", maxAge: 0 }; }