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