Files
better-auth/docs/content/docs/plugins/device-authorization.mdx
T

931 lines
36 KiB
Plaintext

---
icon: pluginsDeviceAuthorization
title: Device Authorization
description: OAuth 2.0 Device Authorization Grant for limited-input devices
---
`RFC 8628` `CLI` `Smart TV` `IoT`
The Device Authorization plugin implements the code issuance and approval flow from the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) for limited-input devices such as smart TVs, CLI applications, IoT devices, and gaming consoles. Use it on its own for Better Auth session tokens, or compose it with the OAuth Provider to issue OAuth access tokens.
## Try It Out
You can test the device authorization flow right now using the Better Auth CLI:
```package-install
npx auth login
```
This will demonstrate the complete device authorization flow by:
1. Requesting a device code from the Better Auth demo server
2. Displaying a user code for you to enter
3. Opening your browser to the verification page
4. Polling for authorization completion
<Callout type="info">
The CLI login command is a demo feature that connects to the Better Auth demo server to showcase the device authorization flow in action.
</Callout>
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
Add the device authorization plugin to your server configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]
export const auth = betterAuth({
// ... other config
plugins: [
deviceAuthorization({ // [!code highlight]
verificationUri: "/device", // [!code highlight]
}), // [!code highlight]
],
});
```
</Step>
<Step>
### Migrate the database
Run the migration or generate the schema to add the necessary tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
</Step>
<Step>
### Add the client plugin
Add the device authorization plugin to your client.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]
export const authClient = createAuthClient({
plugins: [
deviceAuthorizationClient(), // [!code highlight]
],
});
```
</Step>
</Steps>
## How It Works
The device flow follows these steps:
1. **Device requests codes**: The device requests a device code and user code from the authorization server
2. **User authorizes**: The user visits a verification URL and enters the user code
3. **Device polls for token**: The device polls the server until the user completes authorization
4. **Access granted**: Once authorized, the device receives the session token or OAuth access token provided by the configured integration
### Production security requirements
RFC 8628 requires the device to make outbound HTTPS requests and the user to authenticate at the verification URI in a secure TLS-protected session. In production, serve the verification and approval UI, device-code requests, and token polling over HTTPS. Use HTTP only for explicitly local development.
The approval UI is part of the security boundary. It must:
* ask the user to enter the `user_code`, or, when using `verification_uri_complete` such as a QR code, ask the user to confirm that the displayed code matches the code on the device;
* show what is being authorized: the client, requested scopes, and, when the OAuth Provider integration is enabled, the requested resource or resources;
* require an explicit approval or denial; and
* tell the user that they are authorizing a device in their possession and warn them not to approve unexpected requests or codes supplied through phishing messages.
For additional threat-model and mitigation guidance, see the IETF [Cross-Device Flows: Security Best Current Practice](https://datatracker.ietf.org/doc/draft-ietf-oauth-cross-device-security/).
## Choose the token your device needs
Device authorization can finish with two different token types. Choose the token based on what the client needs to call:
| Use case | Token | Plugins | Token endpoint |
| -------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------ | --------------- |
| Sign your own device into the same Better Auth application | Better Auth session token | `deviceAuthorization()` | `/device/token` |
| Let a registered CLI, TV app, or other public client call an OAuth-protected API | Scoped OAuth access token | `jwt()`, `oauthProvider()`, and `oauthDeviceAuthorization()` | `/oauth2/token` |
A command-line application commonly needs the second path: it cannot keep a client secret, but it needs a user-authorized token for your API. Choose the grant by where the CLI runs:
* **The user has a browser on the same machine**, such as a developer laptop: use the authorization code grant with PKCE and a [loopback redirect](https://www.rfc-editor.org/rfc/rfc8252.html#section-7.3). The CLI listens on `http://127.0.0.1` on any free port, opens the browser at `/oauth2/authorize`, and receives the code on that listener. The user signs in and consents with no code to type or compare. Register the CLI as a `native` client with a loopback redirect URI; [the port may vary](/docs/plugins/oauth-provider#custom-redirect-uri-validation).
* **The CLI cannot open a browser or listen locally**, such as over SSH, in a container, or on a TV: use the device grant below. The user approves in a browser on any device while the CLI polls for an audience-bound OAuth access token.
A CLI can try the loopback flow first and fall back to the device grant when it cannot open a browser.
### Authorize a CLI to call an API
Add [JWT](/docs/plugins/jwt), [OAuth Provider](/docs/plugins/oauth-provider), and the OAuth Device Authorization integration:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import {
oauthDeviceAuthorization,
oauthProvider,
} from "@better-auth/oauth-provider"; // [!code highlight]
export const auth = betterAuth({
plugins: [
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
scopes: ["openid", "profile", "offline_access", "api:read"],
resources: ["https://api.example.com"],
}),
oauthDeviceAuthorization({ // [!code highlight]
verificationUri: "/device",
}),
],
});
```
Register the CLI as a public native client and link it to the API resource. Public clients use `token_endpoint_auth_method: "none"` because an installed CLI cannot keep a shared secret confidential:
```ts title="register-cli.ts"
import { DEVICE_CODE_GRANT_TYPE } from "@better-auth/oauth-provider";
const client = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
application_type: "native",
// Loopback sign-in, with the device grant as the fallback.
grant_types: ["authorization_code", DEVICE_CODE_GRANT_TYPE, "refresh_token"],
redirect_uris: ["http://127.0.0.1/callback"],
scope: "openid profile offline_access api:read",
},
});
await auth.api.adminLinkClientResource({
headers,
params: {
identifier: "https://api.example.com",
client_id: client.client_id,
},
});
```
A CLI that only uses the device grant can leave out `authorization_code` and `redirect_uris`. The provider rejects a `resource` the client is not linked to, because `enforcePerClientResources` defaults to `true`. `adminLinkClientResource` creates that link. To link every newly registered client to the API instead, list it in `clientRegistrationDefaultResources`.
Public clients use `token_endpoint_auth_method: "none"` and must send `client_id`. A confidential client using `client_secret_basic` may authenticate at `/device/code` with an `Authorization: Basic ...` header and omit the body `client_id`; `client_secret_post` sends both values in the form body.
The CLI then requests a device code for the API it intends to call:
```ts title="cli.ts"
import { createAuthClient } from "better-auth/client";
import { oauthDeviceAuthorizationClient } from "@better-auth/oauth-provider/client";
const authBaseURL = "https://auth.example.com/api/auth";
const clientId = "YOUR_CLIENT_ID";
const resource = "https://api.example.com";
const authClient = createAuthClient({
baseURL: authBaseURL,
plugins: [oauthDeviceAuthorizationClient()],
});
const { data } = await authClient.device.code({
client_id: clientId,
scope: "openid profile offline_access api:read",
resource,
});
if (!data) throw new Error("Could not start device authorization");
// Show the link (or a QR code of it) and the code the user should see on the consent page.
console.log(`Open ${data.verification_uri_complete}`);
console.log(`Then check that the page shows the code ${data.user_code}`);
```
While the user signs in and approves the request, repeat the following request every `data.interval` seconds. Add 5 seconds to the interval each time the response is `slow_down`, as [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5) requires.
```ts title="cli.ts"
const response = await fetch(`${authBaseURL}/oauth2/token`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: data.device_code,
client_id: clientId,
}),
});
const tokens = await response.json();
```
The access token is a signed JWT whose `aud` identifies `https://api.example.com`. Send it to that API as `Authorization: Bearer <access_token>`, then verify its signature, issuer, audience, expiry, and required scopes at the API. See [API Server verification](/docs/plugins/oauth-provider#api-server). If the CLI requests `offline_access`, store the returned refresh token in the operating system's secure credential store and use it to renew short-lived access tokens.
<Callout type="warn">
Do not call `authClient.device.token` for this OAuth flow. That client method polls `/device/token` and returns a Better Auth session token. Registered OAuth clients must poll `/oauth2/token`.
</Callout>
`oauthDeviceAuthorization()` owns the Device Authorization endpoints and registers the `urn:ietf:params:oauth:grant-type:device_code` grant with OAuth Provider. The provider validates the registered client, scopes, and [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) resource before creating the request.
<Callout type="info">
Both token paths can coexist. First-party device login keeps using `/device/token`. Registered OAuth clients use `/oauth2/token`; `oauthDeviceAuthorization()` prevents their device codes from being redeemed for a Better Auth session token.
</Callout>
#### Approve the device
The user approves an OAuth device code on the same sign-in, account selection, post-login, and consent pages as an authorization request. Your verification page sends the browser to `/oauth2/authorize` with the user code. A user who opens `verification_uri_complete` already has the code in the URL, so the page can continue without a click:
```ts title="app/device/page.tsx"
const params = new URLSearchParams(location.search);
const userCode = params.get("user_code");
// Continue at once; the consent page still shows the code for the user to compare.
// Stay on the page when the provider sent the user back with `status` or `error`.
if (userCode && !params.has("status") && !params.has("error")) {
location.replace(
`${authBaseURL}/oauth2/authorize?user_code=${encodeURIComponent(userCode)}`,
);
}
```
Otherwise the page collects the code in a form and navigates to the same URL.
The provider reads only `user_code` from this request and takes the client, scopes, and resources from the stored device code. It then:
1. Sends the user to `loginPage` when they are not signed in, before it reads the code, then runs `selectAccount` and `postLogin` as it does for an authorization request.
2. Always shows `consentPage`, even for a client with `skip_consent` or an existing consent, because the user must confirm the device ([RFC 8628 §5.4](https://datatracker.ietf.org/doc/html/rfc8628#section-5.4)). The consent page receives `user_code` in its query. Show it next to the client, the scopes, and the organization the device will act for, so the user can check that it matches the code on their device.
3. Binds the device code to the signed-in user, records the consent, and sends the user to your verification page with `user_code` and `status=approved`, or `status=denied` when they deny on the consent page.
When the code is unknown, expired, or already used, the user returns to your verification page with `user_code`, `error`, and `error_description` instead, so the page can show the problem and keep the code in the input for correction.
`/device`, `/device/approve`, and `/device/deny` refuse OAuth device codes with `invalid_request`, and the description names the `/oauth2/authorize` URL to use. Standalone device codes keep using those endpoints.
#### Grant device access for an organization
Device approvals run `postLogin`, so [`postLogin.consentReferenceId`](/docs/plugins/oauth-provider#post-login-screen) selects the organization the same way it does for an authorization request. The device's access tokens and refresh tokens carry the returned `referenceId`, so `customAccessTokenClaims` receives it on every issuance, including refreshes:
```ts title="auth.ts"
oauthProvider({
// ...
postLogin: {
// `page`, `shouldRedirect`, and `consentReferenceId`, as in Post Login Screen
},
customAccessTokenClaims: ({ referenceId }) => ({
"https://example.com/organization": referenceId,
}),
}),
```
## First-party session flow
The rest of this page describes the first-party path, where `/device/token` returns a Better Auth session token.
### Requesting Device Authorization
To initiate device authorization, call `device.code` with the client ID:
<APIMethod path="/device/code" method="POST">
```ts
type deviceCode = {
/**
* The device client identifier
*/
client_id: string;
/**
* Space-separated list of requested scopes (optional)
*/
scope?: string;
/**
* The user ID to which the device code should be pre-bound.
* When set, only that user can approve or deny the code.
* Pass this from trusted server-side code only. (optional)
*/
user_id?: string;
}
```
</APIMethod>
Example usage:
```ts
import { authClient } from "@/lib/auth-client"
const { data } = await authClient.device.code({
client_id: "your-client-id",
scope: "openid profile email",
});
if (data) {
console.log(`User code: ${data.user_code}`);
console.log(`Verification URL: ${data.verification_uri}`);
console.log(`Complete verification URL: ${data.verification_uri_complete}`);
}
```
### Pre-binding to a User
If your server already knows which user a device belongs to, pass `user_id` when requesting the device code. The code is then bound to that user from the start. It skips the claiming step, and only the bound user can approve or deny it. Any other signed-in user receives an `access_denied` error.
This is useful when the user code is displayed where others can see it, because no one else can claim the code before the intended user verifies it.
```ts title="server.ts"
const data = await auth.api.deviceCode({
body: {
client_id: "your-client-id",
scope: "openid profile email",
user_id: user.id,
},
});
```
<Callout type="warn">
Pass `user_id` from trusted server-side code. The parameter only restricts who can approve the code, so an untrusted device cannot use it to access another user's account. The protection is only meaningful when your server controls how codes are issued.
</Callout>
### Polling for a Better Auth session token
After displaying the user code, poll for the Better Auth session token. The response's `access_token` field contains a Better Auth session token, not an RFC 8628 OAuth access token:
<APIMethod path="/device/token" method="POST">
```ts
type deviceToken = {
/**
* Must be "urn:ietf:params:oauth:grant-type:device_code"
*/
grant_type: string;
/**
* The device code from the initial request
*/
device_code: string;
/**
* The device client identifier
*/
client_id: string;
}
```
</APIMethod>
Example polling implementation:
```ts
// `data` is the response from `authClient.device.code()` above
if (!data) throw new Error("Could not start device authorization");
const { device_code, interval } = data;
let pollingInterval = interval; // Seconds
const pollForToken = async () => {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code,
client_id: yourClientId,
fetchOptions: {
headers: {
"user-agent": `My CLI`,
},
},
});
if (data?.access_token) {
console.log("Authorization successful!");
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling
break;
case "slow_down":
pollingInterval += 5;
break;
case "access_denied":
console.error("Access was denied by the user");
return;
case "expired_token":
console.error("The device code has expired. Please try again.");
return;
default:
console.error(`Error: ${error.error_description}`);
return;
}
setTimeout(pollForToken, pollingInterval * 1000);
}
};
pollForToken();
```
### User Authorization Flow
The user authorization flow requires two steps:
1. **Code Verification**: Validate the user code via `GET /device`. The verification request claims the pending device code for the signed-in user.
2. **Authorization**: The user who claimed the code can approve or deny it.
<Callout type="warn">
Users must be authenticated when calling `GET /device`, because the verification step binds the pending device code to the signed-in user. Only that user can later approve or deny it. If the user is not authenticated when entering the code, redirect them to the login page with a return URL and re-call `GET /device` after sign-in.
</Callout>
With `oauthDeviceAuthorization()`, OAuth device codes are approved through the provider instead. See [Approve the device](#approve-the-device).
Create a page where users can enter their code:
```tsx title="app/device/page.tsx"
export default function DeviceAuthorizationPage() {
const { data: session } = authClient.useSession();
const searchParams = useSearchParams();
const [userCode, setUserCode] = useState(searchParams.get("user_code") || "");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Format the code: remove dashes and convert to uppercase
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
const approvalPath = `/device/approve?user_code=${encodeURIComponent(formattedCode)}`;
if (!session?.user) {
const verificationPath = `/device?user_code=${encodeURIComponent(formattedCode)}`;
window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
return;
}
// Check if the code is valid using GET /device endpoint
const response = await authClient.device({
query: { user_code: formattedCode },
});
if (response.data) {
// Redirect to approval page
window.location.href = approvalPath;
}
} catch (err) {
setError("Invalid or expired code");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Enter device code (e.g., ABCD-1234)"
maxLength={12}
/>
<button type="submit">Continue</button>
{error && <p>{error}</p>}
</form>
);
}
```
### Approving or Denying Device
Users must be authenticated to approve or deny device authorization requests. Only the user who claimed the code with `GET /device`, or the user it was pre-bound to with `user_id`, can decide it, and each code accepts one decision. Approve and deny return one of these errors instead of `success: true`:
| Error Code | When |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `unauthorized` | No user is signed in |
| `invalid_request` | The user code is unknown, has no user yet (it was neither claimed with `GET /device` nor pre-bound with `user_id`), or already has a decision ("Device code already processed"), including from a concurrent approve or deny that finished first |
| `expired_token` | The code has expired |
| `access_denied` | The code belongs to another user |
With `oauthDeviceAuthorization()`, both endpoints refuse OAuth device codes. Approve those through the provider instead. See [Approve the device](#approve-the-device).
#### Approve Device
<APIMethod path="/device/approve" method="POST" requireSession>
```ts
type deviceApprove = {
/**
* The user code to approve
*/
userCode: string;
}
```
</APIMethod>
#### Deny Device
<APIMethod path="/device/deny" method="POST" requireSession>
```ts
type deviceDeny = {
/**
* The user code to deny
*/
userCode: string;
}
```
</APIMethod>
#### Example Approval Page
```tsx title="app/device/approve/page.tsx"
export default function DeviceApprovalPage() {
const { user } = useAuth(); // Must be authenticated
const searchParams = useSearchParams();
const userCode = searchParams.get("user_code");
const [isProcessing, setIsProcessing] = useState(false);
const [request, setRequest] = useState<{
client_id?: string;
scope?: string;
resource?: string | string[];
} | null>(null);
useEffect(() => {
if (!user || !userCode) return;
authClient.device({ query: { user_code: userCode } }).then(({ data }) => {
setRequest(data);
});
}, [user, userCode]);
const handleApprove = async () => {
setIsProcessing(true);
try {
const { error } = await authClient.device.approve({ userCode });
if (error) {
alert(`Failed to approve device: ${error.error_description}`);
return;
}
alert("Device approved successfully!");
window.location.href = "/";
} catch {
alert("Could not reach the server. Try again.");
} finally {
setIsProcessing(false);
}
};
const handleDeny = async () => {
setIsProcessing(true);
try {
const { error } = await authClient.device.deny({ userCode });
if (error) {
alert(`Failed to deny device: ${error.error_description}`);
return;
}
alert("Device denied");
window.location.href = "/";
} catch {
alert("Could not reach the server. Try again.");
} finally {
setIsProcessing(false);
}
};
if (!user) {
// Redirect to login if not authenticated
const verificationPath = `/device?user_code=${encodeURIComponent(userCode || "")}`;
window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
return null;
}
return (
<div>
<h2>Device Authorization Request</h2>
<p>Client: {request?.client_id}</p>
<p>Scopes: {request?.scope || "None"}</p>
<p>
Resources: {Array.isArray(request?.resource)
? request.resource.join(", ")
: request?.resource || "None"}
</p>
<p>Code: {userCode}</p>
<button onClick={handleApprove} disabled={isProcessing}>
Approve
</button>
<button onClick={handleDeny} disabled={isProcessing}>
Deny
</button>
</div>
);
}
```
## Advanced Configuration
### Client Validation
You can validate client IDs to ensure only authorized applications can use the device flow:
When the OAuth Provider integration is enabled, an unknown OAuth client ID reaches the standalone flow only when `validateClient` explicitly accepts it. Otherwise, the request returns `invalid_client`.
```ts
deviceAuthorization({
validateClient: async (clientId) => {
// Check if client is authorized
const client = await db.oauth_clients.findOne({ id: clientId });
return client && client.allowDeviceFlow;
},
onDeviceAuthRequest: async (clientId, scope) => {
// Log device authorization requests
await logDeviceAuthRequest(clientId, scope);
},
})
```
### Custom Code Generation
Customize how device and user codes are generated:
```ts
deviceAuthorization({
generateDeviceCode: async () => {
// Custom device code generation
return crypto.randomBytes(32).toString("hex");
},
generateUserCode: async () => {
// Custom user code generation
// Default uses: ABCDEFGHJKLMNPQRSTUVWXYZ23456789
// (excludes 0, O, 1, I to avoid confusion)
const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < 8; i++) {
code += charset[Math.floor(Math.random() * charset.length)];
}
return code;
},
})
```
Default user codes are case-insensitive and accept whitespace or punctuation inserted for readability when submitted to the verification, approve, or deny endpoints. Custom user codes are matched exactly when they contain characters outside the default `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` alphabet.
Device-code issuance makes up to 3 attempts to overcome unique-key collisions. If all attempts collide, `/device/code` returns `server_error`.
## Error Handling
The device flow defines specific error codes:
| Error Code | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
| `authorization_pending` | User hasn't approved yet (continue polling) |
| `slow_down` | Polled too soon (add 5 seconds to the interval for this and every later poll) |
| `expired_token` | Device code has expired |
| `access_denied` | User denied the authorization |
| `invalid_grant` | Invalid device code or client ID |
## Example: CLI Application
Here's a complete example for a CLI application based on the actual demo:
<Callout type="info">
To use the access token for API requests, ensure you have added the [Bearer plugin](/docs/plugins/bearer) to your auth instance.
</Callout>
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [deviceAuthorizationClient()],
});
async function authenticateCLI() {
console.log("🔐 Better Auth Device Authorization Demo");
console.log("⏳ Requesting device authorization...");
try {
// Request device code
const { data, error } = await authClient.device.code({
client_id: "demo-cli",
scope: "openid profile email",
});
if (error || !data) {
console.error("❌ Error:", error?.error_description);
process.exit(1);
}
const {
device_code,
user_code,
verification_uri,
verification_uri_complete,
interval = 5,
} = data;
console.log("\n📱 Device Authorization in Progress");
console.log(`Please visit: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);
// Open browser to verification page
const urlToOpen = verification_uri_complete || verification_uri;
console.log("🌐 Opening browser...");
await open(urlToOpen);
console.log(`⏳ Waiting for authorization... (polling every ${interval}s)`);
// Poll for token
await pollForToken(device_code, interval);
} catch (err) {
console.error("❌ Error:", err.message);
process.exit(1);
}
}
async function pollForToken(deviceCode: string, interval: number) {
let pollingInterval = interval;
return new Promise<void>((resolve) => {
const poll = async () => {
try {
const { data, error } = await authClient.device.token({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: "demo-cli",
});
if (data?.access_token) {
console.log("\nAuthorization Successful!");
console.log("Access token received!");
// Get user session
const { data: session } = await authClient.getSession({
fetchOptions: {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
},
});
console.log(`Hello, ${session?.user?.name || "User"}!`);
resolve();
process.exit(0);
} else if (error) {
switch (error.error) {
case "authorization_pending":
// Continue polling silently
break;
case "slow_down":
pollingInterval += 5;
console.log(`⚠️ Slowing down polling to ${pollingInterval}s`);
break;
case "access_denied":
console.error("❌ Access was denied by the user");
process.exit(1);
break;
case "expired_token":
console.error("❌ The device code has expired. Please try again.");
process.exit(1);
break;
default:
console.error("❌ Error:", error.error_description);
process.exit(1);
}
}
} catch (err) {
console.error("❌ Network error:", err.message);
process.exit(1);
}
// Schedule next poll
setTimeout(poll, pollingInterval * 1000);
};
// Start polling
setTimeout(poll, pollingInterval * 1000);
});
}
// Run the authentication flow
authenticateCLI().catch((err) => {
console.error("❌ Fatal error:", err);
process.exit(1);
});
```
## Security Considerations
1. **Verification and polling limits**: `/device` is limited to 5 requests over a window equal to the configured device-code lifetime. Token polling enforces each code's interval instead: a poll that arrives sooner than `interval` after the last accepted poll returns `slow_down`
2. **Code Expiration**: Device and user codes expire after the configured time (default: 30 minutes)
3. **Client Validation**: Always validate client IDs in production to prevent unauthorized access
4. **HTTPS and approval UI**: Follow the [RFC 8628 TLS and user-interaction requirements](https://datatracker.ietf.org/doc/html/rfc8628#section-3.3) in production, including explicit approval or denial and [device-possession and remote-phishing guidance](https://datatracker.ietf.org/doc/html/rfc8628#section-5.4)
5. **User Code Format**: User codes use a limited character set (excluding similar-looking characters like 0/O, 1/I) to reduce typing errors
6. **Authentication Required**: Users must be authenticated when calling `GET /device`. The verification step claims the pending device code for the signed-in user, and only that user can later approve or deny it
7. **Pre-binding**: Device codes issued with `user_id` skip the claiming step and can only be approved or denied by that user. Pass `user_id` from trusted server-side code only
## Options
### Server
**verificationUri**: The URL of the verification page where users can enter their device code. Match this to the route of your verification page. Returned as `verification_uri` in the response. Can be an absolute URL (e.g., `https://example.com/device`) or relative path (e.g., `/device`). Default: `/device`.
**expiresIn**: The expiration time for device codes. Default: `"30m"` (30 minutes).
**interval**: The minimum polling interval, returned to the device as `interval` in seconds. Default: `"5s"` (5 seconds).
**userCodeLength**: The length of the user code. Maximum: `191`. Default: `8`.
**deviceCodeLength**: The length of the device code. Maximum: `191`. Default: `40`.
**generateDeviceCode**: Custom function to generate device codes. Returns a string or `Promise<string>` with at most 191 characters.
**generateUserCode**: Custom function to generate user codes. Returns a string or `Promise<string>` with at most 191 characters.
**validateClient**: Function to validate client IDs. Takes a clientId and returns boolean or `Promise<boolean>`.
**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId and optional scope.
### Client
No client-specific configuration options. The plugin adds the following methods:
* **device()**: Verify user code validity
* **device.code()**: Request device and user codes
* **device.token()**: Poll for access token
* **device.approve()**: Approve device (requires authentication)
* **device.deny()**: Deny device (requires authentication)
## Schema
The plugin requires a new table to store device authorization data.
Table Name: `deviceCode`
`deviceCode` and `userCode` have unique indexes because the device token, verification, approval, and denial flows use them as lookup fields. Both values are limited to 191 characters.
<Callout type="warn">
Before applying the migration, resolve duplicate `deviceCode` and `userCode` values on every adapter. MySQL and SQL Server installations must also convert both columns to bounded strings and clean up values longer than 191 characters.
</Callout>
export const deviceCodeTableFields = [
{
name: "id",
type: "string",
description: "Unique identifier for the device authorization request",
isPrimaryKey: true,
},
{
name: "deviceCode",
type: "string",
description: "The device verification code",
isUnique: true,
},
{
name: "userCode",
type: "string",
description: "The user-friendly code for verification",
isUnique: true,
},
{
name: "userId",
type: "string",
description: "The ID of the user who approved/denied",
isOptional: true,
},
{
name: "clientId",
type: "string",
description: "The device client identifier",
isOptional: true,
},
{
name: "scope",
type: "string",
description: "Requested scopes",
isOptional: true,
},
{
name: "status",
type: "string",
description: "Current status: pending, approved, or denied",
},
{
name: "expiresAt",
type: "Date",
description: "When the device code expires",
},
{
name: "lastPolledAt",
type: "Date",
description: "Last time the device polled for status",
isOptional: true,
},
{
name: "pollingInterval",
type: "number",
description: "Minimum milliseconds between polls",
isOptional: true,
},
];
<DatabaseTable name="deviceCode" fields={deviceCodeTableFields} />
`oauthDeviceAuthorization()` extends this table with optional `oauthClientId`, `resources`, `referenceId`, and `authTime` fields. Standalone Device Authorization installations do not add those fields and do not expose the RFC 8707 `resource` request parameter.