rodauth-oauth 0.0.4 → 0.3.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.
@@ -0,0 +1,104 @@
1
+ # frozen-string-literal: true
2
+
3
+ require "onelogin/ruby-saml"
4
+
5
+ module Rodauth
6
+ Feature.define(:oauth_saml) do
7
+ depends :oauth
8
+
9
+ auth_value_method :oauth_saml_cert_fingerprint, "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D"
10
+ auth_value_method :oauth_saml_cert_fingerprint_algorithm, nil
11
+ auth_value_method :oauth_saml_name_identifier_format, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
12
+
13
+ auth_value_method :oauth_saml_security_authn_requests_signed, false
14
+ auth_value_method :oauth_saml_security_metadata_signed, false
15
+ auth_value_method :oauth_saml_security_digest_method, XMLSecurity::Document::SHA1
16
+ auth_value_method :oauth_saml_security_signature_method, XMLSecurity::Document::RSA_SHA1
17
+
18
+ SAML_GRANT_TYPE = "http://oauth.net/grant_type/assertion/saml/2.0/bearer"
19
+
20
+ # /token
21
+
22
+ def require_oauth_application
23
+ # requset authentication optional for assertions
24
+ return super unless param("grant_type") == SAML_GRANT_TYPE && !param_or_nil("client_id")
25
+
26
+ # TODO: invalid grant
27
+ authorization_required unless saml_assertion
28
+
29
+ redirect_uri = saml_assertion.destination
30
+
31
+ @oauth_application = db[oauth_applications_table].where(
32
+ oauth_applications_homepage_url_column => saml_assertion.audiences,
33
+ oauth_applications_redirect_uri_column => redirect_uri
34
+ ).first
35
+
36
+ # The Assertion's <Issuer> element MUST contain a unique identifier
37
+ # for the entity that issued the Assertion.
38
+ authorization_required unless saml_assertion.issuers.all? do |issuer|
39
+ issuer.start_with?(@oauth_application[oauth_applications_homepage_url_column])
40
+ end
41
+
42
+ authorization_required unless @oauth_application
43
+ end
44
+
45
+ private
46
+
47
+ def secret_matches?(oauth_application, secret)
48
+ return super unless param_or_nil("assertion")
49
+
50
+ true
51
+ end
52
+
53
+ def saml_assertion
54
+ return @saml_assertion if defined?(@saml_assertion)
55
+
56
+ @saml_assertion = begin
57
+ settings = OneLogin::RubySaml::Settings.new
58
+ settings.idp_cert_fingerprint = oauth_saml_cert_fingerprint
59
+ settings.idp_cert_fingerprint_algorithm = oauth_saml_cert_fingerprint_algorithm
60
+ settings.name_identifier_format = oauth_saml_name_identifier_format
61
+ settings.security[:authn_requests_signed] = oauth_saml_security_authn_requests_signed
62
+ settings.security[:metadata_signed] = oauth_saml_security_metadata_signed
63
+ settings.security[:digest_method] = oauth_saml_security_digest_method
64
+ settings.security[:signature_method] = oauth_saml_security_signature_method
65
+
66
+ response = OneLogin::RubySaml::Response.new(param("assertion"), settings: settings, skip_recipient_check: true)
67
+
68
+ return unless response.is_valid?
69
+
70
+ response
71
+ end
72
+ end
73
+
74
+ def validate_oauth_token_params
75
+ return super unless param("grant_type") == SAML_GRANT_TYPE
76
+
77
+ redirect_response_error("invalid_client") unless param_or_nil("assertion")
78
+
79
+ redirect_response_error("invalid_scope") unless check_valid_scopes?
80
+ end
81
+
82
+ def create_oauth_token
83
+ if param("grant_type") == SAML_GRANT_TYPE
84
+ create_oauth_token_from_saml_assertion
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def create_oauth_token_from_saml_assertion
91
+ account = db[accounts_table].where(login_column => saml_assertion.nameid).first
92
+
93
+ redirect_response_error("invalid_client") unless oauth_application && account
94
+
95
+ create_params = {
96
+ oauth_tokens_account_id_column => account[account_id_column],
97
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
98
+ oauth_tokens_scopes_column => (param_or_nil("scope") || oauth_application[oauth_applications_scopes_column])
99
+ }
100
+
101
+ generate_oauth_token(create_params, false)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,388 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Rodauth
4
+ Feature.define(:oidc) do
5
+ OIDC_SCOPES_MAP = {
6
+ "profile" => %i[name family_name given_name middle_name nickname preferred_username
7
+ profile picture website gender birthdate zoneinfo locale updated_at].freeze,
8
+ "email" => %i[email email_verified].freeze,
9
+ "address" => %i[address].freeze,
10
+ "phone" => %i[phone_number phone_number_verified].freeze
11
+ }.freeze
12
+
13
+ VALID_METADATA_KEYS = %i[
14
+ issuer
15
+ authorization_endpoint
16
+ token_endpoint
17
+ userinfo_endpoint
18
+ jwks_uri
19
+ registration_endpoint
20
+ scopes_supported
21
+ response_types_supported
22
+ response_modes_supported
23
+ grant_types_supported
24
+ acr_values_supported
25
+ subject_types_supported
26
+ id_token_signing_alg_values_supported
27
+ id_token_encryption_alg_values_supported
28
+ id_token_encryption_enc_values_supported
29
+ userinfo_signing_alg_values_supported
30
+ userinfo_encryption_alg_values_supported
31
+ userinfo_encryption_enc_values_supported
32
+ request_object_signing_alg_values_supported
33
+ request_object_encryption_alg_values_supported
34
+ request_object_encryption_enc_values_supported
35
+ token_endpoint_auth_methods_supported
36
+ token_endpoint_auth_signing_alg_values_supported
37
+ display_values_supported
38
+ claim_types_supported
39
+ claims_supported
40
+ service_documentation
41
+ claims_locales_supported
42
+ ui_locales_supported
43
+ claims_parameter_supported
44
+ request_parameter_supported
45
+ request_uri_parameter_supported
46
+ require_request_uri_registration
47
+ op_policy_uri
48
+ op_tos_uri
49
+ ].freeze
50
+
51
+ REQUIRED_METADATA_KEYS = %i[
52
+ issuer
53
+ authorization_endpoint
54
+ token_endpoint
55
+ jwks_uri
56
+ response_types_supported
57
+ subject_types_supported
58
+ id_token_signing_alg_values_supported
59
+ ].freeze
60
+
61
+ depends :oauth_jwt
62
+
63
+ auth_value_method :oauth_application_default_scope, "openid"
64
+ auth_value_method :oauth_application_scopes, %w[openid]
65
+
66
+ auth_value_method :oauth_grants_nonce_column, :nonce
67
+ auth_value_method :oauth_tokens_nonce_column, :nonce
68
+
69
+ auth_value_method :invalid_scope_message, "The Access Token expired"
70
+
71
+ auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
72
+
73
+ auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login"
74
+ auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
75
+ auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
76
+
77
+ auth_value_methods(:get_oidc_param)
78
+
79
+ # /userinfo
80
+ route(:userinfo) do |r|
81
+ next unless is_authorization_server?
82
+
83
+ r.on method: %i[get post] do
84
+ catch_error do
85
+ oauth_token = authorization_token
86
+
87
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
88
+
89
+ oauth_scopes = oauth_token["scope"].split(" ")
90
+
91
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
92
+
93
+ account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
94
+
95
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
96
+
97
+ oauth_scopes.delete("openid")
98
+
99
+ oidc_claims = { "sub" => oauth_token["sub"] }
100
+
101
+ fill_with_account_claims(oidc_claims, account, oauth_scopes)
102
+
103
+ json_response_success(oidc_claims)
104
+ end
105
+
106
+ throw_json_response_error(authorization_required_error_status, "invalid_token")
107
+ end
108
+ end
109
+
110
+ def openid_configuration(issuer = nil)
111
+ request.on(".well-known/openid-configuration") do
112
+ request.get do
113
+ json_response_success(openid_configuration_body(issuer), cache: true)
114
+ end
115
+ end
116
+ end
117
+
118
+ def webfinger
119
+ request.on(".well-known/webfinger") do
120
+ request.get do
121
+ resource = param_or_nil("resource")
122
+
123
+ throw_json_response_error(400, "invalid_request") unless resource
124
+
125
+ response.status = 200
126
+ response["Content-Type"] ||= "application/jrd+json"
127
+
128
+ json_payload = JSON.dump({
129
+ subject: resource,
130
+ links: [{
131
+ rel: webfinger_relation,
132
+ href: authorization_server_url
133
+ }]
134
+ })
135
+ response.write(json_payload)
136
+ request.halt
137
+ end
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def require_authorizable_account
144
+ try_prompt if param_or_nil("prompt")
145
+ super
146
+ end
147
+
148
+ # this executes before checking for a logged in account
149
+ def try_prompt
150
+ prompt = param_or_nil("prompt")
151
+
152
+ case prompt
153
+ when "none"
154
+ redirect_response_error("login_required") unless logged_in?
155
+
156
+ require_account
157
+
158
+ if db[oauth_grants_table].where(
159
+ oauth_grants_account_id_column => account_id,
160
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
161
+ oauth_grants_redirect_uri_column => redirect_uri,
162
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
163
+ oauth_grants_access_type_column => "online"
164
+ ).count.zero?
165
+ redirect_response_error("consent_required")
166
+ end
167
+
168
+ request.env["REQUEST_METHOD"] = "POST"
169
+ when "login"
170
+ if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login"
171
+ ::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options)
172
+ return
173
+ end
174
+
175
+ # logging out
176
+ clear_session
177
+ set_session_value(login_redirect_session_key, request.fullpath)
178
+
179
+ login_cookie_opts = Hash[oauth_prompt_login_cookie_options]
180
+ login_cookie_opts[:value] = "login"
181
+ login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
182
+ ::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
183
+
184
+ redirect require_login_redirect
185
+ when "consent"
186
+ require_account
187
+
188
+ if db[oauth_grants_table].where(
189
+ oauth_grants_account_id_column => account_id,
190
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
191
+ oauth_grants_redirect_uri_column => redirect_uri,
192
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
193
+ oauth_grants_access_type_column => "online"
194
+ ).count.zero?
195
+ redirect_response_error("consent_required")
196
+ end
197
+ when "select-account"
198
+ # obly works if select_account plugin is available
199
+ require_select_account if respond_to?(:require_select_account)
200
+ else
201
+ redirect_response_error("invalid_request")
202
+ end
203
+ end
204
+
205
+ def create_oauth_grant(create_params = {})
206
+ return super unless (nonce = param_or_nil("nonce"))
207
+
208
+ super(oauth_grants_nonce_column => nonce)
209
+ end
210
+
211
+ def create_oauth_token_from_authorization_code(oauth_grant, create_params)
212
+ return super unless oauth_grant[oauth_grants_nonce_column]
213
+
214
+ super(oauth_grant, create_params.merge(oauth_tokens_nonce_column => oauth_grant[oauth_grants_nonce_column]))
215
+ end
216
+
217
+ def create_oauth_token
218
+ oauth_token = super
219
+ generate_id_token(oauth_token)
220
+ oauth_token
221
+ end
222
+
223
+ def generate_id_token(oauth_token)
224
+ oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
225
+
226
+ return unless oauth_scopes.include?("openid")
227
+
228
+ id_token_claims = jwt_claims(oauth_token)
229
+ id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
230
+
231
+ # Time when the End-User authentication occurred.
232
+ #
233
+ # Sounds like the same as issued at claim.
234
+ id_token_claims[:auth_time] = id_token_claims[:iat]
235
+
236
+ account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
237
+
238
+ # this should never happen!
239
+ # a newly minted oauth token from a grant should have been assigned to an account
240
+ # who just authorized its generation.
241
+ return unless account
242
+
243
+ fill_with_account_claims(id_token_claims, account, oauth_scopes)
244
+
245
+ oauth_token[:id_token] = jwt_encode(id_token_claims)
246
+ end
247
+
248
+ def fill_with_account_claims(claims, account, scopes)
249
+ scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
250
+ oidc, param = scope.split(".", 2)
251
+
252
+ by_oidc[oidc] ||= []
253
+
254
+ by_oidc[oidc] << param.to_sym if param
255
+ end
256
+
257
+ oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
258
+
259
+ return if oidc_scopes.empty?
260
+
261
+ if respond_to?(:get_oidc_param)
262
+ oidc_scopes.each do |scope|
263
+ params = scopes_by_oidc[scope]
264
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
265
+
266
+ params.each do |param|
267
+ claims[param] = __send__(:get_oidc_param, account, param)
268
+ end
269
+ end
270
+ else
271
+ warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
272
+ end
273
+ end
274
+
275
+ def json_access_token_payload(oauth_token)
276
+ payload = super
277
+ payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
278
+ payload
279
+ end
280
+
281
+ # Authorize
282
+
283
+ def check_valid_response_type?
284
+ case param_or_nil("response_type")
285
+ when "none", "id_token",
286
+ "code token", "code id_token", "id_token token", "code id_token token" # multiple
287
+ true
288
+ else
289
+ super
290
+ end
291
+ end
292
+
293
+ def do_authorize(redirect_url, query_params = [], fragment_params = [])
294
+ return super unless use_oauth_implicit_grant_type?
295
+
296
+ case param("response_type")
297
+ when "id_token"
298
+ fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
299
+ when "code token"
300
+ redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
301
+
302
+ params = _do_authorize_code.merge(_do_authorize_token)
303
+
304
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
305
+ when "code id_token"
306
+ params = _do_authorize_code.merge(_do_authorize_id_token)
307
+
308
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
309
+ when "id_token token"
310
+ params = _do_authorize_id_token.merge(_do_authorize_token)
311
+
312
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
313
+ when "code id_token token"
314
+ params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
315
+
316
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
317
+ end
318
+
319
+ super(redirect_url, query_params, fragment_params)
320
+ end
321
+
322
+ def _do_authorize_id_token
323
+ create_params = {
324
+ oauth_tokens_account_id_column => account_id,
325
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
326
+ oauth_tokens_scopes_column => scopes
327
+ }
328
+ oauth_token = generate_oauth_token(create_params, false)
329
+ generate_id_token(oauth_token)
330
+ params = json_access_token_payload(oauth_token)
331
+ params.delete("access_token")
332
+ params
333
+ end
334
+
335
+ # Metadata
336
+
337
+ def openid_configuration_body(path)
338
+ metadata = oauth_server_metadata_body(path).select do |k, _|
339
+ VALID_METADATA_KEYS.include?(k)
340
+ end
341
+
342
+ scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
343
+ oidc, param = scope.split(".", 2)
344
+ if param
345
+ claims << param
346
+ else
347
+ oidc_claims = OIDC_SCOPES_MAP[oidc]
348
+ claims.concat(oidc_claims) if oidc_claims
349
+ end
350
+ end
351
+
352
+ scope_claims.unshift("auth_time") if last_account_login_at
353
+
354
+ metadata.merge(
355
+ userinfo_endpoint: userinfo_url,
356
+ response_types_supported: metadata[:response_types_supported] +
357
+ ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"],
358
+ response_modes_supported: %w[query fragment],
359
+ grant_types_supported: %w[authorization_code implicit],
360
+
361
+ subject_types_supported: [oauth_jwt_subject_type],
362
+
363
+ id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
364
+ id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
365
+ id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
366
+
367
+ userinfo_signing_alg_values_supported: [],
368
+ userinfo_encryption_alg_values_supported: [],
369
+ userinfo_encryption_enc_values_supported: [],
370
+
371
+ request_object_signing_alg_values_supported: [],
372
+ request_object_encryption_alg_values_supported: [],
373
+ request_object_encryption_enc_values_supported: [],
374
+
375
+ # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
376
+ # Values defined by this specification are normal, aggregated, and distributed.
377
+ # If omitted, the implementation supports only normal Claims.
378
+ claim_types_supported: %w[normal],
379
+ claims_supported: %w[sub iss iat exp aud] | scope_claims
380
+ ).reject do |key, val|
381
+ # Filter null values in optional items
382
+ (!REQUIRED_METADATA_KEYS.include?(key.to_sym) && val.nil?) ||
383
+ # Claims with zero elements MUST be omitted from the response
384
+ (val.respond_to?(:empty?) && val.empty?)
385
+ end
386
+ end
387
+ end
388
+ end