omniauth_strong_auth_oidc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +34 -0
  3. data/.rspec +3 -0
  4. data/CHANGELOG.md +6 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Gemfile +6 -0
  7. data/LICENSE +21 -0
  8. data/README.md +313 -0
  9. data/Rakefile +12 -0
  10. data/lib/generators/omniauth_strong_auth_oidc/install_generator.rb +73 -0
  11. data/lib/generators/omniauth_strong_auth_oidc/templates/relying_party_entity_statement_controller.rb.tt +61 -0
  12. data/lib/omniauth/strategies/strong_auth_oidc.rb +210 -0
  13. data/lib/omniauth_strong_auth_oidc/entity_statement.rb +22 -0
  14. data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/base.rb +37 -0
  15. data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/federation_url_fetcher.rb +29 -0
  16. data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/file_fetcher.rb +22 -0
  17. data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher.rb +9 -0
  18. data/lib/omniauth_strong_auth_oidc/jwks_cache.rb +31 -0
  19. data/lib/omniauth_strong_auth_oidc/jwks_fetcher.rb +87 -0
  20. data/lib/omniauth_strong_auth_oidc/relying_party_entity_statement_generator.rb +77 -0
  21. data/lib/omniauth_strong_auth_oidc/relying_party_jwks_generator.rb +50 -0
  22. data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/base.rb +101 -0
  23. data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/cache_storage.rb +235 -0
  24. data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/env_storage.rb +112 -0
  25. data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage.rb +10 -0
  26. data/lib/omniauth_strong_auth_oidc/version.rb +3 -0
  27. data/lib/omniauth_strong_auth_oidc.rb +15 -0
  28. data/omniauth_strong_auth_oidc.gemspec +32 -0
  29. metadata +494 -0
