rodauth-oauth 0.10.4 → 1.0.0.pre.beta2
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/MIGRATION-GUIDE-v1.md +286 -0
- data/README.md +28 -35
- data/doc/release_notes/1_0_0_beta1.md +38 -0
- data/doc/release_notes/1_0_0_beta2.md +34 -0
- data/lib/generators/rodauth/oauth/install_generator.rb +0 -1
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +21 -11
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/device_search.html.erb +1 -1
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/device_verification.html.erb +2 -2
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/new_oauth_application.html.erb +1 -6
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_application.html.erb +0 -2
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_application_oauth_grants.html.erb +41 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +2 -2
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_grants.html.erb +37 -0
- data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +57 -57
- data/lib/rodauth/features/oauth_application_management.rb +61 -74
- data/lib/rodauth/features/oauth_assertion_base.rb +19 -23
- data/lib/rodauth/features/oauth_authorization_code_grant.rb +62 -90
- data/lib/rodauth/features/oauth_authorize_base.rb +115 -22
- data/lib/rodauth/features/oauth_base.rb +397 -315
- data/lib/rodauth/features/oauth_client_credentials_grant.rb +20 -18
- data/lib/rodauth/features/{oauth_device_grant.rb → oauth_device_code_grant.rb} +62 -73
- data/lib/rodauth/features/oauth_dynamic_client_registration.rb +52 -31
- data/lib/rodauth/features/oauth_grant_management.rb +70 -0
- data/lib/rodauth/features/oauth_implicit_grant.rb +29 -27
- data/lib/rodauth/features/oauth_jwt.rb +53 -689
- data/lib/rodauth/features/oauth_jwt_base.rb +458 -0
- data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +48 -17
- data/lib/rodauth/features/oauth_jwt_jwks.rb +47 -0
- data/lib/rodauth/features/oauth_jwt_secured_authorization_request.rb +116 -0
- data/lib/rodauth/features/oauth_management_base.rb +2 -0
- data/lib/rodauth/features/oauth_pkce.rb +22 -26
- data/lib/rodauth/features/oauth_resource_indicators.rb +33 -25
- data/lib/rodauth/features/oauth_resource_server.rb +59 -0
- data/lib/rodauth/features/oauth_saml_bearer_grant.rb +7 -1
- data/lib/rodauth/features/oauth_token_introspection.rb +76 -46
- data/lib/rodauth/features/oauth_token_revocation.rb +46 -33
- data/lib/rodauth/features/oidc.rb +382 -241
- data/lib/rodauth/features/oidc_dynamic_client_registration.rb +127 -51
- data/lib/rodauth/features/oidc_rp_initiated_logout.rb +115 -0
- data/lib/rodauth/oauth/database_extensions.rb +8 -6
- data/lib/rodauth/oauth/http_extensions.rb +74 -0
- data/lib/rodauth/oauth/railtie.rb +20 -0
- data/lib/rodauth/oauth/ttl_store.rb +2 -0
- data/lib/rodauth/oauth/version.rb +1 -1
- data/lib/rodauth/oauth.rb +29 -1
- data/locales/en.yml +34 -22
- data/locales/pt.yml +34 -22
- data/templates/authorize.str +19 -17
- data/templates/device_search.str +1 -1
- data/templates/device_verification.str +2 -2
- data/templates/jwks_field.str +1 -0
- data/templates/new_oauth_application.str +1 -2
- data/templates/oauth_application.str +2 -2
- data/templates/oauth_application_oauth_grants.str +54 -0
- data/templates/oauth_applications.str +2 -2
- data/templates/oauth_grants.str +52 -0
- metadata +23 -16
- data/lib/generators/rodauth/oauth/templates/app/models/oauth_token.rb +0 -4
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_application_oauth_tokens.html.erb +0 -39
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_tokens.html.erb +0 -35
- data/lib/rodauth/features/oauth.rb +0 -9
- data/lib/rodauth/features/oauth_http_mac.rb +0 -86
- data/lib/rodauth/features/oauth_token_management.rb +0 -81
- data/lib/rodauth/oauth/refinements.rb +0 -48
- data/templates/jwt_public_key_field.str +0 -4
- data/templates/oauth_application_oauth_tokens.str +0 -52
- data/templates/oauth_tokens.str +0 -50
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "rodauth/oauth"
|
4
|
+
|
3
5
|
module Rodauth
|
4
6
|
Feature.define(:oidc, :Oidc) do
|
5
7
|
# https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
@@ -15,6 +17,7 @@ module Rodauth
|
|
15
17
|
issuer
|
16
18
|
authorization_endpoint
|
17
19
|
end_session_endpoint
|
20
|
+
backchannel_logout_session_supported
|
18
21
|
token_endpoint
|
19
22
|
userinfo_endpoint
|
20
23
|
jwks_uri
|
@@ -60,78 +63,98 @@ module Rodauth
|
|
60
63
|
id_token_signing_alg_values_supported
|
61
64
|
].freeze
|
62
65
|
|
63
|
-
depends :account_expiration, :oauth_jwt
|
66
|
+
depends :account_expiration, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant
|
64
67
|
|
65
|
-
auth_value_method :oauth_application_default_scope, "openid"
|
66
68
|
auth_value_method :oauth_application_scopes, %w[openid]
|
67
69
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
70
|
+
%i[
|
71
|
+
subject_type application_type sector_identifier_uri
|
72
|
+
id_token_signed_response_alg id_token_encrypted_response_alg id_token_encrypted_response_enc
|
73
|
+
userinfo_signed_response_alg userinfo_encrypted_response_alg userinfo_encrypted_response_enc
|
74
|
+
].each do |column|
|
75
|
+
auth_value_method :"oauth_applications_#{column}_column", column
|
76
|
+
end
|
74
77
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
auth_value_method :oauth_tokens_acr_column, :acr
|
78
|
+
%i[nonce acr claims_locales claims].each do |column|
|
79
|
+
auth_value_method :"oauth_grants_#{column}_column", column
|
80
|
+
end
|
79
81
|
|
80
|
-
|
82
|
+
auth_value_method :oauth_jwt_subject_type, "public" # fallback subject type: public, pairwise
|
83
|
+
auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation
|
81
84
|
|
82
|
-
|
85
|
+
translatable_method :oauth_invalid_scope_message, "The Access Token expired"
|
83
86
|
|
84
87
|
auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login"
|
85
88
|
auth_value_method :oauth_prompt_login_cookie_options, {}.freeze
|
86
89
|
auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes
|
87
90
|
|
88
|
-
# logout
|
89
|
-
auth_value_method :oauth_applications_post_logout_redirect_uri_column, :post_logout_redirect_uri
|
90
|
-
auth_value_method :use_rp_initiated_logout?, false
|
91
|
-
|
92
91
|
auth_value_methods(
|
92
|
+
:oauth_acr_values_supported,
|
93
|
+
:get_oidc_account_last_login_at,
|
94
|
+
:oidc_authorize_on_prompt_none?,
|
93
95
|
:get_oidc_param,
|
94
96
|
:get_additional_param,
|
95
97
|
:require_acr_value_phr,
|
96
98
|
:require_acr_value_phrh,
|
97
|
-
:require_acr_value
|
99
|
+
:require_acr_value,
|
100
|
+
:json_webfinger_payload
|
98
101
|
)
|
99
102
|
|
100
103
|
# /userinfo
|
101
|
-
|
102
|
-
next unless is_authorization_server?
|
103
|
-
|
104
|
+
auth_server_route(:userinfo) do |r|
|
104
105
|
r.on method: %i[get post] do
|
105
106
|
catch_error do
|
106
|
-
|
107
|
+
claims = authorization_token
|
107
108
|
|
108
|
-
throw_json_response_error(
|
109
|
+
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless claims
|
109
110
|
|
110
|
-
oauth_scopes =
|
111
|
+
oauth_scopes = claims["scope"].split(" ")
|
111
112
|
|
112
|
-
throw_json_response_error(
|
113
|
+
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
|
113
114
|
|
114
|
-
account = db[accounts_table].where(account_id_column =>
|
115
|
+
account = db[accounts_table].where(account_id_column => claims["sub"]).first
|
115
116
|
|
116
|
-
throw_json_response_error(
|
117
|
+
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless account
|
117
118
|
|
118
119
|
oauth_scopes.delete("openid")
|
119
120
|
|
120
|
-
oidc_claims = { "sub" =>
|
121
|
+
oidc_claims = { "sub" => claims["sub"] }
|
121
122
|
|
122
|
-
|
123
|
+
@oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first
|
123
124
|
|
124
|
-
|
125
|
+
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless @oauth_application
|
125
126
|
|
126
|
-
|
127
|
+
oauth_grant = valid_oauth_grant_ds(
|
128
|
+
oauth_grants_oauth_application_id_column => @oauth_application[oauth_applications_id_column],
|
129
|
+
oauth_grants_account_id_column => account[account_id_column]
|
130
|
+
).first
|
131
|
+
|
132
|
+
claims_locales = oauth_grant[oauth_grants_claims_locales_column] if oauth_grant
|
133
|
+
|
134
|
+
if (claims = oauth_grant[oauth_grants_claims_column])
|
135
|
+
claims = JSON.parse(claims)
|
136
|
+
if (userinfo_essential_claims = claims["userinfo"])
|
137
|
+
oauth_scopes |= userinfo_essential_claims.to_a
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# 5.4 - The Claims requested by the profile, email, address, and phone scope values are returned from the UserInfo Endpoint
|
142
|
+
fill_with_account_claims(oidc_claims, account, oauth_scopes, claims_locales)
|
143
|
+
|
144
|
+
if (algo = @oauth_application[oauth_applications_userinfo_signed_response_alg_column])
|
127
145
|
params = {
|
128
|
-
jwks: oauth_application_jwks,
|
146
|
+
jwks: oauth_application_jwks(@oauth_application),
|
129
147
|
encryption_algorithm: @oauth_application[oauth_applications_userinfo_encrypted_response_alg_column],
|
130
148
|
encryption_method: @oauth_application[oauth_applications_userinfo_encrypted_response_enc_column]
|
131
149
|
}.compact
|
132
150
|
|
133
151
|
jwt = jwt_encode(
|
134
|
-
oidc_claims
|
152
|
+
oidc_claims.merge(
|
153
|
+
# If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value
|
154
|
+
# SHOULD be the OP's Issuer Identifier URL. The aud value SHOULD be or include the RP's Client ID value.
|
155
|
+
iss: oauth_jwt_issuer,
|
156
|
+
aud: @oauth_application[oauth_applications_client_id_column]
|
157
|
+
),
|
135
158
|
signing_algorithm: algo,
|
136
159
|
**params
|
137
160
|
)
|
@@ -141,92 +164,23 @@ module Rodauth
|
|
141
164
|
end
|
142
165
|
end
|
143
166
|
|
144
|
-
throw_json_response_error(
|
145
|
-
end
|
146
|
-
end
|
147
|
-
|
148
|
-
# /oidc-logout
|
149
|
-
route(:oidc_logout) do |r|
|
150
|
-
next unless use_rp_initiated_logout?
|
151
|
-
|
152
|
-
before_oidc_logout_route
|
153
|
-
require_authorizable_account
|
154
|
-
|
155
|
-
# OpenID Providers MUST support the use of the HTTP GET and POST methods
|
156
|
-
r.on method: %i[get post] do
|
157
|
-
catch_error do
|
158
|
-
validate_oidc_logout_params
|
159
|
-
|
160
|
-
#
|
161
|
-
# why this is done:
|
162
|
-
#
|
163
|
-
# we need to decode the id token in order to get the application, because, if the
|
164
|
-
# signing key is application-specific, we don't know how to verify the signature
|
165
|
-
# beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize
|
166
|
-
# the @oauth_application, and then decode-and-verify.
|
167
|
-
#
|
168
|
-
oauth_token = jwt_decode(param("id_token_hint"), verify_claims: false)
|
169
|
-
oauth_application_id = oauth_token["client_id"]
|
170
|
-
|
171
|
-
# check whether ID token belongs to currently logged-in user
|
172
|
-
redirect_response_error("invalid_request") unless oauth_token["sub"] == jwt_subject(
|
173
|
-
oauth_tokens_account_id_column => account_id,
|
174
|
-
oauth_tokens_oauth_application_id_column => oauth_application_id
|
175
|
-
)
|
176
|
-
|
177
|
-
# When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token.
|
178
|
-
redirect_response_error("invalid_request") unless oauth_token && oauth_token["iss"] == issuer
|
179
|
-
|
180
|
-
# now let's logout from IdP
|
181
|
-
transaction do
|
182
|
-
before_logout
|
183
|
-
logout
|
184
|
-
after_logout
|
185
|
-
end
|
186
|
-
|
187
|
-
if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri"))
|
188
|
-
catch(:default_logout_redirect) do
|
189
|
-
oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => oauth_token["client_id"]).first
|
190
|
-
|
191
|
-
throw(:default_logout_redirect) unless oauth_application
|
192
|
-
|
193
|
-
post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uri_column].split(" ")
|
194
|
-
|
195
|
-
throw(:default_logout_redirect) unless post_logout_redirect_uris.include?(post_logout_redirect_uri)
|
196
|
-
|
197
|
-
if (state = param_or_nil("state"))
|
198
|
-
post_logout_redirect_uri = URI(post_logout_redirect_uri)
|
199
|
-
params = ["state=#{state}"]
|
200
|
-
params << post_logout_redirect_uri.query if post_logout_redirect_uri.query
|
201
|
-
post_logout_redirect_uri.query = params.join("&")
|
202
|
-
post_logout_redirect_uri = post_logout_redirect_uri.to_s
|
203
|
-
end
|
204
|
-
|
205
|
-
redirect(post_logout_redirect_uri)
|
206
|
-
end
|
207
|
-
|
208
|
-
end
|
209
|
-
|
210
|
-
# regular logout procedure
|
211
|
-
set_notice_flash(logout_notice_flash)
|
212
|
-
redirect(logout_redirect)
|
213
|
-
end
|
214
|
-
|
215
|
-
redirect_response_error("invalid_request")
|
167
|
+
throw_json_response_error(oauth_authorization_required_error_status, "invalid_token")
|
216
168
|
end
|
217
169
|
end
|
218
170
|
|
219
|
-
def
|
171
|
+
def load_openid_configuration_route(alt_issuer = nil)
|
220
172
|
request.on(".well-known/openid-configuration") do
|
221
173
|
allow_cors(request)
|
222
174
|
|
223
|
-
request.
|
224
|
-
|
175
|
+
request.is do
|
176
|
+
request.get do
|
177
|
+
json_response_success(openid_configuration_body(alt_issuer), cache: true)
|
178
|
+
end
|
225
179
|
end
|
226
180
|
end
|
227
181
|
end
|
228
182
|
|
229
|
-
def
|
183
|
+
def load_webfinger_route
|
230
184
|
request.on(".well-known/webfinger") do
|
231
185
|
request.get do
|
232
186
|
resource = param_or_nil("resource")
|
@@ -236,14 +190,7 @@ module Rodauth
|
|
236
190
|
response.status = 200
|
237
191
|
response["Content-Type"] ||= "application/jrd+json"
|
238
192
|
|
239
|
-
|
240
|
-
subject: resource,
|
241
|
-
links: [{
|
242
|
-
rel: webfinger_relation,
|
243
|
-
href: authorization_server_url
|
244
|
-
}]
|
245
|
-
})
|
246
|
-
return_response(json_payload)
|
193
|
+
return_response(json_webfinger_payload)
|
247
194
|
end
|
248
195
|
end
|
249
196
|
end
|
@@ -257,6 +204,20 @@ module Rodauth
|
|
257
204
|
end
|
258
205
|
end
|
259
206
|
|
207
|
+
def oauth_response_types_supported
|
208
|
+
grant_types = oauth_grant_types_supported
|
209
|
+
oidc_response_types = %w[id_token none]
|
210
|
+
oidc_response_types |= ["code id_token"] if grant_types.include?("authorization_code")
|
211
|
+
oidc_response_types |= ["code token", "id_token token", "code id_token token"] if grant_types.include?("implicit")
|
212
|
+
super | oidc_response_types
|
213
|
+
end
|
214
|
+
|
215
|
+
def current_oauth_account
|
216
|
+
subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
|
217
|
+
|
218
|
+
return super unless subject_type == "pairwise"
|
219
|
+
end
|
220
|
+
|
260
221
|
private
|
261
222
|
|
262
223
|
if defined?(::I18n)
|
@@ -272,18 +233,62 @@ module Rodauth
|
|
272
233
|
end
|
273
234
|
end
|
274
235
|
|
236
|
+
def oauth_acr_values_supported
|
237
|
+
acr_values = []
|
238
|
+
acr_values << "phrh" if features.include?(:webauthn_login)
|
239
|
+
acr_values << "phr" if respond_to?(:require_two_factor_authenticated)
|
240
|
+
acr_values
|
241
|
+
end
|
242
|
+
|
243
|
+
def oidc_authorize_on_prompt_none?(_account)
|
244
|
+
false
|
245
|
+
end
|
246
|
+
|
275
247
|
def validate_authorize_params
|
276
|
-
|
248
|
+
if (max_age = param_or_nil("max_age"))
|
277
249
|
|
278
|
-
|
250
|
+
max_age = Integer(max_age)
|
279
251
|
|
280
|
-
|
252
|
+
redirect_response_error("invalid_request") unless max_age.positive?
|
281
253
|
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
254
|
+
if Time.now - get_oidc_account_last_login_at(session_value) > max_age
|
255
|
+
# force user to re-login
|
256
|
+
clear_session
|
257
|
+
set_session_value(login_redirect_session_key, request.fullpath)
|
258
|
+
redirect require_login_redirect
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
if (claims = param_or_nil("claims"))
|
263
|
+
# The value is a JSON object listing the requested Claims.
|
264
|
+
claims = JSON.parse(claims)
|
265
|
+
|
266
|
+
claims.each do |_, individual_claims|
|
267
|
+
redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash)
|
268
|
+
|
269
|
+
individual_claims.each do |_, claim|
|
270
|
+
redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash)
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
sc = scopes
|
276
|
+
|
277
|
+
if sc && sc.include?("offline_access")
|
278
|
+
|
279
|
+
sc.delete("offline_access")
|
280
|
+
|
281
|
+
# MUST ensure that the prompt parameter contains consent
|
282
|
+
# MUST ignore the offline_access request unless the Client
|
283
|
+
# is using a response_type value that would result in an
|
284
|
+
# Authorization Code
|
285
|
+
if param_or_nil("prompt") == "consent" && (
|
286
|
+
(response_type = param_or_nil("response_type")) && response_type.split(" ").include?("code")
|
287
|
+
)
|
288
|
+
request.params["access_type"] = "offline"
|
289
|
+
end
|
290
|
+
|
291
|
+
request.params["scope"] = sc.join(" ")
|
287
292
|
end
|
288
293
|
|
289
294
|
super
|
@@ -292,7 +297,42 @@ module Rodauth
|
|
292
297
|
def require_authorizable_account
|
293
298
|
try_prompt
|
294
299
|
super
|
295
|
-
try_acr_values
|
300
|
+
@acr = try_acr_values
|
301
|
+
end
|
302
|
+
|
303
|
+
def get_oidc_account_last_login_at(account_id)
|
304
|
+
get_activity_timestamp(account_id, account_activity_last_activity_column)
|
305
|
+
end
|
306
|
+
|
307
|
+
def jwt_subject(oauth_grant, client_application = oauth_application)
|
308
|
+
subject_type = client_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
|
309
|
+
|
310
|
+
case subject_type
|
311
|
+
when "public"
|
312
|
+
super
|
313
|
+
when "pairwise"
|
314
|
+
identifier_uri = client_application[oauth_applications_sector_identifier_uri_column]
|
315
|
+
|
316
|
+
unless identifier_uri
|
317
|
+
identifier_uri = client_application[oauth_applications_redirect_uri_column]
|
318
|
+
identifier_uri = identifier_uri.split(" ")
|
319
|
+
# If the Client has not provided a value for sector_identifier_uri in Dynamic Client Registration
|
320
|
+
# [OpenID.Registration], the Sector Identifier used for pairwise identifier calculation is the host
|
321
|
+
# component of the registered redirect_uri. If there are multiple hostnames in the registered redirect_uris,
|
322
|
+
# the Client MUST register a sector_identifier_uri.
|
323
|
+
if identifier_uri.size > 1
|
324
|
+
# return error message
|
325
|
+
end
|
326
|
+
identifier_uri = identifier_uri.first
|
327
|
+
end
|
328
|
+
|
329
|
+
identifier_uri = URI(identifier_uri).host
|
330
|
+
|
331
|
+
account_id = oauth_grant[oauth_grants_account_id_column]
|
332
|
+
Digest::SHA256.hexdigest("#{identifier_uri}#{account_id}#{oauth_jwt_subject_secret}")
|
333
|
+
else
|
334
|
+
raise StandardError, "unexpected subject (#{subject_type})"
|
335
|
+
end
|
296
336
|
end
|
297
337
|
|
298
338
|
# this executes before checking for a logged in account
|
@@ -301,22 +341,18 @@ module Rodauth
|
|
301
341
|
|
302
342
|
case prompt
|
303
343
|
when "none"
|
344
|
+
return unless request.get?
|
345
|
+
|
304
346
|
redirect_response_error("login_required") unless logged_in?
|
305
347
|
|
306
348
|
require_account
|
307
349
|
|
308
|
-
|
309
|
-
oauth_grants_account_id_column => account_id,
|
310
|
-
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
311
|
-
oauth_grants_redirect_uri_column => redirect_uri,
|
312
|
-
oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
|
313
|
-
oauth_grants_access_type_column => "online"
|
314
|
-
).count.zero?
|
315
|
-
redirect_response_error("consent_required")
|
316
|
-
end
|
350
|
+
redirect_response_error("interaction_required") unless oidc_authorize_on_prompt_none?(account_from_session)
|
317
351
|
|
318
352
|
request.env["REQUEST_METHOD"] = "POST"
|
319
353
|
when "login"
|
354
|
+
return unless request.get?
|
355
|
+
|
320
356
|
if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login"
|
321
357
|
::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options)
|
322
358
|
return
|
@@ -333,18 +369,17 @@ module Rodauth
|
|
333
369
|
|
334
370
|
redirect require_login_redirect
|
335
371
|
when "consent"
|
372
|
+
return unless request.post?
|
373
|
+
|
336
374
|
require_account
|
337
375
|
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
oauth_grants_scopes_column => scopes.join(oauth_scope_separator),
|
343
|
-
oauth_grants_access_type_column => "online"
|
344
|
-
).count.zero?
|
345
|
-
redirect_response_error("consent_required")
|
346
|
-
end
|
376
|
+
sc = scopes || []
|
377
|
+
|
378
|
+
redirect_response_error("consent_required") if sc.empty?
|
379
|
+
|
347
380
|
when "select-account"
|
381
|
+
return unless request.get?
|
382
|
+
|
348
383
|
# only works if select_account plugin is available
|
349
384
|
require_select_account if respond_to?(:require_select_account)
|
350
385
|
else
|
@@ -356,89 +391,140 @@ module Rodauth
|
|
356
391
|
return unless (acr_values = param_or_nil("acr_values"))
|
357
392
|
|
358
393
|
acr_values.split(" ").each do |acr_value|
|
394
|
+
next unless oauth_acr_values_supported.include?(acr_value)
|
395
|
+
|
359
396
|
case acr_value
|
360
|
-
when "phr"
|
361
|
-
|
397
|
+
when "phr"
|
398
|
+
return acr_value if require_acr_value_phr
|
399
|
+
when "phrh"
|
400
|
+
return acr_value if require_acr_value_phrh
|
362
401
|
else
|
363
|
-
require_acr_value(acr_value)
|
402
|
+
return acr_value if require_acr_value(acr_value)
|
364
403
|
end
|
365
404
|
end
|
405
|
+
|
406
|
+
nil
|
366
407
|
end
|
367
408
|
|
368
409
|
def require_acr_value_phr
|
369
|
-
return unless respond_to?(:require_two_factor_authenticated)
|
410
|
+
return false unless respond_to?(:require_two_factor_authenticated)
|
370
411
|
|
371
412
|
require_two_factor_authenticated
|
413
|
+
true
|
372
414
|
end
|
373
415
|
|
374
416
|
def require_acr_value_phrh
|
417
|
+
return false unless features.include?(:webauthn_login)
|
418
|
+
|
375
419
|
require_acr_value_phr && two_factor_login_type_match?("webauthn")
|
376
420
|
end
|
377
421
|
|
378
|
-
def require_acr_value(_acr)
|
422
|
+
def require_acr_value(_acr)
|
423
|
+
true
|
424
|
+
end
|
379
425
|
|
380
426
|
def create_oauth_grant(create_params = {})
|
381
|
-
|
382
|
-
create_params[oauth_grants_nonce_column] = nonce
|
383
|
-
end
|
384
|
-
if (acr = param_or_nil("acr"))
|
385
|
-
create_params[oauth_grants_acr_column] = acr
|
386
|
-
end
|
427
|
+
create_params.replace(oidc_grant_params.merge(create_params))
|
387
428
|
super
|
388
429
|
end
|
389
430
|
|
390
|
-
def
|
391
|
-
create_params[
|
392
|
-
create_params[
|
393
|
-
|
394
|
-
|
431
|
+
def create_oauth_grant_with_token(create_params = {})
|
432
|
+
create_params[oauth_grants_type_column] = "hybrid"
|
433
|
+
create_params[oauth_grants_account_id_column] = account_id
|
434
|
+
create_params[oauth_grants_expires_in_column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_access_token_expires_in)
|
435
|
+
authorization_code = create_oauth_grant(create_params)
|
436
|
+
access_token = if oauth_jwt_access_tokens
|
437
|
+
_generate_jwt_access_token(create_params)
|
438
|
+
else
|
439
|
+
oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => authorization_code).first
|
440
|
+
_generate_access_token(oauth_grant)
|
441
|
+
end
|
442
|
+
|
443
|
+
{
|
444
|
+
"code" => authorization_code,
|
445
|
+
**json_access_token_payload(oauth_grants_token_column => access_token)
|
446
|
+
}
|
395
447
|
end
|
396
448
|
|
397
|
-
def
|
398
|
-
|
399
|
-
generate_id_token(
|
400
|
-
|
449
|
+
def create_token(*)
|
450
|
+
oauth_grant = super
|
451
|
+
generate_id_token(oauth_grant)
|
452
|
+
oauth_grant
|
401
453
|
end
|
402
454
|
|
403
|
-
def generate_id_token(
|
404
|
-
oauth_scopes =
|
455
|
+
def generate_id_token(oauth_grant, include_claims = false)
|
456
|
+
oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator)
|
405
457
|
|
406
458
|
return unless oauth_scopes.include?("openid")
|
407
459
|
|
408
|
-
id_token_claims = jwt_claims(
|
460
|
+
id_token_claims = jwt_claims(oauth_grant)
|
409
461
|
|
410
|
-
id_token_claims[:nonce] =
|
462
|
+
id_token_claims[:nonce] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column]
|
411
463
|
|
412
|
-
id_token_claims[:acr] =
|
464
|
+
id_token_claims[:acr] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column]
|
413
465
|
|
414
466
|
# Time when the End-User authentication occurred.
|
415
|
-
id_token_claims[:auth_time] =
|
467
|
+
id_token_claims[:auth_time] = get_oidc_account_last_login_at(oauth_grant[oauth_grants_account_id_column]).to_i
|
416
468
|
|
417
|
-
account = db[accounts_table].where(account_id_column =>
|
469
|
+
account = db[accounts_table].where(account_id_column => oauth_grant[oauth_grants_account_id_column]).first
|
418
470
|
|
419
471
|
# this should never happen!
|
420
472
|
# a newly minted oauth token from a grant should have been assigned to an account
|
421
473
|
# who just authorized its generation.
|
422
474
|
return unless account
|
423
475
|
|
424
|
-
|
476
|
+
if (claims = oauth_grant[oauth_grants_claims_column])
|
477
|
+
claims = JSON.parse(claims)
|
478
|
+
if (id_token_essential_claims = claims["id_token"])
|
479
|
+
oauth_scopes |= id_token_essential_claims.to_a
|
480
|
+
|
481
|
+
include_claims = true
|
482
|
+
end
|
483
|
+
end
|
484
|
+
|
485
|
+
# 5.4 - However, when no Access Token is issued (which is the case for the response_type value id_token),
|
486
|
+
# the resulting Claims are returned in the ID Token.
|
487
|
+
fill_with_account_claims(id_token_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims
|
425
488
|
|
426
489
|
params = {
|
427
|
-
jwks: oauth_application_jwks,
|
428
|
-
signing_algorithm:
|
490
|
+
jwks: oauth_application_jwks(oauth_application),
|
491
|
+
signing_algorithm: (
|
492
|
+
oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
|
493
|
+
oauth_jwt_keys.keys.first
|
494
|
+
),
|
429
495
|
encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column],
|
430
496
|
encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column]
|
431
497
|
}.compact
|
432
498
|
|
433
|
-
|
499
|
+
oauth_grant[:id_token] = jwt_encode(id_token_claims, **params)
|
434
500
|
end
|
435
501
|
|
436
502
|
# aka fill_with_standard_claims
|
437
|
-
def fill_with_account_claims(claims, account, scopes)
|
503
|
+
def fill_with_account_claims(claims, account, scopes, claims_locales)
|
504
|
+
additional_claims_info = {}
|
505
|
+
|
438
506
|
scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc|
|
439
507
|
next if scope == "openid"
|
440
508
|
|
441
|
-
|
509
|
+
if scope.is_a?(Array)
|
510
|
+
# essential claims
|
511
|
+
param, additional_info = scope
|
512
|
+
|
513
|
+
param = param.to_sym
|
514
|
+
|
515
|
+
oidc, = OIDC_SCOPES_MAP.find do |_, oidc_scopes|
|
516
|
+
oidc_scopes.include?(param)
|
517
|
+
end || param.to_s
|
518
|
+
|
519
|
+
param = nil if oidc == param.to_s
|
520
|
+
|
521
|
+
additional_claims_info[param] = additional_info
|
522
|
+
else
|
523
|
+
|
524
|
+
oidc, param = scope.split(".", 2)
|
525
|
+
|
526
|
+
param = param.to_sym if param
|
527
|
+
end
|
442
528
|
|
443
529
|
by_oidc[oidc] ||= []
|
444
530
|
|
@@ -447,13 +533,11 @@ module Rodauth
|
|
447
533
|
|
448
534
|
oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) }
|
449
535
|
|
450
|
-
|
451
|
-
claims_locales = claims_locales.split(" ").map(&:to_sym)
|
452
|
-
end
|
536
|
+
claims_locales = claims_locales.split(" ").map(&:to_sym) if claims_locales
|
453
537
|
|
454
538
|
unless oidc_scopes.empty?
|
455
539
|
if respond_to?(:get_oidc_param)
|
456
|
-
get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales)
|
540
|
+
get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales, additional_claims_info)
|
457
541
|
|
458
542
|
oidc_scopes.each do |scope|
|
459
543
|
scope_claims = claims
|
@@ -474,7 +558,7 @@ module Rodauth
|
|
474
558
|
return if additional_scopes.empty?
|
475
559
|
|
476
560
|
if respond_to?(:get_additional_param)
|
477
|
-
get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales)
|
561
|
+
get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales, additional_claims_info)
|
478
562
|
|
479
563
|
additional_scopes.each do |scope|
|
480
564
|
get_additional_param[account, scope.to_sym]
|
@@ -484,32 +568,45 @@ module Rodauth
|
|
484
568
|
end
|
485
569
|
end
|
486
570
|
|
487
|
-
def proxy_get_param(get_param_func, claims, claims_locales)
|
571
|
+
def proxy_get_param(get_param_func, claims, claims_locales, additional_claims_info)
|
488
572
|
meth = method(get_param_func)
|
489
573
|
if meth.arity == 2
|
490
|
-
|
574
|
+
lambda do |account, param, cl = claims|
|
575
|
+
additional_info = additional_claims_info[param] || EMPTY_HASH
|
576
|
+
value = additional_info["value"] || meth[account, param]
|
577
|
+
value = nil if additional_info["values"] && additional_info["values"].include?(value)
|
578
|
+
cl[param] = value if value
|
579
|
+
end
|
491
580
|
elsif claims_locales.nil?
|
492
|
-
|
581
|
+
lambda do |account, param, cl = claims|
|
582
|
+
additional_info = additional_claims_info[param] || EMPTY_HASH
|
583
|
+
value = additional_info["value"] || meth[account, param, nil]
|
584
|
+
value = nil if additional_info["values"] && additional_info["values"].include?(value)
|
585
|
+
cl[param] = value if value
|
586
|
+
end
|
493
587
|
else
|
494
588
|
lambda do |account, param, cl = claims|
|
495
589
|
claims_values = claims_locales.map do |locale|
|
496
|
-
|
497
|
-
|
590
|
+
additional_info = additional_claims_info[param] || EMPTY_HASH
|
591
|
+
value = additional_info["value"] || meth[account, param, locale]
|
592
|
+
value = nil if additional_info["values"] && additional_info["values"].include?(value)
|
593
|
+
value
|
594
|
+
end.compact
|
498
595
|
|
499
596
|
if claims_values.uniq.size == 1
|
500
597
|
cl[param] = claims_values.first
|
501
598
|
else
|
502
599
|
claims_locales.zip(claims_values).each do |locale, value|
|
503
|
-
cl["#{param}##{locale}"] = value
|
600
|
+
cl["#{param}##{locale}"] = value if value
|
504
601
|
end
|
505
602
|
end
|
506
603
|
end
|
507
604
|
end
|
508
605
|
end
|
509
606
|
|
510
|
-
def json_access_token_payload(
|
607
|
+
def json_access_token_payload(oauth_grant)
|
511
608
|
payload = super
|
512
|
-
payload["id_token"] =
|
609
|
+
payload["id_token"] = oauth_grant[:id_token] if oauth_grant[:id_token]
|
513
610
|
payload
|
514
611
|
end
|
515
612
|
|
@@ -517,66 +614,102 @@ module Rodauth
|
|
517
614
|
|
518
615
|
def check_valid_response_type?
|
519
616
|
case param_or_nil("response_type")
|
520
|
-
when "none", "id_token",
|
521
|
-
"code token", "code id_token", "id_token token", "code id_token token" # multiple
|
617
|
+
when "none", "id_token", "code id_token" # multiple
|
522
618
|
true
|
619
|
+
when "code token", "id_token token", "code id_token token"
|
620
|
+
supports_token_response_type?
|
523
621
|
else
|
524
622
|
super
|
525
623
|
end
|
526
624
|
end
|
527
625
|
|
528
|
-
def
|
529
|
-
return super unless
|
626
|
+
def supported_response_mode?(response_mode, *)
|
627
|
+
return super unless response_mode == "none"
|
628
|
+
|
629
|
+
param("response_type") == "none"
|
630
|
+
end
|
530
631
|
|
531
|
-
|
632
|
+
def supports_token_response_type?
|
633
|
+
features.include?(:oauth_implicit_grant)
|
634
|
+
end
|
635
|
+
|
636
|
+
def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode"))
|
637
|
+
response_type = param("response_type")
|
638
|
+
case response_type
|
532
639
|
when "id_token"
|
533
|
-
|
640
|
+
grant_params = oidc_grant_params
|
641
|
+
generate_id_token(grant_params, true)
|
642
|
+
response_params.replace("id_token" => grant_params[:id_token])
|
534
643
|
when "code token"
|
535
|
-
redirect_response_error("invalid_request") unless
|
644
|
+
redirect_response_error("invalid_request") unless supports_token_response_type?
|
536
645
|
|
537
|
-
response_params.replace(
|
646
|
+
response_params.replace(create_oauth_grant_with_token)
|
538
647
|
when "code id_token"
|
539
|
-
|
648
|
+
params = _do_authorize_code
|
649
|
+
oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
|
650
|
+
generate_id_token(oauth_grant)
|
651
|
+
response_params.replace(
|
652
|
+
"id_token" => oauth_grant[:id_token],
|
653
|
+
"code" => params["code"]
|
654
|
+
)
|
540
655
|
when "id_token token"
|
541
|
-
|
656
|
+
redirect_response_error("invalid_request") unless supports_token_response_type?
|
657
|
+
|
658
|
+
oauth_grant = _do_authorize_token(oauth_grants_type_column => "hybrid")
|
659
|
+
generate_id_token(oauth_grant)
|
660
|
+
|
661
|
+
response_params.replace(json_access_token_payload(oauth_grant))
|
542
662
|
when "code id_token token"
|
663
|
+
redirect_response_error("invalid_request") unless supports_token_response_type?
|
664
|
+
|
665
|
+
params = create_oauth_grant_with_token
|
666
|
+
oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first
|
667
|
+
generate_id_token(oauth_grant)
|
543
668
|
|
544
|
-
response_params.replace(
|
669
|
+
response_params.replace(params.merge("id_token" => oauth_grant[:id_token]))
|
670
|
+
when "none"
|
671
|
+
response_mode ||= "none"
|
545
672
|
end
|
546
673
|
response_mode ||= "fragment" unless response_params.empty?
|
547
674
|
|
548
675
|
super(response_params, response_mode)
|
549
676
|
end
|
550
677
|
|
551
|
-
def
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
678
|
+
def oidc_grant_params
|
679
|
+
grant_params = {
|
680
|
+
oauth_grants_account_id_column => account_id,
|
681
|
+
oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
682
|
+
oauth_grants_scopes_column => scopes.join(oauth_scope_separator)
|
556
683
|
}
|
557
684
|
if (nonce = param_or_nil("nonce"))
|
558
|
-
|
685
|
+
grant_params[oauth_grants_nonce_column] = nonce
|
686
|
+
end
|
687
|
+
grant_params[oauth_grants_acr_column] = @acr if @acr
|
688
|
+
if (claims_locales = param_or_nil("claims_locales"))
|
689
|
+
grant_params[oauth_grants_claims_locales_column] = claims_locales
|
559
690
|
end
|
560
|
-
if (
|
561
|
-
|
691
|
+
if (claims = param_or_nil("claims"))
|
692
|
+
grant_params[oauth_grants_claims_column] = claims
|
562
693
|
end
|
563
|
-
|
564
|
-
generate_id_token(oauth_token)
|
565
|
-
params = json_access_token_payload(oauth_token)
|
566
|
-
params.delete("access_token")
|
567
|
-
params
|
694
|
+
grant_params
|
568
695
|
end
|
569
696
|
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
return unless (redirect_uri = param_or_nil("post_logout_redirect_uri"))
|
697
|
+
def authorize_response(params, mode)
|
698
|
+
redirect_url = URI.parse(redirect_uri)
|
699
|
+
redirect(redirect_url.to_s) if mode == "none"
|
700
|
+
super
|
701
|
+
end
|
576
702
|
|
577
|
-
|
703
|
+
# Webfinger
|
578
704
|
|
579
|
-
|
705
|
+
def json_webfinger_payload
|
706
|
+
JSON.dump({
|
707
|
+
subject: param("resource"),
|
708
|
+
links: [{
|
709
|
+
rel: "http://openid.net/specs/connect/1.0/issuer",
|
710
|
+
href: authorization_server_url
|
711
|
+
}]
|
712
|
+
})
|
580
713
|
end
|
581
714
|
|
582
715
|
# Metadata
|
@@ -598,29 +731,23 @@ module Rodauth
|
|
598
731
|
|
599
732
|
scope_claims.unshift("auth_time")
|
600
733
|
|
601
|
-
response_types_supported = metadata[:response_types_supported]
|
602
|
-
|
603
|
-
if metadata[:grant_types_supported].include?("implicit")
|
604
|
-
response_types_supported += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"]
|
605
|
-
end
|
606
|
-
|
607
734
|
metadata.merge(
|
608
735
|
userinfo_endpoint: userinfo_url,
|
609
|
-
|
610
|
-
|
611
|
-
|
736
|
+
subject_types_supported: %w[public pairwise],
|
737
|
+
acr_values_supported: oauth_acr_values_supported,
|
738
|
+
claims_parameter_supported: true,
|
612
739
|
|
613
|
-
id_token_signing_alg_values_supported:
|
614
|
-
id_token_encryption_alg_values_supported:
|
615
|
-
id_token_encryption_enc_values_supported:
|
740
|
+
id_token_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
|
741
|
+
id_token_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
|
742
|
+
id_token_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
|
616
743
|
|
617
|
-
userinfo_signing_alg_values_supported:
|
618
|
-
userinfo_encryption_alg_values_supported:
|
619
|
-
userinfo_encryption_enc_values_supported:
|
744
|
+
userinfo_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
|
745
|
+
userinfo_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
|
746
|
+
userinfo_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
|
620
747
|
|
621
|
-
request_object_signing_alg_values_supported:
|
622
|
-
request_object_encryption_alg_values_supported:
|
623
|
-
request_object_encryption_enc_values_supported:
|
748
|
+
request_object_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported,
|
749
|
+
request_object_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported,
|
750
|
+
request_object_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,
|
624
751
|
|
625
752
|
# These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
|
626
753
|
# Values defined by this specification are normal, aggregated, and distributed.
|
@@ -644,5 +771,19 @@ module Rodauth
|
|
644
771
|
response.status = 200
|
645
772
|
return_response
|
646
773
|
end
|
774
|
+
|
775
|
+
def jwt_response_success(jwt, cache = false)
|
776
|
+
response.status = 200
|
777
|
+
response["Content-Type"] ||= "application/jwt"
|
778
|
+
if cache
|
779
|
+
# defaulting to 1-day for everyone, for now at least
|
780
|
+
max_age = 60 * 60 * 24
|
781
|
+
response["Cache-Control"] = "private, max-age=#{max_age}"
|
782
|
+
else
|
783
|
+
response["Cache-Control"] = "no-store"
|
784
|
+
response["Pragma"] = "no-cache"
|
785
|
+
end
|
786
|
+
return_response(jwt)
|
787
|
+
end
|
647
788
|
end
|
648
789
|
end
|