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

context-auth

v0.7.0

Published

Contextser Accounts OAuth2 / OIDC client for any JavaScript runtime — Next.js, Astro, Express, React, Vue, and anything that speaks Request/Response.

Readme

context-auth

Sign in with Contextser Accounts from any JavaScript application.

The core is built on Web standards only — Request, Response, fetch, WebCrypto — with zero runtime dependencies, so the same code runs on Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge and Netlify. Framework adapters are thin wrappers on top.

npm install context-auth

Put three variables in your environment and there is no configuration to write:

CONTEXTSER_CLIENT_ID=ctx_...
CONTEXTSER_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SESSION_SECRET=      # openssl rand -hex 32
export const auth = createNextAuth();   // that's it

| Adapter | Import | For | |---|---|---| | Core | context-auth | Anything that speaks Request/Response — Hono, Remix, SvelteKit, Nuxt server routes, Workers | | Next.js | context-auth/next | App Router and Pages Router | | Astro | context-auth/astro | SSR pages, endpoints, middleware | | Express | context-auth/express | Express, Connect, plain node:http | | React | context-auth/react | <ContextAuthProvider> + useAuth() | | Vue | context-auth/vue | Plugin + useAuth() composable |


Before you start

  1. Register your application in the Contextser admin console. You get a client id, a client secret (shown once), and you set the redirect URI.
  2. The redirect URI must match byte for byte — no wildcards, no prefixes — and must be https, except http://localhost for development.
  3. If you mount the routes at the default /api/auth, register https://yourapp.com/api/auth/callback.
CONTEXTSER_CLIENT_ID=ctx_...
CONTEXTSER_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SESSION_SECRET=      # openssl rand -hex 32

CONTEXTSER_SESSION_SECRET is yours, not the provider's. It encrypts the session cookie; rotating it signs everyone out and does nothing else. AUTH_SECRET works too, if that is the name you already use.

This library is server-side. Contextser has no public-client mode — the token endpoint always requires your client secret. A browser cannot complete the flow on its own, and the React/Vue adapters never see the secret: they read your own backend's session route.


Next.js

// lib/auth.ts
import { createNextAuth } from "context-auth/next";

export const auth = createNextAuth();
// app/api/auth/[...contextauth]/route.ts
import { auth } from "@/lib/auth";
export const { GET, POST } = auth.handlers;
// app/page.tsx  — a Server Component
import { auth } from "@/lib/auth";

export default async function Page() {
  const session = await auth.session();
  if (!session) return <a href={auth.signInUrl("/")}>Sign in with Contextser</a>;
  return <p>Hello {session.user.name}</p>;
}
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth";

export async function middleware(request: NextRequest) {
  // Renews the session and writes the new cookie. A Server Component cannot
  // set cookies, so this is the only place a renewal can be persisted —
  // without it, session() stops working once the access token expires.
  const guard = await auth.protect(request);
  return await auth.keepAlive(request, guard ?? NextResponse.next());
}

export const config = { matcher: ["/dashboard/:path*"] };

keepAlive is not optional if you want sessions that outlive the access token — run it on every route that reads one, not just the protected subtree. protect on its own only redirects visitors who have no session at all.

// pages/api/auth/[...contextauth].ts
import { auth } from "@/lib/auth";
export default auth.pagesHandler;
// getServerSideProps
export async function getServerSideProps({ req }) {
  const session = await auth.getSessionFromCookie(req.headers.cookie);
  if (!session) return { redirect: { destination: auth.signInUrl("/dashboard"), permanent: false } };
  return { props: { user: session.user } };
}

Astro

// src/lib/auth.ts
import { createAstroAuth } from "context-auth/astro";

export const auth = createAstroAuth();

If your values live in a .env file rather than the real process environment, Vite loads them into import.meta.env — which a dependency cannot reach. Hand it over once:

export const auth = createAstroAuth({ env: import.meta.env });
// src/pages/api/auth/[...contextauth].ts
import { auth } from "../../../lib/auth";
export const ALL = auth.ALL;
export const prerender = false;
// src/middleware.ts — puts the session on Astro.locals for every page
import { auth } from "./lib/auth";
export const onRequest = auth.middleware();
---
const { user } = Astro.locals;
---
{user ? <p>Hello {user.name}</p> : <a href="/api/auth/signin">Sign in</a>}

Add to src/env.d.ts:

declare namespace App {
  interface Locals {
    session: import("context-auth").ContextSession | null;
    user: import("context-auth").ContextUser | null;
  }
}

Astro must be in SSR mode (output: "server", or export const prerender = false on the pages that read the session). A prerendered page has no request and therefore no session.


Express

import express from "express";
import { createExpressAuth } from "context-auth/express";

const auth = createExpressAuth();

const app = express();
app.use(auth.middleware());               // serves /api/auth/*, sets req.session
app.get("/me", auth.requireAuth(), (req, res) => res.json(req.user));

React

The provider talks to your backend, so it works with any of the server adapters above.

import { ContextAuthProvider, useAuth } from "context-auth/react";

export default function App() {
  return (
    <ContextAuthProvider>
      <Profile />
    </ContextAuthProvider>
  );
}

function Profile() {
  const { user, status, signIn, signOut } = useAuth();
  if (status === "loading") return <p>Loading…</p>;
  if (!user) return <button onClick={() => signIn()}>Sign in with Contextser</button>;
  return (
    <>
      <img src={user.picture ?? undefined} alt="" width={32} height={32} />
      <span>{user.name}</span>
      <button onClick={() => signOut()}>Sign out</button>
    </>
  );
}

useRequireAuth() is the same hook, but it redirects to sign-in when there is no session.


Vue

// main.ts
import { createContextAuthPlugin } from "context-auth/vue";
app.use(createContextAuthPlugin());
<script setup lang="ts">
import { useAuth } from "context-auth/vue";
const { user, status, signIn, signOut } = useAuth();
</script>

<template>
  <p v-if="status === 'loading'">Loading…</p>
  <button v-else-if="!user" @click="signIn()">Sign in with Contextser</button>
  <button v-else @click="signOut()">Sign out, {{ user.name }}</button>
</template>

Anything else

Every adapter is a wrapper around one function that takes a Request and returns a Response. If your framework speaks those, you already have an adapter:

import { createContextAuth } from "context-auth";

const auth = createContextAuth();

// Hono
app.all("/api/auth/*", (c) => auth.handler(c.req.raw));

// SvelteKit — src/hooks.server.ts
export async function handle({ event, resolve }) {
  const handled = await auth.handler(event.request);
  if (handled) return handled;
  event.locals.session = await auth.getSession(event.request);
  return resolve(event);
}

// Cloudflare Workers — bindings arrive per request, so hand them over first.
// Configuration resolves lazily, so this still lands in time.
import { setContextAuthEnv } from "context-auth";

export default {
  async fetch(request, env) {
    setContextAuthEnv(env);
    return (await auth.handler(request)) ?? new Response("Not found", { status: 404 });
  },
};

handler() returns null for paths outside basePath, so it composes cleanly.


The routes it mounts

All relative to basePath (default /api/auth).

| Route | Method | What it does | |---|---|---| | /signin | GET, POST | Pushes the request to Contextser, sets the transaction cookie, redirects. Takes ?callbackUrl=/path. | | /callback | GET | Verifies state, exchanges the code, fetches the profile, sets the session cookie. | | /session | GET | JSON for the browser: { user, scope, expiresAt }. Never the access token. | | /signout | GET, POST | Clears the session cookie. JSON for fetch, redirect for a link. | | /error | GET | Minimal failure page. Point errorPath at your own UI to replace it. |


Live and sandbox

A Contextser application holds two independent credential sets. They are separate clients with separate secrets and separate callback URLs, and neither can redeem the other's authorization codes — so the only thing that changes between them is which values are in your environment.

# .env.local — development
CONTEXTSER_CLIENT_ID=ctx_sandbox_ecomly_9f3c1a20
CONTEXTSER_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SESSION_SECRET=...

Sandbox client IDs carry a ctx_sandbox_ prefix, so that is all it takes — the environment is inferred and session.environment reads sandbox.

To keep both pairs in one file, name the sandbox ones explicitly and switch with a single line:

CONTEXTSER_ENVIRONMENT=sandbox

CONTEXTSER_CLIENT_ID=ctx_ecomly_3b457e68
CONTEXTSER_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SANDBOX_CLIENT_ID=ctx_sandbox_ecomly_9f3c1a20
CONTEXTSER_SANDBOX_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SESSION_SECRET=...

Setting CONTEXTSER_ENVIRONMENT also enforces it: if the provider reports these credentials as belonging to the other environment, the sign-in fails with environment_mismatch instead of quietly running production on test credentials. An inferred environment is never enforced — a guess should not turn a working deployment into a hard failure.

