Typescript client for a mogh_auth_server auth api: the request and
response types, a typed client, the login token store of the browser,
the passkey helpers, and the request helpers the apps' clients send
their own requests with.
import * as MoghAuth from "mogh_auth_client";
const auth = MoghAuth.MoghAuthClient(
"https://example.com/auth",
MoghAuth.LOGIN_TOKENS.jwt(),
);
const options = await auth.login("GetLoginOptions", {});The second argument is the credential the client sends with its
requests (MoghAuth.ClientCredential):
- a JWT, sent as
Authorization: a string, or{ jwt }. - an api key,
{ key, secret }, sent asX-API-KEY/X-API-SECRET. The server takes it for the manage requests open to api keys, eg.GetUserId,DeleteApiKey, and an admin's login provider / trusted issuer requests. A request with an api key doesn't follow redirects (see Redirects of requests with an api key).
A jwt is sent when set, otherwise the api key when both its parts are,
otherwise nothing (only the session cookie goes along). So an app client
passes the { jwt, key, secret } it was created with as is.
credentialHeaders(credential) gives the same headers for the app's own
requests.
Every request rejects with { status, result, error? }
(MoghAuth.RequestError):
statusis the http status, or1when the server wasn't reached (network / CORS failure).resultis the error body of the server:{ error, trace }, whereerroris always a string andtracea list of strings. When the body isn't json (eg. a proxy's502page), or is json of another shape (eg. a gateway's{"error":{"code":403}}),errornames the status andtraceholds the start of the body. A200with an invalid body rejects withInvalid response body.serveristruewhenresultis the server's own error body (json with a stringerrorand atracelist). Not for the error page of a proxy, nor for the401/403of an auth gateway in front of the server: those say nothing about the credentials the request was sent with.erroris the caught error, if any.
isReauthenticationRequired(e) tells whether a manage request needs the
user to log in again. It takes anything that was caught, never throws,
and is false for any other value.
tokenExchange rejects the same way, with the OAuth error
{ error, error_description } as result. A body which isn't an OAuth
error is server_error, with the status and the start of the body as
error_description. temporarily_unavailable means retry later: 429
after too many failed requests, 503 while a login provider or trusted
issuer of the token's issuer can't be loaded.
The apps' clients send their requests the same way, so a UI handles the failures of auth and app requests alike, and the rules above live in one place:
const notes = await MoghAuth.fetchJson<Note[]>(`${url}/read/ListNotes`, {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json", authorization: jwt },
});fetchJson<Res>(input, init)sends the request withfetchand resolves with the json of a200. It rejects with aRequestErroron any failure, never with anything else.fetchResponse(input, init)resolves with the200response itself, eg. to stream its body, and rejects likefetchJson.- For a response fetched another way:
responseJson(response)(the json of a200, or the rejection),responseError(response)(what a status other than200rejects with) andrequestFailed(error)(what a request which got no response rejects with).
fetchJson and fetchResponse (and so MoghAuthClient) send a request
carrying an api key (X-API-KEY / X-API-SECRET) with
redirect: "error", unless its init sets redirect itself: a redirect
then rejects with status: 1 instead of being followed. fetch outside
a browser (node, Deno, Bun, a Komodo Action) drops only Authorization
(and the browser's cookies) on a redirect to another origin, and sends
every other header on to wherever the redirect points: the login page of
an SSO proxy in front of the server, or the new address of a server
which moved, would get the api key, and a 307 / 308 sends the body
again too. A JWT in Authorization is dropped, so such requests follow
redirects as usual. A request with an api key sent with plain fetch
should pass redirect: "error" too.
LOGIN_TOKENS keeps the tokens of the signed in users in localStorage
(key mogh-auth-tokens-v1).
- The stored tokens are shared by every tab of the origin: a login or a logout in one tab applies to all of them. Every call reads the latest stored state, so tabs never undo each other's changes.
- The current user is kept per tab. A tab starts with the user chosen
last (in any tab), and only changes it by its own
add_and_change,changeorremove. Signing in another user or switching the user in one tab leaves the other tabs with their user, so a tab never starts sending the token of another user than the one it shows. - When a tab's user signs out in another tab, that tab is signed out:
jwt()gives"". It resumes when the user signs in again. LOGIN_TOKENS.subscribe(listener)callslistenerafter a change in this or another tab, eg. to show the login page and drop the cached data when the user signed out in another tab. It fits React'suseSyncExternalStore(LOGIN_TOKENS.subscribe, LOGIN_TOKENS.jwt).- Apps served from the same origin (several apps behind one host, or
localhostin development) share the default key. Give each app its own store withcreateLoginTokens({ key: "my-app-tokens" }), unless it uses the auth pages and hooks ofmogh_ui: they always use the defaultLOGIN_TOKENS, so the app has to use it too (a store of its own would stay empty after every login). - Where
localStorageis unavailable (in node, or in a browser blocking site data, eg. in an iframe with third party cookies blocked), the tokens are kept in the page's memory instead, so the user can still log in: the login lasts until the page is closed or reloaded, and other tabs don't see it. A blockedlocalStorageis warned about once. SoLOGIN_TOKENSis always a store, neverundefined. - The storage is read on first use, not on import: importing the
package (eg. for the client in node, where reading
localStoragewarns) touches no storage.
Every request with a token the server refuses counts against its per IP
auth rate limit, which logging in shares. So a token the server refused
is never sent again, by any caller: the app's client and the auth and
supporter hooks of mogh_ui share the one latch of LOGIN_TOKENS.
const jwt = MoghAuth.LOGIN_TOKENS.sendableJwt();
if (!jwt) return showLoginPage();
try {
return await MoghAuth.MoghAuthClient(url, jwt).manage("GetUserId", {});
} catch (e) {
if (MoghAuth.isTokenRefusal(e, [401, 403])) {
MoghAuth.LOGIN_TOKENS.refuse(jwt);
}
throw e;
}isTokenRefusal(e, refusedOn = [401])tells whether a failed request is the server refusing the token: a status ofrefusedOnwith the server's own error body (RequestError.server), not the401/403of a proxy or an auth gateway in front of it. Pass[401, 403]for a request whose403also means that the session is over (GetUserId, an app'sGetUser), rather than a missing permission.LOGIN_TOKENS.refuse(jwt)notes the refusal.sendableJwt()gives this tab's token unless it was refused (""then),isRefused(jwt)checks a given token.subscribeRefusals(listener)callslisteneron each new refusal, eg. to show the login page. Thesubscribelisteners are called too, souseSyncExternalStore(LOGIN_TOKENS.subscribe, LOGIN_TOKENS.sendableJwt)re-renders.- Refusals are kept per tab, until the page is reloaded. A new login gives a new token, which is sent.
navigator.credentials
.get(MoghAuth.Passkey.prepareRequestChallengeResponse(challenge))
.then((credential) =>
auth.login("CompletePasskeyLogin", {
credential: MoghAuth.Passkey.credentialToJSON(credential),
}),
);credentialToJSON base64url encodes the binary fields of the credential
where the browser (or a password manager extension) has no
PublicKeyCredential.toJSON. The client applies it to
CompletePasskeyLogin and ConfirmPasskeyEnrollment itself.
safeBackto() gives the backto query of the login page when it is a
path on the current origin, otherwise /. Check backto with it before
navigating there after a login. The result is a normalized path which
never starts with //, even for input like /.//evil.example, so it
can be passed to location.replace or a router as is.
externalLogin uses it too: started on the login page itself (/login
or /login/, not other paths starting with it like
/login-providers/:id), the provider sends the user back to the checked
backto, from anywhere else back to the current page.
npm run build # tsc, into dist
npm test # build, then the node tests in test/src/types.ts is generated from the rust types:
node auth/client/ts/generate_types.mjs (needs typeshare), which
fails when typeshare fails.