- Implement iterative session cookie candidate resolution in getAuthenticatedUser - Eliminate Hono first-match limitation causing mobile login redirect loops - Use absolute UTC ISO strings for PostgreSQL session expiry queries - Opportunistically clear host-level cookies upon shadow detection - Ensure exhaustive server-side session revocation across all cookie candidates on logout - Add automated regression test for cookie shadowing in server/main.test.ts - Rename and standardize tasks/path.md with 5-template orchestrator standard
2256 lines
67 KiB
TypeScript
2256 lines
67 KiB
TypeScript
import { recoveryApp } from "./recovery.ts";
|
|
import { Hono } from "jsr:@hono/hono@4";
|
|
import type { Context } from "jsr:@hono/hono@4";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse,
|
|
} from "jsr:@simplewebauthn/server@13";
|
|
import type {
|
|
AuthenticationResponseJSON,
|
|
RegistrationResponseJSON,
|
|
} from "jsr:@simplewebauthn/server@13";
|
|
import { deleteCookie, getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
|
import { extractAllSessionIds } from "./auth-session.ts";
|
|
import {
|
|
decodeBase64Url,
|
|
encodeBase64Url,
|
|
} from "jsr:@std/encoding@1/base64url";
|
|
import { MetadataService } from "jsr:@simplewebauthn/server@13";
|
|
import { initDb, sqlWrapper } from "./db.ts";
|
|
import { pingValkey, valkey } from "./valkey.ts";
|
|
import { rateLimitWrapper } from "./ratelimit.ts";
|
|
import { auditWrapper } from "./audit.ts";
|
|
import { spireWrapper } from "./spire_ffi.ts";
|
|
import { AuthService } from "../sdk/gen/auth_connect.ts";
|
|
import {
|
|
universalServerRequestFromFetch,
|
|
universalServerResponseToFetch,
|
|
} from "npm:@connectrpc/connect@^1.4.0/protocol";
|
|
import type { ConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
|
import { uiApp } from "../ui/mod.ts";
|
|
|
|
type Variables = {
|
|
userId: string;
|
|
};
|
|
|
|
export const app: Hono<{ Variables: Variables }> = new Hono<
|
|
{ Variables: Variables }
|
|
>();
|
|
|
|
// Mount UI Routes
|
|
app.route("/", uiApp);
|
|
|
|
const rpName = "Auth-Yes Identity Provider";
|
|
const rpID = Deno.env.get("RP_ID") ||
|
|
(import.meta.main ? undefined : "localhost");
|
|
const origin = Deno.env.get("ORIGIN") ||
|
|
(import.meta.main ? undefined : "http://localhost");
|
|
const requireHardwareToken = Deno.env.get("REQUIRE_HARDWARE_TOKEN") === "true";
|
|
|
|
export function getCookieDomain(customRpId?: string): string | undefined {
|
|
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
|
if (envDomain) {
|
|
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
|
}
|
|
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
|
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
|
return undefined;
|
|
}
|
|
const parts = targetId.split(".").filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return `.${parts.slice(-2).join(".")}`;
|
|
}
|
|
return `.${targetId}`;
|
|
}
|
|
|
|
if (!rpID || !origin) {
|
|
throw new Error(
|
|
"Missing critical environment variables: RP_ID and ORIGIN must be set.",
|
|
);
|
|
}
|
|
|
|
// Auth API Gateway for Zero-Trust Identity Provider
|
|
// Exposes REST and gRPC endpoints exclusively to the internal Docker network.
|
|
|
|
if (import.meta.main) {
|
|
console.log("[Auth API] Initializing FIDO MDS3 Metadata Blob...");
|
|
try {
|
|
// SIDE EFFECT: Fetch MDS3 metadata dynamically on startup
|
|
await MetadataService.initialize();
|
|
console.log("[Auth API] FIDO MDS3 Metadata Blob successfully loaded.");
|
|
} catch (error) {
|
|
console.error(
|
|
"[Auth API] Fatal Error: Failed to initialize FIDO MDS3 Metadata Blob:",
|
|
);
|
|
console.error(error);
|
|
Deno.exit(1);
|
|
}
|
|
|
|
try {
|
|
// SIDE EFFECT: Initialize database schema on startup
|
|
await initDb();
|
|
} catch (error) {
|
|
console.error(
|
|
"[Auth API] Fatal Error: Failed to initialize Database Schema:",
|
|
error,
|
|
);
|
|
console.error(error);
|
|
Deno.exit(1);
|
|
}
|
|
}
|
|
|
|
function generateSessionId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
// ---------------------------------------------------------
|
|
// Middleware
|
|
// ---------------------------------------------------------
|
|
app.use("*", async (_c, next) => {
|
|
// TODO: Add mTLS verification or internal network constraints
|
|
await next();
|
|
});
|
|
|
|
// Middleware helper to get IP address
|
|
function getClientIp(c: Context): string {
|
|
// Check for X-Real-IP first
|
|
const realIp = c.req.header("x-real-ip");
|
|
if (realIp) {
|
|
return realIp.trim();
|
|
}
|
|
|
|
// Fallback to X-Forwarded-For, bounded to prevent memory exhaustion
|
|
let forwardedFor = c.req.header("x-forwarded-for");
|
|
if (forwardedFor) {
|
|
// Truncate to a max of 256 characters
|
|
if (forwardedFor.length > 256) {
|
|
forwardedFor = forwardedFor.substring(0, 256);
|
|
}
|
|
const parts = forwardedFor.split(",");
|
|
// Extract the last untrusted hop (right-most IP)
|
|
return parts[parts.length - 1].trim();
|
|
}
|
|
// Fallback (might not be accurate behind proxy without X-Forwarded-For)
|
|
return "unknown-ip";
|
|
}
|
|
|
|
// Rate Limiting Middlewares
|
|
|
|
// Public Endpoints (/api/login/*, /api/register/*): 10 requests per minute by IP
|
|
app.use("/api/login/*", async (c, next) => {
|
|
const ip = getClientIp(c);
|
|
const key = `ratelimit:public:${ip}`;
|
|
const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000);
|
|
if (!allowed) {
|
|
return c.json({ error: "Too Many Requests" }, 429);
|
|
}
|
|
await next();
|
|
});
|
|
|
|
app.use("/api/register/*", async (c, next) => {
|
|
const ip = getClientIp(c);
|
|
const key = `ratelimit:public:${ip}`;
|
|
const allowed = await rateLimitWrapper.checkRateLimit(key, 10, 60000);
|
|
if (!allowed) {
|
|
return c.json({ error: "Too Many Requests" }, 429);
|
|
}
|
|
await next();
|
|
});
|
|
|
|
// Admin Endpoints (/api/admin/*): 60 requests per minute by user_id
|
|
app.use("/api/admin/*", async (c, next) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) {
|
|
return c.json({ error: "Missing or invalid session" }, 401);
|
|
}
|
|
|
|
const key = `ratelimit:admin:${auth.userId}`;
|
|
const allowed = await rateLimitWrapper.checkRateLimit(key, 60, 60000);
|
|
if (!allowed) {
|
|
return c.json({ error: "Too Many Requests" }, 429);
|
|
}
|
|
|
|
// Stash userId in context so downstream routes can use it
|
|
c.set("userId", auth.userId);
|
|
await next();
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Provisioning & Registration (Use Cases 1, 2, 3)
|
|
// ---------------------------------------------------------
|
|
|
|
app.post("/api/admin/invites/create", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
|
}
|
|
|
|
const {
|
|
appId,
|
|
role,
|
|
expiresInDays,
|
|
customCode,
|
|
usageLimitType,
|
|
maxUses,
|
|
autoActivate,
|
|
} = await c.req.json();
|
|
const assignedRole = (typeof role === "string" && role.trim())
|
|
? role.trim()
|
|
: "user";
|
|
|
|
let validatedAppId = null;
|
|
if (appId) {
|
|
const uuidRegex =
|
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
if (typeof appId !== "string" || !uuidRegex.test(appId)) {
|
|
return c.json({ error: "appId must be a valid UUID string" }, 400);
|
|
}
|
|
const appExists = await sqlWrapper
|
|
.sql`SELECT id FROM apps WHERE id = ${appId}`.then(
|
|
(res: any) => res[0],
|
|
);
|
|
if (!appExists) {
|
|
return c.json({ error: "Target application does not exist" }, 404);
|
|
}
|
|
validatedAppId = appId;
|
|
}
|
|
|
|
let parsedMaxUses: number | null = 1;
|
|
if (usageLimitType === "unlimited") {
|
|
parsedMaxUses = null;
|
|
} else if (usageLimitType === "limited") {
|
|
const n = parseInt(maxUses);
|
|
parsedMaxUses = (!isNaN(n) && n > 0) ? n : 5;
|
|
} else {
|
|
parsedMaxUses = 1; // single-use default
|
|
}
|
|
|
|
const shouldAutoActivate = autoActivate !== false;
|
|
|
|
const inviteCode =
|
|
(customCode && typeof customCode === "string" && customCode.trim())
|
|
? customCode.trim()
|
|
: encodeBase64Url(crypto.getRandomValues(new Uint8Array(24)));
|
|
|
|
const days = Number(expiresInDays) || 7;
|
|
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
|
return c.json({
|
|
error: "expiresInDays must be an integer between 1 and 30",
|
|
}, 400);
|
|
}
|
|
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + days);
|
|
|
|
try {
|
|
await sqlWrapper.sql`
|
|
INSERT INTO invites (code, app_id, role, created_by, max_uses, uses_count, auto_activate, expires_at)
|
|
VALUES (${inviteCode}, ${validatedAppId}, ${assignedRole}, ${auth.userId}, ${parsedMaxUses}, 0, ${shouldAutoActivate}, ${expiresAt})
|
|
`;
|
|
} catch (err: any) {
|
|
if (err.code === "23505") {
|
|
return c.json({ error: "An invite with this code already exists" }, 409);
|
|
}
|
|
return c.json({ error: "Failed to generate invite" }, 500);
|
|
}
|
|
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"invite_created",
|
|
validatedAppId,
|
|
{
|
|
code: inviteCode,
|
|
role: assignedRole,
|
|
max_uses: parsedMaxUses,
|
|
auto_activate: shouldAutoActivate,
|
|
expiresInDays: days,
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
|
|
return c.json({
|
|
success: true,
|
|
inviteCode,
|
|
expiresAt,
|
|
maxUses: parsedMaxUses,
|
|
autoActivate: shouldAutoActivate,
|
|
});
|
|
});
|
|
|
|
app.get("/.well-known/webauthn", (c) => {
|
|
if (!origin) {
|
|
return c.json({ origins: [] });
|
|
}
|
|
return c.json({ origins: [origin] });
|
|
});
|
|
|
|
// Start a WebAuthn registration ceremony
|
|
app.post("/api/register/challenge", async (c) => {
|
|
const { username, inviteCode } = await c.req.json();
|
|
|
|
if (!username || !inviteCode) {
|
|
return c.json({ error: "Username and inviteCode required" }, 400);
|
|
}
|
|
|
|
// Validate invite code early (checks expiration and max_uses bounds)
|
|
const invite = await sqlWrapper
|
|
.sql`SELECT id, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
|
.then((res: any) => res[0]);
|
|
if (!invite) {
|
|
return c.json(
|
|
{ error: "Invalid, expired, or fully claimed invite code" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
// Prevent hijacking an existing user's account if they already exist
|
|
const existingUser = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
|
res[0]
|
|
);
|
|
if (existingUser) {
|
|
return c.json({ error: "Username already exists" }, 409);
|
|
}
|
|
|
|
const newUserId = crypto.randomUUID();
|
|
const userIdBytes = new TextEncoder().encode(newUserId);
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID,
|
|
userName: username,
|
|
userID: userIdBytes,
|
|
attestationType: "direct",
|
|
authenticatorSelection: {
|
|
residentKey: "required",
|
|
requireResidentKey: true,
|
|
userVerification: "preferred",
|
|
},
|
|
timeout: 60000,
|
|
extensions: {
|
|
["prf" as string]: {},
|
|
} as any,
|
|
});
|
|
|
|
setCookie(c, "expected_registration_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
setCookie(c, "registration_user_id", newUserId, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options, username });
|
|
});
|
|
|
|
// Generate Ephemeral Guest Sandbox
|
|
app.post("/api/guests/sandbox", async (c) => {
|
|
const guestUuid = crypto.randomUUID();
|
|
const sessionId = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
const username = `guest-${guestUuid.substring(0, 8)}`;
|
|
|
|
await valkey.setex(
|
|
sessionId,
|
|
7200, // 2-hour TTL
|
|
JSON.stringify({ uuid: guestUuid, username, account_status: "guest" }),
|
|
);
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
setCookie(c, "session_id", sessionId, {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 7200,
|
|
});
|
|
|
|
return c.json({ success: true, sessionId, guestUuid });
|
|
});
|
|
|
|
// Verify registration and create UUID/session
|
|
app.post("/api/register/verify", async (c) => {
|
|
try {
|
|
const { response, username, inviteCode, upgrade_session } = await c.req
|
|
.json();
|
|
|
|
if (!inviteCode && !upgrade_session) {
|
|
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
|
|
}
|
|
|
|
const expectedChallenge = getCookie(c, "expected_registration_challenge");
|
|
const registrationUserId = getCookie(c, "registration_user_id");
|
|
if (!expectedChallenge || !registrationUserId) {
|
|
return c.json({
|
|
error: "Missing or expired registration challenge/user ID",
|
|
}, 400);
|
|
}
|
|
|
|
// Prevent race condition account hijacking
|
|
let user = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`
|
|
.then(
|
|
(res: any) => res[0],
|
|
);
|
|
if (user) {
|
|
return c.json({ error: "Username already exists" }, 409);
|
|
}
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyRegistrationResponse({
|
|
response: response as RegistrationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false,
|
|
});
|
|
} catch (error: any) {
|
|
return c.json({ error: error.message }, 400);
|
|
}
|
|
|
|
const { verified, registrationInfo } = verification;
|
|
if (!verified || !registrationInfo) {
|
|
return c.json({ error: "Verification failed" }, 400);
|
|
}
|
|
|
|
// Enterprise Allow-List Verification
|
|
const allowlistCount = await sqlWrapper
|
|
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
|
Number(res[0].count)
|
|
);
|
|
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
|
const isAllowed = await sqlWrapper
|
|
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
|
.then((res: any) => res[0]);
|
|
if (!isAllowed) {
|
|
auditWrapper.auditLog(null, "failed_attestation_allowlist", null, {
|
|
aaguid: registrationInfo.aaguid,
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
|
}, 403);
|
|
}
|
|
}
|
|
|
|
// Optional Strict Hardware Attestation (e.g. YubiKey-only)
|
|
if (requireHardwareToken) {
|
|
if (
|
|
!registrationInfo.aaguid ||
|
|
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
|
) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
reason: "No AAGUID provided",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: No AAGUID provided. Only certified hardware security keys are permitted.",
|
|
}, 403);
|
|
}
|
|
|
|
let mdsStatement;
|
|
try {
|
|
mdsStatement = await MetadataService.getStatement(
|
|
registrationInfo.aaguid,
|
|
);
|
|
} catch (mdsError) {
|
|
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
|
}
|
|
|
|
if (!mdsStatement) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "AAGUID not found in MDS3",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob. Only certified hardware security keys are permitted.`,
|
|
}, 403);
|
|
}
|
|
|
|
// @ts-ignore: TypeScript definition might be out of date for FIDO MDS3 (1)
|
|
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
|
auditWrapper.auditLog(null, "registration_failed_attestation", null, {
|
|
username,
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "Software passkey detected",
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: Authenticator is flagged as a software-based passkey. Only certified hardware security keys are permitted.",
|
|
}, 403);
|
|
}
|
|
}
|
|
const credentialID = registrationInfo.credential.id;
|
|
const credentialPublicKey = registrationInfo.credential.publicKey;
|
|
const counter = registrationInfo.credential.counter;
|
|
|
|
const base64CredentialID = typeof credentialID === "string"
|
|
? credentialID
|
|
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
|
const base64PublicKey = encodeBase64Url(
|
|
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
|
);
|
|
|
|
const prfEnabled =
|
|
(response.clientExtensionResults as any)?.prf?.enabled === true;
|
|
let prfSalt = null;
|
|
if (prfEnabled) {
|
|
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
prfSalt = encodeBase64Url(saltBytes);
|
|
}
|
|
|
|
if (upgrade_session) {
|
|
// Ephemeral Guest Sandbox in-flight promotion
|
|
const sessionDataStr = await valkey.get(upgrade_session);
|
|
if (!sessionDataStr) {
|
|
return c.json({ error: "Invalid or expired guest session" }, 400);
|
|
}
|
|
const sessionData = JSON.parse(sessionDataStr);
|
|
if (
|
|
!sessionData || !sessionData.uuid ||
|
|
sessionData.account_status !== "guest"
|
|
) {
|
|
return c.json({ error: "Invalid guest session state" }, 400);
|
|
}
|
|
|
|
const guestUuid = sessionData.uuid;
|
|
|
|
const insertRes = await sqlWrapper
|
|
.sql`INSERT INTO users (id, username, account_status) VALUES (${guestUuid}, ${username}, 'active') RETURNING id`;
|
|
user = insertRes[0];
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
|
`;
|
|
|
|
// Promote Valkey session
|
|
await valkey.setex(
|
|
upgrade_session,
|
|
28800, // Upgrade TTL to 8 hours
|
|
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
|
|
);
|
|
|
|
// Register session in PostgreSQL
|
|
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
|
|
await sqlWrapper.sql`
|
|
INSERT INTO sessions (id, user_id, expires_at)
|
|
VALUES (${upgrade_session}, ${user.id}, ${expiresAt})
|
|
`;
|
|
} else {
|
|
// Standard Registration Flow
|
|
const invite = await sqlWrapper
|
|
.sql`SELECT id, app_id, role, max_uses, uses_count, auto_activate FROM invites WHERE code = ${inviteCode} AND (max_uses IS NULL OR uses_count < max_uses) AND expires_at > NOW()`
|
|
.then((res: any) => res[0]);
|
|
if (!invite) {
|
|
return c.json(
|
|
{ error: "Invalid, expired, or fully claimed invite code" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const initialStatus = invite.auto_activate === false
|
|
? "pending"
|
|
: "active";
|
|
const insertRes = await sqlWrapper
|
|
.sql`INSERT INTO users (id, username, account_status) VALUES (${registrationUserId}, ${username}, ${initialStatus}) RETURNING id`;
|
|
user = insertRes[0];
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter, prf_enabled, prf_salt)
|
|
VALUES (${user.id}, ${base64CredentialID}, ${base64PublicKey}, ${counter}, ${prfEnabled}, ${prfSalt})
|
|
`;
|
|
|
|
await sqlWrapper.sql`
|
|
UPDATE invites
|
|
SET uses_count = uses_count + 1,
|
|
used_at = NOW(),
|
|
used_by = ${user.id}
|
|
WHERE id = ${invite.id}
|
|
`;
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO invite_redemptions (invite_id, user_id)
|
|
VALUES (${invite.id}, ${user.id})
|
|
`;
|
|
|
|
if (invite.app_id) {
|
|
await sqlWrapper.sql`
|
|
INSERT INTO grants (user_id, app_id, role)
|
|
VALUES (${user.id}, ${invite.app_id}, ${invite.role})
|
|
`;
|
|
} else if (invite.role === "admin") {
|
|
const adminApp = await sqlWrapper
|
|
.sql`SELECT id FROM apps WHERE name = 'Auth-Yes Management Console'`
|
|
.then((res: any) => res[0]);
|
|
if (adminApp) {
|
|
await sqlWrapper.sql`
|
|
INSERT INTO grants (user_id, app_id, role)
|
|
VALUES (${user.id}, ${adminApp.id}, 'admin')
|
|
ON CONFLICT (user_id, app_id) DO UPDATE SET role = 'admin'
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
auditWrapper.auditLog(
|
|
user.id,
|
|
"user_registered",
|
|
null,
|
|
{ username, inviteCode },
|
|
getClientIp(c),
|
|
);
|
|
|
|
// Set response cookie to clear out the challenge
|
|
setCookie(c, "expected_registration_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
setCookie(c, "registration_user_id", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
if (cookieDomain) {
|
|
deleteCookie(c, "session_id", { domain: cookieDomain, path: "/" });
|
|
}
|
|
deleteCookie(c, "session_id", { path: "/" });
|
|
|
|
return c.json({ success: true });
|
|
} catch (error: any) {
|
|
console.error(
|
|
"[Auth API] Uncaught Exception in /api/register/verify:",
|
|
error,
|
|
);
|
|
return c.json({ error: error.message || "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Authentication (Login)
|
|
// ---------------------------------------------------------
|
|
|
|
// Start a WebAuthn authentication ceremony
|
|
app.post("/api/login/challenge", async (c) => {
|
|
let body;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch (_err) {
|
|
body = {};
|
|
}
|
|
const username = body.username;
|
|
let extensions: any = undefined;
|
|
let allowCredentials: any[] | undefined = undefined;
|
|
|
|
if (username) {
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
|
res[0]
|
|
);
|
|
if (user) {
|
|
const passkeys = await sqlWrapper
|
|
.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`;
|
|
|
|
if (passkeys.length > 0) {
|
|
allowCredentials = passkeys.map((pk: any) => ({
|
|
id: pk.credential_id,
|
|
type: "public-key",
|
|
}));
|
|
|
|
const prfPasskeys = passkeys.filter((pk: any) =>
|
|
pk.prf_enabled && pk.prf_salt
|
|
);
|
|
if (prfPasskeys.length > 0) {
|
|
extensions = {
|
|
["prf" as string]: { evalByCredential: {} },
|
|
};
|
|
for (const pk of prfPasskeys) {
|
|
const saltBytes = decodeBase64Url(pk.prf_salt);
|
|
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
|
first: saltBytes,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID,
|
|
userVerification: "preferred",
|
|
timeout: 60000,
|
|
allowCredentials,
|
|
extensions,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options });
|
|
});
|
|
|
|
// Verify login and issue session
|
|
app.post("/api/login/verify", async (c) => {
|
|
const { response } = await c.req.json();
|
|
|
|
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
|
|
if (!expectedChallenge) {
|
|
return c.json(
|
|
{ error: "Missing or expired authentication challenge" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const base64CredentialID = response.id;
|
|
|
|
const passkey = await sqlWrapper
|
|
.sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}`
|
|
.then((res: any) => res[0]);
|
|
if (!passkey) {
|
|
return c.json({
|
|
error: "Passkey not found. Please register your passkey first.",
|
|
}, 404);
|
|
}
|
|
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) {
|
|
return c.json({ error: "User not found" }, 404);
|
|
}
|
|
|
|
const userId = user.id;
|
|
|
|
if (user.account_status !== "active") {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: `Account status is ${user.account_status}` },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error: "Account is not active. Please contact an administrator.",
|
|
}, 403);
|
|
}
|
|
|
|
const publicKeyBytes = decodeBase64Url(passkey.public_key);
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyAuthenticationResponse({
|
|
response: response as AuthenticationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false,
|
|
credential: {
|
|
id: passkey.credential_id,
|
|
publicKey: publicKeyBytes,
|
|
counter: Number(passkey.counter),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
return c.json({ error: error.message }, 400);
|
|
}
|
|
|
|
const { verified, authenticationInfo } = verification;
|
|
if (!verified || !authenticationInfo) {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "verification failed" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Verification failed" }, 400);
|
|
}
|
|
|
|
await sqlWrapper
|
|
.sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`;
|
|
|
|
const sessionId = generateSessionId();
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
// Persistence in PostgreSQL
|
|
await sqlWrapper
|
|
.sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`;
|
|
|
|
// Write session to Valkey with TTL matching expiresAt
|
|
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
|
try {
|
|
const sessionData = JSON.stringify({
|
|
uuid: user.id,
|
|
username: user.username,
|
|
});
|
|
await valkey.setex(sessionId, ttlSeconds, sessionData);
|
|
} catch (_err: unknown) {
|
|
// If Valkey fails, log and fail closed for security
|
|
auditWrapper.auditLog(
|
|
user.id,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "Cache write failure" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Internal server error" }, 500);
|
|
}
|
|
|
|
const oldSessionIds = extractAllSessionIds(c);
|
|
if (oldSessionIds.length > 0) {
|
|
for (const old of oldSessionIds) {
|
|
try {
|
|
await valkey.del(old);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
|
|
} catch (_e) {}
|
|
}
|
|
}
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
setCookie(c, "session_id", sessionId, {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
expires: expiresAt,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// gRPC Services (Zero-Trust mTLS internal communication)
|
|
// ---------------------------------------------------------
|
|
|
|
const connectRoutes = (router: ConnectRouter) => {
|
|
router.service(AuthService, {
|
|
async validateSession(req, context) {
|
|
try {
|
|
const spiffeId = spireWrapper.extractSpiffeIdFromCert(
|
|
context.requestHeader.get("x-peer-cert") || "",
|
|
);
|
|
|
|
if (!spiffeId) {
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Validation failed", // Sanitized
|
|
};
|
|
}
|
|
|
|
// Check if the SPIFFE ID is a recognized application
|
|
const appRecord = await sqlWrapper
|
|
.sql`SELECT id FROM apps WHERE spiffe_id = ${spiffeId}`.then((
|
|
res: any,
|
|
) => res[0]);
|
|
|
|
if (!appRecord) {
|
|
auditWrapper.auditLog(null, "session_validation_failed", null, {
|
|
reason: "Unauthorized SPIFFE ID",
|
|
}, "internal-grpc");
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Validation failed", // Sanitized
|
|
};
|
|
}
|
|
|
|
const token = req.token;
|
|
if (!token) {
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Validation failed",
|
|
}; // Sanitized
|
|
}
|
|
|
|
let sessionDataStr;
|
|
try {
|
|
sessionDataStr = await valkey.get(token);
|
|
} catch (err: unknown) {
|
|
console.error("Valkey error during validateSession:", err);
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Internal server error",
|
|
};
|
|
}
|
|
|
|
if (!sessionDataStr) {
|
|
auditWrapper.auditLog(
|
|
null,
|
|
"session_validation_failed",
|
|
appRecord.id,
|
|
{
|
|
reason: "Session invalid or expired",
|
|
},
|
|
"internal-grpc",
|
|
);
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Validation failed", // Sanitized
|
|
};
|
|
}
|
|
|
|
let sessionData;
|
|
try {
|
|
sessionData = JSON.parse(sessionDataStr);
|
|
} catch (err: unknown) {
|
|
console.error("JSON parse error during validateSession:", err);
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Internal server error", // Sanitized
|
|
};
|
|
}
|
|
|
|
if (!sessionData || !sessionData.uuid) {
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Internal server error", // Sanitized
|
|
};
|
|
}
|
|
|
|
const userId = sessionData.uuid;
|
|
|
|
// Check RBAC grant for the user and app
|
|
const grantRecord = await sqlWrapper
|
|
.sql`SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appRecord.id}`
|
|
.then((res: any) => res[0]);
|
|
|
|
if (!grantRecord) {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"session_validation_failed",
|
|
appRecord.id,
|
|
{
|
|
reason: "Access denied (RBAC)",
|
|
},
|
|
"internal-grpc",
|
|
);
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Validation failed",
|
|
}; // Sanitized
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
uuid: userId,
|
|
scopes: [grantRecord.role],
|
|
error: "",
|
|
};
|
|
} catch (err: unknown) {
|
|
console.error("Unexpected error in validateSession:", err);
|
|
return {
|
|
valid: false,
|
|
uuid: "",
|
|
scopes: [],
|
|
error: "Internal server error",
|
|
};
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
// Deno/Hono adapter using Universal Handlers
|
|
import { createConnectRouter } from "npm:@connectrpc/connect@^1.4.0";
|
|
const router = createConnectRouter();
|
|
connectRoutes(router);
|
|
|
|
// `createUniversalHandler` in ConnectRPC is actually exposed via `createConnectRouter().handlers`
|
|
// which returns an array of `UniversalHandler`. We can map those to standard web fetch.
|
|
const handlers = router.handlers;
|
|
|
|
app.all("/auth.v1.AuthService/*", async (c) => {
|
|
const url = new URL(c.req.url);
|
|
const handler = handlers.find((h) => h.requestPath === url.pathname);
|
|
if (!handler) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const uReq = universalServerRequestFromFetch(c.req.raw, {});
|
|
const uRes = await handler(uReq);
|
|
return universalServerResponseToFetch(uRes);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Session & Credential Management (Authenticated APIs)
|
|
// ---------------------------------------------------------
|
|
|
|
import { getAuthenticatedUser, isGlobalAdmin } from "./auth-session.ts";
|
|
|
|
// ---------------------------------------------------------
|
|
// Global Admin APIs (For the Management Console)
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/audit-logs", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
|
}
|
|
|
|
const logs = await sqlWrapper.sql`
|
|
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
|
FROM audit_records a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
ORDER BY a.created_at DESC
|
|
LIMIT 100
|
|
`;
|
|
|
|
return c.json({ logs });
|
|
});
|
|
|
|
app.get("/api/admin/users", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
|
}
|
|
|
|
const users = await sqlWrapper.sql`
|
|
SELECT id, username, display_name, account_status
|
|
FROM users
|
|
ORDER BY username ASC
|
|
`;
|
|
|
|
return c.json({ users });
|
|
});
|
|
|
|
app.post("/api/admin/users/:id/status", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden: Global admin access required" }, 403);
|
|
}
|
|
|
|
const targetUserId = c.req.param("id");
|
|
const { status } = await c.req.json();
|
|
|
|
if (!["active", "pending", "suspended"].includes(status)) {
|
|
return c.json({ error: "Invalid status" }, 400);
|
|
}
|
|
|
|
const targetUser = await sqlWrapper
|
|
.sql`UPDATE users SET account_status = ${status} WHERE id = ${targetUserId} RETURNING id`
|
|
.then((res: any) => res[0]);
|
|
|
|
if (!targetUser) {
|
|
return c.json({ error: "User not found" }, 404);
|
|
}
|
|
|
|
auditWrapper.auditLog(auth.userId, "user_status_changed", targetUserId, {
|
|
newStatus: status,
|
|
}, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/apps", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const apps = await sqlWrapper.sql`
|
|
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
|
COUNT(g.id) AS active_grants_count
|
|
FROM apps a
|
|
LEFT JOIN grants g ON a.id = g.app_id
|
|
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
|
ORDER BY a.created_at ASC
|
|
`;
|
|
return c.json({ apps });
|
|
});
|
|
|
|
app.post("/api/admin/apps", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const {
|
|
name,
|
|
spiffeId,
|
|
description,
|
|
domain,
|
|
is_public,
|
|
bypass_paths,
|
|
allowed_cidrs,
|
|
} = await c.req.json();
|
|
if (!name || !spiffeId) {
|
|
return c.json({ error: "Name and SPIFFE ID are required" }, 400);
|
|
}
|
|
|
|
try {
|
|
const newApp = await sqlWrapper.sql`
|
|
INSERT INTO apps (name, spiffe_id, description, domain, is_public, bypass_paths, allowed_cidrs)
|
|
VALUES (${name.trim()}, ${spiffeId.trim()}, ${
|
|
description?.trim() || null
|
|
}, ${domain?.trim() || null}, ${is_public || false}, ${
|
|
bypass_paths || []
|
|
}, ${allowed_cidrs || []})
|
|
RETURNING id, name, spiffe_id, description, created_at
|
|
`.then((res: any) => res[0]);
|
|
|
|
auditWrapper.auditLog(auth.userId, "app_registered", newApp.id, {
|
|
name: newApp.name,
|
|
spiffe_id: newApp.spiffe_id,
|
|
}, getClientIp(c));
|
|
return c.json({ success: true, app: newApp });
|
|
} catch (err: any) {
|
|
if (err.code === "23505") {
|
|
return c.json({
|
|
error: "An application with this SPIFFE ID already exists",
|
|
}, 409);
|
|
}
|
|
return c.json({ error: "Failed to register application" }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/admin/apps/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const appId = c.req.param("id");
|
|
const app = await sqlWrapper
|
|
.sql`DELETE FROM apps WHERE id = ${appId} RETURNING id, name`
|
|
.then((res: any) => res[0]);
|
|
if (app) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"app_deleted",
|
|
appId,
|
|
{ name: app.name },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
}
|
|
return c.json({ error: "Application not found" }, 404);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Admin Role Catalog Management
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/roles", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const appId = c.req.query("appId");
|
|
let roles;
|
|
if (appId) {
|
|
roles = await sqlWrapper.sql`
|
|
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
|
a.name AS app_name
|
|
FROM roles r
|
|
LEFT JOIN apps a ON r.app_id = a.id
|
|
WHERE r.app_id IS NULL OR r.app_id = ${appId}
|
|
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
|
`;
|
|
} else {
|
|
roles = await sqlWrapper.sql`
|
|
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
|
a.name AS app_name
|
|
FROM roles r
|
|
LEFT JOIN apps a ON r.app_id = a.id
|
|
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
|
`;
|
|
}
|
|
|
|
return c.json({ roles });
|
|
});
|
|
|
|
app.post("/api/admin/roles", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const { name, description, appId } = await c.req.json();
|
|
if (
|
|
!name || typeof name !== "string" || name.trim().length < 2 ||
|
|
name.trim().length > 32
|
|
) {
|
|
return c.json(
|
|
{ error: "Role name must be between 2 and 32 characters" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const normalizedName = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
|
|
|
let validatedAppId = null;
|
|
if (appId && typeof appId === "string" && appId.trim()) {
|
|
const uuidRegex =
|
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
if (!uuidRegex.test(appId)) {
|
|
return c.json({ error: "Invalid App UUID" }, 400);
|
|
}
|
|
const appExists = await sqlWrapper
|
|
.sql`SELECT id, name FROM apps WHERE id = ${appId}`
|
|
.then((res: any) => res[0]);
|
|
if (!appExists) {
|
|
return c.json({ error: "Selected application does not exist" }, 404);
|
|
}
|
|
validatedAppId = appId;
|
|
}
|
|
|
|
try {
|
|
const newRole = await sqlWrapper.sql`
|
|
INSERT INTO roles (name, description, app_id)
|
|
VALUES (${normalizedName}, ${
|
|
description?.trim() || null
|
|
}, ${validatedAppId})
|
|
RETURNING id, name, description, app_id, created_at
|
|
`.then((res: any) => res[0]);
|
|
|
|
auditWrapper.auditLog(auth.userId, "role_created", validatedAppId, {
|
|
role_name: newRole.name,
|
|
scope: validatedAppId ? "app-specific" : "global",
|
|
}, getClientIp(c));
|
|
|
|
return c.json({ success: true, role: newRole });
|
|
} catch (err: any) {
|
|
if (err.code === "23505") {
|
|
return c.json({
|
|
error: "This role already exists for the selected scope",
|
|
}, 409);
|
|
}
|
|
return c.json({ error: "Failed to create role" }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/admin/roles/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const roleId = c.req.param("id");
|
|
const role = await sqlWrapper
|
|
.sql`SELECT id, name, app_id FROM roles WHERE id = ${roleId}`.then(
|
|
(res: any) => res[0],
|
|
);
|
|
if (!role) {
|
|
return c.json({ error: "Role not found" }, 404);
|
|
}
|
|
|
|
if (role.name === "admin" && role.app_id === null) {
|
|
return c.json({ error: "The global 'admin' role cannot be deleted" }, 400);
|
|
}
|
|
|
|
await sqlWrapper.sql`DELETE FROM roles WHERE id = ${roleId}`;
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"role_deleted",
|
|
role.app_id,
|
|
{ role_name: role.name },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Admin Invites / Registration Tokens
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/invites", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const invites = await sqlWrapper.sql`
|
|
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
|
a.name AS app_name, a.id AS app_id,
|
|
u.username AS used_by_username
|
|
FROM invites i
|
|
LEFT JOIN apps a ON i.app_id = a.id
|
|
LEFT JOIN users u ON i.used_by = u.id
|
|
ORDER BY i.created_at DESC
|
|
`;
|
|
return c.json({ invites });
|
|
});
|
|
|
|
app.get("/api/admin/invites/:id/redemptions", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const inviteId = c.req.param("id");
|
|
const redemptions = await sqlWrapper.sql`
|
|
SELECT ir.id, ir.redeemed_at, u.id AS user_id, u.username, u.display_name, u.account_status
|
|
FROM invite_redemptions ir
|
|
JOIN users u ON ir.user_id = u.id
|
|
WHERE ir.invite_id = ${inviteId}
|
|
ORDER BY ir.redeemed_at DESC
|
|
`;
|
|
|
|
return c.json({ redemptions });
|
|
});
|
|
|
|
app.delete("/api/admin/invites/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const inviteId = c.req.param("id");
|
|
const invite = await sqlWrapper
|
|
.sql`DELETE FROM invites WHERE id = ${inviteId} RETURNING id, code`
|
|
.then((res: any) => res[0]);
|
|
if (invite) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"invite_revoked",
|
|
inviteId,
|
|
{ code: invite.code },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
}
|
|
return c.json({ error: "Invite not found" }, 404);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Admin User RBAC Grants Management
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/users/:id/grants", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const targetUserId = c.req.param("id");
|
|
const grants = await sqlWrapper.sql`
|
|
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
|
FROM grants g
|
|
JOIN apps a ON g.app_id = a.id
|
|
WHERE g.user_id = ${targetUserId}
|
|
ORDER BY a.name ASC
|
|
`;
|
|
return c.json({ grants });
|
|
});
|
|
|
|
app.post("/api/admin/users/:id/grants", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const targetUserId = c.req.param("id");
|
|
const { appId, role } = await c.req.json();
|
|
|
|
if (!appId || !role) {
|
|
return c.json({ error: "appId and role are required" }, 400);
|
|
}
|
|
|
|
const app = await sqlWrapper
|
|
.sql`SELECT id, name FROM apps WHERE id = ${appId}`.then(
|
|
(res: any) => res[0],
|
|
);
|
|
if (!app) return c.json({ error: "Application not found" }, 404);
|
|
|
|
const targetUser = await sqlWrapper
|
|
.sql`SELECT id, username FROM users WHERE id = ${targetUserId}`.then(
|
|
(res: any) => res[0],
|
|
);
|
|
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO grants (user_id, app_id, role)
|
|
VALUES (${targetUserId}, ${appId}, ${role})
|
|
ON CONFLICT (user_id, app_id) DO UPDATE SET role = ${role}
|
|
`;
|
|
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"user_grant_assigned",
|
|
targetUserId,
|
|
{ app_id: appId, app_name: app.name, role },
|
|
getClientIp(c),
|
|
);
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
app.delete("/api/admin/users/:id/grants/:appId", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const { id: targetUserId, appId } = c.req.param();
|
|
|
|
const grant = await sqlWrapper.sql`
|
|
DELETE FROM grants
|
|
WHERE user_id = ${targetUserId} AND app_id = ${appId}
|
|
RETURNING id
|
|
`.then((res: any) => res[0]);
|
|
|
|
if (grant) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"user_grant_revoked",
|
|
targetUserId,
|
|
{ app_id: appId },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
}
|
|
|
|
return c.json({ error: "Grant not found" }, 404);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Admin AAGUID Management
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/aaguid", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const allowlist = await sqlWrapper
|
|
.sql`SELECT id, aaguid, description, created_at FROM aaguid_allowlist ORDER BY created_at DESC`;
|
|
return c.json({ allowlist });
|
|
});
|
|
|
|
app.post("/api/admin/aaguid", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const { aaguid, description } = await c.req.json();
|
|
const uuidRegex =
|
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/i;
|
|
if (!aaguid || !uuidRegex.test(aaguid)) {
|
|
return c.json({ error: "Valid AAGUID (UUID) is required" }, 400);
|
|
}
|
|
try {
|
|
await sqlWrapper
|
|
.sql`INSERT INTO aaguid_allowlist (aaguid, description) VALUES (${aaguid.toLowerCase()}, ${
|
|
description || null
|
|
})`;
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"aaguid_added",
|
|
null,
|
|
{ aaguid },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
} catch (err: any) {
|
|
if (err.code === "23505") {
|
|
return c.json({ error: "AAGUID already exists" }, 409);
|
|
}
|
|
return c.json({ error: "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/admin/aaguid/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const id = c.req.param("id");
|
|
const record = await sqlWrapper
|
|
.sql`DELETE FROM aaguid_allowlist WHERE id = ${id} RETURNING aaguid`
|
|
.then((res: any) => res[0]);
|
|
if (record) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"aaguid_removed",
|
|
null,
|
|
{ aaguid: record.aaguid },
|
|
getClientIp(c),
|
|
);
|
|
}
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Admin HWK (Header Web Key) Management
|
|
// ---------------------------------------------------------
|
|
|
|
app.post("/api/admin/hwk", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const { jwk, name } = await c.req.json();
|
|
if (!jwk || !name || typeof name !== "string") {
|
|
return c.json({ error: "Missing required fields: jwk, name" }, 400);
|
|
}
|
|
|
|
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
|
|
return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400);
|
|
}
|
|
|
|
try {
|
|
const fingerprint = await computeJwkThumbprint(jwk);
|
|
|
|
// 1. Dual Storage: PostgreSQL (Durability)
|
|
await sqlWrapper.sql`
|
|
INSERT INTO hwk_keys (fingerprint, public_key, name)
|
|
VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name})
|
|
`;
|
|
|
|
// 2. Dual Storage: Valkey (O(1) Verification)
|
|
await valkey.sadd("auth:hwk:fingerprints", fingerprint);
|
|
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"hwk_added",
|
|
null,
|
|
{ fingerprint, name },
|
|
getClientIp(c),
|
|
);
|
|
|
|
return c.json({ success: true, fingerprint }, 201);
|
|
} catch (err: any) {
|
|
if (err.code === "23505") {
|
|
return c.json({ error: "This key has already been registered" }, 409);
|
|
}
|
|
console.error("Failed to add HWK:", err);
|
|
return c.json({ error: "Internal server error" }, 500);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/admin/hwk/:fingerprint", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
const fingerprint = c.req.param("fingerprint");
|
|
|
|
// 1. Remove from PostgreSQL
|
|
const record = await sqlWrapper.sql`
|
|
DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name
|
|
`.then((res: any) => res[0]);
|
|
|
|
if (record) {
|
|
// 2. Remove from Valkey
|
|
try {
|
|
await valkey.srem("auth:hwk:fingerprints", fingerprint);
|
|
} catch (_err) {}
|
|
|
|
auditWrapper.auditLog(auth.userId, "hwk_removed", null, {
|
|
fingerprint,
|
|
name: record.name,
|
|
}, getClientIp(c));
|
|
return c.json({ success: true });
|
|
}
|
|
|
|
return c.json({ error: "Key not found" }, 404);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Global Session and Device Revocation (Admin)
|
|
// ---------------------------------------------------------
|
|
|
|
app.get("/api/admin/users/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const targetUserId = c.req.param("id");
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id, username, display_name, account_status FROM users WHERE id = ${targetUserId}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) return c.json({ error: "User not found" }, 404);
|
|
const sessions = await sqlWrapper
|
|
.sql`SELECT id, created_at, expires_at FROM sessions WHERE user_id = ${targetUserId} ORDER BY created_at DESC`;
|
|
const passkeys = await sqlWrapper
|
|
.sql`SELECT id, credential_id, counter FROM passkeys WHERE user_id = ${targetUserId}`;
|
|
return c.json({ user, sessions, passkeys });
|
|
});
|
|
|
|
app.delete("/api/admin/sessions/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const sessionId = c.req.param("id");
|
|
const session = await sqlWrapper
|
|
.sql`DELETE FROM sessions WHERE id = ${sessionId} RETURNING user_id`
|
|
.then((res: any) => res[0]);
|
|
if (session) {
|
|
try {
|
|
await valkey.del(sessionId);
|
|
} catch (_err) {}
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"admin_session_revoked",
|
|
session.user_id,
|
|
{
|
|
revoked_session_id: sessionId,
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
}
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
app.delete("/api/admin/users/:id/sessions", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const targetUserId = c.req.param("id");
|
|
const sessions = await sqlWrapper
|
|
.sql`DELETE FROM sessions WHERE user_id = ${targetUserId} RETURNING id`;
|
|
for (const session of sessions) {
|
|
try {
|
|
await valkey.del(session.id);
|
|
} catch (_err) {}
|
|
}
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"admin_all_sessions_revoked",
|
|
targetUserId,
|
|
null,
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
app.delete("/api/admin/users/:userId/passkeys/:passkeyId", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const { userId, passkeyId } = c.req.param();
|
|
const passkey = await sqlWrapper
|
|
.sql`DELETE FROM passkeys WHERE id = ${passkeyId} AND user_id = ${userId} RETURNING id`
|
|
.then((res: any) => res[0]);
|
|
if (passkey) {
|
|
auditWrapper.auditLog(auth.userId, "admin_passkey_revoked", userId, {
|
|
passkey_id: passkey.id,
|
|
}, getClientIp(c));
|
|
return c.json({ success: true });
|
|
}
|
|
return c.json({ error: "Passkey not found" }, 404);
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Out-of-Band Account Recovery (Use Case 12)
|
|
// ---------------------------------------------------------
|
|
|
|
app.post("/api/admin/users/:id/recovery", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
if (!(await isGlobalAdmin(auth.userId))) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
const targetUserId = c.req.param("id");
|
|
const targetUser = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE id = ${targetUserId}`
|
|
.then((res: any) => res[0]);
|
|
if (!targetUser) return c.json({ error: "User not found" }, 404);
|
|
const recoveryCode = encodeBase64Url(
|
|
crypto.getRandomValues(new Uint8Array(24)),
|
|
);
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 1);
|
|
await sqlWrapper
|
|
.sql`INSERT INTO recovery_links (code, user_id, created_by, expires_at) VALUES (${recoveryCode}, ${targetUserId}, ${auth.userId}, ${expiresAt})`;
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"recovery_link_created",
|
|
targetUserId,
|
|
null,
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ success: true, recoveryCode, expiresAt });
|
|
});
|
|
|
|
app.route("/api/recovery", recoveryApp);
|
|
|
|
app.get("/api/admin/check", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const isAdmin = await isGlobalAdmin(auth.userId);
|
|
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, 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 });
|
|
});
|
|
|
|
// 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)
|
|
// ---------------------------------------------------------
|
|
|
|
app.post("/api/passkeys/register/challenge", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const user = await sqlWrapper
|
|
.sql`SELECT username FROM users WHERE id = ${auth.userId}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) return c.json({ error: "User not found" }, 404);
|
|
|
|
const userIdBytes = new TextEncoder().encode(auth.userId);
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName,
|
|
rpID,
|
|
userName: user.username,
|
|
userID: userIdBytes,
|
|
attestationType: "direct",
|
|
authenticatorSelection: {
|
|
residentKey: "required",
|
|
requireResidentKey: true,
|
|
userVerification: "preferred",
|
|
},
|
|
timeout: 60000,
|
|
});
|
|
|
|
setCookie(c, "expected_add_passkey_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options });
|
|
});
|
|
|
|
app.post("/api/passkeys/register/verify", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const { response } = await c.req.json();
|
|
const expectedChallenge = getCookie(c, "expected_add_passkey_challenge");
|
|
|
|
if (!expectedChallenge) {
|
|
return c.json({ error: "Missing or expired registration challenge" }, 400);
|
|
}
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyRegistrationResponse({
|
|
response: response as RegistrationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false,
|
|
});
|
|
} catch (error: any) {
|
|
return c.json({ error: error.message }, 400);
|
|
}
|
|
|
|
const { verified, registrationInfo } = verification;
|
|
if (!verified || !registrationInfo) {
|
|
return c.json({ error: "Verification failed" }, 400);
|
|
}
|
|
|
|
// Enterprise Allow-List Verification
|
|
const allowlistCount = await sqlWrapper
|
|
.sql`SELECT COUNT(*) as count FROM aaguid_allowlist`.then((res: any) =>
|
|
Number(res[0].count)
|
|
);
|
|
if (allowlistCount > 0 && registrationInfo.aaguid) {
|
|
const isAllowed = await sqlWrapper
|
|
.sql`SELECT id FROM aaguid_allowlist WHERE aaguid = ${registrationInfo.aaguid}`
|
|
.then((res: any) => res[0]);
|
|
if (!isAllowed) {
|
|
auditWrapper.auditLog(auth.userId, "failed_attestation_allowlist", null, {
|
|
aaguid: registrationInfo.aaguid,
|
|
}, getClientIp(c));
|
|
return c.json({
|
|
error: "Authenticator AAGUID is not in the enterprise allow-list.",
|
|
}, 403);
|
|
}
|
|
}
|
|
|
|
// Optional Strict Hardware Attestation
|
|
if (requireHardwareToken) {
|
|
if (
|
|
!registrationInfo.aaguid ||
|
|
registrationInfo.aaguid === "00000000-0000-0000-0000-000000000000"
|
|
) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
reason: "No AAGUID provided",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json(
|
|
{ error: "Hardware attestation failed: No AAGUID provided." },
|
|
403,
|
|
);
|
|
}
|
|
|
|
let mdsStatement;
|
|
try {
|
|
mdsStatement = await MetadataService.getStatement(
|
|
registrationInfo.aaguid,
|
|
);
|
|
} catch (mdsError) {
|
|
console.warn("[Auth API] MetadataService lookup error:", mdsError);
|
|
}
|
|
|
|
if (!mdsStatement) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "AAGUID not found in MDS3",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error:
|
|
`Hardware attestation failed: Authenticator AAGUID (${registrationInfo.aaguid}) not found in FIDO MDS3 blob.`,
|
|
}, 403);
|
|
}
|
|
|
|
// @ts-ignore: FIDO MDS3 missing type
|
|
if (mdsStatement.keyProtection?.includes(0x0001)) {
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"add_passkey_failed_attestation",
|
|
null,
|
|
{
|
|
aaguid: registrationInfo.aaguid,
|
|
reason: "Software passkey detected",
|
|
},
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error:
|
|
"Hardware attestation failed: Authenticator is flagged as a software-based passkey.",
|
|
}, 403);
|
|
}
|
|
}
|
|
const credentialID = registrationInfo.credential.id;
|
|
const credentialPublicKey = registrationInfo.credential.publicKey;
|
|
const counter = registrationInfo.credential.counter;
|
|
|
|
const base64CredentialID = typeof credentialID === "string"
|
|
? credentialID
|
|
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
|
|
const base64PublicKey = encodeBase64Url(
|
|
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
|
|
);
|
|
|
|
await sqlWrapper.sql`
|
|
INSERT INTO passkeys (user_id, credential_id, public_key, counter)
|
|
VALUES (${auth.userId}, ${base64CredentialID}, ${base64PublicKey}, ${counter})
|
|
`;
|
|
|
|
auditWrapper.auditLog(
|
|
auth.userId,
|
|
"passkey_added",
|
|
null,
|
|
null,
|
|
getClientIp(c),
|
|
);
|
|
|
|
setCookie(c, "expected_add_passkey_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// Get user's registered passkeys
|
|
app.get("/api/passkeys", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const passkeys = await sqlWrapper.sql`
|
|
SELECT id, counter
|
|
FROM passkeys
|
|
WHERE user_id = ${auth.userId}
|
|
`;
|
|
|
|
return c.json({ passkeys });
|
|
});
|
|
|
|
// Revoke a specific passkey
|
|
app.delete("/api/passkeys/:id", async (c) => {
|
|
const auth = await getAuthenticatedUser(c);
|
|
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
|
|
|
const targetPasskeyId = c.req.param("id");
|
|
|
|
// Verify the passkey belongs to the user
|
|
const passkey = await sqlWrapper.sql`
|
|
SELECT id FROM passkeys WHERE id = ${targetPasskeyId} AND user_id = ${auth.userId}
|
|
`.then((res: any) => res[0]);
|
|
|
|
if (!passkey) {
|
|
return c.json({ error: "Passkey not found or access denied" }, 404);
|
|
}
|
|
|
|
// Prevent deleting the very last passkey to avoid locking out the user
|
|
const passkeyCount = await sqlWrapper.sql`
|
|
SELECT count(*) as count FROM passkeys WHERE user_id = ${auth.userId}
|
|
`.then((res: any) => Number(res[0].count));
|
|
|
|
if (passkeyCount <= 1) {
|
|
return c.json({
|
|
error: "Cannot delete your last passkey. Register another one first.",
|
|
}, 400);
|
|
}
|
|
|
|
await sqlWrapper.sql`DELETE FROM passkeys WHERE id = ${targetPasskeyId}`;
|
|
|
|
auditWrapper.auditLog(auth.userId, "passkey_revoked", null, {
|
|
revoked_passkey_id: targetPasskeyId,
|
|
}, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------
|
|
// Lifecycle & Revocation
|
|
// ---------------------------------------------------------
|
|
|
|
// Revoke a session manually (used by layout logout)
|
|
app.post("/api/revoke", async (c) => {
|
|
// Try Authorization header first (SDK)
|
|
let token = "";
|
|
const authHeader = c.req.header("Authorization");
|
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
token = authHeader.split(" ")[1];
|
|
}
|
|
|
|
if (!token) {
|
|
// Fallback to session cookie (Web UI)
|
|
const tokens = extractAllSessionIds(c);
|
|
if (tokens.length === 0) {
|
|
return c.json({ error: "Missing or invalid token" }, 401);
|
|
}
|
|
|
|
for (const t of tokens) {
|
|
try {
|
|
await valkey.del(t);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${t}`;
|
|
} catch (_e) {}
|
|
}
|
|
} else {
|
|
// SDK Token Path
|
|
try {
|
|
await valkey.del(token);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${token}`;
|
|
} catch (_e) {}
|
|
}
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
if (cookieDomain) {
|
|
deleteCookie(c, "session_id", {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
}
|
|
deleteCookie(c, "session_id", {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
});
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
if (import.meta.main) {
|
|
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
|
|
|
// Fail fast if connection to Valkey fails during startup
|
|
await pingValkey();
|
|
|
|
console.log(`Auth API Server running on port ${PORT}`);
|
|
|
|
Deno.serve({ port: PORT }, app.fetch);
|
|
}
|