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,73 @@
1
+ # frozen_string_literal: true
2
+ # Installed-artifact check, using only local synthetic identities/credentials.
3
+ require "rack/mock"
4
+ require "json"
5
+ require "base64"
6
+ require_relative "demo_app"
7
+
8
+ db = Sequel.sqlite
9
+ CibaDemo.create_schema(db)
10
+ db.alter_table(:oauth_applications) do
11
+ add_column :name, String
12
+ add_column :redirect_uri, String, text: true
13
+ add_column :response_types, String
14
+ add_column :registration_access_token, String
15
+ end
16
+ Rodauth::CibaSupport::Schema.add_registration_token_digest(db)
17
+ app = CibaDemo.build(db)
18
+ app.plugin(:rodauth) do
19
+ enable :oauth_dynamic_client_registration
20
+ ciba_dynamic_client_registration_enabled true
21
+ before_register do
22
+ authorization_required unless request.env["HTTP_AUTHORIZATION"] == "Bearer local-registration-fixture"
23
+ end
24
+ end
25
+ app.route do |r|
26
+ rodauth.load_openid_configuration_route
27
+ r.rodauth
28
+ rodauth.load_registration_client_uri_routes
29
+ end
30
+ http = Rack::MockRequest.new(app)
31
+ issuer = "http://127.0.0.1:9292"
32
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
33
+ params = {grant_types: [Rodauth::CibaSupport::GRANT_TYPE], response_types: [],
34
+ scope: "openid", token_endpoint_auth_method: "client_secret_basic",
35
+ backchannel_token_delivery_mode: "poll"}
36
+ response = http.post(metadata.fetch("registration_endpoint"),
37
+ "HTTP_AUTHORIZATION" => "Bearer local-registration-fixture",
38
+ "CONTENT_TYPE" => "application/json", input: JSON.generate(params))
39
+ raise response.body unless response.status == 201
40
+ client = JSON.parse(response.body)
41
+ credentials = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64("#{client.fetch('client_id')}:#{client.fetch('client_secret')}")}"}
42
+ response = http.post(metadata.fetch("backchannel_authentication_endpoint"),
43
+ credentials.merge(params: {scope: "openid", login_hint: "customer@example.test"}))
44
+ raise response.body unless response.status == 200
45
+ id = JSON.parse(response.body).fetch("auth_req_id")
46
+ instance = app.new(Rack::MockRequest.env_for(issuer)).rodauth
47
+ request = instance.ciba_request(db[:ciba_requests].get(:id))
48
+ grant = instance.create_ciba_grant(account_id: request[:account_id],
49
+ oauth_application_id: request[:oauth_application_id], scopes: request[:scopes])
50
+ instance.backchannel_result(request[:id], grant)
51
+ response = http.post(metadata.fetch("token_endpoint"), credentials.merge(
52
+ params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id}))
53
+ raise response.body unless response.status == 200
54
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
55
+ JWT.decode(JSON.parse(response.body).fetch("id_token"), nil, true, jwks: keys,
56
+ algorithms: ["RS256"], verify_iss: true, iss: issuer, verify_aud: true, aud: client.fetch("client_id"))
57
+
58
+ uri = client.fetch("registration_client_uri")
59
+ bearer = {"HTTP_AUTHORIZATION" => "Bearer #{client.fetch('registration_access_token')}"}
60
+ raise "management read failed" unless http.get(uri, bearer).status == 200
61
+ response = http.put(uri, bearer.merge("CONTENT_TYPE" => "application/json",
62
+ input: JSON.generate(params.merge(client_id: client.fetch("client_id"), client_name: "Updated fixture"))))
63
+ raise response.body unless response.status == 200
64
+ updated = JSON.parse(response.body)
65
+ raise "credential not rotated" if updated.fetch("registration_access_token") == client.fetch("registration_access_token")
66
+ raise "old credential accepted" unless http.get(uri, bearer).status == 401
67
+ bearer = {"HTTP_AUTHORIZATION" => "Bearer #{updated.fetch('registration_access_token')}"}
68
+ raise "delete failed" unless http.delete(uri, bearer).status == 204
69
+ raise "client retained" if db[:oauth_applications].where(client_id: client.fetch("client_id")).first
70
+ raise "issued grant retained" unless db[:oauth_grants].empty?
71
+ raise "unrelated client removed" unless db[:oauth_applications].where(client_id: "demo-client").first
72
+ puts "PASS: installed-gem dynamic registration, verified CIBA issuance, management rotation and dependent deletion"
73
+ db.disconnect
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ require "tmpdir"
3
+ require "rackup/handler/webrick"
4
+ require_relative "demo_app"
5
+
6
+ Dir.mktmpdir("ciba-demo-") do |directory|
7
+ db = Sequel.sqlite(File.join(directory, "demo.db"))
8
+ CibaDemo.create_schema(db)
9
+ begin
10
+ Rackup::Handler::WEBrick.run(CibaDemo.build(db), Host: "127.0.0.1", Port: 9292)
11
+ ensure
12
+ db.disconnect
13
+ end
14
+ end
data/examples/smoke.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+ # Runs from an installed gem without loading the repository's lib directory.
3
+ require "rack/mock"
4
+ require "json"
5
+ require_relative "demo_app"
6
+ db = Sequel.sqlite
7
+ CibaDemo.create_schema(db)
8
+ app = CibaDemo.build(db)
9
+ http = Rack::MockRequest.new(app)
10
+ issuer = "http://127.0.0.1:9292"
11
+ headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
12
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
13
+ response = http.post(metadata.fetch("backchannel_authentication_endpoint"), headers.merge(params: {scope: "openid", login_hint: "customer@example.test"}))
14
+ raise response.body unless response.status == 200
15
+ id = JSON.parse(response.body).fetch("auth_req_id")
16
+ instance = app.new(Rack::MockRequest.env_for(issuer)).rodauth
17
+ request = instance.ciba_request(db[:ciba_requests].get(:id))
18
+ consent = instance.create_ciba_grant(account_id: request[:account_id],
19
+ oauth_application_id: request[:oauth_application_id], scopes: request[:scopes])
20
+ instance.backchannel_result(request[:id], consent)
21
+ response = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id}))
22
+ raise response.body unless response.status == 200
23
+ tokens = JSON.parse(response.body)
24
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
25
+ claims, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"], verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
26
+ raise "wrong subject" unless claims["sub"] == db[:accounts].get(:id).to_s
27
+ raise "unexpected refresh token" if tokens.key?("refresh_token")
28
+ raise "wrong consent reference" unless db[:oauth_grants].get(:ciba_grant_id) == consent[:id]
29
+ replay = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: id}))
30
+ raise "request reused" unless replay.status == 400 && JSON.parse(replay.body)["error"] == "invalid_grant"
31
+ raise "replay did not revoke token" unless db[:oauth_grants].get(:revoked_at)
32
+ instance.revoke_ciba_grant(consent[:id])
33
+ raise "token not revoked" unless db[:oauth_grants].get(:revoked_at)
34
+ puts "PASS: installed-gem discovery, acceptance, saved consent/result API, poll, verified ID Token, replay rejection and revocation"
35
+ response = http.post(metadata.fetch("backchannel_authentication_endpoint"), headers.merge(params: {scope: "openid", login_hint: "customer@example.test"}))
36
+ raise response.body unless response.status == 200
37
+ failed_id = JSON.parse(response.body).fetch("auth_req_id")
38
+ instance.backchannel_result(db[:ciba_requests].order(:id).last[:id],
39
+ Rodauth::CibaSupport::ProtocolError.new("application_failure"))
40
+ %w[application_failure invalid_grant].each do |expected|
41
+ result = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: failed_id}))
42
+ raise result.body unless result.status == 400 && JSON.parse(result.body)["error"] == expected
43
+ end
44
+ raise "failure issued a token" unless db[:oauth_grants].count == 1
45
+ puts "PASS: installed-gem custom completion error returned once without token issuance"
46
+ db.disconnect
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ require "rack/mock"
3
+ require "json"
4
+ require_relative "demo_app"
5
+
6
+ [false, true].product([false, true]).each do |jwt, hashed|
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_encrypted_response_alg, String
13
+ add_column :id_token_encrypted_response_enc, String
14
+ add_column :jwks_uri, String
15
+ end
16
+ secret = "c" * 64
17
+ database[:oauth_applications].update(scopes: "openid offline_access",
18
+ grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token",
19
+ token_endpoint_auth_method: hashed ? "client_secret_basic" : "client_secret_post",
20
+ client_secret: hashed ? BCrypt::Password.create(secret, cost: 4).to_s : secret,
21
+ jwks_uri: "https://unused.invalid/jwks")
22
+ app = CibaDemo.build(database)
23
+ app.plugin(:rodauth) do
24
+ base_url "https://op.example.test"
25
+ authorization_server_url "https://op.example.test"
26
+ ciba_require_tls true
27
+ oauth_jwt_access_tokens jwt
28
+ ciba_refresh_tokens_enabled true
29
+ oauth_application_scopes %w[openid offline_access]
30
+ ciba_id_token_encryption_enabled true
31
+ oauth_applications_client_secret_hash_column(hashed ? :client_secret : nil)
32
+ http_request { |*| raise "unexpected recipient HTTP request" }
33
+ end
34
+ http = Rack::MockRequest.new(app)
35
+ issuer = "https://op.example.test"
36
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
37
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
38
+ post = lambda do |endpoint, params|
39
+ headers = hashed ? {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64("demo-client:#{secret}")}"} : {}
40
+ params = params.merge(client_id: "demo-client", client_secret: secret) unless hashed
41
+ response = http.post(endpoint, headers.merge(params: params))
42
+ raise response.body unless response.status == 200
43
+ JSON.parse(response.body)
44
+ end
45
+ %w[dir A128KW A192KW A256KW A128GCMKW A192GCMKW A256GCMKW].each do |algorithm|
46
+ raise "missing Discovery algorithm" unless metadata.fetch("id_token_encryption_alg_values_supported").include?(algorithm)
47
+ method = "A256CBC-HS512"
48
+ database[:oauth_applications].update(id_token_encrypted_response_alg: algorithm, id_token_encrypted_response_enc: method)
49
+ started = post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid offline_access", login_hint: "customer@example.test")
50
+ row = database[:ciba_requests].order(:id).last
51
+ app.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
52
+ initial = post.call(metadata.fetch("token_endpoint"), grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id"))
53
+ renewed = post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: initial.fetch("refresh_token"))
54
+ length = algorithm == "dir" ? 64 : algorithm[/\d+/].to_i / 8
55
+ key = OpenSSL::Digest.digest(length > 32 ? "SHA512" : "SHA256", secret)[0, length]
56
+ [initial, renewed].each do |tokens|
57
+ token = tokens.fetch("id_token")
58
+ signed = if algorithm.end_with?("GCMKW")
59
+ parts = token.split(".")
60
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
61
+ raise "wrong key algorithm" unless header.fetch("alg") == algorithm
62
+ wrapper = OpenSSL::Cipher.new("aes-#{length * 8}-gcm")
63
+ wrapper.decrypt
64
+ wrapper.key = key
65
+ wrapper.iv = Base64.urlsafe_decode64(header.fetch("iv"))
66
+ wrapper.auth_tag = Base64.urlsafe_decode64(header.fetch("tag"))
67
+ wrapper.auth_data = ""
68
+ cek = wrapper.update(Base64.urlsafe_decode64(parts[1])) + wrapper.final
69
+ JWE::Enc.for(method, cek, Base64.urlsafe_decode64(parts[2]), Base64.urlsafe_decode64(parts[4]))
70
+ .decrypt(Base64.urlsafe_decode64(parts[3]), parts[0])
71
+ else
72
+ JWE.decrypt(token, key)
73
+ end
74
+ claims, = JWT.decode(signed, nil, true, jwks: keys, 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 #{algorithm}/#{method}, #{hashed ? 'hashed Basic' : 'plaintext POST'}, #{jwt ? 'JWT' : 'opaque'}; initial/refresh decrypted and signatures verified; no remote recipient lookup"
79
+ end
80
+ ensure
81
+ database.disconnect
82
+ end
83
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Rodauth
5
+ module CibaSupport
6
+ # Structural validation only. Type registration, client eligibility and
7
+ # permission narrowing belong to the application/protocol integration.
8
+ module AuthorizationDetails
9
+ class Invalid < ArgumentError; end
10
+
11
+ ARRAY_FIELDS = %w[locations actions datatypes privileges].freeze
12
+ MAX_BYTES = 8192
13
+ MAX_ENTRIES = 32
14
+ MAX_NESTING = 10
15
+
16
+ def self.parse(json)
17
+ unless json.is_a?(String) && json.bytesize <= MAX_BYTES
18
+ raise Invalid, "invalid authorization-details JSON"
19
+ end
20
+ json = json.dup.force_encoding(Encoding::UTF_8)
21
+ raise Invalid, "invalid authorization-details encoding" unless json.valid_encoding?
22
+ data = immutable(JSON.parse(json, max_nesting: MAX_NESTING, allow_duplicate_key: false))
23
+ unless data.is_a?(Array) && data.size <= MAX_ENTRIES
24
+ raise Invalid, "authorization details must be a bounded array"
25
+ end
26
+ data.each do |detail|
27
+ unless detail.is_a?(Hash) && nonempty_string?(detail["type"])
28
+ raise Invalid, "authorization detail must have a type"
29
+ end
30
+ ARRAY_FIELDS.each do |field|
31
+ next unless detail.key?(field)
32
+ values = detail[field]
33
+ unless values.is_a?(Array) && values.all? { |value| nonempty_string?(value) }
34
+ raise Invalid, "invalid authorization-details array field"
35
+ end
36
+ end
37
+ if detail.key?("identifier") && !nonempty_string?(detail["identifier"])
38
+ raise Invalid, "invalid authorization-details identifier"
39
+ end
40
+ end
41
+ data
42
+ rescue JSON::ParserError
43
+ raise Invalid, "invalid authorization-details JSON"
44
+ end
45
+
46
+ def self.nonempty_string?(value)
47
+ value.is_a?(String) && !value.empty?
48
+ end
49
+
50
+ def self.immutable(value)
51
+ case value
52
+ when Hash then value.to_h { |key, item| [key.freeze, immutable(item)] }.freeze
53
+ when Array then value.map { |item| immutable(item) }.freeze
54
+ else value.freeze
55
+ end
56
+ end
57
+ private_class_method :nonempty_string?, :immutable
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module CibaSupport
5
+ # CIBA token and UserInfo proof boundary. External resource servers still
6
+ # need their own validation of access-token signatures and sender binding.
7
+ module Dpop
8
+ private
9
+
10
+ def ciba_dpop_userinfo_candidate?(token)
11
+ return false unless token.is_a?(String)
12
+ return true if token.start_with?("ciba_at_")
13
+ return false if token.bytesize > 8192
14
+ if token.count(".") == 2
15
+ claims = JWT::EncodedToken.new(token).unverified_payload
16
+ # Unverified claims select a stricter path only. Signature, expiry,
17
+ # client, subject and stored grant checks still precede authorization.
18
+ return claims.is_a?(Hash) && claims.key?("urn:rodauth:ciba:token_id")
19
+ end
20
+ return false if token.bytesize > 1024
21
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
22
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
23
+ db[oauth_grants_table].where(column => value, oauth_grants_type_column => GRANT_TYPE).any?
24
+ rescue JWT::DecodeError
25
+ false
26
+ end
27
+
28
+ def ciba_dpop_protected_request?
29
+ @ciba_dpop_userinfo || ciba_dpop_token_request?
30
+ end
31
+
32
+ def ciba_dpop_error(code = "invalid_dpop_proof")
33
+ if @ciba_dpop_userinfo
34
+ response["WWW-Authenticate"] = "DPoP error=\"#{code}\""
35
+ ciba_error(code, 401)
36
+ end
37
+ ciba_error(code)
38
+ end
39
+
40
+ def decode_access_token(token = fetch_access_token)
41
+ return super unless @ciba_dpop_userinfo
42
+ decoded = oauth_jwt_access_tokens ? super : oauth_grant_by_token(token)
43
+ ciba_dpop_error("invalid_token") unless decoded
44
+ if decoded[oauth_grants_type_column] == GRANT_TYPE && decoded[oauth_grants_dpop_jkt_column]
45
+ decoded = decoded.merge("cnf" => {"jkt" => decoded[oauth_grants_dpop_jkt_column]})
46
+ end
47
+ decoded
48
+ end
49
+
50
+ def valid_oauth_grant_ds(grant_params = nil)
51
+ return super unless @ciba_dpop_userinfo || @ciba_dpop_userinfo_checked
52
+ # Upstream's OR thumbprint filter widens the complete predicate. Keep
53
+ # revocation, expiration, identity and key binding conjunctive here.
54
+ ds = db[oauth_grants_table].where(oauth_grants_revoked_at_column => nil,
55
+ oauth_grants_type_column => GRANT_TYPE,
56
+ oauth_grants_dpop_jkt_column => @dpop_thumbprint)
57
+ .where(Sequel.expr(Sequel[oauth_grants_expires_in_column]) >= Sequel::CURRENT_TIMESTAMP)
58
+ ds = ds.where(grant_params) if grant_params
59
+ ds = ds.where(oauth_grants_id_column => @ciba_userinfo_grant_id) if @ciba_userinfo_grant_id
60
+ ds
61
+ end
62
+
63
+ def oauth_grant_by_token_ds(token)
64
+ if features.include?(:oauth_dpop) && features.include?(:oauth_token_introspection) &&
65
+ request.path == introspect_path && ciba_dpop_userinfo_candidate?(token)
66
+ # Authenticated introspection reports binding to the caller; it is not
67
+ # a resource request proving possession of the access token's key.
68
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
69
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
70
+ return db[oauth_grants_table].where(column => value,
71
+ oauth_grants_type_column => GRANT_TYPE, oauth_grants_revoked_at_column => nil)
72
+ .where(Sequel.expr(Sequel[oauth_grants_expires_in_column]) >= Sequel::CURRENT_TIMESTAMP)
73
+ end
74
+ return super unless @ciba_dpop_userinfo || @ciba_dpop_userinfo_checked
75
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
76
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
77
+ valid_oauth_grant_ds.where(column => value)
78
+ end
79
+
80
+ def json_token_introspect_payload(grant)
81
+ super.tap do |payload|
82
+ if features.include?(:oauth_dpop) && payload[:active] &&
83
+ grant && grant[oauth_grants_type_column] == GRANT_TYPE &&
84
+ (jkt = grant[oauth_grants_dpop_jkt_column])
85
+ payload[:cnf] = {jkt: jkt}
86
+ payload[:token_type] = "DPoP"
87
+ end
88
+ end
89
+ end
90
+
91
+ def authorization_token
92
+ return super if @ciba_dpop_userinfo_checked
93
+ return super unless features.include?(:oauth_dpop) && request.path == userinfo_path
94
+ access = fetch_access_token_from_authorization_header("dpop") || fetch_access_token
95
+ return super unless ciba_dpop_userinfo_candidate?(access)
96
+ @ciba_dpop_userinfo = true
97
+ ciba_dpop_error("invalid_token") if access.bytesize > 8192
98
+ @dpop_access_token = fetch_access_token_from_authorization_header("dpop")
99
+ if @dpop_access_token
100
+ validate_dpop_token(fetch_dpop_token)
101
+ validate_ath(@dpop_claims, access)
102
+ @authorization_token = decode_access_token(access)
103
+ end
104
+ token = super
105
+ ciba_dpop_error("invalid_token") unless token
106
+ if @dpop_access_token
107
+ stored_jkt = db[oauth_grants_table].where(oauth_grants_id_column => @ciba_userinfo_grant_id)
108
+ .get(oauth_grants_dpop_jkt_column)
109
+ ciba_dpop_error("invalid_token") unless token["cnf"].is_a?(Hash) &&
110
+ token["cnf"]["jkt"] == @dpop_thumbprint && stored_jkt == @dpop_thumbprint
111
+ ciba_claim_dpop_proof!(token.fetch("client_id"))
112
+ elsif token[oauth_grants_dpop_jkt_column] || token.dig("cnf", "jkt")
113
+ ciba_dpop_error("invalid_token")
114
+ end
115
+ @ciba_dpop_userinfo_checked = true
116
+ token
117
+ rescue Sequel::UniqueConstraintViolation
118
+ raise unless @ciba_dpop_userinfo
119
+ ciba_dpop_error("invalid_token")
120
+ ensure
121
+ @ciba_dpop_userinfo = false
122
+ end
123
+
124
+ def validate_ath(claims, access_token)
125
+ return super unless @ciba_dpop_userinfo
126
+ expected = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token), padding: false)
127
+ ciba_dpop_error("invalid_token") unless claims["ath"] == expected
128
+ end
129
+
130
+ def ciba_dpop_token_request?
131
+ features.include?(:oauth_dpop) && request.path == token_path &&
132
+ (param("grant_type") == GRANT_TYPE || ciba_refresh_request?)
133
+ end
134
+
135
+ def validate_token_params
136
+ if ciba_dpop_token_request? && !fetch_dpop_token && dpop_bound_access_tokens_required?
137
+ ciba_error("invalid_grant")
138
+ end
139
+ super
140
+ end
141
+
142
+ def dpop_use_nonce?
143
+ return super unless ciba_dpop_protected_request?
144
+ oauth_dpop_use_nonce
145
+ end
146
+
147
+ def ciba_dpop_nonce_for(step)
148
+ bytes = OpenSSL::KDF.hkdf(ciba_dpop_nonce_secret, salt: [step].pack("Q>"),
149
+ info: "DPoP", length: 32, hash: "SHA256")
150
+ Base64.urlsafe_encode64(bytes, padding: false)
151
+ end
152
+
153
+ def validate_nonce(claims)
154
+ return super unless ciba_dpop_protected_request?
155
+ @ciba_dpop_nonce_verified = false
156
+ nonce = claims["nonce"]
157
+ ciba_dpop_error if claims.key?("nonce") && !nonce.is_a?(String)
158
+ step = ciba_now / 60
159
+ next_nonce = ciba_dpop_nonce_for(step + 1) if ciba_dpop_nonce_secret
160
+ challenge = lambda do
161
+ response["DPoP-Nonce"] = next_nonce
162
+ ciba_dpop_error("use_dpop_nonce")
163
+ end
164
+ if nonce && !nonce.empty?
165
+ ciba_dpop_error unless next_nonce
166
+ valid = nonce.bytesize == 43 && (-2..2).map do |offset|
167
+ OpenSSL.fixed_length_secure_compare(ciba_dpop_nonce_for(step + offset), nonce)
168
+ end.reduce(false, :|)
169
+ challenge.call unless valid
170
+ @ciba_dpop_nonce_verified = true
171
+ else
172
+ if (ciba_now - claims.fetch("iat")).abs > 300
173
+ challenge.call if next_nonce
174
+ ciba_dpop_error
175
+ end
176
+ challenge.call if dpop_use_nonce?
177
+ end
178
+ response["DPoP-Nonce"] = next_nonce if next_nonce && nonce != next_nonce
179
+ end
180
+
181
+ def dpop_decode(proof)
182
+ return super unless ciba_dpop_protected_request?
183
+ ciba_dpop_error unless proof.is_a?(String) && proof.bytesize <= 8192
184
+ header = JWT::EncodedToken.new(proof).header
185
+ unless header.is_a?(Hash) && header["jwk"].is_a?(Hash) && !header.key?("crit") &&
186
+ verify_dpop_jwt_headers(header)
187
+ ciba_dpop_error
188
+ end
189
+ # The generic OP decoder applies its issuer-token iat policy. Proof
190
+ # freshness has its own window, checked below, including future iat.
191
+ claims, = JWT.decode(proof, jwk_key(header["jwk"]), true,
192
+ algorithms: [header["alg"]], verify_iat: false)
193
+ claims
194
+ rescue JWT::DecodeError, JWT::JWKError, ArgumentError, TypeError, OpenSSL::PKey::PKeyError
195
+ raise unless ciba_dpop_protected_request?
196
+ ciba_dpop_error
197
+ end
198
+
199
+ def validate_dpop_jwt_claims(claims)
200
+ return super unless ciba_dpop_protected_request?
201
+ iat, jti = claims.values_at("iat", "jti")
202
+ unless iat.is_a?(Numeric) && iat.finite? && !iat.zero? &&
203
+ jti.is_a?(String) && !jti.empty? && jti.bytesize <= 1024 &&
204
+ claims["htm"] == request.request_method
205
+ ciba_dpop_error
206
+ end
207
+ uri = URI.parse(claims["htu"]) if claims["htu"].is_a?(String)
208
+ ciba_dpop_error unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo
209
+ uri.query = uri.fragment = nil
210
+ # Bind the proof to the endpoint actually reached, including an
211
+ # application-owned alias, rather than the canonical issuer's URL.
212
+ expected = URI.parse(ciba_dpop_endpoint_uri)
213
+ expected.query = expected.fragment = nil
214
+ ciba_dpop_error unless uri.normalize == expected.normalize
215
+ rescue URI::InvalidURIError
216
+ raise unless ciba_dpop_protected_request?
217
+ ciba_dpop_error
218
+ end
219
+
220
+ def validate_dpop_proof_usage(claims)
221
+ return super unless ciba_dpop_protected_request?
222
+ # Pending polls do not reserve proofs. Issuance claims inside its token
223
+ # transaction; UserInfo claims after token provenance and key checks.
224
+ end
225
+
226
+ def generate_token(grant_params = {}, should_generate_refresh_token = true)
227
+ if ciba_dpop_token_request? && @dpop_claims
228
+ begin
229
+ ciba_claim_dpop_proof!(oauth_application[oauth_applications_client_id_column])
230
+ rescue Sequel::UniqueConstraintViolation
231
+ # Raise through the outer token transaction, preserving approval
232
+ # on rejection instead of committing a partially consumed request.
233
+ raise ProtocolError, "invalid_grant"
234
+ end
235
+ end
236
+ super
237
+ end
238
+
239
+ def ciba_claim_dpop_proof!(client_id)
240
+ digest = Digest::SHA256.hexdigest(JSON.generate(["ciba_dpop", client_id, @dpop_claims.fetch("jti")]))
241
+ # A valid server nonce replaces the proof iat as freshness evidence.
242
+ # An ancient iat must never cause immediate replay-ledger cleanup.
243
+ expires_at = @ciba_dpop_nonce_verified ? ciba_now + 301 : (@dpop_claims.fetch("iat") + 300).ceil + 1
244
+ ciba_transaction do
245
+ db[ciba_client_assertions_table].insert(digest: digest, expires_at: expires_at)
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ require "ipaddr"
3
+ require "net/http"
4
+ require "socket"
5
+ require "timeout"
6
+
7
+ module Rodauth
8
+ module CibaSupport
9
+ module HTTP
10
+ class Error < StandardError; end
11
+ # Based on the reference provider's special-use ranges; also block multicast
12
+ # and IPv4-compatible IPv6. IPAddr handles mapped addresses without text tricks.
13
+ BLOCKED = %w[
14
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
15
+ 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.31.196.0/24
16
+ 192.52.193.0/24 192.88.99.0/24 192.168.0.0/16 192.175.48.0/24
17
+ 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
18
+ ::/96 64:ff9b::/96 64:ff9b:1::/48 100::/64 100:0:0:1::/64
19
+ 2001::/23 2001:db8::/32 2002::/16 2620:4f:8000::/48 3fff::/20
20
+ 5f00::/16 fc00::/7 fe80::/10 ff00::/8
21
+ ].map { |range| IPAddr.new(range) }.freeze
22
+ TIMEOUT = 2.5
23
+ MAX_BODY_BYTES = 65_536
24
+
25
+ def self.public_address?(address)
26
+ return false unless address.is_a?(String) && !address.include?("%") && !address.include?("/")
27
+ ip = IPAddr.new(address)
28
+ ip = ip.native if ip.ipv4_mapped?
29
+ BLOCKED.none? { |range| range.include?(ip) }
30
+ rescue IPAddr::InvalidAddressError
31
+ false
32
+ end
33
+
34
+ def self.request(uri, request, address_allowed: method(:public_address?), read_body: true)
35
+ uri = URI(uri.to_s)
36
+ unless %w[http https].include?(uri.scheme) && uri.hostname && !uri.hostname.empty? && !uri.userinfo && !uri.fragment
37
+ raise Error, "invalid outbound URI"
38
+ end
39
+ Timeout.timeout(TIMEOUT) do
40
+ addresses = Addrinfo.getaddrinfo(uri.hostname, uri.port, nil, :STREAM).map(&:ip_address).uniq
41
+ raise Error, "outbound address rejected" if addresses.empty? || addresses.any? { |address| address_allowed.call(address) != true }
42
+ # Disable ambient proxies: otherwise the validated IP would not be the peer.
43
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
44
+ http.ipaddr = addresses.first
45
+ http.use_ssl = uri.scheme == "https"
46
+ http.open_timeout = http.read_timeout = http.write_timeout = TIMEOUT
47
+ result = nil
48
+ http.start do |connection|
49
+ connection.request(request) do |response|
50
+ # Nonlocal return unwinds Net::HTTP.start's ensure and closes the
51
+ # socket without its normal implicit response-body drain.
52
+ return response unless read_body
53
+ body = +""
54
+ response.read_body do |chunk|
55
+ raise Error, "outbound response too large" if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
56
+ body << chunk
57
+ end
58
+ response.body = body
59
+ result = response
60
+ end
61
+ end
62
+ result
63
+ end
64
+ rescue URI::InvalidURIError
65
+ raise Error, "invalid outbound URI"
66
+ end
67
+ end
68
+ end
69
+ end