himari 0.2.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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