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,162 @@
1
+ # Optional signed CIBA requests
2
+
3
+ Run the explicit client-metadata migration once:
4
+
5
+ ```ruby
6
+ Rodauth::CibaSupport::Schema.add_signed_requests(db)
7
+ ```
8
+
9
+ It adds nullable `backchannel_authentication_request_signing_alg` to
10
+ `oauth_applications` without modifying pending requests or existing grants.
11
+ Custom schemas may supply `table:` and `column:`; configure the matching
12
+ `oauth_applications_backchannel_authentication_request_signing_alg_column` in
13
+ Rodauth. Client verification keys use upstream `jwks` or `jwks_uri` metadata;
14
+ the migration does not add those upstream columns.
15
+
16
+ Enable explicitly, then register each signing client's algorithm and public keys:
17
+
18
+ ```ruby
19
+ ciba_request_signing_algorithms ["RS256"]
20
+ ```
21
+
22
+ The default is an empty list. Discovery publishes
23
+ `backchannel_authentication_request_signing_alg_values_supported` only when
24
+ enabled. Asymmetric RS/PS/ES algorithms and Ed25519/EdDSA (Ed25519 curve) may be configured. RS256 has actual
25
+ request-issuance and installed-gem tests. All nine configured RS/PS/ES variants
26
+ have direct acceptance/wrong-key tests in both gem and reference OP; installed
27
+ issuance coverage for the other variants remains absent. Opt-in DCR composition
28
+ is tested separately. Ed25519/EdDSA now use CIBA-local OpenSSL verification with strict OKP/Ed25519 key import; registration, Discovery, wrong-key rejection and approved issuance are tested. Other OAuth routes and
29
+ their JAR/client-authentication policy are unchanged.
30
+
31
+ A client with a registered signing algorithm must send `request`. The gem also
32
+ rejects signed requests from clients without a registered algorithm. Algorithm
33
+ selection is pinned to that registration and the enabled list; header values
34
+ cannot choose another algorithm. Missing/invalid signed requests return
35
+ `invalid_request`; a client registered for a disabled algorithm is ineligible
36
+ (`unauthorized_client`), including for collection of pending results. Keep the
37
+ algorithm enabled until those results have been collected or expired.
38
+
39
+ The client authenticates independently using its registered method. The request
40
+ JWT cannot substitute for a client assertion, and does not enter the client
41
+ assertion replay ledger. Verification keys come only from the authenticated
42
+ client's registered JWKS. The verifier filters key type, kid, optional alg/use
43
+ and key_ops; unknown keys, encrypted objects, none/HS algorithms and unsupported
44
+ critical headers are rejected. JWT header key URLs are never used. Registered
45
+ remote JWKS lookup/cache behavior is delegated to upstream. Loopback HTTP tests
46
+ use a narrowly allowed test receiver; actual fetches now pass through the
47
+ [CIBA outbound HTTP boundary](outbound-http.md). Default private/loopback access
48
+ is rejected without sending a receiver request. Existing cache semantics remain.
49
+ The tests
50
+ exercise actual Net::HTTP fetching, no-cache and max-age caching, key rotation,
51
+ malformed/empty responses, HTTP 503 and connection refusal. Unknown kid does not
52
+ force a cached JWKS refresh. Publish new keys before using them, retaining old
53
+ keys during the cache overlap; removing a key does not revoke cached copies.
54
+ After explicit upstream cache invalidation, tests verify the new key is accepted
55
+ and the removed key rejected. This does not simulate natural TTL passage, TLS,
56
+ production DNS, redirects or multi-process cache invalidation.
57
+
58
+ During this signature-key lookup only, upstream HTTP authentication halts and
59
+ network failures become `invalid_request`, without saving a pending request.
60
+ An actual wrong client credential still returns 401. Before this adaptation,
61
+ upstream HTTP 503 misleadingly returned 401 and connection refusal returned 500.
62
+ The normalized error is a gem policy; node source can propagate
63
+ `invalid_client_metadata` from its remote-key refresh. It is not a claim that
64
+ all remote-key error responses are identical across the two libraries.
65
+
66
+ Signature verification precedes account resolution and persistence. Require
67
+ client issuer, OP audience membership, finite numeric exp/iat/nbf and string jti. Fractional NumericDate values are accepted. Time checks use
68
+ `oauth_jwt_iat_leeway`; exp must remain valid and nbf must not be in the future
69
+ beyond that tolerance. Like the reference, a future iat alone does not reject
70
+ a signed request with valid exp/nbf. JSON duplicate members,
71
+ malformed encodings, nested request/request_uri and payloads beyond the existing
72
+ HTTP body limit are rejected. Like the reference, jti must be present and a
73
+ string, but the OP does not require it to be nonempty or implement request-JWT
74
+ replay consumption. Clients remain responsible for unique identifiers.
75
+
76
+ After verification, only inner CIBA parameters are passed to the normal
77
+ validator. Outer authentication parameters do not fill gaps or override signed
78
+ values, matching the reference's treatment of nonconforming outer input.
79
+ requested_expiry accepts an integer or string; claims objects, authorization
80
+ details arrays and resource arrays enter their existing optional-feature
81
+ validators. Exactly one enabled hint is still required. Raw request JWTs and
82
+ authentication parameters do not enter saved requests, device snapshots or
83
+ observation events. Applications must redact original HTTP logs separately.
84
+
85
+ Like the reference default, submitting the same still-valid signed JWT again
86
+ creates a **new pending authentication request**, not an idempotent lookup or
87
+ reuse of an approval. Normal client/request rate controls remain necessary.
88
+ Each accepted request needs its own new customer approval and retains the
89
+ existing one-time token collection and replay-revocation behavior.
90
+
91
+ See [reference evidence](research/signed-request-reference-contract.md),
92
+ [local tests](validation/signed-request-tests.txt) and the installed-gem
93
+ `examples/extensions_smoke.rb`. Production remote-JWKS scenarios, all feature-enable
94
+ orders, Edwards HTTPS key rotation, installed issuance with all algorithms and broader dynamic-registration combinations remain incomplete;
95
+ passing these cases is not full node parity or CIBA certification.
96
+
97
+ ### Edwards request signatures
98
+
99
+ Configure `ciba_request_signing_algorithms %w[Ed25519 EdDSA]` and register the exact
100
+ algorithm used in the JWT header. Both names use an OKP JWK with crv=Ed25519 and
101
+ an unpadded base64url 32-byte public x value. X25519 and Ed448 are not accepted.
102
+ The key's alg, use, key_ops and kid restrictions still apply. Private material
103
+ is rejected by dynamic registration. ID Token signing, client assertions and
104
+ non-CIBA JWT processing are not enabled for these algorithms by this setting.
105
+
106
+ Verification uses Ruby OpenSSL PKey verification without prehashing and imports
107
+ the public key as [RFC8410 SubjectPublicKeyInfo](https://www.rfc-editor.org/rfc/rfc8410.html#section-4),
108
+ using [OpenSSL PKey verification](https://docs.ruby-lang.org/en/master/OpenSSL/PKey/PKey.html#method-i-verify). It adds no JWT global algorithm
109
+ registration or native dependency. The existing bounded parser, required claims,
110
+ issuer/audience/time validation and per-request approval boundary remain shared
111
+ with RSA/EC requests.
112
+
113
+ [Three-DB regression](validation/edwards-request-matrix.txt): each11 tests /
114
+ 234 assertions pass. [Reference11 algorithms](validation/node-signed-edwards.txt)
115
+ includes acceptance and wrong-key rejection. Node/jose-generated fixed vectors now pass with inline and actual HTTP remote
116
+ JWKS on all three DBs; modified payloads fail without creating requests.
117
+ [Vector/remote regression](validation/edwards-vectors-matrix.txt): each4 tests /
118
+ 108 assertions. The built and isolated installed gem also accepts both fixed
119
+ vectors with opaque/JWT access-token settings and verifies the returned ID Token
120
+ ([installed artifact](validation/edwards-installed.txt)). The installed Edwards
121
+ fixture uses inline keys; remote Edwards HTTPS rotation is not covered by it.
122
+
123
+ The public fixtures are `examples/fixtures/edwards-requests.json`, generated by
124
+ `test/reference/full-op/edwards-vectors.mjs` with the pinned Node/jose dependency.
125
+ They contain no private keys. Their fixed clock is injected only by tests and
126
+ the smoke example; production time validation is unchanged. Regeneration is a
127
+ separate explicit command, so normal test runs verify the saved foreign-generated
128
+ signature bytes rather than signing fresh inputs with the Ruby implementation.
129
+
130
+ ## NumericDate alignment
131
+
132
+ [CIBA Core7.1.1](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html#rfc.section.7.1.1)
133
+ requires exp, iat and nbf on signed requests.
134
+ [RFC7519 section2](https://www.rfc-editor.org/rfc/rfc7519.html#section-2)
135
+ defines NumericDate to allow non-integer values. The previous integer-only
136
+ validation and additional future-iat rejection were extra gem restrictions.
137
+
138
+ Provider9.12.2 over HTTP accepts fractional dates and future iat, while rejecting
139
+ malformed iat, future nbf and expired exp. The gem now matches that boundary;
140
+ mandatory claims, asymmetric signature checks and explicit customer approval
141
+ remain required. This change is scoped to signed CIBA request objects, not
142
+ upstream client assertions or ID Token hints.
143
+
144
+ [Reference execution](validation/node-signed-numericdate.txt),
145
+ [gem before correction](validation/signed-numericdate-red.txt),
146
+ [three-DB regression](validation/signed-numericdate-matrix.txt).
147
+
148
+ ## Request jti versus client assertion replay
149
+
150
+ The reference accepts an empty-string jti on signed authentication requests and
151
+ rejects null/non-string values. The gem now matches that type/presence check.
152
+ This acceptance rule does not advise clients to generate empty identifiers or
153
+ remove their obligation to generate unique request identifiers. Repeating the
154
+ same signed request produces a fresh auth_req_id and approval-pending state,
155
+ including when its first request has already issued tokens. No approval or
156
+ permission is inherited from the repeated JWT.
157
+
158
+ Client authentication assertions are a separate JWT use: their existing nonempty
159
+ jti requirement and atomic replay rejection remain unchanged.
160
+ [Reference](validation/node-signed-jti.txt),
161
+ [before correction](validation/signed-jti-red.txt),
162
+ [three-DB regression](validation/signed-jti-matrix.txt).
data/docs/user-code.md ADDED
@@ -0,0 +1,73 @@
1
+ # Optional user codes
2
+
3
+ User-code registration, storage, comparison, rate limiting and recovery belong
4
+ to the application. The gem implements the protocol boundary and does not store
5
+ the supplied code. It is separate from customer authentication and approval.
6
+
7
+ The application must use a code distinct from the user's OP login password.
8
+ It should provide a way for the user to change that code. Code enrollment and
9
+ change UI remain application responsibilities; a callback accepting a fixture
10
+ string does not demonstrate them. The CIBA client must ask for the code for each
11
+ flow and must not store it. The gem's removal of the code from its own records
12
+ does not prove client compliance. These responsibilities follow
13
+ [CIBA Core §7.1.2](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html#user_code).
14
+
15
+ Run `Rodauth::CibaSupport::Schema.add_user_code(db)` once to add nullable
16
+ `backchannel_user_code_parameter` to `oauth_applications`. Existing requests and
17
+ grants are unchanged. Custom schemas can specify `table:`/`column:` and configure
18
+ `oauth_applications_backchannel_user_code_parameter_column` to match.
19
+
20
+ Enable `ciba_user_code_enabled true` and implement:
21
+
22
+ ```ruby
23
+ verify_ciba_user_code do |code, account_id:, oauth_application:|
24
+ # Application policy decides whether this client/user requires a code.
25
+ # Raise Rodauth::CibaSupport::ProtocolError, "missing_user_code" if required but absent.
26
+ # Return true only after accepting the code or explicitly permitting omission.
27
+ end
28
+ ```
29
+
30
+ Enabling without an implementation is a configuration error. The callback runs
31
+ after hint resolution and account eligibility, before DB insertion, transaction
32
+ hooks and device dispatch. It receives nil for omission; otherwise a nonempty
33
+ UTF-8 string of at most 1024 bytes. Invalid input shapes return invalid_request.
34
+ Only literal true accepts the request: false/nil returns invalid_user_code.
35
+ Applications may raise ProtocolError with missing_user_code or invalid_user_code;
36
+ unexpected exceptions return server_error and do not create a request.
37
+
38
+ Client metadata is available to the callback, but the gem does not decide the
39
+ application's per-user exceptions. A client declaring user-code support is
40
+ ineligible while the feature is disabled. With the feature enabled, the callback
41
+ runs for every eligible client, even when the code is omitted or client metadata
42
+ is false/nil; the application must explicitly accept that case. Discovery's
43
+ backchannel_user_code_parameter_supported reflects the feature flag.
44
+
45
+ The raw value is removed before request persistence, hooks, device snapshots
46
+ and observation events. Original HTTP bodies and application logging remain
47
+ the application's responsibility. With signed requests, only the inner code
48
+ is used; an outer code cannot fill in or override it. A successful code check
49
+ still creates pending work requiring new customer approval. Poll does not rerun
50
+ the code check or inherit approval from it.
51
+
52
+ ## Reference comparison and evidence
53
+
54
+ node-oidc-provider 9.12.2 resolves the account and then calls
55
+ `features.ciba.verifyUserCode(ctx, account, code)`. Its default callback throws
56
+ until replaced; application policy determines omission/validation errors. The
57
+ complete HTTP reference now has a client-specific test policy: missing/wrong
58
+ code returns the corresponding error before device dispatch; correct code
59
+ remains pending until approval and produces a verified ID Token afterwards.
60
+ [Harness](../test/reference/full-op/lifecycle.mjs),
61
+ [reference log](validation/node-full-op-lts.txt).
62
+
63
+ The gem keeps this responsibility boundary but requires explicit true instead
64
+ of node's successful callback completion, bounds input size, and removes the
65
+ code from persisted snapshots. Those are deliberate API/storage differences.
66
+ The test/demo policy uses a fixed fixture string; it is not a production code
67
+ verifier. No production password hashing, account recovery, throttling or
68
+ brute-force defense is provided by these fixtures.
69
+
70
+ [Gem tests](validation/user-code-tests.txt) cover omission, rejection, unknown
71
+ accounts, no persistence/dispatch on failure, callback exceptions, metadata,
72
+ signed-request isolation and new approval. The installed-gem extension example
73
+ combines codes with signed requests, hints, claims, resources and RAR.
@@ -0,0 +1,10 @@
1
+ mise WARN failed to write cache file: /Users/hiroshi/.cache/mise/lua/5.5.0/exec_env_3b86e418117b5590-7f2de.msgpack.z Operation not permitted (os error 1)
2
+ Run options: --seed 24266
3
+
4
+ # Running:
5
+
6
+ ........................................................................................................
7
+
8
+ Finished in 1.224815s, 84.9108 runs/s, 618.0525 assertions/s.
9
+
10
+ 104 runs, 757 assertions, 0 failures, 0 errors, 0 skips
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+ require "json"
4
+ require "jwt"
5
+
6
+ issuer = "http://127.0.0.1:9292"
7
+ metadata = JSON.parse(Net::HTTP.get(URI("#{issuer}/.well-known/openid-configuration")))
8
+ def post_form(url, params)
9
+ uri = URI(url)
10
+ request = Net::HTTP::Post.new(uri)
11
+ request.basic_auth("demo-client", "demo-secret")
12
+ request.set_form_data(params)
13
+ response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) }
14
+ [response.code.to_i, JSON.parse(response.body)]
15
+ end
16
+ status, request = post_form(metadata.fetch("backchannel_authentication_endpoint"),
17
+ scope: "openid", login_hint: "customer@example.test", binding_message: "LOCAL-DEMO")
18
+ abort request.inspect unless status == 200
19
+ puts "Open #{issuer}/ and approve LOCAL-DEMO as the simulated customer."
20
+ interval = request.fetch("interval")
21
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + request.fetch("expires_in")
22
+ loop do
23
+ sleep interval
24
+ abort "Request expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
25
+ status, result = post_form(metadata.fetch("token_endpoint"),
26
+ grant_type: "urn:openid:params:grant-type:ciba", auth_req_id: request.fetch("auth_req_id"))
27
+ if status == 200
28
+ keys = JSON.parse(Net::HTTP.get(URI(metadata.fetch("jwks_uri"))))
29
+ claims, = JWT.decode(result.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
30
+ verify_iss: true, iss: issuer, verify_aud: true, aud: "demo-client")
31
+ puts "Verified ID Token for customer sub=#{claims.fetch('sub')}; received access token."
32
+ break
33
+ end
34
+ case result["error"]
35
+ when "authorization_pending" then next
36
+ when "slow_down" then interval += 5
37
+ else abort result.inspect
38
+ end
39
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ require "roda"
3
+ require "sequel"
4
+ require "openssl"
5
+ require "cgi"
6
+ require "rodauth/ciba"
7
+
8
+ # Local demonstration only: the customer is a simulated, fixed identity.
9
+ module CibaDemo
10
+ def self.create_schema(db)
11
+ db.create_table(:accounts) do
12
+ primary_key :id
13
+ String :email
14
+ Integer :status_id, default: 2
15
+ end
16
+ db.create_table(:oauth_applications) do
17
+ primary_key :id
18
+ String :client_id
19
+ String :client_secret
20
+ String :scopes
21
+ String :grant_types
22
+ String :token_endpoint_auth_method
23
+ String :subject_type
24
+ end
25
+ Rodauth::CibaSupport::Schema.add_client_metadata(db)
26
+ db.create_table(:oauth_grants) do
27
+ primary_key :id
28
+ foreign_key :account_id, :accounts
29
+ foreign_key :oauth_application_id, :oauth_applications
30
+ %i[type code scopes token refresh_token redirect_uri nonce acr claims].each { |name| String name }
31
+ DateTime :expires_in
32
+ DateTime :revoked_at
33
+ end
34
+ Rodauth::CibaSupport::Schema.create_grants(db)
35
+ Rodauth::CibaSupport::Schema.add_grant_reference(db)
36
+ Rodauth::CibaSupport::Schema.create(db)
37
+ db[:accounts].insert(email: "customer@example.test")
38
+ db[:oauth_applications].insert(client_id: "demo-client", client_secret: "demo-secret",
39
+ scopes: "openid", grant_types: Rodauth::CibaSupport::GRANT_TYPE, subject_type: "public",
40
+ token_endpoint_auth_method: "client_secret_basic", backchannel_token_delivery_mode: "poll")
41
+ end
42
+
43
+ def self.build(db)
44
+ key = OpenSSL::PKey::RSA.generate(2048)
45
+ Class.new(Roda) do
46
+ plugin :sessions, secret: SecureRandom.hex(64)
47
+ plugin :json_parser, content_type_regexp: %r{\Aapplication/json\b}i
48
+ plugin :rodauth, json: :only do
49
+ enable :ciba
50
+ db db
51
+ ciba_request_lifetime 300
52
+ ciba_max_request_lifetime 600
53
+ ciba_require_tls false # Local HTTP demo only. Production defaults to true.
54
+ base_url "http://127.0.0.1:9292"
55
+ authorization_server_url "http://127.0.0.1:9292"
56
+ oauth_jwt_keys("RS256" => key)
57
+ oauth_jwt_public_keys("RS256" => key.public_key)
58
+ oauth_jwt_access_tokens false
59
+ oauth_applications_client_secret_hash_column nil # Test-only plaintext fixture.
60
+ hmac_secret SecureRandom.hex(64)
61
+ # This demo displays pending requests on its local approval page.
62
+ trigger_ciba_authentication_device { |_request| }
63
+ resolve_ciba_login_hint { |hint, oauth_application:| db[:accounts].where(email: hint).get(:id) }
64
+ end
65
+ route do |r|
66
+ rodauth.load_openid_configuration_route
67
+ r.rodauth
68
+ response["content-type"] = "text/html; charset=utf-8"
69
+ session["csrf"] ||= SecureRandom.hex(32)
70
+ r.get do
71
+ rows = rodauth.pending_ciba_requests
72
+ forms = rows.map do |row|
73
+ message = CGI.escapeHTML(row[:binding_message].to_s)
74
+ <<~HTML
75
+ <form method="post" action="/decision">
76
+ <input type="hidden" name="csrf" value="#{session['csrf']}">
77
+ <input type="hidden" name="id" value="#{row[:id]}">
78
+ <p>Request #{row[:id]} — binding message: #{message}</p>
79
+ <button name="decision" value="approve">Approve</button>
80
+ <button name="decision" value="deny">Deny</button>
81
+ </form>
82
+ HTML
83
+ end.join
84
+ "<!doctype html><html><meta charset=utf-8><title>CIBA local demo</title><h1>CIBA local demo</h1>" \
85
+ "<p>Simulated customer: customer@example.test. No real identity verification. Local testing only.</p>#{forms}</html>"
86
+ end
87
+ r.post "decision" do
88
+ csrf = r.params["csrf"]
89
+ unless csrf.is_a?(String) && Rack::Utils.secure_compare(session["csrf"], csrf)
90
+ response.status = 403
91
+ next "Invalid CSRF token"
92
+ end
93
+ customer = db[:accounts].where(email: "customer@example.test").get(:id)
94
+ id = Integer(r.params["id"], 10)
95
+ case r.params["decision"]
96
+ when "approve"
97
+ db.transaction(mode: :immediate) do
98
+ request = rodauth.ciba_request(id)
99
+ raise Rodauth::CibaSupport::IdentityMismatch unless request[:account_id] == customer
100
+ # Reuse the saved consent when the browser repeats the decision.
101
+ grant = request[:grant_id] || rodauth.create_ciba_grant(account_id: customer,
102
+ oauth_application_id: request[:oauth_application_id], scopes: request[:scopes])
103
+ rodauth.backchannel_result(id, grant)
104
+ end
105
+ when "deny"
106
+ request = rodauth.ciba_request(id)
107
+ raise Rodauth::CibaSupport::IdentityMismatch unless request[:account_id] == customer
108
+ rodauth.backchannel_result(id, Rodauth::CibaSupport::ProtocolError.new("access_denied"))
109
+ else
110
+ response.status = 400
111
+ next "Unknown decision"
112
+ end
113
+ r.redirect "/"
114
+ rescue ArgumentError, Rodauth::CibaSupport::CompletionError
115
+ response.status = 409
116
+ "Request unavailable"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+ # Local synthetic credentials only. Exercises the installed gem, not test helpers.
3
+ require "rack/mock"
4
+ require "json"
5
+ require "base64"
6
+ require "digest"
7
+ require_relative "demo_app"
8
+
9
+ [false, true].each do |jwt|
10
+ database = Sequel.sqlite
11
+ begin
12
+ CibaDemo.create_schema(database)
13
+ Rodauth::CibaSupport::Schema.create_client_assertions(database)
14
+ Rodauth::CibaSupport::Schema.create_refresh_tokens(database)
15
+ Rodauth::CibaSupport::Schema.add_registration_token_digest(database)
16
+ database.alter_table(:oauth_grants) { add_column :dpop_jkt, String }
17
+ database.alter_table(:oauth_applications) do
18
+ add_column :dpop_bound_access_tokens, TrueClass
19
+ add_column :name, String
20
+ add_column :redirect_uri, String, text: true
21
+ add_column :response_types, String
22
+ add_column :registration_access_token, String
23
+ end
24
+ application = CibaDemo.build(database)
25
+ nonce_secret = SecureRandom.random_bytes(32)
26
+ application.plugin(:rodauth) do
27
+ enable :oauth_dpop, :oauth_dynamic_client_registration, :oauth_token_introspection
28
+ ciba_dynamic_client_registration_enabled true
29
+ ciba_refresh_tokens_enabled true
30
+ oauth_application_scopes %w[openid offline_access]
31
+ oauth_jwt_access_tokens jwt
32
+ ciba_dpop_nonce_secret nonce_secret
33
+ oauth_dpop_use_nonce true
34
+ before_register do
35
+ authorization_required unless request.env["HTTP_AUTHORIZATION"] == "Bearer local-dpop-registration"
36
+ end
37
+ end
38
+ application.route do |r|
39
+ rodauth.load_openid_configuration_route
40
+ rodauth.load_oauth_server_metadata_route
41
+ r.rodauth
42
+ end
43
+ http = Rack::MockRequest.new(application)
44
+ issuer = "http://127.0.0.1:9292"
45
+ metadata = JSON.parse(http.get("#{issuer}/.well-known/openid-configuration").body)
46
+ oauth_metadata = JSON.parse(http.get("#{issuer}/.well-known/oauth-authorization-server").body)
47
+ %w[introspection_endpoint introspection_endpoint_auth_methods_supported dpop_signing_alg_values_supported].each do |name|
48
+ raise "Discovery mismatch: #{name}" unless metadata.fetch(name) == oauth_metadata.fetch(name)
49
+ end
50
+ check = lambda do |response, status, error = nil|
51
+ raise "unexpected status #{response.status}: #{response.body}" unless response.status == status
52
+ body = JSON.parse(response.body)
53
+ raise "unexpected error: #{body}" if error && body["error"] != error
54
+ body
55
+ end
56
+ client = check.call(http.post(metadata.fetch("registration_endpoint"),
57
+ "HTTP_AUTHORIZATION" => "Bearer local-dpop-registration", "CONTENT_TYPE" => "application/json",
58
+ input: JSON.generate(grant_types: [Rodauth::CibaSupport::GRANT_TYPE, "refresh_token"],
59
+ response_types: [], scope: "openid offline_access", token_endpoint_auth_method: "client_secret_basic",
60
+ backchannel_token_delivery_mode: "poll", dpop_bound_access_tokens: true)), 201)
61
+ raise "DPoP policy missing" unless client.fetch("dpop_bound_access_tokens")
62
+ credentials = {"HTTP_AUTHORIZATION" => "Basic #{Base64.strict_encode64("#{client.fetch('client_id')}:#{client.fetch('client_secret')}")}"}
63
+ instance = application.new(Rack::MockRequest.env_for(issuer)).rodauth
64
+ approve = lambda do
65
+ started = check.call(http.post(metadata.fetch("backchannel_authentication_endpoint"),
66
+ credentials.merge(params: {scope: "openid offline_access", login_hint: "customer@example.test"})), 200)
67
+ request = instance.ciba_request(database[:ciba_requests].order(:id).last.fetch(:id))
68
+ grant = instance.create_ciba_grant(account_id: request[:account_id],
69
+ oauth_application_id: request[:oauth_application_id], scopes: request[:scopes])
70
+ instance.backchannel_result(request[:id], grant)
71
+ {grant_type: Rodauth::CibaSupport::GRANT_TYPE, auth_req_id: started.fetch("auth_req_id")}
72
+ end
73
+ key = OpenSSL::PKey::EC.generate("prime256v1")
74
+ proof = lambda do |target, method, signing_key, claims = {}|
75
+ JWT.encode({jti: SecureRandom.uuid, iat: Time.now.to_i, htm: method, htu: target}.merge(claims),
76
+ signing_key, "ES256", typ: "dpop+jwt", jwk: JWT::JWK.new(signing_key).export)
77
+ end
78
+ poll = lambda do |params, signed = nil|
79
+ headers = credentials.merge(params: params)
80
+ headers["HTTP_DPOP"] = signed if signed
81
+ http.post(metadata.fetch("token_endpoint"), headers)
82
+ end
83
+ params = approve.call
84
+ check.call(poll.call(params), 400, "invalid_grant")
85
+ challenge = poll.call(params, proof.call(metadata.fetch("token_endpoint"), "POST", key))
86
+ check.call(challenge, 400, "use_dpop_nonce")
87
+ nonce = challenge["DPoP-Nonce"]
88
+ raise "missing nonce" unless nonce.is_a?(String) && nonce.bytesize == 43
89
+ signed = proof.call(metadata.fetch("token_endpoint"), "POST", key, nonce: nonce, iat: 1)
90
+ tokens = check.call(poll.call(params, signed), 200)
91
+ raise "wrong token type" unless tokens.fetch("token_type") == "DPoP"
92
+ keys = JSON.parse(http.get(metadata.fetch("jwks_uri")).body)
93
+ identity, = JWT.decode(tokens.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
94
+ verify_iss: true, iss: issuer, verify_aud: true, aud: client.fetch("client_id"))
95
+ raise "ID Token inherited DPoP binding" if identity.key?("cnf")
96
+ raise "wrong subject" unless identity.fetch("sub") == database[:accounts].get(:id).to_s
97
+ verify_binding = lambda do |issued, signing_key|
98
+ expected = JWT::JWK::Thumbprint.new(JWT::JWK.new(signing_key)).generate
99
+ if jwt
100
+ payload, = JWT.decode(issued.fetch("access_token"), nil, true, jwks: keys,
101
+ algorithms: ["RS256"], verify_iss: true, iss: issuer)
102
+ raise "JWT binding missing" unless payload.fetch("cnf").fetch("jkt") == expected
103
+ end
104
+ raise "stored binding missing" unless database[:oauth_grants].order(:id).last.fetch(:dpop_jkt) == expected
105
+ end
106
+ verify_binding.call(tokens, key)
107
+ unless jwt
108
+ introspected = check.call(http.post(metadata.fetch("introspection_endpoint"),
109
+ credentials.merge(params: {token: tokens.fetch("access_token")})), 200)
110
+ raise "bound token inactive" unless introspected.fetch("active")
111
+ raise "introspection lost type" unless introspected.fetch("token_type") == "DPoP"
112
+ expected = JWT::JWK::Thumbprint.new(JWT::JWK.new(key)).generate
113
+ raise "introspection lost binding" unless introspected.fetch("cnf").fetch("jkt") == expected
114
+ end
115
+ raise "live proof removed" unless instance.cleanup_ciba_client_assertions == 0
116
+ second = approve.call
117
+ check.call(poll.call(second, signed), 400, "invalid_grant")
118
+ raise "replay consumed approval" unless database[:ciba_requests].order(:id).last.fetch(:status) == "approved"
119
+ userinfo = metadata.fetch("userinfo_endpoint")
120
+ token = tokens.fetch("access_token")
121
+ check.call(http.get(userinfo, "HTTP_AUTHORIZATION" => "Bearer #{token}"), 401, "invalid_token")
122
+ ath = Base64.urlsafe_encode64(Digest::SHA256.digest(token), padding: false)
123
+ get = ->(signed_proof) { http.get(userinfo, "HTTP_AUTHORIZATION" => "DPoP #{token}", "HTTP_DPOP" => signed_proof) }
124
+ check.call(get.call(proof.call(userinfo, "GET", key, ath: ath)), 401, "use_dpop_nonce")
125
+ check.call(get.call(proof.call(userinfo, "GET", key, ath: "wrong", nonce: nonce)), 401, "invalid_token")
126
+ user_proof = proof.call(userinfo, "GET", key, ath: ath, nonce: nonce, iat: 1)
127
+ info = check.call(get.call(user_proof), 200)
128
+ raise "UserInfo subject mismatch" unless info.fetch("sub") == identity.fetch("sub")
129
+ check.call(get.call(user_proof), 401, "invalid_token")
130
+ replacement_key = OpenSSL::PKey::EC.generate("prime256v1")
131
+ check.call(get.call(proof.call(userinfo, "GET", replacement_key, ath: ath, nonce: nonce)), 401, "invalid_token")
132
+ refresh_params = {grant_type: "refresh_token", refresh_token: tokens.fetch("refresh_token")}
133
+ refresh_proof = proof.call(metadata.fetch("token_endpoint"), "POST", replacement_key, nonce: nonce)
134
+ refreshed = check.call(poll.call(refresh_params, refresh_proof), 200)
135
+ raise "refresh lost token type" unless refreshed.fetch("token_type") == "DPoP"
136
+ verify_binding.call(refreshed, replacement_key)
137
+ renewed_identity, = JWT.decode(refreshed.fetch("id_token"), nil, true, jwks: keys, algorithms: ["RS256"],
138
+ verify_iss: true, iss: issuer, verify_aud: true, aud: client.fetch("client_id"))
139
+ raise "refreshed ID Token inherited DPoP binding" if renewed_identity.key?("cnf")
140
+ refreshed_token = refreshed.fetch("access_token")
141
+ refreshed_ath = Base64.urlsafe_encode64(Digest::SHA256.digest(refreshed_token), padding: false)
142
+ refreshed_info = check.call(http.get(userinfo, "HTTP_AUTHORIZATION" => "DPoP #{refreshed_token}",
143
+ "HTTP_DPOP" => proof.call(userinfo, "GET", replacement_key, ath: refreshed_ath, nonce: nonce)), 200)
144
+ raise "refresh changed subject" unless refreshed_info.fetch("sub") == identity.fetch("sub")
145
+ check.call(poll.call(refresh_params, refresh_proof), 400, "invalid_grant")
146
+ alias_userinfo = URI(userinfo)
147
+ alias_userinfo.port += 1
148
+ alias_userinfo = alias_userinfo.to_s
149
+ check.call(http.get(alias_userinfo, "HTTP_AUTHORIZATION" => "DPoP #{refreshed_token}",
150
+ "HTTP_DPOP" => proof.call(userinfo, "GET", replacement_key, ath: refreshed_ath, nonce: nonce)), 401, "invalid_dpop_proof")
151
+ alias_info = check.call(http.get(alias_userinfo, "HTTP_AUTHORIZATION" => "DPoP #{refreshed_token}",
152
+ "HTTP_DPOP" => proof.call(alias_userinfo, "GET", replacement_key, ath: refreshed_ath, nonce: nonce)), 200)
153
+ raise "alias subject changed" unless alias_info.fetch("sub") == identity.fetch("sub")
154
+ alias_token = URI(metadata.fetch("token_endpoint"))
155
+ alias_token.port += 1
156
+ alias_token = alias_token.to_s
157
+ check.call(http.post(alias_token, credentials.merge(params: second,
158
+ "HTTP_DPOP" => proof.call(metadata.fetch("token_endpoint"), "POST", key, nonce: nonce))), 400, "invalid_dpop_proof")
159
+ alias_tokens = check.call(http.post(alias_token, credentials.merge(params: second,
160
+ "HTTP_DPOP" => proof.call(alias_token, "POST", key, nonce: nonce))), 200)
161
+ JWT.decode(alias_tokens.fetch("id_token"), nil, true, jwks: keys,
162
+ algorithms: ["RS256"], verify_iss: true, iss: issuer,
163
+ verify_aud: true, aud: client.fetch("client_id"))
164
+ puts "PASS: installed-gem #{jwt ? 'JWT' : 'opaque'} DPoP alias port binding with canonical issuer (Rack transport)"
165
+ puts "PASS: installed-gem #{jwt ? 'JWT' : 'opaque'} DPoP DCR, nonce/old-iat issuance, binding, UserInfo, replay and confidential refresh with new key"
166
+ ensure
167
+ database.disconnect
168
+ end
169
+ end