Files
gochat/backend/internal/auth/oidc_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

1525 lines
48 KiB
Go

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)
}