himari 0.4.0 → 0.6.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/lib/himari/access_token.rb +77 -4
  4. data/lib/himari/access_token_jwt.rb +46 -0
  5. data/lib/himari/app.rb +101 -28
  6. data/lib/himari/authorization_code.rb +18 -4
  7. data/lib/himari/client_registration.rb +71 -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/refresh_token.rb +93 -0
  32. data/lib/himari/rule.rb +2 -0
  33. data/lib/himari/rule_processor.rb +3 -0
  34. data/lib/himari/services/client_registration_endpoint.rb +78 -0
  35. data/lib/himari/services/downstream_authorization.rb +22 -7
  36. data/lib/himari/services/jwks_endpoint.rb +3 -1
  37. data/lib/himari/services/oidc_authorization_endpoint.rb +56 -3
  38. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +30 -7
  39. data/lib/himari/services/oidc_token_endpoint.rb +225 -38
  40. data/lib/himari/services/oidc_userinfo_endpoint.rb +14 -8
  41. data/lib/himari/services/upstream_authentication.rb +62 -14
  42. data/lib/himari/session_data.rb +31 -2
  43. data/lib/himari/signing_key.rb +17 -14
  44. data/lib/himari/storages/base.rb +45 -1
  45. data/lib/himari/storages/filesystem.rb +14 -3
  46. data/lib/himari/storages/memory.rb +10 -2
  47. data/lib/himari/token_string.rb +40 -4
  48. data/lib/himari/version.rb +1 -1
  49. data/public/public/index.css +18 -0
  50. data/views/consent.erb +59 -0
  51. metadata +49 -14
@@ -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,18 +40,20 @@ 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))
45
53
  [
46
54
  200,
47
55
  {'Content-Type' => 'application/json; charset=utf-8'},
48
- [JSON.pretty_generate(token.claims), "\n"],
56
+ [JSON.pretty_generate(token.userinfo), "\n"],
49
57
  ]
50
58
  rescue InvalidToken, Himari::TokenString::SecretIncorrect, Himari::TokenString::InvalidFormat, Himari::TokenString::TokenExpired => e
