himari 0.5.0 → 0.7.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/lib/himari/access_token.rb +72 -4
  4. data/lib/himari/access_token_jwt.rb +46 -0
  5. data/lib/himari/app.rb +102 -28
  6. data/lib/himari/authorization_code.rb +18 -4
  7. data/lib/himari/client_registration.rb +70 -4
  8. data/lib/himari/config.rb +8 -3
  9. data/lib/himari/decisions/authentication.rb +18 -2
  10. data/lib/himari/decisions/authorization.rb +18 -7
  11. data/lib/himari/decisions/base.rb +7 -3
  12. data/lib/himari/decisions/claims.rb +14 -9
  13. data/lib/himari/dynamic_client_registration.rb +255 -0
  14. data/lib/himari/id_token.rb +15 -28
  15. data/lib/himari/item_provider.rb +3 -1
  16. data/lib/himari/item_providers/oauth_client_metadata.rb +222 -0
  17. data/lib/himari/item_providers/static.rb +2 -0
  18. data/lib/himari/item_providers/storage.rb +33 -0
  19. data/lib/himari/jwt_token.rb +50 -0
  20. data/lib/himari/lifetime_value.rb +5 -3
  21. data/lib/himari/log_line.rb +2 -0
  22. data/lib/himari/middlewares/authentication_rule.rb +2 -0
  23. data/lib/himari/middlewares/authorization_rule.rb +2 -0
  24. data/lib/himari/middlewares/claims_rule.rb +2 -0
  25. data/lib/himari/middlewares/client.rb +2 -0
  26. data/lib/himari/middlewares/config.rb +2 -0
  27. data/lib/himari/middlewares/dynamic_clients.rb +55 -0
  28. data/lib/himari/middlewares/metadata_clients.rb +121 -0
  29. data/lib/himari/middlewares/signing_key.rb +2 -0
  30. data/lib/himari/provider_chain.rb +3 -1
  31. data/lib/himari/rack_oauth2_ext.rb +58 -0
  32. data/lib/himari/refresh_token.rb +93 -0
  33. data/lib/himari/rule.rb +2 -0
  34. data/lib/himari/rule_processor.rb +3 -0
  35. data/lib/himari/services/client_registration_endpoint.rb +78 -0
  36. data/lib/himari/services/downstream_authorization.rb +22 -7
  37. data/lib/himari/services/jwks_endpoint.rb +3 -1
  38. data/lib/himari/services/oidc_authorization_endpoint.rb +63 -3
  39. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +31 -7
  40. data/lib/himari/services/oidc_token_endpoint.rb +225 -46
  41. data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
  42. data/lib/himari/services/upstream_authentication.rb +62 -14
  43. data/lib/himari/session_data.rb +31 -2
  44. data/lib/himari/signing_key.rb +17 -14
  45. data/lib/himari/storages/base.rb +45 -1
  46. data/lib/himari/storages/filesystem.rb +14 -3
  47. data/lib/himari/storages/memory.rb +10 -2
  48. data/lib/himari/token_string.rb +40 -4
  49. data/lib/himari/version.rb +1 -1
  50. data/public/public/index.css +18 -0
  51. data/views/consent.erb +59 -0
  52. metadata +50 -14
@@ -1,14 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/oauth2'
2
4
  require 'digest/sha2'
3
5
  require 'openid_connect'
4
6
  require 'himari/access_token'
7
+ require 'himari/refresh_token'
5
8
  require 'himari/id_token'
9
+ require 'himari/storages/base'
10
+ require 'himari/services/downstream_authorization'
11
+ require 'himari/services/upstream_authentication'
6
12
 
7
13
  module Himari
8
14
  module Services
9
15
  class OidcTokenEndpoint
10
16
  class SigningKeyMissing < StandardError; end
11
17
 
18
+ Issued = Struct.new(:access, :access_token_string, :id_token_jwt, :signing_key, keyword_init: true)
19
+
12
20
  # @param client_provider [Himari::ProviderChain<Himari::ClientRegistration>]
13
21
  # @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>]
14
22
  # @param storage [Himari::Storages::Base]
