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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +9 -0
- data/README.md +100 -0
- data/lib/keycardai/oauth/access_context.rb +161 -0
- data/lib/keycardai/oauth/authenticate.rb +223 -0
- data/lib/keycardai/oauth/authorization_code.rb +80 -0
- data/lib/keycardai/oauth/client_credentials_client.rb +58 -0
- data/lib/keycardai/oauth/client_secret.rb +104 -0
- data/lib/keycardai/oauth/discovery.rb +109 -0
- data/lib/keycardai/oauth/errors.rb +140 -0
- data/lib/keycardai/oauth/exchange_tokens.rb +53 -0
- data/lib/keycardai/oauth/http.rb +89 -0
- data/lib/keycardai/oauth/jwks_keyring.rb +169 -0
- data/lib/keycardai/oauth/jwt_signer.rb +45 -0
- data/lib/keycardai/oauth/jwt_verifier.rb +146 -0
- data/lib/keycardai/oauth/pkce.rb +62 -0
- data/lib/keycardai/oauth/private_key.rb +110 -0
- data/lib/keycardai/oauth/registration.rb +121 -0
- data/lib/keycardai/oauth/substitute_user.rb +29 -0
- data/lib/keycardai/oauth/token_exchange_client.rb +117 -0
- data/lib/keycardai/oauth/token_requests.rb +99 -0
- data/lib/keycardai/oauth/token_sources.rb +169 -0
- data/lib/keycardai/oauth/token_types.rb +51 -0
- data/lib/keycardai/oauth/token_verifier.rb +104 -0
- data/lib/keycardai/oauth/version.rb +7 -0
- data/lib/keycardai/oauth/web_identity.rb +106 -0
- data/lib/keycardai/oauth/workload_identity.rb +104 -0
- data/lib/keycardai/oauth.rb +38 -0
- metadata +87 -0
|
@@ -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
|