Show it in your UI:

const { user, environment } = useAuth();
{environment === "sandbox" && <span className="badge">Sandbox</span>}

Only live credentials may use https callbacks; http://localhost is a sandbox-only privilege, because on a live client the authorization code would be delivered to whatever is listening on that port on the user's machine.


Your application's users

Every user who completes a sign-in is recorded against the credentials they used, and you can read that list back with your client credentials — server-side only.

// One page
const { users, total, nextCursor } = await auth.client.listUsers({ limit: 50 });

// Everyone, streamed
for await (const user of auth.client.iterateUsers()) {
  await db.user.upsert({ where: { contextserId: user.sub }, ... });
}

// Just the number
const total = await auth.client.countUsers();

Each entry carries the profile plus how the user is connected:

{
  sub: "usr_...",
  name: "Ada Lovelace",
  email: "[email protected]",
  email_verified: true,
  status: "active",
  scope: "openid profile email",
  first_authorized_at: "2026-07-14T09:12:03.000Z",
  last_authorized_at: "2026-08-01T04:55:10.000Z",
  authorizations: 7,
}

Three things worth knowing:

  • Claims are filtered per user, by the scope that user granted. Someone who consented to openid alone appears as a bare sub — they are not levelled up to match their neighbours.
  • Live and sandbox are separate. Sandbox test accounts never appear in a live listing.
  • picture is a URL, always — a photo uploaded to Contextser is stored and served as a file, never inlined into the claim. Put it straight in an <img src>; it is cached for a year and changes address whenever the photo does. A few accounts predate that and still hold theirs inline: for those, session.user.picture is simply absent and listings return null with has_picture set. Absent means "nothing to say right now", not "no photo" — each converts the next time its owner opens their Contextser account, and you get a user-updated event when it does.

Managing a connection

const user = await auth.client.getUser(sub);

// Application-scoped state: a role, a plan, your own primary key.
await auth.client.updateUserMetadata(sub, { role: "admin", externalId: "app_4471" });

// End your access. Their Contextser account is untouched.
await auth.client.disconnectUser(sub);

// Invite an email that has no Contextser identity yet.
await auth.client.inviteUser({ email: "[email protected]", name: "New Person" });

updateUserMetadata writes only metadata, never the person's identity. Name, email and avatar are one identity shared with every application they have connected — renaming them in your app would rename them everywhere — and email is the account recovery channel, so writing it from a client would be a takeover primitive. The provider rejects those fields outright. Metadata replaces rather than merges; merge yourself for a partial update.

disconnectUser ends your access, not their account. They leave your listing and your access tokens for them stop working at /api/userinfo. Only the person can undo it, by signing in to your application again — which also restores whatever metadata you had stored.

inviteUser returns nothing, deliberately. It never reports whether the email already had an account, because a client that could tell would be an enumeration oracle over the whole user base. And it never creates a connection: an application cannot manufacture somebody's consent, so an invited person appears in listUsers() only once they have actually signed in.

since fetches only what changed, which is what you want on a schedule:

for await (const user of auth.client.iterateUsers({ since: lastSyncedAt })) { ... }

This returns personal data in bulk, so it widens what a leaked client secret is worth: not just impersonating your application, but paging through its user list. Keep the secret server-side, rotate it if it is ever exposed, and prefer since over refetching everything.


Configuration

Every field is read from the environment when you don't pass it, and anything you do pass wins. The table is the full set:

| Option | Environment variable | Default | |---|---|---| | environment | CONTEXTSER_ENVIRONMENT | inferred from the client ID | | clientId | CONTEXTSER_CLIENT_ID · CONTEXTSER_SANDBOX_CLIENT_ID | — required | | clientSecret | CONTEXTSER_CLIENT_SECRET · CONTEXTSER_SANDBOX_CLIENT_SECRET | — required | | sessionSecret | CONTEXTSER_SESSION_SECRET · CONTEXT_AUTH_SECRET · AUTH_SECRET | — required | | issuer | CONTEXTSER_ISSUER · CONTEXTSER_ACCOUNTS_URL | https://accounts.contextser.com | | redirectUri | CONTEXTSER_REDIRECT_URI | <origin><basePath>/callback | | scope | CONTEXTSER_SCOPE | openid profile email | | basePath | CONTEXT_AUTH_BASE_PATH | /api/auth | | sessionMaxAge | CONTEXT_AUTH_SESSION_MAX_AGE | 604800 (7 days) | | usePar | CONTEXT_AUTH_USE_PAR | true | | afterSignIn | CONTEXT_AUTH_AFTER_SIGN_IN | / | | afterSignOut | CONTEXT_AUTH_AFTER_SIGN_OUT | / | | errorPath | CONTEXT_AUTH_ERROR_PATH | <basePath>/error | | debug | CONTEXT_AUTH_DEBUG | on outside production |

