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,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Installed-artifact fixture, with actual TLS peer certificates and local trust.
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "webrick/https"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "rack/mock"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "rbconfig"
|
|
11
|
+
require "json"
|
|
12
|
+
require "base64"
|
|
13
|
+
require "digest"
|
|
14
|
+
require_relative "demo_app"
|
|
15
|
+
|
|
16
|
+
module CibaMtlsSmoke
|
|
17
|
+
def self.certificate(key, name, serial, server: false)
|
|
18
|
+
cert = OpenSSL::X509::Certificate.new
|
|
19
|
+
cert.version = 2
|
|
20
|
+
cert.serial = serial
|
|
21
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=#{name}")
|
|
22
|
+
cert.public_key = key.public_key
|
|
23
|
+
cert.not_before = Time.now - 60
|
|
24
|
+
cert.not_after = Time.now + 3600
|
|
25
|
+
factory = OpenSSL::X509::ExtensionFactory.new
|
|
26
|
+
factory.subject_certificate = factory.issuer_certificate = cert
|
|
27
|
+
cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
|
|
28
|
+
cert.add_extension(factory.create_extension("keyUsage", "digitalSignature,keyEncipherment,keyCertSign", true))
|
|
29
|
+
cert.add_extension(factory.create_extension("extendedKeyUsage", server ? "serverAuth" : "clientAuth"))
|
|
30
|
+
cert.add_extension(factory.create_extension("subjectAltName", "IP:127.0.0.1")) if server
|
|
31
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.run
|
|
35
|
+
unless ENV["CIBA_MTLS_TEST_CERT"]
|
|
36
|
+
Dir.mktmpdir("ciba-mtls-tls-") do |directory|
|
|
37
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
38
|
+
cert = certificate(key, "localhost", 1, server: true)
|
|
39
|
+
cert_path, key_path = %w[cert.pem key.pem].map { |name| File.join(directory, name) }
|
|
40
|
+
File.write(cert_path, cert.to_pem)
|
|
41
|
+
File.write(key_path, key.to_pem, perm: 0o600)
|
|
42
|
+
env = {"CIBA_MTLS_TEST_CERT" => cert_path, "CIBA_MTLS_TEST_KEY" => key_path, "SSL_CERT_FILE" => cert_path}
|
|
43
|
+
output, errors, status = Open3.capture3(env, RbConfig.ruby, __FILE__)
|
|
44
|
+
print output
|
|
45
|
+
warn errors unless errors.empty?
|
|
46
|
+
raise "mTLS installed smoke failed" unless status.success?
|
|
47
|
+
end
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
server_key = OpenSSL::PKey.read(File.read(ENV.fetch("CIBA_MTLS_TEST_KEY")))
|
|
51
|
+
server_cert = OpenSSL::X509::Certificate.new(File.read(ENV.fetch("CIBA_MTLS_TEST_CERT")))
|
|
52
|
+
client_key = OpenSSL::PKey::RSA.generate(2048)
|
|
53
|
+
client_cert = certificate(client_key, "client", 2)
|
|
54
|
+
other_cert = certificate(client_key, "client", 3)
|
|
55
|
+
foreign_key = OpenSSL::PKey::RSA.generate(2048)
|
|
56
|
+
foreign_cert = certificate(foreign_key, "client", 4)
|
|
57
|
+
expected = Base64.urlsafe_encode64(Digest::SHA256.digest(client_cert.to_der), padding: false)
|
|
58
|
+
%w[tls_client_auth self_signed_tls_client_auth].product([false, true]).each do |method, jwt|
|
|
59
|
+
database = Sequel.sqlite(max_connections: 1)
|
|
60
|
+
ready = Queue.new
|
|
61
|
+
server_options = {BindAddress: "127.0.0.1", Port: 0, SSLEnable: true,
|
|
62
|
+
SSLCertificate: server_cert, SSLPrivateKey: server_key,
|
|
63
|
+
SSLVerifyClient: OpenSSL::SSL::VERIFY_PEER,
|
|
64
|
+
# Self-signed clients need to reach the application. PKI verification
|
|
65
|
+
# below separately validates the actual peer against the fixture CA.
|
|
66
|
+
SSLVerifyCallback: ->(_ok, _context) { true },
|
|
67
|
+
StartCallback: -> { ready << true }, Logger: WEBrick::Log.new(File::NULL), AccessLog: []}
|
|
68
|
+
server = WEBrick::HTTPServer.new(server_options)
|
|
69
|
+
alias_server = WEBrick::HTTPServer.new(server_options)
|
|
70
|
+
alias_thread = nil
|
|
71
|
+
publish_aliases = false
|
|
72
|
+
alias_keys = %w[backchannel_authentication_endpoint token_endpoint introspection_endpoint userinfo_endpoint]
|
|
73
|
+
thread = nil
|
|
74
|
+
begin
|
|
75
|
+
issuer = "https://127.0.0.1:#{server.config[:Port]}"
|
|
76
|
+
alias_origin = "https://127.0.0.1:#{alias_server.config[:Port]}"
|
|
77
|
+
CibaDemo.create_schema(database)
|
|
78
|
+
Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
|
|
79
|
+
Rodauth::CibaSupport::Schema.create_client_assertions(database)
|
|
80
|
+
Rodauth::CibaSupport::Schema.add_registration_token_digest(database)
|
|
81
|
+
Rodauth::CibaSupport::Schema.add_pairwise_subject(database)
|
|
82
|
+
database.alter_table(:oauth_grants) do
|
|
83
|
+
add_column :certificate_thumbprint, String
|
|
84
|
+
add_column :dpop_jkt, String
|
|
85
|
+
end
|
|
86
|
+
database.alter_table(:oauth_applications) do
|
|
87
|
+
add_column :jwks, String, text: true
|
|
88
|
+
add_column :jwks_uri, String, text: true
|
|
89
|
+
add_column :tls_client_auth_subject_dn, String
|
|
90
|
+
add_column :tls_client_certificate_bound_access_tokens, TrueClass
|
|
91
|
+
add_column :dpop_bound_access_tokens, TrueClass
|
|
92
|
+
add_column :name, String
|
|
93
|
+
add_column :redirect_uri, String, text: true
|
|
94
|
+
add_column :response_types, String
|
|
95
|
+
add_column :registration_access_token, String
|
|
96
|
+
end
|
|
97
|
+
jwk = JWT::JWK.new(client_key.public_key).export.merge(x5c: [Base64.strict_encode64(client_cert.to_der)])
|
|
98
|
+
remote_keys, remote_status, remote_hits = [jwk], 200, 0
|
|
99
|
+
remote_uri = "#{issuer}/client-jwks"
|
|
100
|
+
app = CibaDemo.build(database)
|
|
101
|
+
app.plugin(:rodauth) do
|
|
102
|
+
enable :oauth_tls_client_auth, :oauth_token_introspection, :oauth_dynamic_client_registration, :oauth_dpop
|
|
103
|
+
# CIBA client policy remains authoritative even when upstream forces
|
|
104
|
+
# certificate binding for its ordinary OAuth grants.
|
|
105
|
+
oauth_tls_client_certificate_bound_access_tokens true
|
|
106
|
+
ciba_dynamic_client_registration_enabled true
|
|
107
|
+
ciba_pairwise_enabled true
|
|
108
|
+
ciba_pairwise_identifier do |account_id, oauth_application:, sector_identifier:|
|
|
109
|
+
OpenSSL::HMAC.hexdigest("SHA256", "local-pairwise-fixture-secret", JSON.generate([sector_identifier, account_id]))
|
|
110
|
+
end
|
|
111
|
+
before_register do
|
|
112
|
+
authorization_required unless request.env["HTTP_AUTHORIZATION"] == "Bearer local-mtls-registration"
|
|
113
|
+
end
|
|
114
|
+
base_url issuer
|
|
115
|
+
authorization_server_url issuer
|
|
116
|
+
ciba_require_tls true
|
|
117
|
+
oauth_jwt_access_tokens jwt
|
|
118
|
+
ciba_refresh_tokens_enabled true
|
|
119
|
+
oauth_application_scopes %w[openid offline_access]
|
|
120
|
+
ciba_http_address_allowed? { |address| address == "127.0.0.1" }
|
|
121
|
+
ciba_tls_client_certificate { request.env["ciba.tls.peer_certificate"] }
|
|
122
|
+
ciba_tls_client_certificate_authorized? do
|
|
123
|
+
store = OpenSSL::X509::Store.new
|
|
124
|
+
store.add_cert(client_cert)
|
|
125
|
+
peer = ciba_tls_client_certificate
|
|
126
|
+
peer && store.verify(peer)
|
|
127
|
+
end
|
|
128
|
+
ciba_tls_client_certificate_subject_matches? do |property, value|
|
|
129
|
+
property == :tls_client_auth_subject_dn &&
|
|
130
|
+
ciba_tls_client_certificate.subject.cmp(OpenSSL::X509::Name.parse(value)).zero?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
app.route do |r|
|
|
134
|
+
rodauth.load_openid_configuration_route
|
|
135
|
+
r.rodauth
|
|
136
|
+
rodauth.load_registration_client_uri_routes
|
|
137
|
+
end
|
|
138
|
+
[server, alias_server].each do |listener|
|
|
139
|
+
listener_origin = "https://127.0.0.1:#{listener.config[:Port]}"
|
|
140
|
+
listener.mount_proc("/") do |incoming, outgoing|
|
|
141
|
+
env = Rack::MockRequest.env_for("#{listener_origin}#{incoming.unparsed_uri}", method: incoming.request_method, input: incoming.body.to_s)
|
|
142
|
+
incoming.header.each do |name, values|
|
|
143
|
+
key = name.upcase.tr("-", "_")
|
|
144
|
+
env[%w[CONTENT_TYPE CONTENT_LENGTH].include?(key) ? key : "HTTP_#{key}"] = values.join(",")
|
|
145
|
+
end
|
|
146
|
+
# This value comes from WEBrick's TLS socket, never request headers.
|
|
147
|
+
env["ciba.tls.peer_certificate"] = incoming.client_cert
|
|
148
|
+
status, headers, body = app.call(env)
|
|
149
|
+
outgoing.status = status
|
|
150
|
+
headers.each { |name, value| outgoing[name] = value }
|
|
151
|
+
outgoing.body = +""
|
|
152
|
+
begin
|
|
153
|
+
body.each { |chunk| outgoing.body << chunk }
|
|
154
|
+
ensure
|
|
155
|
+
body.close if body.respond_to?(:close)
|
|
156
|
+
end
|
|
157
|
+
# Application-owned Discovery middleware; no alternate issuer or
|
|
158
|
+
# certificate assertions are taken from client-provided headers.
|
|
159
|
+
if publish_aliases && incoming.path == "/.well-known/openid-configuration" && status == 200
|
|
160
|
+
document = JSON.parse(outgoing.body)
|
|
161
|
+
document["mtls_endpoint_aliases"] = alias_keys.to_h do |name|
|
|
162
|
+
[name, "#{alias_origin}#{URI(document.fetch(name)).path}"]
|
|
163
|
+
end
|
|
164
|
+
outgoing.body = JSON.generate(document)
|
|
165
|
+
outgoing["Content-Length"] = outgoing.body.bytesize.to_s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
server.mount_proc("/client-jwks") do |_incoming, outgoing|
|
|
170
|
+
remote_hits += 1
|
|
171
|
+
outgoing.status = remote_status
|
|
172
|
+
outgoing["Content-Type"] = "application/json"
|
|
173
|
+
outgoing["Cache-Control"] = "max-age=3600"
|
|
174
|
+
outgoing.body = JSON.generate(keys: remote_keys)
|
|
175
|
+
end
|
|
176
|
+
thread = Thread.new { server.start }
|
|
177
|
+
alias_thread = Thread.new { alias_server.start }
|
|
178
|
+
Timeout.timeout(5) { 2.times { ready.pop } }
|
|
179
|
+
send_request = lambda do |url, params = nil, cert = client_cert, key = client_key, headers = {}, verb = nil|
|
|
180
|
+
uri = URI(url)
|
|
181
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
182
|
+
http.use_ssl = true
|
|
183
|
+
trust = OpenSSL::X509::Store.new
|
|
184
|
+
trust.add_cert(server_cert)
|
|
185
|
+
http.cert_store = trust
|
|
186
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
187
|
+
http.verify_hostname = true
|
|
188
|
+
http.cert, http.key = cert, key if cert
|
|
189
|
+
http.open_timeout = http.read_timeout = 5
|
|
190
|
+
request_class = verb == "PUT" ? Net::HTTP::Put : (params ? Net::HTTP::Post : Net::HTTP::Get)
|
|
191
|
+
request = request_class.new(uri, headers)
|
|
192
|
+
if params
|
|
193
|
+
if headers["Content-Type"] == "application/json"
|
|
194
|
+
request.body = JSON.generate(params)
|
|
195
|
+
else
|
|
196
|
+
request.set_form_data(params)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
response = http.request(request)
|
|
200
|
+
[response.code.to_i, JSON.parse(response.body)]
|
|
201
|
+
end
|
|
202
|
+
check = lambda do |result, status|
|
|
203
|
+
raise "HTTP #{result[0]}: #{result[1]} (expected #{status})" unless result[0] == status
|
|
204
|
+
result[1]
|
|
205
|
+
end
|
|
206
|
+
baseline = check.call(send_request.call("#{issuer}/.well-known/openid-configuration", nil, nil), 200)
|
|
207
|
+
raise "unexpected automatic aliases" if baseline.key?("mtls_endpoint_aliases")
|
|
208
|
+
publish_aliases = true
|
|
209
|
+
discovered = check.call(send_request.call("#{issuer}/.well-known/openid-configuration", nil, nil), 200)
|
|
210
|
+
raise "alias changed issuer" unless discovered.fetch("issuer") == issuer
|
|
211
|
+
aliases = discovered.fetch("mtls_endpoint_aliases")
|
|
212
|
+
alias_keys.each do |name|
|
|
213
|
+
raise "canonical endpoint changed" unless discovered.fetch(name).start_with?("#{issuer}/")
|
|
214
|
+
raise "alias not on separate listener" unless aliases.fetch(name).start_with?("#{alias_origin}/")
|
|
215
|
+
end
|
|
216
|
+
metadata = discovered.merge(aliases)
|
|
217
|
+
registration_params = {grant_types: [Rodauth::CibaSupport::GRANT_TYPE, "refresh_token"], response_types: [],
|
|
218
|
+
scope: "openid offline_access", token_endpoint_auth_method: method, backchannel_token_delivery_mode: "poll",
|
|
219
|
+
tls_client_certificate_bound_access_tokens: true}
|
|
220
|
+
if method == "tls_client_auth"
|
|
221
|
+
registration_params[:tls_client_auth_subject_dn] = "/CN=client"
|
|
222
|
+
else
|
|
223
|
+
registration_params[:jwks_uri] = remote_uri
|
|
224
|
+
registration_params[:subject_type] = "pairwise"
|
|
225
|
+
end
|
|
226
|
+
registration_headers = {"Authorization" => "Bearer local-mtls-registration", "Content-Type" => "application/json"}
|
|
227
|
+
client = check.call(send_request.call(metadata.fetch("registration_endpoint"), registration_params, nil, nil, registration_headers), 201)
|
|
228
|
+
client_id = client.fetch("client_id")
|
|
229
|
+
raise "binding registration lost" unless client.fetch("tls_client_certificate_bound_access_tokens") == true
|
|
230
|
+
start_params = {client_id: client_id, scope: "openid offline_access", login_hint: "customer@example.test"}
|
|
231
|
+
start_url = metadata.fetch("backchannel_authentication_endpoint")
|
|
232
|
+
check.call(send_request.call(start_url, start_params, nil), 401)
|
|
233
|
+
spoof = {"X-SSL-Client-Cert" => CGI.escape(client_cert.to_pem), "X-SSL-Client-Verify" => "SUCCESS"}
|
|
234
|
+
check.call(send_request.call(start_url, start_params, nil, nil, spoof), 401)
|
|
235
|
+
check.call(send_request.call(start_url, start_params, foreign_cert, foreign_key), 401)
|
|
236
|
+
check.call(send_request.call(start_url, start_params, other_cert), 401) if method == "self_signed_tls_client_auth"
|
|
237
|
+
started = check.call(send_request.call(start_url, start_params), 200)
|
|
238
|
+
auth = app.new(Rack::MockRequest.env_for(issuer)).rodauth
|
|
239
|
+
pending = auth.ciba_request(database[:ciba_requests].get(:id))
|
|
240
|
+
consent = auth.create_ciba_grant(account_id: pending[:account_id], oauth_application_id: pending[:oauth_application_id], scopes: pending[:scopes])
|
|
241
|
+
auth.backchannel_result(pending[:id], consent)
|
|
242
|
+
params = {client_id: client_id, grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id")}
|
|
243
|
+
check.call(send_request.call(metadata.fetch("token_endpoint"), params, nil), 401)
|
|
244
|
+
tokens = check.call(send_request.call(metadata.fetch("token_endpoint"), params), 200)
|
|
245
|
+
jwks = check.call(send_request.call(metadata.fetch("jwks_uri"), nil, nil), 200)
|
|
246
|
+
identity, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: jwks, algorithms: ["RS256"],
|
|
247
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: client_id)
|
|
248
|
+
raise "ID Token inherited certificate binding" if identity.key?("cnf")
|
|
249
|
+
if method == "self_signed_tls_client_auth"
|
|
250
|
+
sector = "127.0.0.1:#{server.config[:Port]}"
|
|
251
|
+
pairwise_subject = OpenSSL::HMAC.hexdigest("SHA256", "local-pairwise-fixture-secret", JSON.generate([sector, pending[:account_id]]))
|
|
252
|
+
raise "pairwise subject mismatch" unless identity.fetch("sub") == pairwise_subject
|
|
253
|
+
raise "pairwise subject was not stored" unless database[:oauth_grants].order(:id).last[:ciba_subject] == pairwise_subject
|
|
254
|
+
end
|
|
255
|
+
if jwt
|
|
256
|
+
access, = JWT.decode(tokens.fetch("access_token"), nil, true, jwks: jwks, algorithms: ["RS256"], verify_iss: true, iss: issuer)
|
|
257
|
+
raise "wrong JWT certificate digest" unless access.fetch("cnf").fetch("x5t#S256") == expected
|
|
258
|
+
else
|
|
259
|
+
info = check.call(send_request.call(metadata.fetch("introspection_endpoint"), {client_id: client_id, token: tokens.fetch("access_token")}), 200)
|
|
260
|
+
raise "inactive bound token" unless info.fetch("active")
|
|
261
|
+
raise "wrong introspection digest" unless info.fetch("cnf").fetch("x5t#S256") == expected
|
|
262
|
+
end
|
|
263
|
+
authorization = {"Authorization" => "Bearer #{tokens.fetch('access_token')}"}
|
|
264
|
+
userinfo = metadata.fetch("userinfo_endpoint")
|
|
265
|
+
check.call(send_request.call(userinfo, nil, nil, nil, authorization), 401)
|
|
266
|
+
check.call(send_request.call(userinfo, nil, other_cert, client_key, authorization), 401)
|
|
267
|
+
info = check.call(send_request.call(userinfo, nil, client_cert, client_key, authorization), 200)
|
|
268
|
+
raise "wrong UserInfo subject" unless info.fetch("sub") == identity.fetch("sub")
|
|
269
|
+
refresh_params = {client_id: client_id, grant_type: "refresh_token", refresh_token: tokens.fetch("refresh_token")}
|
|
270
|
+
check.call(send_request.call(metadata.fetch("token_endpoint"), refresh_params, nil), 401)
|
|
271
|
+
refreshed = check.call(send_request.call(metadata.fetch("token_endpoint"), refresh_params), 200)
|
|
272
|
+
renewed_identity, = JWT.decode(refreshed.fetch("id_token"), nil, true, jwks: jwks, algorithms: ["RS256"],
|
|
273
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: client_id)
|
|
274
|
+
raise "refreshed ID Token inherited certificate binding" if renewed_identity.key?("cnf")
|
|
275
|
+
fresh_auth = {"Authorization" => "Bearer #{refreshed.fetch('access_token')}"}
|
|
276
|
+
check.call(send_request.call(userinfo, nil, nil, nil, fresh_auth), 401)
|
|
277
|
+
check.call(send_request.call(userinfo, nil, client_cert, client_key, fresh_auth), 200)
|
|
278
|
+
management_headers = {"Authorization" => "Bearer #{client.fetch('registration_access_token')}", "Content-Type" => "application/json"}
|
|
279
|
+
replacement = registration_params.merge(client_id: client_id)
|
|
280
|
+
check.call(send_request.call(client.fetch("registration_client_uri"),
|
|
281
|
+
replacement.merge(tls_client_certificate_bound_access_tokens: "false"), nil, nil, management_headers, "PUT"), 400)
|
|
282
|
+
retained = check.call(send_request.call(client.fetch("registration_client_uri"), nil, nil, nil, management_headers), 200)
|
|
283
|
+
raise "failed update changed policy" unless retained.fetch("tls_client_certificate_bound_access_tokens") == true
|
|
284
|
+
replacement.delete(:tls_client_certificate_bound_access_tokens)
|
|
285
|
+
updated = check.call(send_request.call(client.fetch("registration_client_uri"), replacement, nil, nil, management_headers, "PUT"), 200)
|
|
286
|
+
raise "omitted binding not reset" unless updated.fetch("tls_client_certificate_bound_access_tokens") == false
|
|
287
|
+
check.call(send_request.call(client.fetch("registration_client_uri"), nil, nil, nil, management_headers), 401)
|
|
288
|
+
new_headers = management_headers.merge("Authorization" => "Bearer #{updated.fetch('registration_access_token')}")
|
|
289
|
+
check.call(send_request.call(client.fetch("registration_client_uri"), nil, nil, nil, new_headers), 200)
|
|
290
|
+
# Management changes future issuance, never already-issued credentials.
|
|
291
|
+
check.call(send_request.call(userinfo, nil, nil, nil, fresh_auth), 401)
|
|
292
|
+
unbound = check.call(send_request.call(metadata.fetch("token_endpoint"), refresh_params), 200)
|
|
293
|
+
unbound_auth = {"Authorization" => "Bearer #{unbound.fetch('access_token')}"}
|
|
294
|
+
check.call(send_request.call(userinfo, nil, nil, nil, unbound_auth), 200)
|
|
295
|
+
if method == "self_signed_tls_client_auth"
|
|
296
|
+
raise "remote JWKS not cached" unless remote_hits == 1
|
|
297
|
+
remote_keys = [JWT::JWK.new(client_key.public_key).export.merge(x5c: [Base64.strict_encode64(other_cert.to_der)])]
|
|
298
|
+
check.call(send_request.call(start_url, start_params, other_cert), 401)
|
|
299
|
+
raise "certificate miss forced refetch" unless remote_hits == 1
|
|
300
|
+
# Re-enable binding before issuing with the newly published certificate.
|
|
301
|
+
check.call(send_request.call(client.fetch("registration_client_uri"),
|
|
302
|
+
replacement.merge(tls_client_certificate_bound_access_tokens: true), nil, nil, new_headers, "PUT"), 200)
|
|
303
|
+
auth.send(:http_request_cache).uncache(URI(remote_uri))
|
|
304
|
+
check.call(send_request.call(start_url, start_params, client_cert), 401)
|
|
305
|
+
rotated_refresh = {client_id: client_id, grant_type: "refresh_token", refresh_token: unbound.fetch("refresh_token")}
|
|
306
|
+
rotated = check.call(send_request.call(metadata.fetch("token_endpoint"), rotated_refresh, other_cert), 200)
|
|
307
|
+
raise "rotation did not fetch once" unless remote_hits == 2
|
|
308
|
+
rotated_auth = {"Authorization" => "Bearer #{rotated.fetch('access_token')}"}
|
|
309
|
+
check.call(send_request.call(userinfo, nil, other_cert, client_key, rotated_auth), 200)
|
|
310
|
+
check.call(send_request.call(userinfo, nil, client_cert, client_key, rotated_auth), 401)
|
|
311
|
+
check.call(send_request.call(userinfo, nil, client_cert, client_key, authorization), 200)
|
|
312
|
+
check.call(send_request.call(userinfo, nil, other_cert, client_key, authorization), 401)
|
|
313
|
+
expected_new = Base64.urlsafe_encode64(Digest::SHA256.digest(other_cert.to_der), padding: false)
|
|
314
|
+
inspected = check.call(send_request.call(metadata.fetch("introspection_endpoint"),
|
|
315
|
+
{client_id: client_id, token: rotated.fetch("access_token")}, other_cert), 200)
|
|
316
|
+
raise "rotated binding digest mismatch" unless inspected.fetch("cnf").fetch("x5t#S256") == expected_new
|
|
317
|
+
auth.send(:http_request_cache).uncache(URI(remote_uri))
|
|
318
|
+
remote_status = 503
|
|
319
|
+
pending_count = database[:ciba_requests].count
|
|
320
|
+
2.times do
|
|
321
|
+
failure = check.call(send_request.call(start_url, start_params, other_cert), 400)
|
|
322
|
+
raise "wrong lookup error" unless failure.fetch("error") == "invalid_client_metadata"
|
|
323
|
+
end
|
|
324
|
+
raise "failed retrieval created request" unless database[:ciba_requests].count == pending_count
|
|
325
|
+
remote_status = 200
|
|
326
|
+
check.call(send_request.call(start_url, start_params, other_cert), 200)
|
|
327
|
+
puts "PASS: installed-gem #{jwt ? 'JWT' : 'opaque'} remote x5c over verified HTTPS, cache expiry, same-key certificate rotation, refresh/new binding, old binding retained, failed fetch/recovery"
|
|
328
|
+
end
|
|
329
|
+
# Independent client: certificate authenticates the client; DPoP binds the token.
|
|
330
|
+
dpop_registration = registration_params.reject { |name, _| %i[jwks_uri subject_type].include?(name) }.merge(
|
|
331
|
+
tls_client_certificate_bound_access_tokens: false, dpop_bound_access_tokens: true)
|
|
332
|
+
dpop_registration[:jwks] = {keys: [jwk]} if method == "self_signed_tls_client_auth"
|
|
333
|
+
dpop_client = check.call(send_request.call(metadata.fetch("registration_endpoint"), dpop_registration,
|
|
334
|
+
nil, nil, registration_headers), 201)
|
|
335
|
+
dpop_id = dpop_client.fetch("client_id")
|
|
336
|
+
dpop_started = check.call(send_request.call(start_url,
|
|
337
|
+
{client_id: dpop_id, scope: "openid offline_access", login_hint: "customer@example.test"}), 200)
|
|
338
|
+
dpop_pending = auth.ciba_request(database[:ciba_requests].order(:id).last.fetch(:id))
|
|
339
|
+
dpop_consent = auth.create_ciba_grant(account_id: dpop_pending[:account_id],
|
|
340
|
+
oauth_application_id: dpop_pending[:oauth_application_id], scopes: dpop_pending[:scopes])
|
|
341
|
+
auth.backchannel_result(dpop_pending[:id], dpop_consent)
|
|
342
|
+
proof_key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
343
|
+
proof = lambda do |target, verb, token = nil|
|
|
344
|
+
claims = {jti: SecureRandom.uuid, iat: Time.now.to_i, htu: target, htm: verb}
|
|
345
|
+
claims[:ath] = Base64.urlsafe_encode64(Digest::SHA256.digest(token), padding: false) if token
|
|
346
|
+
JWT.encode(claims, proof_key, "ES256", typ: "dpop+jwt", jwk: JWT::JWK.new(proof_key).export)
|
|
347
|
+
end
|
|
348
|
+
token_url = metadata.fetch("token_endpoint")
|
|
349
|
+
dpop_params = {client_id: dpop_id, grant_type: Rodauth::CibaSupport::GRANT_TYPE,
|
|
350
|
+
auth_req_id: dpop_started.fetch("auth_req_id")}
|
|
351
|
+
check.call(send_request.call(token_url, dpop_params, nil, nil,
|
|
352
|
+
{"DPoP" => proof.call(token_url, "POST")}), 401)
|
|
353
|
+
check.call(send_request.call(token_url, dpop_params), 400)
|
|
354
|
+
check.call(send_request.call(token_url, dpop_params, client_cert, client_key,
|
|
355
|
+
{"DPoP" => proof.call(discovered.fetch("token_endpoint"), "POST")}), 400)
|
|
356
|
+
raise "rejected proof consumed approval" unless auth.ciba_request(dpop_pending[:id])[:status] == "approved"
|
|
357
|
+
dpop_tokens = check.call(send_request.call(token_url, dpop_params, client_cert, client_key,
|
|
358
|
+
{"DPoP" => proof.call(token_url, "POST")}), 200)
|
|
359
|
+
dpop_identity, = JWT.decode(dpop_tokens.fetch("id_token"), nil, true, jwks: jwks, algorithms: ["RS256"],
|
|
360
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: dpop_id)
|
|
361
|
+
raise "ID Token inherited sender binding" if dpop_identity.key?("cnf")
|
|
362
|
+
bound_token = dpop_tokens.fetch("access_token")
|
|
363
|
+
binding = check.call(send_request.call(metadata.fetch("introspection_endpoint"),
|
|
364
|
+
{client_id: dpop_id, token: bound_token}), 200).fetch("cnf")
|
|
365
|
+
raise "wrong combined binding" unless binding == {"jkt" => JWT::JWK::Thumbprint.new(JWT::JWK.new(proof_key)).generate}
|
|
366
|
+
check.call(send_request.call(userinfo, nil, client_cert, client_key,
|
|
367
|
+
{"Authorization" => "Bearer #{bound_token}"}), 401)
|
|
368
|
+
dpop_info = check.call(send_request.call(userinfo, nil, nil, nil,
|
|
369
|
+
{"Authorization" => "DPoP #{bound_token}", "DPoP" => proof.call(userinfo, "GET", bound_token)}), 200)
|
|
370
|
+
raise "combined flow subject mismatch" unless dpop_info.fetch("sub") == dpop_identity.fetch("sub")
|
|
371
|
+
dpop_refresh = {client_id: dpop_id, grant_type: "refresh_token", refresh_token: dpop_tokens.fetch("refresh_token")}
|
|
372
|
+
check.call(send_request.call(token_url, dpop_refresh, nil, nil,
|
|
373
|
+
{"DPoP" => proof.call(token_url, "POST")}), 401)
|
|
374
|
+
check.call(send_request.call(token_url, dpop_refresh), 400)
|
|
375
|
+
renewed = check.call(send_request.call(token_url, dpop_refresh, client_cert, client_key,
|
|
376
|
+
{"DPoP" => proof.call(token_url, "POST")}), 200)
|
|
377
|
+
raise "combined refresh lost DPoP" unless renewed.fetch("token_type") == "DPoP"
|
|
378
|
+
renewed_binding = check.call(send_request.call(metadata.fetch("introspection_endpoint"),
|
|
379
|
+
{client_id: dpop_id, token: renewed.fetch("access_token")}), 200).fetch("cnf")
|
|
380
|
+
raise "combined refresh changed binding" unless renewed_binding == binding
|
|
381
|
+
puts "PASS: installed real TLS alias #{method}/#{jwt ? 'JWT' : 'opaque'} authentication plus DPoP issuance, UserInfo and refresh"
|
|
382
|
+
puts "PASS: installed-gem real TLS DCR #{method}/#{jwt ? 'JWT' : 'opaque'} CIBA, binding, UserInfo, refresh and management via discovered aliases; canonical issuer and old binding retained, new policy applied"
|
|
383
|
+
ensure
|
|
384
|
+
server.shutdown
|
|
385
|
+
alias_server.shutdown
|
|
386
|
+
thread&.join(5)
|
|
387
|
+
alias_thread&.join(5)
|
|
388
|
+
raise "TLS server did not stop" if thread&.alive? || alias_thread&.alive?
|
|
389
|
+
database.disconnect
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
CibaMtlsSmoke.run
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Test-only TLS CA, trusted in a child process without disabling verification.
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
unless ENV["CIBA_PING_TEST_CERT"]
|
|
9
|
+
Dir.mktmpdir("ciba-ping-tls-") do |directory|
|
|
10
|
+
key = OpenSSL::PKey::RSA.generate(2048)
|
|
11
|
+
cert = OpenSSL::X509::Certificate.new
|
|
12
|
+
cert.version = 2
|
|
13
|
+
cert.serial = 1
|
|
14
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse("/CN=127.0.0.1")
|
|
15
|
+
cert.public_key = key.public_key
|
|
16
|
+
cert.not_before = Time.now - 60
|
|
17
|
+
cert.not_after = Time.now + 3600
|
|
18
|
+
factory = OpenSSL::X509::ExtensionFactory.new
|
|
19
|
+
factory.subject_certificate = factory.issuer_certificate = cert
|
|
20
|
+
cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
|
|
21
|
+
cert.add_extension(factory.create_extension("subjectAltName", "IP:127.0.0.1"))
|
|
22
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
23
|
+
cert_path = File.join(directory, "cert.pem")
|
|
24
|
+
key_path = File.join(directory, "key.pem")
|
|
25
|
+
File.write(cert_path, cert.to_pem)
|
|
26
|
+
File.write(key_path, key.to_pem, perm: 0o600)
|
|
27
|
+
env = {"CIBA_PING_TEST_CERT" => cert_path, "CIBA_PING_TEST_KEY" => key_path, "SSL_CERT_FILE" => cert_path}
|
|
28
|
+
[env, env.merge("SSL_CERT_FILE" => nil, "CIBA_PING_UNTRUSTED" => "1")].each do |child_env|
|
|
29
|
+
output, errors, status = Open3.capture3(child_env, RbConfig.ruby, __FILE__)
|
|
30
|
+
print output
|
|
31
|
+
warn errors unless errors.empty?
|
|
32
|
+
raise "ping TLS smoke failed" unless status.success?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
require "webrick"
|
|
37
|
+
require "webrick/https"
|
|
38
|
+
require "rack/mock"
|
|
39
|
+
require "json"
|
|
40
|
+
require_relative "demo_app"
|
|
41
|
+
received = []
|
|
42
|
+
release_response = Queue.new
|
|
43
|
+
untrusted = ENV["CIBA_PING_UNTRUSTED"]
|
|
44
|
+
receiver_status = 204
|
|
45
|
+
sector_status = 200
|
|
46
|
+
sector_body = "[]"
|
|
47
|
+
sector_location = "/sector-target"
|
|
48
|
+
sector_redirect_body = "[]"
|
|
49
|
+
sector_requests = []
|
|
50
|
+
pairwise_client_key = OpenSSL::PKey::RSA.generate(2048)
|
|
51
|
+
server = WEBrick::HTTPServer.new(BindAddress: "127.0.0.1", Port: 0, SSLEnable: true,
|
|
52
|
+
SSLCertificate: OpenSSL::X509::Certificate.new(File.read(ENV.fetch("CIBA_PING_TEST_CERT"))),
|
|
53
|
+
SSLPrivateKey: OpenSSL::PKey.read(File.read(ENV.fetch("CIBA_PING_TEST_KEY"))),
|
|
54
|
+
Logger: WEBrick::Log.new(File::NULL), AccessLog: [])
|
|
55
|
+
server.mount_proc("/notify") do |request, response|
|
|
56
|
+
received << [request["authorization"], request["content-type"], JSON.parse(request.body)]
|
|
57
|
+
release_response.pop if receiver_status == :timeout
|
|
58
|
+
response.status = receiver_status == :timeout ? 204 : receiver_status
|
|
59
|
+
response.body = "ignored" * 20_000 if receiver_status == 200
|
|
60
|
+
response["Location"] = "/redirect-target" if receiver_status == 302
|
|
61
|
+
end
|
|
62
|
+
server.mount_proc("/sector") do |request, response|
|
|
63
|
+
sector_requests << request["authorization"]
|
|
64
|
+
response.status = sector_status
|
|
65
|
+
response["Content-Type"] = "application/json"
|
|
66
|
+
response["Location"] = sector_location if sector_status == 302
|
|
67
|
+
response.body = sector_body
|
|
68
|
+
end
|
|
69
|
+
server.mount_proc("/sector-target") do |request, response|
|
|
70
|
+
sector_requests << request["authorization"]
|
|
71
|
+
response["Content-Type"] = "application/json"
|
|
72
|
+
response.body = sector_redirect_body
|
|
73
|
+
end
|
|
74
|
+
server.mount_proc("/jwks") do |_request, response|
|
|
75
|
+
response["Content-Type"] = "application/json"
|
|
76
|
+
response.body = JSON.generate(keys: [JWT::JWK.new(pairwise_client_key.public_key).export])
|
|
77
|
+
end
|
|
78
|
+
thread = Thread.new { server.start }
|
|
79
|
+
db = Sequel.sqlite
|
|
80
|
+
begin
|
|
81
|
+
CibaDemo.create_schema(db)
|
|
82
|
+
Rodauth::CibaSupport::Schema.add_ping(db)
|
|
83
|
+
db[:oauth_applications].update(backchannel_token_delivery_mode: "ping",
|
|
84
|
+
backchannel_client_notification_endpoint: "https://127.0.0.1:#{server.config[:Port]}/notify")
|
|
85
|
+
app = CibaDemo.build(db)
|
|
86
|
+
app.plugin(:rodauth) do
|
|
87
|
+
ciba_ping_enabled true
|
|
88
|
+
# Exact local test receiver only; production default rejects loopback.
|
|
89
|
+
ciba_http_address_allowed? { |address| address == "127.0.0.1" }
|
|
90
|
+
end
|
|
91
|
+
issuer = "http://127.0.0.1:9292"
|
|
92
|
+
http = Rack::MockRequest.new(app)
|
|
93
|
+
headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
|
|
94
|
+
auth = app.new(Rack::MockRequest.env_for(issuer)).rodauth
|
|
95
|
+
account_id = db[:accounts].get(:id)
|
|
96
|
+
(untrusted ? [204] : [204, 200, 503, 302, :timeout]).each do |code|
|
|
97
|
+
response = http.post("#{issuer}/backchannel-authentication", headers.merge(params: {
|
|
98
|
+
scope: "openid", login_hint: "customer@example.test", client_notification_token: "test-notification-token"
|
|
99
|
+
}))
|
|
100
|
+
raise response.body unless response.status == 200
|
|
101
|
+
id = JSON.parse(response.body).fetch("auth_req_id")
|
|
102
|
+
row_id = db[:ciba_requests].max(:id)
|
|
103
|
+
receiver_status = code
|
|
104
|
+
begin
|
|
105
|
+
Timeout.timeout(8) { auth.approve_ciba_request(row_id, account_id: account_id) }
|
|
106
|
+
raise "failure response accepted" if untrusted || ![200, 204].include?(code)
|
|
107
|
+
rescue Rodauth::CibaSupport::PingDeliveryError => error
|
|
108
|
+
raise if !untrusted && [200, 204].include?(code)
|
|
109
|
+
raise "approval rolled back" unless auth.ciba_request(row_id)[:status] == "approved"
|
|
110
|
+
if untrusted
|
|
111
|
+
raise "wrong TLS rejection" unless error.cause.is_a?(OpenSSL::SSL::SSLError) && received.empty?
|
|
112
|
+
else
|
|
113
|
+
if code == :timeout
|
|
114
|
+
raise "wrong timeout failure" unless error.cause.is_a?(Timeout::Error)
|
|
115
|
+
release_response << true
|
|
116
|
+
end
|
|
117
|
+
receiver_status = 204
|
|
118
|
+
raise "retry failed" unless auth.retry_ciba_ping(row_id) == :delivered
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
unless untrusted
|
|
122
|
+
raise "wrong notification" unless received.last == ["Bearer test-notification-token", "application/json", {"auth_req_id" => id}]
|
|
123
|
+
end
|
|
124
|
+
token = http.post("#{issuer}/token", headers.merge(params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id}))
|
|
125
|
+
raise token.body unless token.status == 200
|
|
126
|
+
end
|
|
127
|
+
raise "unexpected automatic retry" unless received.size == (untrusted ? 0 : 8)
|
|
128
|
+
puts(untrusted ? "PASS: installed-gem ping rejects untrusted TLS before sending credentials, retains approval and allows token collection" :
|
|
129
|
+
"PASS: installed-gem ping over verified TLS, Bearer/JSON, 200/204, 503/302/timeout retain approval, explicit retry, token collection")
|
|
130
|
+
|
|
131
|
+
# Reuse this process's trusted/untrusted TLS fixture for actual sector DCR.
|
|
132
|
+
db.alter_table(:oauth_applications) do
|
|
133
|
+
%i[name redirect_uri response_types registration_access_token jwks_uri sector_identifier_uri].each do |column|
|
|
134
|
+
add_column column, String
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
Rodauth::CibaSupport::Schema.add_registration_token_digest(db)
|
|
138
|
+
Rodauth::CibaSupport::Schema.add_pairwise_subject(db)
|
|
139
|
+
Rodauth::CibaSupport::Schema.create_client_assertions(db)
|
|
140
|
+
pairwise_app = CibaDemo.build(db)
|
|
141
|
+
pairwise_app.plugin(:rodauth) do
|
|
142
|
+
enable :oauth_jwt_bearer_grant, :oauth_dynamic_client_registration
|
|
143
|
+
ciba_dynamic_client_registration_enabled true
|
|
144
|
+
ciba_pairwise_identifier do |account, oauth_application:, sector_identifier:|
|
|
145
|
+
OpenSSL::HMAC.hexdigest("SHA256", "local-sector-fixture", JSON.generate([sector_identifier, account]))
|
|
146
|
+
end
|
|
147
|
+
ciba_pairwise_enabled true
|
|
148
|
+
ciba_http_address_allowed? { |address| address == "127.0.0.1" }
|
|
149
|
+
before_register { authorization_required unless request.env["HTTP_AUTHORIZATION"] == "Bearer local-sector-registration" }
|
|
150
|
+
end
|
|
151
|
+
pairwise_app.route do |r|
|
|
152
|
+
rodauth.load_openid_configuration_route
|
|
153
|
+
r.rodauth
|
|
154
|
+
end
|
|
155
|
+
pairwise_http = Rack::MockRequest.new(pairwise_app)
|
|
156
|
+
base = "https://127.0.0.1:#{server.config[:Port]}"
|
|
157
|
+
redirect = "https://rp.example.test/callback"
|
|
158
|
+
sector_redirect_body = JSON.generate(["#{base}/jwks", redirect])
|
|
159
|
+
metadata = {grant_types: [Rodauth::CibaSupport::GRANT_TYPE, "authorization_code"], response_types: ["code"],
|
|
160
|
+
redirect_uris: [redirect], scope: "openid", subject_type: "pairwise", token_endpoint_auth_method: "private_key_jwt",
|
|
161
|
+
backchannel_token_delivery_mode: "poll", jwks_uri: "#{base}/jwks", sector_identifier_uri: "#{base}/sector"}
|
|
162
|
+
cases = untrusted ? [[200, JSON.generate(["#{base}/jwks", redirect]), 400]] : [
|
|
163
|
+
[200, "not json", 400], [200, "{}", 400], [200, JSON.generate([redirect]), 400],
|
|
164
|
+
[200, JSON.generate(["#{base}/jwks"]), 400], [302, "[]", 201], [503, "[]", 400],
|
|
165
|
+
[302, "[]", 400, "/sector"], [302, "[]", 400, "file:///private/invalid"],
|
|
166
|
+
[302, "[]", 400, "https://127.0.0.2/blocked"],
|
|
167
|
+
[200, " " * 65_537, 400], [200, JSON.generate(["#{base}/jwks", redirect]), 201]]
|
|
168
|
+
cases.each do |status, body, expected, location|
|
|
169
|
+
sector_status, sector_body = status, body
|
|
170
|
+
sector_location = location || "/sector-target"
|
|
171
|
+
before = db[:oauth_applications].count
|
|
172
|
+
response = pairwise_http.post("#{issuer}/register", "CONTENT_TYPE" => "application/json",
|
|
173
|
+
"HTTP_AUTHORIZATION" => "Bearer local-sector-registration", input: JSON.generate(metadata))
|
|
174
|
+
raise "sector registration #{response.status}: #{response.body}" unless response.status == expected
|
|
175
|
+
if expected == 400
|
|
176
|
+
raise "wrong sector error" unless JSON.parse(response.body)["error"] == "invalid_client_metadata"
|
|
177
|
+
raise "failed registration persisted" unless db[:oauth_applications].count == before
|
|
178
|
+
else
|
|
179
|
+
client = JSON.parse(response.body)
|
|
180
|
+
raise "pairwise metadata lost" unless client["subject_type"] == "pairwise"
|
|
181
|
+
authenticate = lambda do
|
|
182
|
+
{client_id: client.fetch("client_id"),
|
|
183
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
184
|
+
client_assertion: JWT.encode({iss: client.fetch("client_id"), sub: client.fetch("client_id"),
|
|
185
|
+
aud: issuer, exp: Time.now.to_i + 60, jti: SecureRandom.uuid}, pairwise_client_key, "RS256",
|
|
186
|
+
kid: JWT::JWK.new(pairwise_client_key).kid)}
|
|
187
|
+
end
|
|
188
|
+
discovery = JSON.parse(pairwise_http.get("#{issuer}/.well-known/openid-configuration").body)
|
|
189
|
+
begun = pairwise_http.post(discovery.fetch("backchannel_authentication_endpoint"), params: authenticate.call.merge(
|
|
190
|
+
scope: "openid", login_hint: "customer@example.test"))
|
|
191
|
+
raise begun.body unless begun.status == 200
|
|
192
|
+
pending = JSON.parse(begun.body).fetch("auth_req_id")
|
|
193
|
+
instance = pairwise_app.new(Rack::MockRequest.env_for(issuer)).rodauth
|
|
194
|
+
instance.approve_ciba_request(db[:ciba_requests].max(:id), account_id: account_id)
|
|
195
|
+
issued = pairwise_http.post(discovery.fetch("token_endpoint"), params: authenticate.call.merge(
|
|
196
|
+
grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: pending))
|
|
197
|
+
raise issued.body unless issued.status == 200
|
|
198
|
+
tokens = JSON.parse(issued.body)
|
|
199
|
+
keys = JSON.parse(pairwise_http.get(discovery.fetch("jwks_uri")).body)
|
|
200
|
+
claims, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
|
|
201
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: client.fetch("client_id"))
|
|
202
|
+
expected_sub = OpenSSL::HMAC.hexdigest("SHA256", "local-sector-fixture", JSON.generate(["127.0.0.1:#{server.config[:Port]}", account_id]))
|
|
203
|
+
raise "wrong pairwise subject" unless claims["sub"] == expected_sub
|
|
204
|
+
raise "issuance subject not saved" unless db[:oauth_grants].where(oauth_application_id: db[:oauth_applications].where(client_id: client.fetch("client_id")).get(:id)).get(:ciba_subject) == expected_sub
|
|
205
|
+
info = pairwise_http.get(discovery.fetch("userinfo_endpoint"), "HTTP_AUTHORIZATION" => "Bearer #{tokens.fetch('access_token')}")
|
|
206
|
+
raise "wrong pairwise UserInfo" unless info.status == 200 && JSON.parse(info.body)["sub"] == expected_sub
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
raise "credentials sent to sector endpoint" unless sector_requests.compact.empty?
|
|
210
|
+
raise "untrusted TLS reached sector handler" if untrusted && !sector_requests.empty?
|
|
211
|
+
raise "sector handler not reached" if !untrusted && sector_requests.empty?
|
|
212
|
+
puts(untrusted ? "PASS: installed-gem sector registration rejects untrusted TLS without persistence" :
|
|
213
|
+
"PASS: installed-gem hybrid pairwise sector DCR over verified TLS, safe redirect success, loop/invalid/blocked-target refusal, URI membership and malformed/oversized/HTTP-error refusal without persistence or credential forwarding")
|
|
214
|
+
puts "PASS: installed-gem pairwise private_key_jwt over real TLS JWKS, approval, verified ID Token, saved subject and matching UserInfo" unless untrusted
|
|
215
|
+
require_relative "edwards_tls_smoke"
|
|
216
|
+
CibaEdwardsTlsSmoke.run(server, untrusted: untrusted)
|
|
217
|
+
require_relative "encryption_tls_smoke"
|
|
218
|
+
CibaEncryptionTlsSmoke.run(server, untrusted: untrusted)
|
|
219
|
+
ensure
|
|
220
|
+
release_response << true
|
|
221
|
+
server.shutdown
|
|
222
|
+
thread.join(5)
|
|
223
|
+
thread.kill if thread.alive?
|
|
224
|
+
db.disconnect
|
|
225
|
+
end
|
|
226
|
+
end
|