← Auth / Security

Authentication

auth / security · workzone
1 Entry channels Which channel the sign-in comes through.
Web UI
Admin Panel and Web App · email + password → /login → JWT. The only password-based sign-in.
Slack → Slack
Slack ID → identity_mapping (source='slack') → user: auto-match by workspace email or a link code.
Telegram → Telegram
Telegram ID → identity_mapping (source='telegram') → user: link code only — Telegram doesn't expose email.
Mattermost → Mattermost
Mattermost ID → identity_mapping (source='mattermost') → user: link code only — events carry no email.
MCP → MCP
API key (headless) or OAuth (interactive, v2) → user_id.
Browser Extensionv2 → Browser Extension
OAuth or a token, stored in the extension.
Principle: each channel authenticates in its own way, but all converge one user_id role ACL.
2 Identification and registration Where the user_id comes from.
Admission model — who vouches → User onboarding
How a user enters the system is determined by the trust anchor — who vouches for them. Three anchors, in increasing order of scale:
· admin → invitation (invite flow), one at a time or as a CSV batch — the safe default;
· corporate IdPSSO, auto-provisioning on sign-in — the scale ceiling, v2;
· company email → domain self-join (anyone with a domain address), v2.
Decision: the floor is control (invite, closed default), the ceiling is delegation to the IdP (SSO). Reading the knowledge base is sensitive in itself, so there is no public self-registration; domain self-join is not a step between the two but an option behind an explicit toggle (never default-on, with safeguards: verified domain ownership, an approval queue, and the Member role by default).
Setup Wizard — first launch → Setup wizard
With 0 users → /setup. The Owner sets an email + password. Once created, /setup returns 404.
race-condition guard
An advisory lock on bootstrap — creating the first Owner. A single key guards both entry points (Setup Wizard and init-owner).
CLI init-owner
achilles init-owner --email … --password …. Only with 0 users. For CI/CD.
race-condition guard
The same bootstrap path as the Setup Wizard — guarded by the same advisory lock.
An Owner / Admin creates an invitation (email + role) → a single-use invite token (48 h) → the link is sent by email via SMTP → registration (name + password). Email is the only delivery channel; the link is never handed out manually — delivery itself serves as email verification.
Only an Owner can assign the Owner role in an invitation — an Admin can invite only Members (the server responds 403 FORBIDDEN).
Until SMTP is configured, no invitation can be created: the UI disables the button and the server mirrors the guard — 409 SMTP_NOT_CONFIGURED.
Bulk issuance (CSV) is a batch of single-use tokens of the same mechanics; delivery is handled by the Email transport (background queue, safe pacing), and the import is idempotent — re-uploading the same list creates no duplicate invitations. UI — bulk import.
Admin-initiated reset
An Owner resets Admins / Members, an Admin only Members, via Admin Panel.
An Owner's own password is reset by a second Owner, or, if there is none, by a CLI on the server (achilles reset-password --email …).
mechanism
The primary path is a single-use reset link by email (TTL 1 h; the DB stores only the hash), sharing the machinery of the “forgot password” flow; refresh tokens are revoked when a new password is set.
The fallback without configured SMTP is a temporary password (CSPRNG, shown to the admin once); all refresh tokens are revoked immediately, and the user can change their password themselves in their profile.
Forced change of the temporary password on first sign-in — signing in with a temporary password leads to the forced-change screen; access to the application is closed by a server-side gate until a permanent password is set. The must_change_password flag and the gate mechanics — data model.
Admin-initiated email change
An admin changes an employee's email from the user card — on the trust of their role, without a confirmation email. The new address goes through lower() normalization and a uniqueness check (address taken → 409 CONFLICT). All the user's refresh tokens are revoked — they will sign in again with the new address, while an already-issued access token lives out its ≤15-minute window. The action is written to audit_log (action user.email_change, old → new address in meta). The gate is a confirmation modal (re-auth — v2). An email change re-evaluates the identity mapping (v2).
“Forgot password” reset → Password recovery
Handled over email. A single-use token (1 h); a repeat request revokes the previous one — only the latest is active, and an expired and a used token are indistinguishable to the client. Refresh tokens are revoked when a new password is set. Requires configured SMTP — without it there is no “forgot password?” link on the sign-in screen.
Last-Owner protection
Deletion / deactivation / demotion of the only active Owner is forbidden. A business-logic-level guard.
Email verification
The Owner's email is accepted on trust; invited users are verified by delivery — the invite link is sent only by email to the invited address. The user does not change their own email — it is the login identifier. An employee's email change is performed by an admin from the user card, on the trust of their role (without a confirmation email); refresh tokens are revoked and the action is written to the audit log.
3 Password verification → Sign-in screen Confirming identity.
Email + password
Hashing — argon2id; the strength parameters are set in the crypto core.
User model — SSO readiness
The schema is SSO-ready from the start: the password is optional and the sign-in provider lives on the account — plugging in Okta / Azure AD requires no migration. Sign-in is a swappable strategy (local → IdP). Fields — data model.
SSO / OIDCv2
SAML / OIDC (Okta, Azure AD, Google Workspace). Auto-provisioning of users from the IdP. The redirect exchange with the provider is protected against sign-in forgery by CSRF.
redirect protection
state — a random request marker, checked on return from the IdP; if it doesn't match, sign-in is rejected.
PKCE — binds the authorization code to the sign-in initiator so an intercepted code cannot be exchanged (important for the browser extension).
nonce — ties the issued ID token to a specific sign-in request.
TOTP, ±1-step tolerance (±30 s) for clock drift. An accepted code is single-use: the server stores the last used time step and rejects a code replay within the tolerance window (RFC 6238 §5.2). Whether it is required is an org setting: required by default for Owner / Admin, optional for Members. Recovery codes (single-use).
secret storage
mfa_secret is stored encrypted — the cipher and key handling live in the crypto core.
Password policy — NIST 800-63B → Admin Panel
A single rule set — the source of truth for registration, invite, and password change and reset. All conditions must pass:
8 ≤ len ≤ 128 Length — the only size constraint
zxcvbn ≥ 3 Strength — a 0–4 strength score; below 3 — reject
not in HIBP Breaches — a check against the database of compromised passwords, k-anonymity (only the hash prefix is sent). HIBP unavailable → the check is skipped (fail open), length and zxcvbn still apply, and a warning is logged
composition rules (mandatory digit / symbol) forced rotation
Change password POST /api/v1/auth/password/change → Profile screen
Requires a valid access token.
current + new argon2 verify of the current validation of the new against the rule set hash UPDATE user invalidation of all refresh tokens except the current session.
New ≠ current — rejected. Audit: a record in the audit log on both success and failure. Brute force: an incorrect current is counted by the per-account brute-force counter (delay + alert) — a stolen access token does not turn the endpoint into a password-guessing tool.
4 Token → Admin Panel → Cache & Workers How an already-completed sign-in is proven.
Access + Refresh tokens
Access — stateless JWT, 15 min.
Refresh — 30 days sliding, absolute cap 90 days.
JWT signing → Crypto core
HS256, a single SECRET_KEY; verification with a hard-coded algorithms=["HS256"] (protection against alg substitution).
The header carries a kid for the active key: there is only one key now, but it is in place from the start so a future rotation won't invalidate already-issued tokens.
JWT claims
sub (user_id) · role · exp · iat · jti (logging and revocation) · iss ("achilles") · aud ("achilles-api").
claim freshness
role in the token is a snapshot at issue time; role demotion and deactivation take effect after the access token is refreshed (the shared 15-minute window of the stateless model). Critical checks (user management, security settings) read the role and status from the DB, not from the claim.
5 Session Token lifetime, refresh, and storage.
“Remember me” → Sign-in
The checkbox controls the lifetime of the cookie, not the token:
  • Unchecked → session cookie (no Max-Age), removed when the browser closes (shared machines).
  • Checked → persistent cookie with Max-Age = refresh token TTL (30 d).
