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.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +21 -0
  4. data/README.md +106 -0
  5. data/docs/adr/0003-follow-rodauth-hook-conventions.md +9 -0
  6. data/docs/adr/0004-retain-atomic-issuance-and-stage-alignment.md +16 -0
  7. data/docs/alignment-closeout.md +136 -0
  8. data/docs/alignment-roadmap.md +160 -0
  9. data/docs/api.md +337 -0
  10. data/docs/authentication-claims.md +80 -0
  11. data/docs/authorization-details.md +50 -0
  12. data/docs/claims.md +46 -0
  13. data/docs/failure-contract.md +34 -0
  14. data/docs/id-token-encryption.md +288 -0
  15. data/docs/id-token-hint.md +174 -0
  16. data/docs/login-hint-token.md +35 -0
  17. data/docs/max-age.md +65 -0
  18. data/docs/node-alignment.md +336 -0
  19. data/docs/operations.md +130 -0
  20. data/docs/outbound-http.md +52 -0
  21. data/docs/pairwise.md +76 -0
  22. data/docs/ping.md +118 -0
  23. data/docs/protocol-coverage.md +114 -0
  24. data/docs/refresh-tokens.md +151 -0
  25. data/docs/release-readiness.md +75 -0
  26. data/docs/release-validation.md +498 -0
  27. data/docs/request-context.md +51 -0
  28. data/docs/requirements-audit-current.json +3221 -0
  29. data/docs/requirements-audit-current.md +424 -0
  30. data/docs/requirements-audit.json +3190 -0
  31. data/docs/requirements-audit.md +175 -0
  32. data/docs/research/access-token-revocation-reference.md +81 -0
  33. data/docs/research/alignment-partial-triage.md +50 -0
  34. data/docs/research/claims-reference-contract.md +33 -0
  35. data/docs/research/client-assertion-algorithm-contract.md +177 -0
  36. data/docs/research/completion-error-boundaries.md +56 -0
  37. data/docs/research/consent-api-evolution.md +59 -0
  38. data/docs/research/dpop-reference-contract.md +423 -0
  39. data/docs/research/id-token-hint-reference-contract.md +254 -0
  40. data/docs/research/mtls-reference-contract.md +449 -0
  41. data/docs/research/node-ciba-lifecycle.md +30 -0
  42. data/docs/research/node-oidc-provider-comparison.md +45 -0
  43. data/docs/research/pairwise-reference-contract.md +264 -0
  44. data/docs/research/ping-reference-contract.md +165 -0
  45. data/docs/research/rar-reference-contract.md +55 -0
  46. data/docs/research/refresh-reference-contract.md +85 -0
  47. data/docs/research/registration-reference-contract.md +567 -0
  48. data/docs/research/request-parameter-inventory.md +42 -0
  49. data/docs/research/required-parameter-inventory.md +39 -0
  50. data/docs/research/resource-reference-contract.md +40 -0
  51. data/docs/research/signed-request-reference-contract.md +113 -0
  52. data/docs/resources.md +55 -0
  53. data/docs/security-review.md +294 -0
  54. data/docs/signed-requests.md +162 -0
  55. data/docs/user-code.md +73 -0
  56. data/docs/validation/node-alignment-tests.txt +10 -0
  57. data/examples/client.rb +39 -0
  58. data/examples/demo_app.rb +121 -0
  59. data/examples/dpop_smoke.rb +169 -0
  60. data/examples/ecdh_id_token_smoke.rb +84 -0
  61. data/examples/edwards_id_token_smoke.rb +48 -0
  62. data/examples/edwards_smoke.rb +87 -0
  63. data/examples/edwards_tls_smoke.rb +73 -0
  64. data/examples/encrypted_id_token_smoke.rb +73 -0
  65. data/examples/encryption_tls_smoke.rb +123 -0
  66. data/examples/extensions_smoke.rb +226 -0
  67. data/examples/fixtures/edwards-requests.json +30 -0
  68. data/examples/hmac_id_token_smoke.rb +72 -0
  69. data/examples/migration.rb +18 -0
  70. data/examples/mtls_smoke.rb +395 -0
  71. data/examples/ping_smoke.rb +226 -0
  72. data/examples/registration_smoke.rb +73 -0
  73. data/examples/server.rb +14 -0
  74. data/examples/smoke.rb +46 -0
  75. data/examples/symmetric_id_token_smoke.rb +83 -0
  76. data/lib/rodauth/ciba/authorization_details.rb +60 -0
  77. data/lib/rodauth/ciba/dpop.rb +250 -0
  78. data/lib/rodauth/ciba/http.rb +69 -0
  79. data/lib/rodauth/ciba/id_token_encryption.rb +175 -0
  80. data/lib/rodauth/ciba/jwks_cache.rb +149 -0
  81. data/lib/rodauth/ciba/mtls.rb +174 -0
  82. data/lib/rodauth/ciba/pairwise.rb +105 -0
  83. data/lib/rodauth/ciba/registration.rb +489 -0
  84. data/lib/rodauth/ciba/schema.rb +182 -0
  85. data/lib/rodauth/ciba/version.rb +6 -0
  86. data/lib/rodauth/ciba.rb +4 -0
  87. data/lib/rodauth/features/ciba.rb +2185 -0
  88. 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