# 设备授权 (/docs/plugins/device-authorization)

面向输入受限设备的 OAuth 2.0 设备授权授予



`RFC 8628` `CLI` `智能电视` `物联网`

Device Authorization 插件为智能电视、CLI 应用、物联网设备和游戏主机等输入受限设备，实现了 OAuth 2.0 设备授权授予（[RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)）中的代码签发和批准流程。您可以单独使用它来获取 Better Auth 会话令牌，也可以将其与 OAuth Provider 组合使用来签发 OAuth 访问令牌。

## 立即试用 [#立即试用]

您可以使用 Better Auth CLI 立即测试设备授权流程：

<CodeBlockTabs defaultValue="npm" groupId="persist-install">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="npm">
      npm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="pnpm">
      pnpm
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="yarn">
      yarn
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="bun">
      bun
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="npm">
    ```bash
    npx auth login
    ```
  </CodeBlockTab>

  <CodeBlockTab value="pnpm">
    ```bash
    pnpm dlx auth login
    ```
  </CodeBlockTab>

  <CodeBlockTab value="yarn">
    ```bash
    yarn dlx auth login
    ```
  </CodeBlockTab>

  <CodeBlockTab value="bun">
    ```bash
    bun x auth login
    ```
  </CodeBlockTab>
</CodeBlockTabs>

这将演示完整的设备授权流程：

1. 从 Better Auth 演示服务器请求设备码
2. 显示用户代码供您输入
3. 打开浏览器到验证页面
4. 轮询授权完成情况

<Callout type="info">
  CLI 登录命令是一个演示功能，连接到 Better Auth 演示服务器，用以展示设备授权流程的实际运行。
</Callout>

## 安装 [#安装]

