a73x

internal/server/api/auth_test.go

Ref:   Size: 27.9 KiB   History

package api

import (
	"bytes"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/oidcprovider"
	"github.com/a73x/eitri/internal/server/hub"
	"github.com/a73x/eitri/internal/server/registry"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const testPassword = "hunter2hunter2"

type testUser struct{ email, password string }

// authEnv is a live eitri-server /auth stack wired to a real internal/oidcprovider
// issuer — the exact IdP the server discovers through go-oidc in production.
type authEnv struct {
	apiURL    string
	oidcURL   string
	client    *http.Client // cookie jar; does NOT auto-follow redirects (we step)
	st        *store.Store
	usersPath string
}

// sub returns the oidcprovider-assigned subject for email (read from the flat
// users file), so a test can look a tenant up by its durable identity key.
func (e authEnv) sub(t *testing.T, email string) string {
	t.Helper()
	us, err := oidcprovider.LoadUsers(e.usersPath)
	require.NoError(t, err)
	for _, u := range us.Users {
		if u.Email == email {
			return u.Sub
		}
	}
	t.Fatalf("no such user %q", email)
	return ""
}

// newAuthEnv stands up the store, an internal/oidcprovider issuer seeded with
// users, and the server's /auth handler behind an httptest server (TLS when
// tls). mutate tweaks the OIDC config (allowlists).
func newAuthEnv(t *testing.T, tls bool, mutate func(*OIDCConfig), users ...testUser) authEnv {
	t.Helper()

	// The httptest listener binds on construction, so we learn the server's
	// address (hence PublicURL and the OIDC redirect URL) before the two ends
	// are wired — breaking the issuer↔RP circular dependency.
	apiSrv := httptest.NewUnstartedServer(nil)
	addr := apiSrv.Listener.Addr().String()
	scheme := "http"
	if tls {
		scheme = "https"
	}
	publicURL := scheme + "://" + addr

	st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { st.Close() })

	usersPath := filepath.Join(t.TempDir(), "users.json")
	for _, u := range users {
		pw := u.password
		if pw == "" {
			pw = testPassword
		}
		require.NoError(t, oidcprovider.AddUser(usersPath, u.email, pw))
	}
	prov, err := oidcprovider.New(oidcprovider.Config{
		UsersFile:  usersPath,
		SigningKey: filepath.Join(t.TempDir(), "signing.key"),
		Clients:    []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
	})
	require.NoError(t, err)
	oidcSrv := httptest.NewServer(prov.Handler())
	t.Cleanup(oidcSrv.Close)
	prov.SetIssuer(oidcSrv.URL)

	cfg := OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL}
	if mutate != nil {
		mutate(&cfg)
	}
	client := finishAuthServer(t, apiSrv, st, cfg, tls)
	return authEnv{apiURL: publicURL, oidcURL: oidcSrv.URL, client: client, st: st, usersPath: usersPath}
}

// finishAuthServer builds the API, mounts /auth on the root mux exactly as
// main.go does (outside /api/ and its auth middleware), starts the server, and
// returns a stepping client (cookie jar, redirects surfaced not followed).
func finishAuthServer(t *testing.T, apiSrv *httptest.Server, st *store.Store, cfg OIDCConfig, tls bool) *http.Client {
	t.Helper()
	a := New(Config{HostSecret: []byte("hostsecret"), OIDC: cfg}, st, registry.New(time.Now), hub.New())
	t.Cleanup(a.Close)
	root := http.NewServeMux()
	root.Handle("/auth/", a.AuthHandler())
	apiSrv.Config.Handler = root
	if tls {
		apiSrv.StartTLS()
	} else {
		apiSrv.Start()
	}
	t.Cleanup(apiSrv.Close)

	jar, err := cookiejar.New(nil)
	require.NoError(t, err)
	client := &http.Client{}
	if tls {
		client = apiSrv.Client()
	}
	client.Jar = jar
	client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
	return client
}

