Platform

Platform overviewArchitectureWorkflow orchestrationGitOps configurationGovernance and AAAAI and MCPKnowledge and contextRuntime and executionEvidence and monitoring

Use cases

All use casesProduction incidentRelease preparationHotfix to productionSecurity scan triage
Why NopsAIIntegrationsSecurity

Resources

All resourcesAI agent governanceMCP governanceMCP securitySelf-hosted platforms
PricingGitHub

Company

How a run worksAboutContactBook a demo

Authentication API

Logging in, refreshing, logging out, minting personal tokens, and the two browser flows that hand identity over from a provider.

ReferenceDeveloperSecurityAdministrator

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 identifier and passwordidentifier accepts 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: true means 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

List login methodsapi-authentication request
curl -s "$NOPSAI_URL/v1/auth/providers" | jq
Result

Whether local login is enabled, whether OIDC is enabled, and the providers the login screen should offer.

Responses

200application/json

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

StatusCauseWhat to do
500Auth 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.go
  • services/nopsai/auth_oidc_handlers.go
  • services/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

Discover the provider for an emailapi-authentication request
curl -sX POST "$NOPSAI_URL/v1/auth/discover" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}' | jq
Result

found: true with the provider to redirect to, or found: false when the domain maps to local login.

Replace before running
  • The body takes email — not identifier, which is what the login route takes.

Responses

200application/json

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

StatusCauseWhat to do
400The body is malformed or the email cannot be normalised.Send a syntactically valid email address in the email field.
405A method other than POST.Use POST.
500Provider 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.go
  • services/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

Log in with a local accountapi-authentication request
curl -sX POST "$NOPSAI_URL/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"identifier":"[email protected]","password":"<password>"}' | jq
Result

An access token, a refresh token, the resolved roles, and the capability map the UI renders from.

Replace before running
  • The field is identifier, which accepts the subject or the email.

Responses

200application/json

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

StatusCauseWhat to do
400The body is not valid JSON.Send identifier and password as JSON.
401Unknown 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.
500Auth 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.go
  • services/nopsai/auth_bootstrap_test.go
  • services/nopsai/auth_handlers.go
  • services/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

Refresh an access tokenapi-authentication request
curl -sX POST "$NOPSAI_URL/v1/auth/refresh" \
  -H "Content-Type: application/json" \
  -d "{\"refresh_token\":\"$REFRESH\"}" | jq -r .access_token
Result

A new access token with a fresh expiry.

Replace before running
  • $REFRESH is the refresh_token from login or from the session exchange.

Responses

200application/json

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

StatusCauseWhat to do
400The body is malformed or carries no refresh token.Send refresh_token.
401The 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.go
  • services/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

Log out and follow the provider logoutapi-authentication request
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\"}" | jq
Result

A logout_url when the provider supports single logout, otherwise an empty body with 204.

Replace before running
  • return_to must be a URL the provider accepts as a post-logout redirect.

Responses

200application/json

Session ended and the provider offers a logout URL to redirect to.

{"logout_url":"https://sso.example.com/logout?post_logout_redirect_uri=..."}
204

Session ended and there is nothing further to redirect to.

When it fails

StatusCauseWhat to do
400The body is malformed.Send valid JSON, or no body at all.
500The 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.go
  • services/nopsai/auth_handlers.go
  • services/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

Read the current identityapi-authentication request
curl -s -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/me" | jq
Result

The subject, provider, roles, and the capability map. A 401 here means the token is wrong; a 403 elsewhere means the token is right and the access is not.

Responses

200application/json

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

StatusCauseWhat to do
401Missing, malformed, or expired bearer token.Refresh the access token or log in again.
403The 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.
404The token authenticates a subject that no longer exists.Revoke the token: it outlived its account.
500Capability resolution failed.Platform fault; check AAA reachability.

Side effects

  • None.

Proven by

  • services/nopsai/auth_middleware_test.go
  • services/nopsai/auth_handlers.go
  • services/nopsai/auth_models.go
POST/v1/auth/passwordAuthenticated

Changes the caller password, including the forced first-login rotation.

Call it

Rotate the caller passwordapi-authentication request
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>"}' | jq
Result

204 with no body. The old password stops working immediately.

Replace before running
  • Both fields are required, including during the forced first-login rotation.

Responses

204

Password changed.

When it fails

StatusCauseWhat to do
400The body is malformed or the new password fails policy.The message names the rule that rejected it.
401The current password is wrong, or the bearer token is invalid.Confirm both before retrying.
403The account is externally managed, so its password is not the platform’s to change.Change it at the identity provider.
404The subject no longer exists.Revoke the token.
500The 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.go
  • services/nopsai/auth_profile_handlers.go
  • services/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

Update the caller emailapi-authentication request
curl -sX POST "$NOPSAI_URL/v1/auth/email" \
  -H "Authorization: Bearer $NOPSAI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}' | jq
Result

The updated user record. The subject and provider are unchanged, because those are the identity.

Responses

200application/json

The updated user summary.

{
  "sub": "admin",
  "email": "[email protected]",
  "provider": "local",
  "status": "active",
  "external_managed": false
}

When it fails

StatusCauseWhat to do
400The address is missing or not a valid email.Send a syntactically valid address.
401Invalid bearer token.Refresh or log in again.
403The account is externally managed, so its email comes from the provider.Change it at the identity provider; the platform will not override provider metadata.
404The subject no longer exists.Revoke the token.
409Another account already uses that address.Pick a different address, or resolve the duplicate account first.
500The 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.go
  • services/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