51
59
  @logger&.warn(Himari::LogLine.new('OidcUserinfoEndpoint: invalid_token', req: @env['himari.request_as_log'], err: e.class.inspect, 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
@@ -1,6 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/authorization_code'
2
4
  require 'himari/access_token'
5
+ require 'himari/refresh_token'
3
6
  require 'himari/session_data'
7
+ require 'himari/dynamic_client_registration'
4
8
 
5
9
  module Himari
6
10
  module Storages
@@ -42,6 +46,46 @@ module Himari
42
46
  delete('token', handle)
43
47
  end
44
48
 
49
+ def find_refresh_token(handle)
50
+ content = read('refresh', handle)
51
+ content && RefreshToken.new(**content)
52
+ end
53
+
54
+ # @param if_version [Integer, nil] when given, only write if the stored record's
55
+ # version equals this value (compare-and-swap); raises Conflict otherwise.
56
+ def put_refresh_token(token, overwrite: false, if_version: nil)
57
+ write('refresh', token.handle, token.as_json, overwrite: overwrite, if_version: if_version)
58
+ end
59
+
60
+ def delete_refresh_token(token)
61
+ delete_refresh_token_by_handle(token.handle)
62
+ end
63
+
64
+ def delete_refresh_token_by_handle(handle)
65
+ delete('refresh', handle)
66
+ end
67
+
68
+ def find_dynamic_client(id)
69
+ # ids are server-generated url-safe base64; reject anything else before it reaches a
70
+ # storage key (defense-in-depth against path traversal on filesystem-backed storage).
71
+ return unless id.is_a?(String) && id.match?(/\A[A-Za-z0-9_-]+\z/)
72
+
73
+ content = read('dynamic_client', id)
74
+ content && DynamicClientRegistration.from_json(content)
75
+ end
76
+
77
+ def put_dynamic_client(client, overwrite: false)
78
+ write('dynamic_client', client.id, client.as_json, overwrite: overwrite)
79
+ end
80
+
81
+ def delete_dynamic_client(client)
82
+ delete_dynamic_client_by_id(client.id)
83
+ end
84
+
85
+ def delete_dynamic_client_by_id(id)
86
+ delete('dynamic_client', id)
87
+ end
88
+
45
89
  def find_session(handle)
46
90
  content = read('session', handle)
47
91
  content && SessionData.new(**content)
@@ -59,7 +103,7 @@ module Himari
59
103
  delete('session', handle)
60
104
  end
61
105
 
62
- private def write(kind, key, content, overwrite: false)
106
+ private def write(kind, key, content, overwrite: false, if_version: nil)
63
107
  raise NotImplementedError
64
108
  end
65
109
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/storages/base'
2
4
 
3
5
  module Himari
@@ -11,11 +13,20 @@ module Himari
11
13
 
12
14
  attr_reader :path
13
15
 
14
- private def write(kind, key, content, overwrite: false)
16
+ # The version compare-and-swap below is a read-compare-write, which is not atomic
17
+ # across processes. Adequate for filesystem storage's dev/single-node use; the
18
+ # production atomic path is DynamoDB's conditional update.
19
+ private def write(kind, key, content, overwrite: false, if_version: nil)
15
20
  dir = File.join(@path, kind)
16
21
  path = File.join(dir, key)
17
22
  Dir.mkdir(dir) unless Dir.exist?(dir)
18
- raise Himari::Storages::Base::Conflict if File.exist?(path)
23
+ if if_version
24
+ existing = read(kind, key)
25
+ raise Himari::Storages::Base::Conflict unless existing && existing[:version] == if_version
26
+ elsif File.exist?(path) && !overwrite
27
+ raise Himari::Storages::Base::Conflict
28
+ end
29
+
19
30
  File.write(path, "#{JSON.pretty_generate(content)}\n")
20
31
  nil
21
32
  end
@@ -24,7 +35,7 @@ module Himari
24
35
  path = File.join(@path, kind, key)
25
36
  JSON.parse(File.read(path), symbolize_names: true)
26
37
  rescue Errno::ENOENT
27
- return nil
38
+ nil
28
39
  end
29
40
 
30
41
  private def delete(kind, key)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/storages/base'
2
4
 
3
5
  module Himari
@@ -9,9 +11,15 @@ module Himari
9
11
  @memory = {}
10
12
  end
11
13
 
12
- private def write(kind, key, content, overwrite: false)
14
+ private def write(kind, key, content, overwrite: false, if_version: nil)
13
15
  path = File.join(kind, key)
14
- raise Himari::Storages::Base::Conflict if @memory.key?(path)
16
+ if if_version
17
+ existing = read(kind, key)
18
+ raise Himari::Storages::Base::Conflict unless existing && existing[:version] == if_version
19
+ elsif @memory.key?(path) && !overwrite
20
+ raise Himari::Storages::Base::Conflict
21
+ end
22
+
15
23
  @memory[path] = JSON.pretty_generate(content)
16
24
  nil
17
25
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'securerandom'
2
4
  require 'base64'
3
5
  require 'digest/sha2'
@@ -11,6 +13,10 @@ module Himari
11
13
  class TokenExpired < Error; end
12
14
  class InvalidFormat < Error; end
13
15
 
16
+ # Outcome of a successful verify_secret!: which stored secret slot the presented secret
17
+ # matched (:current or :previous) and the hash it matched against. nil until verified.
18
+ Verification = Data.define(:via, :secret_hash)
19
+
14
20
  module ClassMethods
15
21
  def magic_header
16
22
  raise NotImplementedError
@@ -25,7 +31,7 @@ module Himari
25
31
  handle: SecureRandom.urlsafe_base64(32),
26
32
  secret: SecureRandom.urlsafe_base64(48),
27
33
  expiry: Time.now.to_i + (lifetime || default_lifetime),
28
- **kwargs
34
+ **kwargs,
29
35
  )
30
36
  end
31
37
 
@@ -38,6 +44,10 @@ module Himari
38
44
  k.extend(ClassMethods)
39
45
  end
40
46
 
47
+ def self.hash_secret(secret)
48
+ Base64.urlsafe_encode64(Digest::SHA384.digest(secret), padding: false)
49
+ end
50
+
41
51
  def handle
42
52
  @handle
43
53
  end
@@ -48,11 +58,19 @@ module Himari
48
58
 
49
59
  def secret
50
60
  raise SecretMissing unless @secret
61
+
51
62
  @secret
52
63
  end
53
64
 
54
65
  def secret_hash
55
- @secret_hash ||= Base64.urlsafe_encode64(Digest::SHA384.digest(secret), padding: false)
66
+ @secret_hash ||= TokenString.hash_secret(secret)
67
+ end
68
+
69
+ # Optional second valid secret hash. Tokens that rotate in place (RefreshToken) keep the
70
+ # previously-issued secret valid for one more turn so a client whose rotation response was
71
+ # lost can retry. nil for single-secret tokens (AccessToken, SessionData).
72
+ def secret_hash_prev
73
+ @secret_hash_prev
56
74
  end
57
75
 
58
76
  def verify!(secret:, now: Time.now)
@@ -61,13 +79,30 @@ module Himari
61
79
  end
62
80
 
63
81
  def verify_secret!(given_secret)
64
- dgst = Base64.urlsafe_decode64(secret_hash) # TODO: rescue errors
65
82
  given_dgst = Digest::SHA384.digest(given_secret)
66
- raise SecretIncorrect unless Rack::Utils.secure_compare(dgst, given_dgst)
83
+ @verification =
84
+ if secret_hash_match(secret_hash, given_dgst)
85
+ Verification.new(via: :current, secret_hash: secret_hash)
86
+ elsif secret_hash_prev && secret_hash_match(secret_hash_prev, given_dgst)
87
+ Verification.new(via: :previous, secret_hash: secret_hash_prev)
88
+ end
89
+ raise SecretIncorrect unless @verification
90
+
67
91
  @secret = given_secret
68
92
  true
69
93
  end
70
94
 
95
+ # The Verification from the last successful verify_secret!, or nil. Used for logging
96
+ # (#via) and to let a rotating token keep the just-presented secret valid (#secret_hash).
97
+ attr_reader :verification
98
+
99
+ private def secret_hash_match(stored_hash, given_dgst)
100
+ stored_dgst = Base64.urlsafe_decode64(stored_hash)
101
+ Rack::Utils.secure_compare(stored_dgst, given_dgst)
102
+ rescue ArgumentError
103
+ raise SecretIncorrect
104
+ end
105
+
71
106
  def verify_expiry!(now = Time.now)
72
107
  raise TokenExpired if @expiry <= now.to_i
73
108
  end
@@ -77,6 +112,7 @@ module Himari
77
112
  parts = str.split('.')
78
113
  raise InvalidFormat unless parts.size == 3
79
114
  raise InvalidFormat unless parts[0] == header
115
+
80
116
  new(header: header, handle: parts[1], secret: parts[2])
81
117
  end
82
118
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Himari
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -60,6 +60,24 @@ main > header img, main > footer img {
60
60
  margin-top: 30px;
61
61
  }
62
62
 
63
+ .consent-detail {
64
+ margin: 12px 0 24px;
65
+ }
66
+ .consent-scopes {
67
+ display: inline-block;
68
+ text-align: left;
69
+ }
70
+ .himari-consent .actions form {
71
+ display: flex;
72
+ flex-direction: row;
73
+ gap: 12px;
74
+ }
75
+ .consent-deny {
76
+ border-color: #4E6994 !important;
77
+ background: transparent !important;
78
+ color: #4E6994 !important;
79
+ }
80
+
63
81
  .notice {
64
82
  background-color: white;
65
83
  border: 1px #bfa88a solid;
data/views/consent.erb ADDED
@@ -0,0 +1,59 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title><%= h(msg(:consent_page_title, nil) || msg(:consent_title, "Authorize access")) %></title>
6
+ <link rel="stylesheet" href="/public/index.css?cb=<%= cachebuster %>" type="text/css" />
7
+ <meta name="viewport" content="initial-scale=1">
8
+ <meta name="robots" content="noindex, nofollow">
9
+
10
+ <meta name="himari:release" content="<%= release_code %>">
11
+ </head>
12
+
13
+ <body class='himari-app himari-consent'>
14
+ <main>
15
+
16
+ <header>
17
+ <h1><%= msg(:consent_title, "Authorize access") %></h1>
18
+ <%= msg(:consent_header) %>
19
+
20
+ <% if @notice %>
21
+ <div class='notice'>
22
+ <p><%=h @notice %></p>
23
+ </div>
24
+ <% end %>
25
+ </header>
26
+
27
+ <section class='consent-detail'>
28
+ <p><strong><%=h(@consent_client.name || @consent_client.id) %></strong> <%= msg(:consent_request_message, "is requesting access to your account.") %></p>
29
+
30
+ <% if @consent_scopes.any? %>
31
+ <p><%= msg(:consent_scopes_message, "The following will be shared:") %></p>
32
+ <ul class='consent-scopes'>
33
+ <% @consent_scopes.each do |scope| %>
34
+ <li><%=h msg(:"scope_#{scope}", scope) %></li>
35
+ <% end %>
36
+ </ul>
37
+ <% else %>
38
+ <p><%= msg(:consent_no_scopes_message, "Basic sign-in information will be shared.") %></p>
39
+ <% end %>
40
+ </section>
41
+
42
+ <nav class='actions'>
43
+ <form action="<%=h request.path %>" method="POST" id='consent-form'>
44
+ <input type="hidden" name="<%= csrf_token_name %>" value="<%= csrf_token_value %>" />
45
+ <% request.GET.each do |k, v| %>
46
+ <% next if k == csrf_token_name || k == '_consent' %>
47
+ <input type="hidden" name="<%=h k %>" value="<%=h v %>" />
48
+ <% end %>
49
+ <button type='submit' name='_consent' value='approve' class='consent-approve'><%= msg(:consent_approve, "Approve") %></button>
50
+ <button type='submit' name='_consent' value='deny' class='consent-deny'><%= msg(:consent_deny, "Deny") %></button>
51
+ </form>
52
+ </nav>
53
+
54
+ <footer>
55
+ <%= msg(:consent_footer) %>
56
+ </footer>
57
+ </main>
58
+ </body>
59
+ </html>