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.
- checksums.yaml +7 -0
- data/.gitignore +34 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +6 -0
- data/CONTRIBUTING.md +9 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/Rakefile +12 -0
- data/lib/generators/omniauth_strong_auth_oidc/install_generator.rb +73 -0
- data/lib/generators/omniauth_strong_auth_oidc/templates/relying_party_entity_statement_controller.rb.tt +61 -0
- data/lib/omniauth/strategies/strong_auth_oidc.rb +210 -0
- data/lib/omniauth_strong_auth_oidc/entity_statement.rb +22 -0
- data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/base.rb +37 -0
- data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/federation_url_fetcher.rb +29 -0
- data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher/file_fetcher.rb +22 -0
- data/lib/omniauth_strong_auth_oidc/entity_statement_fetcher.rb +9 -0
- data/lib/omniauth_strong_auth_oidc/jwks_cache.rb +31 -0
- data/lib/omniauth_strong_auth_oidc/jwks_fetcher.rb +87 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_entity_statement_generator.rb +77 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_jwks_generator.rb +50 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/base.rb +101 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/cache_storage.rb +235 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage/env_storage.rb +112 -0
- data/lib/omniauth_strong_auth_oidc/relying_party_jwks_storage.rb +10 -0
- data/lib/omniauth_strong_auth_oidc/version.rb +3 -0
- data/lib/omniauth_strong_auth_oidc.rb +15 -0
- data/omniauth_strong_auth_oidc.gemspec +32 -0
- 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,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
|