@@ -0,0 +1,157 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package auth |
| 4 | + |
| 5 | +import ( |
| 6 | + "encoding/json" |
| 7 | + "errors" |
| 8 | + "net/http" |
| 9 | + "net/url" |
| 10 | + "strings" |
| 11 | + |
| 12 | + "github.com/go-chi/chi/v5" |
| 13 | + |
| 14 | + "github.com/tenseleyFlow/shithub/internal/auth/devicecode" |
| 15 | +) |
| 16 | + |
| 17 | +// MountDeviceCodeAPI registers the RFC 8628 JSON endpoints. Caller is |
| 18 | +// responsible for placing r inside a CSRF-exempt group — these are |
| 19 | +// non-browser endpoints invoked by CLI / native clients. |
| 20 | +func (h *Handlers) MountDeviceCodeAPI(r chi.Router) { |
| 21 | + r.Post("/login/device/code", h.deviceCodeIssue) |
| 22 | + r.Post("/login/oauth/access_token", h.deviceCodeExchange) |
| 23 | +} |
| 24 | + |
| 25 | +type deviceCodeIssueResponse struct { |
| 26 | + DeviceCode string `json:"device_code"` |
| 27 | + UserCode string `json:"user_code"` |
| 28 | + VerificationURI string `json:"verification_uri"` |
| 29 | + VerificationURIComplete string `json:"verification_uri_complete,omitempty"` |
| 30 | + ExpiresIn int `json:"expires_in"` |
| 31 | + Interval int `json:"interval"` |
| 32 | +} |
| 33 | + |
| 34 | +func (h *Handlers) deviceCodeIssue(w http.ResponseWriter, r *http.Request) { |
| 35 | + if err := r.ParseForm(); err != nil { |
| 36 | + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "malformed form body") |
| 37 | + return |
| 38 | + } |
| 39 | + clientID := strings.TrimSpace(r.PostForm.Get("client_id")) |
| 40 | + if clientID == "" { |
| 41 | + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id is required") |
| 42 | + return |
| 43 | + } |
| 44 | + scope := r.PostForm.Get("scope") |
| 45 | + |
| 46 | + auth, err := devicecode.Create(r.Context(), devicecode.Deps{Pool: h.d.Pool}, h.d.DeviceCode, clientID, scope) |
| 47 | + if err != nil { |
| 48 | + writeDeviceCodeError(w, err) |
| 49 | + return |
| 50 | + } |
| 51 | + |
| 52 | + verifyBase := strings.TrimRight(h.d.Branding.BaseURL, "/") + "/login/device" |
| 53 | + verifyComplete := verifyBase + "?user_code=" + url.QueryEscape(auth.UserCode) |
| 54 | + writeJSON(w, http.StatusOK, deviceCodeIssueResponse{ |
| 55 | + DeviceCode: auth.DeviceCode, |
| 56 | + UserCode: auth.UserCode, |
| 57 | + VerificationURI: verifyBase, |
| 58 | + VerificationURIComplete: verifyComplete, |
| 59 | + ExpiresIn: int(auth.ExpiresIn.Seconds()), |
| 60 | + Interval: int(auth.PollInterval.Seconds()), |
| 61 | + }) |
| 62 | +} |
| 63 | + |
| 64 | +type deviceCodeExchangeResponse struct { |
| 65 | + AccessToken string `json:"access_token"` |
| 66 | + TokenType string `json:"token_type"` |
| 67 | + Scope string `json:"scope"` |
| 68 | +} |
| 69 | + |
| 70 | +func (h *Handlers) deviceCodeExchange(w http.ResponseWriter, r *http.Request) { |
| 71 | + if err := r.ParseForm(); err != nil { |
| 72 | + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "malformed form body") |
| 73 | + return |
| 74 | + } |
| 75 | + clientID := strings.TrimSpace(r.PostForm.Get("client_id")) |
| 76 | + deviceCode := strings.TrimSpace(r.PostForm.Get("device_code")) |
| 77 | + grantType := r.PostForm.Get("grant_type") |
| 78 | + const wantGrant = "urn:ietf:params:oauth:grant-type:device_code" |
| 79 | + if grantType != wantGrant { |
| 80 | + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "expected "+wantGrant) |
| 81 | + return |
| 82 | + } |
| 83 | + if clientID == "" || deviceCode == "" { |
| 84 | + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_id and device_code are required") |
| 85 | + return |
| 86 | + } |
| 87 | + |
| 88 | + res, err := devicecode.Exchange(r.Context(), devicecode.Deps{Pool: h.d.Pool}, clientID, deviceCode, deviceCodeTokenName(r)) |
| 89 | + if err != nil { |
| 90 | + writeDeviceCodeError(w, err) |
| 91 | + return |
| 92 | + } |
| 93 | + writeJSON(w, http.StatusOK, deviceCodeExchangeResponse{ |
| 94 | + AccessToken: res.AccessToken, |
| 95 | + TokenType: res.TokenType, |
| 96 | + Scope: strings.Join(res.Scopes, ","), |
| 97 | + }) |
| 98 | +} |
| 99 | + |
| 100 | +// deviceCodeTokenName builds a recognisable name for the PAT minted on |
| 101 | +// behalf of the CLI so the user sees it on their /settings/tokens page. |
| 102 | +// We don't expose the device's own name (it's not in the protocol), so |
| 103 | +// the User-Agent is the only viable hint. |
| 104 | +func deviceCodeTokenName(r *http.Request) string { |
| 105 | + ua := strings.TrimSpace(r.Header.Get("User-Agent")) |
| 106 | + if ua == "" { |
| 107 | + return "device-code" |
| 108 | + } |
| 109 | + if len(ua) > 64 { |
| 110 | + ua = ua[:64] |
| 111 | + } |
| 112 | + return "device-code: " + ua |
| 113 | +} |
| 114 | + |
| 115 | +// writeDeviceCodeError maps a devicecode package error to the RFC 8628 |
| 116 | +// JSON shape and HTTP status. Unknown errors are surfaced as 500 |
| 117 | +// `server_error`; the package-level sentinels cover every well-formed |
| 118 | +// caller path. |
| 119 | +func writeDeviceCodeError(w http.ResponseWriter, err error) { |
| 120 | + switch { |
| 121 | + case errors.Is(err, devicecode.ErrUnauthorizedClient): |
| 122 | + writeOAuthError(w, http.StatusBadRequest, "unauthorized_client", "client_id not allowed") |
| 123 | + case errors.Is(err, devicecode.ErrInvalidScope): |
| 124 | + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", "unknown scope") |
| 125 | + case errors.Is(err, devicecode.ErrInvalidGrant): |
| 126 | + writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "device_code unknown or already exchanged") |
| 127 | + case errors.Is(err, devicecode.ErrAuthorizationPending): |
| 128 | + writeOAuthError(w, http.StatusBadRequest, "authorization_pending", "user has not approved yet") |
| 129 | + case errors.Is(err, devicecode.ErrSlowDown): |
| 130 | + writeOAuthError(w, http.StatusBadRequest, "slow_down", "increase polling interval") |
| 131 | + case errors.Is(err, devicecode.ErrAccessDenied): |
| 132 | + writeOAuthError(w, http.StatusBadRequest, "access_denied", "user denied the request") |
| 133 | + case errors.Is(err, devicecode.ErrExpiredToken): |
| 134 | + writeOAuthError(w, http.StatusBadRequest, "expired_token", "device_code expired") |
| 135 | + default: |
| 136 | + writeOAuthError(w, http.StatusInternalServerError, "server_error", "internal error") |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +type oauthError struct { |
| 141 | + Error string `json:"error"` |
| 142 | + ErrorDescription string `json:"error_description,omitempty"` |
| 143 | +} |
| 144 | + |
| 145 | +func writeOAuthError(w http.ResponseWriter, status int, code, desc string) { |
| 146 | + w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 147 | + w.Header().Set("Cache-Control", "no-store") |
| 148 | + w.WriteHeader(status) |
| 149 | + _ = json.NewEncoder(w).Encode(oauthError{Error: code, ErrorDescription: desc}) |
| 150 | +} |
| 151 | + |
| 152 | +func writeJSON(w http.ResponseWriter, status int, body any) { |
| 153 | + w.Header().Set("Content-Type", "application/json; charset=utf-8") |
| 154 | + w.Header().Set("Cache-Control", "no-store") |
| 155 | + w.WriteHeader(status) |
| 156 | + _ = json.NewEncoder(w).Encode(body) |
| 157 | +} |