DocsPlatformAuthentication

Authentication

Built-in authentication with JWT sessions, Argon2id password hashing, refresh tokens, server-side session management, OIDC Single Sign-On, and passwordless magic links.


Tokens

HS256-signed JWTs.

Token Lifetime Purpose
Access token 15 minutes Sent as Authorization: Bearer <token> on every request.
Refresh token 30 days Used to get a new access token without re-entering credentials.

Security

Property Value
Password hashing Argon2id (OWASP-recommended parameters)
JWT signing HS256
JWT secret ROOTCX_JWT_SECRET env var (min 32 characters), or auto-generated in config/jwt.key
Session store Server-side in PostgreSQL (rootcx_system.sessions). Allows immediate logout.
Password storage Only the Argon2id hash is stored. Plaintext is never persisted.
Password minimum 6 characters

Registration

POST /api/v1/auth/register
{
  "email": "alice@example.com",
  "password": "MySecurePass",
  "displayName": "Alice Martin"
}

Password requirement: minimum 6 characters.

Response (201):

{
  "user": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "email": "alice@example.com",
    "displayName": "Alice Martin",
    "createdAt": "2024-12-01T10:00:00+00:00"
  }
}
The first user to register is automatically assigned the built-in admin role with global permissions (*). This is atomic: only succeeds if no admin exists yet. Registration does not return tokens. Call the login endpoint after.

If ROOTCX_DISABLE_PASSWORD_LOGIN is set, registration is blocked unless no users exist yet (first-user bypass for initial setup).


Login

POST /api/v1/auth/login
{ "email": "alice@example.com", "password": "MySecurePass" }

Response (200):

{
  "accessToken": "eyJ...",
  "refreshToken": "eyJ...",
  "expiresIn": 900,
  "user": {
    "id": "3fa85f64-...",
    "email": "alice@example.com",
    "displayName": "Alice Martin",
    "createdAt": "2024-12-01T10:00:00+00:00"
  }
}

Returns 403 if ROOTCX_DISABLE_PASSWORD_LOGIN is set.


Token refresh

POST /api/v1/auth/refresh
{ "refreshToken": "..." }

Returns { "accessToken": "...", "expiresIn": 900 }.


Logout

POST /api/v1/auth/logout
{ "refreshToken": "..." }

Invalidates the session server-side. Access tokens already issued remain valid until they expire (max 15 minutes).


Current user

GET /api/v1/auth/me
Authorization: Bearer <token>

Returns the authenticated user object.


Auth mode

GET /api/v1/auth/mode

Public endpoint (no auth required). Returns the current authentication configuration:

{
  "authRequired": true,
  "setupRequired": false,
  "passwordLoginEnabled": true,
  "magicLinkEnabled": true,
  "providers": [
    { "id": "okta", "displayName": "Okta SSO" }
  ]
}
Field Description
authRequired Always true.
setupRequired True if no users exist yet (first-user registration needed).
passwordLoginEnabled False if ROOTCX_DISABLE_PASSWORD_LOGIN is set (unless setupRequired).
magicLinkEnabled True if the auth.invite permission exists (magic link feature active).
providers Array of enabled OIDC providers with id and displayName.

User management

GET /api/v1/users

List all users. Requires authentication.

DELETE /api/v1/users/{id}

Delete a user. Returns 400 if this is the last admin (prevents lockout).


OIDC Single Sign-On

RootCX includes a full OIDC Relying Party for integrating with identity providers (Okta, Azure AD, Google Workspace, Auth0, or any OIDC-compliant provider).

Configuring a provider

POST /api/v1/auth/oidc/providers

Admin only. Create or update an OIDC provider:

{
  "id": "okta",
  "displayName": "Okta SSO",
  "issuerUrl": "https://dev-123456.okta.com",
  "clientId": "0oa1b2c3d4...",
  "clientSecret": "secret...",
  "autoRegister": true,
  "defaultRole": "base"
}
Field Required Default Description
id Yes Unique identifier for this provider.
displayName Yes Shown on the login screen.
issuerUrl Yes OIDC issuer URL. Must be HTTPS (except localhost). Core fetches the discovery document to validate.
clientId Yes OAuth 2.0 client ID from your provider.
clientSecret No OAuth 2.0 client secret. Encrypted in the vault, never stored in plaintext.
scopes No ["openid", "email", "profile"] OAuth scopes to request.
autoRegister No true Auto-create users on first OIDC login.
defaultRole No "base" Role assigned to new users created via OIDC. base carries no permissions (deny-by-default).
roleClaim No JWT claim name to extract role from ID token. If the extracted role exists in RBAC, it overrides defaultRole.
enabled No true Whether provider appears on login screen.

Managing providers

GET /api/v1/auth/oidc/providers

List enabled providers (public, no auth required).

DELETE /api/v1/auth/oidc/providers/{id}

Delete a provider (admin only).

Browser login flow

  1. Client calls GET /api/v1/auth/oidc/{providerId}/authorize?redirect_uri=...
  2. Core redirects to the identity provider's authorization endpoint.
  3. User authenticates with the identity provider.
  4. Provider redirects to GET /api/v1/auth/oidc/callback with the authorization code.
  5. Core exchanges the code for tokens, creates or links the user, creates a session.
  6. Core redirects back to the original redirect_uri with access and refresh tokens as query parameters.

Server-to-server token exchange

If your backend already has a valid OIDC id_token, exchange it directly:

POST /api/v1/auth/oidc/token-exchange
{ "providerId": "okta", "idToken": "eyJhbGciOiJSUzI1..." }