@@ -0,0 +1,210 @@
1
+ require 'omniauth-oauth2'
2
+ require 'jwt'
3
+ require 'jwe'
4
+
5
+ module OmniAuth
6
+ module Strategies
7
+ class StrongAuthOidc < OmniAuth::Strategies::OAuth2
8
+ option :name, :strong_auth_oidc
9
+
10
+ # Custom options for Telia authentication
11
+ option :scope, 'openid'
12
+ option :response_type, 'code'
13
+ option :relying_party_jwks_storage
14
+ option :provider_jwks_loader
15
+ option :provider_entity_statement_fetcher, nil
16
+ option :redirect_uri, nil
17
+
18
+ def redirect_uri
19
+ return options.redirect_uri if options.redirect_uri.present?
20
+
21
+ # omniauth adds query prarameters from the original request to the callback_url
22
+ # if we don't strip them, the OIDC provider will reject the redirect_uri mismatch
23
+ uri = URI(callback_url)
24
+ uri.query = nil
25
+ uri.to_s
26
+ end
27
+
28
+ # Override authorize_params to support signed request
29
+ def authorize_params
30
+ params = super.dup
31
+ params[:request] = build_authorize_param_jwt_request(params)
32
+ params[:redirect_uri] = redirect_uri
33
+
34
+ # Remove parameters that are now in the signed request JWT
35
+ # Keep client_id and optionally state/nonce outside the JWT
36
+ params.delete(:scope)
37
+ params.delete(:response_type)
38
+ params.delete(:acr_values)
39
+ params.delete(:audience)
40
+ params
41
+ end
42
+
43
+ # Build signed request JWT for authorization
44
+ def build_authorize_param_jwt_request(params)
45
+ now = Time.now.to_i
46
+
47
+ jwt_payload = {
48
+ iss: options.client_id,
49
+ aud: entity_statement ? entity_statement.openid_configuration.dig(:issuer) : options.authorize_params[:audience],
50
+ client_id: options.client_id,
51
+ response_type: options.response_type,
52
+ scope: options.scope,
53
+ redirect_uri: redirect_uri,
54
+ jti: SecureRandom.uuid,
55
+ exp: now + 900, # 15 minutes
56
+ iat: now,
57
+ nbf: now
58
+ }
59
+
60
+ # Add acr_values if present
61
+ if params['acr_values']
62
+ jwt_payload[:acr_values] = params['acr_values']
63
+ end
64
+
65
+ # Add state if present
66
+ if params[:state]
67
+ jwt_payload[:state] = params[:state]
68
+ end
69
+
70
+ # Add nonce for OIDC
71
+ jwt_payload[:nonce] = SecureRandom.hex(16)
72
+
73
+ JWT.encode(jwt_payload, signing_key.keypair, 'RS256', kid: signing_key.kid)
74
+ end
75
+
76
+ # Override token_params to use JWT bearer authentication
77
+ def token_params
78
+ super.tap do |params|
79
+ params[:grant_type] = 'authorization_code'
80
+ params[:redirect_uri] = redirect_uri
81
+ params[:client_id] = options.client_id
82
+ params[:client_assertion_type] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
83
+ params[:client_assertion] = client_assertion
84
+ end
85
+ end
86
+
87
+ # Build client assertion JWT for authentication
88
+ def client_assertion
89
+ now = Time.now.to_i
90
+
91
+ jwt_payload = {
92
+ iss: options.client_id,
93
+ sub: options.client_id,
94
+ aud: token_url,
95
+ jti: SecureRandom.uuid,
96
+ exp: now + 900, # 15 minutes
97
+ iat: now
98
+ }
99
+
100
+ JWT.encode(jwt_payload, signing_key.keypair, 'RS256', kid: signing_key.kid)
101
+ end
102
+
103
+ # Get user info from ID token
104
+ def raw_info
105
+ @raw_info ||= begin
106
+ # The ID token is returned in the token response
107
+ id_token = access_token.params['id_token']
108
+
109
+ if id_token
110
+ # Decrypt and verify the ID token
111
+ decrypt_and_verify_jwt(id_token)
112
+ else
113
+ raise "No id_token in token response"
114
+ end
115
+ end
116
+ end
117
+
118
+ def token_url
119
+ if entity_statement
120
+ return entity_statement.openid_configuration.dig(:token_endpoint)
121
+ end
122
+ client.connection.build_url(options.client_options.token_url)
123
+ end
124
+
125
+ # Decrypt and verify JWT response
126
+ def decrypt_and_verify_jwt(jwt_string)
127
+ # if key was rotated the first attempt can fail
128
+ begin
129
+ encryption_key_index = 0
130
+ # Telia returns JWE (encrypted JWT) which contains a JWS (signed JWT)
131
+ # If our private key was rotated, we may need to try multiple keys
132
+ inner_jwt = JWE.decrypt(jwt_string, encryption_keys[encryption_key_index].keypair)
133
+
134
+ # Then verify the inner JWT signature with JWKS
135
+ jwt_data = JWT.decode(
136
+ inner_jwt,
137
+ nil,
138
+ true,
139
+ algorithms: ['RS256'],
140
+ jwks: options.provider_jwks_loader
141
+ )
142
+
143
+ jwt_data.first
144
+ rescue OpenSSL::PKey::RSAError => e
145
+ encryption_key_index += 1
146
+ if encryption_keys[encryption_key_index]
147
+ retry
148
+ else
149
+ raise e
150
+ end
151
+ end
152
+ end
153
+
154
+ # Build auth hash
155
+ uid { raw_info['sub'] }
156
+
157
+ info do
158
+ {
159
+ identity_number: raw_info['urn:oid:1.2.246.21'],
160
+ first_name: raw_info['urn:oid:1.2.246.575.1.14'],
161
+ last_name: raw_info['urn:oid:2.5.4.4']
162
+ }
163
+ end
164
+
165
+ extra do
166
+ {
167
+ raw_info: raw_info
168
+ }
169
+ end
170
+
171
+ # Override client to use custom identifier
172
+ # @return [OAuth2::Client]
173
+ def client
174
+ client_options = {}
175
+ if entity_statement
176
+ # Fetch OIDC provider configuration from entity statement
177
+ oidc_provider_config = entity_statement.openid_configuration
178
+ client_options[:site] = oidc_provider_config.dig(:issuer)
179
+ client_options[:authorize_url] = oidc_provider_config.dig(:authorization_endpoint)
180
+ client_options[:token_url] = oidc_provider_config.dig(:token_endpoint)
181
+ client_options[:userinfo_url] = oidc_provider_config.dig(:userinfo_endpoint)
182
+ else
183
+ client_options = options.client_options.dup
184
+ end
185
+ ::OAuth2::Client.new(
186
+ options.client_id,
187
+ nil, # No client secret needed for JWT bearer
188
+ client_options.deep_symbolize_keys
189
+ )
190
+ end
191
+
192
+ private
193
+
194
+ def entity_statement
195
+ return nil unless options.provider_entity_statement_fetcher
196
+ options.provider_entity_statement_fetcher.entity_statement
197
+ end
198
+
199
+ # Get signing key from key storage
200
+ def signing_key
201
+ options.relying_party_jwks_storage.current_jwks.select { |key| key[:use] == 'sig' }.first
202
+ end
203
+
204
+ # Get encryption key from key storage
205
+ def encryption_keys
206
+ options.relying_party_jwks_storage.jwks.select { |key| key[:use] == 'enc' }
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,22 @@
1
+ module OmniauthStrongAuthOidc
2
+ class EntityStatement
3
+ attr_reader :entity_statement_data, :metadata_key
4
+
5
+ def initialize(entity_statement_data, metadata_key: :openid_provider)
6
+ @entity_statement_data = entity_statement_data
7
+ @metadata_key = metadata_key
8
+ end
9
+
10
+ def openid_configuration
11
+ entity_statement_data.dig(:metadata, metadata_key)
12
+ end
13
+
14
+ def signed_jwks_uri
15
+ entity_statement_data.dig(:metadata, metadata_key, :signed_jwks_uri)
16
+ end
17
+
18
+ def configuration_jwks
19
+ JWT::JWK::Set.new(entity_statement_data.dig(:jwks))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ module OmniauthStrongAuthOidc
2
+ # Abstract interface for OpenID key storage
3
+ # Implementations must provide a JWK set containing both signing and encryption keys
4
+ module EntityStatementFetcher
5
+ class Base
6
+ attr_reader :entity_statement
7
+ attr_reader :statement_type
8
+
9
+ def initialize(statement_type: :openid_provider)
10
+ @statement_type = statement_type
11
+ end
12
+
13
+ def entity_statement
14
+ @entity_statement ||= EntityStatement.new(fetch_entity_statement, metadata_key: statement_type)
15
+ end
16
+
17
+ def reload!
18
+ @entity_statement = nil
19
+ end
20
+
21
+ private def fetch_entity_statement
22
+ raise NotImplementedError, "Subclasses must implement fetch_entity_statement"
23
+ end
24
+
25
+ private def decode_with_verification(signed_jwt, entity_statement)
26
+ jwks = entity_statement['jwks']
27
+ JWT.decode(
28
+ signed_jwt,
29
+ nil,
30
+ true,
31
+ algorithms: ['RS256'],
32
+ jwks: jwks
33
+ ).first.deep_symbolize_keys
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ module OmniauthStrongAuthOidc
2
+ module EntityStatementFetcher
3
+ class FederationUrlFetcher < Base
4
+ attr_reader :entity_statement_url
5
+
6
+ def initialize(issuer_url:, entity_statement_path: "/.well-known/openid-federation", statement_type: :openid_provider)
7
+ @entity_statement_url = "#{issuer_url}#{entity_statement_path}"
8
+ super(statement_type: statement_type)
9
+ end
10
+
11
+ # Fetch Telia's Entity Statement
12
+ # The Entity Statement is a signed JWT containing metadata including JWKS
13
+ # @return [Hash] Decoded entity statement payload
14
+ private def fetch_entity_statement
15
+ response = HTTP.get(entity_statement_url)
16
+
17
+ unless response.status.success?
18
+ raise "Failed to fetch entity statement: #{response.status}"
19
+ end
20
+
21
+ # Decode without verification (trusting HTTPS) to get the payload
22
+ entity_statement_raw = response.body.to_s
23
+
24
+ # Check signature with the embedded JWKS
25
+ decode_with_verification(entity_statement_raw, JWT.decode(entity_statement_raw, nil, false).first)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ module OmniauthStrongAuthOidc
2
+ module EntityStatementFetcher
3
+ class FileFetcher < Base
4
+ attr_reader :entity_statement_url
5
+
6
+ def initialize(path_to_file:, statement_type: :openid_provider)
7
+ @path_to_file = path_to_file
8
+ super(statement_type: statement_type)
9
+ end
10
+
11
+ # Fetch Telia's Entity Statement
12
+ # The Entity Statement is a signed JWT containing metadata including JWKS
13
+ # @return [Hash] Decoded entity statement payload
14
+ private def fetch_entity_statement
15
+ File.open(@path_to_file, 'r') do |file|
16
+ entity_statement_raw = file.read
17
+ decode_with_verification(entity_statement_raw, JWT.decode(entity_statement_raw, nil, false).first)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'entity_statement_fetcher/base'
2
+ require_relative 'entity_statement_fetcher/federation_url_fetcher'
3
+ require_relative 'entity_statement_fetcher/file_fetcher'
4
+
5
+ module OmniauthStrongAuthOidc
6
+ module EntityStatementFetcher
7
+
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ module OmniauthStrongAuthOidc
2
+ class JwksCache
3
+ # Implmlement call method for use as a JWK loader in JWT.decode
4
+ # Example:
5
+ # JWT.decode(token, nil, true, { jwks: OmniauthStrongAuthOidc::JwksCache.new(jwks_source) })
6
+ # where jwks_source responds to .jwks and .reload! (optional)
7
+ def initialize(jwks_source, timeout_sec = 300)
8
+ @jwks_source = jwks_source
9
+ @timeout_sec = timeout_sec
10
+ @cache_last_update = 0
11
+ end
12
+
13
+ def call(options = {})
14
+ # The jwk loader would fetch the set of JWKs from a trusted source.
15
+ # To avoid malicious requests triggering cache invalidations there needs to be
16
+ # some kind of grace time or other logic for determining the validity of the invalidation.
17
+ # This example only allows cache invalidations every 5 minutes.
18
+ # and at least once a day.
19
+ if (options[:kid_not_found] && @cache_last_update < Time.now.to_i - @timeout_sec)
20
+ @cached_keys = nil
21
+ @jwks_source.reload! if @jwks_source.respond_to?(:reload!)
22
+ end
23
+ @cached_keys ||= begin
24
+ @cache_last_update = Time.now.to_i
25
+ # Replace with your own JWKS fetching routine
26
+ jwks = @jwks_source.jwks
27
+ jwks.select { |key| key[:use] == 'sig' } # Signing Keys only
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,87 @@
1
+ require 'http'
2
+ require 'jwt'
3
+
4
+ module OmniauthStrongAuthOidc
5
+ # Service to fetch and verify Telia's JWKS
6
+ class JwksFetcher
7
+ attr_reader :signed_jwks_uri
8
+ attr_reader :configuration_jwks
9
+ attr_reader :entity_statement_fetcher
10
+
11
+ def initialize(signed_jwks_uri: nil, configuration_jwks: nil, entity_statement_fetcher: nil)
12
+ # either entity stament or signed_jwks_uri + configuration_jwks must be provided
13
+ if entity_statement_fetcher && (signed_jwks_uri || configuration_jwks)
14
+ raise ArgumentError, "Provide either entity_statement_fetcher or both signed_jwks_uri and configuration_jwks, not both"
15
+ end
16
+
17
+ if !entity_statement_fetcher && (!signed_jwks_uri || !configuration_jwks)
18
+ raise ArgumentError, "Must provide either entity_statement_fetcher or both signed_jwks_uri and configuration_jwks"
19
+ end
20
+
21
+ @signed_jwks_uri = signed_jwks_uri
22
+ @configuration_jwks = configuration_jwks
23
+ @entity_statement_fetcher = entity_statement_fetcher
24
+ end
25
+
26
+ def jwks
27
+ @jwks ||= fetch_and_verify_jwks
28
+ end
29
+
30
+ def reload!
31
+ if entity_statement_fetcher
32
+ entity_statement_fetcher.reload!
33
+ end
34
+ @jwks = nil
35
+ end
36
+
37
+ def signed_jwks_uri
38
+ if entity_statement_fetcher
39
+ return entity_statement_fetcher.entity_statement.signed_jwks_uri
40
+ end
41
+ @signed_jwks_uri
42
+ end
43
+
44
+ def configuration_jwks
45
+ if entity_statement_fetcher
46
+ return entity_statement_fetcher.entity_statement.configuration_jwks
47
+ end
48
+ @configuration_jwks
49
+ end
50
+
51
+ private
52
+
53
+ def fetch_and_verify_jwks
54
+ unless signed_jwks_uri
55
+ raise "No signed_jwks_uri found in entity statement metadata"
56
+ end
57
+
58
+ # Fetch the signed JWKS
59
+ signed_response = HTTP.get(signed_jwks_uri)
60
+
61
+ unless signed_response.status.success?
62
+ raise "Failed to fetch signed JWKS"
63
+ end
64
+
65
+ # Verify and extract JWKS from the signed JWT
66
+ verify_signed_jwks(signed_response.body.to_s, configuration_jwks)
67
+ end
68
+
69
+ # Verify and extract JWKS from signed JWT
70
+ # Uses the JWKS from the entity statement to verify the signature
71
+ def verify_signed_jwks(signed_jwt, configuration_jwks)
72
+ #TODO: Check if configuration_jwks can expire or be rotated
73
+ decoded = nil
74
+
75
+ # Decode and verify the signed JWKS JWT using entity statement's JWKS
76
+ decoded = JWT.decode(
77
+ signed_jwt,
78
+ nil,
79
+ true,
80
+ algorithms: ['RS256'],
81
+ jwks: configuration_jwks
82
+ )
83
+
84
+ JWT::JWK::Set.new(decoded.first)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,77 @@
1
+ module OmniauthStrongAuthOidc
2
+ # Generates OpenID Federation Entity Statement for the relying party (client)
3
+ # This statement contains client metadata and a link to the client's JWKS
4
+ class RelyingPartyEntityStatementGenerator
5
+ attr_reader :iss, :org_name, :jwks_uri, :signed_jwks_uri, :redirect_uris, :configuration_jwks_storage
6
+
7
+ def initialize(iss:, org_name:, jwks_uri:, signed_jwks_uri:, redirect_uris:, configuration_jwks_storage:)
8
+ @iss = iss
9
+ @org_name = org_name
10
+ @jwks_uri = jwks_uri
11
+ @signed_jwks_uri = signed_jwks_uri
12
+ @redirect_uris = redirect_uris
13
+ @configuration_jwks_storage = configuration_jwks_storage
14
+ end
15
+
16
+ # Generate the entity statement JWT
17
+ # @return [String] Signed JWT entity statement
18
+ def generate
19
+ now = Time.now.to_i
20
+
21
+ {
22
+ iss: iss,
23
+ sub: iss,
24
+ iat: now,
25
+ exp: now + (365 * 24 * 60 * 60), # Valid for 1 year
26
+ metadata: {
27
+ openid_relying_party: openid_relying_party_metadata
28
+ },
29
+ jwks: {
30
+ keys: signing_keys_for_entity_statement
31
+ }
32
+ }
33
+ end
34
+
35
+ def generate_signed
36
+ sign_entity_statement(generate)
37
+ end
38
+
39
+ private
40
+
41
+ def openid_relying_party_metadata
42
+ {
43
+ client_name: org_name,
44
+ application_type: "web",
45
+ jwks_uri: jwks_uri,
46
+ signed_jwks_uri: signed_jwks_uri,
47
+ redirect_uris: redirect_uris,
48
+ response_types: ['code'],
49
+ grant_types: ['authorization_code'],
50
+ token_endpoint_auth_method: 'private_key_jwt'
51
+ }
52
+ end
53
+
54
+ def signing_keys_for_entity_statement
55
+ # Export the entity statement signing key (public part)
56
+ # This is the key used to verify the entity statement signature
57
+ configuration_jwks_storage.current_jwks.export[:keys]
58
+ end
59
+
60
+ def sign_entity_statement(payload)
61
+ # Use the separate entity statement signing key
62
+ current_jwks = configuration_jwks_storage.current_jwks
63
+ signing_jwk = current_jwks.select { |k| k[:use] == 'sig' }.first
64
+
65
+ signing_key = signing_jwk.keypair
66
+ kid = signing_jwk[:kid]
67
+
68
+ headers = {
69
+ typ: 'entity-statement+jwt',
70
+ kid: kid,
71
+ alg: 'RS256'
72
+ }
73
+
74
+ JWT.encode(payload, signing_key, 'RS256', headers)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,50 @@
1
+ module OmniauthStrongAuthOidc
2
+ # Generates a signed JWKS JWT containing the client's public keys
3
+ # This is separate from the plain JWKS endpoint and is signed by the entity statement key
4
+ class RelyingPartyJwksGenerator
5
+ attr_reader :relying_party_jwks_storage, :relying_party_configuration_jwks_storage, :issuer
6
+
7
+ def initialize(relying_party_jwks_storage:, relying_party_configuration_jwks_storage:, issuer:)
8
+ @relying_party_jwks_storage = relying_party_jwks_storage
9
+ @relying_party_configuration_jwks_storage = relying_party_configuration_jwks_storage
10
+ @issuer = issuer
11
+ end
12
+
13
+ # Generate the signed JWKS JWT
14
+ # @return [String] Signed JWT containing JWKS
15
+ def generate
16
+ now = Time.now.to_i
17
+
18
+ {
19
+ iss: issuer,
20
+ sub: issuer,
21
+ iat: now
22
+ }.merge(
23
+ relying_party_jwks_storage.current_jwks.export,
24
+ )
25
+ end
26
+
27
+ def generate_signed
28
+ sign_jwks(generate)
29
+ end
30
+
31
+ private
32
+
33
+ def sign_jwks(payload)
34
+ # Use the entity statement key for signing
35
+ entity_jwks = relying_party_configuration_jwks_storage.jwks
36
+ signing_jwk = entity_jwks.select { |k| k[:use] == 'sig' }.first
37
+
38
+ signing_key = signing_jwk.keypair
39
+ kid = signing_jwk[:kid]
40
+
41
+ headers = {
42
+ typ: 'jwks+jwt',
43
+ kid: kid,
44
+ alg: 'RS256'
45
+ }
46
+
47
+ JWT.encode(payload, signing_key, 'RS256', headers)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,101 @@
1
+ module OmniauthStrongAuthOidc
2
+ # Abstract interface for OpenID key storage
3
+ # Implementations must provide a JWK set containing both signing and encryption keys
4
+ module RelyingPartyJwksStorage
5
+ class Base
6
+ MINIMUM_KEY_LENGTH = 2048
7
+ DEFAULT_KEY_LENGTH = 4096
8
+
9
+ class << self
10
+ attr_accessor :instance
11
+ end
12
+
13
+ def support_rotation?
14
+ false
15
+ end
16
+
17
+ # Returns the JWK set containing all keys (signing + encryption, current + previous)
18
+ # @return [JWT::JWK::Set]
19
+ def jwks
20
+ raise NotImplementedError, "#{self.class} must implement #jwks"
21
+ end
22
+
23
+ # Returns first signing and encryption keys only
24
+ # Current keys alsways go first
25
+ def current_jwks
26
+ taken_keys = []
27
+ current_jwks = jwks.select do |key|
28
+ if (key[:use] == 'sig' && !taken_keys.include?('sig')) ||
29
+ (key[:use] == 'enc' && !taken_keys.include?('enc'))
30
+ taken_keys << key[:use]
31
+ true
32
+ else
33
+ false
34
+ end
35
+ end
36
+ JWT::JWK::Set.new(current_jwks)
37
+ end
38
+
39
+ # Rotates the signing key
40
+ # @return [void]
41
+ def rotate_signing_key!
42
+ raise NotImplementedError, "#{self.class} must implement #rotate_signing_key!"
43
+ end
44
+
45
+ # Rotates the encryption key
46
+ # @return [void]
47
+ def rotate_encryption_key!
48
+ raise NotImplementedError, "#{self.class} must implement #rotate_encryption_key!"
49
+ end
50
+
51
+ protected
52
+
53
+ # Generates a new RSA key pair with the specified key length
54
+ # @param key_length [Integer] The length of the key in bits (minimum 2048)
55
+ # @return [OpenSSL::PKey::RSA]
56
+ def generate_rsa_key(key_length = DEFAULT_KEY_LENGTH)
57
+ raise ArgumentError, "Key length must be at least #{MINIMUM_KEY_LENGTH} bits" if key_length < MINIMUM_KEY_LENGTH
58
+
59
+ OpenSSL::PKey::RSA.new(key_length)
60
+ end
61
+
62
+ # Converts an RSA key to a JWK with the specified parameters
63
+ # Uses JWT's built-in kid generation if not provided
64
+ # @param key [OpenSSL::PKey::RSA] The RSA key to convert
65
+ # @param kid [String, nil] The key ID (optional, will auto-generate if nil)
66
+ # @param use [String] The key use ('sig' for signing, 'enc' for encryption)
67
+ # @return [JWT::JWK]
68
+ def key_to_jwk(key, kid: nil, use:)
69
+ if kid
70
+ JWT::JWK.new(key, { use: use, kid: kid })
71
+ else
72
+ JWT::JWK.new(key, { use: use })
73
+ end
74
+ end
75
+
76
+ # Creates a JWK set from signing and encryption key data
77
+ # @param signing_key_data [Array<Hash>] Array of signing key data hashes
78
+ # @param encryption_key_data [Array<Hash>] Array of encryption key data hashes
79
+ # @return [JWT::JWK::Set]
80
+ def create_jwk_set(signing_key_data:, encryption_key_data:)
81
+ jwks = []
82
+
83
+ # Add signing keys
84
+ signing_key_data.each do |key_data|
85
+ key = OpenSSL::PKey::RSA.new(key_data[:pem])
86
+ # Use kid from key_data if available (for backward compatibility)
87
+ jwks << key_to_jwk(key, kid: key_data[:kid], use: 'sig')
88
+ end
89
+
90
+ # Add encryption keys
91
+ encryption_key_data.each do |key_data|
92
+ key = OpenSSL::PKey::RSA.new(key_data[:pem])
93
+ # Use kid from key_data if available (for backward compatibility)
94
+ jwks << key_to_jwk(key, kid: key_data[:kid], use: 'enc')
95
+ end
96
+
97
+ JWT::JWK::Set.new(jwks)
98
+ end
99
+ end
100
+ end
101
+ end