Get, refresh and revoke tokens
Use this page once you have a client. It shows each way to get a token at POST /token, what the server refuses, and how to revoke a token.
Before you start
POLICYARC_URL,CLIENT_IDandCLIENT_SECRETfrom Register a client. That client is entitled toclient_credentialsforurn:as:introspect.curl,jqandopenssl.
Client credentials
A service or agent that acts for itself uses client_credentials. Send the client's credentials and the scope it needs.
resp=$(curl -fsS -u "${CLIENT_ID:?register a client first}:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=introspection)
echo "$resp"
export TOKEN=$(jq -er .access_token <<<"$resp")
[[ "$TOKEN" =~ ^[A-Za-z0-9]{32}$ ]] || { echo 'Expected a 32-character opaque token' >&2; false; }
The response looks like this.
{"access_token": "EXAMPLEtokenEXAMPLEtokenEXAMPLE0", "token_type": "Bearer", "expires_in": 3600, "scope": "introspection"}
The access token is opaque. By default it is 32 random letters and digits, set by pbac.oauth.timing.token-length. It is not a JWT, so a resource server cannot read claims from it. It asks /introspect instead, as First enforcement shows. By default tokens last 3600 seconds, set by pbac.oauth.timing.token-expires-in-seconds, unless policy sets another lifetime.
scope is the granted set, which can be smaller than what you asked for. The response can also carry refresh_token, id_token, authorization_details and issued_token_type, depending on the grant.
Name the resource
A token request names what it is for with these parameters.
| Parameter | Value | Example |
|---|---|---|
scope | The scopes you need, space separated. Required unless the request sends authorization_details. | mcp:tools |
resource | A resource indicator that maps to a registered type, with no fragment | https://your-environment.example/gateway/mcp |
resource_types | The type itself | urn:mcp:server |
What we got back for a few requests.
| Request | Response |
|---|---|
No scope | HTTP 400 invalid_request, scope is required |
A resource the server cannot map to a type | HTTP 400 invalid_target, Resource could not be resolved to a known type |
A resource with a # fragment | HTTP 400 invalid_request, resource must not include a fragment |
Authorization code with PKCE
A client that acts for a user sends the user's browser to /authorize. The user signs in at your identity provider, and the client exchanges the returned code at /token. Your environment needs an identity provider for this flow. Connect an identity provider sets one up.
We have not run this flow by hand for this page. The integration tests test_oauth21.py and test_authorize.py drive it against a mock identity provider.
PKCE is required in this shape.
response_typemust becode. Any other value is refused.- Send
code_challenge_method=S256. The server refuses onlyplain, withinvalid_request. It checks a challenge with any other method, or with no method, asS256. A request withS512was not refused. - A public client, one registered with
token_endpoint_auth_methodnone, must send acode_challenge. Policy can require one from other clients as well, through the PKCE control.
Make a verifier and its challenge.
export CODE_VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')
export CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=\n')
[ ${#CODE_CHALLENGE} -eq 43 ] || { echo 'Expected a 43-character S256 challenge' >&2; false; }
Send the user's browser to a URL of this shape. redirect_uri must be one the client registered. Add openid to scope to also get an ID token.
$POLICYARC_URL/authorize?response_type=code
&client_id=$CLIENT_ID
&redirect_uri=https://reports-agent.example/cb
&scope=openid
&state=<a random value you check on return>
&code_challenge=$CODE_CHALLENGE
&code_challenge_method=S256
The browser comes back to redirect_uri with code and state. By default a code expires after 5 minutes, set by pbac.oauth.timing.auth-code-expiry-minutes. A code works once.
curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://reports-agent.example/cb \
-d code_verifier="$CODE_VERIFIER"
A public client sends client_id as a form field instead of -u. A wrong verifier returns invalid_grant, Invalid code_verifier.
What the server refuses
This block sends a plain challenge and an implicit-flow request to /authorize, and an implicit grant to /token. Each is refused.
expect() {
local want=$1 text=$2; shift 2
local resp; resp=$(curl -sS -w '\n%{http_code}' "$@")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = "$want" ] && grep -qF "$text" <<<"$resp" ||
{ echo "Expected HTTP $want with $text" >&2; false; }
}
authorize="$POLICYARC_URL/authorize?client_id=$CLIENT_ID&redirect_uri=https%3A%2F%2Freports-agent.example%2Fcb&scope=openid"
expect 400 "'plain' is not allowed; use S256" "$authorize&response_type=code&code_challenge=$CODE_VERIFIER&code_challenge_method=plain"
expect 400 "response_type must be 'code'" "$authorize&response_type=token"
expect 400 '"error":"unsupported_grant_type"' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" -d grant_type=implicit
The two /authorize refusals are HTTP 400 JSON bodies with invalid_request. They are not redirects. The /token refusal is unsupported_grant_type. The password grant gets the same answer. The grants /token accepts are authorization_code, client_credentials, refresh_token, token exchange and CIBA.
Refresh a token
curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN"
By default a refresh token lasts 7 days, set by pbac.oauth.timing.refresh-token-expires-in-seconds. A public client always gets one from the code exchange, and each refresh replaces it, unless the token is bound with DPoP. A confidential client gets one only when its software statement lists refresh_token in grant_types. A refresh can narrow scope or resource, but asking for more is refused. A scope outside the original grant returns invalid_scope, scope exceeds the originally granted scope. A resource outside the original one returns invalid_target, resource is outside the originally granted resource, and a resource on a refresh whose grant named none returns invalid_target, resource is not part of the original grant. At the code exchange, a resource outside the one sent to /authorize returns invalid_target, resource is outside the one authorized. We read this in the code and have not run it by hand.
Revoke a token
Send the token to /revoke with your client's credentials.
resp=$(curl -sS -D - -o /dev/null -w '%{http_code}' -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/revoke" -d token="$TOKEN")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 200 ] && grep -qi '^content-length: 0' <<<"$resp" ||
{ echo 'Expected HTTP 200 with an empty body' >&2; false; }
resp=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" "$POLICYARC_URL/introspect" -d token="$TOKEN")
echo "$resp"
jq -e '.active == false and .inactive_reason == "invalid_token"' <<<"$resp" >/dev/null ||
{ echo 'Expected the revoked token to introspect as inactive' >&2; false; }
/revoke answers HTTP 200 with an empty body, whether or not the token existed. It revokes only a token issued to the client that calls it. We sent another client's token with our own credentials, got 200, and that token stayed active. We read in the code that a call with no client credentials can still revoke a public client's token. It removes an access token or a refresh token. Add token_type_hint=refresh_token to look for a refresh token first. After revocation, introspection reports {"active": false, "inactive_reason": "invalid_token"}.
Ask a person to approve
When policy requires a person to approve a request, /token answers HTTP 400 with approval_required. The body names the backchannel endpoint in bc_authorize_endpoint and the approver in login_hint. Human approval with CIBA covers that flow.
Send feedback about this page