@@ -25,70 +33,241 @@ module Himari
25
33
  def call(env)
26
34
  app(env).call(env)
27
35
  rescue Rack::OAuth2::Server::Abstract::Error => e
28
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'], client: client.as_log, err: e.class.inspect, err_content: e.protocol_params))
36
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'], err: e.class.inspect, err_content: e.protocol_params))
29
37
  e.finish
30
38
  end
31
39
 
32
40
  def app(env)
33
41
  Rack::OAuth2::Server::Token.new do |req, res|
34
- code_dgst = req.code ? Digest::SHA256.hexdigest(req.code) : nil
35
42
  client = @client_provider.find(id: req.client_id)
36
43
  unless client
37
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id, code_dgst: code_dgst))
44
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id))
38
45
  next req.invalid_client!
39
46
  end
40
- unless client.match_secret?(req.client_secret)
41
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, client secret mismatch', req: env['himari.request_as_log'], client: client.as_log, code_dgst: code_dgst))
42
- next req.invalid_client!
47
+ # Public clients (token_endpoint_auth_method=none) present no secret; they are bound
48
+ # to the authorization code by PKCE and the client_id check in handle_authorization_code.
49
+ if client.confidential? && !client.match_secret?(req.client_secret)
50
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, client secret mismatch', req: env['himari.request_as_log'], client: client.as_log))
51
+ next req.invalid_client!
43
52
  end
44
53
 
45
54
  case req.grant_type
46
55
  when :authorization_code
47
- authz = @storage.find_authorization(req.code)
48
- unless authz
49
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, no grant code found', req: env['himari.request_as_log'], client: client.as_log))
50
- next req.invalid_grant!
51
- end
52
- unless authz.valid_redirect_uri?(req.redirect_uri)
53
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
54
- next req.invalid_grant!
55
- end
56
- if authz.expiry <= Time.now.to_i
57
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
58
- next req.invalid_grant!
59
- end
60
-
61
- if authz.pkce?
62
- if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
63
- # do nothing
64
- else
65
- # :nocov:
66
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
67
- next req.invalid_grant!
68
- # :nocov:
69
- end
70
- elsif client.require_pkce
71
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, pkce is mandatory', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
72
- next req.invalid_grant!
73
- end
74
-
75
- token = AccessToken.from_authz(authz)
76
- @storage.put_token(token)
77
- res.access_token = token.to_bearer
78
-
79
- if authz.openid
80
- signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
81
- raise SigningKeyMissing unless signing_key
82
- res.id_token = IdToken.from_authz(authz, signing_key: signing_key, access_token: token.format.to_s, issuer: @issuer).to_jwt
83
- end
84
-
85
- @storage.delete_authorization(authz)
86
- @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: token.as_log, signing_key_kid: signing_key&.id))
56
+ handle_authorization_code(env, req, res, client)
57
+ when :refresh_token
58
+ handle_refresh_token(env, req, res, client)
87
59
  else
88
60
  req.unsupported_response_type!
89
61
  end
90
62
  end
91
63
  end