// signIn drives the whole interactive code+PKCE flow against an
// internal/oidcprovider issuer and returns the final /auth/callback response
// (302 → "/" on success). Each hop is stepped so callers can read Set-Cookie.
func (e authEnv) signIn(t *testing.T, email, password string) *http.Response {
	t.Helper()
	if password == "" {
		password = testPassword
	}
	// 1. /auth/login → 302 to the issuer authorize endpoint (state+verifier set).
	resp := e.get(t, e.apiURL+"/auth/login")
	require.Equal(t, http.StatusFound, resp.StatusCode, "login should redirect to issuer")
	authorizeURL := resp.Header.Get("Location")
	resp.Body.Close()

	// 2. authorize GET → 200 login form.
	resp = e.get(t, authorizeURL)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	resp.Body.Close()

	// 3. authorize POST credentials → 302 back to /auth/callback with code+state.
	resp = e.postForm(t, authorizeURL, url.Values{"email": {email}, "password": {password}})
	require.Equal(t, http.StatusFound, resp.StatusCode)
	callbackURL := resp.Header.Get("Location")
	resp.Body.Close()

	// 4. /auth/callback → final auth response.
	return e.get(t, callbackURL)
}

func (e authEnv) get(t *testing.T, u string) *http.Response {
	t.Helper()
	resp, err := e.client.Get(u)
	require.NoError(t, err)
	return resp
}

func (e authEnv) postForm(t *testing.T, u string, v url.Values) *http.Response {
	t.Helper()
	resp, err := e.client.PostForm(u, v)
	require.NoError(t, err)
	return resp
}

func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
	for _, c := range cookies {
		if c.Name == name {
			return c
		}
	}
	return nil
}

func TestAuthHappyPathSetsSessionCookie(t *testing.T) {
	for _, tc := range []struct {
		name string
		tls  bool
	}{{"http", false}, {"https", true}} {
		t.Run(tc.name, func(t *testing.T) {
			env := newAuthEnv(t, tc.tls, nil, testUser{email: "alex@example.com"})
			resp := env.signIn(t, "alex@example.com", "")
			defer resp.Body.Close()

			require.Equal(t, http.StatusFound, resp.StatusCode)
			assert.Equal(t, "/", resp.Header.Get("Location"))

			sess := cookieByName(resp.Cookies(), "eitri_session")
			require.NotNil(t, sess, "callback must set eitri_session")
			assert.True(t, sess.HttpOnly, "session cookie must be HttpOnly")
			assert.Equal(t, http.SameSiteLaxMode, sess.SameSite)
			assert.Equal(t, tc.tls, sess.Secure, "Secure must follow the public_url scheme")

			// The temp oauth cookies must be cleared on success.
			cleared := cookieByName(resp.Cookies(), "eitri_oauth_state")
			require.NotNil(t, cleared)
			assert.True(t, cleared.MaxAge < 0, "oauth state cookie must be cleared")

			tenant, ok, err := env.st.SessionTenant(sess.Value)
			require.NoError(t, err)
			require.True(t, ok, "session row must resolve to a tenant")
			assert.Equal(t, "alex", tenant, "JIT handle derives from the email local part")
		})
	}
}

func TestAuthJITReuseNoDuplicate(t *testing.T) {
	env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})

	first := env.signIn(t, "alex@example.com", "")
	first.Body.Close()
	firstTenant, ok, err := env.st.SessionTenant(cookieByName(first.Cookies(), "eitri_session").Value)
	require.NoError(t, err)
	require.True(t, ok)

	second := env.signIn(t, "alex@example.com", "")
	second.Body.Close()
	secondTenant, ok, err := env.st.SessionTenant(cookieByName(second.Cookies(), "eitri_session").Value)
	require.NoError(t, err)
	require.True(t, ok)

	assert.Equal(t, "alex", firstTenant)
	assert.Equal(t, firstTenant, secondTenant, "second sign-in must reuse the tenant, not create a new one")

	// The identity binding still points at 'alex' — no 'alex-2' was minted.
	tn, ok, err := env.st.TenantByIdentity(env.oidcURL, env.sub(t, "alex@example.com"))
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, "alex", tn.ID)
}

