feat(oauth-provider)!: approve device codes through the consent flow (#11610)

This commit is contained in:
Gustavo Valverde
2026-10-15 06:51:01 +01:00
committed by GitHub
parent ec0afdef5a
commit 034da07dbe
19 changed files with 1640 additions and 335 deletions
@@ -0,0 +1,9 @@
---
"@better-auth/oauth-provider": minor
---
If you use `oauthDeviceAuthorization()`, users now approve OAuth device codes on the provider's sign-in, account selection, post-login, and consent pages, the same as an authorization request. Your device verification page sends the browser to `/oauth2/authorize?user_code=<code>`. The consent page always opens for a device and receives `user_code` to display, and afterwards the user returns to your verification page with `status=approved` or `status=denied`, or with `error` and `error_description` when the code is unknown, expired, or already used. Device tokens and their refreshes now carry the `referenceId` from `postLogin.consentReferenceId` and the user's authentication time, and each approval records a consent.
Previously, approval pages called `/device/approve` or `/device/deny` directly, which skipped post-login selection and consent. Those endpoints and `/device` now refuse OAuth device codes with `invalid_request` and name the URL to use, so update your approval page before upgrading. Standalone `deviceAuthorization()` setups are unaffected.
`oauthDeviceAuthorization()` adds optional `referenceId` and `authTime` columns to the `deviceCode` table. Apply them before deploying: run `npx auth@latest migrate` with the built-in Kysely adapter, or run `npx auth@latest generate` and apply the schema with your ORM.
@@ -0,0 +1,5 @@
---
"better-auth": minor
---
If you wrote a custom `DeviceAuthorizationGrant`, rename `assertSessionRedemption` to `assertStandaloneUse`. It now also runs for `/device`, `/device/approve`, and `/device/deny`, and receives `use: "redeem"` or `use: "approve"`. `getVerificationContext`, `verificationOpenAPIProperties`, and the interface's second type parameter were removed, because `/device` no longer returns grant details. A grant that approves codes on its own pages can use the newly exported `findDeviceCodeByUserCode`, `assertDeviceCodePending`, `decideDeviceCode`, and `buildVerificationUris`. Apps that use `deviceAuthorization()` or `oauthDeviceAuthorization()` without a custom grant are unaffected.
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": patch
---
An authorization request with an empty `scope` parameter no longer stores a consent for a scope named `""`. The consent endpoint now ignores empty entries in both the requested and the accepted scopes.
@@ -0,0 +1,5 @@
---
"@better-auth/oauth-provider": minor
---
Plugins that extend OAuth Provider through `extendOAuthProvider()` can register `interactionRequests`: requests other than an authorization request, keyed by a query parameter, that users approve on the provider's sign-in, account selection, post-login, and consent pages. The provider resumes them through `/oauth2/authorize`, can require consent on every approval, and sends errors to a page the request names. `oauthDeviceAuthorization()` uses one keyed by `user_code`.
@@ -121,7 +121,12 @@ Device authorization can finish with two different token types. Choose the token
| Sign your own device into the same Better Auth application | Better Auth session token | `deviceAuthorization()` | `/device/token` |
| Let a registered CLI, TV app, or other public client call an OAuth-protected API | Scoped OAuth access token | `jwt()`, `oauthProvider()`, and `oauthDeviceAuthorization()` | `/oauth2/token` |
A command-line application commonly needs the second path. The CLI cannot safely keep a client secret, and it may not have a reliable redirect listener, but it still needs a user-authorized token for your API. The device grant lets the user approve the request in a browser while the CLI receives an audience-bound OAuth access token.
A command-line application commonly needs the second path: it cannot keep a client secret, but it needs a user-authorized token for your API. Choose the grant by where the CLI runs:
* **The user has a browser on the same machine**, such as a developer laptop: use the authorization code grant with PKCE and a [loopback redirect](https://www.rfc-editor.org/rfc/rfc8252.html#section-7.3). The CLI listens on `http://127.0.0.1` on any free port, opens the browser at `/oauth2/authorize`, and receives the code on that listener. The user signs in and consents with no code to type or compare. Register the CLI as a `native` client with a loopback redirect URI; [the port may vary](/docs/plugins/oauth-provider#custom-redirect-uri-validation).
* **The CLI cannot open a browser or listen locally**, such as over SSH, in a container, or on a TV: use the device grant below. The user approves in a browser on any device while the CLI polls for an audience-bound OAuth access token.
A CLI can try the loopback flow first and fall back to the device grant when it cannot open a browser.
### Authorize a CLI to call an API
@@ -156,18 +161,29 @@ Register the CLI as a public native client and link it to the API resource. Publ
```ts title="register-cli.ts"
import { DEVICE_CODE_GRANT_TYPE } from "@better-auth/oauth-provider";
await auth.api.adminCreateOAuthClient({
const client = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
type: "native",
grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
application_type: "native",
// Loopback sign-in, with the device grant as the fallback.
grant_types: ["authorization_code", DEVICE_CODE_GRANT_TYPE, "refresh_token"],
redirect_uris: ["http://127.0.0.1/callback"],
scope: "openid profile offline_access api:read",
resources: ["https://api.example.com"],
},
});
await auth.api.adminLinkClientResource({
headers,
params: {
identifier: "https://api.example.com",
client_id: client.client_id,
},
});
```
A CLI that only uses the device grant can leave out `authorization_code` and `redirect_uris`. The provider rejects a `resource` the client is not linked to, because `enforcePerClientResources` defaults to `true`. `adminLinkClientResource` creates that link. To link every newly registered client to the API instead, list it in `clientRegistrationDefaultResources`.
Public clients use `token_endpoint_auth_method: "none"` and must send `client_id`. A confidential client using `client_secret_basic` may authenticate at `/device/code` with an `Authorization: Basic ...` header and omit the body `client_id`; `client_secret_post` sends both values in the form body.
The CLI then requests a device code for the API it intends to call:
@@ -193,11 +209,12 @@ const { data } = await authClient.device.code({
if (!data) throw new Error("Could not start device authorization");
console.log(`Open ${data.verification_uri}`);
console.log(`Enter code ${data.user_code}`);
// Show the link (or a QR code of it) and the code the user should see on the consent page.
console.log(`Open ${data.verification_uri_complete}`);
console.log(`Then check that the page shows the code ${data.user_code}`);
```
While the user signs in and approves the request, repeat the following request at the interval returned by the device-code response:
While the user signs in and approves the request, repeat the following request every `data.interval` seconds. Add 5 seconds to the interval each time the response is `slow_down`, as [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628#section-3.5) requires.
```ts title="cli.ts"
const response = await fetch(`${authBaseURL}/oauth2/token`, {
@@ -221,12 +238,57 @@ The access token is a signed JWT whose `aud` identifies `https://api.example.com
Do not call `authClient.device.token` for this OAuth flow. That client method polls `/device/token` and returns a Better Auth session token. Registered OAuth clients must poll `/oauth2/token`.
</Callout>
`oauthDeviceAuthorization()` owns the Device Authorization endpoints and registers the `urn:ietf:params:oauth:grant-type:device_code` grant with OAuth Provider. The provider validates the registered client, scopes, and [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) resource before creating the request. The approval page can show the client, scopes, and resource to the signed-in user.
`oauthDeviceAuthorization()` owns the Device Authorization endpoints and registers the `urn:ietf:params:oauth:grant-type:device_code` grant with OAuth Provider. The provider validates the registered client, scopes, and [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) resource before creating the request.
<Callout type="info">
Both token paths can coexist. First-party device login keeps using `/device/token`. Registered OAuth clients use `/oauth2/token`; `oauthDeviceAuthorization()` prevents their device codes from being redeemed for a Better Auth session token.
</Callout>
#### Approve the device
The user approves an OAuth device code on the same sign-in, account selection, post-login, and consent pages as an authorization request. Your verification page sends the browser to `/oauth2/authorize` with the user code. A user who opens `verification_uri_complete` already has the code in the URL, so the page can continue without a click:
```ts title="app/device/page.tsx"
const params = new URLSearchParams(location.search);
const userCode = params.get("user_code");
// Continue at once; the consent page still shows the code for the user to compare.
// Stay on the page when the provider sent the user back with `status` or `error`.
if (userCode && !params.has("status") && !params.has("error")) {
location.replace(
`${authBaseURL}/oauth2/authorize?user_code=${encodeURIComponent(userCode)}`,
);
}
```
Otherwise the page collects the code in a form and navigates to the same URL.
The provider reads only `user_code` from this request and takes the client, scopes, and resources from the stored device code. It then:
1. Sends the user to `loginPage` when they are not signed in, before it reads the code, then runs `selectAccount` and `postLogin` as it does for an authorization request.
2. Always shows `consentPage`, even for a client with `skip_consent` or an existing consent, because the user must confirm the device ([RFC 8628 §5.4](https://datatracker.ietf.org/doc/html/rfc8628#section-5.4)). The consent page receives `user_code` in its query. Show it next to the client, the scopes, and the organization the device will act for, so the user can check that it matches the code on their device.
3. Binds the device code to the signed-in user, records the consent, and sends the user to your verification page with `user_code` and `status=approved`, or `status=denied` when they deny on the consent page.
When the code is unknown, expired, or already used, the user returns to your verification page with `user_code`, `error`, and `error_description` instead, so the page can show the problem and keep the code in the input for correction.
`/device`, `/device/approve`, and `/device/deny` refuse OAuth device codes with `invalid_request`, and the description names the `/oauth2/authorize` URL to use. Standalone device codes keep using those endpoints.
#### Grant device access for an organization
Device approvals run `postLogin`, so [`postLogin.consentReferenceId`](/docs/plugins/oauth-provider#post-login-screen) selects the organization the same way it does for an authorization request. The device's access tokens and refresh tokens carry the returned `referenceId`, so `customAccessTokenClaims` receives it on every issuance, including refreshes:
```ts title="auth.ts"
oauthProvider({
// ...
postLogin: {
// `page`, `shouldRedirect`, and `consentReferenceId`, as in Post Login Screen
},
customAccessTokenClaims: ({ referenceId }) => ({
"https://example.com/organization": referenceId,
}),
}),
```
## First-party session flow
The rest of this page describes the first-party path, where `/device/token` returns a Better Auth session token.
@@ -373,7 +435,7 @@ The user authorization flow requires two steps:
Users must be authenticated when calling `GET /device`, because the verification step binds the pending device code to the signed-in user. Only that user can later approve or deny it. If the user is not authenticated when entering the code, redirect them to the login page with a return URL and re-call `GET /device` after sign-in.
</Callout>
When the OAuth Provider integration is enabled, `GET /device` also returns the approved resource context to the authenticated user who owns the request. Show that context in the approval UI alongside the client and scopes. Standalone Device Authorization does not return an RFC 8707 `resource` field.
With `oauthDeviceAuthorization()`, OAuth device codes are approved through the provider instead. See [Approve the device](#approve-the-device).
Create a page where users can enter their code:
@@ -439,6 +501,8 @@ Users must be authenticated to approve or deny device authorization requests. On
| `expired_token` | The code has expired |
| `access_denied` | The code belongs to another user |
With `oauthDeviceAuthorization()`, both endpoints refuse OAuth device codes. Approve those through the provider instead. See [Approve the device](#approve-the-device).
#### Approve Device
<APIMethod path="/device/approve" method="POST" requireSession>
@@ -863,4 +927,4 @@ export const deviceCodeTableFields = [
<DatabaseTable name="deviceCode" fields={deviceCodeTableFields} />
`oauthDeviceAuthorization()` extends this table with optional `oauthClientId` and `resources` fields. Standalone Device Authorization installations do not add those fields and do not expose the RFC 8707 `resource` request parameter.
`oauthDeviceAuthorization()` extends this table with optional `oauthClientId`, `resources`, `referenceId`, and `authTime` fields. Standalone Device Authorization installations do not add those fields and do not expose the RFC 8707 `resource` request parameter.
+4 -4
View File
@@ -672,7 +672,7 @@ The cached response is stored encrypted on the consumed refresh-token row and in
#### 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. Add `oauthDeviceAuthorization()` alongside `oauthProvider()` to register the `urn:ietf:params:oauth:grant-type:device_code` token grant and advertise `device_authorization_endpoint`. The integration adds the OAuth client binding and RFC 8707 resource fields; standalone Device Authorization installations remain unchanged. The device requests codes at `/device/code`, the user approves them, and the client polls `/oauth2/token` for a first-class OAuth token. See [Authorize a CLI to call an API](/docs/plugins/device-authorization#authorize-a-cli-to-call-an-api).
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. Add `oauthDeviceAuthorization()` alongside `oauthProvider()` to register the `urn:ietf:params:oauth:grant-type:device_code` token grant and advertise `device_authorization_endpoint`. The integration adds the OAuth client binding, RFC 8707 resource, consent reference, and authentication time fields; standalone Device Authorization installations remain unchanged. The device requests codes at `/device/code`, the user approves them through `/oauth2/authorize?user_code=…` and the same sign-in, account selection, post-login, and consent pages as an authorization request, and the client polls `/oauth2/token` for a first-class OAuth token. See [Authorize a CLI to call an API](/docs/plugins/device-authorization#authorize-a-cli-to-call-an-api).
At `/device/code`, confidential clients authenticate with their registered method. A client using `client_secret_basic` may send an `Authorization: Basic ...` header and omit the body `client_id`; a client using `client_secret_post` sends `client_id` and `client_secret`; and a public client using `none` sends `client_id`. Empty `client_id`, `scope`, `user_id`, and authentication parameters are treated as omitted regardless of order. Repeated non-empty client identification, base request, or authentication parameters and multiple authentication methods return `invalid_request`, while repeated `resource` parameters remain supported.
@@ -1123,7 +1123,7 @@ You don't need to handle anything from your side; when a new session is created,
When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data.
**Note**: Trusted clients with `skip_consent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications.
**Note**: Trusted clients with `skip_consent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications. Device approvals from `oauthDeviceAuthorization()` always show the consent screen, and their query also carries `user_code`; show it so the user can check that it matches the code on their device.
```ts title="auth.ts"
oauthProvider({
@@ -1272,7 +1272,7 @@ oauthProvider({
})
```
The plugin will redirect the user to the `postLogin.page` to provide a prompt for account selection. Upon completion, call `authClient.oauth2.continue`.
The plugin will redirect the user to the `postLogin.page` to provide a prompt for account selection. Upon completion, call `authClient.oauth2.continue`. Device approvals from `oauthDeviceAuthorization()` run this step too, and their tokens carry the `referenceId` it selects.
`shouldRedirect` runs again on continuation with `isContinuation: true`. Use this value to check the selected organization without asking multi-organization users to select again. It is not proof of selection: check the current session and organization membership before returning `false`. Each new authorization request starts with `isContinuation: false`.
@@ -1720,7 +1720,7 @@ Some clients (notably MCP clients) need to connect to your authorization server
### Provider extensions
OAuth companion plugins can extend the provider without changing OAuth Provider core. Use `extendOAuthProvider()` from a plugin `init()` hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, client registration metadata, and client-id discovery sources. The [`@better-auth/cimd`](/docs/plugins/cimd) plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique `id` that is persisted as client provenance and can provide `fetchClientMetadataResource` for resources owned by that discovery, such as a CIMD client's `jwks_uri`. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed.
OAuth companion plugins can extend the provider without changing OAuth Provider core. Use `extendOAuthProvider()` from a plugin `init()` hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, client registration metadata, client-id discovery sources, and interaction requests. An interaction request runs through the `/oauth2/authorize` sign-in, account selection, post-login, and consent pages, and is approved when the user accepts consent instead of issuing an authorization code; `oauthDeviceAuthorization()` uses one keyed by `user_code`. The [`@better-auth/cimd`](/docs/plugins/cimd) plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique `id` that is persisted as client provenance and can provide `fetchClientMetadataResource` for resources owned by that discovery, such as a CIMD client's `jwks_uri`. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed.
```ts title="custom-oauth-extension.ts"
import type { BetterAuthPlugin } from "better-auth";
@@ -0,0 +1,495 @@
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import {
DEVICE_CODE_GRANT_TYPE,
oauthDeviceAuthorization,
oauthProvider,
} from "@better-auth/oauth-provider";
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { getMigrations } from "better-auth/db/migration";
import { toNodeHandler } from "better-auth/node";
import { jwt, organization } from "better-auth/plugins";
import { createRemoteJWKSet, jwtVerify } from "jose";
/**
* Runs an OAuth device approval the way people meet it: a device shows a code,
* a person approves it in a browser on the provider's own pages, and the device
* then calls a separate API. The provider, the API, and the device client are
* separate apps that only talk over HTTP.
*
* @see https://datatracker.ietf.org/doc/html/rfc8628
*/
const SECRET = "device-approval-e2e-secret-0123456789";
const USER = {
name: "Device User",
email: "device-user@example.com",
password: "correct-horse-battery",
};
const API_AUDIENCE = "https://api.example.com";
const ORGANIZATION_CLAIM = "https://example.com/organization";
type Handler = (req: IncomingMessage, res: ServerResponse) => unknown;
async function listen() {
let handler: Handler = (_req, res) => {
res.statusCode = 503;
res.end();
};
const server: Server = createServer((req, res) => handler(req, res));
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, () => {
const address = server.address();
if (address && typeof address === "object") resolve(address.port);
else reject(new Error("Failed to get server port"));
});
});
return {
origin: `http://localhost:${port}`,
setHandler: (next: Handler) => {
handler = next;
},
stop: () =>
new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
/**
* The provider's pages, written the way an integrator would: each one calls
* the documented endpoint and follows the URL it returns.
*/
const PAGES: Record<string, string> = {
"/device": `
<h1>Connect a device</h1>
<p id="status" role="status"></p>
<form id="code-form">
<label>Code shown on your device <input name="user_code" autocomplete="off"></label>
<button>Continue</button>
</form>
<script>
const params = new URLSearchParams(location.search);
const form = document.getElementById("code-form");
const messages = {
approved: "Device connected. You can return to your device.",
denied: "Device request denied.",
};
const continueURL = (code) =>
"/api/auth/oauth2/authorize?user_code=" + encodeURIComponent(code);
if (params.has("user_code") && !params.has("status") && !params.has("error")) {
location.replace(continueURL(params.get("user_code")));
}
form.user_code.value = params.get("user_code") ?? "";
if (messages[params.get("status")]) {
document.getElementById("status").textContent = messages[params.get("status")];
form.hidden = true;
} else if (params.has("error")) {
document.getElementById("status").textContent = params.get("error_description");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
location.href = continueURL(form.user_code.value);
});
</script>`,
"/login": `
<h1>Sign in</h1>
<form id="login">
<label>Email <input name="email" type="email"></label>
<label>Password <input name="password" type="password"></label>
<button>Sign in</button>
</form>
<script>
document.getElementById("login").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const response = await fetch("/api/auth/sign-in/email", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: form.email.value,
password: form.password.value,
oauth_query: location.search.slice(1),
}),
});
location.href = (await response.json()).url;
});
</script>`,
"/select-organization": `
<h1>Choose an organization</h1>
<ul id="organizations"></ul>
<script>
(async () => {
const organizations = await (await fetch("/api/auth/organization/list")).json();
for (const organization of organizations) {
const button = document.createElement("button");
button.textContent = organization.name;
button.addEventListener("click", async () => {
await fetch("/api/auth/organization/set-active", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ organizationId: organization.id }),
});
const response = await fetch("/api/auth/oauth2/continue", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ postLogin: true, oauth_query: location.search.slice(1) }),
});
location.href = (await response.json()).url;
});
const item = document.createElement("li");
item.append(button);
document.getElementById("organizations").append(item);
}
})();
</script>`,
"/consent": `
<h1>Allow <span id="client"></span> to access <span id="organization"></span>?</h1>
<p>Only allow this if the code on your device is <strong id="user-code"></strong></p>
<ul id="scopes"></ul>
<button id="allow">Allow</button>
<button id="deny">Deny</button>
<script>
const params = new URLSearchParams(location.search);
document.getElementById("user-code").textContent = params.get("user_code");
for (const scope of (params.get("scope") ?? "").split(" ").filter(Boolean)) {
const item = document.createElement("li");
item.textContent = scope;
document.getElementById("scopes").append(item);
}
fetch("/api/auth/oauth2/public-client?client_id=" + encodeURIComponent(params.get("client_id")))
.then((response) => response.json())
.then((client) => {
document.getElementById("client").textContent = client.client_name;
});
fetch("/api/auth/organization/get-full-organization")
.then((response) => response.json())
.then((organization) => {
document.getElementById("organization").textContent = organization.name;
});
const answer = async (accept) => {
const response = await fetch("/api/auth/oauth2/consent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ accept, oauth_query: location.search.slice(1) }),
});
location.href = (await response.json()).url;
};
document.getElementById("allow").addEventListener("click", () => answer(true));
document.getElementById("deny").addEventListener("click", () => answer(false));
</script>`,
};
async function startProvider() {
const http = await listen();
const auth = betterAuth({
database: new DatabaseSync(":memory:"),
baseURL: http.origin,
secret: SECRET,
emailAndPassword: { enabled: true },
trustedOrigins: [http.origin],
plugins: [
jwt(),
organization(),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
scopes: ["openid", "api:read"],
resources: [API_AUDIENCE],
enforcePerClientResources: false,
postLogin: {
page: "/select-organization",
shouldRedirect: ({ session }) => !session.activeOrganizationId,
consentReferenceId: ({ session }) => {
const organizationId = session.activeOrganizationId as
| string
| undefined;
if (!organizationId) {
throw new APIError("BAD_REQUEST", {
error: "set_organization",
error_description: "Select an organization",
});
}
return organizationId;
},
},
customAccessTokenClaims: ({ referenceId }) => ({
[ORGANIZATION_CLAIM]: referenceId,
}),
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
}),
oauthDeviceAuthorization({ interval: "1s" }),
],
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
await auth.api.signUpEmail({ body: USER });
const signIn = await auth.api.signInEmail({
body: { email: USER.email, password: USER.password },
returnHeaders: true,
});
const headers = new Headers({
cookie: signIn.headers
.getSetCookie()
.map((cookie) => cookie.split(";")[0])
.join("; "),
});
const acme = await auth.api.createOrganization({
headers,
body: { name: "Acme", slug: "acme" },
});
const research = await auth.api.createOrganization({
headers,
body: { name: "Research", slug: "research" },
});
const cli = await auth.api.adminCreateOAuthClient({
headers,
body: {
client_name: "Acme CLI",
token_endpoint_auth_method: "none",
application_type: "native",
grant_types: [DEVICE_CODE_GRANT_TYPE],
scope: "openid api:read",
},
});
const authHandler = toNodeHandler(auth);
http.setHandler((req, res) => {
if (req.url?.startsWith("/api/auth")) return authHandler(req, res);
const page = PAGES[new URL(req.url ?? "/", http.origin).pathname];
if (!page) {
res.statusCode = 404;
res.end();
return;
}
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(`<!doctype html><html><body><main>${page}</main></body></html>`);
});
return {
origin: http.origin,
authBaseURL: `${http.origin}/api/auth`,
userId: signIn.response.user.id,
organizations: { acme: acme!.id, research: research!.id },
clientId: cli.client_id,
stop: http.stop,
};
}
type Provider = Awaited<ReturnType<typeof startProvider>>;
/** A resource server that only trusts the provider's JWKS. */
async function startApi(provider: Provider) {
const http = await listen();
const jwks = createRemoteJWKSet(new URL(`${provider.authBaseURL}/jwks`));
http.setHandler(async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: provider.authBaseURL,
audience: API_AUDIENCE,
});
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
user: payload.sub,
organization: payload[ORGANIZATION_CLAIM],
}),
);
} catch {
res.statusCode = 401;
res.end();
}
});
return { origin: http.origin, stop: http.stop };
}
/** The device: requests a code, then polls the token endpoint as RFC 8628 says. */
async function startDevice(provider: Provider) {
const form = (body: Record<string, string>) => ({
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body),
});
const response = await fetch(
`${provider.authBaseURL}/device/code`,
form({
client_id: provider.clientId,
scope: "openid api:read",
resource: API_AUDIENCE,
}),
);
const authorization = (await response.json()) as {
device_code: string;
user_code: string;
verification_uri_complete: string;
interval: number;
};
const poll = async () => {
let interval = authorization.interval;
for (;;) {
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
const token = await fetch(
`${provider.authBaseURL}/oauth2/token`,
form({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: authorization.device_code,
client_id: provider.clientId,
}),
);
const body = (await token.json()) as {
access_token?: string;
error?: string;
};
if (body.error === "authorization_pending") continue;
if (body.error === "slow_down") {
interval += 5;
continue;
}
return body;
}
};
return { ...authorization, tokens: poll() };
}
/** Records the provider pages a person sees, ignoring API redirects. */
function trackPages(page: Page) {
const seen: string[] = [];
page.on("framenavigated", (frame) => {
if (frame !== page.mainFrame()) return;
const { pathname } = new URL(frame.url());
if (!pathname.startsWith("/api/")) seen.push(pathname);
});
return seen;
}
test.describe("OAuth device approval in the browser", () => {
let provider: Provider;
let api: Awaited<ReturnType<typeof startApi>>;
test.beforeEach(async () => {
provider = await startProvider();
api = await startApi(provider);
});
test.afterEach(async () => {
await api.stop();
await provider.stop();
});
test("connects a device for the organization the person picks", async ({
page,
}, testInfo) => {
const device = await startDevice(provider);
const pages = trackPages(page);
// The person scans the device's QR code: the code is in the link, so the
// verification page continues without asking them to type or confirm it.
await page.goto(device.verification_uri_complete);
await page.waitForURL(/\/login\?/);
await page.getByLabel("Email").fill(USER.email);
await page.getByLabel("Password").fill(USER.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/select-organization\?/);
await page.getByRole("button", { name: "Research" }).click();
await page.waitForURL(/\/consent\?/);
await expect(page.getByRole("heading")).toHaveText(
"Allow Acme CLI to access Research?",
);
await expect(page.locator("#user-code")).toHaveText(device.user_code);
await expect(page.locator("#scopes li")).toHaveText(["openid", "api:read"]);
await testInfo.attach("consent page", {
body: await page.screenshot(),
contentType: "image/png",
});
await page.getByRole("button", { name: "Allow" }).click();
await page.waitForURL(/\/device\?.*status=approved/);
await expect(page.getByRole("status")).toHaveText(
"Device connected. You can return to your device.",
);
expect(pages).toEqual([
"/device",
"/login",
"/select-organization",
"/consent",
"/device",
]);
const tokens = await device.tokens;
expect(tokens.access_token).toEqual(expect.any(String));
const me = await fetch(`${api.origin}/me`, {
headers: { authorization: `Bearer ${tokens.access_token}` },
});
expect(await me.json()).toEqual({
user: provider.userId,
organization: provider.organizations.research,
});
});
test("brings a mistyped code back to the device page so the person can fix it", async ({
page,
}) => {
const device = await startDevice(provider);
await page.goto(`${provider.origin}/device`);
await page.getByLabel("Code shown on your device").fill("WRONGCODE");
await page.getByRole("button", { name: "Continue" }).click();
// Signing in comes first, so a signed-out visitor learns nothing about codes.
await page.waitForURL(/\/login\?/);
await page.getByLabel("Email").fill(USER.email);
await page.getByLabel("Password").fill(USER.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL(/\/device\?.*error=/);
await expect(page.getByRole("status")).toHaveText("Invalid user code");
await expect(page.getByLabel("Code shown on your device")).toHaveValue(
"WRONGCODE",
);
await page.getByLabel("Code shown on your device").fill(device.user_code);
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Acme" }).click();
await page.getByRole("button", { name: "Allow" }).click();
await page.waitForURL(/status=approved/);
expect((await device.tokens).access_token).toEqual(expect.any(String));
});
test("goes straight to consent for a signed-in person and stops the device on deny", async ({
page,
}) => {
const first = await startDevice(provider);
await page.goto(first.verification_uri_complete);
await page.getByLabel("Email").fill(USER.email);
await page.getByLabel("Password").fill(USER.password);
await page.getByRole("button", { name: "Sign in" }).click();
await page.getByRole("button", { name: "Acme" }).click();
await page.getByRole("button", { name: "Allow" }).click();
await page.waitForURL(/status=approved/);
await first.tokens;
// A second device: the session and organization carry over, and the
// device still needs explicit consent.
const second = await startDevice(provider);
const pages = trackPages(page);
await page.goto(second.verification_uri_complete);
await page.waitForURL(/\/consent\?/);
await expect(page.getByRole("heading")).toHaveText(
"Allow Acme CLI to access Acme?",
);
await expect(page.locator("#user-code")).toHaveText(second.user_code);
await page.getByRole("button", { name: "Deny" }).click();
await page.waitForURL(/\/device\?.*status=denied/);
await expect(page.getByRole("status")).toHaveText("Device request denied.");
expect(pages).toEqual(["/device", "/consent", "/device"]);
expect(await second.tokens).toMatchObject({ error: "access_denied" });
});
});
@@ -171,27 +171,36 @@ test.describe("OAuth device-code grant over HTTP", () => {
);
expect(signUpResponse.ok(), await signUpResponse.text()).toBe(true);
const verificationResponse = await approvalContext.request.get(
`${authBaseURL}/device?user_code=${encodeURIComponent(deviceAuthorizationResult.user_code)}`,
const userCode = deviceAuthorizationResult.user_code;
const consentResponse = await approvalContext.request.get(
`${authBaseURL}/oauth2/authorize?user_code=${encodeURIComponent(userCode)}`,
{ headers: { accept: "application/json" } },
);
expect(verificationResponse.ok(), await verificationResponse.text()).toBe(
true,
expect(consentResponse.ok(), await consentResponse.text()).toBe(true);
const consentURL = new URL(
((await consentResponse.json()) as { url: string }).url,
origin,
);
expect(await verificationResponse.json()).toMatchObject({
status: "pending",
expect(consentURL.pathname).toBe("/consent");
expect(Object.fromEntries(consentURL.searchParams)).toMatchObject({
user_code: userCode,
client_id: registeredClient.client_id,
scope: "openid profile email",
resource,
});
const approvalResponse = await approvalContext.request.post(
`${authBaseURL}/device/approve`,
`${authBaseURL}/oauth2/consent`,
{
headers: { origin },
data: { userCode: deviceAuthorizationResult.user_code },
headers: { origin, accept: "application/json" },
data: { accept: true, oauth_query: consentURL.search.slice(1) },
},
);
expect(approvalResponse.ok(), await approvalResponse.text()).toBe(true);
expect(await approvalResponse.json()).toEqual({
redirect: true,
url: `${origin}/device?user_code=${encodeURIComponent(userCode)}&status=approved`,
});
const standaloneTokenResponse = await fetch(
`${authBaseURL}/device/token`,
@@ -234,8 +234,7 @@ describe("grant request validation", () => {
},
deviceCodeSchemaFields: {},
authorizeRequest: () => undefined,
assertSessionRedemption: () => {},
getVerificationContext: () => undefined,
assertStandaloneUse: () => {},
} satisfies DeviceAuthorizationGrant;
const { auth } = await getTestInstance({
plugins: [deviceAuthorization({ grant })],
@@ -263,8 +262,7 @@ describe("grant request validation", () => {
requestSchemaFields: {},
deviceCodeSchemaFields: {},
authorizeRequest,
assertSessionRedemption: () => {},
getVerificationContext: () => undefined,
assertStandaloneUse: () => {},
} satisfies DeviceAuthorizationGrant;
const { auth } = await getTestInstance({
plugins: [deviceAuthorization({ grant, onDeviceAuthRequest })],
@@ -278,52 +276,6 @@ describe("grant request validation", () => {
});
});
describe("grant verification context", () => {
/**
* @see https://github.com/better-auth/better-auth/pull/10746#discussion_r3760240763
*/
it("does not let grant context overwrite host-owned response fields", async () => {
const grant = {
requestSchemaFields: {},
deviceCodeSchemaFields: {},
authorizeRequest: () => undefined,
assertSessionRedemption: () => {},
getVerificationContext: () => ({
user_code: "grant-user-code",
status: "grant-status",
client_id: "grant-client",
scope: "grant-scope",
grant_field: "grant-value",
}),
} satisfies DeviceAuthorizationGrant;
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
deviceAuthorization({ grant, validateClient: async () => true }),
],
});
const { headers } = await signInWithTestUser();
const { user_code } = await auth.api.deviceCode({
body: {
client_id: "client",
scope: "read",
},
});
const response = await auth.api.deviceVerify({
query: { user_code },
headers,
});
expect(response).toMatchObject({
user_code,
status: "pending",
client_id: "client",
scope: "read",
grant_field: "grant-value",
});
});
});
describe("device authorization flow", async () => {
const { auth, signInWithTestUser, db } = await getTestInstance(
{
@@ -13,9 +13,8 @@ import { ms } from "../../utils/time";
import { PACKAGE_VERSION } from "../../version";
import { DEVICE_AUTHORIZATION_ERROR_CODES } from "./error-codes";
import {
deviceApprove,
deviceCode,
deviceDeny,
deviceDecisionEndpoint,
deviceToken,
deviceVerify,
} from "./routes";
@@ -154,7 +153,6 @@ export interface DeviceAuthorizationGrantAuthorization {
*/
export interface DeviceAuthorizationGrant<
RequestFields extends z.ZodRawShape = z.ZodRawShape,
VerificationContext extends Record<string, unknown> = Record<string, unknown>,
> {
/** Additional request fields accepted only when this grant is configured. */
requestSchemaFields: RequestFields;
@@ -176,17 +174,16 @@ export interface DeviceAuthorizationGrant<
| DeviceAuthorizationGrantAuthorization
| undefined
| Promise<DeviceAuthorizationGrantAuthorization | undefined>;
/** Refuse the standalone session-token endpoint for grant-owned codes. */
assertSessionRedemption: (input: {
/**
* Throw when a code this grant issued reaches a standalone endpoint:
* `/device/token` (`redeem`) or `/device`, `/device/approve`, `/device/deny`
* (`approve`). The error should name the endpoint to use instead.
*/
assertStandaloneUse: (input: {
ctx: GenericEndpointContext;
deviceCode: Record<string, unknown>;
use: "redeem" | "approve";
}) => void | Promise<void>;
/** Add grant-owned information to the owner-only verification response. */
getVerificationContext: (
deviceCode: Record<string, unknown>,
) => VerificationContext | undefined;
/** OpenAPI properties matching `getVerificationContext()`. */
verificationOpenAPIProperties?: Record<string, Record<string, unknown>>;
}
export type DeviceAuthorizationPluginOptions<
@@ -201,12 +198,6 @@ const deviceAuthorizationRequestFields = new Set([
"user_id",
"scope",
]);
const deviceVerificationResponseFields = new Set([
"user_code",
"status",
"client_id",
"scope",
]);
function assertGrantFieldsAreAdditional(
grant: DeviceAuthorizationGrant | undefined,
@@ -228,15 +219,6 @@ function assertGrantFieldsAreAdditional(
`Device authorization grant request fields must be additional and cannot redefine request fields: ${conflictingRequestFields.join(", ")}`,
);
}
const conflictingVerificationFields = Object.keys(
grant?.verificationOpenAPIProperties ?? {},
).filter((field) => deviceVerificationResponseFields.has(field));
if (conflictingVerificationFields.length > 0) {
throw new BetterAuthError(
`Device authorization grant verification fields must be additional and cannot redefine response fields: ${conflictingVerificationFields.join(", ")}`,
);
}
}
export const deviceAuthorization = <
@@ -268,8 +250,8 @@ export const deviceAuthorization = <
deviceCode: deviceCode(opts, grant),
deviceToken: deviceToken(opts, grant),
deviceVerify: deviceVerify(grant),
deviceApprove,
deviceDeny,
deviceApprove: deviceDecisionEndpoint("/device/approve", grant),
deviceDeny: deviceDecisionEndpoint("/device/deny", grant),
},
rateLimit: [
{
@@ -287,8 +269,12 @@ export const deviceAuthorization = <
export type * from "../../utils/time";
export {
assertDeviceCodePending,
buildVerificationUris,
type DeviceCodeRedemptionAuthorization,
type DeviceCodeRedemptionResult,
decideDeviceCode,
findDeviceCodeByUserCode,
redeemDeviceCode,
} from "./routes";
export type { DeviceCode } from "./schema";
@@ -81,7 +81,7 @@ function normalizeUserCode(userCode: string) {
/**
* Preserve exact custom user codes, and normalize only default-alphabet codes.
*/
async function findDeviceCodeByUserCode(
export async function findDeviceCodeByUserCode(
ctx: GenericEndpointContext,
userCode: string,
) {
@@ -122,7 +122,7 @@ const deviceCodeAlreadyProcessed = () =>
});
/** Throws unless the device code is unexpired and still awaiting a decision. */
function assertDeviceCodePending(deviceCode: DeviceCode) {
export function assertDeviceCodePending(deviceCode: DeviceCode) {
if (deviceCode.expiresAt < new Date()) {
throw expiredUserCode();
}
@@ -132,13 +132,75 @@ function assertDeviceCodePending(deviceCode: DeviceCode) {
}
/**
* Records the signed-in owner's approval or denial of a pending device code.
* The write is guarded on the pending status, owner, and expiry, so a decision
* that loses a race with another approve or deny, or outlasts the code, fails
* instead of overwriting it.
* Records `userId`'s approval or denial of a pending, unexpired device code,
* binding an unclaimed code to that user in the same write. The write is
* guarded, so a decision that loses a race with another decision, or outlasts
* the code, fails instead of overwriting it.
*/
export async function decideDeviceCode(
ctx: GenericEndpointContext,
input: {
deviceCode: DeviceCode;
userId: string;
decision: "approved" | "denied";
/**
* Fields to persist with the decision, such as grant-owned fields or a
* scope the user narrowed on a consent page.
*/
fields?: Record<string, unknown> | undefined;
},
) {
const { deviceCode, userId, decision } = input;
if (deviceCode.userId && deviceCode.userId !== userId) {
throw new APIError("FORBIDDEN", {
error: "access_denied",
error_description: `You are not authorized to ${decision === "approved" ? "approve" : "deny"} this device authorization`,
});
}
const decided = await ctx.context.adapter.incrementOne<DeviceCode>({
model: "deviceCode",
where: [
{ field: "id", value: deviceCode.id },
{ field: "status", value: "pending" },
{ field: "userId", operator: "eq", value: deviceCode.userId ?? null },
{ field: "expiresAt", operator: "gt", value: new Date() },
],
increment: {},
set: { ...input.fields, userId, status: decision },
});
if (!decided) {
// The guard treats a code as expired from `expiresAt` on.
throw deviceCode.expiresAt <= new Date()
? expiredUserCode()
: deviceCodeAlreadyProcessed();
}
}
/**
* Finds the code a standalone endpoint (`/device`, `/device/approve`,
* `/device/deny`) acts on, refusing codes that a grant approves itself.
*/
async function findStandaloneDeviceCode(
ctx: GenericEndpointContext,
grant: DeviceAuthorizationGrant | undefined,
userCode: string,
) {
const deviceCode = await findDeviceCodeByUserCode(ctx, userCode);
if (!deviceCode) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message,
});
}
await grant?.assertStandaloneUse({ ctx, deviceCode, use: "approve" });
return deviceCode;
}
/** Handles `/device/approve` and `/device/deny` for a code the user claimed. */
async function recordDeviceCodeDecision(
ctx: GenericEndpointContext,
grant: DeviceAuthorizationGrant | undefined,
userCode: string,
decision: "approved" | "denied",
) {
@@ -151,15 +213,7 @@ async function recordDeviceCodeDecision(
});
}
const deviceCodeRecord = await findDeviceCodeByUserCode(ctx, userCode);
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message,
});
}
const deviceCodeRecord = await findStandaloneDeviceCode(ctx, grant, userCode);
assertDeviceCodePending(deviceCodeRecord);
if (!deviceCodeRecord.userId) {
@@ -170,30 +224,11 @@ async function recordDeviceCodeDecision(
});
}
if (deviceCodeRecord.userId !== session.user.id) {
throw new APIError("FORBIDDEN", {
error: "access_denied",
error_description: `You are not authorized to ${decision === "approved" ? "approve" : "deny"} this device authorization`,
});
}
const decided = await ctx.context.adapter.incrementOne<DeviceCode>({
model: "deviceCode",
where: [
{ field: "id", value: deviceCodeRecord.id },
{ field: "status", value: "pending" },
{ field: "userId", value: session.user.id },
{ field: "expiresAt", operator: "gt", value: new Date() },
],
increment: {},
set: { status: decision },
await decideDeviceCode(ctx, {
deviceCode: deviceCodeRecord,
userId: session.user.id,
decision,
});
if (!decided) {
// The guard treats a code as expired from `expiresAt` on.
throw deviceCodeRecord.expiresAt <= new Date()
? expiredUserCode()
: deviceCodeAlreadyProcessed();
}
}
const deviceCodeBodySchema = z.object({
@@ -272,25 +307,6 @@ type GrantRequestErrorCodes<
? ErrorCodes
: readonly [];
type GrantVerificationContext<
Grant extends DeviceAuthorizationGrant | undefined,
> =
Grant extends DeviceAuthorizationGrant<
infer _RequestFields,
infer VerificationContext
>
? VerificationContext
: Record<never, never>;
type DeviceVerificationResponse<
Grant extends DeviceAuthorizationGrant | undefined,
> = {
user_code: string;
status: string;
client_id?: string | undefined;
scope?: string | undefined;
} & Partial<GrantVerificationContext<Grant>>;
export const deviceCode = <Grant extends DeviceAuthorizationGrant | undefined>(
opts: DeviceAuthorizationOptions,
grant: Grant,
@@ -826,9 +842,10 @@ Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#secti
error_description: "Client ID mismatch",
});
}
await grant?.assertSessionRedemption({
await grant?.assertStandaloneUse({
ctx,
deviceCode: deviceCodeRecord,
use: "redeem",
});
return {
ownershipWhere: { field: "clientId", value: client_id },
@@ -871,11 +888,7 @@ Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#secti
},
);
export const deviceVerify = <
Grant extends DeviceAuthorizationGrant | undefined,
>(
grant: Grant,
) =>
export const deviceVerify = (grant: DeviceAuthorizationGrant | undefined) =>
createAuthEndpoint(
"/device",
{
@@ -924,7 +937,6 @@ export const deviceVerify = <
description:
"The requested scopes, returned only to the authenticated user who owns this request",
},
...grant?.verificationOpenAPIProperties,
},
},
},
@@ -936,22 +948,14 @@ export const deviceVerify = <
},
async (ctx) => {
const { user_code } = ctx.query;
const deviceCodeRecord = await findDeviceCodeByUserCode(ctx, user_code);
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE.message,
});
}
const deviceCodeRecord = await findStandaloneDeviceCode(
ctx,
grant,
user_code,
);
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE.message,
});
throw expiredUserCode();
}
const session = await getSessionFromCtx(ctx);
@@ -979,13 +983,7 @@ export const deviceVerify = <
const canReviewRequest =
session?.user.id !== undefined &&
deviceCodeRecord.userId === session.user.id;
const grantContext = (
canReviewRequest
? grant?.getVerificationContext(deviceCodeRecord)
: undefined
) as GrantVerificationContext<Grant> | undefined;
const response = {
...(canReviewRequest ? grantContext : undefined),
return ctx.json({
user_code: user_code,
status: deviceCodeRecord.status,
...(canReviewRequest
@@ -994,16 +992,16 @@ export const deviceVerify = <
scope: deviceCodeRecord.scope,
}
: {}),
} as DeviceVerificationResponse<Grant>;
return ctx.json(response);
});
},
);
/** `/device/approve` and `/device/deny`, which differ only in the decision. */
const deviceDecisionEndpoint = <
export const deviceDecisionEndpoint = <
Path extends "/device/approve" | "/device/deny",
>(
path: Path,
grant: DeviceAuthorizationGrant | undefined,
) => {
const decision = path === "/device/approve" ? "approved" : "denied";
const action = decision === "approved" ? "Approve" : "Deny";
@@ -1056,7 +1054,7 @@ const deviceDecisionEndpoint = <
},
},
async (ctx) => {
await recordDeviceCodeDecision(ctx, ctx.body.userCode, decision);
await recordDeviceCodeDecision(ctx, grant, ctx.body.userCode, decision);
return ctx.json({
success: true,
});
@@ -1064,14 +1062,11 @@ const deviceDecisionEndpoint = <
);
};
export const deviceApprove = deviceDecisionEndpoint("/device/approve");
export const deviceDeny = deviceDecisionEndpoint("/device/deny");
/**
* @internal
* Builds the `verification_uri` and `verification_uri_complete` for a user code,
* resolving a relative `verificationUri` against the auth base URL.
*/
const buildVerificationUris = (
export const buildVerificationUris = (
verificationUri: string | undefined,
baseURL: string,
userCode: string,
@@ -13,8 +13,7 @@ function schemaGrant(
requestSchemaFields: {},
deviceCodeSchemaFields,
authorizeRequest: () => undefined,
assertSessionRedemption: () => undefined,
getVerificationContext: () => undefined,
assertStandaloneUse: () => undefined,
};
}
@@ -127,17 +126,6 @@ describe("device authorization schema", () => {
).toThrow(
"Device authorization grant request fields must be additional and cannot redefine request fields: scope",
);
expect(() =>
deviceAuthorization({
grant: {
...schemaGrant({}),
verificationOpenAPIProperties: { status: { type: "string" } },
},
}),
).toThrow(
"Device authorization grant verification fields must be additional and cannot redefine response fields: status",
);
});
it("preserves grant array fields in the portable logical schema", () => {
+90 -41
View File
@@ -12,6 +12,7 @@ import {
getRequestedUserInfoClaims,
isValidOidcClaimsRequest,
} from "./claims-request";
import { getExtensionInteractionRequest } from "./extensions";
import { oAuthState } from "./oauth";
import type { OAuthErrorCode, OAuthRedirectOnError } from "./oauth-endpoint";
import { mapIssuesToOAuthError } from "./oauth-endpoint";
@@ -26,8 +27,9 @@ import { getSupportedClaims } from "./standard-claims";
import type {
OAuthAuthorizationQuery,
OAuthConsent,
OAuthInteractionQuery,
OAuthInteractionRequest,
OAuthOptions,
SchemaClient,
Scope,
VerificationValue,
} from "./types";
@@ -62,9 +64,9 @@ export type OAuthRedirectResult = {
url: string;
};
function removeMaxAgeFromAuthorizationQuery(
query: OAuthAuthorizationQuery,
): OAuthAuthorizationQuery {
function removeMaxAgeFromAuthorizationQuery<
Query extends { max_age?: number | undefined },
>(query: Query): Omit<Query, "max_age"> {
const { max_age: _maxAge, ...queryWithoutMaxAge } = query;
return queryWithoutMaxAge;
}
@@ -162,22 +164,44 @@ export const handleRedirect = (
function redirectWithPromptNoneError(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
query: OAuthAuthorizationQuery,
query: OAuthInteractionQuery,
error: OAuthErrorCode,
description: string,
) {
return handleRedirect(
ctx,
formatErrorURL(
query.redirect_uri,
error,
description,
query.state,
getIssuer(ctx, opts),
),
query.redirect_uri
? formatErrorURL(
query.redirect_uri,
error,
description,
query.state,
getIssuer(ctx, opts),
)
: getErrorURL(ctx, error, description),
);
}
/**
* Returns the URL that reports an `APIError`: `errorPage` when given, otherwise
* the provider error page. Rethrows anything else.
*/
export function getAPIErrorURL(
ctx: GenericEndpointContext,
error: unknown,
errorPage?: string,
) {
if (!(error instanceof APIError)) throw error;
const body = error.body as
| { error?: OAuthErrorCode; error_description?: string }
| undefined;
const code = body?.error ?? "invalid_request";
const description = body?.error_description ?? "invalid request";
return errorPage
? formatErrorURL(errorPage, code, description)
: getErrorURL(ctx, code, description);
}
/**
* Validates that the issuer URL
* - MUST use HTTPS scheme (HTTP allowed for localhost in dev)
@@ -429,6 +453,8 @@ export type AuthorizeEndpointSettings = {
isAuthorize?: boolean;
postLogin?: boolean;
isPostLoginContinuation?: boolean;
/** Set only by the consent endpoint, in the request where the user accepts. */
consentAccepted?: boolean;
};
export async function authorizeEndpoint(
@@ -436,11 +462,6 @@ export async function authorizeEndpoint(
opts: OAuthOptions<Scope[]>,
settings?: AuthorizeEndpointSettings,
): Promise<OAuthRedirectResult> {
// Grant type must include authorization_code to use this endpoint
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) {
throw new APIError("NOT_FOUND");
}
if (!ctx.request) {
throw new APIError("UNAUTHORIZED", {
error_description: "request not found",
@@ -455,6 +476,49 @@ export async function authorizeEndpoint(
ctx,
settings,
);
const interaction = getExtensionInteractionRequest(opts, query);
if (interaction) {
// A direct request learns nothing about the request until sign-in.
if (settings?.isAuthorize === true && !(await getSessionFromCtx(ctx))) {
ctx.query = { [interaction.parameter]: interaction.value };
return redirectWithPromptCode(ctx, opts, "login");
}
const errorPage = interaction.handler.errorPage?.({
ctx,
value: interaction.value,
});
const toErrorURL = (error: unknown) =>
getAPIErrorURL(ctx, error, errorPage);
let interactionRequest: OAuthInteractionRequest;
try {
interactionRequest = await interaction.handler.load({
ctx,
opts,
value: interaction.value,
// Resumed queries were signed by this server; a direct request is not.
signedQuery: settings?.isAuthorize === true ? undefined : query,
});
if (interactionRequest.client.disabled) {
throw new APIError("BAD_REQUEST", {
error: "client_disabled",
error_description: "client is disabled",
});
}
} catch (error) {
return handleRedirect(ctx, toErrorURL(error));
}
return runAuthorizationInteraction(ctx, opts, request.headers, settings, {
...interactionRequest,
approve: (approval) =>
interactionRequest.approve(approval).catch(toErrorURL),
});
}
// Grant type must include authorization_code to use this endpoint
if (opts.grantTypes && !opts.grantTypes.includes("authorization_code")) {
throw new APIError("NOT_FOUND");
}
ctx.query = query;
const requestObject = getStringParameter(query.request);
const requestUri = getStringParameter(query.request_uri);
@@ -779,33 +843,14 @@ export async function authorizeEndpoint(
approve: (approval) =>
createAuthorizationCodeURL(ctx, opts, {
...approval,
// An authorization request always carries its validated redirect_uri.
query: approval.query as OAuthAuthorizationQuery,
clientId: client.clientId,
resource: toResourceList(approval.query.resource) ?? [],
}),
});
}
/**
* The request the user reviews through sign-in, account selection, signup,
* post-login selection, and consent.
*/
export interface InteractionRequest {
/** Signed into each interaction page; carries `prompt` and `max_age`. */
query: OAuthAuthorizationQuery;
client: SchemaClient<Scope[]>;
/**
* Records the approval once every interaction is complete and returns the
* URL to send the user to.
*/
approve: (approval: {
query: OAuthAuthorizationQuery;
userId: string;
sessionId: string;
authTime: number;
referenceId?: string;
}) => Promise<string>;
}
/**
* Sends the user to the next required interaction page, or approves the
* request once none remain.
@@ -815,7 +860,7 @@ async function runAuthorizationInteraction(
opts: OAuthOptions<Scope[]>,
headers: Headers,
settings: AuthorizeEndpointSettings | undefined,
request: InteractionRequest,
request: OAuthInteractionRequest,
): Promise<OAuthRedirectResult> {
let { query } = request;
const { client } = request;
@@ -949,8 +994,12 @@ async function runAuthorizationInteraction(
}
}
// Force consent screen
if (promptSet?.has("consent")) {
// Force consent screen. A request that requires consent is approved only in
// the request where the user accepts it, never from a resumed signed query.
if (
promptSet?.has("consent") ||
(request.requireConsent && !settings?.consentAccepted)
) {
return redirectWithPromptCode(ctx, opts, "consent", {
sessionId: session.session.id,
});
+33 -4
View File
@@ -1,11 +1,12 @@
import type { GenericEndpointContext } from "@better-auth/core";
import { APIError, getSessionFromCtx } from "better-auth/api";
import { formatErrorURL, getIssuer } from "./authorize";
import { formatErrorURL, getAPIErrorURL, getIssuer } from "./authorize";
import {
filterClaimsRequestUserInfoClaims,
getRequestedUserInfoClaims,
} from "./claims-request";
import type { AuthorizeEndpointCaller } from "./continue";
import { getExtensionInteractionRequest } from "./extensions";
import { oAuthState } from "./oauth";
import { getSupportedClaims } from "./standard-claims";
import type { OAuthConsent, OAuthOptions, Scope } from "./types";
@@ -32,7 +33,11 @@ export async function consentEndpoint<Result>(
});
}
const query = new URLSearchParams(_query);
const originalRequestedScopes = query.get("scope")?.split(" ") ?? [];
const originalRequestedScopes =
query
.get("scope")
?.split(" ")
.filter((scope) => scope) ?? [];
const supportedClaims = getSupportedClaims(opts);
const originalRequestedUserInfoClaims = getRequestedUserInfoClaims(
query.get("claims"),
@@ -47,7 +52,9 @@ export async function consentEndpoint<Result>(
}
// Check scopes if received (can only be equal or lesser than originally requested scopes)
const requestedScopes = (ctx.body.scope as string | undefined)?.split(" ");
const requestedScopes = (ctx.body.scope as string | undefined)
?.split(" ")
.filter((scope) => scope);
if (requestedScopes) {
if (!requestedScopes.every((sc) => originalRequestedScopes?.includes(sc))) {
throw new APIError("BAD_REQUEST", {
@@ -73,9 +80,31 @@ export async function consentEndpoint<Result>(
});
}
const session = await getSessionFromCtx(ctx);
// Consent not accepted (ensure it's strictly boolean true)
const accepted = ctx.body.accept === true;
if (!accepted) {
const interaction = getExtensionInteractionRequest(
opts,
searchParamsToQuery(query),
);
if (interaction) {
return {
redirect: true,
url: await interaction.handler
.deny({ ctx, value: interaction.value, userId: session?.user.id! })
.catch((error) =>
getAPIErrorURL(
ctx,
error,
interaction.handler.errorPage?.({
ctx,
value: interaction.value,
}),
),
),
};
}
return {
redirect: true,
url: formatErrorURL(
@@ -89,7 +118,6 @@ export async function consentEndpoint<Result>(
}
// Consent accepted
const session = await getSessionFromCtx(ctx);
const promptSet = parsePrompt(query.get("prompt") ?? "");
const hasLoginPrompt = promptSet.has("login");
const hasSatisfiedLoginPrompt =
@@ -195,5 +223,6 @@ export async function consentEndpoint<Result>(
oauthRequest.postLoginClearedForSession === session?.session.id;
return await authorize(ctx, {
postLogin: postLoginClearedForThisSession,
consentAccepted: true,
});
}
+534 -40
View File
@@ -1,3 +1,4 @@
import { APIError } from "better-auth/api";
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import { openAPI } from "better-auth/plugins";
@@ -21,6 +22,63 @@ interface TokenErrorBody {
error_description?: string;
}
/** Accepts any instance's test client, whose types depend on its options. */
interface TestClient {
$fetch: (path: never, options: never) => Promise<unknown>;
}
/** Sends a browser-style request and returns the URL the provider redirects to. */
async function fetchRedirect(
client: TestClient,
path: string,
init: { headers: Headers; method?: "POST"; body?: Record<string, unknown> },
) {
const fetchJSON = client.$fetch as (
path: string,
options: typeof init,
) => Promise<{
data: { url: string } | null;
error: TokenErrorBody | null;
}>;
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
const { data, error } = await fetchJSON(path, { ...init, headers });
if (error || !data) throw error;
return data.url;
}
/**
* Opens `/oauth2/authorize?user_code=…` as a device verification page does and
* returns the signed query of the page it redirects to.
*/
async function openDeviceConsent(
client: TestClient,
userCode: string,
headers: Headers,
) {
const url = await fetchRedirect(
client,
`/oauth2/authorize?user_code=${encodeURIComponent(userCode)}`,
{ headers },
);
return new URL(url, "http://localhost").searchParams;
}
/** Answers a device's consent page and returns where the user is sent. */
async function answerDeviceConsent(
client: TestClient,
userCode: string,
headers: Headers,
answer: { accept: boolean; scope?: string } = { accept: true },
) {
const consent = await openDeviceConsent(client, userCode, headers);
return fetchRedirect(client, "/oauth2/consent", {
method: "POST",
headers,
body: { ...answer, oauth_query: consent.toString() },
});
}
describe("oauth-provider device-code composition", () => {
it("registers Device Authorization and its OAuth grant from one plugin", async () => {
const baseURL = "http://localhost:3000";
@@ -304,14 +362,11 @@ describe("oauth-provider device-code grant", async () => {
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);
const consent = await openDeviceConsent(client, user_code, headers);
expect(consent.getAll("resource")).toEqual([requestedResource].flat());
}
await auth.api.deviceApprove({ body: { userCode: user_code }, headers });
await answerDeviceConsent(client, user_code, headers);
return device_code;
}
@@ -354,11 +409,12 @@ describe("oauth-provider device-code grant", async () => {
expect(response.error).toBeNull();
expect(response.data?.device_code).toEqual(expect.any(String));
expect(response.data?.user_code).toEqual(expect.any(String));
const verification = await auth.api.deviceVerify({
query: { user_code: response.data?.user_code as string },
const consent = await openDeviceConsent(
client,
response.data?.user_code as string,
headers,
});
expect(verification.client_id).toBe(created.client_id);
);
expect(consent.get("client_id")).toBe(created.client_id);
});
it("accepts a confidential client authenticated with client_secret_post", async () => {
@@ -393,11 +449,12 @@ describe("oauth-provider device-code grant", async () => {
expect(response.error).toBeNull();
expect(response.data?.device_code).toEqual(expect.any(String));
expect(response.data?.user_code).toEqual(expect.any(String));
const verification = await auth.api.deviceVerify({
query: { user_code: response.data?.user_code as string },
const consent = await openDeviceConsent(
client,
response.data?.user_code as string,
headers,
});
expect(verification.client_id).toBe(created.client_id);
);
expect(consent.get("client_id")).toBe(created.client_id);
});
it("accepts client_secret_post through the in-process API contract", async () => {
@@ -495,11 +552,12 @@ describe("oauth-provider device-code grant", async () => {
);
expect(response.error).toBeNull();
const verification = await assertionAuth.api.deviceVerify({
query: { user_code: response.data?.user_code as string },
const consent = await openDeviceConsent(
assertionClient,
response.data?.user_code as string,
headers,
});
expect(verification.client_id).toBe(assertionClientId);
);
expect(consent.get("client_id")).toBe(assertionClientId);
});
it("rejects multiple client authentication methods and repeated authentication parameters", async () => {
@@ -1128,15 +1186,10 @@ describe("oauth-provider device-code grant", async () => {
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).toBeUndefined();
await auth.api.deviceApprove({
body: { userCode: response.data!.user_code as string },
headers,
});
const userCode = response.data!.user_code as string;
const consent = await openDeviceConsent(client, userCode, headers);
expect(consent.getAll("resource")).toEqual([]);
await answerDeviceConsent(client, userCode, headers);
const token = await pollToken({
grant_type: DEVICE_CODE_GRANT_TYPE,
@@ -1204,11 +1257,12 @@ describe("oauth-provider device-code grant", async () => {
expect(response.error).toBeNull();
const { headers } = await signInWithTestUser();
const verification = await auth.api.deviceVerify({
query: { user_code: response.data!.user_code as string },
const consent = await openDeviceConsent(
client,
response.data!.user_code as string,
headers,
});
expect(verification.resource).toEqual([resource, secondResource]);
);
expect(consent.getAll("resource")).toEqual([resource, secondResource]);
});
it("validates every repeated form-encoded resource", async () => {
@@ -1346,8 +1400,7 @@ describe("oauth-provider device-code grant", async () => {
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 });
await answerDeviceConsent(client, user_code, headers, { accept: false });
const res = await pollToken({
grant_type: DEVICE_CODE_GRANT_TYPE,
@@ -1711,15 +1764,9 @@ describe("oauth-provider device-code grant reuse", async () => {
});
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({
const { device_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",
@@ -1737,3 +1784,450 @@ describe("oauth-provider device-code grant reuse", async () => {
expect((res.error as TokenErrorBody)?.error).toBe("invalid_grant");
});
});
describe("oauth-provider device approval through the consent flow", async () => {
const baseURL = "http://localhost:3000";
const resource = "https://api.example.com";
const referenceClaim = "https://example.com/reference";
const firstPartyClientId = "first-party-cli";
const requireReference = (): string => {
throw new APIError("BAD_REQUEST", { error: "reference_required" });
};
let resolveReference: () => string | undefined = requireReference;
let needsSelection = false;
const consentReferenceId = vi.fn(() => resolveReference());
const { auth, client, db, signInWithTestUser, signInWithUser } =
await getTestInstance({
baseURL,
plugins: [
jwt({ jwt: { issuer: baseURL } }),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
resources: [resource],
enforcePerClientResources: false,
scopes: ["openid", "offline_access"],
postLogin: {
page: "/select-organization",
shouldRedirect: () => needsSelection,
consentReferenceId,
},
customAccessTokenClaims: ({ referenceId }) => ({
[referenceClaim]: referenceId,
}),
}),
oauthDeviceAuthorization({
interval: "1s",
validateClient: (clientId) => clientId === firstPartyClientId,
}),
],
});
async function startDevice(clientBody: Record<string, unknown> = {}) {
const { headers, user } = await signInWithTestUser();
const created = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
application_type: "native",
grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
scope: "openid offline_access",
...clientBody,
},
});
const clientId = created!.client_id;
const { device_code, user_code } = await auth.api.deviceCode({
body: { client_id: clientId, scope: "openid offline_access", resource },
});
consentReferenceId.mockClear();
const poll = (body: Record<string, string>) =>
client.$fetch<{
access_token: string;
refresh_token?: string;
id_token?: string;
scope: string;
}>("/oauth2/token", {
method: "POST",
body: new URLSearchParams({ client_id: clientId, resource, ...body }),
headers: FORM_HEADERS,
});
return { clientId, device_code, user_code, headers, user, poll };
}
it("binds device tokens and their refreshes to the selected reference", async () => {
resolveReference = () => "org_1";
const { clientId, device_code, user_code, headers, user, poll } =
await startDevice();
const landing = await answerDeviceConsent(client, user_code, headers);
expect(landing).toBe(
`${baseURL}/device?user_code=${user_code}&status=approved`,
);
expect(consentReferenceId).toHaveBeenCalledWith(
expect.objectContaining({
user: expect.objectContaining({ id: user.id }),
scopes: ["openid", "offline_access"],
}),
);
const consent = await db.findOne<{ referenceId?: string }>({
model: "oauthConsent",
where: [
{ field: "clientId", value: clientId },
{ field: "userId", value: user.id },
],
});
expect(consent?.referenceId).toBe("org_1");
const tokens = await poll({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code,
});
expect(tokens.error).toBeNull();
expect(decodeJwt(tokens.data!.access_token)[referenceClaim]).toBe("org_1");
expect(decodeJwt(tokens.data!.id_token!).auth_time).toEqual(
expect.any(Number),
);
resolveReference = () => "org_2";
const refreshed = await poll({
grant_type: "refresh_token",
refresh_token: tokens.data!.refresh_token!,
});
expect(refreshed.error).toBeNull();
expect(decodeJwt(refreshed.data!.access_token)[referenceClaim]).toBe(
"org_1",
);
});
it("runs post-login selection before consent", async () => {
resolveReference = () => "org_1";
needsSelection = true;
try {
const { user_code, headers } = await startDevice();
const selection = await fetchRedirect(
client,
`/oauth2/authorize?user_code=${user_code}`,
{ headers },
);
expect(new URL(selection, baseURL).pathname).toBe("/select-organization");
needsSelection = false;
const consentPage = await fetchRedirect(client, "/oauth2/continue", {
method: "POST",
headers,
body: {
postLogin: true,
oauth_query: new URL(selection, baseURL).search.slice(1),
},
});
expect(new URL(consentPage, baseURL).pathname).toBe("/consent");
} finally {
needsSelection = false;
}
});
/**
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.4
*/
it("shows consent for a skip-consent client that already has consent", async () => {
resolveReference = () => "org_1";
const first = await startDevice({ skip_consent: true });
await answerDeviceConsent(client, first.user_code, first.headers);
const { device_code, user_code } = await auth.api.deviceCode({
body: {
client_id: first.clientId,
scope: "openid offline_access",
resource,
},
});
const consent = await fetchRedirect(
client,
`/oauth2/authorize?user_code=${user_code}`,
{ headers: first.headers },
);
expect(new URL(consent, baseURL).pathname).toBe("/consent");
expect(new URL(consent, baseURL).searchParams.get("user_code")).toBe(
user_code,
);
const pending = await first.poll({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code,
});
expect((pending.error as TokenErrorBody | null)?.error).toBe(
"authorization_pending",
);
});
it("records no empty scope for a device code requested without scopes", async () => {
resolveReference = () => "org_1";
const { headers, user } = await signInWithTestUser();
const created = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: "none",
application_type: "native",
grant_types: [DEVICE_CODE_GRANT_TYPE],
scope: "openid offline_access",
},
});
const { user_code } = await auth.api.deviceCode({
body: { client_id: created!.client_id, resource },
});
await answerDeviceConsent(client, user_code, headers);
const stored = await db.findOne<{ scopes: string[] }>({
model: "oauthConsent",
where: [
{ field: "clientId", value: created!.client_id },
{ field: "userId", value: user.id },
],
});
expect(stored?.scopes).toEqual([]);
});
/**
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.4
*/
it("does not let another user approve through a query signed after consent", async () => {
resolveReference = () => "org_1";
const attacker = await startDevice({ skip_consent: true });
const consent = await openDeviceConsent(
client,
attacker.user_code,
attacker.headers,
);
// Accepting from a second session whose post-login selection is pending
// yields a selection page signed without the consent prompt.
const { headers: secondSession } = await signInWithTestUser();
needsSelection = true;
let selection: URL;
try {
selection = new URL(
await fetchRedirect(client, "/oauth2/consent", {
method: "POST",
headers: secondSession,
body: { accept: true, oauth_query: consent.toString() },
}),
baseURL,
);
} finally {
needsSelection = false;
}
expect(selection.pathname).toBe("/select-organization");
await auth.api.signUpEmail({
body: {
email: "device-victim@example.com",
password: "device-victim-password",
name: "Victim",
},
});
const { headers: victim } = await signInWithUser(
"device-victim@example.com",
"device-victim-password",
);
const next = await fetchRedirect(client, "/oauth2/continue", {
method: "POST",
headers: victim,
body: { postLogin: true, oauth_query: selection.search.slice(1) },
});
expect(new URL(next, baseURL).pathname).toBe("/consent");
const pending = await attacker.poll({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: attacker.device_code,
});
expect((pending.error as TokenErrorBody | null)?.error).toBe(
"authorization_pending",
);
});
it("signs a signed-out user in before revealing anything about the code", async () => {
resolveReference = () => "org_1";
const { user_code } = await startDevice();
for (const code of [user_code, "NOT-A-CODE"]) {
const login = new URL(
await fetchRedirect(
client,
`/oauth2/authorize?user_code=${encodeURIComponent(code)}`,
{ headers: new Headers() },
),
baseURL,
);
expect(login.pathname).toBe("/login");
expect(login.searchParams.get("user_code")).toBe(code);
expect(login.searchParams.has("client_id")).toBe(false);
}
// Signing in on that page resumes into the device's consent page.
const login = new URL(
await fetchRedirect(client, `/oauth2/authorize?user_code=${user_code}`, {
headers: new Headers(),
}),
baseURL,
);
const consent = new URL(
await fetchRedirect(client, "/sign-in/email", {
method: "POST",
headers: new Headers(),
body: {
email: "test@test.com",
password: "test123456",
oauth_query: login.search.slice(1),
},
}),
baseURL,
);
expect(consent.pathname).toBe("/consent");
expect(consent.searchParams.get("user_code")).toBe(user_code);
});
it("returns a deny on an already approved code to the device page", async () => {
resolveReference = () => "org_1";
const { user_code, headers } = await startDevice();
const consent = await openDeviceConsent(client, user_code, headers);
await fetchRedirect(client, "/oauth2/consent", {
method: "POST",
headers,
body: { accept: true, oauth_query: consent.toString() },
});
const denied = await fetchRedirect(client, "/oauth2/consent", {
method: "POST",
headers,
body: { accept: false, oauth_query: consent.toString() },
});
expect(new URL(denied).pathname).toBe("/device");
expect(new URL(denied).searchParams.get("user_code")).toBe(user_code);
expect(new URL(denied).searchParams.get("error")).toBe("invalid_request");
});
it("denies the device from the consent page", async () => {
const { device_code, user_code, headers, poll } = await startDevice();
const landing = await answerDeviceConsent(client, user_code, headers, {
accept: false,
});
expect(landing).toBe(
`${baseURL}/device?user_code=${user_code}&status=denied`,
);
const res = await poll({ grant_type: DEVICE_CODE_GRANT_TYPE, device_code });
expect((res.error as TokenErrorBody | null)?.error).toBe("access_denied");
});
it("sends a device approval for a client disabled mid-flow to the verification page", async () => {
resolveReference = () => "org_1";
const { clientId, device_code, user_code, headers } = await startDevice();
const consent = await openDeviceConsent(client, user_code, headers);
await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id: clientId, update: { disabled: true } },
});
const landing = new URL(
await fetchRedirect(client, "/oauth2/consent", {
method: "POST",
headers,
body: { accept: true, oauth_query: consent.toString() },
}),
);
expect(landing.pathname).toBe("/device");
expect(landing.searchParams.get("user_code")).toBe(user_code);
expect(landing.searchParams.get("error")).toBe("client_disabled");
const stored = await db.findOne<{ status: string }>({
model: "deviceCode",
where: [{ field: "deviceCode", value: device_code }],
});
expect(stored?.status).toBe("pending");
});
it("applies scopes narrowed on the consent page", async () => {
resolveReference = () => "org_1";
const { device_code, user_code, headers, poll } = await startDevice();
await answerDeviceConsent(client, user_code, headers, {
accept: true,
scope: "openid",
});
const tokens = await poll({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code,
});
expect(tokens.data?.scope).toBe("openid");
expect(tokens.data?.refresh_token).toBeUndefined();
});
it("leaves the code pending when the reference cannot be resolved", async () => {
resolveReference = requireReference;
const { device_code, user_code, headers, poll } = await startDevice();
await expect(
answerDeviceConsent(client, user_code, headers),
).rejects.toMatchObject({ error: "reference_required" });
const res = await poll({ grant_type: DEVICE_CODE_GRANT_TYPE, device_code });
expect((res.error as TokenErrorBody | null)?.error).toBe(
"authorization_pending",
);
});
it("rejects an approval when the code expires before the final step", async () => {
let calls = 0;
resolveReference = () => {
// The consent endpoint resolves the reference first; expire the code
// before the second resolution, which precedes the approval.
calls += 1;
if (calls === 2) vi.setSystemTime(Date.now() + 31 * 60 * 1000);
return "org_1";
};
const { user_code, headers } = await startDevice();
vi.useFakeTimers({ toFake: ["Date"] });
try {
const landing = new URL(
await answerDeviceConsent(client, user_code, headers),
);
expect(landing.pathname).toBe("/device");
expect(landing.searchParams.get("error")).toBe("expired_token");
} finally {
vi.useRealTimers();
}
});
it("refuses the standalone approval endpoints for OAuth device codes", async () => {
const { user_code, headers } = await startDevice();
const hint = expect.stringContaining(
`/oauth2/authorize?user_code=${user_code}`,
);
await expect(
auth.api.deviceVerify({ query: { user_code }, headers }),
).rejects.toMatchObject({ body: { error_description: hint } });
await expect(
auth.api.deviceApprove({ body: { userCode: user_code }, headers }),
).rejects.toMatchObject({ body: { error_description: hint } });
await expect(
auth.api.deviceDeny({ body: { userCode: user_code }, headers }),
).rejects.toMatchObject({ body: { error_description: hint } });
});
it("keeps first-party device codes on the standalone approval endpoints", async () => {
resolveReference = requireReference;
const { headers } = await signInWithTestUser();
const { device_code, user_code } = await auth.api.deviceCode({
body: { client_id: firstPartyClientId, scope: "openid" },
});
consentReferenceId.mockClear();
await auth.api.deviceVerify({ query: { user_code }, headers });
await auth.api.deviceApprove({ body: { userCode: user_code }, headers });
expect(consentReferenceId).not.toHaveBeenCalled();
const tokens = await auth.api.deviceToken({
body: {
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code,
client_id: firstPartyClientId,
},
});
expect(tokens.access_token).toEqual(expect.any(String));
});
});
+122 -32
View File
@@ -1,12 +1,18 @@
import type { AuthContext } from "@better-auth/core";
import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import { appendQueryParams } from "@better-auth/core/utils/url";
import { APIError } from "better-auth/api";
import type {
DeviceAuthorizationGrant,
DeviceAuthorizationPluginOptions,
DeviceCode,
} from "better-auth/plugins/device-authorization";
import {
assertDeviceCodePending,
buildVerificationUris,
decideDeviceCode,
deviceAuthorization,
findDeviceCodeByUserCode,
redeemDeviceCode,
} from "better-auth/plugins/device-authorization";
import * as z from "zod";
@@ -15,6 +21,7 @@ import { resolveResourcePolicy } from "./resources";
import { getOAuthProviderApi } from "./token";
import type {
OAuthExtensionGrantHandlerInput,
OAuthInteractionRequestHandler,
OAuthProviderExtension,
OAuthTokenResponse,
} from "./types";
@@ -23,8 +30,8 @@ import {
getClient,
getOAuthProviderPlugin,
normalizeClientAuthenticationParameters,
normalizeTimestampValue,
readFormParams,
toAudienceClaim,
toResourceList,
validateClientScopes,
} from "./utils";
@@ -74,6 +81,8 @@ const oauthDeviceRequestFields = {
type OAuthDeviceCodeFields = {
oauthClientId?: string | null;
resources?: string[] | null;
referenceId?: string | null;
authTime?: Date | null;
};
function tokenError(
@@ -117,6 +126,7 @@ async function exchangeOAuthDeviceCode(
> & { scopes: string[] };
const {
authorizationContext: { client, confirmation, scopes },
claimedDeviceCode,
redemptionContext: resources,
user,
} = await redeemDeviceCode<
@@ -185,12 +195,98 @@ async function exchangeOAuthDeviceCode(
scopes,
user,
resources,
referenceId: claimedDeviceCode.referenceId ?? undefined,
authTime: normalizeTimestampValue(claimedDeviceCode.authTime),
// Forward a sender-constraint a confidential client-auth strategy proved.
confirmation,
});
}
function buildOAuthDeviceGrant() {
/**
* Lets the user approve an OAuth device code through `/oauth2/authorize`, so
* device approvals run the same sign-in, account selection, post-login, and
* consent pages as authorization requests. The user is bound to the code only
* at the final approval, after any account switch.
*/
function deviceApprovalRequest(
verificationUri: string | undefined,
): OAuthInteractionRequestHandler {
async function findOAuthDeviceCode(
ctx: GenericEndpointContext,
userCode: string,
) {
const deviceCode = (await findDeviceCodeByUserCode(ctx, userCode)) as
| (DeviceCode & OAuthDeviceCodeFields)
| null;
if (!deviceCode?.oauthClientId) {
tokenError("BAD_REQUEST", "invalid_request", "Invalid user code");
}
return { ...deviceCode, oauthClientId: deviceCode.oauthClientId };
}
// The device verification page, which also reports the outcome or error.
const verificationPage = (ctx: GenericEndpointContext, userCode: string) =>
buildVerificationUris(verificationUri, ctx.context.baseURL, userCode)
.verificationUriComplete;
const completionURL = (
ctx: GenericEndpointContext,
userCode: string,
status: "approved" | "denied",
) =>
appendQueryParams(
verificationPage(ctx, userCode),
new URLSearchParams({ status }),
);
return {
load: async ({ ctx, opts, value, signedQuery }) => {
const deviceCode = await findOAuthDeviceCode(ctx, value);
assertDeviceCodePending(deviceCode);
const client = await getClient(ctx, opts, deviceCode.oauthClientId);
if (!client) {
tokenError("BAD_REQUEST", "invalid_client", "client_id is required");
}
// The consent page may have narrowed the scopes of a signed query.
const scopes = parseScopes(signedQuery?.scope ?? deviceCode.scope);
const query = {
user_code: deviceCode.userCode,
client_id: client.clientId,
scope: scopes.join(" "),
resource: deviceCode.resources ?? [],
};
return {
query,
client,
// RFC 8628 section 5.4: the user who approves must see consent.
requireConsent: true,
approve: async (approval) => {
await decideDeviceCode(ctx, {
deviceCode,
userId: approval.userId,
decision: "approved",
fields: {
scope: approval.query.scope,
referenceId: approval.referenceId ?? null,
authTime: new Date(approval.authTime),
},
});
return completionURL(ctx, deviceCode.userCode, "approved");
},
};
},
deny: async ({ ctx, value, userId }) => {
const deviceCode = await findOAuthDeviceCode(ctx, value);
await decideDeviceCode(ctx, {
deviceCode,
userId,
decision: "denied",
});
return completionURL(ctx, deviceCode.userCode, "denied");
},
errorPage: ({ ctx, value }) => verificationPage(ctx, value),
};
}
function buildOAuthDeviceGrant(verificationUri: string | undefined) {
return {
requestSchemaFields: oauthDeviceRequestFields,
requestErrorCodes: ["invalid_target"] as const,
@@ -241,6 +337,14 @@ function buildOAuthDeviceGrant() {
type: "string",
required: false,
},
referenceId: {
type: "string",
required: false,
},
authTime: {
type: "date",
required: false,
},
},
grants: {
[DEVICE_CODE_GRANT_TYPE]: exchangeOAuthDeviceCode,
@@ -313,39 +417,25 @@ function buildOAuthDeviceGrant() {
},
};
},
assertSessionRedemption: ({ deviceCode }) => {
assertStandaloneUse: ({ deviceCode, use }) => {
if (typeof deviceCode.oauthClientId !== "string") return;
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description:
if (use === "redeem") {
tokenError(
"BAD_REQUEST",
"invalid_grant",
"This device code must be exchanged at the OAuth token endpoint (/oauth2/token).",
});
},
getVerificationContext: (deviceCode) => {
if (typeof deviceCode.oauthClientId !== "string") return;
const resources = deviceCode.resources;
if (
!Array.isArray(resources) ||
!resources.every((resource) => typeof resource === "string")
) {
return;
);
}
return { resource: toAudienceClaim(resources) };
tokenError(
"BAD_REQUEST",
"invalid_request",
`Approve this device code through the OAuth provider: send the user to /oauth2/authorize?user_code=${encodeURIComponent(String(deviceCode.userCode))}.`,
);
},
verificationOpenAPIProperties: {
resource: {
oneOf: [
{ type: "string" },
{ type: "array", items: { type: "string" } },
],
description:
"The requested resource indicators, returned only to the authenticated user who owns this request",
},
interactionRequests: {
user_code: deviceApprovalRequest(verificationUri),
},
} satisfies DeviceAuthorizationGrant<
typeof oauthDeviceRequestFields,
{ resource: string | string[] | undefined }
> &
} satisfies DeviceAuthorizationGrant<typeof oauthDeviceRequestFields> &
OAuthProviderExtension;
}
@@ -379,7 +469,7 @@ export type OAuthDeviceAuthorizationOptions = Omit<
export function oauthDeviceAuthorization(
options: OAuthDeviceAuthorizationOptions = {},
) {
const grant = buildOAuthDeviceGrant();
const grant = buildOAuthDeviceGrant(options.verificationUri);
const plugin = deviceAuthorization({ ...options, grant });
return {
+43 -1
View File
@@ -93,6 +93,7 @@ interface ExtensionKeys {
authMethods: string[];
assertionTypes: string[];
clientDiscoveryIds: string[];
interactionRequestParameters: string[];
}
/**
@@ -143,7 +144,15 @@ function collectExtensionKeys(
);
}
}
return { grantTypes, authMethods, assertionTypes, clientDiscoveryIds };
return {
grantTypes,
authMethods,
assertionTypes,
clientDiscoveryIds,
interactionRequestParameters: Object.keys(
extension.interactionRequests ?? {},
),
};
}
function assertNoDuplicateAcrossExtensions(label: string, values: string[]) {
@@ -185,6 +194,10 @@ export function validateOAuthProviderExtensions(
"client discovery id",
keys.flatMap((k) => k.clientDiscoveryIds),
);
assertNoDuplicateAcrossExtensions(
"interaction request parameter",
keys.flatMap((k) => k.interactionRequestParameters),
);
}
function getOAuthProviderExtensions(
@@ -267,6 +280,35 @@ export function getSupportedGrantTypes(
);
}
/**
* Finds the interaction request a query identifies, such as a device approval
* keyed by `user_code`. Authorization requests, which carry `response_type` or
* a pushed `request_uri`, never match.
*/
export function getExtensionInteractionRequest(
opts: OAuthOptions<Scope[]>,
query: object,
) {
const parameters = query as Record<string, unknown>;
if (
parameters.response_type !== undefined ||
parameters.request_uri !== undefined
) {
return undefined;
}
for (const extension of getOAuthProviderExtensions(opts)) {
for (const [parameter, handler] of Object.entries(
extension.interactionRequests ?? {},
)) {
const value = parameters[parameter];
if (typeof value === "string" && value) {
return { parameter, handler, value };
}
}
}
return undefined;
}
export function getExtensionGrantHandler(
opts: OAuthOptions<Scope[]>,
grantType: GrantType,
+11 -2
View File
@@ -371,12 +371,21 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
schema: { type: "string" },
description: "OAuth 2.1 response type (e.g., 'code')",
},
{
name: "user_code",
in: "query",
required: false,
schema: { type: "string" },
description:
"Device user code to approve instead of an authorization request (requires oauthDeviceAuthorization())",
},
{
name: "client_id",
in: "query",
required: true,
required: false,
schema: { type: "string" },
description: "OAuth 2.1 client ID",
description:
"OAuth 2.1 client ID, required for authorization requests (not for user_code)",
},
{
name: "redirect_uri",
+84 -5
View File
@@ -415,7 +415,7 @@ export interface OAuthUserInfoExtensionInput {
* `extendOAuthProvider(ctx, extension)` (or the `oauthProvider({ extensions })`
* option).
*
* All five kinds live on one object so a single protocol plugin (for example
* All seven kinds live on one object so a single protocol plugin (for example
* RFC 8693 token exchange, which adds a grant, advertises metadata, and emits
* claims) registers atomically and the host validates the combined surface in
* one place. Every field is independently optional, so a single-concern plugin
@@ -423,10 +423,11 @@ export interface OAuthUserInfoExtensionInput {
* just the one it needs. This is the shape every future OAuth RFC plugin copies.
*
* Two contribution disciplines:
* - Dispatched kinds (`grants`, `clientAuthentication`) must be disjoint across
* extensions: registering a grant type, auth method, or assertion type that
* another extension already registered is rejected at setup, since the second
* would otherwise be silently unreachable.
* - Dispatched kinds (`grants`, `clientAuthentication`, `interactionRequests`)
* must be disjoint across extensions: registering a grant type, auth method,
* assertion type, or interaction parameter that another extension already
* registered is rejected at setup, since the second would otherwise be
* silently unreachable.
* - Additive kinds (`metadata`, `claims`) never override authorization-server
* core; a key already owned by the provider is kept, and a key two extensions
* both contribute resolves to the first-registered extension.
@@ -504,6 +505,84 @@ export interface OAuthProviderExtension {
*/
validate?: (input: OAuthClientMetadataExtensionInput) => Awaitable<void>;
};
/**
* Requests other than an authorization request that users approve through
* the `/oauth2/authorize` sign-in, account selection, post-login, and
* consent pages, keyed by the query parameter that identifies one, such as
* `user_code`. Every interaction page resumes them through
* `/oauth2/authorize`.
*/
interactionRequests?: Record<string, OAuthInteractionRequestHandler>;
}
/**
* Parameters signed into each interaction page. An authorization request
* carries its `redirect_uri`; other interaction requests have none and add
* their own identifying parameter, such as `user_code`.
*/
export type OAuthInteractionQuery = Omit<
OAuthAuthorizationQuery,
"redirect_uri"
> & {
redirect_uri?: string | undefined;
};
/**
* A request the user reviews through the `/oauth2/authorize` sign-in,
* account selection, signup, post-login, and consent pages.
*/
export interface OAuthInteractionRequest {
/** Signed into each interaction page; carries `prompt` and `max_age`. */
query: OAuthInteractionQuery;
/** The provider sends the user to the error page when this client is disabled. */
client: SchemaClient<Scope[]>;
/**
* Show the consent page even when consent is stored or the client skips
* it, and approve only in the request where the user accepts on that page.
*/
requireConsent?: boolean | undefined;
/**
* Records the approval once every interaction is complete and returns the
* URL to send the user to.
*/
approve: (approval: {
query: OAuthInteractionQuery;
userId: string;
sessionId: string;
authTime: number;
referenceId?: string;
}) => Promise<string>;
}
/** Loads and denies one kind of {@link OAuthInteractionRequest}. */
export interface OAuthInteractionRequestHandler {
/**
* Loads the request identified by `value`. On a direct `/oauth2/authorize`
* request only `value` comes from the caller; when resuming after an
* interaction page, `signedQuery` is the query this server signed into it.
* Throw an `APIError` when the request can no longer be approved.
*/
load: (input: {
ctx: GenericEndpointContext;
opts: OAuthOptions<Scope[]>;
value: string;
signedQuery?: OAuthInteractionQuery | undefined;
}) => Promise<OAuthInteractionRequest>;
/**
* Records a denial from the consent page and returns the URL to send the
* user to.
*/
deny: (input: {
ctx: GenericEndpointContext;
value: string;
userId: string;
}) => Promise<string>;
/**
* Where to send the user when the request cannot be loaded, approved, or
* denied, such as a page where they can correct it. The provider appends
* `error` and `error_description`. Defaults to the provider error page.
*/
errorPage?: (input: { ctx: GenericEndpointContext; value: string }) => string;
}
export interface OAuthClientMetadataExtensionInput {