Token endpoint

This page describes the ordinary OAuth endpoint. Opted-in tenant browsers use browser-direct token pickup and refresh, which sends JSON and mandatory endpoint proofs, holds refresh credentials at SSO, and exposes only its public token package. Ordinary client authentication below is unchanged.

POST https://sso.user.m7.org/token
Content-Type: application/x-www-form-urlencoded

Use the token endpoint to exchange authorization codes and device codes, refresh an existing package, obtain a service token with client_credentials, or delegate user access with RFC 8693 Token Exchange. It accepts only POST.

Client authentication is always tied to the OAuth client's configured token_endpoint_auth_method. A client must also be enabled for the requested grant type.

Client authentication

Method Request contract
none Send client_id in the form body. Suitable for public clients. It cannot use client_credentials or Token Exchange.
client_secret_basic Send HTTP Authorization: Basic base64(client_id:client_secret).
client_secret_post Send client_id and client_secret in the form body.
client_secret_jwt Send client_id, client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, and a signed client_assertion.
private_key_jwt Send the same assertion fields. The client must have an inline jwks or HTTPS jwks_uri configured.
tls_client_auth Present the registered CA-issued client certificate and send client_id in the form body to the discovered mTLS token alias.
self_signed_tls_client_auth Present the exact registered self-signed client certificate and send client_id in the form body to the discovered mTLS token alias.

For JWT client assertions, iss and sub must equal the client_id. The aud claim may identify the canonical authorization-server issuer https://sso.user.m7.org or the exact invoked endpoint URL. The assertion must contain a current iat, unexpired exp, and unique jti. M7 rejects replayed and stale assertions. Supported private_key_jwt signing algorithms are RSA (RS256, RS384, RS512) and ES256; configure the intended algorithm on the client. client_secret_jwt uses a configured HMAC algorithm: HS256, HS384, or HS512. Dynamic Registration selects HS256 when that method's algorithm is omitted and RS256 when a private_key_jwt algorithm is omitted; the effective algorithm is returned and stored authoritatively.

For either mTLS method, use https://mtls-sso.user.m7.org/token, as published in mtls_endpoint_aliases. The authorization-server issuer remains https://sso.user.m7.org. When tls_client_certificate_bound_access_tokens=true, successful issuance places the presented leaf certificate's SHA-256 thumbprint in cnf."x5t#S256". Refresh requests and protected-resource requests must then present the same certificate; there is no bearer fallback.

client_secret_jwt and HS256/384/512 token signing need recoverable HMAC key material. API User stores it in a versioned AES-256-GCM envelope. Secret-JWT uses it for assertion verification; HS token issuance uses the same original secret through the protected signing handoff. API User and SSO, plus ID.M7 for token signing, must have the same M7_OAUTH_CLIENT_SECRET_JWT_KEYS key ring (64-character hexadecimal keys, newest first); M7_OAUTH_CLIENT_SECRET_JWT_KEY remains a single-key fallback. One-way legacy hashes must be replaced by setting a recoverable secret before secret-JWT authentication or HMAC issuance. See HMAC setup.

The current-source eligibility guard rejects a saved none + HS256/384/512 configuration with HTTP 400 unauthorized_client, before code/device pickup or refresh exchange. Posted signing/authentication settings cannot override the saved client. Public clients must use an asymmetric signing algorithm. Local acceptance is complete; production verification of this restriction remains pending. Existing credentials are not revoked by this check.

DPoP policy

After authenticating the client, /token applies its registered dpop_policy consistently to authorization-code, device-code, refresh-token, and client-credentials grants:

Policy Required behavior
disabled /token ignores DPoP and prevents new DPoP or fingerprint binding. An existing DPoP-bound refresh lineage cannot refresh under this setting; start a new authorization.
optional Omit DPoP for an unbound credential, or send a valid proof to bind the issued lineage. Supplied fingerprint bindings are retained.
required Send a valid DPoP header on every token request. Fingerprint binding remains optional.

