mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-25 17:11:27 -05:00
feat(oauth-provider): add device authorization grant (RFC 8628) (#10135)
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
co-authored by
Gustavo Valverde
parent
430c895490
commit
f68044dcfb
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export { getIssuer } from "./authorize";
|
||||
export {
|
||||
DEVICE_CODE_GRANT_TYPE,
|
||||
deviceCodeGrant,
|
||||
} from "./device-code";
|
||||
export { extendOAuthProvider } from "./extensions";
|
||||
export {
|
||||
authServerMetadata,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user