@mantlejs/auth-oauth
Shared OAuth 2.0 base for Mantle JS. Provides the state management, PKCE, find-or-create user logic, and route registration that all OAuth strategy packages (auth-google, auth-github, etc.) build on. Not used directly by application code.
Protocol plumbing — authorization URL construction, token exchange, PKCE derivation — is delegated to Arctic as an implementation dependency (see ADR-002); strategy packages hand-write only profile normalization.
Installation
Section titled “Installation”npm install @mantlejs/auth-oauthConcepts
Section titled “Concepts”Provider contract
Section titled “Provider contract”Each strategy package implements the OAuthProvider interface — three methods covering the provider-specific parts of the authorization code flow:
interface OAuthProvider { usePkce: boolean; defaultScope: string[]; callbackMethod?: "GET" | "POST"; // default "GET" — "POST" for form_post providers (e.g. Apple) buildAuthUrl(params: AuthUrlParams): string; exchangeCode(params: CodeExchangeParams): Promise<string>; // returns provider access token fetchProfile(accessToken: string, extras?: CallbackExtras): Promise<OAuthProfile>; // returns { id, email?, name? }}extras is only populated for callbackMethod: "POST" providers — it carries the raw callback body
({ body?: Record<string, unknown> }) for fields that only exist in a form-encoded POST payload, not the
query string (e.g. Apple’s user JSON, only sent on first authorization).
createOAuthPlugin
Section titled “createOAuthPlugin”The factory that wires a provider into Mantle. It registers two routes (GET /auth/{providerKey} and
/auth/{providerKey}/callback — GET by default, or POST when provider.callbackMethod is "POST")
on the transport’s http:router, manages PKCE state (generating the code verifier via Arctic; the
provider derives the challenge), performs find-or-create on the configured user service, and issues the
Mantle JWT pair via @mantlejs/auth.
State store
Section titled “State store”Pending OAuth state is kept in an OAuthStateStore keyed on the OAuth state parameter. Entries expire after 10 minutes and are cleaned up lazily on each redirect request. The codeVerifier (PKCE only) is stored alongside the state and passed to the token exchange. The callback handler consumes the entry via consume(state) — an atomic read-and-remove, not a separate get() then delete() — so a double-fired network request or a replayed callback URL for the same state can’t both pass the pending-state check before either removes it.
The default store is an in-process, in-memory Map — fine for a single instance, wrong for multi-instance deployments (Cloud Run, horizontal scaling behind a load balancer), where the callback request can land on a different instance than the one that issued the state. Inject the Redis-backed store from @mantlejs/auth-redis instead (store methods are sync-or-async, so any shared backend fits):
import { Redis } from "ioredis";import { redisStateStore } from "@mantlejs/auth-redis";
const redis = new Redis(process.env.REDIS_URL!);
app.configure(googleStrategy({ clientId, clientSecret, stateStore: redisStateStore(redis) }));Find-or-create
Section titled “Find-or-create”On callback, the plugin calls app.service(entity).find({ query: { [entityIdField]: profile.id } }). If the result is empty, it calls app.service(entity).create(...) with the normalized profile fields. The resulting user record is used to mint the Mantle JWT pair.
Quick start (writing a new strategy)
Section titled “Quick start (writing a new strategy)”Arctic ships clients for ~60 providers — pick the matching class (or fall back to its generic
OAuth2Client with explicit endpoints) and hand-write only the profile normalization:
import type { MantlePlugin } from "@mantlejs/mantle";import { GeneralError } from "@mantlejs/mantle";import { Discord } from "arctic"; // or OAuth2Client for providers Arctic doesn't shipimport { createOAuthPlugin } from "@mantlejs/auth-oauth";import type { OAuthPluginConfig, OAuthProvider } from "@mantlejs/auth-oauth";
export type MyStrategyConfig = OAuthPluginConfig;
const myProvider: OAuthProvider = { usePkce: false, // when true, buildAuthUrl receives a codeVerifier; Arctic derives the S256 challenge defaultScope: ["identify", "email"],
buildAuthUrl({ clientId, redirectUri, scope, state }) { const client = new Discord(clientId, "", redirectUri); // secret unused for URL construction return client.createAuthorizationURL(state, scope).toString(); },
async exchangeCode({ code, clientId, clientSecret, redirectUri }) { const client = new Discord(clientId, clientSecret, redirectUri); try { const tokens = await client.validateAuthorizationCode(code); return tokens.accessToken(); } catch { throw new GeneralError("Token exchange failed"); } },
async fetchProfile(accessToken) { const res = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!res.ok) throw new GeneralError("Profile fetch failed"); const data = (await res.json()) as Record<string, unknown>; return { id: String(data["id"]), email: data["email"] as string }; },};
export function myStrategy(config: MyStrategyConfig): MantlePlugin { return createOAuthPlugin("myprovider", myProvider, { ...config, entityIdField: config.entityIdField ?? "myProviderId", });}Register alongside the other plugins:
app .configure(express()) .configure(auth({ secret: process.env.JWT_SECRET! })) .configure(myStrategy({ clientId: "...", clientSecret: "..." }));This registers GET /auth/myprovider and GET /auth/myprovider/callback on the Express app.
createOAuthPlugin(providerKey, provider, config)
Section titled “createOAuthPlugin(providerKey, provider, config)”function createOAuthPlugin(providerKey: string, provider: OAuthProvider, config: OAuthPluginConfig): MantlePlugin;| Parameter | Description |
|---|---|
providerKey |
Short identifier for the provider ('google', 'github', 'facebook'). Used in default route paths and the default entityIdField. |
provider |
The OAuthProvider implementation for this strategy. |
config |
User-supplied configuration (client ID, secret, optional overrides). |
Throws at plugin registration time if @mantlejs/auth or an HTTP transport (@mantlejs/express,
@mantlejs/koa, or @mantlejs/http) is not configured before this plugin.
Routes registered:
| Method | Path | Description |
|---|---|---|
GET |
/auth/{providerKey} |
Redirect to provider consent screen. Generates state + PKCE (if provider.usePkce). |
GET or POST* |
config.callbackUrl |
Verify state, exchange code, fetch profile, find-or-create user, issue JWT pair. |
* POST when provider.callbackMethod === "POST" (e.g. Apple’s response_mode=form_post); GET otherwise.
Response on successful callback:
{ "accessToken": "...", "refreshToken": "...", "user": { "id": "...", "email": "...", "name": "..." }}Redirecting back to a frontend (redirectUrl)
Section titled “Redirecting back to a frontend (redirectUrl)”/auth/{providerKey} is meant to be opened via full-page navigation (an <a href>, not
fetch) — the browser leaves your app to consent on the provider’s site and comes back on
its own. Without redirectUrl, the callback’s JSON response above just strands the user on
the API’s own origin. Set redirectUrl to send them back into the frontend instead:
app.configure(googleStrategy({ clientId, clientSecret, redirectUrl: "https://app.example.com/" }));- Success redirects to
${redirectUrl}#accessToken=...&refreshToken=... - Failure redirects to
${redirectUrl}#error=...
Tokens travel in the URL fragment, never the query string, so they never reach a server
log or a Referer header. On the frontend, read window.location.hash on load, hand the
tokens to @mantlejs/client’s client.setTokens({ accessToken, refreshToken }), and strip
the fragment with history.replaceState. See examples/knowledge-base/web/src/pages/AuthPage.tsx
for a complete example.
OAuthPluginConfig
Section titled “OAuthPluginConfig”| Field | Type | Default | Description |
|---|---|---|---|
clientId |
string |
— | OAuth application client ID (required) |
clientSecret |
string |
— | OAuth application client secret (required) |
callbackUrl |
string |
/auth/{providerKey}/callback |
Path for the callback route |
scope |
string[] |
provider.defaultScope |
OAuth scopes to request |
entity |
string |
'users' |
Service used to find or create users |
entityIdField |
string |
'{providerKey}Id' |
Field matched against the provider’s user ID |
stateStore |
OAuthStateStore |
in-memory | Store for pending OAuth state. Multi-instance deployments must inject a shared (e.g. Redis-backed) implementation |
redirectUrl |
string |
none — returns JSON | Frontend URL to send the browser back to once the callback completes (see below) |
import type { OAuthProvider, OAuthProfile, OAuthPluginConfig, OAuthStateStore, OAuthStateData, AuthUrlParams, CodeExchangeParams, CallbackExtras,} from "@mantlejs/auth-oauth";import { createStateStore } from "@mantlejs/auth-oauth";| Type | Description |
|---|---|
OAuthProvider |
Interface that each strategy package implements |
OAuthProfile |
Normalized profile: { id: string; email?: string; name?: string } |
OAuthPluginConfig |
Shared config options for all OAuth strategies |
OAuthStateStore |
Store for pending OAuth state — inject via config.stateStore |
OAuthStateData |
Stored entry: { codeVerifier?: string; expiresAt: number } |
AuthUrlParams |
Params passed to provider.buildAuthUrl() |
CodeExchangeParams |
Params passed to provider.exchangeCode() |
CallbackExtras |
Raw callback extras passed to provider.fetchProfile(): { body?: Record<string, unknown> } — populated only for callbackMethod: "POST" providers |
createStateStore(ttlMs?) — factory behind the default in-memory OAuthStateStore, exported in case you
want the default backend with a non-default TTL (default 600_000 — 10 minutes) instead of writing a
custom store:
app.configure(myStrategy({ clientId, clientSecret, stateStore: createStateStore(5 * 60 * 1000) }));Development
Section titled “Development”npx nx build auth-oauth # compilenpx nx test auth-oauth # run testsnpx nx lint auth-oauth # lintPublishing
Section titled “Publishing”npx nx build auth-oauthcd packages/auth-oauthnpm publish --access public