<Steps>
  <Step>
    ### 在认证配置中添加插件 [#在认证配置中添加插件]

    将设备授权插件添加到服务器配置中。

    ```ts title="auth.ts"
    import { betterAuth } from "better-auth";
    import { deviceAuthorization } from "better-auth/plugins"; // [!code highlight]

    export const auth = betterAuth({
      // ... 其他配置
      plugins: [
        deviceAuthorization({ // [!code highlight]
          verificationUri: "/device", // [!code highlight]
        }), // [!code highlight]
      ],
    });
    ```
  </Step>

  <Step>
    ### 迁移数据库 [#迁移数据库]

    运行迁移或生成架构以向数据库添加必要的表。

    <Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
      <Tab value="migrate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth migrate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>

      <Tab value="generate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth generate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>
    </Tabs>

    请参见 [架构](#schema) 部分以手动添加字段。
  </Step>

  <Step>
    ### 添加客户端插件 [#添加客户端插件]

    将设备授权插件添加到客户端。

    ```ts title="auth-client.ts"
    import { createAuthClient } from "better-auth/client"
    import { deviceAuthorizationClient } from "better-auth/client/plugins"; // [!code highlight]

    export const authClient = createAuthClient({
      plugins: [
        deviceAuthorizationClient(), // [!code highlight]
      ],
    });
    ```
  </Step>
</Steps>

## 工作原理 [#工作原理]

设备授权流程包含以下步骤：

1. **设备请求代码**：设备向授权服务器请求设备码和用户码
2. **用户授权**：用户访问验证 URL 并输入用户码
3. **设备轮询令牌**：设备轮询服务器，直到用户完成授权
4. **授予访问权限**：授权后，设备会收到由配置的集成提供的会话令牌或 OAuth 访问令牌

### 生产环境安全要求 [#生产环境安全要求]

RFC 8628 要求设备发出出站 HTTPS 请求，并要求用户在安全的 TLS 保护会话中通过验证 URI 进行身份验证。在生产环境中，应通过 HTTPS 提供验证和批准 UI、设备代码请求以及令牌轮询。仅在明确的本地开发场景中使用 HTTP。

批准 UI 是安全边界的一部分。它必须：

* 要求用户输入 `user_code`，或者在使用 `verification_uri_complete`（例如二维码）时，要求用户确认显示的代码与设备上的代码匹配；
* 显示正在授权的内容：客户端、请求的作用域，以及在启用 OAuth Provider 集成时，请求的资源；
* 要求用户明确批准或拒绝；并且
* 告知用户，他们正在授权自己持有的设备，并警告他们不要批准意外的请求或通过钓鱼消息提供的代码。

有关其他威胁模型和缓解措施的指导，请参阅 IETF 的 [跨设备流程：安全最佳当前实践](https://datatracker.ietf.org/doc/draft-ietf-oauth-cross-device-security/)。

## 选择设备所需的令牌 [#选择设备所需的令牌]

设备授权可以通过两种不同的令牌类型完成。请根据客户端需要调用的内容选择令牌：

| 使用场景                                    | 令牌                 | 插件                                                       | 令牌端点            |
| --------------------------------------- | ------------------ | -------------------------------------------------------- | --------------- |
| 将您自己的设备登录到同一个 Better Auth 应用            | Better Auth 会话令牌   | `deviceAuthorization()`                                  | `/device/token` |
| 让已注册的 CLI、电视应用或其他公共客户端调用受 OAuth 保护的 API | 有作用域限制的 OAuth 访问令牌 | `jwt()`、`oauthProvider()` 和 `oauthDeviceAuthorization()` | `/oauth2/token` |

命令行应用通常需要第二种流程。CLI 无法安全地保存客户端密钥，也可能没有可靠的重定向监听器，但它仍然需要一个经过用户授权、用于调用您 API 的令牌。设备授予允许用户在浏览器中批准请求，同时 CLI 接收一个绑定到受众的 OAuth 访问令牌。

### 授权 CLI 调用 API [#授权-cli-调用-api]

添加 [JWT](/docs/plugins/jwt)、[OAuth Provider](/docs/plugins/oauth-provider) 和 OAuth Device Authorization 集成：

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import {
  oauthDeviceAuthorization,
  oauthProvider,
} from "@better-auth/oauth-provider"; // [!code highlight]

export const auth = betterAuth({
  plugins: [
    jwt(),
    oauthProvider({
      loginPage: "/sign-in",
      consentPage: "/consent",
      scopes: ["openid", "profile", "offline_access", "api:read"],
      resources: ["https://api.example.com"],
    }),
    oauthDeviceAuthorization({ // [!code highlight]
      verificationUri: "/device",
    }),
  ],
});
```

将 CLI 注册为公共原生客户端，并将其链接到 API 资源。公共客户端使用 `token_endpoint_auth_method: "none"`，因为已安装的 CLI 无法对共享密钥保密：

```ts title="register-cli.ts"
import { DEVICE_CODE_GRANT_TYPE } from "@better-auth/oauth-provider";

await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    token_endpoint_auth_method: "none",
    type: "native",
    grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
    scope: "openid profile offline_access api:read",
    resources: ["https://api.example.com"],
  },
});
```

公共客户端使用 `token_endpoint_auth_method: "none"`，并且必须发送 `client_id`。使用 `client_secret_basic` 的机密客户端可以通过 `Authorization: Basic ...` 标头在 `/device/code` 进行身份验证，并省略请求体中的 `client_id`；`client_secret_post` 则会在表单请求体中发送这两个值。

随后，CLI 为其打算调用的 API 请求设备码：

```ts title="cli.ts"
import { createAuthClient } from "better-auth/client";
import { oauthDeviceAuthorizationClient } from "@better-auth/oauth-provider/client";

const authBaseURL = "https://auth.example.com/api/auth";
const clientId = "YOUR_CLIENT_ID";
const resource = "https://api.example.com";

const authClient = createAuthClient({
  baseURL: authBaseURL,
  plugins: [oauthDeviceAuthorizationClient()],
});

const { data } = await authClient.device.code({
  client_id: clientId,
  scope: "openid profile offline_access api:read",
  resource,
});

if (!data) throw new Error("Could not start device authorization");

console.log(`Open ${data.verification_uri}`);
console.log(`Enter code ${data.user_code}`);
```

用户登录并批准请求期间，请按照设备代码响应返回的间隔，重复发送以下请求：

```ts title="cli.ts"
const response = await fetch(`${authBaseURL}/oauth2/token`, {
  method: "POST",
  headers: {
    "content-type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
    device_code: data.device_code,
    client_id: clientId,
  }),
});

