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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,7 +102,9 @@ module Himari
|
|
|
53
102
|
@authz.redirect_uri = res.redirect_uri
|
|
54
103
|
@authz.nonce = req.nonce
|
|
55
104
|
|
|
56
|
-
@authz.
|
|
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'
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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,70 +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'],
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
61
|
-
if authz.pkce?
|
|
62
|
-
if req.verify_code_verifier!(authz.code_challenge, authz.code_challenge_method)
|
|
63
|
-
# do nothing
|
|
64
|
-
else
|
|
65
|
-
# :nocov:
|
|
66
|
-
@logger&.warn(Himari::LogLine.new('OidcTokenEndpoint: invalid_grant, invalid pkce', req: env['himari.request_as_log'], client: client.as_log, grant: authz.as_log))
|
|
67
|
-
next req.invalid_grant!
|
|
68
|
-
# :nocov:
|
|
69
|
-
end
|
|
70
|
-
elsif client.require_pkce
|
|
71
|
-
@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))
|
|
72
|
-
next req.invalid_grant!
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
token = AccessToken.from_authz(authz)
|
|
76
|
-
@storage.put_token(token)
|
|
77
|
-
res.access_token = token.to_bearer
|
|
78
|
-
|
|
79
|
-
if authz.openid
|
|
80
|
-
signing_key = @signing_key_provider.find(group: client.preferred_key_group, active: true)
|
|
81
|
-
raise SigningKeyMissing unless signing_key
|
|
82
|
-
res.id_token = IdToken.from_authz(authz, signing_key: signing_key, access_token: token.format.to_s, issuer: @issuer).to_jwt
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
@storage.delete_authorization(authz)
|
|
86
|
-
@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)
|
|
87
59
|
else
|
|
88
60
|
req.unsupported_response_type!
|
|
89
61
|
end
|
|
90
62
|
end
|
|
91
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
|
|
92
271
|
end
|
|
93
272
|
end
|
|
94
273
|
end
|