Skip to content

HTTP transport

@smplcty/auth/http is the transport tier. The core library gives you primitives — the method router, the OIDC handler, the session lifecycle. This subpath wires them into ready-made HTTP endpoints so an app doesn’t re-implement the /auth/* routes, the session cookie, the signed OIDC login-state cookie, and the per-request auth middleware.

Every handler is a Web-standard (Request) => Promise<Response>, so it mounts directly in Hono and Next.js App Router — no adapter. Twilio and oauth4webapi stay optional peers: you pass the handlers you constructed via config, so the transport tier adds no new heavy dependency (it’s built on Web Request/Response and Web Crypto).

After mounting this, the only auth code an app writes is:

  1. Config — its pool, cookie policy, a tenant-slug resolver (Host parsing is deployment-specific), and — opt-in — the OTP and/or OIDC handlers.
  2. The sign-in screen — presentation only; it calls GET …/sign-in/options and renders the IdP buttons + an OTP form.
  3. Its authorization scope + RLS policies — the app’s domain model.
  4. Seed rows — its tenants, auth_domains, service principals.

Everything else — OTP send/verify, dev-OTP fallback, OIDC initiate/callback/token-exchange, session mint + cookie, login-state cookie, per-request session→GUCs→RLS — comes from the library.

import { createAuthHandlers } from '@smplcty/auth/http';
import { oidcHandler } from '@smplcty/auth/oidc';
import { twilioVerifyHandler } from '@smplcty/auth/twilio';
import { createTwilioVerifyClient } from '@smplcty/twilio';
const handlers = createAuthHandlers({
pool,
cookie: { name: 'app_session', domain: '.app.example.com' },
loginStateSecret: process.env.LOGIN_STATE_SECRET!,
// Host → tenant slug (deployment-specific).
tenantSlugFromRequest: (req) => new URL(req.url).hostname.split('.')[0] ?? null,
// Opt-in user-bound OTP. Omit for an SSO-only product.
otpHandler: twilioVerifyHandler({ client: createTwilioVerifyClient(twilioCfg) }),
// Opt-in org-bound OIDC. The client secret comes from YOUR secret store, never the DB.
oidc: oidcHandler({ clientSecret: (ad) => secrets.get(ad.tenantId) }),
});
// One line: the dispatcher matches the known auth routes by method + path suffix.
app.all('/auth/*', (c) => handlers.handle(c.req.raw).then((res) => res ?? c.notFound()));
// app/auth/[...auth]/route.ts — the handlers ARE Web-standard route handlers.
import { createAuthHandlers } from '@smplcty/auth/http';
const handlers = createAuthHandlers(/* … */);
export const GET = (req: Request) => handlers.handle(req).then((r) => r ?? new Response(null, { status: 404 }));
export const POST = GET;

Or wire individual endpoints as explicit routes (export const GET = handlers.signInOptions).

Paths are shown relative to your mount base (e.g. /auth). handle(request) dispatches by method + path suffix (so it’s robust to whatever prefix the app mounts under) and returns null when nothing matches, letting the host fall through.

Method & pathPurposeRequestResponse
GET …/sign-in/optionsWhat the sign-in screen renders for this tenant.tenant from tenantSlugFromRequest{ tenantId, otpAllowed, authDomains: [{ authDomainId, displayName, integrationType }] }
POST …/otp/initiateSend an OTP.{ identifier }{ otpSent: true }
POST …/otp/completeVerify an OTP, mint a session.{ identifier, credential, returnTo? }200 + session cookie, { ok: true, returnTo }
GET …/oidc/start?authDomainId=&returnTo=Begin OIDC: redirect to the IdP.query params302 to the IdP + a signed login-state cookie
GET …/oidc/callbackFinish OIDC: verify, mint a session.provider’s ?code&state + login-state cookie302 to returnTo + session cookie
POST …/sign-outRevoke the session, clear the cookie.session cookie / bearer200 + cleared cookie
GET …/sessionThe current identity context.session cookie / bearer{ authenticated, userId, activeRole, roles, privileges } or 401

integration_params (issuer/clientId) are not included in sign-in/options — the screen only needs the id, label, and type.

