// routes/auth/validate.ts import { setCookie } from "$std/http/cookie.ts"; import { decode } from "djwt"; import { generateNonce, hashText } from "../../services/stateMiddleware.ts"; import { define } from "../../utils.ts"; import * as Iron from "iron"; import { getAllStateKeys, getStateKeys, getUserTokens, setStateKey, setUserTokens, } from "../../services/userService.ts"; import { generatePassword, IdTokenPayload, isSecureRequest, persistWithEncryptedCookie, SessionCookie, } from "./callback.ts"; import { KvCacheStore } from "../../modules/KvCacheStore.ts"; import { StateKey } from "../../db/interfaces.ts"; import { sqlGetAllStateKeys } from "../../db/sqlUserRepository.ts"; const persistPath = Deno.env.get("KV_CACHE_PATH") ?? "./data"; export const handler = define.handlers({ async POST(ctx) { try { console.log("validate POST - validating tokens"); const redirectHome = Deno.env.get("REDIRECT_HOME") || ""; // Parse body - support both JSON and form data let body: { session?: string; xx_state_id?: string; caller_url?: string; }; try { const contentType = ctx.req.headers.get("content-type") || ""; if (contentType.includes("application/json")) { // Parse as JSON body = await ctx.req.json(); } else if ( contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data") ) { // Parse as form data const formData = await ctx.req.formData(); body = { session: formData.get("session")?.toString(), xx_state_id: formData.get("xx_state_id")?.toString(), caller_url: formData.get("caller_url")?.toString(), }; } else { // Try JSON as fallback body = await ctx.req.json(); } } catch (_e) { console.error("Failed to parse request body:", _e); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } console.log("validate POST - body:", body); const xxStateId = body.xx_state_id; const sessionCookie = body.session; const timestampNow = Date.now(); let sessionData: SessionCookie | null = null; let theStateKey: string | null = null; if (xxStateId && sessionCookie) { const kvStore = await KvCacheStore.initialize(persistPath); const keys: StateKey[] = []; for await ( const entry of kvStore.list({ prefix: ["state_keys"] }) ) { keys.push(entry.value); } const allStateKeys = keys; console.log("validate POST - allStateKeys:", allStateKeys); let stateKeys = allStateKeys.filter((k) => k.state_id === xxStateId); if (!stateKeys || stateKeys.length === 0) { stateKeys = await sqlGetAllStateKeys(); console.log("validate POST - sql stateKeys:", stateKeys); } stateKeys.sort((a: any, b: any) => b.created_on - a.created_on); const timestamp = timestampNow - 18000000; console.log("validate POST - stateKeys:", stateKeys); for (const stateKey of stateKeys) { let stateData: SessionCookie | null = null; try { stateData = await Iron.unseal( sessionCookie, stateKey.seal_key, Iron.defaults, ) as SessionCookie; } catch (_e) { console.log("error unsealing cookie"); } if (stateData && stateData.user_id) { const thisHash = await hashText(JSON.stringify(stateData)); if (thisHash === stateKey.cookie_hash) { const renewWindow = timestampNow - 300 * 1000; const newKey = generatePassword(); if (stateKey.created_on < renewWindow) { stateData.started_on = timestampNow; const cookieHash = await hashText(JSON.stringify(stateData)); await setStateKey(newKey, xxStateId, cookieHash); theStateKey = newKey; } else { theStateKey = stateKey.seal_key; } sessionData = stateData; break; } } } } console.log("validate POST - sessionData:", sessionData); const userId = sessionData?.user_id; const callerUrl = body.caller_url || redirectHome; const userTokens = userId ? await getUserTokens(userId) : null; if (!xxStateId || !sessionCookie || !userId || !userTokens) { console.error("Missing refresh_token in request body"); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } 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 tokens = { access_token: "", id_token: "", refresh_token: "", }; // Make refresh token request to validate and get new tokens const params = new URLSearchParams(); params.append("client_id", clientId); params.append("client_secret", clientSecret); params.append("grant_type", "refresh_token"); params.append("refresh_token", userTokens.refresh_token); params.append( "scope", `openid profile offline_access https://graph.microsoft.com/.default`, ); const tokenResponse = await fetch( `${tokenEndpoint}?nonce=${nonce}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params, }, ); if (!tokenResponse.ok) { console.error( "Failed to refresh token:", tokenResponse.status, await tokenResponse.text(), ); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } const tokenData = await tokenResponse.json(); tokens.access_token = tokenData.access_token; tokens.id_token = tokenData.id_token; tokens.refresh_token = tokenData.refresh_token; const response = new Response(null, { status: 302, headers: { "Location": callerUrl, "nonce": nonce, }, }); // Decode id_token to extract session data try { const [header, payload, signature] = decode( tokens.id_token, ); if (payload) { const p = payload as unknown as IdTokenPayload; const roles = Array.isArray(p.roles) && p.roles.length > 0 ? p.roles.join(",") : ""; sessionData = { started_on: Date.now(), user_name: p.name || p.preferred_username || p.email || "", user_id: p.oid || p.sub || "", user_encrypted: false, role: roles, }; const newKey = generatePassword(); const cookieHash = await hashText(JSON.stringify(sessionData)); const success = await setStateKey(newKey, xxStateId, cookieHash); theStateKey = newKey; if (!success) { console.error("Failed to set state key"); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } } } catch (_e) { console.error("Failed to decode id_token:", _e); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } setUserTokens( userId, xxStateId, tokens.access_token, tokens.refresh_token, ); setCookie(response.headers, { name: "xx_state_id", value: xxStateId, httpOnly: true, secure: isSecureRequest(ctx.req), sameSite: "Lax", path: "/", }); return theStateKey && sessionData ? persistWithEncryptedCookie( sessionData as SessionCookie, theStateKey, response, ctx.req, xxStateId, ) : response; } catch (e) { console.error("Validation error:", e); return new Response(null, { status: 302, headers: { "Location": "/", }, }); } }, });