- 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
365 lines
14 KiB
Go
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)
|
|
}
|
|
}
|