2026-02-14 06:10:15 +00:00

402 lines
11 KiB
TypeScript
Executable File

// routes/auth/callback.ts
import { deleteCookie, getCookies, setCookie } from "$std/http/cookie.ts";
import { decode } from "djwt";
import { generateNonce, hashText } from "../../services/stateMiddleware.ts";
import { define } from "../../utils.ts";
import {
getUser,
setStateKey,
setUserTokens,
upsertUser,
} from "../../services/userService.ts";
import * as Iron from "iron";
const authDebug = Deno.env.get("AUTH_DEBUG") === "true";
// Azure AD (CIAM/B2C) id_token payload shape
export interface IdTokenPayload {
aud: string;
iss: string;
iat: number;
nbf: number;
exp: number;
aio?: string;
email?: string;
family_name?: string;
given_name?: string;
idp?: string;
name?: string;
nonce?: string;
oid?: string;
preferred_username?: string;
rh?: string;
roles?: string[];
sid?: string;
sub?: string;
tid?: string;
uti?: string;
ver?: string;
[key: string]: unknown;
}
export function generatePassword(length = 32) {
const charset =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?";
const passwordArray = new Uint8Array(length);
crypto.getRandomValues(passwordArray);
return Array.from(passwordArray)
.map((x) => charset[x % charset.length])
.join("");
}
export function isSecureRequest(req: Request): boolean {
// Check for standard HTTPS
if (req.url.startsWith("https://")) {
return true;
}
// Check for common reverse proxy headers
const forwardedProto = req.headers.get("x-forwarded-proto");
const forwardedSsl = req.headers.get("x-forwarded-ssl");
const frontEndHttps = req.headers.get("front-end-https");
const xArrSsl = req.headers.get("x-arr-ssl"); // IIS specific
return forwardedProto === "https" ||
forwardedSsl === "on" ||
frontEndHttps === "on" ||
xArrSsl === "true";
}
export interface SessionCookie {
started_on: number;
user_name: string;
user_encrypted: boolean;
user_id: string;
role: string;
user_type: string;
}
/**
* Helper function to create an authenticated redirect response with return_path parameter
* This ensures users are returned to their intended destination after login
*
* @param currentPath - The path the user is trying to access (will be used as return_path)
* @returns A Response object that redirects to /auth/login with return_path parameter
*/
export function createAuthRedirect(
currentPath: string,
reason?: string,
authTraceId?: string,
): Response {
const loginUrl = new URL(
"/auth/login",
Deno.env.get("REDIRECT_HOME") || "http://localhost:8080",
);
loginUrl.searchParams.set("return_path", currentPath);
if (reason) {
loginUrl.searchParams.set("auth_error", reason);
}
if (authTraceId) {
loginUrl.searchParams.set("auth_trace_id", authTraceId);
}
return new Response("", {
status: 302,
headers: { Location: loginUrl.toString() },
});
}
export async function persistWithEncryptedCookie(
session: SessionCookie,
stateKey: string,
response: Response,
req: Request,
xxStateId: string,
) {
try {
const encryptedData = await Iron.seal(
session,
stateKey,
Iron.defaults,
);
setCookie(response.headers, {
name: "xx_state_id",
value: xxStateId,
httpOnly: true,
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
secure: req ? isSecureRequest(req) : true,
sameSite: "Lax",
path: "/",
});
setCookie(response.headers, {
name: "session",
value: encryptedData,
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
httpOnly: true,
secure: req ? isSecureRequest(req) : true,
sameSite: "Lax",
path: "/",
});
} catch (error) {
console.error("Failed to seal or set session cookie:", error);
}
return response;
}
export const handler = define.handlers(async (
ctx,
) => {
try {
const uri = new URL(ctx.req.url);
const preXxStateId = uri.searchParams.get("xx-state-id");
const xxStateId = preXxStateId || crypto.randomUUID();
const codeParam: string | null = uri.searchParams.get("code");
const nonce = generateNonce();
const tokenEndpoint = Deno.env.get("TOKEN_URL") || "";
const clientId = Deno.env.get("CLIENT_ID") || "";
const clientSecret = Deno.env.get("CLIENT_SECRET") || "";
const redirectHome = Deno.env.get("REDIRECT_HOME") || "";
// Get return_path from cookie (set by auth/login when user was redirected from protected route)
const cookies = getCookies(ctx.req.headers);
const authTraceId = cookies["auth_trace_id"] || crypto.randomUUID();
const returnPath = "/";
if (authDebug) {
console.log("DEBUG authCallback: start", {
hasCode: !!codeParam,
returnPath,
stateId: xxStateId,
authTraceId,
});
}
let theStateKey: string | null = null;
let sessionData: SessionCookie | null = null;
const tokens = {
access_token: "",
id_token: "",
refresh_token: "",
};
if (!codeParam) {
console.error("Missing authorization code", { authTraceId });
return createAuthRedirect(
returnPath,
"missing_authorization_code",
authTraceId,
);
}
const params = new URLSearchParams();
params.append("client_id", clientId);
params.append("client_secret", clientSecret);
params.append("grant_type", "authorization_code");
params.append("code", codeParam);
params.append("redirect_uri", redirectHome + "/auth/callback");
params.append(
"scope",
`openid profile offline_access https://graph.microsoft.com/.default`,
);
const response = await fetch(
`${tokenEndpoint}?nonce=${nonce}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params,
},
);
if (!response.ok) {
console.error(
"Failed to exchange code:",
response.status,
await response.text(),
{ authTraceId },
);
return createAuthRedirect(
returnPath,
"token_exchange_failed",
authTraceId,
);
}
const tokenResponse = await response.json();
tokens.access_token = tokenResponse.access_token;
tokens.id_token = tokenResponse.id_token;
tokens.refresh_token = tokenResponse.refresh_token;
if (!tokens.id_token) {
console.error("Missing id_token in auth response", { authTraceId });
return createAuthRedirect(returnPath, "missing_id_token", authTraceId);
}
try {
const [_header, payload, _signature] = decode(
tokens.id_token,
);
if (payload) {
const p = payload as unknown as IdTokenPayload;
const name = p.name || p.preferred_username ||
p.given_name + " " + p.family_name || p.email || "";
const userId = p.oid || p.sub || "";
if (!userId) {
console.error("Missing user identifier in id_token", { authTraceId });
return createAuthRedirect(
returnPath,
"missing_user_identifier",
authTraceId,
);
}
const existingUser = await getUser(userId);
let ensuredUser = existingUser;
try {
ensuredUser = await upsertUser(
userId,
existingUser?.name || name,
p.email || "",
);
} catch (upsertError) {
console.error(
"Failed to upsert user (continuing with callback session):",
upsertError,
{ authTraceId },
);
}
const roleSet = new Set(
[
...(Array.isArray(p.roles) ? p.roles : []),
ensuredUser?.user_type,
]
.map((role) => (role ? role.toString().trim().toLowerCase() : ""))
.filter((role) => role.length > 0),
);
const roles = Array.from(roleSet).join(",");
sessionData = {
started_on: Date.now(),
user_name: ensuredUser?.name || name,
user_encrypted: ensuredUser?.encrypted_data ? true : false,
user_id: userId,
role: roles,
user_type: ensuredUser?.user_type || "tenant",
};
if (authDebug) {
console.log("DEBUG authCallback: session created", {
user_id: sessionData.user_id,
role: sessionData.role,
user_type: sessionData.user_type,
});
}
try {
await setUserTokens(
sessionData.user_id,
xxStateId,
tokens.access_token,
tokens.refresh_token,
);
} catch (tokenError) {
console.error(
"Failed to store user tokens (continuing with callback session):",
tokenError,
{ authTraceId },
);
const tokenErrorMessage = tokenError instanceof Error
? tokenError.message
: String(tokenError);
if (tokenErrorMessage.includes("user_tokens_user_id_users_id_fk")) {
console.error(
"Auth callback FK hint: verify POSTGRES_OPTIONS target DB, and ensure search_path resolves users/user_tokens to public first.",
);
}
}
if (authDebug) {
console.log("DEBUG authCallback: tokens stored", {
user_id: sessionData.user_id,
state_id: xxStateId,
});
}
theStateKey = generatePassword();
const cookieHash = await hashText(JSON.stringify(sessionData));
try {
await setStateKey(theStateKey, xxStateId, cookieHash);
} catch (stateKeyError) {
console.error(
"Failed to persist state key (continuing with callback session):",
stateKeyError,
{ authTraceId },
);
}
} else {
console.error("Decoded id_token payload was empty", { authTraceId });
return createAuthRedirect(
returnPath,
"empty_id_token_payload",
authTraceId,
);
}
} catch (_e) {
console.error("Failed to process auth callback:", _e, { authTraceId });
return createAuthRedirect(
returnPath,
"callback_processing_failed",
authTraceId,
);
}
// Delete the return_path cookie after successful login
let finalResponse: Response;
if (theStateKey && sessionData) {
const successRedirect = new URL("/", redirectHome || ctx.req.url)
.toString();
const response = new Response(null, {
status: 302,
headers: {
"Location": successRedirect,
"nonce": nonce,
},
});
finalResponse = await persistWithEncryptedCookie(
sessionData as SessionCookie,
theStateKey,
response,
ctx.req,
xxStateId,
);
if (authDebug) {
console.log("DEBUG authCallback: cookies set", {
hasSetCookie: !!finalResponse.headers.get("set-cookie"),
redirectTo: successRedirect,
});
}
} else {
console.error("Authentication failed: no session state created", {
authTraceId,
});
return new Response("Authentication failed", { status: 500 });
}
deleteCookie(finalResponse.headers, "return_path", { path: "/" });
return finalResponse;
} catch (e) {
console.error(e);
return new Response(null, {
status: 302,
headers: {
"Location": "/",
},
});
}
});