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