64
+
65
+ private def handle_authorization_code(env, req, res, client)
66
+ authz = @storage.find_authorization(req.code)
67
+ unless authz
68
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, no grant code found', req: env['himari.request_as_log'], client: client.as_log))
69
+ return req.invalid_grant!
70
+ end
71
+ unless authz.client_id == client.id
72
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, grant client_id mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
73
+ return req.invalid_grant!
74
+ end
75
+ unless authz.valid_redirect_uri?(req.redirect_uri)
76
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
77
+ return req.invalid_grant!
78
+ end
79
+ if authz.expiry <= Time.now.to_i
80
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
81
+ return req.invalid_grant!
82
+ end
83
+
84
+ if authz.pkce?
85
+ if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
86
+ # do nothing
87
+ else
88
+ # :nocov:
89
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
90
+ return req.invalid_grant!
91
+ # :nocov:
92
+ end
93
+ elsif client.require_pkce
94
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, pkce is mandatory', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
95
+ return req.invalid_grant!
96
+ end
97
+
98
+ issued = issue_access_and_id(
99
+ client: client,
100
+ claims: authz.claims,
101
+ scopes: authz.scopes,
102
+ lifetime: authz.lifetime,
103
+ openid: authz.openid,
104
+ session_handle: authz.session_handle,
105
+ nonce: authz.nonce,
106
+ mint_jwt_access_token: authz.mint_jwt_access_token,
107
+ )
108
+
109
+ refresh = nil
110
+ if authz.offline_access && authz.session_handle && authz.lifetime&.refresh_token
111
+ refresh = RefreshToken.make(client_id: client.id, claims: authz.claims, session_handle: authz.session_handle, openid: authz.openid, scopes: authz.scopes, lifetime: authz.lifetime.refresh_token)
112
+ @storage.put_refresh_token(refresh)
113
+ end
114
+
115
+ bearer = issued.access.to_bearer(token_string: issued.access_token_string)
116
+ bearer.refresh_token = refresh.format.to_s if refresh
117
+ res.access_token = bearer
118
+ res.id_token = issued.id_token_jwt if issued.id_token_jwt
119
+
120
+ @storage.delete_authorization(authz)
121
+ @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: issued.access.as_log, refresh_token: refresh&.as_log, signing_key_kid: issued.signing_key&.id))
122
+ end
123
+
124
+ private def handle_refresh_token(env, req, res, client)
125
+ given_token_str = req.refresh_token
126
+ unless given_token_str
127
+ return reject_refresh!(env, req, client, 'no refresh_token given')
128
+ end
129
+
130
+ begin
131
+ parsed = Himari::RefreshToken.parse(given_token_str)
132
+ rescue Himari::TokenString::InvalidFormat => e
133
+ return reject_refresh!(env, req, client, 'invalid refresh_token format', err: e.class.inspect)
134
+ end
135
+
136
+ refresh = @storage.find_refresh_token(parsed.handle)
137
+ unless refresh
138
+ return reject_refresh!(env, req, client, 'unknown refresh_token')
139
+ end
140
+
141
+ begin
142
+ refresh.verify!(secret: parsed.secret)
143
+ rescue Himari::TokenString::Error => e
144
+ return reject_refresh!(env, req, client, 'refresh_token verify failed', refresh: refresh, err: e.class.inspect)
145
+ end
146
+
147
+ unless refresh.client_id == client.id
148
+ return reject_refresh!(env, req, client, 'refresh_token client_id mismatch', refresh: refresh)
149
+ end
150
+
151
+ session = refresh.session_handle && @storage.find_session(refresh.session_handle)
152
+ unless session
153
+ return reject_refresh!(env, req, client, 'refresh_token has no session', refresh: refresh)
154
+ end
155
+
156
+ unless session.refreshable?
157
+ return reject_refresh!(env, req, client, 'session is not refreshable (no refresh_info)', refresh: refresh, session: session.as_log)
158
+ end
159
+
160
+ unless session.active?
161
+ return reject_refresh!(env, req, client, 'session expired', refresh: refresh, session: session.as_log)
162
+ end
163
+
164
+ rack_request = Rack::Request.new(env)
165
+
166
+ begin
167
+ authn = Himari::Services::UpstreamAuthentication.revalidate_from_request(session: session, request: rack_request).perform
168
+ rescue Himari::Services::UpstreamAuthentication::UnauthorizedError => e
169
+ return reject_refresh!(env, req, client, 'refresh upstream authn denied', refresh: refresh, session: session.as_log, result: e.as_log)
170
+ end
171
+
172
+ updated_session = authn.session_data
173
+
174
+ begin
175
+ downstream = Himari::Services::DownstreamAuthorization.from_request(session: updated_session, client: client, request: rack_request, grant_type: :refresh_token, requested_scopes: refresh.scopes).perform
176
+ rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
177
+ return reject_refresh!(env, req, client, 'refresh downstream authz denied', refresh: refresh, session: updated_session.as_log, result: e.as_log)
178
+ end
179
+
180
+ # Refresh lifetime is recomputed by the authz rules on every refresh; if it is no
181
+ # longer configured the session is no longer refreshable. Fail closed.
182
+ unless downstream.lifetime&.refresh_token
183
+ return reject_refresh!(env, req, client, 'refresh_token lifetime no longer configured', refresh: refresh, session: updated_session.as_log)
184
+ end
185
+
186
+ # Rotate the token in place; verify! above recorded which secret the client presented,
187
+ # which rotate keeps valid as the previous one. The token's original expiry is
188
+ # preserved (absolute cap); the lifetime guard above only gates whether refresh is
189
+ # still permitted by the rules, not how long the rotated token lives.
190
+ rotated = refresh.rotate(claims: downstream.claims, openid: refresh.openid)
191
+
192
+ # Compare-and-swap on the version we read. A concurrent refresh that already rotated
193
+ # this token bumps the version, so the loser's write conflicts. Reject the loser
194
+ # without revoking — the winner's rotation (same handle) must survive.
195
+ begin
196
+ @storage.put_refresh_token(rotated, if_version: refresh.version)
197
+ rescue Himari::Storages::Base::Conflict
198
+ return reject_refresh!(env, req, client, 'refresh_token version conflict (concurrent use)', refresh: refresh, revoke: false)
199
+ end
200
+
201
+ @storage.put_session(updated_session, overwrite: true)
202
+
203
+ # OIDC core §12.2: refreshed ID Token MAY be returned, with no nonce on refresh.
204
+ issued = issue_access_and_id(
205
+ client: client,
206
+ claims: downstream.claims,
207
+ scopes: downstream.scopes,
208
+ lifetime: downstream.lifetime,
209
+ openid: refresh.openid,
210
+ session_handle: updated_session.handle,
211
+ nonce: nil,
212
+ mint_jwt_access_token: downstream.mint_jwt_access_token,
213
+ )
214
+
215
+ bearer = issued.access.to_bearer(token_string: issued.access_token_string)
216
+ bearer.refresh_token = rotated.format.to_s
217
+ res.access_token = bearer
218
+ res.id_token = issued.id_token_jwt if issued.id_token_jwt
219
+
220
+ @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: refreshed', req: env['himari.request_as_log'], client: client.as_log, session: updated_session.as_log, token: issued.access.as_log, refresh_token: rotated.as_log, prev_version: refresh.version, secret_slot: refresh.verification&.via, signing_key_kid: issued.signing_key&.id))
221
+ end
222
+
223
+ # Reject a refresh request with invalid_grant. By default this revokes the presented
224
+ # refresh token when one was looked up, keeping refresh failures fail-closed against
225
+ # replay. revoke: false is used only for the concurrent-conflict path, where the
226
+ # winning request has already rotated this same handle and must not be revoked.
227
+ private def reject_refresh!(env, req, client, reason, refresh: nil, revoke: true, **fields)
228
+ log = {req: env['himari.request_as_log'], client: client.as_log}
229
+ log[:refresh] = refresh.as_log if refresh
230
+ @logger&.warn(Himari::LogLine.new("OidcTokenEndpoint: invalid_grant, #{reason}", **log, **fields))
231
+ @storage.delete_refresh_token(refresh) if refresh && revoke
232
+ req.invalid_grant!
233
+ end
234
+
235
+ # Mint an access token (and, for OIDC, an id_token JWT). Refresh tokens are handled
236
+ # separately by each grant path: the authorization_code path mints a fresh one, while
237
+ # the refresh path rotates the presented token in place.
238
+ private def issue_access_and_id(client:, claims:, scopes:, lifetime:, openid:, session_handle:, nonce:, mint_jwt_access_token:)
239
+ access = AccessToken.make(client_id: client.id, claims: claims, scopes: scopes, session_handle: session_handle, lifetime: lifetime.access_token)
240
+ @storage.put_token(access)
241
+
242
+ # Both the ID Token and a JWT access token are signed by the same key; resolve it once.
243
+ signing_key = nil
244
+ if openid || mint_jwt_access_token
245
+ signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
246
+ raise SigningKeyMissing unless signing_key
247
+ end
248
+
249
+ access_token_string = if mint_jwt_access_token
250
+ access.to_jwt(signing_key: signing_key, issuer: @issuer)
251
+ else
252
+ access.format.to_s
253
+ end
254
+
255
+ id_token_jwt = nil
256
+ if openid
257
+ id_token_jwt = IdToken.new(
258
+ claims: claims,
259
+ client_id: client.id,
260
+ nonce: nonce,
261
+ signing_key: signing_key,
262
+ issuer: @issuer,
263
+ # at_hash binds the ID Token to the access token actually delivered (JWT or opaque).
264
+ access_token: access_token_string,
265
+ lifetime: lifetime.id_token,
266
+ ).to_jwt
267
+ end
268
+
269
+ Issued.new(access: access, access_token_string: access_token_string, id_token_jwt: id_token_jwt, signing_key: signing_key)
270
+ end
92
271
  end
