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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/lib/himari/access_token.rb +72 -4
- data/lib/himari/access_token_jwt.rb +46 -0
- data/lib/himari/app.rb +102 -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/rack_oauth2_ext.rb +58 -0
- 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 +63 -3
- data/lib/himari/services/oidc_provider_metadata_endpoint.rb +31 -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 +50 -14
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'himari/token_string'
|
|
5
|
+
|
|
6
|
+
module Himari
|
|
7
|
+
class RefreshToken
|
|
8
|
+
include TokenString
|
|
9
|
+
|
|
10
|
+
def self.magic_header
|
|
11
|
+
'hmrt'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.default_lifetime
|
|
15
|
+
raise ArgumentError, "RefreshToken requires an explicit lifetime:"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(handle:, client_id:, claims:, session_handle:, expiry:, openid: false, scopes: [], secret: nil, secret_hash: nil, secret_hash_prev: nil, version: 1, updated_at: nil)
|
|
19
|
+
@handle = handle
|
|
20
|
+
@client_id = client_id
|
|
21
|
+
@claims = claims
|
|
22
|
+
@session_handle = session_handle
|
|
23
|
+
@openid = openid
|
|
24
|
+
@scopes = scopes
|
|
25
|
+
@expiry = expiry
|
|
26
|
+
|
|
27
|
+
@secret = secret
|
|
28
|
+
@secret_hash = secret_hash
|
|
29
|
+
@secret_hash_prev = secret_hash_prev
|
|
30
|
+
@version = version
|
|
31
|
+
@updated_at = updated_at || Time.now.to_i
|
|
32
|
+
@verification = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :handle, :client_id, :claims, :session_handle, :openid, :scopes, :expiry, :version, :updated_at
|
|
36
|
+
|
|
37
|
+
# Rotate the token in place (same handle): mint a new current secret while keeping the
|
|
38
|
+
# just-presented secret valid as the previous one, so a client whose rotation response is
|
|
39
|
+
# lost can retry with the secret it still holds. The secret to keep is the hash verify!
|
|
40
|
+
# matched (TokenString#verification) — whichever slot the client used; rotate is therefore
|
|
41
|
+
# only valid after a successful verify!. version is bumped so a concurrent refresh against
|
|
42
|
+
# the version we read fails the conditional update. expiry is preserved: the initial
|
|
43
|
+
# lifetime is an absolute cap on the rotation chain, not slid forward on each refresh.
|
|
44
|
+
def rotate(claims:, openid:, now: Time.now)
|
|
45
|
+
raise TokenString::SecretMissing, "rotate requires a verified secret; call verify! first" unless verification
|
|
46
|
+
|
|
47
|
+
self.class.new(
|
|
48
|
+
handle:,
|
|
49
|
+
client_id:,
|
|
50
|
+
session_handle:,
|
|
51
|
+
claims:,
|
|
52
|
+
openid:,
|
|
53
|
+
scopes:,
|
|
54
|
+
secret: SecureRandom.urlsafe_base64(48),
|
|
55
|
+
secret_hash_prev: verification.secret_hash,
|
|
56
|
+
version: version + 1,
|
|
57
|
+
updated_at: now.to_i,
|
|
58
|
+
expiry:,
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def as_log
|
|
63
|
+
{
|
|
64
|
+
handle: handle,
|
|
65
|
+
client_id: client_id,
|
|
66
|
+
claims: claims,
|
|
67
|
+
session_handle: session_handle,
|
|
68
|
+
openid: openid,
|
|
69
|
+
scopes: scopes,
|
|
70
|
+
expiry: expiry,
|
|
71
|
+
version: version,
|
|
72
|
+
updated_at: updated_at,
|
|
73
|
+
prev_secret_set: !secret_hash_prev.nil?,
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def as_json
|
|
78
|
+
{
|
|
79
|
+
handle: handle,
|
|
80
|
+
secret_hash: secret_hash,
|
|
81
|
+
secret_hash_prev: secret_hash_prev,
|
|
82
|
+
client_id: client_id,
|
|
83
|
+
claims: claims,
|
|
84
|
+
session_handle: session_handle,
|
|
85
|
+
openid: openid,
|
|
86
|
+
scopes: scopes,
|
|
87
|
+
expiry: expiry.to_i,
|
|
88
|
+
version: version,
|
|
89
|
+
updated_at: updated_at.to_i,
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/himari/rule.rb
CHANGED
|
@@ -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,22 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'rack/oauth2'
|
|
2
4
|
require 'digest/sha2'
|
|
3
5
|
require 'openid_connect'
|
|
4
6
|
|
|
7
|
+
require 'himari/rack_oauth2_ext'
|
|
8
|
+
|
|
5
9
|
module Himari
|
|
6
10
|
module Services
|
|
7
11
|
class OidcAuthorizationEndpoint
|
|
8
12
|
class ReauthenticationRequired < StandardError; end
|
|
9
13
|
|
|
14
|
+
# Raised when the user must be shown the consent page before a code is granted. Carries the
|
|
15
|
+
# data the page renders (the requesting client and the requested scopes); app.rb rescues it.
|
|
16
|
+
class ConsentRequired < StandardError
|
|
17
|
+
def initialize(client:, scopes:)
|
|
18
|
+
@client = client
|
|
19
|
+
@scopes = scopes
|
|
20
|
+
super('consent required')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :client, :scopes
|
|
24
|
+
end
|
|
25
|
+
|
|
10
26
|
SUPPORTED_RESPONSE_TYPES = ['code'] # TODO: share with oidc metadata
|
|
11
27
|
|
|
12
28
|
# @param authz [Himari::AuthorizationCode] pending (unpersisted) authz data
|
|
13
29
|
# @param client [Himari::ClientRegistration]
|
|
14
30
|
# @param storage [Himari::Storages::Base]
|
|
31
|
+
# @param issuer [String] issuer identifier, returned to the client as the RFC 9207 `iss` parameter
|
|
32
|
+
# @param consent [:approve, :deny, nil] the user's consent decision (nil = not yet asked)
|
|
15
33
|
# @param logger [Logger]
|
|
16
|
-
def initialize(authz:, client:, storage:, logger: nil)
|
|
34
|
+
def initialize(authz:, client:, storage:, issuer:, consent: nil, logger: nil)
|
|
17
35
|
@authz = authz
|
|
18
36
|
@client = client
|
|
19
37
|
@storage = storage
|
|
38
|
+
@issuer = issuer
|
|
39
|
+
@consent = consent
|
|
20
40
|
@logger = logger
|
|
21
41
|
end
|
|
22
42
|
|
|
@@ -31,19 +51,57 @@ module Himari
|
|
|
31
51
|
|
|
32
52
|
def app(env)
|
|
33
53
|
Rack::OAuth2::Server::Authorize.new do |req, res|
|
|
54
|
+
# RFC 9207: hand the issuer to rack-oauth2 so both the grant response and any error it
|
|
55
|
+
# redirects back to the client carry the `iss` parameter (see Himari::RackOAuth2Ext).
|
|
56
|
+
req.iss = @issuer
|
|
57
|
+
res.iss = @issuer
|
|
58
|
+
|
|
34
59
|
# sanity check
|
|
35
60
|
unless @client.id == req.client_id
|
|
36
61
|
@logger&.warn(Himari::LogLine.new('OidcAuthorizationEndpoint: @client.id != req.client_id', req: env['himari.request_as_log'], known_client: @client.id, given_client: req.client_id))
|
|
37
62
|
next req.bad_request!
|
|
38
63
|
end
|
|
39
64
|
raise "[BUG] client.id != authz.cilent_id" unless @authz.client_id == @client.id
|
|
40
|
-
|
|
65
|
+
|
|
66
|
+
given_redirect_uri = req.redirect_uri&.to_s
|
|
67
|
+
res.redirect_uri = if given_redirect_uri && !given_redirect_uri.empty?
|
|
68
|
+
# Raise before recording the redirect_uri so we never redirect errors to an unverified URI.
|
|
69
|
+
next req.bad_request!(:invalid_request, '"redirect_uri" mismatch') unless @client.redirect_uri_covers?(given_redirect_uri)
|
|
70
|
+
|
|
71
|
+
given_redirect_uri
|
|
72
|
+
elsif @client.redirect_uris.size == 1 && @client.redirect_uris.first.is_a?(String)
|
|
73
|
+
@client.redirect_uris.first
|
|
74
|
+
else
|
|
75
|
+
next req.bad_request!(:invalid_request, '"redirect_uri" missing')
|
|
76
|
+
end
|
|
77
|
+
# rack-oauth2 redirects subsequent errors back to the verified redirect_uri via this accessor.
|
|
78
|
+
req.verified_redirect_uri = res.redirect_uri
|
|
41
79
|
|
|
42
80
|
req.unsupported_response_type! if res.protocol_params_location == :fragment
|
|
43
81
|
req.bad_request!(:request_uri_not_supported, "Request Object is not implemented") if req.request_uri || req.request
|
|
44
82
|
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
83
|
raise ReauthenticationRequired if req.prompt.include?('login') || req.prompt.include?('select_account')
|
|
46
84
|
|
|
85
|
+
# Drop scopes this client does not recognise before they reach consent or the grant.
|
|
86
|
+
scopes = @client.filter_scopes(req.scope)
|
|
87
|
+
|
|
88
|
+
# Consent gate. Clients granted skip_consent (the default for dynamically/metadata-
|
|
89
|
+
# registered clients) bypass it; prompt=consent forces the page regardless.
|
|
90
|
+
if !@client.skip_consent || req.prompt.include?('consent')
|
|
91
|
+
case @consent
|
|
92
|
+
when :approve
|
|
93
|
+
# consent given; fall through and grant
|
|
94
|
+
when :deny
|
|
95
|
+
next req.access_denied!
|
|
96
|
+
else
|
|
97
|
+
# prompt=none forbids interaction (OIDC §3.1.2.1), so surface the error via redirect
|
|
98
|
+
# instead of rendering the page.
|
|
99
|
+
next req.consent_required! if req.prompt.include?('none')
|
|
100
|
+
|
|
101
|
+
raise ConsentRequired.new(client: @client, scopes: scopes)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
47
105
|
requested_response_types = [*req.response_type]
|
|
48
106
|
unless SUPPORTED_RESPONSE_TYPES.include?(requested_response_types.map(&:to_s).join(' '))
|
|
49
107
|
next req.unsupported_response_type!
|
|
@@ -53,7 +111,9 @@ module Himari
|
|
|
53
111
|
@authz.redirect_uri = res.redirect_uri
|
|
54
112
|
@authz.nonce = req.nonce
|
|
55
113
|
|
|
56
|
-
@authz.
|
|
114
|
+
@authz.scopes = scopes
|
|
115
|
+
@authz.openid = scopes.include?('openid')
|
|
116
|
+
@authz.offline_access = scopes.include?('offline_access')
|
|
57
117
|
if req.code_challenge && req.code_challenge_method
|
|
58
118
|
@authz.code_challenge = req.code_challenge
|
|
59
119
|
@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,42 @@ 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
|
+
authorization_response_iss_parameter_supported: true, # RFC 9207
|
|
64
|
+
}.compact
|
|
41
65
|
end
|
|
42
66
|
|
|
43
67
|
def response
|