An empty value counts as unset, so a blank .env line falls through to the next source rather than masking it.

When a runtime hides its environment — Cloudflare Workers bindings, Astro's import.meta.env — hand it over with setContextAuthEnv(env) or the per-instance env option. Configuration resolves on first use, not at construction, so registering it inside fetch() is in time. Missing configuration is deferred for exactly that reason; a wrong value still throws immediately. Call auth.assertConfigured() at boot if you would rather fail fast either way.

createContextAuth({
  clientId: string,
  clientSecret: string,
  sessionSecret: string,          // >= 32 chars

  env: import.meta.env,           // or a Workers `env` binding

  issuer: "https://accounts.contextser.com",
  redirectUri: undefined,         // default: <origin><basePath>/callback
  scope: "openid profile email",
  basePath: "/api/auth",
  sessionMaxAge: 604800,          // 7 days
  usePar: true,
  afterSignIn: "/",
  afterSignOut: "/",
  errorPath: "<basePath>/error",
  storeIdToken: false,

  cookies: { prefix: "context-auth", sameSite: "lax", path: "/", domain, secure },

  callbacks: {
    // Upsert into your own database. Return false to refuse, or an object to
    // merge into session.data.
    async signIn({ user, tokens, request }) {
      await db.user.upsert({ where: { contextserId: user.sub }, ... });
      return { role: "member" };
    },
    async session({ session }) { return session; },
  },
})

Scopes

| Scope | Adds to the profile | |---|---| | openid | sub — the stable user id | | profile | name, picture | | email | email, email_verified |

You get the intersection of what you request and what your client is registered for. Read session.scope rather than assuming.

Match users on user.sub, never on user.email. An email address can change hands; matching on it is how account-takeover bugs happen.

Sessions

The access token is a credential, not the session. It is short-lived and verified offline; the session is the refresh token, which lasts 60 days, slides forward on every use, and can be revoked the moment someone asks — which a signature never can.

getSession renews for you. When the access token has expired it spends the refresh token, stores the replacement, and carries on. Nobody is signed out at expiry.

const session = await auth.getSession(request);                     // renews silently if needed
const live    = await auth.getSession(request, { validate: true }); // + one call to the provider

validate: true costs a request to /api/userinfo and catches what a cookie cannot know on its own: the user signed out anywhere, an admin disabled the account or your client, or they removed your app from their Contextser account.

The renewed cookie has to reach the browser. Refresh tokens rotate — spending one produces a successor, and presenting a spent one is read as theft, which revokes the whole chain and signs the person out of your app entirely. The middleware in each adapter handles this. If you are wiring it by hand:

const session  = await auth.getSession(request);
const response = new Response(body);
const cookie   = await auth.refreshedSessionCookie(request);
if (cookie) response.headers.append("set-cookie", cookie);

Next.js App Router: a Server Component cannot set cookies, so middleware.ts is the only place a renewal can be persisted. Without auth.keepAlive — see the Next.js section — session() simply stops returning a session once the access token expires.

getSessionFromCookie never renews, because it has no response to attach a cookie to. It returns null at expiry instead. That is deliberate: renewing where the result cannot be stored would end the session and poison the next one.

Sending someone to their account

People change their name, photo, email, password, passkeys and connected apps at Contextser, not in your app. accountUrl() is the link.

<a href={auth.accountUrl({ returnTo: "https://app.example.com/settings" })}>
  Manage your account
</a>

auth.accountUrl({ page: "security" })   // profile · security · privacy · billing · home

returnTo puts a Back to link in the account header, so they can get back to what they were doing — the browser's back button usually cannot, because the trip spans a redirect or two.

Contextser validates that URL against the callbacks registered for your client and silently ignores anything else. That check is the point: an account page that rendered an unvetted ?return_to would be an open redirect on the identity provider's own domain, which is the most credible place in the world to host one. Matched on origin, so any page in your app works.

