Skip to content

Commit e596c3d

Browse files
committed
refactor(server): extract the device authorization endpoints into a package
Move the browser-facing side of the OAuth2 device flow (RFC 8628) out of the god Server into a server/device package: the /device user-code page, /device/code authorization request, /device/auth/verify_code verification, and the /device/callback that completes the flow. The handler mounts its own routes and holds only its dependencies (storage, templates, issuer URL, timing, logger) plus the shared ExchangeAuthCode and a RenderError callback. The device_code token grant stays with the token endpoint: it is a /token grant like the others, dispatched from handleToken, so the token endpoint keeps owning it rather than the server reaching into the device handler. The deprecated /device/token endpoint stays alongside it. The PKCE code-challenge method names move to the oauth2 package as PKCEMethodPlain/PKCEMethodS256. No behaviour change. Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
1 parent cf4ab8b commit e596c3d

10 files changed

Lines changed: 437 additions & 407 deletions

File tree

server/device/device.go

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
// Package device implements the browser-facing side of the OAuth2 device
2+
// authorization grant (RFC 8628): the /device user-code entry page, the
3+
// /device/code authorization request, user-code verification, and the callback
4+
// that completes the flow. The device_code token grant that the device polls for
5+
// lives with the token endpoint.
6+
package device
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"errors"
12+
"fmt"
13+
"log/slog"
14+
"net/http"
15+
"net/url"
16+
"path"
17+
"strconv"
18+
"strings"
19+
"time"
20+
21+
"github.com/dexidp/dex/server/oauth2"
22+
"github.com/dexidp/dex/server/router"
23+
"github.com/dexidp/dex/server/templates"
24+
"github.com/dexidp/dex/server/tokens"
25+
"github.com/dexidp/dex/storage"
26+
)
27+
28+
// DeviceCodeResponse is the device authorization response (RFC 8628 §3.2).
29+
type DeviceCodeResponse struct {
30+
// The unique device code for device authentication
31+
DeviceCode string `json:"device_code"`
32+
// The code the user will exchange via a browser and log in
33+
UserCode string `json:"user_code"`
34+
// The url to verify the user code.
35+
VerificationURI string `json:"verification_uri"`
36+
// The verification uri with the user code appended for pre-filling form
37+
VerificationURIComplete string `json:"verification_uri_complete"`
38+
// The lifetime of the device code
39+
ExpireTime int `json:"expires_in"`
40+
// How often the device is allowed to poll to verify that the user login occurred
41+
PollInterval int `json:"interval"`
42+
}
43+
44+
// Handler serves the browser side of the device authorization grant.
45+
type Handler struct {
46+
IssuerURL url.URL
47+
Storage storage.Storage
48+
Templates *templates.Templates
49+
Now func() time.Time
50+
RequestsValidFor time.Duration
51+
Logger *slog.Logger
52+
53+
// ExchangeAuthCode exchanges the authorization code for tokens after the
54+
// user authenticates. It is the authorization-code grant's logic, injected
55+
// so the device handler does not reimplement token issuance.
56+
//
57+
// TODO: this injected Server method is a temporary seam; the auth-code
58+
// exchange should become a shared component both grants depend on directly.
59+
ExchangeAuthCode func(ctx context.Context, w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (tokens.Response, error)
60+
}
61+
62+
// renderError renders an HTML error page.
63+
func (h *Handler) renderError(r *http.Request, w http.ResponseWriter, status int, description string) {
64+
if err := h.Templates.Err(r, w, status, description); err != nil {
65+
h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
66+
}
67+
}
68+
69+
// Mount registers the device authorization routes.
70+
func (h *Handler) Mount(m router.Mux) {
71+
m.HandleFunc("/device", h.handleDeviceExchange)
72+
m.HandleFunc("/device/auth/verify_code", h.verifyUserCode)
73+
m.HandleFunc("/device/code", h.handleDeviceCode)
74+
m.HandleFunc(oauth2.DeviceCallbackURI, h.handleDeviceCallback)
75+
}
76+
77+
func (h *Handler) writeError(w http.ResponseWriter, typ, description string, statusCode int) {
78+
if err := oauth2.WriteError(w, typ, description, statusCode); err != nil {
79+
h.Logger.Error("device error response", "err", err)
80+
}
81+
}
82+
83+
func (h *Handler) getDeviceVerificationURI() string {
84+
return path.Join(h.IssuerURL.Path, "/device/auth/verify_code")
85+
}
86+
87+
func (h *Handler) handleDeviceExchange(w http.ResponseWriter, r *http.Request) {
88+
switch r.Method {
89+
case http.MethodGet:
90+
// Grab the parameter(s) from the query.
91+
// If "user_code" is set, pre-populate the user code text field.
92+
// If "invalid" is set, set the invalidAttempt boolean, which will display a message to the user that they
93+
// attempted to redeem an invalid or expired user code.
94+
userCode := r.URL.Query().Get("user_code")
95+
invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid"))
96+
if err != nil {
97+
invalidAttempt = false
98+
}
99+
if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil {
100+
h.Logger.ErrorContext(r.Context(), "server template error", "err", err)
101+
h.renderError(r, w, http.StatusNotFound, "Page not found")
102+
}
103+
default:
104+
h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
105+
}
106+
}
107+
108+
func (h *Handler) handleDeviceCode(w http.ResponseWriter, r *http.Request) {
109+
ctx := r.Context()
110+
pollIntervalSeconds := 5
111+
112+
switch r.Method {
113+
case http.MethodPost:
114+
err := r.ParseForm()
115+
if err != nil {
116+
h.Logger.ErrorContext(r.Context(), "could not parse Device Request body", "err", err)
117+
h.writeError(w, oauth2.InvalidRequest, "", http.StatusNotFound)
118+
return
119+
}
120+
121+
// Get the client id and scopes from the post
122+
clientID := r.Form.Get("client_id")
123+
clientSecret := r.Form.Get("client_secret")
124+
scopes := strings.Fields(r.Form.Get("scope"))
125+
codeChallenge := r.Form.Get("code_challenge")
126+
codeChallengeMethod := r.Form.Get("code_challenge_method")
127+
128+
if codeChallengeMethod == "" {
129+
codeChallengeMethod = oauth2.PKCEMethodPlain
130+
}
131+
if codeChallengeMethod != oauth2.PKCEMethodS256 && codeChallengeMethod != oauth2.PKCEMethodPlain {
132+
description := fmt.Sprintf("Unsupported PKCE challenge method (%q).", codeChallengeMethod)
133+
h.writeError(w, oauth2.InvalidRequest, description, http.StatusBadRequest)
134+
return
135+
}
136+
137+
if len(scopes) == 0 {
138+
// per RFC8628 section 3.1, https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
139+
// scope is optional but dex requires that it is always at least 'openid' so default it
140+
scopes = []string{"openid"}
141+
}
142+
143+
h.Logger.InfoContext(r.Context(), "received device request", "client_id", clientID, "scoped", scopes)
144+
145+
// Make device code
146+
deviceCode := storage.NewDeviceCode()
147+
148+
// make user code
149+
userCode := storage.NewUserCode()
150+
151+
// Generate the expire time
152+
expireTime := time.Now().Add(h.RequestsValidFor)
153+
154+
// Store the Device Request
155+
deviceReq := storage.DeviceRequest{
156+
UserCode: userCode,
157+
DeviceCode: deviceCode,
158+
ClientID: clientID,
159+
ClientSecret: clientSecret,
160+
Scopes: scopes,
161+
Expiry: expireTime,
162+
}
163+
164+
if err := h.Storage.CreateDeviceRequest(ctx, deviceReq); err != nil {
165+
h.Logger.ErrorContext(r.Context(), "failed to store device request", "err", err)
166+
h.writeError(w, oauth2.InvalidRequest, "", http.StatusInternalServerError)
167+
return
168+
}
169+
170+
// Store the device token
171+
deviceToken := storage.DeviceToken{
172+
DeviceCode: deviceCode,
173+
Status: oauth2.DeviceTokenPending,
174+
Expiry: expireTime,
175+
LastRequestTime: h.Now(),
176+
PollIntervalSeconds: 0,
177+
PKCE: storage.PKCE{
178+
CodeChallenge: codeChallenge,
179+
CodeChallengeMethod: codeChallengeMethod,
180+
},
181+
}
182+
183+
if err := h.Storage.CreateDeviceToken(ctx, deviceToken); err != nil {
184+
h.Logger.ErrorContext(r.Context(), "failed to store device token", "err", err)
185+
h.writeError(w, oauth2.InvalidRequest, "", http.StatusInternalServerError)
186+
return
187+
}
188+
189+
u, err := url.Parse(h.IssuerURL.String())
190+
if err != nil {
191+
h.Logger.ErrorContext(r.Context(), "could not parse issuer URL", "err", err)
192+
h.writeError(w, oauth2.InvalidRequest, "", http.StatusInternalServerError)
193+
return
194+
}
195+
u.Path = path.Join(u.Path, "device")
196+
vURI := u.String()
197+
198+
q := u.Query()
199+
q.Set("user_code", userCode)
200+
u.RawQuery = q.Encode()
201+
vURIComplete := u.String()
202+
203+
code := DeviceCodeResponse{
204+
DeviceCode: deviceCode,
205+
UserCode: userCode,
206+
VerificationURI: vURI,
207+
VerificationURIComplete: vURIComplete,
208+
ExpireTime: int(h.RequestsValidFor.Seconds()),
209+
PollInterval: pollIntervalSeconds,
210+
}
211+
212+
// Device Authorization Response can contain cache control header according to
213+
// https://tools.ietf.org/html/rfc8628#section-3.2
214+
w.Header().Set("Cache-Control", "no-store")
215+
216+
// Response type should be application/json according to
217+
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
218+
w.Header().Set("Content-Type", "application/json")
219+
220+
enc := json.NewEncoder(w)
221+
enc.SetEscapeHTML(false)
222+
enc.SetIndent("", " ")
223+
enc.Encode(code)
224+
225+
default:
226+
h.renderError(r, w, http.StatusBadRequest, "Invalid device code request type")
227+
h.writeError(w, oauth2.InvalidRequest, "", http.StatusBadRequest)
228+
}
229+
}
230+
231+
func (h *Handler) verifyUserCode(w http.ResponseWriter, r *http.Request) {
232+
ctx := r.Context()
233+
switch r.Method {
234+
case http.MethodPost:
235+
err := r.ParseForm()
236+
if err != nil {
237+
h.Logger.Warn("could not parse user code verification request body", "err", err)
238+
h.renderError(r, w, http.StatusBadRequest, "")
239+
return
240+
}
241+
242+
userCode := r.Form.Get("user_code")
243+
if userCode == "" {
244+
h.renderError(r, w, http.StatusBadRequest, "No user code received")
245+
return
246+
}
247+
248+
userCode = strings.ToUpper(userCode)
249+
250+
// Find the user code in the available requests
251+
deviceRequest, err := h.Storage.GetDeviceRequest(ctx, userCode)
252+
if err != nil || h.Now().After(deviceRequest.Expiry) {
253+
if err != nil && err != storage.ErrNotFound {
254+
h.Logger.ErrorContext(r.Context(), "failed to get device request", "err", err)
255+
}
256+
if err := h.Templates.Device(r, w, h.getDeviceVerificationURI(), userCode, true); err != nil {
257+
h.Logger.ErrorContext(r.Context(), "Server template error", "err", err)
258+
h.renderError(r, w, http.StatusNotFound, "Page not found")
259+
}
260+
return
261+
}
262+
263+
// Redirect to Dex Auth Endpoint
264+
u := h.IssuerURL
265+
u.Path = path.Join(u.Path, "/auth")
266+
q := u.Query()
267+
q.Set("client_id", deviceRequest.ClientID)
268+
q.Set("client_secret", deviceRequest.ClientSecret)
269+
q.Set("state", deviceRequest.UserCode)
270+
q.Set("response_type", "code")
271+
q.Set("redirect_uri", path.Join(h.IssuerURL.Path, oauth2.DeviceCallbackURI))
272+
q.Set("scope", strings.Join(deviceRequest.Scopes, " "))
273+
u.RawQuery = q.Encode()
274+
275+
http.Redirect(w, r, u.String(), http.StatusFound)
276+
277+
default:
278+
h.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
279+
}
280+
}
281+
282+
func (h *Handler) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
283+
ctx := r.Context()
284+
switch r.Method {
285+
case http.MethodGet:
286+
userCode := r.FormValue("state")
287+
code := r.FormValue("code")
288+
289+
if userCode == "" || code == "" {
290+
h.renderError(r, w, http.StatusBadRequest, "Request was missing parameters")
291+
return
292+
}
293+
294+
// Authorization redirect callback from OAuth2 auth flow.
295+
if errMsg := r.FormValue("error"); errMsg != "" {
296+
// Log the error details but don't expose them to the user
297+
h.Logger.ErrorContext(r.Context(), "OAuth2 authorization error",
298+
"error", errMsg,
299+
"error_description", r.FormValue("error_description"))
300+
h.renderError(r, w, http.StatusBadRequest, "Authorization failed. Please try again.")
301+
return
302+
}
303+
304+
authCode, err := h.Storage.GetAuthCode(ctx, code)
305+
if err != nil || h.Now().After(authCode.Expiry) {
306+
errCode := http.StatusBadRequest
307+
if err != nil && err != storage.ErrNotFound {
308+
h.Logger.ErrorContext(r.Context(), "failed to get auth code", "err", err)
309+
errCode = http.StatusInternalServerError
310+
}
311+
h.renderError(r, w, errCode, "Invalid or expired auth code.")
312+
return
313+
}
314+
315+
// Grab the device request from storage
316+
deviceReq, err := h.Storage.GetDeviceRequest(ctx, userCode)
317+
if err != nil || h.Now().After(deviceReq.Expiry) {
318+
errCode := http.StatusBadRequest
319+
if err != nil && err != storage.ErrNotFound {
320+
h.Logger.ErrorContext(r.Context(), "failed to get device code", "err", err)
321+
errCode = http.StatusInternalServerError
322+
}
323+
h.renderError(r, w, errCode, "Invalid or expired user code.")
324+
return
325+
}
326+
327+
client, err := h.Storage.GetClient(ctx, deviceReq.ClientID)
328+
if err != nil {
329+
if err != storage.ErrNotFound {
330+
h.Logger.ErrorContext(r.Context(), "failed to get client", "err", err)
331+
h.writeError(w, oauth2.ServerError, "", http.StatusInternalServerError)
332+
} else {
333+
h.writeError(w, oauth2.InvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
334+
}
335+
return
336+
}
337+
if client.Secret != deviceReq.ClientSecret {
338+
h.writeError(w, oauth2.InvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
339+
return
340+
}
341+
342+
resp, err := h.ExchangeAuthCode(ctx, w, authCode, client)
343+
if err != nil {
344+
h.Logger.ErrorContext(r.Context(), "could not exchange auth code for clien", "client_id", deviceReq.ClientID, "err", err)
345+
h.renderError(r, w, http.StatusInternalServerError, "Failed to exchange auth code.")
346+
return
347+
}
348+
349+
// Grab the device token from storage
350+
old, err := h.Storage.GetDeviceToken(ctx, deviceReq.DeviceCode)
351+
if err != nil || h.Now().After(old.Expiry) {
352+
errCode := http.StatusBadRequest
353+
if err != nil && err != storage.ErrNotFound {
354+
h.Logger.ErrorContext(r.Context(), "failed to get device token", "err", err)
355+
errCode = http.StatusInternalServerError
356+
}
357+
h.renderError(r, w, errCode, "Invalid or expired device code.")
358+
return
359+
}
360+
361+
updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
362+
if old.Status == oauth2.DeviceTokenComplete {
363+
return old, errors.New("device token already complete")
364+
}
365+
respStr, err := json.MarshalIndent(resp, "", " ")
366+
if err != nil {
367+
h.Logger.ErrorContext(r.Context(), "failed to marshal device token response", "err", err)
368+
h.renderError(r, w, http.StatusInternalServerError, "")
369+
return old, err
370+
}
371+
372+
old.Token = string(respStr)
373+
old.Status = oauth2.DeviceTokenComplete
374+
return old, nil
375+
}
376+
377+
// Update refresh token in the storage, store the token and mark as complete
378+
if err := h.Storage.UpdateDeviceToken(ctx, deviceReq.DeviceCode, updater); err != nil {
379+
h.Logger.ErrorContext(r.Context(), "failed to update device token", "err", err)
380+
h.renderError(r, w, http.StatusBadRequest, "")
381+
return
382+
}
383+
384+
if err := h.Templates.DeviceSuccess(r, w, client.Name); err != nil {
385+
h.Logger.ErrorContext(r.Context(), "Server template error", "err", err)
386+
h.renderError(r, w, http.StatusNotFound, "Page not found")
387+
}
388+
389+
default:
390+
h.Logger.ErrorContext(r.Context(), "unsupported method in device callback", "method", r.Method)
391+
h.renderError(r, w, http.StatusBadRequest, "Method not allowed.")
392+
return
393+
}
394+
}

0 commit comments

Comments
 (0)