func TestAuthHandleCollision(t *testing.T) {
	env := newAuthEnv(t, false, nil,
		testUser{email: "alex@example.com"},
		testUser{email: "alex@other.com"})

	first := env.signIn(t, "alex@example.com", "")
	first.Body.Close()
	firstTenant, _, err := env.st.SessionTenant(cookieByName(first.Cookies(), "eitri_session").Value)
	require.NoError(t, err)

	second := env.signIn(t, "alex@other.com", "")
	second.Body.Close()
	secondTenant, _, err := env.st.SessionTenant(cookieByName(second.Cookies(), "eitri_session").Value)
	require.NoError(t, err)

	assert.Equal(t, "alex", firstTenant)
	assert.Equal(t, "alex-2", secondTenant, "colliding local part gets a numeric suffix")
}

func TestAuthSignupGateRejectsUnlistedDomain(t *testing.T) {
	env := newAuthEnv(t, false, func(c *OIDCConfig) {
		c.AllowedDomains = []string{"example.com"}
	},
		testUser{email: "carol@evil.org"},
		testUser{email: "bob@example.com"})

	// Rejected: 403, no session, and NO tenant row created.
	rejected := env.signIn(t, "carol@evil.org", "")
	defer rejected.Body.Close()
	assert.Equal(t, http.StatusForbidden, rejected.StatusCode)
	assert.Nil(t, cookieByName(rejected.Cookies(), "eitri_session"))
	_, ok, err := env.st.TenantByIdentity(env.oidcURL, env.sub(t, "carol@evil.org"))
	require.NoError(t, err)
	assert.False(t, ok, "a gated-out identity must not create a tenant")

	// Allowed by domain: succeeds.
	ok2 := env.signIn(t, "bob@example.com", "")
	defer ok2.Body.Close()
	assert.Equal(t, http.StatusFound, ok2.StatusCode)
	require.NotNil(t, cookieByName(ok2.Cookies(), "eitri_session"))
}

func TestAuthSignupGateAllowsExactIdentity(t *testing.T) {
	env := newAuthEnv(t, false, func(c *OIDCConfig) {
		c.AllowedIdentities = []string{"carol@evil.org"}
	},
		testUser{email: "carol@evil.org"},
		testUser{email: "dave@evil.org"})

	// The listed identity is admitted even though its domain is not listed.
	ok := env.signIn(t, "carol@evil.org", "")
	defer ok.Body.Close()
	assert.Equal(t, http.StatusFound, ok.StatusCode)
	require.NotNil(t, cookieByName(ok.Cookies(), "eitri_session"))

	// A different identity on the same domain is still rejected.
	rejected := env.signIn(t, "dave@evil.org", "")
	defer rejected.Body.Close()
	assert.Equal(t, http.StatusForbidden, rejected.StatusCode)
}

func TestAuthSignupGateOpenWhenUnset(t *testing.T) {
	env := newAuthEnv(t, false, nil, testUser{email: "anyone@wherever.net"})
	resp := env.signIn(t, "anyone@wherever.net", "")
	defer resp.Body.Close()
	assert.Equal(t, http.StatusFound, resp.StatusCode)
	require.NotNil(t, cookieByName(resp.Cookies(), "eitri_session"))
}

func TestAuthLogoutRevokesSession(t *testing.T) {
	env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
	resp := env.signIn(t, "alex@example.com", "")
	resp.Body.Close()
	sid := cookieByName(resp.Cookies(), "eitri_session").Value

	// The jar carries the session cookie; logout deletes the row and clears it.
	out, err := env.client.Post(env.apiURL+"/auth/logout", "", nil)
	require.NoError(t, err)
	defer out.Body.Close()
	assert.Equal(t, http.StatusNoContent, out.StatusCode)
	cleared := cookieByName(out.Cookies(), "eitri_session")
	require.NotNil(t, cleared)
	assert.True(t, cleared.MaxAge < 0)

	_, ok, err := env.st.SessionTenant(sid)
	require.NoError(t, err)
	assert.False(t, ok, "session must no longer resolve after logout")
}

