himari 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ac0941b925171e38c0862b97f8a0ad2256c9ac56cfc3d3dd9b59bca1508dc4e
4
- data.tar.gz: 37b2fc9399deca00071f07140c53aac84a8deb6169c9cb11f919d34388dae217
3
+ metadata.gz: d532b382f36d5465772fc09e18feebf0a29db47365e2f5fbd3e83cec7eaa6c4b
4
+ data.tar.gz: cf9632d941835355bea9cde2435ad6e9993cb278d306e8d5888fc804053cce32
5
5
  SHA512:
6
- metadata.gz: c0513156a600ffe7a0364ab2c7da45153702afce8c0fd82caf2c6f697529793570f61280abe50316ae5dd491c49908a084fe8d8341ef0a8882a4c5fee5eaf954
7
- data.tar.gz: 5b63f0a995ebc2be40440d02831c2bbb40d8928277b3e181ffc0d227546274af0d6fee26f26f4cea9dd90578095b8023c9830973e5d39205273e4a1c7f2633d0
6
+ metadata.gz: bb564663bea0c6c39cc0a81d4db848f1e7142b9e2d89ddc34d4b2550d99c44dfc6340dddc239d701ac53d53f762519388095a7806b68d29b675215785cbaf90a
7
+ data.tar.gz: db75ed71b1daef3ae8d3ebbcdf85518c27b9be0fea0a3cc18ca7906271253ac16abc6635c44c5f1b03d3e2a6c3354d02611133e6d71ac4a3e5d6019f756bce47
@@ -1,32 +1,11 @@
1
- require 'securerandom'
2
- require 'base64'
3
- require 'digest/sha2'
4
- require 'rack/utils'
5
-
6
1
  require 'rack/oauth2'
7
2
  require 'openid_connect'
8
3
 
4
+ require 'himari/token_string'
5
+
9
6
  module Himari
10
7
  class AccessToken
11
- class SecretMissing < StandardError; end
12
- class SecretIncorrect < StandardError; end
13
- class TokenExpired < StandardError; end
14
- class InvalidFormat < StandardError; end
15
-
16
- Format = Struct.new(:handler, :secret, keyword_init: true) do
17
- HEADER = 'hmat'
18
-
19
- def self.parse(str)
20
- parts = str.split('.')
21
- raise InvalidFormat unless parts.size == 3
22
- raise InvalidFormat unless parts[0] == HEADER
23
- new(handler: parts[1], secret: parts[2])
24
- end
25
-
26
- def to_s
27
- "#{HEADER}.#{handler}.#{secret}"
28
- end
29
- end
8
+ include TokenString
30
9
 
31
10
  class Bearer < Rack::OAuth2::AccessToken::Bearer
32
11
  def token_response(options = {})
@@ -36,13 +15,12 @@ module Himari
36
15
  end
37
16
  end
38
17
 
39
- def self.make(**kwargs)
40
- new(
41
- handler: SecureRandom.urlsafe_base64(32),
42
- secret: SecureRandom.urlsafe_base64(32),
43
- expiry: Time.now.to_i + 3600,
44
- **kwargs
45
- )
18
+ def self.magic_header
19
+ 'hmat'
20
+ end
21
+
22
+ def self.default_lifetime
23
+ 3600
46
24
  end
47
25
 
48
26
  # @param authz [Himari::AuthorizationCode]
@@ -50,11 +28,12 @@ module Himari
50
28
  make(
51
29
  client_id: authz.client_id,
52
30
  claims: authz.claims,
31
+ lifetime: authz.lifetime.access_token,
53
32
  )
54
33
  end
55
34
 
56
- def initialize(handler:, client_id:, claims:, expiry:, secret: nil, secret_hash: nil)
57
- @handler = handler
35
+ def initialize(handle:, client_id:, claims:, expiry:, secret: nil, secret_hash: nil)
36
+ @handle = handle
58
37
  @client_id = client_id
59
38
  @claims = claims
60
39
  @expiry = expiry
@@ -63,32 +42,8 @@ module Himari
63
42
  @secret_hash = secret_hash
64
43
  end
65
44
 
66
- attr_reader :handler, :client_id, :claims, :expiry
67
-
68
- def secret
69
- raise SecretMissing unless @secret
70
- @secret
71
- end
72
-
73
- def secret_hash
74
- @secret_hash ||= Base64.urlsafe_encode64(Digest::SHA384.digest(secret), padding: false)
75
- end
76
-
77
- def verify_secret!(given_secret)
78
- dgst = Base64.urlsafe_decode64(secret_hash)
79
- given_dgst = Digest::SHA384.digest(given_secret)
80
- raise SecretIncorrect unless Rack::Utils.secure_compare(dgst, given_dgst)
81
- @secret = given_secret
82
- true
83
- end
45
+ attr_reader :handle, :client_id, :claims, :expiry
84
46
 
85
- def verify_expiry!(now = Time.now)
86
- raise TokenExpired if @expiry <= now.to_i
87
- end
88
-
89
- def format
90
- Format.new(handler: handler, secret: secret)
91
- end
92
47
 
