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.
1525 lines
48 KiB
Go
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)
|
|
}
|