internal/server/api/authreject.go
Ref: Size: 9.1 KiB History
package api
import (
"encoding/base64"
"log/slog"
"net/http"
"net/url"
"strings"
)
// A callback that fails the state check has exactly one thing to offer the
// person in front of it: which way it failed. "invalid oauth state" is true of
// every one of them and useful for none — the browser has just come back from a
// successful authentication, so the reading is that eitri lost the sign-in, and
// the operator's next move is a guess.
//
// The causes below are the ones the request can actually be read apart into.
// The first is the common one, and it is not a mistake anybody makes twice once
// it is named: the console is served at public_url, but it answers on every
// address the box has, so a browser at http://127.0.0.1:8080 gets the same page
// public_url serves and signs in from there. The cookie /auth/login sets is
// scoped to the host the browser used; the identity provider sends the callback
// to the redirect URL, which is built from public_url; the cookie is not sent to
// a different host, and the flow dies one hop from done.
//
// That is diagnosable, but not from the callback alone — the callback lands on
// public_url's host in both the working and the broken case, so its own Host
// header says nothing. The evidence is at /auth/login, one redirect earlier, and
// the state parameter is what carries it forward: state round-trips through the
// identity provider in the query string, which is the one channel that survives
// the origin switch that breaks the cookie.
const (
reasonNoStateParam = "no_state_param"
reasonWrongOrigin = "wrong_origin"
reasonNoStateCookie = "no_state_cookie"
reasonStateMismatch = "state_mismatch"
reasonNoVerifierCookie = "no_verifier_cookie"
)
// stateOriginSep separates the CSRF-random half of a state value from the
// base64url origin the sign-in started at: "<32 hex>.<origin>". The random half
// is unchanged and the cookie comparison stays whole-string, so the guard is
// exactly as strong as it was; the suffix is inert data the server wrote and
// only ever reads back to explain a failure.
const stateOriginSep = "."
func newState(origin string) (string, error) {
r, err := randomHex(16) // 32 hex chars
if err != nil {
return "", err
}
if origin == "" {
return r, nil
}
return r + stateOriginSep + base64.RawURLEncoding.EncodeToString([]byte(origin)), nil
}
// stateOrigin reads back what newState wrote, or "" for a state without an
// origin (an in-flight flow from an older server) or one that does not decode
// to a plausible origin. The value came from a request header, so it is treated
// as untrusted input on the way out as much as on the way in.
func stateOrigin(state string) string {
i := strings.Index(state, stateOriginSep)
if i < 0 {
return ""
}
raw, err := base64.RawURLEncoding.DecodeString(state[i+1:])
if err != nil {
return ""
}
return sanitizeOrigin(string(raw))
}
// requestOrigin is the scheme://host the browser used for this request. The
// server never trusts proxy headers to build an external URL (see API.URL,
// which reads public_url and nothing else), so this reads Host as sent and is
// used only to explain a failure, never to construct a redirect.
func requestOrigin(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return sanitizeOrigin(scheme + "://" + r.Host)
}
// sanitizeOrigin passes an http(s) origin whose host is plausible, and "" for
// anything else — the string is echoed into a log line and an error page, and
// its host half is caller-controlled.
func sanitizeOrigin(origin string) string {
if len(origin) > 200 {
return ""
}
u, err := url.Parse(origin)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
return ""
}
for _, c := range u.Host {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '.' || c == '-' || c == ':' || c == '[' || c == ']':
default:
return ""
}
}
return u.Scheme + "://" + u.Host
}
// originHost is an origin's host[:port], lowercased for comparison. Ports are
// part of it deliberately: 127.0.0.1:8080 and 127.0.0.1:9090 are different
// cookie origins, and a mismatched port breaks a sign-in exactly like a
// mismatched name does.
func originHost(origin string) string {
u, err := url.Parse(origin)
if err != nil {
return ""
}
return strings.ToLower(u.Host)
}
// stateRejection is one diagnosed sign-in failure: a stable reason word for the
// log and the facts needed to tell the person what to do about it.
type stateRejection struct {
reason string
browsing string // origin the sign-in started at; set for wrong_origin
console string // public_url, trailing slash trimmed
}
// The order is the order of the evidence, not of the checks it replaces. A
// callback with no state parameter at all was never sent by an identity
// provider. Then the cookie: when it did not arrive, an origin that is not
// public_url's is the whole explanation and wins the diagnosis. When it DID
// arrive, the browser is demonstrably on an origin this server's cookies live
// on, so any difference between Host and public_url is a reverse proxy
// rewriting Host rather than evidence about the browser — the mismatch is
// diagnosed on what it really is, a state that belongs to another sign-in.
func (af *authFlow) diagnoseCallback(r *http.Request) (string, *stateRejection) {
rej := &stateRejection{console: strings.TrimRight(af.cfg.PublicURL, "/")}
state := r.URL.Query().Get("state")
if state == "" {
rej.reason = reasonNoStateParam
return "", rej
}
stateCk, err := r.Cookie(stateCookie)
if err != nil || stateCk.Value == "" {
// Prefer the origin the flow started at over this request's own Host:
// it is the host the missing cookie was scoped to, which is the fact
// that explains the absence.
browsing := stateOrigin(state)
if browsing == "" {
browsing = requestOrigin(r)
}
if want := originHost(rej.console); want != "" && browsing != "" && originHost(browsing) != want {
rej.reason, rej.browsing = reasonWrongOrigin, browsing
return "", rej
}
rej.reason = reasonNoStateCookie
return "", rej
}
if state != stateCk.Value {
rej.reason = reasonStateMismatch
return "", rej
}
verifierCk, err := r.Cookie(verifierCookie)
if err != nil || verifierCk.Value == "" {
rej.reason = reasonNoVerifierCookie
return "", rej
}
return verifierCk.Value, nil
}
// log records the rejection with its reason and the hostnames that explain it.
// Never the state value, the cookie, or anything from the token exchange: a
// rejected sign-in is worth a log line, not a copy of the credentials it
// carried.
func (rej *stateRejection) log() {
if rej.reason == reasonWrongOrigin {
slog.Warn("oauth callback rejected", "reason", rej.reason,
"got_host", originHost(rej.browsing), "want_host", originHost(rej.console))
return
}
slog.Warn("oauth callback rejected", "reason", rej.reason)
}
// message is what the browser is shown. Refusal-family voice, one per cause:
// the request is fine and the person did nothing wrong — this origin, or this
// tab, cannot finish the sign-in — so each one ends at the single next move
// that works.
func (rej *stateRejection) message() string {
console := rej.console
if console == "" {
console = "the console's configured public_url"
}
switch rej.reason {
case reasonWrongOrigin:
return "this sign-in cannot be completed from here: it started at " + rej.browsing +
", but the console is served at " + console + ", so the cookie holding the sign-in was set for " +
"the address you were browsing and was never sent to the one the identity provider returned you " +
"to. Open " + console + " and sign in there."
case reasonNoStateParam:
return "/auth/callback is where an identity provider returns a browser at the end of a sign-in, not a " +
"page to open: this request carried no sign-in to finish. Open " + console + " and sign in from there."
case reasonNoStateCookie:
return "this sign-in cannot be completed: the cookie set when it started did not come back, so nothing " +
"here can match this callback to the flow it belongs to. A sign-in left unfinished for more than " +
"ten minutes expires, and a browser that blocks cookies for this site cannot complete one at all. " +
"Open the console at exactly " + console + " and sign in again from a fresh tab."
case reasonStateMismatch:
return "this callback belongs to a sign-in that is no longer the one in progress: the state it carries " +
"is not the state this browser last started with, which is what a stale tab, a reloaded callback, " +
"or a second sign-in started meanwhile looks like. Sign in again from a fresh tab at " + console + "."
case reasonNoVerifierCookie:
return "this sign-in cannot be completed: the browser returned the sign-in but not the PKCE verifier " +
"cookie that goes with it, so the authorization code cannot be exchanged. Sign in again from a " +
"fresh tab at " + console + "."
}
return "this sign-in cannot be completed. Open " + console + " and sign in again from a fresh tab."
}