rodauth-oauth 0.7.4 → 0.9.1
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 +4 -4
- data/CHANGELOG.md +1 -424
- data/README.md +30 -390
- data/doc/release_notes/0_0_1.md +3 -0
- data/doc/release_notes/0_0_2.md +15 -0
- data/doc/release_notes/0_0_3.md +31 -0
- data/doc/release_notes/0_0_4.md +36 -0
- data/doc/release_notes/0_0_5.md +36 -0
- data/doc/release_notes/0_0_6.md +21 -0
- data/doc/release_notes/0_1_0.md +44 -0
- data/doc/release_notes/0_2_0.md +43 -0
- data/doc/release_notes/0_3_0.md +28 -0
- data/doc/release_notes/0_4_0.md +18 -0
- data/doc/release_notes/0_4_1.md +9 -0
- data/doc/release_notes/0_4_2.md +5 -0
- data/doc/release_notes/0_4_3.md +3 -0
- data/doc/release_notes/0_5_0.md +11 -0
- data/doc/release_notes/0_5_1.md +13 -0
- data/doc/release_notes/0_6_0.md +9 -0
- data/doc/release_notes/0_6_1.md +6 -0
- data/doc/release_notes/0_7_0.md +20 -0
- data/doc/release_notes/0_7_1.md +10 -0
- data/doc/release_notes/0_7_2.md +21 -0
- data/doc/release_notes/0_7_3.md +10 -0
- data/doc/release_notes/0_7_4.md +5 -0
- data/doc/release_notes/0_8_0.md +37 -0
- data/doc/release_notes/0_9_0.md +56 -0
- data/doc/release_notes/0_9_1.md +9 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +25 -4
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/device_search.html.erb +11 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/device_verification.html.erb +20 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/new_oauth_application.html.erb +27 -10
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_application.html.erb +17 -5
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_application_oauth_tokens.html.erb +39 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +6 -5
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_tokens.html.erb +12 -15
- data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +21 -1
- data/lib/rodauth/features/oauth.rb +3 -1418
- data/lib/rodauth/features/oauth_application_management.rb +225 -0
- data/lib/rodauth/features/oauth_assertion_base.rb +96 -0
- data/lib/rodauth/features/oauth_authorization_code_grant.rb +252 -0
- data/lib/rodauth/features/oauth_authorization_server.rb +0 -0
- data/lib/rodauth/features/oauth_base.rb +778 -0
- data/lib/rodauth/features/oauth_client_credentials_grant.rb +33 -0
- data/lib/rodauth/features/oauth_device_grant.rb +220 -0
- data/lib/rodauth/features/oauth_dynamic_client_registration.rb +252 -0
- data/lib/rodauth/features/oauth_http_mac.rb +3 -21
- data/lib/rodauth/features/oauth_implicit_grant.rb +59 -0
- data/lib/rodauth/features/oauth_jwt.rb +275 -100
- data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +59 -0
- data/lib/rodauth/features/oauth_management_base.rb +68 -0
- data/lib/rodauth/features/oauth_pkce.rb +98 -0
- data/lib/rodauth/features/oauth_resource_server.rb +21 -0
- data/lib/rodauth/features/oauth_saml_bearer_grant.rb +102 -0
- data/lib/rodauth/features/oauth_token_introspection.rb +108 -0
- data/lib/rodauth/features/oauth_token_management.rb +79 -0
- data/lib/rodauth/features/oauth_token_revocation.rb +109 -0
- data/lib/rodauth/features/oidc.rb +38 -9
- data/lib/rodauth/features/oidc_dynamic_client_registration.rb +147 -0
- data/lib/rodauth/oauth/database_extensions.rb +15 -2
- data/lib/rodauth/oauth/jwe_extensions.rb +64 -0
- data/lib/rodauth/oauth/refinements.rb +48 -0
- data/lib/rodauth/oauth/ttl_store.rb +9 -3
- data/lib/rodauth/oauth/version.rb +1 -1
- data/locales/en.yml +33 -12
- data/templates/authorize.str +57 -8
- data/templates/client_secret_field.str +2 -2
- data/templates/description_field.str +1 -1
- data/templates/device_search.str +11 -0
- data/templates/device_verification.str +24 -0
- data/templates/homepage_url_field.str +2 -2
- data/templates/jwks_field.str +4 -0
- data/templates/jwt_public_key_field.str +4 -0
- data/templates/name_field.str +1 -1
- data/templates/new_oauth_application.str +9 -0
- data/templates/oauth_application.str +7 -3
- data/templates/oauth_application_oauth_tokens.str +52 -0
- data/templates/oauth_applications.str +3 -2
- data/templates/oauth_tokens.str +10 -11
- data/templates/redirect_uri_field.str +2 -2
- metadata +80 -3
- data/lib/rodauth/features/oauth_saml.rb +0 -104
@@ -0,0 +1,778 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
require "base64"
|
5
|
+
require "securerandom"
|
6
|
+
require "net/http"
|
7
|
+
require "rodauth/version"
|
8
|
+
require "rodauth/oauth/version"
|
9
|
+
require "rodauth/oauth/ttl_store"
|
10
|
+
require "rodauth/oauth/database_extensions"
|
11
|
+
require "rodauth/oauth/refinements"
|
12
|
+
|
13
|
+
module Rodauth
|
14
|
+
Feature.define(:oauth_base, :OauthBase) do
|
15
|
+
using RegexpExtensions
|
16
|
+
|
17
|
+
SCOPES = %w[profile.read].freeze
|
18
|
+
|
19
|
+
before "token"
|
20
|
+
|
21
|
+
error_flash "Please authorize to continue", "require_authorization"
|
22
|
+
error_flash "You are not authorized to revoke this token", "revoke_unauthorized_account"
|
23
|
+
|
24
|
+
button "Cancel", "oauth_cancel"
|
25
|
+
|
26
|
+
auth_value_method :json_response_content_type, "application/json"
|
27
|
+
|
28
|
+
auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes
|
29
|
+
auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes
|
30
|
+
auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year
|
31
|
+
auth_value_method :oauth_unique_id_generation_retries, 3
|
32
|
+
|
33
|
+
auth_value_method :oauth_response_mode, "query"
|
34
|
+
auth_value_method :oauth_auth_methods_supported, %w[client_secret_basic client_secret_post]
|
35
|
+
|
36
|
+
auth_value_method :oauth_scope_separator, " "
|
37
|
+
|
38
|
+
auth_value_method :oauth_tokens_table, :oauth_tokens
|
39
|
+
auth_value_method :oauth_tokens_id_column, :id
|
40
|
+
|
41
|
+
%i[
|
42
|
+
oauth_application_id oauth_token_id oauth_grant_id account_id
|
43
|
+
token refresh_token scopes
|
44
|
+
expires_in revoked_at
|
45
|
+
].each do |column|
|
46
|
+
auth_value_method :"oauth_tokens_#{column}_column", column
|
47
|
+
end
|
48
|
+
|
49
|
+
# Oauth Token Hash
|
50
|
+
auth_value_method :oauth_tokens_token_hash_column, nil
|
51
|
+
auth_value_method :oauth_tokens_refresh_token_hash_column, nil
|
52
|
+
|
53
|
+
# Access Token reuse
|
54
|
+
auth_value_method :oauth_reuse_access_token, false
|
55
|
+
|
56
|
+
auth_value_method :oauth_applications_table, :oauth_applications
|
57
|
+
auth_value_method :oauth_applications_id_column, :id
|
58
|
+
|
59
|
+
%i[
|
60
|
+
account_id
|
61
|
+
name description scopes
|
62
|
+
client_id client_secret
|
63
|
+
homepage_url redirect_uri
|
64
|
+
token_endpoint_auth_method grant_types response_types
|
65
|
+
logo_uri tos_uri policy_uri jwks jwks_uri
|
66
|
+
contacts software_id software_version
|
67
|
+
].each do |column|
|
68
|
+
auth_value_method :"oauth_applications_#{column}_column", column
|
69
|
+
end
|
70
|
+
|
71
|
+
auth_value_method :authorization_required_error_status, 401
|
72
|
+
auth_value_method :invalid_oauth_response_status, 400
|
73
|
+
auth_value_method :already_in_use_response_status, 409
|
74
|
+
|
75
|
+
# Feature options
|
76
|
+
auth_value_method :oauth_application_default_scope, SCOPES.first
|
77
|
+
auth_value_method :oauth_application_scopes, SCOPES
|
78
|
+
auth_value_method :oauth_token_type, "bearer"
|
79
|
+
auth_value_method :oauth_refresh_token_protection_policy, "none" # can be: none, sender_constrained, rotation
|
80
|
+
|
81
|
+
translatable_method :invalid_client_message, "Invalid client"
|
82
|
+
translatable_method :invalid_grant_type_message, "Invalid grant type"
|
83
|
+
translatable_method :invalid_grant_message, "Invalid grant"
|
84
|
+
translatable_method :invalid_scope_message, "Invalid scope"
|
85
|
+
translatable_method :unsupported_token_type_message, "Invalid token type hint"
|
86
|
+
|
87
|
+
translatable_method :unique_error_message, "is already in use"
|
88
|
+
translatable_method :already_in_use_message, "error generating unique token"
|
89
|
+
auth_value_method :already_in_use_error_code, "invalid_request"
|
90
|
+
auth_value_method :invalid_grant_type_error_code, "unsupported_grant_type"
|
91
|
+
|
92
|
+
# Resource Server params
|
93
|
+
# Only required to use if the plugin is to be used in a resource server
|
94
|
+
auth_value_method :is_authorization_server?, true
|
95
|
+
|
96
|
+
auth_value_methods(:only_json?)
|
97
|
+
|
98
|
+
auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i
|
99
|
+
|
100
|
+
# METADATA
|
101
|
+
auth_value_method :oauth_metadata_service_documentation, nil
|
102
|
+
auth_value_method :oauth_metadata_ui_locales_supported, nil
|
103
|
+
auth_value_method :oauth_metadata_op_policy_uri, nil
|
104
|
+
auth_value_method :oauth_metadata_op_tos_uri, nil
|
105
|
+
|
106
|
+
auth_value_methods(
|
107
|
+
:fetch_access_token,
|
108
|
+
:secret_hash,
|
109
|
+
:generate_token_hash,
|
110
|
+
:secret_matches?,
|
111
|
+
:authorization_server_url,
|
112
|
+
:oauth_unique_id_generator,
|
113
|
+
:oauth_tokens_unique_columns,
|
114
|
+
:require_authorizable_account
|
115
|
+
)
|
116
|
+
|
117
|
+
# /token
|
118
|
+
route(:token) do |r|
|
119
|
+
next unless is_authorization_server?
|
120
|
+
|
121
|
+
before_token_route
|
122
|
+
require_oauth_application
|
123
|
+
|
124
|
+
r.post do
|
125
|
+
catch_error do
|
126
|
+
validate_oauth_token_params
|
127
|
+
|
128
|
+
oauth_token = nil
|
129
|
+
|
130
|
+
transaction do
|
131
|
+
before_token
|
132
|
+
oauth_token = create_oauth_token(param("grant_type"))
|
133
|
+
end
|
134
|
+
|
135
|
+
json_response_success(json_access_token_payload(oauth_token))
|
136
|
+
end
|
137
|
+
|
138
|
+
throw_json_response_error(invalid_oauth_response_status, "invalid_request")
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def oauth_server_metadata(issuer = nil)
|
143
|
+
request.on(".well-known") do
|
144
|
+
request.on("oauth-authorization-server") do
|
145
|
+
request.get do
|
146
|
+
json_response_success(oauth_server_metadata_body(issuer), true)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def check_csrf?
|
153
|
+
case request.path
|
154
|
+
when token_path
|
155
|
+
false
|
156
|
+
else
|
157
|
+
super
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Overrides session_value, so that a valid authorization token also authenticates a request
|
162
|
+
def session_value
|
163
|
+
super || begin
|
164
|
+
return unless authorization_token
|
165
|
+
|
166
|
+
authorization_token[oauth_tokens_account_id_column]
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def accepts_json?
|
171
|
+
return true if only_json?
|
172
|
+
|
173
|
+
(accept = request.env["HTTP_ACCEPT"]) && accept =~ json_request_regexp
|
174
|
+
end
|
175
|
+
|
176
|
+
unless method_defined?(:json_request?)
|
177
|
+
# copied from the jwt feature
|
178
|
+
def json_request?
|
179
|
+
return @json_request if defined?(@json_request)
|
180
|
+
|
181
|
+
@json_request = request.content_type =~ json_request_regexp
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def scopes
|
186
|
+
scope = request.params["scope"]
|
187
|
+
case scope
|
188
|
+
when Array
|
189
|
+
scope
|
190
|
+
when String
|
191
|
+
scope.split(" ")
|
192
|
+
when nil
|
193
|
+
Array(oauth_application_default_scope)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def redirect_uri
|
198
|
+
param_or_nil("redirect_uri") || begin
|
199
|
+
return unless oauth_application
|
200
|
+
|
201
|
+
redirect_uris = oauth_application[oauth_applications_redirect_uri_column].split(" ")
|
202
|
+
redirect_uris.size == 1 ? redirect_uris.first : nil
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def oauth_application
|
207
|
+
return @oauth_application if defined?(@oauth_application)
|
208
|
+
|
209
|
+
@oauth_application = begin
|
210
|
+
client_id = param_or_nil("client_id")
|
211
|
+
|
212
|
+
return unless client_id
|
213
|
+
|
214
|
+
db[oauth_applications_table].filter(oauth_applications_client_id_column => client_id).first
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def fetch_access_token
|
219
|
+
value = request.env["HTTP_AUTHORIZATION"]
|
220
|
+
|
221
|
+
return unless value && !value.empty?
|
222
|
+
|
223
|
+
scheme, token = value.split(" ", 2)
|
224
|
+
|
225
|
+
return unless scheme.downcase == oauth_token_type
|
226
|
+
|
227
|
+
return if token.nil? || token.empty?
|
228
|
+
|
229
|
+
token
|
230
|
+
end
|
231
|
+
|
232
|
+
def authorization_token
|
233
|
+
return @authorization_token if defined?(@authorization_token)
|
234
|
+
|
235
|
+
# check if there is a token
|
236
|
+
bearer_token = fetch_access_token
|
237
|
+
|
238
|
+
return unless bearer_token
|
239
|
+
|
240
|
+
@authorization_token = if is_authorization_server?
|
241
|
+
# check if token has not expired
|
242
|
+
# check if token has been revoked
|
243
|
+
oauth_token_by_token(bearer_token)
|
244
|
+
else
|
245
|
+
# where in resource server, NOT the authorization server.
|
246
|
+
payload = introspection_request("access_token", bearer_token)
|
247
|
+
|
248
|
+
return unless payload["active"]
|
249
|
+
|
250
|
+
payload
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
def require_oauth_authorization(*scopes)
|
255
|
+
authorization_required unless authorization_token
|
256
|
+
|
257
|
+
scopes << oauth_application_default_scope if scopes.empty?
|
258
|
+
|
259
|
+
token_scopes = if is_authorization_server?
|
260
|
+
authorization_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
|
261
|
+
else
|
262
|
+
aux_scopes = authorization_token["scope"]
|
263
|
+
if aux_scopes
|
264
|
+
aux_scopes.split(oauth_scope_separator)
|
265
|
+
else
|
266
|
+
[]
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) }
|
271
|
+
end
|
272
|
+
|
273
|
+
def use_date_arithmetic?
|
274
|
+
true
|
275
|
+
end
|
276
|
+
|
277
|
+
def post_configure
|
278
|
+
super
|
279
|
+
|
280
|
+
# all of the extensions below involve DB changes. Resource server mode doesn't use
|
281
|
+
# database functions for OAuth though.
|
282
|
+
return unless is_authorization_server?
|
283
|
+
|
284
|
+
self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db))
|
285
|
+
|
286
|
+
# Check whether we can reutilize db entries for the same account / application pair
|
287
|
+
one_oauth_token_per_account = db.indexes(oauth_tokens_table).values.any? do |definition|
|
288
|
+
definition[:unique] &&
|
289
|
+
definition[:columns] == oauth_tokens_unique_columns
|
290
|
+
end
|
291
|
+
|
292
|
+
self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account }
|
293
|
+
|
294
|
+
i18n_register(File.expand_path(File.join(__dir__, "..", "..", "..", "locales"))) if features.include?(:i18n)
|
295
|
+
end
|
296
|
+
|
297
|
+
private
|
298
|
+
|
299
|
+
def require_authorizable_account
|
300
|
+
require_account
|
301
|
+
end
|
302
|
+
|
303
|
+
def rescue_from_uniqueness_error(&block)
|
304
|
+
retries = oauth_unique_id_generation_retries
|
305
|
+
begin
|
306
|
+
transaction(savepoint: :only, &block)
|
307
|
+
rescue Sequel::UniqueConstraintViolation
|
308
|
+
redirect_response_error("already_in_use") if retries.zero?
|
309
|
+
retries -= 1
|
310
|
+
retry
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
# OAuth Token Unique/Reuse
|
315
|
+
def oauth_tokens_unique_columns
|
316
|
+
[
|
317
|
+
oauth_tokens_oauth_application_id_column,
|
318
|
+
oauth_tokens_account_id_column,
|
319
|
+
oauth_tokens_scopes_column
|
320
|
+
]
|
321
|
+
end
|
322
|
+
|
323
|
+
def authorization_server_url
|
324
|
+
base_url
|
325
|
+
end
|
326
|
+
|
327
|
+
def template_path(page)
|
328
|
+
path = File.join(File.dirname(__FILE__), "../../../templates", "#{page}.str")
|
329
|
+
return super unless File.exist?(path)
|
330
|
+
|
331
|
+
path
|
332
|
+
end
|
333
|
+
|
334
|
+
# to be used internally. Same semantics as require account, must:
|
335
|
+
# fetch an authorization basic header
|
336
|
+
# parse client id and secret
|
337
|
+
#
|
338
|
+
def require_oauth_application
|
339
|
+
# get client credentials
|
340
|
+
auth_method = nil
|
341
|
+
client_id = client_secret = nil
|
342
|
+
|
343
|
+
if (token = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Basic (.*)\Z/, 1]))
|
344
|
+
# client_secret_basic
|
345
|
+
client_id, client_secret = Base64.decode64(token).split(/:/, 2)
|
346
|
+
auth_method = "client_secret_basic"
|
347
|
+
else
|
348
|
+
# client_secret_post
|
349
|
+
client_id = param_or_nil("client_id")
|
350
|
+
client_secret = param_or_nil("client_secret")
|
351
|
+
auth_method = "client_secret_post" if client_secret
|
352
|
+
end
|
353
|
+
|
354
|
+
authorization_required unless client_id
|
355
|
+
|
356
|
+
@oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
|
357
|
+
|
358
|
+
authorization_required unless @oauth_application
|
359
|
+
|
360
|
+
authorization_required unless authorized_oauth_application?(@oauth_application, client_secret, auth_method)
|
361
|
+
end
|
362
|
+
|
363
|
+
def authorized_oauth_application?(oauth_application, client_secret, auth_method)
|
364
|
+
supported_auth_methods = if oauth_application[oauth_applications_token_endpoint_auth_method_column]
|
365
|
+
oauth_application[oauth_applications_token_endpoint_auth_method_column].split(/ +/)
|
366
|
+
else
|
367
|
+
oauth_auth_methods_supported
|
368
|
+
end
|
369
|
+
|
370
|
+
if auth_method
|
371
|
+
supported_auth_methods.include?(auth_method) && secret_matches?(oauth_application, client_secret)
|
372
|
+
else
|
373
|
+
supported_auth_methods.include?("none")
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def no_auth_oauth_application?(_oauth_application)
|
378
|
+
supported_auth_methods.include?("none")
|
379
|
+
end
|
380
|
+
|
381
|
+
def require_oauth_application_from_account
|
382
|
+
ds = db[oauth_applications_table]
|
383
|
+
.join(oauth_tokens_table, Sequel[oauth_tokens_table][oauth_tokens_oauth_application_id_column] =>
|
384
|
+
Sequel[oauth_applications_table][oauth_applications_id_column])
|
385
|
+
.where(oauth_token_by_token_ds(param("token")).opts.fetch(:where, true))
|
386
|
+
.where(Sequel[oauth_applications_table][oauth_applications_account_id_column] => account_id)
|
387
|
+
|
388
|
+
@oauth_application = ds.qualify.first
|
389
|
+
return if @oauth_application
|
390
|
+
|
391
|
+
set_redirect_error_flash revoke_unauthorized_account_error_flash
|
392
|
+
redirect request.referer || "/"
|
393
|
+
end
|
394
|
+
|
395
|
+
def secret_matches?(oauth_application, secret)
|
396
|
+
BCrypt::Password.new(oauth_application[oauth_applications_client_secret_column]) == secret
|
397
|
+
end
|
398
|
+
|
399
|
+
def secret_hash(secret)
|
400
|
+
password_hash(secret)
|
401
|
+
end
|
402
|
+
|
403
|
+
def oauth_unique_id_generator
|
404
|
+
SecureRandom.urlsafe_base64(32)
|
405
|
+
end
|
406
|
+
|
407
|
+
def generate_token_hash(token)
|
408
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(token))
|
409
|
+
end
|
410
|
+
|
411
|
+
def token_from_application?(oauth_token, oauth_application)
|
412
|
+
oauth_token[oauth_tokens_oauth_application_id_column] == oauth_application[oauth_applications_id_column]
|
413
|
+
end
|
414
|
+
|
415
|
+
unless method_defined?(:password_hash)
|
416
|
+
# From login_requirements_base feature
|
417
|
+
|
418
|
+
def password_hash(password)
|
419
|
+
BCrypt::Password.create(password, cost: BCrypt::Engine::DEFAULT_COST)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
def generate_oauth_token(params = {}, should_generate_refresh_token = true)
|
424
|
+
create_params = {
|
425
|
+
oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
|
426
|
+
}.merge(params)
|
427
|
+
|
428
|
+
rescue_from_uniqueness_error do
|
429
|
+
token = oauth_unique_id_generator
|
430
|
+
|
431
|
+
if oauth_tokens_token_hash_column
|
432
|
+
create_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
|
433
|
+
else
|
434
|
+
create_params[oauth_tokens_token_column] = token
|
435
|
+
end
|
436
|
+
|
437
|
+
refresh_token = nil
|
438
|
+
if should_generate_refresh_token
|
439
|
+
refresh_token = oauth_unique_id_generator
|
440
|
+
|
441
|
+
if oauth_tokens_refresh_token_hash_column
|
442
|
+
create_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
|
443
|
+
else
|
444
|
+
create_params[oauth_tokens_refresh_token_column] = refresh_token
|
445
|
+
end
|
446
|
+
end
|
447
|
+
oauth_token = _generate_oauth_token(create_params)
|
448
|
+
oauth_token[oauth_tokens_token_column] = token
|
449
|
+
oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
|
450
|
+
oauth_token
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
def _generate_oauth_token(params = {})
|
455
|
+
ds = db[oauth_tokens_table]
|
456
|
+
|
457
|
+
if __one_oauth_token_per_account
|
458
|
+
|
459
|
+
token = __insert_or_update_and_return__(
|
460
|
+
ds,
|
461
|
+
oauth_tokens_id_column,
|
462
|
+
oauth_tokens_unique_columns,
|
463
|
+
params,
|
464
|
+
Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP,
|
465
|
+
([oauth_tokens_token_column, oauth_tokens_refresh_token_column] if oauth_reuse_access_token)
|
466
|
+
)
|
467
|
+
|
468
|
+
# if the previous operation didn't return a row, it means that the conditions
|
469
|
+
# invalidated the update, and the existing token is still valid.
|
470
|
+
token || ds.where(
|
471
|
+
oauth_tokens_account_id_column => params[oauth_tokens_account_id_column],
|
472
|
+
oauth_tokens_oauth_application_id_column => params[oauth_tokens_oauth_application_id_column]
|
473
|
+
).first
|
474
|
+
else
|
475
|
+
if oauth_reuse_access_token
|
476
|
+
unique_conds = Hash[oauth_tokens_unique_columns.map { |column| [column, params[column]] }]
|
477
|
+
valid_token = ds.where(Sequel.expr(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column]) > Sequel::CURRENT_TIMESTAMP)
|
478
|
+
.where(unique_conds).first
|
479
|
+
return valid_token if valid_token
|
480
|
+
end
|
481
|
+
__insert_and_return__(ds, oauth_tokens_id_column, params)
|
482
|
+
end
|
483
|
+
end
|
484
|
+
|
485
|
+
def oauth_token_by_token_ds(token)
|
486
|
+
ds = db[oauth_tokens_table]
|
487
|
+
|
488
|
+
ds = if oauth_tokens_token_hash_column
|
489
|
+
ds.where(Sequel[oauth_tokens_table][oauth_tokens_token_hash_column] => generate_token_hash(token))
|
490
|
+
else
|
491
|
+
ds.where(Sequel[oauth_tokens_table][oauth_tokens_token_column] => token)
|
492
|
+
end
|
493
|
+
|
494
|
+
ds.where(Sequel[oauth_tokens_table][oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP)
|
495
|
+
.where(Sequel[oauth_tokens_table][oauth_tokens_revoked_at_column] => nil)
|
496
|
+
end
|
497
|
+
|
498
|
+
def oauth_token_by_token(token)
|
499
|
+
oauth_token_by_token_ds(token).first
|
500
|
+
end
|
501
|
+
|
502
|
+
def oauth_token_by_refresh_token(token, revoked: false)
|
503
|
+
ds = db[oauth_tokens_table]
|
504
|
+
#
|
505
|
+
# filter expired refresh tokens out.
|
506
|
+
# an expired refresh token is a token whose access token expired for a period longer than the
|
507
|
+
# refresh token expiration period.
|
508
|
+
#
|
509
|
+
ds = ds.where(Sequel.date_add(oauth_tokens_expires_in_column, seconds: oauth_refresh_token_expires_in) >= Sequel::CURRENT_TIMESTAMP)
|
510
|
+
|
511
|
+
ds = if oauth_tokens_refresh_token_hash_column
|
512
|
+
ds.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token))
|
513
|
+
else
|
514
|
+
ds.where(oauth_tokens_refresh_token_column => token)
|
515
|
+
end
|
516
|
+
|
517
|
+
ds = ds.where(oauth_tokens_revoked_at_column => nil) unless revoked
|
518
|
+
|
519
|
+
ds.first
|
520
|
+
end
|
521
|
+
|
522
|
+
def json_access_token_payload(oauth_token)
|
523
|
+
payload = {
|
524
|
+
"access_token" => oauth_token[oauth_tokens_token_column],
|
525
|
+
"token_type" => oauth_token_type,
|
526
|
+
"expires_in" => oauth_token_expires_in
|
527
|
+
}
|
528
|
+
payload["refresh_token"] = oauth_token[oauth_tokens_refresh_token_column] if oauth_token[oauth_tokens_refresh_token_column]
|
529
|
+
payload
|
530
|
+
end
|
531
|
+
|
532
|
+
# Access Tokens
|
533
|
+
|
534
|
+
def validate_oauth_token_params
|
535
|
+
unless (grant_type = param_or_nil("grant_type"))
|
536
|
+
redirect_response_error("invalid_request")
|
537
|
+
end
|
538
|
+
|
539
|
+
redirect_response_error("invalid_request") if grant_type == "refresh_token" && !param_or_nil("refresh_token")
|
540
|
+
end
|
541
|
+
|
542
|
+
def create_oauth_token(grant_type)
|
543
|
+
if supported_grant_type?(grant_type, "refresh_token")
|
544
|
+
# fetch potentially revoked oauth token
|
545
|
+
oauth_token = oauth_token_by_refresh_token(param("refresh_token"), revoked: true)
|
546
|
+
|
547
|
+
if !oauth_token
|
548
|
+
redirect_response_error("invalid_grant")
|
549
|
+
elsif oauth_token[oauth_tokens_revoked_at_column]
|
550
|
+
if oauth_refresh_token_protection_policy == "rotation"
|
551
|
+
# https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1
|
552
|
+
#
|
553
|
+
# If a refresh token is compromised and subsequently used by both the attacker and the legitimate
|
554
|
+
# client, one of them will present an invalidated refresh token, which will inform the authorization
|
555
|
+
# server of the breach. The authorization server cannot determine which party submitted the invalid
|
556
|
+
# refresh token, but it will revoke the active refresh token. This stops the attack at the cost of
|
557
|
+
# forcing the legitimate client to obtain a fresh authorization grant.
|
558
|
+
|
559
|
+
db[oauth_tokens_table].where(oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column])
|
560
|
+
.update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
|
561
|
+
end
|
562
|
+
redirect_response_error("invalid_grant")
|
563
|
+
end
|
564
|
+
|
565
|
+
update_params = {
|
566
|
+
oauth_tokens_oauth_application_id_column => oauth_token[oauth_tokens_oauth_application_id_column],
|
567
|
+
oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in)
|
568
|
+
}
|
569
|
+
create_oauth_token_from_token(oauth_token, update_params)
|
570
|
+
else
|
571
|
+
redirect_response_error("invalid_request")
|
572
|
+
end
|
573
|
+
end
|
574
|
+
|
575
|
+
def create_oauth_token_from_token(oauth_token, update_params)
|
576
|
+
redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application)
|
577
|
+
|
578
|
+
rescue_from_uniqueness_error do
|
579
|
+
oauth_tokens_ds = db[oauth_tokens_table]
|
580
|
+
token = oauth_unique_id_generator
|
581
|
+
|
582
|
+
if oauth_tokens_token_hash_column
|
583
|
+
update_params[oauth_tokens_token_hash_column] = generate_token_hash(token)
|
584
|
+
else
|
585
|
+
update_params[oauth_tokens_token_column] = token
|
586
|
+
end
|
587
|
+
|
588
|
+
oauth_token = if oauth_refresh_token_protection_policy == "rotation"
|
589
|
+
insert_params = {
|
590
|
+
**update_params,
|
591
|
+
oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column],
|
592
|
+
oauth_tokens_scopes_column => oauth_token[oauth_tokens_scopes_column]
|
593
|
+
}
|
594
|
+
|
595
|
+
refresh_token = oauth_unique_id_generator
|
596
|
+
|
597
|
+
if oauth_tokens_refresh_token_hash_column
|
598
|
+
insert_params[oauth_tokens_refresh_token_hash_column] = generate_token_hash(refresh_token)
|
599
|
+
else
|
600
|
+
insert_params[oauth_tokens_refresh_token_column] = refresh_token
|
601
|
+
end
|
602
|
+
|
603
|
+
# revoke the refresh token
|
604
|
+
oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
605
|
+
.update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
|
606
|
+
|
607
|
+
insert_params[oauth_tokens_oauth_token_id_column] = oauth_token[oauth_tokens_id_column]
|
608
|
+
__insert_and_return__(oauth_tokens_ds, oauth_tokens_id_column, insert_params)
|
609
|
+
else
|
610
|
+
# includes none
|
611
|
+
ds = oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column])
|
612
|
+
__update_and_return__(ds, update_params)
|
613
|
+
end
|
614
|
+
|
615
|
+
oauth_token[oauth_tokens_token_column] = token
|
616
|
+
oauth_token[oauth_tokens_refresh_token_column] = refresh_token if refresh_token
|
617
|
+
oauth_token
|
618
|
+
end
|
619
|
+
end
|
620
|
+
|
621
|
+
def supported_grant_type?(grant_type, expected_grant_type = grant_type)
|
622
|
+
return false unless grant_type == expected_grant_type
|
623
|
+
|
624
|
+
return true unless (grant_types_supported = oauth_application[oauth_applications_grant_types_column])
|
625
|
+
|
626
|
+
grant_types_supported = grant_types_supported.split(/ +/)
|
627
|
+
|
628
|
+
grant_types_supported.include?(grant_type)
|
629
|
+
end
|
630
|
+
|
631
|
+
def oauth_server_metadata_body(path = nil)
|
632
|
+
issuer = base_url
|
633
|
+
issuer += "/#{path}" if path
|
634
|
+
|
635
|
+
{
|
636
|
+
issuer: issuer,
|
637
|
+
token_endpoint: token_url,
|
638
|
+
scopes_supported: oauth_application_scopes,
|
639
|
+
response_types_supported: [],
|
640
|
+
response_modes_supported: [],
|
641
|
+
grant_types_supported: %w[refresh_token],
|
642
|
+
token_endpoint_auth_methods_supported: oauth_auth_methods_supported,
|
643
|
+
service_documentation: oauth_metadata_service_documentation,
|
644
|
+
ui_locales_supported: oauth_metadata_ui_locales_supported,
|
645
|
+
op_policy_uri: oauth_metadata_op_policy_uri,
|
646
|
+
op_tos_uri: oauth_metadata_op_tos_uri
|
647
|
+
}
|
648
|
+
end
|
649
|
+
|
650
|
+
def redirect_response_error(error_code, redirect_url = redirect_uri || request.referer || default_redirect)
|
651
|
+
if accepts_json?
|
652
|
+
status_code = if respond_to?(:"#{error_code}_response_status")
|
653
|
+
send(:"#{error_code}_response_status")
|
654
|
+
else
|
655
|
+
invalid_oauth_response_status
|
656
|
+
end
|
657
|
+
|
658
|
+
throw_json_response_error(status_code, error_code)
|
659
|
+
else
|
660
|
+
redirect_url = URI.parse(redirect_url)
|
661
|
+
query_params = []
|
662
|
+
|
663
|
+
query_params << if respond_to?(:"#{error_code}_error_code")
|
664
|
+
"error=#{send(:"#{error_code}_error_code")}"
|
665
|
+
else
|
666
|
+
"error=#{error_code}"
|
667
|
+
end
|
668
|
+
|
669
|
+
if respond_to?(:"#{error_code}_message")
|
670
|
+
message = send(:"#{error_code}_message")
|
671
|
+
query_params << ["error_description=#{CGI.escape(message)}"]
|
672
|
+
end
|
673
|
+
|
674
|
+
query_params << redirect_url.query if redirect_url.query
|
675
|
+
redirect_url.query = query_params.join("&")
|
676
|
+
redirect(redirect_url.to_s)
|
677
|
+
end
|
678
|
+
end
|
679
|
+
|
680
|
+
def json_response_success(body, cache = false)
|
681
|
+
response.status = 200
|
682
|
+
response["Content-Type"] ||= json_response_content_type
|
683
|
+
if cache
|
684
|
+
# defaulting to 1-day for everyone, for now at least
|
685
|
+
max_age = 60 * 60 * 24
|
686
|
+
response["Cache-Control"] = "private, max-age=#{max_age}"
|
687
|
+
else
|
688
|
+
response["Cache-Control"] = "no-store"
|
689
|
+
response["Pragma"] = "no-cache"
|
690
|
+
end
|
691
|
+
json_payload = _json_response_body(body)
|
692
|
+
return_response(json_payload)
|
693
|
+
end
|
694
|
+
|
695
|
+
def throw_json_response_error(status, error_code, message = nil)
|
696
|
+
set_response_error_status(status)
|
697
|
+
code = if respond_to?(:"#{error_code}_error_code")
|
698
|
+
send(:"#{error_code}_error_code")
|
699
|
+
else
|
700
|
+
error_code
|
701
|
+
end
|
702
|
+
payload = { "error" => code }
|
703
|
+
payload["error_description"] = message || (send(:"#{error_code}_message") if respond_to?(:"#{error_code}_message"))
|
704
|
+
json_payload = _json_response_body(payload)
|
705
|
+
response["Content-Type"] ||= json_response_content_type
|
706
|
+
response["WWW-Authenticate"] = oauth_token_type.upcase if status == 401
|
707
|
+
return_response(json_payload)
|
708
|
+
end
|
709
|
+
|
710
|
+
unless method_defined?(:_json_response_body)
|
711
|
+
def _json_response_body(hash)
|
712
|
+
if request.respond_to?(:convert_to_json)
|
713
|
+
request.send(:convert_to_json, hash)
|
714
|
+
else
|
715
|
+
JSON.dump(hash)
|
716
|
+
end
|
717
|
+
end
|
718
|
+
end
|
719
|
+
|
720
|
+
if Gem::Version.new(Rodauth.version) < Gem::Version.new("2.23")
|
721
|
+
def return_response(body = nil)
|
722
|
+
response.write(body) if body
|
723
|
+
request.halt
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
def authorization_required
|
728
|
+
if accepts_json?
|
729
|
+
throw_json_response_error(authorization_required_error_status, "invalid_client")
|
730
|
+
else
|
731
|
+
set_redirect_error_flash(require_authorization_error_flash)
|
732
|
+
redirect(authorize_path)
|
733
|
+
end
|
734
|
+
end
|
735
|
+
|
736
|
+
def check_valid_scopes?
|
737
|
+
return false unless scopes
|
738
|
+
|
739
|
+
(scopes - oauth_application[oauth_applications_scopes_column].split(oauth_scope_separator)).empty?
|
740
|
+
end
|
741
|
+
|
742
|
+
def check_valid_uri?(uri)
|
743
|
+
URI::DEFAULT_PARSER.make_regexp(oauth_valid_uri_schemes).match?(uri)
|
744
|
+
end
|
745
|
+
|
746
|
+
# Resource server mode
|
747
|
+
|
748
|
+
SERVER_METADATA = OAuth::TtlStore.new
|
749
|
+
|
750
|
+
def authorization_server_metadata
|
751
|
+
auth_url = URI(authorization_server_url)
|
752
|
+
|
753
|
+
server_metadata = SERVER_METADATA[auth_url]
|
754
|
+
|
755
|
+
return server_metadata if server_metadata
|
756
|
+
|
757
|
+
SERVER_METADATA.set(auth_url) do
|
758
|
+
http = Net::HTTP.new(auth_url.host, auth_url.port)
|
759
|
+
http.use_ssl = auth_url.scheme == "https"
|
760
|
+
|
761
|
+
request = Net::HTTP::Get.new("/.well-known/oauth-authorization-server")
|
762
|
+
request["accept"] = json_response_content_type
|
763
|
+
response = http.request(request)
|
764
|
+
authorization_required unless response.code.to_i == 200
|
765
|
+
|
766
|
+
# time-to-live
|
767
|
+
ttl = if response.key?("cache-control")
|
768
|
+
cache_control = response["cache-control"]
|
769
|
+
cache_control[/max-age=(\d+)/, 1].to_i
|
770
|
+
elsif response.key?("expires")
|
771
|
+
Time.parse(response["expires"]).to_i - Time.now.to_i
|
772
|
+
end
|
773
|
+
|
774
|
+
[JSON.parse(response.body, symbolize_names: true), ttl]
|
775
|
+
end
|
776
|
+
end
|
777
|
+
end
|
778
|
+
end
|