package oidc_test import ( "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "keycape/internal/adapters/tenantengine" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/oidc" "keycape/internal/server/telemetry" ) // --------------------------------------------------------------------------- // Mock UserRepository // --------------------------------------------------------------------------- type mockUserRepo struct { users map[string]*domain.User } func (m *mockUserRepo) LookupUser(_ context.Context, username string) (*domain.User, error) { u, ok := m.users[username] if !ok { return nil, domain.ErrUserNotFound } return u, nil } func (m *mockUserRepo) LookupGroups(_ context.Context, _ string) ([]domain.Group, error) { return nil, nil } func (m *mockUserRepo) ValidatePassword(_ context.Context, _, _ string) (bool, error) { return false, nil } func (m *mockUserRepo) ListUsers(_ context.Context) ([]domain.User, error) { users := make([]domain.User, 0, len(m.users)) for _, u := range m.users { users = append(users, *u) } return users, nil } // --------------------------------------------------------------------------- // PKCE helpers // --------------------------------------------------------------------------- // makeVerifierAndChallenge returns a code_verifier and its S256 code_challenge. func makeVerifierAndChallenge() (verifier, challenge string) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { panic(err) } verifier = base64.RawURLEncoding.EncodeToString(b) h := sha256.New() h.Write([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(h.Sum(nil)) return } // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- func newTokenHandler(t *testing.T, sessions *oidc.SessionStore, users domain.UserRepository) (*oidc.TokenHandler, *rsa.PrivateKey) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate key: %v", err) } emitter := &captureEmitter{} h := &oidc.TokenHandler{ ClientConfig: testClient(), Sessions: sessions, Users: users, SigningKey: key, Issuer: "https://auth.netkingdom.local", TokenLifetime: 15 * time.Minute, Emitter: emitter, } return h, key } // seededRedirectURI is the redirect stored by seededSession, and the value the // token endpoint now requires the exchange to repeat (KEY-WP-0016-T02). const seededRedirectURI = "https://app.example.com/callback" func tokenRequest(params url.Values) *http.Request { // Supply the matching redirect_uri for code exchanges unless the test is // deliberately exercising a mismatch, so each case still fails for the // reason it is testing. if params.Get("grant_type") == "authorization_code" && !params.Has("redirect_uri") { params = cloneValues(params) params.Set("redirect_uri", seededRedirectURI) } req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(params.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } func cloneValues(params url.Values) url.Values { out := make(url.Values, len(params)+1) for key, values := range params { out[key] = append([]string(nil), values...) } return out } func seededSession(sessions *oidc.SessionStore, verifier string) (code string) { challenge := s256Challenge(verifier) sess := &oidc.PKCESession{ ClientID: "test-client", RedirectURI: "https://app.example.com/callback", PKCEChallenge: challenge, PKCEChallengeMethod: "S256", State: "state1", Nonce: "nonce1", Username: "alice", Scopes: []string{"openid", "profile", "email", "groups"}, ExpiresAt: time.Now().Add(10 * time.Minute), } return sessions.Create(sess) } func s256Challenge(verifier string) string { h := sha256.New() h.Write([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } func decodeTokenResponse(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("could not decode token response: %v (body: %q)", err, body) } return m } func parseJWTPayload(t *testing.T, token string) map[string]interface{} { t.Helper() parts := strings.Split(token, ".") if len(parts) != 3 { t.Fatalf("expected 3 JWT parts, got %d", len(parts)) } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { t.Fatalf("decode JWT payload: %v", err) } var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { t.Fatalf("unmarshal JWT payload: %v", err) } return claims } func aliceUser() *domain.User { return &domain.User{ ID: "user-alice", Username: "alice", Email: "alice@example.com", Groups: []string{"admin", "users"}, Enabled: true, } } // --------------------------------------------------------------------------- // T07 Token Endpoint Tests // --------------------------------------------------------------------------- func TestTokenHandler_ValidExchange_ReturnsJWT(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) if _, ok := resp["access_token"]; !ok { t.Error("missing access_token") } if _, ok := resp["id_token"]; !ok { t.Error("missing id_token") } if resp["token_type"] != "Bearer" { t.Errorf("expected token_type Bearer, got %v", resp["token_type"]) } if _, ok := resp["expires_in"]; !ok { t.Error("missing expires_in") } } func TestTokenHandler_SuspendedUserCannotExchangeCode(t *testing.T) { sessions := oidc.NewSessionStore() user := aliceUser() user.Groups = append(user.Groups, "netkingdom-suspended") users := &mockUserRepo{users: map[string]*domain.User{"alice": user}} h, _ := newTokenHandler(t, sessions, users) verifier := "suspended-verifier-with-enough-entropy" code := seededSession(sessions, verifier) req := tokenRequest(url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {"test-client"}, "code_verifier": {verifier}, }) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) } if _, ok := sessions.Get(code); ok { t.Fatal("authorization code must be consumed when suspension is detected") } } func TestTokenHandler_WrongGrantType_FeatureNotSupported(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{} h, _ := newTokenHandler(t, sessions, users) params := url.Values{ "grant_type": []string{"password"}, "client_id": []string{"test-client"}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrFeatureNotSupported { t.Errorf("expected feature_not_supported_by_profile, got %q", errType) } } func serviceTokenHandler(t *testing.T) *oidc.TokenHandler { t.Helper() h, _ := newTokenHandler(t, oidc.NewSessionStore(), &mockUserRepo{}) h.ClientConfig["rapp-qonto"] = &domain.Client{ ClientID: "rapp-qonto", AllowedScopes: []string{"finance.qonto.read"}, GrantTypes: []string{"client_credentials"}, ClientType: "confidential", ClientSecret: "test-service-secret", ServiceSubject: "service:rapp-qonto", Tenant: "tenant:friendly:binky", Roles: []string{"finance-reader"}, } return h } func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T) { h := serviceTokenHandler(t) req := tokenRequest(url.Values{ "grant_type": {"client_credentials"}, "scope": {"finance.qonto.read"}, }) req.SetBasicAuth("rapp-qonto", "test-service-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) if _, ok := resp["id_token"]; ok { t.Fatal("service exchange must not return id_token") } claims := parseJWTPayload(t, resp["access_token"].(string)) if claims["sub"] != "service:rapp-qonto" || claims["tenant"] != "tenant:friendly:binky" || claims["principal_type"] != "service" || claims["scope"] != "finance.qonto.read" { t.Fatalf("unexpected service claims: %#v", claims) } } func TestTokenHandler_ClientCredentials_UsesPerClientLifetime(t *testing.T) { h := serviceTokenHandler(t) h.ClientConfig["rapp-qonto"].TokenLifetime = 5 * time.Minute req := tokenRequest(url.Values{ "grant_type": {"client_credentials"}, "scope": {"finance.qonto.read"}, }) req.SetBasicAuth("rapp-qonto", "test-service-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) if got := int(resp["expires_in"].(float64)); got != 300 { t.Fatalf("expires_in: want 300, got %d", got) } claims := parseJWTPayload(t, resp["access_token"].(string)) ttl := int64(claims["exp"].(float64) - claims["iat"].(float64)) if ttl != 300 { t.Fatalf("JWT lifetime: want 300 seconds, got %d", ttl) } } func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) { h := serviceTokenHandler(t) req := tokenRequest(url.Values{"grant_type": {"client_credentials"}}) req.SetBasicAuth("rapp-qonto", "wrong") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestTokenHandler_ClientCredentials_RejectsExcessScope(t *testing.T) { h := serviceTokenHandler(t) req := tokenRequest(url.Values{ "grant_type": {"client_credentials"}, "scope": {"finance.qonto.write"}, }) req.SetBasicAuth("rapp-qonto", "test-service-secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestTokenHandler_PKCEMismatch_InvalidProfileUsage(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) realVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, realVerifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{"wrong-verifier-that-does-not-match"}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } func TestTokenHandler_CodeNotFound_InvalidProfileUsage(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{} h, _ := newTokenHandler(t, sessions, users) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{"no-such-code"}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{"any-verifier"}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } errType := decodeProfileError(t, w.Body.String()) if errType != profileerrors.ErrInvalidProfileUsage { t.Errorf("expected invalid_profile_usage, got %q", errType) } } func TestTokenHandler_JWTClaims_CorrectSubAndIssuer(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } resp := decodeTokenResponse(t, w.Body.String()) idToken, ok := resp["id_token"].(string) if !ok { t.Fatal("id_token is not a string") } claims := parseJWTPayload(t, idToken) if claims["sub"] != "user-alice" { t.Errorf("sub: expected user-alice, got %v", claims["sub"]) } if claims["iss"] != "https://auth.netkingdom.local" { t.Errorf("iss: expected https://auth.netkingdom.local, got %v", claims["iss"]) } if claims["aud"] != "test-client" { t.Errorf("aud: expected test-client, got %v", claims["aud"]) } if claims["nonce"] != "nonce1" { t.Errorf("nonce: expected nonce1, got %v", claims["nonce"]) } } func TestTokenHandler_ScopeFiltering_ProfileScope(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) // Seed session with only openid scope (no email, no groups). verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" challenge := s256Challenge(verifier) sess := &oidc.PKCESession{ ClientID: "test-client", RedirectURI: "https://app.example.com/callback", PKCEChallenge: challenge, PKCEChallengeMethod: "S256", Username: "alice", Scopes: []string{"openid"}, // no profile/email/groups ExpiresAt: time.Now().Add(10 * time.Minute), } code := sessions.Create(sess) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } resp := decodeTokenResponse(t, w.Body.String()) idToken := resp["id_token"].(string) claims := parseJWTPayload(t, idToken) // Without profile scope, preferred_username must not be present. if _, ok := claims["preferred_username"]; ok { t.Error("preferred_username must be absent when profile scope is not granted") } // Without email scope, email must not be present. if _, ok := claims["email"]; ok { t.Error("email must be absent when email scope is not granted") } // groups is a required IAM Profile core claim (iam-profile_v0.3.md) -- // present regardless of scope, unlike preferred_username/email above. if _, ok := claims["groups"]; !ok { t.Error("groups must be present as a core claim even without a groups scope (KEY-WP-0005-T01)") } } func TestTokenHandler_ScopeFiltering_AllScopes(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) // has openid, profile, email, groups params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } resp := decodeTokenResponse(t, w.Body.String()) idToken := resp["id_token"].(string) claims := parseJWTPayload(t, idToken) if claims["preferred_username"] != "alice" { t.Errorf("preferred_username: expected alice, got %v", claims["preferred_username"]) } if claims["email"] != "alice@example.com" { t.Errorf("email: expected alice@example.com, got %v", claims["email"]) } if _, ok := claims["groups"]; !ok { t.Error("groups claim must be present when groups scope is granted") } } func TestTokenHandler_TokenIssuedTelemetry(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generate key: %v", err) } capture := &captureEmitter{} h := &oidc.TokenHandler{ ClientConfig: testClient(), Sessions: sessions, Users: users, SigningKey: key, Issuer: "https://auth.netkingdom.local", TokenLifetime: 15 * time.Minute, Emitter: capture, } verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } found := false for _, ev := range capture.events { if ev.EventType == telemetry.EventTokenIssued { found = true break } } if !found { t.Error("expected token_issued telemetry event") } } func TestTokenHandler_CodeDeletedAfterUse(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } // First use — should succeed. req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("first use: expected 200, got %d", w.Code) } // Second use — code should be gone. req2 := tokenRequest(params) w2 := httptest.NewRecorder() h.ServeHTTP(w2, req2) if w2.Code != http.StatusBadRequest { t.Errorf("second use: expected 400 (code replay), got %d", w2.Code) } } // --------------------------------------------------------------------------- // KEY-WP-0005-T02: cached tenant_roles claim // --------------------------------------------------------------------------- func TestTokenHandler_TenantRoles_PresentWhenTenantEngineReachable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"tenant_id":"tenant:coulomb","roles":["IAM","VEN"]}`)) })) defer server.Close() sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) h.TenantEngine = tenantengine.New(server.URL, nil) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) claims := parseJWTPayload(t, resp["id_token"].(string)) roles, ok := claims["tenant_roles"].([]interface{}) if !ok || len(roles) != 2 || roles[0] != "IAM" || roles[1] != "VEN" { t.Errorf("tenant_roles: want [IAM VEN], got %v", claims["tenant_roles"]) } } func TestTokenHandler_TenantRoles_OmittedButIssuanceSucceedsWhenUnreachable(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) // Unreachable: nothing listens on this port. h.TenantEngine = tenantengine.New("http://127.0.0.1:1", nil) verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) // Token issuance itself must not fail just because the cache source is // down -- this is the literal point of KEY-WP-0005-T02's fail-open rule. if w.Code != http.StatusOK { t.Fatalf("expected 200 even with tenant-engine unreachable, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) claims := parseJWTPayload(t, resp["id_token"].(string)) if _, present := claims["tenant_roles"]; present { t.Errorf("tenant_roles must be omitted (not null, not []), got %v", claims["tenant_roles"]) } // Every other core claim must still be present -- one missing optional // claim must not cascade into missing required ones. for _, c := range []string{"tenant", "principal_type", "groups", "roles", "assurance"} { if _, ok := claims[c]; !ok { t.Errorf("required claim %q missing even though only tenant_roles should be affected", c) } } } func TestTokenHandler_TenantRoles_OmittedWhenTenantEngineNotConfigured(t *testing.T) { sessions := oidc.NewSessionStore() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} h, _ := newTokenHandler(t, sessions, users) // h.TenantEngine left nil -- must not panic, must simply omit the claim. verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" code := seededSession(sessions, verifier) params := url.Values{ "grant_type": []string{"authorization_code"}, "code": []string{code}, "client_id": []string{"test-client"}, "redirect_uri": []string{"https://app.example.com/callback"}, "code_verifier": []string{verifier}, } req := tokenRequest(params) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } resp := decodeTokenResponse(t, w.Body.String()) claims := parseJWTPayload(t, resp["id_token"].(string)) if _, present := claims["tenant_roles"]; present { t.Errorf("tenant_roles must be omitted when TenantEngine is nil, got %v", claims["tenant_roles"]) } }