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