130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
|
import { valkey } from "../valkey.ts";
|
|
import { sqlWrapper } from "../db.ts";
|
|
import { getCookieDomain } from "../auth-session.ts";
|
|
|
|
export const passRoutes = new Hono();
|
|
|
|
// ---------------------------------------------------------
|
|
// Ephemeral 1-Click Magic Link Redemption (/pass)
|
|
// ---------------------------------------------------------
|
|
|
|
passRoutes.get("/", async (c) => {
|
|
const token = c.req.query("token");
|
|
if (!token) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
|
|
// 1. Validate against Valkey, fallback to PostgreSQL
|
|
let sessionDataStr = null;
|
|
try {
|
|
sessionDataStr = await valkey.get(token);
|
|
} catch (_err) {}
|
|
|
|
let sessionInfo: any = null;
|
|
if (sessionDataStr) {
|
|
try {
|
|
sessionInfo = JSON.parse(sessionDataStr);
|
|
} catch (_err) {}
|
|
}
|
|
|
|
let expiresAtDate: Date | null = null;
|
|
let customScopes: string[] = [];
|
|
|
|
if (!sessionInfo || !sessionInfo.uuid) {
|
|
try {
|
|
const nowIso = new Date().toISOString();
|
|
const session = await sqlWrapper.sql`
|
|
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
|
|
FROM sessions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE s.id = ${token} AND s.expires_at > ${nowIso}
|
|
`.then((res: any) => res[0]);
|
|
|
|
if (!session) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
|
|
sessionInfo = {
|
|
uuid: session.user_id,
|
|
username: session.username,
|
|
label: session.label,
|
|
isAgent: session.is_agent,
|
|
customScopes: session.custom_scopes,
|
|
};
|
|
expiresAtDate = new Date(session.expires_at);
|
|
customScopes = session.custom_scopes || [];
|
|
|
|
try {
|
|
const ttlSeconds = Math.max(
|
|
1,
|
|
Math.floor((expiresAtDate.getTime() - Date.now()) / 1000),
|
|
);
|
|
await valkey.setex(token, ttlSeconds, JSON.stringify(sessionInfo));
|
|
} catch (_e) {}
|
|
} catch (_err) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
} else {
|
|
try {
|
|
const ttl = await valkey.ttl(token);
|
|
if (ttl <= 0) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
expiresAtDate = new Date(Date.now() + ttl * 1000);
|
|
customScopes = sessionInfo.customScopes || sessionInfo.custom_scopes ||
|
|
[];
|
|
} catch (_err) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
}
|
|
|
|
if (!sessionInfo || !expiresAtDate) {
|
|
return c.redirect("/login?error=invalid_or_expired_pass", 302);
|
|
}
|
|
|
|
// 2. Cookie Scoping
|
|
deleteCookie(c, "session_id", { path: "/" });
|
|
|
|
const rpID = Deno.env.get("RP_ID");
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
const ttlSeconds = Math.max(
|
|
1,
|
|
Math.floor((expiresAtDate.getTime() - Date.now()) / 1000),
|
|
);
|
|
setCookie(c, "session_id", token, {
|
|
path: "/",
|
|
domain: cookieDomain,
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: ttlSeconds,
|
|
});
|
|
|
|
// 3. Redirect URL Resolution
|
|
let targetDomain = null;
|
|
if (Array.isArray(customScopes)) {
|
|
const appScope = customScopes.find((s: string) =>
|
|
typeof s === "string" && s.startsWith("app:")
|
|
);
|
|
if (appScope) {
|
|
const appName = appScope.substring(4);
|
|
try {
|
|
const appRecord = await sqlWrapper.sql`
|
|
SELECT domain FROM apps WHERE name = ${appName}
|
|
`.then((res: any) => res[0]);
|
|
if (appRecord && appRecord.domain) {
|
|
targetDomain = appRecord.domain;
|
|
}
|
|
} catch (_err) {}
|
|
}
|
|
}
|
|
|
|
if (targetDomain) {
|
|
return c.redirect(`https://${targetDomain}`, 302);
|
|
} else {
|
|
return c.redirect("/dashboard", 302);
|
|
}
|
|
});
|