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

@shware/security

v9.2.0

Published

Readme

@shware/security

Session-based authentication for any server that speaks the Fetch API (Request → Response): Hono, Cloudflare Workers, Next.js route handlers and similar. It ships an Auth class with handlers for CSRF, logout, OAuth2 (redirect, native, Google One Tap), email verification-code sign-in and desktop sign-in, backed by pluggable session stores (Redis or your own database), indexed by principal so a user's sessions can be listed and revoked.

Install

pnpm add @shware/security zod

No peer dependencies. ioredis is a regular dependency; you only need a client instance if you use the Redis repositories.

Entry points

| Import path | Contents | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | @shware/security | Auth, PATH, AuthenticationError, session/KV repositories, OAuth2Client, OAuth2Error, PROVIDERS, types | | @shware/security/oauth2/provider | Built-in OAuth2 providers (apple, facebook, github, google, wechat, x) and createAppleClientSecret |

Quick start (Hono)

import { Auth, PATH, RedisIndexedSessionRepository, RedisKVRepository } from '@shware/security';
import { apple, createAppleClientSecret, github, google } from '@shware/security/oauth2/provider';
import { Hono } from 'hono';
import { Redis } from 'ioredis';
import { env } from './env';
import { onAuthorized } from './services/auth';

const redis = new Redis(env.REDIS_URL);

// namespace must end with ':session'
const sessionRepository = new RedisIndexedSessionRepository(redis, 'mywebsite:session');
sessionRepository.setDefaultMaxInactiveInterval(30 * 24 * 60 * 60); // seconds

const appleClientSecret = createAppleClientSecret({
  teamId: env.OAUTH2_APPLE_TEAM_ID,
  keyId: env.OAUTH2_APPLE_KEY_ID,
  privateKey: env.OAUTH2_APPLE_P8, // PKCS#8 PEM contents of the .p8 key
});

const auth = new Auth({
  timing: true,
  sessionRepository,
  kvRepository: new RedisKVRepository(redis),
  oauth2: {
    client: {
      baseUri: 'https://api.mywebsite.com', // backend, used to build redirect URIs
      errorUri: 'https://mywebsite.com', // receives ?error=...&error_description=...
      successUri: 'https://mywebsite.com', // receives ?status=success&provider=...
      providers: {
        apple: {
          provider: apple,
          web: { clientId: env.OAUTH2_APPLE_SERVICES_ID, clientSecret: appleClientSecret },
          native: [{ clientId: env.OAUTH2_APPLE_BUNDLE_ID, clientSecret: appleClientSecret }],
        },
        google: {
          provider: google,
          web: {
            clientId: env.OAUTH2_GOOGLE_WEB_CLIENT_ID,
            clientSecret: env.OAUTH2_GOOGLE_SECRET,
          },
          // installed-app clients are public: no secret
          native: [
            { clientId: env.OAUTH2_GOOGLE_IOS_CLIENT_ID },
            { clientId: env.OAUTH2_GOOGLE_ANDROID_CLIENT_ID },
          ],
        },
        github: {
          provider: github,
          web: { clientId: env.OAUTH2_GITHUB_CLIENT_ID, clientSecret: env.OAUTH2_GITHUB_SECRET },
        },
      },
    },
  },
});

const app = new Hono();

app.get(PATH.CSRF, (c) => auth.csrf(c.req.raw));
app.get(PATH.LOGGED, (c) => auth.logged(c.req.raw));
app.post(PATH.LOGOUT, (c) => auth.logout(c.req.raw));

app.get(PATH.OAUTH2_STATE, (c) => auth.oauth2State(c.req.raw));
app.get(PATH.OAUTH2_NONCE, (c) => auth.oauth2Nonce(c.req.raw));
app.get(PATH.OAUTH2_AUTHORIZATION, (c) => auth.oauth2Authorization(c.req.raw));
app.get(PATH.LOGIN_OAUTH2_CODE, (c) => auth.loginOAuth2Code(c.req.raw, onAuthorized));
app.post(PATH.LOGIN_OAUTH2_CODE, (c) => auth.loginOAuth2Code(c.req.raw, onAuthorized));
app.post(PATH.LOGIN_OAUTH2_NATIVE, (c) => auth.loginOAuth2Native(c.req.raw, onAuthorized));
app.post(PATH.LOGIN_OAUTH2_ONETAP, (c) => auth.loginOAuth2Onetap(c.req.raw, onAuthorized));

