设备授权(device_code)
设备授权用于电视、命令行工具、IoT 设备等没有浏览器或不便输入的终端:设备先拿到一个 user_code,用户在手机或电脑上打开验证页输入它并登录确认,设备同时轮询令牌端点。
结论先行:wicket v0.1.4 暂不支持完整的设备授权流程,不建议在生产中使用。 下文说明哪些部分已经存在、缺什么,以及在实验环境中如何拼起来验证。
现状一览
| 组成部分 | v0.1.4 状态 |
|---|---|
令牌端点 grant_type=urn:ietf:params:oauth:grant-type:device_code | 可用,能识别 authorization_pending、expired_token、access_denied 等状态 |
DeviceFlowStore 存储端口与内存实现 | 存在,可通过 cfg.SetDeviceFlowStore 注入 |
设备授权端点 /connect/deviceauthorization | 未注册。发现文档中的 device_authorization_endpoint 仍会列出该地址,但请求会直接落到宿主的 handler,wicket 不处理 |
请求校验、响应生成(DeviceAuthorizationRequestValidator、DeviceAuthorizationResponseGenerator) | 已在容器中注册,但没有 handler 调用它们 |
用户输入 user_code 并确认的交互服务 | 没有;只能直接调用 DeviceFlowCodeService |
| 签发的访问令牌 | 缺少 scope 与 aud(见下文),API 资源无法使用 |
WicketOptions.DeviceFlow 默认值 | 未初始化:Interval 为 0、DefaultUserCodeType 为空 |
发现文档与实际不符
/.well-known/openid-configuration 中的 device_authorization_endpoint 与 grant_types_supported 里的 device_code 会让客户端库以为服务端支持设备流。如果你的部署不打算使用设备流,确保没有客户端被分配 wicket.GrantTypesDeviceFlow,并让宿主对 /connect/deviceauthorization 返回 404。
令牌缺少作用域的问题
在实验环境里拼出完整流程后(方法见下一节),设备换到的令牌实测如下:
{"access_token":"eyJ...","expires_in":3600,"id_token":"eyJ...","refresh_token":"","scope":"","token_type":"Bearer"}访问令牌载荷只有:
{"client_id":"tv-app","exp":1794542967,"iat":1794539367,"iss":"http://localhost:5000","nbf":1794539367,"sub":"alice"}请求了 openid orders.read 并已全部授权,但令牌中没有 scope、没有 aud;用 orders 资源调用内省端点得到 {"active":false}。在修复版本发布前,设备流签发的访问令牌对 API 没有用处,请勿在生产环境使用设备流。
实验:由宿主拼出设备授权端点
仅用于评估与测试(展开查看已跑通的代码)
以下代码在实验程序中编译并运行通过:设备拿到 device_code / user_code,轮询得到 authorization_pending,宿主确认后令牌端点签发令牌(但令牌存在上述问题),重复兑换返回 invalid_grant。
先补上未初始化的选项(否则响应中 interval 为 0):
opts.DeviceFlow.Interval = 5
opts.DeviceFlow.DefaultUserCodeType = wicket.UserCodeTypesNumeric
// opts.UserInteraction.DeviceVerificationUrl 默认 "/device",参数名默认 "userCode"设备授权端点挂在宿主 mux 上(wicket 不处理该路径,请求会落到这里):
app.HandleFunc("/connect/deviceauthorization", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
sp := hosting.ScopeFrom(r.Context())
if err := r.ParseForm(); err != nil {
_ = results.NewBadRequestResult(wicket.TokenErrorsInvalidRequest, "").Execute(w, r)
return
}
client := godi.GetRequired[validators.ClientSecretValidator](sp).Validate(r)
if client == nil || client.Error != nil {
_ = results.NewBadRequestResult(wicket.TokenErrorsInvalidClient, "").Execute(w, r)
return
}
vr := godi.GetRequired[validators.DeviceAuthorizationRequestValidator](sp).
ValidateRequest(r.Context(), r.Form, client)
if vr.Error != nil {
_ = results.NewBadRequestResult(vr.Error, vr.Description).Execute(w, r)
return
}
resp, err := godi.GetRequired[responses.DeviceAuthorizationResponseGenerator](sp).
Handle(r.Context(), vr, godi.GetRequired[services.ServerUrls](sp).BaseUrl())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = results.NewDeviceAuthorizationResult(resp).Execute(w, r)
})实测响应(注意 v0.1.4 这里的 Content-Type 是 text/plain):
{"device_code":"39DC5D55EDB9...","expires_in":300,"interval":5,"user_code":"642763698",
"verification_uri":"http://localhost:5000/device",
"verification_uri_complete":"http://localhost:5000/device?userCode=642763698"}验证页 /device 由宿主实现:要求用户先登录,再根据 user_code 找到请求并确认。下面省略了登录检查,currentUser 代表当前登录用户的主体:
codes := godi.GetRequired[services.DeviceFlowCodeService](hosting.ScopeFrom(r.Context()))
userCode := r.URL.Query().Get("userCode")
dc, err := codes.FindByUserCode(r.Context(), userCode)
if err != nil || dc == nil {
http.Error(w, "unknown code", http.StatusNotFound)
return
}
dc.IsAuthorized = true
dc.Subject = currentUser
dc.AuthorizedScopes = dc.RequestedScopes // 拒绝时:IsAuthorized = true 且 AuthorizedScopes 为空
if err := codes.UpdateByUserCode(r.Context(), userCode, dc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}设备轮询:
curl -s -d client_id=tv-app \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=$DEVICE_CODE \
http://localhost:5000/connect/token确认前返回 authorization_pending;用一个未被允许设备授权类型的客户端(billing-worker)提交返回 unauthorized_client;兑换成功后再次提交返回 invalid_grant(均为实测)。
目前可行的替代方案
- 需要用户身份、终端有后端:让终端展示一个短链接或二维码,指向你自己的网页,网页走 授权码 + PKCE,由后端代替终端完成登录。
- 用户在另一台已登录设备上确认:如果终端由你的后端代为发起,可以考虑 CIBA,它在 v0.1.4 中可以端到端工作。
- 终端只代表自己:使用 客户端凭据。
完整的设备授权端点在计划中,具体版本以 更新日志 为准。