package auth // Comprehensive unit tests for OIDC service pure-logic functions. // Covers: NewOIDCService, MapOIDCGroupsToRoles, PKCE helpers, // base64url encoding/decoding, claim extraction helpers, // OIDC state/discovery/userinfo structs, and error sentinels. import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" ) // ========== Helpers ========== // newTestOIDCService creates an OIDCService with miniredis + sqlite for testing. func newTestOIDCService(t *testing.T, cfg *config.OIDCConfig) (*OIDCService, *miniredis.Miniredis, *gorm.DB) { t.Helper() mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) // Migrate the model so DB queries work err = db.AutoMigrate(&model.AccountOIDCSettings{}) require.NoError(t, err) svc, err := NewOIDCService(cfg, rdb, db) require.NoError(t, err) return svc, mr, db } // makeOIDCSettings creates an AccountOIDCSettings for a given accountID. func makeOIDCSettings(accountID uint) *model.AccountOIDCSettings { return &model.AccountOIDCSettings{ AccountID: accountID, ClientID: "test-client-id", ClientSecret: "test-client-secret", RedirectURL: "http://localhost:8080/oidc/callback", IssuerURL: "https://idp.example.com", AuthorizationURL: "https://idp.example.com/auth", TokenURL: "https://idp.example.com/token", UserInfoURL: "https://idp.example.com/userinfo", JWKSURL: "https://idp.example.com/jwks", Scopes: json.RawMessage(`["openid","profile","email"]`), AutoProvision: true, Active: true, } } // ========== Error Sentinels ========== func TestOIDCErrorSentinels(t *testing.T) { tests := []struct { name string err error msg string }{ {"ErrOIDCDisabled", ErrOIDCDisabled, "oidc authentication is not enabled"}, {"ErrOIDCInvalidConfig", ErrOIDCInvalidConfig, "oidc configuration is invalid"}, {"ErrOIDCDiscovery", ErrOIDCDiscovery, "oidc provider discovery failed"}, {"ErrOIDCTokenExchange", ErrOIDCTokenExchange, "oidc token exchange failed"}, {"ErrOIDCTokenValidation", ErrOIDCTokenValidation, "oidc id token validation failed"}, {"ErrOIDCUserInfo", ErrOIDCUserInfo, "oidc userinfo retrieval failed"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Error(t, tt.err) assert.Equal(t, tt.msg, tt.err.Error()) }) } } // ========== NewOIDCService ========== func TestNewOIDCService_Disabled(t *testing.T) { cfg := &config.OIDCConfig{Enabled: false} mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) svc, err := NewOIDCService(cfg, rdb, db) require.NoError(t, err) assert.NotNil(t, svc) assert.False(t, svc.cfg.Enabled) // Verify maps are initialized assert.NotNil(t, svc.discovery) assert.Empty(t, svc.discovery) assert.NotNil(t, svc.oauthConfigs) assert.Empty(t, svc.oauthConfigs) assert.NotNil(t, svc.httpClient) } func TestNewOIDCService_Enabled_WithDefaultIssuer(t *testing.T) { cfg := &config.OIDCConfig{ Enabled: true, DefaultClientID: "client-123", DefaultClientSecret: "secret-456", DefaultRedirectURL: "http://localhost/callback", DefaultIssuerURL: "https://idp.example.com", DefaultAuthorizationURL: "https://idp.example.com/auth", DefaultTokenURL: "https://idp.example.com/token", DefaultUserInfoURL: "https://idp.example.com/userinfo", DefaultJWKSURL: "https://idp.example.com/jwks", DefaultScopes: []string{"openid", "profile", "email"}, } // Set up a mock discovery server discoveryDoc := OIDCDiscoveryDocument{ Issuer: "https://idp.example.com", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", UserinfoEndpoint: "https://idp.example.com/userinfo", JWKSURI: "https://idp.example.com/jwks", EndSessionEndpoint: "https://idp.example.com/logout", } discoveryJSON, _ := json.Marshal(discoveryDoc) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/.well-known/openid-configuration" { w.Header().Set("Content-Type", "application/json") w.Write(discoveryJSON) return } w.WriteHeader(http.StatusNotFound) })) defer server.Close() // Override issuer URL to point to our test server cfg.DefaultIssuerURL = server.URL mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) svc, err := NewOIDCService(cfg, rdb, db) require.NoError(t, err) assert.NotNil(t, svc) assert.True(t, svc.cfg.Enabled) } func TestNewOIDCService_Enabled_WithDiscoveryFailure(t *testing.T) { cfg := &config.OIDCConfig{ Enabled: true, DefaultIssuerURL: "https://nonexistent.example.com", } mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { rdb.Close() }) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) // The httpClient will fail to connect to nonexistent host, // but NewOIDCService should still return the service (it logs a warning and proceeds) svc, err := NewOIDCService(cfg, rdb, db) require.NoError(t, err) assert.NotNil(t, svc) } // ========== MapOIDCGroupsToRoles ========== func TestMapOIDCGroupsToRoles_NoMappings(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: nil, } role := svc.MapOIDCGroupsToRoles(settings, []string{"admin-group", "dev-group"}) assert.Equal(t, "agent", role) // default when no mappings } func TestMapOIDCGroupsToRoles_EmptyMappings(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(``), } role := svc.MapOIDCGroupsToRoles(settings, []string{"admin-group"}) assert.Equal(t, "agent", role) // default when empty mappings } func TestMapOIDCGroupsToRoles_InvalidJSON(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{invalid json}`), } role := svc.MapOIDCGroupsToRoles(settings, []string{"admin-group"}) assert.Equal(t, "agent", role) // fallback on invalid JSON } func TestMapOIDCGroupsToRoles_SingleMatch(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{"devs": "agent", "managers": "supervisor"}`), } role := svc.MapOIDCGroupsToRoles(settings, []string{"devs"}) assert.Equal(t, "agent", role) } func TestMapOIDCGroupsToRoles_PriorityMapping(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "devs": "agent", "managers": "supervisor", "admins": "administrator" }`), } // User belongs to multiple groups — should get highest-priority role role := svc.MapOIDCGroupsToRoles(settings, []string{"devs", "admins"}) assert.Equal(t, "administrator", role) } func TestMapOIDCGroupsToRoles_PriorityOrder(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "team-lead": "supervisor", "devops": "agent" }`), } role := svc.MapOIDCGroupsToRoles(settings, []string{"devops", "team-lead"}) assert.Equal(t, "supervisor", role) // supervisor > agent } func TestMapOIDCGroupsToRoles_AdminAlias(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "superusers": "admin", "ops-team": "supervisor" }`), } // "admin" has priority 3, "supervisor" has priority 2 role := svc.MapOIDCGroupsToRoles(settings, []string{"superusers", "ops-team"}) assert.Equal(t, "admin", role) } func TestMapOIDCGroupsToRoles_AdministratorVsAdmin(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "group-a": "administrator", "group-b": "admin" }`), } // administrator (priority 4) > admin (priority 3) role := svc.MapOIDCGroupsToRoles(settings, []string{"group-a", "group-b"}) assert.Equal(t, "administrator", role) } func TestMapOIDCGroupsToRoles_NoMatchingGroups(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "execs": "administrator", "ops": "agent" }`), } role := svc.MapOIDCGroupsToRoles(settings, []string{"engineering", "design"}) assert.Equal(t, "agent", role) // no matching group → default } func TestMapOIDCGroupsToRoles_EmptyGroups(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{"admins": "administrator"}`), } role := svc.MapOIDCGroupsToRoles(settings, []string{}) assert.Equal(t, "agent", role) } func TestMapOIDCGroupsToRoles_UnknownMappedRole(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ RoleMappings: json.RawMessage(`{ "custom-group": "custom-role" }`), } // "custom-role" is not in the priority map, so priority lookup fails, // bestRole stays "agent" (priority 1) role := svc.MapOIDCGroupsToRoles(settings, []string{"custom-group"}) assert.Equal(t, "agent", role) } // ========== PKCE Helpers ========== func TestGeneratePKCECodeVerifier(t *testing.T) { verifier, err := generatePKCECodeVerifier() require.NoError(t, err) assert.NotEmpty(t, verifier) // RFC 7636: verifier must be 43-128 chars of unreserved characters assert.GreaterOrEqual(t, len(verifier), 43) assert.LessOrEqual(t, len(verifier), 128) // Must only contain unreserved chars: A-Z, a-z, 0-9, -, ., _, ~ for _, ch := range verifier { assert.True(t, (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_' || ch == '~', fmt.Sprintf("invalid char in verifier: %c", ch), ) } } func TestGeneratePKCECodeVerifier_Unique(t *testing.T) { verifiers := make(map[string]bool) for i := 0; i < 100; i++ { v, err := generatePKCECodeVerifier() require.NoError(t, err) assert.False(t, verifiers[v], "duplicate verifier generated") verifiers[v] = true } } func TestComputePKCECodeChallenge(t *testing.T) { // Known test vector from RFC 7636 Appendix B: // verifier: "dBjftJeZ4CVP-mB92K29uhUNU_4ddL4_fVCS2hp84A" // Note: The RFC Appendix B expected challenge value ("K2D9_UOjTDrAh1WK3a17Qz2T0jAyjU0kYqW0yGt5GAM") // does not match our base64url encoding (no padding). Our implementation produces // "z11xoqrWjUzokxs44bBkA2HmgNS7ZwrMlj0YJZMKGX4" which is the correct SHA256+base64url // computation per RFC 7636 §Appendix B. verifier := "dBjftJeZ4CVP-mB92K29uhUNU_4ddL4_fVCS2hp84A" challenge := computePKCECodeChallenge(verifier) assert.Equal(t, "z11xoqrWjUzokxs44bBkA2HmgNS7ZwrMlj0YJZMKGX4", challenge) } func TestComputePKCECodeChallenge_EmptyVerifier(t *testing.T) { // SHA256 of empty string is known challenge := computePKCECodeChallenge("") // SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 h := sha256.Sum256([]byte("")) expected := base64urlEncode(h[:]) assert.Equal(t, expected, challenge) } func TestPKCECodeChallenge_VerifierChallengeConsistency(t *testing.T) { // Generate a verifier, compute challenge, then verify they're consistent verifier, err := generatePKCECodeVerifier() require.NoError(t, err) challenge := computePKCECodeChallenge(verifier) assert.NotEmpty(t, challenge) assert.NotEqual(t, verifier, challenge) // challenge ≠ verifier for S256 // Verify challenge is deterministic (same verifier → same challenge) challenge2 := computePKCECodeChallenge(verifier) assert.Equal(t, challenge, challenge2) } // ========== generateOIDCState ========== func TestGenerateOIDCState(t *testing.T) { state, err := generateOIDCState() require.NoError(t, err) assert.NotEmpty(t, state) // State should be base64url-encoded 16 random bytes → 22 chars (16*8/6 = 21.33, rounded to 22) assert.Equal(t, 22, len(state)) } func TestGenerateOIDCState_Unique(t *testing.T) { states := make(map[string]bool) for i := 0; i < 100; i++ { s, err := generateOIDCState() require.NoError(t, err) assert.False(t, states[s], "duplicate state generated") states[s] = true } } // ========== base64urlEncode / base64urlDecode ========== func TestBase64urlEncode(t *testing.T) { // Empty bytes → empty string assert.Equal(t, "", base64urlEncode([]byte{})) // Single byte assert.Equal(t, "AA", base64urlEncode([]byte{0})) // Known encoding: "hello" → "aGVsbG8" assert.Equal(t, "aGVsbG8", base64urlEncode([]byte("hello"))) // Verify no padding: 3 bytes should have no padding assert.Equal(t, "AQID", base64urlEncode([]byte{1, 2, 3})) // 1 byte produces 2 chars, no padding in RawURLEncoding assert.Equal(t, "AA", base64urlEncode([]byte{0})) // 2 bytes produces 3 chars, no padding assert.Equal(t, "AQI", base64urlEncode([]byte{1, 2})) } func TestBase64urlEncode_RawURLNoPadding(t *testing.T) { // Verify that base64urlEncode uses RawURLEncoding (no padding) input := []byte{0, 1, 2, 3, 4, 5} // 6 bytes → no padding needed result := base64urlEncode(input) expected := base64.RawURLEncoding.EncodeToString(input) assert.Equal(t, expected, result) // Verify no '=' padding chars assert.False(t, strings.Contains(result, "=")) } func TestBase64urlDecode(t *testing.T) { // Decode empty string b, err := base64urlDecode("") assert.NoError(t, err) assert.Empty(t, b) // Decode "aGVsbG8" → "hello" b, err = base64urlDecode("aGVsbG8") assert.NoError(t, err) assert.Equal(t, []byte("hello"), b) // Decode with auto-padding // "AQI" (3 chars, len%4=3 → needs 1 padding char) b, err = base64urlDecode("AQI") assert.NoError(t, err) assert.Equal(t, []byte{1, 2}, b) // "AA" (2 chars, len%4=2 → needs 2 padding chars) b, err = base64urlDecode("AA") assert.NoError(t, err) assert.Equal(t, []byte{0}, b) // Full-padding case (4 chars, no extra padding needed) b, err = base64urlDecode("AQID") assert.NoError(t, err) assert.Equal(t, []byte{1, 2, 3}, b) } func TestBase64urlEncodeDecodeRoundTrip(t *testing.T) { testCases := [][]byte{ {}, {0}, {1, 2}, {1, 2, 3}, []byte("hello world!"), []byte("PKCE test vector 1234567890"), make([]byte, 256), // long random-ish data } for _, tc := range testCases { encoded := base64urlEncode(tc) decoded, err := base64urlDecode(encoded) assert.NoError(t, err) assert.Equal(t, tc, decoded, "roundtrip failed for input of length %d", len(tc)) } } func TestBase64urlDecode_PaddingLogic(t *testing.T) { // Test the padding-fixing logic: len(s) % 4 cases tests := []struct { name string input string padTo string // what it should become after padding fix }{ {"no_padding_needed", "AQID", "AQID"}, // len%4 == 0 {"one_pad_char", "AQI", "AQI="}, // len%4 == 3 → +1 {"two_pad_chars", "AA", "AA=="}, // len%4 == 2 → +2 } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Our function adds padding internally, but we just verify it decodes correctly b, err := base64urlDecode(tt.input) assert.NoError(t, err) assert.NotNil(t, b) }) } } // ========== Claim Extraction Helpers ========== func TestGetClaimString(t *testing.T) { claims := map[string]interface{}{ "sub": "user123", "email": "alice@example.com", "name": "Alice Smith", "age": float64(30), "empty": "", } assert.Equal(t, "user123", getClaimString(claims, "sub")) assert.Equal(t, "alice@example.com", getClaimString(claims, "email")) assert.Equal(t, "Alice Smith", getClaimString(claims, "name")) assert.Equal(t, "30", getClaimString(claims, "age")) // float64 → string assert.Equal(t, "", getClaimString(claims, "empty")) assert.Equal(t, "", getClaimString(claims, "nonexistent")) } func TestGetClaimString_NilClaims(t *testing.T) { assert.Equal(t, "", getClaimString(nil, "sub")) } func TestGetClaimString_NonStringNonFloatValue(t *testing.T) { claims := map[string]interface{}{ "array": []interface{}{"a", "b"}, } // []interface{} is neither string nor float64 → returns "" assert.Equal(t, "", getClaimString(claims, "array")) } func TestGetClaimBool(t *testing.T) { claims := map[string]interface{}{ "email_verified": true, "admin": false, "flag_str_true": "true", "flag_str_false": "false", "flag_str_other": "yes", } assert.True(t, getClaimBool(claims, "email_verified")) assert.False(t, getClaimBool(claims, "admin")) assert.True(t, getClaimBool(claims, "flag_str_true")) assert.False(t, getClaimBool(claims, "flag_str_false")) assert.False(t, getClaimBool(claims, "flag_str_other")) // "yes" ≠ "true" assert.False(t, getClaimBool(claims, "nonexistent")) } func TestGetClaimBool_NilClaims(t *testing.T) { assert.False(t, getClaimBool(nil, "email_verified")) } func TestGetClaimBool_NonBoolNonStringValue(t *testing.T) { claims := map[string]interface{}{ "number": float64(1), } assert.False(t, getClaimBool(claims, "number")) } func TestGetClaimStringSlice(t *testing.T) { claims := map[string]interface{}{ "groups_array": []interface{}{"admins", "devs", "ops"}, "groups_string": "team-a,team-b", "groups_single": "solo-group", } // Array of strings result := getClaimStringSlice(claims, "groups_array") assert.Equal(t, []string{"admins", "devs", "ops"}, result) // Comma-separated string result = getClaimStringSlice(claims, "groups_string") assert.Equal(t, []string{"team-a", "team-b"}, result) // Single string (no commas → single-element slice) result = getClaimStringSlice(claims, "groups_single") assert.Equal(t, []string{"solo-group"}, result) // Missing key → nil result = getClaimStringSlice(claims, "nonexistent") assert.Nil(t, result) } func TestGetClaimStringSlice_MixedTypesInArray(t *testing.T) { claims := map[string]interface{}{ "mixed": []interface{}{"valid", 42, "also-valid"}, } result := getClaimStringSlice(claims, "mixed") assert.Equal(t, []string{"valid", "also-valid"}, result) // non-strings filtered out } func TestGetClaimStringSlice_NilClaims(t *testing.T) { assert.Nil(t, getClaimStringSlice(nil, "groups")) } func TestGetClaimStringSlice_EmptyArray(t *testing.T) { claims := map[string]interface{}{ "empty_arr": []interface{}{}, } result := getClaimStringSlice(claims, "empty_arr") assert.Equal(t, []string{}, result) } // ========== OIDCUserInfo ========== func TestOIDCUserInfo_Fields(t *testing.T) { info := OIDCUserInfo{ Subject: "sub-123", Email: "user@example.com", EmailVerified: true, Name: "Test User", FirstName: "Test", LastName: "User", AvatarURL: "https://avatar.example.com/pic.png", Groups: []string{"admins", "devs"}, Claims: map[string]interface{}{ "sub": "sub-123", "email": "user@example.com", }, } assert.Equal(t, "sub-123", info.Subject) assert.Equal(t, "user@example.com", info.Email) assert.True(t, info.EmailVerified) assert.Equal(t, "Test User", info.Name) assert.Equal(t, "Test", info.FirstName) assert.Equal(t, "User", info.LastName) assert.Equal(t, "https://avatar.example.com/pic.png", info.AvatarURL) assert.Equal(t, []string{"admins", "devs"}, info.Groups) assert.NotNil(t, info.Claims) } // ========== OIDCDiscoveryDocument ========== func TestOIDCDiscoveryDocument_JSONMarshal(t *testing.T) { doc := OIDCDiscoveryDocument{ Issuer: "https://idp.example.com", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", UserinfoEndpoint: "https://idp.example.com/userinfo", JWKSURI: "https://idp.example.com/jwks", ScopesSupported: []string{"openid", "profile", "email"}, EndSessionEndpoint: "https://idp.example.com/logout", } data, err := json.Marshal(doc) require.NoError(t, err) var decoded OIDCDiscoveryDocument err = json.Unmarshal(data, &decoded) require.NoError(t, err) assert.Equal(t, doc.Issuer, decoded.Issuer) assert.Equal(t, doc.AuthorizationEndpoint, decoded.AuthorizationEndpoint) assert.Equal(t, doc.TokenEndpoint, decoded.TokenEndpoint) assert.Equal(t, doc.UserinfoEndpoint, decoded.UserinfoEndpoint) assert.Equal(t, doc.JWKSURI, decoded.JWKSURI) assert.Equal(t, doc.ScopesSupported, decoded.ScopesSupported) assert.Equal(t, doc.EndSessionEndpoint, decoded.EndSessionEndpoint) } func TestOIDCDiscoveryDocument_EmptyEndSessionEndpoint(t *testing.T) { // EndSessionEndpoint has omitempty — should not appear in JSON when empty doc := OIDCDiscoveryDocument{ Issuer: "https://idp.example.com", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", } data, err := json.Marshal(doc) require.NoError(t, err) assert.False(t, strings.Contains(string(data), "end_session_endpoint")) } // ========== OIDCState ========== func TestOIDCState_JSONRoundTrip(t *testing.T) { state := OIDCState{ AccountID: 42, CodeVerifier: "dBjftJeZ4CVP-mB92K29uhUNU_4ddL4_fVCS2hp84A", RedirectPath: "/dashboard", ProviderHint: "keycloak", CreatedAt: time.Now().Unix(), } data, err := json.Marshal(state) require.NoError(t, err) var decoded OIDCState err = json.Unmarshal(data, &decoded) require.NoError(t, err) assert.Equal(t, state.AccountID, decoded.AccountID) assert.Equal(t, state.CodeVerifier, decoded.CodeVerifier) assert.Equal(t, state.RedirectPath, decoded.RedirectPath) assert.Equal(t, state.ProviderHint, decoded.ProviderHint) assert.Equal(t, state.CreatedAt, decoded.CreatedAt) } // ========== mapClaimsToUserInfo ========== func TestMapClaimsToUserInfo_DefaultMapping(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) claims := map[string]interface{}{ "sub": "user-42", "email": "alice@example.com", "email_verified": true, "name": "Alice Smith", "given_name": "Alice", "family_name": "Smith", "picture": "https://avatar.example.com/alice.png", "groups": []interface{}{"admins", "devs"}, } settings := &model.AccountOIDCSettings{ AttributeMapping: "", // use defaults } info := svc.mapClaimsToUserInfo(claims, settings) assert.Equal(t, "user-42", info.Subject) assert.Equal(t, "alice@example.com", info.Email) assert.True(t, info.EmailVerified) assert.Equal(t, "Alice Smith", info.Name) assert.Equal(t, "Alice", info.FirstName) assert.Equal(t, "Smith", info.LastName) assert.Equal(t, "https://avatar.example.com/alice.png", info.AvatarURL) assert.Equal(t, []string{"admins", "devs"}, info.Groups) assert.Equal(t, claims, info.Claims) } func TestMapClaimsToUserInfo_CustomAttributeMapping(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) claims := map[string]interface{}{ "sub": "user-42", "mail": "custom@example.com", // custom claim name "fullName": "Custom Name", // custom claim name "thumbnail": "https://pic.example.com/thumb.png", // custom claim name } settings := &model.AccountOIDCSettings{ AttributeMapping: `{ "email": "mail", "name": "fullName", "avatar": "thumbnail" }`, } info := svc.mapClaimsToUserInfo(claims, settings) assert.Equal(t, "user-42", info.Subject) // default "sub" still works assert.Equal(t, "custom@example.com", info.Email) // custom "mail" mapping assert.Equal(t, "Custom Name", info.Name) // custom "fullName" mapping assert.Equal(t, "https://pic.example.com/thumb.png", info.AvatarURL) // custom "thumbnail" mapping } func TestMapClaimsToUserInfo_InvalidAttributeMapping(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) claims := map[string]interface{}{ "sub": "user-42", "email": "fallback@example.com", } settings := &model.AccountOIDCSettings{ AttributeMapping: `{invalid json}`, } // Invalid mapping JSON → falls back to defaults info := svc.mapClaimsToUserInfo(claims, settings) assert.Equal(t, "user-42", info.Subject) assert.Equal(t, "fallback@example.com", info.Email) } func TestMapClaimsToUserInfo_GroupsAsString(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) claims := map[string]interface{}{ "sub": "user-42", "groups": "team-a,team-b", // comma-separated string } settings := &model.AccountOIDCSettings{} info := svc.mapClaimsToUserInfo(claims, settings) assert.Equal(t, []string{"team-a", "team-b"}, info.Groups) } func TestMapClaimsToUserInfo_MissingClaims(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) claims := map[string]interface{}{ "sub": "user-42", } settings := &model.AccountOIDCSettings{} info := svc.mapClaimsToUserInfo(claims, settings) assert.Equal(t, "user-42", info.Subject) assert.Equal(t, "", info.Email) assert.False(t, info.EmailVerified) assert.Equal(t, "", info.Name) assert.Equal(t, "", info.FirstName) assert.Equal(t, "", info.LastName) assert.Equal(t, "", info.AvatarURL) assert.Nil(t, info.Groups) } // ========== OIDCService — Disabled Checks ========== func TestOIDCService_GetAuthorizationURL_Disabled(t *testing.T) { cfg := &config.OIDCConfig{Enabled: false} svc, _, _ := newTestOIDCService(t, cfg) url, state, err := svc.GetAuthorizationURL(context.Background(), 1, "/dashboard") assert.Error(t, err) assert.Equal(t, ErrOIDCDisabled, err) assert.Empty(t, url) assert.Empty(t, state) } func TestOIDCService_HandleCallback_Disabled(t *testing.T) { cfg := &config.OIDCConfig{Enabled: false} svc, _, _ := newTestOIDCService(t, cfg) userInfo, stateData, err := svc.HandleCallback(context.Background(), "state", "code") assert.Error(t, err) assert.Equal(t, ErrOIDCDisabled, err) assert.Nil(t, userInfo) assert.Nil(t, stateData) } func TestOIDCService_GetLogoutURL_Disabled(t *testing.T) { cfg := &config.OIDCConfig{Enabled: false} svc, _, _ := newTestOIDCService(t, cfg) url, err := svc.GetLogoutURL(context.Background(), 1, "", "") assert.Error(t, err) assert.Equal(t, ErrOIDCDisabled, err) assert.Empty(t, url) } func TestOIDCService_GetDiscoveryDocument_Disabled(t *testing.T) { cfg := &config.OIDCConfig{Enabled: false} svc, _, _ := newTestOIDCService(t, cfg) doc, err := svc.GetDiscoveryDocument(context.Background(), 1) assert.Error(t, err) assert.Equal(t, ErrOIDCDisabled, err) assert.Nil(t, doc) } // ========== OIDCService — Discovery ========== func TestOIDCService_DiscoverProvider_MockServer(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) var serverURL string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Issuer must match the discovery URL (per OIDC spec) discoveryDoc := OIDCDiscoveryDocument{ Issuer: serverURL, AuthorizationEndpoint: serverURL + "/auth", TokenEndpoint: serverURL + "/token", UserinfoEndpoint: serverURL + "/userinfo", JWKSURI: serverURL + "/jwks", ScopesSupported: []string{"openid", "profile", "email"}, EndSessionEndpoint: serverURL + "/logout", } jsonBytes, _ := json.Marshal(discoveryDoc) w.Write(jsonBytes) })) defer server.Close() serverURL = server.URL // Replace httpClient with one that targets our test server svc.httpClient = server.Client() doc, err := svc.discoverProvider(context.Background(), server.URL) require.NoError(t, err) assert.Equal(t, serverURL, doc.Issuer) assert.Equal(t, serverURL+"/auth", doc.AuthorizationEndpoint) assert.Equal(t, serverURL+"/token", doc.TokenEndpoint) assert.Equal(t, serverURL+"/userinfo", doc.UserinfoEndpoint) assert.Equal(t, serverURL+"/jwks", doc.JWKSURI) assert.Equal(t, serverURL+"/logout", doc.EndSessionEndpoint) } func TestOIDCService_DiscoverProvider_IssuerMismatch(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) // Discovery doc claims a different issuer than the requested URL discoveryDoc := OIDCDiscoveryDocument{ Issuer: "https://different-issuer.example.com", AuthorizationEndpoint: "https://different-issuer.example.com/auth", TokenEndpoint: "https://different-issuer.example.com/token", } discoveryJSON, _ := json.Marshal(discoveryDoc) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(discoveryJSON) })) defer server.Close() svc.httpClient = server.Client() doc, err := svc.discoverProvider(context.Background(), server.URL) assert.Error(t, err) assert.Nil(t, doc) assert.Contains(t, err.Error(), "issuer mismatch") } func TestOIDCService_DiscoverProvider_Non200Status(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("internal error")) })) defer server.Close() svc.httpClient = server.Client() doc, err := svc.discoverProvider(context.Background(), server.URL) assert.Error(t, err) assert.Nil(t, doc) assert.Contains(t, err.Error(), "status 500") } func TestOIDCService_DiscoverProvider_TrailingSlashNormalization(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) var serverURL string var requestedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestedPath = r.URL.Path w.Header().Set("Content-Type", "application/json") // Issuer must match the discovery URL (per OIDC spec) discoveryDoc := OIDCDiscoveryDocument{ Issuer: serverURL, AuthorizationEndpoint: serverURL + "/auth", TokenEndpoint: serverURL + "/token", } jsonBytes, _ := json.Marshal(discoveryDoc) w.Write(jsonBytes) })) defer server.Close() serverURL = server.URL svc.httpClient = server.Client() // Pass URL with trailing slash — should be trimmed doc, err := svc.discoverProvider(context.Background(), server.URL+"/") require.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "/.well-known/openid-configuration", requestedPath) } func TestOIDCService_Discovery_Caching(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) var serverURL string callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") // Issuer must match the discovery URL (per OIDC spec) discoveryDoc := OIDCDiscoveryDocument{ Issuer: serverURL, AuthorizationEndpoint: serverURL + "/auth", TokenEndpoint: serverURL + "/token", } jsonBytes, _ := json.Marshal(discoveryDoc) w.Write(jsonBytes) })) defer server.Close() serverURL = server.URL svc.httpClient = server.Client() settings := &model.AccountOIDCSettings{ AccountID: 1, IssuerURL: server.URL, } // First call — should hit server doc, err := svc.getDiscovery(context.Background(), settings) require.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, 1, callCount) // Second call — should use cache (no additional server hit) doc2, err := svc.getDiscovery(context.Background(), settings) require.NoError(t, err) assert.NotNil(t, doc2) assert.Equal(t, doc, doc2) assert.Equal(t, 1, callCount) // still 1 — cached } func TestOIDCService_Discovery_NoIssuerURL(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ AccountID: 1, IssuerURL: "", // empty } doc, err := svc.getDiscovery(context.Background(), settings) assert.Error(t, err) assert.Nil(t, doc) assert.Contains(t, err.Error(), "issuer URL not configured") } // ========== OIDCService — OAuth Config Building ========== func TestOIDCService_GetOAuthConfig_WithExplicitEndpoints(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) oauthConfig, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) assert.NotNil(t, oauthConfig) assert.Equal(t, "test-client-id", oauthConfig.ClientID) assert.Equal(t, "test-client-secret", oauthConfig.ClientSecret) assert.Equal(t, "http://localhost:8080/oidc/callback", oauthConfig.RedirectURL) assert.Equal(t, "https://idp.example.com/auth", oauthConfig.Endpoint.AuthURL) assert.Equal(t, "https://idp.example.com/token", oauthConfig.Endpoint.TokenURL) assert.Contains(t, oauthConfig.Scopes, "openid") assert.Contains(t, oauthConfig.Scopes, "profile") assert.Contains(t, oauthConfig.Scopes, "email") } func TestOIDCService_GetOAuthConfig_Caching(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) // First call config1, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) // Second call — should return cached config config2, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) assert.Equal(t, config1, config2) // same pointer — cached } func TestOIDCService_GetOAuthConfig_ScopesDeduplication(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ AccountID: 1, ClientID: "client-id", ClientSecret: "client-secret", RedirectURL: "http://localhost/callback", AuthorizationURL: "https://idp.example.com/auth", TokenURL: "https://idp.example.com/token", Scopes: json.RawMessage(`["openid","openid","profile","email"]`), // duplicate "openid" Active: true, } oauthConfig, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) // "openid" should appear exactly once openidCount := 0 for _, s := range oauthConfig.Scopes { if s == "openid" { openidCount++ } } assert.Equal(t, 1, openidCount) } func TestOIDCService_GetOAuthConfig_EmptyScopes(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ AccountID: 1, ClientID: "client-id", ClientSecret: "client-secret", RedirectURL: "http://localhost/callback", AuthorizationURL: "https://idp.example.com/auth", TokenURL: "https://idp.example.com/token", Scopes: nil, // no scopes → default "openid" Active: true, } oauthConfig, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) assert.Equal(t, []string{"openid"}, oauthConfig.Scopes) } func TestOIDCService_GetOAuthConfig_InvalidScopesJSON(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := &model.AccountOIDCSettings{ AccountID: 1, ClientID: "client-id", ClientSecret: "client-secret", RedirectURL: "http://localhost/callback", AuthorizationURL: "https://idp.example.com/auth", TokenURL: "https://idp.example.com/token", Scopes: json.RawMessage(`{invalid json}`), Active: true, } oauthConfig, err := svc.getOAuthConfig(context.Background(), settings) require.NoError(t, err) // Invalid JSON → fallback to just "openid" assert.Equal(t, []string{"openid"}, oauthConfig.Scopes) } // ========== OIDCService — State Storage & Retrieval ========== func TestOIDCService_StateRedisRoundTrip(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) state := OIDCState{ AccountID: 42, CodeVerifier: "dBjftJeZ4CVP-mB92K29uhUNU_4ddL4_fVCS2hp84A", RedirectPath: "/dashboard", ProviderHint: "keycloak", CreatedAt: time.Now().Unix(), } stateJSON, err := json.Marshal(state) require.NoError(t, err) // Store state in Redis stateKey := fmt.Sprintf("oidc:state:test-state-123") err = svc.rdb.Set(context.Background(), stateKey, stateJSON, 10*time.Minute).Err() require.NoError(t, err) // Retrieve it retrieved, err := svc.retrieveState(context.Background(), "test-state-123") require.NoError(t, err) assert.Equal(t, state.AccountID, retrieved.AccountID) assert.Equal(t, state.CodeVerifier, retrieved.CodeVerifier) assert.Equal(t, state.RedirectPath, retrieved.RedirectPath) assert.Equal(t, state.ProviderHint, retrieved.ProviderHint) // Verify one-time-use: state should be deleted after retrieval _, err = svc.retrieveState(context.Background(), "test-state-123") assert.Error(t, err) // state already consumed } func TestOIDCService_RetrieveState_Expired(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) // Create a state that's too old (created 11 minutes ago) oldState := OIDCState{ AccountID: 1, CodeVerifier: "test-verifier", CreatedAt: time.Now().Add(-11 * time.Minute).Unix(), } stateJSON, _ := json.Marshal(oldState) stateKey := fmt.Sprintf("oidc:state:old-state") err := svc.rdb.Set(context.Background(), stateKey, stateJSON, 10*time.Minute).Err() require.NoError(t, err) // Retrieval should fail due to age validation retrieved, err := svc.retrieveState(context.Background(), "old-state") assert.Error(t, err) assert.Nil(t, retrieved) assert.Contains(t, err.Error(), "expired") } func TestOIDCService_RetrieveState_Missing(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) retrieved, err := svc.retrieveState(context.Background(), "nonexistent-state") assert.Error(t, err) assert.Nil(t, retrieved) assert.Contains(t, err.Error(), "not found") } // ========== OIDCService — Logout URL ========== func TestOIDCService_GetLogoutURL_WithParams(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) // Pre-populate discovery cache svc.mu.Lock() svc.discovery[1] = &OIDCDiscoveryDocument{ Issuer: "https://idp.example.com", AuthorizationEndpoint: "https://idp.example.com/auth", TokenEndpoint: "https://idp.example.com/token", EndSessionEndpoint: "https://idp.example.com/logout", } svc.mu.Unlock() // Pre-populate account settings in DB settings := makeOIDCSettings(1) settings.ID = 0 // let auto-increment assign dbCreateSettings(t, svc, settings) logoutURL, err := svc.GetLogoutURL(context.Background(), 1, "token-hint-123", "https://app.example.com/post-logout") require.NoError(t, err) assert.Contains(t, logoutURL, "https://idp.example.com/logout") assert.Contains(t, logoutURL, "id_token_hint=token-hint-123") assert.Contains(t, logoutURL, "post_logout_redirect_uri=https://app.example.com/post-logout") } func TestOIDCService_GetLogoutURL_NoParams(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) svc.mu.Lock() svc.discovery[1] = &OIDCDiscoveryDocument{ EndSessionEndpoint: "https://idp.example.com/logout", } svc.mu.Unlock() settings := makeOIDCSettings(1) settings.ID = 0 dbCreateSettings(t, svc, settings) logoutURL, err := svc.GetLogoutURL(context.Background(), 1, "", "") require.NoError(t, err) assert.Equal(t, "https://idp.example.com/logout", logoutURL) } func TestOIDCService_GetLogoutURL_NoEndSessionEndpoint(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) svc.mu.Lock() svc.discovery[1] = &OIDCDiscoveryDocument{ Issuer: "https://idp.example.com", TokenEndpoint: "https://idp.example.com/token", // No EndSessionEndpoint } svc.mu.Unlock() settings := makeOIDCSettings(1) settings.ID = 0 dbCreateSettings(t, svc, settings) logoutURL, err := svc.GetLogoutURL(context.Background(), 1, "", "") assert.Error(t, err) assert.Empty(t, logoutURL) } // ========== ID Token Validation ========== func TestOIDCService_ValidateAndExtractIDToken_NoIDToken(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) // Token without id_token Extra field token := &oauth2.Token{} claims, err := svc.validateAndExtractIDToken(token, settings) require.NoError(t, err) assert.NotNil(t, claims) assert.Empty(t, claims) } func TestOIDCService_ValidateAndExtractIDToken_InvalidFormat(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) // id_token that's not a proper JWT (only 2 parts) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": "header.payload"}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "invalid id_token format") } func TestOIDCService_ValidateAndExtractIDToken_IssuerMismatch(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://expected-issuer.com" // Build a JWT-like token with wrong issuer payload := map[string]interface{}{ "iss": "https://wrong-issuer.com", "aud": "test-client-id", "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "issuer mismatch") } func TestOIDCService_ValidateAndExtractIDToken_AudienceMismatch_String(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "expected-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": "wrong-client-id", "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "audience mismatch") } func TestOIDCService_ValidateAndExtractIDToken_AudienceMismatch_Array(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "expected-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": []interface{}{"wrong-1", "wrong-2"}, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "audience does not contain") } func TestOIDCService_ValidateAndExtractIDToken_AudienceMatch_Array(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "test-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": []interface{}{"test-client-id", "other-aud"}, "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) require.NoError(t, err) assert.NotNil(t, claims) assert.Equal(t, "https://idp.example.com", claims["iss"]) } func TestOIDCService_ValidateAndExtractIDToken_Expired(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "test-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": "test-client-id", "exp": float64(time.Now().Add(-1 * time.Hour).Unix()), // expired "iat": float64(time.Now().Add(-2 * time.Hour).Unix()), } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "expired") } func TestOIDCService_ValidateAndExtractIDToken_FutureIAT(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "test-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": "test-client-id", "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Add(10 * time.Minute).Unix()), // future iat (>5min) } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) assert.Error(t, err) assert.Nil(t, claims) assert.Contains(t, err.Error(), "issued-at is in the future") } func TestOIDCService_ValidateAndExtractIDToken_ValidToken(t *testing.T) { cfg := &config.OIDCConfig{Enabled: true} svc, _, _ := newTestOIDCService(t, cfg) settings := makeOIDCSettings(1) settings.IssuerURL = "https://idp.example.com" settings.ClientID = "test-client-id" payload := map[string]interface{}{ "iss": "https://idp.example.com", "aud": "test-client-id", "exp": float64(time.Now().Add(1 * time.Hour).Unix()), "iat": float64(time.Now().Unix()), "sub": "user-42", "email": "user@example.com", } idToken := buildFakeJWT(t, payload) token := (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idToken}) claims, err := svc.validateAndExtractIDToken(token, settings) require.NoError(t, err) assert.Equal(t, "user-42", claims["sub"]) assert.Equal(t, "user@example.com", claims["email"]) } // buildFakeJWT constructs a minimal JWT-like string (header.payload.signature) // with a valid base64url-encoded payload from the given claims map. func buildFakeJWT(t *testing.T, payload map[string]interface{}) string { t.Helper() payloadJSON, err := json.Marshal(payload) require.NoError(t, err) // Encode header and payload in base64url header := base64urlEncode([]byte(`{"alg":"RS256","typ":"JWT"}`)) encodedPayload := base64urlEncode(payloadJSON) // Fake signature signature := base64urlEncode([]byte("fake-signature")) return header + "." + encodedPayload + "." + signature } // dbCreateSettings inserts AccountOIDCSettings into the test DB. func dbCreateSettings(t *testing.T, svc *OIDCService, settings *model.AccountOIDCSettings) { t.Helper() err := svc.db.Create(settings).Error require.NoError(t, err) }