himari 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/lib/himari/access_token.rb +72 -4
- data/lib/himari/access_token_jwt.rb +46 -0
- data/lib/himari/app.rb +101 -28
- data/lib/himari/authorization_code.rb +18 -4
- data/lib/himari/client_registration.rb +70 -4
- data/lib/himari/config.rb +8 -3
- data/lib/himari/decisions/authentication.rb +18 -2
- data/lib/himari/decisions/authorization.rb +18 -7
- data/lib/himari/decisions/base.rb +7 -3
- data/lib/himari/decisions/claims.rb +14 -9
- data/lib/himari/dynamic_client_registration.rb +255 -0
- data/lib/himari/id_token.rb +15 -28
- data/lib/himari/item_provider.rb +3 -1
- data/lib/himari/item_providers/oauth_client_metadata.rb +222 -0
- data/lib/himari/item_providers/static.rb +2 -0
- data/lib/himari/item_providers/storage.rb +33 -0
- data/lib/himari/jwt_token.rb +50 -0
- data/lib/himari/lifetime_value.rb +5 -3
- data/lib/himari/log_line.rb +2 -0
- data/lib/himari/middlewares/authentication_rule.rb +2 -0
- data/lib/himari/middlewares/authorization_rule.rb +2 -0
- data/lib/himari/middlewares/claims_rule.rb +2 -0
- data/lib/himari/middlewares/client.rb +2 -0
- data/lib/himari/middlewares/config.rb +2 -0
- data/lib/himari/middlewares/dynamic_clients.rb +55 -0
- data/lib/himari/middlewares/metadata_clients.rb +121 -0
- data/lib/himari/middlewares/signing_key.rb +2 -0
- data/lib/himari/provider_chain.rb +3 -1
- data/lib/himari/refresh_token.rb +93 -0
- data/lib/himari/rule.rb +2 -0
- data/lib/himari/rule_processor.rb +3 -0
- data/lib/himari/services/client_registration_endpoint.rb +78 -0
- data/lib/himari/services/downstream_authorization.rb +22 -7
- data/lib/himari/services/jwks_endpoint.rb +3 -1
- data/lib/himari/services/oidc_authorization_endpoint.rb +54 -3
- data/lib/himari/services/oidc_provider_metadata_endpoint.rb +30 -7
- data/lib/himari/services/oidc_token_endpoint.rb +225 -46
- data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
- data/lib/himari/services/upstream_authentication.rb +62 -14
- data/lib/himari/session_data.rb +31 -2
- data/lib/himari/signing_key.rb +17 -14
- data/lib/himari/storages/base.rb +45 -1
- data/lib/himari/storages/filesystem.rb +14 -3
- data/lib/himari/storages/memory.rb +10 -2
- data/lib/himari/token_string.rb +40 -4
- data/lib/himari/version.rb +1 -1
- data/public/public/index.css +18 -0
- data/views/consent.erb +59 -0
- 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
|
|
@@ -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
|
data/lib/himari/log_line.rb
CHANGED
|
@@ -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
|
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
|