Skip to content

@masks/server

The JavaScript apps overview shows how to set each of these up.

Import
@masks/server Masks, which answers a Request with a Response, and the client underneath it: discovery, the handshake, the code flow, tokens, DPoP, introspection, token exchange, delegations, enrollment, and checking tokens.
@masks/server/next masksNext: route handlers, server component helpers, and a proxy for the Next.js App Router.
@masks/server/authjs masksAuth: masks as an Auth.js provider, with the handshake, refresh, privileges, and back-channel logout.
@masks/server/express masksExpress and requireAuth: middleware for Express.
@masks/server/hono masksHono and requireAuth: middleware for Hono, which also runs on Bun, Deno, and Cloudflare Workers.

Masks, masksNext, masksExpress, and masksHono take these options. issuer, resource, name, and organization also accept a function that takes the request.

Option Default
env process.env Where the MASKS_ settings are read from, for a runtime with no process.env.
issuer MASKS_ISSUER The tenant people sign in against, such as https://acme.auth.example.com.
resource MASKS_RESOURCE The app’s absolute URL. The handshake registers the app for it, and tokens for the app name it as their audience.
secret MASKS_SECRET At least 32 characters that seal the session cookie. A list, or a comma-separated variable, puts the newest secret first.
name An application The app’s name on the masks approval screen.
scope openid profile email offline_access The scopes every sign-in asks for.
privileges Named privileges that routes ask for.
manages The privilege, or a function of the request and the person, that lets someone see, reconnect, and disconnect the app’s connection. When it is not set, nobody can.
namespace A scope namespace, such as catalog:, that the handshake registers once for every scope under it.
organization The organization every sign-in names, by key or id.
resourceScopes The scopes the app accepts in an access token.
basePath /auth Where the routes are answered.
afterSignIn / Where a sign-in lands when it names no return_to.
afterSignOut / Where a sign-out lands.
signOutOfIssuer false Whether every sign-out also signs out of masks.
maxAge 14 days How long a session lasts from its sign-in, in seconds.
sessions the cookie A Store that keeps sessions on the server. The cookie then holds only a sealed id.
store memory A Store that every process of the app shares. It holds the back-channel logouts masks sent, the DPoP proofs and logout tokens already used, and each refresh, so a session is refreshed once between the processes.
credentials masks.json A CredentialStore for the client ID and secret the handshake returns. With MASKS_CLIENT_ID set, the app reads MASKS_CLIENT_ID and MASKS_CLIENT_SECRET and writes nothing.
onLogout Called with each back-channel logout token.
delegations The path a provider connection comes back to. Setting it adds masks:delegate: to the handshake.
avatarBytes 5 MiB The largest photo the avatar route passes on, in bytes.
fetch globalThis.fetch The fetch used for every call to masks.
allowPlainHTTP false Whether an http issuer is accepted on a host other than localhost, a .localhost name, or a loopback address.
privileges: {
admin: ["catalog:admin"],
billing: { role: ["owner", "billing"] },
acme_admin: { scope: ["catalog:admin"], role: "owner", organization: "acme" },
}
Key
scope Scopes the token has to hold. A list on its own is a short way to write { scope: [...] }.
role One role or several in the organization signed in to. Any one is enough.
organization The one organization the privilege applies to, by key or id.

Masks answers these under basePath:

Route
GET /auth Starts a sign-in. Takes return_to, organization, and privilege.
GET /auth/callback Finishes it.
GET /auth/refresh Refreshes the session and goes back to return_to.
GET /auth/session The signed-in person as JSON, for a single-page app.
GET /auth/avatar The person’s photo, fetched from masks with their token. Takes size.
GET, POST, or DELETE /auth/logout Signs out. Takes everywhere to sign out of masks as well. A browser that names no site gets a page first.
GET /auth/logout/return Where masks sends the browser after signing out everywhere.
POST /auth/logout/backchannel Takes a back-channel logout token from masks. Each token works once.
GET or POST /auth/handshake Starts the handshake, or shows the connection.
GET /auth/handshake/callback Finishes the handshake.
POST /auth/handshake/disconnect or DELETE /auth/handshake Disconnects the app.

masks.handle also answers /.well-known/oauth-protected-resource, followed by the path of resource when it has one.

