diff --git a/.changeset/oauth-provider-device-code.md b/.changeset/oauth-provider-device-code.md new file mode 100644 index 0000000000..213ea64b2d --- /dev/null +++ b/.changeset/oauth-provider-device-code.md @@ -0,0 +1,10 @@ +--- +"@better-auth/oauth-provider": minor +"better-auth": minor +--- + +Registered OAuth clients can now use the RFC 8628 device flow to obtain provider-managed OAuth tokens. Add `deviceCodeGrant()` alongside `deviceAuthorization()` and `oauthProvider()`; clients request a code at `/device/code` and exchange it at `/oauth2/token` after the user approves it. OAuth and OpenID discovery now advertise `device_authorization_endpoint`. + +Device authorization requests can bind RFC 8707 resource indicators. `GET /device` now returns the requesting `client_id`, `scope`, and `resource` values to the authenticated user who owns the request, and `onDeviceAuthRequest` receives the resource as its third argument. Token requests can reuse or narrow the approved resource set, but requests that add a resource are rejected. Existing first-party device clients continue to receive Better Auth session tokens from `/device/token`. + +The `deviceCode` table adds an optional `resource` field. Run `npx @better-auth/cli generate` and apply the migration before deploying this update. diff --git a/docs/content/docs/plugins/device-authorization.mdx b/docs/content/docs/plugins/device-authorization.mdx index 0e84efc288..3223e6580a 100644 --- a/docs/content/docs/plugins/device-authorization.mdx +++ b/docs/content/docs/plugins/device-authorization.mdx @@ -98,6 +98,50 @@ The device flow follows these steps: 3. **Device polls for token**: The device polls the server until the user completes authorization 4. **Access granted**: Once authorized, the device receives an access token +## Issuing OAuth access tokens (OAuth Provider integration) + +By default, `/device/token` issues a **Better Auth session token** — ideal when the device is your own first-party app signing a user into your service. + +If you run Better Auth as an [OAuth Provider](/docs/plugins/oauth-provider) and want **third-party / registered OAuth clients** (external CLIs, smart-TV apps) to use the device flow and receive a real **OAuth access token** (scoped, audience-bound, introspectable, optionally a JWT and ID token) instead of a session token, add the `deviceCodeGrant()` companion plugin from `@better-auth/oauth-provider`: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { deviceAuthorization } from "better-auth/plugins"; +import { oauthProvider, deviceCodeGrant } from "@better-auth/oauth-provider"; // [!code highlight] + +export const auth = betterAuth({ + plugins: [ + deviceAuthorization(), + oauthProvider({ /* ... */ }), + deviceCodeGrant(), // [!code highlight] + ], +}); +``` + +This registers the `urn:ietf:params:oauth:grant-type:device_code` grant on the provider's `/oauth2/token` endpoint and advertises `device_authorization_endpoint` (pointing at `/device/code`) in the provider's discovery metadata. + +The flow for a registered OAuth client: + +1. The client requests codes at `/device/code` (the advertised `device_authorization_endpoint`). The provider validates registered clients, scopes, and resource indicators before creating the authorization request. +2. The user signs in and approves at the verification URL. The `GET /device` response includes `client_id`, `scope`, and `resource` only for the user who owns the request, so the approval page can show exactly what is requesting access without exposing that context to unauthenticated or unrelated users. +3. The client polls **`/oauth2/token`** (not `/device/token`) with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `device_code`, and `client_id`. A `resource` parameter may be omitted to use the approved resource set, or may request a subset; it cannot add a resource after approval. + +```ts title="register the client with the device-code grant" +await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: "none", // public client (CLI, TV, IoT) + type: "native", + grant_types: ["urn:ietf:params:oauth:grant-type:device_code"], + scope: "openid profile email", + }, +}); +``` + + + Both flows can coexist. First-party device login keeps using `/device/token` (session token). To prevent a registered OAuth client's device code from being redeemed at `/device/token` for a session token, `deviceCodeGrant()` rejects `/device/token` requests whose `client_id` is a registered OAuth client and directs them to `/oauth2/token`. `deviceCodeGrant()` requires both the `deviceAuthorization()` and `oauthProvider()` plugins. + + ## Basic Usage ### Requesting Device Authorization @@ -115,6 +159,10 @@ To initiate device authorization, call `device.code` with the client ID: * Space-separated list of requested scopes (optional) */ scope?: string; + /** + * RFC 8707 resource indicator(s) to bind to this request (optional) + */ + resource?: string | string[]; /** * The user ID to which the device code should be pre-bound. * When set, only that user can approve or deny the code. @@ -330,6 +378,18 @@ export default function DeviceApprovalPage() { const searchParams = useSearchParams(); const userCode = searchParams.get("user_code"); const [isProcessing, setIsProcessing] = useState(false); + const [request, setRequest] = useState<{ + client_id?: string; + scope?: string; + resource?: string | string[]; + } | null>(null); + + useEffect(() => { + if (!user || !userCode) return; + authClient.device({ query: { user_code: userCode } }).then(({ data }) => { + setRequest(data); + }); + }, [user, userCode]); const handleApprove = async () => { setIsProcessing(true); @@ -370,7 +430,13 @@ export default function DeviceApprovalPage() { return (

Device Authorization Request

-

A device is requesting access to your account.

+

Client: {request?.client_id}

+

Scopes: {request?.scope || "None"}

+

+ Resources: {Array.isArray(request?.resource) + ? request.resource.join(", ") + : request?.resource || "None"} +

Code: {userCode}