Compare commits

..

1 Commits

8 changed files with 767 additions and 938 deletions

View File

@ -401,24 +401,3 @@ export function isSafeRedirectUrl(
} }
return false; return false;
} }
/**
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
*/
export function getClientIp(c: Context): string {
const realIp = c.req.header("x-real-ip");
if (realIp) {
return realIp.trim();
}
let forwardedFor = c.req.header("x-forwarded-for");
if (forwardedFor) {
if (forwardedFor.length > 256) {
forwardedFor = forwardedFor.substring(0, 256);
}
const parts = forwardedFor.split(",");
return parts[parts.length - 1].trim();
}
return "127.0.0.1";
}

View File

@ -71,109 +71,6 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
valkeyStub.restore(); valkeyStub.restore();
}); });
Deno.test("Ephemeral 1-Click Magic Link Redemption (/pass)", async (t) => {
await t.step("GET /pass with missing token redirects to login", async () => {
const res = await app.request("/pass", { method: "GET" });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
"/login?error=invalid_or_expired_pass",
);
});
await t.step("GET /pass with invalid token redirects to login", async () => {
const valkeyStub = stub(valkey, "get", () => Promise.resolve(null));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = () => Promise.resolve([]);
try {
const res = await app.request("/pass?token=invalid", { method: "GET" });
assertEquals(res.status, 302);
assertEquals(
res.headers.get("location"),
"/login?error=invalid_or_expired_pass",
);
} finally {
sqlWrapper.sql = originalSql;
valkeyStub.restore();
}
});
await t.step(
"GET /pass with valid token sets cookies and redirects to app domain",
async () => {
const sessionToken = "ay_sess_valid_app";
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === sessionToken) {
return Promise.resolve(JSON.stringify({
uuid: "user-uuid",
username: "testuser",
customScopes: ["app:test-app"],
}));
}
return Promise.resolve(null);
});
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
const originalSql = sqlWrapper.sql;
sqlWrapper.sql = (strings: any, ..._values: any[]) => {
const query = strings.join("?");
if (query.includes("SELECT domain FROM apps WHERE name =")) {
return Promise.resolve([{ domain: "test-app.atyg.org" }]);
}
return Promise.resolve([]);
};
try {
const res = await app.request(`/pass?token=${sessionToken}`, {
method: "GET",
});
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "https://test-app.atyg.org");
// Verify cookies
const cookies = res.headers.get("set-cookie");
assertExists(cookies);
assert(cookies.includes("session_id=;")); // clear host-only cookie
assert(cookies.includes(`session_id=${sessionToken};`)); // set wildcard cookie
// In tests, process.env.COOKIE_DOMAIN or getCookieDomain fallback doesn't output .atyg.org because rpID env variables may not be explicitly set in Deno tests. Wait, if it fails, let's just make sure it sets domain
} finally {
sqlWrapper.sql = originalSql;
valkeyGetStub.restore();
valkeyTtlStub.restore();
}
},
);
await t.step(
"GET /pass with valid token defaults to /dashboard if no app scope",
async () => {
const sessionToken = "ay_sess_valid_dashboard";
const valkeyGetStub = stub(valkey, "get", (key: any) => {
if (String(key) === sessionToken) {
return Promise.resolve(JSON.stringify({
uuid: "user-uuid",
username: "testuser",
customScopes: ["read:audit"],
}));
}
return Promise.resolve(null);
});
const valkeyTtlStub = stub(valkey, "ttl", () => Promise.resolve(3600));
try {
const res = await app.request(`/pass?token=${sessionToken}`, {
method: "GET",
});
assertEquals(res.status, 302);
assertEquals(res.headers.get("location"), "/dashboard");
} finally {
valkeyGetStub.restore();
valkeyTtlStub.restore();
}
},
);
});
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => { Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => {
const req = new Request("http://localhost/api/forward-auth", { const req = new Request("http://localhost/api/forward-auth", {
headers: { "X-Forwarded-Host": "test.app.local" }, headers: { "X-Forwarded-Host": "test.app.local" },

View File

@ -21,6 +21,7 @@ import {
decodeBase64Url, decodeBase64Url,
encodeBase64Url, encodeBase64Url,
} from "jsr:@std/encoding@1/base64url"; } from "jsr:@std/encoding@1/base64url";
import { encodeHex } from "jsr:@std/encoding@1/hex";
import { MetadataService } from "jsr:@simplewebauthn/server@13"; import { MetadataService } from "jsr:@simplewebauthn/server@13";
import { initDb, sqlWrapper } from "./db.ts"; import { initDb, sqlWrapper } from "./db.ts";
import { pingValkey, valkey } from "./valkey.ts"; import { pingValkey, valkey } from "./valkey.ts";
@ -33,12 +34,9 @@ import {
universalServerResponseToFetch, universalServerResponseToFetch,
} from "npm:@connectrpc/connect@^1.4.0/protocol"; } from "npm:@connectrpc/connect@^1.4.0/protocol";
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0"; import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
import { computeJwkThumbprint } from "./http_signatures.ts";
import { uiApp } from "../ui/mod.ts"; import { uiApp } from "../ui/mod.ts";
import { passRoutes } from "./routes/passes_magic.ts"; import { EventJoinPage } from "../ui/components/EventJoinPage.tsx";
import { eventRoutes } from "./routes/events.ts"; import { EventSplashPage } from "../ui/components/EventSplashPage.tsx";
import { sessionRoutes } from "./routes/sessions.ts";
import { forwardAuthRoutes } from "./routes/auth_forward.ts";
type Variables = { type Variables = {
userId: string; userId: string;
@ -48,12 +46,6 @@ export const app: Hono<{ Variables: Variables }> = new Hono<
{ Variables: Variables } { Variables: Variables }
>(); >();
// Mount Sub-routers
app.route("/pass", passRoutes);
app.route("/", eventRoutes);
app.route("/", sessionRoutes);
app.route("/", forwardAuthRoutes);
// Mount UI Routes // Mount UI Routes
app.route("/", uiApp); app.route("/", uiApp);
@ -192,6 +184,399 @@ app.use("/api/admin/*", async (c, next) => {
await next(); await next();
}); });
// ---------------------------------------------------------
// Ephemeral 1-Click Magic Link Redemption (/pass)
// ---------------------------------------------------------
app.get("/pass", 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 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);
}
});
// ---------------------------------------------------------
// Multi-Claim Event Passes & Short Code Join Portal
// ---------------------------------------------------------
app.post("/api/events", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json().catch(() => ({}));
const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } =
body;
let { slug, pinCode } = body;
if (!name || typeof name !== "string") {
return c.json({ error: "Event name is required" }, 400);
}
if (!slug) {
slug =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") +
"-" + Math.random().toString(36).substring(2, 6);
}
if (!pinCode) {
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
}
try {
const expiresAt = new Date(
Date.now() + Number(lifespanHours) * 3600 * 1000,
);
const result = await sqlWrapper.sql`
INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${
Number(maxSeats)
}, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt})
RETURNING *
`;
return c.json({ success: true, event: result[0] });
} catch (e: any) {
console.error("[Events] Failed to create event pass:", e);
return c.json({ error: "Failed to create event pass" }, 500);
}
});
app.post("/api/join", async (c) => {
let code = "";
if (
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
) {
const fd = await c.req.formData();
code = (fd.get("code") as string) || "";
} else {
const body = await c.req.json().catch(() => ({}));
code = body.code || "";
}
if (!code || typeof code !== "string") {
return c.json({ error: "Event code or PIN is required" }, 400);
}
code = code.trim();
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE (slug = ${code} OR pin_code = ${code})
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
AND (max_seats = 0 OR seats_claimed < max_seats)
RETURNING *
`;
if (!result || result.length === 0) {
return c.json({
error: "Invalid event code or workshop capacity reached",
}, 404);
}
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
ON CONFLICT DO NOTHING
`;
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${event.name} Seat #${event.seats_claimed}`;
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
let appDomain = "";
if (event.app_id) {
const apps = await sqlWrapper
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
appDomain = apps[0].domain || "";
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
`;
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
deleteCookie(c, "session_id", { path: "/" });
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
if (
c.req.header("accept")?.includes("text/html") &&
!c.req.header("accept")?.includes("application/json")
) {
return c.redirect(redirectUrl, 302);
}
return c.json({
success: true,
sessionId,
token: sessionId,
guestUuid,
username,
redirectUrl,
});
} catch (e: any) {
console.error("[Events] Failed to join event:", e);
return c.json({ error: "Failed to join event" }, 500);
}
});
app.get("/join/:slug", async (c) => {
const slug = c.req.param("slug");
const format = c.req.query("format") || "html";
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE slug = ${slug}
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
AND (max_seats = 0 OR seats_claimed < max_seats)
RETURNING *
`;
if (!result || result.length === 0) {
if (format === "env" || format === "json") {
return c.text("Invalid slug or workshop capacity reached", 404);
}
return c.redirect("/join?error=not_found", 302);
}
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
ON CONFLICT DO NOTHING
`;
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${event.name} Seat #${event.seats_claimed}`;
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
if (event.app_id) {
const apps = await sqlWrapper
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
`;
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
if (format === "env") {
return c.text(
`export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`,
);
} else if (format === "json") {
return c.json({
success: true,
token: sessionId,
username,
expiresAt: expiresAt.toISOString(),
});
}
deleteCookie(c, "session_id", { path: "/" });
setCookie(c, "session_id", sessionId, {
domain: getCookieDomain(rpID),
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
return c.redirect("/dashboard", 302);
} catch (e: any) {
console.error("[Events] Failed to execute CLI join:", e);
return c.text("Internal Server Error", 500);
}
});
app.get("/join", (c) => {
return c.html(EventJoinPage());
});
app.get("/e/:slug", async (c) => {
const slug = c.req.param("slug");
try {
const result = await sqlWrapper.sql`
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
`;
if (!result || result.length === 0) {
return c.redirect("/join?error=event_not_found", 302);
}
return c.html(EventSplashPage({ event: result[0] }));
} catch (_e) {
return c.redirect("/join?error=db_error", 302);
}
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Provisioning & Registration (Use Cases 1, 2, 3) // Provisioning & Registration (Use Cases 1, 2, 3)
// --------------------------------------------------------- // ---------------------------------------------------------
@ -1136,6 +1521,150 @@ app.post("/api/admin/users/:id/profile", async (c) => {
return c.json({ success: true, user: targetUser }); return c.json({ success: true, user: targetUser });
}); });
// ---------------------------------------------------------
// Traefik ForwardAuth Edge Proxy Route (Tier 2)
// ---------------------------------------------------------
import {
getAppByHost,
getUserGrant,
isIpAllowed,
isPathBypassed,
} from "./auth-session.ts";
import {
computeJwkThumbprint,
verifyHttpSignature,
} from "./http_signatures.ts";
app.get("/api/forward-auth", async (c) => {
const host = c.req.header("X-Forwarded-Host");
if (!host) {
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
}
// 1. Resolve Target App (Valkey -> DB)
const appRecord = await getAppByHost(host);
if (!appRecord) {
const accept = c.req.header("Accept") || "";
// If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view
if (accept.includes("text/html")) {
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/errors/unregistered?host=${
encodeURIComponent(host)
}`,
302,
);
}
// Default-Deny if app is not registered (API requests)
return c.json({ error: "Application not registered" }, 403);
}
// 1.5 Dynamic Bypass Check
const uri = c.req.header("X-Forwarded-Uri") || "/";
const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1";
const requestPath = new URL(uri, `http://${host}`).pathname;
if (
appRecord.is_public === true ||
isPathBypassed(requestPath, appRecord.bypass_paths) ||
isIpAllowed(clientIp, appRecord.allowed_cidrs)
) {
// Append standard headers even on bypass for downstream context if needed
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
}
// 2. Validate Session OR HTTP Signature
const signatureInput = c.req.header("Signature-Input");
const signature = c.req.header("Signature");
if (signatureInput && signature) {
// Headless Edge Node Path (RFC 9421)
try {
const fingerprint = await verifyHttpSignature(c.req.raw);
// Look up key name from postgres if needed, but fingerprint string manipulation is fast enough
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
const serviceId = fingerprint;
const scopes = "edge-node,daemon";
c.header("X-Forwarded-User", serviceName);
c.header("X-Forwarded-User-Id", serviceId);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
} catch (err: any) {
return c.text(`Unauthorized: ${err.message}`, 401);
}
}
// Standard User Session Path
const auth = await getAuthenticatedUser(c);
if (!auth) {
const accept = c.req.header("Accept") || "";
const proto = c.req.header("X-Forwarded-Proto") || "https";
const uri = c.req.header("X-Forwarded-Uri") || "/";
const originalUrl = `${proto}://${host}${uri}`;
// If a browser is requesting a webpage, seamlessly redirect to login
if (accept.includes("text/html")) {
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/login?redirect=${
encodeURIComponent(originalUrl)
}`,
302,
);
}
return c.text("Unauthorized", 401);
}
// Cache lookup for user status can be added later; hitting DB to be safe for now,
// but let's just make sure account is active.
const user = await sqlWrapper.sql`
SELECT id, username, account_status
FROM users
WHERE id = ${auth.userId}
`.then((res: any) => res[0]);
if (!user || user.account_status !== "active") {
return c.text("Forbidden: Account inactive", 403);
}
// 3. Resolve Grants and Roles
const globalAdmin = await isGlobalAdmin(auth.userId);
const grantRole = await getUserGrant(auth.userId, appRecord.id);
if (!globalAdmin && !grantRole) {
// Enforce Default-Deny if no app-specific grants and not global admin
return c.text("Forbidden: Access denied to this application", 403);
}
// Combine scopes, ensuring no duplicates and formatting as comma-separated string
const scopes = [
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
].join(",");
// 4. Inject Headers
c.header("X-Forwarded-User", user.username);
c.header("X-Forwarded-User-Id", user.id);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Admin Application Registry // Admin Application Registry
// --------------------------------------------------------- // ---------------------------------------------------------
@ -1882,6 +2411,226 @@ app.get("/api/admin/check", async (c) => {
return c.json({ isAdmin }); return c.json({ isAdmin });
}); });
// Get user's active sessions
app.get("/api/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessions = await sqlWrapper.sql`
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
return c.json({ sessions, currentSessionId: auth.sessionId });
});
// Delegate a child agent session with custom lifespan and scopes
app.post("/api/sessions/delegate", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const {
label,
lifespanHours = 1,
mode = "read_only",
customScopes = [],
} = await c.req.json();
const cleanLabel = (label && typeof label === "string" && label.trim())
? label.trim()
: "AI Agent Session";
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
let effectiveScopes: string[] = [];
if (mode === "read_only") {
effectiveScopes = [
"read:audit",
"read:users",
"read:apps",
"read:roles",
"read:sessions",
];
} else if (mode === "operator") {
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
} else if (mode === "admin") {
effectiveScopes = ["*"];
} else if (mode === "custom" && Array.isArray(customScopes)) {
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
Boolean,
);
}
const rawBytes = new Uint8Array(32);
crypto.getRandomValues(rawBytes);
const tokenHex = Array.from(rawBytes).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
const sessionId = `ay_sess_${tokenHex}`;
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW())
`;
try {
const sessionData = {
uuid: auth.userId,
username: auth.username,
label: cleanLabel,
isAgent: true,
customScopes: effectiveScopes,
};
await valkey.set(
sessionId,
JSON.stringify(sessionData),
"EX",
Math.floor(hours * 3600),
);
} catch (err) {
console.error("[Valkey] Failed to cache delegated session:", err);
}
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
label: cleanLabel,
lifespan_hours: hours,
mode,
scopes: effectiveScopes,
}, getClientIp(c));
return c.json({
success: true,
sessionId,
token: sessionId,
label: cleanLabel,
expiresAt: expiresAt.toISOString(),
scopes: effectiveScopes,
});
});
// Update permissions on an active session
app.put("/api/sessions/:id/scopes", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { customScopes = [] } = await c.req.json();
const session = await sqlWrapper.sql`
SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const effectiveScopes = Array.isArray(customScopes)
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
: [];
await sqlWrapper.sql`
UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId}
`;
try {
const existingCached = await valkey.get(targetSessionId);
if (existingCached) {
const parsed = JSON.parse(existingCached);
parsed.customScopes = effectiveScopes;
await valkey.set(targetSessionId, JSON.stringify(parsed));
}
} catch (_e) {}
auditWrapper.auditLog(
auth.userId,
"session_scopes_updated",
targetSessionId,
{
scopes: effectiveScopes,
},
getClientIp(c),
);
return c.json({ success: true, scopes: effectiveScopes });
});
// Extend session TTL
app.post("/api/sessions/:id/extend", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { extendHours = 1 } = await c.req.json();
const additionalHours = Math.max(Number(extendHours) || 1, 1);
const session = await sqlWrapper.sql`
SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const currentExpiry = new Date(session.expires_at).getTime();
const newExpiry = new Date(
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
);
await sqlWrapper.sql`
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId}
`;
try {
const ttlSeconds = Math.max(
1,
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
);
await valkey.expire(targetSessionId, ttlSeconds);
} catch (_e) {}
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
extended_by_hours: additionalHours,
new_expires_at: newExpiry.toISOString(),
}, getClientIp(c));
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
});
// Revoke a specific session
app.delete("/api/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
// Verify the session belongs to the user
const session = await sqlWrapper.sql`
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
// Remove from Valkey
try {
await valkey.del(targetSessionId);
} catch (err) {
console.error("Failed to delete session from cache:", err);
}
// Remove from DB (or expire it immediately)
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`;
auditWrapper.auditLog(auth.userId, "session_revoked", null, {
revoked_session_id: targetSessionId,
}, getClientIp(c));
return c.json({ success: true });
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Authenticated Passkey Registration (Adding a new device) // Authenticated Passkey Registration (Adding a new device)
// --------------------------------------------------------- // ---------------------------------------------------------

View File

@ -1,143 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { sqlWrapper } from "../db.ts";
import {
getAppByHost,
getAuthenticatedUser,
getUserGrant,
isGlobalAdmin,
isIpAllowed,
isPathBypassed,
} from "../auth-session.ts";
import { verifyHttpSignature } from "../http_signatures.ts";
export const forwardAuthRoutes = new Hono();
// ---------------------------------------------------------
// ForwardAuth Ingress Check (Traefik Ingress Middleware)
// ---------------------------------------------------------
forwardAuthRoutes.get("/api/forward-auth", async (c) => {
const host = c.req.header("X-Forwarded-Host");
if (!host) {
return c.text("Bad Request: Missing X-Forwarded-Host header", 400);
}
// 1. Resolve Target App (Valkey -> DB)
const appRecord = await getAppByHost(host);
if (!appRecord) {
const accept = c.req.header("Accept") || "";
// If a browser is requesting a webpage on an unregistered domain, seamlessly redirect to unregistered error view
if (accept.includes("text/html")) {
const rpID = Deno.env.get("RP_ID");
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/errors/unregistered?host=${
encodeURIComponent(host)
}`,
302,
);
}
// Default-Deny if app is not registered (API requests)
return c.json({ error: "Application not registered" }, 403);
}
// 1.5 Dynamic Bypass Check
const uri = c.req.header("X-Forwarded-Uri") || "/";
const clientIp = c.req.header("X-Forwarded-For") || "127.0.0.1";
const requestPath = new URL(uri, `http://${host}`).pathname;
if (
appRecord.is_public === true ||
isPathBypassed(requestPath, appRecord.bypass_paths) ||
isIpAllowed(clientIp, appRecord.allowed_cidrs)
) {
// Append standard headers even on bypass for downstream context if needed
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
}
// 2. Validate Session OR HTTP Signature
const signatureInput = c.req.header("Signature-Input");
const signature = c.req.header("Signature");
if (signatureInput && signature) {
// Headless Edge Node Path (RFC 9421)
try {
const fingerprint = await verifyHttpSignature(c.req.raw);
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
const serviceId = fingerprint;
const scopes = "edge-node,daemon";
c.header("X-Forwarded-User", serviceName);
c.header("X-Forwarded-User-Id", serviceId);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
} catch (err: any) {
return c.text(`Unauthorized: ${err.message}`, 401);
}
}
// Standard User Session Path
const auth = await getAuthenticatedUser(c);
if (!auth) {
const accept = c.req.header("Accept") || "";
const proto = c.req.header("X-Forwarded-Proto") || "https";
const uri = c.req.header("X-Forwarded-Uri") || "/";
const originalUrl = `${proto}://${host}${uri}`;
// If a browser is requesting a webpage, seamlessly redirect to login
if (accept.includes("text/html")) {
const rpID = Deno.env.get("RP_ID");
const loginDomain = rpID || "auth.atyg.org";
c.header(
"Cache-Control",
"no-store, no-cache, must-revalidate, max-age=0",
);
return c.redirect(
`https://${loginDomain}/login?redirect=${
encodeURIComponent(originalUrl)
}`,
302,
);
}
return c.text("Unauthorized", 401);
}
const user = await sqlWrapper.sql`
SELECT id, username, account_status
FROM users
WHERE id = ${auth.userId}
`.then((res: any) => res[0]);
if (!user || user.account_status !== "active") {
return c.text("Forbidden: Account inactive", 403);
}
// 3. Resolve Grants and Roles
const globalAdmin = await isGlobalAdmin(auth.userId);
const grantRole = await getUserGrant(auth.userId, appRecord.id);
if (!globalAdmin && !grantRole) {
return c.text("Forbidden: Access denied to this application", 403);
}
const scopes = [
...new Set([grantRole, globalAdmin ? "admin" : null].filter(Boolean)),
].join(",");
// 4. Inject Headers
c.header("X-Forwarded-User", user.username);
c.header("X-Forwarded-User-Id", user.id);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
});

