@atlasauth/backend

中文文档
0.55.0发布于 2026/11/16TypeScriptCommonJS近 30 天下载 7536

Server-side Atlas for Node, Deno, Bun and edge runtimes. It does three things:

原始使用文档
文档目录28 节
  1. Verify a session
  2. Sign in with a redirect (OpenID Connect)
  3. @atlasauth/backend/rp : Request → Response handlers for any…
  4. Next.js (App Router)
  5. Express
  6. Test offline
  7. Management client (secret key)
  8. Check your setup
  9. Retries and observability
  10. Design the sign-in page
  11. API key spend caps
  12. Mirror Atlas data into your database
  13. A local API key mirror
  14. Session keep-alive
  15. Strict request bodies
  16. Errors
  17. Platform client (platform key)
  18. Domain moves, logout fan-out and managed email
  19. Back-channel logout
  20. Webhooks
  21. Standard Webhooks on Web Crypto
  22. Typed events
  23. Mirror the user into your database
  24. Require MFA
  25. CSRF tokens
  26. Referral attribution
  27. Receive the referral protocol (referral platforms)
  28. Two-way sync

@atlasauth/backend

Server-side Atlas for Node, Deno, Bun and edge runtimes. It does three things:

  • verifies Atlas session tokens, webhooks and back-channel logout tokens
  • runs the server side of a hosted-redirect (OpenID Connect) sign-in
  • gives you a typed client for the Backend API, using your secret key (sk_...)
  • gives you a typed client for the Platform API, using your platform key (psk_...)

It needs nothing but fetch.

npm install @atlasauth/backend

Verify a session

import { AtlasBackend } from '@atlasauth/backend';

const atlas = new AtlasBackend({ issuer: 'https://clerk.example.com' });
const result = await atlas.verify(sessionToken);
if (result.ok) console.log(result.claims.sub);

The JWKS is found from the issuer and cached. Instances on the shared host also pass instanceId (or publishableKey).

Sign in with a redirect (OpenID Connect)

oidc runs the relying-party side of the hosted-redirect integration: state, nonce and PKCE, the flow cookie, the code exchange and the ID-token checks (RS256 only, iss, aud = your client id, nonce, exp).

import { oidc, AtlasOidcError } from '@atlasauth/backend';

const config = {
  issuer: 'https://bright-fox-42-fapi.atlasauth.net', // https:// + frontend_api_host
  clientId: process.env.ATLAS_CLIENT_ID!,
  redirectUri: 'https://app.example.com/auth/callback',
  secret: process.env.OIDC_COOKIE_SECRET!, // 32+ random characters; seals the flow cookie
};

// Start: set the flow cookie, redirect.
const { url, cookie } = await oidc.start({ ...config, scope: 'openid profile email' });

// Callback: one call does every check.
const identity = await oidc.callback({
  ...config,
  clientSecret: process.env.ATLAS_CLIENT_SECRET!,
  query: request.url, // or URLSearchParams, or req.query
  cookie: request.headers.get('cookie'),
});
// identity: { sub, namespacedSub, email, emailVerified, name, idToken, accessToken, refreshToken?, expiresAt, claims, clearCookie }

