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
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'addressable/uri'
6
+ require 'concurrent/map'
7
+ require 'concurrent/atomic/atomic_fixnum'
8
+ require 'httpx'
9
+
10
+ require 'himari/log_line'
11
+ require 'himari/item_provider'
12
+ require 'himari/client_registration'
13
+ require 'himari/dynamic_client_registration'
14
+
15
+ module Himari
16
+ module ItemProviders
17
+ # Resolves clients whose client_id is an https URL pointing to a JSON client metadata
18
+ # document, per draft-ietf-oauth-client-id-metadata-document. When the OIDC endpoints look
19
+ # a client up by id, this provider fetches and validates that document on demand and
20
+ # presents it as a public ClientRegistration.
21
+ #
22
+ # The HTTPX session is built once and retained for connection reuse; successful documents
23
+ # are cached in-memory (respecting HTTP cache headers within configured bounds). Errors and
24
+ # malformed documents are never cached, and the provider always fails closed (returns []),
25
+ # so a fetch problem surfaces as an unknown client rather than an exception.
26
+ class OauthClientMetadata
27
+ include Himari::ItemProvider
28
+
29
+ class FetchError < StandardError; end
30
+ class InvalidDocument < StandardError; end
31
+
32
+ CacheEntry = Struct.new(:value, :expires_at, :size, :seq)
33
+
34
+ # @param session [HTTPX::Session] persistent, SSRF-filtered session (built by the middleware)
35
+ # @param allowed_client_ids [Array<String, Regexp>] empty = allow any compliant https URL
36
+ def initialize(session:, allowed_client_ids: [], require_pkce: true, ignore_localhost_redirect_uri_port: true,
37
+ skip_consent: false, scopes: Himari::ClientRegistration::IMPLICIT_SCOPES,
38
+ max_response_size: 5120,
39
+ cache_min_ttl: 60, cache_max_ttl: 86400, cache_default_ttl: 300, cache_max_total_size: 1_048_576, logger: nil)
40
+ @session = session
41
+ @allowed_client_ids = allowed_client_ids
42
+ @require_pkce = require_pkce
43
+ @ignore_localhost_redirect_uri_port = ignore_localhost_redirect_uri_port
44
+ @skip_consent = skip_consent
45
+ @scopes = scopes
46
+ @max_response_size = max_response_size
47
+ @cache_min_ttl = cache_min_ttl
48
+ @cache_max_ttl = cache_max_ttl
49
+ @cache_default_ttl = cache_default_ttl
50
+ @cache_max_total_size = cache_max_total_size
51
+ @logger = logger
52
+ @cache = Concurrent::Map.new
53
+ @cache_total_size = Concurrent::AtomicFixnum.new(0)
54
+ @cache_seq = Concurrent::AtomicFixnum.new(0)
55
+ end
56
+
57
+ def collect(id: nil, **_hint)
58
+ return [] unless id.is_a?(String)
59
+ return [] unless compliant_client_id_url?(id)
60
+ return [] unless allowed?(id)
61
+
62
+ cached = cache_get(id)
63
+ return [cached] if cached
64
+
65
+ registration, ttl, size = fetch_and_build(id)
66
+ cache_put(id, registration, ttl, size) if ttl.positive?
67
+ [registration]
68
+ rescue HTTPX::Error, FetchError, InvalidDocument, JSON::ParserError, Himari::DynamicClientRegistration::ValidationError => e
69
+ @logger&.warn(Himari::LogLine.new('OauthClientMetadata: client_id rejected', client_id: id, error: e.message))
70
+ []
71
+ end
72
+
73
+ private def compliant_client_id_url?(id)
74
+ uri = begin
75
+ Addressable::URI.parse(id)
76
+ rescue Addressable::URI::InvalidURIError
77
+ nil
78
+ end
79
+ return false unless uri
80
+ return false unless uri.scheme == 'https'
81
+ return false if uri.fragment
82
+ return false if uri.user || uri.password
83
+
84
+ path = uri.path
85
+ return false if path.nil? || path.empty?
86
+ return false if path.split('/').any? { |seg| seg == '.' || seg == '..' }
87
+
88
+ true
89
+ end
90
+
91
+ private def allowed?(id)
92
+ return true if @allowed_client_ids.empty?
93
+
94
+ @allowed_client_ids.any? do |matcher|
95
+ matcher.is_a?(Regexp) ? matcher.match?(id) : matcher.to_s == id
96
+ end
97
+ end
98
+
99
+ private def fetch_and_build(url)
100
+ # stream: true must be passed to the request (not preset on the session via .with, which
101
+ # would yield an already-buffered Response with no #each). It returns a StreamResponse:
102
+ # status and headers are inspected first, then the body is consumed under a hard byte cap
103
+ # below, without buffering the whole body up front.
104
+ resp = @session.get(url, stream: true)
105
+ # Surfaces transport/SSRF failures (HTTPX::Error) and HTTP >= 400. The draft also forbids
106
+ # following redirects, so anything other than 200 (including 3xx) is an error too.
107
+ resp.raise_for_status
108
+ raise FetchError, "unexpected status: #{resp.status}" unless resp.status == 200
109
+
110
+ content_length = resp.headers['content-length']
111
+ raise FetchError, 'response exceeds maximum size' if content_length && content_length.to_i > @max_response_size
112
+ raise FetchError, 'unexpected content-type' unless json_content_type?(resp.headers['content-type'])
113
+
114
+ body = read_capped_body(resp)
115
+
116
+ doc = JSON.parse(body, symbolize_names: true)
117
+ registration = build_registration(doc, url)
118
+ # The whole document is safe to log: it is size-capped and client_secret* is rejected.
119
+ @logger&.info(Himari::LogLine.new('OauthClientMetadata: fetched', client_id: url, metadata: doc))
120
+ [registration, compute_ttl(resp), body.bytesize]
121
+ end
122
+
123
+ # Stream the body and abort as soon as it exceeds the cap. The client_id URL is
124
+ # attacker-influenced and HTTPX has no hard body limit, so a malicious host could omit
125
+ # Content-Length and stream an unbounded response; capping during the read (rather than
126
+ # after buffering the whole body) prevents that memory/disk exhaustion.
127
+ #
128
+ # FIXME: this streaming read is a workaround for the lack of a built-in maximum body size in
129
+ # HTTPX. Replace it with a native body cap once available:
130
+ # https://gitlab.com/os85/httpx/-/work_items/383
131
+ private def read_capped_body(resp)
132
+ body = +''
133
+ resp.each do |chunk|
134
+ body << chunk
135
+ raise FetchError, 'response exceeds maximum size' if body.bytesize > @max_response_size
136
+ end
137
+ body
138
+ end
139
+
140
+ private def build_registration(doc, url)
141
+ raise InvalidDocument, 'document must be a JSON object' unless doc.is_a?(Hash)
142
+ raise InvalidDocument, 'client_id does not match document URL' unless doc[:client_id] == url
143
+ raise InvalidDocument, 'client_secret must not be present' if doc.key?(:client_secret) || doc.key?(:client_secret_expires_at)
144
+
145
+ auth_method = doc[:token_endpoint_auth_method]
146
+ raise InvalidDocument, "token_endpoint_auth_method must be 'none'" if auth_method && auth_method.to_s != 'none'
147
+
148
+ redirect_uris = Himari::DynamicClientRegistration.validate_redirect_uris(doc[:redirect_uris])
149
+
150
+ Himari::ClientRegistration.new(
151
+ id: url,
152
+ redirect_uris: redirect_uris,
153
+ confidential: false,
154
+ require_pkce: @require_pkce,
155
+ ignore_localhost_redirect_uri_port: @ignore_localhost_redirect_uri_port,
156
+ skip_consent: @skip_consent,
157
+ scopes: @scopes,
158
+ )
159
+ end
160
+
161
+ private def json_content_type?(value)
162
+ type = value.to_s.split(';', 2).first.to_s.strip.downcase
163
+ type == 'application/json' || type.end_with?('+json')
164
+ end
165
+
166
+ # Honour Cache-Control/Expires within configured bounds. no-store/no-cache disables caching.
167
+ private def compute_ttl(resp)
168
+ cache_control = resp.headers['cache-control'].to_s.downcase
169
+ return 0 if cache_control.include?('no-store') || cache_control.include?('no-cache')
170
+
171
+ ttl = if (m = cache_control.match(/max-age\s*=\s*(\d+)/))
172
+ m[1].to_i
173
+ elsif (expires = resp.headers['expires'])
174
+ parsed = begin
175
+ Time.httpdate(expires)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+ parsed && (parsed - Time.now).to_i
180
+ end
181
+
182
+ (ttl || @cache_default_ttl).clamp(@cache_min_ttl, @cache_max_ttl)
183
+ end
184
+
185
+ private def cache_get(id)
186
+ entry = @cache[id]
187
+ return unless entry
188
+ return entry.value if entry.expires_at > Time.now.to_f
189
+
190
+ forget(id, entry)
191
+ nil
192
+ end
193
+
194
+ private def cache_put(id, value, ttl, size)
195
+ entry = CacheEntry.new(value, Time.now.to_f + ttl, size, @cache_seq.increment)
196
+ previous = @cache[id]
197
+ @cache[id] = entry
198
+ delta = size - (previous&.size || 0)
199
+ @cache_total_size.update { |total| total + delta }
200
+ evict_until_within_limit
201
+ end
202
+
203
+ # Drop the oldest (lowest seq) entries until the tracked total body size fits the limit.
204
+ # Sizes are approximate (original JSON body bytes); concurrent eviction may overshoot a
205
+ # little, which is acceptable for a cache.
206
+ private def evict_until_within_limit
207
+ while @cache_total_size.value > @cache_max_total_size
208
+ oldest = @cache.each_pair.min_by { |_id, entry| entry.seq }
209
+ break unless oldest
210
+
211
+ forget(*oldest)
212
+ end
213
+ end
214
+
215
+ private def forget(id, entry)
216
+ return unless @cache.delete_pair(id, entry)
217
+
218
+ @cache_total_size.update { |total| total - entry.size }
219
+ end
220
+ end
221
+ end
222
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/item_provider'
2
4
 
