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"
}
}
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
- Client calls
GET /api/v1/auth/oidc/{providerId}/authorize?redirect_uri=... - Core redirects to the identity provider's authorization endpoint.
- User authenticates with the identity provider.
- Provider redirects to
GET /api/v1/auth/oidc/callbackwith the authorization code. - Core exchanges the code for tokens, creates or links the user, creates a session.
- Core redirects back to the original
redirect_uriwith 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
- If a user with the same email already exists, the OIDC identity is linked to that account.
- If
autoRegisteris true, a new user is created and assigned thedefaultRole(or the role fromroleClaim). - If
autoRegisteris 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. |
Magic links
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.
Generate a magic link
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"
}
Consume a magic link
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) |