使用 AES-256-GCM 保存认证中心令牌,并以随机 Cookie 哈希关联 PostgreSQL 会话。加入闲置与绝对期限、Refresh Token 轮换以及多实例刷新锁。
420 lines
16 KiB
Go
420 lines
16 KiB
Go
package oidcsession
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestServiceStoresOnlyHashedSessionAndEncryptedTokens(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access-token": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
service := newTestService(t, repository, verifier, &fakePublicClient{})
|
|
service.now = func() time.Time { return now }
|
|
localUser := &auth.User{ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222"}
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access-token", RefreshToken: "refresh-token", IDToken: "id-token"}, localUser)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(raw) != 43 {
|
|
t.Fatalf("opaque session length = %d, want 43", len(raw))
|
|
}
|
|
record := repository.snapshot()
|
|
if bytes.Contains(record.TokenCiphertext, []byte("access-token")) || bytes.Contains(record.TokenCiphertext, []byte("refresh-token")) {
|
|
t.Fatal("repository received plaintext token material")
|
|
}
|
|
hash, _ := sessionTokenHash(raw)
|
|
if !bytes.Equal(hash, record.SessionTokenHash) || bytes.Equal([]byte(raw), record.SessionTokenHash) {
|
|
t.Fatal("repository did not receive only the SHA-256 session hash")
|
|
}
|
|
user, err := service.Resolve(context.Background(), raw)
|
|
if err != nil || user.ID != "subject-1" {
|
|
t.Fatalf("resolve user=%#v err=%v", user, err)
|
|
}
|
|
}
|
|
|
|
func TestServiceConcurrentExpiredRequestsRefreshExactlyOnce(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"old-access": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
"new-access": {ID: "subject-1", Source: "oidc", Roles: []string{"user"}, TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new-access", RefreshToken: "rotated-refresh", ExpiresIn: 300}, delay: 40 * time.Millisecond}
|
|
service := newTestService(t, repository, verifier, client)
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "old-access", RefreshToken: "old-refresh"}, &auth.User{
|
|
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.expireAccessToken(now.Add(-time.Second))
|
|
|
|
var wait sync.WaitGroup
|
|
errorsFound := make(chan error, 20)
|
|
for range 20 {
|
|
wait.Add(1)
|
|
go func() {
|
|
defer wait.Done()
|
|
user, resolveErr := service.Resolve(context.Background(), raw)
|
|
if resolveErr != nil {
|
|
errorsFound <- resolveErr
|
|
return
|
|
}
|
|
if user.ID != "subject-1" {
|
|
errorsFound <- errors.New("wrong resolved subject")
|
|
}
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
close(errorsFound)
|
|
for err := range errorsFound {
|
|
t.Errorf("concurrent resolve: %v", err)
|
|
}
|
|
if got := client.refreshCalls.Load(); got != 1 {
|
|
t.Fatalf("refresh calls = %d, want exactly 1", got)
|
|
}
|
|
if repository.snapshot().RefreshVersion != 2 {
|
|
t.Fatalf("refresh version = %d, want 2", repository.snapshot().RefreshVersion)
|
|
}
|
|
}
|
|
|
|
func TestServiceDoesNotRefreshExpiredIdleSession(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)}}}
|
|
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new", RefreshToken: "rotated"}}
|
|
service := newTestService(t, repository, verifier, client)
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, &auth.User{
|
|
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111", GatewayTenantID: "22222222-2222-4222-8222-222222222222",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.expireIdle(now.Add(-time.Second))
|
|
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionExpired) {
|
|
t.Fatalf("Resolve() error = %v, want ErrSessionExpired", err)
|
|
}
|
|
if client.refreshCalls.Load() != 0 {
|
|
t.Fatal("expired idle session attempted a refresh")
|
|
}
|
|
}
|
|
|
|
func TestServiceKeepsExistingRefreshTokenWhenIssuerDoesNotRotate(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"old-access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
"new-access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
client := &fakePublicClient{refreshResponse: auth.OIDCTokenResponse{AccessToken: "new-access"}}
|
|
service := newTestService(t, repository, verifier, client)
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "old-access", RefreshToken: "existing-refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.expireAccessToken(now.Add(-time.Second))
|
|
if _, err := service.Resolve(context.Background(), raw); err != nil {
|
|
t.Fatalf("Resolve() error = %v", err)
|
|
}
|
|
bundle, err := service.Delete(context.Background(), raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bundle.RefreshToken != "existing-refresh" || bundle.AccessToken != "new-access" {
|
|
t.Fatal("refreshed bundle did not retain the existing refresh token")
|
|
}
|
|
}
|
|
|
|
func TestServiceInvalidGrantDeletesSession(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
client := &fakePublicClient{refreshError: auth.ErrOIDCInvalidGrant}
|
|
service := newTestService(t, repository, verifier, client)
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "revoked-refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.expireAccessToken(now.Add(-time.Second))
|
|
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionExpired) {
|
|
t.Fatalf("Resolve() error = %v, want ErrSessionExpired", err)
|
|
}
|
|
if !repository.isDeleted() || client.refreshCalls.Load() != 1 {
|
|
t.Fatal("invalid_grant did not delete the session after exactly one refresh")
|
|
}
|
|
}
|
|
|
|
func TestServiceUsesStillValidAccessTokenWhenRefreshIsTemporarilyUnavailable(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(30 * time.Second)},
|
|
}}
|
|
client := &fakePublicClient{refreshError: context.DeadlineExceeded}
|
|
service := newTestService(t, repository, verifier, client)
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := service.Resolve(context.Background(), raw)
|
|
if err != nil || user.ID != "subject-1" {
|
|
t.Fatalf("Resolve() user=%#v error=%v", user, err)
|
|
}
|
|
if client.refreshCalls.Load() != 1 || repository.isDeleted() {
|
|
t.Fatal("temporary refresh failure did not fall back to the valid access token")
|
|
}
|
|
}
|
|
|
|
func TestServiceReturnsUnavailableWhenExpiredTokenCannotRefresh(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
service := newTestService(t, repository, verifier, &fakePublicClient{refreshError: context.DeadlineExceeded})
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.expireAccessToken(now.Add(-time.Second))
|
|
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionRefreshUnavailable) {
|
|
t.Fatalf("Resolve() error = %v, want ErrSessionRefreshUnavailable", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsWrongEncryptionKeyAndDisabledUser(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
service := newTestService(t, repository, verifier, &fakePublicClient{})
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrongCipher, err := NewCipher(bytes.Repeat([]byte{8}, 32))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrongKeyService, err := NewService(repository, wrongCipher, verifier, &fakePublicClient{}, Config{
|
|
IdleTTL: 30 * time.Minute, AbsoluteTTL: 8 * time.Hour, RefreshBefore: time.Minute,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrongKeyService.now = func() time.Time { return now }
|
|
if _, err := wrongKeyService.Resolve(context.Background(), raw); !errors.Is(err, ErrSessionStoreUnavailable) {
|
|
t.Fatalf("wrong-key Resolve() error = %v, want ErrSessionStoreUnavailable", err)
|
|
}
|
|
|
|
repository.disableUser()
|
|
if _, err := service.Resolve(context.Background(), raw); !errors.Is(err, ErrGatewayUserDisabled) {
|
|
t.Fatalf("disabled-user Resolve() error = %v, want ErrGatewayUserDisabled", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceDeletesSessionEvenWhenCiphertextCannotBeDecryptedDuringLogout(t *testing.T) {
|
|
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
|
repository := newFakeRepository("subject-1")
|
|
verifier := fakeVerifier{users: map[string]*auth.User{
|
|
"access": {ID: "subject-1", Source: "oidc", TokenExpiresAt: now.Add(5 * time.Minute)},
|
|
}}
|
|
service := newTestService(t, repository, verifier, &fakePublicClient{})
|
|
service.now = func() time.Time { return now }
|
|
raw, err := service.Create(context.Background(), TokenBundle{AccessToken: "access", RefreshToken: "refresh"}, testLocalUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repository.corruptCiphertext()
|
|
if _, err := service.Delete(context.Background(), raw); err != nil {
|
|
t.Fatalf("Delete() error = %v", err)
|
|
}
|
|
if !repository.isDeleted() {
|
|
t.Fatal("logout left a session with unusable ciphertext in the store")
|
|
}
|
|
}
|
|
|
|
func testLocalUser() *auth.User {
|
|
return &auth.User{
|
|
ID: "subject-1", GatewayUserID: "11111111-1111-4111-8111-111111111111",
|
|
GatewayTenantID: "22222222-2222-4222-8222-222222222222",
|
|
}
|
|
}
|
|
|
|
func newTestService(t *testing.T, repository Repository, verifier TokenVerifier, client PublicClient) *Service {
|
|
t.Helper()
|
|
cipher, err := NewCipher(bytes.Repeat([]byte{7}, 32))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(repository, cipher, verifier, client, Config{
|
|
IdleTTL: 30 * time.Minute, AbsoluteTTL: 8 * time.Hour, RefreshBefore: time.Minute,
|
|
RefreshLease: 5 * time.Second, RefreshWait: 2 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return service
|
|
}
|
|
|
|
type fakeVerifier struct{ users map[string]*auth.User }
|
|
|
|
func (f fakeVerifier) Verify(_ context.Context, token string) (*auth.User, error) {
|
|
user := f.users[token]
|
|
if user == nil {
|
|
return nil, auth.ErrUnauthorized
|
|
}
|
|
copy := *user
|
|
return ©, nil
|
|
}
|
|
|
|
type fakePublicClient struct {
|
|
refreshResponse auth.OIDCTokenResponse
|
|
refreshError error
|
|
delay time.Duration
|
|
refreshCalls atomic.Int64
|
|
}
|
|
|
|
func (f *fakePublicClient) Refresh(_ context.Context, _ string) (auth.OIDCTokenResponse, error) {
|
|
f.refreshCalls.Add(1)
|
|
if f.delay > 0 {
|
|
time.Sleep(f.delay)
|
|
}
|
|
return f.refreshResponse, f.refreshError
|
|
}
|
|
func (f *fakePublicClient) RevokeRefreshToken(context.Context, string) error { return nil }
|
|
func (f *fakePublicClient) EndSessionURL(context.Context, string) (string, error) {
|
|
return "https://gateway.example.com/", nil
|
|
}
|
|
|
|
type fakeRepository struct {
|
|
mu sync.Mutex
|
|
record store.OIDCSession
|
|
deleted bool
|
|
external string
|
|
}
|
|
|
|
func newFakeRepository(external string) *fakeRepository { return &fakeRepository{external: external} }
|
|
|
|
func (f *fakeRepository) CreateOIDCSession(_ context.Context, input store.CreateOIDCSessionInput) (store.OIDCSession, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.record = store.OIDCSession{
|
|
ID: "33333333-3333-4333-8333-333333333333", SessionTokenHash: append([]byte(nil), input.SessionTokenHash...),
|
|
GatewayUserID: input.GatewayUserID, GatewayTenantID: input.GatewayTenantID, ExternalUserID: f.external,
|
|
UserStatus: "active", TokenCiphertext: append([]byte(nil), input.TokenCiphertext...), AccessTokenExpiresAt: input.AccessTokenExpiresAt,
|
|
LastSeenAt: input.LastSeenAt, IdleExpiresAt: input.IdleExpiresAt, AbsoluteExpiresAt: input.AbsoluteExpiresAt, RefreshVersion: 1,
|
|
}
|
|
return f.record, nil
|
|
}
|
|
func (f *fakeRepository) FindOIDCSessionByHash(_ context.Context, hash []byte) (store.OIDCSession, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.deleted || !bytes.Equal(hash, f.record.SessionTokenHash) {
|
|
return store.OIDCSession{}, store.ErrOIDCSessionNotFound
|
|
}
|
|
item := f.record
|
|
item.SessionTokenHash = append([]byte(nil), f.record.SessionTokenHash...)
|
|
item.TokenCiphertext = append([]byte(nil), f.record.TokenCiphertext...)
|
|
return item, nil
|
|
}
|
|
func (f *fakeRepository) TouchOIDCSession(_ context.Context, _ string, lastSeen, idle time.Time) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.deleted || !f.record.IdleExpiresAt.After(lastSeen) || !f.record.AbsoluteExpiresAt.After(lastSeen) {
|
|
return store.ErrOIDCSessionNotFound
|
|
}
|
|
f.record.LastSeenAt, f.record.IdleExpiresAt = lastSeen, idle
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) AcquireOIDCSessionRefreshLock(_ context.Context, _ string, version int64, lockID string, until, now time.Time) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.deleted || f.record.RefreshVersion != version || f.record.RefreshLockID != "" && f.record.RefreshLockUntil.After(now) {
|
|
return false, nil
|
|
}
|
|
f.record.RefreshLockID, f.record.RefreshLockUntil = lockID, until
|
|
return true, nil
|
|
}
|
|
func (f *fakeRepository) CompleteOIDCSessionRefresh(_ context.Context, _ string, version int64, lockID string, ciphertext []byte, expires time.Time) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.deleted || f.record.RefreshVersion != version || f.record.RefreshLockID != lockID {
|
|
return store.ErrOIDCSessionNotFound
|
|
}
|
|
f.record.TokenCiphertext = append([]byte(nil), ciphertext...)
|
|
f.record.AccessTokenExpiresAt = expires
|
|
f.record.RefreshVersion++
|
|
f.record.RefreshLockID = ""
|
|
f.record.RefreshLockUntil = time.Time{}
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) DeleteOIDCSessionByHash(context.Context, []byte) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.deleted = true
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) DeleteOIDCSessionByID(context.Context, string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.deleted = true
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) CleanupExpiredOIDCSessions(context.Context, time.Time) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
func (f *fakeRepository) snapshot() store.OIDCSession {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
item := f.record
|
|
item.TokenCiphertext = append([]byte(nil), item.TokenCiphertext...)
|
|
return item
|
|
}
|
|
func (f *fakeRepository) expireAccessToken(expiry time.Time) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.record.AccessTokenExpiresAt = expiry
|
|
}
|
|
func (f *fakeRepository) expireIdle(expiry time.Time) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.record.IdleExpiresAt = expiry
|
|
}
|
|
func (f *fakeRepository) disableUser() {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.record.UserStatus = "disabled"
|
|
}
|
|
func (f *fakeRepository) corruptCiphertext() {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.record.TokenCiphertext = []byte("corrupt")
|
|
}
|
|
func (f *fakeRepository) isDeleted() bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.deleted
|
|
}
|