93
272
  end
94
273
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/access_token'
2
4
  require 'himari/token_string'
3
5
  require 'himari/log_line'
@@ -6,9 +8,12 @@ module Himari
6
8
  module Services
7
9
  class OidcUserinfoEndpoint
8
10
  # @param storage [Himari::Storages::Base]
11
+ # @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>] verifies RFC 9068
12
+ # JWT access tokens; opaque tokens do not need it
9
13
  # @param logger [Logger]
10
- def initialize(storage:, logger: nil)
14
+ def initialize(storage:, signing_key_provider: nil, logger: nil)
11
15
  @storage = storage
16
+ @signing_key_provider = signing_key_provider
12
17
  @logger = logger
13
18
  end
14
19
 
@@ -17,14 +22,15 @@ module Himari
17
22
  end
18
23
 
19
24
  def call(env)
20
- Handler.new(storage: @storage, env: env, logger: @logger).response
25
+ Handler.new(storage: @storage, signing_key_provider: @signing_key_provider, env: env, logger: @logger).response
21
26
  end
22
27
 
23
28
  class Handler
24
29
  class InvalidToken < StandardError; end
25
30
 
26
- def initialize(storage:, env:, logger:)
31
+ def initialize(storage:, env:, logger:, signing_key_provider: nil)
27
32
  @storage = storage
