Skip to main content

Authentication

How you get into the Customertimes platform: WebAuthn passkeys wrapped in a local OAuth 2.0 + PKCE flow, self-registration, magic-link recovery, HttpOnly session cookies, the desktop bridge device link, and route protection.

TL;DR: The platform stores no passwords. Everyone signs in with a passkey (WebAuthn) on /admin/login. The passkey challenge is wrapped in a local OAuth 2.0 Authorization Code flow with PKCE, and a successful sign-in ends with a JWT access token in an HttpOnly session cookie. Registration at /admin/register is open on this evaluation instance and the registrant chooses admin or user; an admin can also create users, and the person then registers a passkey against the same email. Roles and the user's project (Commerce or Core) are edited by an admin in the console, and they decide what a signed-in user can reach.

The desktop bridge is not a second login: linking a device sends you through the same browser sign-in and mints a device credential from the session that sign-in created.

Signing in with a passkey

When you open /admin/login there is no username or password form. The page starts the OAuth flow immediately, the browser prompts for your passkey (Touch ID, Windows Hello, a security key, or a platform authenticator), and on success you land on the dashboard.

State What you see
Loading A spinner with "Processing..." while the flow runs
Error A red message describing what went wrong
Retry A "Try again" link that restarts the flow

How the flow works

  1. Start — The login page generates a random PKCE code verifier and a SHA-256 code challenge, both held in sessionStorage.
  2. Authorise — The browser is sent to the platform's own OAuth authorisation endpoint with the challenge, a CSRF state value, the marketplace-admin client id and the user scope.
  3. Passkey challenge — The authorisation endpoint issues a WebAuthn challenge; the browser signs it with the passkey enrolled for your email.
  4. Callback — On success the browser returns to /admin/login?code=....
  5. Token exchange — The page swaps the authorisation code and the original code verifier for tokens.
  6. Session — Core sets access_token and refresh_token as HttpOnly cookies and records a server-side session.
  7. Redirect — You go to the dashboard, or to the ?redirect= target you arrived with.

If you visit /admin/login while already holding a valid access token, the page skips the flow and redirects you straight to the dashboard.

Registration

Open registration

/admin/register asks for a name, an email and the role to explore: user or admin. This evaluation instance keeps registration open, exactly as the systemprompt.io template does, so anyone can create an account and see the platform from either side. Registration issues a one-shot setup token and sends you to /admin/add-passkey to enrol your first passkey. Until a passkey is enrolled the account cannot sign in.

The admin user the deploy bootstraps (ed@systemprompt.io) is claimed the same way: register with that email and the passkey attaches to it.

Admin-created accounts

An admin can also create a user at /admin/access/users with their name and email, and set the role and project on the detail page. The person then opens /admin/register and registers with that same email, which attaches the passkey to the existing account and keeps what the admin set.

Closing registration

Set security.allow_registration: false in the profile when the evaluation is over. /admin/register still renders, but the register call is refused for any email that is not already an admin-created account; see User & Access Management.

Roles and project

Roles are not carried in the session token. They are read from the user record on every request, so promoting or demoting someone takes effect on their next request with no sign-out. The OAuth scope minted into the JWT is fixed at issue time, so a change that widens the scope itself still needs a fresh sign-in.

Every non-admin belongs to exactly one project, Commerce or Core, set by an admin on the user's detail page. The project is what dashboards, listings and reports are split by. Entitlement to MCP servers, plugins and gateway routes is role-based, from services/access-control/roles.yaml.

If you lose the device holding your passkey, a magic link gets you back in so you can enrol a new one.

  1. RequestPOST /admin/api/magic-link/request with your email.
  2. Token — If the email belongs to an existing user, a random 32-byte token is generated and its SHA-256 hash stored with a 15-minute expiry.
  3. Delivery — No SMTP is wired by default. The token is written to the server log with the requesting email, so an operator reads it from systemprompt infra logs view and passes it to you out of band.
  4. ValidatePOST /admin/api/magic-link/validate with the token consumes it (single use) and establishes a session, from which you can add a passkey.
Property Detail
Token length 32 random bytes (64 hex characters)
Storage SHA-256 hash only
Expiry 15 minutes
Usage Single use
Rate limit 3 tokens per email, and a per-IP limit, in a 15-minute window
Enumeration protection The same response whether or not the email exists

Operator recovery from the CLI

Recovery does not depend on the login page. An operator with database access can create an account, grant the admin role, and mint an admin session directly:

# Create the account and grant the admin role
systemprompt admin users create --name "Jane" --email jane@customertimes.com
systemprompt admin users role promote jane@customertimes.com admin

# Mint a CLI admin session against the instance database
systemprompt admin session switch production   # or the profile you are recovering
systemprompt admin session login

systemprompt admin users webauthn generate-setup-token issues a fresh passkey-enrolment token for an existing account, and systemprompt admin bridge issue-code mints a device-link code headlessly. These, and a personal access token minted from an existing admin session, are the platform's break-glass credentials: every one of them needs database access or a session that already exists.

Linking the desktop bridge reuses the browser session rather than asking for a second credential:

  1. The bridge opens /bridge-auth/device-link in your browser. If you are not signed in you are sent to /admin/login first and return afterwards.
  2. The page shows the device and asks you to approve or deny it.
  3. On approval the platform mints a bridge credential (a personal access token and a device certificate) bound to your account and hands it back to the bridge. The link code is single use with a 10-minute TTL.

Headless or SSH installs use customertimes-bridge login --no-browser with a code from your profile page or from systemprompt admin bridge issue-code. See Install the Desktop Bridge.

Revocation

An admin disables or deletes a user from /admin/access/users, or revokes their sessions and tokens from the user's detail page. A refused login also tears down what the account already held. In each case the platform revokes the user's:

  • browser sessions (user_sessions),
  • bridge PATs (user_api_keys) — the desktop app must be linked again,
  • enrolled device certificates,
  • any unredeemed bridge device-link code.

Rows are marked revoked, never deleted: what someone held and when it was taken away is part of the audit trail.

A gateway token already minted stays valid for its hour. Revocation stops the next one, so treat "the bridge is dead" as true within the hour, not within the second. For an immediate cut, disable the account.

Session Management

Property Value
Cookie names access_token, refresh_token
Token format JWT
Default expiry 3600 seconds (1 hour) for the access token
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 project into a UserContext. Auth check middleware rejects protected routes without a valid user ID, returning HTTP 401.

UserContext carries user_id, username, email, roles, project, and is_admin.

To sign out, clear the session cookies; the login page does this when it starts a new flow.

Public vs. Protected Routes

Route Access
/admin/login Public
/admin/register, /admin/api/register Public — open registration with a chosen role; closed by allow_registration: false
/admin/add-passkey, /admin/verify-pending Public — passkey enrolment with a setup token
/admin/api/magic-link/* Public — recovery request and validate
/admin/profile, /admin/settings, /admin/setup Any valid session, including a plain user
/admin/* (everything else) Requires a valid session and the admin role
/admin/enterprises*, /admin/reports/internal Requires platform admin
/bridge-auth/* Requires a valid session — the device link runs inside it

Anonymous requests to a protected route are redirected to /admin/login?redirect=…. A signed-in user without the admin role is not shown a 403 for console pages — they are redirected to /admin/profile, which is the only part of the console addressed to them. JSON admin API routes return HTTP 403 instead, and the platform-admin routes return an HTML 403.

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.