Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/app/device/actions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
92 changes: 92 additions & 0 deletions src/app/device/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container size="1" py="9">
<Heading size="5" mb="2">
Device verification
</Heading>
<Text color="gray">
This link is missing its verification challenge. Return to your device
and open the verification URL again.
</Text>
</Container>
);
}

return (
<Container size="1" py="9">
<Card size="3">
<Flex direction="column" gap="3">
<Heading size="5">Verify your device</Heading>
<Text color="gray" size="2">
Enter the code shown in your terminal to finish signing in.
</Text>

{error && (
<Callout.Root color="red" role="alert">
<Callout.Text>
{error === "invalid"
? "That code is incorrect or has expired. Check your terminal and try again."
: "Please enter the code shown in your terminal."}
</Callout.Text>
</Callout.Root>
)}

<form action={acceptDeviceCode}>
<input
type="hidden"
name="device_challenge"
defaultValue={device_challenge}
/>
<Flex direction="column" gap="3">
<TextField.Root
name="user_code"
defaultValue={user_code ?? ""}
placeholder="ABCD-1234"
size="3"
autoFocus
autoComplete="off"
spellCheck={false}
required
aria-label="Device code"
/>
<Button type="submit" size="3">
Continue
</Button>
</Flex>
</form>
</Flex>
</Card>
</Container>
);
}
28 changes: 28 additions & 0 deletions src/app/device/success/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Container size="1" py="9">
<Flex direction="column" align="center" gap="3" pt="9">
<Text color="green" asChild>
<CheckCircledIcon width="48" height="48" />
</Text>
<Heading size="6" align="center">
You&rsquo;re signed in
</Heading>
<Text color="gray" align="center">
Your device has been authorized. You can close this window and return
to your terminal.
</Text>
</Flex>
</Container>
);
}
Loading