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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b8557a7f7d75db91d61c20e931bfa9d05d832541b4fb9fdc05055491568e4ec
4
- data.tar.gz: c50b5396fa19b2c03b2b4115d495f3f96a688016a4c8d3fb9b1833ef10de13b7
3
+ metadata.gz: f986e727b538f1f6f512f6787e7546e1ebdaa942398053966d52db1383135fc8
4
+ data.tar.gz: a300ee5b77dc2e1f7c5befbae719876cc60f08a742d97c43f71bdd44fb170adc
5
5
  SHA512:
6
- metadata.gz: eac9157e67de6f6c2538b36f7d6a55e62f1d3a7e3837e72a23d0b6d407a40a2004f7a7fee8c71b036c709daf6be970a75ae45118c116bbd4ab1a085eb23af137
7
- data.tar.gz: b40f7ed7a033f312871a191398ba1d74f376c89d634666b4b076a05666a66309d8886d0bd3d441144d1fea781847e6f020fa9ec39a079ac1ef3f48f9f332efba
6
+ metadata.gz: 1bf93eaa1f6f98c45ec2649278ccb571f26a530f7b5b4d6c35d0b15e250861f9fc7e6efdb7f3c77de9abd177330b86970d9b1de29df2a0cdb4c4d65f33933fb0
7
+ data.tar.gz: 033b8dfa409cea02e7ddd73a6369c261e0d79402ab7a82cab49cbffafc654b12bb08dabd42b824382d4143b426d2749117014b94e96bd4d1b67de7e2ca8e37ef
data/CHANGELOG.md ADDED
@@ -0,0 +1,64 @@
1
+ ## [0.7.0] - 2026-06-06
2
+
3
+ ### Enhancements
4
+
5
+ - Authorization Server Issuer Identification (RFC 9207): the authorization endpoint now returns the `iss` parameter in all authorization responses (success and redirected errors), and discovery metadata advertises `authorization_response_iss_parameter_supported`
6
+
7
+ ## [0.6.0] - 2026-06-03
8
+
9
+ ### Enhancements
10
+
11
+ - Refresh token (`refresh_token` grant) support [#14](https://github.com/sorah/himari/pull/14)
12
+ - 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)
13
+ - Interactive consent page gated by the client `skip_consent` attribute [#16](https://github.com/sorah/himari/pull/16)
14
+ - Per-client `scopes` allow-list [#17](https://github.com/sorah/himari/pull/17)
15
+ - Persist granted scopes on the grant and expose them to authorization rules as `context.scopes` [#19](https://github.com/sorah/himari/pull/19)
16
+ - Opt-in RFC 9068 (`at+jwt`) JWT access tokens [#20](https://github.com/sorah/himari/pull/20)
17
+ - Configurable `scopes_supported`/`claims_supported` and advertise `refresh_token`/`offline_access` in discovery metadata [#21](https://github.com/sorah/himari/pull/21)
18
+
19
+ ## [0.5.0] - 2024-05-11
20
+
21
+ ### Enhancements
22
+
23
+ - Userinfo endpoint now returns the `aud` claim.
24
+ - Client gains the `require_pkce` attribute.
25
+
26
+ ## [0.4.0] - 2023-03-26
27
+
28
+ ### Enhancements
29
+
30
+ - Support `prompt=login` for reauthentication; the userinfo endpoint now also answers POST [#8](https://github.com/sorah/himari/pull/8)
31
+ - Store `SessionData` in a storage backend [#7](https://github.com/sorah/himari/pull/7)
32
+ - Introduce the `omniauth-himari` strategy gem [#6](https://github.com/sorah/himari/pull/6)
33
+ - Access token gains its own lifetime.
34
+
35
+ ### Changes
36
+
37
+ - Rename `AccessToken#handler` to `handle` and stop treating the token handle as a sensitive value [#5](https://github.com/sorah/himari/pull/5)
38
+ - Disable `Rack::Protection::JsonCsrf` for ALB OIDC compatibility [#1](https://github.com/sorah/himari/pull/1)
39
+
40
+ ### Bug fixes
41
+
42
+ - Fix error when logging an expired session token.
43
+
44
+ ## [0.3.0] - 2023-03-22
45
+
46
+ ### Enhancements
47
+
48
+ - Customizable session and token lifetimes.
49
+ - `suggest=reauthenticate` to prompt login, with a decision `user_facing_message`.
50
+
51
+ ### Bug fixes
52
+
53
+ - Callback returns 400 when the auth hash is missing.
54
+
55
+ ## [0.2.0] - 2023-03-22
56
+
57
+ ### Enhancements
58
+
59
+ - Better login page template with cachebuster.
60
+ - Prebuilt container image.
61
+
62
+ ## [0.1.0] - 2023-02-25
63
+
64
+ - 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,35 @@ 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
+ issuer: config.issuer,
204
+ consent: consent,
168
205
  logger: logger,
169
206
  ).call(env)
170
207
  else
171
208
  logger&.info(Himari::LogLine.new('authorize: prompt login', req: request_as_log, client_id: params[:client_id]))
172
209
  erb(config.custom_templates[:login] || :login)
173
210
  end
174
-
175
211
  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))
212
+ 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
213
  next erb(config.custom_templates[:login] || :login)
178
-
214
+ rescue Himari::Services::OidcAuthorizationEndpoint::ConsentRequired => e
215
+ 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))
216
+ @consent_client = e.client
217
+ @consent_scopes = e.scopes
218
+ next erb(config.custom_templates[:consent] || :consent)
179
219
  rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
180
220
  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
221
 
@@ -193,6 +233,8 @@ module Himari
193
233
 
194
234
  halt(403, "Forbidden#{message_human ? "; #{message_human}" : nil}")
195
235
  end
236
+ get '/oidc/authorize', &authorize_ep
237
+ post '/oidc/authorize', &authorize_ep
196
238
 
197
239
  token_ep = proc do
198
240
  Himari::Services::OidcTokenEndpoint.new(
@@ -209,6 +251,7 @@ module Himari
209
251
  userinfo_ep = proc do
210
252
  Himari::Services::OidcUserinfoEndpoint.new(
211
253
  storage: config.storage,
254
+ signing_key_provider: signing_key_provider,
212
255
  logger: logger,
213
256
  ).call(env)
214
257
  end
@@ -225,12 +268,43 @@ module Himari
225
268
  get '/jwks', &jwks_ep
226
269
  get '/public/jwks', &jwks_ep
227
270
 
228
- get '/.well-known/openid-configuration' do
271
+ # RFC 7591 Dynamic Client Registration. Enabled by presence of the DynamicClients
272
+ # middleware; the route always exists but 404s when the feature is off.
273
+ register_ep = proc do
274
+ next halt 404, 'not found' unless dynamic_clients_enabled?
275
+
276
+ Himari::Services::ClientRegistrationEndpoint.new(
277
+ storage: config.storage,
278
+ registration_lifetime: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].registration_lifetime,
279
+ ignore_localhost_redirect_uri_port: request.env[Himari::Middlewares::DynamicClients::RACK_KEY].ignore_localhost_redirect_uri_port,
280
+ logger: logger,
281
+ ).call(env)
282
+ end
283
+ post '/oidc/register', &register_ep
284
+ post '/public/oidc/register', &register_ep
285
+ # Wire the non-POST verbs too so they reach the endpoint, which answers 405 (RFC 7591 only
286
+ # defines POST). Without these Sinatra would 404 a GET, masking the method error.
287
+ %w(/oidc/register /public/oidc/register).each do |path|
288
+ get(path, &register_ep)
289
+ put(path, &register_ep)
290
+ patch(path, &register_ep)
291
+ delete(path, &register_ep)
292
+ end
293
+
294
+ metadata_ep = proc do
229
295
  Himari::Services::OidcProviderMetadataEndpoint.new(
230
296
  signing_key_provider: signing_key_provider,
231
297
  issuer: config.issuer,
298
+ registration_endpoint: dynamic_clients_enabled? ? "#{config.issuer}/public/oidc/register" : nil,
299
+ client_id_metadata_document_supported: metadata_clients_enabled?,
300
+ scopes_supported: config.scopes_supported,
301
+ claims_supported: config.claims_supported,
232
302
  ).call(env)
233
303
  end
304
+ # OpenID Connect Discovery 1.0
305
+ get '/.well-known/openid-configuration', &metadata_ep
306
+ # RFC 8414 OAuth 2.0 Authorization Server Metadata
307
+ get '/.well-known/oauth-authorization-server', &metadata_ep
234
308
 
235
309
  omniauth_callback = proc do
236
310
  authhash = request.env['omniauth.auth']
@@ -243,17 +317,17 @@ module Himari
243
317
 
244
318
  given_back_to = request.env['omniauth.params']&.fetch('back_to', nil)
245
319
  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
320
+ uri = begin
321
+ Addressable::URI.parse(given_back_to)
322
+ rescue Addressable::URI::InvalidURIError
323
+ nil
324
+ end
325
+ if uri && uri.host.nil? && uri.scheme.nil? && uri.path.start_with?('/')
326
+ given_back_to
327
+ else
328
+ logger&.warn(Himari::LogLine.new('invalid back_to', req: request_as_log, given_back_to: given_back_to))
329
+ nil
330
+ end
257
331
  end || '/'
258
332
 
259
333
  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,