197 lines
6.9 KiB
TypeScript
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;
|
|
}
|