himari 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/lib/himari/access_token.rb +72 -4
  4. data/lib/himari/access_token_jwt.rb +46 -0
  5. data/lib/himari/app.rb +102 -28
  6. data/lib/himari/authorization_code.rb +18 -4
  7. data/lib/himari/client_registration.rb +70 -4
  8. data/lib/himari/config.rb +8 -3
  9. data/lib/himari/decisions/authentication.rb +18 -2
  10. data/lib/himari/decisions/authorization.rb +18 -7
  11. data/lib/himari/decisions/base.rb +7 -3
  12. data/lib/himari/decisions/claims.rb +14 -9
  13. data/lib/himari/dynamic_client_registration.rb +255 -0
  14. data/lib/himari/id_token.rb +15 -28
  15. data/lib/himari/item_provider.rb +3 -1
  16. data/lib/himari/item_providers/oauth_client_metadata.rb +222 -0
  17. data/lib/himari/item_providers/static.rb +2 -0
  18. data/lib/himari/item_providers/storage.rb +33 -0
  19. data/lib/himari/jwt_token.rb +50 -0
  20. data/lib/himari/lifetime_value.rb +5 -3
  21. data/lib/himari/log_line.rb +2 -0
  22. data/lib/himari/middlewares/authentication_rule.rb +2 -0
  23. data/lib/himari/middlewares/authorization_rule.rb +2 -0
  24. data/lib/himari/middlewares/claims_rule.rb +2 -0
  25. data/lib/himari/middlewares/client.rb +2 -0
  26. data/lib/himari/middlewares/config.rb +2 -0
  27. data/lib/himari/middlewares/dynamic_clients.rb +55 -0
  28. data/lib/himari/middlewares/metadata_clients.rb +121 -0
  29. data/lib/himari/middlewares/signing_key.rb +2 -0
  30. data/lib/himari/provider_chain.rb +3 -1
  31. data/lib/himari/rack_oauth2_ext.rb +58 -0
  32. data/lib/himari/refresh_token.rb +93 -0
  33. data/lib/himari/rule.rb +2 -0
  34. data/lib/himari/rule_processor.rb +3 -0
  35. data/lib/himari/services/client_registration_endpoint.rb +78 -0
  36. data/lib/himari/services/downstream_authorization.rb +22 -7
  37. data/lib/himari/services/jwks_endpoint.rb +3 -1
  38. data/lib/himari/services/oidc_authorization_endpoint.rb +63 -3
  39. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +31 -7
  40. data/lib/himari/services/oidc_token_endpoint.rb +225 -46
  41. data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
  42. data/lib/himari/services/upstream_authentication.rb +62 -14
  43. data/lib/himari/session_data.rb +31 -2
  44. data/lib/himari/signing_key.rb +17 -14
  45. data/lib/himari/storages/base.rb +45 -1
  46. data/lib/himari/storages/filesystem.rb +14 -3
  47. data/lib/himari/storages/memory.rb +10 -2
  48. data/lib/himari/token_string.rb +40 -4
  49. data/lib/himari/version.rb +1 -1
  50. data/public/public/index.css +18 -0
  51. data/views/consent.erb +59 -0
  52. metadata +50 -14
@@ -1,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.5.0"
4
+ VERSION = "0.7.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>
metadata CHANGED
@@ -1,29 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: himari
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorah Fukumori
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-05-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: sinatra
13
+ name: omniauth
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '3.0'
18
+ version: '2.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '3.0'
25
+ version: '2.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: rack-protection
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -39,19 +38,19 @@ dependencies:
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
40
  - !ruby/object:Gem::Dependency
42
- name: omniauth
41
+ name: sinatra
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - ">="
46
45
  - !ruby/object:Gem::Version
47
- version: '2.0'
46
+ version: '3.0'
48
47
  type: :runtime
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
51
  - - ">="
53
52
  - !ruby/object:Gem::Version
54
- version: '2.0'
53
+ version: '3.0'
55
54
  - !ruby/object:Gem::Dependency
56
55
  name: addressable
