tenseleyflow/shithub / f372f5b

Browse files

auth/device: HTML approval page + RFC 8628 JSON endpoints

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f372f5b4eb603bf97f1172a660912949e1394ab9
Parents
323f392
Tree
6bfb21c

4 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 16 0
A internal/web/handlers/auth/device_api.go 157 0
A internal/web/handlers/auth/device_html.go 128 0
A internal/web/templates/auth/device_code.html 47 0
internal/web/handlers/auth/auth.gomodified
@@ -39,6 +39,7 @@ import (
3939
 
4040
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
4141
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
42
+	"github.com/tenseleyFlow/shithub/internal/auth/devicecode"
4243
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
4344
 	"github.com/tenseleyFlow/shithub/internal/auth/password"
4445
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
@@ -86,6 +87,12 @@ type Deps struct {
8687
 	// owner-only billing/settings routes. The auth settings page uses it
8788
 	// only to decide whether existing orgs can link to plan comparison.
8889
 	OrgBillingEnabled bool
90
+	// DeviceCode configures the RFC 8628 device-code grant exposed at
91
+	// /login/device, /login/device/code, and /login/oauth/access_token.
92
+	// Zero value uses devicecode.Defaults() so a deployment that never
93
+	// wires this still gets a working grant for the canonical
94
+	// shithub-cli client_id.
95
+	DeviceCode devicecode.Config
8996
 }
9097
 
9198
 // Handlers is the registered handler set. Construct with New.
@@ -111,6 +118,9 @@ func New(d Deps) (*Handlers, error) {
111118
 	if d.Audit == nil {
112119
 		d.Audit = audit.NewRecorder()
113120
 	}
121
+	if len(d.DeviceCode.ClientIDs) == 0 {
122
+		d.DeviceCode = devicecode.Defaults()
123
+	}
114124
 	password.MustGenerateDummy(d.Argon2)
115125
 	return &Handlers{d: d, q: usersdb.New()}, nil
116126
 }
@@ -126,6 +136,12 @@ func (h *Handlers) Mount(r chi.Router) {
126136
 		r.Post("/login", h.loginSubmit)
127137
 		r.Get("/login/2fa", h.twoFactorChallengeForm)
128138
 		r.Post("/login/2fa", h.twoFactorChallengeSubmit)
139
+		// S50 §1 — device-code (RFC 8628) user verification page.
140
+		// The matching CSRF-exempt JSON endpoints land under
141
+		// /login/device/code and /login/oauth/access_token via
142
+		// MountDeviceCodeAPI, wired separately by handlers.go.
143
+		r.Get("/login/device", h.deviceCodeForm)
144
+		r.Post("/login/device", h.deviceCodeSubmit)
129145
 		r.Post("/logout", h.logoutSubmit)
130146
 		r.Get("/password/reset", h.resetRequestForm)
131147
 		r.Post("/password/reset", h.resetRequestSubmit)
internal/web/handlers/auth/device_api.goadded
@@ -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
+}
internal/web/handlers/auth/device_html.goadded
@@ -0,0 +1,128 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"net/url"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/devicecode"
13
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
14
+)
15
+
16
+// deviceCodeForm renders the user-facing verification page. Three
17
+// shapes:
18
+//   - no user_code → show the entry form.
19
+//   - user_code present + recognised + pending → show the approve/deny form.
20
+//   - user_code present but already terminal (approved/denied/expired)
21
+//     → render the "you can close this window" terminal page so the
22
+//     polling device sees the result on its next exchange.
23
+//
24
+// Unauthenticated callers are redirected to /login with a `next=` that
25
+// preserves the user_code so the flow resumes after sign-in.
26
+func (h *Handlers) deviceCodeForm(w http.ResponseWriter, r *http.Request) {
27
+	user := middleware.CurrentUserFromContext(r.Context())
28
+	if user.IsAnonymous() {
29
+		next := "/login/device"
30
+		if uc := strings.TrimSpace(r.URL.Query().Get("user_code")); uc != "" {
31
+			next += "?user_code=" + url.QueryEscape(uc)
32
+		}
33
+		http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther)
34
+		return
35
+	}
36
+	userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
37
+	data := map[string]any{
38
+		"Title":     "Authorize device",
39
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
40
+		"UserCode":  userCode,
41
+	}
42
+	if userCode == "" {
43
+		h.renderPage(w, r, "auth/device_code", data)
44
+		return
45
+	}
46
+	row, err := devicecode.LookupByUserCode(r.Context(), devicecode.Deps{Pool: h.d.Pool}, userCode)
47
+	if err != nil {
48
+		data["Error"] = "We don't recognise that code. Check the value and try again."
49
+		h.renderPage(w, r, "auth/device_code", data)
50
+		return
51
+	}
52
+	switch {
53
+	case row.ApprovedAt.Valid:
54
+		data["Terminal"] = true
55
+		data["Notice"] = "Already authorized."
56
+	case row.DeniedAt.Valid:
57
+		data["Terminal"] = true
58
+		data["Notice"] = "Already denied."
59
+	case time.Now().After(row.ExpiresAt.Time):
60
+		data["Terminal"] = true
61
+		data["Notice"] = "This code expired. Ask your device for a new one."
62
+	default:
63
+		data["Approval"] = true
64
+		data["ClientID"] = row.ClientID
65
+		data["Scopes"] = row.Scopes
66
+		data["UserCode"] = row.UserCode
67
+	}
68
+	h.renderPage(w, r, "auth/device_code", data)
69
+}
70
+
71
+// deviceCodeSubmit handles the approve / deny click. We re-resolve the
72
+// row by user_code rather than trust the form id so a malicious page
73
+// can't trick the user into approving a different grant.
74
+func (h *Handlers) deviceCodeSubmit(w http.ResponseWriter, r *http.Request) {
75
+	user := middleware.CurrentUserFromContext(r.Context())
76
+	if user.IsAnonymous() {
77
+		http.Redirect(w, r, "/login?next="+url.QueryEscape("/login/device"), http.StatusSeeOther)
78
+		return
79
+	}
80
+	if err := r.ParseForm(); err != nil {
81
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
82
+		return
83
+	}
84
+	userCode := strings.TrimSpace(r.PostForm.Get("user_code"))
85
+	action := r.PostForm.Get("action")
86
+	if userCode == "" {
87
+		http.Redirect(w, r, "/login/device", http.StatusSeeOther)
88
+		return
89
+	}
90
+	row, err := devicecode.LookupByUserCode(r.Context(), devicecode.Deps{Pool: h.d.Pool}, userCode)
91
+	if err != nil {
92
+		h.renderPage(w, r, "auth/device_code", map[string]any{
93
+			"Title":     "Authorize device",
94
+			"CSRFToken": middleware.CSRFTokenForRequest(r),
95
+			"Error":     "We don't recognise that code.",
96
+		})
97
+		return
98
+	}
99
+	deps := devicecode.Deps{Pool: h.d.Pool}
100
+	switch action {
101
+	case "approve":
102
+		err = devicecode.Approve(r.Context(), deps, row.ID, user.ID)
103
+	case "deny":
104
+		err = devicecode.Deny(r.Context(), deps, row.ID)
105
+	default:
106
+		http.Redirect(w, r, "/login/device?user_code="+url.QueryEscape(userCode), http.StatusSeeOther)
107
+		return
108
+	}
109
+	data := map[string]any{
110
+		"Title":     "Authorize device",
111
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
112
+		"Terminal":  true,
113
+	}
114
+	switch {
115
+	case err == nil && action == "approve":
116
+		data["Notice"] = "Authorized. Return to your device."
117
+	case err == nil && action == "deny":
118
+		data["Notice"] = "Denied. Your device will see the result on its next poll."
119
+	case errors.Is(err, devicecode.ErrExpiredToken):
120
+		data["Error"] = "This code expired before you could authorize it."
121
+	case errors.Is(err, devicecode.ErrAlreadyTerminal):
122
+		data["Notice"] = "Already finalized."
123
+	default:
124
+		h.d.Logger.ErrorContext(r.Context(), "device-code submit", "error", err)
125
+		data["Error"] = "Could not complete authorization. Please retry."
126
+	}
127
+	h.renderPage(w, r, "auth/device_code", data)
128
+}
internal/web/templates/auth/device_code.htmladded
@@ -0,0 +1,47 @@
1
+{{ define "page" -}}
2
+<section class="shithub-auth">
3
+  <h1>Authorize device</h1>
4
+  {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
5
+  {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
6
+  {{ if .Terminal }}
7
+    <p>You can close this window and return to your device.</p>
8
+    <p class="shithub-auth-aside"><a href="/">Back to shithub</a></p>
9
+  {{ else if .Approval }}
10
+    <p>
11
+      A device using <code>{{ .ClientID }}</code> is asking for access to your shithub
12
+      account.
13
+    </p>
14
+    {{ if .Scopes }}
15
+    <p>Requested scopes:</p>
16
+    <ul>
17
+      {{ range .Scopes }}<li><code>{{ . }}</code></li>{{ end }}
18
+    </ul>
19
+    {{ else }}
20
+    <p>This device is asking for read access only.</p>
21
+    {{ end }}
22
+    <p>
23
+      User code: <code>{{ .UserCode }}</code>
24
+    </p>
25
+    <form method="POST" action="/login/device" novalidate>
26
+      <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
27
+      <input type="hidden" name="user_code" value="{{ .UserCode }}">
28
+      <button type="submit" name="action" value="approve"
29
+              class="shithub-button shithub-button-primary">Authorize</button>
30
+      <button type="submit" name="action" value="deny"
31
+              class="shithub-button">Deny</button>
32
+    </form>
33
+  {{ else }}
34
+    <p>Enter the code shown on your device.</p>
35
+    <form method="GET" action="/login/device" novalidate>
36
+      <label>
37
+        <span>User code</span>
38
+        <input type="text" name="user_code" required autofocus
39
+               autocomplete="off" inputmode="text"
40
+               pattern="[A-Za-z0-9-]+"
41
+               value="{{ .UserCode }}">
42
+      </label>
43
+      <button type="submit" class="shithub-button shithub-button-primary">Continue</button>
44
+    </form>
45
+  {{ end }}
46
+</section>
47
+{{- end }}