A supplied proof must be an ES256 dpop+jwt for the exact token endpoint and HTTP POST, with a current iat and nonempty, per-request jti. Missing or invalid required material returns invalid_dpop_proof before an authorization code or device code is consumed and before token issuance begins. Generate a new proof for every request and retry. M7 atomically retains an opaque hash of the verified key thumbprint and jti for the proof window; concurrent or later reuse of the same proof returns invalid_dpop_proof.

The proof sent to /token binds the token request itself and therefore does not contain the protected-resource ath claim. Resource proofs for a returned DPoP access token are separate and must bind the exact access token.

This client setting governs customer application tokens only. It does not change M7's internal SSO root-session binding. See DPoP status. Changing it does not migrate live refresh lineages. Existing packages that no longer fit the configured policy may fail and require authorization again.

Discovery advertises ES256 through dpop_signing_alg_values_supported. Dynamic Registration uses the standard dpop_bound_access_tokens boolean; true maps to required DPoP, while omission or false retains opportunistic DPoP. M7 does not currently issue DPoP-Nonce; clients still need a new proof and unique jti for each request and retry.

Authorization-code exchange

Send these form fields after receiving a code at the registered callback:

Field Required Notes
grant_type Yes authorization_code
code Yes The authorization code from /authorize. It is single-use.
redirect_uri Yes Exactly the same registered URI used in the authorization request.
code_verifier Yes The original, exact 43-to-128-character unreserved PKCE verifier. M7 verifies its SHA-256 challenge and does not trim or normalize it.
fingerprint No A 64-character hexadecimal M7 credential binding, when the client integration uses one.

If the original authorization request used dpop_jkt, the /token request must carry a valid DPoP proof from that exact key. The proof both satisfies the authorization-code binding and determines the cnf.jkt sender constraint on the issued token lineage.

Authenticate the client as described above. See the complete browser flow in Quickstart.

Token Exchange (RFC 8693)

The local-user, single-target profile is deployed and advertised. Consumer RS256 exchange, access-only/offline selection, delegated strict refresh, permission withdrawal and cleanup passed live acceptance on 2026-09-20. This evidence does not establish every tenant, signing or sender-binding combination. Check discovery's grant_types_supported before integration.

An authenticated confidential application can exchange its original M7 user access token for access to one connected provider. Register the canonical exchange grant on the requesting client and configure the provider's connection policy. The provider must permit exchange, and the connection must be active and approved under that policy. Owner-proxy admin applications cannot use this grant.

Field Required Contract
grant_type Yes urn:ietf:params:oauth:grant-type:token-exchange
subject_token Yes Active local M7 user access token issued to the authenticated client.
subject_token_type Yes urn:ietf:params:oauth:token-type:access_token
requested_token_type No Omit or use urn:ietf:params:oauth:token-type:access_token.
audience or resource Yes Exactly one target. It must match the connection audience and both apps' registered audiences. resource must be an absolute URI without a fragment.
scope No Space-delimited subset of the source token, requesting client's allowed scopes, connection scopes and provider's exchange scope ceiling.
fingerprint Conditional Existing M7 fingerprint binding when required by the subject credential.

Parameters cannot repeat. If both audience and resource are supplied, they must identify the same target. This profile does not accept actor_token, external JWT/SAML subjects, client-credentials or refresh-token subjects, ID-token subjects, redelegation, custom claim overrides, or HMAC token signing. Both apps must use the same identity realm: the consumer realm, or the same organization. Tenant subjects require a current delegated M7 identity or consumer-user link; native federated tenant shadow credentials are not supported by this initial exchange profile.

curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/token' \
  --user 'CLIENT_ID:CLIENT_SECRET' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  --data-urlencode 'subject_token=USER_ACCESS_TOKEN' \
  --data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
  --data-urlencode 'audience=https://files.example.com' \
  --data-urlencode 'scope=files:read'

