mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 15:52:21 +10:00
215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { auth } from "@reactive-resume/auth/config";
|
|
import { db } from "@reactive-resume/db/client";
|
|
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
|
import { env } from "@reactive-resume/env/server";
|
|
import { generateId } from "@reactive-resume/utils/string";
|
|
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
|
|
|
const oauthAuthorizeSanitizedParams = [
|
|
"prompt",
|
|
"redirect_uri",
|
|
"client_id",
|
|
"code_challenge",
|
|
"code_challenge_method",
|
|
"response_type",
|
|
"scope",
|
|
"state",
|
|
"resource",
|
|
] as const;
|
|
|
|
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
|
|
|
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
|
if (request.method !== "GET") return request;
|
|
|
|
const url = new URL(request.url);
|
|
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
|
|
|
const sanitizeValue = (value: string) =>
|
|
value
|
|
.replace(/[\r\n\t]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const sanitizeParam = (key: string) => {
|
|
const value = url.searchParams.get(key);
|
|
if (!value) return;
|
|
url.searchParams.set(key, sanitizeValue(value));
|
|
};
|
|
|
|
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
|
|
|
const redirectUri = url.searchParams.get("redirect_uri");
|
|
if (redirectUri && !URL.canParse(redirectUri)) {
|
|
try {
|
|
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
|
if (URL.canParse(decodedRedirectUri)) {
|
|
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
|
}
|
|
} catch {
|
|
// Ignore malformed encoded values and let Better Auth validation handle them.
|
|
}
|
|
}
|
|
|
|
if (url.toString() === request.url) return request;
|
|
return new Request(url.toString(), request);
|
|
}
|
|
|
|
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
|
if (request.method !== "POST") return request;
|
|
|
|
const url = new URL(request.url);
|
|
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
|
|
|
const cloned = request.clone();
|
|
let body: Record<string, unknown>;
|
|
|
|
try {
|
|
body = await cloned.json();
|
|
} catch {
|
|
return request;
|
|
}
|
|
|
|
if (!request.headers.get("authorization")) {
|
|
body.token_endpoint_auth_method = "none";
|
|
}
|
|
|
|
return new Request(url.toString(), {
|
|
method: request.method,
|
|
headers: request.headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
|
if (request.method !== "POST") return;
|
|
|
|
const url = new URL(request.url);
|
|
if (!url.pathname.endsWith("/oauth2/register")) return;
|
|
|
|
const cloned = request.clone();
|
|
let body: Record<string, unknown>;
|
|
|
|
try {
|
|
body = await cloned.json();
|
|
} catch {
|
|
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
|
}
|
|
|
|
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
|
|
|
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
|
for (const redirectUri of redirectUris) {
|
|
if (
|
|
typeof redirectUri !== "string" ||
|
|
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
|
|
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
|
|
})
|
|
) {
|
|
return Response.json(
|
|
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function handleAuth(request: Request) {
|
|
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
|
if (registrationValidationError) return registrationValidationError;
|
|
|
|
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
|
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
|
|
|
return auth.handler(finalRequest);
|
|
}
|
|
|
|
function generateCode() {
|
|
return crypto.randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
function hashCode(code: string) {
|
|
return crypto.createHash("sha256").update(code).digest("base64url");
|
|
}
|
|
|
|
export async function handleOAuth(request: Request) {
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
const url = new URL(request.url);
|
|
|
|
if (session?.user) {
|
|
const clientId = url.searchParams.get("client_id");
|
|
const redirectUri = url.searchParams.get("redirect_uri");
|
|
const state = url.searchParams.get("state");
|
|
const scope = url.searchParams.get("scope");
|
|
const codeChallenge = url.searchParams.get("code_challenge");
|
|
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
|
|
if (!clientId || !redirectUri) {
|
|
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
|
}
|
|
|
|
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
|
|
|
if (!client) {
|
|
return Response.json({ error: "invalid client" }, { status: 400 });
|
|
}
|
|
|
|
if (!client.redirectUris.includes(redirectUri)) {
|
|
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
|
}
|
|
|
|
const code = generateCode();
|
|
const hashedCode = hashCode(code);
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + 600_000);
|
|
|
|
await db.insert(verification).values({
|
|
id: generateId(),
|
|
identifier: hashedCode,
|
|
value: JSON.stringify({
|
|
type: "authorization_code",
|
|
query: {
|
|
response_type: "code",
|
|
client_id: clientId,
|
|
redirect_uri: redirectUri,
|
|
scope,
|
|
state,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: codeChallengeMethod,
|
|
},
|
|
userId: session.user.id,
|
|
sessionId: session.session.id,
|
|
authTime: new Date(session.session.createdAt).getTime(),
|
|
}),
|
|
expiresAt,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const callbackUrl = new URL(redirectUri);
|
|
callbackUrl.searchParams.set("code", code);
|
|
if (state) callbackUrl.searchParams.set("state", state);
|
|
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
|
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: callbackUrl.toString() },
|
|
});
|
|
}
|
|
|
|
const loginUrl = new URL("/auth/login", env.APP_URL);
|
|
const oauthParams = new URLSearchParams();
|
|
for (const [key, value] of url.searchParams) {
|
|
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
|
oauthParams.set(key, value);
|
|
}
|
|
}
|
|
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
|
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
|
});
|
|
}
|