export default app;

Providers and clients

providers is keyed by identity source: one key per namespace of sub. Its key is the :provider in the routes and authorization.provider in onAuthorized. Each entry names the implementation (provider) and the clients users sign in through:

  • web: the client of the redirect flow and of Google One Tap (a Google web client, an Apple Services ID, a GitHub OAuth app). redirectUri defaults to ${baseUri}/login/oauth2/code/<provider key>; scope overrides the implementation's defaults.
  • native: the clients apps sign in as on the device (an iOS bundle id, Google's iOS / Android clients). The app sends its client_id with the native login, which must be one of the provider's clients (web included); without one, the only native client is used, or web when there is no native one. Several native clients and no client_id is refused.

clientSecret is a string, a function clientId => secret called on each use with that client's id (createAppleClientSecret returns one), or absent for a public client. providers may also be an async function, called per request.

Two keys may share an implementation when their users are separate namespaces, e.g. two Google Cloud projects: { google: { provider: google, ... }, googleWorkspace: { provider: google, ... } }.

The onAuthorized handler

Every login method takes an AuthorizedHandler: create or update your user and return the Principal whose name is stored in the session. It gets the request and an Authorization object — { provider, clientId?, userInfo, token?, attributes } — so fields are named, not positional. provider plus userInfo.claims.sub identify the user, whichever client they came through; clientId tells the surfaces apart (absent for email sign-in, whose provider is EMAIL).

import type { Authorization, Principal, Provider } from '@shware/security';

const PROVIDERS: Record<string, Provider | undefined> = {
  apple: 'APPLE',
  google: 'GOOGLE',
  github: 'GITHUB',
  EMAIL: 'EMAIL', // provider used by loginEmail
};

export async function onAuthorized(
  request: Request,
  { provider, userInfo: { claims } }: Authorization
): Promise<Principal> {
  const method = PROVIDERS[provider];
  if (!method) throw new Error(`Unknown provider: ${provider}`);
  const userId = await upsertUser(method, claims.sub, claims.email);
  return { name: userId };
}

PROVIDERS is the list of Provider values (APPLE, GITHUB, GOOGLE, ..., EMAIL, PHONE), handy for a database enum.

To refuse a sign-in the handler could complete but will not, throw an AuthenticationError with a reason and, when the client needs it to act, metadata (string fields only, since it travels as URL parameters and JSON). The built-in reasons cover the account states every host meets:

| Reason | Metadata | When | | -------------------- | ---------------------- | ----------------------------------------------------------- | | ACCOUNT_DISABLED | | Banned, or closed by its owner. | | ACCOUNT_LOCKED | { until?: string } | Locked for now; until is when it opens again, when known. | | EMAIL_ALREADY_USED | { provider?: string} | The address belongs to another account; sign in with that. | | EMAIL_NOT_VERIFIED | { provider?: string} | Only verified addresses are accepted and this one is not. | | SIGNUP_DISABLED | | No new accounts: registration closed or by invitation. |

Add your own by augmenting AuthenticationReasons, each with the shape of its metadata; the constructor accepts exactly the built-in and declared reasons, and requires the metadata when one of its fields is required:

import { AuthenticationError } from '@shware/security';

declare module '@shware/security' {
  interface AuthenticationReasons {
    CLOSED_BETA: { invite?: string };
    REGION_BLOCKED: { region: string }; // required: the throw must pass it
  }
}

throw new AuthenticationError('EMAIL_ALREADY_USED', {
  metadata: { provider: 'GOOGLE' },
  message: 'Email already used by another account',
});

AuthenticationReason is the union of all reasons, for the client-side switch; AUTHENTICATION_REASONS lists the built-in ones at runtime, for a message table.

Auth answers it in the shape of the route: the redirect flow lands on errorUri with error=access_denied&reason=EMAIL_ALREADY_USED&provider=GOOGLE; the JSON routes (native, One Tap, email) answer 403 with a google.rpc error body whose ErrorInfo holds the reason and metadata (what @shware/http's getErrorInfo reads). Anything else the handler throws is a server fault: server_error on the redirect, rethrown to your error handler on the JSON routes.

Auth options

| Option | Type | Notes | | -------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------- | | sessionRepository | SessionRepository | Required. Where sessions live (see below). | | kvRepository | KVRepository | Required. Short-lived values: OAuth2 state/nonce, verification and desktop codes. | | cookie | CookieOptions & { name?: string } | Session cookie. Defaults: name SESSION, path: '/', SameSite=None, Secure, HttpOnly. | | oauth2.client | OAuth2ClientConfig | baseUri, errorUri, successUri, providers (object or async function). | | oauth2.nonce | { optional?: string[] } | Providers whose logins may come without a nonce; every id-token flow requires one otherwise. | | turnstileSecretKey | string | Cloudflare Turnstile secret; required by sendEmailVerificationCode. | | verificationCode | { maxAttempts?, resendCooldown? } | Email code limits: guesses per code (default 5), seconds between sends (default 60). | | timing | boolean | Adds a Server-Timing header to OAuth2 responses. |

The session id lives in the cookie. For clients without a cookie jar (a CLI, a desktop shell), session: { header: true } also carries it in X-Auth-Token, as Spring Session does: every response that sets the cookie answers X-Auth-Token: <id>, and a request without the cookie is read from the same header. Off by default: it exposes the id to same-origin scripts, which the cookie's HttpOnly keeps it from, and a cross-origin client needs the header in Access-Control-Expose-Headers. Authorization is never read: an API token is a different credential from a session and is not handled here. logout clears the cookie with the same attributes it was set with (domain, path, …), so a cookie set with Domain=.example.com is actually removed. logout(request, { scope }) widens what it ends, named as Supabase's signOut names them: local (default) the request's own session, global every session of its principal ("sign out everywhere": deleting the account, a password change), others every session but this one, which keeps the caller signed in ("sign out other devices"). kick(principal) is the same eviction for a principal other than the caller.

Besides the route handlers, Auth exposes session helpers: getPrincipal(request), isAuthenticated(request), getSession(request, create), listSessions(principal), deleteSession(sessionId), kick(principal) (deletes all of a user's sessions) and cleanupExpiredSessions(cleanupCount?).

import type { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';

export async function getUserId(c: Context) {
  const principal = await auth.getPrincipal(c.req.raw);
  if (!principal) throw new HTTPException(401);
  return principal.name;
}

The JSON routes answer every failure they know themselves (see "Errors" below), so your error handler only sees genuine server faults: what onAuthorized or a session store threw besides an AuthenticationError. A flow of your own that calls Auth directly (verifyEmailCode) can throw the same errors; errorResponse(error) gives the response the routes would, or null:

import { errorResponse } from '@shware/security';

app.onError((error, c) => errorResponse(error) ?? yourErrorHandler(error, c));

Session repositories

All repositories implement SessionRepository and index sessions by principal name (PRINCIPAL_NAME_INDEX_NAME), so kick and listSessions work.

  • RedisIndexedSessionRepository(redis, namespace = 'app:session'): <namespace>:sessions:<id> hashes, a principal index set, and an expirations sorted set. Takes an ioredis client.
  • DBIndexedSessionRepository(adapter): for any database; you implement DBAdapter (save, deleteById, findById, findByPrincipalName, cleanupExpiredSessions).
  • RedisKVRepository(redis): KVRepository on Redis. Implement KVRepository (setItem(key, value, expiresIn?), getItem, removeItem, increment(key, expiresIn)) to use another store. increment must be atomic: it creates an absent or expired key at 1 expiring in expiresIn seconds, otherwise adds 1 and keeps the expiry, and returns the new count.

Both session repositories have setDefaultMaxInactiveInterval(seconds) (default 1800).

A database-backed setup with Drizzle:

import {
  Auth,
  DBIndexedSessionRepository,
  type DBAdapter,
  type KVRepository,
} from '@shware/security';

type SessionEntity = Parameters<DBAdapter['save']>[0];

class SessionAdapter implements DBAdapter {
  async save(entity: SessionEntity) {
    // `isNew` says whether this is the first write: a session created in this request is
    // inserted, one that was loaded is only ever updated. Updating zero rows means a concurrent
    // logout or kick deleted it, and an upsert there would bring the session back from the dead.
    if (entity.isNew) {
      await db.insert(session).values(toRow(entity));
      return;
    }
    await db.update(session).set(toRow(entity)).where(eq(session.sessionId, entity.sessionId));
  }
  async deleteById(sessionId: string) {
    await db.delete(session).where(eq(session.sessionId, sessionId));
  }
  async findById(sessionId: string) {
    const [row] = await db.select().from(session).where(eq(session.sessionId, sessionId));
    return row ? toEntity(row) : null;
  }
  async findByPrincipalName(principalName: string) {
    const rows = await db.select().from(session).where(eq(session.principalName, principalName));
    return rows.map(toEntity);
  }
  async cleanupExpiredSessions(cleanupCount = 1000) {
    // delete up to cleanupCount rows whose expiryTime has passed
  }
}

class DBKVRepository implements KVRepository {
  async setItem(key: string, value: string, expiresIn?: number) {
    /* upsert with expiresAt = now + expiresIn seconds */
  }
  async getItem(key: string) {
    /* return the value if not expired, else null */
    return null;
  }
  async removeItem(key: string) {
    /* delete by key */
  }
  async increment(key: string, expiresIn: number) {
    // one atomic upsert, e.g. on Postgres with kv(key text primary key, value text, expires_at timestamptz):
    // insert into kv (key, value, expires_at) values ($1, '1', now() + make_interval(secs => $2))
    // on conflict (key) do update set
    //   value = case when kv.expires_at <= now() then '1' else (kv.value::int + 1)::text end,
    //   expires_at = case when kv.expires_at <= now() then excluded.expires_at else kv.expires_at end
    // returning value::int
    return 1;
  }
}

const sessionRepository = new DBIndexedSessionRepository(new SessionAdapter());
sessionRepository.setDefaultMaxInactiveInterval(30 * 24 * 60 * 60);

export const auth = new Auth({ sessionRepository, kvRepository: new DBKVRepository() });

OAuth2 providers

@shware/security/oauth2/provider exports ready-made providers to put in oauth2.client.provider:

| Export | Notes | | ---------- | ------------------------------------------------------------------- | | apple | Sign in with Apple (web form_post callback and native id_token) | | facebook | Facebook Login | | github | GitHub OAuth app | | google | Google, including One Tap / FedCM (loginOAuth2Onetap) | | wechat | WeChat | | x | X (Twitter) OAuth 2.0 |

Apple has no static client secret; createAppleClientSecret signs one per client id and caches it until shortly before it expires. Pass the function itself as the clientSecret of every Apple client, as in the quick start: the app's bundle id and the web's Services ID share the key and get a secret each.

Id token verification

Every id token (Apple, Google, Facebook Limited Login) is checked for its signature, expiry, issuer and audience. The audience must be the client the token was meant for: the client the code was exchanged as, the client the native login resolved, or the web client for One Tap. A token issued to any other app on the same identity provider is refused.

| Implementation | Accepted iss | | -------------- | ---------------------------------------------------- | | apple | https://appleid.apple.com | | google | https://accounts.google.com, accounts.google.com | | facebook | https://www.facebook.com |

Facebook's native login also takes a classic access_token, which the Graph API would serve to any app; it is sent with an appsecret_proof, so the client needs its clientSecret.

Web redirect flow

  1. The browser opens GET /oauth2/authorization/:provider. oauth2Authorization stores the state and PKCE verifier in the session and redirects to the provider. A browser without a session gets one that lives 10 minutes, the life of the state, so an abandoned flow does not keep a row for the default interval; a session it already carries keeps its own.
  2. The provider calls back GET|POST /login/oauth2/code/:provider (Apple uses form_post, so register both). loginOAuth2Code checks state, exchanges the code as the web client, calls onAuthorized and redirects to successUri with status=success&provider=<key>, or to errorUri with error and error_description.
  3. On success the user is signed in on a new session id, not the one the flow started on, which is deleted (session fixation protection). The attributes the host kept in that session move to the new one; the single-use authorization request (state, PKCE verifier) does not. The new session has the repository's default inactive interval.

Every failure of the callback lands on errorUri — never a raw JSON error in the popup:

  • the provider's own error (access_denied means the user declined; also invalid_scope, server_error, …), with its error_description (unregistered codes become server_error);
  • a failed validation (invalid_request: missing session, state or provider mismatch);
  • an OAuth2Error thrown while exchanging the code or loading the user info, logged with console.warn and reported by code only: invalid_grant (an expired or reused code), access_denied or temporarily_unavailable as is, any other as server_error. Its message stays in the logs, since it can name a client id or quote a provider's response;
  • an AuthenticationError thrown by onAuthorized as access_denied with its reason and metadata entries as parameters (logged with console.warn; see "The onAuthorized handler");
  • anything else thrown by the exchange, onAuthorized or the session store, as server_error (logged with console.error).

error_description is capped at 256 characters. Show users a message per error code, never the description: it is the provider's technical text, and a forged callback can set it to anything.

Carrying data to onAuthorized (attributes)

Something known when the flow starts may be needed when the user comes back — a referral code in a cookie, a ?ref= parameter, where to go after sign-in. The callback is the wrong place to look for it: Apple's form_post is a cross-site POST that drops SameSite=Lax cookies, and query parameters do not survive the provider round trip. Attach it to the authorization request instead (like Spring Security's OAuth2AuthorizationRequest attributes):

app.get(PATH.OAUTH2_AUTHORIZATION, (c) =>
  auth.oauth2Authorization(c.req.raw, {
    attributes: { referralCode: getCookie(c, 'REFERRAL_CODE') },
  })
);

export async function onAuthorized(request: Request, { attributes }: Authorization) {
  const referralCode = attributes.referralCode;
  // ...
}
  • Stored with the authorization request in the session (the same JSON as state and the PKCE verifier), never sent to the provider, so no extra storage, expiry or cleanup.
  • Handed to onAuthorized as authorization.attributes only after the session, state and provider check out, and removed with the authorization request after a successful login, so they are used once.
  • String values only; undefined values are dropped; at most 2 KB serialized (they are written with the session).
  • Logins that post a body — native, One Tap (JSON) and email — take an optional attributes field in that body instead, since the SDK consumes the body before onAuthorized runs: { "id_token": "…", "attributes": { "referralCode": "abc123" } }. Same rules (string values, at most 2 KB); an invalid one is a 400. Without it the handler gets attributes: {}.
  • They come from the browser that started the flow: trust them like a cookie — fine for attribution or a post-login destination, not for authorization decisions.

Carrying data back to the app (redirectParams)

attributes stay on the server. What the page needs back goes in redirectParams: query parameters added to the flow's redirect to successUri and to errorUri alike. For example, the page that opened a sign-in popup can tag the attempt, and match the result against it when the popup reports back without window.opener (cut off by a provider's Cross-Origin-Opener-Policy):

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

app.get(PATH.OAUTH2_AUTHORIZATION, (c) => {
  // From the browser: checked here, since an oversized value is an invariant failure (a 500).
  const attempt = c.req.query('attempt');
  return auth.oauth2Authorization(c.req.raw, {
    redirectParams: { attempt: attempt && UUID.test(attempt) ? attempt : undefined },
  });
});
// → https://app.example.com/?status=success&provider=google&attempt=…
  • Stored with the authorization request like attributes, so every redirect of that flow carries them, success or error, except the two that cannot find the request (no session, or no authorization request in it).
  • The names the redirects use themselves — status, provider, error, error_description, reason — are dropped, even on a redirect that leaves one out (a cancel has no reason): the page would read it as the SDK's. A refusal's metadata wins over a parameter of the same name.
  • String values only; undefined values are dropped; at most 2 KB serialized, checked as an invariant like attributes: validate a value taken from the request before passing it.
  • They reach the browser and its history: put nothing secret in them.

Native login

Mobile apps sign in with the platform SDK and post the result to POST /login/oauth2/native/:provider. The body is a NativeCredential ({ id_token?, access_token?, code?, code_verifier?, state?, client_id?, redirect_uri?, fullName? }); client_id picks the client (see Providers and clients). loginOAuth2Native verifies it with the provider, calls onAuthorized, creates a session and returns the Principal as JSON with the session cookie.

To bind an id token to this request, fetch a nonce first from GET /oauth2/nonce/:provider (oauth2Nonce) and pass it to the platform SDK; when the token carries a nonce, loginOAuth2Native requires it to match one issued for the same provider and consumes it.

const { nonce } = await fetch(`${api}/oauth2/nonce/apple`).then((r) => r.json());
// ... Sign in with Apple with `nonce`, then:
await fetch(`${api}/login/oauth2/native/apple`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: authorizationCode, fullName: credential.fullName }),
});

