@hyper.authio/server
v0.10.0
Published
Protect a Node.js API with Authio: token validation (JWT or introspection), the caller with every form of permission (scopes, roles and permissions by source, groups, claims, organization, acr/amr), authorization rules, webhooks, forwarding and on-behalf-
Maintainers
Readme
@hyper.authio/server
Protect a Node.js API with Authio, in any framework: token validation, the caller with every form of permission
Authio issues, authorization rules, a webhook receiver, and calls to downstream APIs. For Express, use
@hyper.authio/express, built on this package.
npm install @hyper.authio/serverValidate tokens
import { createAuthioVerifier, AuthioBearerError } from "@hyper.authio/server";
const verifier = createAuthioVerifier({
issuer: "https://auth.example.com/realms/acme",
audiences: "https://orders.example.com", // the API's resource identifier (RFC 8707)
});
try {
const user = await verifier.verifyAuthorizationHeader(request.headers.authorization);
} catch (error) {
if (error instanceof AuthioBearerError) {
response.setHeader("WWW-Authenticate", error.wwwAuthenticate);
response.statusCode = 401;
}
}| Option | Default | |
| --- | --- | --- |
| audiences | | Required, unless resource is set. |
| resource | | The API's own resource identifier (RFC 8707); added to audiences and used for RFC 9728. |
| resourceMetadata | off | true (or the URL) adds resource_metadata="{origin}/.well-known/oauth-protected-resource{path}" to 401 challenges (RFC 9728). |
| requiredScopes | | Scopes every request must carry (all); authioBearer answers 403 insufficient_scope. |
| clientEncryptionKey, clientEncryptionKeyPath | | Decrypts access tokens Authio encrypts to this key and validates them locally. Without it nothing is decrypted. RSA1_5 is refused (legacy_algorithm), even with allowLegacyEncryptionAlgorithms. |
| encryptionAlgorithms, encryptionMethods | RSA-OAEP, RSA-OAEP-256; the CBC-HS methods | The JWE alg and enc accepted, as registered in Authio (AES-GCM may be listed; RSA1_5 cannot: Node cannot decrypt it). |
| introspection | none | none: local JWT validation; the caller is read from the token alone. activeCheck: then confirmed active by introspection (cached per jti), so a revoked token, an ended session or a suspension is refused before the token expires; the caller is read from the token, plus the claims of the answer the token does not carry. introspectionOnly: introspection alone, for opaque (Reference) tokens (or encrypted ones without the key); the caller is read from the answer. A Lightweight access token needs activeCheck or introspectionOnly (see below). |
| client | | The API's confidential client, needed by introspection: an AuthioClient or { clientId, clientSecret } (or a private key), with tokenEndpointAuthMethod as the client is registered. It must be the resource server's introspection client. |
| introspectionCacheDuration | 30 s | Never past the token's expiry; only active answers are kept. |
| clockSkew | 30 s | |
| accessTokenSignedResponseAlg | RS*, PS*, ES* (never HS*) | The algorithm (or several) access tokens are signed with, as the client is registered in Authio. HS256/384/512 are verified with the clientSecret of client: { clientId, clientSecret } (at least 32, 48 or 64 bytes). |
| rejectNonAccessTokens | true | Refuses a token whose typ claim is not Bearer (an ID token, for one). The header typ must be at+jwt (RFC 9068 §4) regardless. |
| roleClaims | ["role"] | Claims holding flat role names (a Role claim mapper). |
| groupClaims | ["groups"] | Claims holding group names (a Group claim mapper). |
A refusal is an AuthioBearerError whose wwwAuthenticate is Bearer when the request had no token, and
Bearer error="invalid_token", error_description="…" otherwise (plus resource_metadata when turned on). Its
reason names why, with the outcome names of conformance/tokens.json (expired, invalid_audience,
not_access_token, reference_token, decryption_failed, …, and inactive / unconfirmed for introspection). A
token addressed elsewhere is described with the audience the API accepts; introspection failures fail closed.
Lightweight and reference access tokens
Authio hands out a client's access tokens in the form its access token format setting names:
| Format | The token | Validate it with |
| --- | --- | --- |
| Jwt | A self-contained JWT with every claim. | Any mode. |
| Lightweight | A JWT with only the identity and session claims (iss, sub, aud, exp, nbf, iat, jti, typ, client_id, azp, scope, sid, auth_time, acr, amr, idp, act, delegation_id, session_client, org_id, org_alias). The *_resource_access roles and permissions, subscription, the profile and the email are only in the introspection answer. | activeCheck or introspectionOnly, which read the caller with the answer's claims, so roles, permissions, kind and email are complete. With none the caller has only what the token carries: no role or permission, and a consumer reads as a client. |
| Reference | An opaque handle, no JWT (refused locally as reference_token). | introspectionOnly. |
const verifier = createAuthioVerifier({
issuer,
resource: "https://orders.example.com",
introspection: "activeCheck", // the client's access token format is Lightweight
client: { clientId: "orders-api", clientSecret: process.env.AUTHIO_CLIENT_SECRET! },
});Nothing tells a lightweight token apart from a full one that carries no role (a person without roles has no
*_resource_access either), so the verifier never guesses: the mode decides. The introspection answer is cached per
jti for introspectionCacheDuration, so activeCheck asks Authio once per token and cache period. The answer only
adds claims: one the token carries keeps the token's value, which its signature vouches for (Authio answers a single
aud, and client_id for azp).
Protected resource metadata (RFC 9728)
import { createProtectedResourceMetadata, protectedResourceMetadataPath } from "@hyper.authio/server";
const verifier = createAuthioVerifier({ issuer, resource: "https://orders.example.com/api", resourceMetadata: true });
const path = protectedResourceMetadataPath(verifier.resource!); // /.well-known/oauth-protected-resource/api
const document = createProtectedResourceMetadata({ issuer, resource: verifier.resource!, scopesSupported: ["orders:read"] });Access tokens signed with the client secret (HS*)
Authio signs a client's access tokens with the algorithm of its access token setting. When that is HS256, HS384 or HS512, the signature is made with the client secret: only who holds the secret validates the token locally, so that is the client's own API.
const verifier = createAuthioVerifier({
issuer,
audiences: "storefront",
accessTokenSignedResponseAlg: "HS256",
client: { clientId: "storefront", clientSecret: process.env.AUTHIO_CLIENT_SECRET! }, // 32+ bytes for HS256
});Third-party APIs never get the secret: they validate such tokens with introspection (introspection:
"introspectionOnly" and their own introspection client). Without accessTokenSignedResponseAlg, an HS* token is
refused (invalid_algorithm) with a description naming both ways. authioBearer and the MCP packages take the same
option.
The caller
AuthioUser reads every form of permission, the same way from a JWT, an introspection answer or both (activeCheck):
user.subject; user.clientId; user.scopes;
user.kind; // "user" | "client" | "consumer" | "account_credential"
user.credentialForm; // "client_secret" | "personal_access_token" | "api_key" | "consumer_api_key" (machines only)
user.consumerName; user.credentialId; user.credentialName; user.ownerUserId; user.ownerUsername; user.accountId;
user.credentialRealm; user.credentialClient; // what an account credential is limited to
user.impersonated; user.impersonator; // an administrator acting as the person (act.sub)
user.delegated; user.delegationId; user.actorChain; // clients acting for the person (act.client_id), outermost first
user.authTime; // auth_time, in seconds
user.roles; // *_resource_access roles, then flat role claims
user.permissions; // { orders: ["read", "write"] }
user.resourceAccess; // per-source entries: user, realm, client, plan, consumer, secret, system
user.groups; // from the group claims
user.organizationId; user.organizationAlias; user.authenticationLevel; user.authenticationMethods; user.actor;
user.claims; // every claim, for claim-mapper claims and the person's own claims
user.hasPermission("orders", "read");
user.hasPermission("orders", "read", { sources: ["realm", "client"] });
user.hasRole("admin", { sources: ["user"] });
user.hasGroup("finance");
user.hasClaim("department", "finance", "sales");The raw token is kept as user.token, not enumerable, so it stays out of logs and JSON. The caller fields follow
conformance/callers.json; AuthioUser.fromIntrospection(answer) reads an introspection answer the same way.
Authorize
import { authorize, requirePermission, requireGroup, requireAuthenticationLevel, allOf, anyOf, AuthioForbiddenError } from "@hyper.authio/server";
const refusal = authorize(user, requirePermission("orders", "write"), anyOf(requireGroup("finance"), requireAuthenticationLevel(2)));
if (refusal) {
const error = new AuthioForbiddenError(refusal);
// error.status: 401 when the refusal asks for a stronger or more recent sign-in (insufficient_user_authentication,
// RFC 9470 §3), 403 otherwise; error.wwwAuthenticate for scope (insufficient_scope) and sign-in refusals;
// toProblemDetails(error) for an RFC 9457 body with the same status.
}| Refusal | Status | WWW-Authenticate |
| --- | --- | --- |
| No token | 401 | Bearer (no error, RFC 6750 §3.1) |
| Token not valid | 401 | Bearer error="invalid_token", error_description="…" |
| Missing scope | 403 | Bearer error="insufficient_scope", error_description="…", scope="…" |
| Sign-in too weak or too old (step-up) | 401 | Bearer error="insufficient_user_authentication", error_description="…", acr_values="…", max_age=… |
| Missing role, permission, group, claim, organization, client or caller kind | 403 | none |
When several rules fail at once, a step-up is asked for only if every failing rule is a step-up: a missing scope, role
or permission, which no stronger sign-in fixes, answers 403 (with insufficient_scope for a scope) and no step-up.
resource_metadata (RFC 9728 §5.1) is added to every 401 when the verifier publishes the protected resource
metadata (resourceMetadata): the verifier's own refusals carry it, and new AuthioForbiddenError(refusal, {
resourceMetadata: verifier.resourceMetadataUrl }) (or withResourceMetadata) adds it to a step-up; the guards of
@hyper.authio/express do it for you. The table is conformance/challenges.json, which every Authio library runs.
Rules: requireScope, requireRole, requirePermission, requireGroup, requireClaim, requireOrganization,
requireAuthenticationLevel, requireAuthenticationMethod, requireRecentAuthentication, requireCallerKind,
requireClient, combined with allOf and anyOf. authorize checks every rule and joins their messages.
Step-up (RFC 9470): requireAuthenticationLevel(2, { maxAge: 300 }), requireAuthenticationMethod("otp") and
requireRecentAuthentication(300) refuse with insufficient_user_authentication, acrValues (the level asked for;
2 for mfa/otp/mca, 3 for swk/hwk) and maxAge, so the challenge reads
Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300, answered with
401 (RFC 9470 §3; 403 before 0.8.1). A client answers it with createStepUpAuthorizationUrl from
@hyper.authio/client.
Webhooks
import { createAuthioWebhooks } from "@hyper.authio/server";
const webhooks = createAuthioWebhooks({ secret: process.env.AUTHIO_WEBHOOK_SECRET })
.on("user_created", async (event) => { /* event.data.username */ })
.on("*", (event) => log(event.type));
const outcome = await webhooks.handle({ body: rawBody, headers: request.headers });
response.statusCode = outcome.status; // 200 handled or duplicate, 400, 401, 413, 500Deliveries are deduplicated by X-Authio-Event-Id for 24 hours in memory (or a store you pass); a handler that
throws releases the id so the redelivery runs. Several secrets are accepted while one is rotated.
Downstream APIs
import { createForwardingFetch, createOnBehalfOf } from "@hyper.authio/server";
const sameAudience = createForwardingFetch(user); // forwards the caller's own token
const billing = createOnBehalfOf({ client: authioClient, resource: "https://billing.example.com", scopes: "invoices:read" });
await billing.fetch(user)("https://billing.example.com/invoices"); // exchanged once, cached, retried once on 401License
MIT
