mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-25 12:29:04 +02:00
496 lines
14 KiB
Go
496 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
"uuid"
|
|
|
|
"github.com/lestrrat-go/jwx/v4/jwa"
|
|
"github.com/lestrrat-go/jwx/v4/jwk"
|
|
"github.com/lestrrat-go/jwx/v4/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 (
|
|
// KeyUsageSigning is the usage for the private keys, for the "use" property
|
|
KeyUsageSigning = "sig"
|
|
|
|
// IsAdminClaim is a boolean claim used in access tokens for admin users
|
|
// This may be omitted on non-admin tokens
|
|
IsAdminClaim = "isAdmin"
|
|
|
|
// TokenTypeClaim is the claim used to identify the type of token
|
|
TokenTypeClaim = "type"
|
|
|
|
// AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys
|
|
AuthenticationMethodPhishingResistant = "phr"
|
|
|
|
// AuthenticationMethodOneTimePassword identifies one-time password/code authentication
|
|
AuthenticationMethodOneTimePassword = "otp"
|
|
|
|
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
|
|
AccessTokenJWTType = "access-token"
|
|
|
|
// Acceptable clock skew for verifying tokens
|
|
clockSkew = time.Minute
|
|
)
|
|
|
|
type JwtService struct {
|
|
db *gorm.DB
|
|
envConfig *common.EnvConfigSchema
|
|
// privateKey signs tokens that are consumed externally, such as ID tokens and access tokens for apps
|
|
privateKey jwk.Key
|
|
// sessionKey is the symmetric key that signs Pocket ID's own session tokens
|
|
sessionKey jwk.Key
|
|
keyId string
|
|
instanceID string
|
|
jwksEncoded []byte
|
|
}
|
|
|
|
func NewJwtService(ctx context.Context, db *gorm.DB, instanceID string) (*JwtService, error) {
|
|
service := &JwtService{}
|
|
|
|
err := service.init(ctx, db, instanceID, &common.EnvConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return service, nil
|
|
}
|
|
|
|
func (s *JwtService) init(ctx context.Context, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) (err error) {
|
|
s.envConfig = envConfig
|
|
s.db = db
|
|
s.instanceID = instanceID
|
|
|
|
// Ensure keys are generated or loaded
|
|
return s.LoadOrGenerateKey(ctx)
|
|
}
|
|
|
|
// LoadOrGenerateKey loads the signing keys from the database, generating and persisting them if they don't exist yet
|
|
func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
|
|
// Load the key used for tokens that are consumed externally, such as ID tokens and access tokens for apps
|
|
err := retryKeyStorage(ctx, s.loadOrGenerateSigningKey)
|
|
if err != nil {
|
|
return fmt.Errorf("error loading signing key: %w", err)
|
|
}
|
|
|
|
// Load the key used for Pocket ID's own sessions, which is symmetric
|
|
err = retryKeyStorage(ctx, s.loadOrGenerateSessionKey)
|
|
if err != nil {
|
|
return fmt.Errorf("error loading session key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func retryKeyStorage(ctx context.Context, loadOrGenerate func(context.Context) error) error {
|
|
for retries := 0; ; retries++ {
|
|
// Run the full load and store sequence so a key written by another replica takes precedence
|
|
err := loadOrGenerate(ctx)
|
|
if !errors.Is(err, jwkutils.ErrRetryKeyStorage) {
|
|
return err
|
|
}
|
|
|
|
// Return the last conflict after the configured number of retries has been exhausted
|
|
if retries == 3 {
|
|
return err
|
|
}
|
|
|
|
// Wait briefly before reloading so the competing database transaction has time to finish
|
|
slog.WarnContext(ctx, "Failed to store key, retrying", slog.Int("retry", retries+1), slog.Any("error", err))
|
|
err = utils.SleepWithContext(ctx, 200*time.Millisecond)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *JwtService) loadOrGenerateSigningKey(ctx context.Context) error {
|
|
// Get the key provider
|
|
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.instanceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get key provider: %w", err)
|
|
}
|
|
|
|
// Try loading a key
|
|
key, err := keyProvider.LoadKey(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load key: %w", 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
|
|
err = s.generateKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate key: %w", err)
|
|
}
|
|
|
|
// Save the newly-generated key
|
|
err = keyProvider.SaveKey(ctx, s.privateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save private key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *JwtService) loadOrGenerateSessionKey(ctx context.Context) error {
|
|
// Get the key provider for the session key
|
|
keyProvider, err := jwkutils.GetSessionKeyProvider(s.db, s.envConfig, s.instanceID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get session key provider: %w", err)
|
|
}
|
|
|
|
// Try loading a key
|
|
key, err := keyProvider.LoadKey(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load session key: %w", err)
|
|
}
|
|
|
|
// If we have a key, store it in the object and we're done
|
|
if key != nil {
|
|
err = s.SetSessionKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set session key: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// If we are here, we need to generate a new key
|
|
key, err = jwkutils.GenerateSessionKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate session key: %w", err)
|
|
}
|
|
|
|
// Set the key in the object, which also validates it
|
|
err = s.SetSessionKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set session key: %w", err)
|
|
}
|
|
|
|
// Save the newly-generated key
|
|
err = keyProvider.SaveKey(ctx, key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save session key: %w", 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)
|
|
}
|
|
|
|
// Set the key in the object, which also validates it
|
|
err = s.SetKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set private key: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ValidateKey(privateKey jwk.Key) error {
|
|
// Validate the loaded key
|
|
err := privateKey.Validate()
|
|
if err != nil {
|
|
return fmt.Errorf("key object is invalid: %w", err)
|
|
}
|
|
keyID, ok := privateKey.KeyID()
|
|
if !ok || keyID == "" {
|
|
return errors.New("key object does not contain a key ID")
|
|
}
|
|
usage, ok := privateKey.KeyUsage()
|
|
if !ok || usage != KeyUsageSigning {
|
|
return errors.New("key object is not valid for signing")
|
|
}
|
|
ok, err = jwk.IsPrivateKey(privateKey)
|
|
if err != nil || !ok {
|
|
return errors.New("key object is not a private key")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateSessionKey validates the symmetric key used to sign session tokens
|
|
func ValidateSessionKey(sessionKey jwk.Key) error {
|
|
// Validate the loaded key
|
|
err := sessionKey.Validate()
|
|
if err != nil {
|
|
return fmt.Errorf("key object is invalid: %w", err)
|
|
}
|
|
if sessionKey.KeyType() != jwa.OctetSeq() {
|
|
return errors.New("key object is not a symmetric key")
|
|
}
|
|
keyID, ok := sessionKey.KeyID()
|
|
if !ok || keyID == "" {
|
|
return errors.New("key object does not contain a key ID")
|
|
}
|
|
usage, ok := sessionKey.KeyUsage()
|
|
if !ok || usage != KeyUsageSigning {
|
|
return errors.New("key object is not valid for signing")
|
|
}
|
|
|
|
// Session tokens are always signed with the same algorithm, so a key for anything else can't be used
|
|
alg, ok := sessionKey.Algorithm()
|
|
if !ok || alg == nil || alg.String() != jwkutils.SessionKeyAlg().String() {
|
|
return fmt.Errorf("key object is not valid for the %s algorithm", jwkutils.SessionKeyAlg().String())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *JwtService) SetKey(privateKey jwk.Key) error {
|
|
// Validate the loaded key
|
|
err := ValidateKey(privateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("private key is not valid: %w", err)
|
|
}
|
|
|
|
// Set the private key and key id in the object
|
|
s.privateKey = privateKey
|
|
|
|
keyId, ok := privateKey.KeyID()
|
|
if !ok {
|
|
return errors.New("key object does not contain a key ID")
|
|
}
|
|
s.keyId = keyId
|
|
|
|
// Create and encode a JWKS containing the public key
|
|
publicKey, err := s.GetPublicJWK()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get public JWK: %w", err)
|
|
}
|
|
jwks := jwk.NewSet()
|
|
err = jwks.AddKey(publicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add public key to JWKS: %w", err)
|
|
}
|
|
s.jwksEncoded, err = json.Marshal(jwks)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encode JWKS to JSON: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetSessionKey sets the symmetric key used to sign session tokens
|
|
// This key is never published in the JWKS, since it's a shared secret that only Pocket ID needs
|
|
func (s *JwtService) SetSessionKey(sessionKey jwk.Key) error {
|
|
err := ValidateSessionKey(sessionKey)
|
|
if err != nil {
|
|
return fmt.Errorf("session key is not valid: %w", err)
|
|
}
|
|
|
|
s.sessionKey = sessionKey
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string, sessionDuration time.Duration) (string, error) {
|
|
if s.sessionKey == nil {
|
|
return "", errors.New("session key is not initialized")
|
|
}
|
|
|
|
now := time.Now()
|
|
token, err := jwt.NewBuilder().
|
|
Subject(user.ID).
|
|
Expiration(now.Add(sessionDuration)).
|
|
IssuedAt(now).
|
|
Issuer(s.envConfig.AppURL).
|
|
JwtID(uuid.NewV4().String()).
|
|
Build()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to build token: %w", err)
|
|
}
|
|
|
|
err = SetAudienceString(token, s.envConfig.AppURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
|
}
|
|
|
|
err = SetTokenType(token, AccessTokenJWTType)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
|
}
|
|
|
|
err = SetIsAdmin(token, user.IsAdmin)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
|
|
}
|
|
|
|
err = SetAuthenticationMethods(token, authenticationMethod)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set '%s' claim in token: %w", common.AuthenticationMethodsClaim, err)
|
|
}
|
|
|
|
// Session tokens are signed with the symmetric session key
|
|
signed, err := jwt.Sign(token, jwt.WithKey(jwkutils.SessionKeyAlg(), s.sessionKey))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to sign token: %w", err)
|
|
}
|
|
|
|
return string(signed), nil
|
|
}
|
|
|
|
func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
|
if s.sessionKey == nil {
|
|
return nil, errors.New("session key is not initialized")
|
|
}
|
|
|
|
token, err := jwt.ParseString(
|
|
tokenString,
|
|
jwt.WithValidate(true),
|
|
jwt.WithKey(jwkutils.SessionKeyAlg(), s.sessionKey),
|
|
jwt.WithAcceptableSkew(clockSkew),
|
|
jwt.WithAudience(s.envConfig.AppURL),
|
|
jwt.WithIssuer(s.envConfig.AppURL),
|
|
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse token: %w", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
|
|
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
|
|
if s.privateKey == nil {
|
|
return nil, errors.New("key is not initialized")
|
|
}
|
|
|
|
pubKey, err := s.privateKey.PublicKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
|
}
|
|
|
|
jwkutils.EnsureAlgInKey(pubKey, "", "")
|
|
|
|
return pubKey, nil
|
|
}
|
|
|
|
// GetPublicJWKSAsJSON returns the JSON Web Key Set (JWKS) for the public key, encoded as JSON.
|
|
// The value is cached since the key is static.
|
|
func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) {
|
|
if len(s.jwksEncoded) == 0 {
|
|
return nil, errors.New("key is not initialized")
|
|
}
|
|
|
|
return s.jwksEncoded, nil
|
|
}
|
|
|
|
// GetKeyAlg returns the algorithm of the key
|
|
func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
|
|
if len(s.jwksEncoded) == 0 {
|
|
return nil, errors.New("key is not initialized")
|
|
}
|
|
|
|
alg, ok := s.privateKey.Algorithm()
|
|
if !ok || alg == nil {
|
|
return nil, errors.New("failed to retrieve algorithm for key")
|
|
}
|
|
|
|
return alg, nil
|
|
}
|
|
|
|
// GetKeyID returns the key ID (kid) of the signing key, if one is set.
|
|
func (s *JwtService) GetKeyID() (string, bool) {
|
|
if s.privateKey == nil {
|
|
return "", false
|
|
}
|
|
return s.privateKey.KeyID()
|
|
}
|
|
|
|
// GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token
|
|
func (s *JwtService) GetAuthenticationMethod(token jwt.Token) (string, error) {
|
|
if !token.Has(common.AuthenticationMethodsClaim) {
|
|
return "", nil
|
|
}
|
|
rawAuthenticationMethods, err := jwt.Get[[]any](token, common.AuthenticationMethodsClaim)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get '%s' claim from token: %w", common.AuthenticationMethodsClaim, err)
|
|
}
|
|
|
|
if len(rawAuthenticationMethods) == 0 {
|
|
return "", nil
|
|
}
|
|
authenticationMethod, ok := rawAuthenticationMethods[0].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", common.AuthenticationMethodsClaim)
|
|
}
|
|
return authenticationMethod, nil
|
|
}
|
|
|
|
// SetTokenType sets the "type" claim in the token
|
|
func SetTokenType(token jwt.Token, tokenType string) error {
|
|
if tokenType == "" {
|
|
return nil
|
|
}
|
|
return token.Set(TokenTypeClaim, tokenType)
|
|
}
|
|
|
|
// SetIsAdmin sets the "isAdmin" claim in the token
|
|
func SetIsAdmin(token jwt.Token, isAdmin bool) error {
|
|
// Only set if true
|
|
if !isAdmin {
|
|
return nil
|
|
}
|
|
return token.Set(IsAdminClaim, isAdmin)
|
|
}
|
|
|
|
// SetAuthenticationMethods sets the authentication method references claim in the token
|
|
func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) error {
|
|
if authenticationMethod == "" {
|
|
return nil
|
|
}
|
|
return token.Set(common.AuthenticationMethodsClaim, []string{authenticationMethod})
|
|
}
|
|
|
|
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
|
|
// This is permitted by RFC 7519, and it's done here for backwards-compatibility
|
|
func SetAudienceString(token jwt.Token, audience string) error {
|
|
return token.Set(jwt.AudienceKey, audience)
|
|
}
|
|
|
|
// TokenTypeValidator is a validator function that checks the "type" claim in the token
|
|
func TokenTypeValidator(expectedTokenType string) jwt.ValidatorFunc {
|
|
return func(_ context.Context, t jwt.Token) error {
|
|
tokenType, err := jwt.Get[string](t, TokenTypeClaim)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get token type claim: %w", err)
|
|
}
|
|
if tokenType != expectedTokenType {
|
|
return fmt.Errorf("invalid token type: expected %s, got %s", expectedTokenType, tokenType)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *JwtService) GetPrivateKey() any {
|
|
privateKey, _ := jwk.Export[any](s.privateKey)
|
|
return privateKey
|
|
}
|