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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -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 +101 -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/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 +54 -3
  38. data/lib/himari/services/oidc_provider_metadata_endpoint.rb +30 -7
  39. data/lib/himari/services/oidc_token_endpoint.rb +225 -46
  40. data/lib/himari/services/oidc_userinfo_endpoint.rb +13 -7
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b8557a7f7d75db91d61c20e931bfa9d05d832541b4fb9fdc05055491568e4ec
4
- data.tar.gz: c50b5396fa19b2c03b2b4115d495f3f96a688016a4c8d3fb9b1833ef10de13b7
3
+ metadata.gz: dcb5e4f61b85b69f1ec9b36205664930ab488c46d4bc1f0800a3481926092d2b
4
+ data.tar.gz: eacc822fbf0ccb630a42dae0e7c130122980da8875480132047d23378e88b83a
5
5
  SHA512:
6
- metadata.gz: eac9157e67de6f6c2538b36f7d6a55e62f1d3a7e3837e72a23d0b6d407a40a2004f7a7fee8c71b036c709daf6be970a75ae45118c116bbd4ab1a085eb23af137
7
- data.tar.gz: b40f7ed7a033f312871a191398ba1d74f376c89d634666b4b076a05666a66309d8886d0bd3d441144d1fea781847e6f020fa9ec39a079ac1ef3f48f9f332efba
6
+ metadata.gz: 125693e2f92b453fffd5bf5d1f0b34bce0e5f29e2c86ac8e6b068ce3d223d4ee4abda646c99165653a01bc2304d496ac7a26779553659afca7501acef247fc05
7
+ data.tar.gz: 1227491aa891bbaa40c3db1c144bf2ceb429941206cf859f799f409623854c4a099613883579739b1c6ff6f7ee1fef47038063b1f8dc3f313e477a10f8a71ba9
data/CHANGELOG.md ADDED
@@ -0,0 +1,58 @@
1
+ ## [0.6.0] - 2026-06-03
2
+
3
+ ### Enhancements
4
+
5
+ - Refresh token (`refresh_token` grant) support [#14](https://github.com/sorah/himari/pull/14)
6
+ - Dynamic client registration (RFC 7591), Client ID Metadata Documents, an RFC 8414 `oauth-authorization-server` metadata endpoint, and Himari-owned `redirect_uri` matching with loopback-port relaxation and `Regexp` entries [#15](https://github.com/sorah/himari/pull/15)
7
+ - Interactive consent page gated by the client `skip_consent` attribute [#16](https://github.com/sorah/himari/pull/16)
8
+ - Per-client `scopes` allow-list [#17](https://github.com/sorah/himari/pull/17)
9
+ - Persist granted scopes on the grant and expose them to authorization rules as `context.scopes` [#19](https://github.com/sorah/himari/pull/19)
10
+ - Opt-in RFC 9068 (`at+jwt`) JWT access tokens [#20](https://github.com/sorah/himari/pull/20)
11
+ - Configurable `scopes_supported`/`claims_supported` and advertise `refresh_token`/`offline_access` in discovery metadata [#21](https://github.com/sorah/himari/pull/21)
12
+
13
+ ## [0.5.0] - 2024-05-11
14
+
15
+ ### Enhancements
16
+
17
+ - Userinfo endpoint now returns the `aud` claim.
18
+ - Client gains the `require_pkce` attribute.
19
+
20
+ ## [0.4.0] - 2023-03-26
21
+
22
+ ### Enhancements
23
+
24
+ - Support `prompt=login` for reauthentication; the userinfo endpoint now also answers POST [#8](https://github.com/sorah/himari/pull/8)
25
+ - Store `SessionData` in a storage backend [#7](https://github.com/sorah/himari/pull/7)
26
+ - Introduce the `omniauth-himari` strategy gem [#6](https://github.com/sorah/himari/pull/6)
27
+ - Access token gains its own lifetime.
28
+
29
+ ### Changes
30
+
31
+ - Rename `AccessToken#handler` to `handle` and stop treating the token handle as a sensitive value [#5](https://github.com/sorah/himari/pull/5)
32
+ - Disable `Rack::Protection::JsonCsrf` for ALB OIDC compatibility [#1](https://github.com/sorah/himari/pull/1)
33
+
34
+ ### Bug fixes
35
+
36
+ - Fix error when logging an expired session token.
37
+
38
+ ## [0.3.0] - 2023-03-22
39
+
40
+ ### Enhancements
41
+
42
+ - Customizable session and token lifetimes.
43
+ - `suggest=reauthenticate` to prompt login, with a decision `user_facing_message`.
44
+
45
+ ### Bug fixes
46
+
47
+ - Callback returns 400 when the auth hash is missing.
48
+
49
+ ## [0.2.0] - 2023-03-22
50
+
51
+ ### Enhancements
52
+
53
+ - Better login page template with cachebuster.
54
+ - Prebuilt container image.
55
+
56
+ ## [0.1.0] - 2023-02-25
57
+
58
+ - Initial release
@@ -1,7 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/oauth2'
2
4
  require 'openid_connect'
5
+ require 'json/jwt'
3
6
 
4
7
  require 'himari/token_string'
8
+ require 'himari/access_token_jwt'
5
9
 
6
10
  module Himari
7
11
  class AccessToken
@@ -23,26 +27,67 @@ module Himari
23
27
  3600
24
28
  end
25
29
 
30
+ # Parse a presented access token into its opaque Format (handle + secret) for verification
31
+ # against storage. Two on-the-wire shapes are accepted:
32
+ #
33
+ # - the opaque token "hmat.<handle>.<secret>" (TokenString format), or
34
+ # - an RFC 9068 JWT (Himari::AccessTokenJwt) carrying the opaque token in its +hmat+ claim.
35
+ #
36
+ # For a JWT, the signature is verified first (requires signing_key_provider to resolve the
37
+ # kid), then the embedded opaque token is returned so the caller validates the secret against
38
+ # storage exactly as for an opaque token. Any malformed/unverifiable JWT becomes
39
+ # TokenString::InvalidFormat so callers handle one failure type.
40
+ #
41
+ # @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>, nil]
42
+ def self.parse(str, signing_key_provider: nil)
43
+ return TokenString::Format.parse(magic_header, str) if str.to_s.start_with?("#{magic_header}.")
44
+
45
+ parse_jwt(str, signing_key_provider)
46
+ end
47
+
48
+ def self.parse_jwt(str, signing_key_provider)
49
+ raise TokenString::InvalidFormat, 'signing keys are required to verify a JWT access token' unless signing_key_provider
50
+
51
+ jwt = JSON::JWT.decode(str, :skip_verification)
52
+ key = jwt.kid && signing_key_provider.find(id: jwt.kid)
53
+ raise TokenString::InvalidFormat, 'unknown or missing signing key (kid)' unless key
54
+
55
+ jwt.verify!(key.pkey)
56
+
57
+ hmat = jwt[magic_header]
58
+ raise TokenString::InvalidFormat, 'missing hmat claim' unless hmat.is_a?(String) && hmat.start_with?("#{magic_header}.")
59
+
60
+ TokenString::Format.parse(magic_header, hmat)
61
+ rescue JSON::JWT::Exception, JSON::ParserError => e
62
+ raise TokenString::InvalidFormat, "invalid JWT access token: #{e.class}"
63
+ end
64
+
26
65
  # @param authz [Himari::AuthorizationCode]
27
66
  def self.from_authz(authz)
28
67
  make(
29
68
  client_id: authz.client_id,
30
69
  claims: authz.claims,
70
+ scopes: authz.scopes,
71
+ session_handle: authz.session_handle,
31
72
  lifetime: authz.lifetime.access_token,
32
73
  )
33
74
  end
34
75
 
35
- def initialize(handle:, client_id:, claims:, expiry:, secret: nil, secret_hash: nil)
76
+ def initialize(handle:, client_id:, claims:, expiry:, scopes: [], session_handle: nil, secret: nil, secret_hash: nil)
36
77
  @handle = handle
37
78
  @client_id = client_id
38
79
  @claims = claims
80
+ @scopes = scopes
81
+ @session_handle = session_handle
39
82
  @expiry = expiry
40
83
 
41
84
  @secret = secret
42
85
  @secret_hash = secret_hash
86
+ @secret_hash_prev = nil
87
+ @verification = nil
43
88
  end
44
89
 
45
- attr_reader :handle, :client_id, :claims, :expiry
90
+ attr_reader :handle, :client_id, :claims, :scopes, :session_handle, :expiry
46
91
 
47
92
  def userinfo
48
93
  claims.merge(
@@ -50,18 +95,39 @@ module Himari
50
95
  )
51
96
  end
52
97
 
53
- def to_bearer
98
+ # @param token_string [String] the on-the-wire access token to deliver. Defaults to the
99
+ # opaque format; the token endpoint passes the RFC 9068 JWT when one was minted.
100
+ def to_bearer(token_string: format.to_s)
54
101
  Bearer.new(
55
- access_token: format.to_s,
102
+ access_token: token_string,
56
103
  expires_in: (expiry - Time.now.to_i).to_i,
57
104
  )
58
105
  end
59
106
 
107
+ # Render this token as an RFC 9068 JWT (Himari::AccessTokenJwt). The opaque secret travels in
108
+ # the JWT's hmat claim, so the token validates against storage the same way either form does.
109
+ # exp is tied to this token's own expiry rather than recomputed, keeping both forms in sync.
110
+ # @param signing_key [Himari::SigningKey]
111
+ # @param issuer [String]
112
+ def to_jwt(signing_key:, issuer:, now: Time.now)
113
+ AccessTokenJwt.new(
114
+ access: self,
115
+ claims: claims,
116
+ client_id: client_id,
117
+ signing_key: signing_key,
118
+ issuer: issuer,
119
+ time: now,
120
+ lifetime: expiry - now.to_i,
121
+ ).to_jwt
122
+ end
123
+
60
124
  def as_log
61
125
  {
62
126
  handle: handle,
63
127
  client_id: client_id,
64
128
  claims: claims,
129
+ scopes: scopes,
130
+ session_handle: session_handle,
65
131
  expiry: expiry,
66
132
  }
67
133
  end
@@ -72,6 +138,8 @@ module Himari
72
138
  secret_hash: secret_hash,
73
139
  client_id: client_id,
74
140
  claims: claims,
141
+ scopes: scopes,
142
+ session_handle: session_handle,
75
143
  expiry: expiry.to_i,
76
144
  }
77
145
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'himari/jwt_token'
4
+ require 'himari/access_token'
5
+
6
+ module Himari
7
+ # RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens) representation of an access token. The
8
+ # signed JWT carries the same IdP claims as the ID Token for relying parties to consume
9
+ # directly, plus the registered claims RFC 9068 requires. Himari still authenticates the token
10
+ # by the opaque secret embedded in the +hmat+ claim (see Himari::AccessToken.parse), so the
11
+ # JWT signature is an additional, self-contained guarantee for relying parties.
12
+ class AccessTokenJwt < JwtToken
13
+ # sub is the one RFC 9068 §2.2 required claim sourced from variable IdP claims rather than set
14
+ # by us; fail closed at mint time if a misconfigured allowed_claims stripped it.
15
+ class MissingSubject < StandardError; end
16
+
17
+ # @param access [Himari::AccessToken] the minted, persisted opaque access token this JWT wraps
18
+ def initialize(access:, **kwargs)
19
+ super(**kwargs)
20
+ @access = access
21
+ end
22
+
23
+ # https://www.rfc-editor.org/rfc/rfc9068.html#section-2.1
24
+ def jwt_header
25
+ {typ: 'at+jwt'}
26
+ end
27
+
28
+ # https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2
29
+ def final_claims
30
+ raise MissingSubject, 'RFC 9068 access token requires a sub claim' unless claims[:sub]
31
+
32
+ standard_claims.merge(
33
+ client_id: @client_id,
34
+ jti: @access.handle,
35
+ # The opaque access token Himari validates against storage; relying parties ignore it.
36
+ AccessToken.magic_header.to_sym => @access.format.to_s,
37
+ ).merge(scope_claim)
38
+ end
39
+
40
+ # https://www.rfc-editor.org/rfc/rfc9068.html#section-2.2.1 — space-delimited granted scopes.
41
+ private def scope_claim
42
+ scopes = @access.scopes
43
+ scopes && !scopes.empty? ? {scope: scopes.join(' ')} : {}
44
+ end
45
+ end
46
+ end
data/lib/himari/app.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sinatra/base'
2
4
  require 'addressable'
3
5
  require 'base64'
@@ -15,10 +17,13 @@ require 'himari/session_data'
15
17
  require 'himari/middlewares/client'
16
18
  require 'himari/middlewares/config'
17
19
  require 'himari/middlewares/signing_key'
20
+ require 'himari/middlewares/dynamic_clients'
21
+ require 'himari/middlewares/metadata_clients'
18
22
 
19
23
  require 'himari/services/downstream_authorization'
20
24
  require 'himari/services/upstream_authentication'
21
25
 
26
+ require 'himari/services/client_registration_endpoint'
22
27
  require 'himari/services/jwks_endpoint'
23
28
  require 'himari/services/oidc_authorization_endpoint'
24
29
  require 'himari/services/oidc_provider_metadata_endpoint'
@@ -32,6 +37,7 @@ module Himari
32
37
  # remote_token: disabled in favor of authenticity_token (more stricter)
33
38
  # json_csrf: can be prevented using x-content-type-options:nosniff
34
39
  set :protection, use: %i(authenticity_token), except: %i(remote_token json_csrf)
40
+ set :host_authorization, {}
35
41
 
36
42
  set :logging, nil
37
43
 
@@ -42,13 +48,15 @@ module Himari
42
48
  helpers do
43
49
  def current_user
44
50
  return @current_user if defined? @current_user
51
+
45
52
  given_token = session[:himari_session]
46
- return nil unless given_token
53
+ return unless given_token
47
54
 
48
55
  given_parsed_token = Himari::SessionData.parse(given_token)
49
56
 
50
57
  token = config.storage.find_session(given_parsed_token.handle)
51
58
  raise InvalidSessionToken, "no session found in storage (possibly expired)" unless token
59
+
52
60
  token.verify!(secret: given_parsed_token.secret)
53
61
 
54
62
  @current_user = token
@@ -70,12 +78,28 @@ module Himari
70
78
  Himari::ProviderChain.new(request.env[Himari::Middlewares::Client::RACK_KEY] || [])
71
79
  end
72
80
 
81
+ def dynamic_clients_enabled?
82
+ request.env.key?(Himari::Middlewares::DynamicClients::RACK_KEY)
83
+ end
84
+
85
+ def metadata_clients_enabled?
86
+ request.env.key?(Himari::Middlewares::MetadataClients::RACK_KEY)
87
+ end
88
+
73
89
  def known_providers
74
90
  back_to = if request.query_string.empty?
75
91
  request.path
76
92
  else
77
93
  Addressable::URI.parse(request.fullpath).tap do |u|
78
- u.query_values = u.query_values.reject { |k,_v| k == 'prompt' }
94
+ # Drop only the prompt values that would re-trigger the login screen once the user
95
+ # comes back authenticated (otherwise we'd loop); keep the rest (e.g. consent) so they
96
+ # still apply at the authorize endpoint after login.
97
+ u.query_values = u.query_values.filter_map do |k, v|
98
+ next [k, v] unless k == 'prompt'
99
+
100
+ remaining = v.to_s.split(/\s+/) - %w(login select_account)
101
+ [k, remaining.join(' ')] unless remaining.empty?
102
+ end.to_h
79
103
  end.to_s
80
104
  end
81
105
  query = Addressable::URI.form_encode(back_to: back_to)
@@ -103,12 +127,10 @@ module Himari
103
127
  end
104
128
 
105
129
  def release_code
106
- env['himari.release'] ||= begin
107
- [
108
- Himari::VERSION,
109
- config.release_fragment,
110
- ].compact.join(':')
111
- end
130
+ env['himari.release'] ||= [
131
+ Himari::VERSION,
132
+ config.release_fragment,
133
+ ].compact.join(':')
112
134
  end
113
135
 
114
136
  def request_id
@@ -134,7 +156,7 @@ module Himari
134
156
  end
135
157
 
136
158
  before do
137
- request_as_log()
159
+ request_as_log
138
160
  end
139
161
 
140
162
  get '/' do
@@ -142,16 +164,22 @@ module Himari
142
164
  "Himari #{release_code}\n"
143
165
  end
144
166
 
145
- get '/oidc/authorize' do
167
+ # Served on GET (initial request and consent display) and POST (consent submission). The
168
+ # consent form posts the original authorization params back here as hidden fields plus a
169
+ # _consent decision; everything else flows through OidcAuthorizationEndpoint identically.
170
+ authorize_ep = proc do
146
171
  client = client_provider.find(id: params[:client_id])
147
172
  unless client
148
173
  logger&.warn(Himari::LogLine.new('authorize: no client registration found', req: request_as_log, client_id: params[:client_id]))
149
- next halt 401, 'unknown client'
174
+ next halt 401, 'unknown client'
150
175
  end
151
176
 
152
177
  if current_user
153
- # do downstream authz and process oidc request
154
- decision = Himari::Services::DownstreamAuthorization.from_request(session: current_user, client: client, request: request).perform
178
+ # do downstream authz and process oidc request. The OIDC request's scope is parsed the
179
+ # same way rack-oauth2 parses it downstream (OidcAuthorizationEndpoint), so the scopes the
180
+ # authz rules see match those the grant is filtered to.
181
+ requested_scopes = request.params['scope'].to_s.split(' ')
182
+ decision = Himari::Services::DownstreamAuthorization.from_request(session: current_user, client: client, request: request, requested_scopes: requested_scopes).perform
155
183
  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))
156
184
  raise unless decision.authz_result.allowed # sanity check
157
185
 
@@ -159,23 +187,34 @@ module Himari
159
187
  client_id: decision.client.id,
160
188
  claims: decision.claims,
161
189
  lifetime: decision.lifetime,
190
+ mint_jwt_access_token: decision.mint_jwt_access_token,
191
+ session_handle: current_user.handle,
162
192
  )
163
193
 
194
+ consent = case params[:_consent]
195
+ when 'approve' then :approve
196
+ when 'deny' then :deny
197
+ end
198
+
164
199
  Himari::Services::OidcAuthorizationEndpoint.new(
165
200
  authz: authz,
166
201
  client: client,
167
202
  storage: config.storage,
203
+ consent: consent,
168
204
  logger: logger,
169
205
  ).call(env)
170
206
  else
171
207
  logger&.info(Himari::LogLine.new('authorize: prompt login', req: request_as_log, client_id: params[:client_id]))
172
208
  erb(config.custom_templates[:login] || :login)
173
209
  end
174
-
175
210
  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))
211
+ 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
212
  next erb(config.custom_templates[:login] || :login)
178
-
213
+ rescue Himari::Services::OidcAuthorizationEndpoint::ConsentRequired => e
214
+ logger&.info(Himari::LogLine.new('authorize: prompt consent', req: request_as_log, session: current_user&.as_log, client: e.client.as_log, scopes: e.scopes))
215
+ @consent_client = e.client
216
+ @consent_scopes = e.scopes
217
+ next erb(config.custom_templates[:consent] || :consent)
179
218
  rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
180
219
  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
220
 
@@ -193,6 +232,8 @@ module Himari
193
232
 
194
233
  halt(403, "Forbidden#{message_human ? "; #{message_human}" : nil}")
195
234
  end
235
+ get '/oidc/authorize', &authorize_ep
236
+ post '/oidc/authorize', &authorize_ep
196
237
 
197
238
  token_ep = proc do
198
239
  Himari::Services::OidcTokenEndpoint.new(
@@ -209,6 +250,7 @@ module Himari
209
250
  userinfo_ep = proc do
210
251
  Himari::Services::OidcUserinfoEndpoint.new(
211
252
  storage: config.storage,
253
+ signing_key_provider: signing_key_provider,
212
254
  logger: logger,
213
255
  ).call(env)
214
256
  end
@@ -225,12 +267,43 @@ module Himari
225
267
  get '/jwks', &jwks_ep
226
268
  get '/public/jwks', &jwks_ep
227
269
 
228
- get '/.well-known/openid-configuration' do
270
+ # RFC 7591 Dynamic Client Registration. Enabled by presence of the DynamicClients
271
+ # middleware; the route always exists but 404s when the feature is off.
272
+ register_ep = proc do
273
+ next halt 404, 'not found' unless dynamic_clients_enabled?
274
+
275
+ Himari::Services::ClientRegistrationEndpoint.new(
276
+ storage: config.storage,
277
+ registration_lifetime: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].registration_lifetime,
278
+ ignore_localhost_redirect_uri_port: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].ignore_localhost_redirect_uri_port,
279
+ logger: logger,
280
+ ).call(env)
281
+ end
282
+ post '/oidc/register', &register_ep
283
+ post '/public/oidc/register', &register_ep
284
+ # Wire the non-POST verbs too so they reach the endpoint, which answers 405 (RFC 7591 only
285
+ # defines POST). Without these Sinatra would 404 a GET, masking the method error.
286
+ %w(/oidc/register /public/oidc/register).each do |path|
287
+ get(path, &register_ep)
288
+ put(path, &register_ep)
289
+ patch(path, &register_ep)
290
+ delete(path, &register_ep)
291
+ end
292
+
293
+ metadata_ep = proc do
229
294
  Himari::Services::OidcProviderMetadataEndpoint.new(
230
295
  signing_key_provider: signing_key_provider,
231
296
  issuer: config.issuer,
297
+ registration_endpoint: dynamic_clients_enabled? ? "#{config.issuer}/public/oidc/register" : nil,
298
+ client_id_metadata_document_supported: metadata_clients_enabled?,
299
+ scopes_supported: config.scopes_supported,
300
+ claims_supported: config.claims_supported,
232
301
  ).call(env)
233
302
  end
303
+ # OpenID Connect Discovery 1.0
304
+ get '/.well-known/openid-configuration', &metadata_ep
305
+ # RFC 8414 OAuth 2.0 Authorization Server Metadata
306
+ get '/.well-known/oauth-authorization-server', &metadata_ep
234
307
 
235
308
  omniauth_callback = proc do
236
309
  authhash = request.env['omniauth.auth']
@@ -243,17 +316,17 @@ module Himari
243
316
 
244
317
  given_back_to = request.env['omniauth.params']&.fetch('back_to', nil)
245
318
  back_to = if given_back_to
246
- uri = begin
247
- Addressable::URI.parse(given_back_to)
248
- rescue Addressable::URI::InvalidURIError
249
- nil
250
- end
251
- if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
252
- given_back_to
253
- else
254
- logger&.warn(Himari::LogLine.new('invalid back_to', req: request_as_log, given_back_to: given_back_to))
255
- nil
256
- end
319
+ uri = begin
320
+ Addressable::URI.parse(given_back_to)
321
+ rescue Addressable::URI::InvalidURIError
322
+ nil
323
+ end
324
+ if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
325
+ given_back_to
326
+ else
327
+ logger&.warn(Himari::LogLine.new('invalid back_to', req: request_as_log, given_back_to: given_back_to))
328
+ nil
329
+ end
257
330
  end || '/'
258
331
 
259
332
  session.destroy
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest/sha2'
2
4
  require 'himari/lifetime_value'
3
5
 
@@ -6,7 +8,11 @@ module Himari
6
8
  code
7
9
  client_id
8
10
  claims
11
+ scopes
12
+ mint_jwt_access_token
9
13
  openid
14
+ offline_access
15
+ session_handle
10
16
  redirect_uri
11
17
  nonce
12
18
  code_challenge
@@ -24,23 +30,23 @@ module Himari
24
30
  )
25
31
  end
26
32
 
27
- alias _lifetime_raw lifetime
33
+ alias_method :_lifetime_raw, :lifetime
28
34
  private :_lifetime_raw
29
35
  def lifetime
30
36
  case _lifetime_raw
31
37
  when Hash
32
38
  self.lifetime = LifetimeValue.new(**_lifetime_raw)
33
- when Integer #compat
39
+ when Integer # compat
34
40
  self.lifetime = LifetimeValue.from_integer(_lifetime_raw)
35
41
  else
36
42
  _lifetime_raw
37
43
  end
38
44
  end
39
45
 
40
- alias _expiry_raw expiry
46
+ alias_method :_expiry_raw, :expiry
41
47
  private :_expiry_raw
42
48
  def expiry
43
- self._expiry_raw || (self.expiry = created_at + (lifetime&.code || 900))
49
+ _expiry_raw || (self.expiry = created_at + (lifetime&.code || 900))
44
50
  end
45
51
 
46
52
  def valid_redirect_uri?(given_uri)
@@ -80,7 +86,11 @@ module Himari
80
86
  client_id: client_id,
81
87
  claims: claims,
82
88
  nonce: nonce,
89
+ scopes: scopes,
90
+ mint_jwt_access_token: mint_jwt_access_token,
83
91
  openid: openid,
92
+ offline_access: offline_access,
93
+ session_handle: session_handle,
84
94
  created_at: created_at.to_i,
85
95
  lifetime: lifetime.as_log,
86
96
  expiry: expiry.to_i,
@@ -95,7 +105,11 @@ module Himari
95
105
  code: code,
96
106
  client_id: client_id,
97
107
  claims: claims,
108
+ scopes: scopes,
109
+ mint_jwt_access_token: mint_jwt_access_token,
98
110
  openid: openid,
111
+ offline_access: offline_access,
112
+ session_handle: session_handle,
99
113
  redirect_uri: redirect_uri,
100
114
  nonce: nonce,
101
115
  code_challenge: code_challenge,
@@ -1,8 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'digest/sha2'
4
+ require 'addressable/uri'
2
5
 
3
6
  module Himari
4
7
  class ClientRegistration
5
- def initialize(name:, id:, secret: nil, secret_hash: nil, redirect_uris:, preferred_key_group: nil, require_pkce: false)
8
+ # Loopback hosts whose redirect_uri port may be relaxed (RFC 8252 §7.3,
9
+ # draft-ietf-oauth-v2-1-15 §8.4.2). Addressable returns IPv6 hosts bracketed.
10
+ LOOPBACK_HOSTS = %w[127.0.0.1 [::1] localhost].freeze
11
+
12
+ # Scopes Himari itself acts on; recognised for every client regardless of the configured
13
+ # scopes list, so a client need not enumerate them to use OIDC or obtain a refresh token.
14
+ IMPLICIT_SCOPES = %w[openid offline_access].freeze
15
+
16
+ def initialize(id:, redirect_uris:, name: nil, secret: nil, secret_hash: nil, preferred_key_group: nil, require_pkce: false, confidential: true, ignore_localhost_redirect_uri_port: true, skip_consent: false, scopes: IMPLICIT_SCOPES)
6
17
  @name = name
7
18
  @id = id
8
19
  @secret = secret
@@ -10,18 +21,28 @@ module Himari
10
21
  @redirect_uris = redirect_uris
11
22
  @preferred_key_group = preferred_key_group
12
23
  @require_pkce = require_pkce
24
+ @confidential = confidential
25
+ @ignore_localhost_redirect_uri_port = ignore_localhost_redirect_uri_port
26
+ @skip_consent = skip_consent
27
+ @scopes = (Array(scopes) | IMPLICIT_SCOPES).freeze
13
28
 
14
29
  raise ArgumentError, "name starts with '_' is reserved" if @name&.start_with?('_')
15
- raise ArgumentError, "either secret or secret_hash must be present" if !@secret && !@secret_hash
30
+ raise ArgumentError, "either secret or secret_hash must be present" if confidential && !@secret && !@secret_hash
16
31
  end
17
32
 
18
- attr_reader :name, :id, :redirect_uris, :preferred_key_group, :require_pkce
33
+ attr_reader :name, :id, :redirect_uris, :preferred_key_group, :require_pkce, :ignore_localhost_redirect_uri_port, :skip_consent, :scopes
34
+
35
+ def confidential?
36
+ @confidential
37
+ end
19
38
 
20
39
  def secret_hash
21
40
  @secret_hash ||= Digest::SHA384.hexdigest(secret)
22
41
  end
23
42
 
24
43
  def match_secret?(given_secret)
44
+ return false unless confidential? && given_secret
45
+
25
46
  if @secret
26
47
  Rack::Utils.secure_compare(@secret, given_secret)
27
48
  else
@@ -30,8 +51,26 @@ module Himari
30
51
  end
31
52
  end
32
53
 
54
+ # True when one of the registered redirect_uris covers the given (request) redirect_uri.
55
+ # draft-ietf-oauth-v2-1-15 §4.1.3 / RFC 3986 §6.2.1: simple (exact) string comparison, with the
56
+ # loopback-port exception of RFC 8252 §7.3 / draft-v2-1 §8.4.2 applied when enabled. A registered
57
+ # entry may also be a Regexp (operator-supplied via static config), matched against the request URI.
58
+ def redirect_uri_covers?(given)
59
+ given = given.to_s
60
+ return false if given.empty?
61
+
62
+ redirect_uris.any? { |registered| redirect_uri_match?(registered, given) }
63
+ end
64
+
65
+ # Drop requested scopes this client does not recognise. OAuth servers are expected to ignore
66
+ # unknown scopes rather than reject the request (draft-ietf-oauth-v2-1 §3.2.2.1); request
67
+ # order is preserved.
68
+ def filter_scopes(requested)
69
+ Array(requested).select { |scope| scopes.include?(scope) }
70
+ end
71
+
33
72
  def as_log
34
- {name: name, id: id}
73
+ {name: name, id: id, skip_consent: skip_consent, scopes: scopes}
35
74
  end
36
75
 
37
76
  def match_hint?(id: nil)
@@ -45,5 +84,32 @@ module Himari
45
84
 
46
85
  result
47
86
  end
87
+
88
+ private def redirect_uri_match?(registered, given)
89
+ return registered.match?(given) if registered.is_a?(Regexp)
90
+
91
+ registered = registered.to_s
92
+ return true if registered == given
93
+ return false unless ignore_localhost_redirect_uri_port
94
+
95
+ reg = loopback_uri(registered) or return false
96
+ giv = loopback_uri(given) or return false
97
+
98
+ # Port is intentionally ignored to allow ephemeral loopback ports; fragments are
99
+ # rejected at registration time, so loopback_uri requires their absence here too.
100
+ reg.scheme == giv.scheme && reg.host == giv.host && reg.path == giv.path && reg.query == giv.query
101
+ end
102
+
103
+ private def loopback_uri(str)
104
+ uri = begin
105
+ Addressable::URI.parse(str)
106
+ rescue Addressable::URI::InvalidURIError
107
+ nil
108
+ end
109
+ return unless uri && LOOPBACK_HOSTS.include?(uri.host)
110
+ return if uri.fragment
111
+
112
+ uri
113
+ end
48
114
  end
49
115
  end