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,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Installed-gem integration check for the optional migrations and consent APIs.
|
|
3
|
+
require "rack/mock"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require_relative "demo_app"
|
|
7
|
+
|
|
8
|
+
[false, true].each do |jwt|
|
|
9
|
+
database = Sequel.sqlite
|
|
10
|
+
begin
|
|
11
|
+
CibaDemo.create_schema(database)
|
|
12
|
+
Rodauth::CibaSupport::Schema.add_claims(database)
|
|
13
|
+
Rodauth::CibaSupport::Schema.add_resources(database)
|
|
14
|
+
Rodauth::CibaSupport::Schema.add_authorization_details(database)
|
|
15
|
+
Rodauth::CibaSupport::Schema.add_signed_requests(database)
|
|
16
|
+
Rodauth::CibaSupport::Schema.add_user_code(database)
|
|
17
|
+
Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
|
|
18
|
+
Rodauth::CibaSupport::Schema.add_request_context(database)
|
|
19
|
+
database[:oauth_applications].update(backchannel_user_code_parameter: true)
|
|
20
|
+
database.alter_table(:oauth_applications) { add_column :jwks, String, text: true }
|
|
21
|
+
request_key = OpenSSL::PKey::RSA.generate(2048)
|
|
22
|
+
database[:oauth_applications].update(backchannel_authentication_request_signing_alg: "RS256",
|
|
23
|
+
jwks: JSON.generate(keys: [JWT::JWK.new(request_key.public_key).export.merge(alg: "RS256", use: "sig")]))
|
|
24
|
+
database[:oauth_applications].update(scopes: "openid offline_access read delete",
|
|
25
|
+
grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token", authorization_details_types: JSON.generate(["support_data"]))
|
|
26
|
+
application = CibaDemo.build(database)
|
|
27
|
+
resource = "https://api.example.test/data"
|
|
28
|
+
application.plugin(:rodauth) do
|
|
29
|
+
enable :oauth_token_introspection, :oauth_token_revocation
|
|
30
|
+
ciba_refresh_tokens_enabled true
|
|
31
|
+
ciba_request_context_enabled true
|
|
32
|
+
validate_ciba_request_context do |context, oauth_application:|
|
|
33
|
+
unless context.nil? || (context == "demo-operation-reference" && oauth_application[:client_id] == "demo-client")
|
|
34
|
+
raise Rodauth::CibaSupport::ProtocolError, "invalid_request"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
ciba_claims_enabled true
|
|
38
|
+
ciba_authentication_claims_by_scope("openid" => %w[auth_time])
|
|
39
|
+
ciba_request_signing_algorithms ["RS256"]
|
|
40
|
+
ciba_user_code_enabled true
|
|
41
|
+
# Demo policy only; production applications own user-code registration and checks.
|
|
42
|
+
verify_ciba_user_code do |code, account_id:, oauth_application:|
|
|
43
|
+
raise Rodauth::CibaSupport::ProtocolError, "missing_user_code" if code.nil?
|
|
44
|
+
code == "demo-user-code" && database[:accounts].where(id: account_id, email: "customer@example.test").count == 1
|
|
45
|
+
end
|
|
46
|
+
ciba_resources_enabled true
|
|
47
|
+
ciba_login_hint_token_enabled true
|
|
48
|
+
ciba_id_token_hint_enabled true
|
|
49
|
+
resolve_ciba_id_token_subject do |subject, oauth_application:|
|
|
50
|
+
database[:accounts].where(id: subject.to_i).get(:id) if subject.match?(/\A[0-9]+\z/)
|
|
51
|
+
end
|
|
52
|
+
resolve_ciba_login_hint_token do |hint, oauth_application:|
|
|
53
|
+
if hint == "demo-customer-reference" && oauth_application[:client_id] == "demo-client"
|
|
54
|
+
database[:accounts].where(email: "customer@example.test").get(:id)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
ciba_authorization_details_enabled true
|
|
58
|
+
ciba_authorization_details_types ["support_data"]
|
|
59
|
+
# This fixture defines only a fixed read/delete type, not generic RAR policy.
|
|
60
|
+
ciba_validate_authorization_details do |details, oauth_application:|
|
|
61
|
+
details.all? { |detail| (detail.keys - %w[type actions]).empty? &&
|
|
62
|
+
detail["actions"].is_a?(Array) && (detail["actions"] - %w[read delete]).empty? }
|
|
63
|
+
end
|
|
64
|
+
ciba_authorization_details_for_token do |requested:, approved:, narrowing:, resource:, oauth_application:|
|
|
65
|
+
approved.filter_map do |detail|
|
|
66
|
+
actions = detail["actions"] & requested.flat_map { |value| value["actions"] }
|
|
67
|
+
actions &= narrowing.flat_map { |value| value["actions"] } if narrowing
|
|
68
|
+
detail.merge("actions" => actions) unless actions.empty?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
ciba_resource_servers(resource => {scopes: %w[read delete], audience: "customer-data"})
|
|
72
|
+
oauth_application_scopes %w[openid offline_access read delete]
|
|
73
|
+
oauth_jwt_access_tokens jwt
|
|
74
|
+
get_oidc_param { |account, name| name == :email ? account[:email] : nil }
|
|
75
|
+
end
|
|
76
|
+
http = Rack::MockRequest.new(application)
|
|
77
|
+
issuer = "http://127.0.0.1:9292"
|
|
78
|
+
headers = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64('demo-client:demo-secret')}"}
|
|
79
|
+
metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
|
|
80
|
+
raise "missing signing metadata" unless metadata["backchannel_authentication_request_signing_alg_values_supported"] == ["RS256"]
|
|
81
|
+
start_signed = lambda do |parameters|
|
|
82
|
+
now = Time.now.to_i
|
|
83
|
+
payload = {iss: "demo-client", aud: issuer, iat: now, nbf: now, exp: now + 60, jti: SecureRandom.hex(16), user_code: "demo-user-code"}.merge(parameters)
|
|
84
|
+
request_jwt = JWT.encode(payload, request_key, "RS256", kid: JWT::JWK.new(request_key).kid)
|
|
85
|
+
http.post(metadata.fetch("backchannel_authentication_endpoint"), headers.merge(params: {request: request_jwt}))
|
|
86
|
+
end
|
|
87
|
+
initial_parameters = {
|
|
88
|
+
scope: "openid offline_access read delete", login_hint_token: "demo-customer-reference", resource: resource,
|
|
89
|
+
claims: JSON.generate(id_token: {email: nil, email_verified: nil}), nonce: "installed-extension-check",
|
|
90
|
+
request_context: "demo-operation-reference",
|
|
91
|
+
authorization_details: JSON.generate([{type: "support_data", actions: %w[read delete]}])
|
|
92
|
+
}
|
|
93
|
+
response = start_signed.call(initial_parameters)
|
|
94
|
+
raise response.body unless response.status == 200
|
|
95
|
+
request_id = JSON.parse(response.body).fetch("auth_req_id")
|
|
96
|
+
instance = application.new(Rack::MockRequest.env_for(issuer)).rodauth
|
|
97
|
+
request = instance.ciba_request(database[:ciba_requests].get(:id))
|
|
98
|
+
raise "raw hint persisted" if JSON.generate(request).include?("demo-customer-reference")
|
|
99
|
+
raise "raw user code persisted" if JSON.generate(request).include?("demo-user-code")
|
|
100
|
+
raise "missing validated request context" unless request[:request_context] == "demo-operation-reference"
|
|
101
|
+
grant = instance.create_ciba_grant(account_id: request[:account_id],
|
|
102
|
+
oauth_application_id: request[:oauth_application_id], scopes: "openid offline_access",
|
|
103
|
+
resources: {resource => "read"}, claims: ["email"],
|
|
104
|
+
authorization_details: [{type: "support_data", actions: ["read"]}])
|
|
105
|
+
instance.backchannel_result(request[:id], grant, auth_time: 1)
|
|
106
|
+
token_params = {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: request_id, resource: resource}
|
|
107
|
+
response = http.post(metadata.fetch("token_endpoint"), headers.merge(params: token_params))
|
|
108
|
+
raise response.body unless response.status == 200
|
|
109
|
+
tokens = JSON.parse(response.body)
|
|
110
|
+
keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
|
|
111
|
+
identity, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
|
|
112
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
|
|
113
|
+
raise "unapproved API scope" unless tokens.fetch("scope") == "read"
|
|
114
|
+
expected_details = [{"type" => "support_data", "actions" => ["read"]}]
|
|
115
|
+
raise "wrong RAR permission" unless tokens["authorization_details"] == expected_details
|
|
116
|
+
raise "RAR in ID Token" if identity.key?("authorization_details")
|
|
117
|
+
raise "request context in ID Token" if identity.key?("request_context")
|
|
118
|
+
raise "missing approved identity claim" unless identity["email"] == "customer@example.test"
|
|
119
|
+
raise "unapproved identity claim" if identity.key?("email_verified")
|
|
120
|
+
raise "wrong authentication context" unless identity["nonce"] == "installed-extension-check" && identity["auth_time"] == 1
|
|
121
|
+
expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(tokens.fetch("access_token"))[0, 16], padding: false)
|
|
122
|
+
raise "wrong access-token binding" unless identity["at_hash"] == expected_hash
|
|
123
|
+
raise "private resource marker in ID Token" if identity.key?("urn:rodauth:ciba:resource")
|
|
124
|
+
hinted = start_signed.call({
|
|
125
|
+
scope: "openid", id_token_hint: tokens.fetch("id_token")
|
|
126
|
+
})
|
|
127
|
+
raise hinted.body unless hinted.status == 200
|
|
128
|
+
hinted_id = JSON.parse(hinted.body).fetch("auth_req_id")
|
|
129
|
+
pending = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {
|
|
130
|
+
grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: hinted_id
|
|
131
|
+
}))
|
|
132
|
+
raise "hint inherited approval" unless pending.status == 400 && JSON.parse(pending.body)["error"] == "authorization_pending"
|
|
133
|
+
hint_claims = identity.reject { |name, _| %w[iat exp].include?(name) }.merge("azp" => "another-party")
|
|
134
|
+
hint_input = [{alg: "RS256", typ: "at+jwt", b64: true, crit: ["b64"]}, hint_claims].map do |part|
|
|
135
|
+
Base64.urlsafe_encode64(JSON.generate(part), padding: false)
|
|
136
|
+
end.join(".")
|
|
137
|
+
hint_signature = instance.oauth_jwt_keys.fetch("RS256").sign("SHA256", hint_input)
|
|
138
|
+
profile_hint = hint_input + "." + Base64.urlsafe_encode64(hint_signature, padding: false)
|
|
139
|
+
profile = start_signed.call(scope: "openid", id_token_hint: profile_hint)
|
|
140
|
+
raise profile.body unless profile.status == 200
|
|
141
|
+
profile_pending = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {
|
|
142
|
+
grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: JSON.parse(profile.body).fetch("auth_req_id")
|
|
143
|
+
}))
|
|
144
|
+
raise "profile hint inherited approval" unless profile_pending.status == 400 && JSON.parse(profile_pending.body)["error"] == "authorization_pending"
|
|
145
|
+
puts "PASS: installed-gem reference ID hint validation profile remains pending"
|
|
146
|
+
introspect = -> { http.post("#{issuer}/introspect", headers.merge(params: {token: tokens.fetch("access_token")})) }
|
|
147
|
+
if jwt
|
|
148
|
+
access, = JWT.decode(tokens.fetch("access_token"), nil, true, jwks: keys, algorithms: ["RS256"],
|
|
149
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: "customer-data")
|
|
150
|
+
raise "wrong resource scope" unless access["scope"] == "read"
|
|
151
|
+
raise "wrong JWT RAR permission" unless access["authorization_details"] == expected_details
|
|
152
|
+
raise "identity claim in API token" if access.key?("email")
|
|
153
|
+
info = introspect.call
|
|
154
|
+
raise "structured JWT accepted" unless info.status == 400 && JSON.parse(info.body)["error"] == "unsupported_token_type"
|
|
155
|
+
refused = http.post(metadata.fetch("revocation_endpoint"), headers.merge(params: {token: tokens.fetch("access_token")}))
|
|
156
|
+
raise "structured JWT revocation accepted" unless refused.status == 400 && JSON.parse(refused.body)["error"] == "unsupported_token_type"
|
|
157
|
+
else
|
|
158
|
+
info = introspect.call
|
|
159
|
+
data = JSON.parse(info.body)
|
|
160
|
+
raise "wrong introspected RAR permission" unless data["authorization_details"] == expected_details
|
|
161
|
+
unless info.status == 200 && data["active"] && data["aud"] == "customer-data" && data["scope"] == "read"
|
|
162
|
+
raise "opaque token restrictions missing: #{info.body}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
userinfo = http.get("#{issuer}/userinfo", "HTTP_AUTHORIZATION" => "Bearer #{tokens.fetch('access_token')}")
|
|
166
|
+
raise "resource token accepted at UserInfo" unless userinfo.status == 401
|
|
167
|
+
|
|
168
|
+
refresh = tokens.fetch("refresh_token")
|
|
169
|
+
source = database[:ciba_refresh_tokens].first
|
|
170
|
+
raise "raw refresh persisted" if JSON.generate(database[:ciba_refresh_tokens].all).include?(refresh)
|
|
171
|
+
raise "wrong refresh digest" unless source[:token_digest] == Digest::SHA256.hexdigest(refresh)
|
|
172
|
+
now = Time.now.to_i
|
|
173
|
+
database[:ciba_refresh_tokens].update(issued_at: now - 800, expires_at: now + 200)
|
|
174
|
+
refreshed = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {
|
|
175
|
+
grant_type: "refresh_token", refresh_token: refresh, resource: resource
|
|
176
|
+
}))
|
|
177
|
+
raise refreshed.body unless refreshed.status == 200
|
|
178
|
+
renewed = JSON.parse(refreshed.body)
|
|
179
|
+
raise "no refresh rotation" if renewed.fetch("refresh_token") == refresh
|
|
180
|
+
raise "lost RAR permission" unless renewed["authorization_details"] == expected_details
|
|
181
|
+
renewed_identity, = JWT.decode(renewed.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
|
|
182
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
|
|
183
|
+
unless renewed_identity["auth_time"] == 1 && renewed_identity["nonce"] == "installed-extension-check" &&
|
|
184
|
+
renewed_identity["email"] == "customer@example.test" && !renewed_identity.key?("email_verified")
|
|
185
|
+
raise "refresh changed authenticated identity or permission"
|
|
186
|
+
end
|
|
187
|
+
replay = http.post(metadata.fetch("token_endpoint"), headers.merge(params: token_params))
|
|
188
|
+
raise "replay accepted" unless replay.status == 400 && JSON.parse(replay.body)["error"] == "invalid_grant"
|
|
189
|
+
raise "saved token not revoked" unless database[:oauth_grants].get(:revoked_at)
|
|
190
|
+
unless jwt
|
|
191
|
+
raise "revoked opaque token remains active" unless JSON.parse(introspect.call.body)["active"] == false
|
|
192
|
+
end
|
|
193
|
+
denied_refresh = http.post(metadata.fetch("token_endpoint"), headers.merge(params: {
|
|
194
|
+
grant_type: "refresh_token", refresh_token: renewed.fetch("refresh_token"), resource: resource
|
|
195
|
+
}))
|
|
196
|
+
raise "successor survived grant revocation" unless denied_refresh.status == 400 && JSON.parse(denied_refresh.body)["error"] == "invalid_grant"
|
|
197
|
+
|
|
198
|
+
# A separate consent exercises the advertised revocation endpoint, not replay.
|
|
199
|
+
second = start_signed.call(initial_parameters)
|
|
200
|
+
raise second.body unless second.status == 200
|
|
201
|
+
second_id = JSON.parse(second.body).fetch("auth_req_id")
|
|
202
|
+
second_grant = instance.create_ciba_grant(account_id: request[:account_id],
|
|
203
|
+
oauth_application_id: request[:oauth_application_id], scopes: "openid offline_access",
|
|
204
|
+
resources: {resource => "read"}, claims: ["email"],
|
|
205
|
+
authorization_details: [{type: "support_data", actions: ["read"]}])
|
|
206
|
+
instance.backchannel_result(database[:ciba_requests].max(:id), second_grant, auth_time: 1)
|
|
207
|
+
second_issued = http.post(metadata.fetch("token_endpoint"), headers.merge(params: token_params.merge(auth_req_id: second_id)))
|
|
208
|
+
raise second_issued.body unless second_issued.status == 200
|
|
209
|
+
second_tokens = JSON.parse(second_issued.body)
|
|
210
|
+
revocation_token = second_tokens.fetch(jwt ? "refresh_token" : "access_token")
|
|
211
|
+
revoked = http.post(metadata.fetch("revocation_endpoint"), headers.merge(params: {token: revocation_token}))
|
|
212
|
+
raise "refresh revocation failed" unless revoked.status == 200 && revoked.body.empty?
|
|
213
|
+
raise "active access token survived revocation" unless database[:oauth_grants].where(revoked_at: nil).empty?
|
|
214
|
+
unless jwt
|
|
215
|
+
raise "AT revocation removed consent" if instance.ciba_grant(second_grant[:id])[:revoked_at]
|
|
216
|
+
raise "old request survived AT revocation" unless database[:ciba_requests].where(grant_id: second_grant[:id]).empty?
|
|
217
|
+
end
|
|
218
|
+
raise "premature refresh cleanup" unless instance.cleanup_ciba_refresh_tokens == 0
|
|
219
|
+
database[:ciba_refresh_tokens].update(expires_at: Time.now.to_i - 1)
|
|
220
|
+
raise "refresh cleanup failed" unless instance.cleanup_ciba_refresh_tokens == (jwt ? 3 : 2) && database[:ciba_refresh_tokens].empty?
|
|
221
|
+
puts "PASS: installed-gem #{jwt ? 'JWT' : 'opaque'} refresh migration, rotation, claims/RAR, original identity, grant revocation, advertised revocation endpoint and cleanup"
|
|
222
|
+
puts "PASS: installed-gem #{jwt ? 'JWT' : 'opaque'} signed CIBA requests, validated request context, resource/claims/RAR migrations, consent, signed ID Token hint requiring new approval, nonce, at_hash, audience, UserInfo isolation and replay"
|
|
223
|
+
ensure
|
|
224
|
+
database.disconnect
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generator": "jose via pinned full-op/package-lock.json",
|
|
3
|
+
"now": 1800000000,
|
|
4
|
+
"vectors": [
|
|
5
|
+
{
|
|
6
|
+
"alg": "Ed25519",
|
|
7
|
+
"jwk": {
|
|
8
|
+
"crv": "Ed25519",
|
|
9
|
+
"x": "muo9PzZ14dRvG72gfJlpmPVBDa3-90gKRgVYhgRl2HU",
|
|
10
|
+
"kty": "OKP",
|
|
11
|
+
"kid": "fixture-Ed25519",
|
|
12
|
+
"alg": "Ed25519",
|
|
13
|
+
"use": "sig"
|
|
14
|
+
},
|
|
15
|
+
"token": "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiZml4dHVyZS1FZDI1NTE5In0.eyJpc3MiOiJzdXBwb3J0IiwiYXVkIjoiaHR0cHM6Ly9vcC5leGFtcGxlLnRlc3QiLCJpYXQiOjE4MDAwMDAwMDAsIm5iZiI6MTc5OTk5OTk5OSwiZXhwIjoxODAwMDAwMzAwLCJqdGkiOiJmaXh0dXJlLUVkMjU1MTkiLCJzY29wZSI6Im9wZW5pZCIsImxvZ2luX2hpbnQiOiJjdXN0b21lckBleGFtcGxlLnRlc3QifQ.YO1q5E2Oa965SeFq3ei9IEk43V1GD8uMClCcU3-JuTWWmcoGjuN3uLj_80Db4diVgZrnWrKkjRV1xUnZrJZQBg"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"alg": "EdDSA",
|
|
19
|
+
"jwk": {
|
|
20
|
+
"crv": "Ed25519",
|
|
21
|
+
"x": "ItVK15AUJk_WeBOJeO8tFq0rorWHKtkaaDneSS1MCss",
|
|
22
|
+
"kty": "OKP",
|
|
23
|
+
"kid": "fixture-EdDSA",
|
|
24
|
+
"alg": "EdDSA",
|
|
25
|
+
"use": "sig"
|
|
26
|
+
},
|
|
27
|
+
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImZpeHR1cmUtRWREU0EifQ.eyJpc3MiOiJzdXBwb3J0IiwiYXVkIjoiaHR0cHM6Ly9vcC5leGFtcGxlLnRlc3QiLCJpYXQiOjE4MDAwMDAwMDAsIm5iZiI6MTc5OTk5OTk5OSwiZXhwIjoxODAwMDAwMzAwLCJqdGkiOiJmaXh0dXJlLUVkRFNBIiwic2NvcGUiOiJvcGVuaWQiLCJsb2dpbl9oaW50IjoiY3VzdG9tZXJAZXhhbXBsZS50ZXN0In0.NCHT91KivoQhr4x1czeOH_FmtY2t-4cPwX8RDqXfdGmKkzGGKFU76mMEaxZPT6_Uyn8LpTPU59bC3QfRk6RWBg"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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) { add_column :id_token_signed_response_alg, String }
|
|
12
|
+
database[:oauth_applications].update(scopes: "openid offline_access", grant_types: "#{Rodauth::CibaSupport::GRANT_TYPE} refresh_token")
|
|
13
|
+
op_key = OpenSSL::PKey::RSA.generate(2048)
|
|
14
|
+
op_secret = "o" * 64
|
|
15
|
+
app = CibaDemo.build(database)
|
|
16
|
+
app.plugin(:rodauth) do
|
|
17
|
+
base_url "https://op.example.test"
|
|
18
|
+
authorization_server_url "https://op.example.test"
|
|
19
|
+
ciba_require_tls true
|
|
20
|
+
oauth_jwt_access_tokens jwt
|
|
21
|
+
oauth_jwt_keys("RS256" => op_key, "HS256" => op_secret, "HS384" => op_secret, "HS512" => op_secret)
|
|
22
|
+
oauth_jwt_public_keys("RS256" => op_key.public_key)
|
|
23
|
+
oauth_applications_client_secret_hash_column(hashed ? :client_secret : nil)
|
|
24
|
+
ciba_refresh_tokens_enabled true
|
|
25
|
+
oauth_application_scopes %w[openid offline_access]
|
|
26
|
+
ciba_id_token_hint_enabled true
|
|
27
|
+
resolve_ciba_id_token_subject { |subject, oauth_application:| db[:accounts].where(id: subject.to_i).get(:id) }
|
|
28
|
+
end
|
|
29
|
+
http = Rack::MockRequest.new(app)
|
|
30
|
+
issuer = "https://op.example.test"
|
|
31
|
+
metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
|
|
32
|
+
authenticate = ->(secret) { {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64("demo-client:#{secret}")}"} }
|
|
33
|
+
%w[HS256 HS384 HS512].each do |algorithm|
|
|
34
|
+
secret = "c" * 64
|
|
35
|
+
store = ->(value) { hashed ? BCrypt::Password.create(value, cost: 4).to_s : value }
|
|
36
|
+
database[:oauth_applications].update(client_secret: store.call(secret), id_token_signed_response_alg: algorithm)
|
|
37
|
+
post = lambda do |endpoint, params|
|
|
38
|
+
response = http.post(endpoint, authenticate.call(secret).merge(params: params))
|
|
39
|
+
raise response.body unless response.status == 200
|
|
40
|
+
JSON.parse(response.body)
|
|
41
|
+
end
|
|
42
|
+
verify = lambda do |tokens|
|
|
43
|
+
claims, = JWT.decode(tokens.fetch("id_token"), secret, true, algorithms: [algorithm],
|
|
44
|
+
verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
|
|
45
|
+
raise "wrong subject" unless claims.fetch("sub") == "1"
|
|
46
|
+
begin
|
|
47
|
+
JWT.decode(tokens.fetch("id_token"), op_secret, true, algorithms: [algorithm])
|
|
48
|
+
raise "OP key unexpectedly verifies ID Token"
|
|
49
|
+
rescue JWT::VerificationError
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
started = post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid offline_access", login_hint: "customer@example.test")
|
|
53
|
+
row = database[:ciba_requests].order(:id).last
|
|
54
|
+
app.new(Rack::MockRequest.env_for(issuer)).rodauth.approve_ciba_request(row[:id], account_id: row[:account_id])
|
|
55
|
+
initial = post.call(metadata.fetch("token_endpoint"), grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id"))
|
|
56
|
+
verify.call(initial)
|
|
57
|
+
verify.call(post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: initial.fetch("refresh_token")))
|
|
58
|
+
post.call(metadata.fetch("backchannel_authentication_endpoint"), scope: "openid", id_token_hint: initial.fetch("id_token"))
|
|
59
|
+
previous_secret = secret
|
|
60
|
+
secret = "r" * 64
|
|
61
|
+
database[:oauth_applications].update(client_secret: store.call(secret))
|
|
62
|
+
unauthorized = http.post(metadata.fetch("token_endpoint"), authenticate.call(previous_secret).merge(params: {grant_type: "refresh_token", refresh_token: initial.fetch("refresh_token")}))
|
|
63
|
+
raise "old secret authenticated" unless unauthorized.status == 401
|
|
64
|
+
old_hint = http.post(metadata.fetch("backchannel_authentication_endpoint"), authenticate.call(secret).merge(params: {scope: "openid", id_token_hint: initial.fetch("id_token")}))
|
|
65
|
+
raise "old HMAC hint accepted" unless old_hint.status == 400 && JSON.parse(old_hint.body)["error"] == "invalid_request"
|
|
66
|
+
verify.call(post.call(metadata.fetch("token_endpoint"), grant_type: "refresh_token", refresh_token: initial.fetch("refresh_token")))
|
|
67
|
+
puts "PASS: installed #{algorithm}, #{hashed ? 'hashed' : 'plaintext'} secret, #{jwt ? 'JWT' : 'opaque'} access token; issuance/refresh/hint and client-secret rotation"
|
|
68
|
+
end
|
|
69
|
+
ensure
|
|
70
|
+
database.disconnect
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Copy into your app's Sequel migrations after accounts/oauth_applications exist.
|
|
3
|
+
require "rodauth/ciba/schema"
|
|
4
|
+
Sequel.migration do
|
|
5
|
+
up do
|
|
6
|
+
Rodauth::CibaSupport::Schema.add_client_metadata(self)
|
|
7
|
+
Rodauth::CibaSupport::Schema.create_grants(self)
|
|
8
|
+
Rodauth::CibaSupport::Schema.add_grant_reference(self)
|
|
9
|
+
Rodauth::CibaSupport::Schema.create(self)
|
|
10
|
+
Rodauth::CibaSupport::Schema.create_client_assertions(self)
|
|
11
|
+
end
|
|
12
|
+
down do
|
|
13
|
+
drop_table(:ciba_requests, :ciba_client_assertions)
|
|
14
|
+
alter_table(:oauth_grants) { drop_foreign_key :ciba_grant_id }
|
|
15
|
+
drop_table(:ciba_grants)
|
|
16
|
+
alter_table(:oauth_applications) { drop_column :backchannel_token_delivery_mode }
|
|
17
|
+
end
|
|
18
|
+
end
|