Nothing secret travels on this link and no token is minted — the account pages authenticate on the visitor's own session. It is safe to put in an email.

Receiving events

Contextser pushes a signed JWT when a profile changes or a session ends, so nothing has to poll. Mount an endpoint, verify, act:

// app/api/contextser/events/route.ts
import { parseContextEvent, BACKCHANNEL_LOGOUT, USER_UPDATED } from "context-auth";

export async function POST(request: Request) {
  let event;
  try {
    event = await parseContextEvent(await request.text(), {
      issuer: "https://accounts.contextser.com",
      audience: process.env.CONTEXTSER_CLIENT_ID!,
    });
  } catch {
    return new Response("invalid", { status: 400 }); // not authentic — don't retry
  }

  if (event.type === BACKCHANNEL_LOGOUT) {
    // event.sid names one device; without it, the whole account.
    await endSessions({ userId: event.sub, sessionId: event.sid });
  }
  if (event.type === USER_UPDATED) {
    await updateCachedProfile(event.sub, event.claims);
  }
  return new Response(null, { status: 204 });
}

Then register the URL against your client in the Contextser admin panel.

parseContextEvent verifies the signature against the provider's published keys — fetched once and cached, so it costs nothing per event — and checks the issuer, the audience and expiry. Verifying is not optional: an unverified POST claiming to be a logout is an easy way for somebody to sign your users out. An event addressed to a different client is not yours to act on.

Claims on user-updated are filtered by the scope you were granted. A field that is absent may be one you were never allowed to see, not one that was cleared.

If your endpoint was unreachable, catch up from the cursor rather than waiting for a redelivery — GET /api/events?since=<cursor> with your client credentials, once on boot. Polling it recreates exactly the load events exist to remove.


What this does for you, security-wise

| | | |---|---| | Pushed Authorization Requests | On by default. Your parameters go to Contextser over an authenticated back channel; the browser only ever carries an opaque, single-use, 10-minute handle. No client_id, redirect_uri, scope or state in the address bar, browser history, Referer header, or anybody's access log. | | PKCE | Always. S256 only — plain is never sent. | | state | Generated per attempt, stored in an encrypted cookie, compared in constant time on the callback. A callback with no transaction cookie, or a mismatched state, is refused before the code is ever exchanged. | | Session cookie | AES-256-GCM, key derived by HKDF from your sessionSecret. Authenticated encryption, so a tampered cookie fails to decrypt rather than decoding into an attacker-chosen session. The expiry lives inside the ciphertext and can't be extended by editing Max-Age. | | Cookie flags | HttpOnly, SameSite=Lax, and Secure unless you are genuinely on localhost — deliberately not derived from the request protocol, which silently drops the flag behind a TLS-terminating proxy. | | Access token | Stays server-side. /session returns the profile and never the bearer token, so an XSS can't lift it out of page state. | | Open redirects | Every callbackUrl is reduced to a same-origin path. Absolute URLs, //host, /\host, and embedded control characters all fall back to your default. | | Client secret | Sent as HTTP Basic on the back channel, never in a body or a query string, never in a browser bundle. |

The provider does its own half: authorization codes hashed at rest with a 60-second single-use life, exact redirect-URI matching, consent that can't be driven by a cross-site GET, frame-ancestors 'none' everywhere so the consent screen can't be clickjacked, and revocation that reaches tokens already issued.


Error handling

import { ContextAuthError, isContextAuthError } from "context-auth";

try {
  await auth.requireSession(request);
} catch (error) {
  if (isContextAuthError(error) && error.code === "unauthenticated") { /* … */ }
}

Failures during the flow land on errorPath with ?error=<code>:

| Code | Meaning | |---|---| | access_denied | The user declined, or your signIn callback returned false | | invalid_state | The attempt expired, came from a different browser, or was forged | | invalid_grant | The code was expired, already used, or bound to a different request | | invalid_client | Wrong client id or secret | | environment_mismatch | CONTEXTSER_ENVIRONMENT disagrees with what these credentials actually are | | missing_code | The provider redirected back without a code |


Development

npm install
npm run build
npm test

The test suite runs the whole flow against a stand-in provider that enforces Basic auth, verifies PKCE for real, and burns codes after one use — so replay, state tampering, cookie forgery and open-redirect attempts are all covered as tests rather than as claims.


Links

MIT © Contextser