Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/auth.admin.callback.tsx
T

197 lines
6.9 KiB
TypeScript

import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { db } from "~/utils/db.server";
import {
AdminSsoConfigError,
consumeAdminSsoChallenge,
extractAdminSsoClaims,
getAdminSsoClient,
jitProvisionAdmin,
readAdminSsoEnv,
type AdminSsoClaims,
type JitProvisioningDbClient,
} from "~/modules/admin-sso";
import { serializeIdTokenCookie } from "~/modules/admin-sso/logout.server";
import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server";
import { createUserSession, getUserInfo, setLoggedUser } from "~/utils/session.server";
import { getUser } from "~/utils/db/users.db.server";
import { ensureAdminUserRoleByName } from "~/utils/db/permissions/userRoles.db.server";
import { loadRequiredWebConfig } from "@toolctrl/config";
import { absoluteAdminPath } from "~/utils/app/CrossHostUrls";
/**
* Phase 09 Task #82 — GET /auth/admin/callback
*
* Verifies state + nonce + PKCE via openid-client, exchanges the code
* for tokens, runs JIT provisioning, and rotates the App-Session cookie
* to the freshly-provisioned admin. Failures always redirect to
* /admin/login with a typed error query param.
*/
function failRedirect(reason: string): never {
const path = `/admin/login?error=${encodeURIComponent(reason)}`;
throw redirect(path);
}
function buildJitDbClient(): JitProvisioningDbClient {
// The real Prisma client matches the structural type used by the helper.
// We do the cast at the boundary so the module stays Prisma-free.
return db as unknown as JitProvisioningDbClient;
}
function sanitizeReturnTo(value: string | undefined): string | undefined {
if (!value) return undefined;
if (!value.startsWith("/") || value.startsWith("//")) return undefined;
// Reject backslash variants (browsers treat `/\evil.com` as protocol-relative)
// and percent-encoded slash/backslash/CR-LF.
if (value.includes("\\")) return undefined;
if (/%(?:2f|5c|00|0a|0d)/i.test(value)) return undefined;
if (/[\x00-\x1f\x7f]/.test(value)) return undefined;
return value;
}
export async function loader({ request }: LoaderFunctionArgs): Promise<Response> {
const url = new URL(request.url);
// --- 1. Env + client init ------------------------------------------------
let env;
let client;
try {
env = readAdminSsoEnv();
client = await getAdminSsoClient(env);
} catch (err) {
const reason = err instanceof AdminSsoConfigError ? err.code : "unknown";
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
meta: { stage: "init", reason },
});
return failRedirect("sso_unavailable");
}
// --- 2. Pop the challenge cookie ---------------------------------------
const consumed = consumeAdminSsoChallenge(request);
if (!consumed.ok) {
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
meta: { stage: "consume-challenge", reason: consumed.reason },
});
return failRedirect("sso_state_invalid");
}
const challenge = consumed.payload;
// --- 3. Token exchange + ID-token verification --------------------------
const params = client.callbackParams(url.toString());
let tokenSet;
try {
tokenSet = await client.callback(env.redirectUri, params, {
state: challenge.state,
nonce: challenge.nonce,
code_verifier: challenge.codeVerifier,
});
} catch (err) {
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
meta: { stage: "token-exchange", error: (err as Error).message },
});
return failRedirect("sso_token_exchange_failed");
}
const rawClaims = tokenSet.claims();
const claimResult = extractAdminSsoClaims(rawClaims as Record<string, unknown>);
if (!claimResult.ok) {
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
meta: { stage: "claims", reason: claimResult.reason },
});
return failRedirect(`sso_${claimResult.reason.replace(/-/g, "_")}`);
}
const claims: AdminSsoClaims = claimResult.claims;
// --- 4. JIT-provision the admin ----------------------------------------
const jitResult = await jitProvisionAdmin({ claims }, buildJitDbClient());
if (!jitResult.ok) {
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
ssoSubjectId: claims.subject,
meta: { stage: "jit", reason: jitResult.reason, groups: claims.groups },
});
return failRedirect("sso_forbidden_group");
}
const admin = jitResult.admin;
// --- 4b. Wire the admin-SSO identity to its RBAC permissions -------------
// JIT creates the User + AdminUser but not the permission-bearing UserRole.
// Map super_admin -> the "SuperAdmin" RBAC role so a freshly-provisioned
// colleague has the full admin surface on their FIRST page load. The grant is
// idempotent and invalidates the userRoles cache, so the 24h cache TTL never
// causes a "logged in but no permissions" state. Never block login on failure.
if (admin.role === "super_admin") {
try {
await ensureAdminUserRoleByName(admin.userId, "SuperAdmin");
} catch (error) {
// eslint-disable-next-line no-console
console.error("[admin-sso] failed to grant SuperAdmin RBAC role", error);
}
}
// --- 5. Session rotation ------------------------------------------------
const userRecord = await getUser(admin.userId);
if (!userRecord) {
await writeAdminSsoAuditEvent({
action: "admin.login.sso.failure",
request,
ssoSubjectId: claims.subject,
meta: { stage: "post-jit-user-missing" },
});
return failRedirect("sso_session_failed");
}
const userInfo = await getUserInfo(request);
const loggedUser = await setLoggedUser({
id: userRecord.id,
email: userRecord.email,
defaultTenantId: userRecord.defaultTenantId,
});
await writeAdminSsoAuditEvent({
action: "admin.login.sso.success",
actorAdminUserId: admin.userId,
ssoSubjectId: claims.subject,
request,
meta: { groups: claims.groups, role: admin.role, created: admin.created, linkedExisting: admin.linkedExisting },
});
const returnTo = sanitizeReturnTo(challenge.returnTo);
const fallbackTarget = absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard");
const redirectTarget = returnTo ?? fallbackTarget;
const sessionResponse = await createUserSession(
{
...userInfo,
userId: loggedUser.userId,
lng: userRecord.locale ?? userInfo.lng,
},
redirectTarget,
);
// Append both the clear-challenge cookie and the id-token cookie to
// whatever Set-Cookie createUserSession already set.
const headers = new Headers(sessionResponse.headers);
headers.append("Set-Cookie", consumed.clearCookie);
if (tokenSet.id_token) {
headers.append("Set-Cookie", serializeIdTokenCookie(tokenSet.id_token));
}
return new Response(sessionResponse.body, {
status: sessionResponse.status,
headers,
});
}
export default function AuthAdminCallback() {
return null;
}