33
+ @signing_key_provider = signing_key_provider
28
34
  @env = env
29
35
  @logger = logger
30
36
  end
@@ -34,11 +40,13 @@ module Himari
34
40
  return [404, {'Content-Type' => 'application/json'}, ['{"error": "not_found"}']] unless %w(GET POST).include?(@env['REQUEST_METHOD'])
35
41
 
36
42
  raise InvalidToken unless given_token
37
- given_parsed_token = Himari::AccessToken.parse(given_token)
43
+
44
+ given_parsed_token = Himari::AccessToken.parse(given_token, signing_key_provider: @signing_key_provider)
38
45
 
39
46
  token = @storage.find_token(given_parsed_token.handle)
40
47
  raise InvalidToken unless token
41
- token.verify_expiry!()
48
+
49
+ token.verify_expiry!
42
50
  token.verify_secret!(given_parsed_token.secret)
43
51
 
44
52
  @logger&.info(Himari::LogLine.new('OidcUserinfoEndpoint: returning', req: @env['himari.request_as_log'], token: token.as_log))
@@ -63,8 +71,6 @@ module Himari
63
71
  method, token = ah&.split(/\s+/, 2) # https://www.rfc-editor.org/rfc/rfc9110#name-credentials
64
72
  if method&.downcase == 'bearer' && token && !token.empty?
65
73
  token
66
- else
67
- nil
68
74
  end
69
75
  end
70
76
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/log_line'
2
4
  require 'himari/decisions/authentication'
3
5
  require 'himari/decisions/claims'
@@ -29,20 +31,26 @@ module Himari
29
31
  {
30
32
  session: session_data&.as_log,
31
33
  decision: {
32
- claims: claims_result&.as_log&.reject{ |k,_v| %i(allowed explicit_deny).include?(k) },
34
+ claims: claims_result&.as_log&.reject { |k, _v| %i(allowed explicit_deny).include?(k) },
33
35
  authentication: authn_result&.as_log,
34
36
  },
35
37
  }
36
38
  end
37
39
  end
38
40
 