Each method takes the incoming Request. A cookies array holds Set-Cookie values to append to the response.

Method
masks.handle(request) Answers a masks route, or returns undefined for any other path.
masks.masked(request) The person, refreshing the session first, and the cookies to set.
masks.peek(request) The person, without refreshing or setting anything.
masks.require(request, ...privileges) The person and cookies, or a refusal.
masks.check(request, { privileges, returnTo, refresh }) The same as require. returnTo names the page to come back to, and refresh: false checks the session without refreshing it.
masks.protect(request, ...privileges) The same, for an access token.
masks.guard(request, ...privileges) protect when the request carries an Authorization header, or carries no session and wants JSON, and require otherwise.
masks.metadata(request) The protected resource metadata.
masks.loginURL({ returnTo, organization, privilege }) A link that signs the person in.
masks.logoutPath The path a sign-out form posts to.
masks.connection(request) Whether the app holds credentials, with its client_id and the issuer.
masks.registered(request) Whether masks still knows the app.
masks.disconnect(request) Deletes the registration and the credentials, and returns true. It returns false when the credential store cannot clear. When masks cannot be reached, it throws an UnavailableError and keeps the credentials.
masks.client(request) The app’s Client, for calls of your own.
masks.delegations(request) Delegations for the app’s client. Needs delegations.
masks.startDelegation(request, provider, { prompt, maxAge }) Sends a signed-in person to connect a provider.
masks.finishDelegation(request) The connection and its secret, and the cookies to set.

A refusal says why the request was refused and builds the answer:

Property or method
kind handshake, login, step_up, forbidden, invalid_token, or unavailable.
url Where to send a browser: the handshake, a sign-in, or a sign-in that asks for a privilege. Only handshake, login, and step_up have one.
error The ForbiddenError or InvalidTokenError behind a forbidden or invalid_token refusal.
cookies The Set-Cookie values toResponse sets.
toResponse(request) The answer for request, or for the request that was checked. A browser is sent to url, and a request that wants JSON gets 401 or 403 with the error. A refused token gets the Bearer and DPoP challenges.
const outcome = await masks.require(request, "admin");
if (outcome.refusal) return outcome.refusal.toResponse();

The person is a Masked, and never undefined:

Property or method
signedIn Whether a sign-in is held and its token has not expired.
subject, name, nickname, email, emailVerified Who signed in.
picture, avatars The app’s avatar route when masks holds a photo, since the photo needs the person’s token. Otherwise the picture claim, then the identicon. avatars holds each avatar URL.
tenant The tenant’s uuid, subdomain, and name.
organization The organization signed in to, with its id, key, name, and role, or undefined.
organizations Every organization the person has joined, each with the role held there.
role("owner", "billing") Whether the person holds one of those roles in the organization signed in to.
scopes, permits("catalog:read") What the token holds.
privileges, privilege("admin"), holds("admin") The privileges the sign-in holds.
accessToken, authorization The token to send to a resource server, and the Authorization header that carries it.
expiresAt, accountURL When the access token expires, in seconds since the epoch, and the person’s account page at masks.
claim("groups") Any other claim the sign-in kept.
toJSON() What GET /auth/session returns. It leaves out the access token.
interface Store {
add(
key: string,
value: string,
expiresIn: number,
): boolean | Promise<boolean>;
get(key: string): string | undefined | Promise<string | undefined>;
set(key: string, value: string, expiresIn: number): void | Promise<void>;
delete(key: string): void | Promise<void>;
}
interface CredentialStore {
read(
request: Request,
): Credentials | undefined | Promise<Credentials | undefined>;
write?(request: Request, credentials: Credentials): void | Promise<void>;
clear?(request: Request): void | Promise<void>;
}

add sets a key only when it is absent, in one step, and answers whether it set it. expiresIn is in seconds. Every key starts with masks: and is a hash, so no key names a token, and every value that holds a token is sealed.

memoryStore() and memoryCredentials() keep each in one process. fileCredentials(path) and envCredentials({ prefix }) are the two default credential stores, for a file and for the MASKS_CLIENT_ID and MASKS_CLIENT_SECRET variables. redisStore(send) keeps a Store in Redis through the client the app already has:

