@masks/server
The JavaScript apps overview shows how to set each of these up.
Imports
Section titled “Imports”| 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. |
Options
Section titled “Options”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
Section titled “Privileges”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. |
Routes
Section titled “Routes”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. |
Refusals
Section titled “Refusals”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();Masked
Section titled “Masked”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. |
Stores
Section titled “Stores”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
Section titled “Client”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"],});keysigns the client’s assertions.key.jwks()is the public half to register.dpopbinds 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 withKey.import.- The client authenticates with
client_secret_basicwhensecretis set, and withprivate_key_jwtwhenkeyis set.methodnames another. - Every URL is
https, except onlocalhost, a.localhostname, or a loopback address.allowPlainHTTP: truein the options toIssuer.discoverallowshttpon 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
Section titled “Verifier”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 thestatusandheadersto send.- It checks tokens as in accepting tokens.
- A token that fails a check throws an
InvalidTokenError, answered with401and a challenge. A request with no token throws one whosemissingistrue. - 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 with503and no challenge. - Set
publicURLwhen 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
replayto a store they share, such asreplayStore(redisStore(send)). By default each instance remembers the proofs it has seen in memory, so a proof replayed to another instance passes.