mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-09-05 13:53:33 +08:00
feat: use db store for consent
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
|
||||
"github.com/tinyauthapp/tinyauth/internal/test"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func newTestOIDCService(t *testing.T, cfg *model.Config, runtime *model.RuntimeConfig, store repository.Store) *OIDCService {
|
||||
t.Helper()
|
||||
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
ctx := context.Background()
|
||||
dg := ding.New(ctx)
|
||||
|
||||
svc, err := NewOIDCService(OIDCServiceInput{
|
||||
Log: log,
|
||||
Config: cfg,
|
||||
Runtime: runtime,
|
||||
Queries: store,
|
||||
Ding: dg,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc)
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestOIDCConsentPerClient(t *testing.T) {
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
store := memory.New()
|
||||
svc := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
// Consent for client A
|
||||
_, err := svc.UpsertOIDCConsent(context.Background(), "testuser", "openid profile", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Consent for client B
|
||||
_, err = svc.UpsertOIDCConsent(context.Background(), "testuser", "openid email", "other-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two separate rows, one per client
|
||||
consents, err := store.ListOIDCConsents(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, consents, 2)
|
||||
|
||||
// Each lookup returns the consent for that specific client
|
||||
gotA, err := svc.GetOIDCConsent(context.Background(), "testuser", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gotA)
|
||||
assert.Equal(t, "openid profile", gotA.Scope)
|
||||
|
||||
gotB, err := svc.GetOIDCConsent(context.Background(), "testuser", "other-client-id")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gotB)
|
||||
assert.Equal(t, "openid email", gotB.Scope)
|
||||
}
|
||||
|
||||
func TestOIDCConsentUnionKeepsOneRow(t *testing.T) {
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
store := memory.New()
|
||||
svc := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
_, err := svc.UpsertOIDCConsent(context.Background(), "testuser", "openid profile", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second authorize with an additional scope
|
||||
_, err = svc.UpsertOIDCConsent(context.Background(), "testuser", "openid email", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Still exactly one row
|
||||
consents, err := store.ListOIDCConsents(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, consents, 1)
|
||||
|
||||
// Stored scope is the union of both requests
|
||||
got, err := svc.GetOIDCConsent(context.Background(), "testuser", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, []string{"email", "openid", "profile"}, splitScopes(got.Scope))
|
||||
}
|
||||
|
||||
func TestOIDCConsentSubsetDoesNotShrink(t *testing.T) {
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
store := memory.New()
|
||||
svc := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
_, err := svc.UpsertOIDCConsent(context.Background(), "testuser", "openid profile email", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Authorize with a subset of already-granted scopes
|
||||
_, err = svc.UpsertOIDCConsent(context.Background(), "testuser", "openid profile", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stored scope is unchanged
|
||||
got, err := svc.GetOIDCConsent(context.Background(), "testuser", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, []string{"email", "openid", "profile"}, splitScopes(got.Scope))
|
||||
}
|
||||
|
||||
func TestOIDCConsentNotFound(t *testing.T) {
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
store := memory.New()
|
||||
svc := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
got, err := svc.GetOIDCConsent(context.Background(), "testuser", "some-client-id")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
func TestOIDCConsentReconcileRemovesDeletedClients(t *testing.T) {
|
||||
cfg, runtime := test.CreateTestConfigs(t)
|
||||
|
||||
cfg.OIDC.Clients["client-a"] = model.OIDCClientConfig{
|
||||
ClientID: "client-a",
|
||||
ClientSecret: "secret-a",
|
||||
TrustedRedirectURIs: []string{"https://a.example.com/callback"},
|
||||
}
|
||||
cfg.OIDC.Clients["client-b"] = model.OIDCClientConfig{
|
||||
ClientID: "client-b",
|
||||
ClientSecret: "secret-b",
|
||||
TrustedRedirectURIs: []string{"https://b.example.com/callback"},
|
||||
}
|
||||
|
||||
// First startup: both clients configured
|
||||
store := memory.New()
|
||||
svc := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
_, err := svc.UpsertOIDCConsent(context.Background(), "testuser", "openid profile", "client-a")
|
||||
require.NoError(t, err)
|
||||
_, err = svc.UpsertOIDCConsent(context.Background(), "testuser", "openid email", "client-b")
|
||||
require.NoError(t, err)
|
||||
// Stale consent for a client that was never configured
|
||||
_, err = svc.UpsertOIDCConsent(context.Background(), "testuser", "openid", "stale-client")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second startup: client-b removed from configuration
|
||||
delete(cfg.OIDC.Clients, "client-b")
|
||||
|
||||
svc2 := newTestOIDCService(t, &cfg, &runtime, store)
|
||||
|
||||
// Consents for the removed and unknown clients are gone
|
||||
_, err = store.GetOIDCConsentByUsernameAndClientID(context.Background(), repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: "testuser", ClientID: "client-a",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.GetOIDCConsentByUsernameAndClientID(context.Background(), repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: "testuser", ClientID: "client-b",
|
||||
})
|
||||
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||
|
||||
_, err = store.GetOIDCConsentByUsernameAndClientID(context.Background(), repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: "testuser", ClientID: "stale-client",
|
||||
})
|
||||
assert.ErrorIs(t, err, repository.ErrNotFound)
|
||||
|
||||
require.NotNil(t, svc2)
|
||||
}
|
||||
|
||||
func splitScopes(scope string) []string {
|
||||
return strings.Fields(scope)
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
@@ -14,7 +12,6 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -32,7 +29,6 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/cache"
|
||||
"go.uber.org/dig"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -340,6 +336,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
|
||||
issuer: issuer,
|
||||
}
|
||||
|
||||
// Remove consents for clients that are no longer configured
|
||||
if err := service.reconcileOIDCConsents(context.Background()); err != nil {
|
||||
i.Log.App.Warn().Err(err).Msg("Failed to reconcile OIDC consents")
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
|
||||
|
||||
@@ -975,82 +976,104 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
|
||||
return parsedPromps
|
||||
}
|
||||
|
||||
func (service *OIDCService) deriveKey(key string, info string) ([]byte, error) {
|
||||
kdf := hkdf.New(sha256.New, []byte(key), []byte(model.HKDFSalt), []byte(info))
|
||||
derived := make([]byte, 32)
|
||||
if _, err := io.ReadFull(kdf, derived); err != nil {
|
||||
return nil, fmt.Errorf("failed to derive key: %w", err)
|
||||
func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
|
||||
entry, err := service.queries.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
|
||||
Username: username,
|
||||
ClientID: clientId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get oidc consent: %w", err)
|
||||
}
|
||||
return derived, nil
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) CreateSecureValue(key string, kv map[string]string) (string, error) {
|
||||
aesKey, err := service.deriveKey(key, "oidc-scope-v1")
|
||||
func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, scope, clientId string) (repository.OidcConsent, error) {
|
||||
existing, err := service.GetOIDCConsent(ctx, username, clientId)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
c, err := aes.NewCipher(aesKey)
|
||||
merged := scope
|
||||
|
||||
if existing != nil {
|
||||
merged = mergeScopes(existing.Scope, scope)
|
||||
}
|
||||
|
||||
entry := repository.UpsertOIDCConsentParams{
|
||||
Username: username,
|
||||
Scope: merged,
|
||||
ClientID: clientId,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
consent, err := service.queries.UpsertOIDCConsent(ctx, entry)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create aes cipher: %w", err)
|
||||
service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent")
|
||||
return repository.OidcConsent{}, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
plain, err := json.Marshal(kv)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal data: %w", err)
|
||||
}
|
||||
|
||||
sealed := gcm.Seal(nonce, nonce, plain, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
return consent, nil
|
||||
}
|
||||
|
||||
func (service *OIDCService) DecryptSecureValue(key string, encrypted string) (map[string]string, error) {
|
||||
data, err := base64.RawURLEncoding.DecodeString(encrypted)
|
||||
func (service *OIDCService) reconcileOIDCConsents(ctx context.Context) error {
|
||||
consents, err := service.queries.ListOIDCConsents(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode value: %w", err)
|
||||
return fmt.Errorf("failed to list oidc consents: %w", err)
|
||||
}
|
||||
|
||||
aesKey, err := service.deriveKey(key, "oidc-scope-v1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
cleaned := make(map[string]struct{})
|
||||
|
||||
for _, consent := range consents {
|
||||
if _, ok := cleaned[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
cleaned[consent.ClientID] = struct{}{}
|
||||
|
||||
if _, ok := service.clients[consent.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
service.log.App.Info().Str("clientId", consent.ClientID).Msg("Removed OIDC client no longer in configuration, deleting its consents")
|
||||
|
||||
if err := service.queries.DeleteOIDCConsentByClientID(ctx, consent.ClientID); err != nil {
|
||||
service.log.App.Warn().Err(err).Str("clientId", consent.ClientID).Msg("Failed to delete OIDC consents for removed client")
|
||||
}
|
||||
}
|
||||
|
||||
c, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create aes cipher: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeScopes(existing, requested string) string {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gcm: %w", err)
|
||||
for _, scope := range strings.Split(existing, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
for _, scope := range strings.Split(requested, " ") {
|
||||
if scope != "" {
|
||||
set[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
scopes := make([]string, 0, len(set))
|
||||
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt secure value: %w", err)
|
||||
for scope := range set {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
|
||||
kv := make(map[string]string) // make(), not var — nil map writes panic
|
||||
if err := json.Unmarshal(plain, &kv); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse secure value: %w", err)
|
||||
}
|
||||
slices.Sort(scopes)
|
||||
|
||||
return kv, nil
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user