From bb0c8c6a9bb6bb232a4f2cf39e2b8a6e4e96f38b Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Mon, 28 Sep 2026 13:24:32 +0100 Subject: [PATCH] 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. --- .../nextjs/app/(auth)/device/approve/page.tsx | 45 ++++++++++++++++--- .../docs/plugins/device-authorization.mdx | 19 +++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/demo/nextjs/app/(auth)/device/approve/page.tsx b/demo/nextjs/app/(auth)/device/approve/page.tsx index 72e5ba34a0..9f920fe7a4 100644 --- a/demo/nextjs/app/(auth)/device/approve/page.tsx +++ b/demo/nextjs/app/(auth)/device/approve/page.tsx @@ -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 = { + "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(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() {

Requesting client

-

{request?.client_id ?? "Loading..."}

+ {request ? ( + <> +

+ {KNOWN_CLIENTS[request.client_id] ?? "Unrecognized client"} +

+

+ ID sent by the device: {request.client_id} +

+ + ) : ( +

Loading...

+ )}
diff --git a/docs/content/docs/plugins/device-authorization.mdx b/docs/content/docs/plugins/device-authorization.mdx index 99db519ddf..aff5409af3 100644 --- a/docs/content/docs/plugins/device-authorization.mdx +++ b/docs/content/docs/plugins/device-authorization.mdx @@ -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 = { + "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 (

Device Authorization Request

-

Client: {request?.client_id}

+

+ Client: {request?.client_id + ? knownClients[request.client_id] ?? `Unrecognized client (${request.client_id})` + : "Loading..."} +

Scopes: {request?.scope || "None"}

Resources: {Array.isArray(request?.resource)