Skip to content

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
  1. 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",
    });
  2. Answer the Auth.js routes:

    app/api/auth/[...nextauth]/route.ts
    import { handlers } from "@/auth";
    export const { GET, POST } = handlers;
  3. Add the proxy:

    proxy.ts
    export { proxy } from "@/auth";
  4. Set two variables:

    Variable
    AUTH_MASKS_ISSUER The tenant you sign in against.
    AUTH_SECRET The Auth.js secret, such as the output of openssl rand -base64 32.
  5. 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.

app/settings/page.tsx
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.

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.

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.

auth.ts
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(),
});

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.