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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  class RuleProcessor
3
5
  class MissingDecisionError < StandardError; end
@@ -40,6 +42,7 @@ module Himari
40
42
 
41
43
  rule.call(context, decision)
42
44
  raise MissingDecisionError, "rule '#{rule.name}' returned no decision; rule must use one of decision.allow!, deny!, continue!, skip!" unless decision.effect
45
+
43
46
  result.decision_log.push(decision)
44
47
 
45
48
  case decision.effect
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rack/request'
5
+ require 'himari/log_line'
6
+ require 'himari/dynamic_client_registration'
7
+
8
+ module Himari
9
+ module Services
10
+ # RFC 7591 OAuth 2.0 Dynamic Client Registration endpoint. Accepts a JSON client metadata
11
+ # document via POST, persists a Himari::DynamicClientRegistration, and returns the client
12
+ # information response (including a one-time client_secret for confidential clients).
13
+ class ClientRegistrationEndpoint
14
+ # @param storage [Himari::Storages::Base]
15
+ # @param registration_lifetime [Integer] seconds a registration stays valid
16
+ # @param ignore_localhost_redirect_uri_port [Boolean] relax loopback redirect_uri ports for
17
+ # registered clients (default true; see RFC 8252 §7.3)
18
+ # @param logger [Logger, nil]
19
+ def initialize(storage:, registration_lifetime: Himari::DynamicClientRegistration::REGISTRATION_LIFETIME, ignore_localhost_redirect_uri_port: true, logger: nil)
20
+ @storage = storage
21
+ @registration_lifetime = registration_lifetime
22
+ @ignore_localhost_redirect_uri_port = ignore_localhost_redirect_uri_port
23
+ @logger = logger
24
+ end
25
+
26
+ def app
27
+ self
28
+ end
29
+
30
+ def call(env)
31
+ request = Rack::Request.new(env)
32
+ return error_response(405, :invalid_request, 'method not allowed') unless request.post?
33
+
34
+ metadata = parse_body(request)
35
+ return error_response(400, :invalid_client_metadata, 'request body must be a JSON object') unless metadata
36
+
37
+ client = Himari::DynamicClientRegistration.register(
38
+ metadata: metadata,
39
+ lifetime: @registration_lifetime,
40
+ ignore_localhost_redirect_uri_port: @ignore_localhost_redirect_uri_port,
41
+ registration_ip: request.ip,
42
+ registration_remote_addr: env['REMOTE_ADDR'],
43
+ registration_x_forwarded_for: env['HTTP_X_FORWARDED_FOR'],
44
+ )
45
+ @storage.put_dynamic_client(client)
46
+
47
+ @logger&.info(Himari::LogLine.new('ClientRegistrationEndpoint: registered', req: env['himari.request_as_log'], client: client.as_log))
48
+
49
+ json_response(201, client.registration_response)
50
+ rescue Himari::DynamicClientRegistration::ValidationError => e
51
+ @logger&.warn(Himari::LogLine.new('ClientRegistrationEndpoint: rejected', req: env['himari.request_as_log'], err: e.error_code, message: e.message))
52
+ error_response(400, e.error_code, e.message)
53
+ end
54
+
55
+ private def parse_body(request)
56
+ return unless request.media_type == 'application/json'
57
+
58
+ body = request.body.read
59
+ parsed = JSON.parse(body, symbolize_names: true)
60
+ parsed.is_a?(Hash) ? parsed : nil
61
+ rescue JSON::ParserError
62
+ nil
63
+ end
64
+
65
+ private def json_response(status, body)
66
+ [
67
+ status,
68
+ {'Content-Type' => 'application/json', 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache'},
69
+ [JSON.generate(body), "\n"],
70
+ ]
71
+ end
72
+
73
+ private def error_response(status, error, description)
74
+ json_response(status, {error: error, error_description: description})
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'himari/decisions/authorization'
2
4
  require 'himari/middlewares/authorization_rule'
3
5
  require 'himari/rule_processor'
@@ -21,11 +23,13 @@ module Himari
21
23
  end
22
24
  end
23
25
 
24
- Result = Struct.new(:client, :claims, :lifetime, :authz_result) do
26
+ Result = Struct.new(:client, :claims, :scopes, :lifetime, :mint_jwt_access_token, :authz_result) do
25
27
  def as_log
26
28
  {
27
29
  client: client.as_log,
28
30
  claims: claims,
31
+ scopes: scopes,
32
+ mint_jwt_access_token: mint_jwt_access_token,
29
33
  decision: {
30
34
  authorization: authz_result.as_log,
31
35
  },
@@ -35,13 +39,19 @@ module Himari
35
39
 
36
40
  # @param session [Himari::SessionData]
37
41
  # @param client [Himari::ClientRegistration]
38
- # @param request [Rack::Request]
42
+ # @param request [Rack::Request] exposed to rules as context.request (an escape hatch); the
43
+ # engine never reads it, so requested scopes are supplied explicitly, never derived from it.
44
+ # @param requested_scopes [Array<String>] scopes asked for, before the client's allow-list
45
+ # filter. The caller supplies them from the appropriate source: the authorization endpoint
46
+ # passes the request's parsed scope, the refresh flow the scopes recorded on the grant.
39
47
  # @param authz_rules [Array<Himari::Rule>] Authorization Rules
40
48
  # @param logger [Logger]
41
- def initialize(session:, client:, request: nil, authz_rules: [], logger: nil)
49
+ def initialize(session:, client:, requested_scopes:, grant_type: :initial, request: nil, authz_rules: [], logger: nil)
42
50
  @session = session
43
51
  @client = client
52
+ @grant_type = grant_type
44
53
  @request = request
54
+ @requested_scopes = requested_scopes
45
55
  @authz_rules = authz_rules
46
56
  @logger = logger
47
57
  end
@@ -49,25 +59,30 @@ module Himari
49
59
  # @param session [Himari::SessionData]
50
60
  # @param client [Himari::ClientRegistration]
51
61
  # @param request [Rack::Request]
52
- def self.from_request(session:, client:, request:)
62
+ # @param requested_scopes [Array<String>] see #initialize; always supplied by the caller
63
+ def self.from_request(session:, client:, request:, requested_scopes:, grant_type: :initial)
53
64
  new(
54
65
  session: session,
55
66
  client: client,
67
+ grant_type: grant_type,
56
68
  request: request,
69
+ requested_scopes: requested_scopes,
57
70
  authz_rules: Himari::ProviderChain.new(request.env[Himari::Middlewares::AuthorizationRule::RACK_KEY] || []).collect,
58
71
  logger: request.env['rack.logger'],
59
72
  )
60
73
  end
61
74
 
62
75
  def perform
63
- context = Himari::Decisions::Authorization::Context.new(claims: @session.claims, user_data: @session.user_data, request: @request, client: @client).freeze
76
+ scopes = @client.filter_scopes(@requested_scopes)
77
+ context = Himari::Decisions::Authorization::Context.new(claims: @session.claims, user_data: @session.user_data, request: @request, client: @client, scopes: scopes, grant_type: @grant_type).freeze
64
78
 
65
79
  authorization = Himari::RuleProcessor.new(context, Himari::Decisions::Authorization.new(claims: @session.claims.dup)).run(@authz_rules)
66
- raise ForbiddenError.new(Result.new(@client, nil, nil, authorization)) unless authorization.allowed
80
+ raise ForbiddenError.new(Result.new(@client, nil, scopes, nil, nil, authorization)) unless authorization.allowed
67
81
 
68
82
  claims = authorization.decision.output_claims
69
83
  lifetime = authorization.decision.lifetime
70
- Result.new(@client, claims, lifetime, authorization)
84
+ mint_jwt_access_token = authorization.decision.mint_jwt_access_token
85
+ Result.new(@client, claims, scopes, lifetime, mint_jwt_access_token, authorization)
71
86
  end
72
87
  end
73
88
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  module Services
3
5
  class JwksEndpoint
@@ -26,7 +28,7 @@ module Himari
26
28
  # https://www.rfc-editor.org/rfc/rfc7517#section-5
27
29
  return [404, {'Content-Type' => 'application/json'}, ['{"error": "not_found"}']] unless @env['REQUEST_METHOD'] == 'GET'
28
30
 
29
- signing_keys = @signing_key_provider.collect()
31
+ signing_keys = @signing_key_provider.collect
30
32
 
31
33
  [
32
34
  200,
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/oauth2'
2
4
  require 'digest/sha2'
3
5
  require 'openid_connect'
@@ -7,16 +9,30 @@ module Himari
7
9
  class OidcAuthorizationEndpoint
8
10
  class ReauthenticationRequired < StandardError; end
9
11
 
12
+ # Raised when the user must be shown the consent page before a code is granted. Carries the
13
+ # data the page renders (the requesting client and the requested scopes); app.rb rescues it.
14
+ class ConsentRequired < StandardError
15
+ def initialize(client:, scopes:)
16
+ @client = client
17
+ @scopes = scopes
18
+ super('consent required')
19
+ end
20
+
21
+ attr_reader :client, :scopes
22
+ end
23
+
10
24
  SUPPORTED_RESPONSE_TYPES = ['code'] # TODO: share with oidc metadata
11
25
 
12
26
  # @param authz [Himari::AuthorizationCode] pending (unpersisted) authz data
13
27
  # @param client [Himari::ClientRegistration]
14
28
  # @param storage [Himari::Storages::Base]
29
+ # @param consent [:approve, :deny, nil] the user's consent decision (nil = not yet asked)
15
30
  # @param logger [Logger]
16
- def initialize(authz:, client:, storage:, logger: nil)
31
+ def initialize(authz:, client:, storage:, consent: nil, logger: nil)
17
32
  @authz = authz
18
33
  @client = client
19
34
  @storage = storage
35
+ @consent = consent
20
36
  @logger = logger
21
37
  end
22
38
 
@@ -37,13 +53,46 @@ module Himari
37
53
  next req.bad_request!
38
54
  end
39
55
  raise "[BUG] client.id != authz.cilent_id" unless @authz.client_id == @client.id
40
- res.redirect_uri = req.verify_redirect_uri!(@client.redirect_uris)
56
+
57
+ given_redirect_uri = req.redirect_uri&.to_s
58
+ res.redirect_uri = if given_redirect_uri && !given_redirect_uri.empty?
59
+ # Raise before recording the redirect_uri so we never redirect errors to an unverified URI.
60
+ next req.bad_request!(:invalid_request, '"redirect_uri" mismatch') unless @client.redirect_uri_covers?(given_redirect_uri)
61
+
62
+ given_redirect_uri
63
+ elsif @client.redirect_uris.size == 1 && @client.redirect_uris.first.is_a?(String)
64
+ @client.redirect_uris.first
65
+ else
66
+ next req.bad_request!(:invalid_request, '"redirect_uri" missing')
67
+ end
68
+ # rack-oauth2 redirects subsequent errors back to the verified redirect_uri via this accessor.
69
+ req.verified_redirect_uri = res.redirect_uri
41
70
 
42
71
  req.unsupported_response_type! if res.protocol_params_location == :fragment
43
72
  req.bad_request!(:request_uri_not_supported, "Request Object is not implemented") if req.request_uri || req.request
44
73
  req.bad_request!(:invalid_request, 'prompt=none should not contain any other value') if req.prompt.include?('none') && req.prompt.any? { |x| x != 'none' }
45
74
  raise ReauthenticationRequired if req.prompt.include?('login') || req.prompt.include?('select_account')
46
75
 
76
+ # Drop scopes this client does not recognise before they reach consent or the grant.
77
+ scopes = @client.filter_scopes(req.scope)
78
+
79
+ # Consent gate. Clients granted skip_consent (the default for dynamically/metadata-
80
+ # registered clients) bypass it; prompt=consent forces the page regardless.
81
+ if !@client.skip_consent || req.prompt.include?('consent')
82
+ case @consent
83
+ when :approve
84
+ # consent given; fall through and grant
85
+ when :deny
86
+ next req.access_denied!
87
+ else
88
+ # prompt=none forbids interaction (OIDC §3.1.2.1), so surface the error via redirect
89
+ # instead of rendering the page.
90
+ next req.consent_required! if req.prompt.include?('none')
91
+
92
+ raise ConsentRequired.new(client: @client, scopes: scopes)
93
+ end
94
+ end
95
+
47
96
  requested_response_types = [*req.response_type]
48
97
  unless SUPPORTED_RESPONSE_TYPES.include?(requested_response_types.map(&:to_s).join(' '))
49
98
  next req.unsupported_response_type!
@@ -53,11 +102,15 @@ module Himari
53
102
  @authz.redirect_uri = res.redirect_uri
54
103
  @authz.nonce = req.nonce
55
104
 
56
- @authz.openid = req.scope.include?('openid')
105
+ @authz.scopes = scopes
106
+ @authz.openid = scopes.include?('openid')
107
+ @authz.offline_access = scopes.include?('offline_access')
57
108
  if req.code_challenge && req.code_challenge_method
58
109
  @authz.code_challenge = req.code_challenge
59
110
  @authz.code_challenge_method = req.code_challenge_method || 'plain'
60
111
  next req.bad_request!(:invalid_request, 'Invalid PKCE parameters') unless @authz.pkce_valid_request?
112
+ elsif @client.require_pkce
113
+ next req.bad_request!(:invalid_request, 'PKCE is mandatory')
61
114
  end
62
115
 
63
116
  @storage.put_authorization(@authz)
@@ -1,10 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Himari
2
4
  module Services
3
5
  class OidcProviderMetadataEndpoint
6
+ # Scopes and claims Himari always advertises; configured values are merged on top.
7
+ DEFAULT_SCOPES_SUPPORTED = %w(openid offline_access).freeze
8
+ DEFAULT_CLAIMS_SUPPORTED = %w(sub iss iat nbf exp).freeze
9
+
4
10
  # @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>]
5
- def initialize(signing_key_provider:, issuer:)
11
+ # @param registration_endpoint [String, nil] advertised when Dynamic Client Registration is enabled
12
+ # @param client_id_metadata_document_supported [Boolean] advertised when OAuth Client ID Metadata Document support is enabled
13
+ # @param scopes_supported [Array<String>] extra scopes to advertise alongside the defaults
14
+ # @param claims_supported [Array<String>] extra claims to advertise alongside the defaults
15
+ def initialize(signing_key_provider:, issuer:, registration_endpoint: nil, client_id_metadata_document_supported: false, scopes_supported: [], claims_supported: [])
6
16
  @signing_key_provider = signing_key_provider
7
17
  @issuer = issuer
18
+ @registration_endpoint = registration_endpoint
19
+ @client_id_metadata_document_supported = client_id_metadata_document_supported
20
+ @scopes_supported = scopes_supported
21
+ @claims_supported = claims_supported
8
22
  end
9
23
 
10
24
  def app
@@ -12,32 +26,41 @@ module Himari
12
26
  end
13
27
 
14
28
  def call(env)
15
- Handler.new(signing_key_provider: @signing_key_provider, issuer: @issuer, env: env).response
29
+ Handler.new(signing_key_provider: @signing_key_provider, issuer: @issuer, registration_endpoint: @registration_endpoint, client_id_metadata_document_supported: @client_id_metadata_document_supported, scopes_supported: @scopes_supported, claims_supported: @claims_supported, env: env).response
16
30
  end
17
31
 
18
32
  class Handler
19
33
  class InvalidToken < StandardError; end
20
34
 
21
- def initialize(signing_key_provider:, issuer:, env:)
35
+ def initialize(signing_key_provider:, issuer:, env:, registration_endpoint: nil, client_id_metadata_document_supported: false, scopes_supported: [], claims_supported: [])
22
36
  @signing_key_provider = signing_key_provider
23
37
  @issuer = issuer
38
+ @registration_endpoint = registration_endpoint
39
+ @client_id_metadata_document_supported = client_id_metadata_document_supported
40
+ @scopes_supported = scopes_supported
41
+ @claims_supported = claims_supported
24
42
  @env = env
25
43
  end
26
44
 
27
45
  def metadata
28
- signing_keys = @signing_key_provider.collect()
46
+ signing_keys = @signing_key_provider.collect
29
47
  {
30
48
  issuer: @issuer,
31
49
  authorization_endpoint: "#{@issuer}/oidc/authorize",
32
50
  token_endpoint: "#{@issuer}/public/oidc/token",
33
51
  userinfo_endpoint: "#{@issuer}/public/oidc/userinfo",
34
52
  jwks_uri: "#{@issuer}/public/jwks",
35
- scopes_supported: %w(openid),
53
+ registration_endpoint: @registration_endpoint,
54
+ client_id_metadata_document_supported: @client_id_metadata_document_supported ? true : nil,
55
+ scopes_supported: (DEFAULT_SCOPES_SUPPORTED + @scopes_supported).uniq,
36
56
  response_types_supported: ['code'], # violation: dynamic OpenID Provider MUST support code, id_token, token+id_token
57
+ grant_types_supported: %w(authorization_code refresh_token),
58
+ token_endpoint_auth_methods_supported: %w(client_secret_basic client_secret_post none),
59
+ code_challenge_methods_supported: %w(S256 plain),
37
60
  subject_types_supported: ['public'],
38
61
  id_token_signing_alg_values_supported: signing_keys.map(&:alg).uniq.sort,
39
- claims_supported: %w(sub iss iat nbf exp),
40
- }
62
+ claims_supported: (DEFAULT_CLAIMS_SUPPORTED + @claims_supported).uniq,
63
+ }.compact
41
64
  end
42
65
 
43
66
  def response
@@ -1,14 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/oauth2'
2
4
  require 'digest/sha2'
3
5
  require 'openid_connect'
4
6
  require 'himari/access_token'
7
+ require 'himari/refresh_token'
5
8
  require 'himari/id_token'
9
+ require 'himari/storages/base'
10
+ require 'himari/services/downstream_authorization'
11
+ require 'himari/services/upstream_authentication'
6
12
 
7
13
  module Himari
8
14
  module Services
9
15
  class OidcTokenEndpoint
10
16
  class SigningKeyMissing < StandardError; end
11
17
 
18
+ Issued = Struct.new(:access, :access_token_string, :id_token_jwt, :signing_key, keyword_init: true)
19
+
12
20
  # @param client_provider [Himari::ProviderChain<Himari::ClientRegistration>]
13
21
  # @param signing_key_provider [Himari::ProviderChain<Himari::SigningKey>]
14
22
  # @param storage [Himari::Storages::Base]
@@ -25,62 +33,241 @@ module Himari
25
33
  def call(env)
26
34
  app(env).call(env)
27
35
  rescue Rack::OAuth2::Server::Abstract::Error => e
28
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'], client: client.as_log, err: e.class.inspect, err_content: e.protocol_params))
36
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: returning error', req: env['himari.request_as_log'], err: e.class.inspect, err_content: e.protocol_params))
29
37
  e.finish
30
38
  end
31
39
 
32
40
  def app(env)
33
41
  Rack::OAuth2::Server::Token.new do |req, res|
34
- code_dgst = req.code ? Digest::SHA256.hexdigest(req.code) : nil
35
42
  client = @client_provider.find(id: req.client_id)
36
43
  unless client
37
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id, code_dgst: code_dgst))
44
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, no client registration', req: env['himari.request_as_log'], client_id: req.client_id))
38
45
  next req.invalid_client!