In both cases the refresh token in the DB lives by its own TTL.
Refresh token rotation
On every refresh — a new token, the old one is invalidated. Grace period ~10 s: within this window a just-rotated token is accepted again and returns the same new one — a benign tab race is harmless. Presenting an old token outside the window is reuse detection → revoke the entire chain (family), not all of the user's sessions.
Access token in the SPA
Kept in JS memory (not localStorage) — protection against XSS. Sent via Authorization: Bearer. On reload, restored via /refresh.
Refresh queue — concurrent 401
A single shared /refresh for all requests that hit an expired token. The interceptor holds the Promise of the current refresh (or null):
  • 401 while a refresh is already in flight → await the same Promise.
  • 401 with no refresh in flight → start one, store the Promise.
  • Success → all waiters retry their request with the new token.
  • Failure → reject all, logout.
Safeguards:
  • Retry exactly once — a _retry flag on the request. A 401 on an already-retried request → immediate logout, no new refresh round (loop protection).
  • Only a 401 from an expired access token triggers a refresh. A 401 from /refresh itself → the refresh token is dead → logout without recursion; a 401 on permissions passes through without a refresh.
  • A timeout on the refresh Promise — a hung request rejects all waiters and leads to logout rather than holding them forever.
  • Across tabs, a single-use refresh token may be sent twice. The rotation grace period ↑ Refresh token rotation absorbs the race: the late tab gets the same new token and carries on.
