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