a73x

a00d87a3

fix(auth): a rejected sign-in names the origin that broke it

a73x   2026-08-11 18:21

Commit message
fix(auth): a rejected sign-in names the origin that broke it

The console's OAuth callback answered every failed state check with
"invalid oauth state" — true of all of them, useful for none. The browser
has just come back from a successful authentication, so the only reading
left is that eitri lost the sign-in, and the next move is a guess.

A rejection now says which way it failed. The reason is a stable word in
the log (wrong_origin, no_state_param, no_state_cookie, state_mismatch,
no_verifier_cookie) and a sentence in the browser that ends at the one
next move that works. Reasons and hostnames only: nothing logs a state
value, a cookie, or anything from the token exchange.

The cause worth naming is the first sign-in on a fresh box. The console
is served at public_url but answers on every address the host has, so a
browser at http://127.0.0.1:8080 gets the same page and signs in there.
The cookie is scoped to the address the browser used, the identity
provider returns the browser to the redirect URL built from public_url,
and the cookie is never sent to a different host: the flow dies one hop
from done, with both halves configured correctly.

That is not visible at the callback, which lands on public_url's host in
the working and the broken case alike. The evidence is at /auth/login one
redirect earlier, so state carries it forward — "<32 hex>.<base64url
origin>" — because state round-trips through the identity provider in the
query string, the one channel that survives the origin switch that breaks
the cookie. The random half and the whole-string cookie comparison are
unchanged, so the CSRF guard is exactly as strong as it was. The refusal
names both addresses outright and says which one to sign in at.

The host comparison is confined to the case where no cookie arrived,
which is the only case where it is evidence. When the cookie did come
back the browser is demonstrably on an origin this server's cookies live
on, so a Host that differs from public_url is a reverse proxy rewriting
it — diagnosed as the stale tab it really is, not as a wrong origin.

