File
Blob: src/client/pages/oauth/consent.tsx
| 1 | import { useQuery } from "@tanstack/react-query"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { useNavigate, useSearchParams } from "react-router"; |
| 4 | |
| 5 | import { Button } from "@/client/components/ui/button"; |
| 6 | import { Card } from "@/client/components/ui/card"; |
| 7 | import { PageError } from "@/client/components/ui/page-error"; |
| 8 | import { useSession } from "@/client/lib/auth-client"; |
| 9 | import { apiJsonRequest } from "@/client/lib/http"; |
| 10 | import { signInPath } from "@/client/lib/navigation"; |
| 11 | import { fetchJson, queryKeys } from "@/client/lib/query"; |
| 12 | |
| 13 | interface ClientInfo { |
| 14 | client_id: string; |
| 15 | client_name: string | null; |
| 16 | } |
| 17 | |
| 18 | // Human-language gloss for the OIDC scopes the consent page is likely to |
| 19 | // surface. The protocol name stays visible (operators inspecting the URL |
| 20 | // need it); the description is what a third-party user reads. Keep this |
| 21 | // in sync with the `scopes` allowlist in `src/worker/auth/index.ts` โ |
| 22 | // any scope rendered here without a matching entry falls back to the |
| 23 | // generic placeholder. |
| 24 | const SCOPE_DESCRIPTIONS: Record<string, string> = { |
| 25 | openid: "Confirm your identity.", |
| 26 | profile: "Your name and username.", |
| 27 | email: "Your email address.", |
| 28 | }; |
| 29 | |
| 30 | export const OauthConsentPage = () => { |
| 31 | const navigate = useNavigate(); |
| 32 | const [params] = useSearchParams(); |
| 33 | const session = useSession(); |
| 34 | const clientId = params.get("client_id") ?? ""; |
| 35 | const scope = params.get("scope") ?? ""; |
| 36 | // Better Auth's consent endpoint validates `oauth_query` as the full |
| 37 | // signed query string from /authorize (sig + exp included). It's NOT a |
| 38 | // single param of the same name on the URL โ it's the whole search, |
| 39 | // re-serialised so we don't lose any of the original ordering. |
| 40 | const oauthQuery = params.toString(); |
| 41 | // `null` when idle, `true` when the Authorize button is in flight, |
| 42 | // `false` when Deny is in flight. Per-button keying so the spinner |
| 43 | // matches the button the user actually clicked. |
| 44 | const [pendingAccept, setPendingAccept] = useState<boolean | null>(null); |
| 45 | const submitting = pendingAccept !== null; |
| 46 | // Synchronous lock against double-tap before React re-renders the |
| 47 | // disabled state. Same-task repeat clicks no-op until the in-flight |
| 48 | // request resolves; the `finally` block resets the lock so retry after |
| 49 | // a completed failure is allowed. |
| 50 | const inFlightRef = useRef(false); |
| 51 | const [error, setError] = useState<string | null>(null); |
| 52 | const userId = session.data?.user.id ?? null; |
| 53 | |
| 54 | useEffect(() => { |
| 55 | if (!session.isPending && !userId) { |
| 56 | navigate(signInPath(window.location.pathname + window.location.search), { replace: true }); |
| 57 | } |
| 58 | }, [session.isPending, userId, navigate]); |
| 59 | |
| 60 | // Fetch the human-readable name for the client_id in the URL. Falling |
| 61 | // back to the opaque id until the request resolves keeps the UI from |
| 62 | // flashing two strings if the lookup is fast. |
| 63 | const { data: clientInfo = null } = useQuery({ |
| 64 | queryKey: queryKeys.consentClient(userId, clientId || null), |
| 65 | queryFn: () => |
| 66 | fetchJson<ClientInfo>(`/api/auth/oauth2/public-client?${new URLSearchParams({ client_id: clientId })}`), |
| 67 | enabled: Boolean(userId && clientId), |
| 68 | }); |
| 69 | |
| 70 | const respond = async (accept: boolean) => { |
| 71 | if (inFlightRef.current) return; |
| 72 | inFlightRef.current = true; |
| 73 | setPendingAccept(accept); |
| 74 | setError(null); |
| 75 | try { |
| 76 | const res = await apiJsonRequest( |
| 77 | "/api/auth/oauth2/consent", |
| 78 | { accept, scope, oauth_query: oauthQuery }, |
| 79 | { |
| 80 | method: "POST", |
| 81 | }, |
| 82 | ); |
| 83 | const body = (await res.json().catch(() => null)) as { |
| 84 | redirect?: boolean; |
| 85 | url?: string; |
| 86 | message?: string; |
| 87 | } | null; |
| 88 | if (body?.redirect && body.url) { |
| 89 | window.location.assign(body.url); |
| 90 | return; |
| 91 | } |
| 92 | if (!res.ok) { |
| 93 | setError( |
| 94 | typeof body?.message === "string" && body.message.length > 0 ? body.message : "Could not record consent.", |
| 95 | ); |
| 96 | } |
| 97 | } catch (e) { |
| 98 | setError(e instanceof Error ? e.message : "Could not record consent."); |
| 99 | } finally { |
| 100 | inFlightRef.current = false; |
| 101 | setPendingAccept(null); |
| 102 | } |
| 103 | }; |
| 104 | |
| 105 | if (session.isPending || !session.data) return null; |
| 106 | |
| 107 | return ( |
| 108 | <div className="mx-auto max-w-md py-12"> |
| 109 | <h1 className="mb-2 font-display text-2xl font-semibold tracking-tight text-zinc-100">Authorize access</h1> |
| 110 | <p className="text-sm text-zinc-400"> |
| 111 | <strong className="text-zinc-200">{clientInfo?.client_name ?? clientId}</strong> wants to sign you in via |
| 112 | tessera. |
| 113 | </p> |
| 114 | {clientInfo?.client_name ? ( |
| 115 | // Always surface the client_id alongside the name โ operators |
| 116 | // running multiple RPs with similar names need a verifiable |
| 117 | // identifier before granting tokens. |
| 118 | <p className="mb-6 mt-1 font-mono text-xs text-zinc-500" title="OAuth client_id"> |
| 119 | {clientId} |
| 120 | </p> |
| 121 | ) : ( |
| 122 | <div className="mb-6" /> |
| 123 | )} |
| 124 | <Card> |
| 125 | <PageError message={error} onDismiss={() => setError(null)} /> |
| 126 | <p className="mb-2 text-sm text-zinc-300">It will be able to:</p> |
| 127 | <ul className="mb-6 flex flex-col gap-2 text-sm text-zinc-300"> |
| 128 | {scope |
| 129 | .split(" ") |
| 130 | .filter(Boolean) |
| 131 | .map((s) => ( |
| 132 | <li key={s}> |
| 133 | {SCOPE_DESCRIPTIONS[s] ?? "Grants the permission below."}{" "} |
| 134 | <code className="font-mono text-xs tracking-wide text-zinc-500">{s}</code> |
| 135 | </li> |
| 136 | ))} |
| 137 | </ul> |
| 138 | <div className="flex gap-2"> |
| 139 | <Button |
| 140 | variant="secondary" |
| 141 | onClick={() => respond(false)} |
| 142 | loading={pendingAccept === false} |
| 143 | disabled={submitting} |
| 144 | > |
| 145 | Deny |
| 146 | </Button> |
| 147 | <Button onClick={() => respond(true)} loading={pendingAccept === true} disabled={submitting}> |
| 148 | Authorize |
| 149 | </Button> |
| 150 | </div> |
| 151 | </Card> |
| 152 | </div> |
| 153 | ); |
| 154 | }; |