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,235 @@
1
+ module OmniauthStrongAuthOidc
2
+ module RelyingPartyJwksStorage
3
+ # Cached implementation of KeyStorage that stores keys in Rails cache
4
+ # Returns a single JWT::JWK::Set containing both signing and encryption keys
5
+ # Supports key rotation while keeping previous keys available
6
+ class CacheStorage < Base
7
+ CACHE_PREFIX = 'telia_oidc:keys'
8
+ CURRENT_SIGNING_KEY = "#{CACHE_PREFIX}:signing:current"
9
+ PREVIOUS_SIGNING_KEYS = "#{CACHE_PREFIX}:signing:previous"
10
+ CURRENT_ENCRYPTION_KEY = "#{CACHE_PREFIX}:encryption:current"
11
+ PREVIOUS_ENCRYPTION_KEYS = "#{CACHE_PREFIX}:encryption:previous"
12
+ MAX_PREVIOUS_KEYS = 2 # Keep up to 2 previous keys for rotation
13
+
14
+ attr_reader :cache_store
15
+
16
+ # @param key_length [Integer] RSA key length in bits (default: 4096)
17
+ # @param cache_store [ActiveSupport::Cache::Store] Cache store to use (Rails.cache for example)
18
+ def initialize(key_length: DEFAULT_KEY_LENGTH, cache_store:)
19
+ @key_length = key_length
20
+ @cache_store = cache_store
21
+ validate_key_length!
22
+ ensure_keys_exist!
23
+ end
24
+
25
+ def support_rotation?
26
+ true
27
+ end
28
+
29
+ # Returns the JWK set containing all keys (signing + encryption, current + previous)
30
+ # @return [JWT::JWK::Set]
31
+ def jwks
32
+ @jwks ||= create_jwk_set(
33
+ signing_key_data: all_signing_keys,
34
+ encryption_key_data: all_encryption_keys
35
+ )
36
+ end
37
+
38
+ # Rotates the signing key, moving current to previous
39
+ # @return [JWT::JWK] The new signing JWK
40
+ def rotate_signing_key!
41
+ old_key_data = load_key_data(CURRENT_SIGNING_KEY)
42
+ new_key = generate_rsa_key(@key_length)
43
+ new_key_data = {
44
+ pem: new_key.to_pem,
45
+ created_at: Time.now.to_i
46
+ }
47
+
48
+ # Move current key to previous keys list
49
+ add_to_previous_keys(PREVIOUS_SIGNING_KEYS, old_key_data)
50
+
51
+ # Store new key as current
52
+ store_key_data(CURRENT_SIGNING_KEY, new_key_data)
53
+
54
+ # Clear cached JWK set
55
+ clear_jwks_cache!
56
+
57
+ JWT::JWK.new(new_key, use: 'sig') # Return JWK representation of
58
+ end
59
+
60
+ # Rotates the encryption key, moving current to previous
61
+ # @return [JWT::JWK] The new encryption JWK
62
+ def rotate_encryption_key!
63
+ old_key_data = load_key_data(CURRENT_ENCRYPTION_KEY)
64
+ new_key = generate_rsa_key(@key_length)
65
+ new_key_data = {
66
+ pem: new_key.to_pem,
67
+ created_at: Time.now.to_i
68
+ }
69
+
70
+ # Move current key to previous keys list
71
+ add_to_previous_keys(PREVIOUS_ENCRYPTION_KEYS, old_key_data)
72
+
73
+ # Store new key as current
74
+ store_key_data(CURRENT_ENCRYPTION_KEY, new_key_data)
75
+
76
+ # Clear cached JWK set
77
+ clear_jwks_cache!
78
+
79
+ JWT::JWK.new(new_key, use: 'enc') # Return JWK representation of
80
+ end
81
+
82
+ # Clear cached JWK set (useful for testing or after rotation)
83
+ # @return [void]
84
+ def clear_jwks_cache!
85
+ @jwks = nil
86
+ end
87
+
88
+ # Completely removes all keys from storage (use with caution!)
89
+ # @return [void]
90
+ def self.clear_all_keys!(cache_store)
91
+ store = cache_store
92
+ store.delete(CURRENT_SIGNING_KEY)
93
+ store.delete(PREVIOUS_SIGNING_KEYS)
94
+ store.delete(CURRENT_ENCRYPTION_KEY)
95
+ store.delete(PREVIOUS_ENCRYPTION_KEYS)
96
+ end
97
+
98
+ private
99
+
100
+ # Returns the current signing key (for internal use)
101
+ # @return [OpenSSL::PKey::RSA]
102
+ def current_signing_key
103
+ load_key(CURRENT_SIGNING_KEY)
104
+ end
105
+
106
+ # Returns the current encryption key (for internal use)
107
+ # @return [OpenSSL::PKey::RSA]
108
+ def current_encryption_key
109
+ load_key(CURRENT_ENCRYPTION_KEY)
110
+ end
111
+
112
+ # Returns all signing keys (current + previous) with their metadata
113
+ # @return [Array<OpenSSL::PKey::RSA>]
114
+ def all_signing_keys
115
+ current_data = load_key_data(CURRENT_SIGNING_KEY)
116
+ previous_data = load_previous_key_data(PREVIOUS_SIGNING_KEYS)
117
+
118
+ [current_data] + previous_data
119
+ end
120
+
121
+ # Returns all encryption keys (current + previous) with their metadata
122
+ # @return [Array<OpenSSL::PKey::RSA>]
123
+ def all_encryption_keys
124
+ current_data = load_key_data(CURRENT_ENCRYPTION_KEY)
125
+ previous_data = load_previous_key_data(PREVIOUS_ENCRYPTION_KEYS)
126
+
127
+ [current_data] + previous_data
128
+ end
129
+
130
+ def validate_key_length!
131
+ raise ArgumentError, "Key length must be at least #{MINIMUM_KEY_LENGTH} bits" if @key_length < MINIMUM_KEY_LENGTH
132
+ end
133
+
134
+ def ensure_keys_exist!
135
+ # Generate signing key if it doesn't exist
136
+ unless cache_store.exist?(CURRENT_SIGNING_KEY)
137
+ key_data = {
138
+ pem: generate_rsa_key(@key_length).to_pem,
139
+ created_at: Time.now.to_i
140
+ }
141
+ store_key_data(CURRENT_SIGNING_KEY, key_data)
142
+ end
143
+
144
+ # Generate encryption key if it doesn't exist
145
+ unless cache_store.exist?(CURRENT_ENCRYPTION_KEY)
146
+ key_data = {
147
+ pem: generate_rsa_key(@key_length).to_pem,
148
+ created_at: Time.now.to_i
149
+ }
150
+ store_key_data(CURRENT_ENCRYPTION_KEY, key_data)
151
+ end
152
+
153
+ # Initialize previous keys arrays if they don't exist
154
+ cache_store.write(PREVIOUS_SIGNING_KEYS, []) unless cache_store.exist?(PREVIOUS_SIGNING_KEYS)
155
+ cache_store.write(PREVIOUS_ENCRYPTION_KEYS, []) unless cache_store.exist?(PREVIOUS_ENCRYPTION_KEYS)
156
+ end
157
+
158
+ def load_key(cache_key)
159
+ key_data = cache_store.read(cache_key)
160
+ raise "Key not found in cache: #{cache_key}" unless key_data
161
+
162
+ # Handle legacy PEM-only format
163
+ if key_data.is_a?(String)
164
+ return OpenSSL::PKey::RSA.new(key_data)
165
+ end
166
+
167
+ OpenSSL::PKey::RSA.new(key_data[:pem])
168
+ end
169
+
170
+ def load_key_data(cache_key)
171
+ key_data = cache_store.read(cache_key)
172
+ raise "Key not found in cache: #{cache_key}" unless key_data
173
+
174
+ # Handle legacy PEM-only format
175
+ if key_data.is_a?(String)
176
+ return { pem: key_data, kid: nil, created_at: nil }
177
+ end
178
+
179
+ key_data
180
+ end
181
+
182
+ def store_key(cache_key, key)
183
+ # This method is for backward compatibility
184
+ pem_data = key.to_pem
185
+ cache_store.write(cache_key, pem_data)
186
+ end
187
+
188
+ def store_key_data(cache_key, key_data)
189
+ cache_store.write(cache_key, key_data)
190
+ end
191
+
192
+ def previous_signing_keys
193
+ load_previous_keys(PREVIOUS_SIGNING_KEYS)
194
+ end
195
+
196
+ def previous_encryption_keys
197
+ load_previous_keys(PREVIOUS_ENCRYPTION_KEYS)
198
+ end
199
+
200
+ def load_previous_keys(cache_key)
201
+ key_data_list = cache_store.read(cache_key) || []
202
+ key_data_list.map do |data|
203
+ # Handle legacy PEM-only format
204
+ if data.is_a?(String)
205
+ OpenSSL::PKey::RSA.new(data)
206
+ else
207
+ OpenSSL::PKey::RSA.new(data[:pem])
208
+ end
209
+ end
210
+ end
211
+
212
+ def load_previous_key_data(cache_key)
213
+ key_data_list = cache_store.read(cache_key) || []
214
+ key_data_list.map do |data|
215
+ # Handle legacy PEM-only format
216
+ if data.is_a?(String)
217
+ { pem: data, kid: nil, created_at: nil }
218
+ else
219
+ data
220
+ end
221
+ end
222
+ end
223
+
224
+ def add_to_previous_keys(cache_key, key_data)
225
+ previous_keys = cache_store.read(cache_key) || []
226
+ previous_keys.unshift(key_data)
227
+
228
+ # Keep only the most recent keys
229
+ previous_keys = previous_keys.first(MAX_PREVIOUS_KEYS)
230
+
231
+ cache_store.write(cache_key, previous_keys)
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,112 @@
1
+ module OmniauthStrongAuthOidc
2
+ module RelyingPartyJwksStorage
3
+ # Environment variable-based key storage for production deployments
4
+ # Loads RSA keys from environment variables without rotation support
5
+ # Keys should be base64-encoded PEM-encoded RSA private keys
6
+ #
7
+ # Default environment variables:
8
+ # - OIDC_SIGNING_KEY_BASE64: Base64-encoded PEM RSA private key for signing
9
+ # - OIDC_ENCRYPTION_KEY_BASE64: Base64-encoded PEM RSA private key for encryption
10
+ #
11
+ # Example with custom environment variable names:
12
+ # RelyingPartyJwksStorage::EnvStorage.new(
13
+ # signing_key_env: 'MY_SIGNING_KEY',
14
+ # encryption_key_env: 'MY_ENCRYPTION_KEY'
15
+ # )
16
+ #
17
+ # To create a storage with only signing key (no encryption):
18
+ # RelyingPartyJwksStorage::EnvStorage.new(
19
+ # signing_key_env: 'ENTITY_STATEMENT_SIGNING_KEY',
20
+ # encryption_key_env: nil
21
+ # )
22
+ class EnvStorage < Base
23
+ DEFAULT_SIGNING_KEY_ENV = 'OIDC_SIGNING_KEY_BASE64'
24
+ DEFAULT_ENCRYPTION_KEY_ENV = 'OIDC_ENCRYPTION_KEY_BASE64'
25
+
26
+ attr_reader :signing_key_env, :encryption_key_env
27
+
28
+ def initialize(signing_key_env: DEFAULT_SIGNING_KEY_ENV, encryption_key_env: DEFAULT_ENCRYPTION_KEY_ENV)
29
+ @signing_key_env = signing_key_env
30
+ @encryption_key_env = encryption_key_env
31
+ validate_env_keys!
32
+ end
33
+
34
+ def support_rotation?
35
+ false
36
+ end
37
+
38
+ # Returns the JWK set containing both signing and encryption keys
39
+ # @return [JWT::JWK::Set]
40
+ def jwks
41
+ @jwks ||= begin
42
+ encryption_data = encryption_key_env && !ENV[encryption_key_env].nil? && !ENV[encryption_key_env].empty? ? [{ pem: encryption_key_pem }] : []
43
+
44
+ create_jwk_set(
45
+ signing_key_data: [{ pem: signing_key_pem }],
46
+ encryption_key_data: encryption_data
47
+ )
48
+ end
49
+ end
50
+
51
+ # Rotation is not supported for environment-based keys
52
+ # @raise [NotImplementedError]
53
+ def rotate_signing_key!
54
+ raise NotImplementedError, "Key rotation is not supported for EnvKeyStorage. Update environment variables instead."
55
+ end
56
+
57
+ # Rotation is not supported for environment-based keys
58
+ # @raise [NotImplementedError]
59
+ def rotate_encryption_key!
60
+ raise NotImplementedError, "Key rotation is not supported for EnvKeyStorage. Update environment variables instead."
61
+ end
62
+
63
+ private
64
+
65
+ def signing_key_pem
66
+ Base64.decode64(ENV[signing_key_env])
67
+ end
68
+
69
+ def encryption_key_pem
70
+ return nil unless encryption_key_env && !ENV[encryption_key_env].nil? && !ENV[encryption_key_env].empty?
71
+ Base64.decode64(ENV[encryption_key_env])
72
+ end
73
+
74
+ def validate_env_keys!
75
+ # Signing key is always required
76
+ if ENV[signing_key_env].nil? || ENV[signing_key_env].empty?
77
+ raise ArgumentError, "Missing required environment variable: #{signing_key_env}"
78
+ end
79
+
80
+ # Validate signing key
81
+ begin
82
+ OpenSSL::PKey::RSA.new(signing_key_pem)
83
+ rescue OpenSSL::PKey::RSAError => e
84
+ raise ArgumentError, "Invalid RSA key in #{signing_key_env}: #{e.message}"
85
+ end
86
+
87
+ signing_key = OpenSSL::PKey::RSA.new(signing_key_pem)
88
+ if signing_key.n.num_bits < MINIMUM_KEY_LENGTH
89
+ raise ArgumentError, "#{signing_key_env} must be at least #{MINIMUM_KEY_LENGTH} bits (got #{signing_key.n.num_bits} bits)"
90
+ end
91
+
92
+ # Return early if no encryption key is configured
93
+ return unless encryption_key_env
94
+
95
+ if ENV[encryption_key_env].nil? || ENV[encryption_key_env].empty?
96
+ raise ArgumentError, "Missing required environment variable: #{encryption_key_env}"
97
+ end
98
+
99
+ begin
100
+ OpenSSL::PKey::RSA.new(encryption_key_pem)
101
+ rescue OpenSSL::PKey::RSAError => e
102
+ raise ArgumentError, "Invalid RSA key in #{encryption_key_env}: #{e.message}"
103
+ end
104
+
105
+ encryption_key = OpenSSL::PKey::RSA.new(encryption_key_pem)
106
+ if encryption_key.n.num_bits < MINIMUM_KEY_LENGTH
107
+ raise ArgumentError, "#{encryption_key_env} must be at least #{MINIMUM_KEY_LENGTH} bits (got #{encryption_key.n.num_bits} bits)"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,10 @@
1
+ require_relative 'relying_party_jwks_storage/base'
2
+ require_relative 'relying_party_jwks_storage/cache_storage'
3
+ require_relative 'relying_party_jwks_storage/env_storage'
4
+ module OmniauthStrongAuthOidc
5
+ # Abstract interface for OpenID key storage
6
+ # Implementations must provide a JWK set containing both signing and encryption keys
7
+ module RelyingPartyJwksStorage
8
+
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ module OmniauthStrongAuthOidc
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,15 @@
1
+
2
+ require_relative 'omniauth_strong_auth_oidc/version'
3
+ require_relative 'omniauth_strong_auth_oidc/entity_statement'
4
+ require_relative 'omniauth_strong_auth_oidc/entity_statement_fetcher'
5
+ require_relative 'omniauth_strong_auth_oidc/jwks_cache'
6
+ require_relative 'omniauth_strong_auth_oidc/jwks_fetcher'
7
+ require_relative 'omniauth_strong_auth_oidc/relying_party_jwks_storage'
8
+ require_relative 'omniauth_strong_auth_oidc/relying_party_entity_statement_generator'
9
+ require_relative 'omniauth_strong_auth_oidc/relying_party_jwks_generator'
10
+ require_relative 'omniauth/strategies/strong_auth_oidc'
11
+
12
+ module OmniauthStrongAuthOidc
13
+ # gem version from `lib/omniauth_strong_auth_oidc/version.rb`
14
+ # kept empty module body intentionally
15
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/omniauth_strong_auth_oidc/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "omniauth_strong_auth_oidc"
7
+ spec.version = OmniauthStrongAuthOidc::VERSION
8
+ spec.authors = ["Kisko Labs", "Dmitry Gusev"]
9
+ spec.email = ["dmitry@kiskolabs.com"]
10
+
11
+ spec.summary = %q{OmniAuth strategy for Strong Auth OIDC}
12
+ spec.description = File.exist?("README.md") ? File.read("README.md") : spec.summary
13
+ spec.homepage = "https://github.com/kiskolabs/omniauth_strong_auth_oidc"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.add_dependency "omniauth-oauth2", "~> 1.8"
20
+ spec.add_dependency "jwt", "~> 2.10"
21
+ spec.add_dependency "jwe", "~> 1.1"
22
+ spec.add_dependency "http", "~> 5.0"
23
+
24
+ spec.add_development_dependency "bundler"
25
+ spec.add_development_dependency "rake"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+ spec.add_development_dependency "activesupport", "~> 7.0"
28
+
29
+ spec.metadata = {
30
+ "allowed_push_host" => "https://rubygems.org"
31
+ }
32
+ end