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,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
|