57
56
  requirement: !ruby/object:Gem::Requirement
@@ -67,7 +66,21 @@ dependencies:
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0'
69
68
  - !ruby/object:Gem::Dependency
70
- name: rack-oauth2
69
+ name: concurrent-ruby
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: httpx
71
84
  requirement: !ruby/object:Gem::Requirement
72
85
  requirements:
73
86
  - - ">="
@@ -94,7 +107,20 @@ dependencies:
94
107
  - - ">="
95
108
  - !ruby/object:Gem::Version
96
109
  version: '0'
97
- description:
110
+ - !ruby/object:Gem::Dependency
111
+ name: rack-oauth2
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
98
124
  email:
99
125
  - her@sorah.jp
100
126
  executables: []
@@ -102,10 +128,12 @@ extensions: []
102
128
  extra_rdoc_files: []
103
129
  files:
104
130
  - ".rspec"
131
+ - CHANGELOG.md
105
132
  - LICENSE.txt
106
133
  - Rakefile
107
134
  - lib/himari.rb
108
135
  - lib/himari/access_token.rb
136
+ - lib/himari/access_token_jwt.rb
109
137
  - lib/himari/app.rb
110
138
  - lib/himari/authorization_code.rb
111
139
  - lib/himari/client_registration.rb
@@ -114,9 +142,13 @@ files:
114
142
  - lib/himari/decisions/authorization.rb
115
143
  - lib/himari/decisions/base.rb
116
144
  - lib/himari/decisions/claims.rb
145
+ - lib/himari/dynamic_client_registration.rb
117
146
  - lib/himari/id_token.rb
118
147
  - lib/himari/item_provider.rb
148
+ - lib/himari/item_providers/oauth_client_metadata.rb
119
149
  - lib/himari/item_providers/static.rb
150
+ - lib/himari/item_providers/storage.rb
151
+ - lib/himari/jwt_token.rb
120
152
  - lib/himari/lifetime_value.rb
121
153
  - lib/himari/log_line.rb
122
154
  - lib/himari/middlewares/authentication_rule.rb
@@ -124,10 +156,15 @@ files:
124
156
  - lib/himari/middlewares/claims_rule.rb
125
157
  - lib/himari/middlewares/client.rb
126
158
  - lib/himari/middlewares/config.rb
159
+ - lib/himari/middlewares/dynamic_clients.rb
160
+ - lib/himari/middlewares/metadata_clients.rb
127
161
  - lib/himari/middlewares/signing_key.rb
128
162
  - lib/himari/provider_chain.rb
163
+ - lib/himari/rack_oauth2_ext.rb
164
+ - lib/himari/refresh_token.rb
129
165
  - lib/himari/rule.rb
130
166
  - lib/himari/rule_processor.rb
167
+ - lib/himari/services/client_registration_endpoint.rb
131
168
  - lib/himari/services/downstream_authorization.rb
132
169
  - lib/himari/services/jwks_endpoint.rb
133
170
  - lib/himari/services/oidc_authorization_endpoint.rb
@@ -144,6 +181,7 @@ files:
144
181
  - lib/himari/version.rb
145
182
  - public/public/index.css
146
183
  - sig/himari.rbs
184
+ - views/consent.erb
147
185
  - views/login.erb
148
186
  homepage: https://github.com/sorah/himari
149
187
  licenses:
@@ -151,7 +189,6 @@ licenses:
151
189
  metadata:
152
190
  homepage_uri: https://github.com/sorah/himari
153
191
  source_code_uri: https://github.com/sorah/himari
154
- post_install_message:
155
192
  rdoc_options: []
156
193
  require_paths:
157
194
  - lib
@@ -166,8 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
203
  - !ruby/object:Gem::Version
167
204
  version: '0'
168
205
  requirements: []
169
- rubygems_version: 3.4.6
170
- signing_key:
206
+ rubygems_version: 4.0.10
171
207
  specification_version: 4
172
208
  summary: Small OIDC IdP for small teams - Omniauth to OIDC
173
209
  test_files: []