|
| 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