286 lines
8.5 KiB
TypeScript
286 lines
8.5 KiB
TypeScript
// 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<StateKey>({ 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": "/",
|
|
},
|
|
});
|
|
}
|
|
},
|
|
});
|