diff --git a/prisma/migrations/20260713020500_add_admin_access_token_validation/migration.sql b/prisma/migrations/20260713020500_add_admin_access_token_validation/migration.sql new file mode 100644 index 00000000..b037e0cd --- /dev/null +++ b/prisma/migrations/20260713020500_add_admin_access_token_validation/migration.sql @@ -0,0 +1,45 @@ +CREATE OR REPLACE FUNCTION public.gem_validate_admin_access_token( + p_token_hash text +) +RETURNS TABLE( + valid boolean, + expires_at timestamptz, + request_id text +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + token_row public.admin_access_tokens%rowtype; +BEGIN + IF p_token_hash IS NULL OR length(p_token_hash) <> 64 THEN + RETURN QUERY SELECT false, NULL::timestamptz, NULL::text; + RETURN; + END IF; + + SELECT token.* INTO token_row + FROM public.admin_access_tokens token + JOIN public.users account ON account.id = token.user_id + WHERE token.token_hash = p_token_hash + AND token.used_at IS NULL + AND token.expires_at > now() + AND account.email = 'admin@gemcybersecurityassist.com' + AND account.role IN ('admin', 'super_admin', 'internal') + AND account.status = 'active' + AND account."isActive" = true + AND account."isEmailVerified" = true + LIMIT 1; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, NULL::timestamptz, NULL::text; + RETURN; + END IF; + + RETURN QUERY SELECT true, token_row.expires_at, token_row.request_id; +END; +$function$; + +REVOKE ALL ON FUNCTION public.gem_validate_admin_access_token(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.gem_validate_admin_access_token(text) FROM authenticated; +GRANT EXECUTE ON FUNCTION public.gem_validate_admin_access_token(text) TO anon; diff --git a/src/__tests__/admin-access-validation.test.ts b/src/__tests__/admin-access-validation.test.ts new file mode 100644 index 00000000..c0d53d8a --- /dev/null +++ b/src/__tests__/admin-access-validation.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { validateAdminAccessToken } from "@/lib/admin-access-validation"; + +describe("administrator access validation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("sends only the SHA-256 token hash to the validation RPC", async () => { + const accessToken = "browser-capability-" + "a".repeat(48); + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { + valid: true, + expires_at: "2026-07-13T03:00:00.000Z", + request_id: "aar_1234567890abcdef1234567890abcdef", + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await validateAdminAccessToken(accessToken); + + expect(result.valid).toBe(true); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(String(init.body)) as { p_token_hash: string }; + expect(url).toContain("/rest/v1/rpc/gem_validate_admin_access_token"); + expect(body.p_token_hash).toMatch(/^[a-f0-9]{64}$/); + expect(String(init.body)).not.toContain(accessToken); + }); + + it("returns an invalid result when no active authorization row matches", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { valid: false, expires_at: null, request_id: null }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + await expect(validateAdminAccessToken("b".repeat(64))).resolves.toEqual({ + valid: false, + expiresAt: null, + requestId: null, + }); + }); + + it("fails closed when the validation service is unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ message: "unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect(validateAdminAccessToken("c".repeat(64))).rejects.toThrow( + "Administrator authorization validation is unavailable.", + ); + }); +}); diff --git a/src/app/admin-access/authorize/page.tsx b/src/app/admin-access/authorize/page.tsx index 83f11c08..5002d078 100644 --- a/src/app/admin-access/authorize/page.tsx +++ b/src/app/admin-access/authorize/page.tsx @@ -29,8 +29,9 @@ export default function AdminAccessAuthorizePage() { useState(null); const [ready, setReady] = useState(false); const [generating, setGenerating] = useState(false); + const [verifying, setVerifying] = useState(false); const [copied, setCopied] = useState(false); - const [sqlConfirmed, setSqlConfirmed] = useState(false); + const [verified, setVerified] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -53,7 +54,7 @@ export default function AdminAccessAuthorizePage() { async function generate() { setGenerating(true); setCopied(false); - setSqlConfirmed(false); + setVerified(false); setError(null); try { const next = await generateAdminAccessAuthorization(); @@ -78,23 +79,59 @@ export default function AdminAccessAuthorizePage() { try { await navigator.clipboard.writeText(authorization.sql); setCopied(true); + setError(null); } catch { - setError("Copy failed. Select the SQL manually and copy it."); + setError("Copy failed. Select the SQL statement manually and copy it."); } } - function continueToPasswordSetup() { - if (!authorization || !sqlConfirmed) return; - window.location.assign( - `/admin-access#token=${encodeURIComponent(authorization.token)}`, - ); + async function verifyAndContinue() { + if (!authorization) return; + setVerifying(true); + setVerified(false); + setError(null); + + try { + const response = await fetch("/api/admin-access/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: authorization.token }), + }); + const body = (await response.json().catch(() => ({}))) as { + valid?: boolean; + error?: string; + message?: string; + }; + + if (!response.ok) { + throw new Error(body.error || "Authorization could not be checked."); + } + if (!body.valid) { + throw new Error( + "No active authorization row was found. Return to the Supabase SQL Editor, run the complete statement, confirm that one row is returned, then tap Check authorization again.", + ); + } + + setVerified(true); + window.location.assign( + `/admin-access#token=${encodeURIComponent(authorization.token)}`, + ); + } catch (verificationError) { + setError( + verificationError instanceof Error + ? verificationError.message + : "Authorization could not be checked.", + ); + } finally { + setVerifying(false); + } } function discard() { window.sessionStorage.removeItem(ADMIN_ACCESS_SESSION_KEY); setAuthorization(null); setCopied(false); - setSqlConfirmed(false); + setVerified(false); setError(null); } @@ -121,10 +158,8 @@ export default function AdminAccessAuthorizePage() { Authorize administrator password setup

- This browser creates a one-time capability locally. Only its - SHA-256 hash appears in the SQL statement. The usable capability - remains in this browser session and is never copied into chat, - email, GitHub, analytics, or the SQL editor. + The usable capability stays in this browser. Only its SHA-256 hash + appears in the SQL statement.

@@ -160,11 +195,10 @@ export default function AdminAccessAuthorizePage() {

- Local capability generated + Browser capability generated

- It expires at {expiryLabel}. Keep this tab open while completing - the authorization step. + Expires at {expiryLabel}. Keep this tab open.

@@ -173,10 +207,9 @@ export default function AdminAccessAuthorizePage() {
-

1. Copy the authorization SQL

+

1. Copy the complete SQL statement

- It contains only a token hash, request ID, expiry time, and a - constrained lookup of the approved administrator account. + Do not copy only part of the statement.

- - -
- +
+

3. Check the database authorization

+

+ This check prevents an unregistered or expired browser token from + reaching the password screen. +

-
+ + + )} {error ? ( -
+
{error}
) : null}

- No password is created on this page. You choose it only after the - one-time authorization is registered. + No password is created until database authorization is confirmed. Return to sign-in diff --git a/src/app/admin-access/page.tsx b/src/app/admin-access/page.tsx index 7ec9c701..416e8498 100644 --- a/src/app/admin-access/page.tsx +++ b/src/app/admin-access/page.tsx @@ -23,9 +23,11 @@ const requirements = [ { label: "One special character", test: (value: string) => /[^A-Za-z0-9]/.test(value) }, ]; +type TokenState = "checking" | "valid" | "invalid" | "missing"; + export default function AdminAccessPage() { const [accessToken, setAccessToken] = useState(""); - const [linkChecked, setLinkChecked] = useState(false); + const [tokenState, setTokenState] = useState("checking"); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -37,18 +39,44 @@ export default function AdminAccessPage() { const fragment = window.location.hash.replace(/^#/, ""); const parameters = new URLSearchParams(fragment); const token = parameters.get("token") || parameters.get("access_token") || ""; - setAccessToken(token); - setLinkChecked(true); - - // Remove the capability from the visible URL after it is captured in memory. window.history.replaceState(null, "", window.location.pathname); + + if (!token) { + setTokenState("missing"); + return; + } + + setAccessToken(token); + void (async () => { + try { + const response = await fetch("/api/admin-access/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accessToken: token }), + }); + const body = (await response.json().catch(() => ({}))) as { + valid?: boolean; + error?: string; + }; + if (!response.ok) throw new Error(body.error || "Authorization check failed."); + setTokenState(body.valid ? "valid" : "invalid"); + } catch (validationError) { + setError( + validationError instanceof Error + ? validationError.message + : "Authorization check failed.", + ); + setTokenState("invalid"); + } + })(); }, []); const requirementResults = useMemo( - () => requirements.map((requirement) => ({ - ...requirement, - passed: requirement.test(password), - })), + () => + requirements.map((requirement) => ({ + ...requirement, + passed: requirement.test(password), + })), [password], ); const passwordValid = requirementResults.every((item) => item.passed); @@ -56,7 +84,14 @@ export default function AdminAccessPage() { async function submit(event: FormEvent) { event.preventDefault(); - if (!accessToken || !passwordValid || !confirmationValid) return; + if ( + tokenState !== "valid" || + !accessToken || + !passwordValid || + !confirmationValid + ) { + return; + } setSubmitting(true); setError(null); @@ -90,10 +125,13 @@ export default function AdminAccessPage() { } } - if (!linkChecked) { + if (tokenState === "checking") { return (

- +
+ +

Checking database authorization…

+
); } @@ -105,7 +143,8 @@ export default function AdminAccessPage() {

Administrator password set

- The setup capability has been consumed and cannot be used again. Sign in with the administrator email and the password you just created. + The setup capability has been consumed. Sign in with the administrator + email and the password you just created.

+ @@ -141,11 +190,12 @@ export default function AdminAccessPage() {
- One-time administrator setup + Database authorization confirmed

Create your administrator password

- The setup capability was captured from the protected link and removed from the address bar. It will be invalidated after a successful password change. + The capability is active and will be invalidated after the password is + set successfully.

@@ -196,16 +246,16 @@ export default function AdminAccessPage() { maxLength={256} className="border-white/10 bg-black/20 text-white" /> - {confirmation.length > 0 && !confirmationValid && ( + {confirmation.length > 0 && !confirmationValid ? (

The passwords do not match.

- )} + ) : null} - {error && ( + {error ? (
{error}
- )} + ) : null}