@wtfalch/auth
v0.15.0
Published
Sign in against auth.wtfalch.dev: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.
Readme
@wtfalch/auth
Sign in against auth.wtfalch.dev from a Next.js app.
A thin OIDC client for one self-hosted ZITADEL instance, plus a client for the sign-in service that sits in front of it. Apps host their own password sign-in pages. Flows requiring MFA or another unsupported login policy continue at the identity service's hosted login.
Built for the wtfalch estate, published because valet consumes it from a
container and a file: path does not survive a Docker build.
pnpm add @wtfalch/authNode 22 or later. next is a peer dependency and only the /next entry point
needs it.
Using it
// src/lib/auth.ts
import { nextAuth } from '@wtfalch/auth/next';
export const { handlers, proxy, getUser, requireUser, signIn } = nextAuth({
appUrl: process.env.APP_URL,
clientId: process.env.AUTH_CLIENT_ID,
organizationId: process.env.AUTH_ORGANIZATION_ID,
cookieSecret: process.env.AUTH_COOKIE_SECRET,
appKey: process.env.AUTH_APP_KEY,
afterLogin: '/portal',
});Mount handlers at app/auth/[...auth]/route.ts, call proxy from your
middleware for the paths that need a session, and read the person with
getUser() in a server component.
createAuth from @wtfalch/auth is the same thing without the framework, for
a CLI or a worker that has no router to import.
MFA and hosted login
The broker checks the organisation's login policy and enrolled authentication methods before completing password sign-in, signup, or password reset. MFA, disabled password authentication, and unknown authentication methods require the hosted login. Email links always continue there: ZITADEL email OTP is a second factor and cannot authenticate a session by itself.
The Next.js adapter handles these redirects and preserves the OIDC
transaction. With the framework-neutral API, a successful result carrying
hosted: true means authentication is still pending: redirect to
redirectTo (sign-in/signup) or location (reset), writing any returned
cookies first. Do not pass a hosted URL to complete. The eventual OIDC
callback completes authentication with the original PKCE, state, nonce, and
destination.
Auth-request lookup failures lead to the configured error page with
auth_error=request or auth_error=unavailable. That page should show a
retry link; it must not automatically restart authorization.
In a browser app
@wtfalch/auth/browser is the other half: a public PKCE client for an app that
runs in the browser rather than on a server, and silent single sign-on
across the estate's subdomains.
import { createBrowserAuth } from '@wtfalch/auth/browser';
const auth = createBrowserAuth({
issuer: 'https://auth.wtfalch.dev',
clientId: '…', // this app's client id at the issuer
callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
});
// On load, with no token: ask the issuer without interacting. Either it
// answers with a code — a flash and the app is signed in — or it says
// `login_required` and the app shows its own button.
if (!auth.currentSession()) await auth.trySilentSignIn();Why not one cookie for .<apex>. The server session above is a __Host-
cookie, which by specification carries no Domain: host-only, and that is what
stops one subdomain writing a session the next one trusts. Single sign-on comes
from the issuer's session instead — each origin takes its own token from
it, and the person is asked for credentials once.
silentSignInAvailable() is what a caller draws from: with no session it
cannot otherwise tell about to leave for the issuer from asked already, and
here we are, and the first case paints a sign-in screen at somebody on their
way to being signed in.
Every silent attempt happens at most once per tab, because an issuer
answering login_required returns the person to a page whose load would ask
again. forget() sets the same mark, so a sign-out does not undo itself.
completeSignIn() at the callback returns signed-in, silent-refused — the
answer to a silent attempt, not a failure — or error.
Multi-session seams: a non-default opener, storage, refresh and scopes
Four options on BrowserAuthOptions exist for a client that is not a
plain same-origin web page — a phone driving a system browser, a desktop app,
or an app juggling more than one issuer session. Every default matches the
behaviour above exactly, so a caller that sets none of them sees no change.
const auth = createBrowserAuth({
issuer: 'https://auth.wtfalch.dev',
clientId: '…',
open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
store: capacitorSecureStore, // see "Storage stays synchronous" below
extraScopes: ['urn:zitadel:iam:org:project:id:<mail project>:aud'],
});open(url)replaceswindow.location.assign— what leaves the tab for the issuer. It may return a promise, which is awaited beforeauthorize()returns; the default stays synchronous.storereplaces thesessionStoragewrapper that holds the token, verifier, return path and silent-tried mark, with a plain{ get, set, drop }shape —getsynchronous,set/dropallowed to return a promise (see "Storage:getstays synchronous" below). TwocreateBrowserAuthinstances given different stores never see each other's values — one app's session cannot leak into another's held in the same tab.refresh(), a new method on the returned client, posts therefresh_tokengrant and stores the session it gets back — awaiting the store's write before it resolves, so an app that awaitsrefresh()knows the rotated token is durable, not just in memory. ZITADEL rotates the refresh token on every use — the one just spent cannot be reused — so a refusal ({ kind: 'refused' }) drops the held session rather than leaving a dead one behind;{ kind: 'no-refresh-token' }means there was nothing to refresh. Concurrent calls share one in-flight exchange: two callers racing near expiry both get the freshly rotated session, rather than the second spending the first's now-dead token and refusing a session the first just set. The refresh token itself never appears on the valuecurrentSession()orrefresh()returns to app code — onlyrefresh()'s own closure over the store can reach it — because a value passed around the app should not double as something that can mint new access tokens.extraScopesappends toscope(or its default) at/authorize. Each entry is itself split on whitespace and de-duplicated against the rest —['read write']and['read', 'write']do the same thing — so an audience scope for one project, say, never lands twice or leaves a stray space when an entry is empty. The refresh grant freezes the audience from first sign-in, so this only matters there.forget()is nowPromise<void>rather thanvoid, the one signature change here: it awaits the store's writes, the same ascompleteSignIn()andrefresh().
Storage: get stays synchronous; set and drop may be async.
currentSession() and silentSignInAvailable() are synchronous methods on
every createBrowserAuth client, defaults included, and both read only
through the store's get — so get cannot become a promise without changing
that contract for every existing caller, not just a Capacitor one. set and
drop can, because the methods that write through them —
completeSignIn(), refresh() and forget() — are already async: each now
awaits the store's write before it returns, so a write is durable by the time
an app that awaits one of those methods acts on it.
A Capacitor secure-storage store bridges the two: keep a synchronous
in-memory mirror for get to read, and have set/drop write the mirror
and await the secure store, so the promise those methods return resolves
only once the write actually lands. Hydrate the mirror from the secure store
once at startup, before the app's first call to currentSession() — a get
before that has nothing to read and reports no session even when one is
held.
Namespace clients (namespace: {...}) do not take a custom store. That
path already coordinates a session across tabs and clients through its own
fixed sessionStorage/localStorage keys (see namespace-browser.ts), and
swapping in an injected store would mean redesigning that coordination, not
wiring one in — createBrowserAuth throws rather than silently ignoring it.
open and extraScopes do apply there, unchanged. refresh() is present but
always reports no-refresh-token: the namespace exchange never retains a
refresh token to begin with.
Configuration
| | |
| --- | --- |
| appUrl | This app's origin. Every URL the SDK builds starts here, never from the request's Host. |
| clientId | The OIDC client for this origin. Each URL an app runs at is its own client, because the issuer holds one login URL per application. |
| organizationId | Scopes every sign-in, and is checked on every token. |
| cookieSecret | 32 bytes, openssl rand -base64 32. One per app. |
| appKey | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
Values are read on first use rather than at import, so next build needs none
of them, and a missing one fails by name rather than mysteriously.
A cookie secret is needed where cookies are, and nowhere else. An app that never seals a session, such as a CLI that sends an invitation, can leave it unset: the error waits until something actually reaches for the key. A secret that is supplied and wrong still throws at startup, because that is a typo rather than a deployment that does not need one.
What the app key can do
It reaches the sign-in service, which holds ZITADEL's login client. Scoped to one organisation and to this app's clients, it can create people in that organisation, send mail to an address, and try passwords.
It cannot sign in as somebody without their password or their mailbox, cannot reach another app, and cannot change anything on the instance. Treat it as a secret; it is not a skeleton key.
The session
The cookie is the session. There is no session table and no token store, so
there is nothing to expire, revoke or clean up on the app's side, and
authority is whatever the app reads for itself on each request. The cookie is
encrypted with cookieSecret, carries __Host- and Secure on an https://
app URL, and refreshes itself when the id token is close to expiring.
ORG_CLAIM is enforced on every token: a token from another organisation is
refused even when the issuer and the signature are good.
The cookie never carries an access token -- every exchange and refresh
discards it. accessToken(cookieValue) reads one back for a server that
needs to call a resource server on a signed-in person's behalf: it runs the
refresh grant itself and returns { ok: true, accessToken, expiresAt,
cookies } or { ok: false, reason }. The refresh token is spent on every
call, so cookies -- always the re-sealed session, carrying the grant's
fresh id token even when the refresh token itself did not rotate -- must be
persisted before the session is read or accessToken called again.
Not available in namespace mode. extraScopes on createAuth's options
adds to the scope every sign-in and QR device grant asks for, for a
resource server's own audience scope. See docs/adopting.md for both.
Passkeys and QR sign-in
Passkeys live on each app's own domain: the relying-party ID is the app's
origin, so one made on one app never signs in on another. @wtfalch/auth/passkey
is the browser half of the WebAuthn ceremony; startPasskeyRegistration,
finishPasskeyRegistration, startPasskeySignIn and finishPasskeySignIn are
the server half.
A phone that is already signed in can also approve a new device from a QR
code, the issuer's OAuth device grant opted into per namespace binding with
qrSignIn: true, through startQrSignIn and pollQrSignIn.
Documentation
docs/adopting.md in wtfalch/auth covers a
full adoption: the files to add, the email flows, invitations, refresh,
passkeys, signing in a new device from a QR code, and the checks to run
against a real issuer.
Licence
Private to the wtfalch estate.