List your tokensapi-authentication request
curl -s -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/personal-tokens" | jq
Result

Metadata only: id, name, suffix, creation, expiry, and last use. The value is never listed.

Responses

200application/json

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

StatusCauseWhat to do
405A method other than GET.Use GET.
500The token list could not be read.Platform fault.

Side effects

  • None.

Proven by

  • services/nopsai/personal_tokens_test.go
  • services/nopsai/personal_tokens.go
  • services/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

Create a personal access tokenapi-authentication request
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}' | jq
Result

The token record including token, which appears in this response and nowhere else, ever.

Replace before running
  • Choose exactly one expiry: expires_in_days, an explicit expires_at, or never_expires: true.

Responses

201application/json

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

StatusCauseWhat to do
400The name is missing, or the expiry fields conflict or are unparseable.Send a name and exactly one expiry form.
405A method other than POST.Use POST.
500The 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.go
  • services/nopsai/personal_tokens.go
  • services/nopsai/auth_models.go
DELETE/v1/auth/personal-tokens/{tokenID}Authenticated

Revokes a personal access token.

Parameters

NameInTypeRequiredDescription
tokenIDpathuuidRequiredThe token id from the list route — not the token value.

Call it

Revoke a tokenapi-authentication request
curl -sX DELETE -H "Authorization: Bearer $NOPSAI_TOKEN" "$NOPSAI_URL/v1/auth/personal-tokens/$TOKEN_ID" -w "%{http_code}\n"
Result

204. The token stops working immediately, including for requests already in flight elsewhere.

Responses

204

Token revoked.

When it fails

StatusCauseWhat to do
400The id is missing or malformed.Use the id from the list route.
404No such token for this caller.A caller can only revoke their own tokens; an administrator revokes others through identity administration.
500The 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.go
  • services/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

Exchange a one-time code for tokensapi-authentication request
curl -sX POST "$NOPSAI_URL/v1/auth/session/exchange" \
  -H "Content-Type: application/json" \
  -d "{\"code\":\"$CODE\"}" | jq
Result

The same session shape login returns.

Replace before running
  • $CODE is the one-time code the provider callback handed to the browser.

Responses

200application/json

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

StatusCauseWhat to do
400The body is malformed or carries no code.Send code.
401The code is unknown, expired, or already used.Restart the login flow. A code works exactly once.
405A 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.go
  • services/nopsai/auth_profile_handlers.go
  • services/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

NameInTypeRequiredDescription
providerpathstringRequiredProvider id from /v1/auth/providers.

Call it

Start an OIDC loginapi-authentication request
curl -si "$NOPSAI_URL/v1/auth/oidc/keycloak/start" | head -3
Result

A 302 to the provider authorization endpoint with state and PKCE parameters.

Responses

302

Redirect to the identity provider.

Location: https://sso.example.com/realms/nopsai/protocol/openid-connect/auth?client_id=...&state=...

When it fails

StatusCauseWhat to do
400Unknown 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.go
  • services/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

NameInTypeRequiredDescription
providerpathstringRequiredProvider id the flow was started for.
codequerystringRequiredAuthorization code issued by the provider.
statequerystringRequiredOpaque state that must match the pending login the start route created.

Call it

What the provider callsapi-authentication request
GET /v1/auth/oidc/keycloak/callback?code=<code>&state=<state>
Result

A redirect back to the UI carrying a one-time code, which the browser exchanges at /v1/auth/session/exchange.

Responses

302

Redirect to the UI with a one-time exchange code.

Location: https://nopsai.example.com/login#code=...

When it fails

StatusCauseWhat to do
400State 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.go
  • services/nopsai/auth_oidc_integration_test.go
  • services/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

NameInTypeRequiredDescription
providerpathstringRequiredProvider id from /v1/auth/providers whose auth_url_kind is oauth2.

Call it

Start an OAuth2 loginapi-authentication request
curl -si "$NOPSAI_URL/v1/auth/oauth2/github/start" | head -3
Result

A 302 to the provider authorization endpoint.

Responses

302

Redirect to the provider.

Location: https://github.com/login/oauth/authorize?client_id=...&state=...

When it fails

StatusCauseWhat to do
400Unknown 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.go
  • services/nopsai/auth_oidc_handlers.go
GET/v1/auth/oauth2/{provider}/callbackPublic

Completes the OAuth2 flow.

Parameters

NameInTypeRequiredDescription
providerpathstringRequiredProvider id the flow was started for.
codequerystringRequiredAuthorization code issued by the provider.
statequerystringRequiredOpaque state that must match the pending login.

Call it

What the provider callsapi-authentication request
GET /v1/auth/oauth2/github/callback?code=<code>&state=<state>
Result

A redirect back to the UI carrying a one-time exchange code.

Responses

302

Redirect to the UI with a one-time exchange code.

Location: https://nopsai.example.com/login#code=...

When it fails

StatusCauseWhat to do
400State 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.go
  • services/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.go

    Login, refresh, logout, and identity handlers.

  • services/nopsai/auth_models.go

    Request and response shapes for every route on this page.

  • doc/jwt-authentication.md

    Token kinds, claims, refresh storage, and service tokens.