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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+
5
+ module Keycardai
6
+ module OAuth
7
+ # Signs compact JWTs (RFC 7519) with RS256, the baseline algorithm the SDK
8
+ # family uses for private_key_jwt client assertions (RFC 7523) and signed
9
+ # access tokens (RFC 9068).
10
+ #
11
+ # The signer writes the JWT header ({alg, kid}) from its construction-time
12
+ # key material. Claims are signed verbatim: temporal claims (exp, iat, nbf)
13
+ # come from the caller and the signer does not invent an expiry. When the
14
+ # claims omit iss and the signer carries an issuer, iss is filled in; a
15
+ # caller-supplied iss is preserved.
16
+ class JWTSigner
17
+ ALGORITHM = "RS256"
18
+
19
+ # @param key [OpenSSL::PKey::RSA] the RSA private signing key
20
+ # @param kid [String] key id written to the JWT header
21
+ # @param issuer [String, nil] default iss, applied only when claims omit it
22
+ # @raise [ConfigurationError] when the key is not an RSA private key
23
+ def initialize(key:, kid:, issuer: nil)
24
+ unless key.is_a?(OpenSSL::PKey::RSA) && key.private?
25
+ raise ConfigurationError, "JWTSigner requires an RSA private key"
26
+ end
27
+ raise ConfigurationError, "JWTSigner requires a kid" if kid.nil? || kid.empty?
28
+
29
+ @key = key
30
+ @kid = kid
31
+ @issuer = issuer
32
+ end
33
+
34
+ # Sign a claim set into a compact JWS (header.payload.signature).
35
+ #
36
+ # @param claims [Hash] payload claims; string or symbol keys
37
+ # @return [String] the signed compact JWT
38
+ def sign(claims)
39
+ payload = claims.transform_keys(&:to_s)
40
+ payload["iss"] = @issuer if @issuer && !payload.key?("iss")
41
+ JWT.encode(payload, @key, ALGORITHM, { "kid" => @kid })
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Keycardai
7
+ module OAuth
8
+ # Verifies compact JWTs (RFC 7519) against the RFC 9068 access-token
9
+ # profile, fail-closed. Cheap policy checks (algorithm, trusted issuer,
10
+ # required claims, expiry, audience, kid) run before any key resolution,
11
+ # so a token carrying an attacker-controlled iss never drives key lookup
12
+ # or network I/O. Verification keys are resolved by (issuer, kid) through
13
+ # the injected keyring (see JWKSKeyring).
14
+ #
15
+ # Every rejection raises InvalidTokenError; misconfiguration raises
16
+ # ConfigurationError at construction.
17
+ class JWTVerifier
18
+ SUPPORTED_ALGORITHMS = ["RS256"].freeze
19
+ REQUIRED_CLAIMS = %w[iss sub aud exp iat client_id].freeze
20
+
21
+ # @param issuers [String, Array<String>] trusted-issuer allowlist
22
+ # @param keyring [#key] resolves a verification key for (issuer, kid)
23
+ # @param audiences [String, Array<String>, nil] when set, the token's aud
24
+ # must intersect this set
25
+ # @param algorithms [Array<String>] allowed alg header values
26
+ # @param clock_skew [Numeric] leeway in seconds applied to exp and nbf
27
+ # @param clock [#call] returns the current Time; override in tests
28
+ # @raise [ConfigurationError] no trusted issuer, or an algorithm the
29
+ # verifier does not implement
30
+ def initialize(issuers:, keyring:, audiences: nil, algorithms: SUPPORTED_ALGORITHMS,
31
+ clock_skew: 0, clock: -> { Time.now })
32
+ @issuers = Array(issuers).reject { |issuer| issuer.nil? || issuer.empty? }
33
+ raise ConfigurationError, "JWTVerifier requires at least one trusted issuer" if @issuers.empty?
34
+
35
+ unimplemented = Array(algorithms) - SUPPORTED_ALGORITHMS
36
+ raise ConfigurationError, "unimplemented algorithms: #{unimplemented.join(", ")}" unless unimplemented.empty?
37
+
38
+ @keyring = keyring
39
+ @audiences = audiences.nil? ? nil : Array(audiences)
40
+ @algorithms = Array(algorithms)
41
+ @clock_skew = clock_skew
42
+ @clock = clock
43
+ end
44
+
45
+ # Verify a compact JWT and return its claim set.
46
+ #
47
+ # @param token [String]
48
+ # @return [Hash] the verified claims, string-keyed
49
+ # @raise [InvalidTokenError] on any verification failure
50
+ # @raise [JWKSError] when key resolution fails (see JWKSKeyring)
51
+ def verify(token)
52
+ header, claims, signature, signing_input = decode_parts(token)
53
+ check_algorithm(header)
54
+ check_issuer(claims)
55
+ check_required_claims(claims)
56
+ check_temporal(claims)
57
+ check_audience(claims)
58
+ kid = header["kid"]
59
+ raise InvalidTokenError, "token header has no kid" if kid.nil? || kid.empty?
60
+
61
+ key = @keyring.key(claims["iss"], kid)
62
+ check_signature(key, signature, signing_input)
63
+ claims
64
+ end
65
+
66
+ private
67
+
68
+ def decode_parts(token)
69
+ parts = String(token).split(".")
70
+ raise InvalidTokenError, "token is not a three-part compact JWT" unless parts.length == 3
71
+
72
+ header = decode_json_segment(parts[0], "header")
73
+ claims = decode_json_segment(parts[1], "payload")
74
+ signature = decode_segment(parts[2], "signature")
75
+ [header, claims, signature, parts[0..1].join(".")]
76
+ end
77
+
78
+ def decode_segment(segment, name)
79
+ padded = segment.tr("-_", "+/")
80
+ padded += "=" * ((4 - (padded.length % 4)) % 4)
81
+ padded.unpack1("m0")
82
+ rescue ArgumentError
83
+ raise InvalidTokenError, "token #{name} does not decode"
84
+ end
85
+
86
+ def decode_json_segment(segment, name)
87
+ document = JSON.parse(decode_segment(segment, name))
88
+ raise InvalidTokenError, "token #{name} is not a JSON object" unless document.is_a?(Hash)
89
+
90
+ document
91
+ rescue JSON::ParserError
92
+ raise InvalidTokenError, "token #{name} is not valid JSON"
93
+ end
94
+
95
+ def check_algorithm(header)
96
+ algorithm = header["alg"]
97
+ raise InvalidTokenError, "token alg is missing" if algorithm.nil? || algorithm.empty?
98
+ raise InvalidTokenError, "token alg none is rejected" if algorithm.casecmp("none").zero?
99
+ raise InvalidTokenError, "token alg #{algorithm} is not allowed" unless @algorithms.include?(algorithm)
100
+ end
101
+
102
+ def check_issuer(claims)
103
+ issuer = claims["iss"]
104
+ return if issuer.is_a?(String) && @issuers.include?(issuer)
105
+
106
+ raise InvalidTokenError, "token issuer is not trusted"
107
+ end
108
+
109
+ def check_required_claims(claims)
110
+ missing = REQUIRED_CLAIMS.reject { |name| claims.key?(name) }
111
+ raise InvalidTokenError, "token is missing required claims: #{missing.join(", ")}" unless missing.empty?
112
+ return if claims["exp"].is_a?(Numeric) && claims["exp"].to_f.finite?
113
+
114
+ raise InvalidTokenError,
115
+ "token exp is not a number"
116
+ end
117
+
118
+ def check_temporal(claims)
119
+ current = @clock.call.to_i
120
+ raise InvalidTokenError, "token is expired" if current > claims["exp"] + @clock_skew
121
+
122
+ not_before = claims["nbf"]
123
+ return unless not_before.is_a?(Numeric)
124
+ raise InvalidTokenError, "token is not yet valid" if current < not_before - @clock_skew
125
+ end
126
+
127
+ def check_audience(claims)
128
+ return if @audiences.nil?
129
+
130
+ token_audiences = Array(claims["aud"])
131
+ return if token_audiences.intersect?(@audiences)
132
+
133
+ raise InvalidTokenError, "token audience does not match"
134
+ end
135
+
136
+ def check_signature(key, signature, signing_input)
137
+ # RS256: RSASSA-PKCS1-v1_5 with SHA-256.
138
+ return if key.verify(OpenSSL::Digest.new("SHA256"), signature, signing_input)
139
+
140
+ raise InvalidTokenError, "token signature does not validate"
141
+ rescue OpenSSL::PKey::PKeyError
142
+ raise InvalidTokenError, "token signature does not validate"
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module Keycardai
7
+ module OAuth
8
+ # PKCE primitives (RFC 7636): generate a code verifier and derive its
9
+ # challenge. S256 is the default and the method public clients should use;
10
+ # plain is RFC-permitted but not recommended.
11
+ module PKCE
12
+ VERIFIER_CHARSET = [*"A".."Z", *"a".."z", *"0".."9", "-", ".", "_", "~"].freeze
13
+ DEFAULT_VERIFIER_LENGTH = 128
14
+ METHODS = %w[S256 plain].freeze
15
+
16
+ # A verifier with its derived challenge.
17
+ Pair = Data.define(:code_verifier, :code_challenge, :code_challenge_method)
18
+
19
+ module_function
20
+
21
+ # Generate a cryptographically random verifier (RFC 7636 §4.1).
22
+ #
23
+ # @param length [Integer] 43 to 128
24
+ # @return [String]
25
+ def generate_code_verifier(length: DEFAULT_VERIFIER_LENGTH)
26
+ raise ArgumentError, "verifier length must be between 43 and 128, got #{length}" unless (43..128).cover?(length)
27
+
28
+ Array.new(length) { VERIFIER_CHARSET[SecureRandom.random_number(VERIFIER_CHARSET.length)] }.join
29
+ end
30
+
31
+ # Derive the challenge for a verifier (RFC 7636 §4.2).
32
+ #
33
+ # @param code_verifier [String]
34
+ # @param method ["S256", "plain"]
35
+ # @return [String]
36
+ def generate_code_challenge(code_verifier, method: "S256")
37
+ case method
38
+ when "S256"
39
+ [OpenSSL::Digest.digest("SHA256", code_verifier)].pack("m0").tr("+/", "-_").delete("=")
40
+ when "plain"
41
+ code_verifier
42
+ else
43
+ raise ArgumentError, "unsupported code_challenge_method #{method.inspect}"
44
+ end
45
+ end
46
+
47
+ # Generate a verifier and its challenge together.
48
+ #
49
+ # @param length [Integer] verifier length, 43 to 128
50
+ # @param method ["S256", "plain"]
51
+ # @return [Pair]
52
+ def generate_pair(length: DEFAULT_VERIFIER_LENGTH, method: "S256")
53
+ code_verifier = generate_code_verifier(length: length)
54
+ Pair.new(
55
+ code_verifier: code_verifier,
56
+ code_challenge: generate_code_challenge(code_verifier, method: method),
57
+ code_challenge_method: method
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "jwt"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module Keycardai
9
+ module OAuth
10
+ # Pluggable keypair persistence for WebIdentity. The duck type is
11
+ # load(key_id) returning a PEM string or nil, and store(key_id, pem).
12
+ # This default stores PEM files on disk, checking legacy directories on
13
+ # load so existing keys keep working.
14
+ class FilePrivateKeyStorage
15
+ DEFAULT_DIR = "./server_keys"
16
+ LEGACY_DIRS = ["./mcp_keys"].freeze
17
+
18
+ # @param dir [String] directory for new and existing keys
19
+ def initialize(dir: DEFAULT_DIR)
20
+ @dir = dir
21
+ end
22
+
23
+ # @param key_id [String]
24
+ # @return [String, nil] the stored PEM, or nil when absent
25
+ def load(key_id)
26
+ [@dir, *LEGACY_DIRS].each do |dir|
27
+ path = File.join(dir, "#{key_id}.pem")
28
+ return File.read(path) if File.file?(path)
29
+ end
30
+ nil
31
+ end
32
+
33
+ # @param key_id [String]
34
+ # @param pem [String]
35
+ # @return [void]
36
+ def store(key_id, pem)
37
+ FileUtils.mkdir_p(@dir)
38
+ path = File.join(@dir, "#{key_id}.pem")
39
+ File.write(path, pem)
40
+ File.chmod(0o600, path)
41
+ end
42
+ end
43
+
44
+ # Generates, persists, and loads an RSA-2048 keypair, and signs RFC 7523
45
+ # private_key_jwt client assertions with it. WebIdentity composes this;
46
+ # it is also usable standalone.
47
+ class PrivateKeyManager
48
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
49
+ DEFAULT_ASSERTION_LIFETIME = 300
50
+
51
+ # @return [String] the key id (the JWT kid)
52
+ attr_reader :key_id
53
+
54
+ # @param key_id [String]
55
+ # @param storage [#load, #store] keypair persistence
56
+ # @param clock [#call] returns the current Time; override in tests
57
+ def initialize(key_id:, storage: FilePrivateKeyStorage.new, clock: -> { Time.now })
58
+ @key_id = key_id
59
+ @storage = storage
60
+ @clock = clock
61
+ @key = nil
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ # Load the persisted keypair, generating and storing one on first use.
66
+ #
67
+ # @return [OpenSSL::PKey::RSA]
68
+ def key
69
+ @mutex.synchronize do
70
+ @key ||= begin
71
+ pem = @storage.load(@key_id)
72
+ pem ? OpenSSL::PKey::RSA.new(pem) : generate
73
+ end
74
+ end
75
+ end
76
+
77
+ # Sign a short-lived client assertion (RFC 7523 §3).
78
+ #
79
+ # @param client_id [String] becomes iss and sub
80
+ # @param audience [String] the authorization server's token endpoint
81
+ # @param expiry_seconds [Integer]
82
+ # @return [String] the signed assertion
83
+ def create_client_assertion(client_id:, audience:, expiry_seconds: DEFAULT_ASSERTION_LIFETIME)
84
+ now = @clock.call.to_i
85
+ claims = {
86
+ "iss" => client_id, "sub" => client_id, "aud" => audience,
87
+ "jti" => SecureRandom.uuid, "iat" => now, "exp" => now + expiry_seconds
88
+ }
89
+ JWTSigner.new(key: key, kid: @key_id).sign(claims)
90
+ end
91
+
92
+ # The public half as a JWKS document, for the authorization server to
93
+ # verify assertions against.
94
+ #
95
+ # @return [Hash] {"keys" => [...]}
96
+ def public_jwks
97
+ jwk = JWT::JWK.new(key.public_key, { kid: @key_id, use: "sig", alg: "RS256" })
98
+ { "keys" => [jwk.export.transform_keys(&:to_s)] }
99
+ end
100
+
101
+ private
102
+
103
+ def generate
104
+ key = OpenSSL::PKey::RSA.new(2048)
105
+ @storage.store(@key_id, key.to_pem)
106
+ key
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Keycardai
6
+ # Dynamic client registration (RFC 7591).
7
+ module OAuth
8
+ # The result of registering a client (RFC 7591 §3.2.1). The server's full
9
+ # response, including echoed metadata and AS-specific fields, is preserved
10
+ # in +raw+.
11
+ ClientRegistrationResponse = Data.define(
12
+ :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at,
13
+ :registration_access_token, :registration_client_uri, :raw
14
+ ) do
15
+ # @param field [String] a response field name
16
+ # @return [Object, nil] the field's value from the full response body
17
+ def [](field)
18
+ raw[field]
19
+ end
20
+ end
21
+
22
+ REGISTRATION_METADATA_FIELDS = %w[
23
+ client_name redirect_uris grant_types response_types scope
24
+ token_endpoint_auth_method jwks_uri jwks client_uri logo_uri tos_uri
25
+ policy_uri software_id software_version
26
+ ].freeze
27
+
28
+ # Register a new OAuth client with the zone (RFC 7591). Sends only the
29
+ # fields the caller supplies; omitted metadata is defaulted by the
30
+ # authorization server. Vendor-extension fields go in additional_metadata,
31
+ # with named fields winning on conflict.
32
+ #
33
+ # @param issuer [String] the zone's issuer URL; supplies the registration endpoint
34
+ # @param client_name [String, nil]
35
+ # @param redirect_uris [Array<String>, nil] required by RFC 7591 when
36
+ # grant_types includes authorization_code; enforced by the server
37
+ # @param grant_types [Array<String>, nil]
38
+ # @param response_types [Array<String>, nil]
39
+ # @param scope [String, nil] space-separated scopes
40
+ # @param token_endpoint_auth_method [String, nil]
41
+ # @param jwks_uri [String, nil]
42
+ # @param jwks [Hash, nil]
43
+ # @param client_uri [String, nil]
44
+ # @param logo_uri [String, nil]
45
+ # @param tos_uri [String, nil]
46
+ # @param policy_uri [String, nil]
47
+ # @param software_id [String, nil]
48
+ # @param software_version [String, nil]
49
+ # @param additional_metadata [Hash, nil] vendor or AS-specific fields
50
+ # @param initial_access_token [String, nil] RFC 7591 §3.1 registration
51
+ # authentication, sent as a Bearer credential
52
+ # @param http_client [#get, #post_json] pluggable transport
53
+ # @param timeout [Numeric, nil]
54
+ # @return [ClientRegistrationResponse]
55
+ # @raise [OAuthError] an RFC 7591 §3.2.2 error (invalid_client_metadata,
56
+ # invalid_redirect_uri, ...)
57
+ # @raise [HTTPError, ProtocolError, NetworkError]
58
+ def self.register_client(issuer, client_name: nil, redirect_uris: nil, grant_types: nil,
59
+ response_types: nil, scope: nil, token_endpoint_auth_method: nil,
60
+ jwks_uri: nil, jwks: nil, client_uri: nil, logo_uri: nil, tos_uri: nil,
61
+ policy_uri: nil, software_id: nil, software_version: nil,
62
+ additional_metadata: nil, initial_access_token: nil,
63
+ http_client: HTTP::NetHTTPClient.new, timeout: nil)
64
+ named = {
65
+ "client_name" => client_name, "redirect_uris" => redirect_uris, "grant_types" => grant_types,
66
+ "response_types" => response_types, "scope" => scope,
67
+ "token_endpoint_auth_method" => token_endpoint_auth_method, "jwks_uri" => jwks_uri,
68
+ "jwks" => jwks, "client_uri" => client_uri, "logo_uri" => logo_uri, "tos_uri" => tos_uri,
69
+ "policy_uri" => policy_uri, "software_id" => software_id, "software_version" => software_version
70
+ }.compact
71
+ body = (additional_metadata || {}).transform_keys(&:to_s).merge(named)
72
+
73
+ Registration.post(issuer, body, initial_access_token, http_client, timeout)
74
+ end
75
+
76
+ # Internals of dynamic client registration. Not public API.
77
+ module Registration
78
+ module_function
79
+
80
+ def post(issuer, body, initial_access_token, http_client, timeout)
81
+ metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: http_client, timeout: timeout)
82
+ endpoint = metadata.registration_endpoint ||
83
+ raise(ProtocolError.new("metadata for #{issuer} has no registration_endpoint",
84
+ code: "invalid_metadata"))
85
+
86
+ headers = { "Accept" => "application/json" }
87
+ headers["Authorization"] = "Bearer #{initial_access_token}" if initial_access_token
88
+ parse(http_client.post_json(endpoint, body, headers: headers, timeout: timeout))
89
+ end
90
+
91
+ def parse(response)
92
+ raise TokenRequests.error_for(response) unless response.success?
93
+
94
+ build(parse_document(response.body))
95
+ end
96
+
97
+ def parse_document(body)
98
+ document = JSON.parse(body)
99
+ unless document.is_a?(Hash) && document["client_id"].is_a?(String) && !document["client_id"].empty?
100
+ raise ProtocolError.new("registration response has no client_id", code: "invalid_response")
101
+ end
102
+
103
+ document
104
+ rescue JSON::ParserError
105
+ raise ProtocolError.new("registration response is not valid JSON", code: "invalid_response")
106
+ end
107
+
108
+ def build(document)
109
+ ClientRegistrationResponse.new(
110
+ client_id: document["client_id"],
111
+ client_secret: document["client_secret"],
112
+ client_id_issued_at: document["client_id_issued_at"],
113
+ client_secret_expires_at: document["client_secret_expires_at"],
114
+ registration_access_token: document["registration_access_token"],
115
+ registration_client_uri: document["registration_client_uri"],
116
+ raw: document
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Keycardai
6
+ # The substitute-user token builder for impersonation exchanges.
7
+ module OAuth
8
+ # Build the unsigned substitute-user JWT used as the subject token of an
9
+ # impersonation exchange. The authorization server derives the acting
10
+ # party from client authentication; this token only names the target user.
11
+ #
12
+ # Shape: header {"typ": "vnd.kc.su+jwt", "alg": "none"}, payload
13
+ # {"sub": user_identifier}, encoded header.payload. with a trailing dot
14
+ # and no signature.
15
+ #
16
+ # @param user_identifier [String] the target user (becomes sub)
17
+ # @return [String]
18
+ def self.build_substitute_user_token(user_identifier)
19
+ if user_identifier.nil? || user_identifier.empty?
20
+ raise ArgumentError, "user_identifier must be a non-empty string"
21
+ end
22
+
23
+ header = { "typ" => "vnd.kc.su+jwt", "alg" => "none" }
24
+ payload = { "sub" => user_identifier }
25
+ encode = ->(part) { [JSON.dump(part)].pack("m0").tr("+/", "-_").delete("=") }
26
+ "#{encode.call(header)}.#{encode.call(payload)}."
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module OAuth
5
+ # RFC 8693 token exchange: swap a subject token for a fresh token scoped
6
+ # to a downstream resource, the core delegation primitive. Also exposes
7
+ # the impersonation convenience, a substitute-user exchange where the
8
+ # authorization server derives the acting party from client
9
+ # authentication.
10
+ #
11
+ # The token endpoint is discovered from the issuer on first use and
12
+ # cached. Requests do not retry transparently.
13
+ class TokenExchangeClient
14
+ include TokenRequests
15
+
16
+ # @param issuer [String] the zone's issuer URL
17
+ # @param credential [Object, nil] an application credential (ClientSecret,
18
+ # WebIdentity, WorkloadIdentity); exclusive with client_id/client_secret
19
+ # @param client_id [String, nil] shared-secret client id (HTTP Basic)
20
+ # @param client_secret [String, nil] shared-secret client secret;
21
+ # provide both or neither
22
+ # @param http_client [#get, #post_form] pluggable transport
23
+ # @param timeout [Numeric, nil] request timeout in seconds
24
+ # @raise [ConfigurationError] when only one of client_id/client_secret is
25
+ # given, or a credential is combined with a raw pair
26
+ def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
27
+ http_client: HTTP::NetHTTPClient.new, timeout: nil)
28
+ initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
29
+ client_secret: client_secret, http_client: http_client, timeout: timeout)
30
+ end
31
+
32
+ # Exchange a subject token (RFC 8693).
33
+ #
34
+ # @param subject_token [String] the token being exchanged
35
+ # @param subject_token_type [String] type of the subject token
36
+ # @param resource [String, nil] RFC 8707 target resource
37
+ # @param audience [String, nil] target audience (alternative to resource)
38
+ # @param scope [String, nil] space-separated scopes for the issued token
39
+ # @param requested_token_type [String, nil]
40
+ # @param actor_token [String, nil] the acting party's token, for explicit
41
+ # delegation; requires actor_token_type
42
+ # @param actor_token_type [String, nil]
43
+ # @param client_assertion [String, nil] client-authentication assertion,
44
+ # form-encoded in the body
45
+ # @param client_assertion_type [String, nil]
46
+ # @param issuer [String, nil] per-call zone selection: resolves the zone's
47
+ # token endpoint and, for a multi-zone credential, that zone's client
48
+ # authentication. Defaults to the client's issuer.
49
+ # @return [TokenResponse]
50
+ # @raise [OAuthError] an RFC 6749 §5.2 error response (invalid_grant,
51
+ # invalid_target, invalid_scope, unauthorized_client, ...)
52
+ # @raise [HTTPError, ProtocolError, NetworkError]
53
+ def exchange_token(subject_token:, subject_token_type: nil, resource: nil,
54
+ audience: nil, scope: nil, requested_token_type: nil, actor_token: nil,
55
+ actor_token_type: nil, client_assertion: nil, client_assertion_type: nil,
56
+ issuer: nil)
57
+ raise ArgumentError, "actor_token_type is required when actor_token is set" if actor_token && !actor_token_type
58
+
59
+ target = issuer || @issuer
60
+ overrides = {
61
+ "subject_token_type" => subject_token_type,
62
+ "resource" => resource,
63
+ "audience" => audience,
64
+ "scope" => scope,
65
+ "requested_token_type" => requested_token_type,
66
+ "actor_token" => actor_token,
67
+ "actor_token_type" => actor_token_type,
68
+ "client_assertion" => client_assertion,
69
+ "client_assertion_type" => client_assertion_type
70
+ }.compact
71
+ post_token_request(base_exchange_params(subject_token, target).merge(overrides), issuer: target)
72
+ end
73
+
74
+ # Impersonate a named user: a substitute-user token exchange for
75
+ # privileged operations performed on the user's behalf. No actor token
76
+ # is sent; the authorization server derives the acting party from client
77
+ # authentication and records it in the issued token's act chain.
78
+ #
79
+ # @param user_identifier [String] the target user (becomes sub)
80
+ # @param resource [String] target resource for the issued token
81
+ # @param scope [String, nil] space-separated scopes
82
+ # @param issuer [String, nil] per-call zone selection
83
+ # @return [TokenResponse]
84
+ # @raise [OAuthError] invalid_grant (unknown user), unauthorized_client
85
+ # (impersonation not permitted), and all token-exchange errors
86
+ def impersonate(user_identifier:, resource:, scope: nil, issuer: nil)
87
+ raise ArgumentError, "resource must be a non-empty string" if resource.nil? || resource.empty?
88
+
89
+ exchange_token(
90
+ subject_token: OAuth.build_substitute_user_token(user_identifier),
91
+ subject_token_type: TokenType::SUBSTITUTE_USER,
92
+ resource: resource,
93
+ scope: scope,
94
+ issuer: issuer
95
+ )
96
+ end
97
+
98
+ private
99
+
100
+ # The base exchange parameters: built by the credential when one is
101
+ # configured (it supplies assertion fields and defaults), otherwise the
102
+ # bare RFC 8693 defaults. Caller-supplied fields are merged on top.
103
+ def base_exchange_params(subject_token, issuer)
104
+ if @credential
105
+ @credential.prepare_token_exchange_request(subject_token: subject_token,
106
+ token_endpoint: token_endpoint(issuer), issuer: issuer)
107
+ else
108
+ {
109
+ "grant_type" => GrantType::TOKEN_EXCHANGE,
110
+ "subject_token" => subject_token,
111
+ "subject_token_type" => TokenType::ACCESS_TOKEN
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end