@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.passwordverify(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 JWTopts.expiresIn- seconds until expiration. Always set it for anything that grants access:verifychecksexpwhen present but does not require it, so a token signed withoutexpiresInis valid forever.opts.typ- JOSE headertyp, 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 futurenbf. Does not requireexp(see above).
- Requires HS256, object claims and finite numeric dates; rejects invalid signatures, expired
token.headerOf(token): Record<string, unknown>- decode the JOSE header without checking it; only meaningful afterverifytoken.ACCESS_TOKEN_TYP("at+jwt"),token.isAccessToken(header, claims)- true for an OAuth access token: the RFC 9068 header, orclient_id+scopeclaims (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 untildestroy; capacity defaults to 10,000 and refuses new sessions when fullSessionStoretype withcreate,get,destroymethods
Auth Flows (PipeFn factories for @wess/atlas/server)#
signup({ db, table, fields, onSuccess })- Registration pipe;tableis a name string or adefineSchemaobject- 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 apurposeclaim (password resets, social state).acceptAccessTokens: truelets an API meant for OAuth clients take access tokens; checkconn.assigns.auth.scopethere
isSessionToken(jwt, claims, { acceptAccessTokens? })- the checkrequireAuthapplies afterverify, for custom guards that calltoken.verifythemselvespasswordReset({ db, table, secret, transport, expiresIn?, passwordField? })- Password reset pipe- Generates reset token (JWT signed with
secret, TTLexpiresInseconds, default 3600) withpurpose: "password_reset"andpwd, a keyed fingerprint of the current password hash (passwordField, defaultpassword) - Calls
transportwithout 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
- Generates reset token (JWT signed with
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 oncepasswordFingerprint(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.
| Factory | Provider | Notes |
|---|---|---|
google({ clientId, clientSecret, redirectUri, hostedDomain?, prompt? }) | Google OIDC | id_token-based profile, offline access |
github({ clientId, clientSecret, redirectUri, allowSignup? }) | GitHub OAuth2 | falls back to /user/emails when primary email is private |
apple({ clientId, teamId, keyId, privateKey, redirectUri, responseMode? }) | Sign in with Apple | mints ES256 client_secret JWT per request; default response_mode=form_post |
microsoft({ clientId, clientSecret, redirectUri, tenant?, prompt? }) | Microsoft / Entra ID | tenant-aware (common, consumers, organizations, or a tenant ID) |
facebook({ clientId, clientSecret, redirectUri, apiVersion?, profileFields? }) | Meta Graph API | scope 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 Kit | uses 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.