// captureLogs redirects the default logger into a buffer: a rejected sign-in
// reports its reason the only way a browser-facing refusal can, by logging it.
func captureLogs(t *testing.T) *bytes.Buffer {
	t.Helper()
	var logs bytes.Buffer
	prev := slog.Default()
	slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
	t.Cleanup(func() { slog.SetDefault(prev) })
	return &logs
}

// login drives /auth/login and returns the cookies it set. host overrides the
// Host header the browser appears to have used ("" leaves it at public_url's),
// which is how a sign-in started at the wrong origin is reproduced.
func (e authEnv) login(t *testing.T, host string) []*http.Cookie {
	t.Helper()
	req, err := http.NewRequest(http.MethodGet, e.apiURL+"/auth/login", nil)
	require.NoError(t, err)
	if host != "" {
		req.Host = host
	}
	resp, err := e.bare().Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, http.StatusFound, resp.StatusCode)
	return resp.Cookies()
}

// callback hits /auth/callback with exactly the query and cookies given —
// no jar, so each case controls precisely what comes back from the browser.
func (e authEnv) callback(t *testing.T, q url.Values, cookies []*http.Cookie) (int, string) {
	t.Helper()
	req, err := http.NewRequest(http.MethodGet, e.apiURL+"/auth/callback?"+q.Encode(), nil)
	require.NoError(t, err)
	for _, c := range cookies {
		req.AddCookie(c)
	}
	resp, err := e.bare().Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	return resp.StatusCode, string(body)
}

// bare is a client with no cookie jar that surfaces redirects.
func (e authEnv) bare() *http.Client {
	return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}

// TestAuthCallbackRejectionsSayWhy pins the diagnosis: every way the state
// check fails is a distinct reason word in the log and a distinct next move in
// the browser. The wrong-origin case is the one that bites a first sign-in —
// the console answers on every address the box has, so signing in from one that
// is not public_url sets the cookie somewhere the callback never reaches — and
// it is the one that must name both URLs outright.
func TestAuthCallbackRejectionsSayWhy(t *testing.T) {
	for _, tc := range []struct {
		name string
		// setup returns the callback's query and the cookies the browser sends.
		setup      func(t *testing.T, e authEnv) (url.Values, []*http.Cookie)
		wantReason string
		wantLogged []string // extra key=value pairs the log line must carry
		wantText   []string
		wantAbsent []string // substrings that must appear in neither body nor log
	}{
		{
			name: "wrong origin: signed in at an address public_url does not name",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				// The browser was at console.example.test:8080, so /auth/login
				// set its cookies there; the identity provider returns it to
				// public_url's host, which those cookies never reach.
				set := e.login(t, "console.example.test:8080")
				return url.Values{"state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"}}, nil
			},
			wantReason: "wrong_origin",
			wantLogged: []string{"got_host=console.example.test:8080"},
			wantText:   []string{"http://console.example.test:8080", "sign in there"},
		},
		{
			name: "no state cookie: right origin, nothing came back",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				set := e.login(t, "")
				return url.Values{"state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"}}, nil
			},
			wantReason: "no_state_cookie",
			wantText:   []string{"did not come back", "ten minutes", "blocks cookies", "fresh tab"},
		},
		{
			name: "state mismatch: a stale tab or a replayed callback",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				set := e.login(t, "")
				return url.Values{"state": {"another-sign-in"}, "code": {"c"}}, set
			},
			wantReason: "state_mismatch",
			wantText:   []string{"no longer the one in progress", "fresh tab"},
		},
		{
			name: "no verifier cookie: the state came back without its PKCE half",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				set := e.login(t, "")
				state := cookieByName(set, "eitri_oauth_state")
				return url.Values{"state": {state.Value}, "code": {"c"}}, []*http.Cookie{state}
			},
			wantReason: "no_verifier_cookie",
			wantText:   []string{"PKCE verifier", "fresh tab"},
		},
		{
			// A crafted callback link can put anything in the state's origin
			// half. Sanitizing it to "" makes the diagnosis fall back to the
			// request's own origin, which matches public_url — so the hostile
			// string neither reaches the victim's eyes nor the log, and the
			// wrong-origin page never quotes an attacker's address back.
			name: "hostile origin in the state: sanitized away, not echoed",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				hostile := base64.RawURLEncoding.EncodeToString(
					[]byte("https://evil.example/lure?x=<script>alert(1)</script>"))
				return url.Values{"state": {strings.Repeat("a", 32) + "." + hostile}, "code": {"c"}}, nil
			},
			wantReason: "no_state_cookie",
			wantAbsent: []string{"evil.example", "<script>", "lure"},
		},
		{
			name: "no state at all: /auth/callback opened directly",
			setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
				return url.Values{}, nil
			},
			wantReason: "no_state_param",
			wantText:   []string{"not a", "page to open"},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
			q, cookies := tc.setup(t, env)
			logs := captureLogs(t) // after setup: only the rejection is captured

			code, body := env.callback(t, q, cookies)

			assert.Equal(t, http.StatusBadRequest, code)
			assert.Contains(t, logs.String(), "reason="+tc.wantReason)
			for _, want := range tc.wantLogged {
				assert.Contains(t, logs.String(), want)
			}
			for _, want := range tc.wantText {
				assert.Contains(t, body, want, "the refusal must say what to do about %s", tc.wantReason)
			}
			for _, absent := range tc.wantAbsent {
				assert.NotContains(t, body, absent,
					"a caller-controlled string reached the error page: %q is quoted back to whoever followed the link", absent)
				assert.NotContains(t, logs.String(), absent,
					"a caller-controlled string reached the log line: %q is written unbounded into the operator's journal", absent)
			}
			// Every refusal points at the console's real address, and none of
			// them leaks the state value it was given.
			assert.Contains(t, body, env.apiURL)
			if s := q.Get("state"); s != "" {
				assert.NotContains(t, logs.String(), s, "a rejection logs reasons, never the state")
				assert.NotContains(t, body, s, "a rejection shows reasons, never the state")
			}
		})
	}
}