// Sign out: end your session, then end the Atlas one.
const logout = await oidc.logoutUrl({ ...config, postLogoutRedirectUri: 'https://app.example.com/', idTokenHint });
  • The flow cookie is httpOnly, SameSite=Lax, Secure (except on a http://localhost callback), scoped to the callback path, and lasts 10 minutes. With secret it is encrypted (AES-GCM), so it can't be read or forged. Without secret it is only encoded. Always pass one in production.
  • namespacedSub is ${issuer}#${sub}. Key users on it if you accept more than one instance, so two instances' users never collide.
  • clientAuth is client_secret_basic (default with a secret), client_secret_post, or none (public client, PKCE only).
  • new AtlasBackend({ issuer }).oidc has the same methods with issuer filled in.
  • Did your own code exchange? oidc.verifyIdToken(idToken, { issuer, clientId, nonce }) runs just the token checks.

Failures throw AtlasOidcError with a stable code:

codeMeaning
state_mismatchNo flow cookie, a different state, a cookie from another sign-in or secret, or a flow older than 10 minutes. Start again.
access_deniedAtlas redirected back with an OAuth error. The exact value is in oauthError (access_denied, login_required, …).
issuer_mismatchThe callback's iss parameter names a different server.
invalid_requestThe callback has no code.
token_exchange_failedThe token endpoint refused the code or client (oauthError, status).
invalid_tokenThe ID token failed a check: algorithm, signature, iss, aud/azp, nonce, exp/iat or at_hash.
discovery_failedThe discovery document or the signing keys could not be fetched or don't match the issuer.
reauthentication_requiredstart asked for maxAge and the ID token's auth_time is older (or missing).
subject_mismatchstart asked for expectedSub (a step-up) and a different user signed in.

Every error from callback carries clearCookie: send it with your error page too.

start also takes uiLocales, maxAge (the callback then enforces auth_time), returnTo (a same-site path, returned as identity.returnTo) and expectedSub. The identity carries sid (the Atlas session id) and authTime.

@atlasauth/backend/rp: Request → Response handlers for any runtime

The same flow as (Request) => Response handlers, plus step-up, session keep-alive and sign-out. This entry point loads only Web standards and jose, so it runs on Node 18+, Deno, Bun and Workers.

import { createRelyingParty } from '@atlasauth/backend/rp';

const rp = createRelyingParty({
  issuer: process.env.ATLAS_ISSUER!,
  clientId: process.env.ATLAS_CLIENT_ID!,
  clientSecret: process.env.ATLAS_CLIENT_SECRET!,
  redirectUri: 'https://app.example.com/auth/callback',
  secret: process.env.SESSION_SECRET!,      // 32+ characters
  secretKey: process.env.ATLAS_SECRET_KEY,  // for touchIfDue and server-side revoke
  postLogoutRedirectUri: 'https://app.example.com/',
});

const startResponse = await rp.start(request);                  // 302 to /oauth2/authorize
const { user, tokens, response } = await rp.callback(request);  // user.sub, user.sid, user.auth_time, ...
const stepUp = await rp.requireRecentAuth(request, 300, { sub: user.sub, authTime: user.auth_time }); // null or a 302
const touch = await rp.touchIfDue(user.sid!, lastTouchedAt);    // 'skipped' | 'touched' | 'ended'
const signOut = await rp.signOut({ idToken: tokens.id_token, sid: user.sid });

Or wire all three routes in one line with rp.handle(request) (sign-in, callback and sign-out; null for any other path), giving onSignedIn(user, tokens, request, result) and onSignOut(request) in the config. forwardParams: ['ref'] passes extra query parameters from your sign-in URL to /oauth2/authorize.

It also exports LogoutTokenVerifier, fetchBackchannelLogout and verifyAtlasWebhook / verifyAtlasWebhookRequest (Web Crypto).

The same entry point is published on its own as @atlasauth/rp, whose only dependency is jose.

Next.js (App Router)

// app/auth/login/route.ts
import { oidc } from '@atlasauth/backend';
import { config } from '@/lib/atlas';

export async function GET() {
  const { url, cookie } = await oidc.start(config);
  return new Response(null, { status: 302, headers: { location: url, 'set-cookie': cookie } });
}

// app/auth/callback/route.ts
import { oidc, AtlasOidcError } from '@atlasauth/backend';
import { config } from '@/lib/atlas';

export async function GET(request: Request) {
  try {
    const user = await oidc.callback({
      ...config,
      clientSecret: process.env.ATLAS_CLIENT_SECRET!,
      query: request.url,
      cookie: request.headers.get('cookie'),
    });
    const headers = new Headers({ location: '/' });
    headers.append('set-cookie', user.clearCookie);
    headers.append('set-cookie', await createAppSession(user)); // your own session cookie
    return new Response(null, { status: 302, headers });
  } catch (error) {
    if (!(error instanceof AtlasOidcError)) throw error;
    return new Response(`Sign-in failed (${error.code})`, {
      status: 400,
      headers: error.clearCookie ? { 'set-cookie': error.clearCookie } : {},
    });
  }
}

Express

import express from 'express';
import { oidc, AtlasOidcError } from '@atlasauth/backend';

const app = express();

app.get('/auth/login', async (req, res) => {
  const { url, cookie } = await oidc.start(config);
  res.append('Set-Cookie', cookie).redirect(url);
});

app.get('/auth/callback', async (req, res) => {
  try {
    const user = await oidc.callback({
      ...config,
      clientSecret: process.env.ATLAS_CLIENT_SECRET!,
      query: req.originalUrl,
      cookie: req.headers.cookie,
    });
    res.append('Set-Cookie', user.clearCookie);
    await startYourSession(req, res, user.namespacedSub);
    res.redirect('/');
  } catch (error) {
    if (!(error instanceof AtlasOidcError)) throw error;
    if (error.clearCookie) res.append('Set-Cookie', error.clearCookie);
    res.status(400).send(`Sign-in failed (${error.code})`);
  }
});

Test offline

createTestIssuer() mints session tokens and ID tokens with a throwaway key, and its fetch serves discovery, the JWKS and a token endpoint, so a full redirect sign-in runs in a unit test with no network.

import { createTestIssuer, oidc, TEST_TOKEN_TAMPERS } from '@atlasauth/backend';

const fx = await createTestIssuer({ clientSecret: 'test-secret' });
const config = { issuer: fx.issuer, clientId: fx.clientId, redirectUri: 'https://app.example.com/auth/callback', secret: '…32+ chars…', fetchImpl: fx.fetch };

const { url, cookie } = await oidc.start(config);
const { callbackUrl } = await fx.authorize(url, { sub: 'user_1', claims: { email: '[email protected]', email_verified: true } });
const user = await oidc.callback({ ...config, clientSecret: 'test-secret', query: callbackUrl, cookie });

// Negative cases: wrong_aud, wrong_iss, foreign_key, expired, wrong_nonce, alg_none.
for (const tamper of TEST_TOKEN_TAMPERS) {
  const { url, cookie } = await oidc.start(config);
  const { callbackUrl } = await fx.authorize(url, { tamper });
  await expect(oidc.callback({ ...config, clientSecret: 'test-secret', query: callbackUrl, cookie })).rejects.toMatchObject({ code: 'invalid_token' });
}

fx.mintIdToken(claims, { aud, nonce, tamper }) and fx.mintSession(claims, { tamper }) mint single tokens. @atlasauth/testing re-exports createTestIssuer.

Management client (secret key)

import { createAtlasClient, paginate } from '@atlasauth/backend';

const atlas = createAtlasClient({ secretKey: process.env.ATLAS_SECRET_KEY! });

const user = await atlas.users.create({ email_address: '[email protected]' });
for await (const u of paginate(atlas.users.list)) console.log(u.id);

Every Backend API operation has a method, grouped by resource. The groups are:

AreaNamespaces
Users and sessionsusers, emailAddresses, sessions, signInTokens, actorTokens
Organizationsorganizations, invitations
Roles and authorizationroles, permissions, fga, openfga
Access and sign-up controlwaitlist, allowlist, blocklist, domains, redirectUrls
Instance settingsinstance, instanceSecurity, design, branding, localizations
Templates and messagingemailTemplates, smsTemplates, messaging, notifications
Social and enterprise sign-inoauthProviders, connectors, ssoConnections, ssoOnboarding, orgLdapConnections, scimTokens, scimProvisioning, legacyAuthConnection
OAuth and tokensoauthClients, resourceServers, jwtTemplates, apiKeys, machines, tokens, sessionExchangeApps, tickets
Webhooks and auditwebhooks, auditLogs, auditEvents, events, usage, configChangelog, logStreams
SecurityattackProtection, rateLimitPolicy, riskBasedMfa, botSignals, captcha, networkAcls, managedWaf, radiusClients
Billing and entitlementsbilling, entitlementOverrides, organizationSettingsSchema
PrivacydataSubjectRequests, importExport
Agents and integrationsactions, pca, mcp, mcpConnector, ltiPlatforms
Development toolstestMode, diagnostics

Check your setup

const report = await atlas.instance.preflight();
for (const f of report.findings) console.log(f.severity, f.message, '→', f.fix);

atlas.doctor() (or npx atlas-doctor with ATLAS_SECRET_KEY set) checks the secret key, clock skew against Atlas, the Backend API, the JWKS, the service status and the instance configuration, and never throws:

const report = await atlas.doctor();
if (!report.ok) console.error(formatDoctorReport(report));

Retries and observability

A 429 (any method) or a 503 (GET, PUT, DELETE, or a write with an idempotency key) is retried twice, waiting for Retry-After or an exponential backoff with jitter. Tune it with retry: { maxRetries, baseDelayMs, maxDelayMs } or pass retry: false; when the server asks for a longer wait than maxDelayMs, the AtlasApiError is thrown with retryAfterMs set.

createAtlasClient({ secretKey, otel: true }) records one client span per request plus atlas.client.requests, atlas.client.retries and atlas.client.request.duration, through the global @opentelemetry/api (an optional peer dependency; unset, nothing is loaded). Pass otel: { tracer, meter } to use your own.

Design the sign-in page

import { readFile } from 'node:fs/promises';

const logo = await atlas.design.assets.upload(await readFile('logo.png'), {
  kind: 'logo',
  contentType: 'image/png',
});
await atlas.design.update({
  palette: { primary: '#4f46e5' },
  logo: { assetId: logo.asset_id },
});
// Per-organization override; null removes a setting.
await atlas.design.organizations.update('org_123', { palette: { primary: null } });

Uploads send the raw image bytes with their content type. A Blob brings its own type.

API key spend caps

Give an end-user API key caps per unit and period (request, second, minute, day, month or total; { unit: 'requests', amount: 600, period: 'minute' } is a per-key rate limit), then meter what each call costs. A debit that would exceed a cap is refused without recording anything; it resolves with allowed: false rather than throwing.

const key = await atlas.apiKeys.create({
  subject_type: 'user',
  subject_id: 'user_123',
  limits: [
    { unit: 'requests', amount: 1000, period: 'day' },
    { unit: 'usd_cents', amount: 5000, period: 'month' },
  ],
});

const debit = await atlas.apiKeys.recordUsage(key.id, {
  unit: 'usd_cents',
  amount: 12,
  idempotency_key: 'req_8f2c', // a retry returns the first outcome without counting again
});
if (!debit.allowed) {
  // debit.code === 'LIMIT_EXCEEDED'; debit.exceeded lists the caps that refused it
}

const { usage } = await atlas.apiKeys.getUsage(key.id); // used / remaining / resets_at per cap
await atlas.apiKeys.update(key.id, { limits: null }); // remove the caps

apiKeys.verify(secret) also returns the key's name, scopes, effective_scopes (expanded through the instance scope implications), constraints, limits and current usage.

Mirror Atlas data into your database

createMirror follows the change feed (snapshot first, then ordered changes, automatic re-snapshot when your position was trimmed) and writes into memory, Redis, Postgres or your own Drizzle or Prisma tables (@atlasauth/backend/mirror/drizzle, @atlasauth/backend/mirror/prisma).

import { createMirror, memoryMirrorStore } from '@atlasauth/backend';

const mirror = createMirror({ client: atlas, resources: ['user', 'organization'], store: memoryMirrorStore() });
await mirror.sync(); // or mirror.start() to poll

A local API key mirror

createKeyMirror keeps your own table authoritative: mint and import at Atlas, verify locally against the sha256 with no network call, revoke locally first, and pull revokes made elsewhere from the revocation feed.

import { createKeyMirror, memoryKeyMirrorStore } from '@atlasauth/backend';

const mirror = createKeyMirror({ store: memoryKeyMirrorStore(), apiKeys: atlas.apiKeys });
const { secret } = await mirror.mint({ subject_type: 'organization', subject_id: 'org_123', scopes: ['calls:write'] });
const result = await mirror.verify(presented, { requiredScopes: ['calls:write'] }); // local, no network call
await mirror.revoke(keyId); // local first, then Atlas
setInterval(() => void mirror.sync(), 15_000); // revocation feed
// await mirror.applyWebhook(event) for verified api_key.* webhooks

Back KeyMirrorStore (put, get, getByHash, markRevoked, getCursor, setCursor) with your own table in production.

Session keep-alive

If your app loads the Atlas frontend SDK only on some pages, time spent on the other pages can count as inactivity. Touch the session from your backend to slide its inactivity window:

const s = await atlas.sessions.touch(sessionId);
// s.idle_expires_at is the new inactivity deadline; s.extended is false when a touch in the last minute already did it

A touch never extends past the session's absolute expiry and never revives an ended session (409 SESSION_REVOKED, SESSION_IDLE_EXPIRED or SESSION_EXPIRED). It writes at most once a minute, so calling it on every request is safe.

Strict request bodies

By default Atlas ignores a body field an endpoint does not accept. It lists the ignored fields in the Atlas-Unknown-Fields response header.

const atlas = createAtlasClient({
  secretKey,
  strict: true, // refuse unknown fields with 422 UNKNOWN_FIELD instead
  onUnknownFields: ({ method, path, fields }) => console.warn(method, path, 'ignored', fields),
});

// Override for some calls:
await atlas.withOptions({ strict: false }).users.update('user_123', body);

Errors

Every failed call throws AtlasApiError, which has status, code and the full errors list. AtlasErrorCodes names the codes worth branching on:

import { AtlasApiError, AtlasErrorCodes } from '@atlasauth/backend';

try {
  await atlas.notifications.send(body);
} catch (e) {
  if (e instanceof AtlasApiError && e.hasCode(AtlasErrorCodes.EMAIL_DELIVERY_NOT_CONFIGURED)) {
    // set up an email provider first
  }
}

Platform client (platform key)

import { createAtlasPlatformClient } from '@atlasauth/backend';

const platform = createAtlasPlatformClient({ platformKey: process.env.ATLAS_PLATFORM_KEY! });

const app = await platform.apps.create({ name: 'Acme' });
const signIn = await platform.atlasIdApps.create({
  name: 'Acme',
  redirect_uris: ['https://acme.example/callback'],
  post_logout_redirect_uris: ['https://acme.example/'],
  backchannel_logout_uri: 'https://acme.example/backchannel-logout',
  branding: { theme: 'dark', accent_color: '#4f46e5' },
});
console.log(signIn.client_id, signIn.client_secret); // the secret is shown once

The platform client has these namespaces:

  • apps: create, update, archive, transfer, claim and delete apps, manage their instances, rotate and list secrets
  • accountlessApps
  • atlasIdApps: Sign in with Atlas apps, including design preview and secret rotation
  • designAssets

It accepts the same strict, onUnknownFields and withOptions settings.

Domain moves, logout fan-out and managed email

// Move every URL from the old origin to the new one. dry_run first shows the diff.
const preview = await atlas.instance.moveOrigin({ from: 'https://old.example.com', to: 'https://example.com', dry_run: true });
for (const c of preview.changes) console.log(c.object, c.field, c.before, '->', c.after);

// Send the same logout_token your OIDC apps get to another service.
await atlas.logoutNotifyEndpoints.create({ url: 'https://api.example.com/atlas/logout' });

// Send email from your own domain through Atlas, then publish the returned DNS records.
const managed = await atlas.messaging.managedEmail.create({ from_address: 'Acme <[email protected]>' });
console.log(managed.records);

On the platform client, atlasIdApps.findUsers(id, email) answers a privacy request for one app, atlasIdApps.revokeGrant(id, grantId) revokes one person's approval, and atlasIdPublisher claims and verifies the publisher name shown on consent screens.

Back-channel logout

When a user signs out of Atlas, Atlas POSTs a logout_token to your backchannel_logout_uri. Verify it before you end the matching local sessions:

import { LogoutTokenVerifier, memoryLogoutReplayStore } from '@atlasauth/backend';

const logoutTokens = new LogoutTokenVerifier({
  issuer: 'https://atlasauth.net', // or your instance's issuer
  clientId: process.env.ATLAS_CLIENT_ID!,
  replayStore: memoryLogoutReplayStore(), // use a shared store across processes
});

// Framework-agnostic handler: (form body) -> { status, headers }
export async function handleBackchannelLogout(form: URLSearchParams) {
  const headers = { 'Cache-Control': 'no-store' };
  const token = form.get('logout_token');
  if (!token) return { status: 400, headers };

  const result = await logoutTokens.verify(token);
  if (!result.ok) {
    // retryable: our side failed for now (the issuer's keys could not be fetched).
    // 503 + Retry-After makes Atlas send it again; 400 is final and never retried.
    return result.retryable
      ? { status: 503, headers: { ...headers, 'Retry-After': '30' } }
      : { status: 400, headers };
  }

  const { sid, sub } = result.claims;
  if (sid) await sessions.deleteByAtlasSid(sid); // your session store
  else if (sub) await sessions.deleteAllForUser(sub);
  return { status: 200, headers };
}

The verifier checks the following:

  • the RS256 signature
  • iss, and that aud contains your client id
  • iat, which must be present, not in the future and not too old, and exp if present
  • jti
  • the back-channel logout event
  • that sid or sub is present
  • that there is no nonce
  • that typ is logout+jwt, when the header has one

For tokens signed by your own instance, new AtlasBackend({ issuer }).verifyLogoutToken(token, { clientId }) reuses the instance's cached keys.

To refuse replays across several servers, implement LogoutReplayStore.remember(jti, expiresAtMs) on a shared store, for example Redis SET NX PX.

Atlas retries a delivery that gets a 5xx, 408, 429 or no answer, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours. Any other 4xx is final. Atlas never follows a redirect: a 3xx answer is recorded as backchannel_redirect_not_followed, so after a domain move update backchannel_logout_uri to the final URL. Answer your own transient failures (a database that is down) with 503 too.

To check the route end to end, oauthClients.testBackchannelLogout(id) sends a signed test token (sid starting sid_test_, extra claim atlas_test: true) and returns what your route answered; oauthClients.listBackchannelDeliveries(id) lists every attempt.

Webhooks

verifyWebhook checks the signature on events Atlas sends to your webhook endpoints. You manage endpoints with atlas.webhooks.endpoints: create, update, reveal or rotate a secret, send a test event, and redeliver a past delivery.

The atlas-signature header can carry more than one signature, separated by spaces (v1,<sig> v1,<sig>). While an endpoint secret rotates, Atlas may sign each delivery with the old and the new secret. verifyWebhook accepts the delivery when any one signature matches your secret.

Standard Webhooks on Web Crypto

Events your app publishes through Atlas follow Standard Webhooks. verifyStandardWebhookWeb({ body, headers, secret }) (and verifyStandardWebhookRequest(request, { secret })) verify them with Web Crypto only, so they run on Workers, Deno, Bun and in browsers as well as Node. signAtlasWebhook and signStandardWebhook from @atlasauth/rp/testing produce correctly signed deliveries for your receiver's tests.

Typed events

verifyWebhook returns an AtlasWebhookEvent, a union over type, so a switch narrows data:

const { verified, event } = verifyWebhook({ body: rawBody, headers, secret });
if (!verified || !event) return res.sendStatus(400);
switch (event.type) {
  case 'user.created':
    await createUser(event.data.id, event.data.primary_email_address);
    break;
  case 'organization_membership.updated':
    console.log(event.data.role, event.data.role_source); // RoleSource | null
    break;
  default:
    // an event type newer than this package version
}

ATLAS_WEBHOOK_EVENT_TYPES lists every type. RoleSource is "manual", "scim", "directory_sync", "sso:<connection id>" or "ldap:<connection id>".

Mirror the user into your database

syncLocalUser finds or creates the row for the signed-in Atlas user. Call it after verifying the session:

const { user, created, linked } = await syncLocalUser(auth.claims, {
  findBySub: (sub) => db.user.findUnique({ where: { atlasSub: sub } }),
  findByEmail: (email) => db.user.findUnique({ where: { email } }),
  subOf: (row) => row.atlasSub,
  isLocalEmailVerified: (row) => row.emailVerified,
  link: (row, sub) => db.user.update({ where: { id: row.id }, data: { atlasSub: sub } }),
  create: ({ sub, email, email_verified, external_id }) =>
    db.user.create({ data: { atlasSub: sub, email, emailVerified: email_verified, externalId: external_id } }),
});
  • A row is found by sub first.
  • An existing row is linked by email only when the token says email_verified: true AND isLocalEmailVerified(row) is true. Linking an address nobody proved would let whoever registered it first take the account.
  • A row already bound to a different Atlas user is never relinked: AtlasLinkConflictError is thrown.
  • Put a unique index on the Atlas sub. When two first requests race, the loser's create fails on it and syncLocalUser returns the winner's row. isDuplicateError decides what counts as that failure (default: Postgres 23505, or a message containing "duplicate" or "unique").

The token carries email, email_verified and external_id when the instance includes the email claim in session tokens.

Require MFA

if (!mfaSatisfied(auth.claims)) return res.status(403).json({ error: 'mfa_required' });

mfaSatisfied is true when the token says mfa_satisfied: true, or else when mfa is true, acr is urn:atlas:acr:mfa or amr includes mfa, or the session came through enterprise SSO (sso_connection_id). usedMultiFactor and authenticatedWithin take SessionClaims too.

An organization's MFA policy is a separate bar: mfaOrgSatisfied(auth.claims, orgId) is true when the organization does not require MFA, or the token says org_mfa_ok. The policy only withholds the organization's claims, so a request that never reads them is not stopped; an app that authorizes from its own tables must call mfaOrgSatisfied itself. It describes the token's active organization only; for any other organization the user belongs to, call client.organizations.mfaStatus(orgId, { sessionId }).

CSRF tokens

For cookie-authenticated forms and fetch calls:

const secret = process.env.ATLAS_CSRF_SECRET ?? deriveCsrfSecret(process.env.ATLAS_SECRET_KEY!);
const token = csrfToken({ sid: auth.claims.sid, secret }); // set as the __atlas_csrf cookie, render into the page
// on a POST:
if (!(await verifyCsrf(request, { sid: auth.claims.sid, secret }))) return new Response(null, { status: 403 });

A token is bound to the session (sid). verifyCsrf takes it from the x-atlas-csrf header or an atlas_csrf form field, and it must equal the __atlas_csrf cookie. A form body is read from a clone, so your handler can still read it. With Next.js, atlasProxy({ csrf: true }) in @atlasauth/nextjs does all of this for you.

Referral attribution

With referral attribution on, Atlas reports every interactive sign-up to your referral platform before user.created is sent. The answer lands on the user: referral (who referred them, or null), referral_code and referral_link (the user's own code and link, when they are a referrer).

await atlas.referrals.update({
  enabled: true,
  provider: 'webhook', // the Atlas referral protocol; or a built-in vendor adapter
  endpoint_url: 'https://referrals.example.com/atlas',
  signing_secret: process.env.REFERRAL_SIGNING_SECRET,
  auto_enroll_users: true,
  link_template: 'https://app.example.com/join?ref={code}',
  security: { on_high_risk: 'hold' },
});
const test = await atlas.referrals.test(); // { ok, http_status, latency_ms, error? }
const failed = await atlas.referrals.listCalls({ status: 'failed', limit: 20 });
await atlas.referrals.retryCall(failed.data[0]!.id);

const user = await atlas.users.get('user_123');
user.referral?.referrer_user_id;
await atlas.users.update('user_123', { referral_code: 'ada-1' }); // 409 REFERRAL_CODE_TAKEN if held
await atlas.users.flagReferral('user_123', { reason: 'self-referral' });

Browser capture: browser_sources lists the referral programs whose script or cookie the browser reads (null on update goes back to the provider's default, reported by browser_sources_default), and consent_provider picks the consent manager that decides (auto, one manager, or app).

Enabling without what the provider needs is 422 REFERRALS_NOT_CONFIGURED. When the platform answers late, Atlas sends the user.referral_attributed webhook (UserReferralAttributedEvent). Preflight may report referrals_enabled_unverified and referrals_without_bot_protection.

Receive the referral protocol (referral platforms)

A referral platform that implements the Atlas referral protocol verifies Atlas's calls with verifyReferralRequest. Every call is a POST signed like a webhook, and its JSON body says which operation it is (op): signup, erase, export, partner or signal. Pass the path the call arrived on: a signed body whose op does not match the endpoint is refused (op_mismatch).

import { verifyReferralRequest } from '@atlasauth/backend';

app.post('/atlas/:endpoint', express.raw({ type: '*/*' }), async (req, res) => {
  const { verified, call } = verifyReferralRequest({
    body: req.body,
    headers: req.headers,
    secret: process.env.REFERRAL_SIGNING_SECRET!,
    path: req.path, // or expectedOp: 'signup' on a handler for one endpoint
  });
  if (!verified || !call) return res.sendStatus(401);
  switch (call.op) {
    case 'signup': // ReferralSignupPayload → answer ReferralSignupResponse
      return res.json({ referral: await match(call.payload), partner: await enroll(call.payload) });
    case 'export': // the connection test sends test: true
      return call.test ? res.sendStatus(404) : res.json(await exportFor(call.payload.customerRef));
    default: // erase, partner, signal
      await handle(call);
      return res.sendStatus(204);
  }
});

A retry of one call keeps its atlas-id (deliveryId). Sign-ups are idempotent on customerRef: answer a retry the same way.

Two-way sync

A provider with the inbound capability can send referral changes made after sign-up back to Atlas. The settings report inbound.url (give it to the platform), inbound.secret_set, inbound.endpoint_id and inbound.last_event ({ at, type, outcome }). Store the platform's event secret with inbound_secret (write-only, 16-512 characters) and its subscription id with inbound_endpoint_id:

await atlas.referrals.update({ inbound_secret: process.env.REFERRAL_INBOUND_SECRET, inbound_endpoint_id: 'we_123' });
const { inbound } = await atlas.referrals.get(); // inbound.last_event?.outcome: 'applied', 'stale', …

A platform on the Atlas referral protocol sends a referral.updated event to inbound.url, signed like an Atlas webhook with the inbound secret (or the connection's signing secret when none is set). signReferralUpdatedEvent builds it:

import { signReferralUpdatedEvent } from '@atlasauth/backend';

const e = signReferralUpdatedEvent({
  inboundUrl: settings.inbound.url,
  secret: process.env.REFERRAL_SIGNING_SECRET!,
  event: { customerRef: 'user_123', referralId: 'ref_1', status: 'qualified' },
});
await fetch(e.request());

版本与发布记录

@atlasauth/backend 共发布 71 个版本,首次发布于 2026/09/14;过去 12 个月发布了 71 个版本。

版本发布日期距今
0.55.0latest2026/11/16今天
0.54.02026/11/15今天
0.53.02026/11/15今天
0.52.02026/11/151 天前
0.51.02026/11/151 天前
0.50.02026/11/151 天前

关于 @atlasauth/backend 的常见问题

@atlasauth/backend 是什么?

Server-side Atlas for Node, Deno, Bun and edge runtimes. It does three things:

如何安装 @atlasauth/backend?

在项目目录运行 npm install @atlasauth/backend,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 atlas-doctor,可以用 npx atlas-doctor 直接运行。

@atlasauth/backend 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@atlasauth/backend 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@atlasauth/backend 可以商用吗?

@atlasauth/backend 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@atlasauth/backend 的下载量有多少?

根据 npm 官方下载统计,@atlasauth/backend 在 2026/10/07 – 2026/11/05 这 30 天内被下载 7,536 次,日均约 251 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@atlasauth/backend 还在维护吗?

最新版本 0.55.0 发布于 2026/11/16(今天);至今共发布 71 个版本,其中过去 12 个月发布了 71 个;首次发布于 2026/09/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。