Files
pocket-id/backend/internal/service/jwt_service.go
T

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
}