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