keycardai-oauth 0.1.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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module OAuth
5
+ # Static client_id + client_secret credential, authenticating with
6
+ # client_secret_basic (RFC 6749 §2.3.1 / RFC 7617). The simplest
7
+ # credential type, suitable for development and workloads that can safely
8
+ # hold a long-lived secret.
9
+ #
10
+ # Implements the Credential interface: authorization_header(issuer:) plus
11
+ # prepare_token_exchange_request. Never reads environment variables; the
12
+ # caller sources the secret from its own config or secret manager.
13
+ #
14
+ # Single zone:
15
+ # ClientSecret.new("client_abc", "secret")
16
+ #
17
+ # Multi-zone, keyed by the zone's issuer URL:
18
+ # ClientSecret.new(
19
+ # "https://acme.keycard.cloud" => ["client_a", "secret_a"],
20
+ # "https://beta.keycard.cloud" => ["client_b", "secret_b"],
21
+ # )
22
+ #
23
+ # A token operation for a zone not in the map fails closed: the credential
24
+ # raises rather than fall back to another zone's secret.
25
+ class ClientSecret
26
+ # @overload initialize(client_id, client_secret)
27
+ # @overload initialize(issuer_map)
28
+ # @param issuer_map [Hash{String => Array(String, String)}]
29
+ # @raise [ConfigurationError] empty id/secret, or an empty zone map
30
+ def initialize(client_id_or_map, client_secret = nil)
31
+ if client_id_or_map.is_a?(Hash)
32
+ raise ConfigurationError, "multi-zone ClientSecret requires at least one zone" if client_id_or_map.empty?
33
+
34
+ @pairs = client_id_or_map.to_h do |issuer, (id, secret)|
35
+ validate_pair(id, secret)
36
+ [issuer.chomp("/"), [id, secret]]
37
+ end
38
+ else
39
+ validate_pair(client_id_or_map, client_secret)
40
+ @pair = [client_id_or_map, client_secret]
41
+ end
42
+ end
43
+
44
+ # @return [Boolean] whether this credential holds per-zone pairs
45
+ def multi_zone?
46
+ !@pairs.nil?
47
+ end
48
+
49
+ # @return [Array<String>] the issuer URLs this credential can serve
50
+ def issuers
51
+ multi_zone? ? @pairs.keys : []
52
+ end
53
+
54
+ # The HTTP Basic Authorization header for a token operation.
55
+ #
56
+ # @param issuer [String, nil] the target zone; required for multi-zone
57
+ # @return [String]
58
+ # @raise [ConfigurationError] multi-zone lookup for an unconfigured zone
59
+ # (fails closed, never another zone's secret)
60
+ def authorization_header(issuer: nil)
61
+ client_id, client_secret = resolve_pair(issuer)
62
+ HTTP.basic_authorization(client_id, client_secret)
63
+ end
64
+
65
+ # Build the token-exchange form parameters. Client authentication rides
66
+ # in the Basic header, never in the body.
67
+ #
68
+ # @param subject_token [String]
69
+ # @param resource [String, nil]
70
+ # @param audience [String, nil]
71
+ # @param scope [String, nil]
72
+ # @param token_endpoint [String, nil] unused; part of the interface
73
+ # @param issuer [String, nil] unused here; auth resolution is per-header
74
+ # @return [Hash]
75
+ def prepare_token_exchange_request(subject_token:, resource: nil, audience: nil, scope: nil,
76
+ token_endpoint: nil, issuer: nil)
77
+ {
78
+ "grant_type" => GrantType::TOKEN_EXCHANGE,
79
+ "subject_token" => subject_token,
80
+ "subject_token_type" => TokenType::ACCESS_TOKEN,
81
+ "resource" => resource,
82
+ "audience" => audience,
83
+ "scope" => scope
84
+ }.compact
85
+ end
86
+
87
+ private
88
+
89
+ def validate_pair(client_id, client_secret)
90
+ return unless client_id.nil? || client_id.empty? || client_secret.nil? || client_secret.empty?
91
+
92
+ raise ConfigurationError, "ClientSecret requires a non-empty client_id and client_secret"
93
+ end
94
+
95
+ def resolve_pair(issuer)
96
+ return @pair unless multi_zone?
97
+ raise ConfigurationError, "multi-zone ClientSecret requires an issuer to resolve credentials" if issuer.nil?
98
+
99
+ @pairs[issuer.chomp("/")] ||
100
+ raise(ConfigurationError, "no credentials configured for zone #{issuer}")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Keycardai
7
+ # Authorization-server discovery (RFC 8414): the operation, its metadata
8
+ # type, and the URL/parsing internals shared with JWKSKeyring.
9
+ module OAuth
10
+ # OAuth 2.0 authorization-server metadata (RFC 8414). Standard fields are
11
+ # first-class members; the complete document, including unknown fields, is
12
+ # preserved in +raw+ and reachable through +[]+.
13
+ AuthorizationServerMetadata = Data.define(
14
+ :issuer, :token_endpoint, :authorization_endpoint, :jwks_uri, :registration_endpoint,
15
+ :grant_types_supported, :token_endpoint_auth_methods_supported, :response_types_supported, :raw
16
+ ) do
17
+ # @param field [String] a metadata field name
18
+ # @return [Object, nil] the field's value from the full document
19
+ def [](field)
20
+ raw[field]
21
+ end
22
+ end
23
+
24
+ # Discover OAuth 2.0 authorization-server metadata from an issuer URL
25
+ # (RFC 8414). Performs a single fetch and does not cache; caching belongs
26
+ # to the callers that depend on the endpoints.
27
+ #
28
+ # @param issuer [String] the authorization server's identifier URL
29
+ # @param http_client [#get] pluggable transport
30
+ # @param timeout [Numeric, nil] fetch timeout in seconds
31
+ # @return [AuthorizationServerMetadata]
32
+ # @raise [ConfigurationError] empty or invalid issuer input
33
+ # @raise [HTTPError] non-2xx response
34
+ # @raise [ProtocolError] issuer mismatch (code issuer_mismatch) or a
35
+ # malformed document (code invalid_metadata)
36
+ # @raise [NetworkError] transport failure
37
+ def self.fetch_authorization_server_metadata(issuer, http_client: HTTP::NetHTTPClient.new, timeout: nil)
38
+ url = Discovery.metadata_url(issuer)
39
+ response = http_client.get(url, headers: { "Accept" => "application/json" }, timeout: timeout)
40
+ unless response.success?
41
+ raise HTTPError.new("discovery for #{issuer} returned HTTP #{response.status}",
42
+ status: response.status, body: response.body)
43
+ end
44
+
45
+ Discovery.parse_metadata(issuer, response.body)
46
+ end
47
+
48
+ # Internals of authorization-server discovery, shared with JWKSKeyring.
49
+ module Discovery
50
+ module_function
51
+
52
+ # RFC 8414: the well-known path segment is inserted between the host and
53
+ # any issuer path component.
54
+ def metadata_url(issuer)
55
+ raise ConfigurationError, "issuer must be a non-empty URL" if issuer.nil? || issuer.empty?
56
+
57
+ uri = URI(issuer)
58
+ raise ConfigurationError, "issuer must be an absolute http(s) URL" unless uri.is_a?(URI::HTTP)
59
+
60
+ path = uri.path.chomp("/")
61
+ uri.path = "/.well-known/oauth-authorization-server#{path}"
62
+ uri.to_s
63
+ rescue URI::InvalidURIError
64
+ raise ConfigurationError, "issuer is not a valid URL"
65
+ end
66
+
67
+ def parse_metadata(issuer, body)
68
+ document = parse_document(issuer, body)
69
+ validate_issuer(issuer, document)
70
+
71
+ AuthorizationServerMetadata.new(
72
+ issuer: document["issuer"],
73
+ token_endpoint: document["token_endpoint"],
74
+ authorization_endpoint: document["authorization_endpoint"],
75
+ jwks_uri: document["jwks_uri"],
76
+ registration_endpoint: document["registration_endpoint"],
77
+ grant_types_supported: document["grant_types_supported"],
78
+ token_endpoint_auth_methods_supported: document["token_endpoint_auth_methods_supported"],
79
+ response_types_supported: document["response_types_supported"],
80
+ raw: document
81
+ )
82
+ end
83
+
84
+ def parse_document(issuer, body)
85
+ document = JSON.parse(body)
86
+ unless document.is_a?(Hash)
87
+ raise ProtocolError.new("metadata for #{issuer} is not a JSON object",
88
+ code: "invalid_metadata")
89
+ end
90
+
91
+ document
92
+ rescue JSON::ParserError
93
+ raise ProtocolError.new("metadata for #{issuer} is not valid JSON", code: "invalid_metadata")
94
+ end
95
+
96
+ # RFC 8414 §3.3: the response issuer must be present and match the
97
+ # requested issuer, ignoring a trailing slash.
98
+ def validate_issuer(issuer, document)
99
+ unless document["issuer"].is_a?(String)
100
+ raise ProtocolError.new("metadata for #{issuer} has no issuer", code: "invalid_metadata")
101
+ end
102
+ return if document["issuer"].chomp("/") == issuer.chomp("/")
103
+
104
+ raise ProtocolError.new("metadata issuer #{document["issuer"]} does not match requested issuer #{issuer}",
105
+ code: "issuer_mismatch")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ # Root of the Keycard error taxonomy. Every error raised by any Keycard gem
5
+ # is a subclass, so `rescue Keycardai::Error` catches the whole family.
6
+ class Error < StandardError; end
7
+
8
+ module OAuth
9
+ # Invalid configuration detected at construction time: missing credential,
10
+ # empty issuer list, an algorithm the verifier does not implement.
11
+ class ConfigurationError < Keycardai::Error; end
12
+
13
+ # A transport-level failure (DNS, TLS, timeout) wrapping the underlying
14
+ # exception as +cause+.
15
+ class NetworkError < Keycardai::Error; end
16
+
17
+ # A non-2xx HTTP response. Carries the status code and response body.
18
+ class HTTPError < Keycardai::Error
19
+ # @return [Integer]
20
+ attr_reader :status
21
+ # @return [String]
22
+ attr_reader :body
23
+
24
+ def initialize(message, status:, body: "")
25
+ super(message)
26
+ @status = status
27
+ @body = body
28
+ end
29
+ end
30
+
31
+ # An OAuth error response from the authorization server (RFC 6749 §5.2).
32
+ # Carries the wire error code plus the optional description and URI.
33
+ class OAuthError < HTTPError
34
+ # @return [String] the OAuth error code (e.g. invalid_grant)
35
+ attr_reader :error
36
+ # @return [String, nil]
37
+ attr_reader :error_description
38
+ # @return [String, nil]
39
+ attr_reader :error_uri
40
+
41
+ def initialize(message, error:, status:, body: "", error_description: nil, error_uri: nil)
42
+ super(message, status: status, body: body)
43
+ @error = error
44
+ @error_description = error_description
45
+ @error_uri = error_uri
46
+ end
47
+ end
48
+
49
+ # The server's response violates the protocol contract: metadata with a
50
+ # mismatched or missing issuer, a token response with no access token.
51
+ # The +code+ discriminates the case (issuer_mismatch, invalid_metadata,
52
+ # invalid_response).
53
+ class ProtocolError < Keycardai::Error
54
+ # @return [String]
55
+ attr_reader :code
56
+
57
+ def initialize(message, code:)
58
+ super(message)
59
+ @code = code
60
+ end
61
+ end
62
+
63
+ # A bearer token failed verification: malformed, disallowed algorithm,
64
+ # untrusted issuer, missing required claim, expired, audience mismatch,
65
+ # missing kid, or bad signature. One category for every rejection, so
66
+ # callers handle all verification failures the same way.
67
+ class InvalidTokenError < Keycardai::Error; end
68
+
69
+ # Base class for JWKS key-resolution failures.
70
+ class JWKSError < Keycardai::Error; end
71
+
72
+ # Authorization server discovery failed or its metadata carries no
73
+ # +jwks_uri+.
74
+ class JWKSDiscoveryError < JWKSError; end
75
+
76
+ # The discovered +jwks_uri+ does not share the issuer's origin. Rejected
77
+ # before any key fetch.
78
+ class JWKSUriValidationError < JWKSError; end
79
+
80
+ # The JWKS endpoint returned a non-2xx response or could not be reached.
81
+ class JWKSFetchError < JWKSError; end
82
+
83
+ # The token's +kid+ is not present in the issuer's fetched JWKS.
84
+ class JWKSKeyNotFoundError < JWKSError; end
85
+
86
+ # The user did not complete the browser redirect within the loopback
87
+ # flow's callback timeout.
88
+ class InteractionTimeoutError < Keycardai::Error; end
89
+
90
+ # Raised by AccessContext#access when a token cannot be handed out. The
91
+ # error_type identifies the condition: global_error (a context-wide error
92
+ # is set), resource_error (the named resource's exchange failed), or
93
+ # missing_token (the resource was never granted). For missing_token,
94
+ # available_resources lists what was granted.
95
+ class ResourceAccessError < Keycardai::Error
96
+ # @return [String]
97
+ attr_reader :resource
98
+ # @return [String] global_error | resource_error | missing_token
99
+ attr_reader :error_type
100
+ # @return [Array<String>]
101
+ attr_reader :available_resources
102
+ # @return [Object, nil] the recorded upstream error, when one exists
103
+ attr_reader :error_details
104
+
105
+ def initialize(message, resource:, error_type:, available_resources: [], error_details: nil)
106
+ super(message)
107
+ @resource = resource
108
+ @error_type = error_type
109
+ @available_resources = available_resources
110
+ @error_details = error_details
111
+ end
112
+ end
113
+
114
+ # A workload-identity source is misconfigured at construction: missing
115
+ # token file, no discovery env var set, missing required audience.
116
+ # Carries the source identifier (file, gcp-metadata, fly, custom).
117
+ class WorkloadIdentityConfigurationError < ConfigurationError
118
+ # @return [String]
119
+ attr_reader :source
120
+
121
+ def initialize(message, source:)
122
+ super(message)
123
+ @source = source
124
+ end
125
+ end
126
+
127
+ # A workload-identity source failed at request time: file unreadable or
128
+ # empty, endpoint unreachable, non-200 response, empty token. Carries the
129
+ # source identifier and preserves the underlying cause.
130
+ class WorkloadIdentityRuntimeError < Keycardai::Error
131
+ # @return [String]
132
+ attr_reader :source
133
+
134
+ def initialize(message, source:)
135
+ super(message)
136
+ @source = source
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ # The delegated-access orchestration that feeds an AccessContext.
5
+ module OAuth
6
+ # Exchange a subject token for tokens targeting multiple resources,
7
+ # recording each success or failure on an AccessContext. Non-throwing by
8
+ # design: per-resource failures land on the context so a partial-success
9
+ # flow can proceed. When user_identifier is set, each exchange is an
10
+ # impersonation instead of a subject-token exchange.
11
+ #
12
+ # @param client [TokenExchangeClient] carries the credential and zone
13
+ # @param resources [Array<String>] the target resources
14
+ # @param subject_token [String, nil] the inbound token being delegated;
15
+ # required unless user_identifier is given
16
+ # @param access_context [AccessContext] the container to populate
17
+ # @param user_identifier [String, nil] impersonation target
18
+ # @param request_scopes [String, Hash{String => String}, nil] scopes for
19
+ # every exchange, or a per-resource map
20
+ # @param issuer [String, nil] per-call zone selection
21
+ # @return [AccessContext]
22
+ def self.exchange_tokens_for_resources(client:, resources:, subject_token: nil,
23
+ access_context: AccessContext.new, user_identifier: nil,
24
+ request_scopes: nil, issuer: nil)
25
+ if subject_token.nil? && user_identifier.nil?
26
+ raise ArgumentError, "subject_token is required unless user_identifier is given"
27
+ end
28
+
29
+ resources.each do |resource|
30
+ scope = request_scopes.is_a?(Hash) ? request_scopes[resource] : request_scopes
31
+ token = ExchangeTokens.exchange_one(client: client, resource: resource, subject_token: subject_token,
32
+ user_identifier: user_identifier, scope: scope, issuer: issuer)
33
+ access_context.set_token(resource, token)
34
+ rescue Keycardai::Error => e
35
+ access_context.set_resource_error(resource, e)
36
+ end
37
+ access_context
38
+ end
39
+
40
+ # Internals of exchange_tokens_for_resources. Not public API.
41
+ module ExchangeTokens
42
+ module_function
43
+
44
+ def exchange_one(client:, resource:, subject_token:, user_identifier:, scope:, issuer:)
45
+ if user_identifier
46
+ client.impersonate(user_identifier: user_identifier, resource: resource, scope: scope, issuer: issuer)
47
+ else
48
+ client.exchange_token(subject_token: subject_token, resource: resource, scope: scope, issuer: issuer)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Keycardai
8
+ module OAuth
9
+ # Pluggable HTTP transport. Every network operation in the SDK goes
10
+ # through an object with this surface, so tests and custom transport
11
+ # policies can inject their own client. No hidden retries: retry policy
12
+ # belongs to the injected client.
13
+ module HTTP
14
+ # A minimal HTTP response: numeric status, header hash, body string.
15
+ Response = Data.define(:status, :headers, :body) do
16
+ # @return [Boolean] whether the status is 2xx
17
+ def success?
18
+ (200..299).cover?(status)
19
+ end
20
+ end
21
+
22
+ # Build an HTTP Basic Authorization header value (RFC 7617).
23
+ #
24
+ # @return [String]
25
+ def self.basic_authorization(client_id, client_secret)
26
+ "Basic #{["#{client_id}:#{client_secret}"].pack("m0")}"
27
+ end
28
+
29
+ # Default transport backed by Net::HTTP. TLS is used for https URLs.
30
+ class NetHTTPClient
31
+ # @param url [String]
32
+ # @param headers [Hash{String => String}]
33
+ # @param timeout [Numeric, nil] open/read timeout in seconds
34
+ # @return [Response]
35
+ # @raise [NetworkError] on DNS, TLS, connect, or timeout failures
36
+ def get(url, headers: {}, timeout: nil)
37
+ uri = URI(url)
38
+ request = Net::HTTP::Get.new(uri)
39
+ headers.each { |name, value| request[name] = value }
40
+ perform(uri, request, timeout)
41
+ end
42
+
43
+ # @param url [String]
44
+ # @param params [Hash] form fields, sent application/x-www-form-urlencoded
45
+ # @param headers [Hash{String => String}]
46
+ # @param timeout [Numeric, nil] open/read timeout in seconds
47
+ # @return [Response]
48
+ # @raise [NetworkError] on DNS, TLS, connect, or timeout failures
49
+ def post_form(url, params, headers: {}, timeout: nil)
50
+ uri = URI(url)
51
+ request = Net::HTTP::Post.new(uri)
52
+ headers.each { |name, value| request[name] = value }
53
+ request.set_form_data(params)
54
+ perform(uri, request, timeout)
55
+ end
56
+
57
+ # @param url [String]
58
+ # @param payload [Hash] request body, sent as application/json
59
+ # @param headers [Hash{String => String}]
60
+ # @param timeout [Numeric, nil] open/read timeout in seconds
61
+ # @return [Response]
62
+ # @raise [NetworkError] on DNS, TLS, connect, or timeout failures
63
+ def post_json(url, payload, headers: {}, timeout: nil)
64
+ uri = URI(url)
65
+ request = Net::HTTP::Post.new(uri)
66
+ request["Content-Type"] = "application/json"
67
+ headers.each { |name, value| request[name] = value }
68
+ request.body = JSON.dump(payload)
69
+ perform(uri, request, timeout)
70
+ end
71
+
72
+ private
73
+
74
+ def perform(uri, request, timeout)
75
+ http = Net::HTTP.new(uri.host, uri.port)
76
+ http.use_ssl = uri.scheme == "https"
77
+ if timeout
78
+ http.open_timeout = timeout
79
+ http.read_timeout = timeout
80
+ end
81
+ response = http.request(request)
82
+ Response.new(status: response.code.to_i, headers: response.to_hash, body: response.body.to_s)
83
+ rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, EOFError => e
84
+ raise NetworkError, "request to #{uri.host} failed: #{e.class}"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+ require "uri"
6
+
7
+ module Keycardai
8
+ module OAuth
9
+ # Resolves and caches JWKS verification keys for an issuer (RFC 7517), so
10
+ # bearer-token verification on a hot path does not hit the network per
11
+ # request. Resolution is a lookup keyed by (issuer, kid): discover the
12
+ # issuer's jwks_uri (RFC 8414), fetch the key set, select the key matching
13
+ # the kid. Both steps are cached with a TTL; the key cache is bounded and
14
+ # evicts its oldest entry on overflow.
15
+ #
16
+ # The cache is in-memory and local to this instance. Thread-safe:
17
+ # concurrent resolutions for the same issuer are de-duplicated so a burst
18
+ # of cold-cache requests triggers a single discovery and a single JWKS
19
+ # fetch.
20
+ class JWKSKeyring
21
+ DEFAULT_KEY_TTL = 300
22
+ DEFAULT_DISCOVERY_TTL = 3600
23
+ DEFAULT_FETCH_TIMEOUT = 10
24
+ MAX_CACHED_KEYS = 256
25
+
26
+ # @param http_client [#get] pluggable transport (see HTTP::NetHTTPClient)
27
+ # @param key_ttl [Numeric] per-key cache lifetime in seconds
28
+ # @param discovery_ttl [Numeric] jwks_uri cache lifetime in seconds
29
+ # @param fetch_timeout [Numeric] timeout for discovery and JWKS fetches
30
+ # @param clock [#call] returns the current Time; override in tests
31
+ def initialize(http_client: HTTP::NetHTTPClient.new, key_ttl: DEFAULT_KEY_TTL,
32
+ discovery_ttl: DEFAULT_DISCOVERY_TTL, fetch_timeout: DEFAULT_FETCH_TIMEOUT,
33
+ clock: -> { Time.now })
34
+ @http_client = http_client
35
+ @key_ttl = key_ttl
36
+ @discovery_ttl = discovery_ttl
37
+ @fetch_timeout = fetch_timeout
38
+ @clock = clock
39
+ @keys = {}
40
+ @discovery = {}
41
+ @issuer_locks = {}
42
+ @mutex = Mutex.new
43
+ end
44
+
45
+ # Resolve the verification key for a token's issuer and kid.
46
+ #
47
+ # @param issuer [String] the token issuer URL
48
+ # @param kid [String] the kid from the JWT header
49
+ # @return [OpenSSL::PKey::PKey] the verification key
50
+ # @raise [JWKSDiscoveryError] discovery failed or metadata has no jwks_uri
51
+ # @raise [JWKSUriValidationError] jwks_uri is cross-origin with the issuer
52
+ # @raise [JWKSFetchError] the JWKS endpoint failed
53
+ # @raise [JWKSKeyNotFoundError] the kid is absent from the fetched JWKS
54
+ def key(issuer, kid)
55
+ cached = @mutex.synchronize { fresh_key(issuer, kid) }
56
+ return cached if cached
57
+
58
+ issuer_lock(issuer).synchronize do
59
+ cached = @mutex.synchronize { fresh_key(issuer, kid) }
60
+ cached || resolve(issuer, kid)
61
+ end
62
+ end
63
+
64
+ # Drop cached keys and discovery results, for one issuer or all.
65
+ #
66
+ # @param issuer [String, nil] limit invalidation to this issuer
67
+ # @return [void]
68
+ def invalidate(issuer = nil)
69
+ @mutex.synchronize do
70
+ if issuer
71
+ @keys.delete_if { |(cached_issuer, _), _| cached_issuer == issuer }
72
+ @discovery.delete(issuer)
73
+ else
74
+ @keys.clear
75
+ @discovery.clear
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def fresh_key(issuer, kid)
83
+ entry = @keys[[issuer, kid]]
84
+ return nil unless entry
85
+ return nil if now - entry[:cached_at] > @key_ttl
86
+
87
+ entry[:key]
88
+ end
89
+
90
+ def issuer_lock(issuer)
91
+ @mutex.synchronize { @issuer_locks[issuer] ||= Mutex.new }
92
+ end
93
+
94
+ def resolve(issuer, kid)
95
+ jwks_uri = resolve_jwks_uri(issuer)
96
+ jwk = fetch_jwks(jwks_uri).find { |candidate| candidate["kid"] == kid }
97
+ raise JWKSKeyNotFoundError, "kid #{kid.inspect} not found in JWKS for #{issuer}" unless jwk
98
+
99
+ key = import_key(jwk)
100
+ cache_key(issuer, kid, key)
101
+ key
102
+ end
103
+
104
+ def resolve_jwks_uri(issuer)
105
+ cached = @mutex.synchronize do
106
+ entry = @discovery[issuer]
107
+ entry[:jwks_uri] if entry && now - entry[:fetched_at] <= @discovery_ttl
108
+ end
109
+ return cached if cached
110
+
111
+ jwks_uri = discover_jwks_uri(issuer)
112
+ assert_same_origin(issuer, jwks_uri)
113
+ @mutex.synchronize { @discovery[issuer] = { jwks_uri: jwks_uri, fetched_at: now } }
114
+ jwks_uri
115
+ end
116
+
117
+ def discover_jwks_uri(issuer)
118
+ metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client,
119
+ timeout: @fetch_timeout)
120
+ metadata.jwks_uri || raise(JWKSDiscoveryError, "metadata for #{issuer} has no jwks_uri")
121
+ rescue HTTPError, ProtocolError, NetworkError, ConfigurationError => e
122
+ raise JWKSDiscoveryError, "discovery for #{issuer} failed: #{e.message}"
123
+ end
124
+
125
+ def assert_same_origin(issuer, jwks_uri)
126
+ issuer_uri = URI(issuer)
127
+ keys_uri = URI(jwks_uri)
128
+ same = issuer_uri.scheme == keys_uri.scheme && issuer_uri.host == keys_uri.host &&
129
+ issuer_uri.port == keys_uri.port
130
+ return if same
131
+
132
+ raise JWKSUriValidationError, "jwks_uri #{jwks_uri} is cross-origin with issuer #{issuer}"
133
+ end
134
+
135
+ def fetch_jwks(jwks_uri)
136
+ response = begin
137
+ @http_client.get(jwks_uri, headers: { "Accept" => "application/json" }, timeout: @fetch_timeout)
138
+ rescue NetworkError => e
139
+ raise JWKSFetchError, "JWKS fetch from #{jwks_uri} failed: #{e.message}"
140
+ end
141
+ raise JWKSFetchError, "JWKS fetch from #{jwks_uri} returned HTTP #{response.status}" unless response.success?
142
+
143
+ document = begin
144
+ JSON.parse(response.body)
145
+ rescue JSON::ParserError
146
+ raise JWKSFetchError, "JWKS from #{jwks_uri} is invalid JSON"
147
+ end
148
+ document["keys"] || raise(JWKSFetchError, "JWKS from #{jwks_uri} has no keys field")
149
+ end
150
+
151
+ def import_key(jwk)
152
+ JWT::JWK.import(jwk.transform_keys(&:to_sym)).verify_key
153
+ rescue JWT::JWKError, ArgumentError
154
+ raise JWKSKeyNotFoundError, "JWK with kid #{jwk["kid"].inspect} could not be converted to a verification key"
155
+ end
156
+
157
+ def cache_key(issuer, kid, key)
158
+ @mutex.synchronize do
159
+ @keys.shift while @keys.size >= MAX_CACHED_KEYS
160
+ @keys[[issuer, kid]] = { key: key, cached_at: now }
161
+ end
162
+ end
163
+
164
+ def now
165
+ @clock.call
166
+ end
167
+ end
168
+ end
169
+ end