1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-16 04:56:12 +00:00

feat: encrypt private keys saved on disk and in database (#682)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
Alessandro (Ale) Segala
2025-07-03 11:34:34 -07:00
committed by GitHub
parent 9872608d61
commit 5550729120
25 changed files with 2311 additions and 328 deletions

View File

@@ -4,10 +4,12 @@ import (
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/require"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
@@ -22,7 +24,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -36,7 +38,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loads value from config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
@@ -66,7 +68,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
@@ -87,7 +89,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
@@ -129,7 +131,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -165,7 +167,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -189,7 +191,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -214,7 +216,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("update multiple values", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -258,7 +260,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -279,7 +281,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -295,7 +297,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with invalid key", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -313,7 +315,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -386,7 +388,7 @@ func TestUpdateAppConfig(t *testing.T) {
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
@@ -451,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
@@ -25,6 +26,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
"github.com/pocket-id/pocket-id/backend/resources"
)
@@ -60,7 +62,7 @@ func (s *TestService) initExternalIdP() error {
return fmt.Errorf("failed to generate private key: %w", err)
}
s.externalIdPKey, err = utils.ImportRawKey(rawKey)
s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
if err != nil {
return fmt.Errorf("failed to import private key: %w", err)
}

View File

@@ -2,23 +2,20 @@ package service
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
const (
@@ -26,8 +23,9 @@ const (
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
RsaKeySize = 2048
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
@@ -59,58 +57,74 @@ const (
)
type JwtService struct {
envConfig *common.EnvConfigSchema
privateKey jwk.Key
keyId string
appConfigService *AppConfigService
jwksEncoded []byte
}
func NewJwtService(appConfigService *AppConfigService) *JwtService {
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
service := &JwtService{}
// Ensure keys are generated or loaded
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil {
err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
}
return service
}
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error {
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
// Ensure keys are generated or loaded
return s.loadOrGenerateKey(keysPath)
return s.loadOrGenerateKey(db)
}
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
var key jwk.Key
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
return fmt.Errorf("failed to get key provider: %w", err)
}
if ok {
key, err = s.loadKeyJWK(jwkPath)
if err != nil {
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
}
// Set the key, and we are done
// Try loading a key
key, err := keyProvider.LoadKey()
if err != nil {
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
// If we have a key, store it in the object and we're done
if key != nil {
err = s.SetKey(key)
if err != nil {
return fmt.Errorf("failed to set private key: %w", err)
}
return nil
}
// If we are here, we need to generate a new key
key, err = s.generateNewRSAKey()
err = s.generateKey()
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
return nil
}
// generateKey generates a new key and stores it in the object
func (s *JwtService) generateKey() error {
// Default is to generate RS256 (RSA-2048) keys
key, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
if err != nil {
return fmt.Errorf("failed to generate new private key: %w", err)
}
@@ -121,12 +135,6 @@ func (s *JwtService) loadOrGenerateKey(keysPath string) error {
return fmt.Errorf("failed to set private key: %w", err)
}
// Save the key as JWK
err = SaveKeyJWK(s.privateKey, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
return nil
}
@@ -192,13 +200,13 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject(user.ID).
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, common.EnvConfig.AppURL)
err = SetAudienceString(token, s.envConfig.AppURL)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
@@ -229,8 +237,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithAudience(s.envConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
)
if err != nil {
@@ -246,7 +254,7 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)
@@ -305,7 +313,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
)
@@ -335,7 +343,7 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
Subject(user.ID).
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)
@@ -377,7 +385,7 @@ func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, erro
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
)
if err != nil {
@@ -393,7 +401,7 @@ func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, r
Subject(userID).
Expiration(now.Add(RefreshTokenDuration)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
@@ -430,7 +438,7 @@ func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, client
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
)
if err != nil {
@@ -488,7 +496,7 @@ func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
utils.EnsureAlgInKey(pubKey)
jwkutils.EnsureAlgInKey(pubKey, "", "")
return pubKey, nil
}
@@ -517,56 +525,6 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
return alg, nil
}
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
return key, nil
}
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
}
// Import the raw key
return utils.ImportRawKey(rawKey)
}
// SaveKeyJWK saves a JWK to a file
func SaveKeyJWK(key jwk.Key, path string) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
}
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
// Write the JSON file to disk
enc := json.NewEncoder(keyFile)
enc.SetEscapeHTML(false)
err = enc.Encode(key)
if err != nil {
return fmt.Errorf("failed to write key file: %w", err)
}
return nil
}
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {

View File

@@ -21,7 +21,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
func TestJwtService_Init(t *testing.T) {
@@ -33,9 +33,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Initialize the JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set
@@ -66,9 +73,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// First create a service to generate a key
firstService := &JwtService{}
err := firstService.init(mockConfig, tempDir)
err := firstService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Get the key ID of the first service
@@ -77,7 +91,7 @@ func TestJwtService_Init(t *testing.T) {
// Now create a new service that should load the existing key
secondService := &JwtService{}
err = secondService.init(mockConfig, tempDir)
err = secondService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Verify the loaded key has the same ID as the original
@@ -90,12 +104,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk
origKeyID := createECDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(mockConfig, tempDir)
err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Ensure loaded key has the right algorithm
@@ -113,12 +134,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(mockConfig, tempDir)
err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Ensure loaded key has the right algorithm and curve
@@ -147,9 +175,16 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a JWT service with initialized key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -178,12 +213,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an ECDSA key and save it as JWK
originalKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the ECDSA key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -216,12 +258,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an EdDSA key and save it as JWK
originalKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the EdDSA key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -276,16 +325,16 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates token for regular user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -328,7 +377,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
t.Run("generates token for admin user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test admin user
@@ -364,7 +413,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
service := &JwtService{}
err := service.init(customMockConfig, tempDir)
err := service.init(nil, customMockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -399,7 +448,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -453,7 +505,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -507,7 +562,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -563,16 +621,16 @@ func TestGenerateVerifyIdToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims
@@ -601,7 +659,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
@@ -614,7 +672,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("can accept expired tokens if told so", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims
@@ -628,7 +686,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a token that's already expired
token, err := jwt.NewBuilder().
Subject(userClaims["sub"].(string)).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Audience([]string{clientID}).
IssuedAt(time.Now().Add(-2 * time.Hour)).
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
@@ -666,13 +724,13 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
})
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims with nonce
@@ -703,7 +761,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token with standard claims
@@ -714,7 +772,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.NoError(t, err, "Failed to generate ID token")
// Temporarily change the app URL to simulate wrong issuer
common.EnvConfig.AppURL = "https://wrong-issuer.com"
service.envConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString, false)
@@ -731,7 +789,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -762,7 +823,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is OKP
publicKey, err := service.GetPublicJWK()
@@ -784,7 +845,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -795,7 +859,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create test claims
userClaims := map[string]interface{}{
"sub": "ecdsauser456",
"name": "ECDSA User",
"email": "ecdsauser@example.com",
}
const clientID = "ecdsa-client-123"
@@ -815,7 +878,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is EC
publicKey, err := service.GetPublicJWK()
@@ -837,7 +900,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -868,17 +934,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
})
}
@@ -892,16 +948,16 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -931,7 +987,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
@@ -944,7 +1000,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service with a mock function to generate an expired token
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -961,7 +1017,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{clientID}).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
@@ -980,11 +1036,17 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service")
// Create a test user
@@ -1014,7 +1076,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1068,7 +1133,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1122,7 +1190,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1176,16 +1247,16 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies refresh token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -1211,7 +1282,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token using JWT directly to create an expired token
@@ -1220,7 +1291,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{"client123"}).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
@@ -1236,11 +1307,17 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir())
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir())
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service")
// Generate a token with the first service
@@ -1308,7 +1385,10 @@ func TestGetTokenType(t *testing.T) {
// Initialize the JWT service
mockConfig := NewTestAppConfigService(&model.AppConfig{})
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
@@ -1402,10 +1482,19 @@ func TestGetTokenType(t *testing.T) {
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()
privateKey, err := utils.ImportRawKey(privateKeyRaw)
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))
keyProvider := &jwkutils.KeyProviderFile{}
err = keyProvider.Init(jwkutils.KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: path,
},
})
require.NoError(t, err, "Failed to init file key provider")
err = keyProvider.SaveKey(privateKey)
require.NoError(t, err, "Failed to save key")
kid, _ := privateKey.KeyID()

View File

@@ -18,6 +18,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// generateTestECDSAKey creates an ECDSA key for testing
@@ -62,12 +63,12 @@ func TestOidcService_jwkSetForURL(t *testing.T) {
)
mockResponses := map[string]*http.Response{
//nolint:bodyclose
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
//nolint:bodyclose
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
}
httpClient := &http.Client{
Transport: &MockRoundTripper{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
@@ -139,7 +140,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
var err error
// Create a test database
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
@@ -149,12 +150,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &MockRoundTripper{
Transport: &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
//nolint:bodyclose
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)),
federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)),
//nolint:bodyclose
federatedClientIssuerDefaults + ".well-known/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
federatedClientIssuerDefaults + ".well-known/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
},
},
}

View File

@@ -1,97 +0,0 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
func newDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Perform migrations with the embedded migrations
sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB")
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
require.NoError(t, err, "Failed to create migration driver")
source, err := iofs.New(resources.FS, "migrations/sqlite")
require.NoError(t, err, "Failed to create embedded migration source")
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
require.NoError(t, err, "Failed to create migration instance")
err = m.Up()
require.NoError(t, err, "Failed to perform migrations")
return db
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
// MockRoundTripper is a custom http.RoundTripper that returns responses based on the URL
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}