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,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
|
data/examples/server.rb
ADDED
|
@@ -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
|