Files
NianAIGC/lib/server/auth/oauth.ts
2026-05-29 15:54:13 +08:00

171 lines
5.9 KiB
TypeScript

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<NextResponse> {
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<NextResponse> {
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<AuthStateCookie>(
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<TokenResponse> {
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
};
}