GET /auth/sign-in/options → { authDomains[], otpAllowed }
sign-in screen ───────────────────────────────────────────────────────────────────►
├─ user picks an IdP button ──► GET /auth/oidc/start?authDomainId=ID
│ └─► 302 to IdP ──► IdP ──► GET /auth/oidc/callback
│ └─► 302 home + session
└─ user enters email/phone ───► POST /auth/otp/initiate { identifier }
POST /auth/otp/complete { identifier, credential }
└─► 200 + session

The chooser logic the screen renders from sign-in/options: 0 IdPs → OTP form only; 1 IdP → a single button (or auto-start); N IdPs → one button per IdP. The OTP form shows only when otpAllowed is true — and that flag is enforced server-side in the OTP endpoints too, so an SSO-only tenant can’t be bypassed by a crafted request.

Two cookies, both built from Web standards (no Buffer; edge-safe):

  • Session cookie (cookie.name) — carries the raw opaque session token. HttpOnly, Secure (default), SameSite=Lax, Path=/. Set cookie.domain to a parent domain (e.g. .app.example.com) to share the session across tenant sub-domains. Max-Age defaults to 30 days (cookie.maxAgeSeconds); the authoritative expiry is server-side on the sessions row.
  • OIDC login-state cookie (<name>_oidc) — short-lived (loginStateTtlSeconds, default 600s), HMAC-signed with loginStateSecret. Holds the auth_domains id plus the PKCE state/nonce/codeVerifier the OIDC handler produced, so the callback can complete the flow. SameSite=Lax so it survives the top-level GET redirect back from the IdP. Verified constant-time and freshness-checked; cleared after callback.

The signing helpers are exported if you need them directly: serializeCookie, parseCookies, signLoginState, verifyLoginState.

Use these in your own middleware / loaders to authenticate every other request:

import { getSessionToken, withRequestSession } from '@smplcty/auth/http';
// Read the raw token from the cookie or `Authorization: Bearer`.
const token = getSessionToken(request, config); // string | null
// Resolve + validate the session, set identity GUCs, run the app scope hook,
// and run your code under RLS — the request-shaped wrapper around withSession.
const data = await withRequestSession(request, config, async (client, ctx) => {
// ctx = { userId, activeRole, roles, privileges }
const { rows } = await client.query('SELECT * FROM widgets'); // RLS-scoped
return rows;
}, { roleName: 'user' });

withRequestSession rejects with the same withSession errors (SessionNotFoundError, SessionExpiredError, RoleNotHeldError), plus SessionNotFoundError when the request carries no token.

FieldRequiredPurpose
poolThe RLS pg.Pool used for sessions, the method router, and OIDC.
cookie.nameSession cookie name.
cookie.domainParent domain for sub-domain session sharing.
cookie.sameSite / secure / path / maxAgeSecondsCookie attributes (defaults: lax / true / / / 30 days).
loginStateSecretHMAC secret for the OIDC login-state cookie.
tenantSlugFromRequestResolve the tenant slug from the request (e.g. parse the Host).
otpHandlerThe user-bound OTP handler. Omit for SSO-only.
oidcThe oidcHandler. Omit if the app has no OIDC.
scopeApp-owned intra-tenant scope hook (applied by withRequestSession and GET …/session).
sessionTtlSession lifetime, a Postgres interval. Default '30 days'.
loginStateTtlSecondsOIDC login-state cookie lifetime. Default 600.
returnToDefaultPost-sign-in redirect when no safe returnTo is given. Default '/'.
sessionMetaDerive IP/geo for the sessions row from the request.
loggerPassed through to withSession.
  • Open-redirect guardreturnTo is honored only when it’s a same-origin relative path; absolute URLs, protocol-relative //host, and the backslash variant /\host (which browsers normalize to //host) fall back to returnToDefault.
  • Signed login-state — HMAC-SHA256, constant-time verify, freshness-checked; a tampered or stale cookie is rejected (the callback returns 400).
  • allow_otp enforced server-side, not just hidden in the UI.
  • Cookies are HttpOnly + Secure + SameSite=Lax; the session token is a 256-bit opaque value whose hash is all that’s stored.
  • Error mapping is opaque — handlers map known auth errors to status codes (400/401/403) with generic messages and never leak internals (unknown errors become a bare 500).

See the security model for the library-wide guarantees.