const tokens = await response.json();
```

访问令牌是一个已签名的 JWT，其 `aud` 标识为 `https://api.example.com`。将其作为 `Authorization: Bearer <access_token>` 发送给该 API，然后在 API 中验证其签名、签发者、受众、过期时间和所需作用域。请参阅 [API 服务器验证](/docs/plugins/oauth-provider#api-服务器)。如果 CLI 请求了 `offline_access`，请将返回的刷新令牌存储在操作系统的安全凭据存储中，并使用它来续期有效期较短的访问令牌。

<Callout type="warn">
  不要为此 OAuth 流程调用 `authClient.device.token`。该客户端方法会轮询 `/device/token` 并返回 Better Auth 会话令牌。已注册的 OAuth 客户端必须轮询 `/oauth2/token`。
</Callout>

`oauthDeviceAuthorization()` 负责设备授权端点，并向 OAuth Provider 注册 `urn:ietf:params:oauth:grant-type:device_code` 授予类型。Provider 会在创建请求前验证已注册的客户端、作用域和 [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) 资源。批准页面可以向已登录用户显示客户端、作用域和资源。

<Callout type="info">
  两种令牌路径可以共存。第一方设备登录继续使用 `/device/token`。已注册的 OAuth 客户端使用 `/oauth2/token`；`oauthDeviceAuthorization()` 会阻止它们将设备码兑换为 Better Auth 会话令牌。
</Callout>

## 第一方会话流程 [#第一方会话流程]

本页其余部分介绍第一方流程，其中 `/device/token` 返回 Better Auth 会话令牌。

### 请求设备授权 [#请求设备授权]

调用 `device.code` 并传入客户端 ID 来发起设备授权：

**Endpoint:** `POST /device/code`

### Client Side

```ts
const { data, error } = await authClient.device.code({
    client_id, // required, The device client identifier
    scope, // 可选的空格分隔的请求作用域列表
    user_id, // 设备码应预先绑定到的用户 ID。 设置后，只有该用户可以批准或拒绝该代码。 仅从受信任的服务器端代码传入此值。（可选）
});
```

### Server Side

```ts
const data = await auth.api.deviceCode({
    body: {
        client_id, // required, The device client identifier
        scope, // 可选的空格分隔的请求作用域列表
        user_id, // 设备码应预先绑定到的用户 ID。 设置后，只有该用户可以批准或拒绝该代码。 仅从受信任的服务器端代码传入此值。（可选）
    },
});
```

### Type Definition

```ts
type deviceCode = {
    /**
     * The device client identifier
     */
    client_id: string;
    /**
     * 可选的空格分隔的请求作用域列表
     */
    scope?: string;
    /**
     * 设备码应预先绑定到的用户 ID。
     * 设置后，只有该用户可以批准或拒绝该代码。
     * 仅从受信任的服务器端代码传入此值。（可选）
     */
    user_id?: string;
}
```

使用示例：

```ts
import { authClient } from "@/lib/auth-client"

const { data } = await authClient.device.code({
  client_id: "your-client-id",
  scope: "openid profile email",
});

if (data) {
  console.log(`用户代码: ${data.user_code}`);
  console.log(`验证 URL: ${data.verification_uri}`);
  console.log(`完整验证 URL: ${data.verification_uri_complete}`);
}
```

### 预绑定到用户 [#预绑定到用户]

如果您的服务器已经知道设备属于哪个用户，请在请求设备码时传入 `user_id`。这样该代码会从一开始就绑定到该用户。它会跳过认领步骤，只有绑定的用户才能批准或拒绝它。任何其他已登录用户都会收到 `access_denied` 错误。

这在用户代码显示在别人也能看到的地方时非常有用，因为在目标用户验证之前，其他人无法先行认领该代码。

```ts title="server.ts"
const data = await auth.api.deviceCode({
  body: {
    client_id: "your-client-id",
    scope: "openid profile email",
    user_id: user.id,
  },
});
```

<Callout type="warn">
  请从受信任的服务器端代码中传入 `user_id`。该参数只会限制谁可以批准该代码，因此不受信任的设备无法利用它访问其他用户的账户。只有在您的服务器控制代码签发方式时，这种保护才有意义。
</Callout>

