Authentication
The two ways into the platform: Salesforce SSO for organization users, and CLI-provisioned passkeys for platform operators. Covers sessions, JWTs, and route protection.
On this page
TL;DR: There are exactly two ways into the platform, and no self-service registration. Organization users sign in with Salesforce SSO, which provisions their account on first login. Platform operators are created from the CLI and enrol a passkey through a setup link. No passwords are created or stored anywhere.
Why There Is No Registration Page
Self-service registration was removed. It created a third way for an account to exist, and every account it made had to be reconciled against an organization, a seat, and a Salesforce identity afterwards — reconciliation that had no automatic path and no owner.
The two remaining doors each have a clear authority behind them. Salesforce decides who is an employee; a platform operator decides who operates the platform. Neither requires a signup form.
Path 1 — Organization Users: Salesforce SSO
This is how everyone who is not a platform operator signs in.
The user clicks Sign in with Salesforce on /admin/login. The platform runs
an OAuth 2.0 authorization-code flow with PKCE against your Salesforce org, so
your org's own login policies — including MFA — apply unchanged.
On the callback, the platform:
- Exchanges the code and reads verified
userinfoclaims. - Gates them: the email must be present,
email_verified, and on an allow-listed domain. A failure never creates an account. - Resolves the identity — returning login, merge onto an existing account by verified email, or just-in-time provisioning against the organization's seat allocation.
- Records the Salesforce Username, which later authorizes per-user Salesforce tool calls.
- Mints a session JWT and sets it as the
access_tokencookie.
First login creates the account with no admin step, provided the domain is
allow-listed and a seat is free. Failures return to
/admin/login?sso=<reason>; the reasons are tabulated in
Salesforce App Setup.
Path 2 — Platform Operators: CLI + Passkey
Operators are created out-of-band. There is no way to self-provision one.
# 1. Create the account
systemprompt admin users create --name "Jane" --email jane@astounddigital.com
# 2. Grant the admin role
systemprompt admin users role promote jane@astounddigital.com admin
# 3. Mint a one-shot passkey setup link
systemprompt admin users webauthn generate-setup-token --email jane@astounddigital.com
The third command prints a copy-paste URL of the form
{api_external_url}/auth/link-passkey?token=…, valid for 15 minutes by default
(--expires-minutes to change). Send it to the operator through a channel you
already trust; they open it, create a passkey, and sign in with that passkey
from then on.
The role is baked into the JWT at issue time, so an operator promoted while signed in must sign out and back in before the change takes effect.
Passkey Sign-In
Passkey authentication uses public-key cryptography. The browser generates a key pair bound to this domain; the private key stays on the device or in the user's password manager. The server stores only the public key, verifies a signed challenge, and issues an OAuth 2.0 session token via PKCE.
Lost Passkey
There is no self-service recovery — magic links were removed, and no email
service is configured to deliver them. An operator who loses passkey access
needs another operator to mint a fresh setup link with the same
generate-setup-token command. Keep more than one operator account so this is
never a single point of failure.
Session Management
| Property | Value |
|---|---|
| Cookie name | access_token |
| Token format | JWT |
| Default expiry | 3600 seconds (1 hour) |
| Cookie flags | path=/, HttpOnly, SameSite=Lax, Secure on HTTPS |
| Required scopes | user or admin |
Every admin request passes through two middleware layers. User context
middleware extracts and validates the JWT, then loads the user's roles and
department into a UserContext. Auth check middleware rejects protected
routes without a valid user ID, returning HTTP 401.
UserContext carries user_id, username, email, roles, department, and
is_admin.
To sign out, clear the access_token cookie.
Public vs. Protected Routes
| Route | Access |
|---|---|
/admin/login |
Public |
/admin/auth/salesforce/* |
Public — the SSO start and callback |
/auth/link-passkey |
Public — consumes a one-shot setup token |
/admin/* (everything else) |
Requires a valid session |
/bridge-auth/* |
Requires a valid session |
Pages requiring admin privileges perform an additional is_admin check and
return HTTP 403 if the caller lacks the role.
System-originated actions
Every action recorded by the platform — including scheduled jobs, hooks, and MCP-server invocations — traces to a real users row. There is no separate "system user" or synthesized principal. The platform refuses to attribute work to an invented identity.
How ownership is declared
Each scheduled job in services/scheduler/config.yaml carries an explicit owner: field naming an existing admin user:
- name: publish_pipeline
extension: web
owner: admin
schedule: "0 */15 * * * *"
enabled: true
At startup the scheduler resolves owner: to a users.id. If the named user does not exist or is inactive, startup fails loudly — the platform refuses to run with unowned jobs. To change ownership, edit the YAML and restart.
How attribution flows
The resolved owner becomes JobContext.actor for every execute() call. Job implementations consume it through ctx.actor() and pass it to any audit-row write. Governance audit rows carry three fields that together give full forensic clarity:
| Column | Meaning |
|---|---|
user_id |
The accountable principal — a real users.id. |
actor_kind |
The surface that ran the action: user, job, mcp. |
actor_id |
A label for that surface (job name, MCP server name, etc.). |
A direct human action shows as (user_id = alice, actor_kind = 'user', actor_id = 'alice'). A scheduled job owned by Alice shows as (user_id = alice, actor_kind = 'job', actor_id = 'publish_pipeline'). Same accountability column, different surface, queryable separately:
SELECT actor_kind, user_id, COUNT(*)
FROM governance_decisions
GROUP BY actor_kind, user_id;
Why no separate "system" user
A dedicated "system" identity would be either a synthesized principal (impersonation) or a backdoor account with no real human accountability. Neither passes the "every action traces to a real user" bar. The designated owner is a normal admin who legitimately authorized the platform's existence by installing it — same accountability model as a unix crontab. Compromising the designated owner is exactly as bad as compromising that admin's credentials directly; there is no additional power and no amplification path.