// TestAuthCallbackWrongOriginNamesBothURLs is the sentence a first-time
// self-hoster reads: both addresses, in full, and which one to use.
func TestAuthCallbackWrongOriginNamesBothURLs(t *testing.T) {
	env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
	logs := captureLogs(t)

	set := env.login(t, "127.0.0.1:9999")
	_, body := env.callback(t, url.Values{
		"state": {cookieByName(set, "eitri_oauth_state").Value}, "code": {"c"},
	}, nil)

	assert.Contains(t, body, "it started at http://127.0.0.1:9999")
	assert.Contains(t, body, "the console is served at "+env.apiURL)
	assert.Contains(t, body, "Open "+env.apiURL+" and sign in there.")
	assert.Contains(t, logs.String(), "got_host=127.0.0.1:9999")
	assert.Contains(t, logs.String(), "want_host="+strings.TrimPrefix(env.apiURL, "http://"))
}

// TestAuthCallbackStateCarriesOriginWithoutWeakeningTheGuard: the origin rides
// along in the state parameter, and the CSRF half in front of it is still the
// 32 random hex chars the cookie must match whole.
func TestAuthCallbackStateCarriesOrigin(t *testing.T) {
	env := newAuthEnv(t, false, nil, testUser{email: "alex@example.com"})
	set := env.login(t, "")
	state := cookieByName(set, "eitri_oauth_state").Value

	random, encoded, found := strings.Cut(state, ".")
	require.True(t, found, "state must carry the origin after the random half")
	assert.Len(t, random, 32, "the CSRF value is unchanged")
	assert.Regexp(t, "^[0-9a-f]+$", random)
	got, err := base64.RawURLEncoding.DecodeString(encoded)
	require.NoError(t, err)
	assert.Equal(t, env.apiURL, string(got), "the origin the sign-in started at")

	// A callback carrying only the random half is still rejected: the cookie
	// comparison is whole-string.
	code, _ := env.callback(t, url.Values{"state": {random}, "code": {"c"}}, set)
	assert.Equal(t, http.StatusBadRequest, code)
}

// --- hand-rolled stub issuer: cases internal/oidcprovider can't produce ---

