Skip to content
File

Blob: src/client/pages/oauth/consent.tsx

typescript155 lines
1import { useQuery } from "@tanstack/react-query";
2import { useEffect, useRef, useState } from "react";
3import { useNavigate, useSearchParams } from "react-router";
4 
5import { Button } from "@/client/components/ui/button";
6import { Card } from "@/client/components/ui/card";
7import { PageError } from "@/client/components/ui/page-error";
8import { useSession } from "@/client/lib/auth-client";
9import { apiJsonRequest } from "@/client/lib/http";
10import { signInPath } from "@/client/lib/navigation";
11import { fetchJson, queryKeys } from "@/client/lib/query";
12 
13interface 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.
24const SCOPE_DESCRIPTIONS: Record<string, string> = {
25 openid: "Confirm your identity.",
26 profile: "Your name and username.",
27 email: "Your email address.",
28};
29 
30export 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};