rodauth-oauth 0.0.4 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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