Concurrent sessions
A session = one token family — a single sign-in from a device or browser; an active session is a family with a live refresh token. The number of concurrent sessions is unlimited; visibility and control depend on role:
  • User — own sessions, self-defense. Sees all their sessions and can end any but the current one — one at a time or “all others” (protection when compromised). The card carries the device, IP, and last activity (from the refresh token). Screen — session management.
  • Admin — others' sessions, without private details. The owner's device, IP, and geo are not shown to the admin (private data, not needed to respond): the user card shows only the active-session count and a blunt “end all”. It deletes all refresh tokens — no new access tokens are issued, while an already-issued stateless access JWT lives out its 15-minute window (↑ JWT claims). When the suspicious session is known but the owner is not, the audit log names the user (auth events carry the session id), and from there — the same card.
what v2 adds
Per-user session limit (default 5, configurable in Admin Panel; on overflow the oldest is ended automatically).
Sign-in alert from a new device / geo — an in-UI banner plus an email.
Instant revocation of an access token via a jti blacklist in Redis — without waiting out the 15-minute window.
Invalidation on deactivationv2
Revocation of refresh tokens + the access token's jti in a Redis blacklist (TTL = access token lifetime).
Re-authentication — sudov2 → Re-auth modal
  • Critical actions (password change, user management, security settings, ending sessions) require re-entering the password — even within an open session.
  • With MFA enabled, the check accepts a TOTP code.
  • After success — a grace period (a few minutes, tied to the current session): adjacent critical actions pass without a repeat prompt, then the check closes again.
  • An incorrect entry counts under the same rate limit as sign-in.
⎋ Session termination → Session management
Standard: delete the refresh token from the DB (identified by the refresh cookie) + clear the httpOnly cookie. The access token expires on its own (15 min). The “Sign out” button — admin header. All devices: delete all of the user's refresh tokens — by the user themselves, from the session-management UI. Forced: the same “all devices”, but by an admin — from the user card. All the user's refresh tokens are deleted; no new access tokens are issued, and an already-issued stateless access JWT lives out its ≤15-minute window.
6 Lifecycle and ACL → Admin Panel user_id → role → permissions and account state.
Statuses
active (default after registration)
deactivated (disabled by an admin).
Field — in the data model.
Deactivation — who can
Owner → anyone (except the only Owner). Admin → Members only. Self-deactivation is forbidden.
Deactivation — what happens
All refresh tokens are deleted, all the user's API keys are revoked (machine access dies instantly). The access token is stateless and lives to expiry — the shared 15-minute window of the stateless model. The owner's personal agents are disabled (enabled=false) so background runs don't burn budget under a departed user's account — Agent Engine.
v2
jti in a Redis blacklist for instant invalidation of the access token.
Reactivation
Same permissions as for deactivation. The user can sign in again. Tokens, API keys, and agents are not restored — new ones are needed, and the owner re-enables agents themselves.
Deletion
Deletion is a hard delete: physically erasing the row, Owner only, irreversible. Soft delete (a flag marker that keeps the row) is deliberately not introduced — its role is already played by deactivation, a separate account-state mechanism. The cascade removes only the user's auth data (tokens, keys); source content and audit_log are untouched.
user_id role ACL · access granted
API keys
side entrance → MCP → Public API → Admin Panel → HTTP API
key → user_id → role / ACL
Storage
The key is shown once — at creation. Only its SHA-256 hash and the ach_xxxx prefix remain in the database.
Binding and scope restriction
Key → user_id → the user's role and ACL; a key never exceeds its owner's permissions — even one issued by an admin.
details
The scope narrows along two axes: access — read-only (writing is Agent Engine's territory, not a key's); sources — by default all available to the owner, narrower if desired. A user can have several keys.
Lifetime and rotation
Lifetime — 30 / 90 / 365 days or never-expiring; on expiry the key stops working on its own, no revocation needed. An expiry reminder 7 days ahead.
rotation
Create a new one → verify → revoke the old.
Creation and revocation
The user creates and revokes their own keys — in their profile or via CLI sign-in. Owner and Admin see everyone's keys and can revoke any; revocation applies instantly via Admin Panel. When a user is deactivated, all their keys are revoked automatically.
Admin issuance · search
When issuing a key for an employee, the Owner / Admin picks the user via type-ahead search. The request goes to the backend from the 2nd character typed and is debounced; the server returns the top 10 matches by name and email.
Rate limiting
Per-key limit — 60 req/min.
resolves to user_id
the key resolves to user_id → then the usual path: role → ACL
OAuthv2
side entrance → MCP
sign-in → token → user_id → role / ACL
Purpose
Interactive sign-in for clients next to the user: AI assistant, IDE, CLI. Connect without manually copying a key.
Sign-in flow
The client opens the sign-in page, the user approves access — the client receives a token and stores it itself.
details
achilles login opens the browser; after consent the token is saved locally, and the client then works without the user's involvement. The protocol is OAuth 2.1, as the MCP specification describes.
Tokens → Tokens
A short-lived access token and renewal via refresh — the shared session mechanism.
OAuth or key
OAuth — for interactive clients; an API key — for headless (CI, server, background jobs) where there is no one to approve the sign-in.
resolves to user_id
the token resolves to user_id → then the usual path: role → ACL