Class: Keypair

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
lib/keypair.rb

Overview

This class contains functionality needed for signing messages and publishing JWK.

Keypairs are considered valid based on their #not_before, #not_after and #expires_at attributes.

A keypair can be used for signing if:

  • The current time is greater than or equal to #not_before

  • The current time is less than or equal to #not_after

A keypair can be used for validation if:

By default, this means that when a key is created, it can be used for signing for 1 month and can still be used for signature validation 1 month after it is not used for signing (i.e. for 2 months since it started being used for signing).

If you need to sign messages, use the Keypair.current keypair for this. This method performs the rotation of the keypairs if required.

You can also use the jwt_encode and jwt_decode methods directly to encode and securely decode your payloads

Examples:

payload = { foo: 'bar' }
id_token = Keypair.jwt_encode(payload)
decoded = Keypair.jwt_decode(id_token)

Constant Summary collapse

ALGORITHM =

rubocop:disable Metrics/ClassLength

'RS256'
ROTATION_INTERVAL =
1.month

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#expires_atTime

The time after which the keypair may not be used for signature validation.

Returns:

  • (Time)

    the current value of expires_at



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

def expires_at
  @expires_at
end

#jwk_kidString

The public external id of the key used to find the associated key on decoding.

Returns:

  • (String)

    the current value of jwk_kid



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

def jwk_kid
  @jwk_kid
end

#not_afterTime

The time after which no payloads may be signed using the keypair.

Returns:

  • (Time)

    the current value of not_after



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

def not_after
  @not_after
end

#not_beforeTime

The time before which no payloads may be signed using the keypair.

Returns:

  • (Time)

    the current value of not_before



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

def not_before
  @not_before
end

Class Method Details

.cached_jwks(force: false) ⇒ Object



151
152
153
154
155
# File 'lib/keypair.rb', line 151

def self.cached_jwks(force: false)
  Rails.cache.fetch('keypairs/Keypair/jwks', force: force, skip_nil: true) do
    keyset
  end
end

.cached_keysetHash

Returns a cached version of the keyset.

Returns:

  • (Hash)

    a cached version of the keyset

See Also:

  • #keyset


103
104
105
106
107
# File 'lib/keypair.rb', line 103

def self.cached_keyset
  Rails.cache.fetch('keypairs/Keypair/keyset', expires_in: 12.hours) do
    keyset
  end
end

.currentKeypair

Returns the keypair used to sign messages and autorotates if it has expired.

Returns:

  • (Keypair)

    the keypair used to sign messages and autorotates if it has expired.



59
60
61
62
63
64
# File 'lib/keypair.rb', line 59

def self.current
  order(not_before: :asc)
    .where(arel_table[:not_before].lteq(Time.zone.now))
    .where(arel_table[:not_after].gteq(Time.zone.now))
    .last || create!
end

.jwk_loader_cachedObject

