Skip to main content

Enforce a token at your API

You build a small notes API that trusts PolicyArc to decide each call. It sends every token it receives to /introspect with what the call needs, and serves the call only when the answer is active. At the end the API serves a read, refuses a write, and drops a token you revoked.

Before you start​

  • POLICYARC_URL and POLICYARC_ADMIN_KEY for your environment. Register a client shows how to set them.
  • curl, jq and Node.js 18 or later.

Build and run the API​

Total time: 4 min

  1. Add a resource type for the notes

    1 min

    Policy grants scopes on resource types. Add the type urn:example:notes with a read and a write scope.

    curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.resource_registry&path=resource.types.urn:example:notes" \
    -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"scopes": ["notes:read", "notes:write"]}'

    You should see. The PATCH returns the updated resource registry row.

  2. Register the API and the app

    1 min

    The API is a client too. It needs the introspection scope to call /introspect. The app that calls the API gets notes:read and nothing more.

    register() {
    curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
    -d "{\"client_name\": \"$1\", \"redirect_uris\": [\"https://$1.example/cb\"]}"
    }
    entitle() {
    curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$1" \
    -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d "$2"
    }
    api=$(register notes-api)
    export RS_CLIENT_ID=$(jq -er .client_id <<<"$api") RS_CLIENT_SECRET=$(jq -er .client_secret <<<"$api")
    app=$(register notes-app)
    export APP_CLIENT_ID=$(jq -er .client_id <<<"$app") APP_CLIENT_SECRET=$(jq -er .client_secret <<<"$app")
    entitle "$RS_CLIENT_ID" '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
    entitle "$APP_CLIENT_ID" '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:example:notes", "scopes": ["notes:read"]}]}'

    You should see. Both registrations print a client_id, and both PATCH calls return the entitlement row.

  3. Write the API

    1 min

    The API maps each method to the scope it needs. It sends the token and that need to /introspect. An inactive answer with insufficient_scope becomes HTTP 403. Any other inactive answer becomes HTTP 401.

    cat > notes-api.mjs <<'EOF'
    import http from 'node:http';

    const {POLICYARC_URL, RS_CLIENT_ID, RS_CLIENT_SECRET, PORT = '4005'} = process.env;
    const auth = `Basic ${Buffer.from(`${RS_CLIENT_ID}:${RS_CLIENT_SECRET}`).toString('base64')}`;
    const NEEDS = {GET: 'notes:read', POST: 'notes:write'};

    async function introspect(token, scope) {
    const context = JSON.stringify({resource_type: 'urn:example:notes', scopes: [scope]});
    const res = await fetch(`${POLICYARC_URL}/introspect`, {
    method: 'POST',
    headers: {Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded'},
    body: new URLSearchParams({token, context}),
    });
    if (!res.ok) throw new Error(`introspect returned ${res.status}`);
    return res.json();
    }

    http.createServer(async (req, res) => {
    const token = /^Bearer (.+)$/.exec(req.headers.authorization || '')?.[1];
    const scope = NEEDS[req.method];
    if (!token || !scope) return res.writeHead(token ? 405 : 401).end();
    try {
    const answer = await introspect(token, scope);
    if (!answer.active) {
    return res.writeHead(answer.inactive_reason === 'insufficient_scope' ? 403 : 401).end(JSON.stringify(answer));
    }
    res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({notes: ['hello'], client: answer.client_id}));
    } catch (err) {
    res.writeHead(503).end(String(err.message));
    }
    }).listen(Number(PORT), () => console.log(`notes-api on ${PORT}`));
    EOF

    You should see. notes-api.mjs exists in your working directory.

  4. Start the API

    1 min

    Start it in the background and check that a call with no token is refused. The API listens on port 4005 unless you set NOTES_PORT. If an earlier run left an API running from this directory, the block stops it first.

    if [ -f notes-api.pid ]; then
    old=$(cat notes-api.pid); kill "$old" 2>/dev/null || true
    while kill -0 "$old" 2>/dev/null; do sleep 0.1; done
    fi
    export NOTES_PORT=${NOTES_PORT:-4005}
    export NOTES_URL=http://localhost:$NOTES_PORT
    PORT=$NOTES_PORT node notes-api.mjs > notes-api.log 2>&1 &
    echo $! > notes-api.pid
    for _ in $(seq 50); do grep -q 'notes-api on' notes-api.log && break; kill -0 "$(cat notes-api.pid)" 2>/dev/null || break; sleep 0.2; done
    grep -q 'notes-api on' notes-api.log || { cat notes-api.log; echo "Expected the API to start on port $NOTES_PORT" >&2; false; }
    code=$(curl -s -o /dev/null -w '%{http_code}' "$NOTES_URL/notes")
    echo "$code"
    [ "$code" = 401 ] || { echo 'Expected HTTP 401 with no token' >&2; false; }

    You should see. curl to the API with no token prints 401.

Call it with a token​

Total time: 3 min

  1. Get a token for the app

    1 min

    The policy engine loads new entitlements within a few seconds, so the block waits first.

    sleep 5
    export APP_TOKEN=$(curl -fsS -u "$APP_CLIENT_ID:$APP_CLIENT_SECRET" "$POLICYARC_URL/token" \
    -d grant_type=client_credentials -d scope=notes:read -d resource_types=urn:example:notes | jq -er .access_token)

    You should see. An access token is stored in APP_TOKEN.

  2. Read, then try to write

    1 min
    expect() {
    local want=$1; shift
    local resp; resp=$(curl -sS -w '\n%{http_code}' "$@")
    echo "$resp"
    [ "$(tail -n1 <<<"$resp")" = "$want" ] || { echo "Expected HTTP $want" >&2; false; }
    }
    expect 200 "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN"
    expect 403 -X POST "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN"

    The read is served. The write is refused, because the token's grant does not cover notes:write. The body the API passed on is {"active":false,"inactive_reason":"insufficient_scope"}.

    You should see. GET prints 200 with the notes. POST prints 403 with insufficient_scope.

  3. Revoke the token and call again

    1 min
    curl -fsS -u "$APP_CLIENT_ID:$APP_CLIENT_SECRET" "$POLICYARC_URL/revoke" -d token="$APP_TOKEN"
    resp=$(curl -sS -w '\n%{http_code}' "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN")
    echo "$resp"
    [ "$(tail -n1 <<<"$resp")" = 401 ] && grep -qF 'invalid_token' <<<"$resp" ||
    { echo 'Expected HTTP 401 with invalid_token' >&2; false; }
    kill "$(cat notes-api.pid)"

    The API asked again, and the answer was {"active":false,"inactive_reason":"invalid_token"}. The block then stops the API.

    You should see. GET prints 401 with invalid_token.

What happened​

The token carries no claims your API can read. Every call costs one round trip to /introspect, and policy runs again on that call with the context your API sent. That is how the same token was good for a read and refused for a write. It is also why revocation took effect on the next call. Each introspection writes an audit row under the API's client ID.

Check your work​

Check your work

Next steps​