// Package e2e contains end-to-end tests for the MCIAS server. // // These tests start a real httptest.Server (not TLS; mciassrv adds TLS at the // listener level, but for e2e we use net/http/httptest which wraps any handler) // and exercise complete user flows: login, token renewal, revocation, admin // account management, TOTP enrolment, and system account token issuance. // // Security attack scenarios tested here: // - alg confusion (HS256 token accepted by EdDSA server → must reject) // - alg:none (crafted unsigned token → must reject) // - revoked token reuse → must reject // - expired token → must reject // - non-admin calling admin endpoint → must return 403 package e2e import ( "bytes" "crypto/ed25519" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "git.wntrmute.dev/kyle/mcias/internal/auth" "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/server" "git.wntrmute.dev/kyle/mcias/internal/token" ) const e2eIssuer = "https://auth.e2e.test" // testEnv holds all the state for one e2e test run. type testEnv struct { server *httptest.Server srv *server.Server db *db.DB privKey ed25519.PrivateKey pubKey ed25519.PublicKey } // newTestEnv spins up an httptest.Server backed by a fresh in-memory DB. func newTestEnv(t *testing.T) *testEnv { t.Helper() pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("generate key: %v", err) } database, err := db.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } if err := db.Migrate(database); err != nil { t.Fatalf("migrate db: %v", err) } masterKey := make([]byte, 32) if _, err := rand.Read(masterKey); err != nil { t.Fatalf("generate master key: %v", err) } cfg := config.NewTestConfig(e2eIssuer) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := server.New(database, cfg, priv, pub, masterKey, logger) ts := httptest.NewServer(srv.Handler()) t.Cleanup(func() { ts.Close() _ = database.Close() }) return &testEnv{ server: ts, srv: srv, db: database, privKey: priv, pubKey: pub, } } // createAccount creates a human account directly in the DB. func (e *testEnv) createAccount(t *testing.T, username string) *model.Account { t.Helper() hash, err := auth.HashPassword("testpass123", auth.DefaultArgonParams()) if err != nil { t.Fatalf("hash: %v", err) } acct, err := e.db.CreateAccount(username, model.AccountTypeHuman, hash) if err != nil { t.Fatalf("create account %q: %v", username, err) } return acct } // createAdminAccount creates a human account with the admin role. func (e *testEnv) createAdminAccount(t *testing.T, username string) (*model.Account, string) { t.Helper() acct := e.createAccount(t, username) if err := e.db.GrantRole(acct.ID, "admin", nil); err != nil { t.Fatalf("grant admin: %v", err) } // Issue and track an admin token. tokenStr, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, []string{"admin"}, time.Hour) if err != nil { t.Fatalf("issue token: %v", err) } if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("track token: %v", err) } return acct, tokenStr } // do performs an HTTP request against the test server. func (e *testEnv) do(t *testing.T, method, path string, body interface{}, bearerToken string) *http.Response { t.Helper() var r io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshal body: %v", err) } r = bytes.NewReader(b) } req, err := http.NewRequest(method, e.server.URL+path, r) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", "application/json") if bearerToken != "" { req.Header.Set("Authorization", "Bearer "+bearerToken) } resp, err := e.server.Client().Do(req) if err != nil { t.Fatalf("do request %s %s: %v", method, path, err) } return resp } // decodeJSON decodes the response body into v and closes the body. func decodeJSON(t *testing.T, resp *http.Response, v interface{}) { t.Helper() defer func() { _ = resp.Body.Close() }() if err := json.NewDecoder(resp.Body).Decode(v); err != nil { t.Fatalf("decode JSON: %v", err) } } // mustStatus fails the test if resp.StatusCode != want. func mustStatus(t *testing.T, resp *http.Response, want int) { t.Helper() if resp.StatusCode != want { body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, want, body) } } // ---- E2E Tests ---- // TestE2ELoginLogoutFlow verifies the complete login → validate → logout → invalidate cycle. func TestE2ELoginLogoutFlow(t *testing.T) { e := newTestEnv(t) e.createAccount(t, "alice") // Login. resp := e.do(t, "POST", "/v1/auth/login", map[string]string{ "username": "alice", "password": "testpass123", }, "") mustStatus(t, resp, http.StatusOK) var loginResp struct { Token string `json:"token"` ExpiresAt string `json:"expires_at"` } decodeJSON(t, resp, &loginResp) if loginResp.Token == "" { t.Fatal("empty token in login response") } // Validate — should be valid. resp2 := e.do(t, "POST", "/v1/token/validate", nil, loginResp.Token) mustStatus(t, resp2, http.StatusOK) var vr struct { Valid bool `json:"valid"` } decodeJSON(t, resp2, &vr) if !vr.Valid { t.Fatal("expected valid=true after login") } // Logout. resp3 := e.do(t, "POST", "/v1/auth/logout", nil, loginResp.Token) mustStatus(t, resp3, http.StatusNoContent) _ = resp3.Body.Close() // Validate — should now be invalid (revoked). resp4 := e.do(t, "POST", "/v1/token/validate", nil, loginResp.Token) mustStatus(t, resp4, http.StatusOK) var vr2 struct { Valid bool `json:"valid"` } decodeJSON(t, resp4, &vr2) if vr2.Valid { t.Fatal("expected valid=false after logout") } } // TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one. func TestE2ETokenRenewal(t *testing.T) { e := newTestEnv(t) e.createAccount(t, "bob") // Login. resp := e.do(t, "POST", "/v1/auth/login", map[string]string{ "username": "bob", "password": "testpass123", }, "") mustStatus(t, resp, http.StatusOK) var lr struct { Token string `json:"token"` } decodeJSON(t, resp, &lr) oldToken := lr.Token // Renew. resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken) mustStatus(t, resp2, http.StatusOK) var nr struct { Token string `json:"token"` } decodeJSON(t, resp2, &nr) newToken := nr.Token if newToken == "" || newToken == oldToken { t.Fatal("renewal must return a distinct non-empty token") } // Old token should be invalid. resp3 := e.do(t, "POST", "/v1/token/validate", nil, oldToken) mustStatus(t, resp3, http.StatusOK) var vr struct { Valid bool `json:"valid"` } decodeJSON(t, resp3, &vr) if vr.Valid { t.Fatal("old token should be invalid after renewal") } // New token should be valid. resp4 := e.do(t, "POST", "/v1/token/validate", nil, newToken) mustStatus(t, resp4, http.StatusOK) var vr2 struct { Valid bool `json:"valid"` } decodeJSON(t, resp4, &vr2) if !vr2.Valid { t.Fatal("new token should be valid after renewal") } } // TestE2EAdminAccountManagement verifies full admin account CRUD. func TestE2EAdminAccountManagement(t *testing.T) { e := newTestEnv(t) _, adminToken := e.createAdminAccount(t, "admin") // Create account. resp := e.do(t, "POST", "/v1/accounts", map[string]string{ "username": "carol", "password": "carolpass123", "account_type": "human", }, adminToken) mustStatus(t, resp, http.StatusCreated) var acctResp struct { ID string `json:"id"` Username string `json:"username"` Status string `json:"status"` } decodeJSON(t, resp, &acctResp) if acctResp.Username != "carol" { t.Errorf("username = %q, want carol", acctResp.Username) } carolUUID := acctResp.ID // Get account. resp2 := e.do(t, "GET", "/v1/accounts/"+carolUUID, nil, adminToken) mustStatus(t, resp2, http.StatusOK) _ = resp2.Body.Close() // Set roles. resp3 := e.do(t, "PUT", "/v1/accounts/"+carolUUID+"/roles", map[string][]string{ "roles": {"reader"}, }, adminToken) mustStatus(t, resp3, http.StatusNoContent) _ = resp3.Body.Close() // Get roles. resp4 := e.do(t, "GET", "/v1/accounts/"+carolUUID+"/roles", nil, adminToken) mustStatus(t, resp4, http.StatusOK) var rolesResp struct { Roles []string `json:"roles"` } decodeJSON(t, resp4, &rolesResp) if len(rolesResp.Roles) != 1 || rolesResp.Roles[0] != "reader" { t.Errorf("roles = %v, want [reader]", rolesResp.Roles) } // Delete account. resp5 := e.do(t, "DELETE", "/v1/accounts/"+carolUUID, nil, adminToken) mustStatus(t, resp5, http.StatusNoContent) _ = resp5.Body.Close() } // TestE2ELoginCredentialsNeverInResponse verifies that no credential material // appears in any response body across all endpoints. func TestE2ELoginCredentialsNeverInResponse(t *testing.T) { e := newTestEnv(t) e.createAccount(t, "dave") _, adminToken := e.createAdminAccount(t, "admin-dave") credentialPatterns := []string{ "argon2id", "password_hash", "PasswordHash", "totp_secret", "TOTPSecret", "signing_key", } endpoints := []struct { method string path string body interface{} token string }{ {"POST", "/v1/auth/login", map[string]string{"username": "dave", "password": "testpass123"}, ""}, {"GET", "/v1/accounts", nil, adminToken}, {"GET", "/v1/keys/public", nil, ""}, {"GET", "/v1/health", nil, ""}, } for _, ep := range endpoints { resp := e.do(t, ep.method, ep.path, ep.body, ep.token) body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() bodyStr := string(body) for _, pattern := range credentialPatterns { if strings.Contains(bodyStr, pattern) { t.Errorf("%s %s: response contains credential pattern %q", ep.method, ep.path, pattern) } } } } // TestE2EUnauthorizedAccess verifies that unauthenticated and insufficient-role // requests are properly rejected. func TestE2EUnauthorizedAccess(t *testing.T) { e := newTestEnv(t) acct := e.createAccount(t, "eve") // Issue a non-admin token for eve. tokenStr, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, []string{"reader"}, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { t.Fatalf("TrackToken: %v", err) } // No token on admin endpoint → 401. resp := e.do(t, "GET", "/v1/accounts", nil, "") if resp.StatusCode != http.StatusUnauthorized { t.Errorf("no token: status = %d, want 401", resp.StatusCode) } _ = resp.Body.Close() // Non-admin token on admin endpoint → 403. resp2 := e.do(t, "GET", "/v1/accounts", nil, tokenStr) if resp2.StatusCode != http.StatusForbidden { t.Errorf("non-admin: status = %d, want 403", resp2.StatusCode) } _ = resp2.Body.Close() } // TestE2EAlgConfusionAttack verifies that a token signed with HMAC-SHA256 // using the public key as the secret is rejected. This is the classic alg // confusion attack against JWT libraries that don't validate the alg header. // // Security: The server's ValidateToken always checks alg == "EdDSA" before // attempting signature verification. HS256 tokens must be rejected. func TestE2EAlgConfusionAttack(t *testing.T) { e := newTestEnv(t) acct := e.createAccount(t, "frank") _ = acct // Craft an HS256 JWT using the server's public key as the HMAC secret. // If the server doesn't check alg, it might accept this. header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf( `{"iss":%q,"sub":%q,"roles":["admin"],"jti":"attack","iat":%d,"exp":%d}`, e2eIssuer, acct.UUID, time.Now().Unix(), time.Now().Add(time.Hour).Unix(), ))) sigInput := header + "." + payload mac := hmac.New(sha256.New, e.pubKey) mac.Write([]byte(sigInput)) sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) craftedToken := sigInput + "." + sig resp := e.do(t, "GET", "/v1/accounts", nil, craftedToken) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("alg confusion attack: status = %d, want 401", resp.StatusCode) } _ = resp.Body.Close() } // TestE2EAlgNoneAttack verifies that a token with alg:none is rejected. // // Security: The server's ValidateToken explicitly rejects alg:none before // any processing. A crafted unsigned token must not grant access. func TestE2EAlgNoneAttack(t *testing.T) { e := newTestEnv(t) acct := e.createAccount(t, "grace") _ = acct // Craft an alg:none JWT (no signature). header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf( `{"iss":%q,"sub":%q,"roles":["admin"],"jti":"none-attack","iat":%d,"exp":%d}`, e2eIssuer, acct.UUID, time.Now().Unix(), time.Now().Add(time.Hour).Unix(), ))) craftedToken := header + "." + payload + "." resp := e.do(t, "GET", "/v1/accounts", nil, craftedToken) if resp.StatusCode != http.StatusUnauthorized { t.Errorf("alg:none attack: status = %d, want 401", resp.StatusCode) } _ = resp.Body.Close() } // TestE2ERevokedTokenRejected verifies that a revoked token cannot be reused // to access protected endpoints. func TestE2ERevokedTokenRejected(t *testing.T) { e := newTestEnv(t) _, adminToken := e.createAdminAccount(t, "admin-revoke") // Admin can list accounts. resp := e.do(t, "GET", "/v1/accounts", nil, adminToken) mustStatus(t, resp, http.StatusOK) _ = resp.Body.Close() // Logout revokes the admin token. resp2 := e.do(t, "POST", "/v1/auth/logout", nil, adminToken) mustStatus(t, resp2, http.StatusNoContent) _ = resp2.Body.Close() // Revoked token should no longer work. resp3 := e.do(t, "GET", "/v1/accounts", nil, adminToken) if resp3.StatusCode != http.StatusUnauthorized { t.Errorf("revoked token: status = %d, want 401", resp3.StatusCode) } _ = resp3.Body.Close() } // TestE2ESystemAccountTokenIssuance verifies the system account token flow: // create system account → admin issues token → token is valid. func TestE2ESystemAccountTokenIssuance(t *testing.T) { e := newTestEnv(t) _, adminToken := e.createAdminAccount(t, "admin-sys") // Create a system account. resp := e.do(t, "POST", "/v1/accounts", map[string]string{ "username": "my-service", "account_type": "system", }, adminToken) mustStatus(t, resp, http.StatusCreated) var sysAcct struct { ID string `json:"id"` } decodeJSON(t, resp, &sysAcct) // Issue a service token. resp2 := e.do(t, "POST", "/v1/token/issue", map[string]string{ "account_id": sysAcct.ID, }, adminToken) mustStatus(t, resp2, http.StatusOK) var tokenResp struct { Token string `json:"token"` } decodeJSON(t, resp2, &tokenResp) if tokenResp.Token == "" { t.Fatal("empty service token") } // The issued token should be valid. resp3 := e.do(t, "POST", "/v1/token/validate", nil, tokenResp.Token) mustStatus(t, resp3, http.StatusOK) var vr struct { Subject string `json:"sub"` Valid bool `json:"valid"` } decodeJSON(t, resp3, &vr) if !vr.Valid { t.Fatal("issued service token should be valid") } if vr.Subject != sysAcct.ID { t.Errorf("subject = %q, want %q", vr.Subject, sysAcct.ID) } } // TestE2EWrongPassword verifies that wrong passwords are rejected and the // response is indistinguishable from unknown-user responses (generic 401). func TestE2EWrongPassword(t *testing.T) { e := newTestEnv(t) e.createAccount(t, "heidi") // Wrong password. resp := e.do(t, "POST", "/v1/auth/login", map[string]string{ "username": "heidi", "password": "wrongpassword", }, "") if resp.StatusCode != http.StatusUnauthorized { t.Errorf("wrong password: status = %d, want 401", resp.StatusCode) } // Check that the error is generic, not leaking existence. var errBody map[string]string decodeJSON(t, resp, &errBody) if strings.Contains(errBody["error"], "heidi") { t.Error("error message leaks username") } } // TestE2EUnknownUserSameResponseAsWrongPassword verifies that unknown users // and wrong passwords return identical status codes and error codes to prevent // user enumeration. func TestE2EUnknownUserSameResponseAsWrongPassword(t *testing.T) { e := newTestEnv(t) e.createAccount(t, "ivan") // Wrong password for known user. resp1 := e.do(t, "POST", "/v1/auth/login", map[string]string{ "username": "ivan", "password": "wrong", }, "") var err1 map[string]string decodeJSON(t, resp1, &err1) // Unknown user. resp2 := e.do(t, "POST", "/v1/auth/login", map[string]string{ "username": "nobody-exists", "password": "anything", }, "") var err2 map[string]string decodeJSON(t, resp2, &err2) // Both should return 401 with the same error code. if resp1.StatusCode != http.StatusUnauthorized || resp2.StatusCode != http.StatusUnauthorized { t.Errorf("status mismatch: known-wrong=%d, unknown=%d, both want 401", resp1.StatusCode, resp2.StatusCode) } if err1["code"] != err2["code"] { t.Errorf("error codes differ: known-wrong=%q, unknown=%q; must be identical", err1["code"], err2["code"]) } }