With no offline_access in the requested scope, the response contains only the access credential, even when the source authorization permits offline access. Omitted scope uses the allowed intersection excluding offline_access. Explicitly requesting authorized offline_access returns access and refresh credentials together, including the normal M7 binding metadata. The requesting client must also register refresh_token. Requesting requested_token_type=...:refresh_token is not a bundle-selection mechanism and is rejected. These are M7 issuance-policy choices under RFC 8693's optional refresh-token response. A refresh token is usable without presenting the previous access token.

{
  "access_token": "ACCESS_TOKEN",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "files:read"
}

The JWT preserves the user sub, identifies the requesting app in client_id and act.sub, and stamps the receiving app's registered OAuth UUID in m7.target_client_id. Its aud contains the selected resource audience and the issuer-added receiving-app UUID. Introspection includes m7 and act. See requester and target claims before applying a client restriction; client_id is not the receiving app. No ID token is issued by this grant. Existing DPoP or certificate binding must be proved and is retained; ordinary client authentication and DPoP replay checks still apply. Initial access lifetime cannot exceed the remaining subject token lifetime. Registered client lifetimes, the provider's exchange TTL cap, and app/connection expiration further limit issued credentials.

Refresh an exchanged bundle using the ordinary refresh_token grant below. Every refresh rechecks both registrations, current subject identity and tenant group admission, connection approval, audiences, and the complete original scope ceiling. It preserves the actor and target and cannot extend the delegation's absolute offline expiration. Only scope subsets, the existing binding/fingerprint package and permitted refresh lifecycle selection apply; custom claims, requested lifetimes and target overrides do not change this profile's issuance policy. Obtain a new authorization when the original scope ceiling is no longer permitted.

Withdrawal of connection or exchange approval blocks new exchange and delegated refresh. It does not, by itself, revoke the original source login. An offline JWT check alone cannot observe a policy change; use the applicable live token and connection verification checks where current permission is required.

External token acceptance is out of scope. Impersonation, multiple targets and chained exchanges are deferred and unsupported in this profile. The SDK exchange guide covers the current-source PHP operation and CLI command, including safe storage and the distinction from ordinary refresh.

Refresh-token exchange

The standard advisory binding policy permits a refresh request with the refresh token and client authentication. M7 binding-required profiles additionally need the stored binding package:

Field Required Notes
grant_type Yes refresh_token
refresh_token Yes The refresh credential. M7 also accepts the legacy alias token.
binding_chain Conditional Required by a binding-required profile or legacy personal-lineage signing. Save the value returned with the refresh token.
binding_link Conditional Send with binding_chain when the binding package is required or supplied.
fingerprint Conditional Required when the existing lineage is fingerprint-bound. Exactly 64 hexadecimal characters.
new_fingerprint No A replacement binding fingerprint. When fingerprint is supplied and new_fingerprint is omitted, M7 keeps the same value.
scope No A space-delimited subset of the authenticated refresh lineage. Omission uses the lineage's full granted scope.
access_expires No Requests the next access-token lifetime when allowed by policy.
access_claims No Requests allowed access-token claims when allowed by policy.
refresh_mode No M7 lifecycle extension; normally omit it. See below.

The optional binding policy permits omission but validates a supplied package; the required policy requires a valid complete package. Registration and provider policy determine the mode; request input cannot select it.

The refresh token must belong to the authenticated client_id. Persist the full replacement package before removing the old package. If the requested refresh cannot proceed, keep the prior package unless the service has returned a confirmed replacement or terminal error that makes it unusable.

A refresh request cannot add a scope absent from either the signed lineage or the client's current registration. A requested subset limits the new access token; it does not rewrite the refresh lineage's original maximum scope. If client policy removes authority already present in the lineage, refresh fails and the application must start a new authorization.

The refresh lineage's resource-audience set is immutable. Do not send aud on a refresh request; obtain a new authorization or grant to change the set. Refresh also fails when current client policy no longer allows any lineage audience.

Refresh lifecycle extension

Without refresh_mode, M7 currently uses strict_rotation. Supported modes are static, strict_rotation, grace_rotation, ack, and ack_supersede_pending. Most applications should omit this parameter.

