npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/auth

Node 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) replaces window.location.assign — what leaves the tab for the issuer. It may return a promise, which is awaited before authorize() returns; the default stays synchronous.
  • store replaces the sessionStorage wrapper that holds the token, verifier, return path and silent-tried mark, with a plain { get, set, drop } shape — get synchronous, set/drop allowed to return a promise (see "Storage: get stays synchronous" below). Two createBrowserAuth instances 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 the refresh_token grant and stores the session it gets back — awaiting the store's write before it resolves, so an app that awaits refresh() 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 value currentSession() or refresh() returns to app code — only refresh()'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.
  • extraScopes appends to scope (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 now Promise<void> rather than void, the one signature change here: it awaits the store's writes, the same as completeSignIn() and refresh().

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.