39
- # @param auth [Hash] Omniauth Auth Hash
41
+ # @param auth [Hash, nil] Omniauth Auth Hash (nil on revalidation)
42
+ # @param session [Himari::SessionData, nil] Existing session to revalidate (nil on initial login)
43
+ # @param grant_type [Symbol] :initial for omniauth callback, :refresh_token for revalidation
40
44
  # @param claims_rules [Array<Himari::Rule>] Claims Rules
41
45
  # @param authn_rules [Array<Himari::Rule>] Authentication Rules
42
46
  # @param logger [Logger]
43
- def initialize(auth:, request: nil, claims_rules: [], authn_rules: [], logger: nil)
47
+ def initialize(auth: nil, session: nil, grant_type: :initial, request: nil, claims_rules: [], authn_rules: [], logger: nil)
48
+ raise ArgumentError, "auth or session is required" if auth.nil? && session.nil?
49
+
44
50
  @request = request
45
51
  @auth = auth
52
+ @session = session
53
+ @grant_type = grant_type
46
54
  @claims_rules = claims_rules
47
55
  @authn_rules = authn_rules
48
56
  @logger = logger
@@ -52,6 +60,22 @@ module Himari
52
60
  def self.from_request(request)
53
61
  new(
54
62
  auth: request.env.fetch('omniauth.auth'),
63
+ grant_type: :initial,
64
+ request: request,
65
+ claims_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::ClaimsRule::RACK_KEY] || []).collect,
66
+ authn_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthenticationRule::RACK_KEY] || []).collect,
67
+ logger: request.env['rack.logger'],
68
+ )
69
+ end
70
+
71
+ # Re-run claims/authn rules against an existing session, e.g. on refresh_token grant.
72
+ #
73
+ # @param session [Himari::SessionData] existing session loaded from storage
74
+ # @param request [Rack::Request]
75
+ def self.revalidate_from_request(session:, request:)
76
+ new(
77
+ session: session,
78
+ grant_type: :refresh_token,
55
79
  request: request,
56
80
  claims_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::ClaimsRule::RACK_KEY] || []).collect,
57
81
  authn_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthenticationRule::RACK_KEY] || []).collect,
@@ -60,27 +84,37 @@ module Himari
60
84
  end
61
85
 
62
86
  def provider
63
- @auth&.fetch(:provider)
87
+ (@auth && @auth[:provider]) || @session&.user_data&.dig(:provider)
64
88
  end
65
89
 
66
- def perform
67
- @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: perform', objid: self.object_id.to_s(16), uid: @auth[:uid], provider: @auth[:provider]))
68
- claims_result = make_claims()
69
- session_data = claims_result.decision.output
90
+ def uid_for_log
91
+ (@auth && @auth[:uid]) || @session&.claims&.dig(:sub)
92
+ end
70
93
 
71
- authn_result = check_authn(claims_result, session_data)
94
+ def perform
95
+ @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: perform', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type))
96
+ claims_result = make_claims
97
+ base = derive_base_session(claims_result)
72
98
 
99
+ authn_result = check_authn(claims_result, base)
100
+ final_refresh_info = authn_result.decision&.refresh_info || claims_result.decision&.refresh_info
101
+ session_data = base.with(refresh_info: final_refresh_info)
73
102
 
74
103
  result = Result.new(claims_result, authn_result, session_data)
75
- @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: result', objid: self.object_id.to_s(16), uid: @auth[:uid], provider: @auth[:provider], result: result.as_log))
104
+ @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: result', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, result: result.as_log))
76
105
  result
77
106
  end
78
107
 
79
108
  def make_claims
80
- context = Himari::Decisions::Claims::Context.new(request: @request, auth: @auth).freeze
109
+ context = Himari::Decisions::Claims::Context.new(request: @request, auth: @auth, provider: provider, grant_type: @grant_type, refresh_info: @session&.refresh_info).freeze
81
110
  result = Himari::RuleProcessor.new(context, Himari::Decisions::Claims.new).run(@claims_rules)
82
111
 