39
46
  end
40
- unless client.match_secret?(req.client_secret)
41
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, client secret mismatch', req: env['himari.request_as_log'], client: client.as_log, code_dgst: code_dgst))
42
- next req.invalid_client!
47
+ # Public clients (token_endpoint_auth_method=none) present no secret; they are bound
48
+ # to the authorization code by PKCE and the client_id check in handle_authorization_code.
49
+ if client.confidential? && !client.match_secret?(req.client_secret)
50
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_client, client secret mismatch', req: env['himari.request_as_log'], client: client.as_log))
51
+ next req.invalid_client!
43
52
  end
44
53
 
45
54
  case req.grant_type
46
55
  when :authorization_code
47
- authz = @storage.find_authorization(req.code)
48
- unless authz
49
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, no grant code found', req: env['himari.request_as_log'], client: client.as_log))
50
- next req.invalid_grant!
51
- end
52
- unless authz.valid_redirect_uri?(req.redirect_uri)
53
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
54
- next req.invalid_grant!
55
- end
56
- if authz.expiry <= Time.now.to_i
57
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
58
- next req.invalid_grant!
59
- end
60
- if authz.pkce? && !req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
61
- # :nocov:
62
- @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
63
- next req.invalid_grant!
64
- # :nocov:
65
- end
66
-
67
- token = AccessToken.from_authz(authz)
68
- @storage.put_token(token)
69
- res.access_token = token.to_bearer
70
-
71
- if authz.openid
72
- signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
73
- raise SigningKeyMissing unless signing_key
74
- res.id_token = IdToken.from_authz(authz, signing_key: signing_key, access_token: token.format.to_s, issuer: @issuer).to_jwt
75
- end
76
-
77
- @storage.delete_authorization(authz)
78
- @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: token.as_log, signing_key_kid: signing_key&.id))
56
+ handle_authorization_code(env, req, res, client)
57
+ when :refresh_token
58
+ handle_refresh_token(env, req, res, client)
79
59
  else
