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.
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rodauth
4
4
  module OAuth
5
- VERSION = "0.0.2"
5
+ VERSION = "0.1.0"
6
6
  end
7
7
  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.2
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-05-29 00:00:00.000000000 Z
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: []