rodauth-oauth 0.0.3 → 0.2.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,267 @@
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
+ depends :oauth_jwt
14
+
15
+ auth_value_method :oauth_application_default_scope, "openid"
16
+ auth_value_method :oauth_application_scopes, %w[openid]
17
+
18
+ auth_value_method :oauth_grants_nonce_column, :nonce
19
+ auth_value_method :oauth_tokens_nonce_column, :nonce
20
+
21
+ auth_value_method :invalid_scope_message, "The Access Token expired"
22
+
23
+ auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
24
+
25
+ auth_value_methods(:get_oidc_param)
26
+
27
+ def openid_configuration(issuer = nil)
28
+ request.on(".well-known/openid-configuration") do
29
+ request.get do
30
+ json_response_success(openid_configuration_body(issuer))
31
+ end
32
+ end
33
+ end
34
+
35
+ def webfinger
36
+ request.on(".well-known/webfinger") do
37
+ request.get do
38
+ resource = param_or_nil("resource")
39
+
40
+ throw_json_response_error(400, "invalid_request") unless resource
41
+
42
+ response.status = 200
43
+ response["Content-Type"] ||= "application/jrd+json"
44
+
45
+ json_payload = JSON.dump({
46
+ subject: resource,
47
+ links: [{
48
+ rel: webfinger_relation,
49
+ href: authorization_server_url
50
+ }]
51
+ })
52
+ response.write(json_payload)
53
+ request.halt
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def create_oauth_grant(create_params = {})
61
+ return super unless (nonce = param_or_nil("nonce"))
62
+
63
+ super(oauth_grants_nonce_column => nonce)
64
+ end
65
+
66
+ def create_oauth_token_from_authorization_code(oauth_grant, create_params)
67
+ return super unless oauth_grant[oauth_grants_nonce_column]
68
+
69
+ super(oauth_grant, create_params.merge(oauth_tokens_nonce_column => oauth_grant[oauth_grants_nonce_column]))
70
+ end
71
+
72
+ def create_oauth_token
73
+ oauth_token = super
74
+ generate_id_token(oauth_token)
75
+ oauth_token
76
+ end
77
+
78
+ def generate_id_token(oauth_token)
79
+ oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
80
+
81
+ return unless oauth_scopes.include?("openid")
82
+
83
+ id_token_claims = jwt_claims(oauth_token)
84
+ id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
85
+
86
+ # Time when the End-User authentication occurred.
87
+ #
88
+ # Sounds like the same as issued at claim.
89
+ id_token_claims[:auth_time] = id_token_claims[:iat]
90
+
91
+ account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
92
+
93
+ # this should never happen!
94
+ # a newly minted oauth token from a grant should have been assigned to an account
95
+ # who just authorized its generation.
96
+ return unless account
97
+
98
+ fill_with_account_claims(id_token_claims, account, oauth_scopes)
99
+
100
+ oauth_token[:id_token] = jwt_encode(id_token_claims)
101
+ end
102
+
103
+ def fill_with_account_claims(claims, account, scopes)
104
+ scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
105
+ oidc, param = scope.split(".", 2)
106
+
107
+ by_oidc[oidc] ||= []
108
+
109
+ by_oidc[oidc] << param.to_sym if param
110
+ end
111
+
112
+ oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
113
+
114
+ return if oidc_scopes.empty?
115
+
116
+ if respond_to?(:get_oidc_param)
117
+ oidc_scopes.each do |scope|
118
+ params = scopes_by_oidc[scope]
119
+ params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
120
+
121
+ params.each do |param|
122
+ claims[param] = __send__(:get_oidc_param, account, param)
123
+ end
124
+ end
125
+ else
126
+ warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
127
+ end
128
+ end
129
+
130
+ def json_access_token_payload(oauth_token)
131
+ payload = super
132
+ payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
133
+ payload
134
+ end
135
+
136
+ # Authorize
137
+
138
+ def check_valid_response_type?
139
+ case param_or_nil("response_type")
140
+ when "none", "id_token",
141
+ "code token", "code id_token", "id_token token", "code id_token token" # multiple
142
+ true
143
+ else
144
+ super
145
+ end
146
+ end
147
+
148
+ def do_authorize(redirect_url, query_params = [], fragment_params = [])
149
+ return super unless use_oauth_implicit_grant_type?
150
+
151
+ case param("response_type")
152
+ when "id_token"
153
+ fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
154
+ when "code token"
155
+ redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
156
+
157
+ params = _do_authorize_code.merge(_do_authorize_token)
158
+
159
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
160
+ when "code id_token"
161
+ params = _do_authorize_code.merge(_do_authorize_id_token)
162
+
163
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
164
+ when "id_token token"
165
+ params = _do_authorize_id_token.merge(_do_authorize_token)
166
+
167
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
168
+ when "code id_token token"
169
+ params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
170
+
171
+ fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
172
+ end
173
+
174
+ super(redirect_url, query_params, fragment_params)
175
+ end
176
+
177
+ def _do_authorize_id_token
178
+ create_params = {
179
+ oauth_tokens_account_id_column => account_id,
180
+ oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
181
+ oauth_tokens_scopes_column => scopes
182
+ }
183
+ oauth_token = generate_oauth_token(create_params, false)
184
+ generate_id_token(oauth_token)
185
+ params = json_access_token_payload(oauth_token)
186
+ params.delete("access_token")
187
+ params
188
+ end
189
+
190
+ # Metadata
191
+
192
+ def openid_configuration_body(path)
193
+ metadata = oauth_server_metadata_body(path)
194
+
195
+ scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
196
+ oidc, param = scope.split(".", 2)
197
+ if param
198
+ claims << param
199
+ else
200
+ oidc_claims = OIDC_SCOPES_MAP[oidc]
201
+ claims.concat(oidc_claims) if oidc_claims
202
+ end
203
+ end
204
+
205
+ scope_claims.unshift("auth_time") if last_account_login_at
206
+
207
+ metadata.merge({
208
+ userinfo_endpoint: userinfo_url,
209
+ response_types_supported: metadata[:response_types_supported] +
210
+ ["none", "id_token", %w[code token], %w[code id_token], %w[id_token token], %w[code id_token token]],
211
+ response_modes_supported: %w[query fragment],
212
+ grant_types_supported: %w[authorization_code implicit],
213
+
214
+ subject_types_supported: [oauth_jwt_subject_type],
215
+
216
+ id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
217
+ id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
218
+ id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
219
+
220
+ userinfo_signing_alg_values_supported: [],
221
+ userinfo_encryption_alg_values_supported: [],
222
+ userinfo_encryption_enc_values_supported: [],
223
+
224
+ request_object_signing_alg_values_supported: [],
225
+ request_object_encryption_alg_values_supported: [],
226
+ request_object_encryption_enc_values_supported: [],
227
+
228
+ # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
229
+ # Values defined by this specification are normal, aggregated, and distributed.
230
+ # If omitted, the implementation supports only normal Claims.
231
+ claim_types_supported: %w[normal],
232
+ claims_supported: %w[sub iss iat exp aud] | scope_claims
233
+ })
234
+ end
235
+
236
+ # /userinfo
237
+ route(:userinfo) do |r|
238
+ next unless is_authorization_server?
239
+
240
+ r.on method: %i[get post] do
241
+ catch_error do
242
+ oauth_token = authorization_token
243
+
244
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
245
+
246
+ oauth_scopes = oauth_token["scope"].split(" ")
247
+
248
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
249
+
250
+ account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
251
+
252
+ throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
253
+
254
+ oauth_scopes.delete("openid")
255
+
256
+ oidc_claims = { "sub" => oauth_token["sub"] }
257
+
258
+ fill_with_account_claims(oidc_claims, account, oauth_scopes)
259
+
260
+ json_response_success(oidc_claims)
261
+ end
262
+
263
+ throw_json_response_error(authorization_required_error_status, "invalid_token")
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module OAuth
5
+ # rubocop:disable Naming/MethodName, Metrics/ParameterLists
6
+ def self.ExtendDatabase(db)
7
+ Module.new do
8
+ dataset = db.dataset
9
+
10
+ if dataset.supports_returning?(:insert)
11
+ def __insert_and_return__(dataset, _pkey, params)
12
+ dataset.returning.insert(params).first
13
+ end
14
+ else
15
+ def __insert_and_return__(dataset, pkey, params)
16
+ id = dataset.insert(params)
17
+ dataset.where(pkey => id).first
18
+ end
19
+ end
20
+
21
+ if dataset.supports_returning?(:update)
22
+ def __update_and_return__(dataset, params)
23
+ dataset.returning.update(params).first
24
+ end
25
+ else
26
+ def __update_and_return__(dataset, params)
27
+ dataset.update(params)
28
+ dataset.first
29
+ end
30
+ end
31
+
32
+ if dataset.respond_to?(:supports_insert_conflict?) && dataset.supports_insert_conflict?
33
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
34
+ to_update = params.keys - unique_columns
35
+ to_update -= exclude_on_update if exclude_on_update
36
+
37
+ dataset = dataset.insert_conflict(
38
+ target: unique_columns,
39
+ update: Hash[ to_update.map { |attribute| [attribute, Sequel[:excluded][attribute]] } ],
40
+ update_where: conds
41
+ )
42
+
43
+ __insert_and_return__(dataset, pkey, params)
44
+ end
45
+ else
46
+ def __insert_or_update_and_return__(dataset, pkey, unique_columns, params, conds = nil, exclude_on_update = nil)
47
+ find_params, update_params = params.partition { |key, _| unique_columns.include?(key) }.map { |h| Hash[h] }
48
+
49
+ dataset_where = dataset.where(find_params)
50
+ record = if conds
51
+ dataset_where_conds = dataset_where.where(conds)
52
+
53
+ # this means that there's still a valid entry there, so return early
54
+ return if dataset_where.count != dataset_where_conds.count
55
+
56
+ dataset_where_conds.first
57
+ else
58
+ dataset_where.first
59
+ end
60
+
61
+ if record
62
+ update_params.reject! { |k, _v| exclude_on_update.include?(k) } if exclude_on_update
63
+ __update_and_return__(dataset_where, update_params)
64
+ else
65
+ __insert_and_return__(dataset, pkey, params)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ # rubocop:enable Naming/MethodName, Metrics/ParameterLists
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The TTL store is a data structure which keeps data by a key, and with a time-to-live.
5
+ # It is specifically designed for data which is static, i.e. for a certain key in a
6
+ # sufficiently large span, the value will be the same.
7
+ #
8
+ # Because of that, synchronizations around reads do not exist, while write synchronizations
9
+ # will be short-circuited by a read.
10
+ #
11
+ class Rodauth::OAuth::TtlStore
12
+ DEFAULT_TTL = 60 * 60 * 24 # default TTL is one day
13
+
14
+ def initialize
15
+ @store_mutex = Mutex.new
16
+ @store = Hash.new {}
17
+ end
18
+
19
+ def [](key)
20
+ lookup(key, now)
21
+ end
22
+
23
+ def set(key, &block)
24
+ @store_mutex.synchronize do
25
+ # short circuit
26
+ return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
27
+
28
+ payload, ttl = block.call
29
+ @store[key] = { payload: payload, ttl: (ttl || (now + DEFAULT_TTL)) }
30
+
31
+ @store[key][:payload]
32
+ end
33
+ end
34
+
35
+ def uncache(key)
36
+ @store_mutex.synchronize do
37
+ @store.delete(key)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def now
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ end
46
+
47
+ # do not use directly!
48
+ def lookup(key, ttl)
49
+ return unless @store.key?(key)
50
+
51
+ value = @store[key]
52
+
53
+ return if value.empty?
54
+
55
+ return unless value[:ttl] > ttl
56
+
57
+ value[:payload]
58
+ end
59
+ end