设备授权
针对输入受限设备的 OAuth 2.0 设备授权授予
RFC 8628 CLI 智能电视 物联网
Device Authorization 插件为智能电视、CLI 应用、物联网设备和游戏主机等输入受限设备,实现了 OAuth 2.0 设备授权授予(RFC 8628)中的代码签发和批准流程。您可以单独使用它来获取 Better Auth 会话令牌,也可以将其与 OAuth Provider 组合使用来签发 OAuth 访问令牌。
立即试用
您可以使用 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 并输入用户码
- 设备轮询令牌:设备轮询服务器,直到用户完成授权
- 授予访问权限:授权后,设备会收到由配置的集成提供的会话令牌或 OAuth 访问令牌
生产环境安全要求
RFC 8628 要求设备发出出站 HTTPS 请求,并要求用户在安全的 TLS 保护会话中通过验证 URI 进行身份验证。在生产环境中,应通过 HTTPS 提供验证和批准 UI、设备代码请求以及令牌轮询。仅在明确的本地开发场景中使用 HTTP。
批准 UI 是安全边界的一部分。它必须:
- 要求用户输入
user_code,或者在使用verification_uri_complete(例如二维码)时,要求用户确认显示的代码与设备上的代码匹配; - 显示正在授权的内容:客户端、请求的作用域,以及在启用 OAuth Provider 集成时,请求的资源;
- 要求用户明确批准或拒绝;并且
- 告知用户,他们正在授权自己持有的设备,并警告他们不要批准意外的请求或通过钓鱼消息提供的代码。
有关其他威胁模型和缓解措施的指导,请参阅 IETF 的 跨设备流程:安全最佳当前实践。
选择设备所需的令牌
设备授权可以通过两种不同的令牌类型完成。请根据客户端需要调用的内容选择令牌:
| 使用场景 | 令牌 | 插件 | 令牌端点 |
|---|---|---|---|
| 将您自己的设备登录到同一个 Better Auth 应用 | Better Auth 会话令牌 | deviceAuthorization() | /device/token |
| 让已注册的 CLI、电视应用或其他公共客户端调用受 OAuth 保护的 API | 有作用域限制的 OAuth 访问令牌 | jwt()、oauthProvider() 和 oauthDeviceAuthorization() | /oauth2/token |
命令行应用通常需要第二种流程。CLI 无法安全地保存客户端密钥,也可能没有可靠的重定向监听器,但它仍然需要一个经过用户授权、用于调用您 API 的令牌。设备授予允许用户在浏览器中批准请求,同时 CLI 接收一个绑定到受众的 OAuth 访问令牌。
授权 CLI 调用 API
添加 JWT、OAuth Provider 和 OAuth Device Authorization 集成:
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import {
oauthDeviceAuthorization,
oauthProvider,
} from "@better-auth/oauth-provider";
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({
verificationUri: "/device",
}),
],
});将 CLI 注册为公共原生客户端,并将其链接到 API 资源。公共客户端使用 token_endpoint_auth_method: "none",因为已安装的 CLI 无法对共享密钥保密:
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 请求设备码:
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}`);用户登录并批准请求期间,请按照设备代码响应返回的间隔,重复发送以下请求:
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 服务器验证。如果 CLI 请求了 offline_access,请将返回的刷新令牌存储在操作系统的安全凭据存储中,并使用它来续期有效期较短的访问令牌。
不要为此 OAuth 流程调用 authClient.device.token。该客户端方法会轮询 /device/token 并返回 Better Auth 会话令牌。已注册的 OAuth 客户端必须轮询 /oauth2/token。
oauthDeviceAuthorization() 负责设备授权端点,并向 OAuth Provider 注册 urn:ietf:params:oauth:grant-type:device_code 授予类型。Provider 会在创建请求前验证已注册的客户端、作用域和 RFC 8707 资源。批准页面可以向已登录用户显示客户端、作用域和资源。
两种令牌路径可以共存。第一方设备登录继续使用 /device/token。已注册的 OAuth 客户端使用 /oauth2/token;oauthDeviceAuthorization() 会阻止它们将设备码兑换为 Better Auth 会话令牌。
第一方会话流程
本页其余部分介绍第一方流程,其中 /device/token 返回 Better Auth 会话令牌。
请求设备授权
调用 device.code 并传入客户端 ID 来发起设备授权:
const { data, error } = await authClient.device.code({ client_id, // required scope, user_id,});client_idstring;requiredThe device client identifier
scopestring;可选的空格分隔的请求作用域列表
user_idstring;设备码应预先绑定到的用户 ID。 设置后,只有该用户可以批准或拒绝该代码。 仅从受信任的服务器端代码传入此值。(可选)
使用示例:
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 错误。
这在用户代码显示在别人也能看到的地方时非常有用,因为在目标用户验证之前,其他人无法先行认领该代码。
const data = await auth.api.deviceCode({
body: {
client_id: "your-client-id",
scope: "openid profile email",
user_id: user.id,
},
});请从受信任的服务器端代码中传入 user_id。该参数只会限制谁可以批准该代码,因此不受信任的设备无法利用它访问其他用户的账户。只有在您的服务器控制代码签发方式时,这种保护才有意义。
轮询 Better Auth 会话令牌
显示用户代码后,轮询 Better Auth 会话令牌。响应的 access_token 字段包含 Better Auth 会话令牌,而不是 RFC 8628 OAuth 访问令牌:
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;requiredThe device client identifier
示例轮询实现:
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();用户授权流程
用户授权流程包含两个步骤:
- 代码验证:通过
GET /device验证用户代码。验证请求会为当前会话声明待处理的设备代码。 - 授权:声明了该代码的会话可以批准或拒绝它。
用户在调用 GET /device 时必须已通过身份验证,因为验证步骤会将待处理的设备代码绑定到该会话。之后只有同一会话才能批准或拒绝。如果用户在输入代码时尚未登录,请将其重定向到登录页面并带上返回 URL,登录后重新调用 GET /device。
启用 OAuth Provider 集成后,GET /device 还会向拥有该请求的已验证身份用户返回已批准的资源上下文。在批准 UI 中,将该上下文与客户端和作用域一起显示。独立使用 Device Authorization 时不会返回 RFC 8707 resource 字段。
创建一个允许用户输入代码的页面:
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>
);
}批准或拒绝设备
用户必须登录后才能批准或拒绝授权请求:
批准设备
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("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。
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;
},
})默认用户代码不区分大小写,在提交到验证、批准或拒绝端点时,可以接受为便于阅读而插入的空格或标点符号。当自定义用户代码包含默认 ABCDEFGHJKLMNPQRSTUVWXYZ23456789 字母表之外的字符时,系统会进行精确匹配。
设备代码签发最多尝试 3 次,以解决唯一键冲突。如果所有尝试都发生冲突,/device/code 会返回 server_error。
错误处理
设备流程定义了具体的错误代码:
| 错误代码 | 说明 |
|---|---|
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);
});安全注意事项
- 验证和轮询限制:
/device在一个等于配置的设备代码生命周期的时间窗口内最多允许 5 次请求。/device/token有独立的轮询间隔和slow_down行为 - 代码过期:设备代码和用户代码会在配置的时间后过期(默认:30 分钟)
- 客户端验证:在生产环境中始终验证客户端 ID,以防止未经授权的访问
- HTTPS 和批准 UI:在生产环境中遵循 RFC 8628 TLS 和用户交互要求,包括明确的批准或拒绝,以及设备持有和远程钓鱼指南
- 用户代码格式:用户代码使用有限的字符集,并排除类似的字符(如 0/O、1/I),以减少输入错误
- 需要身份验证:用户调用
GET /device时必须通过身份验证。验证步骤会为调用会话声明待处理的设备代码,之后只有该会话才能批准或拒绝它 - 预绑定:使用
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 个字符。
在应用迁移之前,请在每个适配器上解决重复的 deviceCode 和 userCode 值。MySQL 和 SQL Server 安装还必须将两列转换为有长度限制的字符串,并清理长度超过 191 个字符的值。
oauthDeviceAuthorization() 将此表扩展为包含可选的 oauthClientId 和 resources 字段。独立的 Device Authorization 安装不会添加这些字段,也不会公开 RFC 8707 的 resource 请求参数。