93
48
  def to_bearer
94
49
  Bearer.new(
@@ -99,7 +54,7 @@ module Himari
99
54
 
100
55
  def as_log
101
56
  {
102
- handler_dgst: Digest::SHA256.hexdigest(handler),
57
+ handle: handle,
103
58
  client_id: client_id,
104
59
  claims: claims,
105
60
  expiry: expiry,
@@ -108,7 +63,7 @@ module Himari
108
63
 
109
64
  def as_json
110
65
  {
111
- handler: handler,
66
+ handle: handle,
112
67
  secret_hash: secret_hash,
113
68
  client_id: client_id,
114
69
  claims: claims,
data/lib/himari/app.rb CHANGED
@@ -6,8 +6,11 @@ require 'himari/version'
6
6
 
7
7
  require 'himari/log_line'
8
8
 
9
+ require 'himari/token_string'
9
10
  require 'himari/provider_chain'
11
+
10
12
  require 'himari/authorization_code'
13
+ require 'himari/session_data'
11
14
 
12
15
  require 'himari/middlewares/client'
13
16
  require 'himari/middlewares/config'
@@ -26,14 +29,33 @@ module Himari
26
29
  class App < Sinatra::Base
27
30
  set :root, File.expand_path(File.join(__dir__, '..', '..'))
28
31
 
29
- set :protection, use: %i(authenticity_token), except: %i(remote_token)
32
+ # remote_token: disabled in favor of authenticity_token (more stricter)
33
+ # json_csrf: can be prevented using x-content-type-options:nosniff
34
+ set :protection, use: %i(authenticity_token), except: %i(remote_token json_csrf)
35
+
30
36
  set :logging, nil
31
37
 
32
38
  ProviderCandidate = Struct.new(:name, :button, :action, keyword_init: true)
33
39
 
40
+ class InvalidSessionToken < StandardError; end
41
+
34
42
  helpers do
35
43
  def current_user
36
- session[:session_data]
44
+ return @current_user if defined? @current_user
45
+ given_token = session[:himari_session]
46
+ return nil unless given_token
47
+
48
+ given_parsed_token = Himari::SessionData.parse(given_token)
49
+
50
+ token = config.storage.find_session(given_parsed_token.handle)
51
+ raise InvalidSessionToken, "no session found in storage (possibly expired)" unless token
52
+ token.verify!(secret: given_parsed_token.secret)
53
+
54
+ @current_user = token
55
+ rescue InvalidSessionToken, Himari::TokenString::Error => e
56
+ logger&.warn(Himari::LogLine.new('invalid session token given', req: request_as_log, err: e.class.inspect))
57
+ session.delete(:himari_session)
58
+ nil
37
59
  end
38
60
 
39
61
  def config
@@ -49,7 +71,15 @@ module Himari
49
71
  end
50
72
 
51
73
  def known_providers
52
- query = Addressable::URI.form_encode(back_to: request.fullpath)
74
+ back_to = if request.query_string.empty?
75
+ request.path
76
+ else
77
+ Addressable::URI.parse(request.fullpath).tap do |u|
78
+ u.query_values = u.query_values.reject { |k,_v| k == 'prompt' }
79
+ end.to_s
80
+ end
81
+ query = Addressable::URI.form_encode(back_to: back_to)
82
+
53
83
  config.providers.map do |pr|
54
84
  name = pr.fetch(:name)
55
85
  ProviderCandidate.new(
@@ -109,7 +139,7 @@ module Himari
109
139
 
110
140
  get '/' do
111
141
  content_type :text
112
- "Himari\n"
142
+ "Himari #{release_code}\n"
113
143
  end
114
144
 
115
145
  get '/oidc/authorize' do
@@ -118,15 +148,17 @@ module Himari
118
148
  logger&.warn(Himari::LogLine.new('authorize: no client registration found', req: request_as_log, client_id: params[:client_id]))
119
149
  next halt 401, 'unknown client'
120
150
  end
151
+
121
152
  if current_user
122
153
  # do downstream authz and process oidc request
123
154
  decision = Himari::Services::DownstreamAuthorization.from_request(session: current_user, client: client, request: request).perform
124
- logger&.info(Himari::LogLine.new('authorize: downstream authorized', req: request_as_log, allowed: decision.authz_result.allowed, result: decision.as_log))
155
+ logger&.info(Himari::LogLine.new('authorize: downstream authorized', req: request_as_log, session: current_user.as_log, allowed: decision.authz_result.allowed, result: decision.as_log))
125
156
  raise unless decision.authz_result.allowed # sanity check
126
157
 
127
158
  authz = AuthorizationCode.make(
128
159
  client_id: decision.client.id,
129
160
  claims: decision.claims,
161
+ lifetime: decision.lifetime,
130
162
  )
131
163
 
132
164
  Himari::Services::OidcAuthorizationEndpoint.new(
@@ -137,11 +169,29 @@ module Himari
137
169
  ).call(env)
138
170
  else
139
171
  logger&.info(Himari::LogLine.new('authorize: prompt login', req: request_as_log, client_id: params[:client_id]))
140
- erb config.custom_templates[:login] || :login
172
+ erb(config.custom_templates[:login] || :login)
141
173
  end
174
+
175
+ rescue Himari::Services::OidcAuthorizationEndpoint::ReauthenticationRequired
176
+ logger&.warn(Himari::LogLine.new('authorize: prompt login to reauthenticate (demanded by oidc request)', req: request_as_log, session: current_user&.as_log, allowed: decision&.authz_result&.allowed, result: decision&.as_log))
177
+ next erb(config.custom_templates[:login] || :login)
178
+
142
179
  rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
143
- logger&.warn(Himari::LogLine.new('authorize: downstream forbidden', req: request_as_log, allowed: e.result.authz_result.allowed, err: e.class.inspect, result: e.as_log))
144
- halt 403, "Forbidden"
180
+ logger&.warn(Himari::LogLine.new('authorize: downstream forbidden', req: request_as_log, session: current_user&.as_log, allowed: e.result.authz_result.allowed, err: e.class.inspect, result: e.as_log))
181
+
182
+ @notice = message_human = e.result.authz_result&.user_facing_message
183
+
184
+ case e.result.authz_result&.suggestion
185
+ when nil
186
+ # do nothing
187
+ when :reauthenticate
188
+ logger&.warn(Himari::LogLine.new('authorize: prompt login to reauthenticate (suggested by decision)', req: request_as_log, session: current_user&.as_log, allowed: e.result.authz_result.allowed, err: e.class.inspect, result: e.as_log))
189
+ next erb(config.custom_templates[:login] || :login)
190
+ else
191
+ raise ArgumentError, "Unknown suggestion value for DownstreamAuthorization denial; #{e.as_log.inspect}"
192
+ end
193
+
194
+ halt(403, "Forbidden#{message_human ? "; #{message_human}" : nil}")
145
195
  end
146
196
 
147
197
  token_ep = proc do
@@ -164,7 +214,8 @@ module Himari
164
214
  end
165
215
  get '/oidc/userinfo', &userinfo_ep
166
216
  get '/public/oidc/userinfo', &userinfo_ep
167
-
217
+ post '/oidc/userinfo', &userinfo_ep
218
+ post '/public/oidc/userinfo', &userinfo_ep
168
219
 
169
220
  jwks_ep = proc do
170
221
  Himari::Services::JwksEndpoint.new(
@@ -182,14 +233,21 @@ module Himari
182
233
  end
183
234
 
184
235
  omniauth_callback = proc do
236
+ authhash = request.env['omniauth.auth']
237
+ next halt(400, 'Bad Request') unless authhash
238
+
185
239
  # do upstream auth
186
240
  authn = Himari::Services::UpstreamAuthentication.from_request(request).perform
187
- logger&.info(Himari::LogLine.new('authentication allowed', req: request_as_log, allowed: authn.authn_result.allowed, uid: request.env.fetch('omniauth.auth')[:uid], provider: request.env.fetch('omniauth.auth')[:provider], result: authn.as_log))
241
+ logger&.info(Himari::LogLine.new('authentication allowed', req: request_as_log, allowed: authn.authn_result.allowed, uid: authhash[:uid], provider: authhash[:provider], result: authn.as_log, existing_session: current_user&.as_log))
188
242
  raise unless authn.authn_result.allowed # sanity check
189
243
 
190
244
  given_back_to = request.env['omniauth.params']&.fetch('back_to', nil)
191
245
  back_to = if given_back_to
192
- uri = Addressable::URI.parse(given_back_to)
246
+ uri = begin
247
+ Addressable::URI.parse(given_back_to)
248
+ rescue Addressable::URI::InvalidURIError
249
+ nil
250
+ end
193
251
  if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
194
252
  given_back_to
195
253
  else
@@ -199,11 +257,16 @@ module Himari
199
257
  end || '/'
200
258
 
201
259
  session.destroy
202
- session[:session_data] = authn.session_data
260
+
261
+ new_session = authn.session_data
262
+ config.storage.put_session(new_session)
263
+ session[:himari_session] = new_session.format.to_s
264
+
203
265
  redirect back_to
204
266
  rescue Himari::Services::UpstreamAuthentication::UnauthorizedError => e
205
- logger&.warn(Himari::LogLine.new('authentication denied', req: request_as_log, err: e.class.inspect, allowed: e.result.authn_result.allowed, uid: request.env.fetch('omniauth.auth')[:uid], provider: request.env.fetch('omniauth.auth')[:provider], result: e.as_log))
206
- halt(401, 'Unauthorized')
267
+ logger&.warn(Himari::LogLine.new('authentication denied', req: request_as_log, err: e.class.inspect, allowed: e.result.authn_result.allowed, uid: request.env.fetch('omniauth.auth')[:uid], provider: request.env.fetch('omniauth.auth')[:provider], result: e.as_log, existing_session: current_user&.as_log))
268
+ message_human = e.result.authn_result&.user_facing_message
269
+ halt(401, "Unauthorized#{message_human ? "; #{message_human}" : nil}")
207
270
  end
208
271
  get '/auth/:provider/callback', &omniauth_callback
209
272
  post '/auth/:provider/callback', &omniauth_callback
@@ -1,4 +1,5 @@
1
1
  require 'digest/sha2'
2
+ require 'himari/lifetime_value'
2
3
 
3
4
  module Himari
4
5
  authz_attrs = %i(
@@ -10,17 +11,38 @@ module Himari
10
11
  nonce
11
12
  code_challenge
12
13
  code_challenge_method
14
+ created_at
15
+ lifetime
13
16
  expiry
14
17
  )
15
18
  AuthorizationCode = Struct.new(*authz_attrs, keyword_init: true) do
16
19
  def self.make(**kwargs)
17
20
  new(
18
21
  code: SecureRandom.urlsafe_base64(32),
19
- expiry: Time.now.to_i + 900,
22
+ created_at: Time.now.to_i,
20
23
  **kwargs,
21
24
  )
22
25
  end
23
26
 
27
+ alias _lifetime_raw lifetime
28
+ private :_lifetime_raw
29
+ def lifetime
30
+ case _lifetime_raw
31
+ when Hash
32
+ self.lifetime = LifetimeValue.new(**_lifetime_raw)
33
+ when Integer #compat
34
+ self.lifetime = LifetimeValue.from_integer(_lifetime_raw)
35
+ else
36
+ _lifetime_raw
37
+ end
38
+ end
39
+
40
+ alias _expiry_raw expiry
41
+ private :_expiry_raw
42
+ def expiry
43
+ self._expiry_raw || (self.expiry = created_at + (lifetime&.code || 900))
44
+ end
45
+
24
46
  def valid_redirect_uri?(given_uri)
25
47
  redirect_uri == given_uri
26
48
  end
@@ -59,6 +81,8 @@ module Himari
59
81
  claims: claims,
60
82
  nonce: nonce,
61
83
  openid: openid,
84
+ created_at: created_at.to_i,
85
+ lifetime: lifetime.as_log,
62
86
  expiry: expiry.to_i,
63
87
  pkce: pkce?,
64
88
  pkce_method: code_challenge_method,
@@ -76,6 +100,8 @@ module Himari
76
100
  nonce: nonce,
77
101
  code_challenge: code_challenge,
78
102
  code_challenge_method: code_challenge_method,
103
+ created_at: created_at.to_i,
104
+ lifetime: lifetime.as_json,
79
105
  expiry: expiry.to_i,
80
106
  }
81
107
  end
@@ -10,6 +10,7 @@ module Himari
10
10
  @redirect_uris = redirect_uris
11
11
  @preferred_key_group = preferred_key_group
12
12
 
13
+ raise ArgumentError, "name starts with '_' is reserved" if @name&.start_with?('_')
13
14
  raise ArgumentError, "either secret or secret_hash must be present" if !@secret && !@secret_hash
14
15
  end
15
16
 
@@ -1,4 +1,5 @@
1
1
  require 'himari/decisions/base'
2
+ require 'himari/lifetime_value'
2
3
 
3
4
  module Himari
4
5
  module Decisions
@@ -19,28 +20,38 @@ module Himari
19
20
 
20
21
  allow_effects(:allow, :deny, :continue, :skip)
21
22
 
22
- def initialize(claims: {}, allowed_claims: DEFAULT_ALLOWED_CLAIMS, lifetime: 3600 * 12)
23
+ def initialize(claims: {}, allowed_claims: DEFAULT_ALLOWED_CLAIMS, lifetime: 3600)
23
24
  super()
24
25
  @claims = claims
25
26
  @allowed_claims = allowed_claims
26
- @lifetime = lifetime
27
+ self.lifetime = lifetime
27
28
  end
28
29
 
29
- attr_reader :claims, :allowed_claims, :lifetime
30
+ attr_reader :claims, :allowed_claims
31
+ attr_reader :lifetime
32
+
33
+ def lifetime=(x)
34
+ case x
35
+ when LifetimeValue
36
+ @lifetime = x
37
+ else
38
+ @lifetime = LifetimeValue.from_integer(x)
39
+ end
40
+ end
30
41
 
31
42
  def to_evolve_args
32
43
  {
33
44
  claims: @claims.dup,
34
45
  allowed_claims: @allowed_claims.dup,
35
- lifetime: @lifetime&.to_i,
46
+ lifetime: @lifetime,
36
47
  }
37
48
  end
38
49
 
39
50
  def as_log
40
- to_h.merge(claims: output, lifetime: @lifetime&.to_i)
51
+ to_h.merge(claims: output_claims, lifetime: @lifetime.to_h)
41
52
  end
42
53
 
43
- def output
54
+ def output_claims
44
55
  claims.select { |k,_v| allowed_claims.include?(k) }
45
56
  end
46
57
  end
@@ -18,7 +18,7 @@ module Himari
18
18
  raise "#{self.class.name}.valid_effects is missing [BUG]" unless self.class.valid_effects
19
19
  end
20
20
 
21
- attr_reader :effect, :effect_comment, :rule_name
21
+ attr_reader :effect, :effect_comment, :effect_user_facing_message, :effect_suggestion, :rule_name
22
22
 
23
23
  def to_evolve_args
24
24
  raise NotImplementedError
@@ -29,7 +29,10 @@ module Himari
29
29
  rule_name: rule_name,
30
30
  effect: effect,
31
31
  effect_comment: effect_comment,
32
- }
32
+ }.tap do |x|
33
+ x[:effect_user_facing_message] = effect_user_facing_message if effect_user_facing_message
34
+ x[:effect_suggestion] = effect_suggestion if effect_suggestion
35
+ end
33
36
  end
34
37
 
35
38
  def as_log
@@ -46,18 +49,21 @@ module Himari
46
49
  self
47
50
  end
48
51
 
49
- def decide!(effect, comment = "")
52
+ def decide!(effect, comment = "", user_facing_message: nil, suggest: nil)
50
53
  raise DecisionAlreadyMade, "decision can only be made once per rule (#{rule_name})" if @effect
51
54
  raise InvalidEffect, "this effect is not valid under this rule. Valid effects: #{self.class.valid_effects.inspect} (#{rule_name})" unless self.class.valid_effects.include?(effect)
55
+ raise InvalidEffect, "only deny effect can have suggestion" if suggest&& effect != :deny
52
56
  @effect = effect
53
57
  @effect_comment = comment
58
+ @effect_user_facing_message = user_facing_message
59
+ @effect_suggestion = suggest
54
60
  nil
55
61
  end
56
62
 
57
- def allow!(comment = ""); decide!(:allow, comment); end
58
- def continue!(comment = ""); decide!(:continue, comment); end
59
- def deny!(comment = ""); decide!(:deny, comment); end
60
- def skip!(comment = ""); decide!(:skip, comment); end
63
+ def allow!(*args, **kwargs); decide!(:allow, *args, **kwargs); end
64
+ def continue!(*args, **kwargs); decide!(:continue, *args, **kwargs); end
65
+ def deny!(*args, **kwargs); decide!(:deny, *args, **kwargs); end
66
+ def skip!(*args, **kwargs); decide!(:skip, *args, **kwargs); end
61
67
  end
62
68
  end
63
69
  end
@@ -13,16 +13,20 @@ module Himari
13
13
 
14
14
  allow_effects(:continue, :skip)
15
15
 
16
- def initialize(claims: nil, user_data: nil)
16
+ def initialize(claims: nil, user_data: nil, lifetime: nil)
17
17
  super()
18
18
  @claims = claims
19
19
  @user_data = user_data
20
+ @lifetime = lifetime
20
21
  end
21
22
 
23
+ attr_accessor :lifetime
24
+
22
25
  def to_evolve_args
23
26
  {
24
27
  claims: @claims.dup,
25
28
  user_data: @user_data.dup,
29
+ lifetime: @lifetime&.to_i,
26
30
  }
27
31
  end
28
32
 
@@ -31,7 +35,7 @@ module Himari
31
35
  end
32
36
 
33
37
  def output
34
- Himari::SessionData.new(claims: claims, user_data: user_data)
38
+ Himari::SessionData.make(claims: claims, user_data: user_data, lifetime: lifetime)
35
39
  end
36
40
 
37
41
  def initialize_claims!(claims = {})
@@ -7,15 +7,17 @@ module Himari
7
7
  class IdToken
8
8
  # @param authz [Himari::AuthorizationCode]
9
9
  def self.from_authz(authz, **kwargs)
10
+
10
11
  new(
11
12
  claims: authz.claims,
12
13
  client_id: authz.client_id,
13
14
  nonce: authz.nonce,
15
+ lifetime: authz.lifetime.is_a?(Integer) ? authz.lifetime : authz.lifetime.id_token, # compat
14
16
  **kwargs
15
17
  )
16
18
  end
17
19
 
18
- def initialize(claims:, client_id:, nonce:, signing_key:, issuer:, access_token: nil, time: Time.now)
20
+ def initialize(claims:, client_id:, nonce:, signing_key:, issuer:, access_token: nil, time: Time.now, lifetime: 3600)
19
21
  @claims = claims
20
22
  @client_id = client_id
21
23
  @nonce = nonce
@@ -23,6 +25,7 @@ module Himari
23
25
  @issuer = issuer
24
26
  @access_token = access_token
25
27
  @time = time
28
+ @lifetime = lifetime
26
29
  end
27
30
 
28
31
  attr_reader :claims, :nonce, :signing_key
@@ -34,7 +37,7 @@ module Himari
34
37
  aud: @client_id,
35
38
  iat: @time.to_i,
36
39
  nbf: @time.to_i,
37
- exp: (@time + 3600).to_i, # TODO: lifetime
40
+ exp: (@time + @lifetime).to_i,
38
41
  ).merge(
39
42
  @nonce ? { nonce: @nonce } : {}
40
43
  ).merge(
@@ -0,0 +1,15 @@
1
+ module Himari
2
+ LifetimeValue = Struct.new(:access_token, :id_token, :code, keyword_init: true) do
3
+ def self.from_integer(i)
4
+ new(access_token: i, id_token: i, code: nil)
5
+ end
6
+
7
+ def as_log
8
+ as_json&.compact
9
+ end
10
+
11
+ def as_json
12
+ {access_token: access_token, id_token: id_token, code: code}
13
+ end
14
+ end
15
+ end
@@ -2,7 +2,7 @@ module Himari
2
2
  class RuleProcessor
3
3
  class MissingDecisionError < StandardError; end
4
4
 
5
- Result = Struct.new(:rule_name, :allowed, :explicit_deny, :decision, :decision_log, keyword_init: true) do
5
+ Result = Struct.new(:rule_name, :allowed, :explicit_deny, :decision, :decision_log, :user_facing_message, :suggestion, keyword_init: true) do
6
6
  def as_log
7
7
  {
8
8
  rule_name: rule_name,
@@ -10,7 +10,9 @@ module Himari
10
10
  explicit_deny: explicit_deny,
11
11
  decision: decision&.as_log,
12
12
  decision_log: decision_log.map(&:to_h),
13
- }
13
+ }.tap do |x|
14
+ x[:suggestion] = suggestion if suggestion
15
+ end
14
16
  end
15
17
  end
16
18
 
@@ -47,6 +49,7 @@ module Himari
47
49
  result.decision = decision
48
50
  result.allowed = true
49
51
  result.explicit_deny = false
52
+ result.user_facing_message = decision.effect_user_facing_message
50
53
 
51
54
  when :continue
52
55
  @decision = decision
@@ -61,6 +64,8 @@ module Himari
61
64
  result.decision = nil
62
65
  result.allowed = false
63
66
  result.explicit_deny = true
67
+ result.user_facing_message = decision.effect_user_facing_message
68
+ result.suggestion = decision.effect_suggestion
64
69
 
65
70
  else
66
71
  raise "Unknown effect #{decision.effect} [BUG]"
@@ -21,7 +21,7 @@ module Himari
21
21
  end
22
22
  end
23
23
 
24
- Result = Struct.new(:client, :claims, :authz_result) do
24
+ Result = Struct.new(:client, :claims, :lifetime, :authz_result) do
25
25
  def as_log
26
26
  {
27
27
  client: client.as_log,
@@ -63,10 +63,11 @@ module Himari
63
63
  context = Himari::Decisions::Authorization::Context.new(claims: @session.claims, user_data: @session.user_data, request: @request, client: @client).freeze
64
64
 
65
65
  authorization = Himari::RuleProcessor.new(context, Himari::Decisions::Authorization.new(claims: @session.claims.dup)).run(@authz_rules)
66
- raise ForbiddenError.new(Result.new(@client, nil, authorization)) unless authorization.allowed
66
+ raise ForbiddenError.new(Result.new(@client, nil, nil, authorization)) unless authorization.allowed
67
67
 
68
- claims = authorization.decision.output
69
- Result.new(@client, claims, authorization)
68
+ claims = authorization.decision.output_claims
69
+ lifetime = authorization.decision.lifetime
70
+ Result.new(@client, claims, lifetime, authorization)
70
71
  end
71
72
  end
72
73
  end
@@ -5,6 +5,8 @@ require 'openid_connect'
5
5
  module Himari
6
6
  module Services
7
7
  class OidcAuthorizationEndpoint
8
+ class ReauthenticationRequired < StandardError; end
9
+
8
10
  SUPPORTED_RESPONSE_TYPES = ['code'] # TODO: share with oidc metadata
9
11
 
10
12
  # @param authz [Himari::AuthorizationCode] pending (unpersisted) authz data
@@ -39,6 +41,8 @@ module Himari
39
41
 
40
42
  req.unsupported_response_type! if res.protocol_params_location == :fragment
41
43
  req.bad_request!(:request_uri_not_supported, "Request Object is not implemented") if req.request_uri || req.request
44
+ req.bad_request!(:invalid_request, 'prompt=none should not contain any other value') if req.prompt.include?('none') && req.prompt.any? { |x| x != 'none' }
45
+ raise ReauthenticationRequired if req.prompt.include?('login') || req.prompt.include?('select_account')
42
46
 
43
47
  requested_response_types = [*req.response_type]
44
48
  unless SUPPORTED_RESPONSE_TYPES.include?(requested_response_types.map(&:to_s).join(' '))
@@ -1,4 +1,5 @@
1
1
  require 'himari/access_token'
2
+ require 'himari/token_string'
2
3
  require 'himari/log_line'
3
4
 
4
5
  module Himari
@@ -33,9 +34,9 @@ module Himari
33
34
  return [404, {'Content-Type' => 'application/json'}, ['{"error": "not_found"}']] unless %w(GET POST).include?(@env['REQUEST_METHOD'])
34
35
 
35
36
  raise InvalidToken unless given_token
36
- given_parsed_token = Himari::AccessToken::Format.parse(given_token)
37
+ given_parsed_token = Himari::AccessToken.parse(given_token)
37
38
 
38
- token = @storage.find_token(given_parsed_token.handler)
39
+ token = @storage.find_token(given_parsed_token.handle)
39
40
  raise InvalidToken unless token
40
41
  token.verify_expiry!()
41
42
  token.verify_secret!(given_parsed_token.secret)
@@ -46,7 +47,7 @@ module Himari
46
47
  {'Content-Type' => 'application/json; charset=utf-8'},
47
48
  [JSON.pretty_generate(token.claims), "\n"],
48
49
  ]
49
- rescue InvalidToken, Himari::AccessToken::SecretIncorrect, Himari::AccessToken::InvalidFormat, Himari::AccessToken::TokenExpired => e
50
+ rescue InvalidToken, Himari::TokenString::SecretIncorrect, Himari::TokenString::InvalidFormat, Himari::TokenString::TokenExpired => e
50
51
  @logger&.warn(Himari::LogLine.new('OidcUserinfoEndpoint: invalid_token', req: @env['himari.request_as_log'], err: e.class.inspect, token: token&.as_log))
51
52
  [
52
53
  401,
@@ -27,7 +27,7 @@ module Himari
27
27
  Result = Struct.new(:claims_result, :authn_result, :session_data) do
28
28
  def as_log
29
29
  {
30
- claims: session_data&.claims,
30
+ session: session_data&.as_log,
31
31
  decision: {
32
32
  claims: claims_result&.as_log&.reject{ |k,_v| %i(allowed explicit_deny).include?(k) },
33
33
  authentication: authn_result&.as_log,
@@ -1,7 +1,46 @@
1
+ require 'himari/token_string'
2
+
1
3
  module Himari
2
- SessionData = Struct.new(:claims, :user_data, keyword_init: true) do
4
+ class SessionData
5
+ include Himari::TokenString
6
+
7
+ def initialize(claims: {}, user_data: {}, handle:, secret: nil, secret_hash: nil, expiry: nil)
8
+ @claims = claims
9
+ @user_data = user_data
10
+
11
+ @handle = handle
12
+ @secret = secret
13
+ @secret_hash = secret_hash
14
+ @expiry = expiry
15
+ end
16
+
17
+ def self.magic_header
18
+ 'hmas'
19
+ end
20
+
21
+ def self.default_lifetime
22
+ 3600
23
+ end
24
+
25
+ attr_reader :claims, :user_data
26
+
3
27
  def as_log
4
- {claims: claims}
28
+ {
29
+ handle: handle,
30
+ claims: claims,
31
+ expiry: expiry,
32
+ }
33
+ end
34
+
35
+ def as_json
36
+ {
37
+ handle: handle,
38
+ secret_hash: secret_hash,
39
+ expiry: expiry,
40
+
41
+ claims: claims,
42
+ user_data: user_data,
43
+ }
5
44
  end
6
45
  end
7
46
  end
@@ -1,5 +1,6 @@
1
1
  require 'himari/authorization_code'
2
2
  require 'himari/access_token'
3
+ require 'himari/session_data'
3
4
 
4
5
  module Himari
5
6
  module Storages
@@ -23,23 +24,40 @@ module Himari
23
24
  delete('authz', code)
24
25
  end
25
26
 
26
- def find_token(handler)
27
- content = read('token', handler)
27
+ def find_token(handle)
28
+ content = read('token', handle)
29
+ content[:handle] = content.delete(:handle) if content.key?(:handler) # compat
28
30
  content && AccessToken.new(**content)
29
31
  end
30
32
 
31
33
  def put_token(token, overwrite: false)
32
- write('token', token.handler, token.as_json, overwrite: overwrite)
34
+ write('token', token.handle, token.as_json, overwrite: overwrite)
33
35
  end
34
36
 
35
37
  def delete_token(token)
36
- delete_authorization_by_token(token.handler)
38
+ delete_authorization_by_token(token.handle)
37
39
  end
38
40
 
39
- def delete_token_by_handler(handler)
40
- delete('token', handler)
41
+ def delete_token_by_handle(handle)
42
+ delete('token', handle)
41
43
  end
42
44
 
45
+ def find_session(handle)
46
+ content = read('session', handle)
47
+ content && SessionData.new(**content)
48
+ end
49
+
50
+ def put_session(session, overwrite: false)
51
+ write('session', session.handle, session.as_json, overwrite: overwrite)
52
+ end
53
+
54
+ def delete_session(session)
55
+ delete_session_by_handle(session.handle)
56
+ end
57
+
58
+ def delete_session_by_handle(handle)
59
+ delete('session', handle)
60
+ end
43
61
 
44
62
  private def write(kind, key, content, overwrite: false)
45
63
  raise NotImplementedError
@@ -0,0 +1,96 @@
1
+ require 'securerandom'
2
+ require 'base64'
3
+ require 'digest/sha2'
4
+ require 'rack/utils'
5
+
6
+ module Himari
7
+ module TokenString
8
+ class Error < StandardError; end
9
+ class SecretMissing < Error; end
10
+ class SecretIncorrect < Error; end
11
+ class TokenExpired < Error; end
12
+ class InvalidFormat < Error; end
13
+
14
+ module ClassMethods
15
+ def magic_header
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def default_lifetime
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def make(lifetime: nil, **kwargs)
24
+ new(
25
+ handle: SecureRandom.urlsafe_base64(32),
26
+ secret: SecureRandom.urlsafe_base64(48),
27
+ expiry: Time.now.to_i + (lifetime || default_lifetime),
28
+ **kwargs
29
+ )
30
+ end
31
+
32
+ def parse(str)
33
+ Format.parse(magic_header, str)
34
+ end
35
+ end
36
+
37
+ def self.included(k)
38
+ k.extend(ClassMethods)
39
+ end
40
+
41
+ def handle
42
+ @handle
43
+ end
44
+
45
+ def expiry
46
+ @expiry
47
+ end
48
+
49
+ def secret
50
+ raise SecretMissing unless @secret
51
+ @secret
52
+ end
53
+
54
+ def secret_hash
55
+ @secret_hash ||= Base64.urlsafe_encode64(Digest::SHA384.digest(secret), padding: false)
56
+ end
57
+
58
+ def verify!(secret:, now: Time.now)
59
+ verify_expiry!(now)
60
+ verify_secret!(secret)
61
+ end
62
+
63
+ def verify_secret!(given_secret)
64
+ dgst = Base64.urlsafe_decode64(secret_hash) # TODO: rescue errors
65
+ given_dgst = Digest::SHA384.digest(given_secret)
66
+ raise SecretIncorrect unless Rack::Utils.secure_compare(dgst, given_dgst)
67
+ @secret = given_secret
68
+ true
69
+ end
70
+
71
+ def verify_expiry!(now = Time.now)
72
+ raise TokenExpired if @expiry <= now.to_i
73
+ end
74
+
75
+ Format = Struct.new(:header, :handle, :secret, keyword_init: true) do
76
+ def self.parse(header, str)
77
+ parts = str.split('.')
78
+ raise InvalidFormat unless parts.size == 3
79
+ raise InvalidFormat unless parts[0] == header
80
+ new(header: header, handle: parts[1], secret: parts[2])
81
+ end
82
+
83
+ def to_s
84
+ "#{header}.#{handle}.#{secret}"
85
+ end
86
+ end
87
+
88
+ def magic_header
89
+ self.class.magic_header
90
+ end
91
+
92
+ def format
93
+ Format.new(header: magic_header, handle: handle, secret: secret)
94
+ end
95
+ end
96
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Himari
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,5 +1,7 @@
1
- body {
1
+ body, button, select {
2
2
  font-family: "Segoe UI", Helvetica, sans-serif;
3
+ }
4
+ body {
3
5
  font-size: 16px;
4
6
  background-color: #FDF7EF;
5
7
  text-align: center;
@@ -58,6 +60,19 @@ main > header img, main > footer img {
58
60
  margin-top: 30px;
59
61
  }
60
62
 
63
+ .notice {
64
+ background-color: white;
65
+ border: 1px #bfa88a solid;
66
+ border-radius: 4px;
67
+ padding: 4px;
68
+ margin: 12px;
69
+ margin-bottom: 24px;
70
+ }
71
+
72
+ footer, footer a, footer a:visited {
73
+ color: #5e5e6b;
74
+ }
75
+
61
76
  .d-none {
62
77
  display: none;
63
78
  }
data/views/login.erb CHANGED
@@ -16,6 +16,12 @@
16
16
  <header>
17
17
  <h1><%= msg(:title, "Login to Himari") %></h1>
18
18
  <%= msg(:header) %>
19
+
20
+ <% if @notice %>
21
+ <div class='notice'>
22
+ <p><%=h @notice %></p>
23
+ </div>
24
+ <% end %>
19
25
  </header>
20
26
 
21
27
  <nav class='actions'>
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: himari
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorah Fukumori
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-21 00:00:00.000000000 Z
11
+ date: 2023-03-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sinatra
@@ -117,6 +117,7 @@ files:
117
117
  - lib/himari/id_token.rb
118
118
  - lib/himari/item_provider.rb
119
119
  - lib/himari/item_providers/static.rb
120
+ - lib/himari/lifetime_value.rb
120
121
  - lib/himari/log_line.rb
121
122
  - lib/himari/middlewares/authentication_rule.rb
122
123
  - lib/himari/middlewares/authorization_rule.rb
@@ -139,6 +140,7 @@ files:
139
140
  - lib/himari/storages/base.rb
140
141
  - lib/himari/storages/filesystem.rb
141
142
  - lib/himari/storages/memory.rb
143
+ - lib/himari/token_string.rb
142
144
  - lib/himari/version.rb
143
145
  - public/public/index.css
144
146
  - sig/himari.rbs