acyka api

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.

1 — send them
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
}));
2 and 3 — come back
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.