### 轮询 Better Auth 会话令牌 [#轮询-better-auth-会话令牌]

显示用户代码后，轮询 Better Auth 会话令牌。响应的 `access_token` 字段包含 Better Auth 会话令牌，而不是 RFC 8628 OAuth 访问令牌：

**Endpoint:** `POST /device/token`

### Client Side

```ts
const { data, error } = await authClient.device.token({
    grant_type, // required, 必须为 "urn:ietf:params:oauth:grant-type:device_code"
    device_code, // required, 初始请求中的设备码
    client_id, // required, The device client identifier
});
```

### Server Side

```ts
const data = await auth.api.deviceToken({
    body: {
        grant_type, // required, 必须为 "urn:ietf:params:oauth:grant-type:device_code"
        device_code, // required, 初始请求中的设备码
        client_id, // required, The device client identifier
    },
});
```

### Type Definition

```ts
type deviceToken = {
    /**
     * 必须为 "urn:ietf:params:oauth:grant-type:device_code"
     */
    grant_type: string;
    /**
     * 初始请求中的设备码
     */
    device_code: string;
    /**
     * The device client identifier
     */
    client_id: string;
}
```

示例轮询实现：

```ts
let pollingInterval = 5; // 初始轮询间隔为 5 秒
const pollForToken = async () => {
  const { data, error } = await authClient.device.token({
    grant_type: "urn:ietf:params:oauth:grant-type:device_code",
    device_code,
    client_id: yourClientId,
    fetchOptions: {
      headers: {
        "user-agent": `My CLI`,
      },
    },
  });

  if (data?.access_token) {
    console.log("授权成功！");
  } else if (error) {
    switch (error.error) {
      case "authorization_pending":
        // 继续轮询
        break;
      case "slow_down":
        pollingInterval += 5;
        break;
      case "access_denied":
        console.error("用户拒绝了访问请求");
        return;
      case "expired_token":
        console.error("设备代码已过期，请重试。");
        return;
      default:
        console.error(`错误：${error.error_description}`);
        return;
    }
    setTimeout(pollForToken, pollingInterval * 1000);
  }
};

pollForToken();
```

### 用户授权流程 [#用户授权流程]

用户授权流程包含两个步骤：

1. **代码验证**：通过 `GET /device` 验证用户代码。验证请求会为当前会话声明待处理的设备代码。
2. **授权**：声明了该代码的会话可以批准或拒绝它。

<Callout type="warn">
  用户在调用 `GET /device` 时必须已通过身份验证，因为验证步骤会将待处理的设备代码绑定到该会话。之后只有同一会话才能批准或拒绝。如果用户在输入代码时尚未登录，请将其重定向到登录页面并带上返回 URL，登录后重新调用 `GET /device`。
</Callout>

启用 OAuth Provider 集成后，`GET /device` 还会向拥有该请求的已验证身份用户返回已批准的资源上下文。在批准 UI 中，将该上下文与客户端和作用域一起显示。独立使用 Device Authorization 时不会返回 RFC 8707 `resource` 字段。

创建一个允许用户输入代码的页面：

```tsx title="app/device/page.tsx"
export default function DeviceAuthorizationPage() {
  const { data: session } = authClient.useSession();
  const searchParams = useSearchParams();
  const [userCode, setUserCode] = useState(searchParams.get("user_code") || "");
  const [error, setError] = useState(null);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    
    try {
      // 格式化代码：去除短横线并转换为大写
      const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
      const approvalPath = `/device/approve?user_code=${encodeURIComponent(formattedCode)}`;

      if (!session?.user) {
        const verificationPath = `/device?user_code=${encodeURIComponent(formattedCode)}`;
        window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
        return;
      }

      // 调用 GET /device 接口验证代码有效性
      const response = await authClient.device({
        query: { user_code: formattedCode },
      });
      
      if (response.data) {
        // 重定向到批准页面
        window.location.href = approvalPath;
      }
    } catch (err) {
      setError("无效或已过期的代码");
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={userCode}
        onChange={(e) => setUserCode(e.target.value)}
        placeholder="输入设备代码（例如 ABCD-1234）"
        maxLength={12}
      />
      <button type="submit">继续</button>
      {error && <p>{error}</p>}
    </form>
  );
}
```

