@wess/atlas/auth#

Authentication primitives and prebuilt auth flow pipes for the Atlas framework.

Entry points#

Use @wess/atlas/auth/password for hash and verify, /auth/token for JWT and JWK functions, and /auth/session for the in-memory session store. These entry points load no server, database flows, or social providers. /auth/flows provides the server pipes; /auth/social provides social login. /auth is the aggregate.

Exports#

Password Hashing#

  • hash(password: string): Promise<string> - Hash a password using Argon2id via Bun.password
  • verify(password: string, hashed: string): Promise<boolean> - Verify a password against a hash

JWT Tokens (token namespace)#

  • token.sign(payload, secret, opts?): Promise<string> - Create an HS256 JWT
    • opts.expiresIn - seconds until expiration. Always set it for anything that grants access: verify checks exp when present but does not require it, so a token signed without expiresIn is valid forever.
    • opts.typ - JOSE header typ, default "JWT". OAuth access tokens use "at+jwt".
  • token.verify(token, secret): Promise<TokenPayload> - Verify and decode a JWT
    • Requires HS256, object claims and finite numeric dates; rejects invalid signatures, expired exp (including zero), and future nbf. Does not require exp (see above).
  • token.headerOf(token): Record<string, unknown> - decode the JOSE header without checking it; only meaningful after verify
  • token.ACCESS_TOKEN_TYP ("at+jwt"), token.isAccessToken(header, claims) - true for an OAuth access token: the RFC 9068 header, or client_id + scope claims (tokens minted before the header was set)
  • TokenPayload - Record<string, unknown> & { iat?: number; exp?: number }

Session Management#

  • createMemoryStore({ ttl?, maxEntries? }): SessionStore - In-memory session store for dev/testing; ttl (seconds) expires sessions, without it they live until destroy; capacity defaults to 10,000 and refuses new sessions when full
  • SessionStore type with create, get, destroy methods

