rodauth-oauth 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +189 -0
- data/LICENSE.txt +191 -0
- data/README.md +258 -30
- data/lib/generators/roda/oauth/install_generator.rb +1 -1
- data/lib/generators/roda/oauth/templates/db/migrate/create_rodauth_oauth.rb +4 -1
- data/lib/generators/roda/oauth/views_generator.rb +1 -6
- data/lib/rodauth/features/oauth.rb +567 -295
- data/lib/rodauth/features/oauth_http_mac.rb +110 -0
- data/lib/rodauth/features/oauth_jwt.rb +448 -0
- data/lib/rodauth/features/oidc.rb +267 -0
- data/lib/rodauth/oauth/ttl_store.rb +59 -0
- data/lib/rodauth/oauth/version.rb +1 -1
- metadata +11 -5
@@ -0,0 +1,267 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Rodauth
|
4
|
+
Feature.define(:oidc) do
|
5
|
+
OIDC_SCOPES_MAP = {
|
6
|
+
"profile" => %i[name family_name given_name middle_name nickname preferred_username
|
7
|
+
profile picture website gender birthdate zoneinfo locale updated_at].freeze,
|
8
|
+
"email" => %i[email email_verified].freeze,
|
9
|
+
"address" => %i[address].freeze,
|
10
|
+
"phone" => %i[phone_number phone_number_verified].freeze
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
depends :oauth_jwt
|
14
|
+
|
15
|
+
auth_value_method :oauth_application_default_scope, "openid"
|
16
|
+
auth_value_method :oauth_application_scopes, %w[openid]
|
17
|
+
|
18
|
+
auth_value_method :oauth_grants_nonce_column, :nonce
|
19
|
+
auth_value_method :oauth_tokens_nonce_column, :nonce
|
20
|
+
|
21
|
+
auth_value_method :invalid_scope_message, "The Access Token expired"
|
22
|
+
|
23
|
+
auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer"
|
24
|
+
|
25
|
+
auth_value_methods(:get_oidc_param)
|
26
|
+
|
27
|
+
def openid_configuration(issuer = nil)
|
28
|
+
request.on(".well-known/openid-configuration") do
|
29
|
+
request.get do
|
30
|
+
json_response_success(openid_configuration_body(issuer))
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def webfinger
|
36
|
+
request.on(".well-known/webfinger") do
|
37
|
+
request.get do
|
38
|
+
resource = param_or_nil("resource")
|
39
|
+
|
40
|
+
throw_json_response_error(400, "invalid_request") unless resource
|
41
|
+
|
42
|
+
response.status = 200
|
43
|
+
response["Content-Type"] ||= "application/jrd+json"
|
44
|
+
|
45
|
+
json_payload = JSON.dump({
|
46
|
+
subject: resource,
|
47
|
+
links: [{
|
48
|
+
rel: webfinger_relation,
|
49
|
+
href: authorization_server_url
|
50
|
+
}]
|
51
|
+
})
|
52
|
+
response.write(json_payload)
|
53
|
+
request.halt
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def create_oauth_grant(create_params = {})
|
61
|
+
return super unless (nonce = param_or_nil("nonce"))
|
62
|
+
|
63
|
+
super(oauth_grants_nonce_column => nonce)
|
64
|
+
end
|
65
|
+
|
66
|
+
def create_oauth_token_from_authorization_code(oauth_grant, create_params)
|
67
|
+
return super unless oauth_grant[oauth_grants_nonce_column]
|
68
|
+
|
69
|
+
super(oauth_grant, create_params.merge(oauth_tokens_nonce_column => oauth_grant[oauth_grants_nonce_column]))
|
70
|
+
end
|
71
|
+
|
72
|
+
def create_oauth_token
|
73
|
+
oauth_token = super
|
74
|
+
generate_id_token(oauth_token)
|
75
|
+
oauth_token
|
76
|
+
end
|
77
|
+
|
78
|
+
def generate_id_token(oauth_token)
|
79
|
+
oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator)
|
80
|
+
|
81
|
+
return unless oauth_scopes.include?("openid")
|
82
|
+
|
83
|
+
id_token_claims = jwt_claims(oauth_token)
|
84
|
+
id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column]
|
85
|
+
|
86
|
+
# Time when the End-User authentication occurred.
|
87
|
+
#
|
88
|
+
# Sounds like the same as issued at claim.
|
89
|
+
id_token_claims[:auth_time] = id_token_claims[:iat]
|
90
|
+
|
91
|
+
account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first
|
92
|
+
|
93
|
+
# this should never happen!
|
94
|
+
# a newly minted oauth token from a grant should have been assigned to an account
|
95
|
+
# who just authorized its generation.
|
96
|
+
return unless account
|
97
|
+
|
98
|
+
fill_with_account_claims(id_token_claims, account, oauth_scopes)
|
99
|
+
|
100
|
+
oauth_token[:id_token] = jwt_encode(id_token_claims)
|
101
|
+
end
|
102
|
+
|
103
|
+
def fill_with_account_claims(claims, account, scopes)
|
104
|
+
scopes_by_oidc = scopes.each_with_object({}) do |scope, by_oidc|
|
105
|
+
oidc, param = scope.split(".", 2)
|
106
|
+
|
107
|
+
by_oidc[oidc] ||= []
|
108
|
+
|
109
|
+
by_oidc[oidc] << param.to_sym if param
|
110
|
+
end
|
111
|
+
|
112
|
+
oidc_scopes = (OIDC_SCOPES_MAP.keys & scopes_by_oidc.keys)
|
113
|
+
|
114
|
+
return if oidc_scopes.empty?
|
115
|
+
|
116
|
+
if respond_to?(:get_oidc_param)
|
117
|
+
oidc_scopes.each do |scope|
|
118
|
+
params = scopes_by_oidc[scope]
|
119
|
+
params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params)
|
120
|
+
|
121
|
+
params.each do |param|
|
122
|
+
claims[param] = __send__(:get_oidc_param, account, param)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
else
|
126
|
+
warn "`get_oidc_param(token, param)` must be implemented to use oidc scopes."
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def json_access_token_payload(oauth_token)
|
131
|
+
payload = super
|
132
|
+
payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token]
|
133
|
+
payload
|
134
|
+
end
|
135
|
+
|
136
|
+
# Authorize
|
137
|
+
|
138
|
+
def check_valid_response_type?
|
139
|
+
case param_or_nil("response_type")
|
140
|
+
when "none", "id_token",
|
141
|
+
"code token", "code id_token", "id_token token", "code id_token token" # multiple
|
142
|
+
true
|
143
|
+
else
|
144
|
+
super
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def do_authorize(redirect_url, query_params = [], fragment_params = [])
|
149
|
+
return super unless use_oauth_implicit_grant_type?
|
150
|
+
|
151
|
+
case param("response_type")
|
152
|
+
when "id_token"
|
153
|
+
fragment_params.replace(_do_authorize_id_token.map { |k, v| "#{k}=#{v}" })
|
154
|
+
when "code token"
|
155
|
+
redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type?
|
156
|
+
|
157
|
+
params = _do_authorize_code.merge(_do_authorize_token)
|
158
|
+
|
159
|
+
fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
|
160
|
+
when "code id_token"
|
161
|
+
params = _do_authorize_code.merge(_do_authorize_id_token)
|
162
|
+
|
163
|
+
fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
|
164
|
+
when "id_token token"
|
165
|
+
params = _do_authorize_id_token.merge(_do_authorize_token)
|
166
|
+
|
167
|
+
fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
|
168
|
+
when "code id_token token"
|
169
|
+
params = _do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)
|
170
|
+
|
171
|
+
fragment_params.replace(params.map { |k, v| "#{k}=#{v}" })
|
172
|
+
end
|
173
|
+
|
174
|
+
super(redirect_url, query_params, fragment_params)
|
175
|
+
end
|
176
|
+
|
177
|
+
def _do_authorize_id_token
|
178
|
+
create_params = {
|
179
|
+
oauth_tokens_account_id_column => account_id,
|
180
|
+
oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column],
|
181
|
+
oauth_tokens_scopes_column => scopes
|
182
|
+
}
|
183
|
+
oauth_token = generate_oauth_token(create_params, false)
|
184
|
+
generate_id_token(oauth_token)
|
185
|
+
params = json_access_token_payload(oauth_token)
|
186
|
+
params.delete("access_token")
|
187
|
+
params
|
188
|
+
end
|
189
|
+
|
190
|
+
# Metadata
|
191
|
+
|
192
|
+
def openid_configuration_body(path)
|
193
|
+
metadata = oauth_server_metadata_body(path)
|
194
|
+
|
195
|
+
scope_claims = oauth_application_scopes.each_with_object([]) do |scope, claims|
|
196
|
+
oidc, param = scope.split(".", 2)
|
197
|
+
if param
|
198
|
+
claims << param
|
199
|
+
else
|
200
|
+
oidc_claims = OIDC_SCOPES_MAP[oidc]
|
201
|
+
claims.concat(oidc_claims) if oidc_claims
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
scope_claims.unshift("auth_time") if last_account_login_at
|
206
|
+
|
207
|
+
metadata.merge({
|
208
|
+
userinfo_endpoint: userinfo_url,
|
209
|
+
response_types_supported: metadata[:response_types_supported] +
|
210
|
+
["none", "id_token", %w[code token], %w[code id_token], %w[id_token token], %w[code id_token token]],
|
211
|
+
response_modes_supported: %w[query fragment],
|
212
|
+
grant_types_supported: %w[authorization_code implicit],
|
213
|
+
|
214
|
+
subject_types_supported: [oauth_jwt_subject_type],
|
215
|
+
|
216
|
+
id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported],
|
217
|
+
id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact,
|
218
|
+
id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact,
|
219
|
+
|
220
|
+
userinfo_signing_alg_values_supported: [],
|
221
|
+
userinfo_encryption_alg_values_supported: [],
|
222
|
+
userinfo_encryption_enc_values_supported: [],
|
223
|
+
|
224
|
+
request_object_signing_alg_values_supported: [],
|
225
|
+
request_object_encryption_alg_values_supported: [],
|
226
|
+
request_object_encryption_enc_values_supported: [],
|
227
|
+
|
228
|
+
# These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core].
|
229
|
+
# Values defined by this specification are normal, aggregated, and distributed.
|
230
|
+
# If omitted, the implementation supports only normal Claims.
|
231
|
+
claim_types_supported: %w[normal],
|
232
|
+
claims_supported: %w[sub iss iat exp aud] | scope_claims
|
233
|
+
})
|
234
|
+
end
|
235
|
+
|
236
|
+
# /userinfo
|
237
|
+
route(:userinfo) do |r|
|
238
|
+
next unless is_authorization_server?
|
239
|
+
|
240
|
+
r.on method: %i[get post] do
|
241
|
+
catch_error do
|
242
|
+
oauth_token = authorization_token
|
243
|
+
|
244
|
+
throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token
|
245
|
+
|
246
|
+
oauth_scopes = oauth_token["scope"].split(" ")
|
247
|
+
|
248
|
+
throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid")
|
249
|
+
|
250
|
+
account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first
|
251
|
+
|
252
|
+
throw_json_response_error(authorization_required_error_status, "invalid_token") unless account
|
253
|
+
|
254
|
+
oauth_scopes.delete("openid")
|
255
|
+
|
256
|
+
oidc_claims = { "sub" => oauth_token["sub"] }
|
257
|
+
|
258
|
+
fill_with_account_claims(oidc_claims, account, oauth_scopes)
|
259
|
+
|
260
|
+
json_response_success(oidc_claims)
|
261
|
+
end
|
262
|
+
|
263
|
+
throw_json_response_error(authorization_required_error_status, "invalid_token")
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
#
|
4
|
+
# The TTL store is a data structure which keeps data by a key, and with a time-to-live.
|
5
|
+
# It is specifically designed for data which is static, i.e. for a certain key in a
|
6
|
+
# sufficiently large span, the value will be the same.
|
7
|
+
#
|
8
|
+
# Because of that, synchronizations around reads do not exist, while write synchronizations
|
9
|
+
# will be short-circuited by a read.
|
10
|
+
#
|
11
|
+
class Rodauth::OAuth::TtlStore
|
12
|
+
DEFAULT_TTL = 60 * 60 * 24 # default TTL is one day
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@store_mutex = Mutex.new
|
16
|
+
@store = Hash.new {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def [](key)
|
20
|
+
lookup(key, now)
|
21
|
+
end
|
22
|
+
|
23
|
+
def set(key, &block)
|
24
|
+
@store_mutex.synchronize do
|
25
|
+
# short circuit
|
26
|
+
return @store[key][:payload] if @store[key] && @store[key][:ttl] < now
|
27
|
+
|
28
|
+
payload, ttl = block.call
|
29
|
+
@store[key] = { payload: payload, ttl: (ttl || (now + DEFAULT_TTL)) }
|
30
|
+
|
31
|
+
@store[key][:payload]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def uncache(key)
|
36
|
+
@store_mutex.synchronize do
|
37
|
+
@store.delete(key)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def now
|
44
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
45
|
+
end
|
46
|
+
|
47
|
+
# do not use directly!
|
48
|
+
def lookup(key, ttl)
|
49
|
+
return unless @store.key?(key)
|
50
|
+
|
51
|
+
value = @store[key]
|
52
|
+
|
53
|
+
return if value.empty?
|
54
|
+
|
55
|
+
return unless value[:ttl] > ttl
|
56
|
+
|
57
|
+
value[:payload]
|
58
|
+
end
|
59
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rodauth-oauth
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tiago Cardoso
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-08-01 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: Implementation of the OAuth 2.0 protocol on top of rodauth.
|
14
14
|
email:
|
@@ -16,10 +16,12 @@ email:
|
|
16
16
|
executables: []
|
17
17
|
extensions: []
|
18
18
|
extra_rdoc_files:
|
19
|
+
- LICENSE.txt
|
19
20
|
- README.md
|
20
21
|
- CHANGELOG.md
|
21
22
|
files:
|
22
23
|
- CHANGELOG.md
|
24
|
+
- LICENSE.txt
|
23
25
|
- README.md
|
24
26
|
- lib/generators/roda/oauth/install_generator.rb
|
25
27
|
- lib/generators/roda/oauth/templates/app/models/oauth_application.rb
|
@@ -28,8 +30,12 @@ files:
|
|
28
30
|
- lib/generators/roda/oauth/templates/db/migrate/create_rodauth_oauth.rb
|
29
31
|
- lib/generators/roda/oauth/views_generator.rb
|
30
32
|
- lib/rodauth/features/oauth.rb
|
33
|
+
- lib/rodauth/features/oauth_http_mac.rb
|
34
|
+
- lib/rodauth/features/oauth_jwt.rb
|
35
|
+
- lib/rodauth/features/oidc.rb
|
31
36
|
- lib/rodauth/oauth.rb
|
32
37
|
- lib/rodauth/oauth/railtie.rb
|
38
|
+
- lib/rodauth/oauth/ttl_store.rb
|
33
39
|
- lib/rodauth/oauth/version.rb
|
34
40
|
homepage: https://gitlab.com/honeyryderchuck/roda-oauth
|
35
41
|
licenses: []
|
@@ -37,7 +43,7 @@ metadata:
|
|
37
43
|
homepage_uri: https://gitlab.com/honeyryderchuck/roda-oauth
|
38
44
|
source_code_uri: https://gitlab.com/honeyryderchuck/roda-oauth
|
39
45
|
changelog_uri: https://gitlab.com/honeyryderchuck/roda-oauth/-/blob/master/CHANGELOG.md
|
40
|
-
post_install_message:
|
46
|
+
post_install_message:
|
41
47
|
rdoc_options: []
|
42
48
|
require_paths:
|
43
49
|
- lib
|
@@ -53,7 +59,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
59
|
version: '0'
|
54
60
|
requirements: []
|
55
61
|
rubygems_version: 3.1.2
|
56
|
-
signing_key:
|
62
|
+
signing_key:
|
57
63
|
specification_version: 4
|
58
64
|
summary: Implementation of the OAuth 2.0 protocol on top of rodauth.
|
59
65
|
test_files: []
|