a73x

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."
}