381 lines
11 KiB
TypeScript
Executable File
381 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": "/",
|
|
},
|
|
});
|
|
}
|
|
});
|