171 lines
5.9 KiB
TypeScript
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
|
|
};
|
|
}
|