Looking for a plainer explanation of what API tokens are and when you'd want one, instead of a full endpoint reference? See API Tokens (in-app) or concepts-api-tokens.md (repo).
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
Base URL: https://your-domain.com
Content Type: application/json (all request and response bodies)
Login via POST /api/auth/login to receive an auth token.
Include the token in every protected request:
auth-token: <token>
/api/user/* — user management/api/group/* — group management/api/token/* — token management/api/notification/* — notifications/api/oauth/client/* — OAuth client management| Group | Grants |
|---|---|
app_sso_admin |
Full user/group/notification management |
app_sso_oauth_admin |
Register and manage OAuth clients |
app_sso_invite |
Invitation management |
| Group owner | Manage membership of that specific group |
Self-service: users can always read and modify their own account without admin membership.
| Endpoint | Limit |
|---|---|
POST /api/auth/login |
10 requests / 15 min |
POST /api/auth/resetpassword |
5 requests / hour |
POST /api/auth/otp/request |
5 requests / 15 min |
POST /api/auth/otp/verify |
10 requests / 15 min |
POST /api/auth/invite/* |
20 requests / hour |
Limits are per IP. The server must be behind a trusted proxy for these to apply correctly.
Base path: /api/auth
POST /api/auth/login — No auth required
Request:
{ "uid": "username", "password": "user_password" }
Response:
{ "login": true, "token": "auth_token_string", "message": "username logged in!" }
ALL /api/auth/logout — No auth required
Response:
{ "message": "Bye" }
Returns available username suggestions based on name and optional date of birth.
GET /api/auth/username-suggestions — No auth required
Query Parameters:
givenName — First namesn — Last namedob (optional) — Date of birth (used to generate year-variant suggestions)Response:
{ "suggestions": ["jsmith", "jsmith1990", "johnsmith"] }
POST /api/auth/resetpassword — No auth required
Request:
{ "mail": "user@example.com" }
Response:
{ "message": "If the email address is in our system, you will receive a message." }
The same response is returned whether or not the email exists. Reset tokens expire after 24 hours.
POST /api/auth/resetpassword/:token — No auth required
URL Parameters: token — from the reset email
Request:
{ "password": "new_password", "confirm": "new_password" }
Response:
{ "message": "Password has been changed." }
The token is invalidated after a successful reset.
Send a one-time login code via email or SMS.
POST /api/auth/otp/request — No auth required
Request:
{ "login": "username_or_email", "method": "email" }
method must be email or sms. For sms, the account must have a mobile number on file.
Response:
{ "message": "Code sent", "method": "email", "expires_at": 1234567890000 }
Verify a one-time code and receive an auth token. Also marks the corresponding contact method (email or phone) as verified on the account.
POST /api/auth/otp/verify — No auth required
Request:
{ "login": "username_or_email", "code": "123456" }
Response:
{ "login": true, "token": "auth_token_string" }
Returns 401 if the code is invalid or expired.
Create an account from an invite token after verifying email.
POST /api/auth/invite/:token/:mailToken — No auth required
URL Parameters: token — invite token, mailToken — email verification token
Request:
{
"uid": "username",
"password": "user_password",
"givenName": "First Name",
"sn": "Last Name",
"mail": "user@example.com"
}
Response:
{ "user": "username", "token": "auth_token_string" }
Send an email verification link for an invite token.
POST /api/auth/invite/:token — No auth required
URL Parameters: token — invite token
Request:
{ "mail": "user@example.com" }
Response:
{ "message": "sent" }
Creates a temporary password for a target user and returns it. Allows admins to log in as another user for support purposes.
POST /api/auth/impersonate/:uid — Auth required, app_sso_admin
URL Parameters: uid — target username
Response:
{
"uid": "target_username",
"temp_password": "random_temp_password",
"expires_at": 1234567890000
}
The temp password is short-lived. Any previous impersonation session for the same target is revoked first.
Revoke all active impersonation sessions for a user.
DELETE /api/auth/impersonate/:uid — Auth required, app_sso_admin
URL Parameters: uid — target username
Response:
{ "message": "Impersonation ended for username", "revoked": 1 }
Base path: /api/user
All endpoints require authentication.
GET /api/user/ — app_sso_admin required
Query Parameters:
detail (optional) — return full user objects instead of a minimal listResponse:
{
"results": [
{ "uid": "username", "mail": "user@example.com", "givenName": "First", "sn": "Last" }
]
}
POST /api/user/ — app_sso_admin required
Request:
{
"uid": "username",
"password": "user_password",
"givenName": "First Name",
"sn": "Last Name",
"mail": "user@example.com"
}
Response:
{ "results": { "uid": "username", "dn": "uid=username,ou=people,dc=..." } }
Admin-created accounts have password_must_change set automatically.
GET /api/user/me — Any authenticated user
Returns the full user object for the authenticated user, including onboarding state.
Response:
{
"uid": "username",
"mail": "user@example.com",
"givenName": "First",
"sn": "Last",
"dn": "uid=username,ou=people,dc=...",
"onboardingRequired": "yes",
"onboardingNeeds": ["tos", "dob", "password"],
"memberOf": ["cn=group_name,ou=groups,dc=..."]
}
onboardingRequired is "yes" when onboardingNeeds is non-empty. Needs can be "tos", "dob", or "password".
Mark TOS as accepted for the authenticated user.
POST /api/user/accept-tos — Any authenticated user
Response:
{ "success": true }
Returns user and group counts, recent signups, and inactive users.
GET /api/user/stats — app_sso_admin required
Response:
{
"totalUsers": 42,
"activeUsers": 38,
"inactiveUsers": 4,
"totalGroups": 10,
"recentSignups": [
{ "uid": "newuser", "givenName": "New", "sn": "User", "mail": "new@example.com", "createTimestamp": "20240101000000Z" }
],
"inactiveList": [
{ "uid": "lockeduser", "givenName": "Locked", "sn": "User", "mail": "locked@example.com" }
]
}
Download all users as a CSV file.
GET /api/user/export — app_sso_admin required
Response: Content-Type: text/csv, attachment download
Columns: uid, givenName, sn, mail, mobile, uidNumber, isActive, createTimestamp
GET /api/user/:uid/verification — app_sso_admin required
Response:
{
"uid": "username",
"emailVerified": true,
"emailVerifiedAt": 1234567890000,
"phoneVerified": false,
"phoneVerifiedAt": null,
"tosAccepted": true,
"tosAcceptedAt": 1234567890000
}
GET /api/user/:uid — Any authenticated user
Response:
{ "results": { "uid": "username", "mail": "user@example.com", "givenName": "First", "sn": "Last" } }
PUT /api/user/:uid — Own account, or app_sso_admin for others
Request: Any subset of editable fields:
{
"givenName": "New First Name",
"sn": "New Last Name",
"mail": "newemail@example.com",
"dateOfBirth": "1990-01-15",
"mobile": "+15551234567",
"sshPublicKey": "ssh-rsa AAAA..."
}
Response:
{ "results": { <updated user object> }, "message": "Updated username user" }
Lock or unlock a user account.
PUT /api/user/:uid/active — app_sso_admin required
Request:
{ "active": true }
Response:
{ "uid": "username", "active": true, "message": "User username activated" }
DELETE /api/user/:uid — Own account, or app_sso_admin for others
Response:
{ "uid": "username", "results": true }
PUT /api/user/password — Any authenticated user
Request:
{ "password": "new_password", "confirm": "new_password" }
Response:
{ "results": true }
Clears password_must_change on the account.
PUT /api/user/:uid/password — Own account, or app_sso_admin for others
Request:
{ "password": "new_password", "confirm": "new_password" }
Response:
{ "results": true, "message": "User username password changed." }
When an admin changes another user's password, password_must_change is set on that account.
Create an invite token, optionally emailing it to the invitee and pre-assigning LDAP groups the new account should be added to on signup.
POST /api/user/invite — app_sso_admin or app_sso_invite required
Request:
{ "mail": "invitee@example.com", "groups": ["group_name"] }
mail and groups are both optional. If mail is provided, a verification
email is sent to that address.
Response:
{
"token": "invite_token_string",
"link": "https://your-domain.com/login/invite/invite_token_string",
"mail_sent": true
}
GET /api/user/invite — app_sso_admin or app_sso_invite required
Admins (app_sso_admin) see all invite tokens; non-admin app_sso_invite
members see only invites they created.
Response:
{
"results": [
{
"token": "invite_token_string",
"created_by": "username",
"created_on": 1234567890000,
"is_valid": true,
"mail": "invitee@example.com",
"groups": "[\"group_name\"]"
}
]
}
Update the groups an invite will assign, or change/clear the invitee's email (re-sends verification if a new email is set).
PUT /api/user/invite/:token — app_sso_admin, or the app_sso_invite
member who created the invite
URL Parameters: token — invite token
Request: Any subset of:
{ "groups": ["group_name"], "mail": "newinvitee@example.com" }
Set mail to null/empty to clear it. Fails with 400 if the token is no
longer valid.
Response:
{ "results": { "token": "invite_token_string", "is_valid": true, "...": "..." } }
DELETE /api/user/invite/:token — app_sso_admin, or the app_sso_invite
member who created the invite
URL Parameters: token — invite token
Response:
{ "results": true }
POST /api/user/key — Any authenticated user
Request:
{ "key": "ssh-rsa AAAAB3NzaC1yc2E... user@host" }
Response:
200 — { "message": true }400 — { "message": "error description" }Base path: /api/group
All endpoints require authentication.
GET /api/group/ — Any authenticated user
Query Parameters:
detail (optional) — return full group objectsmember (optional) — filter to groups containing this UID as a memberResponse:
{ "results": [{ "cn": "group_name", "description": "Group description" }] }
POST /api/group/ — app_sso_admin required
Request:
{ "name": "group_name", "description": "Group description" }
Response:
{ "results": { "cn": "group_name" }, "message": "group_name was added!" }
The authenticated user is automatically set as the group owner.
GET /api/group/:name — Any authenticated user
Response:
{
"results": {
"cn": "group_name",
"description": "Group description",
"member": ["uid=user1,ou=people,dc=...", "uid=user2,ou=people,dc=..."],
"owner": ["uid=owner,ou=people,dc=..."]
}
}
PUT /api/group/owner/:group/:uid — app_sso_admin or group owner
Response:
{ "results": true, "message": "Added owner uid to group group." }
DELETE /api/group/owner/:group/:uid — app_sso_admin or group owner
Response:
{ "results": true, "message": "Removed Owner uid from group group." }
PUT /api/group/:group/:uid — app_sso_admin or group owner
Response:
{ "results": true, "message": "Added user uid to group group." }
Returns 409 if the user is already a member — common in practice, since
groupOfNames requires at least one member and so seeds whoever created the
group into it.
DELETE /api/group/:group/:uid — app_sso_admin or group owner
Response:
{ "results": true, "message": "Removed user uid from group group." }
PUT /api/group/:group/nested/:child — app_sso_admin or group owner
Makes :child a member of :group, so everyone in :child is a member of
:group at any depth.
Response:
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
Errors:
| Status | When |
|---|---|
400 |
:group and :child are the same group |
409 |
already nested, or the nesting would create a loop (:child already contains :group, directly or transitively) |
DELETE /api/group/:group/nested/:child — app_sso_admin or group owner
Response:
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
Errors:
| Status | When |
|---|---|
409 |
:child is the only member — groupOfNames requires at least one |
GET /api/group/:group/effective — Any authenticated user
Who a group actually grants. direct is users listed on the group itself
(never groups); nestedGroups is what is nested into it; effective is every
user reachable through the whole chain.
Response:
{
"results": {
"cn": "app_gitea_access",
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
}
}
DELETE /api/group/:group — app_sso_admin or group owner
Response:
{ "removed": true, "results": { "cn": "group_name" }, "message": "Group group_name Deleted" }
Base path: /api/token
All endpoints require authentication.
GET /api/token/ — Any authenticated user
Response:
{ "results": ["InviteToken", "PasswordResetToken"] }
GET /api/token/:name — Any authenticated user
Query Parameters: detail (optional) — include full token objects
Response:
{
"results": [
{ "token": "token_string", "created_by": "username", "created_on": 1234567890000, "is_valid": true }
]
}
GET /api/token/:name/:token — Any authenticated user
Response:
{ "results": { "token": "token_string", "created_by": "username", "created_on": 1234567890000, "is_valid": true } }
Base path: /api/notification
All endpoints require authentication and app_sso_admin membership.
Notifications send email blasts to filtered groups of users and record history.
POST /api/notification — app_sso_admin required
Request:
{
"subject": "Maintenance window tonight",
"message": "<p>We will be performing maintenance starting at midnight.</p>",
"filter_type": "group",
"filter_value": "host_hec-bot_admin, app_sso_admin",
"active_only": true
}
filter_type values:
| Value | Description |
|---|---|
all_active |
All active (unlocked) users |
all |
All users including inactive |
group |
Members of one or more LDAP groups |
users |
Specific users by UID |
filter_value:
group: comma-separated group names (e.g. "host_hec-bot_admin, app_sso_admin")users: JSON array of UIDs (e.g. '["wmantly","jsmith"]')all_active and allactive_only (boolean, default false): when true with group or all filter types, only active (unlocked) users receive the notification. Has no effect on all_active (which is always active-only).
Response:
{
"results": {
"notification_id": "uuid",
"created_by": "wmantly",
"created_on": 1234567890000,
"subject": "Maintenance window tonight",
"message": "<p>...</p>",
"filter_type": "group",
"filter_value": "host_hec-bot_admin",
"active_only": true,
"status": "sent",
"sent_count": 5,
"failed_count": 0,
"sent_at": 1234567890123
}
}
External API usage example:
# Login once, store token
TOKEN=$(curl -s -X POST https://sso.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"uid":"monitor","password":"..."}' | jq -r .token)
# Send notification to a group
curl -X POST https://sso.example.com/api/notification \
-H "auth-token: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"subject": "hec-bot maintenance",
"message": "<p>The hec-bot VM will restart at 3am.</p>",
"filter_type": "group",
"filter_value": "host_hec-bot_admin",
"active_only": true
}'
GET /api/notification — app_sso_admin required
Response:
{
"results": [
{
"notification_id": "uuid",
"subject": "Maintenance window",
"filter_type": "group",
"filter_value": "host_hec-bot_admin",
"active_only": true,
"status": "sent",
"sent_count": 5,
"failed_count": 0,
"created_by": "wmantly",
"created_on": 1234567890000,
"sent_at": 1234567890123
}
]
}
Results are sorted by created_on descending (most recent first).
GET /api/notification/:id — app_sso_admin required
Response: { "results": { <notification object> } }
The SSO Manager acts as an OAuth 2.0 Authorization Server and OpenID Connect Provider.
GET /.well-known/openid-configuration — No auth required
Returns the OIDC discovery document with endpoint URLs, supported scopes, and signing algorithms.
GET /oauth/authorize — No auth required (redirects to login if not authenticated)
Query Parameters:
response_type — Must be codeclient_id — Registered OAuth client IDredirect_uri — Must match a URI registered for the client, either exactly
or against a registered wildcard pattern (* = one hostname label, ** =
any number of labels)scope — Space-separated: openid, profile, emailstate — Opaque value returned unchanged in the redirectcode_challenge — PKCE challenge (SHA-256 of code_verifier, base64url-encoded)code_challenge_method — Must be S256Renders the consent screen.
Issues the authorization code after the user approves the consent form shown
by the Authorization Endpoint above. This is called by the consent page itself
(an authenticated request, via auth-token), not by the OAuth client
directly.
POST /api/oauth/authorize — Auth required (auth-token header)
Request:
{
"response_type": "code",
"client_id": "uuid",
"redirect_uri": "https://ha.example.com/auth/external/callback",
"scope": "openid profile email",
"state": "opaque-state-value",
"code_challenge": "pkce-challenge",
"code_challenge_method": "S256"
}
Only scopes the client is actually registered for are granted, even if more
are requested. If the client has allowed_groups set, the authenticated user
must be a member of at least one of those groups or the request is rejected
with 403.
Response:
{ "redirect_url": "https://ha.example.com/auth/external/callback?code=<code>&state=<state>" }
The caller (the consent page) redirects the browser to redirect_url, which
completes the flow described in the Authorization Endpoint section above.
POST /oauth/token — No auth required (client authenticates via credentials)
Content-Type: application/x-www-form-urlencoded or application/json
Client Authentication: client_id + client_secret in the request body, or HTTP Basic Auth.
grant_type=authorization_code
&code=<auth_code>
&redirect_uri=<redirect_uri>
&client_id=<client_id>
&client_secret=<client_secret>
&code_verifier=<pkce_verifier>
grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
&client_secret=<client_secret>
Refresh tokens are rotated on each use — the old token is invalidated and a new one is returned.
Response:
{
"access_token": "uuid",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "uuid",
"id_token": "<jwt>"
}
GET /oauth/userinfo — Bearer token required (Authorization: Bearer <access_token>)
Response (claims vary by granted scopes):
{
"sub": "username",
"name": "First Last",
"given_name": "First",
"family_name": "Last",
"preferred_username": "username",
"email": "user@example.com"
}
Base path: /api/oauth/client
All endpoints require authentication and app_sso_oauth_admin membership.
GET /api/oauth/client/
Response:
{
"results": [
{
"client_id": "uuid",
"name": "Home Assistant",
"description": "Home automation",
"redirect_uris": ["https://ha.example.com/auth/external/callback"],
"scopes": ["openid", "profile", "email"],
"allowed_groups": [],
"token_lifetime": { "access_token": 3600, "refresh_token": 2592000 },
"created_by": "wmantly",
"created_on": 1234567890000
}
]
}
POST /api/oauth/client/
Request:
{
"name": "Home Assistant",
"description": "Home automation dashboard",
"redirect_uris": ["https://ha.example.com/auth/external/callback"],
"scopes": ["openid", "profile", "email"],
"allowed_groups": [],
"token_lifetime": { "access_token": 3600, "refresh_token": 2592000 }
}
redirect_uris may also be a newline-separated string. scopes may also be a space-separated string.
allowed_groups restricts the client to members of the listed SSO groups (empty/omitted = any valid user).
Response:
{
"results": { "client_id": "uuid", "name": "Home Assistant" },
"client_secret": "raw-secret-shown-once",
"message": "OAuth client 'Home Assistant' created. Save the client secret — it will not be shown again."
}
The client_secret is shown only once. Store it immediately.
GET /api/oauth/client/:client_id
Response: { "results": { <client object> } }
PUT /api/oauth/client/:client_id
Request: Any subset of name, description, redirect_uris, scopes, allowed_groups, token_lifetime, is_valid.
Response: { "results": { <updated client> }, "message": "..." }
DELETE /api/oauth/client/:client_id
Response: { "client_id": "uuid", "message": "OAuth client '...' deleted." }
POST /api/oauth/client/:client_id/rotate
Response:
{
"client_secret": "new-raw-secret-shown-once",
"message": "Client secret rotated for '...'. Save it — it will not be shown again."
}
The old secret is invalidated immediately. The new secret is shown only once.
Configurable per-client via token_lifetime. Global defaults (in seconds):
{ "access_token": 3600, "refresh_token": 2592000 }
Base path: /api/plugins
All endpoints require authentication and app_sso_admin, app_sso_directory_admin, or app_super_admin membership. Secret field values are always returned masked (********); they are stored in OpenBao at secret/plugins/<instance-id>/conf, never in the database row. See Plugins.
GET /api/plugins/types
Returns the installed plugin types and their configSchema (used to build the create-instance form).
Response:
{
"results": [
{
"type": "proxmox",
"category": "discovery",
"name": "Proxmox VE",
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
"configSchema": [
{ "key": "url", "label": "API URL", "type": "url", "required": true },
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
]
}
]
}
GET /api/plugins/
Response: { "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }
GET /api/plugins/:id — same shape as a list entry.
POST /api/plugins/
config is a flat object of all field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. slug is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
Request:
{
"pluginType": "proxmox",
"name": "Proxmox — Home Lab",
"slug": "proxmox-homelab",
"cron": "0 * * * *",
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
}
Errors: 400 if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; 400 with an OpenBao hint if writing the secret fails (re-run ./setup.sh with theta-suite ≥ v1.30.1).
PUT /api/plugins/:id — update name, cron, enabled, and non-secret config. Secret fields are changed via PUT /:id/secrets. Re-schedules if cron or enabled changed.
PUT /api/plugins/:id/secrets — body is a flat object of secret field values. Blank/******** values are ignored (kept as-is).
POST /api/plugins/:id/test — runs the plugin's validate. Returns { "ok": true } or 400 { "ok": false, "error": "..." }.
POST /api/plugins/:id/load — enable + schedule + run now.POST /api/plugins/:id/unload — unschedule + disable.POST /api/plugins/:id/run — enqueue one immediate run (regardless of enabled).GET /api/plugins/:id/runs → { "results": { "lastRunAt", "lastStatus", "lastError" } } (lastStatus is ok | error | running).
DELETE /api/plugins/:id — unschedules, removes the OpenBao secret namespace, and deletes the row.
Base path: /api/conf
All endpoints require authentication and app_sso_admin membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at secret/sso-manager/conf and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (smtp.pass, oauth.jwtSecret) are always returned masked (********); submit a blank or ******** value to keep the current stored secret, or a new non-blank value to replace it.
GET /api/conf — returns the editable config groups (smtp, discovery, oauth) with secret fields masked to ********.
Response:
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"discovery": { },
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
POST /api/conf — deep-merges the submitted groups into secret/sso-manager/conf (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or ******** value for smtp.pass or oauth.jwtSecret preserves the stored secret.
Request:
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
Response: { "success": true }
All endpoints return errors in this format:
{ "name": "ErrorName", "message": "Error message description" }
| Code | Meaning |
|---|---|
200 |
Success |
400 |
Bad Request — invalid input |
401 |
Unauthorized — auth required, token invalid, or insufficient permission |
404 |
Not Found |
429 |
Too Many Requests — rate limit exceeded |
500 |
Internal Server Error |
502 |
Bad Gateway — upstream service failure (e.g. SMS delivery) |