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.
Maintainers
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-authPut 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 32export 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
- Register your application in the Contextser admin console. You get a client id, a client secret (shown once), and you set the redirect URI.
- The redirect URI must match byte for byte — no wildcards, no prefixes — and must be
https, excepthttp://localhostfor development. - If you mount the routes at the default
/api/auth, registerhttps://yourapp.com/api/auth/callback.
CONTEXTSER_CLIENT_ID=ctx_...
CONTEXTSER_CLIENT_SECRET=ctx_secret_...
CONTEXTSER_SESSION_SECRET= # openssl rand -hex 32CONTEXTSER_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
openidalone appears as a baresub— they are not levelled up to match their neighbours. - Live and sandbox are separate. Sandbox test accounts never appear in a live listing.
pictureis 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.pictureis simply absent and listings returnnullwithhas_pictureset. Absent means "nothing to say right now", not "no photo" — each converts the next time its owner opens their Contextser account, and you get auser-updatedevent 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
sinceover 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 onuser.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 providervalidate: 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.tsis the only place a renewal can be persisted. Withoutauth.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 · homereturnTo 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 testThe 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
- Provider docs — https://accounts.contextser.com/docs/
- RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 9126 (PAR)
MIT © Contextser
