Skip to content

Commit c424137

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 c424137

10 files changed

Lines changed: 436 additions & 407 deletions

File tree

server/device/device.go

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

0 commit comments

Comments
 (0)