PKCE for a native authorization code

When the app runs the authorization code flow itself (Google's installed-app clients, redirecting to a custom scheme), it must use PKCE (RFC 8252 §8.1): those clients have no secret, and on Android any app can register the same scheme and receive the code. The app:

  1. generates a code_verifier, 43-128 characters from the RFC 7636 unreserved set [A-Za-z0-9-._~] (32 random bytes, base64url-encoded, give 43), and its S256 code_challenge = base64url(sha256(code_verifier)), plus a random state;
  2. opens the authorize URL with code_challenge and code_challenge_method=S256;
  3. on the redirect, checks that state is the one it sent before using code;
  4. posts code_verifier with the code to /login/oauth2/native/:provider.

loginOAuth2Native hands code_verifier to the provider's token exchange (Google refuses the code without the matching verifier) and answers 400 INVALID_CODE_VERIFIER when it is not 43-128 unreserved characters. Providers whose token endpoint takes no verifier (Apple) leave it out. Without code_verifier the exchange runs as before. With expo-crypto and expo-web-browser:

import * as Crypto from 'expo-crypto';
import * as WebBrowser from 'expo-web-browser';

const base64url = (base64: string) =>
  base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

const codeVerifier = base64url(btoa(String.fromCharCode(...Crypto.getRandomBytes(32))));
const codeChallenge = base64url(
  await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, codeVerifier, {
    encoding: Crypto.CryptoEncoding.BASE64,
  })
);
const state = Crypto.randomUUID();

