@mantlejs/auth-redis
Redis-backed stores for Mantle JS authentication: a shared RefreshTokenStore for @mantlejs/auth and a shared OAuthStateStore for @mantlejs/auth-oauth. Both defaults are in-memory and single-instance only — in multi-instance deployments (Cloud Run, horizontal scaling behind a load balancer) every instance must see the same outstanding refresh tokens and pending OAuth states. This package backs both with Redis.
Installation
Section titled “Installation”npm install @mantlejs/auth-redis ioredisThe package has no hard dependency on a Redis client — the stores accept any client matching the small RedisClientLike interface (a subset of Redis commands shaped to match ioredis, which is the recommended client). Requires Redis ≥ 6.2 (GETDEL).
Concepts
Section titled “Concepts”Why a shared refresh-token store?
Section titled “Why a shared refresh-token store?”@mantlejs/auth issues access + refresh token pairs and records each refresh token’s jti in a RefreshTokenStore. Rotation consumes the jti exactly once; a second consume of the same token is treated as theft and revokes the whole family. The in-memory default cannot see tokens issued by another instance, so rotation and revocation break fleet-wide. The Redis store keys each jti with a TTL matching the token’s own exp (Redis evicts stale entries itself — no sweeper) and uses GETDEL so concurrent consumers of the same token resolve atomically: exactly one wins.
Why a shared OAuth state store?
Section titled “Why a shared OAuth state store?”@mantlejs/auth-oauth stores the pending state (and PKCE code verifier) between the redirect to the provider and the callback. Behind a load balancer, the callback can land on a different instance than the one that started the flow. The Redis store writes each state with SET … EX, so entries expire server-side and cleanup() is a no-op.
Quick start
Section titled “Quick start”import { Redis } from "ioredis";import { mantle } from "@mantlejs/mantle";import { express } from "@mantlejs/express";import { auth } from "@mantlejs/auth";import { googleStrategy } from "@mantlejs/auth-google";import { redisRefreshTokenStore, redisStateStore } from "@mantlejs/auth-redis";
const redis = new Redis(process.env.REDIS_URL!);
const app = mantle() .configure(express()) .configure( auth({ secret: process.env.JWT_SECRET!, refreshTokenStore: redisRefreshTokenStore(redis), }), ) .configure( googleStrategy({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, stateStore: redisStateStore(redis), }), );
app.listen(3030);redisRefreshTokenStore(client, options?)
Section titled “redisRefreshTokenStore(client, options?)”Returns a RefreshTokenStore backed by Redis. Pass it as refreshTokenStore in the auth() config.
Layout: each jti is a string key holding its subject, expiring with the token (SET … EX); each subject keeps a set of its outstanding jtis so revokeAll can find them. The set’s own TTL is re-armed to the newest token’s expiry on every add — under a fixed refreshExpiresIn the newest token always expires last, so the set outlives all its members. Stale members left behind by an individual jti key’s expiry are harmless: consume and revokeAll both treat a missing jti key as already gone.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
"mantle:auth:refresh:" |
Prefix applied to all keys written by the store |
redisStateStore(client, options?)
Section titled “redisStateStore(client, options?)”Returns an OAuthStateStore backed by Redis. Pass it as stateStore in any auth-oauth strategy config.
consume(state) uses GETDEL — the same atomic read-and-remove pattern redisRefreshTokenStore
uses for rotation — so two concurrent OAuth callback requests for the same state (a double-fired
network request, or a replayed callback URL) resolve atomically: exactly one sees the pending entry
and proceeds, the other sees it already gone.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
ttlMs |
number |
600000 (10 minutes) |
How long a pending authorization state is valid |
prefix |
string |
"mantle:oauth:state:" |
Prefix applied to all keys written by the store |
import type { RedisClientLike, RedisRefreshTokenStoreOptions, RedisStateStoreOptions } from "@mantlejs/auth-redis";| Type | Description |
|---|---|
RedisClientLike |
The Redis command subset the stores need (set/get/getdel/del/sadd/srem/smembers/expire) — satisfied by an ioredis instance |
RedisRefreshTokenStoreOptions |
Options passed to redisRefreshTokenStore() |
RedisStateStoreOptions |
Options passed to redisStateStore() |
The stores implement RefreshTokenStore from @mantlejs/auth (add(jti, sub, exp) / consume(jti) / revokeAll(sub)) and OAuthStateStore from @mantlejs/auth-oauth (set / get / delete / consume / cleanup).
Development
Section titled “Development”npx nx build auth-redis # compilenpx nx test auth-redis # run tests (uses ioredis-mock — no Redis server needed)npx nx lint auth-redis # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build auth-redisFirst publish (scoped packages require --access public):
cd packages/auth-redisnpm publish --access publicSubsequent releases — bump version in packages/auth-redis/package.json, then:
cd packages/auth-redisnpm publishTesting locally with Verdaccio
Section titled “Testing locally with Verdaccio”# Terminal 1 — start the local registrynpx nx run @mantle/source:local-registry
# Terminal 2 — publish to itcd packages/auth-redisnpm publish --registry http://localhost:4873
# Install from it in another projectnpm install @mantlejs/auth-redis --registry http://localhost:4873