Class: StandardId::JwtService

Inherits:
Object
  • Object
show all
Defined in:
lib/standard_id/jwt_service.rb

Constant Summary collapse

RESERVED_JWT_KEYS =
%i[sub client_id scope grant_type exp iat aud iss nbf jti]
BASE_SESSION_FIELDS =
%i[account_id client_id scopes grant_type aud]
SUPPORTED_ALGORITHMS =

Supported signing algorithms categorized by type Symmetric: use shared secret (Rails.application.secret_key_base) Asymmetric: use key pairs (RSA or EC private key)

{
  # HMAC (symmetric)
  "HS256" => { type: :symmetric },
  "HS384" => { type: :symmetric },
  "HS512" => { type: :symmetric },
  # RSA (asymmetric)
  "RS256" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
  "RS384" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
  "RS512" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
  # ECDSA (asymmetric)
  "ES256" => { type: :asymmetric, key_class: OpenSSL::PKey::EC },
  "ES384" => { type: :asymmetric, key_class: OpenSSL::PKey::EC },
  "ES512" => { type: :asymmetric, key_class: OpenSSL::PKey::EC }
}.freeze
SESSION_CLASS =
Concurrent::Delay.new do
  Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
    def active?
      true
    end
  end
end

Class Method Summary collapse

Class Method Details

.algorithmObject



41
42
43
# File 'lib/standard_id/jwt_service.rb', line 41

def self.algorithm
  StandardId.config.oauth.signing_algorithm.to_s.upcase
end

.algorithm_configObject



45
46
47
# File 'lib/standard_id/jwt_service.rb', line 45

def self.algorithm_config
  SUPPORTED_ALGORITHMS[algorithm] || raise(ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}")
end

.asymmetric?Boolean

Returns:

  • (Boolean)


49
50
51
# File 'lib/standard_id/jwt_service.rb', line 49

def self.asymmetric?
  algorithm_config[:type] == :asymmetric
end

.claim_resolver_keysObject



149
150
151
152
153
154
155
# File 'lib/standard_id/jwt_service.rb', line 149

def self.claim_resolver_keys
  resolvers = StandardId.config.oauth.claim_resolvers
  keys = Hash.try_convert(resolvers)&.keys
  keys.compact.map(&:to_sym).uniq.excluding(*RESERVED_JWT_KEYS)
rescue StandardError
  []
end

.decode(token) ⇒ Object



97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/standard_id/jwt_service.rb', line 97

def self.decode(token)
  options = { algorithm: algorithm }

  if StandardId.config.issuer.present?
    options[:iss] = StandardId.config.issuer
    options[:verify_iss] = true
  end

  decoded = JWT.decode(token, verification_key, true, options)
  decoded.first.with_indifferent_access
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError, JWT::InvalidIssuerError
  nil
end

.decode_session(token) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/standard_id/jwt_service.rb', line 111

def self.decode_session(token)
  payload = decode(token)
  return unless payload

  scopes = if payload[:scope].is_a?(String)
    payload[:scope].split(" ")
  else
    Array(payload[:scope]).compact
  end

  session_class.new(
    **payload.slice(*claim_resolver_keys),
    account_id: payload[:sub],
    client_id: payload[:client_id],
    scopes: scopes,
    grant_type: payload[:grant_type],
    aud: payload[:aud]
  )
end

.encode(payload, expires_in: 1.hour) ⇒ Object



86
87
88
89
90
91
92
93
94
95
# File 'lib/standard_id/jwt_service.rb', line 86

def self.encode(payload, expires_in: 1.hour)
  payload[:exp] = expires_in.from_now.to_i
  payload[:iat] = Time.current.to_i
  payload[:iss] ||= StandardId.config.issuer if StandardId.config.issuer.present?

  headers = {}
  headers[:kid] = key_id if asymmetric?

  JWT.encode(payload, signing_key, algorithm, headers)
end

.jwksObject



131
132
133
134
135
136
137
138
# File 'lib/standard_id/jwt_service.rb', line 131

def self.jwks
  return nil unless asymmetric?

  @jwks ||= begin
    jwk = JWT::JWK.new(verification_key, kid: key_id)
    { keys: [jwk.export] }
  end
end

.key_idObject



72
73
74
75
76
77
78
# File 'lib/standard_id/jwt_service.rb', line 72

def self.key_id
  return nil unless asymmetric?

  # Generate stable key ID from public key fingerprint
  # Use public_to_pem which works for both RSA and EC keys
  @key_id ||= Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
end

.parse_private_key(key_source) ⇒ Object



142
143
144
145
146
147
# File 'lib/standard_id/jwt_service.rb', line 142

def self.parse_private_key(key_source)
  pem = key_source.is_a?(Pathname) ? File.read(key_source) : key_source
  key_class = algorithm_config[:key_class]

  key_class.new(pem)
end

.reset_cached_key!Object



80
81
82
83
84
# File 'lib/standard_id/jwt_service.rb', line 80

def self.reset_cached_key!
  @key_id = nil
  @signing_key_cache = nil
  @jwks = nil
end

.session_classObject



37
38
39
# File 'lib/standard_id/jwt_service.rb', line 37

def self.session_class
  SESSION_CLASS.value
end

.signing_keyObject



53
54
55
56
57
58
59
# File 'lib/standard_id/jwt_service.rb', line 53

def self.signing_key
  if asymmetric?
    @signing_key_cache ||= parse_private_key(StandardId.config.oauth.signing_key)
  else
    Rails.application.secret_key_base
  end
end

.verification_keyObject



61
62
63
64
65
66
67
68
69
70
# File 'lib/standard_id/jwt_service.rb', line 61

def self.verification_key
  if asymmetric?
    key = signing_key
    # For EC keys, the key itself can be used for verification
    # For RSA keys, we extract the public key
    key.is_a?(OpenSSL::PKey::EC) ? key : key.public_key
  else
    Rails.application.secret_key_base
  end
end