const authorize = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authorize.search = new URLSearchParams({
  client_id: clientId, // the iOS / Android client
  redirect_uri: redirectUri, // its custom scheme
  response_type: 'code',
  scope: 'openid email profile',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
}).toString();

const result = await WebBrowser.openAuthSessionAsync(authorize.href, redirectUri);
if (result.type !== 'success') return;
const params = new URL(result.url).searchParams;
if (params.get('state') !== state) throw new Error('OAuth2 state mismatch');

await fetch(`${api}/login/oauth2/native/google`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    code: params.get('code'),
    code_verifier: codeVerifier,
    client_id: clientId,
    redirect_uri: redirectUri,
  }),
});

Google One Tap

POST /login/oauth2/onetap/google accepts the One Tap credential (an id_token) or a code, as form data or JSON, both issued to the web client of the provider. The third argument of loginOAuth2Onetap is the provider key (default 'google').

A credential must carry a nonce: fetch one from GET /oauth2/nonce/google (oauth2Nonce, valid 10 minutes) right before prompt and pass it on, so Google writes it into the ID token's nonce claim. The login consumes the nonce, so a leaked ID token cannot be replayed for another session; a credential without one, or with one that was not issued or was already used, is answered 403 NONCE_MISSING / 403 NONCE_MISMATCH. The nonce takes the place of Google's g_csrf_token double-submit cookie, which FedCM mode no longer sets. A code (the popup fallback) is single use already and needs no nonce.

