package oidc_test import ( "crypto/rand" "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "keycape/internal/domain" "keycape/internal/server/oidc" "keycape/internal/server/telemetry" ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func newUserinfoHandler(t *testing.T, users domain.UserRepository) (*oidc.UserinfoHandler, *rsa.PrivateKey) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate RSA key: %v", err) } capture := &captureEmitter{} h := &oidc.UserinfoHandler{ Users: users, SigningKey: &key.PublicKey, Issuer: "https://auth.netkingdom.local", Emitter: capture, } return h, key } // buildToken builds and signs a JWT with the given claims using the private key. func buildToken(t *testing.T, claims map[string]interface{}, key *rsa.PrivateKey) string { t.Helper() tok, err := oidc.BuildJWT(claims, "key-1", key) if err != nil { t.Fatalf("buildToken: %v", err) } return tok } func userinfoRequest(token string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/userinfo", nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return req } func decodeUserinfoClaims(t *testing.T, body string) map[string]interface{} { t.Helper() var m map[string]interface{} if err := json.Unmarshal([]byte(body), &m); err != nil { t.Fatalf("decode userinfo response: %v (body: %q)", err, body) } return m } // --------------------------------------------------------------------------- // T09 — Userinfo Endpoint Tests // --------------------------------------------------------------------------- func TestUserinfoHandler_ValidToken_ReturnsClaims(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{ "user-alice": aliceUser(), // LookupUser by sub (= user.ID) "alice": aliceUser(), // also by username }} h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "aud": "test-client", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), "scope": "openid profile email groups", } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } ct := w.Header().Get("Content-Type") if ct == "" { t.Error("Content-Type must be set") } resp := decodeUserinfoClaims(t, w.Body.String()) if resp["sub"] != "alice" { t.Errorf("sub: expected alice, got %v", resp["sub"]) } } func TestUserinfoHandler_MissingAuthorization_Returns401(t *testing.T) { users := &mockUserRepo{} h, _ := newUserinfoHandler(t, users) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest("")) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } } func TestUserinfoHandler_ExpiredToken_Returns401(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "aud": "test-client", "exp": now.Add(-5 * time.Minute).Unix(), // already expired "iat": now.Add(-10 * time.Minute).Unix(), } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for expired token, got %d", w.Code) } } func TestUserinfoHandler_InvalidSignature_Returns401(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newUserinfoHandler(t, users) // handler uses key1.Public // Sign with a DIFFERENT key wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate wrong key: %v", err) } now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), } token := buildToken(t, claims, wrongKey) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 for invalid signature, got %d", w.Code) } } func TestUserinfoHandler_WithEmailScope_EmailPresent(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), "scope": "openid email", } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeUserinfoClaims(t, w.Body.String()) if resp["email"] != "alice@example.com" { t.Errorf("email: expected alice@example.com, got %v", resp["email"]) } } func TestUserinfoHandler_WithoutEmailScope_EmailAbsent(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), "scope": "openid profile", // no email scope } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeUserinfoClaims(t, w.Body.String()) if _, ok := resp["email"]; ok { t.Error("email must be absent when email scope is not present in token") } } func TestUserinfoHandler_WithProfileScope_UsernamePresent(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), "scope": "openid profile", } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeUserinfoClaims(t, w.Body.String()) if resp["preferred_username"] != "alice" { t.Errorf("preferred_username: expected alice, got %v", resp["preferred_username"]) } } func TestUserinfoHandler_EmitsTelemetry(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} key, _ := rsa.GenerateKey(rand.Reader, 2048) capture := &captureEmitter{} h := &oidc.UserinfoHandler{ Users: users, SigningKey: &key.PublicKey, Issuer: "https://auth.netkingdom.local", Emitter: capture, } now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), } token, _ := oidc.BuildJWT(claims, "key-1", key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } found := false for _, ev := range capture.events { if ev.EventType == telemetry.EventAuthSuccess && ev.Endpoint == "/userinfo" { found = true break } } if !found { t.Error("expected auth_success telemetry event for /userinfo") } } // Ensure mockUserRepo also satisfies the extended interface with ListUsers. func TestUserinfoHandler_UserNotFound_Returns401(t *testing.T) { users := &mockUserRepo{users: map[string]*domain.User{}} // empty — no alice h, key := newUserinfoHandler(t, users) now := time.Now() claims := map[string]interface{}{ "iss": "https://auth.netkingdom.local", "sub": "alice", "exp": now.Add(10 * time.Minute).Unix(), "iat": now.Unix(), } token := buildToken(t, claims, key) w := httptest.NewRecorder() h.ServeHTTP(w, userinfoRequest(token)) // user not found → treat as 401 (token references unknown user) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401 when user not found, got %d", w.Code) } } // Compile-time check: mockUserRepo satisfies domain.UserRepository (including ListUsers). var _ domain.UserRepository = (*mockUserRepo)(nil)