### 批准或拒绝设备 [#批准或拒绝设备]

用户必须登录后才能批准或拒绝授权请求：

#### 批准设备 [#批准设备]

**Endpoint:** `POST /device/approve`

### Client Side

```ts
const { data, error } = await authClient.device.approve({
    userCode, // required, 要批准的用户代码
});
```

### Server Side

```ts
const data = await auth.api.deviceApprove({
    body: {
        userCode, // required, 要批准的用户代码
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type deviceApprove = {
    /**
     * 要批准的用户代码
     */
    userCode: string;
}
```

#### 拒绝设备 [#拒绝设备]

**Endpoint:** `POST /device/deny`

### Client Side

```ts
const { data, error } = await authClient.device.deny({
    userCode, // required, 要拒绝的用户代码
});
```

### Server Side

```ts
const data = await auth.api.deviceDeny({
    body: {
        userCode, // required, 要拒绝的用户代码
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type deviceDeny = {
    /**
     * 要拒绝的用户代码
     */
    userCode: string;
}
```

#### 批准页面示例 [#批准页面示例]

```tsx title="app/device/approve/page.tsx"
export default function DeviceApprovalPage() {
  const { user } = useAuth(); // 必须已登录
  const searchParams = useSearchParams();
  const userCode = searchParams.get("user_code");
  const [isProcessing, setIsProcessing] = useState(false);
  const [request, setRequest] = useState<{
    client_id?: string;
    scope?: string;
    resource?: string | string[];
  } | null>(null);

  useEffect(() => {
    if (!user || !userCode) return;
    authClient.device({ query: { user_code: userCode } }).then(({ data }) => {
      setRequest(data);
    });
  }, [user, userCode]);
  
  const handleApprove = async () => {
    setIsProcessing(true);
    try {
      await authClient.device.approve({
        userCode: userCode,
      });
      // 显示成功消息
      alert("设备授权成功！");
      window.location.href = "/";
    } catch (error) {
      alert("批准设备失败");
    }
    setIsProcessing(false);
  };
  
  const handleDeny = async () => {
    setIsProcessing(true);
    try {
      await authClient.device.deny({
        userCode: userCode,
      });
      alert("已拒绝设备");
      window.location.href = "/";
    } catch (error) {
      alert("拒绝设备失败");
    }
    setIsProcessing(false);
  };

  if (!user) {
    // 如果未通过身份验证，则重定向到登录页
    const verificationPath = `/device?user_code=${encodeURIComponent(userCode || "")}`;
    window.location.href = `/login?redirect=${encodeURIComponent(verificationPath)}`;
    return null;
  }
  
  return (
    <div>
      <h2>设备授权请求</h2>
      <p>客户端：{request?.client_id}</p>
      <p>作用域：{request?.scope || "None"}</p>
      <p>
        资源：{Array.isArray(request?.resource)
          ? request.resource.join(", ")
          : request?.resource || "None"}
      </p>
      <p>代码：{userCode}</p>
      
      <button onClick={handleApprove} disabled={isProcessing}>
        批准
      </button>
      <button onClick={handleDeny} disabled={isProcessing}>
        拒绝
      </button>
    </div>
  );
}
```

## 高级配置 [#高级配置]

### 客户端验证 [#客户端验证]

您可以验证客户端 ID，确保只有授权的应用可以使用设备流：

启用 OAuth Provider 集成后，只有在 `validateClient` 明确接受未知 OAuth 客户端 ID 时，该 ID 才会进入独立流程。否则，请求会返回 `invalid_client`。

```ts
deviceAuthorization({
  validateClient: async (clientId) => {
    // 检查客户端是否有权限
    const client = await db.oauth_clients.findOne({ id: clientId });
    return client && client.allowDeviceFlow;
  },
  
  onDeviceAuthRequest: async (clientId, scope) => {
    // 记录设备授权请求
    await logDeviceAuthRequest(clientId, scope);
  },
})
```

### 自定义代码生成 [#自定义代码生成]

自定义设备代码和用户代码生成方式：

