设备授权

针对输入受限设备的 OAuth 2.0 设备授权授予

RFC 8628 CLI 智能电视 物联网

设备授权插件实现了 OAuth 2.0 设备授权授予 (RFC 8628),支持智能电视、CLI 应用、物联网设备和游戏主机等输入能力有限的设备进行身份验证。

立即试用

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

npx auth login

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

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

CLI 登录命令是一个演示功能,连接到 Better Auth 演示服务器,用以展示设备授权流程的实际运行。

安装

在认证配置中添加插件

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

auth.ts
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins"; 

export const auth = betterAuth({
  // ... 其他配置
  plugins: [
    deviceAuthorization({ 
      verificationUri: "/device", 
    }), 
  ],
});

迁移数据库

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

npx auth migrate
npx auth generate

请参见 架构 部分以手动添加字段。

添加客户端插件

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

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

export const authClient = createAuthClient({
  plugins: [
    deviceAuthorizationClient(), 
  ],
});

工作原理

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

  1. 设备请求代码:设备从授权服务器请求设备码和用户码
  2. 用户授权:用户访问验证 URL 并输入用户码
  3. 设备轮询获取令牌:设备持续轮询服务器,等待用户完成授权
  4. 访问授权:授权完成后,设备接收访问令牌

基础用法

请求设备授权

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

POST/device/code
const { data, error } = await authClient.device.code({    client_id, // required    scope,});
Parameters
client_idstring;required

OAuth 客户端标识符

scopestring;

可选的空格分隔的请求作用域列表

使用示例:

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}`);
}

轮询获取令牌

显示用户代码后,轮询服务器获取访问令牌:

POST/device/token
const { data, error } = await authClient.device.token({    grant_type, // required    device_code, // required    client_id, // required});
Parameters
grant_typestring;required

必须为 "urn:ietf:params:oauth:grant-type:device_code"

device_codestring;required

初始请求中的设备码

client_idstring;required

OAuth 客户端标识符

示例轮询实现:

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. 授权:声明了该代码的会话可以批准或拒绝它。

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

创建让用户输入代码的页面:

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>
  );
}

批准或拒绝设备

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

批准设备

POST/device/approve
const { data, error } = await authClient.device.approve({    userCode, // required});
Parameters
userCodestring;required

要批准的用户代码

拒绝设备

POST/device/deny
const { data, error } = await authClient.device.deny({    userCode, // required});
Parameters
userCodestring;required

要拒绝的用户代码

批准页面示例

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 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>有设备请求访问您的账户。</p>
      <p>代码: {userCode}</p>
      
      <button onClick={handleApprove} disabled={isProcessing}>
        批准
      </button>
      <button onClick={handleDeny} disabled={isProcessing}>
        拒绝
      </button>
    </div>
  );
}

高级配置

客户端验证

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

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);
  },
})

自定义代码生成

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

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;
  },
})

错误处理

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

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

示例:CLI 应用

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

若要使用访问令牌调用 API,请确保您的认证实例已添加了 Bearer 插件

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. 速率限制:插件会强制执行轮询间隔以防止滥用
  2. 代码过期:设备码和用户码会在配置的时间后过期(默认:30 分钟)
  3. 客户端验证:在生产环境中始终验证客户端 ID,以防止未授权访问
  4. 仅使用 HTTPS:在生产环境中进行设备授权时始终使用 HTTPS
  5. 用户码格式:用户码使用有限的字符集(不包括 0/O、1/I 等相似字符)以减少输入错误
  6. 需要身份验证:在调用 GET /device 时,用户必须已通过身份验证。验证步骤会为调用会话获取待处理的设备码,之后只有该会话才能批准或拒绝它

选项

服务器端

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

expiresIn:设备代码的过期时间。默认值:"30m"(30 分钟)。

interval:最小轮询间隔。默认值:"5s"(5 秒)。

userCodeLength:用户码长度。默认值:8

deviceCodeLength:设备码长度。默认值:40

generateDeviceCode:自定义设备码生成函数。返回字符串或 Promise<string>

generateUserCode:自定义用户码生成函数。返回字符串或 Promise<string>

validateClient:客户端 ID 验证函数,接收 clientId,返回布尔值或 Promise<boolean>

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

客户端

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

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

模式

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

表名:deviceCode

Table
字段
类型
描述
id
string
PK
Unique identifier for the device authorization request
deviceCode
string
-
The device verification code
userCode
string
-
The user-friendly code for verification
userId ?
string
-
The ID of the user who approved/denied
clientId ?
string
-
The OAuth client identifier
scope ?
string
-
Requested OAuth scopes
status
string
-
Current status: pending, approved, or denied
expiresAt
Date
-
When the device code expires
lastPolledAt ?
Date
-
Last time the device polled for status
pollingInterval ?
number
-
Minimum seconds between polls