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.