Exchange user access for a connected service
Included in Token/PHP 0.1.3 and CLI/PHP 0.1.2.
Install both matching packages as described in
installation.
The existing CLI exchange and refresh-token commands renew a refresh
package. The new token-exchange command and PHP tokenExchange() operation
perform RFC 8693 exchange for a different connected service.
Prepare the caller and receiving app
The calling app must be confidential, register
urn:ietf:params:oauth:grant-type:token-exchange, and hold its original active
M7 user access token. Client-credentials, refresh and ID tokens cannot be
subjects. The provider must enable incoming exchange and approve an active
connection for the exact target audience and scopes. An absent provider scope
ceiling permits nothing. Offline use also needs the caller's refresh_token
grant and authorized offline_access throughout the scope ceilings.
Configure these through SSO registration and User API application connections. The SDK does not create connections or approvals. Same-site approval compares registered personal owners or organizations, not website hostnames or the people managing those organizations.
M7 supports one local-user subject, one connected target in the same identity realm and one exchange hop. External subjects, impersonation, multiple targets, actor-token input and chained exchange are unsupported. A browser frontend must use its confidential backend for this operation; never embed client secrets in browser code.
Exchange from PHP
After loading the updated Token/PHP autoloader, use trusted server-side client configuration and an existing source access token:
$sdk = new \M7\Identity\M7IdentitySDK();
$report = $sdk->tokenExchange($sourceAccessToken, [
'endpoint' => 'consumer',
'auth' => [
'method' => 'client_secret_basic',
'client_id' => $callerClientId,
'client_secret' => $callerClientSecret,
],
'audience' => 'https://files.example.com',
'scope' => 'files:read',
]);
if (!$report->ok()) {
// Keep credentials and the complete response out of logs.
throw new \RuntimeException('Token exchange failed');
}
$targetBundle = $report->response();
// Persist this complete result in protected target storage, separately from login.
The library sends one form-encoded POST to the selected token endpoint.
endpoint defaults to consumer; tenant and explicit HTTPS token URLs are
supported. Certificate authentication selects the named endpoint's mTLS alias.
Custom endpoint URLs cannot include credentials, query or fragment.
| Option | Contract |
|---|---|
auth |
Required; client_secret_basic, client_secret_post, tls_client_auth or self_signed_tls_client_auth. This SDK operation does not supply JWT client-assertion authentication. |
audience / resource |
At least one scalar exact target; if both are supplied they must match. Use audience, not the separate machine-grant aud option. |
scope |
Optional space-delimited string. Omission lets the server select the permitted intersection excluding offline_access. |
subject_token_type, requested_token_type |
Default to urn:ietf:params:oauth:token-type:access_token; that is the only accepted value. |
fingerprint |
Original 64-character hexadecimal value when the source is fingerprint-bound. |
dpop |
['proof' => $freshProof], made with the source's binding key for POST to the token endpoint. |
connect_timeout, timeout, max_response_bytes |
Positive integers; defaults: 5 seconds, 20 seconds, 131072 bytes. |
Supply the matching certificate and private-key configuration for a certificate-bound source. Both constraints apply when a source has both DPoP and certificate binding. The SDK verifies TLS, rejects redirects and bounds responses. It does not generate proofs, automatically retry, refresh the source, acknowledge packages or persist state. Unknown options fail locally.
Choose access-only or offline use
| Requested scope | Result under M7 policy |
|---|---|
files:read |
Access token only, even if the source permits offline access. |
| Omitted | Allowed scope intersection, excluding offline_access; access token only. |
files:read offline_access |
Access and refresh tokens with the complete native refresh binding package, if all permissions allow it. |
Do not set requested_token_type to the refresh-token URI; it is unsupported.
The SDK permits a valid access-only response to an offline request because
refresh issuance remains the authority's decision. Check refreshToken() before
depending on offline renewal. This grant does not return an ID token.
The report exposes ok(), accessToken(), issuedTokenType(), tokenType(),
expiresIn(), scope(), refreshToken(), bindingChain(), bindingLink(),
response(), httpStatus(), error(), reason() and dpopNonce().
A successful report checks the response shape; it does not cryptographically
validate the returned JWT or establish current resource permission. Use local
validate() or authenticated introspect() and the resource's authorization
checks as appropriate. M7 currently does not issue DPoP nonces; nonce reporting
does not mean this operation automatically handles a challenge.
Use the CLI
After installing the matching packages, an existing confidential app/device-code profile with an active user login can supply the source:
m7-identity token-exchange \
--app caller-app \
--audience https://files.example.com \
--scope 'files:read offline_access' \
--output-file ./files-offline.json
--profile NAME selects a device-code profile; --profile-root DIR or
M7_IDENTITY_HOME selects its keychain. The command reads the source without
renewing it. Source profile scopes and audiences are not inherited. An app
configured with none needs explicit confidential authentication and its
matching credential. The server registration must permit that method.
For a supplied complete bundle and protected client-secret file:
m7-identity token-exchange \
--client-id CALLER_CLIENT_ID \
--auth-method basic \
--client-secret-file /secure/caller-secret \
--bundle-file /secure/source-login.json \
--resource https://files.example.com \
--scope 'files:read' \
--output-file ./files-access.json
--subject-token-file supplies a raw access token; --bundle-file and
--bundle-stdin supply a bundle. Files must be regular, non-symlink files with
no group/other permissions, normally mode 0600. --password prompts invisibly;
--interactive prompts for missing authentication inputs. Raw secrets and
tokens are not command-line values.
--auth-method accepts basic, post, tls_client_auth and
self_signed_tls_client_auth. Certificate methods use --certificate-file,
--private-key-file and optional --private-key-passphrase-file.
--fingerprint-file supplies the original binding; a concrete fingerprint in
the input bundle can also be reused. A boolean binding marker is insufficient.
--dpop-proof-file supplies a fresh caller-generated token-endpoint proof.
--output-file exclusively creates a new mode-0600 file; an existing destination
fails before the request. Without it the complete credential bundle goes to
stdout. The command never overwrites the original login. M7_TOKEN_EXCHANGE_*
environment values override matching command options; the separate
M7_EXCHANGE_* refresh settings do not apply. Exit status is 0 for success,
1 for endpoint/response failure and 2 for local configuration/storage failure.
Refresh and validate the target bundle
Keep the whole target response, including refresh binding and lifecycle fields,
separate from the original login. Renew it with
$sdk->refreshToken($targetBundle, $refreshOptions) using the original calling
client, endpoint and required binding material. Persist the full replacement
and follow the ordinary rotation and ACK lifecycle.
tokenExchange() does not automatically use ensure() or ACK. Avoid
exchange --app against the original login when intending to renew separate
target storage.
For Bob's Blog → Files exchange, sub remains Bob, client_id and act.sub
identify Blog, m7.target_client_id identifies Files, and aud contains the
selected Files resource plus Files' OAuth UUID. Refresh preserves these values
and cannot extend the delegation's absolute offline expiry. Permission withdrawal
blocks new exchange and delegated refresh; offline JWT verification alone does
not detect it. The original login is not revoked merely by that withdrawal.
Current Token/PHP source supports an optional receiving-app validation policy:
$validation = $sdk->validate($targetAccessToken, [
'allowed_algs' => ['RS256'],
'issuer' => 'https://sso.user.m7.org',
'audience' => 'https://files.example.com',
'principal_type' => 'consumer',
'm7' => ['target_client_id' => $filesOAuthClientId],
]);
if (!$validation->ok()) {
throw new \RuntimeException('Target token validation failed');
}
Choose the exact issued algorithm and trusted key-resolution policy for your
deployment. The nested target value may be a string or an allowed list.
A mismatch or missing target fails with m7_target_client_id_mismatch; there
is no fallback to client_id, an aud UUID or a flat claim name. Add a
client_id policy only when you also mean to restrict the requesting app.
Retain route scopes, sender proofs and current authorization checks separately.
The target is issuer-owned and cannot be replaced through custom token claims.
These are access-token rules. ID-token aud/azp and UserInfo's existing m7
identity projection remain separate. See the SSO claim reference
and supported exchange contract.