Skip to content

auth-mongo

Package @theoven/auth-mongo
Adds to context ctx.user, ctx.auth — it is a provider for auth
Endpoints eight, at /auth/*
Creates files none
Creates collections auth_users, auth_refresh_tokens, auth_reset_tokens
Status shipped
Terminal window
bun add @theoven/auth @theoven/auth-mongo mongoose
src/app.ts
import { createApp, env } from '@theoven/core'
import { auth } from '@theoven/auth'
import { mongoAuth } from '@theoven/auth-mongo'
import { db } from '@theoven/db'
import { mongooseDb } from '@theoven/db-mongoose'
import { createConnection } from 'mongoose'
const connection = await createConnection(env.string('MONGO_URL')).asPromise()
export const app = createApp()
.use(db(mongooseDb({ url: env.string('MONGO_URL') })))
.use(auth(mongoAuth({ connection, secret: env.string('AUTH_SECRET') })))

Email-and-password authentication with users, sessions and password resets stored in MongoDB. It mounts the whole flow at /auth/*, hashes passwords with argon2id, issues a short-lived access JWT plus a revocable refresh token, and identifies every request from that JWT.

It is auth-basic with Mongoose underneath — literally, not approximately. See below.

Method Path Purpose Auth
POST /auth/signup create an account none
POST /auth/login sign in none
POST /auth/refresh new access token from the refresh cookie cookie
POST /auth/logout revoke the session cookie
POST /auth/forgot-password email a reset link none
POST /auth/reset-password redeem a reset token token
POST /auth/change-password change while signed in bearer
GET /auth/me the current user bearer

The prefix is configurable: auth(mongoAuth(...), { prefix: '/identity' }). A test asserts this list matches auth-basic’s exactly — that is what “different storage, same brick” has to mean to be worth saying.

That is the whole description, and it is meant literally. The same eight endpoints, the same tokens, the same cookie, the same rate limits, the same refusals — a test asserts the endpoint list matches auth-basic’s exactly.

So everything on the auth-basic page applies here: the endpoints, the configuration, the rate limits, the security decisions. Only two options differ:

Option auth-basic auth-mongo
storage db — a Drizzle client connection — a Mongoose Connection

Three collections, created on first write:

Collection Holds
auth_users id, email, name, argon2id hash, emailVerifiedAt, createdAt
auth_refresh_tokens id, user, SHA-256 of the token, expiry
auth_reset_tokens id, user, SHA-256 of the token, expiry, usedAt

Neither token is stored in the clear. A leaked database should not hand over working credentials.

Indexes are built when the models compile — unique on auth_users.email and on both token hashes, plus auth_refresh_tokens.userId, which is what “sign out everywhere” deletes by.

The ids are generated by @theoven/auth, so a user id has the same shape whichever storage brick you chose. Letting Mongo assign an ObjectId instead would make the id format depend on that choice — and those ids are visible in JWT subjects, in URLs, and in anything that stored one.

mongoAuth({
connection,
secret: env.string('AUTH_SECRET'),
accessTtl: 15 * 60,
refreshTtl: 30 * 24 * 60 * 60,
minPasswordLength: 12,
sendResetEmail: (to, token) => mail.send({ to, subject: 'Reset', text: resetUrl(token) }),
})
Option Default
connection — required; a Mongoose Connection, not the global singleton
secret — required; signs access tokens. No default exists, deliberately
accessTtl 900 access-token lifetime, seconds
refreshTtl 2592000 refresh-token lifetime, seconds (30 days)
resetTtl 3600 reset-link lifetime, seconds
minPasswordLength 8
sendResetEmail — without it the reset link is logged, and boot warns
rateLimit on { login: 10, signup: 5, forgotPassword: 3, window: 900000 }, or false
refreshCookie oven_refresh name of the httpOnly refresh cookie

Every one of these is the shared option from @theoven/auth, so the auth-basic configuration notes apply unchanged.

The endpoints are mounted for you; your own routes just read ctx.user:

src/routes/me.get.ts
export const auth = true
export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name })

user.raw is the stored user document, so anything you added to the collection is reachable.

It is the whole document, passwordHash included — fine for a policy, not something to return from a handler.

When you want your own endpoint — a signup that also creates a workspace, say — use the flows rather than reimplementing them:

src/routes/register.post.ts
import { signup } from '@theoven/auth'
import { z } from 'zod'
export const body = z.object({
email: z.email(),
password: z.string().min(8),
name: z.string(),
workspace: z.string(),
})
export default async ({ body, auth, db }) => {
// `signup` returns the identity and the token pair, not one merged object.
const { user, tokens } = await signup(auth.flows, body)
await db.model('Workspace', workspaceSchema).create({ name: body.workspace, owner: user.id })
return { user, ...tokens }
}

ctx.auth.flows is the shared FlowConfig — the same one the mounted endpoints use, so password rules, hashing and token lifetimes stay identical rather than being re-specified here.

identify ✓ — from the access JWT
routes ✓ — the eight endpoints above
signOut ✓ — /auth/logout revokes the refresh row
refresh ✓ — /auth/refresh rotates it

What fails at boot: a missing secret, and a missing connection. A missing sendResetEmail warns rather than fails, so reset works in development before mail is configured.

import { pruneExpiredTokens } from '@theoven/auth-mongo'
await pruneExpiredTokens(connection)

Nothing calls this. Expired tokens are already refused on use, so it is housekeeping rather than a security control — run it from a cron job when the collections start to bother you.

AuthStore is seven methods, and until this package it had one implementation. A contract with one implementation is a guess.

It fit without changes. The conformance suite lives in @theoven/auth/testing and both bricks run the same one:

import { describeAuthStore } from '@theoven/auth/testing'
describeAuthStore('my storage', () => myStore(connection))

If you write a storage brick, run it. It checks the behaviours the flows actually depend on — case-insensitive email lookup, tokens found by hash, usedAt persisting, and the one that matters most: that deleteRefreshTokens({}) deletes nothing rather than signing out every user in the database.

Against a real MongoDB, not a fake — a fake store would only prove the fake works, and the things that go wrong here are Mongo’s own: unique index violations, case-insensitive lookup, usedAt persisting.

It runs the shared describeAuthStore conformance suite from @theoven/auth/testing — the same suite auth-basic runs — plus the full flow end to end through a real app: signup, login, refresh, logout, password change, reset.

The test that matters most is the one asserting deleteRefreshTokens({}) deletes nothing. In Mongo an empty filter matches every document, so the naive implementation of “sign out this user” signs out the entire database. That is a Mongo-specific footgun auth-basic cannot have, and it is exactly why the conformance suite exists.

  • Requires a running MongoDB. There is no in-memory mode; a fake would only prove the fake works. The integration tests are gated on MONGO_URL and run in CI against mongo:8.
  • Mongoose 8, not 9 — see db-mongoose for why.
  • No email verification. emailVerifiedAt exists and is never set, exactly as in auth-basic.
  • Rate limiting is per process, as in auth-basic.
  • No OAuth. Use auth-clerk or auth-better — both ship, and both satisfy the same contract, so auth: true and your policies keep working.