Merge branch 'main' into feat/huma-api-docs

This commit is contained in:
Kyle Mendell
2026-07-12 12:48:50 -05:00
committed by GitHub
44 changed files with 705 additions and 274 deletions
@@ -10,7 +10,6 @@ import (
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -40,11 +39,6 @@ func NewAppConfigService(ctx context.Context, db *gorm.DB) (service *AppConfigSe
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
}
err = service.initInstanceID(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
}
return service, nil
}
@@ -74,8 +68,6 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
InstanceID: model.AppConfigVariable{Value: ""},
// Email
RequireUserEmail: model.AppConfigVariable{Value: "true"},
SmtpHost: model.AppConfigVariable{},
@@ -422,23 +414,3 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil
}
+12 -2
View File
@@ -630,12 +630,22 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Manually set instance ID
err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id")
// Manually set the instance ID used to derive the JWK encryption key, so the seeded JWK can be decrypted
// Persist the fixed test value so it survives an export/import round-trip
const testInstanceID = "test-instance-id"
err = s.db.WithContext(ctx).
Exec(
`INSERT INTO kv (key, value) VALUES ('instance_id', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
testInstanceID,
).
Error
if err != nil {
return err
}
// The instance ID is loaded once at startup, so we also set it directly on the JWT service so it takes effect immediately
s.jwtService.instanceID = testInstanceID
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {
+6 -4
View File
@@ -48,13 +48,14 @@ type JwtService struct {
privateKey jwk.Key
keyId string
appConfigService *AppConfigService
instanceID string
jwksEncoded []byte
}
func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{}
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
err := service.init(ctx, db, instanceID, appConfigService, &common.EnvConfig)
if err != nil {
return nil, err
}
@@ -62,10 +63,11 @@ func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfig
return service, nil
}
func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
s.db = db
s.instanceID = instanceID
// Ensure keys are generated or loaded
return s.LoadOrGenerateKey(ctx)
@@ -73,7 +75,7 @@ func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *Ap
func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.instanceID)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
+60 -50
View File
@@ -1,7 +1,6 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
@@ -19,6 +18,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/model"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -35,23 +35,33 @@ func newTestEnvConfig() *common.EnvConfigSchema {
}
}
func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
func initJwtService(t *testing.T, db *gorm.DB, instanceID string, appConfig *AppConfigService, envConfig *common.EnvConfigSchema) *JwtService {
t.Helper()
service := &JwtService{}
err := service.init(t.Context(), db, appConfig, envConfig)
err := service.init(t.Context(), db, instanceID, appConfig, envConfig)
require.NoError(t, err, "Failed to initialize JWT service")
return service
}
func setupJwtService(t *testing.T, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
func setupJwtService(t *testing.T, instanceID string, appConfig *AppConfigService) (*JwtService, *gorm.DB, *common.EnvConfigSchema) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
envConfig := newTestEnvConfig()
return initJwtService(t, db, appConfig, envConfig), db, envConfig
service := initJwtService(t, db, instanceID, appConfig, envConfig)
return service, db, envConfig
}
func newInstanceID(t *testing.T, db *gorm.DB) string {
t.Helper()
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
return instanceID
}
func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
@@ -60,10 +70,10 @@ func newTestDbAndEnv(t *testing.T) (*gorm.DB, *common.EnvConfigSchema) {
return testutils.NewDatabaseForTest(t), newTestEnvConfig()
}
func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string {
func saveKeyToDatabase(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, key jwk.Key) string {
t.Helper()
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err, "Failed to init key provider")
err = keyProvider.SaveKey(t.Context(), key)
@@ -84,15 +94,16 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should generate new key when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Initialize the JWT service
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Verify the private key was set
require.NotNil(t, service.privateKey, "Private key should be set")
// Verify the key has been persisted in the database
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, instanceID)
require.NoError(t, err, "Failed to init key provider")
key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err, "Failed to load key from provider")
@@ -111,16 +122,17 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// First create a service to generate a key
firstService := initJwtService(t, db, mockConfig, mockEnvConfig)
firstService := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the key ID of the first service
origKeyID, ok := firstService.privateKey.KeyID()
require.True(t, ok)
// Now create a new service that should load the existing key
secondService := initJwtService(t, db, mockConfig, mockEnvConfig)
secondService := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := secondService.privateKey.KeyID()
@@ -131,12 +143,13 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK for ECDSA keys", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Create a new JWK and save it to the database
origKeyID := createECDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
origKeyID := createECDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Now create a new service that should load the existing key
svc := initJwtService(t, db, mockConfig, mockEnvConfig)
svc := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Ensure loaded key has the right algorithm
alg, ok := svc.privateKey.Algorithm()
@@ -152,12 +165,13 @@ func TestJwtService_Init(t *testing.T) {
t.Run("should load existing JWK for EdDSA keys", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
// Create a new JWK and save it to the database
origKeyID := createEdDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
origKeyID := createEdDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Now create a new service that should load the existing key
svc := initJwtService(t, db, mockConfig, mockEnvConfig)
svc := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Ensure loaded key has the right algorithm and curve
alg, ok := svc.privateKey.Algorithm()
@@ -181,9 +195,12 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
instanceID := newInstanceID(t, db)
t.Run("returns public key when private key is initialized", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -208,14 +225,11 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
})
t.Run("returns public key when ECDSA private key is initialized", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
mockEnvConfig := newTestEnvConfig()
// Create an ECDSA key and save it in the database
originalKeyID := createECDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
originalKeyID := createECDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Create a JWT service that loads the ECDSA key
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -248,10 +262,10 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
mockEnvConfig := newTestEnvConfig()
// Create an EdDSA key and save it in the database
originalKeyID := createEdDSAKeyJWK(t, db, mockEnvConfig, mockConfig)
originalKeyID := createEdDSAKeyJWK(t, db, instanceID, mockEnvConfig, mockConfig)
// Create a JWT service that loads the EdDSA key
service := initJwtService(t, db, mockConfig, mockEnvConfig)
service := initJwtService(t, db, instanceID, mockConfig, mockEnvConfig)
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
@@ -299,9 +313,11 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
db, envConfig := newTestDbAndEnv(t)
instanceID := newInstanceID(t, db)
t.Run("generates token for regular user", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
user := model.User{
Base: model.Base{ID: "user123"},
@@ -342,7 +358,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("generates token for admin user", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
adminUser := model.User{
Base: model.Base{ID: "admin123"},
@@ -367,7 +383,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
service, _, _ := setupJwtService(t, instanceID, mockConfig)
user := model.User{
Base: model.Base{ID: "user-with-auth-method"},
@@ -388,7 +404,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
customMockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
})
service, _, _ := setupJwtService(t, customMockConfig)
service, _, _ := setupJwtService(t, instanceID, customMockConfig)
user := model.User{
Base: model.Base{ID: "user456"},
@@ -408,9 +424,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("works with Ed25519 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createEdDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -447,9 +462,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("works with P-256 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createECDSAKeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -486,9 +500,8 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
t.Run("works with RSA-4096 keys", func(t *testing.T) {
db, envConfig := newTestDbAndEnv(t)
origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
service := initJwtService(t, db, mockConfig, envConfig)
origKeyID := createRSA4096KeyJWK(t, db, instanceID, envConfig, mockConfig)
service := initJwtService(t, db, instanceID, mockConfig, envConfig)
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
@@ -526,9 +539,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
}
func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function
ctx := context.Background()
t.Run("succeeds when token type matches expected type", func(t *testing.T) {
// Create a token with the expected type
token := jwt.New()
@@ -539,7 +549,7 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err = validator(ctx, token)
err = validator(t.Context(), token)
assert.NoError(t, err, "Validator should accept token with matching type")
})
@@ -553,7 +563,7 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err = validator(ctx, token)
err = validator(t.Context(), token)
require.Error(t, err, "Validator should reject token with non-matching type")
assert.Contains(t, err.Error(), "invalid token type: expected access-token, got other-token")
})
@@ -566,19 +576,19 @@ func TestTokenTypeValidator(t *testing.T) {
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err := validator(ctx, token)
err := validator(t.Context(), token)
require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token type claim")
})
}
func importKey(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
func importKey(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
t.Helper()
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
require.NoError(t, err, "Failed to import private key")
return saveKeyToDatabase(t, db, envConfig, appConfig, privateKey)
return saveKeyToDatabase(t, db, instanceID, envConfig, appConfig, privateKey)
}
// Because generating a RSA-406 key isn't immediate, we pre-compute one
@@ -587,7 +597,7 @@ var (
rsaKeyPrecomputeOnce sync.Once
)
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
t.Helper()
rsaKeyPrecomputeOnce.Do(func() {
@@ -599,10 +609,10 @@ func createRSA4096KeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigS
})
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, rsaKeyPrecomputed)
return importKey(t, db, instanceID, envConfig, appConfig, rsaKeyPrecomputed)
}
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createECDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
t.Helper()
// Generate a new P-256 ECDSA key
@@ -610,11 +620,11 @@ func createECDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
require.NoError(t, err, "Failed to generate ECDSA key")
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, privateKeyRaw)
return importKey(t, db, instanceID, envConfig, appConfig, privateKeyRaw)
}
// Helper function to create an Ed25519 key and save it as JWK
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema, appConfig *AppConfigService) string {
t.Helper()
// Generate a new Ed25519 key pair
@@ -622,5 +632,5 @@ func createEdDSAKeyJWK(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
require.NoError(t, err, "Failed to generate Ed25519 key")
// Import as JWK and save it
return importKey(t, db, envConfig, appConfig, privateKeyRaw)
return importKey(t, db, instanceID, envConfig, appConfig, privateKeyRaw)
}
@@ -15,7 +15,8 @@ import (
func TestExchangeOneTimeAccessTokenRejectsDisabledUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
appConfig := NewTestAppConfigService((&AppConfigService{}).getDefaultDbConfig())
jwtService := initJwtService(t, db, appConfig, newTestEnvConfig())
instanceID := newInstanceID(t, db)
jwtService := initJwtService(t, db, instanceID, appConfig, newTestEnvConfig())
auditLogService := NewAuditLogService(db, appConfig, nil, &GeoLiteService{})
oneTimeAccessService := NewOneTimeAccessService(db, nil, jwtService, auditLogService, nil, appConfig)