Auth Flows (PipeFn factories for @wess/atlas/server)#

  • signup({ db, table, fields, onSuccess }) - Registration pipe; table is a name string or a defineSchema object
    • Reads body fields, hashes password, inserts into DB
    • Calls onSuccess with conn and the new row, without its password
    • A duplicate answers 409, which reveals that the account exists. Rate limit signup, or answer generically and notify the address out of band, if that matters for your app
  • login({ db, table, identity, password, onSuccess }) - Login pipe
    • Looks up user by identity field, verifies password
    • Calls onSuccess with the row, without its password, or halts 401
    • An unknown account still runs one argon2 verify against a fixed dummy hash, so timing doesn't reveal which accounts exist
  • requireAuth({ secret, acceptAccessTokens? }) - JWT guard pipe
    • Reads Bearer token from Authorization header
    • Puts decoded payload into conn.assigns.auth
    • Halts 401 if missing or invalid
    • Refuses OAuth access tokens from @wess/atlas/oauth (they are third-party grants, not the user's session) and any token with a purpose claim (password resets, social state). acceptAccessTokens: true lets an API meant for OAuth clients take access tokens; check conn.assigns.auth.scope there
  • isSessionToken(jwt, claims, { acceptAccessTokens? }) - the check requireAuth applies after verify, for custom guards that call token.verify themselves
  • passwordReset({ db, table, secret, transport, expiresIn?, passwordField? }) - Password reset pipe
    • Generates reset token (JWT signed with secret, TTL expiresIn seconds, default 3600) with purpose: "password_reset" and pwd, a keyed fingerprint of the current password hash (passwordField, default password)
    • Calls transport without awaiting it, so delivery time can't reveal whether the address has an account. Delivery errors are logged with the token redacted; the response is 200 either way
    • Always returns 200 to prevent email enumeration
  • verifyPasswordReset(token, secret, currentHash) → Promise<{ email }> - use it in the endpoint that accepts the new password. currentHash(email) returns the stored hash (or null). Throws unless the token is an unexpired reset token whose fingerprint matches, so a link stops working as soon as the password changes, including after it is used once
  • passwordFingerprint(secret, passwordHash) - the fingerprint itself, for apps that mint their own reset tokens

Usage#

import { hash, verify, token, requireAuth, login, signup, passwordReset, verifyPasswordReset } from "@wess/atlas/auth"
import { from } from "@wess/atlas/db"

// password hashing
const hashed = await hash("mypassword")
const ok = await verify("mypassword", hashed)

// JWT — always pass expiresIn for anything that grants access
const jwt = await token.sign({ userId: 1 }, SECRET, { expiresIn: 3600 })
const payload = await token.verify(jwt, SECRET)

// auth guard pipe
const guard = requireAuth({ secret: SECRET })

// login flow
const loginPipe = login({
  db,
  table: "users",
  identity: "email",
  password: "password",
  onSuccess: async (conn, user) =>
    json(conn, 200, { token: await token.sign({ userId: user.id }, SECRET, { expiresIn: 3600 }) }),
})

// password reset: request, then confirm
const requestReset = passwordReset({ db, table: "users", secret: RESET_SECRET, transport: sendResetEmail })
const confirmReset = async (resetToken: string, newPassword: string) => {
  const { email } = await verifyPasswordReset(resetToken, RESET_SECRET, async (email) =>
    (await db.one(from("users").where((q) => q("email").equals(email))))?.password ?? null,
  )
  await db.execute(from("users").where((q) => q("email").equals(email)).update({ password: await hash(newPassword) }))
}

Social Login (@wess/atlas/auth/social)#

Pluggable OAuth-client side of authentication. Seven built-in providers — each exported as a factory that takes its config and returns a SocialProvider.

FactoryProviderNotes
google({ clientId, clientSecret, redirectUri, hostedDomain?, prompt? })Google OIDCid_token-based profile, offline access
github({ clientId, clientSecret, redirectUri, allowSignup? })GitHub OAuth2falls back to /user/emails when primary email is private
apple({ clientId, teamId, keyId, privateKey, redirectUri, responseMode? })Sign in with Applemints ES256 client_secret JWT per request; default response_mode=form_post
microsoft({ clientId, clientSecret, redirectUri, tenant?, prompt? })Microsoft / Entra IDtenant-aware (common, consumers, organizations, or a tenant ID)
facebook({ clientId, clientSecret, redirectUri, apiVersion?, profileFields? })Meta Graph APIscope joined with commas, default v19.0
twitter({ clientId, clientSecret?, redirectUri, userFields? })X (OAuth 2 + PKCE)confidential clients use Basic auth on /oauth2/token
tiktok({ clientKey, clientSecret, redirectUri, userFields? })TikTok Login Kituses client_key (not client_id), scopes are comma-joined, profile id is open_id

All providers are PKCE-S256, scope-configurable, and return a normalized SocialProfile:

type SocialProfile = {
  provider: string
  id: string
  email?: string | null
  emailVerified?: boolean
  name?: string | null
  picture?: string | null
  username?: string | null
  raw: Record<string, unknown>
}

Usage#

import {
  socialAuth, google, github, apple, microsoft, facebook, twitter, tiktok,
} from "@wess/atlas/auth/social"
import { get, redirect, putHeader } from "@wess/atlas/server"
import { token } from "@wess/atlas/auth"

const social = socialAuth({
  secret: process.env.OAUTH_STATE_SECRET!,
  providers: {
    google:    google({ clientId, clientSecret, redirectUri: `${origin}/auth/google/callback` }),
    github:    github({ clientId, clientSecret, redirectUri: `${origin}/auth/github/callback` }),
    apple:     apple({ clientId, teamId, keyId, privateKey, redirectUri: `${origin}/auth/apple/callback` }),
    microsoft: microsoft({ clientId, clientSecret, redirectUri: `${origin}/auth/microsoft/callback` }),
    facebook:  facebook({ clientId, clientSecret, redirectUri: `${origin}/auth/facebook/callback` }),
    twitter:   twitter({ clientId, redirectUri: `${origin}/auth/twitter/callback` }),
    tiktok:    tiktok({ clientKey, clientSecret, redirectUri: `${origin}/auth/tiktok/callback` }),
  },
})

const routes = [
  get("/auth/google", social.start("google", { returnTo: "/welcome" })),
  get("/auth/google/callback", social.callback("google", {
    onSuccess: async (conn, { profile, returnTo }) => {
      // app owns the user table — upsert here
      const user = await upsertUserFromProfile(profile)
      const jwt = await token.sign({ sub: user.id }, JWT_SECRET, { expiresIn: 3600 })
      return redirect(putHeader(conn, "set-cookie", `session=${jwt}; HttpOnly; Path=/`), localReturnPath(returnTo ?? "") ?? "/")
    },
  })),
  // …same for github / apple / microsoft / facebook / twitter
]

Apple uses response_mode=form_post by default, so the callback arrives as a POST with form-encoded code/state in the body — mount the route with post(...) and compose parseForm before social.callback("apple", …). That POST is cross-site, so Apple's state cookie is set SameSite=None; Secure automatically (a provider opts in with crossSiteCallback: true).

Return destinations#

returnTo is opaque app state, signed into the state cookie and handed back to onSuccess and onError. Atlas never redirects to it, so it can carry anything, such as a JSON intent. Before redirecting to it, check it: browsers strip tabs and newlines, so /\t/evil.example becomes //evil.example.

localReturnPath(value) → string | null returns the value only if it is a same-site path, refusing control characters, backslashes, //host, and anything that resolves off the site. Use it on every path you redirect to.

The default callback error answers 400 { error: "Sign-in failed. Please try again." } and logs the underlying message (which can carry a provider's raw response). onError still receives the real Error.

Stateless flow#

State + PKCE verifier live in a signed, HttpOnly, SameSite=lax cookie (SameSite=none for form_post providers) (_atlas_oauth_state by default, 10-minute TTL). Override via socialAuth({ cookie: { name?, path?, secure?, sameSite? } }). Behind a non-HTTPS dev proxy, set cookie: { secure: false }.

Pure helpers#

If you need to render the consent flow yourself (e.g. show a "Continue with X" button list), call social.authorize(name, opts?) to get { url, cookie } without writing to a Conn. social.complete(name, conn) is the inverse — verifies + exchanges + fetches and returns the normalized result.

Dependencies#

  • @wess/atlas/db - database access for flows
  • @wess/atlas/server - Conn/PipeFn types for flows and social pipes
  • Zero external dependencies; uses Bun.password, Web Crypto, and fetch

Testing#

bun test packages/auth/

Social authorization URLs carry an opaque state nonce. The signed HttpOnly cookie keeps the PKCE verifier private. Google, Apple, and Microsoft profiles verify RS256 signatures plus issuer, audience, and expiry against provider JWKS; extra authorization parameters cannot replace state, redirect URI, or PKCE fields.

For native clients that must recover an error destination even when the browser loses its state cookie, configure intentStore: createMemoryIntentStore() on socialAuth. onError(conn, error, context) receives the stored context.returnTo. This is only a redirect hint: clamp it to allowed destinations and never authorize an account, link, or invitation from error context. Successful authentication still requires the signed cookie and matching nonce. Do not decode the provider's state.

createMemoryIntentStore({ maxEntries? }) defaults to 512 entries and refuses hints over 4096 characters. Entries expire after the login flow's ten-minute TTL and are consumed once, bound to provider and nonce. It starts no timers. Multi-instance hosts can supply a shared SocialIntentStore implementing asynchronous remember(provider, nonce, returnTo, expires) and take(provider, nonce); the adapter must enforce expiry and atomic consumption. Without an intent store, social login remains stateless and missing-cookie errors have no redirect hint.

Canonical sourcepackages/auth/AGENTS.md
Type to search guides and package references.