View File

@ -1,285 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { deleteCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeHex } from "jsr:@std/encoding@1/hex";
import { sqlWrapper } from "../db.ts";
import { valkey } from "../valkey.ts";
import { getAuthenticatedUser, getCookieDomain } from "../auth-session.ts";
import { EventJoinPage } from "../../ui/components/EventJoinPage.tsx";
import { EventSplashPage } from "../../ui/components/EventSplashPage.tsx";
export const eventRoutes = new Hono();
// ---------------------------------------------------------
// Multi-Claim Event Passes & Short Code Join Portal
// ---------------------------------------------------------
eventRoutes.post("/api/events", async (c) => {
const user = await getAuthenticatedUser(c);
if (!user) return c.json({ error: "Unauthorized" }, 401);
const body = await c.req.json().catch(() => ({}));
const { name, appId, role = "viewer", maxSeats = 50, lifespanHours = 3 } =
body;
let { slug, pinCode } = body;
if (!name || typeof name !== "string") {
return c.json({ error: "Event name is required" }, 400);
}
if (!slug) {
slug =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") +
"-" + Math.random().toString(36).substring(2, 6);
}
if (!pinCode) {
const randPin = Math.floor(100000 + Math.random() * 900000).toString();
pinCode = randPin.substring(0, 3) + "-" + randPin.substring(3);
}
try {
const expiresAt = new Date(
Date.now() + Number(lifespanHours) * 3600 * 1000,
);
const result = await sqlWrapper.sql`
INSERT INTO event_passes (slug, pin_code, name, app_id, role, max_seats, lifespan_hours, created_by, expires_at)
VALUES (${slug}, ${pinCode}, ${name}, ${appId || null}, ${role}, ${
Number(maxSeats)
}, ${Number(lifespanHours)}, ${user.userId}, ${expiresAt})
RETURNING *
`;
return c.json({ success: true, event: result[0] });
} catch (e: any) {
console.error("[Events] Failed to create event pass:", e);
return c.json({ error: "Failed to create event pass" }, 500);
}
});
eventRoutes.post("/api/join", async (c) => {
let code = "";
if (
c.req.header("content-type")?.includes("application/x-www-form-urlencoded")
) {
const fd = await c.req.formData();
code = (fd.get("code") as string) || "";
} else {
const body = await c.req.json().catch(() => ({}));
code = body.code || "";
}
if (!code || typeof code !== "string") {
return c.json({ error: "Event code or PIN is required" }, 400);
}
code = code.trim();
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE (slug = ${code} OR pin_code = ${code})
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
AND (max_seats = 0 OR seats_claimed < max_seats)
RETURNING *
`;
if (!result || result.length === 0) {
return c.json(
{ error: "Invalid event code or workshop capacity reached" },
404,
);
}
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
ON CONFLICT DO NOTHING
`;
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${event.name} Seat #${event.seats_claimed}`;
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
let appDomain = "";
if (event.app_id) {
const apps = await sqlWrapper
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
appDomain = apps[0].domain || "";
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
`;
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
const redirectUrl = appDomain ? `https://${appDomain}` : "/dashboard";
if (
c.req.header("accept")?.includes("text/html") &&
!c.req.header("accept")?.includes("application/json")
) {
return c.redirect(redirectUrl, 302);
}
return c.json({
success: true,
sessionId,
token: sessionId,
guestUuid,
username,
redirectUrl,
});
} catch (e: any) {
console.error("[Events] Failed to join event:", e);
return c.json({ error: "Failed to join event" }, 500);
}
});
eventRoutes.get("/join/:slug", async (c) => {
const slug = c.req.param("slug");
const format = c.req.query("format") || "html";
try {
const result = await sqlWrapper.sql`
UPDATE event_passes
SET seats_claimed = seats_claimed + 1
WHERE slug = ${slug}
AND is_active = TRUE
AND (expires_at IS NULL OR expires_at > NOW())
AND (max_seats = 0 OR seats_claimed < max_seats)
RETURNING *
`;
if (!result || result.length === 0) {
if (format === "env" || format === "json") {
return c.text("Invalid slug or workshop capacity reached", 404);
}
return c.redirect("/join?error=not_found", 302);
}
const event = result[0];
const guestUuid = crypto.randomUUID();
const username = `guest_${event.slug}_${event.seats_claimed}`;
await sqlWrapper.sql`
INSERT INTO users (id, username, display_name, account_status)
VALUES (${guestUuid}, ${username}, ${event.name + " Attendee"}, 'guest')
ON CONFLICT DO NOTHING
`;
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionId = `ay_sess_${encodeHex(randomBytes)}`;
const label = `${event.name} Seat #${event.seats_claimed}`;
const ttl = (Number(event.lifespan_hours) || 3) * 3600;
let customScopes = ["guest", "trial"];
if (event.app_id) {
const apps = await sqlWrapper
.sql`SELECT name, domain FROM apps WHERE id = ${event.app_id}`;
if (apps.length > 0) {
customScopes = [`app:${apps[0].name}`, event.role || "viewer"];
}
}
const expiresAt = new Date(Date.now() + ttl * 1000);
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at)
VALUES (${sessionId}, ${guestUuid}, ${label}, false, ${customScopes}, ${expiresAt})
`;
await valkey.setex(
sessionId,
ttl,
JSON.stringify({
uuid: guestUuid,
username,
account_status: "guest",
customScopes,
}),
);
if (format === "env") {
return c.text(
`export AUTH_YES_TOKEN="${sessionId}"\nexport AUTH_YES_USER="${username}"\n`,
);
} else if (format === "json") {
return c.json({
success: true,
token: sessionId,
username,
expiresAt: expiresAt.toISOString(),
});
}
deleteCookie(c, "session_id", { path: "/" });
const rpID = Deno.env.get("RP_ID");
setCookie(c, "session_id", sessionId, {
domain: getCookieDomain(rpID),
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: ttl,
});
return c.redirect("/dashboard", 302);
} catch (e: any) {
console.error("[Events] Failed to execute CLI join:", e);
return c.text("Internal Server Error", 500);
}
});
eventRoutes.get("/join", (c) => {
return c.html(EventJoinPage());
});
eventRoutes.get("/e/:slug", async (c) => {
const slug = c.req.param("slug");
try {
const result = await sqlWrapper.sql`
SELECT * FROM event_passes WHERE slug = ${slug} AND is_active = TRUE
`;
if (!result || result.length === 0) {
return c.redirect("/join?error=event_not_found", 302);
}
return c.html(EventSplashPage({ event: result[0] }));
} catch (_e) {
return c.redirect("/join?error=db_error", 302);
}
});

View File

@ -1,129 +0,0 @@
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);
}
});

View File

@ -1,239 +0,0 @@
import { Hono } from "jsr:@hono/hono@4";
import { sqlWrapper } from "../db.ts";
import { valkey } from "../valkey.ts";
import { auditWrapper } from "../audit.ts";
import { getAuthenticatedUser, getClientIp } from "../auth-session.ts";
export const sessionRoutes = new Hono();
// ---------------------------------------------------------
// Active Sessions Listing
// ---------------------------------------------------------
sessionRoutes.get("/api/sessions", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const sessions = await sqlWrapper.sql`
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
FROM sessions
WHERE user_id = ${auth.userId} AND expires_at > NOW()
ORDER BY created_at DESC
`;
return c.json({ sessions, currentSessionId: auth.sessionId });
});
// ---------------------------------------------------------
// Delegate a child agent session with custom lifespan and scopes
// ---------------------------------------------------------
sessionRoutes.post("/api/sessions/delegate", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const {
label,
lifespanHours = 1,
mode = "read_only",
customScopes = [],
} = await c.req.json();
const cleanLabel = (label && typeof label === "string" && label.trim())
? label.trim()
: "AI Agent Session";
const hours = Math.min(Math.max(Number(lifespanHours) || 1, 1), 720); // Max 30 days
const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
let effectiveScopes: string[] = [];
if (mode === "read_only") {
effectiveScopes = [
"read:audit",
"read:users",
"read:apps",
"read:roles",
"read:sessions",
];
} else if (mode === "operator") {
effectiveScopes = ["operator", "read:audit", "read:users", "read:apps"];
} else if (mode === "admin") {
effectiveScopes = ["*"];
} else if (mode === "custom" && Array.isArray(customScopes)) {
effectiveScopes = customScopes.map((s: string) => String(s).trim()).filter(
Boolean,
);
}
const rawBytes = new Uint8Array(32);
crypto.getRandomValues(rawBytes);
const tokenHex = Array.from(rawBytes).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
const sessionId = `ay_sess_${tokenHex}`;
await sqlWrapper.sql`
INSERT INTO sessions (id, user_id, label, is_agent, custom_scopes, expires_at, created_at)
VALUES (${sessionId}, ${auth.userId}, ${cleanLabel}, true, ${effectiveScopes}, ${expiresAt.toISOString()}, NOW())
`;
try {
const sessionData = {
uuid: auth.userId,
username: auth.username,
label: cleanLabel,
isAgent: true,
customScopes: effectiveScopes,
};
await valkey.set(
sessionId,
JSON.stringify(sessionData),
"EX",
Math.floor(hours * 3600),
);
} catch (err) {
console.error("[Valkey] Failed to cache delegated session:", err);
}
auditWrapper.auditLog(auth.userId, "session_delegated", sessionId, {
label: cleanLabel,
lifespan_hours: hours,
mode,
scopes: effectiveScopes,
}, getClientIp(c));
return c.json({
success: true,
sessionId,
token: sessionId,
label: cleanLabel,
expiresAt: expiresAt.toISOString(),
scopes: effectiveScopes,
});
});
// ---------------------------------------------------------
// Update permissions on an active session
// ---------------------------------------------------------
sessionRoutes.put("/api/sessions/:id/scopes", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { customScopes = [] } = await c.req.json();
const session = await sqlWrapper.sql`
SELECT id, is_agent FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const effectiveScopes = Array.isArray(customScopes)
? customScopes.map((s: string) => String(s).trim()).filter(Boolean)
: [];
await sqlWrapper.sql`
UPDATE sessions SET custom_scopes = ${effectiveScopes} WHERE id = ${targetSessionId}
`;
try {
const existingCached = await valkey.get(targetSessionId);
if (existingCached) {
const parsed = JSON.parse(existingCached);
parsed.customScopes = effectiveScopes;
await valkey.set(targetSessionId, JSON.stringify(parsed));
}
} catch (_e) {}
auditWrapper.auditLog(
auth.userId,
"session_scopes_updated",
targetSessionId,
{
scopes: effectiveScopes,
},
getClientIp(c),
);
return c.json({ success: true, scopes: effectiveScopes });
});
// ---------------------------------------------------------
// Extend session TTL
// ---------------------------------------------------------
sessionRoutes.post("/api/sessions/:id/extend", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const { extendHours = 1 } = await c.req.json();
const additionalHours = Math.max(Number(extendHours) || 1, 1);
const session = await sqlWrapper.sql`
SELECT id, expires_at FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
const currentExpiry = new Date(session.expires_at).getTime();
const newExpiry = new Date(
Math.max(Date.now(), currentExpiry) + additionalHours * 3600 * 1000,
);
await sqlWrapper.sql`
UPDATE sessions SET expires_at = ${newExpiry.toISOString()} WHERE id = ${targetSessionId}
`;
try {
const ttlSeconds = Math.max(
1,
Math.floor((newExpiry.getTime() - Date.now()) / 1000),
);
await valkey.expire(targetSessionId, ttlSeconds);
} catch (_e) {}
auditWrapper.auditLog(auth.userId, "session_extended", targetSessionId, {
extended_by_hours: additionalHours,
new_expires_at: newExpiry.toISOString(),
}, getClientIp(c));
return c.json({ success: true, newExpiresAt: newExpiry.toISOString() });
});
// ---------------------------------------------------------
// Revoke a specific session
// ---------------------------------------------------------
sessionRoutes.delete("/api/sessions/:id", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
const targetSessionId = c.req.param("id");
const session = await sqlWrapper.sql`
SELECT id FROM sessions WHERE id = ${targetSessionId} AND user_id = ${auth.userId}
`.then((res: any) => res[0]);
if (!session) {
return c.json({ error: "Session not found or access denied" }, 404);
}
try {
await valkey.del(targetSessionId);
} catch (err) {
console.error("Failed to delete session from cache:", err);
}
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${targetSessionId}`;
auditWrapper.auditLog(auth.userId, "session_revoked", null, {
revoked_session_id: targetSessionId,
}, getClientIp(c));
return c.json({ success: true });
});

