rodauth-ciba 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 +34 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/docs/adr/0003-follow-rodauth-hook-conventions.md +9 -0
- data/docs/adr/0004-retain-atomic-issuance-and-stage-alignment.md +16 -0
- data/docs/alignment-closeout.md +136 -0
- data/docs/alignment-roadmap.md +160 -0
- data/docs/api.md +337 -0
- data/docs/authentication-claims.md +80 -0
- data/docs/authorization-details.md +50 -0
- data/docs/claims.md +46 -0
- data/docs/failure-contract.md +34 -0
- data/docs/id-token-encryption.md +288 -0
- data/docs/id-token-hint.md +174 -0
- data/docs/login-hint-token.md +35 -0
- data/docs/max-age.md +65 -0
- data/docs/node-alignment.md +336 -0
- data/docs/operations.md +130 -0
- data/docs/outbound-http.md +52 -0
- data/docs/pairwise.md +76 -0
- data/docs/ping.md +118 -0
- data/docs/protocol-coverage.md +114 -0
- data/docs/refresh-tokens.md +151 -0
- data/docs/release-readiness.md +75 -0
- data/docs/release-validation.md +498 -0
- data/docs/request-context.md +51 -0
- data/docs/requirements-audit-current.json +3221 -0
- data/docs/requirements-audit-current.md +424 -0
- data/docs/requirements-audit.json +3190 -0
- data/docs/requirements-audit.md +175 -0
- data/docs/research/access-token-revocation-reference.md +81 -0
- data/docs/research/alignment-partial-triage.md +50 -0
- data/docs/research/claims-reference-contract.md +33 -0
- data/docs/research/client-assertion-algorithm-contract.md +177 -0
- data/docs/research/completion-error-boundaries.md +56 -0
- data/docs/research/consent-api-evolution.md +59 -0
- data/docs/research/dpop-reference-contract.md +423 -0
- data/docs/research/id-token-hint-reference-contract.md +254 -0
- data/docs/research/mtls-reference-contract.md +449 -0
- data/docs/research/node-ciba-lifecycle.md +30 -0
- data/docs/research/node-oidc-provider-comparison.md +45 -0
- data/docs/research/pairwise-reference-contract.md +264 -0
- data/docs/research/ping-reference-contract.md +165 -0
- data/docs/research/rar-reference-contract.md +55 -0
- data/docs/research/refresh-reference-contract.md +85 -0
- data/docs/research/registration-reference-contract.md +567 -0
- data/docs/research/request-parameter-inventory.md +42 -0
- data/docs/research/required-parameter-inventory.md +39 -0
- data/docs/research/resource-reference-contract.md +40 -0
- data/docs/research/signed-request-reference-contract.md +113 -0
- data/docs/resources.md +55 -0
- data/docs/security-review.md +294 -0
- data/docs/signed-requests.md +162 -0
- data/docs/user-code.md +73 -0
- data/docs/validation/node-alignment-tests.txt +10 -0
- data/examples/client.rb +39 -0
- data/examples/demo_app.rb +121 -0
- data/examples/dpop_smoke.rb +169 -0
- data/examples/ecdh_id_token_smoke.rb +84 -0
- data/examples/edwards_id_token_smoke.rb +48 -0
- data/examples/edwards_smoke.rb +87 -0
- data/examples/edwards_tls_smoke.rb +73 -0
- data/examples/encrypted_id_token_smoke.rb +73 -0
- data/examples/encryption_tls_smoke.rb +123 -0
- data/examples/extensions_smoke.rb +226 -0
- data/examples/fixtures/edwards-requests.json +30 -0
- data/examples/hmac_id_token_smoke.rb +72 -0
- data/examples/migration.rb +18 -0
- data/examples/mtls_smoke.rb +395 -0
- data/examples/ping_smoke.rb +226 -0
- data/examples/registration_smoke.rb +73 -0
- data/examples/server.rb +14 -0
- data/examples/smoke.rb +46 -0
- data/examples/symmetric_id_token_smoke.rb +83 -0
- data/lib/rodauth/ciba/authorization_details.rb +60 -0
- data/lib/rodauth/ciba/dpop.rb +250 -0
- data/lib/rodauth/ciba/http.rb +69 -0
- data/lib/rodauth/ciba/id_token_encryption.rb +175 -0
- data/lib/rodauth/ciba/jwks_cache.rb +149 -0
- data/lib/rodauth/ciba/mtls.rb +174 -0
- data/lib/rodauth/ciba/pairwise.rb +105 -0
- data/lib/rodauth/ciba/registration.rb +489 -0
- data/lib/rodauth/ciba/schema.rb +182 -0
- data/lib/rodauth/ciba/version.rb +6 -0
- data/lib/rodauth/ciba.rb +4 -0
- data/lib/rodauth/features/ciba.rb +2185 -0
- metadata +231 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Rodauth
|
|
3
|
+
module CibaSupport
|
|
4
|
+
module IdTokenEncryption
|
|
5
|
+
SYMMETRIC_ALGORITHMS = %w[dir A128KW A192KW A256KW A128GCMKW A192GCMKW A256GCMKW].freeze
|
|
6
|
+
ECDH_ALGORITHMS = %w[ECDH-ES ECDH-ES+A128KW ECDH-ES+A192KW ECDH-ES+A256KW].freeze
|
|
7
|
+
ALGORITHMS = (%w[RSA-OAEP RSA-OAEP-256] + SYMMETRIC_ALGORITHMS + ECDH_ALGORITHMS).freeze
|
|
8
|
+
METHODS = %w[A128GCM A192GCM A256GCM A128CBC-HS256 A192CBC-HS384 A256CBC-HS512].freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def generate_id_token(grant, include_claims = false)
|
|
13
|
+
previous = @ciba_symmetric_recipient_client
|
|
14
|
+
if @ciba_authentication && ciba_id_token_encryption_enabled &&
|
|
15
|
+
SYMMETRIC_ALGORITHMS.include?(oauth_application[oauth_applications_id_token_encrypted_response_alg_column])
|
|
16
|
+
@ciba_symmetric_recipient_client = oauth_application[oauth_applications_id_column]
|
|
17
|
+
end
|
|
18
|
+
super
|
|
19
|
+
ensure
|
|
20
|
+
@ciba_symmetric_recipient_client = previous
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def oauth_application_jwks(application)
|
|
24
|
+
# Upstream eagerly resolves recipient keys while generating an ID Token.
|
|
25
|
+
# Symmetric encryption has no public recipient key. Authentication runs
|
|
26
|
+
# outside this scope and must still resolve/verify its registered keys.
|
|
27
|
+
return if @ciba_symmetric_recipient_client &&
|
|
28
|
+
@ciba_symmetric_recipient_client == application[oauth_applications_id_column]
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ciba_gcm_key_wrap(token, key, algorithm, method)
|
|
33
|
+
# RFC 7518 §4.7: a fresh 96-bit wrapping IV, empty AAD and a
|
|
34
|
+
# 128-bit wrapping tag. Content encryption remains in the JWE backend.
|
|
35
|
+
content = JWE::Enc.for(method)
|
|
36
|
+
wrapper = OpenSSL::Cipher.new("aes-#{key.bytesize * 8}-gcm")
|
|
37
|
+
wrapper.encrypt
|
|
38
|
+
wrapper.key = key
|
|
39
|
+
iv = SecureRandom.random_bytes(12)
|
|
40
|
+
wrapper.iv = iv
|
|
41
|
+
wrapper.auth_data = ""
|
|
42
|
+
encrypted_key = wrapper.update(content.cek) + wrapper.final
|
|
43
|
+
header = JSON.generate(alg: algorithm, enc: method, cty: "JWT",
|
|
44
|
+
iss: oauth_jwt_issuer, aud: oauth_jwt_audience,
|
|
45
|
+
iv: Base64.urlsafe_encode64(iv, padding: false),
|
|
46
|
+
tag: Base64.urlsafe_encode64(wrapper.auth_tag(16), padding: false))
|
|
47
|
+
ciphertext = content.encrypt(token, Base64.urlsafe_encode64(header, padding: false))
|
|
48
|
+
JWE::Serialization::Compact.encode(header, encrypted_key, content.iv, ciphertext, content.tag)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ciba_ecdh_public_key(jwk)
|
|
52
|
+
if jwk["kty"] == "EC" && %w[P-256 P-384 P-521].include?(jwk["crv"])
|
|
53
|
+
key = JWT::JWK.import(jwk.reject { |name, _| name == "d" }).keypair
|
|
54
|
+
return unless key.is_a?(OpenSSL::PKey::EC) && key.check_key
|
|
55
|
+
OpenSSL::PKey.read(key.public_to_der)
|
|
56
|
+
elsif jwk["kty"] == "OKP" && jwk["crv"] == "X25519"
|
|
57
|
+
encoded = jwk["x"]
|
|
58
|
+
return unless encoded.is_a?(String) && encoded.match?(/\A[A-Za-z0-9_-]{43}\z/)
|
|
59
|
+
raw = Base64.urlsafe_decode64(encoded)
|
|
60
|
+
return unless raw.bytesize == 32 && Base64.urlsafe_encode64(raw, padding: false) == encoded
|
|
61
|
+
identifier = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("1.3.101.110")])
|
|
62
|
+
OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([identifier, OpenSSL::ASN1::BitString.new(raw)]).to_der)
|
|
63
|
+
end
|
|
64
|
+
rescue JWT::JWKError, OpenSSL::PKey::PKeyError, ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ciba_ecdh_derived_key(shared, algorithm, method)
|
|
69
|
+
direct = algorithm == "ECDH-ES"
|
|
70
|
+
bits = (direct ? (method[/HS(\d+)$/, 1] || method[/^A(\d+)/, 1]) : algorithm[/A(\d+)KW$/, 1]).to_i
|
|
71
|
+
name = direct ? method : algorithm
|
|
72
|
+
# RFC 7518 §4.6.2: empty PartyUInfo/PartyVInfo; SHA-256 Concat KDF.
|
|
73
|
+
info = [name.bytesize].pack("N") + name + [0, 0, bits].pack("N3")
|
|
74
|
+
(1..((bits + 255) / 256)).map do |counter|
|
|
75
|
+
OpenSSL::Digest.digest("SHA256", [counter].pack("N") + shared + info)
|
|
76
|
+
end.join.byteslice(0, bits / 8)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ciba_ecdh_encrypt(token, recipient, algorithm, method, headers)
|
|
80
|
+
# The reference's public ECDH CryptoKey import accepts no key usages.
|
|
81
|
+
# Do not silently discard a selected recipient's declared operations.
|
|
82
|
+
if recipient.key?("key_ops") && recipient["key_ops"] != []
|
|
83
|
+
raise ConfigurationError, "CIBA ECDH public recipient key_ops must be omitted or empty"
|
|
84
|
+
end
|
|
85
|
+
public_key = ciba_ecdh_public_key(recipient)
|
|
86
|
+
raise ConfigurationError, "Invalid CIBA ECDH recipient key" unless public_key
|
|
87
|
+
if recipient["crv"] == "X25519"
|
|
88
|
+
ephemeral = OpenSSL::PKey.generate_key("X25519")
|
|
89
|
+
raw = OpenSSL::ASN1.decode(ephemeral.public_to_der).value.last.value
|
|
90
|
+
epk = {kty: "OKP", crv: "X25519", x: Base64.urlsafe_encode64(raw, padding: false)}
|
|
91
|
+
else
|
|
92
|
+
ephemeral = OpenSSL::PKey::EC.generate(public_key.group.curve_name)
|
|
93
|
+
epk = JWT::JWK.new(ephemeral).export.select { |name, _| %i[kty crv x y].include?(name) }
|
|
94
|
+
end
|
|
95
|
+
shared = ephemeral.derive(public_key)
|
|
96
|
+
raise ConfigurationError, "Invalid CIBA ECDH shared secret" if shared.bytes.all?(&:zero?)
|
|
97
|
+
derived = ciba_ecdh_derived_key(shared, algorithm, method)
|
|
98
|
+
content = JWE::Enc.for(method)
|
|
99
|
+
encrypted_key = if algorithm == "ECDH-ES"
|
|
100
|
+
content.cek = derived
|
|
101
|
+
""
|
|
102
|
+
else
|
|
103
|
+
JWE::Alg.encrypt_cek(algorithm.split("+").last, derived, content.cek)
|
|
104
|
+
end
|
|
105
|
+
header = JSON.generate(headers.merge(alg: algorithm, enc: method, epk: epk))
|
|
106
|
+
ciphertext = content.encrypt(token, Base64.urlsafe_encode64(header, padding: false))
|
|
107
|
+
JWE::Serialization::Compact.encode(header, encrypted_key, content.iv, ciphertext, content.tag)
|
|
108
|
+
rescue OpenSSL::PKey::PKeyError
|
|
109
|
+
raise ConfigurationError, "Invalid CIBA ECDH recipient key agreement"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ciba_encrypt_id_token(token, options)
|
|
113
|
+
algorithm = options[:encryption_algorithm]
|
|
114
|
+
method = options[:encryption_method]
|
|
115
|
+
unless ALGORITHMS.include?(algorithm) && METHODS.include?(method)
|
|
116
|
+
raise ConfigurationError, "Unsupported CIBA ID Token encryption"
|
|
117
|
+
end
|
|
118
|
+
if SYMMETRIC_ALGORITHMS.include?(algorithm)
|
|
119
|
+
secret = ciba_id_token_encryption_secret(oauth_application: oauth_application)
|
|
120
|
+
unless secret.is_a?(String) && secret.valid_encoding? && !secret.empty?
|
|
121
|
+
raise ConfigurationError, "CIBA symmetric encryption requires the client secret"
|
|
122
|
+
end
|
|
123
|
+
bits = if algorithm == "dir"
|
|
124
|
+
method[/HS(\d+)$/, 1] || method[/^A(\d+)/, 1]
|
|
125
|
+
else
|
|
126
|
+
algorithm[/^A(\d+)/, 1]
|
|
127
|
+
end
|
|
128
|
+
length = bits.to_i / 8
|
|
129
|
+
digest = length <= 32 ? "SHA256" : length <= 48 ? "SHA384" : "SHA512"
|
|
130
|
+
key = OpenSSL::Digest.digest(digest, secret.encode(Encoding::UTF_8))[0, length]
|
|
131
|
+
require "jwe"
|
|
132
|
+
return ciba_gcm_key_wrap(token, key, algorithm, method) if algorithm.end_with?("GCMKW")
|
|
133
|
+
return JWE.encrypt(token, key, alg: algorithm, enc: method,
|
|
134
|
+
cty: "JWT", iss: oauth_jwt_issuer, aud: oauth_jwt_audience)
|
|
135
|
+
end
|
|
136
|
+
jwks = options[:jwks]
|
|
137
|
+
keys = jwks.is_a?(Hash) ? (jwks[:keys] || jwks["keys"]) : jwks
|
|
138
|
+
candidates = Array(keys).select do |item|
|
|
139
|
+
next false unless item.is_a?(Hash)
|
|
140
|
+
candidate = item.transform_keys(&:to_s)
|
|
141
|
+
ecdh = ECDH_ALGORITHMS.include?(algorithm)
|
|
142
|
+
next false unless (ecdh ? %w[EC OKP].include?(candidate["kty"]) : candidate["kty"] == "RSA") &&
|
|
143
|
+
(!candidate.key?("alg") || candidate["alg"] == algorithm) &&
|
|
144
|
+
(!candidate.key?("use") || candidate["use"] == "enc") &&
|
|
145
|
+
(ecdh || !candidate.key?("key_ops") || (candidate["key_ops"].is_a?(Array) &&
|
|
146
|
+
candidate["key_ops"].include?("encrypt"))) &&
|
|
147
|
+
(!candidate.key?("kid") || (candidate["kid"].is_a?(String) && !candidate["kid"].empty?))
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
raise ProtocolError, "invalid_client_metadata" if candidates.empty?
|
|
151
|
+
# node-oidc-provider scores explicit matching alg/use above omitted
|
|
152
|
+
# metadata; equal scores retain registration order.
|
|
153
|
+
recipient = candidates.each_with_index.min_by do |item, index|
|
|
154
|
+
candidate = item.transform_keys(&:to_s)
|
|
155
|
+
score = (candidate["alg"] == algorithm ? 1 : 0) + (candidate["use"] == "enc" ? 1 : 0)
|
|
156
|
+
[-score, index]
|
|
157
|
+
end.first
|
|
158
|
+
recipient = recipient.transform_keys(&:to_s)
|
|
159
|
+
headers = {cty: "JWT", iss: oauth_jwt_issuer, aud: oauth_jwt_audience}
|
|
160
|
+
headers[:kid] = recipient["kid"] if recipient["kid"]
|
|
161
|
+
# Load after Rodauth's feature initialization. The JWE backend is used
|
|
162
|
+
# only here; upstream algorithm advertisement/authentication is unchanged.
|
|
163
|
+
require "jwe"
|
|
164
|
+
return ciba_ecdh_encrypt(token, recipient, algorithm, method, headers) if ECDH_ALGORITHMS.include?(algorithm)
|
|
165
|
+
if recipient.key?("key_ops") && !recipient["key_ops"].include?("wrapKey")
|
|
166
|
+
raise ConfigurationError, "CIBA RSA recipient key_ops must include wrapKey"
|
|
167
|
+
end
|
|
168
|
+
key = JWT::JWK.import(recipient).keypair
|
|
169
|
+
raise ConfigurationError, "CIBA RSA recipient key must be at least 2048 bits" if key.n.num_bits < 2048
|
|
170
|
+
JWE.encrypt(token, key.public_key,
|
|
171
|
+
alg: algorithm, enc: method, **headers)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "time"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rodauth
|
|
6
|
+
module CibaSupport
|
|
7
|
+
# Application-scoped JWKS cache. Ordinary HTTP caching remains delegated.
|
|
8
|
+
# The separate entries retain explicit stale allowances without depending on
|
|
9
|
+
# upstream TtlStore internals. Replacing an entry invalidates in-flight snapshots.
|
|
10
|
+
class JwksCache
|
|
11
|
+
class StatusError < HTTP::Error
|
|
12
|
+
attr_reader :status
|
|
13
|
+
def initialize(status)
|
|
14
|
+
@status = status
|
|
15
|
+
super("JWKS retrieval failed")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
LIMIT = 1024
|
|
19
|
+
|
|
20
|
+
def initialize(upstream)
|
|
21
|
+
@upstream, @entries, @mutex = upstream, {}, Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](key)
|
|
25
|
+
@upstream[key]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def set(key, &block)
|
|
29
|
+
@upstream.set(key, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def uncache(key)
|
|
33
|
+
@mutex.synchronize { @entries.delete(key) }
|
|
34
|
+
@upstream.uncache(key)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch(key)
|
|
38
|
+
snapshot = @mutex.synchronize do
|
|
39
|
+
@entries.delete_if { |_uri, entry| entry[:until] && entry[:until] <= now }
|
|
40
|
+
entry = @entries[key]
|
|
41
|
+
return entry[:payload] if entry && entry[:fresh] && now < entry[:fresh]
|
|
42
|
+
unless entry
|
|
43
|
+
@entries.shift while @entries.size >= LIMIT
|
|
44
|
+
entry = @entries[key] = {}
|
|
45
|
+
end
|
|
46
|
+
entry
|
|
47
|
+
end
|
|
48
|
+
started = now
|
|
49
|
+
response = yield
|
|
50
|
+
received, wall = now, Time.now.to_f
|
|
51
|
+
raise StatusError, response.code.to_i unless response.code.to_i == 200
|
|
52
|
+
payload = JSON.parse(response.body, symbolize_names: true)
|
|
53
|
+
unless payload.is_a?(Hash) && payload[:keys].is_a?(Array) && payload[:keys].all? { |jwk| jwk.is_a?(Hash) }
|
|
54
|
+
raise HTTP::Error, "invalid remote JWKS"
|
|
55
|
+
end
|
|
56
|
+
lifetime, grace = policy(response, wall, received - started)
|
|
57
|
+
replacement = {payload: payload, fresh: received + lifetime, until: received + lifetime + grace}
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
if @entries[key].equal?(snapshot)
|
|
60
|
+
if replacement[:until] > now
|
|
61
|
+
@entries[key] = replacement
|
|
62
|
+
else
|
|
63
|
+
@entries.delete(key)
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
# A newer fetch/eviction wins. Never return the older fetched keys.
|
|
67
|
+
newer = @entries[key]
|
|
68
|
+
return newer[:payload] if newer && newer[:fresh] && now < newer[:fresh]
|
|
69
|
+
raise HTTP::Error, "JWKS changed during retrieval"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
payload
|
|
73
|
+
rescue StatusError, Timeout::Error => error
|
|
74
|
+
transient = error.is_a?(Timeout::Error) || [500, 502, 503, 504].include?(error.status)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
entry = @entries[key]
|
|
77
|
+
if transient && entry.equal?(snapshot) && entry[:payload] && now < entry[:until]
|
|
78
|
+
return entry[:payload]
|
|
79
|
+
end
|
|
80
|
+
# A newer successful fetch can satisfy this call, but stale snapshots
|
|
81
|
+
# cannot survive eviction or a concurrent invalidating response.
|
|
82
|
+
if transient && entry && !entry.equal?(snapshot) && entry[:fresh] && now < entry[:fresh]
|
|
83
|
+
return entry[:payload]
|
|
84
|
+
end
|
|
85
|
+
@entries.delete(key) if !transient && entry.equal?(snapshot)
|
|
86
|
+
end
|
|
87
|
+
raise
|
|
88
|
+
rescue StandardError
|
|
89
|
+
@mutex.synchronize { @entries.delete(key) if @entries[key].equal?(snapshot) }
|
|
90
|
+
raise
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def now
|
|
96
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def policy(response, wall, delay)
|
|
100
|
+
directives = {}
|
|
101
|
+
# Split outside quoted strings; ambiguous syntax/duplicates disable reuse.
|
|
102
|
+
parts = response["cache-control"].to_s.split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/)
|
|
103
|
+
parts.each do |part|
|
|
104
|
+
match = /\A\s*([!#$%&'*+.^_`|~0-9A-Za-z-]+)(?:\s*=\s*("[^"\\]*"|[!#$%&'*+.^_`|~0-9A-Za-z-]+))?\s*\z/.match(part)
|
|
105
|
+
return [0, 0] unless match
|
|
106
|
+
name = match[1].downcase
|
|
107
|
+
return [0, 0] if directives.key?(name)
|
|
108
|
+
directives[name] = match[2]&.delete_prefix('"')&.delete_suffix('"')
|
|
109
|
+
end
|
|
110
|
+
return [0, 0] if directives.key?("no-store") || directives.key?("no-cache")
|
|
111
|
+
# Do not cache variant responses without a full Vary-aware cache key.
|
|
112
|
+
return [0, 0] if response["vary"] && !response["vary"].strip.empty?
|
|
113
|
+
numeric = lambda do |name|
|
|
114
|
+
value = directives[name]
|
|
115
|
+
value && /\A\d+\z/.match?(value) ? value.to_i : nil
|
|
116
|
+
end
|
|
117
|
+
date = response["date"] ? Time.httpdate(response["date"]).to_f : wall
|
|
118
|
+
age = response["age"] || "0"
|
|
119
|
+
return [0, 0] unless /\A\d+\z/.match?(age)
|
|
120
|
+
current_age = [[wall - date, 0].max, age.to_i + delay].max
|
|
121
|
+
lifetime = if directives.key?("max-age")
|
|
122
|
+
numeric.call("max-age") or return [0, 0]
|
|
123
|
+
elsif response["expires"]
|
|
124
|
+
Time.httpdate(response["expires"]).to_f - date
|
|
125
|
+
else
|
|
126
|
+
# No heuristic reuse: absent explicit freshness means fetch on demand.
|
|
127
|
+
return [0, 0]
|
|
128
|
+
end
|
|
129
|
+
if directives.key?("s-maxage")
|
|
130
|
+
shared_lifetime = numeric.call("s-maxage")
|
|
131
|
+
return [0, 0] unless shared_lifetime
|
|
132
|
+
lifetime = [lifetime, shared_lifetime].min
|
|
133
|
+
end
|
|
134
|
+
grace = if directives.key?("must-revalidate") || directives.key?("proxy-revalidate") || directives.key?("s-maxage")
|
|
135
|
+
0
|
|
136
|
+
elsif directives.key?("stale-if-error")
|
|
137
|
+
allowance = numeric.call("stale-if-error")
|
|
138
|
+
return [0, 0] unless allowance
|
|
139
|
+
[allowance, 60].min
|
|
140
|
+
else
|
|
141
|
+
0
|
|
142
|
+
end
|
|
143
|
+
[lifetime - current_age, grace]
|
|
144
|
+
rescue ArgumentError
|
|
145
|
+
[0, 0]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rodauth
|
|
4
|
+
module CibaSupport
|
|
5
|
+
module Mtls
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def oauth_server_metadata_body(*)
|
|
9
|
+
super.tap do |metadata|
|
|
10
|
+
metadata[:tls_client_certificate_bound_access_tokens] = true if features.include?(:oauth_tls_client_auth)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ciba_mtls_request?
|
|
15
|
+
features.include?(:oauth_tls_client_auth) && (ciba_protocol_request? || @ciba_mtls_userinfo ||
|
|
16
|
+
(features.include?(:oauth_token_introspection) && request.path == introspect_path &&
|
|
17
|
+
ciba_dpop_userinfo_candidate?(param("token"))))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def client_certificate
|
|
21
|
+
return super unless ciba_mtls_request?
|
|
22
|
+
return if @ciba_mtls_storing
|
|
23
|
+
return @ciba_tls_certificate if defined?(@ciba_tls_certificate)
|
|
24
|
+
value = ciba_tls_client_certificate
|
|
25
|
+
@ciba_tls_certificate = case value
|
|
26
|
+
when nil then nil
|
|
27
|
+
when OpenSSL::X509::Certificate then value
|
|
28
|
+
when String
|
|
29
|
+
authorization_required if value.bytesize > 16_384
|
|
30
|
+
OpenSSL::X509::Certificate.new(value)
|
|
31
|
+
else
|
|
32
|
+
authorization_required
|
|
33
|
+
end
|
|
34
|
+
rescue OpenSSL::X509::CertificateError, ArgumentError
|
|
35
|
+
authorization_required
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ciba_certificate_thumbprint(certificate)
|
|
39
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(certificate.to_der), padding: false)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def require_oauth_application
|
|
43
|
+
return super unless ciba_mtls_request?
|
|
44
|
+
client_id = param_or_nil("client_id")
|
|
45
|
+
application = client_id && db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
|
|
46
|
+
methods = application ? application[oauth_applications_token_endpoint_auth_method_column].to_s.split : []
|
|
47
|
+
return super if (methods & %w[tls_client_auth self_signed_tls_client_auth]).empty?
|
|
48
|
+
# Like the reference registration contract, TLS clients select one
|
|
49
|
+
# authentication method. Never fall back to upstream header trust.
|
|
50
|
+
authorization_required unless methods.length == 1
|
|
51
|
+
method = methods.first
|
|
52
|
+
authorization_required if request.env["HTTP_AUTHORIZATION"] || request.params.key?("client_secret") || request.params.key?("client_assertion")
|
|
53
|
+
certificate = client_certificate
|
|
54
|
+
authorization_required unless certificate
|
|
55
|
+
if method == "tls_client_auth"
|
|
56
|
+
authorization_required unless ciba_tls_client_certificate_authorized? == true
|
|
57
|
+
properties = %i[subject_dn san_dns san_uri san_ip san_email].filter_map do |suffix|
|
|
58
|
+
name = :"tls_client_auth_#{suffix}"
|
|
59
|
+
value = application[send(:"oauth_applications_#{name}_column")]
|
|
60
|
+
[name, value] if value
|
|
61
|
+
end
|
|
62
|
+
authorization_required unless properties.length == 1 &&
|
|
63
|
+
ciba_tls_client_certificate_subject_matches?(*properties.first) == true
|
|
64
|
+
else
|
|
65
|
+
authorization_required unless ciba_registered_certificate?(application, certificate)
|
|
66
|
+
end
|
|
67
|
+
@oauth_application = application
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ciba_registered_certificate?(application, certificate)
|
|
71
|
+
previous = @ciba_tls_key_lookup
|
|
72
|
+
@ciba_tls_key_lookup = true
|
|
73
|
+
jwks = oauth_application_jwks(application)
|
|
74
|
+
keys = jwks.is_a?(Hash) ? (jwks[:keys] || jwks["keys"]) : jwks
|
|
75
|
+
unless keys.is_a?(Array) && keys.all? { |key| key.is_a?(Hash) }
|
|
76
|
+
raise ProtocolError, "invalid_client_metadata"
|
|
77
|
+
end
|
|
78
|
+
expected = ciba_certificate_thumbprint(certificate)
|
|
79
|
+
keys.is_a?(Array) && keys.any? do |jwk|
|
|
80
|
+
next false unless jwk.is_a?(Hash)
|
|
81
|
+
chain = jwk[:x5c] || jwk["x5c"]
|
|
82
|
+
next false unless chain.is_a?(Array) && chain.first.is_a?(String) && chain.first.bytesize <= 24_000
|
|
83
|
+
registered = OpenSSL::X509::Certificate.new(Base64.strict_decode64(chain.first))
|
|
84
|
+
OpenSSL.fixed_length_secure_compare(ciba_certificate_thumbprint(registered), expected)
|
|
85
|
+
end
|
|
86
|
+
rescue CibaSupport::HTTP::Error, JSON::ParserError, IOError, SystemCallError, Timeout::Error,
|
|
87
|
+
SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
|
|
88
|
+
raise ProtocolError, "invalid_client_metadata"
|
|
89
|
+
rescue OpenSSL::X509::CertificateError, ArgumentError
|
|
90
|
+
false
|
|
91
|
+
ensure
|
|
92
|
+
@ciba_tls_key_lookup = previous
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ciba_certificate_binding_required?
|
|
96
|
+
# Capability is advertised separately. CIBA binding is a client policy;
|
|
97
|
+
# leave upstream's global policy in effect only for its other grants.
|
|
98
|
+
oauth_application[oauth_applications_tls_client_certificate_bound_access_tokens_column]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def generate_token(grant_params = {}, should_generate_refresh_token = true)
|
|
102
|
+
if ciba_mtls_request? && grant_params[oauth_grants_type_column] == GRANT_TYPE && ciba_certificate_binding_required?
|
|
103
|
+
# Validate before upstream's inner uniqueness transaction can convert a
|
|
104
|
+
# protocol exception into a response and commit the outer consumption.
|
|
105
|
+
raise ProtocolError, "invalid_grant" unless client_certificate
|
|
106
|
+
raise ProtocolError, "invalid_request" if @dpop_claims
|
|
107
|
+
end
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def store_token(grant_params, update_params = {})
|
|
112
|
+
return super unless ciba_mtls_request? && grant_params[oauth_grants_type_column] == GRANT_TYPE
|
|
113
|
+
if ciba_certificate_binding_required?
|
|
114
|
+
update_params = update_params.merge(oauth_grants_certificate_thumbprint_column => ciba_certificate_thumbprint(client_certificate))
|
|
115
|
+
end
|
|
116
|
+
# Upstream would replace the certificate digest with a public-key JWK
|
|
117
|
+
# thumbprint. Supply the completed binding and delegate token persistence.
|
|
118
|
+
@ciba_mtls_storing = true
|
|
119
|
+
super(grant_params, update_params)
|
|
120
|
+
ensure
|
|
121
|
+
@ciba_mtls_storing = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def authorization_token
|
|
125
|
+
token = super
|
|
126
|
+
return token unless token && features.include?(:oauth_tls_client_auth) && request.path == userinfo_path && @ciba_userinfo_grant_id
|
|
127
|
+
thumbprint = db[oauth_grants_table].where(oauth_grants_id_column => @ciba_userinfo_grant_id,
|
|
128
|
+
oauth_grants_type_column => GRANT_TYPE).get(oauth_grants_certificate_thumbprint_column)
|
|
129
|
+
return token unless thumbprint
|
|
130
|
+
@ciba_mtls_userinfo = true
|
|
131
|
+
certificate = client_certificate
|
|
132
|
+
unless certificate && thumbprint == ciba_certificate_thumbprint(certificate)
|
|
133
|
+
response["WWW-Authenticate"] = 'Bearer error="invalid_token"'
|
|
134
|
+
ciba_error("invalid_token", 401)
|
|
135
|
+
end
|
|
136
|
+
token
|
|
137
|
+
ensure
|
|
138
|
+
@ciba_mtls_userinfo = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def json_token_introspect_payload(grant)
|
|
142
|
+
if features.include?(:oauth_tls_client_auth) && grant && grant.key?("urn:rodauth:ciba:token_id")
|
|
143
|
+
# Introspection receives verified JWT claims, not the persisted grant.
|
|
144
|
+
# Resolve its live issuance before reporting certificate binding.
|
|
145
|
+
id = grant["urn:rodauth:ciba:token_id"]
|
|
146
|
+
return {active: false} unless id.is_a?(Integer) && id.positive?
|
|
147
|
+
row = db[oauth_grants_table].where(oauth_grants_id_column => id,
|
|
148
|
+
oauth_grants_type_column => GRANT_TYPE, oauth_grants_revoked_at_column => nil)
|
|
149
|
+
.where(Sequel.expr(Sequel[oauth_grants_expires_in_column]) >= Sequel::CURRENT_TIMESTAMP).first
|
|
150
|
+
return {active: false} unless row
|
|
151
|
+
application = oauth_application_ds(row[oauth_grants_oauth_application_id_column]).first
|
|
152
|
+
return {active: false} unless application && application[oauth_applications_client_id_column] == grant["client_id"]
|
|
153
|
+
subject = row[ciba_token_subject_column] || jwt_subject(row[oauth_grants_account_id_column], application)
|
|
154
|
+
return {active: false} unless subject == grant["sub"]
|
|
155
|
+
thumbprint = row[oauth_grants_certificate_thumbprint_column]
|
|
156
|
+
return {active: false} if thumbprint && grant.dig("cnf", "x5t#S256") != thumbprint
|
|
157
|
+
# The upstream username resolver otherwise treats a pairwise sub as
|
|
158
|
+
# a local account key. Preserve the public sub and supply its verified
|
|
159
|
+
# internal identity only for resolving the upstream response fields.
|
|
160
|
+
return super(grant.merge(oauth_grants_account_id_column => row[oauth_grants_account_id_column])).tap do |payload|
|
|
161
|
+
(payload[:cnf] ||= {})["x5t#S256"] = thumbprint if payload[:active] && thumbprint
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
super.tap do |payload|
|
|
165
|
+
if features.include?(:oauth_tls_client_auth) && payload[:active] && grant &&
|
|
166
|
+
grant[oauth_grants_type_column] == GRANT_TYPE &&
|
|
167
|
+
(thumbprint = grant[oauth_grants_certificate_thumbprint_column])
|
|
168
|
+
(payload[:cnf] ||= {})["x5t#S256"] = thumbprint
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rodauth
|
|
4
|
+
module CibaSupport
|
|
5
|
+
module PairwiseSubjects
|
|
6
|
+
def jwt_subject(account_id, application = oauth_application)
|
|
7
|
+
return super unless application &&
|
|
8
|
+
application[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE) &&
|
|
9
|
+
(application[oauth_applications_subject_type_column] || oauth_jwt_subject_type) == "pairwise"
|
|
10
|
+
sector = ciba_pairwise_sector(application)
|
|
11
|
+
raise ConfigurationError, "invalid CIBA pairwise client" unless ciba_pairwise_enabled && sector
|
|
12
|
+
subject = ciba_pairwise_identifier(account_id, oauth_application: application, sector_identifier: sector)
|
|
13
|
+
unless subject.is_a?(String) && subject.ascii_only? && !subject.empty? && subject.bytesize <= 255
|
|
14
|
+
raise ConfigurationError, "invalid CIBA pairwise identifier"
|
|
15
|
+
end
|
|
16
|
+
subject
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def fill_with_account_claims(claims, account, scopes, locales)
|
|
22
|
+
result = super
|
|
23
|
+
if @ciba_userinfo_grant_id && request.path == userinfo_path && oauth_application &&
|
|
24
|
+
(oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type) == "pairwise"
|
|
25
|
+
claims["sub"] = jwt_subject(account[account_id_column], oauth_application)
|
|
26
|
+
end
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ciba_pairwise_sector(application)
|
|
31
|
+
return unless ciba_pairwise_enabled
|
|
32
|
+
authentication = application[oauth_applications_token_endpoint_auth_method_column]
|
|
33
|
+
return unless authentication == "private_key_jwt" ||
|
|
34
|
+
(authentication == "self_signed_tls_client_auth" && features.include?(:oauth_tls_client_auth))
|
|
35
|
+
jwks_uri = application[oauth_applications_jwks_uri_column]
|
|
36
|
+
return unless ciba_pairwise_uri(jwks_uri)
|
|
37
|
+
return if application[oauth_applications_jwks_column]
|
|
38
|
+
sector_uri = application[oauth_applications_sector_identifier_uri_column]
|
|
39
|
+
responses = application[oauth_applications_response_types_column].to_s.split
|
|
40
|
+
return if !responses.empty? && !sector_uri
|
|
41
|
+
uri = sector_uri ? ciba_pairwise_uri(sector_uri, https: true) : ciba_pairwise_uri(jwks_uri)
|
|
42
|
+
return unless uri
|
|
43
|
+
# WHATWG URL.host includes a nondefault port, unlike Ruby URI#host.
|
|
44
|
+
authority = uri.host.downcase
|
|
45
|
+
authority += ":#{uri.port}" unless uri.port == uri.default_port
|
|
46
|
+
authority
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ciba_pairwise_uri(value, https: false)
|
|
50
|
+
return unless value.is_a?(String) && !value.empty?
|
|
51
|
+
uri = URI(value)
|
|
52
|
+
return unless (https ? uri.scheme == "https" : %w[http https].include?(uri.scheme)) &&
|
|
53
|
+
uri.host && !uri.userinfo && !uri.fragment
|
|
54
|
+
uri
|
|
55
|
+
rescue URI::InvalidURIError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ciba_pairwise_sector_document_valid?(application, fresh: false)
|
|
60
|
+
value = application[oauth_applications_sector_identifier_uri_column]
|
|
61
|
+
return true unless value
|
|
62
|
+
redirects = application[oauth_applications_redirect_uri_column].to_s.split
|
|
63
|
+
required = [application[oauth_applications_jwks_uri_column]]
|
|
64
|
+
required += redirects unless application[oauth_applications_response_types_column].to_s.empty?
|
|
65
|
+
# Like node's dynamic-client cache, bind validation to complete client
|
|
66
|
+
# metadata rather than the document URL. Never cache failed validation.
|
|
67
|
+
mutex, cache = self.class.instance_variable_get(:@ciba_sector_validation_cache)
|
|
68
|
+
key = Digest::SHA256.hexdigest(JSON.generate(application.sort_by { |column, _| column.to_s }))
|
|
69
|
+
cached = !fresh && mutex.synchronize { cache[key] = true if cache.delete(key) }
|
|
70
|
+
return true if cached
|
|
71
|
+
uri = ciba_pairwise_uri(value, https: true)
|
|
72
|
+
return false unless uri
|
|
73
|
+
response = ciba_pairwise_sector_response(uri)
|
|
74
|
+
body = JSON.parse(response.body) if response.code.to_i == 200
|
|
75
|
+
valid = body.is_a?(Array) && body.all? { |entry| entry.is_a?(String) } && (required - body).empty?
|
|
76
|
+
if valid
|
|
77
|
+
mutex.synchronize do
|
|
78
|
+
cache.delete(key)
|
|
79
|
+
cache[key] = true
|
|
80
|
+
cache.shift while cache.size > 100
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
valid
|
|
84
|
+
rescue HTTP::Error, JSON::ParserError, IOError, SystemCallError, Timeout::Error,
|
|
85
|
+
SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ciba_pairwise_sector_response(uri)
|
|
90
|
+
Timeout.timeout(HTTP::TIMEOUT) do
|
|
91
|
+
21.times do |redirect_count|
|
|
92
|
+
response = HTTP.request(uri, Net::HTTP::Get.new(uri.request_uri), address_allowed: method(:ciba_http_address_allowed?))
|
|
93
|
+
return response unless [301, 302, 303, 307, 308].include?(response.code.to_i) && response["location"]
|
|
94
|
+
raise HTTP::Error, "too many sector redirects" if redirect_count == 20
|
|
95
|
+
destination = URI.join(uri.to_s, response["location"]).to_s
|
|
96
|
+
uri = ciba_pairwise_uri(destination)
|
|
97
|
+
raise HTTP::Error, "invalid sector redirect URI" unless uri
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
rescue URI::InvalidURIError
|
|
101
|
+
raise HTTP::Error, "invalid sector redirect URI"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|