a00d87a3
fix(auth): a rejected sign-in names the origin that broke it
a73x 2026-08-11 18:21
Commit message
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 | } | ||