80
60
  req.unsupported_response_type!
81
61
  end
82
62
  end
83
63
  end
64
+
65
+ private def handle_authorization_code(env, req, res, client)
66
+ authz = @storage.find_authorization(req.code)
67
+ unless authz
68
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, no grant code found', req: env['himari.request_as_log'], client: client.as_log))
69
+ return req.invalid_grant!
70
+ end
71
+ unless authz.client_id == client.id
72
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, grant client_id mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
73
+ return req.invalid_grant!
74
+ end
75
+ unless authz.valid_redirect_uri?(req.redirect_uri)
76
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, redirect_uri mismatch', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
77
+ return req.invalid_grant!
78
+ end
79
+ if authz.expiry <= Time.now.to_i
80
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, expired grant', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
81
+ return req.invalid_grant!
82
+ end
83
+
84
+ if authz.pkce?
85
+ if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
86
+ # do nothing
87
+ else
88
+ # :nocov:
89
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
90
+ return req.invalid_grant!
91
+ # :nocov:
92
+ end
93
+ elsif client.require_pkce
94
+ @logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, pkce is mandatory', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
95
+ return req.invalid_grant!
96
+ end
97
+
98
+ issued = issue_access_and_id(
99
+ client: client,
100
+ claims: authz.claims,
101
+ scopes: authz.scopes,
102
+ lifetime: authz.lifetime,
103
+ openid: authz.openid,
104
+ session_handle: authz.session_handle,
105
+ nonce: authz.nonce,
106
+ mint_jwt_access_token: authz.mint_jwt_access_token,
107
+ )
108
+
109
+ refresh = nil
110
+ if authz.offline_access && authz.session_handle && authz.lifetime&.refresh_token
111
+ refresh = RefreshToken.make(client_id: client.id, claims: authz.claims, session_handle: authz.session_handle, openid: authz.openid, scopes: authz.scopes, lifetime: authz.lifetime.refresh_token)
112
+ @storage.put_refresh_token(refresh)
113
+ end
114
+
115
+ bearer = issued.access.to_bearer(token_string: issued.access_token_string)
116
+ bearer.refresh_token = refresh.format.to_s if refresh
117
+ res.access_token = bearer
118
+ res.id_token = issued.id_token_jwt if issued.id_token_jwt
119
+
120
+ @storage.delete_authorization(authz)
121
+ @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: issued', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log, token: issued.access.as_log, refresh_token: refresh&.as_log, signing_key_kid: issued.signing_key&.id))
122
+ end
123
+
124
+ private def handle_refresh_token(env, req, res, client)
125
+ given_token_str = req.refresh_token
126
+ unless given_token_str
127
+ return reject_refresh!(env, req, client, 'no refresh_token given')
128
+ end
129
+
130
+ begin
131
+ parsed = Himari::RefreshToken.parse(given_token_str)
132
+ rescue Himari::TokenString::InvalidFormat => e
133
+ return reject_refresh!(env, req, client, 'invalid refresh_token format', err: e.class.inspect)
134
+ end
135
+
136
+ refresh = @storage.find_refresh_token(parsed.handle)
137
+ unless refresh
138
+ return reject_refresh!(env, req, client, 'unknown refresh_token')
139
+ end
140
+
141
+ begin
142
+ refresh.verify!(secret: parsed.secret)
143
+ rescue Himari::TokenString::Error => e
144
+ return reject_refresh!(env, req, client, 'refresh_token verify failed', refresh: refresh, err: e.class.inspect)
145
+ end
146
+
147
+ unless refresh.client_id == client.id
148
+ return reject_refresh!(env, req, client, 'refresh_token client_id mismatch', refresh: refresh)
149
+ end
150
+
151
+ session = refresh.session_handle && @storage.find_session(refresh.session_handle)
152
+ unless session
153
+ return reject_refresh!(env, req, client, 'refresh_token has no session', refresh: refresh)
154
+ end
155
+
156
+ unless session.refreshable?
157
+ return reject_refresh!(env, req, client, 'session is not refreshable (no refresh_info)', refresh: refresh, session: session.as_log)
158
+ end
159
+
160
+ unless session.active?
161
+ return reject_refresh!(env, req, client, 'session expired', refresh: refresh, session: session.as_log)
162
+ end
163
+
164
+ rack_request = Rack::Request.new(env)
165
+
166
+ begin
167
+ authn = Himari::Services::UpstreamAuthentication.revalidate_from_request(session: session, request: rack_request).perform
168
+ rescue Himari::Services::UpstreamAuthentication::UnauthorizedError => e
169
+ return reject_refresh!(env, req, client, 'refresh upstream authn denied', refresh: refresh, session: session.as_log, result: e.as_log)
170
+ end
171
+
172
+ updated_session = authn.session_data
173
+
174
+ begin
175
+ downstream = Himari::Services::DownstreamAuthorization.from_request(session: updated_session, client: client, request: rack_request, grant_type: :refresh_token, requested_scopes: refresh.scopes).perform
176
+ rescue Himari::Services::DownstreamAuthorization::ForbiddenError => e
177
+ return reject_refresh!(env, req, client, 'refresh downstream authz denied', refresh: refresh, session: updated_session.as_log, result: e.as_log)
178
+ end
179
+
180
+ # Refresh lifetime is recomputed by the authz rules on every refresh; if it is no
181
+ # longer configured the session is no longer refreshable. Fail closed.
182
+ unless downstream.lifetime&.refresh_token
183
+ return reject_refresh!(env, req, client, 'refresh_token lifetime no longer configured', refresh: refresh, session: updated_session.as_log)
184
+ end
185
+
186
+ # Rotate the token in place; verify! above recorded which secret the client presented,
187
+ # which rotate keeps valid as the previous one. The token's original expiry is
188
+ # preserved (absolute cap); the lifetime guard above only gates whether refresh is
189
+ # still permitted by the rules, not how long the rotated token lives.
190
+ rotated = refresh.rotate(claims: downstream.claims, openid: refresh.openid)
191
+
192
+ # Compare-and-swap on the version we read. A concurrent refresh that already rotated
193
+ # this token bumps the version, so the loser's write conflicts. Reject the loser
194
+ # without revoking — the winner's rotation (same handle) must survive.
195
+ begin
196
+ @storage.put_refresh_token(rotated, if_version: refresh.version)
197
+ rescue Himari::Storages::Base::Conflict
198
+ return reject_refresh!(env, req, client, 'refresh_token version conflict (concurrent use)', refresh: refresh, revoke: false)
199
+ end
200
+
201
+ @storage.put_session(updated_session, overwrite: true)
202
+
203
+ # OIDC core §12.2: refreshed ID Token MAY be returned, with no nonce on refresh.
204
+ issued = issue_access_and_id(
205
+ client: client,
206
+ claims: downstream.claims,
207
+ scopes: downstream.scopes,
208
+ lifetime: downstream.lifetime,
209
+ openid: refresh.openid,
210
+ session_handle: updated_session.handle,
211
+ nonce: nil,
212
+ mint_jwt_access_token: downstream.mint_jwt_access_token,
213
+ )
214
+
215
+ bearer = issued.access.to_bearer(token_string: issued.access_token_string)
216
+ bearer.refresh_token = rotated.format.to_s
217
+ res.access_token = bearer
218
+ res.id_token = issued.id_token_jwt if issued.id_token_jwt
219
+
220
+ @logger&.info(Himari::LogLine.new('OidcTokenEndpoint: refreshed', req: env['himari.request_as_log'], client: client.as_log, session: updated_session.as_log, token: issued.access.as_log, refresh_token: rotated.as_log, prev_version: refresh.version, secret_slot: refresh.verification&.via, signing_key_kid: issued.signing_key&.id))
221
+ end
222
+
223
+ # Reject a refresh request with invalid_grant. By default this revokes the presented
224
+ # refresh token when one was looked up, keeping refresh failures fail-closed against
225
+ # replay. revoke: false is used only for the concurrent-conflict path, where the
226
+ # winning request has already rotated this same handle and must not be revoked.
227
+ private def reject_refresh!(env, req, client, reason, refresh: nil, revoke: true, **fields)
228
+ log = {req: env['himari.request_as_log'], client: client.as_log}
229
+ log[:refresh] = refresh.as_log if refresh
230
+ @logger&.warn(Himari::LogLine.new("OidcTokenEndpoint: invalid_grant, #{reason}", **log, **fields))
231
+ @storage.delete_refresh_token(refresh) if refresh && revoke
232
+ req.invalid_grant!
233
+ end
234
+
235
+ # Mint an access token (and, for OIDC, an id_token JWT). Refresh tokens are handled
236
+ # separately by each grant path: the authorization_code path mints a fresh one, while
237
+ # the refresh path rotates the presented token in place.
238
+ private def issue_access_and_id(client:, claims:, scopes:, lifetime:, openid:, session_handle:, nonce:, mint_jwt_access_token:)
239
+ access = AccessToken.make(client_id: client.id, claims: claims, scopes: scopes, session_handle: session_handle, lifetime: lifetime.access_token)
240
+ @storage.put_token(access)
241
+
242
+ # Both the ID Token and a JWT access token are signed by the same key; resolve it once.
243
+ signing_key = nil
244
+ if openid || mint_jwt_access_token
245
+ signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
246
+ raise SigningKeyMissing unless signing_key
247
+ end
248
+
249
+ access_token_string = if mint_jwt_access_token
250
+ access.to_jwt(signing_key: signing_key, issuer: @issuer)
251
+ else
252
+ access.format.to_s
253
+ end
254
+
255
+ id_token_jwt = nil
256
+ if openid
257
+ id_token_jwt = IdToken.new(
258
+ claims: claims,
259
+ client_id: client.id,
260
+ nonce: nonce,
261
+ signing_key: signing_key,
262
+ issuer: @issuer,
263
+ # at_hash binds the ID Token to the access token actually delivered (JWT or opaque).
264
+ access_token: access_token_string,
265
+ lifetime: lifetime.id_token,
266
+ ).to_jwt
267
+ end
268
+
269
+ Issued.new(access: access, access_token_string: access_token_string, id_token_jwt: id_token_jwt, signing_key: signing_key)
270
+ end
84
271
  end
85
272
  end
86
273
  end