Skip to content

JavaScript apps

@masks/server signs people in to a JavaScript server app against a masks server. It covers what Masks::Rails covers in a Rails app: the handshake, the sign-in, a session, organizations, privileges, and checking access tokens. Its core uses only fetch, Request, Response, and Web Crypto, so it runs on Node 20 and later, Bun, and Deno.

Terminal window
npm install @masks/server

Each framework has its own entry point and page. The framework is an optional peer dependency, so install only the one your app uses.

Page Import Peer dependency
Next.js masksNext from @masks/server/next next 15 or later
Auth.js masksAuth from @masks/server/authjs next-auth 5
Express masksExpress from @masks/server/express express 4.18 or later
Hono masksHono from @masks/server/hono hono 4 or later

A site with no server uses @masks/client instead. See static sites. The @masks/server reference lists every option, route, and method.

Every integration except Auth.js reads three settings. Each is an option, or an environment variable:

Option Variable
issuer MASKS_ISSUER The tenant you sign in against, such as https://acme.auth.example.com.
resource MASKS_RESOURCE Your app’s absolute URL, such as https://app.example.com. 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, such as the output of openssl rand -base64 32.

The app builds its own addresses, such as its redirect URI, from resource, so they stay correct behind a proxy. An http issuer works only on localhost, a .localhost name, or a loopback address, unless allowPlainHTTP is set.

issuer, resource, name, and organization each accept a function that takes the request, so one app can serve several tenants. Derive them from something the app trusts, such as a tenant it looked up by host.

Masks from @masks/server works with any framework that hands you a Request:

  • masks.handle(request) answers a masks route, and returns undefined for every other path.
  • masks.require, masks.protect, and masks.guard check a request. See Masks.

A cookies array holds Set-Cookie values. Append each one to the response you send.

The handshake connects your app to the masks server:

  1. The app sends the browser to the masks server’s approval screen.
  2. You approve the app there.
  3. A one-time token comes back. The app redeems it by talking to the masks server directly, so the client secret never passes through the browser.

The handshake registers the app for resource. It registers these addresses under /auth, or basePath:

/auth/callback The redirect URI for a sign-in.
/auth/handshake/callback Where masks sends the browser after the approval.
/auth/logout/return The only address masks sends the browser back to after a sign-out.
/auth/logout/backchannel Where masks posts a logout token. It is registered only when masks supports back-channel logout.

It asks for scope and the scopes of every privilege. Set namespace, such as catalog:, to register the namespace once for every scope under it.

By default, the app writes its credentials to masks.json in the working directory, with file mode 600. Add it to .gitignore. When MASKS_CLIENT_ID is set, the app reads MASKS_CLIENT_ID and MASKS_CLIENT_SECRET instead and writes nothing.

A host with no writable disk, or a multi-tenant app, sets credentials to a store of its own:

import type { CredentialStore } from "@masks/server";
const credentials: CredentialStore = {
read: (request) => tenantFor(request).masksCredentials(),
write: (request, held) => tenantFor(request).connect(held),
clear: (request) => tenantFor(request).disconnect(),
};

Before a sign-in starts, the app checks that masks still knows it, at most once a minute. When masks has forgotten the app, the app forgets its credentials and starts the handshake again.

Once the app is connected, /auth/handshake shows the connection to somebody manages allows, with buttons to reconnect and to disconnect. manages is the name of a privilege, or a function:

masksNext({ manages: "admin" });
masksNext({
manages: (request, masked) => masked.subject === process.env.OWNER_SUB,
});
  • A person who is not signed in goes to sign in, and comes back to /auth/handshake.
  • When manages names a privilege whose scopes the person lacks, masks is asked for them once, as on any guarded page.
  • Anybody else is refused with 403.
  • When manages is not set, everyone is refused.

Disconnecting deletes the registration at masks and the credentials. When masks cannot be reached to delete the registration, the app keeps the credentials and answers 503, so masks keeps no registration that the app no longer knows.

masks.connection, masks.registered, masks.disconnect, and masks.client read and change the connection from code. See Masks.

The app answers its routes under /auth, or basePath. A sign-in starts at /auth, which takes return_to, organization, and privilege.

  • A sign-in uses PKCE, a state, and a nonce.
  • The app checks the iss that masks sends back, and the ID token’s signature, audience, and nonce.
  • A callback the browser did not start sends the browser to sign in again, at most once a minute. In between, it is refused with invalid_state.
  • return_to is followed only to a path on the app.

A request that wants JSON gets 401 instead of a redirect, with login_required and a login_url, or handshake_required and a handshake_url. A request wants JSON when its Accept header does not name text/html, or when it sends X-Requested-With: XMLHttpRequest.

The person is a Masked, and never undefined. toJSON() leaves out the access token, so passing a Masked to Response.json or res.json never sends the token to the browser.