83
- @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: claims', objid: self.object_id.to_s(16), uid: @auth[:uid], provider: @auth[:provider], claims_result: result.as_log))
112
+ @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: claims', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, claims_result: result.as_log))
113
+
114
+ if result.explicit_deny
115
+ @logger&.warn(Himari::LogLine.new('UpstreamAuthentication: claims explicit deny', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, claims_result: result.as_log))
116
+ raise UnauthorizedError.new(Result.new(result, nil, nil))
117
+ end
84
118
 
85
119
  begin
86
120
  claims = result.decision&.output&.claims
@@ -92,13 +126,27 @@ module Himari
92
126
  result
93
127
  end
94
128
 
129
+ def derive_base_session(claims_result)
130
+ decision = claims_result.decision
131
+ if @session
132
+ # revalidation: keep existing handle/secret/expiry, refresh claims/user_data
133
+ @session.with(claims: decision.claims, user_data: decision.user_data)
134
+ else
135
+ decision.output
136
+ end
137
+ end
138
+
95
139
  def check_authn(claims_result, session_data)
96
- context = Himari::Decisions::Authentication::Context.new(provider: provider, claims: session_data.claims, user_data: session_data.user_data, request: @request).freeze
140
+ context = Himari::Decisions::Authentication::Context.new(provider: provider, claims: session_data.claims, user_data: session_data.user_data, request: @request, grant_type: @grant_type, refresh_info: @session&.refresh_info).freeze
141
+ # Don't preseed decision.refresh_info from session; otherwise a no-op authn rule would clobber whatever
142
+ # the claims rule wrote (via Claims#refresh_info=). Authn rules that want to preserve session.refresh_info
143
+ # must read context.refresh_info and assign it explicitly.
97
144
  result = Himari::RuleProcessor.new(context, Himari::Decisions::Authentication.new).run(@authn_rules)
98
145
 
99
- @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: authentication', objid: self.object_id.to_s(16), uid: @auth[:uid], provider: @auth[:provider], authn_result: result.as_log))
146
+ @logger&.debug(Himari::LogLine.new('UpstreamAuthentication: authentication', objid: object_id.to_s(16), uid: uid_for_log, provider: provider, grant_type: @grant_type, authn_result: result.as_log))
100
147
 
101
148
  raise UnauthorizedError.new(Result.new(claims_result, result, nil)) unless result.allowed
149
+
102
150
  result
103
151
  end
104
152
  end
@@ -1,17 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/token_string'
2
4
 
3
5
  module Himari
4
6
  class SessionData
5
7
  include Himari::TokenString
6
8
 
7
- def initialize(claims: {}, user_data: {}, handle:, secret: nil, secret_hash: nil, expiry: nil)
9
+ def initialize(claims: {}, user_data: {}, refresh_info: nil, handle:, secret: nil, secret_hash: nil, expiry: nil)
8
10
  @claims = claims
9
11
  @user_data = user_data
12
+ @refresh_info = refresh_info
10
13
 
11
14
  @handle = handle
12
15
  @secret = secret
13
16
  @secret_hash = secret_hash
17
+ @secret_hash_prev = nil
14
18
  @expiry = expiry
19
+ @verification = nil
15
20
  end
16
21
 
17
22
  def self.magic_header
@@ -22,13 +27,36 @@ module Himari
22
27
  3600
23
28
  end
24
29
 
25
- attr_reader :claims, :user_data
30
+ attr_reader :claims, :user_data, :refresh_info
31
+
32
+ def refreshable?
33
+ !@refresh_info.nil?
34
+ end
35
+
36
+ def active?(now: Time.now)
37
+ @expiry.nil? || @expiry > now.to_i
38
+ end
39
+
40
+ # Return a copy with selected fields replaced. Reads @secret directly to
41
+ # sidestep TokenString#secret raising SecretMissing for storage-loaded sessions.
42
+ def with(claims: @claims, user_data: @user_data, refresh_info: @refresh_info, expiry: @expiry)
43
+ self.class.new(
44
+ handle: @handle,
45
+ secret: @secret,
46
+ secret_hash: @secret_hash,
47
+ expiry: expiry,
48
+ claims: claims,
49
+ user_data: user_data,
50
+ refresh_info: refresh_info,
51
+ )
52
+ end
26
53
 
