Files
member-console/internal/auth/callback_test.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

365 lines
14 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/spf13/viper"
"golang.org/x/oauth2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// signingKey is a test RSA key, generated once per process: the verifier is
// built over its public half, so a token signed with it is as verifiable
// here as a provider's would be in production.
var signingKey = func() *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return key
}()
// signIDToken produces a real, signed ID token carrying claims, the way the
// identity provider's token endpoint would.
func signIDToken(t *testing.T, claims map[string]any) string {
t.Helper()
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: signingKey}, nil)
if err != nil {
t.Fatalf("signer: %v", err)
}
payload, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
jws, err := signer.Sign(payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
raw, err := jws.CompactSerialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return raw
}
// Returning-person fakes: the two queriers CallbackHandler consults when the
// subject is already known. The interface is embedded so only the methods
// this path calls need bodies; any other call is a nil dereference, which is
// the failure a test should see.
type fakeIdentity struct {
identity.Querier
user identity.User
person identity.Person
}
func (f fakeIdentity) GetUserByOIDCSubject(context.Context, string) (identity.User, error) {
return f.user, nil
}
func (f fakeIdentity) UpdateUserLogin(context.Context, identity.UpdateUserLoginParams) (identity.User, error) {
return f.user, nil
}
func (f fakeIdentity) GetPersonByUserID(context.Context, string) (identity.Person, error) {
return f.person, nil
}
func (f fakeIdentity) UpdatePerson(context.Context, identity.UpdatePersonParams) (identity.Person, error) {
return f.person, nil
}
type fakeOrganization struct {
organization.Querier
}
func (fakeOrganization) GetOrganizationsByOwner(context.Context, string) ([]organization.Organization, error) {
return []organization.Organization{{OrgID: "org-1"}}, nil
}
func (fakeOrganization) GetWorkspacesByOrgID(context.Context, string) ([]organization.Workspace, error) {
return []organization.Workspace{{WorkspaceID: "ws-1"}}, nil
}
// The refresh token the provider issues at sign-in is kept in the session,
// because sign-out mints a fresh id_token_hint from it. This drives the whole
// callback: a real signed ID token, verified against the signing key, with
// the nonce the session holds, exchanged at a fake token endpoint.
func TestCallbackStoresTheRefreshToken(t *testing.T) {
const (
issuer = "https://idp.test/realms/main"
clientID = "test-client"
nonce = "the-nonce"
state = "the-state"
)
viper.Set("base-url", "http://console.test")
t.Cleanup(viper.Reset)
idToken := signIDToken(t, map[string]any{
"iss": issuer,
"sub": "subject-1",
"aud": clientID,
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"nonce": nonce,
"email": "bob@example.test",
"email_verified": true,
"name": "Bob Builder",
"preferred_username": "bob",
"roles": []string{},
})
var exchanged struct{ grant, code string }
tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint: %v", err)
}
exchanged.grant = r.PostForm.Get("grant_type")
exchanged.code = r.PostForm.Get("code")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-1",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "refresh-from-the-provider",
"id_token": idToken,
})
}))
t.Cleanup(tokenEndpoint.Close)
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: "test-secret",
RedirectURL: "http://console.test/callback",
Endpoint: oauth2.Endpoint{AuthURL: issuer + "/auth", TokenURL: tokenEndpoint.URL},
}
cfg.Verifier = oidc.NewVerifier(issuer,
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}},
&oidc.Config{ClientID: clientID})
cfg.IdentityQ = fakeIdentity{
user: identity.User{UserID: "user-1", OidcSubject: "subject-1"},
person: identity.Person{PersonID: "person-1", UserID: "user-1", DisplayName: "Bob Builder", PrimaryEmail: "bob@example.test"},
}
cfg.OrgQ = fakeOrganization{}
// The session /login would have left behind.
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
cfg.SessionManager.Put(r.Context(), sessionKeyNonce, nonce)
cfg.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, "verifier-1")
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued")
}
req := httptest.NewRequest(http.MethodGet, "/callback?state="+state+"&code=code-1", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("status %d Location %q body %q; want a 302 to /", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if exchanged.grant != "authorization_code" || exchanged.code != "code-1" {
t.Errorf("token endpoint saw grant %q code %q", exchanged.grant, exchanged.code)
}
// Read the session back through the manager, as a later request would.
var got struct {
authenticated bool
personID string
refreshToken string
idToken string
}
after := httptest.NewRequest(http.MethodGet, "/", nil)
after.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: callbackSessionToken(t, rec, cfg)})
cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.authenticated = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated)
got.personID = cfg.SessionManager.GetString(r.Context(), sessionKeyPersonID)
got.refreshToken = cfg.SessionManager.GetString(r.Context(), sessionKeyRefreshToken)
got.idToken = cfg.SessionManager.GetString(r.Context(), sessionKeyIDToken)
})).ServeHTTP(httptest.NewRecorder(), after)
if !got.authenticated || got.personID != "person-1" {
t.Errorf("session after sign-in: authenticated=%v person=%q", got.authenticated, got.personID)
}
if got.refreshToken != "refresh-from-the-provider" {
t.Errorf("refresh token in session = %q, want the one the provider issued", got.refreshToken)
}
if got.idToken != idToken {
t.Error("the ID token in the session is not the one the provider issued")
}
}
// callbackSessionToken returns the session token the callback response set.
func callbackSessionToken(t *testing.T, rec *httptest.ResponseRecorder, cfg *Config) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == cfg.SessionManager.Cookie.Name {
return c.Value
}
}
t.Fatal("the callback response set no session cookie")
return ""
}
// newCallbackTestFixture wires the same happy-path callback
// (TestCallbackStoresTheRefreshToken) needs, for the rotation and deadline
// tests below, which care about the session token and its deadline rather
// than the claims a successful sign-in stores.
type callbackTestFixture struct {
cfg *Config
nonce string
}
func newCallbackTestFixture(t *testing.T) callbackTestFixture {
t.Helper()
const (
issuer = "https://idp.test/realms/main"
clientID = "test-client"
nonce = "the-nonce"
)
viper.Set("base-url", "http://console.test")
t.Cleanup(viper.Reset)
idToken := signIDToken(t, map[string]any{
"iss": issuer,
"sub": "subject-1",
"aud": clientID,
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"nonce": nonce,
"email": "bob@example.test",
"email_verified": true,
"name": "Bob Builder",
"preferred_username": "bob",
"roles": []string{},
})
tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-1",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "refresh-from-the-provider",
"id_token": idToken,
})
}))
t.Cleanup(tokenEndpoint.Close)
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: "test-secret",
RedirectURL: "http://console.test/callback",
Endpoint: oauth2.Endpoint{AuthURL: issuer + "/auth", TokenURL: tokenEndpoint.URL},
}
cfg.Verifier = oidc.NewVerifier(issuer,
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}},
&oidc.Config{ClientID: clientID})
cfg.IdentityQ = fakeIdentity{
user: identity.User{UserID: "user-1", OidcSubject: "subject-1"},
person: identity.Person{PersonID: "person-1", UserID: "user-1", DisplayName: "Bob Builder", PrimaryEmail: "bob@example.test"},
}
cfg.OrgQ = fakeOrganization{}
return callbackTestFixture{cfg: cfg, nonce: nonce}
}
// runSuccessfulCallback seeds the pending-flow session /login would have
// left and drives it through /callback, exactly as TestCallbackStoresTheRefreshToken
// does, returning the response and the session token the seed step issued
// (the one the browser held while at the provider).
func runSuccessfulCallback(t *testing.T, cfg *Config, nonce string) (rec *httptest.ResponseRecorder, seedToken string) {
t.Helper()
const state = "the-state"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
seedToken = tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
cfg.SessionManager.Put(r.Context(), sessionKeyNonce, nonce)
cfg.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, "verifier-1")
}, seed)
if seedToken == "" {
t.Fatal("setup: no session token was issued")
}
req := httptest.NewRequest(http.MethodGet, "/callback?state="+state+"&code=code-1", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: seedToken})
rec = httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("callback status %d Location %q body %q; want a 302 to /", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
return rec, seedToken
}
// sessionAuthenticated reads the authenticated flag directly through the
// session store for token, the way CallbackHandler's own tests read the
// session back — not through Middleware, which needs an IdentityQ that
// answers GetPersonByID and which the callback fixtures above do not wire.
func sessionAuthenticated(t *testing.T, cfg *Config, token string) bool {
t.Helper()
var got bool
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated)
})).ServeHTTP(httptest.NewRecorder(), req)
return got
}
// The verdict behind design D2: the callback binds the identity into
// whatever session presents the matching state, without a new token, so a
// session id fixed between /login and /callback survives sign-in. RenewToken
// after the checks pass and before the identity is written closes that.
func TestCallbackRotatesTheSessionToken(t *testing.T) {
fx := newCallbackTestFixture(t)
rec, seedToken := runSuccessfulCallback(t, fx.cfg, fx.nonce)
newToken := callbackSessionToken(t, rec, fx.cfg)
if newToken == seedToken {
t.Fatalf("session token was not rotated at the callback (%q); a session id fixed between /login and /callback would survive sign-in", newToken)
}
if sessionAuthenticated(t, fx.cfg, seedToken) {
t.Error("the token the browser held while at the provider still resolves to the authenticated session")
}
if !sessionAuthenticated(t, fx.cfg, newToken) {
t.Error("the rotated session token does not resolve to the authenticated session")
}
}
// Design D3: a pre-authentication session lives fifteen minutes
// (LoginHandler); a successful callback restores the full configured
// lifetime, since the person is now signed in for the session's ordinary
// life rather than mid pending flow.
func TestCallbackRestoresTheFullSessionLifetime(t *testing.T) {
fx := newCallbackTestFixture(t)
rec, _ := runSuccessfulCallback(t, fx.cfg, fx.nonce)
newToken := callbackSessionToken(t, rec, fx.cfg)
var deadline time.Time
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: fx.cfg.SessionManager.Cookie.Name, Value: newToken})
fx.cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
deadline = fx.cfg.SessionManager.Deadline(r.Context())
})).ServeHTTP(httptest.NewRecorder(), req)
want := time.Now().Add(fx.cfg.SessionManager.Lifetime)
if diff := deadline.Sub(want); diff < -time.Minute || diff > time.Minute {
t.Errorf("deadline = %v, want within a minute of %v (the full session lifetime from now)", deadline, want)
}
}