Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
384 lines
12 KiB
Go
384 lines
12 KiB
Go
package oidc_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
josev4 "github.com/go-jose/go-jose/v4"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/web"
|
|
"git.d-ma.be/mathias/tapir/internal/web/oidc"
|
|
)
|
|
|
|
const (
|
|
testClientID = "tapir-web"
|
|
testSubject = "dex-subject-123"
|
|
)
|
|
|
|
// fakeIssuer is an httptest-backed OIDC provider: it serves a discovery
|
|
// document, a JWKS, and a token endpoint that mints an RS256-signed ID token
|
|
// from its mutable sub/email/nonce fields. No live Dex.
|
|
type fakeIssuer struct {
|
|
server *httptest.Server
|
|
key *rsa.PrivateKey
|
|
clientID string
|
|
|
|
sub string
|
|
email string
|
|
nonce string
|
|
}
|
|
|
|
const testKID = "test-key"
|
|
|
|
func newFakeIssuer(t *testing.T) *fakeIssuer {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
f := &fakeIssuer{key: key, clientID: testClientID}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(t, w, map[string]any{
|
|
"issuer": f.server.URL,
|
|
"authorization_endpoint": f.server.URL + "/authorize",
|
|
"token_endpoint": f.server.URL + "/token",
|
|
"jwks_uri": f.server.URL + "/jwks",
|
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
|
"response_types_supported": []string{"code"},
|
|
"subject_types_supported": []string{"public"},
|
|
})
|
|
})
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(t, w, josev4.JSONWebKeySet{Keys: []josev4.JSONWebKey{{
|
|
Key: &f.key.PublicKey,
|
|
KeyID: testKID,
|
|
Algorithm: "RS256",
|
|
Use: "sig",
|
|
}}})
|
|
})
|
|
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(t, w, map[string]any{
|
|
"access_token": "fake-access-token",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"id_token": f.signIDToken(t),
|
|
})
|
|
})
|
|
|
|
f.server = httptest.NewServer(mux)
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeIssuer) signIDToken(t *testing.T) string {
|
|
t.Helper()
|
|
signer, err := josev4.NewSigner(
|
|
josev4.SigningKey{Algorithm: josev4.RS256, Key: f.key},
|
|
(&josev4.SignerOptions{}).WithType("JWT").WithHeader("kid", testKID),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
now := time.Now()
|
|
payload, err := json.Marshal(map[string]any{
|
|
"iss": f.server.URL,
|
|
"sub": f.sub,
|
|
"aud": f.clientID,
|
|
"exp": now.Add(time.Hour).Unix(),
|
|
"iat": now.Unix(),
|
|
"nonce": f.nonce,
|
|
"email": f.email,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
obj, err := signer.Sign(payload)
|
|
require.NoError(t, err)
|
|
s, err := obj.CompactSerialize()
|
|
require.NoError(t, err)
|
|
return s
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
require.NoError(t, json.NewEncoder(w).Encode(v))
|
|
}
|
|
|
|
func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth {
|
|
t.Helper()
|
|
auth, err := oidc.New(context.Background(), oidc.Config{
|
|
Issuer: f.server.URL,
|
|
ClientID: testClientID,
|
|
ClientSecret: "test-client-secret",
|
|
RedirectURL: "http://tapir.test/auth/callback",
|
|
SessionSecret: "test-session-secret-please-change",
|
|
}, oidc.WithInsecureCookies())
|
|
require.NoError(t, err)
|
|
return auth
|
|
}
|
|
|
|
// login drives /auth/login and returns the state and nonce from the authorize
|
|
// redirect, as a real browser hop would surface them.
|
|
func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
|
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
q := loc.Query()
|
|
return q.Get("state"), q.Get("nonce")
|
|
}
|
|
|
|
// authenticate completes a full login+callback for the test subject and returns
|
|
// the resulting session cookie.
|
|
func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie {
|
|
t.Helper()
|
|
state, nonce := login(t, auth)
|
|
f.sub, f.email, f.nonce = testSubject, "maintainer@d-ma.be", nonce
|
|
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
|
"/auth/callback?code=valid-code&state="+state, nil))
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
|
|
|
c := sessionCookie(t, rec.Result())
|
|
require.NotEmpty(t, c.Value)
|
|
return c
|
|
}
|
|
|
|
func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie {
|
|
t.Helper()
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == "tapir_session" {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatal("no tapir_session cookie set")
|
|
return nil
|
|
}
|
|
|
|
func TestLoginRedirectsToAuthorize(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
|
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, f.server.URL+"/authorize", loc.Scheme+"://"+loc.Host+loc.Path)
|
|
|
|
q := loc.Query()
|
|
require.Equal(t, "code", q.Get("response_type"))
|
|
require.Equal(t, testClientID, q.Get("client_id"))
|
|
require.NotEmpty(t, q.Get("state"))
|
|
require.NotEmpty(t, q.Get("nonce"))
|
|
require.Contains(t, q.Get("scope"), "openid")
|
|
}
|
|
|
|
func TestCallbackSetsSession(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
cookie := authenticate(t, auth, f)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
user, ok := auth.CurrentUser(req)
|
|
require.True(t, ok)
|
|
require.Equal(t, testSubject, user.Subject)
|
|
require.Equal(t, "maintainer@d-ma.be", user.Email)
|
|
|
|
require.True(t, cookie.HttpOnly)
|
|
require.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
|
}
|
|
|
|
// TestCallbackAnySubjectAuthenticates proves the single-subject allowlist is gone
|
|
// (ADR-012): a subject other than any prior allowlist still gets a session.
|
|
func TestCallbackAnySubjectAuthenticates(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
state, nonce := login(t, auth)
|
|
f.sub, f.email, f.nonce = "some-other-subject-999", "other@elsewhere.test", nonce
|
|
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
|
"/auth/callback?code=valid-code&state="+state, nil))
|
|
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
|
|
|
cookie := sessionCookie(t, rec.Result())
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
user, ok := auth.CurrentUser(req)
|
|
require.True(t, ok)
|
|
require.Equal(t, "some-other-subject-999", user.Subject)
|
|
}
|
|
|
|
func TestCallbackUnknownStateRejected(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
|
|
"/auth/callback?code=valid-code&state=forged-state", nil))
|
|
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
}
|
|
|
|
func TestMiddlewareRedirectsUnauthenticated(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// The bare root sends an unauthenticated visitor to the public landing page.
|
|
rec := httptest.NewRecorder()
|
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
require.Equal(t, "/welcome", rec.Header().Get("Location"))
|
|
|
|
// A deeper guarded path goes to login so the post-login round-trip returns there.
|
|
rec = httptest.NewRecorder()
|
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil))
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
require.Equal(t, "/auth/login", rec.Header().Get("Location"))
|
|
}
|
|
|
|
func TestMiddlewarePassesAuthenticated(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
cookie := authenticate(t, auth, f)
|
|
|
|
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusTeapot) // a marker the handler actually ran
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
rec := httptest.NewRecorder()
|
|
guarded.ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusTeapot, rec.Code)
|
|
}
|
|
|
|
func TestMiddlewarePublicPathsBypassAuth(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
|
|
guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
for _, path := range []string{"/healthz", "/welcome", "/auth/login"} {
|
|
rec := httptest.NewRecorder()
|
|
guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path)
|
|
}
|
|
}
|
|
|
|
func TestLogoutClearsSession(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
cookie := authenticate(t, auth, f)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
|
req.AddCookie(cookie)
|
|
rec := httptest.NewRecorder()
|
|
auth.Routes().ServeHTTP(rec, req)
|
|
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page")
|
|
cleared := sessionCookie(t, rec.Result())
|
|
require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it")
|
|
require.Empty(t, cleared.Value, "logout blanks the cookie value")
|
|
|
|
// Sessions are stateless (ADR-029): logout clears the cookie client-side, so a
|
|
// request carrying the cleared (empty) cookie is unauthenticated. The original
|
|
// signed cookie remains technically valid until its expiry — the accepted
|
|
// trade for no server-side store; the browser no longer holds it.
|
|
check := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
check.AddCookie(cleared)
|
|
_, ok := auth.CurrentUser(check)
|
|
require.False(t, ok, "the cleared cookie does not authenticate")
|
|
}
|
|
|
|
// TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one
|
|
// process is accepted by a FRESH instance with the same session secret — so a
|
|
// deploy/pod-restart no longer logs users out (the old in-memory store did).
|
|
func TestSessionSurvivesRestart(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth1 := newAuth(t, f)
|
|
cookie := authenticate(t, auth1, f)
|
|
|
|
auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
user, ok := auth2.CurrentUser(req)
|
|
require.True(t, ok, "a session must survive a restart (stateless signed cookie)")
|
|
require.Equal(t, testSubject, user.Subject)
|
|
}
|
|
|
|
// TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it
|
|
// survives the browser/app being closed (a session cookie was dropped on iOS).
|
|
func TestSessionCookieIsPersistent(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
cookie := authenticate(t, auth, f)
|
|
require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)")
|
|
}
|
|
|
|
func TestExpiredSessionRejected(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
clock := time.Now()
|
|
auth, err := oidc.New(context.Background(), oidc.Config{
|
|
Issuer: f.server.URL,
|
|
ClientID: testClientID,
|
|
ClientSecret: "test-client-secret",
|
|
RedirectURL: "http://tapir.test/auth/callback",
|
|
SessionSecret: "test-session-secret-please-change",
|
|
}, oidc.WithInsecureCookies(),
|
|
oidc.WithSessionTTL(time.Minute),
|
|
oidc.WithClock(func() time.Time { return clock }))
|
|
require.NoError(t, err)
|
|
|
|
cookie := authenticate(t, auth, f)
|
|
|
|
clock = clock.Add(2 * time.Minute) // push past the TTL
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(cookie)
|
|
_, ok := auth.CurrentUser(req)
|
|
require.False(t, ok)
|
|
}
|
|
|
|
func TestTamperedCookieRejected(t *testing.T) {
|
|
f := newFakeIssuer(t)
|
|
auth := newAuth(t, f)
|
|
cookie := authenticate(t, auth, f)
|
|
|
|
tampered := &http.Cookie{Name: cookie.Name, Value: cookie.Value + "x"}
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.AddCookie(tampered)
|
|
_, ok := auth.CurrentUser(req)
|
|
require.False(t, ok)
|
|
}
|
|
|
|
var _ web.Auth = (*oidc.DexAuth)(nil)
|