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