@hyper.authio/client
v0.10.1
Published
Authio client for Node.js and edge runtimes: discovery, interactive sign-in with ID tokens and logout, every grant, JWE, introspection, client-credentials cache, Admin API credentials, roles and permissions, webhooks.
Maintainers
Readme
@hyper.authio/client
The Authio client for Node.js 20.19+ and edge runtimes: discovery, interactive sign-in (PKCE, ID tokens, end session,
back-channel logout tokens, step-up), every grant, client authentication with a secret or private_key_jwt,
introspection, revocation, UserInfo, JWE, a cached client-credentials fetch, account credentials for the Admin API,
the roles and permissions of a token, and webhook verification. Its only dependency is
jose.
npm install @hyper.authio/clientConfigure
import { createAuthioClient } from "@hyper.authio/client";
const authio = createAuthioClient({
issuer: "https://auth.example.com/realms/acme", // or authority + realm
clientId: "orders-worker",
clientSecret: process.env.AUTHIO_CLIENT_SECRET,
});| Option | Default | |
| --- | --- | --- |
| issuer, or authority + realm | | The realm's issuer. Must be HTTPS, except on loopback or with requireHttps: false. |
| clientId | | Required. |
| tokenEndpointAuthMethod | client_secret_basic with a secret, private_key_jwt with only a key, none with neither | The client's authentication method in Authio, which matches it exactly: client_secret_basic, client_secret_post, private_key_jwt or none (a public client). |
| clientSecret | | For client_secret_basic / client_secret_post. A function returning the secret is called for every request, so a rotated secret is used without a restart. |
| clientPrivateKey, clientPrivateKeyPath, clientKeyId, clientSigningAlgorithm | RS256 for RSA, the curve's ES algorithm for EC | For private_key_jwt: PEM (PKCS#8, PKCS#1, SEC1) or JWK; the file is re-read when it changes; RSA keys need 2048 bits. |
| clientAssertionLifetime | 120 s | At most 3600. |
| checks | none | What the sign-in checks: pkce, state, nonce (or none). Only the listed checks are made. |
| codeChallengeMethod | S256 | S256 or plain, with the pkce check: one of the client's allowed PKCE methods. |
| responseMode | not sent | query or web_message. |
| idTokenSignedResponseAlg | the asymmetric algorithms the discovery document announces | The client's signing algorithm: RS/PS/ES 256/384/512, or HS256/384/512 verified with clientSecret (at least 32, 48 or 64 bytes, RFC 7518 §3.2). Also used for logout tokens. |
| accessTokenSignedResponseAlg | the asymmetric algorithms the discovery document announces | The algorithm of the client's access tokens (Authio's second signing setting), for readAccessTokenClaims; HS* with clientSecret. |
| userinfoSignedResponseAlg | idTokenSignedResponseAlg | For UserInfo answered as a signed JWT. |
| clientEncryptionKey, clientEncryptionKeyPath | | The private half of the client's encryption key (JWK or PEM, or a function returning it; the path is Node only), to read ID tokens, UserInfo and logout tokens Authio encrypts. Without it nothing is decrypted. |
| encryptionAlgorithms | RSA-OAEP, RSA-OAEP-256 | The JWE alg accepted. RSA1_5 cannot be listed: Node and WebCrypto cannot decrypt it. |
| encryptionMethods | A128CBC-HS256, A192CBC-HS384, A256CBC-HS512 | The JWE enc accepted; A128GCM, A192GCM and A256GCM may be listed. |
| allowLegacyEncryptionAlgorithms | false | Accepted for parity; RSA1_5 stays refused in Node, which cannot decrypt it (legacy_algorithm). |
| redirectUri, postLogoutRedirectUri | | Defaults for sign-in and sign-out requests. |
| clockSkew | 30 s | Tolerated on ID tokens, logout tokens and signed UserInfo. |
| discoveryCacheDuration | 3600 s | |
| httpTimeout | 30 s | |
| tokenRefreshSkew | 60 s | How early cached tokens are renewed (at most half their lifetime). |
| fetch, clock | global fetch, system clock | Replace them in tests. |
Every endpoint is read from the discovery document, which is cached, read once for concurrent callers, and refused when it names another issuer.
Option names follow the shared conformance/options.md; there are no aliases for older names.
Mirror the client
The options describe the client as it is registered in Authio's console, and an option left out is off: the library hard-codes none of it. Authio refuses a request that does not match the client and accepts what the client does not require.
| In the console | Options |
| --- | --- |
| PKCE required, method S256 | checks: ["pkce", "state"] (add "nonce" to bind the ID token), codeChallengeMethod: "S256" |
| PKCE required, method plain only | checks: ["pkce", "state"], codeChallengeMethod: "plain" |
| PKCE off | checks: ["state"], or leave checks out to check nothing (sending PKCE anyway is accepted) |
| Authentication on, client_secret_post | clientSecret, tokenEndpointAuthMethod: "client_secret_post" |
| Authentication on, private_key_jwt | clientPrivateKey (or clientPrivateKeyPath), clientKeyId |
| Authentication off (public client) | no secret nor key: tokenEndpointAuthMethod is none |
| ID token signing algorithm HS256 | clientSecret, idTokenSignedResponseAlg: "HS256" |
| Access token signing algorithm HS256 | clientSecret, accessTokenSignedResponseAlg: "HS256" (an API: the verifier's accessTokenSignedResponseAlg) |
| Tokens encrypted with the client's key, RSA-OAEP-256 | clientEncryptionKey, encryptionAlgorithms: ["RSA-OAEP-256"] |
Sign people in
const authio = createAuthioClient({
issuer, clientId: "storefront", // a public client: no secret nor key, so tokenEndpointAuthMethod is none
checks: ["pkce", "state", "nonce"], codeChallengeMethod: "S256", // the client requires PKCE with S256
redirectUri: "https://shop.example.com/callback",
});
// 1. Send the person to Authio, keeping the transaction (session, encrypted cookie…).
const { url, transaction } = await authio.createAuthorizationUrl({
scopes: ["openid", "profile", "offline_access"],
resource: "https://orders.example.com", // RFC 8707
organization: "acme-europe", // optional: sign in for one organization
prompt: "login", // none | login | select_account | consent
maxAge: 900, acrValues: "2", loginHint, uiLocales: "pt-BR", idpHint: "google",
});
// 2. On the callback: state (with the state check) and iss (RFC 9207) are checked, the code is redeemed (with the
// PKCE verifier, with the pkce check), and the ID token validated (nonce with the nonce check, at_hash, auth_time
// against maxAge).
const { tokens, idToken } = await authio.handleAuthorizationCallback(request.url, transaction);Each check generates its parameter and is verified on the callback; the transaction lists the checks it was made with,
and a state it did not ask for is ignored. A request may pass its own checks, codeChallengeMethod and
responseMode. popup: true sets display=popup and
response_mode=web_message; pass the parameters the popup posted to the opener to handleAuthorizationCallback. A
callback that is not for the transaction throws AuthioCallbackError (reason: state_mismatch, issuer_mismatch,
missing_issuer, missing_code); an OAuth error Authio sent back (login_required after prompt: "none", for one)
throws AuthioProtocolError.
acrValues asks for a sign-in level; it does not guarantee one. Authio may sign the person in with less (no second
factor enrolled and none required by the client), as OpenID Connect allows (acr_values is a voluntary claim), so
read idToken.acr after the callback and refuse a sign-in below the level the app needs, as
examples/web-login does.
ID tokens and sign-out
const claims = await authio.validateIdToken(idToken, { nonce, accessToken, maxAge }); // AuthioTokenValidationError otherwise
const logoutUrl = await authio.createEndSessionUrl({ idTokenHint, postLogoutRedirectUri, state });
const logout = await authio.validateLogoutToken(form.logout_token); // { subject, sessionId, tokenId, claims }ID tokens are checked against the realm's JWKS (cached, re-read on an unknown kid), or against clientSecret for
the HS* algorithm idTokenSignedResponseAlg names: iss, aud = the client id,
exp with clockSkew, payload typ = ID, azp when there are several audiences, nonce, at_hash (hash of the
signing algorithm) and auth_time against maxAge. An encrypted ID token is decrypted with clientEncryptionKey
first. A back-channel logout token must be typed logout+jwt, carry the back-channel event, no nonce, and a sub
or sid. Every refusal is an AuthioTokenValidationError whose reason uses the outcome names of
conformance/tokens.json (invalid_nonce, invalid_at_hash, invalid_logout_token, invalid_algorithm, expired,
…). In Express, authioBackchannelLogout from @hyper.authio/express is the endpoint.
readAccessTokenClaims(accessToken) returns the payload of an access token the client received once it is decrypted
and its signature, iss and exp verify (undefined for an opaque token). For front-channel logout, Authio loads the
client's front-channel logout URI in an iframe; validateFrontChannelLogout(request.url) checks iss and returns the
sessionId (sid) to end (authioFrontchannelLogout in @hyper.authio/express).
Step-up
When an API answers 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="2",
max_age=300 (RFC 9470 §3; servers before @hyper.authio/server 0.8.1 and @hyper.authio/mcp 0.4.1 answer 403, and
the challenge reads the same):
const challenge = parseAuthioBearerChallenge(response.headers.get("www-authenticate"));
const { url, transaction } = await authio.createStepUpAuthorizationUrl(challenge!); // acr_values, max_age, prompt=login for max_age=0Grants
await authio.tokens.clientCredentials({ scopes: ["orders:read"], resource: "https://orders.example.com" });
await authio.tokens.authorizationCode({ code, redirectUri, codeVerifier });
await authio.tokens.refresh({ refreshToken, resource }); // store the rotated refresh_token it returns; resource: RFC 8707
await authio.tokens.exchange({ subjectToken, resource }); // on-behalf-of (RFC 8693)
await authio.tokens.password({ username, password });
await authio.tokens.personalAccessToken({ token: "pat_…", clientId: "billing-api" }); // clientId only for a consumer's PAT
const device = await authio.tokens.deviceAuthorization({ scopes: "openid" });
console.log(device.verification_uri_complete);
const token = await authio.tokens.pollDevice(device); // waits, slows down, throws access_denied / expired_token
const connection = await authio.tokens.exchangeForConnection({ subjectToken, provider: "github" });
switch (connection.status) {
case "granted": /* accessToken, issuedTokenType, tokenType, expiresIn, scope, connectionId, provider, providerType, externalAccount */ break;
case "required": /* send the person to connection.connectUri */ break;
case "selection_required": /* ask again with one of connection.connections[].id (label, externalLogin) */ break;
case "refused": /* connection.error, connection.errorCode */ break;
}Device polling waits interval, keeps polling on authorization_pending, adds 5 seconds on slow_down, and stops
with the error on access_denied and expired_token (toDevicePollOutcome tells which).
OAuth errors throw AuthioProtocolError (error, errorDescription, errorCode, status); error is
http_{status} when Authio sent none, invalid_response when a success body could not be read. Tokens print as
[redacted] in console.log and util.inspect, and serialize normally.
Machine-to-machine calls
const ordersFetch = authio.clientCredentialsFetch({ scopes: "orders:read", resource: "https://orders.example.com" });
await ordersFetch("https://orders.example.com/orders");The token is cached per scopes and resource, renewed before expiry with one request for concurrent callers, and on a
401 dropped and retried once. authio.clientCredentials.getToken() gives the token itself.
Introspection, revocation, UserInfo
const result = await authio.introspect(token); // { active, sub, scope, …, and Authio's extra members }
await authio.revoke(refreshToken, { tokenTypeHint: "refresh_token" });
const info = await authio.userInfo(accessToken); // JSON, or a signed / encrypted JWT verified for youAuthio answers introspection according to who asks: a token requested for a resource is active only to the client that resource server names as its introspection client.
Admin API credentials
import { createAuthioAccountCredential, readAuthioAdminResponse } from "@hyper.authio/client";
const credential = createAuthioAccountCredential({
authority: "https://auth.example.com",
credential: process.env.AUTHIO_CREDENTIAL, // ak_…, cs_… or pat_…
credentialId: process.env.AUTHIO_CREDENTIAL_ID, // needed for a cs_ secret
});
const response = await credential.fetch("https://auth.example.com/api/v1/admin/realms");
const { data } = await readAuthioAdminResponse(response); // { items, totalCount, pageNumber, pageSize } for a listing; AuthioAdminError otherwiseak_ and pat_ go directly as Authorization: Bearer (or x-api-key with presentAs); a cs_ secret is exchanged
at {authority}/realms/authio with client_credentials and the client_id cred_{id}, and a pat_ may be exchanged
with the personal_access_token grant (exchangePersonalAccessToken: true). The fetch retries an idempotent request
once after Retry-After on 429/503. AuthioAdminError carries status (the HTTP one), errorCode,
notifications, requestId (body or X-Request-Id) and retryAfterSeconds; parseAuthioAdminEnvelope reads an
answer you fetched yourself. The full Admin client comes in a later release.
Roles and permissions
import { parseResourceAccess } from "@hyper.authio/client";
const access = parseResourceAccess(jwtPayload); // or introspection answer, or [type, value] pairs
access.roles; // ["billing-admin", "viewer"]
access.permissions; // { invoice: ["read", "write"], report: ["read"] }
access.grants("invoice", "write"); // true, case-insensitive
access.grants("report", "read", ["user"]); // only roles from user_resource_access
access.fromSource("client"); // the client's rolesThe seven *_resource_access claims (user, realm, client, plan, consumer, secret, system) are merged.
A claim that is not exactly { role: { resource: [method] } } contributes nothing, as in Authio's own check. To
protect an API, use @hyper.authio/server, which also reads groups, mapper claims and the rest.
Webhooks
import { verifyWebhookSignature, parseWebhookEvent, asUserEvent } from "@hyper.authio/client";
const result = await verifyWebhookSignature(request.headers["x-authio-signature"], rawBody, secret);
if (!result.valid) return reply(401);
const event = parseWebhookEvent(rawBody);
const user = asUserEvent(event); // { id, username, emailConfirmed, … } for user_created / user_updated / user_deleted, else undefinedEvery event type has its accessor (asLoginEvent, asUserLockedOutEvent, asTokenRevokedEvent,
asKeysRotatedEvent, asTokenReuseDetectedEvent, asAdminResourceEvent, asUserSuspensionEvent,
asAttackDetectedEvent, asCredentialCompromisedEvent, asAuthenticatorAppEvent, asPingEvent), which exposes every member of data in
camelCase with its value unchanged; event.data stays as delivered (snake_case). An unknown type still parses
(isKnownWebhookEventType is false, event.data is the raw JSON).
The signature is HMAC-SHA256 of {t}.{body}, compared in constant time; the timestamp must be within 5 minutes.
@hyper.authio/server adds deduplication and dispatch.
License
MIT
