// 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": "/", }, }); } });