Returns { "accessToken": "eyJ...", "expiresIn": 900 }.

What happens on first OIDC login

  1. If a user with the same email already exists, the OIDC identity is linked to that account.
  2. If autoRegister is true, a new user is created and assigned the defaultRole (or the role from roleClaim).
  3. If autoRegister is false, login is rejected with 403.

Disabling password login

Set ROOTCX_DISABLE_PASSWORD_LOGIN=true (or 1) to force SSO-only authentication. Password login and registration are blocked. A first-user bypass allows initial setup when no users exist yet.

Auto-provisioning via environment variables

Set these to seed an OIDC provider automatically on first boot (useful for cloud/Docker deployments):

Variable Description
ROOTCX_OIDC_ISSUER OIDC issuer URL. Seeds a provider with id "rootcx" on first boot.
ROOTCX_OIDC_CLIENT_ID Client ID (required if issuer set).
ROOTCX_OIDC_CLIENT_SECRET Client secret (required if issuer set, encrypted in vault).
ROOTCX_PUBLIC_URL Public URL for OIDC callbacks (e.g., https://core.example.com). Falls back to ROOTCX_URL.

Passwordless authentication via secure one-time tokens. An admin or authorized user generates a link, delivers it (email, Slack, in-app), and the recipient clicks it to authenticate.

POST /api/v1/auth/magic-link/generate
Authorization: Bearer <token>

Requires auth.invite permission (or *). Non-admins can only assign roles they already hold.

{
  "email": "bob@example.com",
  "roles": ["editor"],
  "redirectUri": "https://your-app.com/callback",
  "expiresInSeconds": 900
}
Field Required Default Description
email Yes Recipient email. Normalized to lowercase.
roles No [] RBAC roles assigned on consume. Caller must hold these roles (privilege containment).
redirectUri No Where to redirect on consume. Must be http(s), no credentials in URL.
expiresInSeconds No 900 (15 min) Token lifetime. Range: 60 to 86400 (24 hours).

Response (201):

{
  "magicLinkUrl": "https://<host>/api/v1/auth/magic-link/consume?token=<token>",
  "expiresAt": "2026-06-10T12:15:00+00:00"
}

Two endpoints for the same operation:

POST /api/v1/auth/magic-link/consume
{ "token": "..." }
GET /api/v1/auth/magic-link/consume?token=...

The GET variant is for clickable links (emails, Slack messages). Both are anonymous (the token is the credential).

On valid token:

  • Creates the user if they do not exist, assigns the roles specified at generation.
  • If the user already exists, links the identity.
  • Returns access and refresh tokens.

POST response (200):

{
  "accessToken": "eyJ...",
  "refreshToken": "eyJ...",
  "expiresIn": 900,
  "user": { "id": "...", "email": "bob@example.com", "displayName": null, "createdAt": "..." },
  "redirectUri": "https://your-app.com/callback"
}

GET response: HTTP 307 redirect to redirectUri with tokens delivered via URL fragment (#access_token=...&refresh_token=...&expires_in=...). Fragments are never sent to the server or included in the Referer header. If no redirectUri was specified, returns the JSON response instead.

Token security

Property Value
Entropy 256 bits (32 bytes from OS CSPRNG)
Encoding base64url, no padding (43 characters)
Storage Only the SHA-256 hash is persisted
Single-use Atomic UPDATE ... WHERE consumed_at IS NULL — one consumer wins, concurrent attempts get 401
Constant-time Hash comparison prevents timing attacks

SDK usage

Two flows for consuming magic links in your app:

Flow A — Core redirect (recommended). Generate with a redirectUri pointing to your app. The invitee clicks the magicLinkUrl, Core consumes the token and redirects. useAuth() picks up tokens from the URL fragment automatically.

Flow B — App-managed consume. Generate without redirectUri. Embed the raw token in your own link. Call magicLinkConsume(token) from the SDK:

const { magicLinkConsume, user } = useAuth();
const [params] = useSearchParams();

useEffect(() => {
  const t = params.get("token");
  if (t && !user) magicLinkConsume(t).catch(() => setError("Link expired or already used."));
}, []);

---

## API endpoints summary

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/v1/auth/register` | No | Register a new user |
| POST | `/api/v1/auth/login` | No | Login and receive tokens |
| POST | `/api/v1/auth/refresh` | No | Refresh an expired access token |
| POST | `/api/v1/auth/logout` | Yes | Invalidate session |
| GET | `/api/v1/auth/me` | Yes | Get current user |
| GET | `/api/v1/auth/mode` | No | Get auth configuration |
| GET | `/api/v1/users` | Yes | List all users |
| DELETE | `/api/v1/users/{id}` | Yes | Delete a user |
| GET | `/api/v1/auth/oidc/providers` | No | List enabled OIDC providers |
| POST | `/api/v1/auth/oidc/providers` | Admin | Create/update OIDC provider |
| DELETE | `/api/v1/auth/oidc/providers/{id}` | Admin | Delete OIDC provider |
| GET | `/api/v1/auth/oidc/{providerId}/authorize` | No | Start OIDC browser flow |
| GET | `/api/v1/auth/oidc/callback` | No | OIDC authorization callback |
| POST | `/api/v1/auth/oidc/token-exchange` | No | Server-to-server token exchange |
| POST | `/api/v1/auth/magic-link/generate` | Yes (`auth.invite`) | Generate a magic link |
| POST | `/api/v1/auth/magic-link/consume` | No | Consume a magic link token |
| GET | `/api/v1/auth/magic-link/consume` | No | Consume via GET (clickable links) |