FrançaisPlayground

Service to service

Every page before this one answers "who is using this app". This answers "which service is calling", which is the other half of most systems: a nightly job reading an API, one service talking to another, a webhook handler that needs to write back.

There is no user in any of it, so there is nothing to redirect and no login page. The service proves who it is with its own credentials.

import { createServiceClient } from '@fluixi/oauth2/service';

const orders = createServiceClient({
  issuer: process.env.OIDC_ISSUER!,
  clientId: 'reports-job',
  clientSecret: process.env.REPORTS_SECRET!,
  scopes: ['orders:read'],
});

const response = await fetch('https://api.example.com/orders', {
  headers: { authorization: `Bearer ${await orders.token()}` },
});

That is the whole thing. token() returns a valid token, fetching one when it has to and reusing it when it does not.

Server side only

The grant is the client secret. Anything holding it can mint tokens as this service, so it belongs in an environment variable on a server and never in anything shipped to a browser.

If you find yourself wanting this in front-end code, the thing you actually want is OAuth2: a user signs in, and their token carries what they may do.

Caching, and why it matters

token() keeps the token until it is close to expiring, then fetches a new one. refreshSkewMs is how close, and defaults to 30 seconds. A token that expires in flight fails a request that was fine when it started, which is the whole reason for the margin.

The second behaviour is less obvious and matters more under load. When several calls arrive on a cold cache, they make one token request between them rather than one each:

// One request to the provider, three callers served.
const [a, b, c] = await Promise.all([orders.token(), orders.token(), orders.token()]);

Without that, a burst of work at the start of a job is a burst at the token endpoint, and providers rate-limit it.

A failed request is not cached, so a provider that was briefly down is retried rather than remembered as broken.

Setting it up at the provider

The client has to be confidential, meaning it has a secret, and it needs the client credentials grant enabled. In Keycloak that is a client with Client authentication on and Service accounts roles enabled. Most providers name it something similar.

Worth knowing how the resulting token reads: a service is a principal in its own right, not an absent one. Keycloak gives it a service account, so sub is that account's id and preferred_username is service-account-<client>. It is not a person, but it is not anonymous either.

Provider differences

Auth0 wants the API named in audience, and issues an opaque token rather than a JWT without it:

createServiceClient({ issuer, clientId, clientSecret, audience: 'https://api.example.com' });

Credentials in the body. They go in an Authorization: Basic header by default, which keeps the secret out of the request body. Some providers want them in the form instead:

createServiceClient({ issuer, clientId, clientSecret, clientAuthMethod: 'body' });

A key instead of a secret. A client registered with a public key authenticates with private_key_jwt: pass privateKey (a JWK) and keyId, and no secret travels at all. Okta's org authorization server accepts nothing else for client credentials. credentialsFromEnv reads either setup from the environment and spreads straight in; see Token verification.

import { createServiceClient } from '@fluixi/oauth2/service';
import { credentialsFromEnv } from '@fluixi/oauth2/verify';

const credentials = await credentialsFromEnv({ prefix: 'orders' });
if (!credentials) throw new Error('Set ORDERS_CLIENT_SECRET, or ORDERS_PRIVATE_KEY and ORDERS_KEY_ID.');

export const orders = createServiceClient({ issuer, ...credentials });

No discovery document. Give the token endpoint directly with tokenEndpoint.

Anything else goes through extraParams, which is merged into the token request.

The other side

A service receiving one of these tokens verifies it exactly as it verifies a user's. The token names the calling client rather than a person, so the check is usually on that:

const verify = createTokenVerifier({
  issuer,
  audience: 'orders-api',
  require: (claims) => String(claims.scope ?? '').includes('orders:read'),
});

See Token verification. Scopes are how a provider says what a service may do, and checking them is the point of issuing narrow ones.

Reading the current token

current() returns what the provider sent, for a caller that needs more than the string:

const { accessToken, expiresAt, scope } = await orders.current();

reset() drops the cached token, so the next call fetches a new one. Useful after a 401 that suggests the token was revoked before its expiry.

Next: Token verification.