// stubIssuer is a hand-rolled OIDC issuer that auto-approves authorize (no login
// form) and mints a validly-signed id_token. It is parameterized for the two
// external-IdP cases oidcprovider cannot cover:
//   - email == "" mints an id_token WITHOUT an email claim (oidcprovider always
//     sets one), proving the server rejects an emailless identity.
//   - requireSecret != "" makes /token demand that client_secret (a confidential
//     client), proving the secret actually flows on the external-IdP path.
type stubIssuer struct {
	url             string
	key             *rsa.PrivateKey
	kid             string
	email           string // included as the email claim when non-empty
	requireSecret   string // when non-empty, /token demands this client_secret
	unverifiedEmail bool   // emit email_verified:false instead of true

	mu             sync.Mutex
	observedSecret string // client_secret the token endpoint actually received
}

// newStubIssuer starts the stub. email is the email claim to mint ("" omits it);
// requireSecret makes the token endpoint a confidential client demanding that
// secret ("" leaves it a public client).
func newStubIssuer(t *testing.T, email, requireSecret string) *stubIssuer {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	require.NoError(t, err)
	s := &stubIssuer{key: key, kid: "stub", email: email, requireSecret: requireSecret}

	mux := http.NewServeMux()
	mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
		writeStubJSON(w, map[string]any{
			"issuer":                                s.url,
			"authorization_endpoint":                s.url + "/authorize",
			"token_endpoint":                        s.url + "/token",
			"jwks_uri":                              s.url + "/jwks.json",
			"response_types_supported":              []string{"code"},
			"subject_types_supported":               []string{"public"},
			"id_token_signing_alg_values_supported": []string{"RS256"},
		})
	})
	mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		redirect, _ := url.Parse(q.Get("redirect_uri"))
		rq := redirect.Query()
		rq.Set("code", "stubcode")
		rq.Set("state", q.Get("state"))
		redirect.RawQuery = rq.Encode()
		http.Redirect(w, r, redirect.String(), http.StatusFound)
	})
	mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
		if s.requireSecret != "" {
			// x/oauth2's autodetect probes HTTP Basic first (RFC 6749 §2.3.1
			// client_secret_basic); accept that, and fall back to the form-post
			// style. A missing/wrong secret is a 401 so the test proves the secret
			// actually reached the issuer.
			_, secret, ok := r.BasicAuth()
			if !ok {
				secret = r.FormValue("client_secret")
			}
			s.mu.Lock()
			s.observedSecret = secret
			s.mu.Unlock()
			if secret != s.requireSecret {
				http.Error(w, "invalid client", http.StatusUnauthorized)
				return
			}
		}
		writeStubJSON(w, map[string]any{
			"access_token": "stub-access",
			"token_type":   "Bearer",
			"id_token":     s.idToken(),
			"expires_in":   300,
		})
	})
	mux.HandleFunc("/jwks.json", func(w http.ResponseWriter, r *http.Request) {
		pub := s.key.PublicKey
		writeStubJSON(w, map[string]any{"keys": []map[string]any{{
			"kty": "RSA", "alg": "RS256", "use": "sig", "kid": s.kid,
			"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
			"e": base64.RawURLEncoding.EncodeToString([]byte{0x01, 0x00, 0x01}), // 65537
		}}})
	})

	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	s.url = srv.URL
	return s
}

// secretSeen returns the client_secret the token endpoint last received.
func (s *stubIssuer) secretSeen() string {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.observedSecret
}

// idToken builds a compact RS256 JWS with iss/sub/aud/iat/exp, adding the email
// claim only when the stub was configured with one.
func (s *stubIssuer) idToken() string {
	seg := func(v any) string {
		b, _ := json.Marshal(v)
		return base64.RawURLEncoding.EncodeToString(b)
	}
	now := time.Now()
	claims := map[string]any{
		"iss": s.url, "sub": "stubuser", "aud": "eitri-console",
		"iat": now.Unix(), "exp": now.Add(5 * time.Minute).Unix(),
	}
	claims["email_verified"] = !s.unverifiedEmail
	if s.email != "" {
		claims["email"] = s.email
	}
	signing := seg(map[string]any{"alg": "RS256", "typ": "JWT", "kid": s.kid}) + "." + seg(claims)
	h := sha256.Sum256([]byte(signing))
	sig, _ := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:])
	return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}

func writeStubJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}

// newStubEnv wires the server's /auth handler to a stub issuer and returns the
// public URL, a stepping client, and the store. clientSecret configures the RP
// side (non-empty ⇒ confidential client, so auth.go leaves oauth2 autodetect on).
func newStubEnv(t *testing.T, stub *stubIssuer, clientSecret string) (string, *http.Client, *store.Store) {
	t.Helper()
	apiSrv := httptest.NewUnstartedServer(nil)
	publicURL := "http://" + apiSrv.Listener.Addr().String()
	st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { st.Close() })
	client := finishAuthServer(t, apiSrv, st,
		OIDCConfig{Issuer: stub.url, ClientID: "eitri-console", ClientSecret: clientSecret, PublicURL: publicURL}, false)
	return publicURL, client, st
}

// driveStub steps the auto-approving stub flow (login → authorize → callback)
// and returns the final /auth/callback response.
func driveStub(t *testing.T, client *http.Client, publicURL string) *http.Response {
	t.Helper()
	resp, err := client.Get(publicURL + "/auth/login")
	require.NoError(t, err)
	authorizeURL := resp.Header.Get("Location")
	resp.Body.Close()
	require.Equal(t, http.StatusFound, resp.StatusCode)

	resp, err = client.Get(authorizeURL)
	require.NoError(t, err)
	callbackURL := resp.Header.Get("Location")
	resp.Body.Close()
	require.Equal(t, http.StatusFound, resp.StatusCode)

	resp, err = client.Get(callbackURL)
	require.NoError(t, err)
	return resp
}

func TestAuthCallbackNoEmailClaim(t *testing.T) {
	stub := newStubIssuer(t, "", "") // no email claim, public client
	publicURL, client, _ := newStubEnv(t, stub, "")

	resp := driveStub(t, client, publicURL)
	defer resp.Body.Close()
	assert.Equal(t, http.StatusForbidden, resp.StatusCode, "an id_token with no email must be rejected")
}

// TestAuthCallbackUnverifiedEmailRejected pins the claims contract: an issuer
// that has not verified the address (email_verified false or absent) must not
// mint an identity — an unverified Google address could otherwise pass a
// domain allowlist it doesn't own.
func TestAuthCallbackUnverifiedEmailRejected(t *testing.T) {
	stub := newStubIssuer(t, "sneak@corp.example", "")
	stub.unverifiedEmail = true
	publicURL, client, st := newStubEnv(t, stub, "")

	resp := driveStub(t, client, publicURL)
	defer resp.Body.Close()
	assert.Equal(t, http.StatusForbidden, resp.StatusCode, "unverified email must be rejected")

	_, ok, err := st.TenantByIdentity(stub.url, "stubuser")
	require.NoError(t, err)
	assert.False(t, ok, "no tenant may be JIT-provisioned for an unverified email")
}

// TestAuthConfidentialClientExternalIdP covers the client_secret branch: against
// an external confidential IdP the server keeps oauth2 autodetect on (no
// AuthStyleInParams pin) and must present the secret at the token endpoint. The
// stub 401s the exchange unless the correct secret arrives, so a minted session
// proves the secret actually flowed.
func TestAuthConfidentialClientExternalIdP(t *testing.T) {
	const secret = "s3cr3t-confidential"
	stub := newStubIssuer(t, "external@corp.example", secret)
	publicURL, client, st := newStubEnv(t, stub, secret)

	resp := driveStub(t, client, publicURL)
	defer resp.Body.Close()
	require.Equal(t, http.StatusFound, resp.StatusCode)
	assert.Equal(t, "/", resp.Header.Get("Location"))

	sess := cookieByName(resp.Cookies(), "eitri_session")
	require.NotNil(t, sess, "confidential-client sign-in must mint a session")
	tenant, ok, err := st.SessionTenant(sess.Value)
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, "external", tenant, "JIT handle derives from the email local part")

	assert.Equal(t, secret, stub.secretSeen(), "the client_secret must reach the issuer's token endpoint")
}