internal/server/api/auth.go
Old New
@@ -119,7 +119,7 @@ func (af *authFlow) handleLogin(w http.ResponseWriter, r *http.Request) {
119 http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable) 119 http.Error(w, "sign-in temporarily unavailable", http.StatusServiceUnavailable)
120 return 120 return
121 } 121 }
122 state, err := randomHex(16) // 32 hex chars 122 state, err := newState(requestOrigin(r))
123 if err != nil { 123 if err != nil {
124 http.Error(w, "internal error", http.StatusInternalServerError) 124 http.Error(w, "internal error", http.StatusInternalServerError)
125 return 125 return
@@ -146,19 +146,16 @@ func (af *authFlow) handleCallback(w http.ResponseWriter, r *http.Request) {
146 } 146 }
147 147
148 // State must round-trip through the cookie set at /auth/login (CSRF guard): 148 // State must round-trip through the cookie set at /auth/login (CSRF guard):
149 // a missing or mismatched value is rejected before any token exchange. 149 // a missing or mismatched value is rejected before any token exchange. The
150 stateCk, err := r.Cookie(stateCookie) 150 // guard is unchanged; what it rejects now says which way it failed.
151 if err != nil || stateCk.Value == "" || r.URL.Query().Get("state") != stateCk.Value { 151 pkceVerifier, rej := af.diagnoseCallback(r)
152 http.Error(w, "invalid oauth state", http.StatusBadRequest) 152 if rej != nil {
153 return 153 rej.log()
154 } 154 http.Error(w, rej.message(), http.StatusBadRequest)
155 verifierCk, err := r.Cookie(verifierCookie)
156 if err != nil || verifierCk.Value == "" {
157 http.Error(w, "invalid oauth state", http.StatusBadRequest)
158 return 155 return
159 } 156 }
160 157
161 tok, err := af.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifierCk.Value)) 158 tok, err := af.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(pkceVerifier))
162 if err != nil { 159 if err != nil {
163 slog.Warn("oauth code exchange failed", "err", err) 160 slog.Warn("oauth code exchange failed", "err", err)
164 http.Error(w, "sign-in failed", http.StatusBadGateway) 161 http.Error(w, "sign-in failed", http.StatusBadGateway)
internal/server/api/auth_test.go
Old New
@@ -1,17 +1,21 @@
1 package api 1 package api
2 2
3 import ( 3 import (
4 "bytes"
4 "crypto" 5 "crypto"
5 "crypto/rand" 6 "crypto/rand"
6 "crypto/rsa" 7 "crypto/rsa"
7 "crypto/sha256" 8 "crypto/sha256"
8 "encoding/base64" 9 "encoding/base64"
9 "encoding/json" 10 "encoding/json"
11 "io"
12 "log/slog"
10 "net/http" 13 "net/http"
11 "net/http/cookiejar" 14 "net/http/cookiejar"
12 "net/http/httptest" 15 "net/http/httptest"
13 "net/url" 16 "net/url"
14 "path/filepath" 17 "path/filepath"
18 "strings"
15 "sync" 19 "sync"
16 "testing" 20 "testing"
17 "time" 21 "time"
@@ -326,28 +330,185 @@ func TestAuthLogoutRevokesSession(t *testing.T) {
326 assert.False(t, ok, "session must no longer resolve after logout") 330 assert.False(t, ok, "session must no longer resolve after logout")
327 } 331 }
328 332
329 func TestAuthCallbackStateMismatch(t *testing.T) { 333 // captureLogs redirects the default logger into a buffer: a rejected sign-in
330 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"}) 334 // reports its reason the only way a browser-facing refusal can, by logging it.
335 func captureLogs(t *testing.T) *bytes.Buffer {
336 t.Helper()
337 var logs bytes.Buffer
338 prev := slog.Default()
339 slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
340 t.Cleanup(func() { slog.SetDefault(prev) })
341 return &logs
342 }
331 343
332 // Prime the state cookie via /auth/login, then hit the callback with a 344 // login drives /auth/login and returns the cookies it set. host overrides the
333 // mismatched state query value. 345 // Host header the browser appears to have used ("" leaves it at public_url's),
334 resp := env.get(t, env.apiURL+"/auth/login") 346 // which is how a sign-in started at the wrong origin is reproduced.
335 resp.Body.Close() 347 func (e authEnv) login(t *testing.T, host string) []*http.Cookie {
348 t.Helper()
349 req, err := http.NewRequest(http.MethodGet, e.apiURL+"/auth/login", nil)
350 require.NoError(t, err)
351 if host != "" {
352 req.Host = host
353 }
354 resp, err := e.bare().Do(req)
355 require.NoError(t, err)
356 defer resp.Body.Close()
336 require.Equal(t, http.StatusFound, resp.StatusCode) 357 require.Equal(t, http.StatusFound, resp.StatusCode)
358 return resp.Cookies()
359 }
360
361 // callback hits /auth/callback with exactly the query and cookies given —
362 // no jar, so each case controls precisely what comes back from the browser.
363 func (e authEnv) callback(t *testing.T, q url.Values, cookies []*http.Cookie) (int, string) {
364 t.Helper()
365 req, err := http.NewRequest(http.MethodGet, e.apiURL+"/auth/callback?"+q.Encode(), nil)
366 require.NoError(t, err)
367 for _, c := range cookies {
368 req.AddCookie(c)
369 }
370 resp, err := e.bare().Do(req)
371 require.NoError(t, err)
372 defer resp.Body.Close()
373 body, err := io.ReadAll(resp.Body)
374 require.NoError(t, err)
375 return resp.StatusCode, string(body)
376 }
377
378 // bare is a client with no cookie jar that surfaces redirects.
379 func (e authEnv) bare() *http.Client {
380 return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
381 }
382
383 // TestAuthCallbackRejectionsSayWhy pins the diagnosis: every way the state
384 // check fails is a distinct reason word in the log and a distinct next move in
385 // the browser. The wrong-origin case is the one that bites a first sign-in —
386 // the console answers on every address the box has, so signing in from one that
387 // is not public_url sets the cookie somewhere the callback never reaches — and
388 // it is the one that must name both URLs outright.
389 func TestAuthCallbackRejectionsSayWhy(t *testing.T) {
390 for _, tc := range []struct {
391 name string
392 // setup returns the callback's query and the cookies the browser sends.
393 setup func(t *testing.T, e authEnv) (url.Values, []*http.Cookie)
394 wantReason string
395 wantLogged []string // extra key=value pairs the log line must carry
396 wantText []string
397 }{
398 {
399 name: "wrong origin: signed in at an address public_url does not name",
400 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
401 // The browser was at console.example.test:8080, so /auth/login
402 // set its cookies there; the identity provider returns it to
403 // public_url's host, which those cookies never reach.
404 set := e.login(t, "console.example.test:8080")
405 return url.Values{"state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"}}, nil
406 },
407 wantReason: "wrong_origin",
408 wantLogged: []string{"got_host=console.example.test:8080"},
409 wantText: []string{"http://console.example.test:8080", "sign in there"},
410 },
411 {
412 name: "no state cookie: right origin, nothing came back",
413 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
414 set := e.login(t, "")
415 return url.Values{"state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"}}, nil
416 },
417 wantReason: "no_state_cookie",
418 wantText: []string{"did not come back", "ten minutes", "blocks cookies", "fresh tab"},
419 },
420 {
421 name: "state mismatch: a stale tab or a replayed callback",
422 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
423 set := e.login(t, "")
424 return url.Values{"state": {"another-sign-in"}, "code": {"c"}}, set
425 },
426 wantReason: "state_mismatch",
427 wantText: []string{"no longer the one in progress", "fresh tab"},
428 },
429 {
430 name: "no verifier cookie: the state came back without its PKCE half",
431 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
432 set := e.login(t, "")
433 state := cookieByName(set, "eitri_oauth_state")
434 return url.Values{"state": {state.Value}, "code": {"c"}}, []*http.Cookie{state}
435 },
436 wantReason: "no_verifier_cookie",
437 wantText: []string{"PKCE verifier", "fresh tab"},
438 },
439 {
440 name: "no state at all: /auth/callback opened directly",
441 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
442 return url.Values{}, nil
443 },
444 wantReason: "no_state_param",
445 wantText: []string{"not a", "page to open"},
446 },
447 } {
448 t.Run(tc.name, func(t *testing.T) {
449 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
450 q, cookies := tc.setup(t, env)
451 logs := captureLogs(t) // after setup: only the rejection is captured
452
453 code, body := env.callback(t, q, cookies)
454
455 assert.Equal(t, http.StatusBadRequest, code)
456 assert.Contains(t, logs.String(), "reason="+tc.wantReason)
457 for _, want := range tc.wantLogged {
458 assert.Contains(t, logs.String(), want)
459 }
460 for _, want := range tc.wantText {
461 assert.Contains(t, body, want, "the refusal must say what to do about %s", tc.wantReason)
462 }
463 // Every refusal points at the console's real address, and none of
464 // them leaks the state value it was given.
465 assert.Contains(t, body, env.apiURL)
466 if s := q.Get("state"); s != "" {
467 assert.NotContains(t, logs.String(), s, "a rejection logs reasons, never the state")
468 assert.NotContains(t, body, s, "a rejection shows reasons, never the state")
469 }
470 })
471 }
472 }
337 473
338 bad := env.get(t, env.apiURL+"/auth/callback?state=wrong&code=whatever") 474 // TestAuthCallbackWrongOriginNamesBothURLs is the sentence a first-time
339 defer bad.Body.Close() 475 // self-hoster reads: both addresses, in full, and which one to use.
340 assert.Equal(t, http.StatusBadRequest, bad.StatusCode) 476 func TestAuthCallbackWrongOriginNamesBothURLs(t *testing.T) {
477 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
478 logs := captureLogs(t)
479
480 set := env.login(t, "127.0.0.1:9999")
481 _, body := env.callback(t, url.Values{
482 "state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"},
483 }, nil)
484
485 assert.Contains(t, body, "it started at http://127.0.0.1:9999")
486 assert.Contains(t, body, "the console is served at "+env.apiURL)
487 assert.Contains(t, body, "Open "+env.apiURL+" and sign in there.")
488 assert.Contains(t, logs.String(), "got_host=127.0.0.1:9999")
489 assert.Contains(t, logs.String(), "want_host="+strings.TrimPrefix(env.apiURL, "http://"))
341 } 490 }
342 491
343 func TestAuthCallbackMissingStateCookie(t *testing.T) { 492 // TestAuthCallbackStateCarriesOriginWithoutWeakeningTheGuard: the origin rides
493 // along in the state parameter, and the CSRF half in front of it is still the
494 // 32 random hex chars the cookie must match whole.
495 func TestAuthCallbackStateCarriesOrigin(t *testing.T) {
344 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"}) 496 env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
345 // Fresh client, no prior /auth/login: there is no state cookie to match. 497 set := env.login(t, "")
346 jar, _ := cookiejar.New(nil) 498 state := cookieByName(set, "eitri_oauth_state").Value
347 env.client.Jar = jar 499
348 bad := env.get(t, env.apiURL+"/auth/callback?state=x&code=y") 500 random, encoded, found := strings.Cut(state, ".")
349 defer bad.Body.Close() 501 require.True(t, found, "state must carry the origin after the random half")
350 assert.Equal(t, http.StatusBadRequest, bad.StatusCode) 502 assert.Len(t, random, 32, "the CSRF value is unchanged")
503 assert.Regexp(t, "^[0-9a-f]+$", random)
504 got, err := base64.RawURLEncoding.DecodeString(encoded)
505 require.NoError(t, err)
506 assert.Equal(t, env.apiURL, string(got), "the origin the sign-in started at")
507
508 // A callback carrying only the random half is still rejected: the cookie
509 // comparison is whole-string.
510 code, _ := env.callback(t, url.Values{"state": {random}, "code": {"c"}}, set)
511 assert.Equal(t, http.StatusBadRequest, code)
351 } 512 }
352 513
353 // --- hand-rolled stub issuer: cases internal/oidcprovider can't produce --- 514 // --- hand-rolled stub issuer: cases internal/oidcprovider can't produce ---
internal/server/api/authreject.go
Old New
@@ -0,0 +1,226 @@
1 package api
2
3 import (
4 "encoding/base64"
5 "log/slog"
6 "net/http"
7 "net/url"
8 "strings"
9 )
10
11 // A callback that fails the state check has exactly one thing to offer the
12 // person in front of it: which way it failed. "invalid oauth state" is true of
13 // every one of them and useful for none — the browser has just come back from a
14 // successful authentication, so the reading is that eitri lost the sign-in, and
15 // the operator's next move is a guess.
16 //
17 // The causes below are the ones the request can actually be read apart into.
18 // The first is the common one, and it is not a mistake anybody makes twice once
19 // it is named: the console is served at public_url, but it answers on every
20 // address the box has, so a browser at http://127.0.0.1:8080 gets the same page
21 // public_url serves and signs in from there. The cookie /auth/login sets is
22 // scoped to the host the browser used; the identity provider sends the callback
23 // to the redirect URL, which is built from public_url; the cookie is not sent to
24 // a different host, and the flow dies one hop from done.
25 //
26 // That is diagnosable, but not from the callback alone — the callback lands on
27 // public_url's host in both the working and the broken case, so its own Host
28 // header says nothing. The evidence is at /auth/login, one redirect earlier, and
29 // the state parameter is what carries it forward: state round-trips through the
30 // identity provider in the query string, which is the one channel that survives
31 // the origin switch that breaks the cookie.
32 const (
33 reasonNoStateParam = "no_state_param"
34 reasonWrongOrigin = "wrong_origin"
35 reasonNoStateCookie = "no_state_cookie"
36 reasonStateMismatch = "state_mismatch"
37 reasonNoVerifierCookie = "no_verifier_cookie"
38 )
39
40 // stateOriginSep separates the CSRF-random half of a state value from the
41 // base64url origin the sign-in started at: "<32 hex>.<origin>". The random half
42 // is unchanged and the cookie comparison stays whole-string, so the guard is
43 // exactly as strong as it was; the suffix is inert data the server wrote and
44 // only ever reads back to explain a failure.
45 const stateOriginSep = "."
46
47 // newState mints a state value carrying the origin the browser used to reach
48 // /auth/login.
49 func newState(origin string) (string, error) {
50 r, err := randomHex(16) // 32 hex chars
51 if err != nil {
52 return "", err
53 }
54 if origin == "" {
55 return r, nil
56 }
57 return r + stateOriginSep + base64.RawURLEncoding.EncodeToString([]byte(origin)), nil
58 }
59
60 // stateOrigin reads back what newState wrote, or "" for a state without an
61 // origin (an in-flight flow from an older server) or one that does not decode
62 // to a plausible origin. The value came from a request header, so it is treated
63 // as untrusted input on the way out as much as on the way in.
64 func stateOrigin(state string) string {
65 i := strings.Index(state, stateOriginSep)
66 if i < 0 {
67 return ""
68 }
69 raw, err := base64.RawURLEncoding.DecodeString(state[i+1:])
70 if err != nil {
71 return ""
72 }
73 return sanitizeOrigin(string(raw))
74 }
75
76 // requestOrigin is the scheme://host the browser used for this request. The
77 // server never trusts proxy headers to build an external URL (see API.URL,
78 // which reads public_url and nothing else), so this reads Host as sent and is
79 // used only to explain a failure, never to construct a redirect.
80 func requestOrigin(r *http.Request) string {
81 scheme := "http"
82 if r.TLS != nil {
83 scheme = "https"
84 }
85 return sanitizeOrigin(scheme + "://" + r.Host)
86 }
87
88 // sanitizeOrigin passes an http(s) origin whose host is plausible, and "" for
89 // anything else — the string is echoed into a log line and an error page, and
90 // its host half is caller-controlled.
91 func sanitizeOrigin(origin string) string {
92 if len(origin) > 200 {
93 return ""
94 }
95 u, err := url.Parse(origin)
96 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
97 return ""
98 }
99 if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
100 return ""
101 }
102 for _, c := range u.Host {
103 switch {
104 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
105 case c == '.' || c == '-' || c == ':' || c == '[' || c == ']':
106 default:
107 return ""
108 }
109 }
110 return u.Scheme + "://" + u.Host
111 }
112
113 // originHost is an origin's host[:port], lowercased for comparison. Ports are
114 // part of it deliberately: 127.0.0.1:8080 and 127.0.0.1:9090 are different
115 // cookie origins, and a mismatched port breaks a sign-in exactly like a
116 // mismatched name does.
117 func originHost(origin string) string {
118 u, err := url.Parse(origin)
119 if err != nil {
120 return ""
121 }
122 return strings.ToLower(u.Host)
123 }
124
125 // stateRejection is one diagnosed sign-in failure: a stable reason word for the
126 // log and the facts needed to tell the person what to do about it.
127 type stateRejection struct {
128 reason string
129 browsing string // origin the sign-in started at; set for wrong_origin
130 console string // public_url, trailing slash trimmed
131 }
132
133 // diagnoseCallback runs the state check and, on failure, says which way it
134 // failed. It returns the PKCE verifier when every check passes.
135 //
136 // The order is the order of the evidence, not of the checks it replaces. A
137 // callback with no state parameter at all was never sent by an identity
138 // provider. Then the cookie: when it did not arrive, an origin that is not
139 // public_url's is the whole explanation and wins the diagnosis. When it DID
140 // arrive, the browser is demonstrably on an origin this server's cookies live
141 // on, so any difference between Host and public_url is a reverse proxy
142 // rewriting Host rather than evidence about the browser — the mismatch is
143 // diagnosed on what it really is, a state that belongs to another sign-in.
144 func (af *authFlow) diagnoseCallback(r *http.Request) (string, *stateRejection) {
145 rej := &stateRejection{console: strings.TrimRight(af.cfg.PublicURL, "/")}
146
147 state := r.URL.Query().Get("state")
148 if state == "" {
149 rej.reason = reasonNoStateParam
150 return "", rej
151 }
152 stateCk, err := r.Cookie(stateCookie)
153 if err != nil || stateCk.Value == "" {
154 // Prefer the origin the flow started at over this request's own Host:
155 // it is the host the missing cookie was scoped to, which is the fact
156 // that explains the absence.
157 browsing := stateOrigin(state)
158 if browsing == "" {
159 browsing = requestOrigin(r)
160 }
161 if want := originHost(rej.console); want != "" && browsing != "" && originHost(browsing) != want {
162 rej.reason, rej.browsing = reasonWrongOrigin, browsing
163 return "", rej
164 }
165 rej.reason = reasonNoStateCookie
166 return "", rej
167 }
168 if state != stateCk.Value {
169 rej.reason = reasonStateMismatch
170 return "", rej
171 }
172 verifierCk, err := r.Cookie(verifierCookie)
173 if err != nil || verifierCk.Value == "" {
174 rej.reason = reasonNoVerifierCookie
175 return "", rej
176 }
177 return verifierCk.Value, nil
178 }
179
180 // log records the rejection with its reason and the hostnames that explain it.
181 // Never the state value, the cookie, or anything from the token exchange: a
182 // rejected sign-in is worth a log line, not a copy of the credentials it
183 // carried.
184 func (rej *stateRejection) log() {
185 if rej.reason == reasonWrongOrigin {
186 slog.Warn("oauth callback rejected", "reason", rej.reason,
187 "got_host", originHost(rej.browsing), "want_host", originHost(rej.console))
188 return
189 }
190 slog.Warn("oauth callback rejected", "reason", rej.reason)
191 }
192
193 // message is what the browser is shown. Refusal-family voice, one per cause:
194 // the request is fine and the person did nothing wrong — this origin, or this
195 // tab, cannot finish the sign-in — so each one ends at the single next move
196 // that works.
197 func (rej *stateRejection) message() string {
198 console := rej.console
199 if console == "" {
200 console = "the console's configured public_url"
201 }
202 switch rej.reason {
203 case reasonWrongOrigin:
204 return "this sign-in cannot be completed from here: it started at " + rej.browsing +
205 ", but the console is served at " + console + ", so the cookie holding the sign-in was set for " +
206 "the address you were browsing and was never sent to the one the identity provider returned you " +
207 "to. Open " + console + " and sign in there."
208 case reasonNoStateParam:
209 return "/auth/callback is where an identity provider returns a browser at the end of a sign-in, not a " +
210 "page to open: this request carried no sign-in to finish. Open " + console + " and sign in from there."
211 case reasonNoStateCookie:
212 return "this sign-in cannot be completed: the cookie set when it started did not come back, so nothing " +
213 "here can match this callback to the flow it belongs to. A sign-in left unfinished for more than " +
214 "ten minutes expires, and a browser that blocks cookies for this site cannot complete one at all. " +
215 "Open the console at exactly " + console + " and sign in again from a fresh tab."
216 case reasonStateMismatch:
217 return "this callback belongs to a sign-in that is no longer the one in progress: the state it carries " +
218 "is not the state this browser last started with, which is what a stale tab, a reloaded callback, " +
219 "or a second sign-in started meanwhile looks like. Sign in again from a fresh tab at " + console + "."
220 case reasonNoVerifierCookie:
221 return "this sign-in cannot be completed: the browser returned the sign-in but not the PKCE verifier " +
222 "cookie that goes with it, so the authorization code cannot be exchanged. Sign in again from a " +
223 "fresh tab at " + console + "."
224 }
225 return "this sign-in cannot be completed. Open " + console + " and sign in again from a fresh tab."
226 }