import { prompt } from '@shware/http/google-one-tap';

const { nonce } = await fetch(`${api}/oauth2/nonce/google`).then((r) => r.json());
const result = await prompt({ client_id: GOOGLE_WEB_CLIENT_ID, nonce });
if (result.authorized) {
  await fetch(`${api}/login/oauth2/onetap/google`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ credential: result.credential.credential }),
  });
}

While a front end that does not pass nonce yet is still deployed, list the provider in oauth2: { nonce: { optional: ['google'] } }: credentials without a nonce are then accepted, and a nonce a credential does carry is still checked and consumed. Remove it once that front end is gone.

The same rule covers every flow in which an id token comes back: a provider declares which of its flows echo a nonce (Provider.nonce: Apple web and native, Google web and One Tap, Facebook Limited Login), and Auth requires it there. The redirect flow sends its own nonce with the authorization request and refuses a callback whose id token does not carry it back; a native login must carry one issued by GET /oauth2/nonce/:provider, which it consumes.

Email sign-in

A six-digit code, valid for 10 minutes, protected by Cloudflare Turnstile. Set turnstileSecretKey and wire both routes; you supply the function that delivers the email.

const auth = new Auth({
  sessionRepository,
  kvRepository,
  turnstileSecretKey: env.TURNSTILE_SECRET_KEY,
});

