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