Key points
- Identity is provider ID, issuer, and subject. Email is metadata with a verification status and never decides who a caller is.
- Login takes
identifierandpassword—identifieraccepts the subject or the email. - Provider discovery takes
email, which is a different field name from login on purpose: one resolves a domain, the other authenticates a subject. - A 401 on login is deliberately identical for an unknown account and a wrong password.
- Token values — session, personal, and service account alike — are returned once at creation and never readable afterwards.
- The browser flows never put provider tokens in a URL: the callback issues a one-time code that is exchanged server side.
must_change_password: truemeans the forced rotation is pending and other routes will keep refusing until it is done.
Operations
GET/v1/auth/providersPublic
Login methods offered by this installation.
Notes
local_enabled: false is accepted only when external auth is enabled and at least one identity provider remains enabled, so an install cannot lock itself out.
Call it
curl -s "$NOPSAI_URL/v1/auth/providers" | jqResponses
Public provider list. Client credentials and issuer configuration are deliberately not included.
{
"local_enabled": true,
"oidc_enabled": true,
"providers": [
{
"id": "keycloak",
"type": "oidc",
"display_name": "Corporate SSO",
"scopes": ["openid", "email", "profile"],
"allowed_email_domains": ["example.com"],
"auth_url_kind": "oidc"
}
]
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 500 | Auth settings or the provider list could not be loaded from the database. | Check /healthz — this failure almost always means the database is unreachable. |
Side effects
- None.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_handlers.goservices/nopsai/auth_oidc_models.go
POST/v1/auth/discoverPublic
Resolves which provider should handle a given login identifier.
Notes
This is how a login screen decides between showing a password field and redirecting to SSO.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/discover" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' | jqResponses
Whether a provider claims this email domain.
{
"found": true,
"provider": {
"id": "keycloak",
"type": "oidc",
"display_name": "Corporate SSO",
"auth_url_kind": "oidc"
}
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is malformed or the email cannot be normalised. | Send a syntactically valid email address in the email field. |
| 405 | A method other than POST. | Use POST. |
| 500 | Provider lookup failed. | Treat as a platform fault, not a client error. |
Side effects
- None. Discovery does not create a session or a login attempt record.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_handlers.go
POST/v1/auth/loginPublic
Local username and password login.
Notes
must_change_password: true means the forced rotation is pending: change it before expecting other routes to answer.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"identifier":"[email protected]","password":"<password>"}' | jqResponses
Session established.
{
"access_token": "eyJhbGciOi...",
"refresh_token": "rt_9f2c...",
"expires_at": "2026-08-19T11:04:11Z",
"roles": ["nopsai-admin"],
"provider": "local",
"email": "[email protected]",
"sub": "admin",
"must_change_password": false,
"capabilities": {
"pipelines": { "write": true, "delete": true },
"system": { "config_read": true, "config_write": true, "access": true }
}
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is not valid JSON. | Send identifier and password as JSON. |
| 401 | Unknown identifier, wrong password, a disabled account, or local login turned off. | The message is deliberately identical for each: it does not reveal whether the account exists. |
| 500 | Auth settings could not be loaded. | Platform fault. Check database reachability. |
Side effects
- Records a login attempt, which feeds rate limiting and lockout.
- Issues a refresh token that is stored server side and can be revoked by logout.
- Writes an audit record for the authentication decision.
Proven by
services/nopsai/auth_middleware_test.goservices/nopsai/auth_bootstrap_test.goservices/nopsai/auth_handlers.goservices/nopsai/auth_models.go
POST/v1/auth/refreshPublic
Issues a new access token from a refresh token.
Notes
This route is public because a caller with an expired access token has nothing else to authenticate with.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\"}" | jq -r .access_tokenResponses
A new session, in the same shape login returns.
{
"access_token": "eyJhbGciOi...",
"refresh_token": "rt_9f2c...",
"expires_at": "2026-08-19T11:04:11Z",
"roles": ["nopsai-admin"],
"provider": "local",
"email": "[email protected]",
"sub": "admin",
"must_change_password": false,
"capabilities": {
"pipelines": { "write": true, "delete": true },
"system": { "config_read": true, "config_write": true, "access": true }
}
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is malformed or carries no refresh token. | Send refresh_token. |
| 401 | The refresh token is unknown, expired, or already revoked by a logout. | Start a new login. A revoked refresh token never becomes valid again. |
Side effects
- Rotates the stored refresh token state for the session.
Proven by
services/nopsai/auth_middleware_test.goservices/nopsai/auth_handlers.go
POST/v1/auth/logoutPublic
Ends the current session and invalidates its refresh token.
Notes
Public at the middleware, like login and refresh: what it acts on is the refresh token in the body, so a caller whose access token already expired can still end its session.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/logout" \
-H "Authorization: Bearer $NOPSAI_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"refresh_token\":\"$REFRESH\",\"return_to\":\"https://nopsai.example.com/login\"}" | jqResponses
Session ended and the provider offers a logout URL to redirect to.
{"logout_url":"https://sso.example.com/logout?post_logout_redirect_uri=..."}Session ended and there is nothing further to redirect to.
When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is malformed. | Send valid JSON, or no body at all. |
| 500 | The refresh token could not be revoked. | Retry. Until it succeeds the refresh token remains usable. |
Side effects
- Revokes the refresh token server side.
- Writes an audit record.
Proven by
services/nopsai/auth_logout_test.goservices/nopsai/auth_handlers.goservices/nopsai/http_middleware.go
GET/v1/auth/meAuthenticated
Current identity, roles, and effective capabilities.
Notes
The capability map is what the UI renders navigation from. It is a convenience, not the authorization boundary — AAA still decides every request.
Call it
curl -s -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/me" | jqResponses
Identity and capabilities for the caller behind this token.
{
"sub": "admin",
"email": "[email protected]",
"provider": "local",
"roles": [{ "role": "nopsai-admin" }],
"external_managed": false,
"authentication_source": "local"
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 401 | Missing, malformed, or expired bearer token. | Refresh the access token or log in again. |
| 403 | The token is valid but the subject may not read its own record, which happens for a disabled account. | Check the account status in identity administration. |
| 404 | The token authenticates a subject that no longer exists. | Revoke the token: it outlived its account. |
| 500 | Capability resolution failed. | Platform fault; check AAA reachability. |
Side effects
- None.
Proven by
services/nopsai/auth_middleware_test.goservices/nopsai/auth_handlers.goservices/nopsai/auth_models.go
POST/v1/auth/passwordAuthenticated
Changes the caller password, including the forced first-login rotation.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/password" \
-H "Authorization: Bearer $NOPSAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"<old>","new_password":"<new>"}' | jqResponses
Password changed.
When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is malformed or the new password fails policy. | The message names the rule that rejected it. |
| 401 | The current password is wrong, or the bearer token is invalid. | Confirm both before retrying. |
| 403 | The account is externally managed, so its password is not the platform’s to change. | Change it at the identity provider. |
| 404 | The subject no longer exists. | Revoke the token. |
| 500 | The change could not be persisted. | Retry; the old password remains valid until it succeeds. |
Side effects
- Clears the forced-rotation flag when one was pending.
- Writes an audit record.
Proven by
services/nopsai/auth_bootstrap_test.goservices/nopsai/auth_profile_handlers.goservices/nopsai/auth_models.go
POST/v1/auth/emailAuthenticated
Updates the caller email. Email is metadata, not identity.
Notes
Changing an email never changes who the caller is. Identity is provider ID, issuer, and subject.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/email" \
-H "Authorization: Bearer $NOPSAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' | jqResponses
The updated user summary.
{
"sub": "admin",
"email": "[email protected]",
"provider": "local",
"status": "active",
"external_managed": false
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The address is missing or not a valid email. | Send a syntactically valid address. |
| 401 | Invalid bearer token. | Refresh or log in again. |
| 403 | The account is externally managed, so its email comes from the provider. | Change it at the identity provider; the platform will not override provider metadata. |
| 404 | The subject no longer exists. | Revoke the token. |
| 409 | Another account already uses that address. | Pick a different address, or resolve the duplicate account first. |
| 500 | The update could not be persisted. | Retry. |
Side effects
- Writes an audit record.
- Does not change the subject, so nothing about authorization changes.
Proven by
services/nopsai/auth_middleware_test.goservices/nopsai/auth_profile_handlers.go
GET/v1/auth/personal-tokensAuthenticated
Lists the caller personal access tokens.
Notes
last_used_at is the fastest way to find a token nothing uses any more before revoking it.
Call it
curl -s -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/personal-tokens" | jqResponses
The caller own tokens. Never another subject’s.
[
{
"id": "5f0f5c0c-4a1e-4a6f-9f3a-6b1f7d2c9a11",
"name": "laptop-cli",
"token_suffix": "9a11",
"created_at": "2026-08-19T10:41:02Z",
"last_used_at": "2026-08-19T12:03:55Z"
}
]When it fails
| Status | Cause | What to do |
|---|---|---|
| 405 | A method other than GET. | Use GET. |
| 500 | The token list could not be read. | Platform fault. |
Side effects
- None.
Proven by
services/nopsai/personal_tokens_test.goservices/nopsai/personal_tokens.goservices/nopsai/auth_models.go
POST/v1/auth/personal-tokensAuthenticated
Creates a personal access token. The value is returned once.
Notes
A personal token acts as the person who created it. A system that needs its own access should get a service account instead.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/personal-tokens" \
-H "Authorization: Bearer $NOPSAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"integration","expires_in_days":365}' | jqResponses
Token created. Store the value now.
{
"id": "5f0f5c0c-4a1e-4a6f-9f3a-6b1f7d2c9a11",
"name": "integration",
"token": "nopat_2f9c...only-returned-once",
"token_suffix": "9a11",
"created_at": "2026-08-19T10:41:02Z",
"expires_at": "2027-08-19T10:41:02Z"
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The name is missing, or the expiry fields conflict or are unparseable. | Send a name and exactly one expiry form. |
| 405 | A method other than POST. | Use POST. |
| 500 | The token could not be stored. | Retry; no token was issued. |
Side effects
- Creates a credential that carries the caller own permissions.
- Writes an audit record.
Proven by
services/nopsai/personal_tokens_test.goservices/nopsai/personal_tokens.goservices/nopsai/auth_models.go
DELETE/v1/auth/personal-tokens/{tokenID}Authenticated
Revokes a personal access token.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tokenID | path | uuid | Required | The token id from the list route — not the token value. |
Call it
curl -sX DELETE -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/personal-tokens/$TOKEN_ID" -w "%{http_code}\n"Responses
Token revoked.
When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The id is missing or malformed. | Use the id from the list route. |
| 404 | No such token for this caller. | A caller can only revoke their own tokens; an administrator revokes others through identity administration. |
| 500 | The revocation could not be persisted. | Retry: the token remains valid until it succeeds. |
Side effects
- Invalidates the token immediately.
- Writes an audit record.
Proven by
services/nopsai/personal_tokens_test.goservices/nopsai/personal_tokens.go
POST/v1/auth/session/exchangePublic
Exchanges a browser session artifact for API tokens.
Notes
This is what keeps provider tokens out of the browser URL: the callback hands over a code, and the code is exchanged server side.
Call it
curl -sX POST "$NOPSAI_URL/v1/auth/session/exchange" \
-H "Content-Type: application/json" \
-d "{\"code\":\"$CODE\"}" | jqResponses
Session established from the exchanged code.
{
"access_token": "eyJhbGciOi...",
"refresh_token": "rt_9f2c...",
"expires_at": "2026-08-19T11:04:11Z",
"roles": ["nopsai-admin"],
"provider": "local",
"email": "[email protected]",
"sub": "admin",
"must_change_password": false,
"capabilities": {
"pipelines": { "write": true, "delete": true },
"system": { "config_read": true, "config_write": true, "access": true }
}
}When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | The body is malformed or carries no code. | Send code. |
| 401 | The code is unknown, expired, or already used. | Restart the login flow. A code works exactly once. |
| 405 | A method other than POST. | Use POST. |
Side effects
- Consumes the one-time code.
- Issues access and refresh tokens.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_profile_handlers.goservices/nopsai/auth_oidc_models.go
GET/v1/auth/oidc/{provider}/startPublic
Begins an OIDC authorization code flow.
Notes
A browser route: it is meant to be navigated to, not called from an API client.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Required | Provider id from /v1/auth/providers. |
Call it
curl -si "$NOPSAI_URL/v1/auth/oidc/keycloak/start" | head -3Responses
Redirect to the identity provider.
Location: https://sso.example.com/realms/nopsai/protocol/openid-connect/auth?client_id=...&state=...When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | Unknown provider id, or the provider is disabled. | Use an id from /v1/auth/providers. |
Side effects
- Creates the pending login state the callback will validate.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_handlers.go
GET/v1/auth/oidc/{provider}/callbackPublic
Completes the OIDC flow and establishes identity from provider ID, issuer, and subject.
Notes
Email is optional metadata with verified, unverified, unknown, or not-provided status. Only an explicitly verified email may be used by opt-in email-linking policy.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Required | Provider id the flow was started for. |
code | query | string | Required | Authorization code issued by the provider. |
state | query | string | Required | Opaque state that must match the pending login the start route created. |
Call it
GET /v1/auth/oidc/keycloak/callback?code=<code>&state=<state>Responses
Redirect to the UI with a one-time exchange code.
Location: https://nopsai.example.com/login#code=...When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | State does not match, the code is missing, or the ID token fails validation. | Restart the flow. A mismatched state is treated as a failed login, not a retryable error. |
Side effects
- Establishes or reconciles the local user record from provider ID, issuer, and subject.
- Records the email verification status the provider asserted.
- Writes an audit record.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_integration_test.goservices/nopsai/auth_oidc_handlers.go
GET/v1/auth/oauth2/{provider}/startPublic
Begins an OAuth2 flow for providers without full OIDC discovery.
Notes
GitHub uses this pair rather than the OIDC one because it does not issue an ID token.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Required | Provider id from /v1/auth/providers whose auth_url_kind is oauth2. |
Call it
curl -si "$NOPSAI_URL/v1/auth/oauth2/github/start" | head -3Responses
Redirect to the provider.
Location: https://github.com/login/oauth/authorize?client_id=...&state=...When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | Unknown or disabled provider. | Use an id from /v1/auth/providers. |
Side effects
- Creates the pending login state the callback will validate.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_handlers.go
GET/v1/auth/oauth2/{provider}/callbackPublic
Completes the OAuth2 flow.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider | path | string | Required | Provider id the flow was started for. |
code | query | string | Required | Authorization code issued by the provider. |
state | query | string | Required | Opaque state that must match the pending login. |
Call it
GET /v1/auth/oauth2/github/callback?code=<code>&state=<state>Responses
Redirect to the UI with a one-time exchange code.
Location: https://nopsai.example.com/login#code=...When it fails
| Status | Cause | What to do |
|---|---|---|
| 400 | State mismatch, missing code, or the provider profile call failed. | Restart the flow. |
Side effects
- Establishes or reconciles the local user record.
- Writes an audit record.
Proven by
services/nopsai/auth_oidc_flow_test.goservices/nopsai/auth_oidc_handlers.go
How it works
Three token kinds reach the same surface with the same authorization: a session access token from login, a personal access token that acts as the person who created it, and a service account token that acts as a system. Choosing the wrong one is the most common integration mistake, because it works right up until the person leaves.
The OIDC and OAuth2 pairs exist for different provider capabilities rather than different security models. GitHub uses the OAuth2 pair because it does not issue an ID token; everything else about the flow, including state validation and the one-time exchange code, is the same.
Logout is the only way a refresh token stops working before it expires. If a refresh token leaks, revoking the session is the fix — rotating a password does not invalidate it.
Implementation evidence
services/nopsai/auth_handlers.goLogin, refresh, logout, and identity handlers.
services/nopsai/auth_models.goRequest and response shapes for every route on this page.
doc/jwt-authentication.mdToken kinds, claims, refresh storage, and service tokens.