app.post(PATH.SEND_EMAIL_VERIFICATION_CODE, (c) =>
  auth.sendEmailVerificationCode(c.req.raw, async ({ email, verificationCode }) => {
    await sendMail({ to: email, subject: 'Your sign-in code', text: verificationCode });
  })
);
app.post(PATH.LOGIN_EMAIL, (c) => auth.loginEmail(c.req.raw, onAuthorized));
  • POST /auth/sendEmailVerificationCode with { email, turnstileToken }.
  • auth.verifyEmailCode(email, code) spends a mailed code without signing in, for a host flow that only needs proof of the address (a signed-in user verifying or changing their email). Same storage and limits as loginEmail; a wrong code throws the AuthenticationError loginEmail would answer with (INVALID_VERIFICATION_CODE, VERIFICATION_CODE_ATTEMPTS_EXCEEDED), so letting it propagate to an error handler that maps AuthenticationError answers the same 403.
  • claims.email_verified is set only by providers that state it (Google, Apple, GitHub, and X when it returns confirmed_email); Facebook leaves it unset. Trust an address only when it is true. X hands out the address only with the users.email scope (in the default scope) and "Request email from users" enabled on the app in the X developer console.
  • POST /login/email with { email, verificationCode }. On success onAuthorized is called with provider: 'EMAIL', claims { sub: email, email, email_verified: true } and no token.

