Skip to main content

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_ID and CLIENT_SECRET from Register a client. That client is entitled to client_credentials for urn:as:introspect.
  • curl, jq and openssl.

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.

ParameterValueExample
scopeThe scopes you need, space separated. Required unless the request sends authorization_details.mcp:tools
resourceA resource indicator that maps to a registered type, with no fragmenthttps://your-environment.example/gateway/mcp
resource_typesThe type itselfurn:mcp:server

What we got back for a few requests.

RequestResponse
No scopeHTTP 400 invalid_request, scope is required
A resource the server cannot map to a typeHTTP 400 invalid_target, Resource could not be resolved to a known type
A resource with a # fragmentHTTP 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_type must be code. Any other value is refused.
  • Send code_challenge_method=S256. The server refuses only plain, with invalid_request. It checks a challenge with any other method, or with no method, as S256. A request with S512 was not refused.
  • A public client, one registered with token_endpoint_auth_method none, must send a code_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.