Core Concepts

Consumer Identity

Tell Kipchak who each request is from, once, for usage metering and quotas, whether clients use API keys, tokens from an identity provider, or signed requests.

Introduction

Authentication decides whether a request may proceed. Billing and quotas need something more: a stable name for who the request is from, the consumer, and often the organisation it belongs to, the tenant.

Where that name comes from depends on how clients authenticate:

AuthenticationClients are definedThe consumer is
API keysIn Kipchak, in kipchak.auth.keyThe client name the key maps to
JWKS or JWTIn your identity provider (Keycloak, Auth0, Entra ID, Cognito, ...)A claim in the verified token, such as client_id, azp or sub
Request SigningIn Kipchak, in kipchak.auth.hmacThe key ID of the verified signature
An API gateway in frontIn the gatewayA header the gateway sets

config/kipchak.identity.php describes this once. Usage Metering and Quotas both read it, so a client is the same consumer on the bill and in the limits.

kipchak/identity is installed as a dependency of those packages; there is nothing to install or initialise.

Configuration

<?php
// config/kipchak.identity.php

return [
    // Who the request is billed and limited as. Sources are tried in order; the first that answers wins.
    'consumer' => [
        ['source' => 'token', 'claim' => 'client_id'],   // tokens from your identity provider
        ['source' => 'api_key'],                         // auth-key clients
    ],

    // Which organisation it belongs to. Optional.
    'tenant' => [
        ['source' => 'token', 'claim' => 'org_id'],
    ],
];

A request that no source can identify has no consumer. Metering does not record it by default, and quotas let it through by default, leaving the decision to authentication.

Sources

SourceOptionsReads
tokenclaimA claim of the token that auth-jwks or auth-jwt verified. Dots reach into nested claims: org.id.
api_keykeys, header, query_paramThe client name for the request's API key in kipchak.auth.key's authorised_keys. The key itself is never used as an identity, so it never reaches the event stream or the counter store.
attributename, claimAny request attribute set by an earlier middleware, such as kipchak.hmac.key_id.
headername, hashA request header. With 'hash' => true, sha256:<hex> of it.
callableresolveYour own function: fn (ServerRequestInterface $request): ?string.

Every source also takes a map, which translates what it found into a name. A value that is not in the map resolves to nothing, and the next source is tried:

['source' => 'token', 'claim' => 'client_id', 'map' => [
    '0oa1b2c3d4e5f6' => 'acme-corp',
    '0oa9z8y7x6w5v4' => 'globex',
]],

Where identity can be read from

Identity is read from the request when the metering or quota middleware runs. A middleware can only see what the middlewares that ran before it attached to the request, so authentication must run first.

Slim runs the middleware added last first. In middlewares/middlewares.php, initialise quotas and metering before authentication:

Error::initialise($app);
Quotas::initialise($app);        // runs after authentication and metering
Metering::initialise($app);      // runs after authentication
Key::initialise($app);           // authentication
JWKS::initialise($app);
SubashiPro::initialise($app);    // the firewall: runs first of all

Authentication added to a single route or group runs after every global middleware, so global metering and quotas cannot see what it verifies. Authenticate globally (using ignore_paths for public routes), or attach Meter and Quota to the same routes, after the authentication middleware:

$app->get('/v1/reports', [Reports::class, 'list'])
    ->add(new Quota($app->getContainer()))
    ->add(new Meter($app->getContainer()))
    ->add(new AuthJWKS($app->getContainer()));    // added last, so runs first

Never trust an unverified identity

  • token reads only the request attribute that auth-jwks and auth-jwt set after verifying the token. A bearer token that has not been verified is never read, so a forged token cannot claim to be another client.
  • api_key only answers for keys listed in authorised_keys.
  • header is safe only for a header your gateway sets and overwrites. A header the client controls lets the client bill or limit someone else.

Examples

API keys (auth-key)

Clients are defined in Kipchak. The key identifies them, and the name it maps to is the consumer:

// config/kipchak.auth.key.php
'authorised_keys' => [
    'k_live_8f2c...' => 'acme-corp',
    'k_live_31d0...' => 'globex',
],
// config/kipchak.identity.php
return [
    'consumer' => [['source' => 'api_key']],
];

A request with apikey: k_live_8f2c... is billed and limited as acme-corp. A new key for the same client, for rotation, maps to the same name, so its usage continues on the same account.

Tokens from an identity provider (auth-jwks, auth-jwt)

Clients are defined in your identity provider, which issues signed tokens. Which claim names the consumer depends on what you are billing:

You bill perClaimTypical in
Application (machine-to-machine, OAuth client credentials)client_id, or azpAuth0, Okta, Keycloak, Cognito
UsersubEvery provider
Organisationa custom claim such as org_id, as the tenantAuth0 Organisations, Keycloak, Entra ID (tid)
// config/kipchak.identity.php
return [
    'consumer' => [
        ['source' => 'token', 'claim' => 'client_id'],   // machine-to-machine clients
        ['source' => 'token', 'claim' => 'azp'],         // user tokens: the application acting for the user
    ],
    'tenant' => [
        ['source' => 'token', 'claim' => 'org_id'],
    ],
];

The provider's client IDs are opaque. To bill under readable names, add a map, or keep the IDs and map them in your billing system.

Several identity providers

When tokens from more than one provider are accepted, the issuer (iss) tells them apart. Map it to a name and use it as the tenant, so each provider's users are counted and billed separately:

'tenant' => [
    ['source' => 'token', 'claim' => 'iss', 'map' => [
        'https://login.acme.example/realms/main' => 'acme-sso',
        'https://globex.eu.auth0.com/' => 'globex-sso',
    ]],
],

To limit everyone signing in through one provider together, see Limits shared by everyone from one identity provider.

Signed requests (auth-hmac)

Request Signing puts the verified key ID on the request:

'consumer' => [
    ['source' => 'attribute', 'name' => 'kipchak.hmac.key_id'],
],

A gateway in front

When an API gateway authenticates and passes the result on in headers it controls:

'consumer' => [['source' => 'header', 'name' => 'X-Consumer-ID']],
'tenant' => [['source' => 'header', 'name' => 'X-Tenant-ID']],

Several kinds of client at once

Sources are tried in order, so one API can serve all of them:

'consumer' => [
    ['source' => 'token', 'claim' => 'client_id'],
    ['source' => 'attribute', 'name' => 'kipchak.hmac.key_id'],
    ['source' => 'api_key'],
],

Overriding per package

The metering and quota configs can set their own consumer or tenant, which then replaces the shared one for that package only. Each key falls back separately, so a package can override the tenant and keep the shared consumer.

Setting the consumer from a route

When the identity is only known inside the route, set it there:

use Kipchak\Middleware\Metering\Usage;

Usage::of($request)?->setConsumer($account->billingId);

This applies to metering, which reads the identity after the route has run. Quotas decide before the route runs, so they need the identity from a source.

Worker mode

The auth-jwks and auth-jwt middlewares also keep the decoded token in the container under token, as before. In FrankenPHP worker mode the container lives for the whole worker, so that entry still holds the previous request's token on later requests that carry none. In testing, 12 of 12 unauthenticated requests served after authenticated ones saw a previous caller's token there.

The token source reads the request attribute, which exists only on the request that carried the verified token. Use the attribute in your own code too:

$token = $request->getAttribute('kipchak.auth.token');   // null on routes without a verified token
Previous
Data Transfer Objects (DTOs)
Next
Config