View File

@ -284,22 +284,22 @@ export const SessionsPage = ({
</p> </p>
<div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;"> <div style="display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem;">
{/* 1-Click Magic Link */} {/* 1-Tap Copy 1-Click Magic Link */}
<div> <div>
<label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;"> <label style="display: block; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 0.25rem;">
1-Click Magic Link (Web / Friend / Interview) 1-Click Magic Link (Web / Friend / Interviewer)
</label> </label>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<code <code
id="handoffMagicLinkText" id="handoffLinkText"
style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--success);" style="flex: 1; padding: 0.5rem 0.75rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); font-family: monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; color: var(--primary);"
> >
</code> </code>
<button <button
type="button" type="button"
class="btn-primary" class="btn-primary"
style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;" style="min-height: 36px; padding: 0 0.85rem; font-size: 0.8rem;"
onclick="copyHandoff('magic')" onclick="copyHandoff('link')"
> >
Copy Link Copy Link
</button> </button>
@ -793,7 +793,8 @@ export const SessionsPage = ({
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
lastMintedToken = data.token; lastMintedToken = data.token;
document.getElementById('handoffMagicLinkText').textContent = window.location.origin + '/pass?token=' + data.token; const origin = window.location.origin;
document.getElementById('handoffLinkText').textContent = origin + '/pass?token=' + data.token;
document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"'; document.getElementById('handoffCliText').textContent = 'export AUTH_YES_TOKEN="' + data.token + '"';
document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"'; document.getElementById('handoffCurlText').textContent = '-H "Authorization: Bearer ' + data.token + '"';
document.getElementById('handoffModal').style.display = 'block'; document.getElementById('handoffModal').style.display = 'block';
@ -814,7 +815,6 @@ export const SessionsPage = ({
if (type === 'link') text = document.getElementById('handoffLinkText').textContent; if (type === 'link') text = document.getElementById('handoffLinkText').textContent;
if (type === 'cli') text = document.getElementById('handoffCliText').textContent; if (type === 'cli') text = document.getElementById('handoffCliText').textContent;
if (type === 'curl') text = document.getElementById('handoffCurlText').textContent; if (type === 'curl') text = document.getElementById('handoffCurlText').textContent;
if (type === 'magic') text = document.getElementById('handoffMagicLinkText').textContent;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
showNotice('Copied to clipboard: ' + text, false); showNotice('Copied to clipboard: ' + text, false);
} }