Both routes trim the email and lowercase it before validating, so " [email protected] " is "[email protected]" everywhere: the address the sender receives, the code, its attempt counter and resend cooldown, and the claims' sub and email. The local part is lowercased too: case-sensitive mailboxes are allowed by RFC 5321 but practically unused, and honouring them would let one mailbox hold several accounts.

Error reasons (google.rpc.ErrorInfo, see "Errors"):

| Reason | Status | When | | ------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- | | TURNSTILE_VERIFICATION_FAILED | 400 INVALID_ARGUMENT | The Turnstile token was rejected. | | VERIFICATION_CODE_RESEND_TOO_SOON | 429 RESOURCE_EXHAUSTED | A code was sent to this email within resendCooldown. | | EMAIL_DELIVERY_FAILED | 503 UNAVAILABLE | The sender threw; the cooldown is released, so the user can retry at once. | | INVALID_VERIFICATION_CODE | 403 PERMISSION_DENIED | Wrong code, or no code is pending for this email. | | VERIFICATION_CODE_ATTEMPTS_EXCEEDED | 403 PERMISSION_DENIED | More than maxAttempts guesses at this code; it is discarded, ask for a new one. |

Every login attempt against a pending code is counted before the code is compared, so parallel requests cannot exceed maxAttempts guesses either. A new code starts with a fresh budget.

Errors