options will be ‘true` if a matching `kid` was not found github.com/jwt/ruby-jwt/blob/master/lib/jwt/jwk/key_finder.rb#L31



145
146
147
148
149
# File 'lib/keypair.rb', line 145

def self.jwk_loader_cached
  lambda do |options|
    cached_jwks(force: options[:invalidate]) || {}
  end
end

.jwt_decode(id_token, options = {}) ⇒ Hash

Decodes the payload and verifies the signature against the current valid keypairs.

Parameters:

  • id_token (String)

    A JWT that should be decoded.

  • options (Hash) (defaults to: {})

    options for decoding, passed to JWT::Decode.

Returns:

  • (Hash)

    Decoded payload hash with indifferent access.

Raises:

  • (JWT::DecodeError)

    or any of it’s subclasses if the decoding / validation fails.



130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/keypair.rb', line 130

def self.jwt_decode(id_token, options = {})
  # Add default decoding options
  options.reverse_merge!(
    # Change the default algorithm to match the encoding algorithm
    algorithm: ALGORITHM,
    # Load our own keyset as valid keys
    jwks: jwk_loader_cached,
    # If the `sub` is provided, validate that it matches the payload `sub`
    verify_sub: true
  )
  JWT.decode(id_token, nil, true, options).first.with_indifferent_access
end

.jwt_encode(payload) ⇒ String

Encodes the payload with the current keypair. It forewards the call to the instance method #jwt_encode.

Parameters:

  • payload (Hash)

    Hash which should be encoded.

Returns:

  • (String)

    Encoded JWT token with security credentials.



113
114
115
# File 'lib/keypair.rb', line 113

def self.jwt_encode(payload)
  current.jwt_encode(payload)
end

.jwt_encode_without_nonce(payload) ⇒ String

Encodes the payload with the current keypair. It forewards the call to the instance method #jwt_encode.

Parameters:

  • payload (Hash)

    Hash which should be encoded.

Returns:

  • (String)

    Encoded JWT token with security credentials.



121
122
123
# File 'lib/keypair.rb', line 121

def self.jwt_encode_without_nonce(payload)
  current.jwt_encode(payload, {}, nonce: false)
end

.keysetHash

The JWK Set of our valid keypairs.

Examples:

{
  keys: [{
    e: "AQAB",
    use: "sig",
    alg: "RS256",
    kty: "RSA",
    n: "oNqXxxWuX7LlovO5reRNauF6TEFa-RRRl8Dw==...",
    kid: "1516918956_0"
  }, {
    e: "AQAB",
    use: "sig",
    alg: "RS256",
    kty: "RSA",
    n: "kMfHwTp2dIYybtvU-xzF2E3dRJBNm6g5kTQi8itw==...",
    kid: "1516918956_1"
  }]
}

Returns:

  • (Hash)

See Also:



88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/keypair.rb', line 88

def self.keyset
  valid_keys = valid.order(not_before: :asc).to_a
  # If we don't have any keys or if we don't have a future key (i.e. the last key is the current key)
  while valid_keys.last.nil? || valid_keys.last.not_before <= Time.zone.now
    # There is an automatic fallback to Time.zone.now if not_before is not set
    valid_keys << create!(not_before: valid_keys.last&.not_after)
  end

  {
    keys: valid_keys.map(&:public_jwk_export)
  }
end

.validObject

Non-expired keypairs are considered valid and can be used to validate signatures and export public jwks.



56
# File 'lib/keypair.rb', line 56

scope :valid, -> { where(arel_table[:expires_at].gt(Time.zone.now)) }

Instance Method Details

#jwt_encode(payload, headers = {}, nonce: true) ⇒ Object

JWT encodes the payload with this keypair. It automatically adds the security attributes iat, exp and nonce to the payload. It automatically sets the kid in the header.

Parameters:

  • payload (Hash)

    you have to provide a hash since the security attributes have to be added.

  • headers (Hash) (defaults to: {})

    you can optionally add additional headers to the JWT.



162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/keypair.rb', line 162

def jwt_encode(payload, headers = {}, nonce: true)
  # Add security claims to payload
  payload = secure_payload(payload, nonce: nonce)

  # Add additional info into the headers
  headers.reverse_merge!(
    # Set the id of they key
    kid: jwk_kid
  )

  JWT.encode(payload, private_key, ALGORITHM, headers)
end

#private_keyOpenSSL::PKey::RSA

Returns OpenSSL::PKey::RSA instance loaded with our keypair.

Returns:

  • (OpenSSL::PKey::RSA)

    OpenSSL::PKey::RSA instance loaded with our keypair.



198
199
200
# File 'lib/keypair.rb', line 198

def private_key
  OpenSSL::PKey::RSA.new(_keypair)
end

#public_jwk_exportObject

Public representation of the keypair in the JWK format. We append the alg, and use parameters to our JWK to indicate that our intended use is to generate signatures using RS256.

alg

This (algorithm) parameter identifies the algorithm intended for use with the key. It is based in the ALGORITHM. The IMS Security framework specifies that the alg value SHOULD be the default of RS256. Use of this member is OPTIONAL.

use

This (public key use) parameter identifies the intended use of the public key. Use of this member is OPTIONAL, unless the application requires its presence.



190
191
192
193
194
195
# File 'lib/keypair.rb', line 190

def public_jwk_export
  public_jwk.export.merge(
    alg: ALGORITHM,
    use: 'sig'
  )
end

#public_keyOpenSSL::PKey::RSA

Returns OpenSSL::PKey::RSA instance loaded with the public part our keypair.

Returns:

  • (OpenSSL::PKey::RSA)

    OpenSSL::PKey::RSA instance loaded with the public part our keypair.



203
# File 'lib/keypair.rb', line 203

delegate :public_key, to: :private_key