From d9e8ceb73376ba7b663e82e572f16920411d16d2 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 02:57:36 +0100 Subject: [PATCH 1/6] fix(auth): add one-time authorization validation client --- src/lib/admin-access-validation.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/lib/admin-access-validation.ts diff --git a/src/lib/admin-access-validation.ts b/src/lib/admin-access-validation.ts new file mode 100644 index 00000000..f415ae62 --- /dev/null +++ b/src/lib/admin-access-validation.ts @@ -0,0 +1,70 @@ +const DEFAULT_SUPABASE_URL = "https://slzdjoqpzbkwzuaexlkj.supabase.co"; +const DEFAULT_SUPABASE_ANON_KEY = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNsemRqb3FwemJrd3p1YWV4bGtqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMyOTk1MTQsImV4cCI6MjA5ODg3NTUxNH0.0wfgX_m6SBn_TtD0ZNjkOZ-bk8Frp2Tq1HL9mYFBm4M"; + +export interface AdminAccessValidationResult { + valid: boolean; + expiresAt: string | null; + requestId: string | null; +} + +function projectUrl() { + return ( + process.env.NEXT_PUBLIC_SUPABASE_URL?.trim() || DEFAULT_SUPABASE_URL + ).replace(/\/$/, ""); +} + +function publishableKey() { + return ( + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim() || + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY?.trim() || + DEFAULT_SUPABASE_ANON_KEY + ); +} + +async function sha256Hex(value: string) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function validateAdminAccessToken( + accessToken: string, +): Promise { + const tokenHash = await sha256Hex(accessToken); + const key = publishableKey(); + const response = await fetch( + `${projectUrl()}/rest/v1/rpc/gem_validate_admin_access_token`, + { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + apikey: key, + "Content-Type": "application/json", + }, + body: JSON.stringify({ p_token_hash: tokenHash }), + cache: "no-store", + }, + ); + + if (!response.ok) { + throw new Error("Administrator authorization validation is unavailable."); + } + + const body = (await response.json().catch(() => [])) as Array<{ + valid?: boolean; + expires_at?: string | null; + request_id?: string | null; + }>; + const result = body[0]; + + return { + valid: result?.valid === true, + expiresAt: result?.expires_at ?? null, + requestId: result?.request_id ?? null, + }; +} From c87351f81a6d01dfab4c955e9318c173facc43e9 Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 02:57:47 +0100 Subject: [PATCH 2/6] fix(auth): add admin authorization preflight endpoint --- src/app/api/admin-access/validate/route.ts | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/app/api/admin-access/validate/route.ts diff --git a/src/app/api/admin-access/validate/route.ts b/src/app/api/admin-access/validate/route.ts new file mode 100644 index 00000000..ba513026 --- /dev/null +++ b/src/app/api/admin-access/validate/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getRequestContext } from "@/lib/api/auth-helpers"; +import { rateLimit, rateLimitedResponse } from "@/lib/api/rate-limit"; +import { validateAdminAccessToken } from "@/lib/admin-access-validation"; + +const schema = z + .object({ + accessToken: z.string().min(32).max(512), + }) + .strict(); + +function json(body: unknown, status = 200) { + return NextResponse.json(body, { + status, + headers: { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + }); +} + +export async function POST(request: NextRequest) { + const { ipAddress } = getRequestContext(request); + const limit = rateLimit(ipAddress, { + key: "auth:admin-access-validate", + windowMs: 15 * 60_000, + max: 20, + }); + if (!limit.ok) return rateLimitedResponse(limit.retryAfterSeconds); + + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ error: "Invalid JSON" }, 400); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return json({ error: "Invalid authorization capability." }, 400); + } + + try { + const result = await validateAdminAccessToken(parsed.data.accessToken); + return json({ + valid: result.valid, + expiresAt: result.expiresAt, + requestId: result.requestId, + message: result.valid + ? "Authorization confirmed." + : "No active authorization row matches this browser capability.", + }); + } catch (error) { + console.error("[POST /api/admin-access/validate]", error); + return json( + { + error: "Administrator authorization validation is temporarily unavailable.", + }, + 503, + ); + } +} From ef089e475511039c59fd5208c017d6a8ae061e8c Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 02:58:22 +0100 Subject: [PATCH 3/6] fix(auth): verify admin authorization before password setup --- src/app/admin-access/authorize/page.tsx | 138 +++++++++++++++--------- 1 file changed, 86 insertions(+), 52 deletions(-) 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 From 02c3477c90129689127087996520e47024d8759b Mon Sep 17 00:00:00 2001 From: GEM CYBERSECURITY-MONITORING ASSIST Date: Mon, 13 Jul 2026 02:58:57 +0100 Subject: [PATCH 4/6] fix(auth): reject unregistered setup links before password entry --- src/app/admin-access/page.tsx | 103 +++++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 25 deletions(-) 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}