The JSON routes (native, One Tap, email, desktop) answer every failure with a google.rpc error body, the shape @shware/http's getErrorInfo reads: { error: { code, status, message, details: [{ '@type': '…google.rpc.ErrorInfo', reason, metadata? }] } }. Branch on reason; the status tells the kind:

| Status | What | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 400 INVALID_ARGUMENT | The request itself: a body that is not JSON (INVALID_JSON), a wrong content type, a field that failed validation (INVALID_ARGUMENT with a BadRequest detail naming it), INVALID_ATTRIBUTES, INVALID_CODE_VERIFIER. | | 401 UNAUTHENTICATED | desktopAuthorize without a signed-in session. | | 403 PERMISSION_DENIED | An AuthenticationError: the host refused the sign-in (see "The onAuthorized handler"), or the SDK did (INVALID_VERIFICATION_CODE, VERIFICATION_CODE_ATTEMPTS_EXCEEDED, NONCE_MISSING, NONCE_MISMATCH, INVALID_DESKTOP_CODE). | | 429 RESOURCE_EXHAUSTED | VERIFICATION_CODE_RESEND_TOO_SOON. | | 503 UNAVAILABLE | EMAIL_DELIVERY_FAILED, or the provider is down. |

An OAuth2Error from the provider client is answered by what it means, with OAUTH2_<error> as the reason: OAUTH2_INVALID_REQUEST and the other malformed-request codes as 400, OAUTH2_INVALID_GRANT (a code or id token that is expired, used, or for another client or issuer), OAUTH2_INVALID_CLIENT, OAUTH2_INVALID_TOKEN, OAUTH2_ACCESS_DENIED as 403, OAUTH2_SERVER_ERROR and OAUTH2_TEMPORARILY_UNAVAILABLE as 503. Its message stays in the logs: it can name a client id or quote the provider's response.

Anything else thrown (by onAuthorized, a session store, a sender) propagates to your error handler as the server fault it is. The redirect flow is the exception to all of this: a page, not a client, reads its outcome, so it lands on errorUri with error=… (see "Redirect flow").

Routes (PATH)

PATH holds the route patterns the handlers parse :provider from, so mount each handler on its own constant.

| Constant | Path | Handler | | ------------------------------ | --------------------------------- | ------------------------------------------------- | | CSRF | /csrf | csrf (double-submit XSRF-TOKEN cookie) | | LOGOUT | /logout | logout | | LOGGED | /logged | logged ({ data: boolean }, renews the cookie) | | OAUTH2_STATE | /oauth2/state/:provider | oauth2State | | OAUTH2_NONCE | /oauth2/nonce/:provider | oauth2Nonce | | OAUTH2_AUTHORIZATION | /oauth2/authorization/:provider | oauth2Authorization | | LOGIN_OAUTH2_CODE | /login/oauth2/code/:provider | loginOAuth2Code (GET and POST) | | LOGIN_OAUTH2_NATIVE | /login/oauth2/native/:provider | loginOAuth2Native | | LOGIN_OAUTH2_ONETAP | /login/oauth2/onetap/google | loginOAuth2Onetap | | LOGIN_EMAIL | /login/email | loginEmail | | SEND_EMAIL_VERIFICATION_CODE | /auth/sendEmailVerificationCode | sendEmailVerificationCode | | DESKTOP_AUTHORIZE | /auth/desktop/authorize | desktopAuthorize | | DESKTOP_EXCHANGE | /auth/desktop/exchange | desktopExchange | | CLEANUP_EXPIRED_SESSIONS | /sessions/expired/cleanup | cleanupExpiredSessions |

cleanupExpiredSessions has no authentication of its own; prefer calling it from a scheduled job over exposing the route.

Desktop sign-in

Desktop apps (Electron, Tauri) sign in through the system browser and receive a session over a loopback redirect, using a PKCE-protected single-use code:

app.post(PATH.DESKTOP_AUTHORIZE, (c) => auth.desktopAuthorize(c.req.raw));
app.post(PATH.DESKTOP_EXCHANGE, (c) => auth.desktopExchange(c.req.raw));

See docs/desktop-sign-in.md for the full flow, web and desktop client code, and security notes.