masks.loginURL({ returnTo: "/billing" }) builds a link to sign in. masks.logoutPath is the path a sign-out form posts to:

<form method="post" action="/auth/logout">
<button type="submit">Sign out</button>
</form>

A token that expires within 30 seconds is refreshed with its refresh token. The app reads the person’s name, photo, and organizations from userinfo again at the same time.

  • Requests that arrive together with the same expired token share one refresh, since masks revokes a refresh token that is used twice.
  • A refresh that masks refuses signs the person out.
  • A refresh that does not reach masks keeps the session, and the next request tries again.

A sign-out revokes the refresh token and clears the session. A sign-out of any method is refused with 403 when its Sec-Fetch-Site header is anything other than same-origin or none, or its Origin header names another origin. A POST or DELETE that sends neither header is refused with 403 as well.

A GET sign-out signs out at once when its Sec-Fetch-Site header is same-origin or none. A GET with no Sec-Fetch-Site header, as older browsers send, does not sign out. A browser gets a page with a Sign out button that posts to the same address, query included. A JSON request gets 403. So a link on another site cannot sign anybody out.

With signOutOfIssuer, or everywhere, the browser goes on to masks to end its session there. It comes back through /auth/logout/return to afterSignOut. That return carries a state the sign-out stored in the browser, and the state works once, so a link to the return from anywhere else is refused.

When masks ends a session itself, it sends a logout token to /auth/logout/backchannel. The app signs out the session the token names, or every session of the person it names, at their next request.

  • Each logout token is accepted once, and only before its exp. A token with no exp is refused.
  • When the store cannot be reached, the app answers 503, and accepts the same token when masks sends it again.
  • onLogout is called with each logout token.

The session lives in a cookie, sealed with AES-GCM under a key derived from secret.

  • It is HttpOnly and SameSite=Lax. On https, it is also Secure and named __Host-masks, so no other host can set it.
  • A session too large for one cookie is split across up to eight.
  • It lasts 14 days from the sign-in, or maxAge seconds.
  • The browser cannot read the tokens in it, and /auth/session leaves them out.
  • It is replaced at every sign-in.
  • It records the issuer that signed the person in, and the app ignores it under any other issuer. An app that serves several tenants from one origin keeps their sessions apart.

To keep sessions on the server, set sessions to a Store. The cookie then holds only the session’s id, sealed. The store holds the session, also sealed, under a key that names no token. A sign-in gives the session a new id and deletes the old one.

To change the secret, list the new one first: secret: [next, previous], or MASKS_SECRET=next,previous. Sessions sealed with the old secret still open, and each is sealed with the new one the next time it changes.

store holds what the app’s processes share. It is in memory by default, so an app that runs as several processes, or on serverless functions, sets it to a Store they all reach, such as Redis. It holds:

  • Each refresh, so a session is refreshed once between the processes. A refusal is kept too, so every process signs the person out.
  • The back-channel logouts masks sent, so every process ends those sessions.
  • The logout tokens and DPoP proofs already used, so none is accepted twice.

When the store cannot be reached, a refresh keeps the current token and runs again on a later request.

An app signs a person in as a member of one organization when it asks for the organization scope. Name one for every sign-in with organization, or for one sign-in with /auth?organization=acme:

masksNext({
scope: ["openid", "profile", "email", "offline_access", "organization"],
organization: (request) => new URL(request.url).hostname.split(".")[0],
});
masked.organization The organization signed in to.
masked.organizations Every organization the person has joined.
masked.role("owner") Whether the person holds that role in the organization signed in to.

/auth/session returns organization and organizations as well. The app reads the organizations from userinfo at sign-in and at every refresh, so a changed role reaches the app within an access token’s lifetime.

To let a person switch, link each organization to a new sign-in. masks asks a person who already belongs to it for nothing more:

{
masked.organizations.map((held) => (
<a
key={held.id}
href={masks.loginURL({ organization: held.key, returnTo: "/" })}
>
{held.name}
</a>
));
}

A privilege is a name for what a route needs. It can ask for scopes the person holds in masks, a role in the organization they signed in to, or both:

masksNext({
scope: [
"openid",
"profile",
"email",
"offline_access",
"organization",
"catalog:read",
],
privileges: {
admin: ["catalog:admin"],
billing: { role: ["owner", "billing"] },
acme_admin: {
scope: ["catalog:admin"],
role: "owner",
organization: "acme",
},
},
});
  • A list is a short way to write { scope: [...] }.
  • role takes one role or several. Any one of them is enough.
  • organization limits the privilege to one organization, named by key or id.
The sign-in holds the privilege The route runs.
A scope is missing The browser goes through masks once. It asks for the usual scopes and the privilege’s scopes, then comes back to the page it was on. A JSON request gets 403 with privilege_required, the privileges, and the login_url to send the browser to.
masks did not grant the scope on that trip 403 insufficient_scope. The person does not hold the scope, so the app does not send them through masks again for five minutes.
A role or organization does not match 403 insufficient_role or insufficient_organization. The browser does not go through masks.

