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,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Keycardai
|
|
6
|
+
module OAuth
|
|
7
|
+
# Shared internals for clients that POST to the token endpoint: lazy
|
|
8
|
+
# endpoint discovery with caching, shared-secret (HTTP Basic) client
|
|
9
|
+
# authentication, and RFC 6749 §5.2 error parsing. Not public API.
|
|
10
|
+
module TokenRequests
|
|
11
|
+
# Parse a token-endpoint response: a TokenResponse on 2xx, a raised
|
|
12
|
+
# typed error otherwise.
|
|
13
|
+
#
|
|
14
|
+
# @param response [HTTP::Response]
|
|
15
|
+
# @return [TokenResponse]
|
|
16
|
+
def self.parse_response(response)
|
|
17
|
+
raise error_for(response) unless response.success?
|
|
18
|
+
|
|
19
|
+
document = begin
|
|
20
|
+
JSON.parse(response.body)
|
|
21
|
+
rescue JSON::ParserError
|
|
22
|
+
raise ProtocolError.new("token response is not valid JSON", code: "invalid_response")
|
|
23
|
+
end
|
|
24
|
+
unless document.is_a?(Hash)
|
|
25
|
+
raise ProtocolError.new("token response is not a JSON object",
|
|
26
|
+
code: "invalid_response")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
TokenResponse.from_wire(document)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# RFC 6749 §5.2: an error response carries error / error_description /
|
|
33
|
+
# error_uri. Anything else non-2xx is a plain HTTP error.
|
|
34
|
+
#
|
|
35
|
+
# @param response [HTTP::Response]
|
|
36
|
+
# @return [OAuthError, HTTPError]
|
|
37
|
+
def self.error_for(response)
|
|
38
|
+
payload = begin
|
|
39
|
+
JSON.parse(response.body)
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
unless payload.is_a?(Hash) && payload["error"].is_a?(String)
|
|
44
|
+
return HTTPError.new("token endpoint returned HTTP #{response.status}",
|
|
45
|
+
status: response.status, body: response.body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
OAuthError.new("token endpoint returned #{payload["error"]}",
|
|
49
|
+
error: payload["error"], error_description: payload["error_description"],
|
|
50
|
+
error_uri: payload["error_uri"], status: response.status, body: response.body)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def initialize_token_client(issuer:, credential:, client_id:, client_secret:, http_client:, timeout:)
|
|
56
|
+
validate_client_auth(credential, client_id, client_secret)
|
|
57
|
+
|
|
58
|
+
@issuer = issuer
|
|
59
|
+
@credential = credential || (client_id ? ClientSecret.new(client_id, client_secret) : nil)
|
|
60
|
+
@http_client = http_client
|
|
61
|
+
@timeout = timeout
|
|
62
|
+
@token_endpoints = {}
|
|
63
|
+
@token_endpoint_mutex = Mutex.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_client_auth(credential, client_id, client_secret)
|
|
67
|
+
if credential && (client_id || client_secret)
|
|
68
|
+
raise ConfigurationError, "provide a credential or a client_id/client_secret pair, not both"
|
|
69
|
+
end
|
|
70
|
+
return unless (client_id.nil? || client_secret.nil?) && client_id != client_secret
|
|
71
|
+
|
|
72
|
+
raise ConfigurationError, "client_id and client_secret must be provided together"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Discover a zone's token endpoint once and cache it, keyed by issuer
|
|
76
|
+
# so multi-zone clients never reuse another zone's endpoint.
|
|
77
|
+
def token_endpoint(issuer = @issuer)
|
|
78
|
+
@token_endpoint_mutex.synchronize do
|
|
79
|
+
@token_endpoints[issuer] ||= begin
|
|
80
|
+
metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client,
|
|
81
|
+
timeout: @timeout)
|
|
82
|
+
metadata.token_endpoint ||
|
|
83
|
+
raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def post_token_request(params, issuer: @issuer)
|
|
89
|
+
headers = { "Accept" => "application/json" }
|
|
90
|
+
authorization = @credential&.authorization_header(issuer: issuer)
|
|
91
|
+
headers["Authorization"] = authorization if authorization
|
|
92
|
+
|
|
93
|
+
response = @http_client.post_form(token_endpoint(issuer), params.compact, headers: headers,
|
|
94
|
+
timeout: @timeout)
|
|
95
|
+
TokenRequests.parse_response(response)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Keycardai
|
|
8
|
+
module OAuth
|
|
9
|
+
# Reads a platform-projected identity token file, fresh on every call.
|
|
10
|
+
# Covers EKS (AWS_* env vars), AKS (AZURE_FEDERATED_TOKEN_FILE), any
|
|
11
|
+
# Kubernetes projected service-account token, and file-mode CI providers.
|
|
12
|
+
#
|
|
13
|
+
# Path discovery through the well-known env vars below is the one blessed
|
|
14
|
+
# environment read in the SDK (a deliberate platform convention). An
|
|
15
|
+
# explicit token_file_path wins; env_var_name is consulted first when
|
|
16
|
+
# given. The resolved file is validated at construction (exists,
|
|
17
|
+
# non-empty), preserving fail-fast behavior.
|
|
18
|
+
class FileTokenSource
|
|
19
|
+
DEFAULT_ENV_VARS = %w[
|
|
20
|
+
KEYCARD_EKS_WORKLOAD_IDENTITY_TOKEN_FILE
|
|
21
|
+
AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE
|
|
22
|
+
AWS_WEB_IDENTITY_TOKEN_FILE
|
|
23
|
+
AZURE_FEDERATED_TOKEN_FILE
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# @param token_file_path [String, nil] explicit path; skips discovery
|
|
27
|
+
# @param env_var_name [String, nil] extra env var consulted first
|
|
28
|
+
# @param env [Hash] the environment to discover from; override in tests
|
|
29
|
+
# @raise [WorkloadIdentityConfigurationError] no path resolved, or the
|
|
30
|
+
# resolved file is missing or empty
|
|
31
|
+
def initialize(token_file_path: nil, env_var_name: nil, env: ENV)
|
|
32
|
+
@path = token_file_path || discover_path(env_var_name, env)
|
|
33
|
+
validate_file
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String] the current platform token, read fresh
|
|
37
|
+
# @raise [WorkloadIdentityRuntimeError] unreadable or empty file
|
|
38
|
+
def identity_token
|
|
39
|
+
token = File.read(@path).strip
|
|
40
|
+
raise WorkloadIdentityRuntimeError.new("token file #{@path} is empty", source: "file") if token.empty?
|
|
41
|
+
|
|
42
|
+
token
|
|
43
|
+
rescue SystemCallError => e
|
|
44
|
+
raise WorkloadIdentityRuntimeError.new("token file #{@path} is unreadable: #{e.message}", source: "file")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def source_identifier
|
|
48
|
+
"file"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def discover_path(env_var_name, env)
|
|
54
|
+
[env_var_name, *DEFAULT_ENV_VARS].compact.each do |name|
|
|
55
|
+
value = env[name]
|
|
56
|
+
return value if value && !value.empty?
|
|
57
|
+
end
|
|
58
|
+
raise WorkloadIdentityConfigurationError.new(
|
|
59
|
+
"no token_file_path given and no discovery env var set (#{DEFAULT_ENV_VARS.join(", ")})",
|
|
60
|
+
source: "file"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_file
|
|
65
|
+
return if File.file?(@path) && !File.empty?(@path)
|
|
66
|
+
|
|
67
|
+
raise WorkloadIdentityConfigurationError.new("token file #{@path} is missing or empty", source: "file")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fetches an identity token from the GCP metadata server. Covers GKE,
|
|
72
|
+
# GCE, and Cloud Run.
|
|
73
|
+
class GCPMetadataTokenSource
|
|
74
|
+
DEFAULT_METADATA_URL = "http://metadata.google.internal"
|
|
75
|
+
IDENTITY_PATH = "/computeMetadata/v1/instance/service-accounts/default/identity"
|
|
76
|
+
|
|
77
|
+
# @param audience [String] the aud claim the platform mints into the token
|
|
78
|
+
# @param metadata_url [String] override for testing
|
|
79
|
+
# @param timeout [Numeric, nil]
|
|
80
|
+
# @param http_client [#get] pluggable transport
|
|
81
|
+
# @raise [WorkloadIdentityConfigurationError] missing audience
|
|
82
|
+
def initialize(audience:, metadata_url: DEFAULT_METADATA_URL, timeout: nil,
|
|
83
|
+
http_client: HTTP::NetHTTPClient.new)
|
|
84
|
+
if audience.nil? || audience.empty?
|
|
85
|
+
raise WorkloadIdentityConfigurationError.new("GCPMetadataTokenSource requires an audience",
|
|
86
|
+
source: "gcp-metadata")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@audience = audience
|
|
90
|
+
@metadata_url = metadata_url
|
|
91
|
+
@timeout = timeout
|
|
92
|
+
@http_client = http_client
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String] the current platform token
|
|
96
|
+
# @raise [WorkloadIdentityRuntimeError] endpoint unreachable or non-200
|
|
97
|
+
def identity_token
|
|
98
|
+
url = "#{@metadata_url}#{IDENTITY_PATH}?#{URI.encode_www_form("audience" => @audience, "format" => "full")}"
|
|
99
|
+
response = begin
|
|
100
|
+
@http_client.get(url, headers: { "Metadata-Flavor" => "Google" }, timeout: @timeout)
|
|
101
|
+
rescue NetworkError => e
|
|
102
|
+
raise WorkloadIdentityRuntimeError.new("metadata server unreachable: #{e.message}", source: "gcp-metadata")
|
|
103
|
+
end
|
|
104
|
+
unless response.success?
|
|
105
|
+
raise WorkloadIdentityRuntimeError.new("metadata server returned HTTP #{response.status}",
|
|
106
|
+
source: "gcp-metadata")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
response.body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def source_identifier
|
|
113
|
+
"gcp-metadata"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Fetches an identity token from the Fly.io machines API over its local
|
|
118
|
+
# Unix socket.
|
|
119
|
+
class FlyTokenSource
|
|
120
|
+
DEFAULT_SOCKET_PATH = "/.fly/api"
|
|
121
|
+
TOKEN_PATH = "/v1/tokens/oidc"
|
|
122
|
+
|
|
123
|
+
# @param audience [String, nil] included as {"aud": audience} when set
|
|
124
|
+
# @param socket_path [String]
|
|
125
|
+
def initialize(audience: nil, socket_path: DEFAULT_SOCKET_PATH)
|
|
126
|
+
@audience = audience
|
|
127
|
+
@socket_path = socket_path
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [String] the current platform token
|
|
131
|
+
# @raise [WorkloadIdentityRuntimeError] socket unreachable or non-200
|
|
132
|
+
def identity_token
|
|
133
|
+
body = JSON.dump(@audience ? { "aud" => @audience } : {})
|
|
134
|
+
status, response_body = post_over_socket(body)
|
|
135
|
+
unless (200..299).cover?(status)
|
|
136
|
+
raise WorkloadIdentityRuntimeError.new("fly API returned HTTP #{status}", source: "fly")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
response_body
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def source_identifier
|
|
143
|
+
"fly"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def post_over_socket(body)
|
|
149
|
+
UNIXSocket.open(@socket_path) do |socket|
|
|
150
|
+
socket.write("POST #{TOKEN_PATH} HTTP/1.1\r\n" \
|
|
151
|
+
"Host: localhost\r\n" \
|
|
152
|
+
"Content-Type: application/json\r\n" \
|
|
153
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
154
|
+
"Connection: close\r\n\r\n#{body}")
|
|
155
|
+
parse_http_response(socket.read)
|
|
156
|
+
end
|
|
157
|
+
rescue SystemCallError => e
|
|
158
|
+
raise WorkloadIdentityRuntimeError.new("fly API socket #{@socket_path} unreachable: #{e.message}",
|
|
159
|
+
source: "fly")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_http_response(raw)
|
|
163
|
+
header, body = raw.split("\r\n\r\n", 2)
|
|
164
|
+
status = header[%r{\AHTTP/\d\.\d (\d{3})}, 1].to_i
|
|
165
|
+
[status, body.to_s]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keycardai
|
|
4
|
+
module OAuth
|
|
5
|
+
# RFC 8693 token-type URIs, plus the Keycard substitute-user extension.
|
|
6
|
+
module TokenType
|
|
7
|
+
ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
|
|
8
|
+
REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token"
|
|
9
|
+
ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
|
|
10
|
+
JWT = "urn:ietf:params:oauth:token-type:jwt"
|
|
11
|
+
SUBSTITUTE_USER = "urn:keycard:params:oauth:token-type:substitute-user"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# OAuth 2.0 grant-type identifiers the SDK speaks.
|
|
15
|
+
module GrantType
|
|
16
|
+
CLIENT_CREDENTIALS = "client_credentials"
|
|
17
|
+
AUTHORIZATION_CODE = "authorization_code"
|
|
18
|
+
TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A token issued by the authorization server, the shared response shape of
|
|
22
|
+
# token exchange, client credentials, and the authorization-code exchange.
|
|
23
|
+
# +scope+ is parsed from the space-separated wire value into an array;
|
|
24
|
+
# +token_type+ defaults to "Bearer" when the server omits it. The complete
|
|
25
|
+
# response body is preserved in +raw+.
|
|
26
|
+
TokenResponse = Data.define(
|
|
27
|
+
:access_token, :token_type, :expires_in, :scope, :issued_token_type, :refresh_token, :id_token, :raw
|
|
28
|
+
) do
|
|
29
|
+
# @param payload [Hash] the parsed token-endpoint response body
|
|
30
|
+
# @return [TokenResponse]
|
|
31
|
+
# @raise [ProtocolError] when the response carries no access token
|
|
32
|
+
def self.from_wire(payload)
|
|
33
|
+
access_token = payload["access_token"]
|
|
34
|
+
unless access_token.is_a?(String) && !access_token.empty?
|
|
35
|
+
raise ProtocolError.new("token response has no access_token", code: "invalid_response")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
access_token: access_token,
|
|
40
|
+
token_type: payload["token_type"] || "Bearer",
|
|
41
|
+
expires_in: payload["expires_in"],
|
|
42
|
+
scope: payload["scope"]&.split,
|
|
43
|
+
issued_token_type: payload["issued_token_type"],
|
|
44
|
+
refresh_token: payload["refresh_token"],
|
|
45
|
+
id_token: payload["id_token"],
|
|
46
|
+
raw: payload
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keycardai
|
|
4
|
+
module OAuth
|
|
5
|
+
# A verified bearer token: the raw compact JWT plus its verified claims,
|
|
6
|
+
# with convenience accessors for the RFC 9068 profile.
|
|
7
|
+
AccessToken = Data.define(:token, :claims) do
|
|
8
|
+
# @return [String] the token's subject
|
|
9
|
+
def subject
|
|
10
|
+
claims["sub"]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [String]
|
|
14
|
+
def client_id
|
|
15
|
+
claims["client_id"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] the issuer that minted the token
|
|
19
|
+
def issuer
|
|
20
|
+
claims["iss"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] audiences the token was minted for
|
|
24
|
+
def audiences
|
|
25
|
+
Array(claims["aud"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array<String>] scopes granted to the token
|
|
29
|
+
def scopes
|
|
30
|
+
claims["scope"].to_s.split
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Time]
|
|
34
|
+
def expires_at
|
|
35
|
+
Time.at(claims["exp"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Server-tier bearer-token verification: JWTVerifier over a JWKSKeyring,
|
|
40
|
+
# returning an AccessToken. Construct once per server with the trusted
|
|
41
|
+
# zone issuer(s); JWKS and discovery caches are keyed by issuer so no
|
|
42
|
+
# zone's keys are ever served for another.
|
|
43
|
+
class TokenVerifier
|
|
44
|
+
# @param issuers [String, Array<String>] trusted zone issuer URL(s)
|
|
45
|
+
# @param audiences [String, Array<String>, nil] when set, tokens must
|
|
46
|
+
# carry an intersecting aud
|
|
47
|
+
# @param http_client [#get] pluggable transport
|
|
48
|
+
# @param key_ttl [Numeric] JWKS key cache lifetime in seconds
|
|
49
|
+
# @param discovery_ttl [Numeric] jwks_uri cache lifetime in seconds
|
|
50
|
+
# @param fetch_timeout [Numeric] discovery and JWKS fetch timeout
|
|
51
|
+
# @param clock [#call] returns the current Time; override in tests
|
|
52
|
+
# @raise [ConfigurationError] no trusted issuer
|
|
53
|
+
def initialize(issuers:, audiences: nil, http_client: HTTP::NetHTTPClient.new,
|
|
54
|
+
key_ttl: JWKSKeyring::DEFAULT_KEY_TTL, discovery_ttl: JWKSKeyring::DEFAULT_DISCOVERY_TTL,
|
|
55
|
+
fetch_timeout: JWKSKeyring::DEFAULT_FETCH_TIMEOUT, clock: -> { Time.now })
|
|
56
|
+
@issuers = Array(issuers).reject { |issuer| issuer.nil? || issuer.empty? }
|
|
57
|
+
raise ConfigurationError, "TokenVerifier requires at least one trusted issuer" if @issuers.empty?
|
|
58
|
+
|
|
59
|
+
@audiences = audiences
|
|
60
|
+
@clock = clock
|
|
61
|
+
@keyring = JWKSKeyring.new(http_client: http_client, key_ttl: key_ttl,
|
|
62
|
+
discovery_ttl: discovery_ttl, fetch_timeout: fetch_timeout, clock: clock)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Verify a bearer token against the configured issuer allowlist.
|
|
66
|
+
#
|
|
67
|
+
# @param token [String]
|
|
68
|
+
# @return [AccessToken]
|
|
69
|
+
# @raise [InvalidTokenError, JWKSError]
|
|
70
|
+
def verify_token(token)
|
|
71
|
+
verify_against(@issuers, token)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Verify a bearer token pinned to one zone's issuer. A token minted by
|
|
75
|
+
# any other zone is rejected before key resolution, and an issuer
|
|
76
|
+
# outside the configured allowlist fails closed.
|
|
77
|
+
#
|
|
78
|
+
# @param token [String]
|
|
79
|
+
# @param issuer [String] the zone's issuer URL
|
|
80
|
+
# @return [AccessToken]
|
|
81
|
+
# @raise [InvalidTokenError, JWKSError]
|
|
82
|
+
def verify_token_for_zone(token, issuer)
|
|
83
|
+
raise InvalidTokenError, "zone issuer is not configured on this verifier" unless @issuers.include?(issuer)
|
|
84
|
+
|
|
85
|
+
verify_against([issuer], token)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Drop all cached keys and discovery results.
|
|
89
|
+
#
|
|
90
|
+
# @return [void]
|
|
91
|
+
def clear_cache
|
|
92
|
+
@keyring.invalidate
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def verify_against(issuers, token)
|
|
98
|
+
claims = JWTVerifier.new(issuers: issuers, keyring: @keyring, audiences: @audiences,
|
|
99
|
+
clock: @clock).verify(token)
|
|
100
|
+
AccessToken.new(token: token, claims: claims)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Keycardai
|
|
6
|
+
module OAuth
|
|
7
|
+
# private_key_jwt credential (RFC 7523) with auto-managed asymmetric keys:
|
|
8
|
+
# generates and persists an RSA-2048 keypair on first use, signs a
|
|
9
|
+
# short-lived client assertion on every token request, and exposes the
|
|
10
|
+
# public JWKS for the authorization server to verify. The recommended
|
|
11
|
+
# credential for production workloads that can persist a keypair; the
|
|
12
|
+
# private key never leaves the workload.
|
|
13
|
+
#
|
|
14
|
+
# Holds no shared secret and contributes no Basic header. Never reads
|
|
15
|
+
# environment variables; the caller supplies storage configuration.
|
|
16
|
+
class WebIdentity
|
|
17
|
+
# @param client_id [String] the registered OAuth client id, signed as
|
|
18
|
+
# the assertion's iss and sub
|
|
19
|
+
# @param server_name [String, nil] sanitized into the key id when no
|
|
20
|
+
# key_id is given
|
|
21
|
+
# @param key_id [String, nil] explicit key id (the JWT kid); otherwise
|
|
22
|
+
# derived from server_name, otherwise a generated UUID
|
|
23
|
+
# @param storage [#load, #store, nil] pluggable keypair storage
|
|
24
|
+
# @param storage_dir [String, nil] directory for the default file store
|
|
25
|
+
# @param clock [#call] returns the current Time; override in tests
|
|
26
|
+
# @raise [ConfigurationError] missing client_id
|
|
27
|
+
def initialize(client_id:, server_name: nil, key_id: nil, storage: nil, storage_dir: nil,
|
|
28
|
+
clock: -> { Time.now })
|
|
29
|
+
raise ConfigurationError, "WebIdentity requires a client_id" if client_id.nil? || client_id.empty?
|
|
30
|
+
|
|
31
|
+
@client_id = client_id
|
|
32
|
+
storage ||= FilePrivateKeyStorage.new(dir: storage_dir || FilePrivateKeyStorage::DEFAULT_DIR)
|
|
33
|
+
@key_manager = PrivateKeyManager.new(
|
|
34
|
+
key_id: key_id || sanitize(server_name) || SecureRandom.uuid,
|
|
35
|
+
storage: storage, clock: clock
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the key id used as the assertion's kid
|
|
40
|
+
def key_id
|
|
41
|
+
@key_manager.key_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Assertion-based authentication contributes no Basic header.
|
|
45
|
+
#
|
|
46
|
+
# @param issuer [String, nil] unused; part of the Credential interface
|
|
47
|
+
# @return [nil]
|
|
48
|
+
def authorization_header(issuer: nil)
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Build the token-exchange form parameters, signing a fresh client
|
|
53
|
+
# assertion for this request.
|
|
54
|
+
#
|
|
55
|
+
# @param subject_token [String]
|
|
56
|
+
# @param token_endpoint [String] the assertion's audience
|
|
57
|
+
# @param resource [String, nil]
|
|
58
|
+
# @param audience [String, nil]
|
|
59
|
+
# @param scope [String, nil]
|
|
60
|
+
# @param issuer [String, nil] unused; part of the Credential interface
|
|
61
|
+
# @return [Hash]
|
|
62
|
+
# @raise [ConfigurationError] when token_endpoint is missing
|
|
63
|
+
def prepare_token_exchange_request(subject_token:, token_endpoint: nil, resource: nil,
|
|
64
|
+
audience: nil, scope: nil, issuer: nil)
|
|
65
|
+
if token_endpoint.nil? || token_endpoint.empty?
|
|
66
|
+
raise ConfigurationError, "WebIdentity requires the token_endpoint as the assertion audience"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
"grant_type" => GrantType::TOKEN_EXCHANGE,
|
|
71
|
+
"subject_token" => subject_token,
|
|
72
|
+
"subject_token_type" => TokenType::ACCESS_TOKEN,
|
|
73
|
+
"resource" => resource,
|
|
74
|
+
"audience" => audience,
|
|
75
|
+
"scope" => scope,
|
|
76
|
+
"client_assertion" => @key_manager.create_client_assertion(client_id: @client_id,
|
|
77
|
+
audience: token_endpoint),
|
|
78
|
+
"client_assertion_type" => PrivateKeyManager::ASSERTION_TYPE
|
|
79
|
+
}.compact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The public JWKS the authorization server verifies assertions against.
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash] {"keys" => [...]}
|
|
85
|
+
def public_jwks
|
|
86
|
+
@key_manager.public_jwks
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The conventional URL where this workload serves its public JWKS.
|
|
90
|
+
#
|
|
91
|
+
# @param base_url [String] the workload's public base URL
|
|
92
|
+
# @return [String]
|
|
93
|
+
def client_jwks_url(base_url)
|
|
94
|
+
"#{base_url.chomp("/")}/.well-known/jwks.json"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def sanitize(server_name)
|
|
100
|
+
return nil if server_name.nil? || server_name.empty?
|
|
101
|
+
|
|
102
|
+
server_name.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Keycardai
|
|
4
|
+
module OAuth
|
|
5
|
+
# Workload-identity credential: authenticates with a platform-signed OIDC
|
|
6
|
+
# token as its client assertion (RFC 7523 over RFC 8693). One generic
|
|
7
|
+
# credential owns the exchange contract; a pluggable identity-token source
|
|
8
|
+
# supplies the platform token, fetched fresh on every request because
|
|
9
|
+
# platforms rotate these tokens. The credential never caches the token; a
|
|
10
|
+
# source may cache internally when the platform contract makes that safe.
|
|
11
|
+
#
|
|
12
|
+
# Holds no shared secret and contributes no Basic header.
|
|
13
|
+
class WorkloadIdentity
|
|
14
|
+
# @param source [#identity_token, #call] supplies the platform-signed
|
|
15
|
+
# OIDC token; a bare callable is accepted
|
|
16
|
+
# @param client_id [String, nil] the Keycard application credential this
|
|
17
|
+
# workload authenticates as; sent as the client_id form parameter when
|
|
18
|
+
# set (token-federation credentials are resolved by it)
|
|
19
|
+
# @raise [ConfigurationError] when the source is missing or has neither
|
|
20
|
+
# an identity_token method nor call
|
|
21
|
+
def initialize(source:, client_id: nil)
|
|
22
|
+
@source = normalize_source(source)
|
|
23
|
+
@client_id = client_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Assertion-based authentication contributes no Basic header.
|
|
27
|
+
#
|
|
28
|
+
# @param issuer [String, nil] unused; part of the Credential interface
|
|
29
|
+
# @return [nil]
|
|
30
|
+
def authorization_header(issuer: nil)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Build the token-exchange form parameters, fetching a fresh platform
|
|
35
|
+
# token from the source.
|
|
36
|
+
#
|
|
37
|
+
# @param subject_token [String]
|
|
38
|
+
# @param resource [String, nil]
|
|
39
|
+
# @param audience [String, nil]
|
|
40
|
+
# @param scope [String, nil]
|
|
41
|
+
# @param token_endpoint [String, nil] unused; part of the interface
|
|
42
|
+
# @param issuer [String, nil] unused; part of the Credential interface
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
# @raise [WorkloadIdentityRuntimeError] the source failed to produce a token
|
|
45
|
+
def prepare_token_exchange_request(subject_token:, resource: nil, audience: nil, scope: nil,
|
|
46
|
+
token_endpoint: nil, issuer: nil)
|
|
47
|
+
{
|
|
48
|
+
"grant_type" => GrantType::TOKEN_EXCHANGE,
|
|
49
|
+
"subject_token" => subject_token,
|
|
50
|
+
"subject_token_type" => TokenType::ACCESS_TOKEN,
|
|
51
|
+
"resource" => resource,
|
|
52
|
+
"audience" => audience,
|
|
53
|
+
"scope" => scope,
|
|
54
|
+
"client_id" => @client_id,
|
|
55
|
+
"client_assertion" => fetch_identity_token,
|
|
56
|
+
"client_assertion_type" => PrivateKeyManager::ASSERTION_TYPE
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def normalize_source(source)
|
|
63
|
+
return source if source.respond_to?(:identity_token)
|
|
64
|
+
return FunctionTokenSource.new(source) if source.respond_to?(:call)
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError, "WorkloadIdentity source must respond to identity_token or call"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fetch_identity_token
|
|
70
|
+
token = @source.identity_token
|
|
71
|
+
if token.nil? || token.empty?
|
|
72
|
+
raise WorkloadIdentityRuntimeError.new("identity token source returned an empty token",
|
|
73
|
+
source: source_identifier)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
token
|
|
77
|
+
rescue WorkloadIdentityRuntimeError, WorkloadIdentityConfigurationError
|
|
78
|
+
raise
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
raise WorkloadIdentityRuntimeError.new("identity token source failed: #{e.message}",
|
|
81
|
+
source: source_identifier)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def source_identifier
|
|
85
|
+
@source.respond_to?(:source_identifier) ? @source.source_identifier : "custom"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Adapter making a bare callable usable as an identity-token source.
|
|
89
|
+
class FunctionTokenSource
|
|
90
|
+
def initialize(callable)
|
|
91
|
+
@callable = callable
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def identity_token
|
|
95
|
+
@callable.call
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def source_identifier
|
|
99
|
+
"custom"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "oauth/version"
|
|
4
|
+
require_relative "oauth/errors"
|
|
5
|
+
require_relative "oauth/http"
|
|
6
|
+
require_relative "oauth/discovery"
|
|
7
|
+
require_relative "oauth/token_types"
|
|
8
|
+
require_relative "oauth/token_requests"
|
|
9
|
+
require_relative "oauth/substitute_user"
|
|
10
|
+
require_relative "oauth/pkce"
|
|
11
|
+
require_relative "oauth/authorization_code"
|
|
12
|
+
require_relative "oauth/authenticate"
|
|
13
|
+
require_relative "oauth/registration"
|
|
14
|
+
require_relative "oauth/client_secret"
|
|
15
|
+
require_relative "oauth/private_key"
|
|
16
|
+
require_relative "oauth/web_identity"
|
|
17
|
+
require_relative "oauth/workload_identity"
|
|
18
|
+
require_relative "oauth/token_sources"
|
|
19
|
+
require_relative "oauth/access_context"
|
|
20
|
+
require_relative "oauth/exchange_tokens"
|
|
21
|
+
require_relative "oauth/token_verifier"
|
|
22
|
+
require_relative "oauth/token_exchange_client"
|
|
23
|
+
require_relative "oauth/client_credentials_client"
|
|
24
|
+
require_relative "oauth/jwks_keyring"
|
|
25
|
+
require_relative "oauth/jwt_signer"
|
|
26
|
+
require_relative "oauth/jwt_verifier"
|
|
27
|
+
|
|
28
|
+
# Root namespace shared by all Keycard gems.
|
|
29
|
+
module Keycardai
|
|
30
|
+
# OAuth 2.0 primitives for the Keycard platform: token exchange (RFC 8693),
|
|
31
|
+
# client credentials, authorization code + PKCE, dynamic client registration
|
|
32
|
+
# (RFC 7591), authorization server discovery (RFC 8414), JWT/JWKS
|
|
33
|
+
# verification, application credentials, and AccessContext.
|
|
34
|
+
#
|
|
35
|
+
# Contract: https://github.com/keycardai/keycard-sdk-spec
|
|
36
|
+
module OAuth
|
|
37
|
+
end
|
|
38
|
+
end
|