```ts
deviceAuthorization({
  generateDeviceCode: async () => {
    // 自定义设备代码生成
    return crypto.randomBytes(32).toString("hex");
  },
  
  generateUserCode: async () => {
    // 自定义用户代码生成
    // 默认字符集：ABCDEFGHJKLMNPQRSTUVWXYZ23456789
    // （排除了 0、O、1、I 以避免混淆）
    const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    let code = "";
    for (let i = 0; i < 8; i++) {
      code += charset[Math.floor(Math.random() * charset.length)];
    }
    return code;
  },
})
```

默认用户代码不区分大小写，在提交到验证、批准或拒绝端点时，可以接受为便于阅读而插入的空格或标点符号。当自定义用户代码包含默认 `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` 字母表之外的字符时，系统会进行精确匹配。

设备代码签发最多尝试 3 次，以解决唯一键冲突。如果所有尝试都发生冲突，`/device/code` 会返回 `server_error`。

## 错误处理 [#错误处理]

设备流程定义了具体的错误代码：

| 错误代码                    | 说明             |
| ----------------------- | -------------- |
| `authorization_pending` | 用户尚未批准（继续轮询）   |
| `slow_down`             | 轮询过于频繁（增加间隔）   |
| `expired_token`         | 设备代码已过期        |
| `access_denied`         | 用户拒绝了授权        |
| `invalid_grant`         | 无效的设备代码或客户端 ID |

## 示例：CLI 应用 [#示例cli-应用]

以下是一个基于官方演示的 CLI 应用程序完整示例：

<Callout type="info">
  若要使用访问令牌调用 API，请确保您的认证实例已添加了 [Bearer 插件](/docs/plugins/bearer)。
</Callout>

```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
import open from "open";

const authClient = createAuthClient({
  baseURL: "http://localhost:3000",
  plugins: [deviceAuthorizationClient()],
});

async function authenticateCLI() {
  console.log("🔐 Better Auth 设备授权演示");
  console.log("⏳ 请求设备授权...");
  
  try {
    // 请求设备代码
    const { data, error } = await authClient.device.code({
      client_id: "demo-cli",
      scope: "openid profile email",
    });
    
    if (error || !data) {
      console.error("❌ 错误:", error?.error_description);
      process.exit(1);
    }
    
    const {
      device_code,
      user_code,
      verification_uri,
      verification_uri_complete,
      interval = 5,
    } = data;
    
    console.log("\n📱 设备授权进行中");
    console.log(`请访问: ${verification_uri}`);
    console.log(`输入代码: ${user_code}\n`);
    
    // 打开浏览器至验证页面
    const urlToOpen = verification_uri_complete || verification_uri;
    console.log("🌐 正在打开浏览器...");
    await open(urlToOpen);
    
    console.log(`⏳ 等待授权...（每 ${interval} 秒轮询一次）`);
    
    // 开始轮询获取令牌
    await pollForToken(device_code, interval);
  } catch (err) {
    console.error("❌ 错误:", err.message);
    process.exit(1);
  }
}

async function pollForToken(deviceCode: string, interval: number) {
  let pollingInterval = interval;
  
  return new Promise<void>((resolve) => {
    const poll = async () => {
      try {
        const { data, error } = await authClient.device.token({
          grant_type: "urn:ietf:params:oauth:grant-type:device_code",
          device_code: deviceCode,
          client_id: "demo-cli",
        });
        
        if (data?.access_token) {
          console.log("\n授权成功！");
          console.log("已收到访问令牌！");
          
          // 使用访问令牌获取用户会话
          const { data: session } = await authClient.getSession({
            fetchOptions: {
              headers: {
                Authorization: `Bearer ${data.access_token}`,
              },
            },
          });
          
          console.log(`您好，${session?.user?.name || "用户"}！`);
          resolve();
          process.exit(0);
        } else if (error) {
          switch (error.error) {
            case "authorization_pending":
              // 静默继续轮询
              break;
            case "slow_down":
              pollingInterval += 5;
              console.log(`⚠️  减慢轮询频率至 ${pollingInterval} 秒`);
              break;
            case "access_denied":
              console.error("❌ 用户拒绝了访问请求");
              process.exit(1);
              break;
            case "expired_token":
              console.error("❌ 设备代码已过期，请重试。");
              process.exit(1);
              break;
            default:
              console.error("❌ 错误:", error.error_description);
              process.exit(1);
          }
        }
      } catch (err) {
        console.error("❌ 网络错误:", err.message);
        process.exit(1);
      }
      
      // 安排下一次轮询
      setTimeout(poll, pollingInterval * 1000);
    };
    
    // 启动轮询
    setTimeout(poll, pollingInterval * 1000);
  });
}

// 运行认证流程
authenticateCLI().catch((err) => {
  console.error("❌ 致命错误:", err);
  process.exit(1);
});
```