3
5
  module Himari
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'himari/item_provider'
4
+ require 'himari/client_registration'
5
+
6
+ module Himari
7
+ module ItemProviders
8
+ # Looks up dynamically registered clients from storage and presents them to the OIDC
9
+ # endpoints as plain ClientRegistration objects. Lookups always carry an id hint; without
10
+ # one this returns nothing (there is no list operation). Expired registrations are filtered
11
+ # out here so backends without TTL (Memory, Filesystem) and DynamoDB's delayed TTL both
12
+ # fail closed.
13
+ class Storage
14
+ include Himari::ItemProvider
15
+
16
+ # @param storage [Himari::Storages::Base]
17
+ # @param skip_consent [Boolean] applied to every dynamic client this provider resolves
18
+ # @param scopes [Array<String>] recognised scopes applied to every dynamic client resolved
19
+ def initialize(storage:, skip_consent: false, scopes: Himari::ClientRegistration::IMPLICIT_SCOPES)
20
+ @storage = storage
21
+ @skip_consent = skip_consent
22
+ @scopes = scopes
23
+ end
24
+
25
+ def collect(id: nil, **_hint)
26
+ return [] unless id
27
+
28
+ client = @storage.find_dynamic_client(id)
29
+ client&.active? ? [client.to_client_registration(skip_consent: @skip_consent, scopes: @scopes)] : []
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json/jwt'
4
+
5
+ module Himari
6
+ # Shared minting process for the JWTs Himari signs for relying parties: the OIDC ID Token
7
+ # and the RFC 9068 access token. Holds the common claim derivation (registered claims merged
8
+ # over the IdP claims) and the signing step (kid, optional JOSE header fields, signature).
9
+ # Subclasses add their token-specific claims/header by overriding #final_claims / #jwt_header.
10
+ class JwtToken
11
+ def initialize(claims:, client_id:, signing_key:, issuer:, time: Time.now, lifetime: 3600)
12
+ @claims = claims
13
+ @client_id = client_id
14
+ @signing_key = signing_key
15
+ @issuer = issuer
16
+ @time = time
17
+ @lifetime = lifetime
18
+ end
19
+
20
+ attr_reader :claims, :client_id, :signing_key, :issuer
21
+
22
+ # Registered claims common to every Himari-minted JWT. The IdP claims (sub and the rest) are
23
+ # carried verbatim so the access token exposes the same claim set as the ID Token.
24
+ def standard_claims
25
+ claims.merge(
26
+ iss: @issuer,
27
+ aud: @client_id,
28
+ iat: @time.to_i,
29
+ nbf: @time.to_i,
30
+ exp: (@time + @lifetime).to_i,
31
+ )
32
+ end
33
+
34
+ def final_claims
35
+ standard_claims
36
+ end
37
+
38
+ # JOSE header fields beyond kid; subclasses override (e.g. typ=at+jwt for RFC 9068).
39
+ def jwt_header
40
+ {}
41
+ end
42
+
43
+ def to_jwt
44
+ jwt = JSON::JWT.new(final_claims)
45
+ jwt.kid = @signing_key.id
46
+ jwt_header.each { |k, v| jwt.header[k] = v }
47
+ jwt.sign(@signing_key.pkey, @signing_key.alg.to_sym).to_s
48
+ end
49
+ end
50
+ end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
- LifetimeValue = Struct.new(:access_token, :id_token, :code, keyword_init: true) do
4
+ LifetimeValue = Struct.new(:access_token, :id_token, :code, :refresh_token, keyword_init: true) do
3
5
  def self.from_integer(i)
