Authorising
Four ways to hold a token, and which one your application wants.
acyka is an OpenID provider, so none of this is bespoke: a library that already speaks OAuth 2.1 will work against it, and the discovery document says everything a client needs to configure itself.
curl https://acyka.cc/.well-known/openid-configuration Three decisions are worth knowing before you pick a flow.
- PKCE is required of every client, confidential ones included, and only
S256. A code that leaks from a log, a referer or a browser's history is then worth nothing without the verifier. - Access tokens are opaque rows, not JWTs. That is what makes “revoke” mean something: a person taking an application back at settings/security stops it immediately, rather than in an hour.
- Refresh tokens rotate, and reuse kills the family. The one handed back is the one to keep. Presenting a retired one means two parties hold the chain, and the only safe reading is that one of them stole it — so the whole chain dies.
An application acting for itself #
client_credentials. A bot looking up a title has nobody to sign in and nobody to
show a consent screen to. Confidential clients only: a public client's only proof is PKCE, and
PKCE has nothing to bind to when there is no code and no user.
// A bot, a cron, anything with no person in front of it.
const acyka = Acyka.app({ clientId, clientSecret }); It can carry catalog:read and people:read and nothing else. Asking for lists:read here is refused at the door rather than minted and then refused by every
route that reads it — a token that opens nothing is worse than a refusal, because it fails later
and somewhere else.
Acting for a person #
The authorization code flow, in three steps.
import { authorizeUrl, pkce } from '@acyka/api';
// 1. before the redirect. Keep both of these in the session.
const { verifier, challenge } = await pkce();
const state = crypto.randomUUID();
redirect(authorizeUrl({
clientId,
redirectUri: 'https://example.com/callback',
scopes: ['openid', 'profile', 'lists:read', 'offline_access'],
challenge,
state
})); import { Acyka, exchangeCode } from '@acyka/api';
// 2. in the callback. Compare the state *first*: without it, anybody who
// can make a browser visit this route can hand it a code of their own.
if (given.state !== stored.state) throw new Error('the state did not match');
const tokens = await exchangeCode({
clientId, clientSecret,
code: given.code,
redirectUri: 'https://example.com/callback',
verifier: stored.verifier
});
// 3. and from then on. `keep` is told every time the set is replaced.
const theirs = Acyka.user({ clientId, clientSecret }, tokens, (fresh) =>
save(userId, fresh)
); Store what keep hands you. Concurrent requests that all notice the
same expiry send one refresh between them — that is why this is a class rather than a helper —
but a caller who stores the original set for ever has a credential that stops working, and
presenting it again is what the server reads as theft.
Ask for offline_access if you need to act while nobody is watching. Without it
there is no refresh token at all, which is the consent screen's decision rather than something a
client can work around.
Something with no browser #
A television, a terminal, a set-top box. The device flow, RFC 8628. What these clients did before it existed is draw a login form of their own — and a password typed into whatever is running on a television is a password given to whatever is running on a television.
import { awaitDevice, startDevice, Acyka } from '@acyka/api';
const started = await startDevice({ clientId, scopes: ['openid', 'lists:read'] });
console.log(`go to ${started.verification_uri} and type ${started.user_code}`);
// Polls, backs off when told to, and stops when somebody presses cancel.
const tokens = await awaitDevice({
clientId,
deviceCode: started.device_code,
interval: started.interval
}); The code shown is eight characters from an alphabet with no 0, O, 1, I or L in it, because somebody squinting at a screen across a room will confuse
those. The dash and the case do not matter: abcd-2345 and ABCD2345 are
the same code.
A token you already have #
Acyka.token(accessToken). No refresh: when it runs out the next call raises Unauthorized, rather than a credential being swapped underneath a caller who did
not ask for that.