@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/authReplaces 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_uriof the authorize request, so it is rebuilt fromappUrlwith only the query string taken from the request. Otherwise Zitadel answersinvalid_grant: redirect_uri does not correspond. - Base path. The callback URL is joined as a string;
new URL('/api/...', appUrl)would drop/accountfromhttps://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.
returnTogoes through the URL parser:/\evil.examplepasses 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*.localhostsubdomain); a plain-http issuer on any other hostname is rejected even withallowInsecureHttp: true. Without the opt-in, or on a non-loopback host, discovery itself fails against a plain-http issuer, surfacing asAuthError('discovery_failed')like any other discovery failure. - Discovery is cached per process, and retried after a failure.
