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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module OAuth
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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