rodauth-token_exchange 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb462644996fe8687d587e1a0ce2de697ceb99185185b6843cbb34ab7d364afb
4
+ data.tar.gz: b19c7385614bc78939aa7df47f78c1ac0f797771251f6f05affb43f9c4b2c431
5
+ SHA512:
6
+ metadata.gz: 27b60c8694c613ed639aaf74bdd12281a0c223cde3bf2d1b6df7c6f624bbbbcb7612a2d8c1546ba6bd186f710ea2a7af146595ea600a172955b6e074f72255c8
7
+ data.tar.gz: 4f4d90d445658576de85a9b2166c1809e573aee6404cd5829927006aedb1bfd32d281f2156bb34815ce674c15d937277bad47dbe6d7ff9ddb4838a05386e9446
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-05)
4
+
5
+ Initial release.
6
+
7
+ - Add the RFC 8693 token-exchange grant to the rodauth-oauth token endpoint, accepting and issuing access tokens.
8
+ - Issue independent access tokens; source grants are neither replaced nor revoked.
9
+ - Bound scopes to the intersection of the subject token, actor token and client, and lifetime to an absolute deadline no later than either input's expiration, shared by the database and JWT `exp`.
10
+ - Support delegation: actor tokens, nested `act` history up to 16 entries, `act.iss` for actors with an issuer, and actors named by the subject's `may_act` claim.
11
+ - Support repeated `resource` and `audience` targets, inherited from inputs and never broadened.
12
+ - Render `sub` and local `act.sub` per the receiving client's OIDC subject type (public/pairwise).
13
+ - Provide policy hooks for exchange authorization and targets, and a resolver hook for trusted custom input tokens.
14
+ - Reject inputs whose local subject or actor account is no longer available.
15
+ - Require HTTPS and bound form size, parameter count and encoding for token-exchange requests.
16
+ - Preserve subject, actor and target claims in introspection and JWT output.
17
+ - Enforce resource origins and path boundaries for opaque and JWT access tokens when composed with `oauth_resource_indicators`, without changing the upstream gem or other auth classes.
18
+ - Store exchange context as JSON in a configurable grants column; include an example Sequel migration and nginx reference configuration.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hiroshi Kajisha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # rodauth-token_exchange
2
+
3
+ An extension gem for [rodauth-oauth](https://github.com/HoneyryderChuck/rodauth-oauth) that adds the [RFC 8693 OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html) grant to the token endpoint.
4
+
5
+ Requires Ruby 3.0 or newer and rodauth-oauth `~> 1.7.0`.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile and run `bundle install`:
10
+
11
+ ```ruby
12
+ gem "rodauth-token_exchange", "~> 0.1.0"
13
+ ```
14
+
15
+ In your existing Rodauth OAuth server, load the extension and enable it **after** other OAuth features, including `oauth_jwt`, `oauth_token_introspection`, and `oauth_resource_indicators` when used:
16
+
17
+ ```ruby
18
+ require "rodauth/token_exchange"
19
+
20
+ # Inside your existing Rodauth configuration:
21
+ enable :oauth_token_introspection
22
+ enable :oauth_token_exchange
23
+ ```
24
+
25
+ Keep the existing OAuth database, application registration, client authentication, and signing-key configuration. If an application's `grant_types` is restricted, allow `urn:ietf:params:oauth:grant-type:token-exchange` for that application. The server advertises this grant in its supported grant types.
26
+
27
+ Enable `oauth_pkce` and `oauth_device_code_grant` before `oauth_token_exchange`
28
+ when composing these features; unsafe ordering raises `Rodauth::ConfigurationError`.
29
+ Features using `oauth_assertion_base`, including `oauth_jwt_bearer_grant`, cannot
30
+ currently be combined with this extension and are rejected at configuration time
31
+ regardless of ordering. Upstream assertion dispatch treats the token-exchange URN
32
+ as an assertion grant. This limitation does not affect JWT access-token output
33
+ through `oauth_jwt`, which is supported and tested.
34
+
35
+ Add a nullable text column named `token_exchange_context` to the grants table; it stores JSON containing subject, actor, and target context. A Sequel migration is provided in [examples/migrate/001_token_exchange_context.rb](examples/migrate/001_token_exchange_context.rb). The column name is configurable with `oauth_grants_token_exchange_context_column`.
36
+
37
+ The grants table must allow **multiple grants for the same account and client**. Remove any unique index enforcing one grant per account/client (including variants containing scopes), using your application's actual index name. Keep token and refresh-token uniqueness constraints. Exchanges always insert a new grant and bypass access-token reuse; other grant types retain their existing behavior. The example migration does not guess or remove application-specific indexes.
38
+
39
+ ### Compatibility notes
40
+
41
+ Enabling the extension changes behavior beyond token-exchange requests in these ways:
42
+
43
+ - Client authentication changes apply to every endpoint that authenticates a client, including other grants, introspection, and revocation. Basic credentials are form-decoded (see [Exchange an access token](#exchange-an-access-token)), so existing clients whose ID or secret contains `+` or `%` must percent-encode them. Requests combining more than one client-authentication method are rejected.
44
+ - Applications whose `grant_types` column is `NULL` accept every supported grant, as in rodauth-oauth, and can therefore use token exchange immediately. The default policy still limits them to their own tokens, with no actors or targets. Set `grant_types` explicitly to opt applications out.
45
+ - Expiration deadlines are computed in Ruby and stored in the grants table, and expiration checks run in the database. As rodauth-oauth already assumes for introspection and JWT `exp`, Sequel's timezone settings must match the database session timezone.
46
+ - With `oauth_resource_indicators`, this extension replaces rodauth-oauth 1.7's URL prefix checks for the auth class that enables it. This applies to all resource-protected access tokens in that class, including tokens from other grants. Authentication, scope, signature, expiry, and sender-binding checks still run. Other auth classes and the installed upstream gem are unchanged.
47
+
48
+ ## Exchange an access token
49
+
50
+ Send form-encoded parameters to the existing token endpoint:
51
+
52
+ Use HTTPS for the token endpoint and every endpoint receiving tokens. The extension
53
+ rejects token-exchange requests, before client authentication, unless the server's
54
+ `rack.url_scheme` is `https`. Other grants and endpoints keep rodauth-oauth's
55
+ transport behavior. A TLS-terminating proxy must use trusted middleware to set that value;
56
+ untrusted `Forwarded` or `X-Forwarded-Proto` headers alone are not accepted. Do not
57
+ expose the backend directly or send credentials over HTTP before a redirect.
58
+
59
+ ```sh
60
+ curl --user "$CLIENT_ID:$CLIENT_SECRET" \
61
+ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
62
+ --data-urlencode "subject_token=$ACCESS_TOKEN" \
63
+ --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
64
+ --data-urlencode 'scope=read' \
65
+ https://auth.example.com/token
66
+ ```
67
+
68
+ The response contains `access_token`, `issued_token_type`, `token_type`, `expires_in`, and `scope`. The issued token type is `urn:ietf:params:oauth:token-type:access_token`, including when `oauth_jwt` produces a JWT-formatted access token. No refresh token is issued. The source token is neither replaced nor revoked by exchange.
69
+
70
+ `requested_token_type` is optional and accepts only the access-token type above. `actor_token` and `actor_token_type` must be supplied together. Repeated bare `resource` and `audience` parameters are supported. OAuth names are flat: unknown names such as `resource[]` are ignored and never become arrays or overwrite recognized parameters. Empty values are treated as omitted; missing required parameters and duplicate recognized single-value parameters are rejected. Resource values must be absolute URIs without fragments. Exchange and client-authentication parameters belong in the form body, not the query string.
71
+
72
+ Basic authentication accepts case-insensitive scheme names and form-decodes the
73
+ client ID and secret once, as required by RFC 6749. Percent-encode each component
74
+ before constructing the Basic credential when it contains reserved characters.
75
+ Combining Basic authentication with a body secret or client assertion is rejected.
76
+
77
+ For token-exchange requests, form bodies and query strings are capped at 4 MiB
78
+ and 4,096 parameters. The body is read with a byte bound before authentication.
79
+ Limits enforced by Rack's configured flat parser also apply, including on
80
+ versions without a pair parser. Stricter Rack limits are preserved. Excess input
81
+ returns `invalid_request`. A body naming the token-exchange grant type in any
82
+ parameter or encoding is parsed as an exchange; other grants keep
83
+ rodauth-oauth's parsing and limits.
84
+
85
+ Password-authenticated endpoints also require brute-force protection (RFC 6749
86
+ §2.3.1). Configure and test an attempt limit at the deployment's trusted gateway
87
+ or authentication service, shared across workers and covering every endpoint
88
+ alias. The packaged [nginx reference configuration](examples/nginx-token-exchange.conf)
89
+ provides a worker-shared IP limit and verified TLS to the backend. Adapt its
90
+ placeholders, authority, certificates and network boundaries before deployment.
91
+ Enabling the feature alone does not install a limiter or prove deployment compliance.
92
+
93
+ ## Defaults and policy
94
+
95
+ By default, only a token issued to the authenticated client can be exchanged, resource/audience targets are rejected, and actors are rejected unless the subject identity carries a `may_act` claim (RFC 8693 §4.4) naming that actor. Every `may_act` claim must equal the actor's `sub` or `iss`; any other claim never matches. A `may_act` without `iss` names a principal of the subject's issuer, so the actor's issuer must equal the subject's (both absent for local tokens). An exchange without an actor preserves `may_act`, together with that issuer, for later exchanges, and an exchange that adds an actor consumes it. `may_act` is stored for the authorization server only and is not emitted in JWT or introspection output. Policies can reuse the check through `oauth_token_exchange_may_act?(subject, actor)`. The default resolver accepts active, unexpired local database access tokens, including exchange-issued JWT access tokens whose token hash is stored locally. It rejects sender-bound inputs rather than converting them into bearer tokens. It does not accept arbitrary external JWTs, SAML assertions, refresh tokens, or the `urn:ietf:params:oauth:token-type:jwt` type.
96
+
97
+ For account-backed inputs, the default resolver also requires the account to be
98
+ available through Rodauth's `oauth_account_ds`. Deleted accounts and accounts
99
+ excluded by that dataset are rejected for both subject and actor tokens. The
100
+ default dataset checks existence; applications can filter closed or disabled
101
+ accounts through that existing hook. Client subjects with no account remain supported.
102
+
103
+ Scopes are limited to the intersection of the subject token, authenticated application's allowed scopes, and actor token when present. Omitting `scope` selects that intersection; an empty intersection or explicitly requesting anything broader returns `invalid_scope`. Lifetime cannot exceed the configured access-token lifetime or the remaining lifetime of either input token.
104
+
105
+ Exchange expiration is fixed as an absolute deadline before issuance. Database
106
+ expiration and JWT `exp` share that deadline, so processing delays cannot extend
107
+ the lifetime. Inputs that expire before storage cause `invalid_request`.
108
+
109
+ Override these Rodauth configuration hooks to implement your application's trust policy:
110
+
111
+ ```ruby
112
+ oauth_token_exchange_authorized? do |subject, actor|
113
+ # Example: retain same-client, actor-free authorization.
114
+ actor.nil? && subject[:application_id] ==
115
+ oauth_application[oauth_applications_id_column]
116
+ end
117
+
118
+ oauth_token_exchange_targets_allowed? do |resources, audiences, subject, actor|
119
+ # Example: permit only this API, with no named audience or delegation.
120
+ actor.nil? && audiences.empty? &&
121
+ resources == ["https://api.example.com"] &&
122
+ subject[:application_id] == oauth_application[oauth_applications_id_column]
123
+ end
124
+ ```
125
+
126
+ For delegation or cross-client exchange, the authorization hook must check which authenticated client may act for the subject and actor. Possession of two tokens alone is not sufficient authorization. Avoid unconditional `true` policies.
127
+
128
+ Targets already attached to subject or actor tokens are inherited when omitted and cannot be broadened on subsequent exchanges, even by an approving target policy. Incompatible target restrictions fail with `invalid_target`. Native `oauth_resource_indicators` resource restrictions are preserved. Named audiences require an application-defined routing and authorization policy.
129
+
130
+ When composing `oauth_resource_indicators`, protected-resource checks compare the
131
+ request and target's scheme, host, and effective port. A target with no path or
132
+ with `/` permits that origin; another path permits that exact path and descendants
133
+ separated by `/`, not similarly prefixed sibling paths. A query in the target must
134
+ match the request query exactly. Paths containing dot segments (including encoded
135
+ dot segments) or encoded slash/backslash separators are rejected rather than
136
+ depending on router normalization. Non-HTTP audiences need application-defined
137
+ routing; they do not match an HTTP resource URL on their own. Keep the request's
138
+ authority trustworthy through fixed Host configuration or equivalent ingress
139
+ validation. Enable this extension after the other OAuth features on a separate
140
+ resource server too; unsafe ordering is rejected there as well.
141
+
142
+ Introspection and JWT output preserve `sub`, `act`, and target `aud` claims. Adding an actor creates a new `act.sub` and nests the subject's previous actor history under `act.act`. Exchanging without a new actor preserves existing actor context. Resource servers must enforce the intended audience and current actor authorization as well as token validity; verifying a JWT signature alone is insufficient. Historical nested actors describe prior delegation and do not independently grant current authority.
143
+
144
+ An actor identity with an `issuer` produces `act.iss` alongside `act.sub`, distinguishing equal subjects from different issuers. An `act` entry without `iss` identifies a subject of this authorization server.
145
+
146
+ With the `oidc` feature, the issued token's `sub` and local actors' `act.sub` follow the subject type (`public` or `pairwise`) of the client receiving the token, matching rodauth-oauth's JWT access tokens. Pairwise identifiers use that client's sector. The exchange context stores public identifiers and recomputes them for each client, so a pairwise identifier never flows into another client's token. Client subjects and actors with an `iss` are emitted unchanged.
147
+
148
+ Actor history is limited to 16 nested actors. An exchange exceeding this bound
149
+ returns `invalid_request` without issuing a grant or truncating the history.
150
+
151
+ JWT exchange tokens include a private `token_exchange_id` claim used to produce a distinct `jti` for each grant. A separate rodauth-oauth resource server using upstream's `jti` verification should load and enable this extension too (after `oauth_jwt`, with `is_authorization_server? false`). Generic JWT consumers can treat `jti` as an opaque identifier. Introspection checks the stored exchange token's expiration and revocation; offline JWT consumers need their own revocation strategy. Revoking an input does not automatically revoke previously issued exchange tokens.
152
+
153
+ Errors use the existing OAuth JSON response format: `invalid_request` for malformed or invalid credentials and denied exchange policies, `invalid_target` for unacceptable targets, `invalid_scope` for scope escalation, and `unauthorized_client` when the client's allowed grant list excludes exchange. Existing client-authentication errors come from rodauth-oauth.
154
+
155
+ ## Custom input token verification
156
+
157
+ Override `oauth_token_exchange_resolve_token(token, token_type)` to accept additional trusted inputs. Return `nil` for invalid or unsupported tokens, or a hash containing:
158
+
159
+ | Key | Meaning |
160
+ | --- | --- |
161
+ | `subject` | Nonempty verified subject string |
162
+ | `account_id` | Mapped local account ID, or `nil` for a client subject |
163
+ | `application_id` | Trusted local issuing application ID used by authorization policy |
164
+ | `scopes` | Array of authorized scope strings |
165
+ | `expires_at` | Expiration as an integer Unix timestamp |
166
+ | `resources` | Optional array of permitted resource URI strings |
167
+ | `audiences` | Optional array of permitted audience strings |
168
+ | `act` | Optional verified actor claim hash |
169
+ | `issuer` | Optional verified issuer string; emitted as `act.iss` when this identity is the actor |
170
+ | `may_act` | Optional verified RFC 8693 `may_act` hash naming who may act for this subject |
171
+
172
+ Call `super(token, token_type)` for input types handled by the built-in resolver. An external JWT or SAML resolver must verify signature, issuer, expiry, intended audience, and applicable client/key bindings before returning an identity. It must preserve target restrictions and verified actor context. Do not treat decoded claims as verified identity, silently drop sender constraints, or map an untrusted issuer's subject directly onto a local account.
173
+
174
+ ## Development
175
+
176
+ Run these commands from a source checkout. The installed Gem includes runtime
177
+ code, example configurations and migration, README, changelog, and license;
178
+ development scripts and tests are distributed with the source checkout.
179
+ Development uses Bundler 2.4.22 and requires a C toolchain and Ruby development
180
+ headers to compile the SQLite test dependency for the Ruby version in use.
181
+
182
+ ```sh
183
+ gem install bundler -v 2.4.22
184
+ bundle _2.4.22_ install
185
+ bundle exec rake
186
+ bundle exec ruby -Ilib script/verify_tls.rb
187
+ gem build rodauth-token_exchange.gemspec
188
+ bundle exec ruby script/verify_package.rb rodauth-token_exchange-0.1.0.gem --test
189
+ ```
190
+
191
+ Tests exercise the extension through a Roda token endpoint with an SQLite database. They cover validation, policy, scope/lifetime restriction, delegation, introspection, JWT output, target inheritance, and independent grant storage. Database adapters and framework integrations beyond this test setup are not certified by this project.
192
+
193
+ Licensed under the [MIT License](LICENSE).
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/extensions/migration"
5
+
6
+ # Apply after the application's rodauth-oauth grants migration.
7
+ # Adjust the table/column name to match your Rodauth configuration.
8
+ # Separately remove any application-specific unique index that limits grants
9
+ # to one per account/client (possibly including scopes). Keep token uniqueness.
10
+ Sequel.migration do
11
+ change do
12
+ alter_table(:oauth_grants) do
13
+ add_column :token_exchange_context, String, text: true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ # Reference topology. Render @PLACEHOLDERS@ before use; verify production settings.
2
+ # The backend must be reachable only through trusted ingress or enforce its own
3
+ # equivalent rate limit. This loopback listener is for the executable local probe.
4
+ worker_processes 2;
5
+ pid @DIRECTORY@/nginx.pid;
6
+ error_log @DIRECTORY@/error.log;
7
+ events { worker_connections 128; }
8
+ http {
9
+ access_log off;
10
+ client_body_temp_path @DIRECTORY@/body;
11
+ proxy_temp_path @DIRECTORY@/proxy;
12
+ fastcgi_temp_path @DIRECTORY@/fastcgi;
13
+ uwsgi_temp_path @DIRECTORY@/uwsgi;
14
+ scgi_temp_path @DIRECTORY@/scgi;
15
+ # Shared by workers. Do not replace the key with supplied headers/credentials.
16
+ limit_req_zone $binary_remote_addr zone=token_exchange:1m rate=5r/m;
17
+ server {
18
+ listen 127.0.0.1:@PORT@ ssl reuseport;
19
+ ssl_certificate @DIRECTORY@/server.pem;
20
+ ssl_certificate_key @DIRECTORY@/server.key;
21
+ ssl_protocols TLSv1.2 TLSv1.3;
22
+ limit_req zone=token_exchange burst=4 nodelay;
23
+ limit_req_status 429;
24
+ # One location covers every route and alias; no unrestricted token path.
25
+ location / {
26
+ proxy_pass https://127.0.0.1:@UPSTREAM_PORT@;
27
+ proxy_ssl_protocols TLSv1.2 TLSv1.3;
28
+ proxy_ssl_verify on;
29
+ proxy_ssl_trusted_certificate @DIRECTORY@/upstream-ca.pem;
30
+ proxy_ssl_verify_depth 2;
31
+ proxy_ssl_server_name on;
32
+ proxy_ssl_name @UPSTREAM_NAME@;
33
+ # Replace localhost with the deployment's fixed public authority.
34
+ proxy_set_header Host localhost;
35
+ proxy_set_header X-Forwarded-Proto https;
36
+ proxy_set_header X-Forwarded-For $remote_addr;
37
+ proxy_set_header Forwarded "";
38
+ proxy_set_header X-Forwarded-Host "";
39
+ proxy_set_header X-Forwarded-Port "";
40
+ proxy_set_header X-Forwarded-Scheme "";
41
+ proxy_set_header X-Forwarded-Ssl "";
42
+ proxy_set_header Front-End-Https "";
43
+ proxy_connect_timeout 3s;
44
+ proxy_read_timeout 3s;
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,522 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rodauth/oauth"
4
+ require "json"
5
+ require "uri"
6
+ require "rodauth/token_exchange/client_authentication"
7
+ require "rodauth/token_exchange/resource_authorization"
8
+
9
+ module Rodauth
10
+ module TokenExchange
11
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
12
+ ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
13
+ SINGLE_PARAMETERS = %w[grant_type subject_token subject_token_type actor_token actor_token_type requested_token_type scope].freeze
14
+ TARGET_PARAMETERS = %w[resource audience].freeze
15
+ CLIENT_PARAMETERS = %w[client_id client_secret client_assertion client_assertion_type].freeze
16
+ MAX_FORM_BYTES = 4 * 1024 * 1024
17
+ MAX_FORM_PARAMETERS = 4096
18
+ MAX_ACTOR_DEPTH = 16
19
+
20
+ # Rodauth configuration methods live on the auth class, above feature
21
+ # modules. Extend the request instance with these constraints so configured
22
+ # lifetime/reuse values cannot bypass the exchange's security bounds.
23
+ module TokenSettings
24
+ def oauth_access_token_expires_in
25
+ [@oauth_token_exchange_expires_at - Time.now.to_i, 0].max
26
+ end
27
+
28
+ def oauth_reuse_access_token
29
+ false
30
+ end
31
+ end
32
+ end
33
+
34
+ Feature.define(:oauth_token_exchange, :OauthTokenExchange) do
35
+ depends :oauth_base
36
+ include TokenExchange::ClientAuthentication
37
+ include TokenExchange::ResourceAuthorization
38
+
39
+ auth_value_method :oauth_grants_token_exchange_context_column, :token_exchange_context
40
+ auth_methods :oauth_token_exchange_resolve_token, :oauth_token_exchange_authorized?,
41
+ :oauth_token_exchange_targets_allowed?
42
+
43
+ def oauth_grant_types_supported
44
+ super | [TokenExchange::GRANT_TYPE]
45
+ end
46
+
47
+ def raw_param(key)
48
+ return @oauth_token_exchange_params[key] if @oauth_token_exchange_params
49
+
50
+ super
51
+ end
52
+
53
+ def resource_indicators
54
+ return @oauth_token_exchange_params["resource"] if @oauth_token_exchange_params
55
+
56
+ super
57
+ end
58
+
59
+ # A resolver returns a validated identity, never unverified decoded claims.
60
+ def oauth_token_exchange_resolve_token(token, token_type)
61
+ return unless token_type == TokenExchange::ACCESS_TOKEN_TYPE
62
+
63
+ grant = oauth_token_exchange_stored_grant(token)
64
+ return unless grant
65
+ binding_columns = [:certificate_thumbprint, :dpop_jkt]
66
+ binding_columns << oauth_grants_certificate_thumbprint_column if respond_to?(:oauth_grants_certificate_thumbprint_column)
67
+ binding_columns << oauth_grants_dpop_jkt_column if respond_to?(:oauth_grants_dpop_jkt_column)
68
+ return if binding_columns.any? { |binding| grant[binding] }
69
+
70
+ context = oauth_token_exchange_context(grant)
71
+ account = grant[oauth_grants_account_id_column]
72
+ return if account && oauth_account_ds(account).empty?
73
+
74
+ application = grant[oauth_grants_oauth_application_id_column]
75
+ subject = context["sub"] || account&.to_s ||
76
+ db[oauth_applications_table].where(oauth_applications_id_column => application)
77
+ .get(oauth_applications_client_id_column)
78
+ {
79
+ subject: subject, account_id: account, application_id: application,
80
+ scopes: grant[oauth_grants_scopes_column].to_s.split(oauth_scope_separator),
81
+ expires_at: grant[oauth_grants_expires_in_column].to_time.to_i,
82
+ act: context["act"], act_account_ids: context["act_account_ids"], may_act: context["may_act"],
83
+ resources: context.fetch("resource") {
84
+ resource_column = respond_to?(:oauth_grants_resource_column) ? oauth_grants_resource_column : :resource
85
+ grant[resource_column].to_s.split(" ")
86
+ },
87
+ audiences: context.fetch("audience", [])
88
+ }.compact
89
+ end
90
+
91
+ def oauth_token_exchange_authorized?(subject, actor)
92
+ return false unless subject[:application_id] == oauth_application[oauth_applications_id_column]
93
+
94
+ !actor || oauth_token_exchange_may_act?(subject, actor)
95
+ end
96
+
97
+ # RFC 8693 §4.4: every claim in the subject's may_act must identify the actor.
98
+ # Unsupported may_act claims never match, so they deny rather than widen.
99
+ def oauth_token_exchange_may_act?(subject, actor)
100
+ may_act = oauth_token_exchange_may_act(subject)
101
+ return false unless may_act
102
+
103
+ # A sub is unique only within its issuer, so the issuer always takes part.
104
+ claims = {"sub" => actor[:subject], "iss" => actor[:issuer]}
105
+ may_act.all? { |claim, value| claims.key?(claim) && claims[claim] == value }
106
+ end
107
+
108
+ # A may_act without iss names a principal of the subject's issuer.
109
+ def oauth_token_exchange_may_act(subject)
110
+ may_act = subject[:may_act]
111
+ return unless may_act.is_a?(Hash) && !may_act.empty?
112
+
113
+ may_act = may_act.transform_keys(&:to_s)
114
+ may_act["iss"] = subject[:issuer] unless may_act.key?("iss")
115
+ may_act
116
+ end
117
+
118
+ def oauth_token_exchange_targets_allowed?(resources, audiences, _subject, _actor)
119
+ resources.empty? && audiences.empty?
120
+ end
121
+
122
+ def generate_jti(payload)
123
+ exchange_id = payload[:token_exchange_id] || payload["token_exchange_id"]
124
+ exchange_id ? Digest::SHA256.hexdigest("#{super}:#{exchange_id}") : super
125
+ end
126
+
127
+ def oauth_token_subject
128
+ token = authorization_token
129
+ if token && token[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
130
+ return oauth_token_exchange_context(token)["sub"]
131
+ end
132
+ super
133
+ end
134
+
135
+ def post_configure
136
+ super
137
+ if is_authorization_server? && features.include?(:oauth_assertion_base)
138
+ raise Rodauth::ConfigurationError, "oauth_token_exchange cannot be combined with oauth_assertion_base (including oauth_jwt_bearer_grant)"
139
+ end
140
+ composed = %i[oauth_jwt oauth_token_introspection oauth_resource_indicators oauth_tls_client_auth oauth_dpop
141
+ oauth_pkce oauth_device_code_grant]
142
+ if composed.any? { |feature| features.include?(feature) && features.index(feature) > features.index(:oauth_token_exchange) }
143
+ raise Rodauth::ConfigurationError, "enable oauth_token_exchange after the other OAuth features"
144
+ end
145
+ return unless is_authorization_server?
146
+
147
+ grant_keys = [oauth_grants_oauth_application_id_column, oauth_grants_account_id_column,
148
+ oauth_grants_scopes_column, oauth_grants_type_column]
149
+ incompatible_index = db.indexes(oauth_grants_table).values.any? do |index|
150
+ index[:unique] && !index[:columns].empty? && (index[:columns] - grant_keys).empty?
151
+ end
152
+ if __one_oauth_token_per_account || incompatible_index
153
+ raise Rodauth::ConfigurationError, "token exchange requires multiple grants per account/client; remove the unique grant index"
154
+ end
155
+ unless db.schema(oauth_grants_table).any? { |column, _| column == oauth_grants_token_exchange_context_column }
156
+ raise Rodauth::ConfigurationError, "add the token exchange context column before enabling oauth_token_exchange"
157
+ end
158
+ end
159
+
160
+ def json_token_introspect_payload(grant)
161
+ if features.include?(:oauth_jwt) && oauth_jwt_access_tokens && request.path == introspect_path
162
+ stored = oauth_token_exchange_stored_grant(param("token"))
163
+ exchange = stored && stored[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
164
+ # The stored fingerprint authenticates the entire locally minted JWT,
165
+ # including targeted tokens which upstream's client-audience check rejects.
166
+ # Consult this record for revocation just as for an opaque access token.
167
+ if exchange || (grant && grant["token_exchange_id"])
168
+ grant = exchange ? stored : nil
169
+ end
170
+ end
171
+ payload = super
172
+ return payload unless grant && payload[:active]
173
+
174
+ if grant["sub"]
175
+ # Decoded JWT claims were already rendered for their issuing client.
176
+ %w[sub act aud].each { |key| payload[key.to_sym] = grant[key] if grant.key?(key) }
177
+ return payload
178
+ end
179
+
180
+ application = db[oauth_applications_table]
181
+ .where(oauth_applications_id_column => grant[oauth_grants_oauth_application_id_column]).first
182
+ if grant[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
183
+ payload[:client_id] = application && application[oauth_applications_client_id_column]
184
+ end
185
+ oauth_token_exchange_claims(grant, application).each { |key, value| payload[key.to_sym] = value }
186
+ payload
187
+ end
188
+
189
+ private
190
+
191
+ def require_oauth_application
192
+ if request.path == token_path && request.post? && request.media_type == "application/x-www-form-urlencoded" &&
193
+ oauth_token_exchange_candidate?
194
+ @oauth_token_exchange_params = oauth_token_exchange_parameters(true)
195
+ end
196
+ # Only server/trusted middleware may assert transport security. Raw
197
+ # Forwarded headers are client-controlled and must not bypass this check.
198
+ # Other grants and endpoints keep rodauth-oauth's transport behavior.
199
+ if @oauth_token_exchange_params && request.env["rack.url_scheme"] != "https"
200
+ throw_json_response_error(400, "invalid_request")
201
+ end
202
+ super
203
+ rescue Rack::QueryParser::InvalidParameterError, Rack::QueryParser::ParameterTypeError
204
+ redirect_response_error("invalid_request")
205
+ end
206
+
207
+ def www_authenticate_header(payload)
208
+ # Authentication can fail before form parsing; do not read params here.
209
+ if request.path == token_path && payload["error"] == "invalid_client" &&
210
+ TokenExchange::ClientAuthentication::BASIC_SCHEME.match?(request.env["HTTP_AUTHORIZATION"].to_s)
211
+ return 'Basic realm="token"'
212
+ end
213
+ super
214
+ end
215
+
216
+ def oauth_token_exchange_stored_grant(token)
217
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
218
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
219
+ # Reapply validity and exact-token predicates after feature-specific lookup
220
+ # filters; an OR in another feature must never broaden this selection.
221
+ oauth_grant_by_token_ds(token).where(column => value, oauth_grants_revoked_at_column => nil)
222
+ .where(Sequel[oauth_grants_expires_in_column] > Sequel::CURRENT_TIMESTAMP).first
223
+ end
224
+
225
+ def validate_token_params
226
+ super
227
+ return unless param("grant_type") == TokenExchange::GRANT_TYPE
228
+
229
+ @oauth_token_exchange_params ||= oauth_token_exchange_parameters
230
+ params = @oauth_token_exchange_params
231
+ redirect_response_error("invalid_request") unless request.env["rack.url_scheme"] == "https"
232
+ %w[subject_token subject_token_type].each do |key|
233
+ redirect_response_error("invalid_request") unless params[key]
234
+ end
235
+ if params.key?("actor_token") != params.key?("actor_token_type")
236
+ redirect_response_error("invalid_request")
237
+ end
238
+ if params["requested_token_type"] && params["requested_token_type"] != TokenExchange::ACCESS_TOKEN_TYPE
239
+ redirect_response_error("invalid_request")
240
+ end
241
+ rescue Rack::QueryParser::InvalidParameterError, Rack::QueryParser::ParameterTypeError
242
+ # Rack parses params in super, before the exchange-specific wire parser.
243
+ redirect_response_error("invalid_request")
244
+ end
245
+
246
+ # Exchange-specific form limits must not change other grants. Scan the whole
247
+ # body, leniently decoding valid escapes, for any occurrence of the grant
248
+ # type; a match in any name or encoding gets the strict exchange parser.
249
+ def oauth_token_exchange_candidate?
250
+ input = request.body
251
+ input.rewind
252
+ overlap = TokenExchange::GRANT_TYPE.bytesize * 3
253
+ tail = String.new(encoding: Encoding::BINARY)
254
+ while (chunk = input.read(64 * 1024))
255
+ window = tail + chunk.b
256
+ decoded = window.gsub(/%(\h\h)/n) { Regexp.last_match(1).hex.chr }
257
+ return true if decoded.include?(TokenExchange::GRANT_TYPE)
258
+
259
+ tail = window.byteslice(-[overlap, window.bytesize].min, overlap)
260
+ end
261
+ false
262
+ ensure
263
+ input&.rewind
264
+ end
265
+
266
+ def oauth_token_exchange_parameters(only_if_exchange = false)
267
+ redirect_response_error("invalid_request") unless request.media_type == "application/x-www-form-urlencoded"
268
+ # Rack's nested parameter parser discards repeated bare keys. Parse the wire
269
+ # representation to retain RFC 8693 resource/audience multiplicity.
270
+ input = request.body
271
+ input.rewind
272
+ parser = Rack::Utils.default_query_parser
273
+ limit = TokenExchange::MAX_FORM_BYTES
274
+ limit = [limit, parser.bytesize_limit].min if parser.respond_to?(:bytesize_limit)
275
+ raw = input.read(limit + 1)
276
+ input.rewind
277
+ redirect_response_error("invalid_request") if raw.bytesize > limit
278
+ pairs = oauth_token_exchange_form_pairs(raw)
279
+ return if only_if_exchange && !pairs.include?(["grant_type", TokenExchange::GRANT_TYPE])
280
+
281
+ names = TokenExchange::SINGLE_PARAMETERS + TokenExchange::TARGET_PARAMETERS + TokenExchange::CLIENT_PARAMETERS
282
+ query_names = oauth_token_exchange_form_pairs(request.query_string).map(&:first)
283
+ redirect_response_error("invalid_request") unless (query_names & names).empty?
284
+ params = {}
285
+ seen = {}
286
+ pairs.each do |name, value|
287
+ value ||= String.new
288
+ name.force_encoding(Encoding::UTF_8)
289
+ value.force_encoding(Encoding::UTF_8)
290
+ redirect_response_error("invalid_request") unless name.valid_encoding? && value.valid_encoding?
291
+ # OAuth parameter names are flat: unknown names (including brackets)
292
+ # are ignored, never interpreted by Rack as arrays or nested objects.
293
+ next unless names.include?(name)
294
+
295
+ target = TokenExchange::TARGET_PARAMETERS.include?(name)
296
+ redirect_response_error("invalid_request") if !target && seen[name]
297
+ seen[name] = true
298
+ next if value.empty?
299
+
300
+ if target
301
+ (params[name] ||= []) << value
302
+ else
303
+ params[name] = value
304
+ end
305
+ end
306
+ redirect_response_error("invalid_request") unless params["grant_type"] == TokenExchange::GRANT_TYPE
307
+ params
308
+ rescue ArgumentError
309
+ redirect_response_error("invalid_request")
310
+ end
311
+
312
+ def oauth_token_exchange_form_pairs(raw)
313
+ if raw.bytesize > TokenExchange::MAX_FORM_BYTES || raw.count("&") >= TokenExchange::MAX_FORM_PARAMETERS ||
314
+ raw.match?(/%(?![0-9a-fA-F]{2})/)
315
+ redirect_response_error("invalid_request")
316
+ end
317
+ parser = Rack::Utils.default_query_parser
318
+ if parser.respond_to?(:parse_query_pairs)
319
+ # Preserve repeated flat keys while retaining Rack's configured limits.
320
+ parser.parse_query_pairs(raw)
321
+ else
322
+ # Older Rack versions have no pair parser. Validate their flat-parser
323
+ # limits before decoding again to retain repeated keys and raw bytes.
324
+ parser.parse_query(raw)
325
+ URI.decode_www_form(raw, Encoding::ASCII_8BIT)
326
+ end
327
+ rescue Rack::QueryParser::ParamsTooDeepError, ArgumentError
328
+ redirect_response_error("invalid_request")
329
+ end
330
+
331
+ def create_token(grant_type)
332
+ return super unless grant_type == TokenExchange::GRANT_TYPE
333
+
334
+ redirect_response_error("unauthorized_client") unless supported_grant_type?(grant_type)
335
+ params = @oauth_token_exchange_params
336
+ subject = oauth_token_exchange_identity(params["subject_token"], params["subject_token_type"])
337
+ actor = oauth_token_exchange_identity(params["actor_token"], params["actor_token_type"]) if params["actor_token"]
338
+ redirect_response_error("invalid_request") unless oauth_token_exchange_authorized?(subject, actor)
339
+
340
+ resources = oauth_token_exchange_targets(params, "resource", :resources, subject, actor)
341
+ audiences = oauth_token_exchange_targets(params, "audience", :audiences, subject, actor)
342
+ targets = (resources + audiences).uniq
343
+ [subject, actor].compact.each do |identity|
344
+ bound = identity.fetch(:resources, []) + identity.fetch(:audiences, [])
345
+ if !bound.empty? && !(targets - bound).empty?
346
+ redirect_response_error("invalid_target")
347
+ end
348
+ end
349
+ resources.each do |resource|
350
+ begin
351
+ uri = URI.parse(resource)
352
+ redirect_response_error("invalid_target") unless uri.absolute? && !uri.fragment
353
+ rescue URI::InvalidURIError
354
+ redirect_response_error("invalid_target")
355
+ end
356
+ end
357
+ unless oauth_token_exchange_targets_allowed?(resources, audiences, subject, actor)
358
+ redirect_response_error("invalid_target")
359
+ end
360
+ scope = oauth_token_exchange_scopes(params["scope"], subject, actor)
361
+ expirations = [Time.now.to_i + oauth_access_token_expires_in, subject[:expires_at]]
362
+ expirations << actor[:expires_at] if actor
363
+ @oauth_token_exchange_expires_at = expirations.min
364
+ redirect_response_error("invalid_request") unless @oauth_token_exchange_expires_at > Time.now.to_i
365
+ extend(TokenExchange::TokenSettings)
366
+
367
+ context = {"sub" => subject[:subject], "resource" => resources, "audience" => audiences}
368
+ context["aud"] = targets unless targets.empty?
369
+ context["act"] = subject[:act] if subject[:act]
370
+ # Local account IDs aligned with the actor chain (outermost first) allow
371
+ # pairwise clients to receive their own identifiers for local actors.
372
+ act_account_ids = subject.fetch(:act_account_ids, [])
373
+ if actor
374
+ context["act"] = {"sub" => actor[:subject], "iss" => actor[:issuer], "act" => context["act"]}.compact
375
+ act_account_ids = [actor[:account_id]] + act_account_ids
376
+ else
377
+ # A delegation permission is consumed by the exchange that adds an actor.
378
+ # Store the issuer with it so the namespace survives later exchanges.
379
+ may_act = oauth_token_exchange_may_act(subject)
380
+ context["may_act"] = may_act.compact if may_act
381
+ end
382
+ context["act_account_ids"] = act_account_ids if act_account_ids.any?
383
+ history = context["act"]
384
+ depth = 0
385
+ while history
386
+ depth += 1
387
+ unless history.is_a?(Hash) && depth <= TokenExchange::MAX_ACTOR_DEPTH
388
+ redirect_response_error("invalid_request")
389
+ end
390
+ history = history["act"]
391
+ end
392
+
393
+ grant_params = {
394
+ oauth_grants_type_column => TokenExchange::GRANT_TYPE,
395
+ oauth_grants_account_id_column => subject[:account_id],
396
+ oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
397
+ oauth_grants_scopes_column => scope.join(oauth_scope_separator),
398
+ oauth_grants_token_exchange_context_column => JSON.generate(context)
399
+ }
400
+ if features.include?(:oauth_resource_indicators) && !targets.empty?
401
+ grant_params[oauth_grants_resource_column] = targets.join(" ")
402
+ end
403
+ generate_token(grant_params, false)
404
+ end
405
+
406
+ def oauth_token_exchange_identity(token, type)
407
+ identity = oauth_token_exchange_resolve_token(token, type)
408
+ unless identity.is_a?(Hash) && identity[:subject].is_a?(String) && !identity[:subject].empty? &&
409
+ identity[:scopes].is_a?(Array) && identity[:scopes].all? { |scope| scope.is_a?(String) } &&
410
+ identity[:expires_at].is_a?(Integer) && identity[:expires_at] > Time.now.to_i &&
411
+ (identity[:issuer].nil? || (identity[:issuer].is_a?(String) && !identity[:issuer].empty?)) &&
412
+ (identity[:may_act].nil? || identity[:may_act].is_a?(Hash)) &&
413
+ (identity[:act_account_ids].nil? || identity[:act_account_ids].is_a?(Array))
414
+ redirect_response_error("invalid_request")
415
+ end
416
+ identity
417
+ end
418
+
419
+ def oauth_token_exchange_targets(params, parameter, key, subject, actor)
420
+ bound = [subject, actor].compact.map { |identity| identity.fetch(key, []) }.reject(&:empty?).reduce(:&)
421
+ targets = params[parameter] || bound || []
422
+ # An empty intersection must not erase an existing target restriction.
423
+ if bound && (targets.empty? || !(targets - bound).empty?)
424
+ redirect_response_error("invalid_target")
425
+ end
426
+ targets.uniq
427
+ end
428
+
429
+ def oauth_token_exchange_scopes(requested, subject, actor)
430
+ allowed = subject[:scopes] & oauth_application[oauth_applications_scopes_column].to_s.split(oauth_scope_separator)
431
+ allowed &= actor[:scopes] if actor
432
+ redirect_response_error("invalid_scope") if allowed.empty?
433
+ return allowed unless requested
434
+
435
+ # RFC 6749 scope-token: printable ASCII excluding double quote and backslash.
436
+ unless requested.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/)
437
+ redirect_response_error("invalid_scope")
438
+ end
439
+ selected = requested.split(" ").uniq
440
+ redirect_response_error("invalid_scope") unless (selected - allowed).empty?
441
+ selected
442
+ end
443
+
444
+ def store_token(grant_params, update_params = {})
445
+ if grant_params[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
446
+ redirect_response_error("invalid_request") unless @oauth_token_exchange_expires_at > Time.now.to_i
447
+ update_params = update_params.merge(oauth_grants_expires_in_column => Time.at(@oauth_token_exchange_expires_at))
448
+ end
449
+ super(grant_params, update_params)
450
+ end
451
+
452
+ def generate_token(grant_params = {}, should_generate_refresh_token = true)
453
+ grant = super
454
+ if grant_params[oauth_grants_type_column] == TokenExchange::GRANT_TYPE &&
455
+ features.include?(:oauth_jwt) && oauth_jwt_access_tokens
456
+ # Upstream JWT issuance does not persist the access token. Retain its
457
+ # fingerprint so later exchanges can check revocation and exact identity.
458
+ token = grant[oauth_grants_token_column]
459
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
460
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
461
+ db[oauth_grants_table].where(oauth_grants_id_column => grant[oauth_grants_id_column]).update(column => value)
462
+ end
463
+ grant
464
+ end
465
+
466
+ def json_access_token_payload(grant)
467
+ payload = super
468
+ if grant[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
469
+ payload["issued_token_type"] = TokenExchange::ACCESS_TOKEN_TYPE
470
+ payload["scope"] = grant[oauth_grants_scopes_column]
471
+ end
472
+ payload
473
+ end
474
+
475
+ def jwt_claims(grant)
476
+ claims = super
477
+ context = oauth_token_exchange_context(grant)
478
+ oauth_token_exchange_claims(grant, oauth_application, context).each { |key, value| claims[key.to_sym] = value }
479
+ if grant[oauth_grants_type_column] == TokenExchange::GRANT_TYPE
480
+ claims[:exp] = grant[oauth_grants_expires_in_column].to_time.to_i
481
+ claims[:token_exchange_id] = grant[oauth_grants_id_column].to_s
482
+ claims[:resource] = context["aud"] if context["aud"]
483
+ end
484
+ claims
485
+ end
486
+
487
+ def oauth_token_exchange_context(grant)
488
+ value = grant[oauth_grants_token_exchange_context_column]
489
+ value ? JSON.parse(value) : {}
490
+ end
491
+
492
+ # Context stores subject identifiers as resolved (public for local
493
+ # accounts). Pairwise clients receive their own sector identifiers for the
494
+ # subject and local actors, matching upstream JWT access-token subjects.
495
+ def oauth_token_exchange_claims(grant, application, context = oauth_token_exchange_context(grant))
496
+ claims = context.slice("sub", "act", "aud")
497
+ return claims unless oauth_token_exchange_pairwise?(application)
498
+
499
+ account = grant[oauth_grants_account_id_column]
500
+ claims["sub"] = jwt_subject(account, application) if account && claims.key?("sub")
501
+ if claims["act"]
502
+ claims["act"] = oauth_token_exchange_pairwise_act(claims["act"], context.fetch("act_account_ids", []), application)
503
+ end
504
+ claims
505
+ end
506
+
507
+ def oauth_token_exchange_pairwise?(application)
508
+ return false unless application && features.include?(:oidc)
509
+
510
+ (application[oauth_applications_subject_type_column] || oauth_jwt_subject_type) == "pairwise"
511
+ end
512
+
513
+ def oauth_token_exchange_pairwise_act(act, account_ids, application)
514
+ return act unless act.is_a?(Hash)
515
+
516
+ account, *older = account_ids
517
+ act = act.merge("sub" => jwt_subject(account, application)) if account && !act.key?("iss")
518
+ act = act.merge("act" => oauth_token_exchange_pairwise_act(act["act"], older, application)) if act["act"]
519
+ act
520
+ end
521
+ end
522
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "uri"
5
+
6
+ module Rodauth
7
+ module TokenExchange
8
+ module ClientAuthentication
9
+ BASIC_SCHEME = /\A *Basic(?:\s|\z)/i.freeze
10
+
11
+ private
12
+
13
+ def require_oauth_application
14
+ authorization = request.env["HTTP_AUTHORIZATION"]
15
+ basic = authorization && BASIC_SCHEME.match?(authorization)
16
+ methods = [basic, !param_or_nil("client_secret").to_s.empty?, !param_or_nil("client_assertion").to_s.empty?]
17
+ if methods.count { |present| present } > 1
18
+ redirect_response_error("invalid_request")
19
+ end
20
+ return super unless basic
21
+
22
+ token = authorization[/\A *Basic +([^\s]+)\z/i, 1]
23
+ authorization_required unless token
24
+
25
+ # Preserve the authentication checks supplied by other OAuth features.
26
+ begin
27
+ request.env["HTTP_AUTHORIZATION"] = "Basic #{token}"
28
+ super
29
+ ensure
30
+ request.env["HTTP_AUTHORIZATION"] = authorization
31
+ end
32
+ end
33
+
34
+ def supports_auth_method?(application, auth_method)
35
+ return super unless @oauth_token_exchange_params && auth_method == "none"
36
+ return false unless application
37
+
38
+ # PKCE/device grant exceptions must not make an exchange client public.
39
+ configured = application[oauth_applications_token_endpoint_auth_method_column]
40
+ methods = configured ? configured.split(/ +/) : oauth_default_token_endpoint_auth_methods
41
+ methods.include?("none")
42
+ end
43
+
44
+ def require_oauth_application_from_client_secret_basic(token)
45
+ begin
46
+ credentials = Base64.strict_decode64(token).split(":", 2)
47
+ authorization_required unless credentials.length == 2
48
+ client_id, client_secret = credentials.map { |value| URI.decode_www_form_component(value) }
49
+ authorization_required unless client_id.valid_encoding? && client_secret.valid_encoding?
50
+ rescue ArgumentError
51
+ authorization_required
52
+ end
53
+ application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
54
+ authorization_required unless supports_auth_method?(application, "client_secret_basic") &&
55
+ secret_matches?(application, client_secret)
56
+ application
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Rodauth
6
+ module TokenExchange
7
+ # Compatibility with rodauth-oauth 1.7's resource indicators. Keep the
8
+ # authentication/scope/sender-binding chain, replacing only its URL checks
9
+ # for auth classes which explicitly enable this extension.
10
+ module ResourceAuthorization
11
+ def require_oauth_authorization(*scopes)
12
+ return super unless features.include?(:oauth_resource_indicators)
13
+
14
+ previous = @oauth_token_exchange_checking_authorization
15
+ begin
16
+ @oauth_token_exchange_checking_authorization = true
17
+ super
18
+ ensure
19
+ @oauth_token_exchange_checking_authorization = previous
20
+ end
21
+
22
+ token = authorization_token
23
+ resources = token[oauth_grants_resource_column] || token["resource"]
24
+ return unless resources
25
+
26
+ resources = resources.split(" ") if resources.is_a?(String)
27
+ authorization_required unless oauth_token_exchange_resource_matches?(resources)
28
+ end
29
+
30
+ private
31
+
32
+ def authorization_token
33
+ token = super
34
+ return token unless token && @oauth_token_exchange_checking_authorization
35
+
36
+ # Suppress the upstream base_url prefix check only while its full
37
+ # authorization chain runs. Do not alter its cached grant or JWT claims.
38
+ # The original resource restriction is checked immediately afterwards,
39
+ # before require_oauth_authorization returns to the protected route.
40
+ token.reject { |key, _| key == oauth_grants_resource_column || key == "resource" }
41
+ end
42
+
43
+ def jwt_decode(token, verify_aud: true, **options)
44
+ return super unless features.include?(:oauth_resource_indicators)
45
+
46
+ # The upstream resource wrapper honors verify_aud:false, avoiding both
47
+ # its starts_with? typo and its unsafe prefix matcher. Other verification
48
+ # options, including signature, issuer, expiry, headers and jti, survive.
49
+ claims = super(token, verify_aud: false, **options)
50
+ return claims unless verify_aud
51
+ return unless claims && oauth_token_exchange_resource_matches?(claims["aud"])
52
+
53
+ claims
54
+ end
55
+
56
+ def oauth_token_exchange_resource_matches?(resources)
57
+ return false unless resources.is_a?(Array) && !resources.empty?
58
+
59
+ requested = URI.parse(request.url)
60
+ return false unless oauth_token_exchange_http_resource?(requested)
61
+
62
+ resources.any? do |resource|
63
+ next false unless resource.is_a?(String)
64
+
65
+ begin
66
+ target = URI.parse(resource)
67
+ next false unless oauth_token_exchange_http_resource?(target)
68
+ next false unless [requested.scheme, requested.host.downcase, requested.port] ==
69
+ [target.scheme, target.host.downcase, target.port]
70
+
71
+ path = target.path
72
+ request_path = requested.path
73
+ path_match = path.empty? || path == "/" || request_path == path ||
74
+ request_path.start_with?(path.end_with?("/") ? path : "#{path}/")
75
+ path_match && (target.query.nil? || target.query == requested.query)
76
+ rescue URI::InvalidURIError
77
+ false
78
+ end
79
+ end
80
+ rescue URI::InvalidURIError
81
+ false
82
+ end
83
+
84
+ def oauth_token_exchange_http_resource?(uri)
85
+ return false unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.fragment
86
+
87
+ # Avoid path aliases which a downstream router might normalize outside
88
+ # the allowed subtree. Encoded separators are deliberately fail-closed.
89
+ return false if uri.path.match?(/%(?:2f|5c)/i)
90
+
91
+ uri.path.split("/").none? { |segment| %w[. ..].include?(URI::DEFAULT_PARSER.unescape(segment)) }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module TokenExchange
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rodauth/oauth"
4
+ require "rodauth/token_exchange/version"
5
+ require "rodauth/features/oauth_token_exchange"
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rodauth-token_exchange
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hiroshi Kajisha
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rodauth-oauth
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.7.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.7.0
27
+ description: Adds the RFC 8693 token-exchange grant to rodauth-oauth's token endpoint,
28
+ with delegation (act and may_act), bounded scopes and lifetimes, and policy hooks.
29
+ email:
30
+ - 1960696+kajisha@users.noreply.github.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - examples/migrate/001_token_exchange_context.rb
39
+ - examples/nginx-token-exchange.conf
40
+ - lib/rodauth/features/oauth_token_exchange.rb
41
+ - lib/rodauth/token_exchange.rb
42
+ - lib/rodauth/token_exchange/client_authentication.rb
43
+ - lib/rodauth/token_exchange/resource_authorization.rb
44
+ - lib/rodauth/token_exchange/version.rb
45
+ homepage: https://github.com/kajisha/rodauth-token_exchange
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ source_code_uri: https://github.com/kajisha/rodauth-token_exchange
50
+ bug_tracker_uri: https://github.com/kajisha/rodauth-token_exchange/issues
51
+ changelog_uri: https://github.com/kajisha/rodauth-token_exchange/blob/main/CHANGELOG.md
52
+ rubygems_mfa_required: 'true'
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.3.5
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: RFC 8693 Token Exchange for rodauth-oauth
72
+ test_files: []