## 安全注意事项 [#安全注意事项]

1. **验证和轮询限制**：`/device` 在一个等于配置的设备代码生命周期的时间窗口内最多允许 5 次请求。`/device/token` 有独立的轮询间隔和 `slow_down` 行为
2. **代码过期**：设备代码和用户代码会在配置的时间后过期（默认：30 分钟）
3. **客户端验证**：在生产环境中始终验证客户端 ID，以防止未经授权的访问
4. **HTTPS 和批准 UI**：在生产环境中遵循 [RFC 8628 TLS 和用户交互要求](https://datatracker.ietf.org/doc/html/rfc8628#section-3.3)，包括明确的批准或拒绝，以及[设备持有和远程钓鱼指南](https://datatracker.ietf.org/doc/html/rfc8628#section-5.4)
5. **用户代码格式**：用户代码使用有限的字符集，并排除类似的字符（如 0/O、1/I），以减少输入错误
6. **需要身份验证**：用户调用 `GET /device` 时必须通过身份验证。验证步骤会为调用会话声明待处理的设备代码，之后只有该会话才能批准或拒绝它
7. **预绑定**：使用 `user_id` 签发的设备代码会跳过认领步骤，并且只能由该用户批准或拒绝。只能从受信任的服务器端代码传入 `user_id`

## 选项 [#选项]

### 服务器端 [#服务器端]

**verificationUri**：用户输入设备代码的验证页面 URL。应与您的验证页面路由匹配。在响应中返回为 `verification_uri`。可以是完整 URL（例如 `https://example.com/device`）或相对路径（例如 `/device`）。默认值：`/device`。

**expiresIn**：设备代码的过期时间。默认值：`"30m"`（30 分钟）。

**interval**：最小轮询间隔。默认值：`"5s"`（5 秒）。

**userCodeLength**：用户代码的长度。最大值：`191`。默认值：`8`。

**deviceCodeLength**：设备代码的长度。最大值：`191`。默认值：`40`。

**generateDeviceCode**：用于生成设备代码的自定义函数。返回最多包含 191 个字符的字符串或 `Promise<string>`。

**generateUserCode**：用于生成用户代码的自定义函数。返回最多包含 191 个字符的字符串或 `Promise<string>`。

**validateClient**：客户端 ID 验证函数，接收 clientId，返回布尔值或 `Promise<boolean>`。

**onDeviceAuthRequest**：设备授权请求时调用的钩子，接收 clientId 和可选 scope。

### 客户端 [#客户端]

无客户端特定配置选项。插件添加以下方法：

* **device()**：验证用户码有效性
* **device.code()**：请求设备码和用户码
* **device.token()**：轮询访问令牌
* **device.approve()**：批准设备（需要身份验证）
* **device.deny()**：拒绝设备（需要身份验证）

## 模式 [#模式]

插件需要新增一个表用以存储设备授权数据。

表名：`deviceCode`

`deviceCode` 和 `userCode` 具有唯一索引，因为设备令牌、验证、批准和拒绝流程会使用它们作为查找字段。两个值的长度都限制为 191 个字符。

<Callout type="warn">
  在应用迁移之前，请在每个适配器上解决重复的 `deviceCode` 和 `userCode` 值。MySQL 和 SQL Server 安装还必须将两列转换为有长度限制的字符串，并清理长度超过 191 个字符的值。
</Callout>



<DatabaseTable name="deviceCode" fields="deviceCodeTableFields" />

`oauthDeviceAuthorization()` 将此表扩展为包含可选的 `oauthClientId` 和 `resources` 字段。独立的 Device Authorization 安装不会添加这些字段，也不会公开 RFC 8707 的 `resource` 请求参数。