An ordinary sign-in never asks for privilege scopes. masks puts a scope in the token only for a person who already holds it, so asking never gives anyone more access. The handshake registers the scopes of every privilege, so the issuer lets the app ask for them.

require, protect, guard, requireAuth, withMasks, and loginURL throw on a name that privileges does not define. In a URL, the app ignores the name.

A resource server is an app that accepts access tokens from other apps. resource is the audience that tokens must name, and resourceScopes lists the scopes your app accepts. In Next.js, withMasks checks a token when the request carries one:

lib/masks.ts
import { masksNext } from "@masks/server/next";
export const { handlers, withMasks } = masksNext({
resourceScopes: ["catalog:read"],
privileges: { read: ["catalog:read"] },
});
app/api/catalog/route.ts
import { withMasks } from "@/lib/masks";
export const GET = withMasks(
async (request, { masked }) => Response.json({ subject: masked.subject }),
"read",
);
app/.well-known/oauth-protected-resource/route.ts
import { handlers } from "@/lib/masks";
export const { GET } = handlers;

In Express and Hono, requireAuth("read") checks a token when the request carries one, and the middleware serves /.well-known/oauth-protected-resource itself.

The check takes the same privileges, roles and organizations included, and checks them against the token:

  • A missing or invalid token is refused with 401. The challenge for a missing token carries no error code, as RFC 6750 asks. The one for an invalid token carries invalid_token.
  • A token that lacks a privilege is refused with 403 and insufficient_scope, insufficient_role, or insufficient_organization. A token cannot go through masks again, so there is no second trip.
  • A refusal carries an RFC 6750 challenge whose resource_metadata points at /.well-known/oauth-protected-resource.
  • When the app cannot check a token, because the issuer’s keys could not be fetched or the store failed, it answers 503 with no challenge, so the client keeps a token that may be valid.

The app fetches the issuer’s keys again every five minutes. While masks cannot be reached, keys fetched within the last hour still check tokens.

A token bound to a key with DPoP is accepted only as Authorization: DPoP, with a proof that:

  • is signed by that key
  • names this request’s method, URL, and token
  • was made within the last 60 seconds, with 30 seconds of leeway for the clock
  • has not been seen before

The URL is the resource’s origin and the request’s path, so a proxy in front of the app needs no other setting.

An API with no browser sign-in uses Verifier on its own.

A delegation lets the app act as a person at a provider while they are away. Set delegations to the path the provider’s answer comes back to. The handshake then asks for masks:delegate: and registers that path:

export const { masks } = masksNext({ delegations: "/connect/callback" });

With Auth.js, pass the same option to masksAuth, and use the masks it returns the same way:

export const { handlers, auth, masks } = masksAuth(NextAuth, {
delegations: "/connect/callback",
});
app/connect/route.ts
export const GET = (request: Request) =>
masks.startDelegation(request, "google");
app/connect/callback/route.ts
export async function GET(request: Request) {
const { connection, cookies } = await masks.finishDelegation(request);
await save(connection.connection, connection.secret);
const headers = new Headers({ location: "/settings" });
for (const cookie of cookies) headers.append("set-cookie", cookie);
return new Response(null, { status: 303, headers });
}

startDelegation sends a person who is not signed in to sign in first. finishDelegation refuses a connection that this browser did not start, that somebody other than the person signed in started, or that masks made for another account.

Keep the secret encrypted. To call the provider later, ask for its token:

const delegations = await masks.delegations(request);
const upstream = await delegations.token(secret, connection);
await save(connection, upstream.secret);

Save upstream.secret before anything else, since each call replaces the secret. A DelegationRefused or DelegationUnavailable carries the replacement in secret even when the call fails after the refresh.

  • DelegationUnavailable means masks or the provider did not answer, and nothing was lost.
  • DelegationRefused means masks refused the call. Its signedInAgain is true when the person has to sign in again.

A JavaScript program on a machine, such as an agent an app installs, signs in as itself with Client.

An app that has claimed a namespace, through namespace in its handshake, enrolls each machine without a browser. It asks masks for an initial access token and hands it to the machine:

const client = await masks.client(request);
const enrollment = await client.enroll({
scope: ["catalog:agent"],
name: "nas",
});

The machine makes its own keys and registers with the token:

import { Issuer, Key } from "@masks/server";
const key = await Key.generate();
const issuer = await Issuer.discover("https://acme.auth.example.com");
const registration = await issuer.register(
{
client_name: "nas",
token_endpoint_auth_method: "private_key_jwt",
jwks: key.jwks(),
},
enrollment.initial_access_token,
);

client.unenroll(machineClientId) archives the machine and revokes its tokens. See enrolling a machine.