4
- new(access_token: i, id_token: i, code: nil)
6
+ new(access_token: i, id_token: i, code: nil, refresh_token: nil)
5
7
  end
6
8
 
7
9
  def as_log
@@ -9,7 +11,7 @@ module Himari
9
11
  end
10
12
 
11
13
  def as_json
12
- {access_token: access_token, id_token: id_token, code: code}
14
+ {access_token: access_token, id_token: id_token, code: code, refresh_token: refresh_token}
13
15
  end
14
16
  end
15
17
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
 
3
5
  module Himari
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/rule'
2
4
  require 'himari/item_providers/static'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/rule'
2
4
  require 'himari/item_providers/static'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/rule'
2
4
  require 'himari/item_providers/static'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/client_registration'
2
4
  require 'himari/item_providers/static'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/config'
2
4
 
3
5
  module Himari
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'himari/dynamic_client_registration'
4
+ require 'himari/item_providers/storage'
5
+ require 'himari/middlewares/client'
6
+ require 'himari/middlewares/config'
7
+
8
+ module Himari
9
+ module Middlewares
10
+ # Enables RFC 7591 Dynamic Client Registration. Its presence in the Rack env
11
+ # (RACK_KEY) is what turns on the registration endpoint and its advertisement in the
12
+ # OIDC discovery document. It also appends a storage-backed provider to the client
13
+ # chain (Middlewares::Client::RACK_KEY) so registered clients resolve through the same
14
+ # client_provider.find(id:) lookup the OIDC endpoints already use.
15
+ #
16
+ # Must be placed after Middlewares::Config (it reads storage from the config).
17
+ class DynamicClients
18
+ RACK_KEY = 'himari.dynamic_clients'
19
+
20
+ Options = Data.define(:registration_lifetime, :ignore_localhost_redirect_uri_port, :skip_consent, :scopes, :grant_types_supported, :response_types_supported, :token_endpoint_auth_methods_supported)
21
+
22
+ # @param registration_lifetime [Integer] seconds a registration stays valid (default 180 days)
23
+ # @param ignore_localhost_redirect_uri_port [Boolean] relax the port of loopback redirect_uris
24
+ # for registered clients (default true; see RFC 8252 §7.3)
25
+ # @param skip_consent [Boolean] let registered clients bypass the consent page (default false)
26
+ # @param scopes [Array<String>] recognised scopes inherited by registered clients; scopes
27
+ # outside this list are dropped from authorization requests (default openid, offline_access)
28
+ def initialize(app, kwargs = {})
29
+ @app = app
30
+ @options = Options.new(
31
+ registration_lifetime: kwargs.fetch(:registration_lifetime) { Himari::DynamicClientRegistration::REGISTRATION_LIFETIME },
32
+ ignore_localhost_redirect_uri_port: kwargs.fetch(:ignore_localhost_redirect_uri_port, true),
33
+ skip_consent: kwargs.fetch(:skip_consent, false),
34
+ scopes: kwargs.fetch(:scopes, Himari::ClientRegistration::IMPLICIT_SCOPES),
35
+ grant_types_supported: Himari::DynamicClientRegistration::SUPPORTED_GRANT_TYPES,
36
+ response_types_supported: Himari::DynamicClientRegistration::SUPPORTED_RESPONSE_TYPES,
37
+ token_endpoint_auth_methods_supported: Himari::DynamicClientRegistration::SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS,
38
+ )
39
+ end
40
+
41
+ attr_reader :app
42
+
43
+ def call(env)
44
+ config = env[Himari::Middlewares::Config::RACK_KEY]
45
+ raise "Himari::Middlewares::DynamicClients must be placed after Himari::Middlewares::Config" unless config
46
+
47
+ env[RACK_KEY] = @options
48
+ env[Himari::Middlewares::Client::RACK_KEY] ||= []
49
+ env[Himari::Middlewares::Client::RACK_KEY] += [Himari::ItemProviders::Storage.new(storage: config.storage, skip_consent: @options.skip_consent, scopes: @options.scopes)]
50
+
51
+ @app.call(env)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httpx'
4
+
5
+ require 'himari/version'
6
+ require 'himari/item_providers/oauth_client_metadata'
7
+ require 'himari/middlewares/client'
8
+ require 'himari/middlewares/config'
9
+
10
+ module Himari
11
+ module Middlewares
12
+ # Enables OAuth Client ID Metadata Document support
13
+ # (draft-ietf-oauth-client-id-metadata-document). Its presence in the Rack env (RACK_KEY)
14
+ # advertises support in the discovery documents. It appends a single, long-lived
15
+ # OauthClientMetadata provider to the client chain (Middlewares::Client::RACK_KEY) so URL
16
+ # client_ids resolve through the same client_provider.find(id:) lookup the OIDC endpoints
17
+ # already use; the provider retains its HTTPX session and document cache across requests.
18
+ #
19
+ # Must be placed after Middlewares::Config.
20
+ #
21
+ # Options:
22
+ # - allowed_client_ids [Array<String, Regexp>] empty (default) accepts any compliant https
23
+ # URL; otherwise a client_id must match an entry (String exact, Regexp =~).
24
+ # - require_pkce [Boolean] force PKCE for metadata clients (default true; they are public).
25
+ # - ignore_localhost_redirect_uri_port [Boolean] relax the port of loopback redirect_uris when
26
+ # matching at the authorization endpoint (default true; see RFC 8252 §7.3).
27
+ # - skip_consent [Boolean] let metadata clients bypass the consent page (default false).
28
+ # - scopes [Array<String>] recognised scopes inherited by metadata clients; scopes outside
29
+ # this list are dropped from authorization requests (default openid, offline_access).
30
+ # - ssrf [true, false, Hash] SSRF filtering. true (default) restricts to https; a Hash is
31
+ # merged into the ssrf_filter plugin options (e.g. allowed_schemes); false disables it
32
+ # (only for an authorization server running on a loopback address).
33
+ # - user_agent [String] User-Agent header for fetches.
34
+ # - http_timeout [Hash] HTTPX timeout options.
35
+ # - max_response_size [Integer] reject documents larger than this many bytes (default 5 KiB).
36
+ # - cache_min_ttl / cache_max_ttl / cache_default_ttl [Integer] cache bounds in seconds.
37
+ # - cache_max_total_size [Integer] approximate cap on total cached document bytes; the oldest
38
+ # entries are evicted once exceeded (default 1 MiB).
39
+ class MetadataClients
40
+ RACK_KEY = 'himari.metadata_clients'
41
+
42
+ DEFAULT_USER_AGENT = "Himari-OauthClientMetadataFetch/#{Himari::VERSION} (+https://github.com/sorah/himari)"
43
+ # read_timeout is set explicitly: the stream plugin otherwise defaults it to Infinity, which
44
+ # would let a slow sender hold the fetch open indefinitely.
45
+ DEFAULT_HTTP_TIMEOUT = {connect_timeout: 5, request_timeout: 10, read_timeout: 10}.freeze
46
+
47
+ Options = Data.define(:allowed_client_ids, :require_pkce, :ignore_localhost_redirect_uri_port, :skip_consent, :scopes, :ssrf, :user_agent, :http_timeout, :max_response_size, :cache_min_ttl, :cache_max_ttl, :cache_default_ttl, :cache_max_total_size)
48
+
49
+ def initialize(app, kwargs = {})
50
+ @app = app
51
+ @options = Options.new(
52
+ allowed_client_ids: kwargs.fetch(:allowed_client_ids, []),
53
+ require_pkce: kwargs.fetch(:require_pkce, true),
54
+ ignore_localhost_redirect_uri_port: kwargs.fetch(:ignore_localhost_redirect_uri_port, true),
55
+ skip_consent: kwargs.fetch(:skip_consent, false),
56
+ scopes: kwargs.fetch(:scopes, Himari::ClientRegistration::IMPLICIT_SCOPES),
57
+ ssrf: kwargs.fetch(:ssrf, true),
58
+ user_agent: kwargs.fetch(:user_agent, DEFAULT_USER_AGENT),
59
+ http_timeout: kwargs.fetch(:http_timeout, DEFAULT_HTTP_TIMEOUT),
60
+ max_response_size: kwargs.fetch(:max_response_size, 5120),
61
+ cache_min_ttl: kwargs.fetch(:cache_min_ttl, 60),
62
+ cache_max_ttl: kwargs.fetch(:cache_max_ttl, 86400),
63
+ cache_default_ttl: kwargs.fetch(:cache_default_ttl, 300),
64
+ cache_max_total_size: kwargs.fetch(:cache_max_total_size, 1_048_576),
65
+ )
66
+ @provider = Himari::ItemProviders::OauthClientMetadata.new(
67
+ session: build_session(@options),
68
+ allowed_client_ids: @options.allowed_client_ids,
69
+ require_pkce: @options.require_pkce,
70
+ ignore_localhost_redirect_uri_port: @options.ignore_localhost_redirect_uri_port,
71
+ skip_consent: @options.skip_consent,
72
+ scopes: @options.scopes,
73
+ max_response_size: @options.max_response_size,
74
+ cache_min_ttl: @options.cache_min_ttl,
75
+ cache_max_ttl: @options.cache_max_ttl,
76
+ cache_default_ttl: @options.cache_default_ttl,
77
+ cache_max_total_size: @options.cache_max_total_size,
78
+ logger: kwargs[:logger],
79
+ )
80
+ end
81
+
82
+ attr_reader :app, :options
83
+
84
+ def call(env)
85
+ config = env[Himari::Middlewares::Config::RACK_KEY]
86
+ raise "Himari::Middlewares::MetadataClients must be placed after Himari::Middlewares::Config" unless config
87
+
88
+ env[RACK_KEY] = @options
89
+ env[Himari::Middlewares::Client::RACK_KEY] ||= []
90
+ env[Himari::Middlewares::Client::RACK_KEY] += [@provider]
91
+
92
+ @app.call(env)
93
+ end
94
+
95
+ # Build a persistent, SSRF-filtered HTTPX session with the stream plugin loaded, so the
96
+ # provider can request a streaming response (get(url, stream: true)) and cap the body during
97
+ # the read instead of buffering it whole. stream: true is passed per-request, not set here
98
+ # via .with: a session-level stream option yields an already-buffered Response with no #each.
99
+ # Notably it does not enable the follow_redirects plugin: the draft requires redirects not be
100
+ # followed.
101
+ private def build_session(options)
102
+ session = HTTPX.plugin(:persistent).plugin(:stream)
103
+ session = case options.ssrf
104
+ when true
105
+ session.plugin(:ssrf_filter, allowed_schemes: %w(https))
106
+ when Hash
107
+ ssrf_options = {allowed_schemes: %w(https)}.merge(options.ssrf)
108
+ session.plugin(:ssrf_filter, **ssrf_options)
109
+ when false
110
+ session
111
+ else
112
+ raise ArgumentError, "ssrf option must be true, false, or a Hash"
113
+ end
114
+ session.with(
115
+ headers: {'user-agent' => options.user_agent, 'accept' => 'application/json'},
116
+ timeout: options.http_timeout,
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/signing_key'
2
4
  require 'himari/item_providers/static'
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  class ProviderChain
3
5
  # @param providers [Array<ItemProvider>]
@@ -8,7 +10,7 @@ module Himari
8
10
  attr_reader :providers
9
11
 
10
12
  def find(**hint, &block)
11
- block ||= proc { |i,h| i.match_hint?(**h) } # ItemProvider#collect doesn't guarantee exact matches, so do exact match by match_hint?
13
+ block ||= proc { |i, h| i.match_hint?(**h) } # ItemProvider#collect doesn't guarantee exact matches, so do exact match by match_hint?
12
14
  @providers.each do |provider|
13
15
  provider.collect(**hint).each do |item|
14
16
  return item if block.call(item, hint)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'himari/token_string'
5
+
6
+ module Himari
7
+ class RefreshToken
8
+ include TokenString
9
+
10
+ def self.magic_header
11
+ 'hmrt'
12
+ end
13
+
14
+ def self.default_lifetime
15
+ raise ArgumentError, "RefreshToken requires an explicit lifetime:"
16
+ end
17
+
18
+ def initialize(handle:, client_id:, claims:, session_handle:, expiry:, openid: false, scopes: [], secret: nil, secret_hash: nil, secret_hash_prev: nil, version: 1, updated_at: nil)
19
+ @handle = handle
20
+ @client_id = client_id
21
+ @claims = claims
22
+ @session_handle = session_handle
23
+ @openid = openid
24
+ @scopes = scopes
25
+ @expiry = expiry
26
+
27
+ @secret = secret
28
+ @secret_hash = secret_hash
29
+ @secret_hash_prev = secret_hash_prev
30
+ @version = version
31
+ @updated_at = updated_at || Time.now.to_i
32
+ @verification = nil
33
+ end
34
+
35
+ attr_reader :handle, :client_id, :claims, :session_handle, :openid, :scopes, :expiry, :version, :updated_at
36
+
37
+ # Rotate the token in place (same handle): mint a new current secret while keeping the
38
+ # just-presented secret valid as the previous one, so a client whose rotation response is
39
+ # lost can retry with the secret it still holds. The secret to keep is the hash verify!
40
+ # matched (TokenString#verification) — whichever slot the client used; rotate is therefore
41
+ # only valid after a successful verify!. version is bumped so a concurrent refresh against
42
+ # the version we read fails the conditional update. expiry is preserved: the initial
43
+ # lifetime is an absolute cap on the rotation chain, not slid forward on each refresh.
44
+ def rotate(claims:, openid:, now: Time.now)
45
+ raise TokenString::SecretMissing, "rotate requires a verified secret; call verify! first" unless verification
46
+
47
+ self.class.new(
48
+ handle:,
49
+ client_id:,
50
+ session_handle:,
51
+ claims:,
52
+ openid:,
53
+ scopes:,
54
+ secret: SecureRandom.urlsafe_base64(48),
55
+ secret_hash_prev: verification.secret_hash,
56
+ version: version + 1,
57
+ updated_at: now.to_i,
58
+ expiry:,
59
+ )
60
+ end
61
+
62
+ def as_log
63
+ {
64
+ handle: handle,
65
+ client_id: client_id,
66
+ claims: claims,
67
+ session_handle: session_handle,
68
+ openid: openid,
69
+ scopes: scopes,
70
+ expiry: expiry,
71
+ version: version,
72
+ updated_at: updated_at,
73
+ prev_secret_set: !secret_hash_prev.nil?,
74
+ }
75
+ end
76
+
77
+ def as_json
78
+ {
79
+ handle: handle,
80
+ secret_hash: secret_hash,
81
+ secret_hash_prev: secret_hash_prev,
82
+ client_id: client_id,
83
+ claims: claims,
84
+ session_handle: session_handle,
85
+ openid: openid,
86
+ scopes: scopes,
87
+ expiry: expiry.to_i,
88
+ version: version,
89
+ updated_at: updated_at.to_i,
90
+ }
91
+ end
92
+ end
93
+ end
data/lib/himari/rule.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  Rule = Struct.new(:name, :block, keyword_init: true) do
3
5
  def call(context, decision)