docs(device-authorization): gate approval on owner-only request details

Approve stays disabled unless the lookup returned the client ID, which
GET /device includes only for the session that claimed the code. The
demo also discards stale lookups, reports invalid or expired codes, and
both examples show a registered client name instead of the raw ID.
This commit is contained in:
Gustavo Valverde
2026-09-28 13:24:32 +01:00
parent 84193e0514
commit bb0c8c6a9b
2 changed files with 57 additions and 7 deletions
+40 -5
View File
@@ -9,6 +9,14 @@ import { Card } from "@/components/ui/card";
import { useSessionQuery } from "@/data/user/session-query";
import { authClient } from "@/lib/auth-client";
/**
* Client IDs this app registered, mapped to names users recognize. A device
* chooses its own `client_id`, so never present an unknown ID as a trusted name.
*/
const KNOWN_CLIENTS: Record<string, string> = {
"demo-cli": "Better Auth demo CLI",
};
export default function Page() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -18,15 +26,31 @@ export default function Page() {
const [isDenyPending, startDenyTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [request, setRequest] = useState<{
client_id?: string;
client_id: string;
scope?: string;
} | null>(null);
useEffect(() => {
setRequest(null);
setError(null);
if (!session || !userCode) return;
authClient.device({ query: { user_code: userCode } }).then(({ data }) => {
if (data) setRequest(data);
});
let active = true;
void authClient
.device({ query: { user_code: userCode } })
.then(({ data, error }) => {
if (!active) return;
if (!data?.client_id) {
setError(
error?.message ||
"This code is invalid, expired, or was claimed by another session.",
);
return;
}
setRequest({ client_id: data.client_id, scope: data.scope });
});
return () => {
active = false;
};
}, [session, userCode]);
const handleApprove = () => {
@@ -86,7 +110,18 @@ export default function Page() {
<div className="rounded-lg bg-muted p-4">
<p className="text-sm font-medium">Requesting client</p>
<p className="font-mono">{request?.client_id ?? "Loading..."}</p>
{request ? (
<>
<p>
{KNOWN_CLIENTS[request.client_id] ?? "Unrecognized client"}
</p>
<p className="font-mono text-xs text-muted-foreground">
ID sent by the device: {request.client_id}
</p>
</>
) : (
<p>Loading...</p>
)}
</div>
<div className="rounded-lg bg-muted p-4">
@@ -462,6 +462,11 @@ Users must be authenticated to approve or deny device authorization requests:
Avoid approval pages that show only the user code, or that enable Approve before the client and scopes appear. On such a page the user cannot tell a request from their own device apart from one started by someone else, for example a code sent to them in a message. The risk is higher when `validateClient` is not configured, because anyone can then start a request.
```tsx title="app/device/approve/page.tsx"
// Client IDs you registered, mapped to names your users recognize.
const knownClients: Record<string, string> = {
"acme-cli": "Acme CLI",
};
export default function DeviceApprovalPage() {
const { user } = useAuth(); // Must be authenticated
const searchParams = useSearchParams();
@@ -474,10 +479,16 @@ export default function DeviceApprovalPage() {
} | null>(null);
useEffect(() => {
setRequest(null);
if (!user || !userCode) return;
let active = true;
authClient.device({ query: { user_code: userCode } }).then(({ data }) => {
setRequest(data);
// Owner-only details are missing when another session claimed the code.
if (active && data?.client_id) setRequest(data);
});
return () => {
active = false;
};
}, [user, userCode]);
const handleApprove = async () => {
@@ -519,7 +530,11 @@ export default function DeviceApprovalPage() {
return (
<div>
<h2>Device Authorization Request</h2>
<p>Client: {request?.client_id}</p>
<p>
Client: {request?.client_id
? knownClients[request.client_id] ?? `Unrecognized client (${request.client_id})`
: "Loading..."}
</p>
<p>Scopes: {request?.scope || "None"}</p>
<p>
Resources: {Array.isArray(request?.resource)