设备授权
针对输入受限设备的 OAuth 2.0 设备授权授予
RFC 8628 CLI 智能电视 物联网
设备授权插件实现了 OAuth 2.0 设备授权授予 (RFC 8628),支持智能电视、CLI 应用、物联网设备和游戏主机等输入能力有限的设备进行身份验证。
立即试用
您可以使用 Better Auth CLI 立即测试设备授权流程:
npx auth login该命令将演示完整的设备授权流程:
- 从 Better Auth 演示服务器请求设备代码
- 显示供您输入的用户代码
- 打开浏览器至验证页面
- 轮询检查授权完成情况
CLI 登录命令是一个演示功能,连接到 Better Auth 演示服务器,用以展示设备授权流程的实际运行。
安装
将插件添加到您的认证配置中
在服务器配置中添加设备授权插件。
import { betterAuth } from "better-auth";
import { deviceAuthorization } from "better-auth/plugins";
export const auth = betterAuth({
// ... 其他配置
plugins: [
deviceAuthorization({
verificationUri: "/device",
}),
],
});添加客户端插件
在客户端中添加设备授权插件。
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
deviceAuthorizationClient(),
],
});工作原理
设备授权流程包括以下步骤:
- 设备请求代码:设备从授权服务器请求设备码和用户码
- 用户授权:用户访问验证 URL 并输入用户码
- 设备轮询获取令牌:设备持续轮询服务器,等待用户完成授权
- 访问授权:授权完成后,设备接收访问令牌
基础用法
请求设备授权
调用 device.code 并传入客户端 ID 来发起设备授权:
const { data, error } = await authClient.device.code({ client_id, // required scope,});client_idstring;requiredOAuth 客户端标识符
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}`);
}轮询获取令牌
显示用户代码后,轮询服务器获取访问令牌:
const { data, error } = await authClient.device.token({ grant_type, // required device_code, // required client_id, // required});grant_typestring;required必须是 "urn:ietf:params:oauth:grant-type:device_code"
device_codestring;required初始请求返回的设备代码
client_idstring;requiredOAuth 客户端标识符
示例轮询实现:
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();用户授权流程
用户授权流程包括两个步骤:
- 代码验证:验证用户输入的代码是否有效
- 授权操作:用户必须登录后才能批准或拒绝设备授权请求
用户必须先登录,才能批准或拒绝设备授权请求。如果未认证,则应重定向至登录页面并带上返回 URL。
创建让用户输入代码的页面:
export default function DeviceAuthorizationPage() {
const [userCode, setUserCode] = useState("");
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
try {
// 格式化代码:去除短横线并转换为大写
const formattedCode = userCode.trim().replace(/-/g, "").toUpperCase();
// 调用 GET /device 接口验证代码有效性
const response = await authClient.device({
query: { user_code: formattedCode },
});
if (response.data) {
// 跳转到批准页面
window.location.href = `/device/approve?user_code=${formattedCode}`;
}
} 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>
);
}批准或拒绝设备
用户必须登录后才能批准或拒绝授权请求:
批准设备
const { data, error } = await authClient.device.approve({ userCode, // required});userCodestring;required要批准的用户代码
拒绝设备
const { data, error } = await authClient.device.deny({ userCode, // required});userCodestring;required要拒绝的用户代码
批准页面示例
export default function DeviceApprovalPage() {
const { user } = useAuth(); // 必须已登录
const searchParams = useSearchParams();
const userCode = searchParams.get("userCode");
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) {
// 未登录时重定向至登录页面
window.location.href = `/login?redirect=/device/approve?user_code=${userCode}`;
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 插件。
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);
});安全注意事项
- 速率限制:插件强制轮询间隔以防止滥用
- 代码过期:设备码和用户码在配置的时间后过期(默认:30 分钟)
- 客户端验证:生产环境中务必验证客户端 ID 以防止未授权访问
- 仅支持 HTTPS:生产环境必须使用 HTTPS 进行设备授权
- 用户码格式:用户码使用有限字符集(排除易混淆字符如 0/O、1/I)以减少输入错误
- 需要身份验证:用户必须登录后才能批准或拒绝设备请求
选项
服务器端
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