diff --git a/.changeset/oauth-provider-device-code.md b/.changeset/oauth-provider-device-code.md
new file mode 100644
index 0000000000..213ea64b2d
--- /dev/null
+++ b/.changeset/oauth-provider-device-code.md
@@ -0,0 +1,10 @@
+---
+"@better-auth/oauth-provider": minor
+"better-auth": minor
+---
+
+Registered OAuth clients can now use the RFC 8628 device flow to obtain provider-managed OAuth tokens. Add `deviceCodeGrant()` alongside `deviceAuthorization()` and `oauthProvider()`; clients request a code at `/device/code` and exchange it at `/oauth2/token` after the user approves it. OAuth and OpenID discovery now advertise `device_authorization_endpoint`.
+
+Device authorization requests can bind RFC 8707 resource indicators. `GET /device` now returns the requesting `client_id`, `scope`, and `resource` values to the authenticated user who owns the request, and `onDeviceAuthRequest` receives the resource as its third argument. Token requests can reuse or narrow the approved resource set, but requests that add a resource are rejected. Existing first-party device clients continue to receive Better Auth session tokens from `/device/token`.
+
+The `deviceCode` table adds an optional `resource` field. Run `npx @better-auth/cli generate` and apply the migration before deploying this update.
diff --git a/docs/content/docs/plugins/device-authorization.mdx b/docs/content/docs/plugins/device-authorization.mdx
index 0e84efc288..3223e6580a 100644
--- a/docs/content/docs/plugins/device-authorization.mdx
+++ b/docs/content/docs/plugins/device-authorization.mdx
@@ -98,6 +98,50 @@ The device flow follows these steps:
3. **Device polls for token**: The device polls the server until the user completes authorization
4. **Access granted**: Once authorized, the device receives an access token
+## Issuing OAuth access tokens (OAuth Provider integration)
+
+By default, `/device/token` issues a **Better Auth session token** — ideal when the device is your own first-party app signing a user into your service.
+
+If you run Better Auth as an [OAuth Provider](/docs/plugins/oauth-provider) and want **third-party / registered OAuth clients** (external CLIs, smart-TV apps) to use the device flow and receive a real **OAuth access token** (scoped, audience-bound, introspectable, optionally a JWT and ID token) instead of a session token, add the `deviceCodeGrant()` companion plugin from `@better-auth/oauth-provider`:
+
+```ts title="auth.ts"
+import { betterAuth } from "better-auth";
+import { deviceAuthorization } from "better-auth/plugins";
+import { oauthProvider, deviceCodeGrant } from "@better-auth/oauth-provider"; // [!code highlight]
+
+export const auth = betterAuth({
+ plugins: [
+ deviceAuthorization(),
+ oauthProvider({ /* ... */ }),
+ deviceCodeGrant(), // [!code highlight]
+ ],
+});
+```
+
+This registers the `urn:ietf:params:oauth:grant-type:device_code` grant on the provider's `/oauth2/token` endpoint and advertises `device_authorization_endpoint` (pointing at `/device/code`) in the provider's discovery metadata.
+
+The flow for a registered OAuth client:
+
+1. The client requests codes at `/device/code` (the advertised `device_authorization_endpoint`). The provider validates registered clients, scopes, and resource indicators before creating the authorization request.
+2. The user signs in and approves at the verification URL. The `GET /device` response includes `client_id`, `scope`, and `resource` only for the user who owns the request, so the approval page can show exactly what is requesting access without exposing that context to unauthenticated or unrelated users.
+3. The client polls **`/oauth2/token`** (not `/device/token`) with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `device_code`, and `client_id`. A `resource` parameter may be omitted to use the approved resource set, or may request a subset; it cannot add a resource after approval.
+
+```ts title="register the client with the device-code grant"
+await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ token_endpoint_auth_method: "none", // public client (CLI, TV, IoT)
+ type: "native",
+ grant_types: ["urn:ietf:params:oauth:grant-type:device_code"],
+ scope: "openid profile email",
+ },
+});
+```
+
+
+ Both flows can coexist. First-party device login keeps using `/device/token` (session token). To prevent a registered OAuth client's device code from being redeemed at `/device/token` for a session token, `deviceCodeGrant()` rejects `/device/token` requests whose `client_id` is a registered OAuth client and directs them to `/oauth2/token`. `deviceCodeGrant()` requires both the `deviceAuthorization()` and `oauthProvider()` plugins.
+
+
## Basic Usage
### Requesting Device Authorization
@@ -115,6 +159,10 @@ To initiate device authorization, call `device.code` with the client ID:
* Space-separated list of requested scopes (optional)
*/
scope?: string;
+ /**
+ * RFC 8707 resource indicator(s) to bind to this request (optional)
+ */
+ resource?: string | string[];
/**
* The user ID to which the device code should be pre-bound.
* When set, only that user can approve or deny the code.
@@ -330,6 +378,18 @@ export default function DeviceApprovalPage() {
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);
@@ -370,7 +430,13 @@ export default function DeviceApprovalPage() {
return (
Device Authorization Request
-
A device is requesting access to your account.
+
Client: {request?.client_id}
+
Scopes: {request?.scope || "None"}
+
+ Resources: {Array.isArray(request?.resource)
+ ? request.resource.join(", ")
+ : request?.resource || "None"}
+
Code: {userCode}
@@ -398,9 +464,9 @@ deviceAuthorization({
return client && client.allowDeviceFlow;
},
- onDeviceAuthRequest: async (clientId, scope) => {
+ onDeviceAuthRequest: async (clientId, scope, resource) => {
// Log device authorization requests
- await logDeviceAuthRequest(clientId, scope);
+ await logDeviceAuthRequest(clientId, scope, resource);
},
})
```
@@ -604,7 +670,7 @@ authenticateCLI().catch((err) => {
**validateClient**: Function to validate client IDs. Takes a clientId and returns boolean or `Promise`.
-**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId and optional scope.
+**onDeviceAuthRequest**: Hook called when device authorization is requested. Takes clientId, optional scope, and optional resource indicator(s).
### Client
@@ -665,6 +731,12 @@ export const deviceCodeTableFields = [
description: "Requested OAuth scopes",
isOptional: true,
},
+ {
+ name: "resource",
+ type: "string",
+ description: "Requested resource indicator or serialized resource list",
+ isOptional: true,
+ },
{
name: "status",
type: "string",
diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx
index 614f8d3135..a4072de086 100644
--- a/docs/content/docs/plugins/oauth-provider.mdx
+++ b/docs/content/docs/plugins/oauth-provider.mdx
@@ -616,6 +616,10 @@ The default is `0`, which keeps strict replay detection. During the interval, Be
The cached response is stored encrypted on the consumed refresh-token row and includes the replacement refresh token. `expires_in` is recalculated from the cached `expires_at` each time the response is replayed.
+#### Device code grant
+
+The device authorization grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) lets limited-input clients (CLIs, smart TVs, IoT) obtain an OAuth access token. It is opt-in: add the `deviceCodeGrant()` companion plugin (which requires the [Device Authorization](/docs/plugins/device-authorization) plugin) to register the `urn:ietf:params:oauth:grant-type:device_code` grant and advertise `device_authorization_endpoint`. The device requests codes at `/device/code`, the user approves them, and the client polls `/oauth2/token` for a first-class OAuth token. See [Issuing OAuth access tokens](/docs/plugins/device-authorization#issuing-oauth-access-tokens-oauth-provider-integration).
+
### Consent Endpoint
Accept or deny user consent for a set of scopes. Note that when denying scopes, the consent cancels and pre-existing consent remains. To remove consent, delete that user's "oauthConsent" for that client.
diff --git a/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts b/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
index 2ac6a17d7f..6b6ea0810b 100644
--- a/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
+++ b/packages/better-auth/src/plugins/device-authorization/device-authorization.test.ts
@@ -137,7 +137,7 @@ describe("client validation", async () => {
});
describe("device authorization flow", async () => {
- const { auth, signInWithTestUser, db } = await getTestInstance(
+ const { auth, client, signInWithTestUser, db } = await getTestInstance(
{
plugins: [
deviceAuthorization({
@@ -186,6 +186,37 @@ describe("device authorization flow", async () => {
expect(response.device_code).toBeDefined();
expect(response.user_code).toBeDefined();
});
+
+ it("should preserve repeated resources from a form request", async () => {
+ const form = new URLSearchParams({
+ client_id: "test-client",
+ scope: "read",
+ });
+ form.append("resource", "https://api.example.com");
+ form.append("resource", "https://files.example.com");
+
+ const created = await client.$fetch>(
+ "/device/code",
+ {
+ method: "POST",
+ body: form,
+ headers: {
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ },
+ );
+ expect(created.error).toBeNull();
+
+ const { headers } = await signInWithTestUser();
+ const verification = await auth.api.deviceVerify({
+ query: { user_code: created.data!.user_code as string },
+ headers,
+ });
+ expect(verification.resource).toEqual([
+ "https://api.example.com",
+ "https://files.example.com",
+ ]);
+ });
});
describe("device token polling", () => {
@@ -264,20 +295,41 @@ describe("device authorization flow", async () => {
});
describe("device verification", () => {
- it("should verify valid user code", async () => {
+ it("only returns authorization context to the authenticated owner", async () => {
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "test-client",
+ scope: "read write",
+ resource: ["https://api.example.com", "https://files.example.com"],
},
});
+ const anonymousResponse = await auth.api.deviceVerify({
+ query: { user_code },
+ });
+ expect(anonymousResponse).toMatchObject({
+ user_code,
+ status: "pending",
+ });
+ expect(anonymousResponse).not.toHaveProperty("client_id");
+ expect(anonymousResponse).not.toHaveProperty("scope");
+ expect(anonymousResponse).not.toHaveProperty("resource");
+
+ const { headers } = await signInWithTestUser();
const response = await auth.api.deviceVerify({
query: { user_code },
+ headers,
});
expect("error" in response).toBe(false);
if (!("error" in response)) {
expect(response.user_code).toBe(user_code);
expect(response.status).toBe("pending");
+ expect(response.client_id).toBe("test-client");
+ expect(response.scope).toBe("read write");
+ expect(response.resource).toEqual([
+ "https://api.example.com",
+ "https://files.example.com",
+ ]);
}
});
@@ -869,7 +921,7 @@ describe("device authorization ownership gate", () => {
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-cq3f-vc6p-68fh
*/
- it("rejects approve from a different user after another claimed the code", async () => {
+ it("does not expose or authorize a code claimed by a different user", async () => {
const { auth, client, signInWithTestUser, signInWithUser } =
await getTestInstance(
{
@@ -900,7 +952,11 @@ describe("device authorization ownership gate", () => {
);
const { user_code } = await auth.api.deviceCode({
- body: { client_id: "test-client" },
+ body: {
+ client_id: "test-client",
+ scope: "read write",
+ resource: "https://api.example.com",
+ },
});
await auth.api.deviceVerify({
@@ -908,6 +964,14 @@ describe("device authorization ownership gate", () => {
headers: claimerHeaders,
});
+ const verification = await auth.api.deviceVerify({
+ query: { user_code },
+ headers: attackerHeaders,
+ });
+ expect(verification).not.toHaveProperty("client_id");
+ expect(verification).not.toHaveProperty("scope");
+ expect(verification).not.toHaveProperty("resource");
+
await expect(
auth.api.deviceApprove({
body: { userCode: user_code },
diff --git a/packages/better-auth/src/plugins/device-authorization/index.ts b/packages/better-auth/src/plugins/device-authorization/index.ts
index 4835afa370..2da90318ab 100644
--- a/packages/better-auth/src/plugins/device-authorization/index.ts
+++ b/packages/better-auth/src/plugins/device-authorization/index.ts
@@ -106,7 +106,11 @@ export const deviceAuthorizationOptionsSchema = z.object({
),
onDeviceAuthRequest: z
.custom<
- (clientId: string, scope: string | undefined) => void | Promise
+ (
+ clientId: string,
+ scope: string | undefined,
+ resource?: string | string[],
+ ) => void | Promise
>((val) => typeof val === "function", {
message:
"onDeviceAuthRequest must be a function that returns void or a promise that resolves to void.",
diff --git a/packages/better-auth/src/plugins/device-authorization/routes.ts b/packages/better-auth/src/plugins/device-authorization/routes.ts
index ee17d8a024..569462c94a 100644
--- a/packages/better-auth/src/plugins/device-authorization/routes.ts
+++ b/packages/better-auth/src/plugins/device-authorization/routes.ts
@@ -28,6 +28,45 @@ function validateGeneratedCode(code: unknown, label: "device" | "user") {
return code;
}
+function serializeResource(resource: string | string[]): string {
+ return typeof resource === "string" ? resource : JSON.stringify(resource);
+}
+
+function parseStoredResource(
+ resource: string | null | undefined,
+): string | string[] | undefined {
+ if (!resource) return undefined;
+ if (!resource.startsWith("[")) return resource;
+ try {
+ const parsed: unknown = JSON.parse(resource);
+ if (
+ Array.isArray(parsed) &&
+ parsed.every((value): value is string => typeof value === "string")
+ ) {
+ return parsed;
+ }
+ } catch {
+ // Treat legacy/unrecognized stored values as a single resource string.
+ }
+ return resource;
+}
+
+async function extractFormResources(
+ request: Request | undefined,
+): Promise {
+ const contentType = request?.headers.get("content-type")?.toLowerCase() ?? "";
+ if (!request || !contentType.includes("application/x-www-form-urlencoded")) {
+ return undefined;
+ }
+ try {
+ const params = new URLSearchParams(await request.text());
+ if (!params.has("resource")) return undefined;
+ return params.getAll("resource").filter(Boolean);
+ } catch {
+ return undefined;
+ }
+}
+
const deviceCodeBodySchema = z.object({
client_id: z.string().meta({
description: "The client ID of the application",
@@ -44,12 +83,27 @@ const deviceCodeBodySchema = z.object({
description: "Space-separated list of scopes",
})
.optional(),
+ resource: z
+ .union([z.string(), z.array(z.string())])
+ .meta({
+ description:
+ "RFC 8707 resource indicator(s) to bind to this authorization request",
+ })
+ .optional(),
});
const deviceCodeErrorSchema = z.object({
- error: z.enum(["invalid_request", "invalid_client"]).meta({
- description: "Error code",
- }),
+ error: z
+ .enum([
+ "invalid_request",
+ "invalid_client",
+ "unauthorized_client",
+ "invalid_scope",
+ "invalid_target",
+ ])
+ .meta({
+ description: "Error code",
+ }),
error_description: z.string().meta({
description: "Detailed error description",
}),
@@ -73,10 +127,15 @@ export const deviceCode = (opts: DeviceAuthorizationOptions) => {
"/device/code",
{
method: "POST",
+ cloneRequest: true,
body: deviceCodeBodySchema,
error: deviceCodeErrorSchema,
metadata: {
noStore: true,
+ allowedMediaTypes: [
+ "application/json",
+ "application/x-www-form-urlencoded",
+ ],
openapi: {
description: `Request a device and user code
@@ -131,7 +190,13 @@ Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#secti
properties: {
error: {
type: "string",
- enum: ["invalid_request", "invalid_client"],
+ enum: [
+ "invalid_request",
+ "invalid_client",
+ "unauthorized_client",
+ "invalid_scope",
+ "invalid_target",
+ ],
},
error_description: {
type: "string",
@@ -146,6 +211,11 @@ Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#secti
},
},
async (ctx) => {
+ const formResources = await extractFormResources(ctx.request);
+ if (formResources) {
+ ctx.body.resource =
+ formResources.length === 1 ? formResources[0] : formResources;
+ }
if (opts.validateClient) {
const isValid = await opts.validateClient(ctx.body.client_id);
if (!isValid) {
@@ -157,7 +227,11 @@ Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#secti
}
if (opts.onDeviceAuthRequest) {
- await opts.onDeviceAuthRequest(ctx.body.client_id, ctx.body.scope);
+ await opts.onDeviceAuthRequest(
+ ctx.body.client_id,
+ ctx.body.scope,
+ ctx.body.resource,
+ );
}
const deviceCode = await generateDeviceCode();
@@ -176,6 +250,9 @@ Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#secti
pollingInterval: ms(opts.interval),
clientId: ctx.body.client_id,
scope: ctx.body.scope,
+ resource: ctx.body.resource
+ ? serializeResource(ctx.body.resource)
+ : null,
},
});
@@ -545,6 +622,24 @@ export const deviceVerify = createAuthEndpoint(
enum: ["pending", "approved", "denied"],
description: "Current status of the device authorization",
},
+ client_id: {
+ type: "string",
+ description:
+ "The client requesting authorization, returned only to the authenticated user who owns this request",
+ },
+ scope: {
+ type: "string",
+ description:
+ "The requested scopes, returned only to the authenticated user who owns this request",
+ },
+ resource: {
+ oneOf: [
+ { type: "string" },
+ { type: "array", items: { type: "string" } },
+ ],
+ description:
+ "The requested resource indicators, returned only to the authenticated user who owns this request",
+ },
},
},
},
@@ -606,9 +701,19 @@ export const deviceVerify = createAuthEndpoint(
}
}
+ const canReviewRequest =
+ session?.user.id !== undefined &&
+ deviceCodeRecord.userId === session.user.id;
return ctx.json({
user_code: user_code,
status: deviceCodeRecord.status,
+ ...(canReviewRequest
+ ? {
+ client_id: deviceCodeRecord.clientId,
+ scope: deviceCodeRecord.scope,
+ resource: parseStoredResource(deviceCodeRecord.resource),
+ }
+ : {}),
});
},
);
diff --git a/packages/better-auth/src/plugins/device-authorization/schema.ts b/packages/better-auth/src/plugins/device-authorization/schema.ts
index 1e068c3fd8..26d9460749 100644
--- a/packages/better-auth/src/plugins/device-authorization/schema.ts
+++ b/packages/better-auth/src/plugins/device-authorization/schema.ts
@@ -42,6 +42,10 @@ export const schema = {
type: "string",
required: false,
},
+ resource: {
+ type: "string",
+ required: false,
+ },
},
indexes: [{ fields: ["deviceCode"] }, { fields: ["userCode"] }],
},
@@ -58,6 +62,7 @@ const deviceCode = z.object({
pollingInterval: z.number().optional(),
clientId: z.string().optional(),
scope: z.string().optional(),
+ resource: z.string().optional(),
});
export type DeviceCode = z.infer;
diff --git a/packages/oauth-provider/src/device-code.test.ts b/packages/oauth-provider/src/device-code.test.ts
new file mode 100644
index 0000000000..e03a84e44a
--- /dev/null
+++ b/packages/oauth-provider/src/device-code.test.ts
@@ -0,0 +1,589 @@
+import { deviceAuthorization } from "better-auth/plugins/device-authorization";
+import { jwt } from "better-auth/plugins/jwt";
+import { getTestInstance } from "better-auth/test";
+import { decodeJwt } from "jose";
+import { describe, expect, it } from "vitest";
+import { oauthProviderClient } from "./client";
+import { DEVICE_CODE_GRANT_TYPE, deviceCodeGrant } from "./device-code";
+import { oauthProvider } from "./oauth";
+
+const FORM_HEADERS = { "content-type": "application/x-www-form-urlencoded" };
+
+interface TokenErrorBody {
+ status?: number;
+ error?: string;
+ error_description?: string;
+}
+
+describe("oauth-provider device-code grant", async () => {
+ const baseURL = "http://localhost:3000";
+ const resource = "https://api.example.com";
+ const secondResource = "https://files.example.com";
+
+ const { auth, client, db, signInWithTestUser } = await getTestInstance(
+ {
+ baseURL,
+ plugins: [
+ jwt({ jwt: { issuer: baseURL } }),
+ deviceAuthorization({ expiresIn: "5min", interval: "2s" }),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ resources: [resource, secondResource],
+ enforcePerClientResources: false,
+ allowDynamicClientRegistration: true,
+ scopes: ["openid", "profile", "email", "offline_access"],
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ deviceCodeGrant(),
+ ],
+ },
+ {
+ clientOptions: {
+ plugins: [oauthProviderClient()],
+ },
+ },
+ );
+
+ const { user } = await signInWithTestUser();
+
+ /** Registers a public OAuth client able to use the device-code grant. */
+ async function createDeviceClient(
+ grantTypes: string[] = [DEVICE_CODE_GRANT_TYPE],
+ ) {
+ const { headers } = await signInWithTestUser();
+ const created = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ token_endpoint_auth_method: "none",
+ grant_types: grantTypes,
+ scope: "openid profile email",
+ type: "native",
+ },
+ });
+ return created!.client_id;
+ }
+
+ /** Drives the device authorization request and user approval, returning the device code. */
+ async function approvedDeviceCode(
+ clientId: string,
+ scope = "openid profile email",
+ requestedResource?: string | string[],
+ ) {
+ const { headers } = await signInWithTestUser();
+ const { device_code, user_code } = await auth.api.deviceCode({
+ body: { client_id: clientId, scope, resource: requestedResource },
+ });
+ const verification = await auth.api.deviceVerify({
+ query: { user_code },
+ headers,
+ });
+ if (requestedResource !== undefined) {
+ expect(verification.resource).toEqual(requestedResource);
+ }
+ await auth.api.deviceApprove({ body: { userCode: user_code }, headers });
+ return device_code;
+ }
+
+ function pollToken(body: Record) {
+ return client.$fetch>("/oauth2/token", {
+ method: "POST",
+ body: new URLSearchParams(body),
+ headers: FORM_HEADERS,
+ });
+ }
+
+ it("advertises device_authorization_endpoint in discovery metadata", async () => {
+ const authServer =
+ (await auth.api.getOAuthServerConfig()) as unknown as Record<
+ string,
+ unknown
+ >;
+ // Derive from token_endpoint so the assertion is agnostic to basePath.
+ const expectedEndpoint = String(authServer.token_endpoint).replace(
+ "/oauth2/token",
+ "/device/code",
+ );
+ expect(authServer.device_authorization_endpoint).toBe(expectedEndpoint);
+
+ const openid = (await auth.api.getOpenIdConfig()) as unknown as Record<
+ string,
+ unknown
+ >;
+ expect(openid.device_authorization_endpoint).toBe(expectedEndpoint);
+ });
+
+ it("advertises the device_code grant in supported grant types", async () => {
+ const authServer = (await auth.api.getOAuthServerConfig()) as unknown as {
+ grant_types_supported?: string[];
+ };
+ expect(authServer.grant_types_supported).toContain(DEVICE_CODE_GRANT_TYPE);
+ });
+
+ it("issues a real OAuth token for an approved device code", async () => {
+ const clientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(clientId, undefined, resource);
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ resource,
+ });
+
+ expect(res.error).toBeNull();
+ expect(res.data?.token_type).toBe("Bearer");
+ expect(res.data?.scope).toBe("openid profile email");
+
+ // A resource was requested, so the access token is a signed JWT bound to it
+ // (RFC 9068): a real OAuth token, not a Better Auth session token.
+ const accessToken = decodeJwt(res.data!.access_token as string);
+ expect(accessToken.sub).toBe(user.id);
+ expect(accessToken.client_id).toBe(clientId);
+ expect(accessToken.aud).toContain(resource);
+ expect((accessToken.scope as string).split(" ")).toContain("openid");
+
+ // openid scope -> an ID token bound to the same subject.
+ const idToken = decodeJwt(res.data!.id_token as string);
+ expect(idToken.sub).toBe(user.id);
+ expect(idToken.aud).toBe(clientId);
+ });
+
+ /**
+ * @see https://github.com/better-auth/better-auth/pull/10135
+ */
+ it("normalizes whitespace in approved scopes", async () => {
+ const clientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(
+ clientId,
+ " openid\tprofile email ",
+ resource,
+ );
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ resource,
+ });
+
+ expect(res.error).toBeNull();
+ expect(res.data?.scope).toBe("openid profile email");
+ const accessToken = decodeJwt(res.data!.access_token as string);
+ expect(accessToken.scope).toBe("openid profile email");
+ });
+
+ it("validates registered client scopes before creating a device code", async () => {
+ const clientId = await createDeviceClient();
+
+ await expect(
+ auth.api.deviceCode({
+ body: { client_id: clientId, scope: "openid admin" },
+ }),
+ ).rejects.toMatchObject({
+ body: { error: "invalid_scope" },
+ });
+ });
+
+ it("binds repeated form-encoded resources at the device endpoint", async () => {
+ const clientId = await createDeviceClient();
+ const form = new URLSearchParams({
+ client_id: clientId,
+ scope: "openid",
+ });
+ form.append("resource", resource);
+ form.append("resource", secondResource);
+
+ const response = await client.$fetch>(
+ "/device/code",
+ {
+ method: "POST",
+ body: form,
+ headers: FORM_HEADERS,
+ },
+ );
+ expect(response.error).toBeNull();
+
+ const { headers } = await signInWithTestUser();
+ const verification = await auth.api.deviceVerify({
+ query: { user_code: response.data!.user_code as string },
+ headers,
+ });
+ expect(verification.resource).toEqual([resource, secondResource]);
+ });
+
+ it("rejects a resource added after approval without consuming the code", async () => {
+ const clientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(clientId, "openid");
+
+ const widened = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ resource,
+ });
+ expect(widened.error?.status).toBe(400);
+ expect((widened.error as TokenErrorBody)?.error).toBe("invalid_target");
+
+ const stored = await db.findOne<{ deviceCode: string }>({
+ model: "deviceCode",
+ where: [{ field: "deviceCode", value: deviceCode }],
+ });
+ expect(stored?.deviceCode).toBe(deviceCode);
+ });
+
+ it("single-uses the device code (second exchange fails)", async () => {
+ const clientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(clientId);
+
+ const first = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ });
+ expect(first.error).toBeNull();
+
+ const second = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ });
+ expect(second.error?.status).toBe(400);
+ expect((second.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+
+ it("returns authorization_pending before approval", async () => {
+ const clientId = await createDeviceClient();
+ const { device_code } = await auth.api.deviceCode({
+ body: { client_id: clientId, scope: "openid" },
+ });
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: clientId,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("authorization_pending");
+ });
+
+ it("returns slow_down when polling faster than the interval", async () => {
+ const clientId = await createDeviceClient();
+ const { device_code } = await auth.api.deviceCode({
+ body: { client_id: clientId, scope: "openid" },
+ });
+
+ // First poll records lastPolledAt (still pending); the immediate second poll
+ // is inside the 2s interval and must be told to slow down.
+ await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: clientId,
+ });
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: clientId,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("slow_down");
+ });
+
+ it("returns access_denied when the user denies the request", async () => {
+ const clientId = await createDeviceClient();
+ const { headers } = await signInWithTestUser();
+ const { device_code, user_code } = await auth.api.deviceCode({
+ body: { client_id: clientId, scope: "openid" },
+ });
+ await auth.api.deviceVerify({ query: { user_code }, headers });
+ await auth.api.deviceDeny({ body: { userCode: user_code }, headers });
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: clientId,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("access_denied");
+ });
+
+ it("rejects an unknown device code", async () => {
+ const clientId = await createDeviceClient();
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: "does-not-exist",
+ client_id: clientId,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+
+ it("rejects a device code presented by a different client", async () => {
+ const clientId = await createDeviceClient();
+ const otherClientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(clientId);
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: otherClientId,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+
+ it("returns invalid_grant (not invalid_scope) when a narrower-scoped client replays a code", async () => {
+ // Victim code is created for a client with a broad scope set.
+ const victimClientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(
+ victimClientId,
+ "openid profile email",
+ );
+
+ // Attacker client is registered for only `openid`. The ownership check must
+ // fire before scope validation, so replaying the code can't reveal the
+ // victim's requested scopes through an invalid_scope error.
+ const { headers } = await signInWithTestUser();
+ const attacker = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ token_endpoint_auth_method: "none",
+ grant_types: [DEVICE_CODE_GRANT_TYPE],
+ scope: "openid",
+ type: "native",
+ },
+ });
+
+ const res = await pollToken({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: attacker!.client_id,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+
+ it("does not reveal device-code scopes to a confidential client", async () => {
+ const victimClientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(
+ victimClientId,
+ "openid profile email",
+ );
+
+ const { headers } = await signInWithTestUser();
+ const attacker = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ token_endpoint_auth_method: "client_secret_basic",
+ grant_types: [DEVICE_CODE_GRANT_TYPE],
+ scope: "openid",
+ type: "web",
+ },
+ });
+
+ const res = await client.$fetch>("/oauth2/token", {
+ method: "POST",
+ body: new URLSearchParams({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ }),
+ headers: {
+ ...FORM_HEADERS,
+ authorization: `Basic ${Buffer.from(
+ `${attacker!.client_id}:${attacker!.client_secret}`,
+ ).toString("base64")}`,
+ },
+ });
+
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+
+ it("blocks redeeming an OAuth-client device code at the first-party /device/token", async () => {
+ const clientId = await createDeviceClient();
+ const deviceCode = await approvedDeviceCode(clientId);
+
+ // /device/token accepts JSON (not form-encoded), unlike /oauth2/token.
+ const res = await client.$fetch>("/device/token", {
+ method: "POST",
+ body: {
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code: deviceCode,
+ client_id: clientId,
+ },
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ expect((res.error as TokenErrorBody)?.error_description).toContain(
+ "/oauth2/token",
+ );
+ });
+
+ it("still issues a first-party session token for a non-OAuth client at /device/token", async () => {
+ // A plain device client id that is NOT a registered OAuth client keeps the
+ // original first-party device flow (session token), unaffected by the guard.
+ const firstPartyClientId = "first-party-cli";
+ const { headers } = await signInWithTestUser();
+ const { device_code, user_code } = await auth.api.deviceCode({
+ body: { client_id: firstPartyClientId },
+ });
+ await auth.api.deviceVerify({ query: { user_code }, headers });
+ await auth.api.deviceApprove({ body: { userCode: user_code }, headers });
+
+ const res = await client.$fetch>("/device/token", {
+ method: "POST",
+ body: {
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: firstPartyClientId,
+ },
+ });
+ expect(res.error).toBeNull();
+ expect(res.data?.access_token).toBeDefined();
+ expect(res.data?.token_type).toBe("Bearer");
+ });
+});
+
+describe("oauth-provider device-code grant expiry", async () => {
+ const baseURL = "http://localhost:3000";
+
+ const { auth, client, signInWithTestUser } = await getTestInstance(
+ {
+ baseURL,
+ plugins: [
+ jwt({ jwt: { issuer: baseURL } }),
+ deviceAuthorization({ expiresIn: "1s", interval: "1s" }),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ allowDynamicClientRegistration: true,
+ scopes: ["openid", "profile", "email"],
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ deviceCodeGrant(),
+ ],
+ },
+ { clientOptions: { plugins: [oauthProviderClient()] } },
+ );
+
+ it("returns expired_token once the device code has expired", async () => {
+ const { headers } = await signInWithTestUser();
+ const created = await auth.api.adminCreateOAuthClient({
+ headers,
+ body: {
+ token_endpoint_auth_method: "none",
+ grant_types: [DEVICE_CODE_GRANT_TYPE],
+ scope: "openid",
+ type: "native",
+ },
+ });
+ const clientId = created!.client_id;
+ const { device_code } = await auth.api.deviceCode({
+ body: { client_id: clientId, scope: "openid" },
+ });
+
+ // Let the 1s device code lapse, then poll.
+ await new Promise((resolve) => setTimeout(resolve, 1200));
+
+ const res = await client.$fetch>("/oauth2/token", {
+ method: "POST",
+ body: new URLSearchParams({
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: clientId,
+ }),
+ headers: FORM_HEADERS,
+ });
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("expired_token");
+ });
+});
+
+describe("oauth-provider device-code grant reuse", async () => {
+ const baseURL = "http://localhost:3000";
+ const discoveredClientId = "discovered-device-client";
+ const sharedDeviceCodeGrant = deviceCodeGrant();
+ const first = await getTestInstance({
+ baseURL,
+ plugins: [
+ jwt({ jwt: { issuer: baseURL } }),
+ deviceAuthorization(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid"],
+ extensions: [
+ {
+ clientDiscovery: {
+ id: "device-code-reuse-test",
+ matches: (clientId) => clientId === discoveredClientId,
+ resolve: (_ctx, clientId) => ({
+ clientId,
+ public: true,
+ tokenEndpointAuthMethod: "none",
+ grantTypes: [DEVICE_CODE_GRANT_TYPE],
+ scopes: ["openid"],
+ }),
+ },
+ },
+ ],
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ sharedDeviceCodeGrant,
+ ],
+ });
+
+ await getTestInstance({
+ baseURL: "http://localhost:3001",
+ plugins: [
+ jwt({ jwt: { issuer: "http://localhost:3001" } }),
+ deviceAuthorization(),
+ oauthProvider({
+ loginPage: "/login",
+ consentPage: "/consent",
+ scopes: ["openid"],
+ silenceWarnings: {
+ oauthAuthServerConfig: true,
+ openidConfig: true,
+ },
+ }),
+ sharedDeviceCodeGrant,
+ ],
+ });
+
+ it("keeps each auth instance bound to its own provider options", async () => {
+ const { headers } = await first.signInWithTestUser();
+ const { device_code, user_code } = await first.auth.api.deviceCode({
+ body: { client_id: discoveredClientId, scope: "openid" },
+ });
+ await first.auth.api.deviceVerify({ query: { user_code }, headers });
+ await first.auth.api.deviceApprove({
+ body: { userCode: user_code },
+ headers,
+ });
+
+ const res = await first.client.$fetch>(
+ "/device/token",
+ {
+ method: "POST",
+ body: {
+ grant_type: DEVICE_CODE_GRANT_TYPE,
+ device_code,
+ client_id: discoveredClientId,
+ },
+ },
+ );
+
+ expect(res.error?.status).toBe(400);
+ expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
+ });
+});
diff --git a/packages/oauth-provider/src/device-code.ts b/packages/oauth-provider/src/device-code.ts
new file mode 100644
index 0000000000..72106180e4
--- /dev/null
+++ b/packages/oauth-provider/src/device-code.ts
@@ -0,0 +1,414 @@
+import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
+import { BetterAuthError } from "@better-auth/core/error";
+import { APIError, createAuthMiddleware } from "better-auth/api";
+import type { BetterAuthPlugin } from "better-auth/types";
+import { extendOAuthProvider } from "./extensions";
+import { resolveResourcePolicy } from "./resources";
+import { getOAuthProviderApi } from "./token";
+import type {
+ OAuthExtensionGrantHandlerInput,
+ OAuthTokenResponse,
+} from "./types";
+import {
+ getClient,
+ getOAuthProviderPlugin,
+ toResourceList,
+ validateClientScopes,
+} from "./utils";
+import { PACKAGE_VERSION } from "./version";
+
+/**
+ * RFC 8628 device authorization grant type. A registered OAuth client polls the
+ * token endpoint with this `grant_type` to exchange an approved device code for
+ * a first-class OAuth token set.
+ */
+export const DEVICE_CODE_GRANT_TYPE =
+ "urn:ietf:params:oauth:grant-type:device_code";
+
+/**
+ * Path of the device authorization request endpoint contributed by the
+ * `device-authorization` plugin and advertised in provider discovery metadata.
+ */
+const DEVICE_AUTHORIZATION_PATH = "/device/code";
+
+/** Path of the first-party session token endpoint this grant guards. */
+const DEVICE_TOKEN_PATH = "/device/token";
+
+/** Model name of the shared device-code table owned by `device-authorization`. */
+const DEVICE_CODE_MODEL = "deviceCode";
+
+/**
+ * The subset of the `device-authorization` plugin's `deviceCode` row this grant
+ * reads. Declared locally (type-only) so the oauth-provider package takes no
+ * value dependency on the device-authorization plugin: at runtime the row is
+ * resolved by model name through the shared adapter.
+ */
+interface DeviceCodeRecord {
+ id: string;
+ deviceCode: string;
+ userId?: string | null;
+ expiresAt: Date;
+ status: string;
+ lastPolledAt?: Date | null;
+ pollingInterval?: number | null;
+ clientId?: string | null;
+ scope?: string | null;
+ resource?: string | null;
+}
+
+function tokenError(
+ status: "BAD_REQUEST" | "UNAUTHORIZED" | "INTERNAL_SERVER_ERROR",
+ error: string,
+ errorDescription: string,
+): never {
+ throw new APIError(status, {
+ error,
+ error_description: errorDescription,
+ });
+}
+
+function parseScopes(scope: string | null | undefined): string[] {
+ const normalized = scope?.trim();
+ return normalized ? normalized.split(/\s+/) : [];
+}
+
+function parseStoredResource(
+ resource: string | null | undefined,
+): string | string[] | undefined {
+ if (!resource) return undefined;
+ if (!resource.startsWith("[")) return resource;
+ try {
+ const parsed: unknown = JSON.parse(resource);
+ if (
+ Array.isArray(parsed) &&
+ parsed.every((value): value is string => typeof value === "string")
+ ) {
+ return parsed;
+ }
+ } catch {
+ // Treat legacy/unrecognized stored values as a single resource string.
+ }
+ return resource;
+}
+
+async function extractFormResources(
+ request: Request | undefined,
+): Promise {
+ const contentType = request?.headers.get("content-type")?.toLowerCase() ?? "";
+ if (!request || !contentType.includes("application/x-www-form-urlencoded")) {
+ return undefined;
+ }
+ try {
+ const params = new URLSearchParams(await request.text());
+ if (!params.has("resource")) return undefined;
+ return params.getAll("resource").filter(Boolean);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Exchanges an approved RFC 8628 device code for an OAuth token set. Unlike the
+ * device-authorization plugin's `/device/token` (which mints a first-party
+ * session token), this issues a real OAuth token through the provider's shared
+ * issuance: scoped, audience-bound, introspectable, with optional refresh and ID
+ * tokens. The device-code row is owned by the device-authorization plugin; this
+ * handler only reads and atomically consumes it.
+ */
+async function handleDeviceCodeGrant(
+ input: OAuthExtensionGrantHandlerInput,
+): Promise {
+ const { ctx, provider } = input;
+ const body = ctx.body as
+ | { device_code?: string; client_id?: string; resource?: string | string[] }
+ | undefined;
+ const deviceCode = body?.device_code;
+ if (!deviceCode) {
+ tokenError("BAD_REQUEST", "invalid_request", "device_code is required");
+ }
+
+ const record = await ctx.context.adapter.findOne({
+ model: DEVICE_CODE_MODEL,
+ where: [{ field: "deviceCode", value: deviceCode }],
+ });
+ if (!record) {
+ tokenError("BAD_REQUEST", "invalid_grant", "invalid device code");
+ }
+
+ // Confirm the caller owns this device code before any work that depends on the
+ // record. Comparing the request's client_id up front returns a uniform
+ // invalid_grant instead of leaking the recorded scopes: otherwise
+ // authenticateClient validates those scopes against the caller's registered
+ // set, and a narrower-scoped client replaying a stolen device_code would get
+ // invalid_scope. The post-auth check below still covers confidential clients
+ // that send client_id only in the Authorization header.
+ if (
+ record.clientId &&
+ body?.client_id &&
+ record.clientId !== body.client_id
+ ) {
+ tokenError("BAD_REQUEST", "invalid_grant", "Client ID mismatch");
+ }
+
+ // Authenticate before validating the recorded scopes. Public clients (the
+ // common device-flow shape) need only client_id; confidential clients still
+ // authenticate. The grant type is bound by the dispatcher, so a client not
+ // registered for the device-code grant is rejected here as unauthorized_client.
+ // Scope validation is intentionally deferred until ownership is confirmed so
+ // no authentication method can probe another client's requested scopes.
+ const scopes = parseScopes(record.scope);
+ const { client, confirmation } = await provider.authenticateClient({
+ requireCredentials: false,
+ });
+
+ if (record.clientId && record.clientId !== client.clientId) {
+ tokenError("BAD_REQUEST", "invalid_grant", "Client ID mismatch");
+ }
+ validateClientScopes(client, scopes);
+
+ // RFC 8628 §3.5 slow_down: reject polls faster than the advertised interval.
+ if (record.lastPolledAt && record.pollingInterval) {
+ const elapsed = Date.now() - new Date(record.lastPolledAt).getTime();
+ if (elapsed < record.pollingInterval) {
+ tokenError("BAD_REQUEST", "slow_down", "Polling too frequently");
+ }
+ }
+ await ctx.context.adapter.update({
+ model: DEVICE_CODE_MODEL,
+ where: [{ field: "id", value: record.id }],
+ update: { lastPolledAt: new Date() },
+ });
+
+ if (new Date(record.expiresAt) < new Date()) {
+ await ctx.context.adapter.delete({
+ model: DEVICE_CODE_MODEL,
+ where: [{ field: "id", value: record.id }],
+ });
+ tokenError("BAD_REQUEST", "expired_token", "Device code has expired");
+ }
+
+ if (record.status === "pending") {
+ tokenError(
+ "BAD_REQUEST",
+ "authorization_pending",
+ "Authorization request is still pending",
+ );
+ }
+
+ if (record.status === "denied") {
+ await ctx.context.adapter.delete({
+ model: DEVICE_CODE_MODEL,
+ where: [{ field: "id", value: record.id }],
+ });
+ tokenError(
+ "BAD_REQUEST",
+ "access_denied",
+ "Authorization request was denied",
+ );
+ }
+
+ if (record.status === "approved" && record.userId) {
+ const requestedResources = toResourceList(body?.resource);
+ const boundResources = toResourceList(parseStoredResource(record.resource));
+ if (requestedResources) {
+ const boundResourceSet = new Set(boundResources);
+ if (
+ !boundResources ||
+ requestedResources.some((resource) => !boundResourceSet.has(resource))
+ ) {
+ tokenError(
+ "BAD_REQUEST",
+ "invalid_target",
+ "Requested resource was not authorized by the user",
+ );
+ }
+ }
+ const resources = requestedResources ?? boundResources;
+ // Validate the bound resource policy before the approved code is consumed.
+ // Token issuance validates it again while applying its TTL, signing, and
+ // claims policy, but an invalid request must not burn a one-time device code.
+ await resolveResourcePolicy(ctx, input.opts, {
+ resource: resources,
+ clientId: client.clientId,
+ requestedScopes: scopes,
+ });
+
+ // Atomically claim the approved code as the single race gate, mirroring the
+ // device-authorization session flow: concurrent polls contend on this
+ // delete-and-return and only the caller that removes the row issues tokens.
+ const claimed = await ctx.context.adapter.consumeOne({
+ model: DEVICE_CODE_MODEL,
+ where: [
+ { field: "id", value: record.id },
+ { field: "clientId", value: client.clientId },
+ { field: "status", value: "approved" },
+ ],
+ });
+ if (!claimed?.userId) {
+ tokenError("BAD_REQUEST", "invalid_grant", "invalid device code");
+ }
+
+ const user = await ctx.context.internalAdapter.findUserById(claimed.userId);
+ if (!user) {
+ tokenError(
+ "INTERNAL_SERVER_ERROR",
+ "server_error",
+ "User not found for approved device code",
+ );
+ }
+
+ return provider.issueTokens({
+ client,
+ scopes,
+ user,
+ resources,
+ // Forward a sender-constraint a confidential client-auth strategy proved.
+ confirmation,
+ });
+ }
+
+ tokenError(
+ "INTERNAL_SERVER_ERROR",
+ "server_error",
+ "invalid device code status",
+ );
+}
+
+/**
+ * Bridges the {@link https://datatracker.ietf.org/doc/html/rfc8628 RFC 8628}
+ * device authorization grant into the OAuth Provider. Pair it with the
+ * `device-authorization` plugin (which owns the `/device/code` request endpoint,
+ * the user verification flow, and the `deviceCode` table) and the
+ * `oauthProvider` plugin: this registers a `device_code` token grant on
+ * `/oauth2/token` that issues real OAuth tokens for a registered OAuth client,
+ * and advertises `device_authorization_endpoint` in discovery metadata.
+ *
+ * First-party device login (the device-authorization plugin's own
+ * `/device/token`, which mints a Better Auth session token) keeps working
+ * unchanged. To stop a registered OAuth client's device code from being redeemed
+ * there for a session token, a `before` hook rejects `/device/token` requests
+ * whose `client_id` resolves to a registered OAuth client, directing them to
+ * `/oauth2/token`.
+ *
+ * @example
+ * ```ts
+ * const auth = betterAuth({
+ * plugins: [
+ * deviceAuthorization(),
+ * oauthProvider({ ... }),
+ * deviceCodeGrant(),
+ * ],
+ * });
+ * ```
+ */
+export function deviceCodeGrant(): BetterAuthPlugin {
+ return {
+ id: "oauth-provider-device-code",
+ version: PACKAGE_VERSION,
+ init: (ctx: AuthContext) => {
+ if (!ctx.getPlugin("device-authorization")) {
+ throw new BetterAuthError(
+ "deviceCodeGrant requires the device-authorization plugin.",
+ );
+ }
+ const provider = ctx.getPlugin("oauth-provider");
+ if (!provider) {
+ throw new BetterAuthError(
+ "deviceCodeGrant requires the oauth-provider plugin.",
+ );
+ }
+ extendOAuthProvider(ctx, {
+ grants: {
+ [DEVICE_CODE_GRANT_TYPE]: handleDeviceCodeGrant,
+ },
+ metadata: (metadataInput) => ({
+ device_authorization_endpoint: `${metadataInput.ctx.context.baseURL}${DEVICE_AUTHORIZATION_PATH}`,
+ }),
+ });
+ },
+ hooks: {
+ before: [
+ {
+ matcher(ctx) {
+ return ctx.path === DEVICE_AUTHORIZATION_PATH;
+ },
+ handler: createAuthMiddleware(async (ctx) => {
+ const body = ctx.body as
+ | {
+ client_id?: string;
+ scope?: string;
+ resource?: string | string[];
+ }
+ | undefined;
+ if (!body?.client_id) return;
+ const formResources = await extractFormResources(ctx.request);
+ if (formResources) {
+ body.resource =
+ formResources.length === 1 ? formResources[0] : formResources;
+ }
+ const provider = getOAuthProviderPlugin(ctx.context);
+ if (!provider) return;
+ const endpointCtx = ctx as GenericEndpointContext;
+ const oauthClient = await getClient(
+ endpointCtx,
+ provider.options,
+ body.client_id,
+ );
+ // Unknown ids belong to the device-authorization plugin's existing
+ // first-party flow. Registered OAuth clients are authenticated and
+ // authorized before their request is shown to the user.
+ if (!oauthClient) return;
+ const scopes = parseScopes(body.scope);
+ if (body.scope !== undefined) {
+ body.scope = scopes.join(" ");
+ }
+ const api = getOAuthProviderApi(
+ endpointCtx,
+ provider.options,
+ DEVICE_CODE_GRANT_TYPE,
+ );
+ const authenticated = await api.authenticateClient({
+ scopes,
+ requireCredentials: false,
+ });
+ if (authenticated.clientId !== body.client_id) {
+ tokenError("BAD_REQUEST", "invalid_grant", "Client ID mismatch");
+ }
+ await resolveResourcePolicy(endpointCtx, provider.options, {
+ resource: body.resource,
+ clientId: authenticated.clientId,
+ requestedScopes: scopes,
+ });
+ }),
+ },
+ {
+ matcher(ctx) {
+ return ctx.path === DEVICE_TOKEN_PATH;
+ },
+ handler: createAuthMiddleware(async (ctx) => {
+ const clientId = (ctx.body as { client_id?: string } | undefined)
+ ?.client_id;
+ if (!clientId) return;
+ const provider = getOAuthProviderPlugin(ctx.context);
+ if (!provider) return;
+ // A device code minted for a registered OAuth client must yield an
+ // OAuth token at /oauth2/token, never a first-party session token
+ // here. Public/first-party client ids resolve to null and pass.
+ const oauthClient = await getClient(
+ ctx as GenericEndpointContext,
+ provider.options,
+ clientId,
+ );
+ if (oauthClient) {
+ throw new APIError("BAD_REQUEST", {
+ error: "invalid_grant",
+ error_description:
+ "This client is a registered OAuth client. Exchange the device code at the OAuth token endpoint (/oauth2/token).",
+ });
+ }
+ }),
+ },
+ ],
+ },
+ } satisfies BetterAuthPlugin;
+}
diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts
index fad0fc8858..14483748eb 100644
--- a/packages/oauth-provider/src/index.ts
+++ b/packages/oauth-provider/src/index.ts
@@ -1,4 +1,8 @@
export { getIssuer } from "./authorize";
+export {
+ DEVICE_CODE_GRANT_TYPE,
+ deviceCodeGrant,
+} from "./device-code";
export { extendOAuthProvider } from "./extensions";
export {
authServerMetadata,
diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts
index 01ae835f6c..a827725919 100644
--- a/packages/oauth-provider/src/utils/index.ts
+++ b/packages/oauth-provider/src/utils/index.ts
@@ -521,6 +521,27 @@ export function clientAllowsGrant(
return allowedGrants.includes(grantType);
}
+/**
+ * Validates requested scopes against a registered client's allowed scopes.
+ *
+ * @internal
+ */
+export function validateClientScopes(
+ client: Pick, "scopes">,
+ scopes?: string[],
+) {
+ if (!scopes || !client.scopes) return;
+ const validScopes = new Set(client.scopes);
+ for (const scope of scopes) {
+ if (!validScopes.has(scope)) {
+ throw new APIError("BAD_REQUEST", {
+ error_description: `client does not allow scope ${scope}`,
+ error: "invalid_scope",
+ });
+ }
+ }
+}
+
/**
* Resolves the registered client by id and authorizes it: existence, disabled
* state, registered auth method, requested scopes, and grant type. The record is
@@ -615,18 +636,7 @@ export async function validateClientCredentials(
}
}
- // If scopes set, check against client allowed scopes
- if (scopes && client.scopes) {
- const validScopes = new Set(client.scopes);
- for (const sc of scopes) {
- if (!validScopes.has(sc)) {
- throw new APIError("BAD_REQUEST", {
- error_description: `client does not allow scope ${sc}`,
- error: "invalid_scope",
- });
- }
- }
- }
+ validateClientScopes(client, scopes);
// Enforce the client is registered for the requested grant type
if (grantType && !clientAllowsGrant(client, grantType)) {