# Anchr.ing: documentation for AI agents > Generated from the Anchr.ing docs for `@anchring/auth` 0.3.1. Index: https://anchr.ing/llms.txt --- # What Anchr.ing is The token is the spine: when a user signs in, their token carries identity, organisation and role, plan and payment status, and what feature flags target, so one guard in your API checks them all, offline, with no call per request (`auth.require('report:export', { plan: ['team'], flag: 'bulk-export' })`). OpenID Connect underneath, so any OIDC library works too. EU-hosted, isolated by design: each customer has its own namespace with separate test and production databases, and the server reaches each environment only through that environment's own database user and signing keys, so a bug in the request path can't read another customer's data (the database refuses). On Enterprise and Sovereign it also runs on-premises under licence. - **Sign-in**: hosted login with passkeys, passwords, email codes, magic links and social logins, two-step verification, enterprise SSO (SAML, OIDC) and SCIM; a test and a production environment per project, promoted in one step - **Organisations and roles**: orgs, invites, custom org roles and org switching; typed permissions (`defineAccess`) checked offline with `auth.require`, `auth.guard` and, in the browser, `auth.can` - **Billing**: bill your own users on your own payment account (plans, checkout, proration, dunning, invoices, EU VAT); the plan and payment status land in the token, and a guard's `plan` / `entitlement` answers 402 `plan_required` - **Feature flags**: `defineFlags`, `auth.flag`, `auth.guard({ flag })`: targeting by plan, org, role, country or user, percentage rollouts and a kill switch, evaluated offline from the token - **Support**: support chat with signed-in users and visitors (`@anchring/auth/chat`), the user's plan, roles and flags beside each conversation, your own AI agent on a webhook; time-limited, audited support access - **Machines and AI**: personal access tokens, client credentials, CI trust (GitHub Actions OIDC), protecting your own MCP server; and for coding agents this file, rule files and the MCP server (`npx -y @anchring/mcp`) Plans (EUR a month excluding VAT; yearly = two months free; only apps users sign in to count, APIs and backend services are free; test environments include every feature on every plan): - Free €0: 5,000 monthly active users, 2 sign-in apps. For: [Anchr.ing for developers](https://anchr.ing/for/developers) - Startup €29/month or €290/year: 20,000 monthly active users (+€3 per 1,000 more), 5 sign-in apps, 1 custom domain. For: [Anchr.ing for startups](https://anchr.ing/for/startups) - Team €99/month or €990/year: 50,000 monthly active users (+€2 per 1,000 more), 15 sign-in apps, 3 projects, 1 enterprise SSO connection with SCIM, 3 custom domains. For: [Anchr.ing for B2B SaaS](https://anchr.ing/for/saas), [Anchr.ing for agencies](https://anchr.ing/for/agencies) - Scale €299/month or €2,990/year: 150,000 monthly active users (+€1.50 per 1,000 more), unlimited sign-in apps, 10 projects, 3 enterprise SSO connections with SCIM, 10 custom domains, support's first reply within 4 business hours. For: [Anchr.ing for scale-ups](https://anchr.ing/for/scale) - Enterprise from €2,000/month: unlimited monthly active users, unlimited sign-in apps, unlimited projects, unlimited enterprise SSO connections with SCIM, 10 custom domains. For: [Anchr.ing for enterprise](https://anchr.ing/for/enterprise) - Sovereign from €20,000/year: unlimited monthly active users, unlimited sign-in apps, unlimited projects, unlimited enterprise SSO connections with SCIM, 10 custom domains. For: [Anchr.ing Sovereign](https://anchr.ing/for/sovereign) --- # Rules for agents Anchr.ing is the identity provider this project uses (sign-in, API tokens, roles, webhooks). Apps integrate it with the `@anchring/auth` SDK. Follow these rules whenever you add or change code that signs users in, verifies tokens or imports `@anchring/auth`. **Sources, in this order.** The Anchr.ing MCP server, when it is connected (`@anchring/mcp`): call `integration_plan` for each surface and apply it, `explain_error` for error codes, and read `anchring://sdk/reference`. Otherwise https://anchr.ing/llms-full.txt. Never invent SDK functions, options, endpoints or environment variables: if the docs don't name it, it doesn't exist. Don't call OAuth endpoints by hand; the SDK derives them from discovery. **Ask, don't guess:** the project id (e.g. `acme`), the environment (`test` while building, `prod` for real users) and each app's client id from the dashboard (**Apps**). Register one app per surface: | Surface | Dashboard kind | Import | Sign-in | | --- | --- | --- | --- | | Single-page app (React, Vue, Angular, Svelte, plain JS) | `spa` | `@anchring/auth/browser` | `auth.signIn()`, PKCE, callback at `/auth/callback` handled by the SDK | | API that receives tokens | `resource` | `@anchring/auth/server` (+ `/hono`, `/express`) | `auth.middleware()`, `authMiddleware(auth)` or `auth.authenticate()` | | Server-rendered website | `web` | `@anchring/auth/server` | `auth.webApp()`, client secret | | Command-line tool | `cli` | `@anchring/auth/cli` | `auth.device.signIn()` (device code) | | Backend job, no user | `service` | `@anchring/auth/server` | `auth.clientCredentials()`, client secret | | Desktop app (Node, Electron) | `native` | `@anchring/auth/cli` | system browser, loopback redirect `http://127.0.0.1/callback`, PKCE | | Your own MCP server | `resource` | `@anchring/auth/server` | verify tokens and PATs, serve protected-resource metadata | | Tests | | `@anchring/auth/testing` | `createTestIssuer()`: no real sign-ins in CI | **Code rules.** - One auth file per surface, each with its own entry point. Never import `@anchring/auth/server` or the root `@anchring/auth` in browser code. Browser config comes from the bundler (`import.meta.env.VITE_*`), never `process.env`. - `createAuth({ project, env, … })` derives the issuer (`https://..anchr.ing`); `issuer: undefined` derives it too, so one build runs against the hosted service with the variable unset. For a custom domain, a self-hosted or a local server the app passes `issuer` itself, keeping `project` and `env`: the SDK reads no `AUTH_*` variable, so your code reads `AUTH_ISSUER` (browsers: the bundler's public twin) and passes it. A self-hosted https install can pass `domain` (its base domain) instead; a local stack over plain http (an `http://` issuer with a port) can only be given as `issuer`, never together with `domain`. - Configuration names, the same in every project: servers and CLIs read `AUTH_PROJECT`, `AUTH_ENV`, `AUTH_AUDIENCE` (APIs) and `AUTH_ISSUER`; browsers read the bundler's public twins `VITE_AUTH_PROJECT`, `VITE_AUTH_ENV`, `VITE_AUTH_CLIENT_ID` and `VITE_AUTH_ISSUER` (Next.js: `NEXT_PUBLIC_AUTH_…`; Angular: the same fields in `environment.ts`, `authProject`, `authEnv`, `authClientId`, `authIssuer`). Hardcoding project and audience is also fine; they are not secrets. Every name goes into `.env.example`. - A custom domain (dashboard **Settings → Domains**, e.g. `https://auth.acme.com`) is its own issuer: tokens, sessions and refresh tokens belong to the host they were issued on. Switch a client app and every API that verifies its tokens to the same `issuer` together; users sign in once more after the switch. - The SDK does PKCE (S256), state, nonce, refresh and token storage. Don't implement OAuth yourself, don't use the implicit flow, never put tokens in `localStorage`. Production SPAs use `refresh: 'cookie'`; a local cross-site http setup (the app on `localhost:5173`, the issuer on a local stack) relies on partitioned cookies for it, so leave it out there (memory storage: a reload signs in again silently) and turn it on for the hosted environments. - The API's identifier (e.g. `acme-api`) is the `audience` in the API's `createAuth`, and every client app needs it as its audience in the dashboard, or the API answers `401 wrong_audience`. - Access tokens carry no email or name by default; key your data by `user.sub`. - Roles, one pattern: give client apps the claims template `{ "org": "{{org}}" }` (the SDK reads it as `user.org` and `user.orgRole`), declare the roles once in a shared `access.ts` with `defineAccess({ roles: { member: ['report:read'], admin: ['report:*', 'members:manage'], owner: ['*'] } })`, pass it on both sides as `createAuth({ …, access })`, guard API routes with `auth.require('report:read')` (or `auth.middleware({ require })`), and shape the UI with `auth.can('members:manage')` in the browser. The checks use `user.orgRole` by themselves; permission strings are typed from the definition. Roles kept in your own database: `createAuth({ …, access, permissionContext: async (user) => ({ role: await roleOf(user.sub) }) })`, same guards. - Webhooks: `auth.webhooks.handler({...})`, mounted before any JSON body parser (it needs the raw body). **Secrets.** Client ids are public. Client secrets (`anchr_sk_…`), webhook secrets (`anchr_whsec_…`) and personal access tokens (`anchr_pat_…`) go only into environment variables or a secret manager (`ANCHRING_CLIENT_SECRET`, `ANCHRING_WEBHOOK_SECRET`): never into code, the repo, a browser bundle or logs. Keep `.env` in `.gitignore`; commit a `.env.example` without values. **Done means:** the project typechecks, a user signs in on the test environment, the API answers `200` with that user's token and `401` without one, and no secret is in the diff. --- # Getting started on Anchr.ing Create your account, set up your sign-in with a test and a production environment, and register your first apps in the dashboard. Anchr.ing runs the identity provider for you. You get an account on the platform and one **sign-in setup** (in the SDK and the URLs: a _project_), your own identity provider with two environments: `test` to build against and `prod` for real users. ## 1. Create your account Create your account: you sign in to the platform through Anchr.ing's own hosted login. Enter your email, type the code that arrives, and you are in the dashboard. Add a passkey on your account page afterwards if you like. ## 2. Set up your sign-in The dashboard starts you on **Set up your sign-in**: pick a name and an ID. The ID is permanent: it becomes your two hosts and the passkey domain of each environment. - `https://acme.test.anchr.ing`: test - `https://acme.prod.anchr.ing`: production The dashboard checks the ID as you type. IDs that use `www`, `api`, `admin`, `prod` or `test` as a part, or contain `anchr`, are reserved. The project shows as _provisioning_ for a few seconds while its databases, keys and users are created. ## 3. Pick an environment Its pages sit in the dashboard's sidebar (Apps, Users, Connections, Billing, Support, Flags, Settings and Logs; webhooks and server keys are tabs under Apps), with a **Test / Production** switch at the top. Everything below starts in test. > Mail in test goes only to the project's members. Every other message (sign-in codes and invites for your test users) is written to the test environment's mail log instead, so testing never emails real people. Production sends to everyone. Teammates: add them on the project's dashboard under **Members**, with or without production access; they sign in to Anchr.ing with that address. An account has one sign-in setup below Enterprise; on Enterprise, more come from the switcher at the bottom left (**New project**). ## 4. Register your apps In the dashboard, **Apps** (test), add one app per client. An example with a browser app, a CLI and the API they call: | App | Kind | Redirect / grant | Audience | You get | | --- | --- | --- | --- | --- | | Web | `spa` (public, PKCE) | `http://localhost:5173/auth/callback` | `acme-api` | client id `test_…` | | CLI | `cli` (public, device grant) | none | `acme-api` | client id `test_…` | | API | `resource` | none (verifies tokens) | `acme-api` | nothing to copy | Set the audience on the web and CLI apps too: an access token's `aud` is the app's audience, or its client id when none is set, and your API accepts only `acme-api`. Other kinds: `web` (server-rendered, confidential), `native` (desktop and mobile, PKCE) and `service` (machine to machine, see [Service to service](https://anchr.ing/guides/service)). ## 5. Install the SDK ```sh title="terminal" bun add @anchring/auth # or: npm i @anchring/auth ``` Use one file per surface, each importing its own entry point, so the browser bundle carries no server code and nothing reads `process.env` in the browser: ```ts // web/auth.ts import { createAuth } from '@anchring/auth/browser'; export const auth = createAuth({ project: 'acme', env: import.meta.env.VITE_AUTH_ENV ?? 'test', clientId: import.meta.env.VITE_AUTH_CLIENT_ID }); // api/auth.ts import { createAuth } from '@anchring/auth/server'; export const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); // cli/auth.ts (tokens persist in ~/.config/anchring/tokens//.json) import { createAuth } from '@anchring/auth/cli'; export const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', clientId: process.env.ACME_CLI_CLIENT_ID }); ``` The project and environment determine the issuer (`https://acme.test.anchr.ing`), discovery, keys and endpoints. Then follow the guide for each app you registered. --- # Sign in from a single-page app A browser app signs users in with @anchring/auth/browser: hosted login with passkeys, PKCE, refresh before expiry and a fetch that adds the token. Register a **public, PKCE** app (kind `spa`) in the dashboard (**Apps**) with the redirect `http://localhost:5173/auth/callback` (your dev server's origin) and the audience of your API, e.g. `acme-api`. Without an audience, tokens carry the client id as `aud` and your API answers `401 wrong_audience`. ```ts title="web/auth.ts" import { createAuth } from '@anchring/auth/browser'; const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…' }); // the id the dashboard shows (Apps) await auth.ready(); // finishes a pending callback / restores the session auth.user(); // { sub, email, name, claims } or null auth.user.subscribe((u) => render(u)); // signal / Svelte-store compatible await auth.signIn(); // redirects to hosted login: passkey, password, email code await auth.fetch('/api/reports'); // adds Authorization await auth.token(); // fresh access token if you need it yourself await auth.signOut(); // revokes the refresh token, RP-initiated logout ``` ## The account button in your framework One `auth` for the app, and the user as your framework's own reactive primitive: `useAuthUser(auth)` from `@anchring/auth/react` (a hook) or `@anchring/auth/vue` (a composable), `authUser(auth)` from `@anchring/auth/angular` (a signal, unsubscribed with the component). Angular apps that use `HttpClient` add `authInterceptor(auth, { prefix: '/api/' })` so every request to the API carries the token. Each adapter imports nothing but its framework. ```ts title="Vanilla JS: web/account.ts" import { auth } from './auth'; const button = document.querySelector('#account')!; auth.user.subscribe((user) => { button.textContent = user ? `Sign out ${user.email}` : 'Sign in'; }); button.addEventListener('click', () => (auth.user() ? auth.signOut() : auth.signIn())); // API calls: auth.fetch('/api/reports') adds the token, refreshing it first when it is about to expire. ``` ```ts title="React: web/account.tsx" import { useAuthUser } from '@anchring/auth/react'; import { auth } from './auth'; export function Account() { const user = useAuthUser(auth); if (!user) return ; return ; } // API calls: auth.fetch('/api/reports') adds the token, refreshing it first when it is about to expire. ``` ```ts title="Angular: web/account.component.ts" import { Component } from '@angular/core'; import { authUser } from '@anchring/auth/angular'; import { auth } from './auth'; @Component({ selector: 'app-account', template: ` @if (user(); as current) { } @else { } `, }) export class Account { user = authUser(auth); signIn() { return auth.signIn(); } signOut() { return auth.signOut(); } } // API calls: auth.fetch('/api/reports') adds the token, refreshing it first when it is about to expire; // HttpClient does the same with authInterceptor(auth, { prefix: '/api/' }). ``` ```ts title="Vue: web/Account.vue" ``` ## The callback The SDK handles the callback: when the page loads on the redirect path it exchanges the code (PKCE S256 with state and nonce), verifies the ID token and restores the URL you signed in from. Nothing to write. To route it yourself, turn `autoCallback` off: ```ts const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…', autoCallback: false, refresh: 'cookie' }); // on your /auth/callback route: location.assign((await auth.handleCallback()) ?? '/'); ``` ## Sign-in options ```ts await auth.signIn({ redirectTo: '/reports' }); // back to /reports after the callback await auth.signIn({ prompt: 'create' }); // open hosted login on sign-up (self sign-up on) await auth.signIn({ loginHint: 'ada@acme.example' }); // prefill the email await auth.signIn({ prompt: 'login', params: { acr_values: 'mfa' } }); // step up to MFA ``` `redirectTo` must be a same-origin URL or path; anything else throws a `TypeError`, so it cannot become an open redirect. `prompt: 'create'` opens hosted login on its sign-up wording when the project has self sign-up turned on (dashboard, **Settings**). ## Sessions and storage Use `refresh: 'cookie'`: the access token stays in memory and the refresh token goes into an HttpOnly cookie on the project's host, out of reach of page scripts. A reload or a new tab restores the session from that cookie, tabs refresh one at a time, and `signOut()` revokes it. Without it, tokens are kept in memory only and a reload signs in again (silently while the tenant session is alive). `localStorage` is deliberately not offered. More in [Keeping browser tokens safe](https://github.com/anchring/oss/blob/main/sdk/browser.md#keeping-browser-tokens-safe). ## Email and name in the API In the browser, `auth.user()` has the email from the ID token. Access tokens carry no email or name by default; give the app a claims template (**Apps → Custom claims template (JSON)**) if your API needs them: ```json { "email": "{{user.email}}", "email_verified": "{{user.email_verified}}", "name": "{{user.name}}" } ``` Next: [protect the API](https://anchr.ing/guides/web-api) the app calls. --- # Protect your API Verify access tokens offline in Hono, Express or any other framework, read the user, add policies and permissions, accept personal access tokens. Register a **resource** app with an audience (e.g. `acme-api`) and give the web and CLI apps the same audience, so the tokens they get carry it in `aud`. Verification is offline against cached keys: no call to anchring per request. ## Hono ```ts title="api/server.ts" import { Hono } from 'hono'; import { createAuth } from '@anchring/auth/server'; import '@anchring/auth/hono'; // types c.var.user / c.var.resource const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); const app = new Hono(); app.use('/api/*', auth.middleware()); // 401 without a valid token app.get('/api/reports', (c) => c.json(listReports(c.var.user.sub))); ``` The `@anchring/auth/hono` import types `c.var.user`; if your app already types its own `user`, use `new Hono()` instead. `c.var.user` has `sub`, `type` (`session`, `apiKey` or `impersonation`), `scopes`, `amr`, `env` and all `claims`. ## Express ```ts title="api/server.ts" import express from 'express'; import { createAuth } from '@anchring/auth/server'; import { authMiddleware } from '@anchring/auth/express'; const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api' }); const app = express(); app.use(express.json()); app.use('/api', authMiddleware(auth)); app.get('/api/me', (req, res) => res.json(req.user)); // also res.locals.user ``` The caller IP defaults to `req.ip`, so set Express `trust proxy` when you use `allowIps` behind a proxy. ## Anything else ```ts const result = await auth.authenticate(request.headers.get('authorization'), { audience: 'acme-api' }); if (!result.ok) return new Response(JSON.stringify(result.body), { status: result.status, headers: result.headers }); const user = result.user; // AuthUser: sub, type, scopes, amr, env, claims … // or, for a bare token: const verified = await auth.verify(token, { audience: 'acme-api' }); // throws AuthError ``` `authenticate` never throws for bad input: it returns the response to send, with the right `WWW-Authenticate` header. ## Policies ```ts auth.policy({ maxSessionAge: '12h', requireMfaWithin: '1h', allowIps: ['10.0.0.0/8'] }); app.use('/api/billing/*', auth.middleware({ policy: { requireMfaWithin: '15m' } })); // stricter per route ``` A session too old or without a recent second factor gets the RFC 9470 step-up challenge (`401 insufficient_user_authentication`); the browser app answers it with `signIn({ prompt: 'login', params: { acr_values: 'mfa' } })` and retries. ## Permissions The default: the role comes from the token. Give the apps that call the API a claims template (**Apps → Custom claims template (JSON)**) with the user's organisation and role: ```json { "org_id": "{{org.id}}", "org_role": "{{org.role}}" } ``` Then one guard checks it offline. Plans and billing state will resolve from the same token. ```ts const perms = auth.permissions({ roles: { member: ['report:read'], admin: ['report:*', 'members:manage'], owner: ['*'], }, tokens: { apiKey: { max: ['report:read'] } }, // PATs and machine tokens never exceed this context: (user) => ({ role: user.claims.org_role as string | undefined }), }); app.get('/api/reports', perms.guard('report:read'), (c) => c.json(listReports(c.var.user.claims.org_id))); app.put('/api/reports/:id', perms.guard('report:write', (c) => loadReport(c.req.param('id'))), handler); ``` Roles add up with no hierarchy (list everything an `owner` may do), token ceilings beat every role, and a role change reaches the API with the next refresh (10 minutes by default). Roles in your own database work too: `context: async (user) => ({ role: await roleOf(user.sub) })`. More in [Permissions](https://github.com/anchring/oss/blob/main/sdk/permissions.md). ## Personal access tokens Users create personal access tokens on their account page for scripts and CI. Accept them in the middleware with the resource app's audience; the middleware exchanges each one for a short-lived token and caches it: ```ts const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', pat: { audience: 'acme-api' } }); app.use('/api/*', auth.middleware()); // accepts JWTs and anchr_pat_… alike ``` --- # Sign in from a command-line tool The device flow for CLIs: the user approves in a browser, tokens persist in ~/.config/anchring and refresh on their own. Personal access tokens for CI. Register a **public, device grant** app (kind `cli`) with your API's audience, e.g. `acme-api`. ```ts title="cli/login.ts" import { createAuth } from '@anchring/auth/cli'; const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…' }); // the CLI app's client id await auth.device.signIn({ open: true }); // Open acme.test.anchr.ing/device and enter ABCD-1234 await auth.fetch('https://api.acme.example/reports'); await auth.signOut(); ``` The user opens the printed address in any browser, signs in (passkey, password, email code) and approves the code. Tokens are stored in a file of the app's own, `~/.config/anchring/tokens//.json` (respects `XDG_CONFIG_HOME`; file 0600, atomic writes; `tokenFile` picks another), and refreshed automatically; processes sharing the file refresh one at a time under a lock file. Pass `storage` to use the OS keychain instead. `device.signIn` options: `onCode` (show the code yourself), `open` (launch the browser; only `https:` URLs, or `http:` on loopback, never through a shell), `scope` and `signal` (cancel). ## Not signed in ```ts import { createAuth, SignInRequiredError } from '@anchring/auth/cli'; try { await auth.fetch('https://api.acme.example/reports'); } catch (e) { if (e instanceof SignInRequiredError) console.error('Not signed in: run `acme login`.'); else throw e; } ``` ## CI and scripts For CI, users create a personal access token on their account page (name, scopes, at most a year) and your CLI or script sends it as a bearer. The API accepts it when its middleware has `pat: { audience }` (see [Protect your API](https://anchr.ing/guides/web-api)). ```sh title="terminal" ACME_TOKEN=anchr_pat_test_… # created on the user's account page curl -H "Authorization: Bearer $ACME_TOKEN" https://api.acme.example/api/reports ``` --- # Machine-to-machine with client credentials A confidential service app gets tokens for itself with the client_credentials grant; your API verifies them like any other token, within their ceilings. A backend job or another service calls your API as itself, with no user. Register an app of kind `service` (machine to machine, client credentials) with your API's audience. It is confidential: copy its client secret into the service's environment, never into code. ```ts title="worker/token.ts" // A machine token (client_credentials): the app calls your API as itself. const res = await fetch('https://acme.test.anchr.ing/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: 'test_…', client_secret: process.env.CLIENT_SECRET! }), }); const { access_token, expires_in } = await res.json(); await fetch('https://api.acme.example/api/reports', { headers: { authorization: `Bearer ${access_token}` } }); ``` Tokens from the `client_credentials` grant have the app's client id as `sub` and no session. Request a new one when `expires_in` runs out. ## In your API ```ts // In the API: the same middleware verifies it (typed 'apiKey', sub = the client id). app.use('/api/*', auth.middleware()); app.get('/api/reports', (c) => { const machine = c.var.user.claims.client_id === c.var.user.sub; return c.json(listReports(machine ? undefined : c.var.user.sub)); }); ``` The middleware types machine tokens `apiKey`, like a personal access token, so `tokens.apiKey` permission ceilings and the token's scopes limit them. `maxSessionAge` and `requireMfaWithin` do not apply (there is no sign-in); limit machine callers with scopes, ceilings and `allowIps`. `/userinfo` refuses these tokens: key your data by `sub`. --- # Protect your MCP server Let AI clients call your own MCP server as the signed-in user: protected-resource metadata, OAuth sign-in from Claude, Cursor and VS Code, the token audience, scopes, and tokens for clients that send a header. Your MCP server is an API whose callers are AI clients (Claude, Cursor, Copilot and others). Protect it like any API: Anchr.ing signs the tokens, your server verifies them offline and acts as the user they belong to. ## 1. Register it In the dashboard, **Apps**, add an app of kind `resource` whose API identifier is the endpoint's public URL, e.g. `https://mcp.acme.example/mcp`. That URL is the audience your server checks. In **Settings**, turn on **Let MCP clients register themselves**. ## 2. Serve the metadata and the challenge ```ts title="mcp/server.ts" import { createAuth } from '@anchring/auth/server'; const resource = 'https://mcp.acme.example/mcp'; const auth = createAuth({ project: 'acme', env: 'prod', audience: resource, pat: { audience: resource } }); const mcp = auth.mcp.protectedResource({ resource, scopes: ['notes:read', 'notes:write'], resourceName: 'Notes' }); app.get(mcp.metadataPath, mcp.metadataHandler()); // /.well-known/oauth-protected-resource/mcp app.all('/mcp', mcp.middleware(), (c) => handleMcp(c.req.raw, c.var.user)); // tools act as the user, within its scopes ``` The metadata (RFC 9728) names your project's issuer and your scopes; the `401` challenge points clients at it. Tools check `user.scopes` before acting, and `user.sub` is the signed-in user. ## 3. Connect a client Claude, Claude Code, Cursor and VS Code add the server by its URL and sign the user in themselves: ```sh title="terminal" claude mcp add --transport http notes https://mcp.acme.example/mcp ``` The client follows the `401` to your metadata, reads the issuer's OAuth server metadata (RFC 8414), registers itself (dynamic client registration, or a client ID metadata document) and opens your project's sign-in with `resource` set to your URL (RFC 8707), so the token's audience is your server. Self-registered clients always show the sign-in page; your plan caps how many may register in production. ## Clients that send a header A personal access token with your scopes (from the user's account page), or an org token for a team's CI or shared agent, sent as a header. Cursor, in `.cursor/mcp.json`: ```json title=".cursor/mcp.json" { "mcpServers": { "notes": { "url": "https://mcp.acme.example/mcp", "headers": { "Authorization": "Bearer ${env:NOTES_TOKEN}" } } } } ``` With `pat` in `createAuth`, your server exchanges the token for a 10-minute access token and caches it. A revoked token stops within that window. The full template, with DPoP, scoped tools and tests against a local test issuer: [MCP server example](https://github.com/anchring/oss/tree/main/examples/mcp-server). --- # Keep your data in step with webhooks Signed, retried events for users, sessions and organisations, verified and deduplicated by the SDK; back-channel logout for ended sessions. Add your endpoint (e.g. `https://api.acme.example/hooks/auth`) in the dashboard, **Apps** → **Webhooks**, and put its signing secret in `ANCHRING_WEBHOOK_SECRET`. Each environment's webhook has its own secret (promoting creates a new one for prod), so set the variable per environment. ```ts title="api/hooks.ts" const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); // reads ANCHRING_WEBHOOK_SECRET app.post('/hooks/auth', auth.webhooks.handler({ 'user.created': (e) => createAccount(e.sub, e.email), 'user.deleted': (e) => deleteAccount(e.sub), })); ``` The handler checks the `anchring-signature` HMAC against the raw body with a 5 minute tolerance and deduplicates by event id: `401` for a bad or old signature, `200` for a replay or a type you don't handle, `500` if your handler throws (the event is retried). Deliveries are retried with backoff, so make handlers idempotent. Events: `user.created`, `user.updated`, `user.email_changed`, `user.deleted`, `user.disabled`, `user.enabled`, `session.revoked`, `credential.changed`, `invite.accepted`, `org.member_added`, `org.member_removed`, `org.member_updated`, and for billing `subscription.*`, `invoice.*` and `payment.*`. An unknown event name in the handler is a type error. ## Express Signed bodies need the raw bytes: mount the handler before `express.json()`. ```ts import { toExpress } from '@anchring/auth/express'; app.post('/hooks/auth', toExpress(auth.webhooks.handler({ /* … */ }))); // before express.json() app.use(express.json()); ``` ## Rotating the secret **Rotate secret** on the webhook returns the new secret once; the old one keeps signing next to it for a grace period (a day by default). Accept both meanwhile: ```ts createAuth({ project: 'acme', env: 'prod', webhookSecret: [process.env.WEBHOOK_SECRET_NEW!, process.env.WEBHOOK_SECRET_OLD!] }); ``` Webhook URLs must be `https`; plain `http` is accepted only for `localhost` in test. With several instances of your API, pass a shared `replayStore` so a replay to another instance is caught too. ## Back-channel logout When a user signs out, or an admin ends their sessions, Anchr.ing posts an OIDC back-channel logout token to the app's back-channel logout URI. Configure the app's `clientId` (the token's audience) in `createAuth`: ```ts app.post('/auth/backchannel-logout', auth.backchannel.handler((e) => destroyAppSessions(e.sid, e.sub))); // Reject app sessions the IdP has ended (by sid, or every session of a sub before the logout) if (await auth.backchannel.isLoggedOut(c.var.user)) return c.json({ error: 'logged_out' }, 401); ``` --- # Feature flags from the token Turn features on by plan, entitlement, org, role or user, roll out by percentage and switch off in one click; your API and your UI read flags offline from the token. Flags live per environment in the dashboard (**Flags**). A flag has a type (on/off, text, a number or JSON), a default, an on/off switch and rules. Your API and your browser app evaluate them offline, from the user's token and the environment's rules, which the SDK caches like signing keys. ## 1. Declare your flags One file, shared by the API and the UI, like your roles. Keys and values are typed, and each flag has the default used until the environment has it: ```ts title="flags.ts" import { defineFlags } from '@anchring/auth'; export const flags = defineFlags({ 'new-editor': { default: false }, theme: { default: 'classic', variants: ['classic', 'compact'] }, }); ``` ## 2. Read them ```ts // API (Hono): from the verified token, offline const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api', flags }); app.get('/api/editor', auth.guard({ flag: 'new-editor' }), handler); const theme = await auth.flag('theme', c.var.user); // 'classic' | 'compact' // Your own attributes (not in the token), for rules on "Attribute project" const beta = await auth.flag('new-editor', c.var.user, { attributes: { project: 'acme' } }); // Browser: the signed-in user const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…', flags }); if (await auth.flag('new-editor')) showNewEditor(); const all = await auth.flags(); // every flag at once ``` `auth.flag` never throws: without the environment's rules (or for an unknown key) it gives the default. `auth.guard({ flag })` answers `403` when the flag is off for the caller (`denyStatus: 404` hides the route). ## 3. Target with rules Rules are checked from the top, and the first that matches gives the value. A condition reads the token: plan, entitlement, billing status or country, the active organisation and the user's role in it, app roles, the user id, the email domain or any claim. Put what you target in your app's claims template, for example "billing": "{{billing}}" and "org": "{{org}}". A rule's rollout slider gives the value to a share of the users it matches. The share is fixed per user (a hash of the flag key and the user id), so raising 20% to 30% keeps everyone already in. Roll out by organisation to move whole organisations together. The preview on the flag's page shows what a user with given claims gets, before you save. ## 4. Switch off Turning a flag off gives everyone its default: the kill switch. Changes reach your apps within about 30 seconds (the rules' cache time), and every change is in the flag's change log with who made it and what it was before. From an alert or CI, use a server key with `flags:write`: ```ts import { createAdminClient } from '@anchring/auth/admin'; const admin = createAdminClient(); // ANCHRING_PROJECT, ANCHRING_SERVER_KEY await admin.flags.update('new-editor', { enabled: false }); ``` ## Going live **Promote to production** copies flags and their rules. A new flag arrives switched off in production, and an existing one keeps its production switch. User and organisation ids differ between environments, so the preview points out rules that name them. --- # Act as a user to help them: support access Open your app as one of your users from the dashboard, with a reason, a time limit and a ceiling on what support may do. Every action is audited and the user is told. Support access lets you see your app the way one of your users does, to reproduce a problem or fix it with them. You start it from the dashboard with a reason, it lasts a limited time, it can never touch the account's credentials, money or data export, every action is logged, and the user is told. ## 1. Turn it on In the dashboard, **Settings → Support access**: turn on **Let support act as a user**. Choose whether the user must approve each access first, whether they get an email (on by default), the longest session (30 minutes unless you change it, at most 240) and the ceiling: permissions support access never uses. The defaults block account deletion, email, password and two-step changes, payments, refunds, data export, deleting organisations and creating organisation tokens or invites. Add your app's own, such as `projects:delete`. The project owner may always start support access; give it to the members who do support on the project's **Members** list. ## 2. Give your app a start URL **Apps → your app → Support access start URL**: a page of your app on the same site as its callback URL, for example `https://app.acme.example/support-access`. The dashboard opens it with a one-time code (60 seconds, single use, only for this app); the SDK exchanges it for the user's tokens, so no token is ever in a URL. In a browser app nothing else is needed. Show a banner while support is in: ```ts title="app.ts" const auth = createAuth({ project: 'acme', env: 'prod', clientId: 'prod_…' }); await auth.ready(); // exchanges the dashboard's one-time code on the start URL const imp = auth.impersonation(); // null for the user's own sign-in if (imp) showBanner(`${imp.actor.email} from support, until ${new Date(imp.expiresAt!).toLocaleTimeString()}: ${imp.reason}`, () => auth.endImpersonation()); ``` A website with a server exchanges the code with its client secret: ```ts title="server.ts" app.get('/support-access', async (c) => { const r = await web.impersonation(c.req.url); // exchanges the one-time code with the client secret if (!r) return c.redirect('/'); await saveSession(c, r.session); return c.redirect(r.returnTo); }); ``` ## 3. Guard your API Support access tokens are the user's own, with an `act` claim naming who is really acting (RFC 8693) and the project's ceiling. Your API sees `user.type === 'impersonation'` and `user.impersonator`; guards deny the ceiling and the permissions you mark destructive: ```ts title="api.ts" export const access = defineAccess({ roles: { admin: ['projects:*', 'invoices:*'] }, destructive: ['projects:delete', 'invoices:refund'], // never during support access (unless allowDestructive) }); app.use('/api/*', auth.middleware(), auth.impersonationAudit()); // changes under support access go to the user's timeline app.delete('/api/projects/:id', auth.require('projects:delete'), deleteProject); // 403 for support access ``` ## 4. Help a user Open the user on the **Users** page and choose **Act as user**. Give a reason or a ticket or conversation link, the minutes and the app to open; you sign in again with your second factor if your last sign-in is older than 10 minutes. The app opens in a new tab as the user. **End now** ends it at once: its session and every token stop working, and apps with back-channel logout are told. ## What is recorded, and what the user sees The user's timeline in the Users panel and the audit log show the start (who, why, for how long), every app opened, every change made through Anchr.ing and your app (refused ones too), and the end. The user gets an email and sees each access on their account page: who, when and why; with consent on, they approve it there first. Your backend gets the `impersonation.started` and `impersonation.ended` webhooks. --- # Support chat on your site and in your app Drop the chat widget into your marketing site and your app: visitors and signed-in users write to you, you answer from the dashboard's inbox, your AI agent can answer first. Your users and your site's visitors write to your team from a chat in the corner of the page. You answer from **Support** in the dashboard, beside what the token knows about them: plan, billing, roles and flags. Replies they miss are emailed, and their email answer comes back into the conversation. ## 1. Turn it on In the dashboard, **Support → Settings**: **Support chat is on**. For a marketing site also turn on **Visitors can start a conversation** and list the site under **Your sites**; your apps' own addresses always work. **Install the widget** on the same page has both snippets below with your project filled in. ## 2. On your site, for visitors Copy `node_modules/@anchring/auth/anchring-chat.js` to your site (one file, no CDN, about 13 KB compressed) and add two lines: ```plain title="index.html" ``` A visitor agrees to be answered before the first message; product news is a separate choice, only with an email. The widget stores the wording they saw with their consent, and visitors who never sign up are deleted after 90 days without activity. ## 3. In your app, for signed-in users ```ts title="app.ts" import '@anchring/auth/chat-widget'; import { auth } from './auth'; // createAuth() from @anchring/auth/browser // in your page: document.querySelector('anchring-chat')!.auth = auth; ``` The widget lists the user's conversations, shows replies live, labels the AI agent and lets the user ask for a person. It is a custom element, so it works the same in React, Vue, Angular or plain HTML. ## 4. Make it yours Colours, logo and corners come from **Branding**. Attributes and CSS custom properties override them: ```plain ``` The texts are English and Danish (the page's language), and each can be replaced with the `strings` property. With a Content-Security-Policy, allow your issuer in `connect-src` (and `img-src` for the logo); the widget needs no inline scripts or styles. ## Your AI agent Turn on **An AI agent answers first**, give a server key `chat:agent`, and answer the `message.created` webhook with `admin.chat.reply`, or hand over with `admin.chat.handover`. Users always see that it is AI. --- # Going live: promote test to production Import your users, promote apps, webhooks and settings from test to prod, swap in the prod client ids and secrets, and optionally add a custom domain. Test and production are separate environments with their own issuers, keys, users and secrets. Going live copies your configuration across, with the users you imported, and switches your apps' environment. ## 1. Bring your users Import into **Test** first: **Users → Import** reads the export of Auth0, Clerk, Firebase, Supabase, Cognito or Keycloak (converted in your browser; only the rows are uploaded), an Anchr.ing export, or a JSON list of your own (check first, then import). Sign in as a few of them in test; promoting (step 2) then moves the imported users to production. Each row of your own list: `email`, `email_verified` (or `verified`), optional `name`, `password_hash` (bcrypt, argon2, PBKDF2 or Firebase scrypt) and `metadata`: ```json [ { "email": "ada@acme.example", "email_verified": true, "name": "Ada", "password_hash": "$2b$10$…" }, { "email": "bob@acme.example", "verified": true } ] ``` Existing emails are skipped, so a rerun is safe. Imported hashes are rehashed on first sign-in; users without a hash sign in once by email code. Imports emit no webhooks. What carries over from each provider, and the steps to export from it, are in the MCP server's `migration_plan` and `check_import_file` tools. From another provider, pick it under **Users → Import**. Passwords carry over where the provider exports the hashes; where it doesn't, you email every user a link to set a new one, from the dashboard (the import's **Email everyone a link to set a password**). ## 2. Promote to production **Promote to production** in the dashboard copies apps (with their audiences and claims templates), webhooks, social connections, settings and branding, the logo and the metadata schema. Prod apps get new `prod_` client ids, and each prod webhook gets its own signing secret, shown once in the result. Webhook URLs are copied as they are: replace an `http://localhost` receiver with the real one in prod. ## 3. Switch the environment ```sh title=".env" AUTH_ENV=prod VITE_AUTH_CLIENT_ID=prod_… # the prod ids the promote created ACME_CLI_CLIENT_ID=prod_… ANCHRING_WEBHOOK_SECRET=… # the prod webhook's own secret, shown once in the promote result ``` Audiences (`acme-api`, and so `pat.audience`) stay the same. ## 4. Check Sign in, call the API, run your CLI's `login`, and watch events arrive in the webhook log. ## A custom domain A custom domain such as `auth.acme.example` for prod is the passkey domain of that host, so add it before any user registers a passkey. Custom domains are not self-service on Anchr.ing yet. Once one is set up, pass the issuer to the SDK: ```ts createAuth({ project: 'acme', env: 'prod', issuer: 'https://auth.acme.example', clientId: 'prod_…' }); ``` --- # Export your users Take your users with you: ids, emails, linked logins, organisations and roles, and their password hashes if you want them, as a file Anchr.ing or Auth0 imports. No lock-in. Your users are yours. Export them any time, from the dashboard or your backend, as a file you can import again here or at another provider. Their ids stay the same, so the `sub` your app stores keeps working. ## 1. Make an export In the dashboard, pick the environment, then **Users → Export**. Choose a format and what to include, then **Export users**. You confirm your sign-in with your second factor first. A project with up to 1,000 users is ready at once; a larger one is prepared in the background and shows up under **Recent exports** a few minutes later. Each file can be downloaded for 24 hours, then it is deleted. You can delete it sooner. The project owner can always export. Give other members the **User export** permission on the project's **Members** list. Every export and download is in the audit log. ## 2. What is in the file For every user: the id, email and whether it is verified, name, picture, metadata, status (active or disabled), when they joined and last signed in, their linked social logins (provider and subject), their passkeys (credential id and public key), their organisations and roles, and their consent records. The Anchr.ing formats also carry the organisations and your custom organisation roles. **Password hashes** are included only when you tick them. They are as stored: argon2id, or the format a user was imported in (bcrypt, PBKDF2, Firebase scrypt) until they sign in once. Anyone with the file can try to crack them offline, so every owner of the project gets an email when hashes are exported. **TOTP secrets** (authenticator apps) are off by default. Only the owner can include them, after a warning: the file then holds your users' second factors in plain text. **Recovery codes** are never exported. Passkeys only work on the domain they were registered on, so another provider on another domain can't use them: your users add a new one there. ## 3. Pick a format **Anchr.ing JSON** is one document that **Users → Import** reads back, here or in another project: ```json title="acme-prod-users-2026-10-30.json" { "format": "anchring.users/1", "project": "acme", "environment": "prod", "passwords": true, "totp_secrets": false, "orgs": [{ "slug": "globex", "name": "Globex" }], "org_roles": [{ "name": "auditor", "permissions": ["report:read"] }], "users": [{ "id": "k3x9…", "email": "ada@acme.example", "email_verified": true, "name": "Ada", "status": "active", "metadata": { "plan": "pro" }, "created_at": "2026-01-04T09:12:00Z", "last_login": "2026-10-29T17:40:00Z", "password_hash": "$argon2id$v=19$m=65536,t=3,p=4$…", "identities": [{ "preset": "google", "connection_name": "Google", "subject": "1078…" }], "passkeys": [{ "credential_id": "…", "public_key": "…", "name": "MacBook" }], "orgs": [{ "slug": "globex", "role": "auditor" }], "consents": [], "mfa": { "totp": [{ "name": "Authenticator app" }] } }] } ``` **Anchr.ing NDJSON** is the same as JSON Lines: a header line, then one user per line, for large projects and tools that read line by line. The import reads it too. **Auth0 bulk import** is Auth0's user import file: hashes as `custom_password_hash` (argon2, bcrypt and PBKDF2), disabled users as `blocked`, metadata as `user_metadata`, and the Anchr.ing id, organisations and logins in `app_metadata.anchring`. Users without an email are left out, because Auth0's database users need one. Auth0 takes files of up to 500 KB per import job, so split a larger file. ## 4. From your backend A server key with the `users:export` scope exports without password hashes or TOTP secrets: those always need a person in the dashboard. It's useful for scheduled backups of your user list: ```ts title="backup.ts" import { createAdminClient } from '@anchring/auth/admin'; const admin = createAdminClient(); // ANCHRING_PROJECT, ANCHRING_SERVER_KEY (scope users:export) let e = await admin.users.exports.create({ format: 'anchring-ndjson' }); while (e.status === 'queued' || e.status === 'running') { await new Promise((r) => setTimeout(r, 5000)); e = await admin.users.exports.get(e.id); } if (e.status !== 'ready') throw new Error('the export failed'); await Bun.write('users.ndjson', await admin.users.exports.download(e.id)); ``` --- # Set up Anchr.ing with your AI agent The MCP server for coding assistants, llms.txt, and rule files for Claude Code, Cursor, Copilot and other agents, so the integration comes out right the first time. Anchr.ing is meant to be set up by your coding agent in one go. Give it the MCP server, the rule file for your agent and one prompt. ## 1. Connect the MCP server ```sh title="terminal" claude mcp add anchring -- npx -y @anchring/mcp # Claude Code codex mcp add anchring -- npx -y @anchring/mcp # OpenAI Codex # GitHub Copilot (VS Code), .vscode/mcp.json: # { "servers": { "anchring": { "type": "stdio", "command": "npx", "args": ["-y", "@anchring/mcp"] } } } # Cursor, .cursor/mcp.json: # { "mcpServers": { "anchring": { "command": "npx", "args": ["-y", "@anchring/mcp"] } } } ``` It carries the SDK reference and these guides for its SDK version, works offline, and its `integration_plan` tool writes the setup for your framework: install command, apps to register, environment variables and files. Other agents: see the [MCP server's README](https://github.com/anchring/oss/tree/main/mcp). Self-hosted or local install: set `ANCHRING_DOMAIN` to its base domain and every tool targets it: `claude mcp add anchring -e ANCHRING_DOMAIN=auth.example.com -- npx -y @anchring/mcp` (a local stack: its `host:port`, plus `ANCHRING_SITE_ORIGIN` for the origin its dashboard is served from). ## 2. Add the rules for your agent The rules tell the agent which entry point fits each part of your app, that the SDK does PKCE and storage, where secrets go, and never to invent endpoints. Download the file for your agent into your project: | Agent | File | Put it at | | --- | --- | --- | | Claude Code | [CLAUDE.md](https://anchr.ing/ai/CLAUDE.md) or the [skill](https://anchr.ing/ai/claude-skill/SKILL.md) | append to `CLAUDE.md`, or `.claude/skills/anchring/SKILL.md` | | Cursor | [anchring.mdc](https://anchr.ing/ai/anchring.mdc) | `.cursor/rules/anchring.mdc` | | GitHub Copilot | [copilot-instructions.md](https://anchr.ing/ai/copilot-instructions.md) | append to `.github/copilot-instructions.md` | | Codex, Windsurf and others | [AGENTS.md](https://anchr.ing/ai/AGENTS.md) | append to `AGENTS.md` | ```sh title="terminal" curl -s https://anchr.ing/ai/CLAUDE.md >> CLAUDE.md # Claude Code (appends) mkdir -p .cursor/rules && curl -so .cursor/rules/anchring.mdc https://anchr.ing/ai/anchring.mdc # Cursor mkdir -p .github && curl -s https://anchr.ing/ai/copilot-instructions.md >> .github/copilot-instructions.md curl -s https://anchr.ing/ai/AGENTS.md >> AGENTS.md # Codex, Windsurf and others ``` ## 3. Ask Register the apps first ([Getting started](https://anchr.ing/guides/getting-started)), then: ```plain title="prompt" Add Anchr.ing sign-in to this app with @anchring/auth: project acme, environment test. The web app's client id is test_…, the API's identifier is acme-api. Sign users in from the web app, protect the API, and keep secrets out of the code. ``` The agent asks for anything it can't find. Check the result yourself: sign in on test, call the API with and without a token (200, then 401), and look for secrets in the diff. ## Docs for any agent [llms.txt](https://anchr.ing/llms.txt) indexes the guides and rule files; [llms-full.txt](https://anchr.ing/llms-full.txt) is the rules, every guide and the SDK reference in one Markdown file. Both are generated from the same docs as these pages. --- # Bookkeeping: getting billing data into your accounts Anchr.ing gives you billing data in formats bookkeeping systems import: e-invoices (UBL), a double-entry journal (CSV) and a VAT summary. There are no built-in connectors to e-conomic, Dinero, Billy, Xero or others: you import the files, or let an AI assistant with the Anchr.ing MCP server read them and map them for you. Two kinds of invoices: - **Your Anchr.ing invoices** (what you pay us for your plan): dashboard → Your plan → Invoices, or the platform API. For your books these are purchases. - **Your project's invoices** (what your users and orgs pay you, end-user billing): the project's admin API (`/api/admin/billing/*`) with a server key that has `billing:read`, or the dashboard. These are your sales. ## Formats | Format | What it is | Use it for | | --- | --- | --- | | `ubl` | UBL 2.1 XML, Peppol BIS Billing 3.0 (EN 16931) | Importing invoices as e-invoices (most bookkeeping systems read UBL); attaching the legal document to a voucher | | `oioubl` | OIOUBL 2.1 XML (the Danish NemHandel profile) | Danish systems and public buyers that still expect OIOUBL | | `pdf`, `txt` | The invoice as a document or text | A human-readable copy | | `csv` (document) | One row per invoice line | Spreadsheets | | Journal CSV | Double-entry lines per document, with your account numbers | A journal / "kassekladde" import | | VAT summary | Net, VAT and gross per period, scheme, country and rate | The VAT return, the OSS return, the EC sales list | VAT categories in the XML: **S** (standard rate: domestic, or EU One-Stop-Shop at the buyer country's rate), **AE** (reverse charge, 0 %, an EU business with a valid VAT id), **O** (outside the scope of EU VAT: buyers outside the EU). Danish parties carry their CVR number as their electronic address (scheme 0184), others their VAT number in their country's scheme. A consumer has no electronic address: such an invoice imports fine but cannot be sent over Peppol. ## The journal `GET …/billing/journal?from=2026-01-01&to=2026-03-31` (CSV; `format=json` for rows in cents). Per invoice: the receivable (or, for your Anchr.ing invoices, the payable) against revenue (expense) and VAT; per payment the money that came in, on the day it did (a January invoice paid in February is in January's journal, its payment in February's); per credit note the reversal and, once the payment provider confirmed it, the refund (a credit note whose refund failed is not booked); per chargeback the money taken back, on the day it was. An earlier period's journal never changes because of what happens later. Debits equal credits per document. Columns: `date, document, type, account, debit, credit, vat_code, vat_rate, vat_amount, vat_scheme, vat_country, currency, customer, customer_name, customer_vat_id, reference, description` Pass your chart of accounts as `accounts` (JSON, URL-encoded). Every role defaults to its own name, so you can map only some: ```json { "revenue": "1010", "revenue_by_plan": { "pro": "1011" }, "revenue_by_scheme": { "reverse_charge": "1020", "outside_scope": "1030" }, "vat_output": "7130", "vat_oss_by_country": { "DE": "7141", "SE": "7142" }, "receivables": "5600", "bank": "5820", "refunds": "1050" } ``` Sales roles: `revenue`, `revenue_by_plan`, `revenue_by_kind` (plan, addon, usage, proration, discount, credit), `revenue_by_scheme`, `refunds`, `vat_output`, `vat_oss`, `vat_oss_by_country`, `receivables`, `bank`, `customer_credit` (credit carried to a later invoice). Purchase roles (your Anchr.ing invoices): `expense`, `expense_by_plan`, `vat_input`, `vat_reverse_charge_input`, `vat_reverse_charge_output`, `payables`, `bank`. A reverse-charged purchase books the VAT you self-assess at your country's standard rate on both VAT accounts. Revenue rows carry the VAT code and rate of their invoice, and a separate row books the VAT itself. Import either the net rows with your system's VAT code (and skip the VAT rows), or all rows without VAT codes, not both. Payment provider fees are not in the journal: they come from the provider's settlement report. ## The VAT summary `GET …/billing/vat-summary?from=…&to=…&granularity=quarter` (JSON, or `format=csv`): invoices less credit notes per period, scheme (`domestic`, `oss`, `reverse_charge`, `outside_scope`), country, rate and currency, with totals and `ec_sales`, the reverse-charged sales per buyer VAT id. Credit notes count in the period they were issued, under their invoice's VAT. ## Payments `GET …/billing/payments?from=…&to=…`: payments (card or SEPA, with the payment provider's reference to match its settlement report and your bank statement), refunds and chargebacks. ## With an AI assistant Add the MCP server (`npx -y @anchring/mcp`) with `ANCHRING_BILLING_KEY` (a project server key with `billing:read`) for your project's billing, or `ANCHRING_PLATFORM_TOKEN` for your Anchr.ing invoices. Its read-only tools `billing_list_invoices`, `billing_list_credit_notes`, `billing_get_invoice`, `billing_vat_summary`, `billing_journal_export` and `billing_payments` return these formats, and describe them so the assistant can map them to your system: for example "make an e-conomic journal import of last quarter's sales with my accounts", or "which invoices are reverse-charged to German customers this year?". ## Endpoints | | Your Anchr.ing invoices (platform API, `https://.prod./platform/v1`) | Your project's invoices (admin API, `/api/admin`, `billing:read`) | | --- | --- | --- | | List (date range) | `GET /billing/invoices?from=&to=` (invoices and credit notes) | `GET /billing/invoices?from=&to=`, `GET /billing/credit-notes?from=&to=` | | One document | `GET /billing/invoices/{id}/{pdf\|csv\|txt\|ubl\|oioubl}`, `GET /billing/credit-notes/{id}/{…}` | `GET /billing/invoices/{id}/file?format=`, `GET /billing/credit-notes/{id}/file?format=` | | A range at once | `GET /billing/invoices/export?from=&to=&format=csv\|txt\|zip\|ubl` | | | Journal | `GET /billing/journal?from=&to=&accounts=&format=csv\|json` | the same | | VAT summary | `GET /billing/vat-summary?from=&to=&granularity=&format=json\|csv` | the same | | Payments | `GET /billing/payments?from=&to=` | the same, also `customer`, `status` | --- # @anchring/auth 0.3.1: SDK reference Sign-in, token verification and permissions for apps on an [Anchr.ing](https://anchr.ing) project: one package for the browser, the API, websites with a server, CLIs, desktop apps and backend services, with the same `createAuth` everywhere. The user's token carries identity, organisation and role, plan and feature flags, so one offline check in your API covers them all. ```sh npm i @anchring/auth # or: bun add @anchring/auth ``` Node 20+, Bun 1.1+ or a modern browser; the only runtime dependency is `jose`. Every surface has its own entry point (`@anchring/auth/browser`, `/server`, `/cli`, `/native`, `/admin`, `/testing`, …): the full list is in [Configure](https://github.com/anchring/oss/blob/main/sdk/configure.md#entry-points). ## Quick start: browser app Register a **public, PKCE** app (kind `spa`) in the dashboard with the redirect URI `http://localhost:5173/auth/callback` and your API's audience (`acme-api`). ```ts import { createAuth } from '@anchring/auth/browser'; const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…' }); // the id the dashboard shows await auth.ready(); // finishes a pending callback / restores the session auth.user(); // { sub, email, name, claims } or null auth.user.subscribe((u) => render(u)); // signal / store compatible await auth.signIn(); // redirects to hosted login: passkey, password, email code await auth.fetch('/api/reports'); // adds Authorization (and a DPoP proof when enabled) await auth.signOut(); // revokes the refresh token, RP-initiated logout ``` The callback is handled by the SDK; with a client-side router there is nothing to write. React, Vue and Angular bindings, popup sign-in, and keeping tokens safe in production (`refresh: 'cookie'`): [Sign-in in the browser](https://github.com/anchring/oss/blob/main/sdk/browser.md). ## Quick start: API Register a **resource** app with the audience `acme-api`. Verification is offline against cached keys: no call to the auth service per request. ```ts import { Hono } from 'hono'; import { createAuth } from '@anchring/auth/server'; import '@anchring/auth/hono'; // types c.var.user const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); const app = new Hono(); app.use('/api/*', auth.middleware()); // 401 without a valid token app.get('/api/reports', (c) => c.json(listReports(c.var.user.sub))); ``` Express, any other framework (`auth.authenticate`), policies such as "MFA within the hour", DPoP and what a token must look like: [Protecting an API](https://github.com/anchring/oss/blob/main/sdk/server.md). Who may call what: [Permissions](https://github.com/anchring/oss/blob/main/sdk/permissions.md). ## Quick start: website with a server Register a **confidential** app (kind `web`) with the callback URL `https://app.acme.example/auth/callback`; its client secret goes into `ANCHRING_CLIENT_SECRET` on the server. The browser never sees a token. ```ts import { createAuth } from '@anchring/auth/server'; const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', clientId: 'test_…' }); const web = auth.webApp({ redirectUri: 'https://app.acme.example/auth/callback', postLogoutRedirectUri: 'https://app.acme.example/' }); // GET /login: keep `transaction` in a short-lived HttpOnly cookie, redirect to `url` const { url, transaction } = await web.signIn({ returnTo: '/reports' }); // GET /auth/callback: store `session` server-side behind an opaque cookie, redirect to `returnTo` const { session, returnTo } = await web.callback(request.url, cookies.auth_tx); // each request: refreshed shortly before the access token expires (store the result) const current = await web.fresh(session); // POST /logout const logoutUrl = await web.signOut(current); ``` Sessions, org switching and the backend-service variant (`auth.clientCredentials()`): [Websites with a server](https://github.com/anchring/oss/blob/main/sdk/web-server.md). ## One token for everything When a user signs in, their access token carries who they are, the organisation they act for and their role in it, their plan and payment status, and what feature flags target them. Your API reads all of it from the token it already verified, offline: ```ts app.get('/api/reports/export', auth.require('report:export', { plan: ['team'], flag: 'bulk-export' }), handler); ``` Roles come from a claims template (`"org": "{{org}}"`, `"billing": "{{billing}}"`), the browser's `auth.can()` and `auth.flag()` answer the same from the same token, and a change reaches your API with the next refresh (10 minutes by default, sooner if you want: [Sessions](https://github.com/anchring/oss/blob/main/sdk/sessions.md)). ## Docs - [Configure](https://github.com/anchring/oss/blob/main/sdk/configure.md): entry points, `createAuth` options, environment variable names, custom domains and self-hosted installs - [Sign-in in the browser](https://github.com/anchring/oss/blob/main/sdk/browser.md): the web quick start, the React, Vue and Angular adapters, keeping browser tokens safe, popup sign-in, sign in by code (another device) - [Protecting an API](https://github.com/anchring/oss/blob/main/sdk/server.md): Hono, Express and any framework, policies (session age, MFA, IP allowlists), DPoP, token types - [Websites with a server, and backend services](https://github.com/anchring/oss/blob/main/sdk/web-server.md): the confidential code flow with `webApp()`, machine-to-machine with `clientCredentials()` - [Command-line tools](https://github.com/anchring/oss/blob/main/sdk/cli.md): the device flow, where tokens live, sharing a session between processes - [Desktop and mobile apps](https://github.com/anchring/oss/blob/main/sdk/native.md): system browser plus loopback, Tauri with the keychain and a DPoP key, iOS and Android by hand - [Your own MCP server](https://github.com/anchring/oss/blob/main/sdk/mcp.md): protect an MCP endpoint so AI clients sign in through your project; the `@anchring/mcp` server for coding assistants - [Permissions](https://github.com/anchring/oss/blob/main/sdk/permissions.md): roles from token claims, `defineAccess`, guards that load the resource, plans and entitlements, admin vs owner - [Organisations](https://github.com/anchring/oss/blob/main/sdk/organisations.md): self-service orgs, invites, switching the active org, custom org roles - [Billing](https://github.com/anchring/oss/blob/main/sdk/billing.md): checkout, plan changes, invoices and payment methods from the app and from your backend - [Feature flags](https://github.com/anchring/oss/blob/main/sdk/feature-flags.md): offline from the token, typed, your own attributes, rollouts, the kill switch - [Support chat](https://github.com/anchring/oss/blob/main/sdk/support-chat.md): the headless client, your AI agent on a webhook, the `` widget - [Webhooks](https://github.com/anchring/oss/blob/main/sdk/webhooks.md): the handler, every event and its fields, rotation and replay protection - [Sessions, lifetimes and revocation](https://github.com/anchring/oss/blob/main/sdk/sessions.md): token lifetimes, switching accounts, stale permissions, sessions ended elsewhere, the user's devices, back-channel logout - [Machine access](https://github.com/anchring/oss/blob/main/sdk/tokens.md): personal access tokens, org tokens, server keys (the admin client), CI jobs with GitHub Actions OIDC - [Bring your own IdP (trusted issuers)](https://github.com/anchring/oss/blob/main/sdk/bring-your-own-idp.md): exchange tokens from Entra ID, Okta or Keycloak for your project's - [Support access and impersonation](https://github.com/anchring/oss/blob/main/sdk/impersonation.md): support access from the dashboard, impersonation tokens with your own key - [Errors reference](https://github.com/anchring/oss/blob/main/sdk/errors.md): every HTTP response of the middleware, `invalid_token` reasons, thrown errors - [Testing your app](https://github.com/anchring/oss/blob/main/sdk/testing.md): `createTestIssuer`, tokens of every kind, the code flow end to end - [Going live](https://github.com/anchring/oss/blob/main/sdk/going-live.md): import users, promote to production, switch the app's environment Runnable examples that typecheck against the SDK: [`examples/`](https://github.com/anchring/oss/tree/main/sdk/examples) (Hono and Express APIs, roles from claims, CLI, desktop, browser, popup, testing). Full apps, one per app type and framework: [examples on GitHub](https://github.com/anchring/oss/tree/main/examples). Using an AI coding assistant? `claude mcp add anchring -- npx -y @anchring/mcp` gives it these docs and a setup planner ([Coding assistants](https://github.com/anchring/oss/blob/main/sdk/mcp.md#coding-assistants); rule files: https://anchr.ing/guides/ai). ## Support and licence Changes between versions: [CHANGELOG](https://github.com/anchring/oss/blob/main/sdk/CHANGELOG.md). Issues and questions: [github.com/anchring/oss/issues](https://github.com/anchring/oss/issues); security reports as described at https://anchr.ing/security. MIT licensed, Copyright (c) 2026 Simon ApS. --- # Configure **When you need this:** every app, once. `createAuth({ project, env, … })` names your project and environment, and everything else (issuer, discovery, signing keys, endpoints) follows from them. Pick the entry point for the surface you are on; the options are the same everywhere. ```ts import { createAuth } from '@anchring/auth/server'; // or /browser, /cli, /native, … export const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); ``` ## Entry points | Import | Use it in | Contains | | --- | --- | --- | | `@anchring/auth` | anywhere | everything below in one `createAuth` (simplest; largest bundle) | | `@anchring/auth/server` | APIs, servers | `verify`, `authenticate`, Hono `middleware`, `policy`, `permissions`, `webhooks`, `backchannel`, `impersonate`, PAT exchange, `webApp` and `clientCredentials` (confidential clients) | | `@anchring/auth/browser` | browsers, Tauri, SPAs | `signIn`, `user`, `token`, `fetch`, `signOut`, DPoP; no server code, no Node imports | | `@anchring/auth/admin` | your app's backend | `createAdminClient`: the project's admin API with a server key (delete, disable, metadata) | | `@anchring/auth/cli` | command-line tools | device flow, tokens persisted to a file of the app's own (`~/.config/anchring/tokens//.json`) | | `@anchring/auth/native` | desktop apps (Tauri, Electron, Bun / Node) | system browser + loopback + PKCE, device code without a browser; native-code bridges for the listener, keychain store and DPoP key | | `@anchring/auth/hono` | Hono apps | `honoMiddleware`, `AuthEnv`, and the typed `c.var.user` (types only from hono) | | `@anchring/auth/express` | Express 4/5 apps | `authMiddleware`, `guard`, `toExpress` | | `@anchring/auth/react` | React 18+ apps | `useAuthUser(auth)`: the signed-in user as a hook (`useSyncExternalStore`) | | `@anchring/auth/vue` | Vue 3.3+ apps | `useAuthUser(auth)`: the signed-in user as a readonly ref (a composable) | | `@anchring/auth/angular` | Angular 19+ apps | `authUser(auth)`: the signed-in user as a signal (like `toSignal`); `authInterceptor(auth, { prefix })` for `HttpClient` | | `@anchring/auth/testing` | your tests | `createTestIssuer`: sign access tokens offline for the real `verify` and middleware | | `@anchring/auth/flags` | anywhere | the [feature flag](https://github.com/anchring/oss/blob/main/sdk/feature-flags.md#feature-flags) evaluator on its own (`defineFlags`, `evaluateFlag`, `flagContext`); `auth.flag` is in every `createAuth` | | `@anchring/auth/chat` | browsers, marketing sites | [support chat](https://github.com/anchring/oss/blob/main/sdk/support-chat.md#support-chat): `openChat` (a signed-in user or a visitor; live over server-sent events), `isAgentTurn` for your AI agent; headless, no UI | | `@anchring/auth/chat-widget` | browsers, marketing sites | the [chat widget](https://github.com/anchring/oss/blob/main/sdk/support-chat.md#chat-widget): ``, a drop-in web component on `openChat` (also one file, `anchring-chat.js`, for a ` ``` **Angular** (19+): `authUser(auth)` returns a `Signal` and works like `toSignal`: call it where `inject` works (a field initializer, a constructor, a factory) and it unsubscribes when that component or service is destroyed; anywhere else pass `{ injector }`. `authInterceptor(auth, { prefix })` is an `HttpInterceptorFn` that adds the token (and a DPoP proof, when the client uses DPoP) to requests whose URL starts with `prefix` (one or a list), refreshed first when it is about to expire. The Angular CLI has no `import.meta.env`, so the settings live in `src/environments/environment.ts` (one file per environment, swapped for the prod build with `fileReplacements` in `angular.json`): ```ts // src/environments/environment.ts export const environment = { authEnv: 'test', authClientId: 'test_…', // the web app's client id from the dashboard }; // src/app/auth.ts import { createAuth, OAuthError } from '@anchring/auth/browser'; import { environment } from '../environments/environment'; export const auth = createAuth({ project: 'acme', env: environment.authEnv, clientId: environment.authClientId }); export const ready = auth.ready().catch((e) => { if (e instanceof OAuthError) return null; throw e; }); // src/app/app.config.ts import { provideAppInitializer, type ApplicationConfig } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { authInterceptor } from '@anchring/auth/angular'; import { auth, ready } from './auth'; export const appConfig: ApplicationConfig = { providers: [provideAppInitializer(() => ready), provideHttpClient(withInterceptors([authInterceptor(auth, { prefix: '/api/' })]))], }; // src/app/account.ts import { Component } from '@angular/core'; import { authUser } from '@anchring/auth/angular'; import { auth } from './auth'; @Component({ selector: 'app-account', template: ` @if (user(); as current) { } @else { } `, }) export class Account { user = authUser(auth); signIn() { return auth.signIn(); } signOut() { return auth.signOut(); } } ``` A route guard needs no adapter: `auth.user()` is synchronous, so `const authGuard: CanActivateFn = (_route, state) => auth.user() ? true : (void auth.signIn({ redirectTo: state.url }), false)`. In all three, a token that can't be refreshed (the session was revoked) makes `auth.fetch` / `authHeaders` throw `SignInRequiredError` and the user becomes `null`, so the UI switches to signed out by itself. ### Keeping browser tokens safe Anything your page's scripts can read, an XSS bug or a compromised dependency can read too. What such a script can steal depends on where the tokens live, so pick the highest level your app can use. | Level | Config | Survives a reload | What a script on your page can take | | --- | --- | --- | --- | | 1 | `storage: 'session'` | yes | the access token **and the refresh token** (valid up to 30 days unused, 90 in all, until the user's next refresh rotates it) | | 2 | default (memory) | no: a redirect through hosted login, silent while the login session lives | tokens in memory, while the page is open | | 3 | `refresh: 'cookie'` | yes | a 10-minute access token, while the page is open. **Never the refresh token** | | 4 | `refresh: 'cookie'` + `dpop` with a persistent key | yes | nothing that works off the device | **Level 1** works everywhere and is fine for prototypes and internal tools. **Level 3 is what we recommend for production SPAs.** The Anchr.ing site's own dashboard uses it. **Level 3: refresh cookie.** ```ts const auth = createAuth({ project: 'acme', env: 'prod', clientId: 'prod_…', refresh: 'cookie' }); await auth.ready(); // restores the session from the cookie after a reload / in a new tab ``` The issuer sets the refresh token as an `HttpOnly; Secure; SameSite=None; Partitioned` cookie (`__Host-kh_rt_`) on its own host and leaves it out of the token response. The access token stays in memory. On a reload, in a new tab or shortly before expiry, the SDK calls `/token` with `credentials: 'include'`, and the issuer reads, rotates and re-sets the cookie. `signOut()` revokes it and clears it. Tabs refresh one at a time (Web Locks), and the issuer's 60 s rotation grace covers browsers without Web Locks. A `localStorage` flag (not a secret) remembers that a cookie was set, so pages of signed-out visitors don't send a token request. What makes it hold: - The call carries an `Anchring-Refresh: cookie` header. That header forces a CORS preflight, which only origins of your app's registered redirect URIs pass, and the issuer checks the `Origin` again on the request itself. Another site can't use the cookie, and a form post can't set the header. - The cookie is only for public clients (`client_id` in the body, no client secret). - It is partitioned by your app's site (CHIPS), so it is only sent when your app is the page in the address bar. **Where it works.** When your app and the issuer are on the same site (the recommended setup), it always works. For example, `app.acme.com` with the custom auth domain `auth.acme.com` (the `issuer` option in [Configure](https://github.com/anchring/oss/blob/main/sdk/configure.md#configure)). When they are on different sites (`app.acme.com` calling `acme.prod.anchr.ing`), it depends on the browser supporting partitioned cookies. Where it doesn't, the cookie isn't stored and the app behaves like level 2: the user signs in again after a reload. Use a custom domain to be sure. Not with `storage: 'session'` (that would put the access token back in storage). With DPoP, not with `dpop: true`: the refresh token is bound to the key, and `dpop: true` makes a new key on every page load. **Level 4: add DPoP with a persistent key.** Bind the tokens to a non-extractable key that survives reloads (`fromCryptoKeyPair(kp)` with a key pair you keep in IndexedDB; see [DPoP](https://github.com/anchring/oss/blob/main/sdk/server.md#dpop)). A stolen access token is then useless without the key, which a script can use but not copy. **Whatever the level:** keep a strict CSP (no inline scripts, no third-party script origins) and review what goes into your bundle. Tokens only limit what an XSS can take, not whether it can act as the user while the page is open. ## Popup sign-in `auth.signInWithPopup()` opens hosted login in a popup and resolves with the user once they are signed in. The page that opened it never navigates away, so its state survives: a half-filled form, an editor, a checkout. The redirect (`signIn()`) stays the default and is the better choice on phones and in in-app browsers, where a popup opens as a new tab or not at all. ```ts button.addEventListener('click', () => { // No await before this call: the popup must open inside the click, or the browser blocks it. auth.signInWithPopup({ fallback: 'redirect' }).then(render, (e) => { if (e instanceof OAuthError) return; // popup_closed, popup_timeout, access_denied, …: still signed out throw e; }); }); ``` | Option | Default | Notes | | --- | --- | --- | | `loginHint`, `prompt`, `params` | | as for `signIn` (`params` can't set the protocol parameters) | | `width` / `height` | 500 / 650 | popup size in CSS pixels, centred on the window | | `name` | `anchring-signin` | `window.open` target; a second call reuses the window (and replaces the first sign-in, which rejects `popup_closed`) | | `timeoutMs` | 5 minutes | rejects `popup_timeout` and closes the popup | | `fallback` | none | `'redirect'`: when the browser blocks the popup, run `signIn({ redirectTo, … })` instead; the promise resolves `null` while the page navigates | | `redirectTo` | the current URL | only for the redirect fallback | It rejects with a `PopupError` (a subclass of `OAuthError`, so `e instanceof OAuthError` covers it): `popup_blocked` (`window.open` returned nothing: call it straight from a click handler, or use `fallback: 'redirect'`), `popup_closed` or `popup_timeout`. The issuer's own errors come through as a plain `OAuthError` (`access_denied` when the user cancels). Tokens end up where `storage` / `refresh` say, as after a redirect: in memory by default, never in `localStorage`. **The callback page.** The popup comes back to your registered redirect URI, by default the same `/auth/callback` as the redirect flow, so your app loads in the popup. The SDK there sees from the `state` that the page is a sign-in popup, hands the code to the window that opened it and closes the popup; `ready()` in the popup resolves `null` (the popup never signs in itself). For less work in the popup, set `popupRedirectUri` (register it as a redirect URI too) to a small page on your origin that only runs your `createAuth` with the same config and calls `auth.ready()`. Whichever page it is, it must load the SDK with the automatic callback on (the default) or call `auth.handleCallback()`, and its router must not redirect away first. If the opening window is gone (the user closed that tab), the popup's `ready()` rejects with `popup_unreachable` and the popup stays open, so the page can say so. **How the code gets back, and why it is safe.** - The opening window creates the transaction (PKCE S256 verifier, state, nonce) and keeps it **in memory only**: unlike the redirect flow it isn't written to `sessionStorage`, and the verifier never leaves that window. - The popup's callback page sends only `{ state, code, iss }` (or the `error`): with `window.opener.postMessage` to its own origin (never `"*"`), and over a `BroadcastChannel` named after the issuer and the state. Both stay on your origin. No token travels, and nothing is put in a URL. - The opener accepts a message only from its own origin, only for the state of the pending popup sign-in, and only once; then it acknowledges it (so the popup can close), checks `iss`, and exchanges the code with its verifier through the same code path as the redirect flow (nonce and ID token checks included). - A code is single use, valid for 60 seconds and bound to the verifier, so a script on your origin that read it from the channel couldn't redeem it. Such a script could act as your page anyway, which is why the advice in [Keeping browser tokens safe](#keeping-browser-tokens-safe) still applies. - A callback whose `state` isn't a popup sign-in's (every redirect sign-in) never posts anything, even in a window that has an opener. **COOP.** The hosted login sends `Cross-Origin-Opener-Policy: unsafe-none`, so the popup keeps its link to your window there. An upstream identity provider behind SSO or a social login may send a stricter one, and when the popup navigates to such a page the browser cuts the link for good: `window.opener` is `null` in the popup from then on, even after it is back on your origin. The `BroadcastChannel` still delivers the code (same origin), so sign-in works. It also makes `popup.closed` read `true` in your window while the popup is still open, so the SDK only takes the popup as closed once your window has the focus again (the user came back to it). If the user clicks back into your window while the popup is still open, the sign-in rejects with `popup_closed`; clicking Sign in again reuses the popup. On your own pages, don't send `Cross-Origin-Opener-Policy: same-origin` (it cuts the link at once); `same-origin-allow-popups` or no header is fine. **React, Vue, Angular, vanilla.** Add a helper next to `ready` in the `auth.ts` from [React, Vue and Angular](#react-vue-and-angular) and call it from the sign-in button: ```ts // auth.ts /** Popup sign-in (from a click); the redirect where popups are blocked. Cancelled or closed: still signed out. */ export const signInPopup = () => auth.signInWithPopup({ fallback: 'redirect' }).catch((e) => { if (e instanceof OAuthError) return null; throw e; }); ``` ```tsx // React: Account.tsx if (!user) return ; ``` ```vue ``` ```ts // Angular: the component (import { signInPopup } from './auth') signIn() { return signInPopup(); } // Vanilla document.querySelector('#sign-in')!.addEventListener('click', () => void signInPopup()); ``` The user hook, ref or signal from the framework adapters updates by itself when the popup finishes, as after a redirect. The full vanilla example is [`examples/browser-popup.ts`](https://github.com/anchring/oss/blob/main/sdk/examples/browser-popup.ts). ## Sign in by code (another device) Like signing in on a TV: the app shows a short code and a QR code, the user opens the link on a phone or computer where they are already signed in to your project, sees "Sign in to *App* on *Chrome · macOS*?", approves, and the app is signed in. For a TV app, a kiosk, a shared screen, a device without a usable keyboard, or simply as another way in. It is the device authorization grant (RFC 8628) that CLIs use, in the browser. **Hosted first.** The hosted login page offers it with no code on your side: in the dashboard, Settings → Sign-in, turn on "Sign in on another device". The sign-in page then has a "Sign in on another device" button that shows the code and QR, waits, and continues to your app once approved, exactly as after a password. Reach for the SDK call when you want to own the page. **In your app.** Add the device code grant to the app's grant types in the dashboard (kind `spa`; the grant is bound to the `client_id` and to the session that approves, so a public client is fine and PKCE does not apply: there is no redirect and no code in a URL). Then: ```ts const s = await auth.signInWithDeviceCode(); show(s.userCode); // "WDJB-MJHT": the user compares it on the phone drawQr(s.verificationUriComplete); // the approval page with the code filled in say(`or open ${s.verificationUri}`); // "https://acme.anchr.ing/device" countDownTo(s.expiresAt); // ms since the epoch, 10 minutes const user = await s.done; // resolves once approved; the session is stored as after any sign-in ``` | Field | What it is | | --- | --- | | `userCode`, `verificationUri`, `verificationUriComplete` | what to show: the code, the page to open, and the page with the code filled in (the QR code's content) | | `expiresAt` (ms), `interval` (s) | when the code dies, and how often the SDK polls (it slows down when the issuer says so) | | `done` | a promise of the user; rejects `OAuthError` `access_denied` (denied on the other device), `expired_token` (ask for a new code), or the abort reason | | `cancel()` | stop waiting; or pass `signal: AbortSignal` in the options | One sign-in by code runs at a time: while one is pending, `signInWithDeviceCode()` returns the same object (the same code), so a component that re-renders never asks for a second code; after `done` settles, the next call gets a new one. Options: `clientId`, `scope`, `signal`. The full example is [`examples/browser-device-code.ts`](https://github.com/anchring/oss/blob/main/sdk/examples/browser-device-code.ts). **What the approving side sees, and why it is safe.** The approval page is the hosted login's `/device` page. Even when the link carries the code, it shows the code, the app's name, when the code was requested and the requesting device's browser family, names the signed-in account (with "Use another account"), and does nothing without an explicit Approve (Deny is right beside it; RFC 8628 §5.4 against links sent by someone else). Approving needs a sign-in on that device within the last 10 minutes, with the second factor for MFA users; it is audited (`device.approved`, `device.denied`). Codes are 8 letters from a 20-letter alphabet (~35 bits), live 10 minutes, and guesses are rate limited per address. The session your app gets carries the approver's factors (`amr`). --- # Protecting an API **When you need this:** any API that receives the tokens your apps get: Hono, Express or anything with a `Request`. Verification is offline against the issuer's cached keys, so there is no call to the auth service per request. On top of it: [Permissions](https://github.com/anchring/oss/blob/main/sdk/permissions.md) for who may call what, [Tokens](https://github.com/anchring/oss/blob/main/sdk/tokens.md) for machine callers, and [Sessions](https://github.com/anchring/oss/blob/main/sdk/sessions.md) for how soon a revoked session is refused. ```ts import { createAuth } from '@anchring/auth/server'; const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); app.use('/api/*', auth.middleware()); // 401 without a valid token; c.var.user afterwards ``` ## Quick start: API Register a **resource** app with an audience (e.g. `acme-api`), and give the web and CLI apps the same audience so the tokens they get carry it in `aud`. Verification is offline against cached keys: no call to the auth service per request. Full example: [Hono](https://github.com/anchring/oss/tree/main/examples/api/hono) · [Express](https://github.com/anchring/oss/tree/main/examples/api/express). ### Hono ```ts import { Hono } from 'hono'; import { createAuth } from '@anchring/auth/server'; import '@anchring/auth/hono'; // types c.var.user / c.var.resource const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' }); const app = new Hono(); app.use('/api/*', auth.middleware()); // 401 without a valid token app.get('/api/reports', (c) => c.json(listReports(c.var.user.sub))); ``` The published types of `@anchring/auth/server` do not depend on hono (it is an optional peer), so the typed `c.var.user` comes from `@anchring/auth/hono`: import it once anywhere in the app, which adds `user` and `resource` to Hono's global `ContextVariableMap`. If your app already types its own `user` variable, skip that import and use `new Hono()` (`import type { AuthEnv } from '@anchring/auth/hono'`) on the routes behind the middleware. `auth.middleware(opts)` (or `honoMiddleware(auth, opts)` from `@anchring/auth/hono`) takes `audience`, `policy` (merged over the global one), `ip` (how to read the caller IP; default the Bun socket address) and `pat`. On success `c.var.user` is an `AuthUser`: ```ts interface AuthUser { sub: string; email?: string; emailVerified?: boolean; name?: string; type: 'session' | 'apiKey' | 'impersonation'; actor?: { sub: string }; // who is really acting (impersonation) reason?: string; // impersonation reason impersonator?: { id: string; actor: { sub: string; email?: string }; reason?: string; expiresAt?: number; deny: string[] }; // support access scopes: string[]; // e.g. a PAT's scopes roles: string[]; // app roles from the token's `roles` / `role` claims org?: { id: string; slug?: string; name?: string; role?: string }; // the active org (`org` claim) orgRole?: string; // the user's role in it amr: string[]; authTime?: number; env: 'prod' | 'test'; issuer: string; claims: Record; } ``` **No email in access tokens by default.** An access token carries `sub`, `scope`, `client_id`, `tid`, `env` (plus `sid`, `auth_time`, `amr` for user sessions), so `email`, `emailVerified` and `name` are `undefined` unless you add them. Either give the web and CLI apps a claims template (dashboard **Apps → Custom claims template (JSON)**, or `claims_template` on the app in the admin API; a value that is only a placeholder keeps its type): ```json { "email": "{{user.email}}", "email_verified": "{{user.email_verified}}", "name": "{{user.name}}" } ``` or look the user up with their token at `GET /userinfo` (returns `email` / `email_verified` when the token has the `email` scope, which the SDK requests by default; `name` with `profile`). Tokens exchanged from a PAT never carry `openid` (`403 insufficient_scope`) and `client_credentials` tokens have no user (`401`), so `/userinfo` refuses both; key your data by `sub` instead. In the browser, `auth.user()` already has `email` from the ID token. Users without an email exist when a social connection allows it (`email: "optional"`, for Discord or GitHub apps that never asked for one): their tokens and `/userinfo` carry no `email` and no `email_verified` (not an empty value), `user.created` has no `email`, and `{{user.email}}` in a claims template leaves the claim out. Key them by `sub`, or by the provider id with `{{user.identities.discord.id}}` / `{{user.identities.github.id}}`. ### Express ```ts import express from 'express'; import { createAuth } from '@anchring/auth/server'; import { authMiddleware, guard, toExpress } from '@anchring/auth/express'; const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api' }); const app = express(); app.post('/hooks/auth', toExpress(auth.webhooks.handler({ /* … */ }))); // before express.json() app.use(express.json()); app.use('/api', authMiddleware(auth)); app.get('/api/me', (req, res) => res.json(req.user)); // also res.locals.user ``` - `authMiddleware(auth, { audience, policy, ip, pat })`: the caller IP defaults to `req.ip`, so set Express `trust proxy` when you use `allowIps` behind a proxy. - `guard(perms, 'report:write', loader)` or `guard(perms, 'report:write', { load, context })`: the Express form of `perms.guard`. - `toExpress(handler)`: mounts any fetch-style handler (`webhooks.handler`, `backchannel.handler`). Signed bodies need the raw bytes, so mount these before `express.json()`, or give the route `express.raw({ type: '*/*' })`. ### Any other framework `auth.authenticate(authorizationHeader, { audience, policy, ip, pat })` never throws for bad input. It returns `{ ok: true, user }` or `{ ok: false, status, body, headers }`, ready to send as the response. Pass `dpop: { proof: req.headers.get('dpop'), method, url }` to accept `Authorization: DPoP` and DPoP-bound tokens (see [DPoP](#dpop)); without it the `DPoP` scheme and bound tokens are refused. For a plain token, `await auth.verify(token, { audience })` returns the user or throws `AuthError`. ## Policies ```ts auth.policy({ maxSessionAge: '12h', requireMfaWithin: '1h', allowIps: ['10.0.0.0/8', '2001:db8::/32'] }); app.use('/api/billing/*', auth.middleware({ policy: { requireMfaWithin: '15m' } })); // stricter per route ``` | Rule | Checks | Failure | | --- | --- | --- | | `maxSessionAge` | `auth_time` is recent enough | `401 insufficient_user_authentication`, `max_age=` | | `requireMfaWithin` | a multi-factor `amr` no older than this: a second step (`mfa`, `otp`) or a passkey sign-in (`hwk`, `swk`, `fido`, `passkey`; a passkey with user verification is two factors on its own) | same, plus `acr_values="mfa"` | | `allowIps` | caller IP in one of the CIDRs (IPv4/IPv6) | `403 forbidden`, `reason: ip_not_allowed` | `maxSessionAge` and `requireMfaWithin` apply to user sessions only (`user.type === 'session'`). Personal access tokens and machine tokens (`apiKey`) and impersonation tokens carry no `auth_time` / `amr`, so these two rules are skipped for them (a step-up could never satisfy them); limit those with scopes, permission ceilings and `allowIps`, which applies to every token type. Machine tokens from the `client_credentials` grant (a confidential app calling your API as itself: `sub` is the app's `client_id`, no `sid`) are typed `apiKey`, like a PAT: `tokens.apiKey` ceilings and the token's `scopes` limit them. `user.sub` is then the client id, not a user; tell them apart with `user.claims.client_id === user.sub`, or pass your own `classify`. `allowIps` entries are checked when the policy is set (`createAuth({ policy })`, `auth.policy()`, `middleware({ policy })` / `authMiddleware(auth, { policy })`): an entry that isn't an IP or `ip/prefix` with a prefix in range for its family (`10.0.0.0/`, `10.0.0.0/33`, `::/129`, …) throws a `TypeError`, so a typo can't turn into `/0`. `ipInCidr` never matches a malformed range. Failures use the RFC 9470 step-up challenge (`WWW-Authenticate: Bearer error="insufficient_user_authentication"`), so a client can call `auth.signIn({ prompt: 'login', params: { acr_values: 'mfa' } })` and retry. Durations are seconds or `'90s'`, `'15m'`, `'12h'`, `'7d'`. `auth.verify(token, { audience, policy, ip })` checks a policy too and throws `PolicyError`. ## DPoP DPoP (RFC 9449) binds tokens to a key held by the client, so a stolen token is useless elsewhere. ```ts const auth = createAuth({ project: 'acme', env: 'prod', clientId: 'prod_…', dpop: true }); await auth.fetch('https://api.acme.example/reports'); // Authorization: DPoP … + DPoP proof ``` `dpop: true` generates a non-extractable ES256 key with WebCrypto (kept for the page's lifetime). Pass a `DpopSigner` (`fromCryptoKeyPair(kp)`, or your own, e.g. backed by the OS keychain in Tauri or a CLI) to persist it. **DPoP with persisted storage needs a persistent key.** Tokens are bound to the key that requested them, and a persisted session records that key's thumbprint. With `dpop: true` every page load / process run has a new key, so a session kept in `storage: 'session'`, the CLI's token file or your own `TokenStore` can't be used after a reload: the SDK discards it on load (the user is signed out, `token()` throws `SignInRequiredError`) instead of sending tokens the API and token endpoint would refuse. The same happens whenever the stored thumbprint doesn't match the current signer. To keep sessions across reloads, keep the key too, e.g. a non-extractable key pair in IndexedDB (browsers can store `CryptoKey`s without exporting them): ```ts import { createAuth, fromCryptoKeyPair } from '@anchring/auth/browser'; async function dpopKey(): Promise { const db = await new Promise((ok, fail) => { const r = indexedDB.open('anchring-dpop', 1); r.onupgradeneeded = () => r.result.createObjectStore('keys'); r.onsuccess = () => ok(r.result); r.onerror = () => fail(r.error); }); const get = () => new Promise((ok) => { const q = db.transaction('keys').objectStore('keys').get('dpop'); q.onsuccess = () => ok(q.result); q.onerror = () => ok(undefined); }); const existing = await get(); if (existing) return existing; const kp = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']); db.transaction('keys', 'readwrite').objectStore('keys').put(kp, 'dpop'); return kp; } const auth = createAuth({ project: 'acme', env: 'prod', clientId: 'prod_…', storage: 'session', dpop: async () => fromCryptoKeyPair(await dpopKey()), // called once, lazily }); ``` In a CLI, back the signer with the OS keychain (or a key file with 0600 permissions) and implement `DpopSigner` (`alg`, `publicJwk`, `sign`). The SDK handles `DPoP-Nonce` retries: it keeps the latest nonce per origin and retries once on `400 use_dpop_nonce` from the token endpoint and on `401 use_dpop_nonce` from `auth.fetch` (string or form bodies only). The Anchr.ing server requires nonces on `/token` and `/userinfo` when started with `ANCHRING_DPOP_NONCE=1` (off by default). `auth.authHeaders(method, url)` returns the headers if you use your own HTTP client. Server side, the middleware (Hono and Express) accepts `Authorization: DPoP ` with a `DPoP` proof header. When a token carries `cnf.jkt` (it was issued to a DPoP client) the proof is required and checked: `typ dpop+jwt`, ES256/EdDSA signature, `htm` and `htu` match the request (query ignored), `iat` within 60 s, `ath` is the hash of the token, the proof key's thumbprint equals `cnf.jkt`, and the `jti` has not been seen. A bound token sent as `Bearer` is rejected (`dpop_required`), so a stolen token cannot be replayed without the key. An unbound token sent with the `DPoP` scheme is rejected too. ```ts const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', dpopVerification: { replayStore: { remember: (key, expiresAtMs) => redisSetNx(key, expiresAtMs) }, // share across instances maxSkewSeconds: 60, nonce: { current: () => currentNonce(), check: (n) => isRecentNonce(n) }, // optional (RFC 9449 §9) }, }); ``` - The default replay store is in memory, per process. Behind several instances, pass a shared one (`remember` returns `false` for a key it already holds; it also gets the proof key's `jkt` as a third argument, and may return `'quota'` to refuse a key that has too many live proofs). - **Throughput limits of the in-memory store.** It remembers every proof until it expires (`iat` + 2 × `maxSkewSeconds`, 120 s by default) and never forgets a live one. Per DPoP key (`jkt`) it holds at most `maxPerKey` live proofs (default 10 000, about 80 requests/s sustained per key); a key over its cap gets `401 invalid_dpop_proof` (`reason: 'too many live proofs for this key'`) until some of its proofs expire, and other keys are unaffected. In total it holds at most `maxEntries` (default 100 000); when that many live proofs come from many keys, every proof is refused (fails closed). A busy machine client that needs more per key: `dpopVerification: { replayStore: memoryDpopReplayStore({ maxPerKey: 50_000, maxEntries: 500_000 }) }` (from `@anchring/auth/server`), or lower `maxSkewSeconds`. - `htu` is the URL your server sees (`c.req.url` / `req.protocol://Host + originalUrl`). Behind a proxy that rewrites scheme or host, pass `url: (c) => …` to `middleware()` / `authMiddleware()`. - With `nonce`, a proof without a valid nonce gets `401 use_dpop_nonce` and a `DPoP-Nonce` header; the SDK client retries with it. - `auth.verify(token, { dpop: { proof, method, url, scheme: 'DPoP' } })` does the same checks outside a middleware. Without `dpop`, `verify` rejects bound tokens. ## Token types `auth.verify` and the middleware only accept access tokens from the issuer: the JOSE header must be `typ: at+jwt` (or `application/at+jwt`, RFC 9068), and tokens carrying `nonce`, `at_hash` or `events` are refused. ID tokens (`typ JWT`) and back-channel logout tokens (`logout+jwt`) share the issuer, audience and `sub`, and are rejected as `invalid_token`. Impersonation tokens (issuer `/impersonation`) keep their own rules. **Claim-schema version.** Every token carries `cv` (an integer; a token without one is version 1), the version of the claim contract it follows: what `sub`, `tid` / `env`, `org`, `roles`, `billing` and `cv` itself look like. This SDK reads version `SUPPORTED_CLAIM_VERSION` (1) and refuses a newer token with `AuthError` `unsupported_claims_version` instead of misreading it; `user.cv` is the token's version. The issuer raises `cv` only with a release note and an SDK that reads both, so the fix is always "update `@anchring/auth`". --- # Websites with a server, and backend services **When you need this:** your pages are rendered on a server that holds a client secret (a confidential app): the server runs the sign-in and keeps the session, and the browser never sees a token. The second half is for jobs and services that call your API as themselves, with no user. ```ts import { createAuth } from '@anchring/auth/server'; const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', clientId: 'test_…' }); const web = auth.webApp({ redirectUri: 'https://app.acme.example/auth/callback' }); const { url, transaction } = await web.signIn({ returnTo: '/reports' }); // redirect to url; transaction in a cookie const { session, returnTo } = await web.callback(request.url, cookies.auth_tx); // store session server-side ``` ## Quick start: website with a server Register a **confidential** app (kind `web`) with the callback URL `https://app.acme.example/auth/callback`, the sign-out return URL `https://app.acme.example/` and audience `acme-api`. Put its client secret in `ANCHRING_CLIENT_SECRET` on the server (shown once, when the app is created or the secret rotated). `auth.webApp()` runs the authorization code flow (PKCE S256, state, nonce) and exchanges the code with the secret (`client_secret_basic`). It keeps no state itself: you hold the sign-in transaction in a short-lived cookie and the session in your own session store, so it works with any framework and any number of instances. ```ts import { createAuth } from '@anchring/auth/server'; const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', clientId: 'test_…' }); const web = auth.webApp({ redirectUri: 'https://app.acme.example/auth/callback', postLogoutRedirectUri: 'https://app.acme.example/' }); // GET /login const { url, transaction } = await web.signIn({ returnTo: '/reports' }); // set-cookie auth_tx=; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600, then redirect to url // GET /auth/callback const { session, returnTo } = await web.callback(request.url, cookies.auth_tx); // OAuthError on a bad or cancelled sign-in // store the session server-side (it holds the tokens), set an opaque HttpOnly session cookie, redirect to returnTo // On each request const current = await web.fresh(session); // refreshed shortly before the access token expires; store it await fetch('https://api.acme.example/reports', { headers: { authorization: `Bearer ${current.accessToken}` } }); // POST /logout const logoutUrl = await web.signOut(current); // revokes the refresh token; redirect to the hosted logout ``` - `session` is `{ user, accessToken, refreshToken, idToken, expiresAt, scope }`; `user` comes from the verified ID token. Never send it to the browser. - `returnTo` only accepts a path on your site (`/reports`); anything else becomes `/`. - `fresh` / `refresh` throw `OAuthError` (`invalid_grant`) when the session ended (signed out elsewhere, revoked, a disabled user): drop your session and sign in again. - Parallel requests of one user may all find the session stale: `fresh` / `refresh` with the same session share one token request, and a request that still holds the old session shortly after the rotation (30 s) gets the new one instead of presenting a spent refresh token. Store the result whichever request returns first. - `session.claims` are the access token's claims, **verified** (signature, issuer, expiry, `client_id`; the audience too with the `audience` option): read your claims template's custom claims (`github_id`, `roles`, `plan`…) from it without verifying the token again. Or turn on the app's "Also put custom claims in the ID token" (`id_token_claims`), and `session.user.claims` has them too. - `session.user` has `roles`, `org`, `orgRole` and `orgs` from the access token ([Roles and organisations in the token](https://github.com/anchring/oss/blob/main/sdk/permissions.md#roles-and-organisations-in-the-token)), updated on every refresh. - `await web.switchOrg(session, orgId)` makes another org the active one (store the result); `web.orgs(session)` is the [Organisations](https://github.com/anchring/oss/blob/main/sdk/organisations.md#organisations) API as that user. - Options: `clientId` (default `createAuth({ clientId })` or `ANCHRING_CLIENT_ID`), `clientSecret` (default `ANCHRING_CLIENT_SECRET`), `redirectUri` (required), `postLogoutRedirectUri`, `scope` (default `openid profile email offline_access`), `refreshLeewaySeconds` (60). Full example: [Hono](https://github.com/anchring/oss/tree/main/examples/web-server/hono) · [Express](https://github.com/anchring/oss/tree/main/examples/web-server/express). ## Quick start: backend service A job or service that calls your API as itself, with no user. Register a **confidential** app (kind `service`) with audience `acme-api`, and put its client secret in `ANCHRING_CLIENT_SECRET`. ```ts import { createAuth } from '@anchring/auth/server'; const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', clientId: 'test_…' }); const service = auth.clientCredentials({ scope: 'reports:read' }); await service.fetch('https://api.acme.example/reports'); // Authorization: Bearer await service.token(); // the token, if you send it yourself ``` The token is cached until a minute before it expires (`refreshLeewaySeconds`), concurrent calls share one token request, and `fetch` renews it once when the API answers `401` (parallel requests that got a `401` share that renewal). A wrong secret is an `OAuthError` (`invalid_client`). Your API sees `type: 'apiKey'` with `sub` = the client id ([Policies](https://github.com/anchring/oss/blob/main/sdk/server.md#policies)). Full example: [Node](https://github.com/anchring/oss/tree/main/examples/service/node). --- # Command-line tools **When you need this:** a CLI your users sign in to once (the device flow: a code typed on a page in their browser) and that keeps the session in a file of its own. For CI and scripts without a person, use a [personal access token or an org token](https://github.com/anchring/oss/blob/main/sdk/tokens.md) instead. ## Quick start: CLI Register a **public, device grant** app (kind `cli`) with audience `acme-api`. ```ts import { createAuth } from '@anchring/auth/cli'; const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…' }); // the CLI app's client id await auth.device.signIn({ open: true }); // prints: Open acme.test.anchr.ing/device and enter ABCD-1234 (the issuer's /device: https://../device) await auth.fetch('https://api.acme.example/reports'); await auth.signOut(); ``` Tokens are stored in a file of this app's own, `~/.config/anchring/tokens//.json` (respects `XDG_CONFIG_HOME`; file 0600, directory 0700, atomic writes), and refreshed automatically; processes sharing the file refresh one at a time under a lock file ([sharing a session](https://github.com/anchring/oss/blob/main/sdk/browser.md#quick-start-web)). Two Anchr.ing CLIs on one machine never share a file or a lock, and signing out of one leaves the other alone. Choose the file yourself with `createAuth({ …, tokenFile: '~/.config/acme/auth.json' })` (expand `~` yourself), or a store with `appTokenStore({ issuer, clientId, path })`. Earlier versions kept every CLI's tokens in one `~/.config/anchring/tokens.json`: on first use, this app's entries move from there into its own file (under that file's lock; other apps' entries stay, and the file is removed once empty), so users stay signed in across the upgrade. (Only this entry persists by default; the root and `/browser` entries keep tokens in memory, so a CLI built on them signs in on every run.) Pass `storage` to use the OS keychain instead. `device.signIn` options: `onCode` (show the code yourself), `open` (launch the browser; the built-in opener only opens `https:` URLs, or `http:` on loopback, and never goes through a shell), `scope`, `signal` (cancel). When there is no session, `token()` / `fetch()` throw `SignInRequiredError`: tell the user to run your `login` command. Full example: [Node](https://github.com/anchring/oss/tree/main/examples/cli/node). --- # Desktop and mobile apps **When you need this:** an app installed on the user's device (Tauri, Electron, a Bun or Node desktop app, iOS, Android): a public client that signs in through the system browser with PKCE and keeps its tokens in the OS keychain. ## Quick start: desktop and mobile Native apps are public clients (kind `native`, no secret) that sign in in the **system browser** with PKCE (RFC 8252), never in an embedded web view. Register `http://127.0.0.1/callback` (for this kind any loopback port matches; `localhost` is refused) and keep the kind's default grants: authorization code, the device code (the fallback when no browser can open) and refresh token. `@anchring/auth/native` does the whole round trip: it starts a one-shot listener on `127.0.0.1` on a port the OS picks, opens the system browser (usually already signed in to the provider), takes the redirect (only with this sign-in's `state`), exchanges the code with PKCE, checks the ID token and answers the browser tab. With no browser (no display, an opener that fails, no loopback socket) it signs in with the device code instead. ```ts import { createNativeAuth } from '@anchring/auth/native'; const auth = createNativeAuth({ project: 'acme', env: 'prod', clientId: 'prod_…' }); // tokens: ~/.config/anchring/tokens//.json (0600) const { user, method } = await auth.signIn({ onDeviceCode: (c) => console.log(`Open ${c.verificationUri} and enter ${c.userCode}`), // only without a browser }); await auth.fetch('https://api.acme.example/reports'); ``` | Option | Default | | | --- | --- | --- | | `callbackPath` | `/callback` | the registered path | | `listener` | Node's `http` on 127.0.0.1 | `commandLoopbackListener(invoke)` for one in native code (Tauri) | | `openBrowser(url)` | the platform opener (`open`, `xdg-open`, `rundll32`; https and loopback only) | throw or return `false` when it can't: device code | | `canOpenBrowser()` | macOS and Windows; Linux with `DISPLAY` / `WAYLAND_DISPLAY` | | | `storage`, `dpop`, `scope` | the token file, no DPoP, `openid profile email offline_access` | as everywhere else | `signIn({ fallback: 'none' })` throws `NativeSignInError` (`no_browser`, `listener_failed`, `open_failed`) instead of using the device code; `timeoutMs` (default 5 minutes) and `signal` end the wait. An error from the sign-in itself (`OAuthError`, e.g. `access_denied`) never falls back. ### Tauri: keychain session and DPoP key In a Tauri web view there is no Node and nothing secret should live in JavaScript. Keep the loopback socket, the session and the DPoP key in Rust and the OS keychain, and hand the SDK three bridges over `invoke`: ```ts import { commandDpopSigner, commandLoopbackListener, commandTokenStore, createNativeAuth } from '@anchring/auth/native'; const { invoke } = window.__TAURI__.core; // app.withGlobalTauri, or import { invoke } from '@tauri-apps/api/core' const auth = createNativeAuth({ project: 'acme', env: 'prod', clientId: 'prod_…', listener: commandLoopbackListener(invoke), // loopback_start / loopback_wait / loopback_stop openBrowser: (url) => window.__TAURI__.opener.openUrl(url), storage: commandTokenStore(invoke), // token_get / token_set / token_delete dpop: () => commandDpopSigner(invoke), // dpop_public_jwk / dpop_sign }); ``` The Rust side (the `keyring` and `p256` crates) creates the P-256 key once, keeps it in the keychain, and only ever gives the web view the public JWK and signatures: ```rust #[tauri::command] fn dpop_public_jwk() -> Result { let point = dpop_key()?.verifying_key().to_encoded_point(false); // dpop_key(): load or create it in the keychain Ok(json!({ "kty": "EC", "crv": "P-256", "x": B64.encode(point.x().unwrap()), "y": B64.encode(point.y().unwrap()) })) } #[tauri::command] fn dpop_sign(data: String) -> Result { let signature: Signature = dpop_key()?.sign(&B64.decode(data).map_err(|e| e.to_string())?); // ES256, r||s Ok(B64.encode(signature.to_bytes())) } ``` Tokens are then bound to that key (`Authorization: DPoP …` plus a proof on every call), so a token copied out of the app is useless, and a DPoP-bound session lasts up to a year (90 days unused) where a plain one has the project's refresh lifetime. The whole app, with the loopback listener and the keychain store in Rust: [examples/native/tauri](https://github.com/anchring/oss/tree/main/examples/native/tauri). ### Mobile, and doing it by hand On iOS and Android, open the same authorize request with `ASWebAuthenticationSession` / Custom Tabs (or AppAuth) with a universal link, App Link or custom scheme as the redirect (custom schemes are allowed for this kind), and keep tokens in the Keychain / Keystore. Without the helper, the steps are `auth.signIn()` with your own `redirectUri` and `navigate`, then `auth.handleCallback(url)` with the URL the app was opened with: [examples/native/node](https://github.com/anchring/oss/tree/main/examples/native/node). ### Sign in by code (TVs, kiosks, no keyboard) `auth.device.signIn()` prints the code and waits (the CLI way). To draw the code and a QR in your own screen, use `auth.signInWithDeviceCode()`: it returns what to show and a `done` promise, the same polling underneath. The user approves on a phone or computer where they are already signed in. See [Sign in by code (another device)](https://github.com/anchring/oss/blob/main/sdk/browser.md#sign-in-by-code-another-device) in the browser guide; the native client has the same call. --- # Your own MCP server **When you need this:** you serve an MCP server whose tools act as the signed-in user, and AI clients (Claude, Claude Code, Cursor, VS Code) should sign in through your project. The server is an API with a discovery document on top. The other direction, `@anchring/mcp`, which gives *your coding assistant* these docs and a setup planner: [Coding assistants](#coding-assistants) below. ## Quick start: MCP server Your own MCP server is an API whose callers are AI clients. Register it as a **resource** app whose API identifier is the endpoint's public URL (e.g. `https://mcp.acme.example/mcp`), turn on **Settings → Let MCP clients register themselves**, and serve the MCP authorization flow's server side: ```ts const resource = 'https://mcp.acme.example/mcp'; const auth = createAuth({ project: 'acme', env: 'prod', audience: resource, pat: { audience: resource } }); const mcp = auth.mcp.protectedResource({ resource, scopes: ['notes:read', 'notes:write'], resourceName: 'Notes' }); app.get(mcp.metadataPath, mcp.metadataHandler()); // RFC 9728: /.well-known/oauth-protected-resource/mcp app.all('/mcp', mcp.middleware(), (c) => handleMcp(c.req.raw, c.var.user)); // tools act as the user, within its scopes ``` Clients (Claude, Claude Code, Cursor, VS Code) add the server by URL. The call without a token answers `401` with `WWW-Authenticate: Bearer resource_metadata="…"`; the client reads your metadata, then the issuer's OAuth server metadata (`/.well-known/oauth-authorization-server`, RFC 8414), registers itself (dynamic client registration, RFC 7591, at `/register`, or a client ID metadata document: an https `client_id` whose JSON describes it) and signs the user in with `resource=` (RFC 8707), so the access token's `aud` is your server. Notes: - Self-registered clients are public (PKCE, no secret) and always show the sign-in page: an existing sign-in is never reused silently for them. - `resource` must be a resource app's API identifier in the project; when that app lists scopes, clients may only ask for those (plus `openid profile email offline_access`). - Registration is per network rate limited, and production projects hold as many self-registered clients as the plan allows (`dynamicClients`). - Clients that send a header keep working: a personal access token (`claude mcp add --transport http notes --header "Authorization: Bearer $TOKEN"`) or an [org token](https://github.com/anchring/oss/blob/main/sdk/tokens.md#org-tokens). Full template (metadata, challenge, DPoP, scoped tools, tests): [MCP server](https://github.com/anchring/oss/tree/main/examples/mcp-server). ## Coding assistants Using an AI coding assistant? The [`@anchring/mcp`](https://github.com/anchring/oss/tree/main/mcp) MCP server gives it this reference, the guides and an `integration_plan` tool that writes the setup for your framework (`claude mcp add anchring -- npx -y @anchring/mcp`; the rule files for each agent: https://anchr.ing/guides/ai). Self-hosted or local install: set `ANCHRING_DOMAIN` in its environment (`claude mcp add anchring -e ANCHRING_DOMAIN=auth.example.com -- npx -y @anchring/mcp`). --- # Permissions **When you need this:** your API has routes not every user may call. The role comes from the token (a claims template puts the user's organisation and role in it), `defineAccess` turns role names into typed permission strings, and one guard per route checks them offline; the browser uses the same definition to shape the UI. ```ts export const access = defineAccess({ roles: { member: ['report:read'], admin: ['report:*', 'members:manage'] } }); const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api', access }); app.get('/api/reports', auth.require('report:read'), handler); // 403 forbidden, reason missing_permission ``` ## Permissions The default: the role comes from the access token, and one guard checks it offline. **1. Put the role in the token.** Give every app that calls your API a claims template (dashboard **Apps → Custom claims template (JSON)**): ```json { "org": "{{org}}" } ``` `{{org}}` is the user's organisation on Anchr.ing: `{ id, slug, name, role }`, where `role` is `owner`, `admin` or `member` (with several orgs, the one they last signed in to with SSO). The flat form `{ "org_id": "{{org.id}}", "org_role": "{{org.role}}" }` works the same. The SDK reads either as `user.org` and `user.orgRole` ([Roles and organisations in the token](#roles-and-organisations-in-the-token)). Claims are rendered on every token issue and refresh. Plans and billing state will resolve from the same token, so the same guard will check role and plan. **2. Declare the roles once and guard the routes.** Put the definition in a file the API and the browser app both import; permission strings become a literal union, so a typo (`'members:mange'`) is a compile error in `auth.require`, guards, `middleware({ require })` and the browser's `auth.can`. `access.ts` below is one file imported by both packages when they share a workspace (a `packages/access` or a root `shared/` folder); when the API and the web app are separate repositories, keep a copy in each and change them together. ```ts // access.ts: shared by server and browser (no server-only imports here) import { defineAccess, type PermissionOf } from '@anchring/auth/browser'; // also exported by /server and the root export const access = defineAccess({ roles: { member: ['report:read'], admin: ['report:*', 'members:manage'], owner: ['*'], }, tokens: { apiKey: { max: ['report:read'] } }, // PATs and machine tokens never exceed this }); export type Permission = PermissionOf; // 'report:read' | `report:${string}` | 'members:manage' | … ``` ```ts // api.ts const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api', access }); app.use('/api/*', auth.middleware()); app.get('/api/reports', auth.require('report:read'), (c) => c.json(listReports(c.var.user.org?.id))); app.post('/api/members', auth.require('members:manage'), (c) => c.json(addMember(c.var.user.org?.id))); app.use('/api/admin/*', auth.middleware({ require: 'members:manage' })); // 403 forbidden, reason missing_permission ``` ```ts // In the browser: the same definition shapes the UI (your API still enforces it) const auth = createAuth({ project: 'acme', env: 'test', clientId: 'test_…', access }); if (auth.can('members:manage')) showMembers(); // the signed-in user's token roles; false when signed out ``` The whole file: [`examples/roles-from-claims.ts`](https://github.com/anchring/oss/blob/main/sdk/examples/roles-from-claims.ts). Roles kept in your own database instead (teams, invites, per-project roles): `permissionContext: async (user) => ({ role: await roleOf(user.sub) })`, same guards. A role change then applies on the next request instead of the next refresh ([stale permissions](https://github.com/anchring/oss/blob/main/sdk/sessions.md#stale-permissions)). ### Guards: load the resource, hide it, check the org A guard can load the resource it checks; the handler gets it typed, loaded once: ```ts const canEdit = auth.require('report:write', { load: (c) => db.report(c.req.param('id')) }); app.put('/api/reports/:id', canEdit, (c) => { const report = canEdit.resource(c); // Report, not unknown (also c.var.resource) return c.json(update(report)); }); // An extra rule for one route, and hiding what the caller may not see (404 instead of 403). const viewProject = auth.require('report:read', { load: (c) => db.project(c.req.param('id')), allow: (user, project) => project.orgId === user.org?.id, denyStatus: 404, }); // Org admins only, for the org in the path. app.get('/api/orgs/:org/settings', auth.guard({ org: (c) => c.req.param('org'), orgRole: ['owner', 'admin'] }), handler); auth.can(c.var.user, 'members:manage'); // sync, from the token (+ optional resource, context) await auth.authenticate(header, { require: ['report:read'] }); // any framework ``` | Option | | | --- | --- | | `load(c)` | loads the resource: nothing (`null` / `undefined`) → `404 not_found`; the handler reads it with `guard.resource(c)` (Express: `g.resource(req)`), typed, or `c.var.resource` / `req.resource` | | `allow(user, resource, ctx)` | an extra rule for this route, after the permission | | `context(user, c)` | context for this route (plan, membership…), merged over the token's | | `org` | the token's active org must be this id (or `(c) => c.req.param('org')`) | | `orgRole` | the user's role in that org must be one of these | | `denyStatus` | `403` (default) or `404`, so a caller can't tell a hidden resource exists; for every guard: `defineAccess({ denyStatus: 404, … })` | `auth.require(permission, opts)` and `auth.guard(opts)` use `createAuth({ access })`; `perms.guard(…)` does the same for a definition of its own (`auth.permissions(config)`, the same object as `defineAccess`), and `guard(perms, …)` from `@anchring/auth/express` is the Express form. Guards answer 401 without a user, 404 when the loader finds nothing, and 403 (or `denyStatus`) when denied. ### Everything a definition can hold Roles, resource rules, attribute rules and token ceilings, evaluated locally (no network call): ```ts const access = defineAccess({ roles: { viewer: ['report:read'], member: ['report:read', 'report:write', 'check:run'], admin: ['report:*', 'check:*', 'billing:manage', 'members:manage'], }, resources: { report: { 'report:write': ({ user, resource }) => resource.ownerId === user.sub } }, attributes: { 'billing:manage': ({ ctx }) => ctx.plan === 'team' }, tokens: { apiKey: { max: ['check:run', 'report:read'] }, // CI keys: scoped, never admin impersonation: { deny: ['billing:manage', 'members:manage'] }, // support can't change money or people }, destructive: ['report:delete'], // impersonation tokens are denied these (unless impersonation.allowDestructive) }); const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api', access, permissionContext: async (user) => ({ plan: await planOf(user.sub) }), // server-only context, merged over the token's }); const can = access.for(user, { plan: 'team' }); // ctx merged over the token's roles and org can('report:read'); // role can('report:write', report); // role + resource rule can('billing:manage'); // role + attribute rule + token ceiling await access.explain(user, 'billing:manage'); // { allowed, steps: [...] } for debugging and audit ``` Permission strings are `resource:action`; `resource:*` is a wildcard for every `resource:…` permission and `*` for everything (a `*` that doesn't follow a `:` is literal, so `report*` doesn't match `reports:read`). A token's `type` comes from the token itself (session, API key, or impersonation with `act`), so a CI key or a support agent can never exceed its ceiling, even when the user is an admin. API keys are further limited to their `scopes`. ### Roles and organisations in the token | Claim | On `AuthUser` / `SessionUser` | Notes | | --- | --- | --- | | `org`: `{ id, slug, name, role, permissions? }` (`"org": "{{org}}"`; or flat `org_id`, `org_role`, `org_slug`, `org_name`) | `user.org`, `user.orgRole` | the **active** org this token speaks for: the one the user switched to ([Organisations](https://github.com/anchring/oss/blob/main/sdk/organisations.md#organisations)), else their only membership, else the org they last signed in to with SSO. `permissions`: a custom org role's | | `orgs`: `[{ id, role }]` (`"orgs": "{{orgs}}"`, at most 20) | `user.orgs` | every org of the user, for an org switcher | | `roles` (array or space-separated) and `role` | `user.roles` | app roles your claims template adds | Without a context, a check uses `role` = `user.roles` plus `user.orgRole`, `org` = `user.org`, and `permissions` = the custom org role's `user.org.permissions` (granted like a role's list). A context you pass, from `permissionContext`, a definition's or a guard's `context`, or `access.for(user, ctx)`, is merged over it (a `role` there replaces the token's). In the browser, `auth.user()` has the same `roles`, `org`, `orgRole` and `orgs`, read from the access token and updated on every refresh, and `auth.claims()` returns its claims. Other template values: `{{user.metadata.}}` reads the user's app metadata (set by the admin API, import or SCIM, never by the user): strings up to 256 characters, numbers, booleans and arrays of those, at most 2 KB in all. The template can't set `iss`, `sub`, `aud`, `exp`, `iat`, `nbf`, `jti`, `nonce`, `at_hash`, `azp`, `acr`, `typ`, `events`, `sid`, `cnf`, `act`, `kind`, `pat_id`, `org_token_id` and friends (refused at save), and `email_verified` only as `"{{user.email_verified}}"` (the real flag). ### Plans and entitlements in the token With billing on, `"billing": "{{billing}}"` in the claims template puts the subscription into the token: the active org's when the org is a paying customer, else the user's own. One guard then checks role and billing, offline. ```json { "billing": { "plan": "pro", "status": "active", "entitlements": ["export"], "country": "DK" } } ``` ```ts app.get('/api/reports/export', auth.require('report:read', { entitlement: 'export' }), handler); app.get('/api/sso', auth.require('settings:write', { plan: ['team', 'scale'] }), handler); ``` - `user.billing` (server and browser): `{ plan, status, entitlements, country? }`. Without an entitled subscription `plan` is your free plan (or `null`). The token holds at most **32** entitlements. - `status`: `active`, `trialing`, `past_due` (a payment failed and is being retried; the plan still applies, so show a banner), `paused`, `canceled` or `none`. - A guard's `plan` (one of) and `entitlement` (all of) answer **402** `{ error: 'plan_required' }`, so the app can offer the upgrade. Checks also get `plan`, `entitlements` and `billingStatus` in their context, for attribute rules: `attributes: { 'report:export': ({ ctx }) => ctx.entitlements?.includes('export') === true }`. - Single values work too: `"plan": "{{billing.plan}}"`, `"features": "{{billing.entitlements}}"`, `"seats": "{{billing.limits.seats}}"` (a key of your own may read any part of the context). - A plan change shows at the next token refresh. **Limits and the period end** are not in the token (it carries `cv` 1: plan, status and the first 32 entitlements). `auth.entitlements()` reads them from the issuer's `GET /me/entitlements` as the signed-in user, for the subject the token speaks for (its `org`, else the user), and caches the answer like a browser would: 60 s without a request, then revalidated with `If-None-Match` (a 304 keeps it). On a web app's server: `web.entitlements(session)`. ```ts const { entitlements, limits, period_end } = await auth.entitlements(); if (members.length >= (limits.seats ?? Infinity)) showUpgrade(); await auth.entitlements({ fresh: true }); // right after a checkout returned ``` `user.billing.limits` and `user.billing.periodEnd` are still typed (deprecated) and filled from tokens issued before this change; new tokens give `{}` / `undefined`. Move reads to `auth.entitlements()` within this release. **Reserved claims.** `billing`, `flags`, `chat`, `org`, `orgs`, `cv` and `anchring.*` are the platform's: a template may ask for the platform's own value (`"billing": "{{billing}}"`, `"org": "{{org}}"`, `"orgs": "{{orgs}}"`) and nothing else under those names (the dashboard and the admin API refuse the save; a template saved earlier has the key left out of the token, audited as `claims.template.skipped`). `roles` stays yours. ### Admin vs owner A check runs in this order, and the first step that fails denies: 1. **Roles** add up; there is no hierarchy. A user with `role: ['admin', 'staff']` gets both lists, and `owner` gets nothing from `admin` unless you list it (or give it `*`). 2. **Token ceilings** beat every role: an owner's PAT stays within `tokens.apiKey` and its scopes, a support agent impersonating an owner within `tokens.impersonation`. 3. **Resource and attribute rules** apply to every role, owners included. To let owners edit any report, say so in the rule: `({ user, resource, ctx }) => ctx.role === 'owner' || resource.ownerId === user.sub`. Keep your own operators (your staff, across all customers) apart from customer roles: add them as a separate role from your own allowlist, e.g. `permissionContext: (user) => ({ role: [...tokenRoles(user), ...(staff.has(user.sub) ? ['staff'] : [])] })`. On Anchr.ing's side, org roles only matter for sign-in: an org's `owner` can be marked break-glass (keeps password and passkey sign-in when the org enforces SSO), SCIM group mappings assign `admin` or `member` and never change an `owner`, and SCIM can't remove an org's last owner. In the dashboard, a project's owner has both environments; a member has test, and production only when the owner allows it. --- # Organisations **When you need this:** your users work in teams, companies or workspaces: they create an organisation, invite colleagues, give them roles and switch between the organisations they belong to, from your app. Each token speaks for one organisation, so your API knows which one a request is about without a lookup. ## Organisations Users can belong to several organisations, and each token speaks for one: the **active org** (`user.org`). Admins manage orgs in the dashboard (enterprise SSO and SCIM included); with **Settings → Let users create organisations** (`self_service_orgs`), users do it from your app too, through the project's `/orgs` API with their own access token: ```ts // browser / CLI (auth.orgs); on a web app's server: web.orgs(session) const org = await auth.orgs.create({ name: 'Acme' }); // the user is its owner await auth.switchOrg(org.id); // new tokens: user.org is Acme const { url } = await auth.orgs.invite(org.id, { email: 'bob@acme.example', role: 'admin' }); // mailed; or omit email for a link // Bob opens the link: the hosted page signs him in, he joins, and lands in your app with ?org_joined=: const joined = orgJoinedFromUrl(); if (joined) await auth.switchOrg(joined); await auth.orgs.members(org.id); await auth.orgs.setRole(org.id, bobId, 'billing'); await auth.orgs.leave(org.id); ``` - **Switching** is a refresh with the extension parameter `org` (an org id or slug): the refresh token family speaks for that org from then on, and the user's other sessions are unaffected. At sign-in, `signIn({ org })` sends `org` to `/authorize`. A user who is not a member gets `invalid_request` and keeps their session. A user removed from the active org gets their default org on the next refresh. Put `"org": "{{org}}"` (and `"orgs": "{{orgs}}"`) in the app's claims template. - **Who may do what** is the org's own roles: owners and admins invite, change roles, remove members and manage [org tokens](https://github.com/anchring/oss/blob/main/sdk/tokens.md#org-tokens); admins never touch owners or make anyone an owner; the last owner stays; everyone may leave; an owner deletes an org a user made (never one the dashboard made, or one with SSO). Members a directory provisions (SCIM) stay the directory's. Changes send the `org.member_added` / `_updated` / `_removed` webhooks (`source`: `app`, `invite`, `leave`). - **Invites**: an email invite is single use and only that verified address accepts it; a link (`maxUses`, default 1) works for anyone signed in, including users without an email address. Invites expire (7 days by default, at most 30). The link (`url`, mailed for email invites) is the **hosted accept page** on your sign-in domain (`…/login/org-invite?token=…`): it shows "Join Acme as admin?" with your app's name, signs the invitee in first when nobody is (an email invite insists on the invited, verified address), joins, and sends them on to your app with `?org_joined=`: the first registered redirect origin's `/`, or a native app's redirect URI (deep link), or a "You're in, open " page when the app has neither. On that return, `orgJoinedFromUrl()` gives the org and `switchOrg` makes it active. To own the page instead, pass `redirectUri` (a page on one of the app's registered origins): the link then opens it with `?org_invite=`, and your page calls `lookupInvite` for a "Join Acme?" screen and `acceptInvite(orgInviteFromUrl()!)` as the signed-in user. Both pages make the same two calls; `lookupInvite` works before signing in too, and its `app` says which app the invite came from and where it continues. - **Custom org roles** (dashboard: Organisations → Roles, or `PUT /api/admin/org-roles/{name}`): a role name and the permission strings it grants, the same strings as your `defineAccess` roles. A member holding one carries them in `user.org.permissions`, which checks grant like a role's list, so your code needs no entry for the role. They never manage the org (that stays owner and admin). - Errors are `OrgsError` with the API's code: `forbidden`, `not_found`, `conflict`, `self_service_orgs_off`, `managed_by_admin`, `managed_by_directory`, `invite_email_mismatch`, `limit_reached` (20 orgs created, 100 held per user, 200 pending invites per org), `rate_limited`. --- # Billing **When you need this:** you charge your users or their organisations for plans: checkout, upgrades and downgrades, cancellation, invoices, EU VAT, on your own payment account. The plan and payment status land in the token, so the guard that checks a role checks the plan too ([Plans and entitlements in the token](https://github.com/anchring/oss/blob/main/sdk/permissions.md#plans-and-entitlements-in-the-token)). ## Billing Bill your own users and orgs on your own payment account (dashboard: **Billing → Settings**, connect your Adyen account; you are the merchant, Anchr.ing never holds your money). Plans, prices and entitlements are made in **Billing → Plans**; the subscription reaches the token as [`billing`](https://github.com/anchring/oss/blob/main/sdk/permissions.md#plans-and-entitlements-in-the-token). From the app, as the signed-in user (`auth.billing`; on a web app's server `web.billing(session)`): ```ts const { plans, subscription } = await auth.billing.get(); await auth.billing.details({ country: 'DK', vat_id: 'DK12345678' }); // once, before the first checkout const { url } = await auth.billing.checkout({ price: 'pro_monthly' }); // the payment page location.href = url!; // it returns to your app (default: the app's first origin, or return_url) // afterwards: await auth.refresh(); user.billing.plan === 'pro' await auth.billing.preview('max_monthly'); // what an upgrade charges now (prorated, VAT) await auth.billing.change('max_monthly'); // upgrades now, downgrades at the period end await auth.billing.cancel(); // at the period end; resume() takes it back const { url: card } = await auth.billing.updatePaymentMethod(); ``` - A user acts only on their own subscription. Pass `{ org }` to act for an org: its owners and admins, or members with a [custom org role](https://github.com/anchring/oss/blob/main/sdk/organisations.md#organisations) holding the permission `billing:manage`. - Payment pages return only to a page on the app's own origins (its redirect URIs). - Errors are `BillingApiError`: `billing_details_required`, `already_subscribed`, `no_subscription`, `forbidden`, `billing_not_configured`, `rate_limited`. From your backend, with a [server key](https://github.com/anchring/oss/blob/main/sdk/tokens.md#admin-client-server-keys) holding `billing:read` / `billing:write` (your code decides who may act for whom): ```ts const admin = createAdminClient(); const { url } = await admin.billing.checkout({ subject: `user:${user.sub}`, price: 'pro_monthly', return_url: 'https://app.example/billing' }); const customer = await admin.billing.customers.ensure({ subject: `org:${orgId}` }); await admin.billing.subscriptions.change(subId, { price: 'max_monthly' }); await admin.billing.invoices.list({ customer: customer.id }); ``` Settings, the payment account, plans and refunds stay in the dashboard. The `subscription.*`, `invoice.*` and `payment.*` [webhooks](https://github.com/anchring/oss/blob/main/sdk/webhooks.md#webhooks) tell your app about changes. --- # Feature flags **When you need this:** turning a feature on for some users, plans or organisations, rolling it out by percentage, or switching it off in an emergency, without a deploy and without a call per request: flags are evaluated from the token, in your API and in the browser alike. ## Feature flags Flags are evaluated **offline**, from the token's claims (plan, entitlements, billing status and country, org and org role, roles, `sub`, email) and the env's rules, which the SDK fetches and caches like the signing keys. The same evaluator runs in your API, in the browser and in the dashboard's preview, so a flag gives one answer everywhere. Declare your flags once, in a file the API and the browser share (like [`defineAccess`](https://github.com/anchring/oss/blob/main/sdk/permissions.md#permissions)): ```ts export const flags = defineFlags({ 'new-editor': { default: false }, theme: { default: 'classic', variants: ['classic', 'compact'] }, }); const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api', flags }); // server: for a verified user (c.var.user), typed: boolean, 'classic' | 'compact' if (await auth.flag('new-editor', c.var.user)) { … } app.get('/api/editor', auth.guard({ flag: 'new-editor' }), handler); // 403 when off (denyStatus: 404 hides it) app.get('/api/flags', async (c) => c.json(await auth.flags(c.var.user))); // every flag, to bootstrap a UI // browser: the signed-in user (signed out: an anonymous caller) const theme = await auth.flag('theme'); const all = await auth.flags(); ``` `auth.flag` never throws: when the env's rules don't have a flag (or can't be fetched or verified) you get its `defineFlags` default, or `null` for an undeclared key. `auth.explainFlag(key, user?)` also says why: `off`, `rule` (with the rule's id), `default`, `unknown` or `unavailable`. **Rules.** A flag has a type (`boolean`, `string`, `number`, `json`), a default and an on/off switch: off is the kill switch, and everyone gets the default. Its rules run top down, and the first that matches gives the value. A rule is a list of conditions (all must hold) on `plan`, `entitlement`, `billing_status`, `country` (the billing address, in the `billing` claim), `org` (id), `org_role`, `role`, `user` (`sub`), `email_domain`, any `claim` (a dot path) or an `attribute` your code passes (below), with `in` / `not_in`. Your app's claims template must carry what you target: `"billing": "{{billing}}"`, `"org": "{{org}}"`, roles, and so on. **Your own attributes.** What isn't in the token (the workspace or project the user is looking at, the app's region, a device type) goes in with the evaluation, and an **Attribute** condition (`attr: "attribute"`, `key`: its name or a dot path) reads it: ```ts // rule: Attribute project is acme, beta → true const on = await auth.flag('new-editor', c.var.user, { attributes: { project: workspace.slug } }); const all = await auth.flags(c.var.user, { attributes: { project: workspace.slug, region: 'eu' } }); await auth.flag('new-editor', undefined, { attributes: { project: 'acme' } }); // browser: the signed-in user ``` Values compare with `===` (strings, numbers, booleans; a list matches when any item does), a claim of the same name never counts as an attribute, and `not_in` holds when the attribute is missing. Attributes are not signed: in a browser the user can change them, so use them there for what the UI shows and keep data behind a server check. **Rollouts.** A rule can hold a percentage: who is in is decided by `sha256(flagKey + "." + sub)` (the first 4 bytes, mod 10 000), so a user always gets the same answer, raising 20% to 30% keeps the first 20% in, and each flag buckets independently. With **Roll out by: organisation** (`bucket_by: "org"`) the active org's id is the bucket key, so a whole org moves together. A caller without the key (no `sub`, no active org) is not in a rollout; the next rule decides. **Distribution.** `/.well-known/flags.json` is public and holds `{ jws }`: a JWT (`typ: flags+jwt`) signed with the env's signing key, which the SDK verifies against the JWKS before using it. It carries only what evaluation needs: no descriptions, a switched-off flag's default only, and user ids as salted hashes (a per-env salt), never the ids. It is cached (`Cache-Control: public, max-age=30, stale-while-revalidate=300`, an `ETag`), and the SDK checks it for changes at most every `flagRules.refreshMs` (default 30 s), right before an evaluation: no timers, also in the browser. A failed fetch or a document that doesn't verify keeps the rules it has, and an older document (a lower revision) never replaces a newer one. So a change in the dashboard reaches your apps within about 30 seconds; `auth.refreshFlags()` fetches at once. ```ts createAuth({ ..., flags, flagRules: { refreshMs: 10_000, onError: (e) => log.warn(e) } }); ``` From your backend or CI, with a [server key](https://github.com/anchring/oss/blob/main/sdk/tokens.md#admin-client-server-keys) holding `flags:read` / `flags:write`: ```ts const admin = createAdminClient(); await admin.flags.update('new-checkout', { enabled: false }); // the kill switch, e.g. from an alert await admin.flags.evaluate({ context: { plan: 'pro', org: 'org_1' } }); // what each flag gives such a caller await admin.flags.audit({ key: 'new-checkout' }); // who changed what (before / after) ``` Every change is in the flag's change log (dashboard: **Flags → Change log**). **Promote** copies flags from test to production: definitions and rules, never the on/off of an existing production flag, and a new flag arrives switched off. Users and orgs are per environment, so rules naming test user or org ids are flagged in the promote preview. --- # Support chat **When you need this:** a chat with your users inside your app (and with visitors on your marketing site), answered by your team in the dashboard and, if you like, by your own AI agent first. A headless client, or the drop-in `` [widget](#chat-widget). ## Support chat Your users (and your marketing site's visitors) chat with your team; your own AI agent can answer first. Turn it on in the dashboard: **Support → Settings**. ```ts import { openChat } from '@anchring/auth/chat'; const chat = openChat({ auth }); // the browser auth from createAuth(): the signed-in user's token const stop = chat.subscribe((e) => show(e.message)); // live: replies from the team and the AI agent const { conversation } = await chat.start('My invoices are missing'); await chat.send(conversation.id, 'It is invoice 4812'); ``` - Headless: no UI. `e.message.ai` is true for the AI agent's messages: always label them (`name` or "AI assistant"). A `system` message `handover` means a person takes over; `chat.handover(id)` asks for one. - Live updates are server-sent events: `subscribe` takes a one-time ticket per connection (an `EventSource` cannot send a token), resumes after the last message it saw and reconnects by itself. - Replies the user has not seen within a few minutes are emailed; their email answer comes back into the conversation. - **Visitors** (not signed in; Settings → "Visitors can start a conversation" and your site's origin): `openChat({ project, env, visitor: { store: sessionStorageStore() } })`, then `chat.startVisitor({ email?, consent: { support: true, marketing?, text, marketingText } })` once (a proof of work runs in the browser). Support and marketing consent are separate records with the wording you showed. A visitor who signs up keeps their conversations: by their verified email, or with `chat.conversionCode()` on the site and `chat.convert(code)` in the app. Visitors who never sign up are deleted after 90 days without activity (Settings). `chat.exportVisitor()` and `chat.forgetVisitor()` are their data rights. - Gate it with a boolean [feature flag](https://github.com/anchring/oss/blob/main/sdk/feature-flags.md#feature-flags) (Settings → "Only for users the flag lets in"). **Your AI agent** is your code, on the `message.created` [webhook](https://github.com/anchring/oss/blob/main/sdk/webhooks.md#webhooks), with a server key holding `chat:agent`: ```ts import { isAgentTurn } from '@anchring/auth/chat'; 'message.created': async (e) => { if (!isAgentTurn(e)) return; // the user wrote, and no person has taken over const ctx = await admin.chat.context(e.conversation); // the messages, and the user's plan, billing, flags, sessions const answer = await myModel(ctx); if (answer) await admin.chat.reply(e.conversation, answer); // shown to the user as AI else await admin.chat.handover(e.conversation, 'Wants a refund'); // the note is for the team only }, ``` A key with `chat:read` reads conversations and support contexts only. Runnable: [`examples/support-chat.ts`](https://github.com/anchring/oss/blob/main/sdk/examples/support-chat.ts); an Angular widget on the headless client: `examples/spa/angular/src/app/support-chat.ts`. ### Chat widget A ready-made chat on the same client: launcher bubble and panel, the visitor's consent, conversations, AI labels and handover, unread badge, email hint, English and Danish. Your colours, logo and corners come from the project's Branding. On a site, for visitors (copy `node_modules/@anchring/auth/anchring-chat.js` to your site; no CDN): ```html ``` In an app, for its signed-in users (any framework; it is a custom element): ```ts import '@anchring/auth/chat-widget'; document.querySelector('anchring-chat')!.auth = auth; // from createAuth() in @anchring/auth/browser ``` - **Attributes**: `project` + `env` (+ `domain` when self-hosted) or `issuer`; `lang` (`en`, `da`; default the page's); `theme` (`auto`, `light`, `dark`); `position` (`right`, `left`); `color` (`#rrggbb`), `radius` (`none`, `small`, `medium`, `large` or a length), `font`, over the project's branding (`brand="off"` ignores it); `heading`; `privacy-url` (linked from the visitor's consent); `visitor-storage` (`session`, the default: the tab keeps the visitor's conversation across your pages; `memory`: until the page goes); `launcher="off"` with your own button calling `el.open()`; `open`. - **Properties and methods**: `auth`, `client` (an `openChat()` client of your own), `strings` (override texts: `{ start: 'Ask us' }` for every language, `{ da: { … } }` for one), `open()`, `close()`, `toggle()`, `refresh()` (after sign-in or sign-out with the same `auth`), `unread`. - **Events** (bubbling, composed): `anchring-chat-open`, `anchring-chat-close`, `anchring-chat-message` (`detail`: the stream event), `anchring-chat-unread` (`detail.count`), `anchring-chat-error` (`detail.code`). - **Styling**: CSS custom properties on the element (they reach into its shadow root): `--anchring-chat-primary`, `--anchring-chat-on-primary`, `--anchring-chat-surface`, `--anchring-chat-surface-2`, `--anchring-chat-text`, `--anchring-chat-muted`, `--anchring-chat-border`, `--anchring-chat-radius`, `--anchring-chat-font`, `--anchring-chat-offset`, `--anchring-chat-z`. - **CSP**: no inline script, no `eval`, styles through a constructable stylesheet (nothing for `style-src`). Allow your issuer in `connect-src` (`https://acme.prod.anchr.ing`), and in `img-src` for your logo; `script-src 'self'` covers the file you serve. - Accessible: a labelled dialog that keeps focus inside while open, Escape closes and returns focus to the launcher, replies announced to screen readers, reduced motion respected. About 13 KB gzip, no dependencies. - Visitors: the widget shows the consent texts (support required, marketing separate and only with an email) and stores them with the consent record. Change the wording with `strings` (`consentSupport`, `consentMarketing`). Runnable: [plain HTML and React](https://github.com/anchring/oss/tree/main/examples/chat-widget). --- # Webhooks **When you need this:** your backend should hear about what happens at Anchr.ing: a user signed up or was deleted, a subscription changed, a support conversation started. One signed POST per event to your endpoint, verified and deduplicated by the SDK's handler. ## Webhooks Add your endpoint (e.g. `https://api.acme.example/hooks/auth`) in the admin UI and copy the signing secret (`anchr_whsec_…`) into `ANCHRING_WEBHOOK_SECRET` (or `webhookSecret`). Each environment's webhook has its own secret (promoting creates a new one for prod), so set the variable per environment. ```ts app.post('/hooks/auth', auth.webhooks.handler({ 'user.created': (e) => createAccount(e.sub, e.email), 'user.deleted': (e) => deleteAccount(e.sub), })); ``` The handler is a Hono handler and a plain `(Request) => Response` (use `toExpress` on Express). It checks the `anchring-signature` HMAC (`t=…,v1=…`) against the raw body with a 5 minute tolerance, and deduplicates by event id. Responses: `401` bad or old signature, `200 { duplicate: true }` for a replay, `200 { ignored: true }` for types you don't handle, `500` if your handler throws (the id is forgotten so the retry is processed). Deliveries are retried by the service, so handlers should be idempotent. Each event gets `{ id, type, v, created, sub, email?, … }` plus the fields below. `id` (`evt_…`) is the same on every retry and replay of the event (dedupe on it); `v` is the payload's version for that type (`1` for every type today, absent from events sent before it existed): when a payload changes shape, its type's `v` goes up and the old shape stays described under the old number, so a handler can branch on `e.v`. `source` says how a change happened when it wasn't the user or an admin in the dashboard / admin API: `scim` (your directory), `jit` (SSO just-in-time) or `admin` (org events). Only the org events' extra fields are typed; read the others from the event (`e.fields`, `e.previous_email`, …). | Event | When | Fields besides `sub` | | --- | --- | --- | | `user.created` | sign-up, admin or API create, invite, SCIM, social or SSO first sign-in (not bulk imports) | `email` (absent for a social-only user: a connection with `email: "optional"`), `name?` (admin, SCIM), `org_id?` and `source?` (SCIM) | | `user.updated` | profile fields changed (admin, API or SCIM) | `email`, `fields` (names of the changed fields), `source?` | | `user.email_changed` | the sign-in email changed (account page, admin, SCIM) | `email` (new), `previous_email?`, `source?` | | `user.deleted` | the user was deleted | `email`, `source?` | | `user.disabled` | the user was disabled (admin or SCIM); their sessions end | `email`, `source?` | | `user.enabled` | a disabled user was enabled again | `email`, `source?` | | `session.revoked` | one session ended (sign-out), or all of the user's sessions (password change, admin "sign out everywhere", SCIM deactivation) | `sid` (one session) or `all: true`; `reason?` (`scim`) | | `users.password_mails.queued` | an import batch's users were emailed a link to set a password (no `sub`: one event per call) | `batch`, `all`, `resend`, `days`, `queued`, `kept`, `suppressed` | | `credential.changed` | a credential or security setting changed | `email?`, `kind` (below), `provider?` (with `identity_linked` / `identity_unlinked`: the social connection) | | `invite.accepted` | an invited user accepted and set up their account | `email` | | `org.member_added` | a user joined an organisation | `email?`, `org_id`, `role`, `source` (`admin` / `scim` / `jit`) | | `org.member_removed` | a user left an organisation, or it was deleted | `email?`, `org_id`, `source?`, `reason?` (`org_deleted`) | | `org.member_updated` | a member's role changed (admin, or a SCIM group mapping) | `email`, `org_id`, `role`, `previous_role?`, `source` | | `subscription.created` | a [subscription](https://github.com/anchring/oss/blob/main/sdk/billing.md#billing) started (its first payment, or its trial) | `subject_type`, `org_id?`, `customer`, `subscription`, `plan`, `status`, `current_period_end`, `cancel_at_period_end` | | `subscription.updated` | a plan change, a cancellation set or taken back, a renewal, a pause | the same, `pending_plan?` | | `subscription.canceled` | the subscription ended | the same | | `subscription.past_due` | a charge failed and is being retried (the plan still applies) | the same | | `invoice.finalized` / `invoice.paid` | an invoice was issued / paid | `subject_type`, `customer`, `invoice`, `subscription`, `amount`, `currency` | | `invoice.payment_failed` | charging an invoice failed | the same, `attempt`, `next_attempt_at` (null: no more retries) | | `payment.succeeded` / `payment.failed` | a payment went through / was refused | `subject_type`, `customer`, `payment`, `invoice`, `amount`, `currency`, `reason?` | | `payment.refunded` | a refund was confirmed (a credit note) | the same, `credit_note`, `number` | | `payment.chargeback` | the customer's bank took a payment back | the same | | `impersonation.started` | support started acting as the user ([support access](https://github.com/anchring/oss/blob/main/sdk/impersonation.md#support-access-impersonation)) | `id` (the support access; `act.reason_id` in its tokens), `actor`, `actor_email?`, `reason`, `expires_at` | | `impersonation.ended` | a support access ended (the member, the app, the user signing out everywhere, or its time limit) | `id`, `actor`, `actor_email?`, `reason` (`ended_by_admin`, `ended_by_app`, `expired`, or how the session ended) | | `conversation.created` | a [support chat](https://github.com/anchring/oss/blob/main/sdk/support-chat.md#support-chat) conversation started | `subject_type` (`user` / `contact`: `sub` is then the visitor), `conversation`, `topic`, `ai_active`, `started_by` | | `conversation.updated` | its status or assignee changed | `conversation`, `status?`, `snoozed_until?`, `assignee?`, `assignee_email?` | | `conversation.handed_over` | the AI agent handed over to the team | `conversation`, `by`, `note?` | | `conversation.converted` | a visitor signed up: the conversation is their user's now | `conversation`, `contact`, `by` (`email` / `code`) | | `message.created` | a message: the user's, a member's, the AI agent's or a team note | `conversation`, `message`, `seq`, `role`, `author`, `internal`, `via`, `ai_active`, `body` | In the billing events `sub` is the paying user, or the org's id when an org pays (`subject_type: 'org'`). Amounts are minor units (cents). `credential.changed` kinds: `password_changed`, `passkey_added`, `passkey_removed`, `passkeys_review` (passkeys added shortly before a password reset, for the user to review), `mfa_enabled`, `mfa_disabled`, `mfa_reset_by_admin`, `recovery_codes_regenerated`, `recovery_code_used`, `pat_created`, `identity_linked`, `identity_unlinked`, `signed_out_everywhere`, `verify_limit_reached` (failed email-code budget spent). A `password_changed` is followed by `session.revoked` with `all: true`. Handler keys are checked: an unknown event name is a type error and throws when the handler is created. - Rotation: `webhookSecret: [newSecret, oldSecret]`. - Several instances: pass `replayStore` (e.g. Redis `SET NX EX`) so a replay to another instance is caught. - Lower level: `verifyWebhook(body, header, secrets)`, and `signWebhook(secret, body)` for tests. --- # Sessions, lifetimes and revocation **When you need this:** to know how long a sign-in lasts, what happens when a user signs out, is disabled or changes their password, how fast a role change reaches your API, how to cut access off sooner than the access token's expiry (a revocation check, or back-channel logout), and how a user sees and ends their own sign-ins ([Devices](#devices)). ```ts // API: an ended session is refused within 30 s instead of at the token's expiry const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', revocation: { check: 'status' } }); // browser / CLI: the session ended elsewhere (local state is already cleared) auth.on('revoked', ({ reason }) => showSignedOut(reason)); ``` ## Lifetimes and revocation Your API verifies access tokens offline, so it never asks the auth service whether a token is still good. That is what makes verification fast, and it means a token stays valid until it expires. The lifetimes keep that window short. ### Token lifetimes | What | Lifetime | Per app | After sign-out, a disabled user or an admin ending the session | | --- | --- | --- | --- | | Access token | 10 minutes | 1 to 60 minutes (**Apps → Access-token lifetime**, `access_token_ttl`) | valid until `exp` | | ID token | 10 minutes | | read by the client, never sent to your API | | Authorization code | 60 seconds, single use | | | | Device code (CLI) | 10 minutes, polled every 5 s | | | | Refresh token | 30 days unused, 90 days after sign-in at most; rotated on every use | the maximum (**Apps → Sign-in lifetime**, `refresh_token_max_ttl`, 1 hour to 400 days) | refused at once: `SignInRequiredError` on the next refresh | | Refresh token, DPoP-bound ([DPoP](https://github.com/anchring/oss/blob/main/sdk/server.md#dpop) with a persistent key) | 90 days unused, 365 days at most | same setting | same | | Login session at the auth service (browser cookie `__Host-kh_session` on the issuer) | 3 days unused, 14 days at most | | ended | | Refresh cookie (`refresh: 'cookie'`, `__Host-kh_rt_`) | the refresh token's | | cleared by `signOut()`; the token in it is refused | | PAT exchange token | 10 minutes (less if the PAT expires sooner) | | a revoked PAT stops at its next exchange | A rotated refresh token still works for 60 seconds (two tabs refreshing at once get the same successor); presented after that, it counts as stolen and ends the whole session. Access tokens are the same with or without DPoP; DPoP only lengthens refresh tokens, because a stolen one is useless off the device. So a revoked session loses access within the access-token lifetime without anything on your side. Shorter narrows that window and costs one refresh call per lifetime per user; the SDK refreshes 60 seconds before expiry either way, so users never see it. To cut access off at once, handle [back-channel logout](#back-channel-logout): the service tells your app which session ended and `auth.backchannel.isLoggedOut(user)` rejects its remaining access tokens. The `session.revoked` and `user.disabled` [webhooks](https://github.com/anchring/oss/blob/main/sdk/webhooks.md#webhooks) carry the same news if you would rather act on those. **How long a sign-in lasts.** Refreshing keeps an app signed in, but not forever: each sign-in's refresh tokens end at a fixed time after the code (or device) exchange that started them, however often they rotate (the `refresh_expires_in` of each token response counts down to it). The SDK then reports `SignInRequiredError`; in the browser, `auth.signIn()` usually comes straight back without a prompt while the login session is still live. That fresh sign-in also picks up what changed in the meantime: a second factor turned on, an organisation now enforcing SSO. An offline-first app (a desktop app working on local data) should use DPoP with a key kept on the device: it stays signed in through 90 days offline and a year in all. Turning on a second factor ends the user's other sessions and their refresh tokens; a step-up to a second factor replaces the browser's session with a new one (the old one ends, so its apps get a back-channel logout and sign in again silently). `auth_time` stays the time of the first-factor sign-in after a step-up: the second factor shows in `amr`, so `max_age` and `maxSessionAge` still count from the real sign-in. ### Signing in again and switching accounts The auth service keeps one login session per browser (`__Host-kh_session` on the issuer's host). Your app keeps its own tokens per client id: in memory, in `sessionStorage`, or in the refresh cookie `__Host-kh_rt_`. - `signIn()` while the login session is live comes straight back as the **same** user, without a prompt. - A completed sign-in replaces the app's stored tokens and its refresh cookie. It does not revoke the previous refresh token: that one stays valid until it expires unused. - A sign-in at the login page (a new user, or `prompt: 'login'`) replaces the login-session cookie. The old login session is no longer reachable from that browser and ends after 3 days unused. - `signOut()` revokes the app's refresh token (and clears the refresh cookie), then ends the login session through RP-initiated logout, which sends back-channel logouts to the other apps of that session. When the logout request can't be trusted end to end (no ID token hint for the session, or a `post_logout_redirect_uri` the app hasn't registered), the issuer shows a confirmation page before ending the session instead of signing out silently. To switch accounts, sign out first, then sign in with the form: ```ts await auth.signOut({ redirect: false }); // revoke this app's tokens, keep the page await auth.signIn({ prompt: 'login' }); // the login form, even with a live login session ``` Without `redirect: false` the login session at the auth service ends too, so the next `signIn()` shows the form anyway. Each project environment is its own issuer, and so is each custom domain: cookies, sessions and refresh tokens of one host never replace or carry over to another, so moving an app to a custom domain means one more sign-in. ### Stale permissions Claims (roles from a claims template, `org_id`, email) are rendered when a token is issued: at sign-in and on every refresh. A change reaches your API with the client's next access token: | Change | Reaches your API | | --- | --- | | Role or org membership (claims template) | at the next refresh: within the access-token lifetime (10 minutes by default) | | User disabled or deleted, session ended by an admin | the next refresh fails (`SignInRequiredError` with `reason`, `auth.on('revoked')`); access tokens already issued work until `exp`, unless you turn on a [revocation check](#when-a-session-ends-that-the-user-didnt-end) or handle back-channel logout | | Roles in your own database (`context` reads them) | on the next request | | PAT revoked | at its next exchange (within 10 minutes) | To make a change take effect sooner: - shorten the app's access-token lifetime (down to 1 minute); - have the user sign in again: `auth.signIn()` returns silently with fresh claims while the login session is live; - end the user's sessions (dashboard **Users**, the user, **Sign out everywhere**): every refresh fails, and with back-channel logout handled, the tokens already issued stop at once; - turn on `revocation: { check: 'status' }` in the API: an ended session is refused within `cacheTtl` (30 s); - for decisions that must be current to the second (removing an admin, billing), read the role from your database in `context` instead of from the claim. ### When a session ends that the user didn't end An admin ends a user's sessions, disables them, the user changes their password elsewhere or turns on a second factor, their organisation starts enforcing SSO, or they sign out in another app. Every client of that session should find out promptly and show "signed out", not fail in odd places. The session records why it ended, and every answer about it carries that `reason`: | `reason` | | | --- | --- | | `signed_out` | the user signed out of this session (hosted logout), or a back-channel logout this API received | | `signed_out_everywhere` | the user's "sign out everywhere" | | `signed_out_device` | the user signed this device out from another one ([Devices](#devices)) | | `password_changed` | a password change or reset ended every session | | `mfa_enabled` | a second factor turned on ends the one-factor sessions | | `stepped_up` | a step-up replaced the session with a stronger one (the app signs in again silently) | | `admin` | an admin ended the user's sessions (dashboard or admin API) | | `user_disabled` | the user was disabled | | `scim` | the organisation's directory deprovisioned the user | | `sso_enforced` | the organisation now requires sign-in through its IdP | | `token_reuse` | a rotated refresh token was presented again (a sign of theft): the whole session ends | | `expired` | the session's idle or absolute lifetime ran out | | `key_incident` | the project retired a signing key as compromised and signed everyone out of the environment | | `revoked` | any other revocation | **Clients with a refresh token** (browser, CLI, mobile, agents) hear it on their next refresh: the token endpoint answers `invalid_grant` with `reason`. The SDK clears its state, emits `revoked` and throws `SignInRequiredError` (with `.reason`); a `webApp()` refresh throws `OAuthError` with `.reason`. **A retired signing key** (the project rotated it with *retire now*, Settings → Signing keys) is no longer in the environment's key set, and an API refuses the tokens it signed as soon as its copy of the key set is refreshed: the SDK refreshes it in the background once `jwksTtlMs` (default 10 min) has passed, refetches it at once for a token with a key id it does not know (at most once per `jwksCooldownMs`, default 30 s) and rejects the token when the key is still missing afterwards, so a retired key's tokens stop working within the TTL and the new key's work at once. ```ts auth.on('revoked', ({ reason, source }) => { // Local state is already cleared. source: 'refresh' (the issuer refused) or 'api' (an API said session_revoked). showSignedOut(reason === 'user_disabled' ? 'Your account was disabled.' : 'You were signed out.'); }); ``` **APIs** verify access tokens offline, so a revoked session's access token is accepted until it expires (10 minutes by default). To cut it off sooner, turn on a revocation check: ```ts const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', revocation: { check: 'status' } }); app.use('/api/billing/*', auth.middleware({ revocation: { check: 'status', cacheTtl: '5s' } })); // stricter per route ``` | `check` | How | Costs | Revocation reaches the API | | --- | --- | --- | --- | | none (default) | offline only | nothing | at the token's `exp` (≤ access-token lifetime) | | `'status'` | `POST /session/status` with the token, cached per session for `cacheTtl` (default `30s`); parallel checks share one request; an ended session is remembered for an hour | one small request per active session per `cacheTtl` | within `cacheTtl` | | `'backchannel'` | the [back-channel logouts](#back-channel-logout) this app received (`store`: share one across instances) | nothing per request; register the app's back-channel logout URL | as soon as the logout is delivered | When the issuer can't be reached, `'status'` accepts the token (`failOpen: true`, the default: offline verification still holds and the token expires on its own) or, with `failOpen: false`, answers `503 auth_unavailable`. Tokens without a session (PATs, machine tokens) are never checked. A revoked session answers `401 { error: 'session_revoked', reason }` with `WWW-Authenticate: Bearer error="invalid_token", error_description="session_revoked", reason=""`, and `auth.fetch` in the SDK clients signs out and emits `revoked` (`source: 'api'`) when an API says so. `auth.sessionStatus(user, token)` asks directly. **Clients without the SDK** (mobile apps, agents, other languages) follow the same contract on a `401` from an API or `/userinfo`. `authChallenge(response)` (exported by every entry) does this parsing for you: | `WWW-Authenticate` | Meaning | Do | | --- | --- | --- | | `error="invalid_token", error_description="expired"` (or a bare `invalid_token`) | the access token expired, early or by clock skew | refresh, then retry the request once | | `error="invalid_token", error_description="session_revoked", reason="…"` | the session ended | don't refresh: clear tokens, sign in again | | `error="insufficient_user_authentication"` | a policy wants a fresher sign-in or MFA ([Policies](https://github.com/anchring/oss/blob/main/sdk/server.md#policies)) | sign in again with `prompt=login` / `acr_values=mfa` | | any other `error_description` | the token is wrong for this API | don't retry; fix the setup | `auth.fetch` does exactly this: on `expired` it refreshes (one refresh shared by parallel requests, only if the token that failed is still the current one) and retries once, if the body can be sent again (a string, `URLSearchParams` or none). There is no timer: refreshes happen only before a request. To ask about a session without calling an API, `POST /session/status` with `token=` (form-encoded) answers `{ "active": true }` or `{ "active": false, "reason": "admin" }`. It allows 600 checks a minute from one network (an IPv4 address or IPv6 /64) and answers `429` with `Retry-After` beyond that; the SDK's `revocation: 'status'` treats that like an unreachable issuer (`failOpen`). ## Devices A user can see where they are signed in and end a sign-in they don't recognise. The page is hosted on the project's login domain, `https://..auth.anchr.ing/account/devices` (or the custom domain), next to the other account pages; the same calls are in the SDK, so an app can draw the page in its own UI instead. ```ts const devices = await auth.sessions.list(); // [{ id, device: 'Chrome on macOS', label?, ip, amr: ['pwd'], createdAt, lastSeenAt, current: true }, …] await auth.sessions.revoke(devices[1].id); // sign that device out await auth.sessions.revokeOthers(); // keep this one, sign out everywhere else ``` | Call | Does | HTTP (as the user, `auth.fetch`) | | --- | --- | --- | | `auth.sessions.list()` | the user's live sessions: a friendly name from the browser string (`device`), the name a sign-in gave it (`label`, a CLI's host name), `ip`, `amr`, `createdAt`, `lastSeenAt`; `current: true` for the session this call was made with, listed first, the rest by last activity | `GET /account/sessions` | | `auth.sessions.revoke(id)` | ends that session and every token of it: its next refresh fails with `reason: signed_out_device`, its apps get a back-channel logout. Its own session (`current`) is a sign-out (`reason: signed_out`); call `signOut()` instead, which also clears local state | `DELETE /account/sessions/{id}` | | `auth.sessions.revokeOthers()` | every other session and its tokens; this one and its refresh token stay. Answers `{ sessions }`, how many ended. Personal access tokens are not touched (that is the account page's "Sign out everywhere") | `POST /account/sessions/revoke-others` | Signing another device out needs a recent sign-in: within the last 10 minutes and, for a user with a second factor, with it. Otherwise the call throws `SessionsError` with `error: 'reauth_required'` (or `'mfa_required'`); sign in again first, `auth.signIn({ prompt: 'login' })` (with `acr_values: 'mfa'` for the second factor), and retry. In the browser the user comes straight back. The other errors: `not_found` (not a live session of this user; another user's id gets the same answer), `rate_limited` (more than 60 sign-outs in an hour), `forbidden` (a support access acting as the user may look but not sign devices out). A web app's server calls the same as `webApp.sessions(session)`. The user's sessions on the dashboard (**Users**, the user, **Active sessions**) show the same browser names; an admin ends them all with **Sign out everywhere** (`reason: admin`). ## Back-channel logout When a user signs out, or an admin ends their sessions, the service POSTs an OIDC Back-Channel Logout token to your app's `backchannel_logout_uri`. Set the app's `clientId` (the token's audience). ```ts app.post('/auth/backchannel-logout', auth.backchannel.handler((e) => destroyAppSessions(e.sid, e.sub))); // Reject app sessions the IdP has ended (by sid, or every session of a sub before the logout) if (await auth.backchannel.isLoggedOut(c.var.user)) return c.json({ error: 'logged_out' }, 401); ``` The handler verifies the `logout+jwt` (signature, issuer, audience, `events`, no `nonce`, age, `jti` replay), records it, then calls you: `200` on success, `400 invalid_request` for a bad token, `501` if your callback throws (so the service retries). Logouts are remembered in memory for 14 days; pass `store` (a `LogoutStore`) to share them across instances. --- # Machine access **When you need this:** something other than a signed-in browser calls your API or Anchr.ing's admin API: a user's script (a personal access token), a team's CI or agent (an org token), your own backend managing users (a server key), or a GitHub Actions job (CI trust, no stored secret). The first three are secrets starting with `anchr_`, which GitHub secret scanning recognises. ```ts // Your API accepts personal access tokens and org tokens next to JWTs: const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', pat: { audience: 'acme-api' } }); app.use('/api/*', auth.middleware()); ``` ## Personal access tokens For CI and scripts, users create a personal access token on their account page (name, scopes, expiry of at most a year) and set it, e.g. `ACME_TOKEN=anchr_pat_test_…` (`anchr_pat_…` in production). Your CLI or script sends it as a bearer: ```sh curl -H "Authorization: Bearer $ACME_TOKEN" https://api.acme.example/api/reports ``` Turn it on in the API. `pat.audience` is the **resource app's audience** (the same `acme-api` the middleware checks), so it does not change between test and prod: ```ts const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', pat: { audience: 'acme-api' } }); app.use('/api/*', auth.middleware()); // accepts JWTs and anchr_pat_… alike ``` When the bearer looks like a PAT, the middleware exchanges it at the project's `/token` (RFC 8693 token exchange) for a ~10 minute access JWT, verifies that offline, and caches it per PAT (keyed by its SHA-256) until 30 seconds before it expires. So the auth service sees about one request per PAT per 10 minutes, and a revoked PAT stops working within that window. The user is `type: 'apiKey'` with the PAT's `scopes`, so `tokens.apiKey` permission ceilings apply automatically. - A PAT for the other environment (a production `anchr_pat_…` against test, or `anchr_pat_test_…` against prod) is rejected without a network call, and so is a mistyped or truncated one: the last 6 characters are a checksum the SDK checks offline. - Every anchring secret starts with `anchr_` (`anchr_pat_`, `anchr_sk_` for client secrets, `anchr_whsec_` for webhook signing secrets, `anchr_scim_`, `anchr_key_` for server keys): GitHub secret scanning recognises them, and a token pushed to a public repository is revoked and its owner emailed. - `pat: { audience, scope }` requests only a subset of the PAT's scopes. - `pat: { solvePow }` answers the server's proof-of-work challenge (428) after repeated failures from your IP. - Per route: `auth.middleware({ pat: false })` disables it, `{ pat: { audience: 'other' } }` overrides it. - Outside the middleware: `await auth.exchangePat(pat)` or `createPatExchanger({ issuer, audience })`. Without `pat` configured, a PAT is just an invalid token (`401 invalid_token`). ### Org tokens An org token (`anchr_org_…`) is a machine token **owned by an organisation**, not a person: a team's CI or a shared agent keeps working when whoever made it leaves. Org owners and admins create them in your app (`auth.orgs.tokens.create(orgId, { name, scopes, role, expiresInDays })`) or the dashboard (org → Tokens): a name, scopes, a role in the org (default `member`, never `owner`) and an expiry of at most a year. Shown once, revocable, audited, recognised by GitHub secret scanning. Your API accepts them with the same `pat` option as personal access tokens: the middleware exchanges either at `/token` (RFC 8693, `subject_token_type` `urn:anchring:params:oauth:token-type:org`). The caller is `type: 'apiKey'` with `sub` = `org_`, `user.org` = the org with the token's role (and a custom role's `permissions`), `scopes` the token's, and the claims `kind: "org"` and `org_token_id`. So `auth.require(…)` and guards check the org role within the token's scopes, and `tokens.apiKey` ceilings apply. **Org tokens or server keys?** They point in opposite directions. An org token is an organisation's credential for calling **your** API as that org (its CI, its scripts, its agents). A [server key](#admin-client-server-keys) is your app's backend calling **Anchr.ing's** admin API (delete a user, change an org). A server key can list and revoke an org's tokens, but never create one. ## Admin client (server keys) Your app's backend can call the project's admin API itself: delete a user who closed their account in your app, disable one, keep a plan in their metadata, sign them out everywhere. Make a **server key** in the dashboard (**Apps → Server keys**): a name, the scopes it needs, and an optional expiry. The key (`anchr_key_test_…` in test, `anchr_key_…` in production) is shown once; put it in `ANCHRING_SERVER_KEY` on the server. ```ts import { createAdminClient } from '@anchring/auth/admin'; const admin = createAdminClient({ project: 'acme' }); // key from ANCHRING_SERVER_KEY; its env picks the issuer await admin.users.delete(sub); // false when the user was already gone (safe to retry) await admin.users.update(sub, { metadata: { plan: 'pro' } }); await admin.users.disable(sub); await admin.users.revokeSessions(sub); const page = await admin.users.list({ q: 'ada@example.com' }); ``` | Scope | Allows | | --- | --- | | `users:read` | list and read users, their sessions and activity, the metadata schema | | `users:write` | create users, change profile fields (name, email, verification, metadata) | | `users:metadata` | change a user's `metadata` only | | `users:disable` | disable and enable users | | `users:delete` | delete users and everything that belongs to them | | `users:export` | export users without password hashes or TOTP secrets; list, download and delete exports | | `sessions:revoke` | sign a user out everywhere | | `orgs:read`, `orgs:write` | organisations, their members and the org roles; list and revoke org tokens (a key never creates one) | - A key works in **its own environment only**: a test key at the production issuer is unknown (401), and `?env=` to the other environment is refused (403). It never reaches another project, the dashboard's platform API, or token exchange. - A key never configures the project: apps, keys, settings, webhooks, connections and promote answer `403 key_not_allowed`; an operation outside the key's scopes answers `403 insufficient_scope` with the scope it needs (`AdminApiError.scope`). The dashboard's step-up rules (a recent sign-in, a second factor) are for people; a key's scopes are its authorization. - Every write is in the audit log as `key/`. Revoking a key in the dashboard takes effect on its next request. Each key is limited to 600 requests a minute (`429` with `Retry-After`; the client waits and retries twice), and repeated unknown keys from one IP are locked out for a while. - `admin.call('', { path, query, body })` reaches any operation a key may call, typed from the admin API's OpenAPI document (the operations marked `x-key-scopes`). - Server only: `createAdminClient` refuses to run in a browser. **Deleting an account from your app (GDPR).** When a user deletes their account in your app, delete your own data and then `admin.users.delete(sub)` with a `users:delete` key. Anchr.ing deletes the user with everything that belongs to them (sessions, refresh tokens, personal access tokens, passkeys and authenticator apps, linked identities, org memberships), sends `user.deleted` to every webhook endpoint and back-channel logouts to every app the user was signed in to, and keeps only a pseudonym in the audit log. Users can also delete their account themselves on the account page (`/account/data`): your app then hears about it through the same `user.deleted` webhook. [Example](https://github.com/anchring/oss/blob/main/sdk/examples/admin-client.ts). **Exporting your users (no lock-in).** A `users:export` key makes the same file the dashboard's **Users → Export** does: ids, emails and verification, names, metadata, status, linked logins, passkeys, organisations and roles, consent records. Formats: `anchring` (one JSON document that `POST /users/import` reads back, ids kept), `anchring-ndjson` (a header line, then one user per line) and `auth0` (Auth0's bulk user import JSON). Small projects are `ready` at once; larger ones are made in the background, so poll until they are. A file can be downloaded for 24 hours. ```ts let e = await admin.users.exports.create({ format: 'anchring-ndjson' }); while (e.status === 'queued' || e.status === 'running') e = await admin.users.exports.get(e.id); const file = await admin.users.exports.download(e.id); // Uint8Array: the file's bytes ``` Password hashes (`passwords: true`) and TOTP secrets (`totp_secrets: true`) need a person: the project owner, or a member the owner allowed, with a recent sign-in and a second factor, in the dashboard. A key asking for them gets `403 human_required`. Recovery codes are never exported. ## CI jobs (GitHub Actions OIDC) A CI job can call your API without a stored secret. Add a **CI trust** rule in the dashboard (**Apps → CI trust**): the provider (GitHub Actions), conditions on the job's OIDC token (repository, branch, environment, workflow file; `*` matches anything), and what the job gets: a machine identity, your API's audience and scopes. Every rule pins one owner exactly (`repository: acme/web`, `acme/*`, `repository_owner` or the numeric `repository_id` / `repository_owner_id`, which survive renames), so no rule trusts all of GitHub. ```yaml # .github/workflows/deploy.yml permissions: id-token: write # the job may ask GitHub for its OIDC token ``` ```ts import { exchangeCiToken } from '@anchring/auth/server'; const { accessToken } = await exchangeCiToken({ project: 'acme', env: 'prod', audience: 'acme-api' }); await fetch('https://api.acme.example/deploy', { method: 'POST', headers: { authorization: `Bearer ${accessToken}` } }); ``` `exchangeCiToken` asks GitHub for the job's token with your env's issuer (`https://acme.prod.anchr.ing`) as its audience, and exchanges it at the issuer's `/token` (RFC 8693, `subject_token_type` `urn:ietf:params:oauth:token-type:id_token`). Anchr.ing checks GitHub's signature (its published keys), `iss`, `exp`, `nbf`, `iat` and that the audience is this env's issuer (a token minted for another project or the other environment never matches), then the env's rules. The access token lives 10 minutes: `sub` is `ci:`, `aud` the rule's API, `scope` the rule's scopes (or the subset you ask for with `scope`), and `ci` says what ran (repository, ref, workflow, run id, sha). Your API's middleware sees `type: 'apiKey'`. Every exchange is in the audit log. Other OIDC CI providers (GitLab, Buildkite) work the same way once added to the server's provider list: pass the job's token as `idToken` (or `ANCHRING_ID_TOKEN`), minted with the issuer as audience. [Example](https://github.com/anchring/oss/tree/main/examples/ci/github-action). --- # Bring your own IdP (trusted issuers) **When you need this:** a customer (or you) already runs an identity provider (Entra ID, Okta, Keycloak, a home-grown OIDC server) and will not sign in through Anchr.ing's login, but you still want one token in your API with the same billing, flags, organisations and support. Your backend exchanges their token for one of yours. ## Bring your own IdP (trusted issuers) *Enterprise and Sovereign in production; test environments always.* A company that keeps its own identity provider (Entra ID, Okta, Keycloak, its own OIDC server) can still use billing, feature flags, organisations and support chat: its backend exchanges a token from that provider for a token from your Anchr.ing environment, and your API sees the same claims as for any other sign-in. Add the provider under **Settings → Trusted issuers**: its issuer URL (the `iss` of its tokens; keys are read from the discovery document, or give a JWKS URL), the audience its tokens carry, the claim that identifies the user (default `sub`) and, if you like, the claim holding the email and whether you trust the provider for it. **Test** fetches the keys and reports what it found. The exchange is RFC 8693 token exchange at the environment's `/token`, by a **confidential app** (an app with a client secret: the token says who the user is, the secret says the caller is your backend; public apps are refused): ```sh curl -X POST https://acme.prod.anchr.ing/token \ -u 'acme-backend:' \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \ -d subject_token= \ -d 'scope=openid profile email offline_access' ``` ```ts // Your backend (Node, Bun, Deno): after your own provider signed the user in. const res = await fetch('https://acme.prod.anchr.ing/token', { method: 'POST', headers: { authorization: `Basic ${btoa(`acme-backend:${process.env.ANCHRING_CLIENT_SECRET}`)}`, 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', subject_token: idpToken, // never log it scope: 'openid profile email offline_access', }), }); const { access_token, id_token, refresh_token, expires_in } = await res.json(); ``` The answer is a normal token response (`issued_token_type` is `…:access_token`): the access token is verified by `auth.verify` and the middleware like any other, with `sub` the Anchr.ing user id, your custom claims rendered (`{{billing.*}}`, `{{org.*}}`, metadata), an ID token when `openid` is asked for, and a refresh token when the app has the `refresh_token` grant (the exchange opens a session: refresh, revocation and back-channel logout work as for a sign-in; `sid` is in the tokens). The first exchange for a subject creates the user (email from the configured claim, unverified unless the provider is trusted for it; an address another user already holds is left off: a token never takes over an account); every later one finds the same user through the (issuer, subject) link, which the user's record shows and deleting the issuer removes. Checks on the subject token: signed by one of the issuer's published keys (RSA, EC or Ed25519 keys; `HS*` and `none` are refused), `iss` the configured issuer, `aud` holding the configured audience, `exp`, `nbf` and `iat` with a minute's skew and at most a day old, a non-empty subject claim. Any failure is `invalid_grant`, and failed exchanges count against the caller's address like failed sign-ins. Every exchange is in the audit log (`token_exchange`: the issuer, the client, the user; never the token). `unauthorized_client` with "not available here" means the plan is below Enterprise in production. The subject token's own lifetime does not carry over: the Anchr.ing tokens have the app's lifetimes. --- # Support access and impersonation **When you need this:** your support team must see what a user sees. Support access starts from the Anchr.ing dashboard with a reason, a time limit, a ceiling and an audit trail; impersonation tokens you mint yourself are for support tooling on your own server. Your API tells both apart from the user's own sign-in. ```ts const imp = auth.impersonation(); // browser: null for the user's own sign-in, else { actor, reason, expiresAt, deny } if (c.var.user.type === 'impersonation') log(c.var.user.actor); // API: who is really acting ``` ## Support access (impersonation) Support access is impersonation started from the Anchr.ing dashboard (Users → a user → **Act as user**), with a reason, a time limit, a ceiling and an audit trail, and the user is told. The full design, settings and audit actions: [the support access guide](https://anchr.ing/guides/support-access). The dashboard opens your app's **support access start URL** (Apps → the app) with a one-time code, `?anchring_impersonation=`: 60 seconds, single use, this app only. The browser client exchanges it on page load (any path) and removes it from the address bar; no token is ever in a URL. The session is the user's, with `act` naming who is really acting: ```ts const auth = createAuth({ project: 'acme', env: 'prod', clientId: 'prod_…' }); await auth.ready(); const imp = auth.impersonation(); // null for the user's own sign-in if (imp) { // { id, actor: { sub: 'platform/…', email }, reason: 'Ticket 4812', expiresAt (ms), deny: ['account:delete', …] } banner.show(`${imp.actor.email} from support is using this account until ${new Date(imp.expiresAt!).toLocaleTimeString()}: ${imp.reason}`); banner.onEnd(() => auth.endImpersonation()); // ends its session and every token at the issuer, then signs out here } ``` `autoCallback: false`? Call `auth.handleImpersonation()` on the start page. A website with a server exchanges the code with its secret: ```ts app.get('/support-access', async (c) => { const r = await web.impersonation(c.req.url); // null without a code if (!r) return c.redirect('/'); await saveSession(c, r.session); // r.impersonation: who, why, until when return c.redirect(r.returnTo); }); // later: web.impersonationOf(session), web.endImpersonation(session) ``` On your API the token verifies like the user's own, as `user.type === 'impersonation'` with `user.impersonator` (`{ id, actor, reason, expiresAt, deny }`) and `user.actor`. Guards then deny: - the project's ceiling, `user.impersonator.deny` (Settings → Support access in the dashboard; add your app's permission strings there), always; - the permissions your definition marks `destructive`, unless `impersonation: { allowDestructive }` opens them: ```ts export const access = defineAccess({ roles: { admin: ['projects:*', 'invoices:*'] }, destructive: ['projects:delete', 'invoices:refund'], // impersonation: { allowDestructive: ['projects:delete'] }, // or true }); ``` `auth.can` in the browser answers the same, so the UI can hide what support access may not do. Report the changes made under support access to the user's timeline in the dashboard (fire and forget, Hono): ```ts app.use('/api/*', auth.middleware(), auth.impersonationAudit()); ``` The `impersonation.started` / `impersonation.ended` webhooks tell your backend (`e.id` is `act.reason_id`). An API that must never accept support access: `createAuth({ impersonation: { support: false } })` (then `impersonation_rejected`). Tests: `issuer.accessToken({ impersonation: { reason, deny } })` from `@anchring/auth/testing` mints one. ## Impersonation Impersonation tokens you mint yourself are signed with **your** key: support tooling you build on your own server (the dashboard's support access above is the built-in alternative). The keys are configured only in your own server, in `createAuth({ impersonation: { keys, key } })`: `keys` are the public JWKs (each with a `kid`) that `verify` and the middleware accept, `key` is the private JWK used by `auth.impersonate`. Keep the private key in your server or KMS. There is nothing to register at Anchr.ing. ```ts const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', impersonation: { keys: [publicJwk], key: privateJwk, maxTtl: '1h' }, }); const token = await auth.impersonate({ actor: agent.sub, subject: user.sub, ttl: '15m', reason: 'ticket 4812' }); ``` The token carries the RFC 8693 `act` claim, so every API sees `user.type === 'impersonation'`, `user.actor.sub` (the agent) and `user.reason`; log them. `tokens.impersonation` permission ceilings apply automatically, and so do the `destructive` permissions of your definition. `auth.verify` and the middleware accept two signers: the project environment's issuer (JWKS; support access tokens among them) and your configured impersonation keys (issuer `/impersonation`). --- # Errors reference **When you need this:** an API answered `401`, `402` or `403` and you want to know why, or your code caught an error the SDK threw. The MCP server's `explain_error` tool answers from the same tables. ```ts // Middleware answers are JSON with an `error` (and often a `reason`); auth.verify throws the same codes. try { await auth.verify(token, { audience: 'acme-api' }); } catch (e) { if (e instanceof AuthError) log(e.code); // wrong_audience, expired, wrong_issuer, … } ``` ## Errors reference ### HTTP responses from the middleware (`authenticate`, Hono, Express) | Status | Body | `WWW-Authenticate` | Cause | | --- | --- | --- | --- | | 401 | `{ error: 'unauthorized' }` | `Bearer, DPoP algs="ES256 EdDSA"` | no `Authorization: Bearer …` / `DPoP …` header | | 401 | `{ error: 'invalid_token', reason }` | `Bearer error="invalid_token", error_description=""` | token rejected; `reason` below | | 401 | `{ error: 'invalid_dpop_proof', reason }` | `DPoP error="invalid_dpop_proof", …` | DPoP proof missing a check; `reason` says which | | 401 | `{ error: 'use_dpop_nonce' }` | `DPoP error="use_dpop_nonce"` (+ `DPoP-Nonce`) | only with `dpopVerification.nonce` | | 401 | `{ error: 'insufficient_user_authentication', reason }` | `…, max_age="…"[, acr_values="mfa"]` | `session_too_old` / `mfa_required` | | 403 | `{ error: 'forbidden', reason: 'ip_not_allowed' }` | | `allowIps` | | 403 | `{ error: 'forbidden', reason: 'missing_permission' }` | | `require` (middleware / `authenticate`): the token's roles don't grant it | | 401 | `{ error: 'session_revoked', reason }` | `Bearer error="invalid_token", error_description="session_revoked", reason="…"` | with `revocation`: the session ended ([reasons](https://github.com/anchring/oss/blob/main/sdk/sessions.md#when-a-session-ends-that-the-user-didnt-end)); sign in again | | 503 | `{ error: 'auth_unavailable' }` | | keys or PAT exchange unreachable (cold cache), or the session status with `failOpen: false`; retryable | `invalid_token` reasons (also `AuthError.code` from `auth.verify`): | Code | Meaning | | --- | --- | | `missing_token` | empty token | | `invalid_token` | malformed, bad signature, a failed claim check, not an access token (`typ` other than `at+jwt`), or an unbound token with the `DPoP` scheme | | `dpop_required` | a DPoP-bound (`cnf.jkt`) token sent as `Bearer` or without a proof | | `expired` | past `exp` (plus clock tolerance) | | `wrong_audience` | `aud` doesn't include your audience | | `wrong_issuer` | not this project environment's issuer (or your impersonation issuer); this is what a test JWT at a prod API gets, or the reverse, since each env is its own issuer | | `wrong_env` | a PAT for the other env (`anchr_pat_test_…` at a prod API, or the reverse), refused without a network call; or a token from this issuer whose `env` claim is not your `env` | | `unknown_key` | `kid` not in the JWKS, even after a refetch; or, for an impersonation token, not one of your configured impersonation keys | | `impersonation_rejected` | impersonation token but no impersonation keys configured, no `act.sub`, lifetime over `maxTtl` or `iat` in the future; an `act` claim in a token from the issuer that is not a support access (`act.reason_id` and `imp`); or a support access token with `impersonation: { support: false }` | | `unsupported_claims_version` | the token's `cv` (claim-schema version) is newer than this SDK reads, or not a positive integer: update `@anchring/auth` ([Token types](https://github.com/anchring/oss/blob/main/sdk/server.md#token-types)) | | `invalid_pat` | middleware only: the PAT is unknown, revoked or expired (exchange refused) | | `dpop_unsupported` | `authenticate` only: an `Authorization: DPoP …` header but no `dpop` option was passed (the Hono and Express middleware always pass it) | Permission guards: `401 unauthorized` (no user), `402 plan_required` (a guard's `plan` or `entitlement` the token's billing does not have), `404 not_found` (loader returned nothing, or denied with `denyStatus: 404`), `403 forbidden`. ### Thrown errors | Class | Thrown by | Fields | | --- | --- | --- | | `AuthError` | `auth.verify` | `code` (table above) | | `DpopError` | `auth.verify` with `dpop` | `code` (`invalid_dpop_proof` / `use_dpop_nonce`), `nonce?` | | `PolicyError` | `auth.verify` with `policy` | `violation: { code, maxAge? / ip? }` | | `SignInRequiredError` | `token`, `fetch`, `authHeaders` | no session, or refresh failed: call `signIn()` / `device.signIn()`; `reason` says why the session ended, when known | | `OAuthError` | `ready()` / `handleCallback()` after a failed sign-in redirect, `signInWithPopup`, `device.signIn`, refresh | `error`, `description`, `status` (e.g. `access_denied`, `expired_token`), `reason` (a refused refresh: why the session ended) | | `PopupError` (an `OAuthError`) | `signInWithPopup`; `ready()` in a popup whose opener is gone | `error`: `popup_blocked`, `popup_closed`, `popup_timeout`, `popup_unreachable` | | `PatExchangeError` | `exchangePat`, `createPatExchanger` | `error` (`invalid_grant`, …), `status` | | `LogoutTokenError` | `backchannel.verify` | `code`, message | | `TypeError` | `createAuth` and friends | invalid or missing project / env, missing audience, clientId or webhook secret: fail at startup | --- # Testing your app **When you need this:** tests of your API and web app that run without the auth service, test users or a browser: a local issuer signs tokens the real `verify`, middleware and guards accept. ## Testing your app `createTestIssuer` gives your tests a local issuer: it signs access tokens shaped like the service's and answers discovery and JWKS through its own `fetch`. Hand that `fetch` to `createAuth` and the real `verify`, middleware, policies and permissions accept its tokens. No network, no test users, no logins in CI. ```ts import { createAuth } from '@anchring/auth/server'; import { createTestIssuer } from '@anchring/auth/testing'; const issuer = await createTestIssuer({ project: 'acme', audience: 'acme-api' }); const app = buildApp(createAuth({ ...issuer.config, audience: 'acme-api' })); // your app, given its auth await app.request('/api/me', { headers: await issuer.headers({ sub: 'user_1', email: 'ada@acme.example' }) }); await issuer.accessToken({ amr: ['passkey'] }); // passes requireMfaWithin await issuer.accessToken({ type: 'apiKey' }); // a PAT: permissions apply the apiKey ceiling await issuer.accessToken({ ttl: -60 }); // expired await issuer.accessToken({ claims: { org_id: 'org_1' } }); // what your claims templates add ``` Token options: `sub` (default `user_test`), `audience`, `email`, `name`, `type` (`session` or `apiKey`), `scopes`, `amr` (default `['pwd']`), `authTime`, `ttl` (default 10 minutes), `sid`, `clientId`, `claims`. Pass `now` to `createTestIssuer` to share a fake clock with your app. The whole example is in [`examples/testing.ts`](https://github.com/anchring/oss/blob/main/sdk/examples/testing.ts). Only use `issuer.fetch` in tests: an app given it trusts the test key instead of the service's. **The code flow and back-channel logout.** The test issuer also has a token endpoint (authorization code with PKCE S256, `client_secret_basic` or `client_secret_post`, refresh rotation, revocation), so a `web` app's callback route runs end to end against it, with the real `webApp()`: ```ts const issuer = await createTestIssuer({ project: 'acme', audience: 'acme-api', client: { id: 'test_web', secret: 's3cret' } }); issuer.user({ sub: 'user_1', email: 'ada@acme.example', claims: { github_id: 42 } }); // claims: your access-token claims const auth = createAuth({ ...issuer.config, clientId: 'test_web' }); const web = auth.webApp({ clientSecret: 's3cret', redirectUri: 'http://localhost:3100/auth/callback' }); const { url, transaction } = await web.signIn(); // what your login route does const callback = issuer.callbackUrl({ user: 'user_1', signInUrl: url }); // where the browser would land const { session } = await web.callback(callback, transaction); // session.claims.github_id === 42 await auth.backchannel.verify(await issuer.logoutToken({ sid: session.claims.sid as string })); ``` `issuer.callbackUrl` reads `redirect_uri`, `state`, `nonce` and the PKCE challenge from the sign-in URL and returns the callback URL with a single-use code; `issuer.authorizationCode({ user, redirectUri, nonce, codeChallenge })` mints one by hand, `issuer.idToken()` and `issuer.logoutToken({ sub?, sid? })` sign the other two token kinds with the same key. A user given by `sub` that was not registered with `issuer.user` signs in with no extra claims. The access token's audience is the issuer's `audience`, else the client id. `jose` is the SDK's own dependency. A test that imports `jose` itself (to decode a token, say) adds it to the app's `devDependencies`; a `file:` link to a built SDK does not hoist it. --- # Going live **When you need this:** your app works against the test environment and real users are next: import existing users, promote the configuration to production, switch the app's environment, check. ```sh # What changes in the app's environment after the promote (the code stays the same): AUTH_ENV=prod # servers and CLIs; browsers: VITE_AUTH_ENV / NEXT_PUBLIC_AUTH_ENV VITE_AUTH_CLIENT_ID=prod_… # the prod client ids the promote gave each app ANCHRING_WEBHOOK_SECRET=anchr_whsec_… # the prod webhook's own secret, shown once in the promote result ``` ## Going live 1. Import users: `anchring users import --tenant acme --env prod users.json [--dry-run]` (operator CLI), or **Users → Import** in the dashboard (`POST /api/admin/users/import`). Each row: `email`, `email_verified` (alias `verified`), optional `name`, `password_hash` (bcrypt or argon2), `metadata`; the full format, dry-run and exit codes are in the [Going live guide](https://anchr.ing/guides/going-live). Coming from Auth0, Clerk, Firebase, Supabase, Cognito or Keycloak: `--from ` (or pick the source on the Import page) takes their export as it is, with password hashes where it has them and linked social logins; create the social connections first so those logins link. A row without an email is a social-only user keyed by its identities: `{ "name": "Ada", "identities": [{ "connection": "discord", "id": "80351110224678912", "username": "ada" }] }` (`preset` instead of `connection` picks the environment's one connection of that preset); it is created when an identity links, skipped when that identity is already linked, and that provider's first sign-in claims it. Import into **test** first and check there (sign in as an imported user); the users come along to production with the next step. Passwords carry over where the provider exports the hashes; where it doesn't (Cognito; Auth0 without the hash file), email every imported user a link to set a new one: the import's **Email everyone a link to set a password** in the dashboard, or `admin.users.imports.passwordMails.queue(batchId, { days: 7 })` with a server key (`users:write`). Each user gets one mail in the project's name, saying their account moved, why, with a link that works for `days` (default 7) and that nothing changes if they ignore it; about 100 leave per minute, users already mailed are skipped unless `resend`, and `admin.users.imports.passwordMails.status(batchId)` follows them (queued, sent, failed, links opened, passwords set). The batch is the unit, after promote too. 2. **Promote to production** in the dashboard (apps with their audiences and claims templates, webhooks, social connections, settings and branding, metadata schema, and the users you imported into test: same ids, passwords, authenticator apps and social logins; passkeys stay behind; users made in test by hand never go). You confirm a summary and your second factor, and a promote can be undone for 7 days. Prod apps get new `prod_` client ids and prod webhooks get their own signing secrets, shown once in the result. Webhook URLs are copied as they are: replace an `http://localhost` test receiver with the real one in prod. 3. Set `AUTH_ENV=prod`, swap the test client ids for the prod ones and set `ANCHRING_WEBHOOK_SECRET` to the prod webhook's secret. Audiences (and so `pat.audience`) stay the same. If `NODE_ENV` is `production` and `env` is still `test` (`AUTH_ENV ?? 'test'` fell back), `createAuth` logs a warning once: that API would accept test sign-ins. 4. Browser apps: move to `refresh: 'cookie'` if you haven't yet (see [Keeping browser tokens safe](https://github.com/anchring/oss/blob/main/sdk/browser.md#keeping-browser-tokens-safe)). 5. Check: sign in, call the API, run your CLI's `login`, and watch events arrive in the webhook log.