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,84 @@
1
+ # frozen_string_literal: true
2
+ require "rack/mock"
3
+ require "json"
4
+ require_relative "demo_app"
5
+
6
+ [false, true].each do |jwt|
7
+ database = Sequel.sqlite
8
+ begin
9
+ CibaDemo.create_schema(database)
10
+ Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
11
+ database.alter_table(:oauth_applications) do
12
+ add_column :id_token_signed_response_alg, String
13
+ add_column :id_token_encrypted_response_alg, String
14
+ add_column :id_token_encrypted_response_enc, String
15
+ add_column :jwks, String, text: true
16
+ end
17
+ database[:oauth_applications].update(scopes: "openid offline_access",
18
+ grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token",
19
+ id_token_encrypted_response_enc: "A256CBC-HS512")
20
+ application = CibaDemo.build(database)
21
+ application.plugin(:rodauth) do
22
+ base_url "https://op.example.test"
23
+ authorization_server_url "https://op.example.test"
24
+ ciba_require_tls true
25
+ oauth_jwt_access_tokens jwt
26
+ ciba_refresh_tokens_enabled true
27
+ oauth_application_scopes %w[openid offline_access]
28
+ ciba_id_token_encryption_enabled true
29
+ end
30
+ http = Rack::MockRequest.new(application)
31
+ issuer = "https://op.example.test"
32
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
33
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
34
+ post = lambda do |endpoint, params|
35
+ response = http.post(endpoint, headers.merge(params: params))
36
+ raise response.body unless response.status == 200
37
+ JSON.parse(response.body)
38
+ end
39
+ {"P-256" => "prime256v1", "P-384" => "secp384r1", "P-521" => "secp521r1", "X25519" => nil}.each do |curve, group|
40
+ recipient = group ? OpenSSL::PKey::EC.generate(group) : OpenSSL::PKey.generate_key("X25519")
41
+ public_recipient = group ? JWT::JWK.new(recipient).export :
42
+ {kty: "OKP", crv: curve, x: Base64.urlsafe_encode64(OpenSSL::ASN1.decode(recipient.public_to_der).value.last.value, padding: false)}
43
+ %w[ECDH-ES ECDH-ES+A128KW ECDH-ES+A192KW ECDH-ES+A256KW].each do |algorithm|
44
+ database[:oauth_applications].update(id_token_encrypted_response_alg: algorithm,
45
+ jwks: JSON.generate(keys: [public_recipient.merge(alg: algorithm, use: "enc", kid: "recipient")]))
46
+ started = post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid offline_access", login_hint: "customer@example.test")
47
+ row = database[:ciba_requests].order(:id).last
48
+ application.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
49
+ issued = post.call(metadata.fetch("token_endpoint"), grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id"))
50
+ renewed = post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: issued.fetch("refresh_token"))
51
+ [issued, renewed].each do |tokens|
52
+ token = tokens.fetch("id_token")
53
+ raise "unencrypted response" unless token.split(".").length == 5
54
+ parts = token.split(".", -1)
55
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
56
+ epk = header.fetch("epk")
57
+ raise "wrong curve or private ephemeral key" unless epk.fetch("crv") == curve && !epk.key?("d")
58
+ public_key = if group
59
+ JWT::JWK.import(epk).keypair
60
+ else
61
+ identifier = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("1.3.101.110")])
62
+ OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([identifier,
63
+ OpenSSL::ASN1::BitString.new(Base64.urlsafe_decode64(epk.fetch("x")))]).to_der)
64
+ end
65
+ bits = algorithm == "ECDH-ES" ? 512 : algorithm[/A(\d+)KW$/, 1].to_i
66
+ name = algorithm == "ECDH-ES" ? "A256CBC-HS512" : algorithm
67
+ info = [name.bytesize].pack("N") + name + [0, 0, bits].pack("N3")
68
+ shared = recipient.derive(public_key)
69
+ derived = (1..((bits + 255) / 256)).map { |counter| OpenSSL::Digest.digest("SHA256", [counter].pack("N") + shared + info) }.join.byteslice(0, bits / 8)
70
+ cek = algorithm == "ECDH-ES" ? derived : JWE::Alg.decrypt_cek(algorithm.split("+").last, derived, Base64.urlsafe_decode64(parts[1]))
71
+ signed = JWE::Enc.for("A256CBC-HS512", cek, Base64.urlsafe_decode64(parts[2]), Base64.urlsafe_decode64(parts[4]))
72
+ .decrypt(Base64.urlsafe_decode64(parts[3]), parts[0])
73
+ jwks = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
74
+ claims, = JWT.decode(signed, nil, true, jwks: jwks, algorithms: ["RS256"],
75
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
76
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
77
+ end
78
+ puts "PASS: installed #{curve}/#{algorithm} encrypted ID Token issuance/refresh, #{jwt ? 'JWT' : 'opaque'} access token, recipient decryption and signature verified"
79
+ end
80
+ end
81
+ ensure
82
+ database.disconnect
83
+ end
84
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # Synthetic credentials and ephemeral keys only. Optional export contains no private keys.
3
+ require "rack/mock"
4
+ require "json"
5
+ require_relative "demo_app"
6
+
7
+ vectors = []
8
+ [false, true].each do |jwt|
9
+ database = Sequel.sqlite
10
+ begin
11
+ CibaDemo.create_schema(database)
12
+ database.alter_table(:oauth_applications) { add_column :id_token_signed_response_alg, String }
13
+ signing_keys = %w[Ed25519 EdDSA].to_h { |algorithm| [algorithm, OpenSSL::PKey.generate_key("ED25519")] }
14
+ application = CibaDemo.build(database)
15
+ application.plugin(:rodauth) do
16
+ base_url "https://op.example.test"
17
+ authorization_server_url "https://op.example.test"
18
+ ciba_require_tls true
19
+ oauth_jwt_access_tokens jwt
20
+ ciba_id_token_signing_keys signing_keys
21
+ end
22
+ http = Rack::MockRequest.new(application)
23
+ issuer = "https://op.example.test"
24
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
25
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
26
+ signing_keys.each do |algorithm, key|
27
+ database[:oauth_applications].update(id_token_signed_response_alg: algorithm)
28
+ started = http.post(metadata.fetch("backchannel_authentication_endpoint"), headers.merge(params: {scope: "openid", login_hint: "customer@example.test"}))
29
+ raise started.body unless started.status == 200
30
+ row = database[:ciba_requests].order(:id).last
31
+ application.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
32
+ issued = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {
33
+ grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: JSON.parse(started.body).fetch("auth_req_id")}))
34
+ raise issued.body unless issued.status == 200
35
+ tokens = JSON.parse(issued.body)
36
+ parts = tokens.fetch("id_token").split(".")
37
+ raise "invalid signature" unless key.verify(nil, Base64.urlsafe_decode64(parts.last), parts.first(2).join("."))
38
+ jwks = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
39
+ raise "private key exported" if jwks.fetch("keys").any? { |jwk| jwk.key?("d") || jwk.key?("k") }
40
+ vectors << {alg: algorithm, jwt_access_token: jwt, checked_at: Time.now.to_i,
41
+ id_token: tokens.fetch("id_token"), access_token: tokens.fetch("access_token"), jwks: jwks}
42
+ puts "PASS: installed #{algorithm} ID Token, #{jwt ? 'JWT' : 'opaque'} access token, public JWKS"
43
+ end
44
+ ensure
45
+ database.disconnect
46
+ end
47
+ end
48
+ File.write(ENV.fetch("CIBA_EDWARDS_ID_TOKEN_EXPORT"), JSON.pretty_generate(vectors) + "\n") if ENV["CIBA_EDWARDS_ID_TOKEN_EXPORT"]
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ # Uses public Node/jose-generated vectors from the installed artifact.
3
+ require "rack/mock"
4
+ require "json"
5
+ require_relative "demo_app"
6
+ fixture = JSON.parse(File.read(File.join(__dir__, "fixtures/edwards-requests.json")))
7
+ [false, true].each do |jwt|
8
+ database = Sequel.sqlite
9
+ begin
10
+ CibaDemo.create_schema(database)
11
+ Rodauth::CibaSupport::Schema.add_signed_requests(database)
12
+ database.alter_table(:oauth_applications) { add_column :jwks, String, text: true }
13
+ database[:oauth_applications].update(client_id: "support")
14
+ application = CibaDemo.build(database)
15
+ application.plugin(:rodauth) do
16
+ base_url "https://op.example.test"
17
+ authorization_server_url "https://op.example.test"
18
+ ciba_require_tls true
19
+ ciba_now { fixture.fetch("now") }
20
+ oauth_jwt_access_tokens jwt
21
+ ciba_request_signing_algorithms %w[Ed25519 EdDSA]
22
+ end
23
+ http = Rack::MockRequest.new(application)
24
+ issuer = "https://op.example.test"
25
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
26
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('support:demo-secret')}"}
27
+ fixture.fetch("vectors").each do |vector|
28
+ database[:oauth_applications].update(backchannel_authentication_request_signing_alg: vector.fetch("alg"),
29
+ jwks: JSON.generate(keys: [vector.fetch("jwk")]))
30
+ started = http.post(metadata.fetch("backchannel_authentication_endpoint"), headers.merge(params: {request: vector.fetch("token")}))
31
+ raise started.body unless started.status == 200
32
+ id = JSON.parse(started.body).fetch("auth_req_id")
33
+ instance = application.new(Rack::MockRequest.env_for(issuer)).rodauth
34
+ row = database[:ciba_requests].order(:id).last
35
+ instance.approve_ciba_request(row[:id], account_id: row[:account_id])
36
+ issued = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id}))
37
+ raise issued.body unless issued.status == 200
38
+ tokens = JSON.parse(issued.body)
39
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
40
+ claims, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
41
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "support")
42
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
43
+ raise "missing access token" unless tokens.fetch("access_token").is_a?(String)
44
+ puts "PASS: installed #{vector.fetch('alg')} Node/jose vector, #{jwt ? 'JWT' : 'opaque'} access token, verified ID Token"
45
+ end
46
+ Rodauth::CibaSupport::Schema.create_client_assertions(database)
47
+ database.alter_table(:oauth_applications) { add_column :token_endpoint_auth_signing_alg, String }
48
+ application.plugin(:rodauth) do
49
+ enable :oauth_jwt_bearer_grant
50
+ ciba_client_assertion_signing_algorithms %w[Ed25519 EdDSA]
51
+ end
52
+ key = OpenSSL::PKey.generate_key("ED25519")
53
+ raw = OpenSSL::ASN1.decode(key.public_to_der).value.last.value
54
+ jwk = {kty: "OKP", crv: "Ed25519", x: Base64.urlsafe_encode64(raw, padding: false), kid: "auth", use: "sig"}
55
+ %w[Ed25519 EdDSA].each do |algorithm|
56
+ database[:oauth_applications].update(backchannel_authentication_request_signing_alg: nil,
57
+ token_endpoint_auth_method: "private_key_jwt", token_endpoint_auth_signing_alg: algorithm,
58
+ jwks: JSON.generate(keys: [jwk.merge(alg: algorithm)]))
59
+ authentication = lambda do
60
+ parts = [{alg: algorithm, kid: "auth"}, {iss: "support", sub: "support", aud: issuer,
61
+ exp: Time.now.to_i + 60, jti: SecureRandom.uuid}].map { |part| Base64.urlsafe_encode64(JSON.generate(part), padding: false) }
62
+ token = (parts + [Base64.urlsafe_encode64(key.sign(nil, parts.join(".")), padding: false)]).join(".")
63
+ {client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion: token}
64
+ end
65
+ start_auth = authentication.call
66
+ started = http.post(metadata.fetch("backchannel_authentication_endpoint"), params: start_auth.merge(scope: "openid", login_hint: "customer@example.test"))
67
+ raise started.body unless started.status == 200
68
+ replay = http.post(metadata.fetch("backchannel_authentication_endpoint"), params: start_auth.merge(scope: "openid", login_hint: "customer@example.test"))
69
+ raise "assertion replay accepted" unless replay.status == 401
70
+ id = JSON.parse(started.body).fetch("auth_req_id")
71
+ instance = application.new(Rack::MockRequest.env_for(issuer)).rodauth
72
+ row = database[:ciba_requests].order(:id).last
73
+ instance.approve_ciba_request(row[:id], account_id: row[:account_id])
74
+ issued = http.post(metadata.fetch("token_endpoint"), params: authentication.call.merge(grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id))
75
+ raise issued.body unless issued.status == 200
76
+ tokens = JSON.parse(issued.body)
77
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
78
+ claims, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
79
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "support")
80
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
81
+ raise "missing access token" unless tokens.fetch("access_token").is_a?(String)
82
+ puts "PASS: installed #{algorithm} client authentication at both endpoints, replay rejected, #{jwt ? 'JWT' : 'opaque'} access token, verified ID Token"
83
+ end
84
+ ensure
85
+ database.disconnect
86
+ end
87
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ # Called by the installed ping fixture with its real, verified HTTPS server.
3
+ module CibaEdwardsTlsSmoke
4
+ def self.run(server, untrusted:)
5
+ public_keys = []
6
+ requests = []
7
+ server.mount_proc("/edwards-jwks") do |request, response|
8
+ requests << [request.request_method, request["authorization"]]
9
+ response["Content-Type"] = "application/json"
10
+ response["Cache-Control"] = "no-cache"
11
+ response.body = JSON.generate(keys: public_keys)
12
+ end
13
+ endpoint = "https://127.0.0.1:#{server.config[:Port]}/edwards-jwks"
14
+ [false, true].each do |jwt|
15
+ %w[Ed25519 EdDSA].each do |algorithm|
16
+ database = Sequel.sqlite
17
+ begin
18
+ CibaDemo.create_schema(database)
19
+ Rodauth::CibaSupport::Schema.create_client_assertions(database)
20
+ database.alter_table(:oauth_applications) do
21
+ add_column :jwks_uri, String
22
+ add_column :token_endpoint_auth_signing_alg, String
23
+ end
24
+ database[:oauth_applications].update(token_endpoint_auth_method: "private_key_jwt",
25
+ token_endpoint_auth_signing_alg: algorithm, jwks_uri: endpoint)
26
+ key = OpenSSL::PKey.generate_key("ED25519")
27
+ raw = OpenSSL::ASN1.decode(key.public_to_der).value.last.value
28
+ public_keys = [{kty: "OKP", crv: "Ed25519", x: Base64.urlsafe_encode64(raw, padding: false), alg: algorithm, kid: "client", use: "sig"}]
29
+ app = CibaDemo.build(database)
30
+ app.plugin(:rodauth) do
31
+ enable :oauth_jwt_bearer_grant
32
+ ciba_client_assertion_signing_algorithms %w[Ed25519 EdDSA]
33
+ oauth_jwt_access_tokens jwt
34
+ end
35
+ app.new(Rack::MockRequest.env_for("http://127.0.0.1:9292")).rodauth.send(:http_request_cache).uncache(URI(endpoint))
36
+ http = Rack::MockRequest.new(app)
37
+ issuer = "http://127.0.0.1:9292"
38
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
39
+ authentication = lambda do
40
+ parts = [{alg: algorithm, kid: "client"}, {iss: "demo-client", sub: "demo-client", aud: issuer,
41
+ exp: Time.now.to_i + 60, jti: SecureRandom.uuid}].map { |part| Base64.urlsafe_encode64(JSON.generate(part), padding: false) }
42
+ token = (parts + [Base64.urlsafe_encode64(key.sign(nil, parts.join(".")), padding: false)]).join(".")
43
+ {client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion: token}
44
+ end
45
+ start = -> { http.post(metadata.fetch("backchannel_authentication_endpoint"), params: authentication.call.merge(scope: "openid", login_hint: "customer@example.test")) }
46
+ before = requests.length
47
+ raise "default loopback guard bypassed" unless start.call.status == 401 && requests.length == before
48
+ app.plugin(:rodauth) { ciba_http_address_allowed? { |address| address == "127.0.0.1" } }
49
+ accepted = start.call
50
+ if untrusted
51
+ raise "untrusted JWKS accepted or reached handler" unless accepted.status == 401 && requests.length == before && database[:ciba_requests].count.zero?
52
+ puts "PASS: installed #{algorithm} HTTPS client JWKS rejects untrusted TLS before key retrieval, #{jwt ? 'JWT' : 'opaque'} mode"
53
+ next
54
+ end
55
+ raise accepted.body unless accepted.status == 200
56
+ id = JSON.parse(accepted.body).fetch("auth_req_id")
57
+ row = database[:ciba_requests].first
58
+ app.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
59
+ issued = http.post(metadata.fetch("token_endpoint"), params: authentication.call.merge(grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id))
60
+ raise issued.body unless issued.status == 200
61
+ token = JSON.parse(issued.body).fetch("id_token")
62
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
63
+ claims, = JWT.decode(token, nil, true, jwks: keys, algorithms: ["RS256"], verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
64
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
65
+ raise "JWKS credential forwarding or missing fetch" unless requests.length >= before + 2 && requests.drop(before).all? { |entry| entry == ["GET", nil] }
66
+ puts "PASS: installed #{algorithm} HTTPS client JWKS, both endpoint authentication, verified ID Token, #{jwt ? 'JWT' : 'opaque'} access token"
67
+ ensure
68
+ database.disconnect
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ require "rack/mock"
3
+ require "json"
4
+ require_relative "demo_app"
5
+
6
+ [false, true].each do |jwt|
7
+ database = Sequel.sqlite
8
+ begin
9
+ CibaDemo.create_schema(database)
10
+ Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
11
+ database.alter_table(:oauth_applications) do
12
+ add_column :id_token_signed_response_alg, String
13
+ add_column :id_token_encrypted_response_alg, String
14
+ add_column :id_token_encrypted_response_enc, String
15
+ add_column :jwks, String, text: true
16
+ end
17
+ recipient = OpenSSL::PKey::RSA.generate(2048)
18
+ edwards = OpenSSL::PKey.generate_key("ED25519")
19
+ public_recipient = JWT::JWK.new(recipient.public_key).export.merge(use: "enc", alg: "RSA-OAEP-256", kid: "recipient")
20
+ database[:oauth_applications].update(scopes: "openid offline_access",
21
+ grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token",
22
+ id_token_encrypted_response_alg: "RSA-OAEP-256", id_token_encrypted_response_enc: "A256GCM",
23
+ jwks: JSON.generate(keys: [public_recipient]))
24
+ application = CibaDemo.build(database)
25
+ application.plugin(:rodauth) do
26
+ base_url "https://op.example.test"
27
+ authorization_server_url "https://op.example.test"
28
+ ciba_require_tls true
29
+ oauth_jwt_access_tokens jwt
30
+ ciba_refresh_tokens_enabled true
31
+ oauth_application_scopes %w[openid offline_access]
32
+ ciba_id_token_encryption_enabled true
33
+ ciba_id_token_signing_keys("Ed25519" => edwards, "EdDSA" => edwards)
34
+ end
35
+ http = Rack::MockRequest.new(application)
36
+ issuer = "https://op.example.test"
37
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
38
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
39
+ post = lambda do |endpoint, params|
40
+ response = http.post(endpoint, headers.merge(params: params))
41
+ raise response.body unless response.status == 200
42
+ JSON.parse(response.body)
43
+ end
44
+ %w[RS256 Ed25519 EdDSA].each do |algorithm|
45
+ database[:oauth_applications].update(id_token_signed_response_alg: algorithm)
46
+ started = post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid offline_access", login_hint: "customer@example.test")
47
+ row = database[:ciba_requests].order(:id).last
48
+ application.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
49
+ issued = post.call(metadata.fetch("token_endpoint"), grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id"))
50
+ renewed = post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: issued.fetch("refresh_token"))
51
+ [issued, renewed].each do |tokens|
52
+ token = tokens.fetch("id_token")
53
+ raise "unencrypted response" unless token.split(".").length == 5
54
+ signed = JWE.decrypt(token, recipient)
55
+ if algorithm == "RS256"
56
+ jwks = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
57
+ claims, = JWT.decode(signed, nil, true, jwks: jwks, algorithms: [algorithm],
58
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
59
+ else
60
+ parts = signed.split(".")
61
+ raise "invalid signature" unless edwards.verify(nil, Base64.urlsafe_decode64(parts.last), parts.first(2).join("."))
62
+ claims, header = JWT.decode(signed, nil, false)
63
+ raise "wrong algorithm" unless header.fetch("alg") == algorithm
64
+ raise "wrong issuer/audience" unless claims.fetch("iss") == issuer && claims.fetch("aud") == "demo-client"
65
+ end
66
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
67
+ end
68
+ puts "PASS: installed #{algorithm} encrypted ID Token issuance/refresh, #{jwt ? 'JWT' : 'opaque'} access token, recipient decryption and signature verified"
69
+ end
70
+ ensure
71
+ database.disconnect
72
+ end
73
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ module CibaEncryptionTlsSmoke
3
+ def self.run(server, untrusted:)
4
+ requests = []
5
+ state = {keys: [], status: 200, cache: "max-age=3600", body: nil}
6
+ server.mount_proc("/encryption-jwks") do |request, response|
7
+ requests << [request.request_method, request["authorization"]]
8
+ response.status = state[:status]
9
+ response["Content-Type"] = "application/json"
10
+ response["Cache-Control"] = state[:cache]
11
+ response.body = state[:body] || JSON.generate(keys: state[:keys])
12
+ end
13
+ endpoint = "https://127.0.0.1:#{server.config[:Port]}/encryption-jwks"
14
+ [false, true].product([nil, "P-256", "X25519"]).each do |jwt, curve|
15
+ algorithm = curve == "X25519" ? "ECDH-ES+A256KW" : curve ? "ECDH-ES" : "RSA-OAEP-256"
16
+ database = Sequel.sqlite
17
+ begin
18
+ CibaDemo.create_schema(database)
19
+ Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
20
+ database.alter_table(:oauth_applications) do
21
+ add_column :jwks_uri, String
22
+ add_column :id_token_encrypted_response_alg, String
23
+ add_column :id_token_encrypted_response_enc, String
24
+ end
25
+ database[:oauth_applications].update(jwks_uri: endpoint, scopes: "openid offline_access",
26
+ grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token",
27
+ id_token_encrypted_response_alg: algorithm, id_token_encrypted_response_enc: "A256GCM")
28
+ old_key, new_key = 2.times.map do
29
+ curve == "X25519" ? OpenSSL::PKey.generate_key("X25519") : curve ? OpenSSL::PKey::EC.generate("prime256v1") : OpenSSL::PKey::RSA.generate(2048)
30
+ end
31
+ jwk = lambda do |key, kid|
32
+ public_key = curve == "X25519" ?
33
+ {kty: "OKP", crv: curve, x: Base64.urlsafe_encode64(OpenSSL::ASN1.decode(key.public_to_der).value.last.value, padding: false)} :
34
+ JWT::JWK.new(curve ? key : key.public_key).export
35
+ public_key.merge(use: "enc", alg: algorithm, kid: kid)
36
+ end
37
+ state.merge!(keys: [jwk.call(old_key, "old")], status: 200, cache: "max-age=3600", body: nil)
38
+ app = CibaDemo.build(database)
39
+ app.plugin(:rodauth) do
40
+ oauth_jwt_access_tokens jwt
41
+ ciba_id_token_encryption_enabled true
42
+ ciba_refresh_tokens_enabled true
43
+ oauth_application_scopes %w[openid offline_access]
44
+ report_ciba_processing_error { |_error| }
45
+ end
46
+ issuer = "http://127.0.0.1:9292"
47
+ http = Rack::MockRequest.new(app)
48
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
49
+ auth = app.new(Rack::MockRequest.env_for(issuer)).rodauth
50
+ auth.send(:http_request_cache).uncache(URI(endpoint))
51
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
52
+ post = ->(path, params) { http.post(path, headers.merge(params: params)) }
53
+ started = post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid offline_access", login_hint: "customer@example.test")
54
+ raise started.body unless started.status == 200
55
+ id = JSON.parse(started.body).fetch("auth_req_id")
56
+ row = database[:ciba_requests].first
57
+ auth.approve_ciba_request(row[:id], account_id: row[:account_id])
58
+ poll = -> { post.call(metadata.fetch("token_endpoint"), grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id) }
59
+ before = requests.length
60
+ denied = poll.call
61
+ raise "default encryption destination guard bypassed: #{denied.status}" unless denied.status == 500 && requests.length == before && database[:oauth_grants].count.zero?
62
+ raise "failed lookup consumed request" unless database[:ciba_requests].get(:status) == "approved"
63
+ app.plugin(:rodauth) { ciba_http_address_allowed? { |address| address == "127.0.0.1" } }
64
+ issued = poll.call
65
+ if untrusted
66
+ raise "untrusted encryption key TLS accepted" unless issued.status == 500 && requests.length == before && database[:oauth_grants].count.zero?
67
+ raise "untrusted lookup consumed request" unless database[:ciba_requests].get(:status) == "approved"
68
+ puts "PASS: installed HTTPS #{curve || "RSA"}/#{algorithm} encryption keys reject untrusted TLS, #{jwt ? 'JWT' : 'opaque'} mode"
69
+ next
70
+ end
71
+ verify = lambda do |response, key, kid|
72
+ raise response.body unless response.status == 200
73
+ tokens = JSON.parse(response.body)
74
+ encrypted = tokens.fetch("id_token")
75
+ raise "wrong recipient kid" unless JSON.parse(Base64.urlsafe_decode64(encrypted.split('.').first)).fetch("kid") == kid
76
+ signed = if curve
77
+ parts = encrypted.split(".", -1)
78
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
79
+ shared = key.derive(auth.send(:ciba_ecdh_public_key, header.fetch("epk")))
80
+ derived = auth.send(:ciba_ecdh_derived_key, shared, algorithm, "A256GCM")
81
+ cek = algorithm == "ECDH-ES" ? derived : JWE::Alg.decrypt_cek("A256KW", derived, Base64.urlsafe_decode64(parts[1]))
82
+ JWE::Enc.for("A256GCM", cek, Base64.urlsafe_decode64(parts[2]), Base64.urlsafe_decode64(parts[4]))
83
+ .decrypt(Base64.urlsafe_decode64(parts[3]), parts[0])
84
+ else
85
+ JWE.decrypt(encrypted, key)
86
+ end
87
+ public_keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
88
+ claims, = JWT.decode(signed, nil, true, jwks: public_keys, algorithms: ["RS256"],
89
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
90
+ raise "wrong subject" unless claims.fetch("sub") == row[:account_id].to_s
91
+ tokens
92
+ end
93
+ tokens = verify.call(issued, old_key, "old")
94
+ refresh = tokens.fetch("refresh_token")
95
+ renew = -> { post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: refresh) }
96
+ state[:keys] = [jwk.call(new_key, "new")]
97
+ cached_requests = requests.length
98
+ verify.call(renew.call, old_key, "old")
99
+ raise "live cache unexpectedly refreshed" unless requests.length == cached_requests
100
+ auth.send(:http_request_cache).uncache(URI(endpoint))
101
+ state[:cache] = "no-cache"
102
+ verify.call(renew.call, new_key, "new")
103
+ source = database[:ciba_refresh_tokens].where(token_digest: Digest::SHA256.hexdigest(refresh))
104
+ source.update(issued_at: Time.now.to_i - 800, expires_at: Time.now.to_i + 200)
105
+ count = database[:oauth_grants].count
106
+ auth.send(:http_request_cache).uncache(URI(endpoint))
107
+ [[503, nil], [200, "invalid JSON"]].each do |status, body|
108
+ state[:status], state[:body] = status, body
109
+ failed = renew.call
110
+ raise "recipient failure returned token" unless failed.status == 500
111
+ raise "recipient failure consumed refresh" unless source.get(:consumed_at).nil? && database[:oauth_grants].count == count && database[:ciba_refresh_tokens].count == 1
112
+ end
113
+ state[:status], state[:body] = 200, nil
114
+ recovered = verify.call(renew.call, new_key, "new")
115
+ raise "refresh did not rotate after recovery" if recovered.fetch("refresh_token") == refresh || source.get(:consumed_at).nil?
116
+ raise "JWKS credential forwarded" unless requests.drop(before).all? { |entry| entry == ["GET", nil] }
117
+ puts "PASS: installed HTTPS #{curve || "RSA"}/#{algorithm} encryption key cache/rotation, 503/malformed rollback and recovery, #{jwt ? 'JWT' : 'opaque'} mode"
118
+ ensure
119
+ database.disconnect
120
+ end
121
+ end
122
+ end
123
+ end