From 441e6a68fa92d3696d2c485b67b99520fca61351 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 24 Jun 2026 16:18:18 -0700 Subject: [PATCH] feat: add OAuth2 device authorization verification UI Ory Hydra has no hosted UI for the RFC 8628 device flow, so provide one: - /device verification page (reads device_challenge, accepts user_code) - /device (action) PUT /admin/oauth2/auth/requests/device/accept, origin-checks redirect_to, then hands off to the normal login/consent flow - /device/success post-authorization landing page Top-level route so it gets the root but skips the (app) nav/footer/ auth-banner chrome (which assumes a session). Pure Server Components; mirrors the raw-fetch-to-Hydra-admin pattern in lib/actions/proxy-credentials.ts. Wiring (separate, ops): set the Ory project config keys urls.device.verification -> /device and urls.device.success -> /device/success. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/device/actions.ts | 82 +++++++++++++++++++++++++++++ src/app/device/page.tsx | 92 +++++++++++++++++++++++++++++++++ src/app/device/success/page.tsx | 28 ++++++++++ 3 files changed, 202 insertions(+) create mode 100644 src/app/device/actions.ts create mode 100644 src/app/device/page.tsx create mode 100644 src/app/device/success/page.tsx diff --git a/src/app/device/actions.ts b/src/app/device/actions.ts new file mode 100644 index 00000000..9b65ba79 --- /dev/null +++ b/src/app/device/actions.ts @@ -0,0 +1,82 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { CONFIG } from "@/lib/config"; +import { LOGGER } from "@/lib/logging"; + +/** + * Server action backing the device-verification form (RFC 8628). + * + * Ory Hydra has no hosted UI for the device flow, so this app provides one: + * Hydra redirects the user's browser to `urls.device.verification` (our + * `/device` page) with a `device_challenge`, the user enters the `user_code` + * shown in their terminal, and we accept it via Hydra's admin API. Hydra then + * returns a `redirect_to` that runs the normal login/consent flow (reusing the + * Account Experience UIs) before landing on `urls.device.success`. + * + * On a bad/expired code we bounce back to `/device` with an `error` marker so + * the page can render a message — keeping the page a pure Server Component with + * no client-side state. + */ +export async function acceptDeviceCode(formData: FormData): Promise { + const deviceChallenge = String(formData.get("device_challenge") ?? ""); + const userCode = String(formData.get("user_code") ?? "").trim(); + + const { + api: { backendUrl }, + accessToken: adminApiKey, + } = CONFIG.auth; + if (!backendUrl || !adminApiKey) { + throw new Error("Incomplete Ory configuration"); + } + + const back = (error: string) => + `/device?device_challenge=${encodeURIComponent(deviceChallenge)}&error=${error}`; + + if (!deviceChallenge || !userCode) { + redirect(back("missing")); + } + + // Accept the user code via Hydra's admin API. Endpoint/shape mirror the + // login/consent accepts in lib/actions/proxy-credentials.ts. + const resp = await fetch( + `${backendUrl}/admin/oauth2/auth/requests/device/accept?device_challenge=${encodeURIComponent(deviceChallenge)}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${adminApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ user_code: userCode }), + }, + ); + + if (!resp.ok) { + const body = await resp.text(); + LOGGER.error("Device user-code accept failed", { + operation: "acceptDeviceCode", + metadata: { + status: resp.status, + // Hydra error bodies can carry flow detail useful outside production. + body: CONFIG.environment.isProduction ? undefined : body, + }, + }); + redirect(back("invalid")); + } + + const { redirect_to: redirectTo } = (await resp.json()) as { + redirect_to?: string; + }; + if (!redirectTo) { + throw new Error("Device accept returned no redirect_to"); + } + + // Hydra's redirect_to is normally same-origin; refuse to bounce the browser + // to an unexpected host if Hydra is misconfigured (mirrors assertHydraOrigin + // in proxy-credentials.ts). + if (new URL(redirectTo, backendUrl).origin !== new URL(backendUrl).origin) { + throw new Error("Device accept redirected to an untrusted host"); + } + + redirect(redirectTo); +} diff --git a/src/app/device/page.tsx b/src/app/device/page.tsx new file mode 100644 index 00000000..6c9a58b5 --- /dev/null +++ b/src/app/device/page.tsx @@ -0,0 +1,92 @@ +import { + Button, + Callout, + Card, + Container, + Flex, + Heading, + Text, + TextField, +} from "@radix-ui/themes"; +import { acceptDeviceCode } from "./actions"; + +export const metadata = { title: "Verify your device" }; + +/** + * Device-verification page (`urls.device.verification`). Ory Hydra redirects + * here with a `device_challenge`, and `verification_uri_complete` may also pass + * the `user_code` to pre-fill. Pure Server Component: the form posts straight to + * the `acceptDeviceCode` server action. + */ +export default async function DevicePage({ + searchParams, +}: { + searchParams: Promise<{ + device_challenge?: string; + user_code?: string; + error?: string; + }>; +}) { + const { device_challenge, user_code, error } = await searchParams; + + if (!device_challenge) { + return ( + + + Device verification + + + This link is missing its verification challenge. Return to your device + and open the verification URL again. + + + ); + } + + return ( + + + + Verify your device + + Enter the code shown in your terminal to finish signing in. + + + {error && ( + + + {error === "invalid" + ? "That code is incorrect or has expired. Check your terminal and try again." + : "Please enter the code shown in your terminal."} + + + )} + +
+ + + + + +
+
+
+
+ ); +} diff --git a/src/app/device/success/page.tsx b/src/app/device/success/page.tsx new file mode 100644 index 00000000..3a503c1b --- /dev/null +++ b/src/app/device/success/page.tsx @@ -0,0 +1,28 @@ +import { CheckCircledIcon } from "@radix-ui/react-icons"; +import { Container, Flex, Heading, Text } from "@radix-ui/themes"; + +export const metadata = { title: "Device authorized" }; + +/** + * Device-success page (`urls.device.success`). Ory lands the browser here after + * the user has authorized the device; the terminal that started the flow has + * already received its tokens by this point. + */ +export default function DeviceSuccessPage() { + return ( + + + + + + + You’re signed in + + + Your device has been authorized. You can close this window and return + to your terminal. + + + + ); +}