feat(oauth-provider): add device authorization grant (RFC 8628) (#10135)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Brent Mitchell
2026-07-23 12:32:32 +00:00
committed by GitHub
co-authored by Gustavo Valverde
parent 430c895490
commit f68044dcfb
11 changed files with 1307 additions and 26 deletions
+10
View File
@@ -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.
@@ -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",
},
});
```
<Callout type="info">
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.
</Callout>
## 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 (
<div>
<h2>Device Authorization Request</h2>
<p>A device is requesting access to your account.</p>
<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}>
@@ -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<boolean>`.
**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",
@@ -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.
@@ -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<Record<string, unknown>>(
"/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 },
@@ -106,7 +106,11 @@ export const deviceAuthorizationOptionsSchema = z.object({
),
onDeviceAuthRequest: z
.custom<
(clientId: string, scope: string | undefined) => void | Promise<void>
(
clientId: string,
scope: string | undefined,
resource?: string | string[],
) => void | Promise<void>
>((val) => typeof val === "function", {
message:
"onDeviceAuthRequest must be a function that returns void or a promise that resolves to void.",
@@ -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<string[] | undefined> {
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),
}
: {}),
});
},
);
@@ -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<typeof deviceCode>;
@@ -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<string, string>) {
return client.$fetch<Record<string, unknown>>("/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<Record<string, unknown>>(
"/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<Record<string, unknown>>("/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<Record<string, unknown>>("/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<Record<string, unknown>>("/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<Record<string, unknown>>("/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<Record<string, unknown>>(
"/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");
});
});
+414
View File
@@ -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<string[] | undefined> {
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<OAuthTokenResponse> {
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<DeviceCodeRecord>({
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<DeviceCodeRecord>({
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;
}
+4
View File
@@ -1,4 +1,8 @@
export { getIssuer } from "./authorize";
export {
DEVICE_CODE_GRANT_TYPE,
deviceCodeGrant,
} from "./device-code";
export { extendOAuthProvider } from "./extensions";
export {
authServerMetadata,
+22 -12
View File
@@ -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<SchemaClient<Scope[]>, "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)) {