import { createClient } from "redis";
import { Masks, redisStore } from "@masks/server";
const redis = await createClient({ url: process.env.REDIS_URL }).connect();
export const masks = new Masks({
store: redisStore((command) => redis.sendCommand(command)),
});

With ioredis, send is ([name, ...rest]) => redis.call(name, ...rest).

upstashStore() sends each command to Upstash over HTTPS with fetch, so it needs no client and runs on serverless functions and edge runtimes. It reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, then KV_REST_API_URL and KV_REST_API_TOKEN, or takes url and token.

replayStore(store) turns a Store into the ReplayStore a Verifier takes, so an API with no browser sign-in shares the proofs it has seen:

interface ReplayStore {
first(key: string, expiresIn: number): boolean | Promise<boolean>;
}

Client signs a server or a machine in to masks as itself, with nobody signed in. masks.client returns the app’s own.

import { Client, Issuer, Key } from "@masks/server";
const key = await Key.generate();
const dpop = await Key.generate();
const issuer = await Issuer.discover("https://acme.auth.example.com");
const client = new Client({ issuer, id: clientId, key, dpop });
const token = await client.clientCredentials({
scope: ["files:read"],
resource: ["https://files.example.com"],
});
  • key signs the client’s assertions. key.jwks() is the public half to register.
  • dpop binds each token to a second key, so a token copied off the server does not work without it. A token that comes back unbound is refused.
  • JSON.stringify(key) writes the private key. Keep it where only the service can read it, and read it back with Key.import.
  • The client authenticates with client_secret_basic when secret is set, and with private_key_jwt when key is set. method names another.
  • Every URL is https, except on localhost, a .localhost name, or a loopback address. allowPlainHTTP: true in the options to Issuer.discover allows http on a private network.
Method
clientCredentials({ scope, resource }) A token for the client itself.
refresh(refreshToken) A new token from a refresh token.
start and complete The code flow with PKCE, for a sign-in of your own.
deviceAuthorization({ scope }), awaitDevice(device) The device flow. awaitDevice polls at the interval masks names, and stops when the code expires or its signal aborts.
exchange(subjectToken, { scope, resource, lifetime }) Trades a token for a narrower one. See exchanging a token.
revoke(token, hint) Revokes an access token or a refresh token.
introspect(token, hint) Asks masks whether a token is active. masks describes a token only to the client it was issued to, or to an approved client named in its audience that is not an enrolled machine. Any other token comes back as { active: false }.
enroll({ scope, name, expiresIn }), unenroll(clientId) Gives out an initial access token for one machine, and archives a machine. See machines and enrollment.
fetch(source, origins) A fetch that gets tokens from source and sends them only to origins. With dpop, it signs a proof for each request. credentialsSource and refreshSource make a source.

start takes maxAge in seconds. When it did, complete refuses an ID token whose auth_time is missing or older than that, plus 30 seconds of leeway, with an InvalidTokenError.

issuer.register(metadata, initialAccessToken) redeems an initial access token, and issuer.client(registration, key, dpop) builds the client it registered. readRegistration, updateRegistration, and deleteRegistration manage it with its registration access token.

Verifier checks access tokens in an API with no browser sign-in:

import { hasScope, Verifier } from "@masks/server";
const verifier = new Verifier(issuer, "https://files.example.com", {
publicURL: "https://files.example.com",
});
export async function GET(request: Request) {
try {
const claims = await verifier.authenticate(request);
if (!hasScope(claims, "files:read"))
return new Response(null, { status: 403 });
return Response.json({ subject: claims.sub });
} catch (error) {
return verifier.challenge(error);
}
}
  • authenticateNode(request) takes a Node or Express request. refusal(error) returns the status and headers to send.
  • It checks tokens as in accepting tokens.
  • A token that fails a check throws an InvalidTokenError, answered with 401 and a challenge. A request with no token throws one whose missing is true.
  • When the token cannot be checked, because the issuer’s keys could not be fetched or the replay store failed, it throws an UnavailableError, answered with 503 and no challenge.
  • Set publicURL when a proxy sits in front of the API, since a DPoP proof names the address the client used.
  • An API on more than one instance sets replay to a store they share, such as replayStore(redisStore(send)). By default each instance remembers the proofs it has seen in memory, so a proof replayed to another instance passes.