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

@ultimatelemon-eu/auth

v1.6.0

Published

Zitadel login for UltimateLemon apps: OIDC code flow with PKCE and a signed session cookie. Framework-agnostic, with cookies handed back to the caller.

Readme

@ultimatelemon-eu/auth

Zitadel login for UltimateLemon apps: the OIDC authorization code flow with PKCE, and a signed session cookie. It takes and returns plain values and cookies, so it works in any framework; the Next.js wiring is ten lines.

npm install @ultimatelemon-eu/auth

Replaces the copies of oidc.ts and session.ts in accounts and nova-v2.

Setup

// src/lib/auth.ts
import 'server-only';

import { createAuth } from '@ultimatelemon-eu/auth';

export const auth = createAuth(() => ({
  issuer: process.env.OIDC_ISSUER ?? '',
  clientId: process.env.OIDC_CLIENT_ID ?? '',
  clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
  appUrl: process.env.APP_URL ?? '',
  sessionSecret: process.env.SESSION_SECRET ?? '',
  cookiePrefix: 'myapp'
}));

Pass a function: nothing is read until the first login or session check, so next build without env does not fail on an import.

| Option | Default | Does | | ---------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | issuer | required | Zitadel instance URL | | clientId | required | OIDC client id | | clientSecret | required | OIDC client secret | | appUrl | required | Public URL of the app, including base path | | sessionSecret | required | HS256 key for the session, 32+ characters | | cookiePrefix | 'ul' | Cookies become __Host-<prefix>_session | | callbackPath | '/api/auth/callback' | Register appUrl + callbackPath in Zitadel | | scope | 'openid profile email' | | | sessionMaxAgeSeconds | 14 days | After this, back through Zitadel | | allowInsecureHttp | false | Allow a plain-http issuer on a loopback hostname only (localhost, 127.0.0.1, [::1], *.localhost); ignored when NODE_ENV === 'production' |

Next.js

// src/app/api/auth/login/route.ts
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

import { auth } from '@/lib/auth';

export async function GET(request: Request): Promise<never> {
  const next = new URL(request.url).searchParams.get('next');
  const { url, cookie } = await auth.startLogin(next);
  (await cookies()).set(cookie.name, cookie.value, cookie.options);
  redirect(url);
}
// src/app/api/auth/callback/route.ts
export async function GET(request: Request): Promise<never> {
  const store = await cookies();
  const result = await auth.finishLogin(
    request.url,
    store.get(auth.flowCookieName())?.value
  );
  for (const c of result.cookies) store.set(c.name, c.value, c.options);
  redirect(result.returnTo);
}
// anywhere on the server
const session = await auth.readSession(
  (await cookies()).get(auth.sessionCookieName())?.value
);

startLogin and finishLogin throw an AuthError with a code on a discovery failure: discovery_failed. finishLogin additionally throws flow_expired, exchange_failed, no_identity, profile_unreachable or no_email. Catch it in the login route and the callback and show a page instead of a stack trace.

A throw from your own options config function (e.g. a lazily-read missing env var) during startLogin or finishLogin is never wrapped as an AuthError — it propagates as-is. finishLogin can likewise propagate a raw TypeError from a malformed request.url; startLogin never reads the request, so that case doesn't apply there.

readSession only swallows problems with the session itself — a missing/malformed/expired/forged token, or a sessionSecret shorter than 32 characters — resolving to null the same as a missing cookie. It does not swallow a throw from your own options config function: readSession reads your config synchronously before it ever looks at the token, so that throw escapes immediately, not even as a rejected promise.

The original error from the identity provider is in cause — for exchange_failed and profile_unreachable that can be a token-endpoint response or userinfo claims (email, sub). Don't log or render cause (or the whole AuthError, which carries it) as-is. Use describeAuthError() for a safe summary instead:

import { describeAuthError } from '@ultimatelemon-eu/auth';

try {
  const result = await auth.finishLogin(request.url, flowCookie);
  // ...set result.cookies, redirect to result.returnTo
} catch (error) {
  logger.error(describeAuthError(error), 'login failed');
  // { name: 'AuthError', code: 'exchange_failed', message: 'exchange_failed' }
}

| Function | Returns | | -------------------------- | -------------------------------------------------------------- | | describeAuthError(error) | { name, code, message } for an AuthError, null otherwise |

If a consumer needs the raw provider error (e.g. for a support ticket), read error.cause directly and redact it first.

readSession checks signature, expiry, issuer and audience, and returns null on any doubt. It uses jose only, so a proxy.ts can call it.

A session is { sub, email, name, authTime }, where sub is the Zitadel user id and authTime (seconds) is when the user last entered credentials. Permissions do not belong in it: read them per request, so revoking access works immediately rather than when the token expires.

Claims at login

finishLogin also returns claims: the raw id_token claims. Use them for a decision made once at login, such as mapping Zitadel project roles onto another system's roles. They are not part of the session. Zitadel only puts urn:zitadel:iam:org:project:roles in the id_token when the project asserts roles on authentication and the app has "User roles inside ID Token" on.

const { claims } = await auth.finishLogin(request.url, flowCookie);
const roles = Object.keys(
  (claims['urn:zitadel:iam:org:project:roles'] as object | undefined) ?? {}
);

Sensitive actions

Single sign-on can log a user in without asking for anything. Before showing a secret, require a recent authTime and otherwise send the user through Zitadel with prompt=login:

const fresh = session.authTime > Date.now() / 1000 - 10 * 60;
if (!fresh) {
  const { url, cookie } = await auth.startLogin(next, { reauthenticate: true });
  // set cookie, redirect to url
}

What it gets right

  • Reverse proxy. Behind Traefik the request URL is the internal one. The token request must repeat the exact redirect_uri of the authorize request, so it is rebuilt from appUrl with only the query string taken from the request. Otherwise Zitadel answers invalid_grant: redirect_uri does not correspond.
  • Base path. The callback URL is joined as a string; new URL('/api/...', appUrl) would drop /account from https://id.example/account.
  • Profile claims. Zitadel leaves email and name out of the id_token unless "User Info inside ID Token" is on, so it falls back to the userinfo endpoint.
  • Open redirects. returnTo goes through the URL parser: /\evil.example passes a string check for // but resolves to another origin.
  • Local http. A plain-http issuer is rejected unless the consumer opts in with allowInsecureHttp: true — and never in production, regardless of that flag. The opt-in also only applies to a loopback issuer hostname (localhost, 127.0.0.1, [::1], or a *.localhost subdomain); a plain-http issuer on any other hostname is rejected even with allowInsecureHttp: true. Without the opt-in, or on a non-loopback host, discovery itself fails against a plain-http issuer, surfacing as AuthError('discovery_failed') like any other discovery failure.
  • Discovery is cached per process, and retried after a failure.