Device Authorization (device_code)
Device authorization is for terminals without a browser or with limited input, such as TVs, CLI tools, and IoT devices: the device first obtains a user_code, the user opens a verification page on a phone or computer, enters the code, signs in, and confirms, while the device polls the token endpoint.
Bottom line: wicket v0.1.4 does not yet support the complete device authorization flow, and we do not recommend using it in production. The rest of this page explains which parts already exist, what is missing, and how to piece it together for verification in an experimental environment.
Status at a glance
| Component | v0.1.4 status |
|---|---|
Token endpoint grant_type=urn:ietf:params:oauth:grant-type:device_code | Works; recognizes states such as authorization_pending, expired_token, and access_denied |
DeviceFlowStore storage port and in-memory implementation | Exists; can be injected with cfg.SetDeviceFlowStore |
Device authorization endpoint /connect/deviceauthorization | Not registered. The discovery document still lists this address as device_authorization_endpoint, but requests fall straight through to the host's handler; wicket does not handle them |
Request validation and response generation (DeviceAuthorizationRequestValidator, DeviceAuthorizationResponseGenerator) | Registered in the container, but no handler calls them |
Interaction service for the user to enter user_code and confirm | None; you can only call DeviceFlowCodeService directly |
| Issued access tokens | Missing scope and aud (see below); unusable by API resources |
WicketOptions.DeviceFlow defaults | Not initialized: Interval is 0 and DefaultUserCodeType is empty |
The discovery document does not match reality
The device_authorization_endpoint and the device_code entry in grant_types_supported in /.well-known/openid-configuration lead client libraries to believe the server supports the device flow. If your deployment does not intend to use the device flow, make sure no client is assigned wicket.GrantTypesDeviceFlow, and have the host return 404 for /connect/deviceauthorization.
Tokens are missing scopes
After piecing together the full flow in an experimental environment (see the next section), the tokens the device received were as follows:
{"access_token":"eyJ...","expires_in":3600,"id_token":"eyJ...","refresh_token":"","scope":"","token_type":"Bearer"}The access token payload contains only:
{"client_id":"tv-app","exp":1794542967,"iat":1794539367,"iss":"http://localhost:5000","nbf":1794539367,"sub":"alice"}openid orders.read was requested and fully granted, but the token has no scope and no aud; calling the introspection endpoint as the orders resource returns {"active":false}. Until a fixed version is released, access tokens issued by the device flow are useless to APIs; do not use the device flow in production.
Experiment: the host builds the device authorization endpoint
For evaluation and testing only (expand to see working code)
The following code compiles and runs in the experimental program: the device gets a device_code / user_code, polling returns authorization_pending, after the host confirms the token endpoint issues tokens (but with the problems above), and redeeming again returns invalid_grant.
First fill in the uninitialized options (otherwise interval in the response is 0):
opts.DeviceFlow.Interval = 5
opts.DeviceFlow.DefaultUserCodeType = wicket.UserCodeTypesNumeric
// opts.UserInteraction.DeviceVerificationUrl defaults to "/device"; the parameter name defaults to "userCode"Mount the device authorization endpoint on the host mux (wicket does not handle this path, so requests land here):
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)
})Observed response (note that in v0.1.4 the Content-Type here is 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"}The host implements the verification page /device: it requires the user to sign in first, then finds the request by user_code and confirms it. The sign-in check is omitted below; currentUser stands for the principal of the currently signed-in user:
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 // to deny: IsAuthorized = true and AuthorizedScopes empty
if err := codes.UpdateByUserCode(r.Context(), userCode, dc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}Device polling:
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/tokenBefore confirmation, it returns authorization_pending; submitting with a client not allowed the device grant type (billing-worker) returns unauthorized_client; submitting again after a successful redemption returns invalid_grant (all observed).
Workable alternatives for now
- You need the user's identity and the terminal has a backend: have the terminal show a short link or QR code pointing to your own web page, which uses authorization code + PKCE, with the backend completing sign-in on the terminal's behalf.
- The user confirms on another signed-in device: if your backend initiates on behalf of the terminal, consider CIBA, which works end to end in v0.1.4.
- The terminal represents only itself: use client credentials.
A complete device authorization endpoint is planned; see the Changelog for the target version.
Related reading
- Choosing a flow
- Discovery document: each field compared with the actual implementation
- Protocol endpoints: registered and unregistered endpoints