Add authenticated login and SSO protection
This commit is contained in:
170
lib/server/auth/oauth.ts
Normal file
170
lib/server/auth/oauth.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user