Both ACK modes return an unusable pending successor while active R0 remains active. ACK of the eligible package activates it and revokes R0. The modes differ when R1 is already pending: ack rejects another R0 refresh with refresh_in_progress, while ack_supersede_pending lets R0 issue R2, atomically revokes never-active R1, and makes only R2 eligible for ACK. ack_supersede_pending is a normal lifecycle, not a recovery-only mode.

The two modes have identical client, refresh-package binding, activation-secret, and conditional DPoP requirements. They require durable, atomic persistence and explicit handling for lost refresh or ACK responses, idempotent ACK retries, and terminal stale activations. See Token acknowledgement for the complete state and error contract.

Client credentials

Use this grant for a machine application acting as itself, never as an end user. The client must be confidential and be enabled for client_credentials.

Field Required Notes
grant_type Yes client_credentials
aud Yes One or more exact requested audiences from the client's registered allowed_audiences. Repeat the field for multiple values.
scope No Requested space-delimited scope string, subject to client policy.
access_expires No Requested access lifetime. expires_in and expires are accepted aliases.
claims No Requested JSON claims object. payload is an accepted alias.
fingerprint No 64-character hexadecimal M7 binding.
label No A management label for the issued record.

Example:

curl --fail-with-body --silent --show-error \
  --request POST 'https://sso.user.m7.org/token' \
  --user 'CLIENT_ID:CLIENT_SECRET' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'aud=https://api.example.com' \
  --data-urlencode 'scope=file.read file.write' \
  --data-urlencode 'access_expires=900'

This flow returns an access token only; it does not issue a refresh token. A client using token_endpoint_auth_method=none is rejected for this grant.

Device-code exchange

Use this only after obtaining device_code from /device_authorization:

Field Required Notes
grant_type Yes device_code. The standard urn:ietf:params:oauth:grant-type:device_code identifier is also accepted.
device_code Yes Secret device code returned by /device_authorization.

Authenticate the same client that initiated the device flow. If the user has not approved yet, the response is authorization_pending. Observe the response's interval; do not poll more frequently. A device code is consumed after successful pickup.

Token responses

The response varies by grant and client policy. A user-authorized success typically contains:

{
  "token_type": "Bearer",
  "access_token": "ACCESS_TOKEN",
  "expires_in": 900,
  "refresh_token": "REFRESH_TOKEN",
  "refresh_expires_in": 2592000,
  "binding_chain": "BINDING_CHAIN",
  "binding_link": "BINDING_LINK",
  "scope": "openid profile",
  "id_token": "ID_TOKEN"
}

For authorization-code and device-code flows, id_token is returned when openid was granted. RFC 8693 exchange does not return an ID token. An access token from client_credentials does not have a user refresh package. Treat every token and every binding or activation field as a secret.

The response above is the OAuth token-package envelope, not the decoded access token payload. See Access-token formats for the consumer, tenant-member, and OAuth-application JWT claim profiles.

The example is an unbound response. M7 derives token_type from the access JWT in every supported grant and refresh envelope:

  • Bearer means the access token has no cnf sender constraint;
  • DPoP means the complete cnf object contains only a valid jkt; and
  • a malformed, legacy, mixed, or ambiguous confirmation shape fails closed with server_error instead of returning the access token.

For DPoP, retain the private key for the complete lineage and use the exact authorization and proof contract required by each protected resource. See DPoP status.

Errors

Errors use OAuth JSON fields such as invalid_request, invalid_client, invalid_grant, unauthorized_client, invalid_scope, invalid_target, and unsupported_grant_type. invalid_dpop_proof means the required proof was missing or the supplied proof was invalid. Do not branch on prose in error_description.

An unexpected persistence, configuration, dependency, or processing failure returns server_error with the safe description An unspecified error occurred. Please try again. and an opaque trace_id. Retain the trace ID for support correlation only. Sanitized internal failure metadata is logged privately. Raw exception text and downstream diagnostics are never part of the token response.