27
54
  def as_log
28
55
  {
29
56
  handle: handle,
30
57
  claims: claims,
31
58
  expiry: expiry,
59
+ refreshable: refreshable?,
32
60
  }
33
61
  end
34
62
 
@@ -40,6 +68,7 @@ module Himari
40
68
 
41
69
  claims: claims,
42
70
  user_data: user_data,
71
+ refresh_info: refresh_info,
43
72
  }
44
73
  end
45
74
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest/sha2'
2
4
  require 'base64'
3
5
  module Himari
@@ -15,7 +17,6 @@ module Himari
15
17
 
16
18
  attr_reader :id, :pkey, :group
17
19
 
18
-
19
20
  def active?
20
21
  !@inactive
21
22
  end
@@ -30,7 +31,7 @@ module Himari
30
31
  end
31
32
 
32
33
  result &&= if !active.nil?
33
- active == self.active?
34
+ active == active?
34
35
  else
35
36
  true
36
37
  end
@@ -55,9 +56,9 @@ module Himari
55
56
  'RS256'
56
57
  when OpenSSL::PKey::EC
57
58
  case ec_crv
58
- when 'P-256'; 'ES256'
59
- when 'P-384'; 'ES384'
60
- when 'P-521'; 'ES512'
59
+ when 'P-256' then 'ES256'
60
+ when 'P-384' then 'ES384'
61
+ when 'P-521' then 'ES512'
61
62
  else
62
63
  raise AlgUnknown
63
64
  end
@@ -68,9 +69,9 @@ module Himari
68
69
 
69
70
  def hash_function
70
71
  case alg
71
- when 'ES256', 'RS256'; Digest::SHA256
72
- when 'ES384'; Digest::SHA384
73
- when 'ES512'; Digest::SHA512
72
+ when 'ES256', 'RS256' then Digest::SHA256
73
+ when 'ES384' then Digest::SHA384
74
+ when 'ES512' then Digest::SHA512
74
75
  else
75
76
  raise AlgUnknown
76
77
  end
@@ -78,6 +79,7 @@ module Himari
78
79
 
79
80
  def ec_crv
80
81
  raise OperationInvalid, "this key is not EC" unless pkey.is_a?(OpenSSL::PKey::EC)
82
+
81
83
  # https://www.rfc-editor.org/rfc/rfc8422.html#appendix-A
82
84
  case pkey.group.curve_name
83
85
  when 'prime256v1', 'secp256r1'
@@ -97,10 +99,11 @@ module Himari
97
99
  when OpenSSL::PKey::EC # https://www.rfc-editor.org/rfc/rfc7518#section-6.2
98
100
  # https://www.secg.org/sec1-v2.pdf - 2.3.3. Elliptic-Curve-Point-to-Octet-String Conversion
99
101
  xy = pkey.public_key.to_octet_string(:uncompressed) # 0x04 || X || Y
100
- len = pkey.group.degree/8
101
- raise unless xy[0] == "\x04".b && xy.size == ((len*2)+1)
102
- x = xy[1,len]
103
- y = xy[1+len,len]
102
+ len = pkey.group.degree / 8
103
+ raise unless xy[0] == "\x04".b && xy.size == ((len * 2) + 1)
104
+
105
+ x = xy[1, len]
106
+ y = xy[1 + len, len]
104
107
 
105
108
  {
106
109
  kid: id,
@@ -117,8 +120,8 @@ module Himari
117
120
  kty: 'RSA',
118
121
  use: "sig",
119
122
  alg: alg,
120
- n: Base64.urlsafe_encode64(pkey.n.to_s(2)).gsub(/=+/,''),
121
- e: Base64.urlsafe_encode64(pkey.e.to_s(2)).gsub(/=+/,''),
123
+ n: Base64.urlsafe_encode64(pkey.n.to_s(2)).gsub(/=+/, ''),
124
+ e: Base64.urlsafe_encode64(pkey.e.to_s(2)).gsub(/=+/, ''),
122
125
  }
123
126
  else
124
127
  raise AlgUnknown