integratedhub/routes/auth/callback.ts
2026-01-20 09:02:26 -06:00

266 lines
6.8 KiB
TypeScript
Executable File

// routes/auth/callback.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 {
createUser,
getUser,
setStateKey,
setUserTokens,
} from "../../services/userService.ts";
import * as Iron from "iron";
// 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;
}
export async function persistWithEncryptedCookie(
session: SessionCookie,
stateKey: string,
response: Response,
req: Request,
xxStateId: string,
) {
const encryptedData = await Iron.seal(
session,
stateKey,
Iron.defaults,
);
try {
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.log("error:");
// console.log(JSON.stringify(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") || "";
let theStateKey: string | null = null;
let sessionData: SessionCookie | null = null;
const tokens = {
access_token: "",
id_token: "",
refresh_token: "",
};
if (codeParam) {
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(),
);
} else {
const tokenResponse = await response.json();
tokens.access_token = tokenResponse.access_token;
tokens.id_token = tokenResponse.id_token;
tokens.refresh_token = tokenResponse.refresh_token;
}
}
const response = new Response(null, {
status: 302,
headers: {
"Location": `${redirectHome}`,
"nonce": nonce,
},
});
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 roles = Array.isArray(p.roles) && p.roles.length > 0
? p.roles.join(",")
: "";
const userId = p.oid || p.sub || "";
const existingUser = await getUser(userId);
sessionData = {
started_on: Date.now(),
user_name: existingUser?.name || name,
user_encrypted: existingUser?.encrypted_data ? true : false,
user_id: userId,
role: roles,
};
if (!existingUser) {
// Create/update user before saving tokens (to satisfy foreign key constraint)
await createUser(
sessionData.user_id,
sessionData.user_name,
p.email || "",
"",
"",
"",
"",
"",
"",
"",
);
}
await setUserTokens(
sessionData.user_id,
xxStateId,
tokens.access_token,
tokens.refresh_token,
);
theStateKey = generatePassword();
const cookieHash = await hashText(JSON.stringify(sessionData));
const success = await setStateKey(theStateKey, xxStateId, cookieHash);
if (!success) {
console.error("Failed to set state key");
return new Response(null, {
status: 302,
headers: {
"Location": "/",
},
});
}
}
} catch (_e) {
void 0;
}
return theStateKey && sessionData
? persistWithEncryptedCookie(
sessionData as SessionCookie,
theStateKey,
response,
ctx.req,
xxStateId,
)
: response;
} catch (e) {
console.error(e);
return new Response(null, {
status: 302,
headers: {
"Location": "/",
},
});
}
});