Auth.js
An app that already uses Auth.js (next-auth 5) keeps it. masksAuth from
@masks/server/authjs builds the Auth.js config and adds what masks offers beyond a plain OpenID
Connect provider:
- the handshake, so the app needs no client ID or secret
- token refresh that uses each refresh token once, however many requests arrive together
- organizations, the tenant, avatars, scopes, and privileges on the session
- a trip through masks to ask for a missing privilege
- back-channel logout, which ends the session when the person signs out of masks
- revoking the refresh token when the person signs out
Setting up
Section titled “Setting up”-
Build Auth.js with
masksAuth:auth.ts import NextAuth from "next-auth";import { masksAuth } from "@masks/server/authjs";export const {handlers,auth,signIn,signOut,requireAuth,accessToken,proxy,} = masksAuth(NextAuth, {privileges: { admin: ["catalog:admin"] },manages: "admin",}); -
Answer the Auth.js routes:
app/api/auth/[...nextauth]/route.ts import { handlers } from "@/auth";export const { GET, POST } = handlers; -
Add the proxy:
proxy.ts export { proxy } from "@/auth"; -
Set two variables:
Variable AUTH_MASKS_ISSUERThe tenant you sign in against. AUTH_SECRETThe Auth.js secret, such as the output of openssl rand -base64 32. -
Start the app and sign in. The first
signIn("masks")runs the handshake. After the approval, the browser signs in and comes back to the page it started on.
The second argument to masksAuth takes every Auth.js option as well, such as pages, callbacks,
and other providers. Your jwt and session callbacks run after the masks ones. Sessions are
always JSON Web Tokens.
In a page
Section titled “In a page”import { requireAuth } from "@/auth";
export default async function Settings() { const masked = await requireAuth("admin");
return <p>{masked.organization?.name}</p>;}requireAuth returns a signed-in Masked. It sends a signed-out person to sign in, and asks masks
once for a privilege’s missing scopes. After a sign-in, it sends the person back to the page the
proxy recorded. Without the proxy, name the page first, as in
requireAuth({ returnTo: "/billing" }, "admin").
accessToken() hands a fresh access token to server code. session.masks holds the person, with no
token, and is typed on the Auth.js Session.
A link to /api/auth/masks/signin signs in without a form. Before a sign-in starts, there or through
signIn("masks"), 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.
Signing out
Section titled “Signing out”signOut() revokes the refresh token and clears the session. The person stays signed in to masks.
To sign out of masks as well, post a form to /api/auth/masks/logout with everywhere:
<form method="post" action="/api/auth/masks/logout"> <input type="hidden" name="everywhere" value="1" /> <input type="hidden" name="callbackUrl" value="/goodbye" /> <button type="submit">Sign out</button></form>The app signs the person out, and the browser goes on to masks to end its session there. masks sends
the browser back through /api/auth/masks/logout/return to callbackUrl. The return works as in
signing out.
With signOutOfIssuer: true, every sign-out signs out of masks: that route, the signOut server
action from auth.ts, and signOut() from next-auth/react. Auth.js clears the session, then sends
the browser through /api/auth/masks/logout on its way to masks.
masks.signOutPath("/goodbye") builds that address for a single sign-out, as in
signOut({ redirectTo: masks.signOutPath("/goodbye") }).
/api/auth/masks/logout refuses a sign-out from another site with 403, as in
signing out. A GET to it 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. A POST that sends neither
Sec-Fetch-Site nor Origin is refused with 403.
Several servers
Section titled “Several servers”Within one Node process, masksAuth refreshes each sign-in once, even though Next.js loads auth.ts
once for the proxy and again for the routes. An app on several servers, or on serverless functions,
sets store to a Store that every server shares, such as
upstashStore() for Upstash Redis or Redis from the Vercel marketplace. See
running several processes for what it holds.
import NextAuth from "next-auth";import { upstashStore } from "@masks/server";import { masksAuth } from "@masks/server/authjs";
export const { handlers, auth, requireAuth, proxy } = masksAuth(NextAuth, { store: upstashStore(),});Reference
Section titled “Reference”masksAuth(NextAuth, options) takes every Auth.js option, and these:
| Option | Default | |
|---|---|---|
issuer |
AUTH_MASKS_ISSUER, then MASKS_ISSUER |
The tenant people sign in against. |
resource |
MASKS_RESOURCE, then the origin of AUTH_URL, then the request’s origin |
The app’s public address. |
secret |
AUTH_SECRET |
The Auth.js secret. |
id |
masks |
The provider id, as in signIn("masks"). It also names the variables: with id: "acme", the issuer is AUTH_ACME_ISSUER. |
label |
masks |
The provider’s name on the Auth.js sign-in page. |
name |
the app’s host | The app’s name on the masks approval screen. |
credentials |
AUTH_MASKS_ID and AUTH_MASKS_SECRET when set, then MASKS_CLIENT_ID and MASKS_CLIENT_SECRET, then masks.json |
A CredentialStore for the client the handshake registers. |
store |
memory | A Store every server shares. |
scope, namespace, organization, privileges, manages, delegations, avatarBytes, signOutOfIssuer, onLogout, allowPlainHTTP |
As in options. |
It returns what NextAuth returns, and:
handlers |
GET and POST, which also answer the routes below. |
auth() |
The Auth.js session. session.masks holds the person, and session.user.id is the subject. No token reaches the browser. |
requireAuth({ returnTo }, ...privileges) |
A signed-in Masked. { returnTo } is optional. A missing role or organization throws ForbiddenError. |
accessToken() |
The access token, refreshed when it is about to expire, for calling an API from the server. |
proxy |
Refreshes the session before a page renders, and records the page for requireAuth. When your authorized callback refuses a page, the proxy sends its redirect or answer. |
masks |
The MasksAuth underneath, with signOutPath, and with delegations, startDelegation, and finishDelegation as in Masks. |
session.user.image is /api/auth/masks/avatar when masks holds a photo, since the photo needs the
person’s token. Otherwise it is the picture claim, then the identicon.
These routes are answered under the Auth.js base path, /api/auth by default:
| Route | |
|---|---|
GET /api/auth/masks/signin |
Starts a sign-in. Takes callbackUrl, organization, privilege, and prompt, which is login or consent. |
GET /api/auth/masks/handshake |
Starts the handshake before the app is connected. After that, shows the connection to somebody manages allows. |
POST /api/auth/masks/handshake |
Reconnects. |
POST /api/auth/masks/handshake/disconnect or DELETE /api/auth/masks/handshake |
Disconnects, and goes back to the handshake. |
GET /api/auth/masks/handshake/callback |
Where masks sends the browser after an approval. |
GET or POST /api/auth/masks/logout |
Signs out. Takes callbackUrl, and everywhere to sign out of masks as well. A browser that names no site gets a page first. |
GET /api/auth/masks/logout/return |
Where masks sends the browser after a sign-out everywhere. |
GET /api/auth/masks/avatar |
The person’s photo, fetched from masks with their token. Takes size. |
POST /api/auth/masks/logout/backchannel |
Where masks posts a logout token. |