NanoIDP
A test identity provider: real OAuth2/OIDC and SAML 2.0, built for testing.
You are building or testing a client that speaks OAuth2/OIDC or SAML 2.0.
You need a real identity provider to integrate against, but standing up
Keycloak or wiring a cloud tenant is a project in itself. NanoIDP is the
alternative: pip install, two YAML files, go.
pip install nanoidp
python -m nanoidp init && python -m nanoidp
$ curl -s -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin&scope=openid'
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
...
}
That first token uses the password grant because it is one request; it is a legacy grant that OAuth 2.1 removes. The Quickstart then verifies the token the way an API would, and points to Authorization Code with PKCE for a real login.
Start from what you are testing
- A single-page app's login. A public client with PKCE, the login scripted for CI, and the cases that must fail: Test an SPA login with Authorization Code and PKCE.
- An identity provider inside your CI pipeline. Started in the job, checked for readiness, a user per test, isolated and cleaned up: Run a real OIDC provider in CI with GitHub Actions.
- A SAML service provider, such as a Spring Boot app. Metadata, signed assertions, roles mapped to authorities, a login tested without a browser: Test a Spring Boot SAML service provider without a real IdP.
- Service-to-service calls. Client credentials, a scope per operation, an audience per API, and the tokens an API must refuse: Test service-to-service auth with client credentials.
- A command-line tool or a device without a browser. The device authorization flow, polling, deny and reuse, rotating refresh tokens: Test a CLI login with the device authorization flow.
- An MCP server that requires OAuth. Scoped tools, a token for the wrong audience rejected, revocation versus JWKS validation: Testing an MCP client against nanoidp.
- An MCP host such as n8n, end to end. A real host runs discovery, PKCE and resource indicators against nanoidp, allowed and denied cases included: Test MCP OAuth with n8n and NanoIDP.
- Tests that need users and clients for one run. Create them on a running IdP through a REST API and remove them all with one call, without touching the declared files: Disposable test identities.
- Two processes that must see the same state, such as an app server and a CLI talking to the same IdP: Two processes, one runtime state.
- A client that registers itself. Dynamic client registration (RFC 7591) and client ID metadata documents, both opt-in: Dynamic client registration, Client ID metadata documents.
- Every grant with curl. Authorization Code with PKCE, client credentials, refresh, device flow, resource indicators: Requesting tokens.
The product is confidence: the behaviors NanoIDP advertises and implements are grounded in the relevant specifications, so clients can test against them without depending on accidental or invented semantics.
What it is for
- Test OAuth2/OIDC flows. Authorization Code with PKCE, Password, Client Credentials, Refresh Token (with optional rotation), and Device Authorization grants, plus introspection, revocation, and RP-initiated logout. See the Quickstart.
- Test against draft OAuth 2.1. The
oauth21profile enforces the draft's strictness: PKCE required with S256 only, rotation on, no password grant, and registered redirect URIs with exact matching. The discovery document reflects it. - Test SAML 2.0. SSO over HTTP-POST and HTTP-Redirect bindings and AttributeQuery, with configurable response signing, strict-binding mode, canonicalization algorithms, and opt-in verification of signed AuthnRequests against registered SP certificates.
- Test MCP authorization. Issue tokens with the audience, scopes and resource indicators an MCP server checks, and watch it accept or refuse them. See Testing an MCP client against nanoidp.
- Manage it from an agent. A separate MCP server exposes users, clients, tokens, keys, and settings to Claude Code and other MCP-compatible tools.
- See who you're testing as. Persona login mode lists your configured
users right in the interactive login UI, so you sign in by picking one
instead of hunting down a password in
users.yaml- opt-in, off by default. See the Security guide. - Configure it your way. A full web UI, plain YAML files, a REST API. No database required.
What it is not
NanoIDP is not a production identity provider and must not operate as
one. Defaults favor getting a first token in under a minute: plaintext
passwords in config files, permissive CORS, open redirects. Hardening is
opt-in where testing needs it: the stricter-dev (runtime) and
oauth21 (protocol) profiles, require_pkce, refresh_token_rotation,
want_authn_requests_signed; the Security guide
draws the line precisely.
What it promises instead: metadata never lies. Discovery advertises exactly what the endpoints implement, and spec-relevant behavior is RFC-citable. The full set of principles and non-goals is in the Vision.
Install
Pick whichever fits your environment.
PyPI
pip install nanoidp
Ships the server (python -m nanoidp) and the MCP server (nanoidp-mcp).
Docker (GHCR)
docker pull ghcr.io/cdelmonte-zg/nanoidp:latest
Run it with your config directory mounted:
docker run --rm -p 8000:8000 \
-v $(pwd)/config:/app/config \
ghcr.io/cdelmonte-zg/nanoidp:latest
Container tags are derived from release tags (for example v2.6.0);
latest points at the newest non-prerelease.
Since 3.1.0 the image runs as uid 1000 (gid 0) rather than root. Anything
you mount for nanoidp to write, a keys volume in particular, must be
writable by that user: a volume first created by an older release is
root-owned and needs a one-time chown -R 1000:0. A read-only config
mount needs nothing.
Helm
helm install nanoidp oci://ghcr.io/cdelmonte-zg/charts/nanoidp --values values.yaml
Without --version, Helm resolves the newest final release; pre-releases
need --devel or an explicit --version. See the
chart README
for a complete, working values.yaml (config files, a registered OAuth
client, Ingress), the full set of values, and the chart's limitations.
From source
git clone https://github.com/cdelmonte-zg/nanoidp.git
cd nanoidp
pip install .
For development (tests, lint, type checking):
pip install -e ".[dev]"
The repository also ships a docker-compose.yml for running from a
checkout:
docker-compose up -d
Next: the Quickstart gets you from a fresh install to a first token.
Quickstart
From a fresh install to a token you have verified, in a couple of minutes.
1. Create a configuration
# Create config in ./config (default)
python -m nanoidp init
# Or specify a custom path
python -m nanoidp init ./my-idp-config
This creates:
users.yaml: user definitions (a defaultadmin/adminuser)settings.yaml: OAuth/SAML settings (a defaultdemo-clientwith secretdemo-secret)keys/: RSA keys, auto-generated on first startup
Prefer a guided setup? The interactive wizard walks through server configuration, OAuth clients, admin user, and token settings:
python -m nanoidp wizard
2. Run the server
# Default config directory (./config)
python -m nanoidp
# Custom config directory
python -m nanoidp --config ./my-idp-config
# Or via environment variable
NANOIDP_CONFIG_DIR=./my-idp-config python -m nanoidp
The server listens on http://localhost:8000 (--port to change it).
3. Get a token
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin&scope=openid'
The response carries an access_token (its aud is the resource audience
from oauth.audience) and, because the request included the openid
scope, an id_token (its aud is the client's client_id).
The password grant is used here because it is one request. It is a legacy grant that OAuth 2.1 removes, so do not model a real login on it: a browser login is Authorization Code with PKCE, see Public clients.
4. Verify the token
Verify it the way your client or API would: find the keys through discovery, then check the signature, the issuer and the audience. PyJWT is installed with nanoidp, so this runs as is:
TOKEN=$(curl -s -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin&scope=openid' \
| python -c 'import json, sys; print(json.load(sys.stdin)["access_token"])')
python - "$TOKEN" <<'EOF'
import json, sys, urllib.request
import jwt # PyJWT, installed with nanoidp
token = sys.argv[1]
discovery = json.load(urllib.request.urlopen(
"http://localhost:8000/.well-known/openid-configuration"))
key = jwt.PyJWKClient(discovery["jwks_uri"]).get_signing_key_from_jwt(token)
claims = jwt.decode(token, key, algorithms=["RS256"],
issuer=discovery["issuer"], audience="my-app")
print("valid:", claims["sub"], claims["iss"], claims["aud"])
EOF
valid: admin http://localhost:8000 my-app
Change audience="my-app" to any other value and the same script fails with
InvalidAudienceError: that rejection is what your API must do with a
token issued for someone else.
5. Open the web UI
The admin UI at http://localhost:8000 covers the rest: users, OAuth clients, settings, keys and certificates, claims mappings, an audit log, and a token tester for generating and inspecting tokens interactively.
Next steps
- Exercise every grant with curl: Requesting tokens
- Drive it from Claude Code: MCP with Claude Code
- Understand the trade-offs and the
stricter-devprofile: Security guide - The full reference: Configuration, Endpoints, Tokens and claims, SAML options
Test an SPA login with Authorization Code and PKCE, locally
A single-page app (React, Vue, Angular, Svelte) logs its users in with the
Authorization Code flow and PKCE. It runs in the browser, so it cannot keep
a secret: it is a public client, and the PKCE code_verifier is the only
proof that the app which asked for the code is the one redeeming it.
Testing that login against a cloud tenant means a network dependency and an
account; against Keycloak, a realm to configure. NanoIDP gives you the same
flow from two YAML files, with a public client that behaves as the specs
say: PKCE with S256 required, redirect URIs matched exactly, a code that
works once.
The configuration below is the
react-spa-pkce
preset.
1. Register the SPA as a public client
# settings.yaml
oauth:
issuer: "http://localhost:8000"
audience: "spa-api" # the access token's aud: the API your SPA calls
clients:
- client_id: "spa-client"
token_endpoint_auth_method: "none" # public client: no secret
redirect_uris: # exact string match at /authorize
- "http://localhost:3000/callback"
- "http://localhost:5173/callback" # Vite default port
Fetch the preset into a config directory and start NanoIDP on it
(pip install nanoidp first; no checkout of the repository needed):
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/react-spa-pkce
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
The preset's users.yaml has two users, admin / admin and
user / user.
CORS needs nothing: the default dev profile allows every origin, and
under security_profile: stricter-dev it is limited to localhost and
127.0.0.1 on any port. To test the SPA against a policy that names its
origin, 3.4.0 and later take a list, which applies in every profile:
cors_allowed_origins:
- "http://localhost:3000"
2. Point the SPA at NanoIDP
With react-oidc-context,
in an existing React app (for example one created with
npm create vite@latest -- --template react-ts, which serves on port 5173:
use http://localhost:5173/callback as the redirect_uri there):
npm install react-oidc-context oidc-client-ts
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import App from "./App";
const oidcConfig = {
authority: "http://localhost:8000",
client_id: "spa-client",
redirect_uri: "http://localhost:3000/callback",
scope: "openid profile email",
response_type: "code",
loadUserInfo: true, // in NanoIDP, email and profile come from /userinfo
// Required by react-oidc-context: remove the code and state from the URL
// once the login completes, or token renewal fails after a page reload
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
},
};
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AuthProvider {...oidcConfig}>
<App />
</AuthProvider>
</StrictMode>,
);
// src/App.tsx
import { useAuth } from "react-oidc-context";
function App() {
const auth = useAuth();
if (auth.isLoading) return <div>Loading...</div>;
if (auth.error) return <div>Error: {auth.error.message}</div>;
if (auth.isAuthenticated) {
return (
<div>
<p>Hello {auth.user?.profile.sub} ({auth.user?.profile.email})</p>
{/* local logout: clears the library's state, not NanoIDP's session */}
<button onClick={() => void auth.removeUser()}>Log out</button>
</div>
);
}
return <button onClick={() => void auth.signinRedirect()}>Log in</button>;
}
The library does the PKCE work: it generates the verifier, sends the
S256 challenge to /authorize, and redeems the code at /token with the
verifier and no secret. Other OIDC client libraries take the same values:
the authority, the client_id and the redirect_uri.
The Log out button is a local logout: removeUser() forgets the user in
the SPA and does not call NanoIDP.
3. Know which token carries what
Three places hold facts about the user, and looking in the wrong one is the most common surprise. Providers split the claims differently; in NanoIDP:
| Where | What is in it | Who reads it |
|---|---|---|
| ID token | sub, aud = spa-client, auth_time, at_hash: who logged in, for this app | the SPA's OIDC library, to confirm the login |
| Access token | aud = spa-api, scope, roles, authorities and the other user attributes | your API, on every request |
/userinfo | email, email_verified, profile claims, per granted scope | the SPA, with loadUserInfo: true |
NanoIDP's ID token does not carry email: without loadUserInfo: true,
auth.user.profile.email is undefined. The details are in
Tokens and claims.
4. Run the login without a browser
The whole flow can be scripted, which makes it a test you can run in CI.
This script does what the browser and the SPA do together, then checks the
ID token's signature against NanoIDP's JWKS, its issuer, its audience and
its expiry. An OIDC library may check more, depending on the library and its
settings; this is a test of the flow, not a replacement for the library. It needs requests
(pip install requests); PyJWT comes with NanoIDP.
"""Log in as a browser-based SPA would: Authorization Code with PKCE, no secret."""
import base64, hashlib, re, secrets
from urllib.parse import parse_qs, urlparse
import jwt # PyJWT, installed with nanoidp
import requests
IDP = "http://localhost:8000"
CLIENT_ID = "spa-client"
REDIRECT_URI = "http://localhost:3000/callback"
# 1. PKCE: a random verifier, and its S256 hash as the challenge
verifier = secrets.token_urlsafe(48)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
# 2. The browser goes to /authorize and gets the login form
browser = requests.Session()
page = browser.get(f"{IDP}/authorize", params={
"response_type": "code", "client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI, "scope": "openid profile email",
"state": state, "code_challenge": challenge,
"code_challenge_method": "S256",
})
page.raise_for_status()
transaction = re.search(r'name="transaction_id" value="([^"]+)"', page.text)[1]
# 3. The user submits the form; nanoidp redirects back with a code
answer = browser.post(f"{IDP}/authorize", allow_redirects=False, data={
"transaction_id": transaction, "username": "user", "password": "user",
})
callback = parse_qs(urlparse(answer.headers["Location"]).query)
assert callback["state"] == [state], "state must come back unchanged"
code = callback["code"][0]
# 4. The SPA exchanges the code: client_id and code_verifier, no secret
tokens = requests.post(f"{IDP}/token", data={
"grant_type": "authorization_code", "code": code,
"client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}).json()
# 5. Check the ID token: signature (JWKS), issuer, audience, expiry
discovery = requests.get(f"{IDP}/.well-known/openid-configuration").json()
id_token = tokens["id_token"]
key = jwt.PyJWKClient(discovery["jwks_uri"]).get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, key, algorithms=["RS256"],
issuer=discovery["issuer"], audience=CLIENT_ID)
print("logged in:", claims["sub"], "| id_token aud:", claims["aud"],
"| refresh token:", "refresh_token" in tokens)
logged in: user | id_token aud: spa-client | refresh token: True
Steps 2 and 3 are specific to NanoIDP: transaction_id, username and
password are the fields of its login page. The rest is the standard
Authorization Code exchange with PKCE.
5. Drive the real SPA in an end-to-end test
To test the SPA itself with Playwright or Cypress, without typing a password into a form, turn on auto-login:
# settings.yaml
login:
mode: persona
auto_login: true
An /authorize request whose login_hint is persona-auto-login:USERNAME
then logs that user in directly and redirects back with a code. The SPA
passes the hint through its library:
auth.signinRedirect({ login_hint: "persona-auto-login:admin" });
An unknown user comes back to the SPA as a standard OAuth error
(error=invalid_request, "Unknown persona for auto-login", state
preserved), so the failure path is testable too. See
Auto-login.
What must fail
A login test that only checks the happy path does not tell you the client is correct. With this preset, NanoIDP answers each of these as below, and each one is worth a test on your side. The protocol answers follow the OAuth and PKCE specifications; the login form is NanoIDP's own.
| What the client does | NanoIDP's answer, with this preset |
|---|---|
/authorize without code_challenge | redirect with error=invalid_request: PKCE with S256 is required for a public client |
code_challenge_method=plain | redirect with error=invalid_request: must be S256 |
a redirect_uri not in redirect_uris | 400 invalid_request answered directly, never a redirect to the unregistered URI |
the code redeemed with the wrong code_verifier | 400 invalid_grant |
| the same code redeemed twice | 400 invalid_grant the second time |
| a wrong password | the login form again, with an error |
Takeaways
- An SPA is a public client: register it with
token_endpoint_auth_method: "none"and no secret. PKCE is what protects the code. - Register the redirect URIs. Exact matching is what stops a code from being sent somewhere else, and it is what production providers enforce.
- The ID token says who logged in; the access token is for your API. In
NanoIDP, email and profile come from
/userinfo. - The flow is scriptable end to end, and auto-login lets a browser test drive the real SPA without a login form.
Run a real OIDC provider in CI with GitHub Actions
Integration tests for an app that logs users in need an identity provider in the pipeline. A shared cloud tenant makes every job depend on the network and on state other jobs leave behind; mocking the provider tests your mock. NanoIDP runs inside the job, as a Python process or a service container, issues real signed tokens, and lets each test create the users it needs and remove them afterwards.
This page covers the test environment: starting the instance, knowing it is ready, an identity per test, isolation between tests and jobs, and cleanup. For the login flow itself, see Test an SPA login with PKCE.
Every file on this page is in
examples/ci-github-actions/,
and the repository's own CI runs it on every change, both ways shown here.
Copy it into your repository with the same layout, which is the one the
workflows below expect:
.github/workflows/tests.yml one of the two workflows below
ci/start-nanoidp.sh start and wait (step 2)
tests/conftest.py the test_user fixture (step 3)
tests/test_identities.py tests that use it (step 5)
1. Start NanoIDP in the job
Either way, pin the version: the IdP your tests run against should change when you decide, not when a release comes out.
As a Python process (any runner with Python, and the same script works on a laptop):
# .github/workflows/tests.yml - NanoIDP started from PyPI inside the job
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install nanoidp==3.3.0 pytest pytest-xdist requests
- run: bash ci/start-nanoidp.sh
- run: pytest -n 4 tests
- if: failure()
run: cat nanoidp.log
As a service container, from the published image:
# .github/workflows/tests.yml - NanoIDP as a service container
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
nanoidp:
image: ghcr.io/cdelmonte-zg/nanoidp:v3.3.0
ports:
- 8000:8000
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pytest pytest-xdist requests pyjwt
- name: Wait for NanoIDP
run: timeout 30 sh -c 'until curl -fsS --max-time 2 http://localhost:8000/api/health; do sleep 1; done'
- run: pytest -n 4 tests
The image starts with its default configuration: the demo-client /
demo-secret client, the admin user, and the issuer
http://localhost:8000, which is the address the job's steps reach it at.
Tests that need more users or clients create them at runtime (step 3), so no
configuration file has to be mounted into the container.
2. Ready means your instance answered
/api/health answers {"status":"ok"} once the server is up. Waiting for
it is not enough on its own: if something else already listens on the port,
the new process fails to bind and exits, and the health check is answered
by the other process. The tests then run against an IdP that is not the one
the job configured. The start script refuses a port that is already taken,
even by a process that accepts connections and never answers, stops waiting
as soon as its own process exits, and bounds every request in time:
#!/usr/bin/env bash
# Start a NanoIDP for this CI job and wait until it answers.
set -euo pipefail
# Refuse a port another process holds: the readiness check below would
# otherwise be answered by that process instead of this job's IdP. Only
# "connection refused" (curl exit code 7) means the port is free; anything
# else, a listener that accepts and never answers included, means it is not.
probe=0
curl -s -o /dev/null --max-time 2 http://localhost:8000/ || probe=$?
if [ "$probe" -ne 7 ]; then
echo "port 8000 is already in use" >&2
exit 1
fi
python -m nanoidp init ./idp-config
python -m nanoidp --config ./idp-config > nanoidp.log 2>&1 &
echo $! > nanoidp.pid
# Ready when /api/health answers; give up if the process exits or after 30 s
timeout 30 sh -c 'until curl -fsS --max-time 2 http://localhost:8000/api/health; do
kill -0 "$(cat nanoidp.pid)" || exit 1; sleep 1; done' \
|| { cat nanoidp.log; exit 1; }
On a fresh hosted runner the port is free; on a self-hosted runner or a laptop, an IdP left over from an earlier run is exactly what the first check catches.
3. One identity per test
/api/runtime creates users and clients on the running instance, without
touching its configuration files, and they work on every protocol surface
at once. A pytest fixture gives each test a user of its own:
import os
import uuid
import pytest
import requests
IDP = os.environ.get("NANOIDP_URL", "http://localhost:8000")
TIMEOUT = 10 # seconds: a request to a stuck IdP fails instead of hanging
@pytest.fixture
def test_user():
"""A user that exists for one test only, under a name no other test uses."""
user = {
"username": f"ci-{uuid.uuid4().hex[:12]}",
"password": uuid.uuid4().hex,
"roles": ["TESTER"],
}
created = requests.post(f"{IDP}/api/runtime/users", json=user, timeout=TIMEOUT)
assert created.status_code == 201, created.text
yield user
# Delete this user by name, never with DELETE /api/runtime: that removes
# every runtime identity, including those of tests running in parallel
deleted = requests.delete(f"{IDP}/api/runtime/users/{user['username']}",
timeout=TIMEOUT)
# 404: the test deleted the user itself, as two tests here do on purpose
assert deleted.status_code in (200, 404), deleted.text
def password_token(user, **extra):
"""A token for the user, from the client every default config declares."""
return requests.post(f"{IDP}/token", auth=("demo-client", "demo-secret"), data={
"grant_type": "password",
"username": user["username"], "password": user["password"], **extra,
}, timeout=TIMEOUT)
The body of POST /api/runtime/users is a users.yaml entry plus the
username; clients work the same way through /api/runtime/clients. The
full API is in Disposable test identities.
4. Isolation
Between jobs, there is nothing to do: each job starts its own instance on its own runner, and runtime identities live in that process's memory.
Between tests in one job is where the care goes, because parallel
workers (pytest -n 4) share one instance:
- Unique names. Two tests creating
ci-alicecollide: the secondPOSTanswers409. A random suffix makes every name unique. - Delete by name.
DELETE /api/runtimeremoves every runtime identity, including those of tests still running on other workers. With it in the teardown, a run of eight parallel tests that each sleep briefly before logging in loses one or two of them to a user deleted underneath; deleting by name, the same run passes every time. - Check the delete. A teardown that ignores the answer lets an identity
survive a failed delete while the test still passes. The fixture accepts
200, and404for a user the test deleted itself; anything else fails the test.
5. Cleanup, and what deleting does not do
The fixture's teardown deletes its user, and the instance itself ends with the job, taking every runtime identity with it.
Deleting a user does not revoke what it was issued. An access token taken
before the delete stays valid until it expires (introspection still says
active: true); only its refresh token is refused. A test that asserts
"the user is gone" has to check a new login, not a token it already holds.
The example's tests pin both behaviours:
import jwt
import pytest
import requests
from conftest import IDP, TIMEOUT, password_token
@pytest.mark.parametrize("attempt", range(8))
def test_each_test_gets_its_own_user(test_user, attempt):
response = password_token(test_user)
assert response.status_code == 200, response.text
claims = jwt.decode(response.json()["access_token"],
options={"verify_signature": False})
assert claims["sub"] == test_user["username"]
assert claims["roles"] == ["TESTER"]
def test_a_deleted_user_cannot_log_in(test_user):
requests.delete(f"{IDP}/api/runtime/users/{test_user['username']}", timeout=TIMEOUT)
assert password_token(test_user).json()["error"] == "invalid_grant"
def test_deleting_a_user_does_not_revoke_its_tokens(test_user):
tokens = password_token(test_user, scope="openid offline_access").json()
requests.delete(f"{IDP}/api/runtime/users/{test_user['username']}", timeout=TIMEOUT)
# The access token taken before the delete is still valid until it expires
introspection = requests.post(f"{IDP}/introspect", auth=("demo-client", "demo-secret"),
data={"token": tokens["access_token"]}, timeout=TIMEOUT).json()
assert introspection["active"] is True
# The refresh token is refused
refreshed = requests.post(f"{IDP}/token", auth=("demo-client", "demo-secret"), data={
"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"],
}, timeout=TIMEOUT)
assert refreshed.json()["error"] == "invalid_grant"
Access to the management API
/api/runtime, like the rest of /api/*, is open by default: fine for an
instance on the job's loopback or a service container on the job's own
network. When the IdP is reachable from elsewhere, set a
management_secret and send it
as X-Management-Secret on the POST and DELETE calls.
Takeaways
- Run the IdP inside the job and pin its version; a pip process and a service container both work, and neither needs a config file mounted.
- A health check proves that something answers on the port. Make sure it is the process the job started.
- Give each test its own identity with a unique name, and delete it by name.
DELETE /api/runtimeis for when nothing else is running. - Deleting an identity stops new logins, not the tokens it already has.
Test a Spring Boot SAML service provider without a real IdP
A Spring Boot application that logs its users in over SAML 2.0 needs an identity provider to test against: one that serves metadata, answers an AuthnRequest with a signed assertion, and sends the attributes your authorization rules read. A corporate IdP is rarely available to a developer machine or a CI job, and a hosted one means an account and a network dependency.
NanoIDP is that IdP on localhost: SAML SSO over the HTTP-POST and
HTTP-Redirect bindings, signed responses, and users with roles and groups
from a YAML file. This page connects a Spring Boot 4 service provider to
it, maps the roles to Spring authorities, and tests the login without a
browser.
Everything here is in
examples/spring-boot-saml/,
and the repository's CI builds the SP and runs the tests below against it.
1. Start NanoIDP with the preset
pip install nanoidp requests pytest # requests and pytest for the tests below
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/spring-boot-saml
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - Spring Boot SAML Integration
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/spring-boot-saml.html
server:
host: "127.0.0.1"
# host: "0.0.0.0" # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
port: 8000
oauth:
issuer: "http://localhost:8000"
audience: "spring-boot-app"
saml:
# entity_id / sso_url: absent = derived from the effective issuer as
# <issuer>/saml and <issuer>/saml/sso (follows issuer_from_request); set
# them only to pin a fixed value
# entity_id: "http://localhost:8000/saml"
# sso_url: "http://localhost:8000/saml/sso"
# Used only when a request names no ACS URL; Spring always names one
default_acs_url: "http://localhost:8080/login/saml2/sso/nanoidp"
# Export roles/groups as SAML attributes (off by default in NanoIDP)
export_roles: true
export_groups: true
roles_attr_name: "roles"
groups_attr_name: "groups"
# Authority prefix mapping for Spring Security GrantedAuthority
authority_prefixes:
roles: "ROLE_"
groups: "GROUP_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
# Allowed identity classes
allowed_identity_classes:
- "INTERNAL"
- "EXTERNAL"
- "CONTRACTOR"
NanoIDP does not send roles and groups over SAML unless export_roles and
export_groups are on. The users are admin / admin (roles ADMIN,
USER, group ADMINISTRATORS), user / user (role USER) and
readonly / readonly (role VIEWER).
2. Point Spring Boot at NanoIDP
Spring Boot 4 has a starter for SAML. Its OpenSAML dependency is not on
Maven Central, so the build needs the Shibboleth repository as well; without
it, the build fails with Could not find artifact org.opensaml:opensaml-saml-api:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-saml2</artifactId>
</dependency>
</dependencies>
<repositories>
<!-- OpenSAML, which Spring Security's SAML support needs, is not on Maven Central -->
<repository>
<id>shibboleth-releases</id>
<url>https://build.shibboleth.net/maven/releases/</url>
</repository>
</repositories>
The registration needs one URL, NanoIDP's metadata, which carries the SSO endpoint, both bindings and the signing certificate:
spring:
security:
saml2:
relyingparty:
registration:
nanoidp:
assertingparty:
# Everything about the IdP (SSO URL, bindings, signing
# certificate) comes from NanoIDP's metadata
metadata-uri: http://localhost:8000/saml/metadata
Spring's default entity ID, {baseUrl}/saml2/service-provider-metadata/nanoidp,
is what the SP sends as the AuthnRequest's Issuer, and NanoIDP puts that
value in the assertion's Audience (SAML Profiles §4.1.4.2), so the two
agree without configuration. Up to 3.3.0 every assertion carried
oauth.audience instead, and the SP's entity-id had to be set to it.
3. Turn SAML roles into Spring authorities
A SAML login in Spring Security carries no role by default: hasRole fails
for everyone, and /admin answers 403 even for admin. The response
converter below reads the roles attribute and grants ROLE_ADMIN,
ROLE_USER and so on:
package example;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.schema.XSAny;
import org.opensaml.core.xml.schema.XSString;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AttributeStatement;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// By default a SAML login carries no role; turn the assertion's
// "roles" attribute into ROLE_ authorities so hasRole() works
var converter = new OpenSaml5AuthenticationProvider.ResponseAuthenticationConverter();
converter.setGrantedAuthoritiesConverter(SecurityConfig::rolesFromAssertion);
var provider = new OpenSaml5AuthenticationProvider();
provider.setResponseAuthenticationConverter(converter);
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.saml2Login(saml -> saml.authenticationManager(provider::authenticate))
.saml2Metadata(Customizer.withDefaults());
return http.build();
}
static Collection<GrantedAuthority> rolesFromAssertion(Assertion assertion) {
List<GrantedAuthority> authorities = new ArrayList<>();
for (AttributeStatement statement : assertion.getAttributeStatements()) {
for (Attribute attribute : statement.getAttributes()) {
if (!"roles".equals(attribute.getName())) {
continue;
}
for (XMLObject value : attribute.getAttributeValues()) {
// NanoIDP sends values without xsi:type: OpenSAML reads them as XSAny
String role = value instanceof XSString s ? s.getValue()
: value instanceof XSAny any ? any.getTextContent() : null;
if (role != null) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
}
}
return authorities;
}
}
NanoIDP sends attribute values without an xsi:type, which OpenSAML reads
as XSAny, not XSString; a converter that only accepts XSString finds
no role at all. The one above accepts both.
4. What arrives in Spring
For admin, the application sees:
| Where | Value |
|---|---|
principal name (the NameID) | admin@example.org: NanoIDP uses the user's email |
roles attribute | ADMIN, USER |
groups attribute | ADMINISTRATORS |
| other attributes | email, identity_class, entitlements |
| authorities, with the converter | ROLE_ADMIN, ROLE_USER, and FACTOR_SAML_RESPONSE, which Spring Security 7 adds to every SAML login |
tenant and source_acl are not in the login assertion; the SAML
reference lists what each SAML surface sends in
The two SAML surfaces.
5. Test the login without a browser
The SAML login is four HTTP exchanges, and they can be scripted: the SP's
auto-submitting form with the AuthnRequest, NanoIDP's login form, NanoIDP's
auto-submitting form with the SAMLResponse, and the SP's answer. The tests
below do what a browser does, against the SP on :8080 and NanoIDP on
:8000:
"""SAML login to the Spring Boot SP through NanoIDP, the way a browser does it.
Needs NanoIDP on :8000 with this preset and the SP (sp/) on :8080.
"""
import html
import re
import requests
SP = "http://localhost:8080"
TIMEOUT = 10 # seconds: a stuck server fails the test instead of hanging it
def form(page, url=None):
"""The action and the fields of the first form on a page."""
action = re.search(r'<form[^>]*action="([^"]+)"', page)
fields = {html.unescape(name): html.unescape(value) for name, value in
re.findall(r'<input[^>]*name="([^"]+)"[^>]*value="([^"]*)"', page)}
return (html.unescape(action[1]) if action else url), fields
def saml_login(path, username, password):
"""Open `path` on the SP, log in at NanoIDP, return (browser, final page)."""
browser = requests.Session()
# 1. The SP answers with a form that posts an AuthnRequest to NanoIDP
action, fields = form(browser.get(SP + path, timeout=TIMEOUT).text)
# 2. NanoIDP shows its login form (it posts back to the same URL)
login_page = browser.post(action, data=fields, timeout=TIMEOUT)
action, fields = form(login_page.text, login_page.url)
fields.update(username=username, password=password)
# 3. NanoIDP answers with a form that posts the SAMLResponse to the SP's ACS
answer = browser.post(action, data=fields, timeout=TIMEOUT)
if "SAMLResponse" not in answer.text:
return browser, answer
action, fields = form(answer.text)
# 4. The SP validates the response and sends the browser where it started
return browser, browser.post(action, data=fields, timeout=TIMEOUT)
def test_admin_reaches_the_admin_area():
browser, page = saml_login("/admin", "admin", "admin")
assert page.status_code == 200, page.text
assert page.text == "admin area for admin@example.org\n"
def test_roles_become_authorities_and_attributes_arrive():
browser, _ = saml_login("/", "admin", "admin")
home = browser.get(SP + "/", timeout=TIMEOUT).text
assert "user=admin@example.org" in home # NameID: the email
assert "ROLE_ADMIN" in home and "ROLE_USER" in home # from the roles attribute
assert "roles=[ADMIN, USER]" in home
assert "groups=[ADMINISTRATORS]" in home
def test_a_user_without_the_admin_role_is_refused():
_, page = saml_login("/admin", "user", "user")
assert page.status_code == 403
def test_a_wrong_password_gets_no_saml_response():
_, page = saml_login("/", "admin", "wrong")
assert "SAMLResponse" not in page.text
assert page.url.startswith("http://localhost:8000/saml/sso")
The login form fields (username, password) are NanoIDP's own; the rest
is the standard SAML Web Browser SSO profile over the HTTP-POST binding.
What must fail
| What happens | Result with this example |
|---|---|
user opens /admin | 403: the login works, the role is missing |
| a wrong password | NanoIDP shows its form again; no SAMLResponse reaches the SP |
Limits to know
- No single logout. NanoIDP's metadata has no
SingleLogoutService, sosaml2Logout()has nothing to talk to. Log out locally with Spring's ordinarylogout().
Takeaways
- Spring Boot 4 needs the
spring-boot-starter-security-saml2starter and the Shibboleth Maven repository; the IdP side is one metadata URL. - Roles are not authorities until you map them. Map the attribute your IdP
sends, and accept values without
xsi:type. - An SP that rejects every response with "validation errors" is most often
an audience mismatch. Compare the assertion's
Audiencewith the SP's entity ID: NanoIDP takes it from the AuthnRequest'sIssuer, so the two differ only when the SP sends one identifier and expects another. - The login is scriptable end to end, so SAML can be tested in CI like anything else.
Test service-to-service auth with client credentials
When one microservice calls another, there is no user: the calling service gets a token with the OAuth 2.0 client credentials grant and sends it as a bearer token. The receiving service has to check three things: that the token is genuine, that it was issued for this service, and that the caller has the scope for the operation. The second check is the one most often missing, and the one a test should prove.
NanoIDP issues these tokens locally, with per-client scopes and per-service audiences (RFC 8707 resource indicators), so you can test both what a service accepts and what it must refuse. This page sets up two services calling two APIs, and a Spring Boot resource server that enforces the three checks.
Everything here is in
examples/microservices-client-credentials/,
and the repository's CI builds the API and runs the tests below against it.
1. Declare the services, their scopes and their audiences
pip install nanoidp requests pytest # requests and pytest for the tests below
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/microservices-client-credentials
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - Microservices Client Credentials
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/service-to-service-client-credentials.html
server:
host: "127.0.0.1"
# host: "0.0.0.0" # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
port: 8000
oauth:
issuer: "http://localhost:8000"
# The aud of a token requested without a resource: accepted by no service
audience: "microservices"
token_expiry_minutes: 60
# Every scope a client may ask for must be in the vocabulary
scopes_supported:
- openid
- profile
- email
- offline_access
- orders:read
- inventory:read
- inventory:reserve
clients:
# Calls the inventory API: reads stock, reserves items
- client_id: "order-service"
client_secret: "order-service-secret"
description: "Order management microservice"
allowed_scopes: [inventory:read, inventory:reserve]
allowed_resources: ["https://inventory.internal"]
# Calls the orders API: reads orders to notify about them
- client_id: "notification-service"
client_secret: "notification-service-secret"
description: "Notification delivery microservice"
allowed_scopes: [orders:read]
allowed_resources: ["https://orders.internal"]
scopes_supportedis the vocabulary: NanoIDP refuses a scope outside it withinvalid_scope, for every client.allowed_scopesnarrows what one client may ask for.allowed_resourceslists the APIs a client may get a token for. A token requested withresource=https://inventory.internalhas that as itsaud; without aresource, itsaudisoauth.audience, which no API here accepts.
2. Get a token as a service
curl -X POST http://localhost:8000/token \
-u 'order-service:order-service-secret' \
-d 'grant_type=client_credentials' \
-d 'scope=inventory:read' \
-d 'resource=https://inventory.internal'
The access token's claims include:
{
"iss": "http://localhost:8000",
"aud": "https://inventory.internal",
"client_id": "order-service",
"scope": "inventory:read",
"sub": "order-service"
}
No refresh token: the service asks again when the token expires.
The token is the service's own: sub is the calling client, as RFC 9068
§2.2 asks for grants with no user, and no user's roles or attributes come
with it. Authorize services on their scopes and identify them by
client_id or sub.
In releases up to 3.3.0, a client credentials token was issued for the
default_userofusers.yaml, with that user's roles (#445):subwas that user's name, not the service.default_useris deprecated since, and this preset no longer needs a user for its services.
3. Enforce the checks in the receiving service
A Spring Boot 4 resource server needs one starter:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
The issuer URI gives Spring NanoIDP's discovery document and JWKS, so the
signature and iss are checked. The audience is not checked unless you
say so: without the audiences line, the inventory API accepts a token
issued for any other service.
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
# Discovery and JWKS come from NanoIDP; iss is checked against it
issuer-uri: http://localhost:8000
# Spring does not check aud unless told to: without this line a
# token issued for any other service is accepted here
audiences: https://inventory.internal
Spring turns the scope claim into SCOPE_ authorities, one per scope:
package example;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Spring turns the token's "scope" claim into SCOPE_ authorities.
// Authorize a service on its scopes; its sub is its client_id, and a
// client_credentials token carries no roles
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/stock/**").hasAuthority("SCOPE_inventory:read")
.requestMatchers(HttpMethod.POST, "/reservations").hasAuthority("SCOPE_inventory:reserve")
.anyRequest().denyAll())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
4. Test what is accepted and what is refused
These tests play order-service and notification-service against the
inventory API on :8081, and ask NanoIDP for tokens it must refuse:
"""order-service calls the inventory API with a client_credentials token.
Needs NanoIDP on :8000 with this preset and the inventory API
(inventory-api/) on :8081.
"""
import jwt
import requests
IDP = "http://localhost:8000"
INVENTORY_API = "http://localhost:8081"
INVENTORY = "https://inventory.internal" # the inventory API's audience
TIMEOUT = 10 # seconds: a stuck server fails the test instead of hanging it
def token_request(client_id, secret, scope=None, resource=None):
data = {"grant_type": "client_credentials"}
if scope:
data["scope"] = scope
if resource:
data["resource"] = resource # RFC 8707: the token's aud becomes this
return requests.post(f"{IDP}/token", auth=(client_id, secret), data=data,
timeout=TIMEOUT)
def token(client_id, secret, scope=None, resource=None):
response = token_request(client_id, secret, scope, resource)
assert response.status_code == 200, response.text
return response.json()["access_token"]
def call(method, path, access_token=None):
headers = {"Authorization": f"Bearer {access_token}"} if access_token else {}
return requests.request(method, INVENTORY_API + path, headers=headers,
timeout=TIMEOUT)
ORDER_SERVICE = ("order-service", "order-service-secret")
# What the inventory API accepts
def test_order_service_reads_stock():
access = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
response = call("GET", "/stock/sku-1", access)
assert response.status_code == 200
assert response.json()["caller"] == "order-service"
def test_reserving_needs_the_reserve_scope():
read_only = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
assert call("POST", "/reservations", read_only).status_code == 403
both = token(*ORDER_SERVICE, scope="inventory:read inventory:reserve",
resource=INVENTORY)
assert call("POST", "/reservations", both).status_code == 200
# What the inventory API refuses
def test_no_token_is_refused():
assert call("GET", "/stock/sku-1").status_code == 401
def test_a_token_without_a_resource_is_refused():
# aud is oauth.audience ("microservices"), not this API's audience
access = token(*ORDER_SERVICE, scope="inventory:read")
assert call("GET", "/stock/sku-1", access).status_code == 401
def test_a_token_for_another_service_is_refused():
access = token("notification-service", "notification-service-secret",
scope="orders:read", resource="https://orders.internal")
assert call("GET", "/stock/sku-1", access).status_code == 401
# What NanoIDP refuses to issue
def test_a_scope_the_client_may_not_have():
response = token_request(*ORDER_SERVICE, scope="orders:read", resource=INVENTORY)
assert response.json()["error"] == "invalid_scope"
def test_a_resource_the_client_may_not_target():
response = token_request(*ORDER_SERVICE, scope="inventory:read",
resource="https://orders.internal")
assert response.json()["error"] == "invalid_target"
def test_a_wrong_secret():
response = token_request("order-service", "wrong", scope="inventory:read")
assert response.status_code == 401
assert response.json()["error"] == "invalid_client"
# What the token says about the caller
def test_the_caller_is_the_client_id_and_carries_no_user_privileges():
access = token(*ORDER_SERVICE, scope="inventory:read", resource=INVENTORY)
claims = jwt.decode(access, options={"verify_signature": False})
assert claims["client_id"] == "order-service"
assert claims["aud"] == INVENTORY
# The token is the client's own (RFC 9068 §2.2): its subject is the
# client, and no user's roles or attributes come with it
assert claims["sub"] == "order-service"
for user_claim in ("roles", "authorities", "tenant", "entitlements", "groups", "attributes"):
assert user_claim not in claims
| Case | Answer |
|---|---|
order-service, inventory:read, audience https://inventory.internal, GET /stock | 200, the caller is order-service |
the same token on POST /reservations | 403: the scope is missing |
| no token | 401 |
a token requested without resource (aud is microservices) | 401 |
notification-service's token for https://orders.internal | 401: issued for another API |
order-service asks for orders:read | NanoIDP: invalid_scope |
order-service asks for https://orders.internal | NanoIDP: invalid_target |
| a wrong client secret | NanoIDP: 401 invalid_client |
Remove the audiences line from the API and the two "issued for another
API" cases turn into accepted calls: the tests catch it.
Takeaways
- Give every API its own audience and have clients request it with
resource. A shared audience means any service's token works everywhere. - Spring checks the signature and the issuer by itself, the audience only when configured. Test that a token for another service is refused.
- Authorize a service on its scopes, and identify it by its
sub, which is itsclient_id. A client credentials token carries no user's roles. - Declare scopes in
scopes_supportedand per client inallowed_scopes: a scope NanoIDP does not know is refused, not silently dropped.
Test a CLI login with the device authorization flow
A command-line tool, a smart TV or a build agent cannot show a login page. The OAuth 2.0 device authorization grant (RFC 8628) solves that: the tool asks for a short code, tells the user to enter it in a browser anywhere, and polls until the user has logged in and approved. The GitHub CLI logs in this way, and so can several cloud CLIs.
Testing that loop needs an authorization server that issues device codes, serves the page where the user enters them, and answers the polling correctly: pending, denied, used once. NanoIDP does all of it locally, and the browser step can be scripted, so the whole login runs in a test.
Everything here is in
examples/cli-device-flow/,
and the repository's CI runs the tests below on every change.
1. Register the CLI as a public client
A CLI ships to its users, so a secret inside it is not a secret. Register it
as a public client, identified by its client_id alone:
pip install nanoidp requests pytest # the CLI uses requests; pytest runs the tests
mkdir -p config
base=https://raw.githubusercontent.com/cdelmonte-zg/nanoidp/main/examples/cli-device-flow
curl -fsSL -o config/settings.yaml "$base/settings.yaml"
curl -fsSL -o config/users.yaml "$base/users.yaml"
python -m nanoidp --config ./config
# NanoIDP Settings - CLI / Device Flow Integration
# Copy this file to your config directory
# Guide: https://cdelmonte-zg.github.io/nanoidp/use-cases/cli-device-flow.html
server:
host: "127.0.0.1"
# host: "0.0.0.0" # LAN access is intentionally opt-in (GHSA-2473-px8h-rvg6)
port: 8000
oauth:
issuer: "http://localhost:8000"
audience: "cli-devices"
token_expiry_minutes: 60
clients:
# A CLI ships to its users, so it cannot keep a secret: a public client,
# identified by client_id alone. Its refresh tokens always rotate
- client_id: "cli-tool"
token_endpoint_auth_method: "none"
description: "Command-line interface tool"
# Device codes last 600 seconds and clients are told to poll every 5
# seconds. A device_flow section changes both (code_expiry_seconds,
# polling_interval) since 3.4.0; 3.3.0 and earlier ignore it
# Authority prefix mapping
authority_prefixes:
roles: "ROLE_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
# Allowed identity classes
allowed_identity_classes:
- "INTERNAL"
- "EXTERNAL"
The users are admin / admin (roles ADMIN, USER) and user /
user (role USER).
2. The CLI side
"""Log a command-line tool in with the device authorization grant (RFC 8628)."""
import time
import requests
IDP = "http://localhost:8000"
CLIENT_ID = "cli-tool" # a public client: no secret
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
TIMEOUT = 10 # seconds, per HTTP request
class LoginFailed(Exception):
pass
def request_device_code(scope="openid profile offline_access"):
response = requests.post(f"{IDP}/device_authorization",
data={"client_id": CLIENT_ID, "scope": scope},
timeout=TIMEOUT)
response.raise_for_status()
return response.json()
def poll_for_token(device, sleep=time.sleep):
"""Poll /token until the user decides, the code expires, or it fails."""
interval = device.get("interval", 5)
deadline = time.monotonic() + device["expires_in"]
while time.monotonic() < deadline:
sleep(interval)
answer = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
"grant_type": DEVICE_GRANT,
"client_id": CLIENT_ID,
"device_code": device["device_code"],
}).json()
error = answer.get("error")
if error is None:
return answer
if error == "authorization_pending":
continue
if error == "slow_down": # RFC 8628 §3.5: wait 5 seconds longer
interval += 5
continue
raise LoginFailed(error) # access_denied, expired_token, invalid_grant...
raise LoginFailed("expired_token")
def refresh(refresh_token):
"""Exchange a refresh token. It rotates: store the new one, drop the old."""
response = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
})
answer = response.json()
if "error" in answer:
raise LoginFailed(answer["error"])
return answer
if __name__ == "__main__":
device = request_device_code()
print(f"Open {device['verification_uri']} and enter the code {device['user_code']}")
print(f"(or open {device['verification_uri_complete']})")
tokens = poll_for_token(device)
print("Logged in; access token expires in", tokens["expires_in"], "seconds")
$ python cli_login.py
Open http://localhost:8000/device and enter the code H3V74VHV
(or open http://localhost:8000/device?user_code=H3V74VHV)
Logged in; access token expires in 3600 seconds
The user opens the URL and enters the code (with
verification_uri_complete it is already filled in), logs in and chooses
Authorize Device or Deny. Meanwhile the CLI polls /token
every interval seconds:
| NanoIDP's answer to a poll | What the CLI does |
|---|---|
authorization_pending | waits and polls again |
slow_down | waits 5 seconds longer from then on (RFC 8628 §3.5; NanoIDP does not send it today, a CLI should handle it anyway) |
| tokens | done |
access_denied | the user chose Deny: stop |
invalid_grant | the device code is unknown or already used: stop |
expired_token, or its own deadline | the code's lifetime is over: start again |
The access token is the user's: its sub is the user who approved, with
that user's roles, and its client_id is cli-tool.
A device code lives 600 seconds, and clients are told to poll every 5. In
3.4.0 and later both can be set, which makes expired_token testable
in a second:
device_flow:
code_expiry_seconds: 1 # default 600
polling_interval: 1 # default 5
3.3.0 and earlier accept that section and ignore it.
3. Script the browser step and test the loop
What the user does in the browser is one form post to /device, with the
user code, the credentials and the button pressed. The tests run the CLI's
own polling in a thread, and approve or deny while it waits:
"""The CLI's device flow against NanoIDP, with the browser step scripted.
Needs NanoIDP on :8000 with this preset.
"""
import threading
import time
import jwt
import pytest
import requests
from cli_login import IDP, TIMEOUT, LoginFailed, poll_for_token, refresh, request_device_code
def user_decides(user_code, username, password, action="authorize"):
"""What the user does in the browser at verification_uri."""
return requests.post(f"{IDP}/device", timeout=TIMEOUT, data={
"user_code": user_code, "username": username, "password": password,
"action": action,
})
def poll_in_background(device):
"""Start polling as the CLI does, and return a way to collect the result.
Returns once the CLI has polled at least once and gone back to waiting,
so the user's decision always finds it mid-poll, as in real use.
"""
outcome = {}
pauses = threading.Semaphore(0)
def pause(seconds):
# Tests do not wait out the polling interval, only a moment
pauses.release()
time.sleep(0.05)
def run():
try:
outcome["tokens"] = poll_for_token(device, sleep=pause)
except LoginFailed as failure:
outcome["error"] = str(failure)
thread = threading.Thread(target=run, daemon=True) # a stuck CLI fails the test, not the run
thread.start()
# One pause before the first poll, one after its authorization_pending
assert pauses.acquire(timeout=10) and pauses.acquire(timeout=10)
return lambda: (thread.join(timeout=30), outcome)[1]
def test_the_user_approves_and_the_cli_gets_tokens_for_that_user():
device = request_device_code()
result = poll_in_background(device)
assert user_decides(device["user_code"], "user", "user").status_code == 200
tokens = result()["tokens"]
claims = jwt.decode(tokens["access_token"], options={"verify_signature": False})
assert claims["sub"] == "user"
assert claims["client_id"] == "cli-tool"
assert claims["roles"] == ["USER"]
assert "refresh_token" in tokens
def test_the_user_denies():
device = request_device_code()
result = poll_in_background(device)
user_decides(device["user_code"], "user", "user", action="deny")
assert result()["error"] == "access_denied"
def test_a_wrong_password_leaves_the_request_pending():
device = request_device_code()
page = user_decides(device["user_code"], "user", "wrong")
assert "Invalid username or password" in page.text
# The CLI keeps polling; the request is still open for a correct login
pending = requests.post(f"{IDP}/token", timeout=TIMEOUT, data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": "cli-tool", "device_code": device["device_code"],
}).json()
assert pending["error"] == "authorization_pending"
def test_an_unknown_user_code_is_refused():
page = user_decides("ZZZZZZZZ", "user", "user")
assert "Invalid or expired user code" in page.text
def test_a_device_code_gives_tokens_once():
device = request_device_code()
result = poll_in_background(device)
user_decides(device["user_code"], "user", "user")
result()
# A short deadline, so a CLI that retried every error would fail here
# within seconds instead of polling for the code's full lifetime
with pytest.raises(LoginFailed, match="invalid_grant"):
poll_for_token(dict(device, expires_in=3), sleep=lambda seconds: time.sleep(0.05))
def test_refresh_tokens_rotate_and_a_reused_one_revokes_the_rest():
device = request_device_code()
result = poll_in_background(device)
user_decides(device["user_code"], "user", "user")
first = result()["tokens"]["refresh_token"]
second = refresh(first)["refresh_token"]
assert second != first
# Using the old one again is treated as theft: it fails, and it revokes
# the newest one too, so the CLI must log in again
with pytest.raises(LoginFailed, match="invalid_grant"):
refresh(first)
with pytest.raises(LoginFailed, match="invalid_grant"):
refresh(second)
The form fields (user_code, username, password, action) belong to
NanoIDP's verification page; the device code request and the polling are
RFC 8628 and look the same against any authorization server.
Refresh tokens rotate
A CLI keeps its refresh token between runs. For a public client NanoIDP always rotates it: each refresh answers a new refresh token, and the old one stops working. Using an old one again is treated as a stolen token: it fails, and it also revokes the newest one, so the CLI has to log in again. The revocation covers the refresh tokens of that one login: access tokens already issued stay valid until they expire, and other logins of the same user are not affected. A CLI must therefore write the new refresh token to disk after every refresh, before anything else can fail. The last test pins that behaviour.
Takeaways
- Register a CLI as a public client (
token_endpoint_auth_method: "none"): it cannot keep a secret. - Poll at the interval the server gives. Keep polling on
authorization_pending, and onslow_downwith 5 seconds more between polls; stop on every other error. - A device code gives tokens once.
- Store the rotated refresh token every time. Reusing an old one revokes that login's refresh tokens, the newest included, so the CLI has to log in again.
- The browser step is one form post, so the whole device login is a test that runs in CI.
Requesting tokens
curl examples for every supported grant, against the default
demo-client / demo-secret client from a fresh
Quickstart setup.
Password grant
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin'
Add &scope=openid to also receive an ID Token: see
Tokens and claims for what comes back.
Under the
oauth21profile this grant is rejected with400and does not appear ingrant_types_supported: OAuth 2.1 removes it entirely.
Client credentials grant
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=client_credentials'
The response carries no refresh_token: the client authenticates itself
on every request, so there is nothing a refresh token could stand in for
(RFC 6749 §4.4.3, "A refresh token SHOULD NOT be included"). Request a
new access token the same way when the current one expires.
Refresh token
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN'
If the original grant included the openid scope, the refresh re-issues
an ID Token as well. A scope parameter may narrow, but never broaden,
the originally granted scope (RFC 6749 §6). Claims requested via the
OIDC claims parameter persist across the refresh, including a
narrowed one; see
Tokens and claims.
With oauth.refresh_token_rotation: true, each refresh invalidates the
consumed refresh token; reuse revokes the whole rotation family
(RFC 9700 §4.14.2).
Public clients (no secret)
A client registered with token_endpoint_auth_method: none (a CLI,
desktop or native app, SPA, or MCP client - see the public-clients note
in the configuration reference) has no
secret, so it sends no -u: it is identified by client_id in the body,
and the PKCE code_verifier is what proves it started the flow. The
code comes from the browser step (GET /authorize with a
code_challenge, then login), which cannot be scripted with curl; only
the exchange is shown here.
curl -X POST 'http://localhost:8000/token' \
-d 'grant_type=authorization_code' \
-d 'code=CODE_FROM_THE_AUTHORIZE_REDIRECT' \
-d 'client_id=cli-client' \
-d 'redirect_uri=http://127.0.0.1:8765/callback' \
-d 'code_verifier=YOUR_PKCE_CODE_VERIFIER'
A public client MUST use PKCE with S256 (the request is rejected
without it), cannot use the client_credentials grant, and always gets a
rotating refresh token. A client_secret sent by a public client is
ignored.
Resource indicators (RFC 8707)
To bind the access token to a specific resource server - so a token for
MCP server A is rejected by server B - send a resource parameter (an
absolute URI without a fragment). An MCP client sends it on both
/authorize and /token; the access token's aud becomes that
resource. resource is repeatable for more than one target, and a
/token request may narrow the resources an authorization code bound but
never widen them. A resource outside the client's allowed_resources (or
not a valid URI) is rejected with invalid_target.
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=client_credentials' \
-d 'resource=https://mcp.example/server'
# the returned access token's aud is https://mcp.example/server
Sending no resource leaves the audience at oauth.audience, unchanged.
Device authorization flow
# 1. Request device code
curl -X POST 'http://localhost:8000/device_authorization' \
-u 'demo-client:demo-secret' \
-d 'scope=openid'
# Response:
# {
# "device_code": "...",
# "user_code": "ABCD1234",
# "verification_uri": "http://localhost:8000/device",
# "expires_in": 600
# }
# 2. User visits verification_uri and enters user_code
# 3. Poll for token
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=YOUR_DEVICE_CODE'
UserInfo
The email / profile claims (email, email_verified,
preferred_username, ...) are returned here, using the access token - not
embedded in the ID Token. See
Tokens and claims
for the details and for the claims request parameter that can put a
claim inside the ID Token.
curl 'http://localhost:8000/userinfo' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
Token introspection
curl -X POST 'http://localhost:8000/introspect' \
-u 'demo-client:demo-secret' \
-d 'token=YOUR_ACCESS_TOKEN'
Token revocation
curl -X POST 'http://localhost:8000/revoke' \
-u 'demo-client:demo-secret' \
-d 'token=YOUR_ACCESS_TOKEN'
Disposable test identities
An integration test or a CI job often needs users and clients that exist for
one run: ci-alice with a given role, a client with a narrow scope, a
public client for a PKCE flow. Declaring them in users.yaml and
settings.yaml changes the baseline every other test starts from, and
restarting the IdP to pick them up is slow.
/api/runtime creates them on a running IdP instead. They work in every
protocol flow at once, they never touch the declared files, and a single
call removes them all. When one turns out to be worth keeping, it can be
promoted into the declared configuration.
Create, use, remove
# A runtime user: the body is a users.yaml entry plus "username"
curl -X POST http://localhost:8000/api/runtime/users \
-H 'Content-Type: application/json' \
-d '{"username": "ci-alice", "password": "alice-pw", "roles": ["TESTER"]}'
# A runtime client: the body is an oauth.clients[] entry
curl -X POST http://localhost:8000/api/runtime/clients \
-H 'Content-Type: application/json' \
-d '{"client_id": "ci-app", "client_secret": "app-secret", "redirect_uris": ["http://localhost:3000/callback"]}'
# Use them like declared ones
curl -X POST http://localhost:8000/token -u ci-app:app-secret \
-d grant_type=password -d username=ci-alice -d password=alice-pw
# Remove one, or everything created at runtime
curl -X DELETE http://localhost:8000/api/runtime/users/ci-alice
curl -X DELETE http://localhost:8000/api/runtime
# {"clients_deleted": 1, "users_deleted": 0}
Payloads are validated by the same models as a declared entry, with the same
defaults and rules, and an invalid one answers 400 with the reason. The
responses never include a password or a client secret.
Runtime objects resolve through the same lookup as declared ones, on every
surface that authenticates: /authorize (persona auto-login included),
/login, every /token grant, client authentication, /userinfo,
/introspect, /revoke, the device flow, SAML SSO and AttributeQuery, and
POST /api/users/{username}/token. They appear, marked as runtime, in
GET /api/users, the persona picker, and the users and clients pages of the
web UI, where they are read-only: edit and delete there act on the declared
configuration, and a runtime object's lifecycle is /api/runtime's. The
MCP server is a separate process and keeps working on the declared
configuration only.
Operations
| Endpoint | What it does |
|---|---|
POST /api/runtime/users | Create a runtime user (201) |
GET /api/runtime/users, GET /api/runtime/users/{username} | Read runtime users (404 for a declared or unknown name) |
DELETE /api/runtime/users/{username} | Delete one |
POST /api/runtime/users/{username}/promote | Write it into users.yaml and retire the runtime object |
POST /api/runtime/clients, GET /api/runtime/clients[/{client_id}], DELETE ..., POST .../promote | The same for clients, promoted into settings.yaml |
DELETE /api/runtime | Remove every runtime user and client; answers the counts. It waits for a promotion in progress, and keeps an object whose promotion is waiting for a successful reload |
There is no update: delete and create again. Errors are JSON with an error
and a kind: invalid (400, the request body), not_found (404),
declared or exists (409, the name is declared, or already a runtime
object), promotion_in_progress (409), conflict (409) and
lock_unavailable (503) from the file writer, unloadable (422, the
file the promotion would write would not load back, so it was not written),
and write_failed or reload_failed (500) for a promotion.
The rules
- Declared first. A name resolves to the declared object when there is
one. A runtime object cannot be created under a declared name (
409). - Declared wins on reload. When a reload introduces a declared object with
the name of a runtime one, the runtime one is removed, with a warning in the
log and a
runtime_identity_removed_on_reloadaudit event. - Reloads keep runtime state.
POST /api/config/reloadand the writes of the web UI leave runtime objects in place. A restart clears them: they live in process memory. - Deleting is not revoking. An access token already issued to a deleted runtime identity keeps verifying until it expires, as for a deleted declared one; a refresh token issued to it is refused.
- Audit. Creating, deleting, promoting and resetting are audit events
(
runtime_identity_created,runtime_identity_deleted,runtime_identity_promoted,runtime_identities_reset), with the object's kind and name; so are a reload removing a runtime object (runtime_identity_removed_on_reload) and an abandoned promotion (runtime_identity_promotion_abandoned).
Promotion
POST /api/runtime/users/{username}/promote writes the user as a new entry
of users.yaml through the same writer the web UI uses, the file's comments
and placeholders included, and the runtime object is retired by the reload
that follows: one runtime_identity_promoted audit event, no collision
warning. The declared entry is then the only copy.
- If the name is already in the file, even one not reloaded yet, the
promotion answers
409and the runtime object stays. - While a promotion runs, deleting or promoting the same object answers
409. - A strict
on_config_savedhook that fails after the write does not undo it: the response carriesmirror_hook_error, and the object is declared. - If the file is written but what follows the write fails (the configuration
is rejected, a strict plugin does not load, anything unexpected), the
response is
500(reload_failed) and the runtime object stays, marked, until a reload succeeds. A failure after the entry reached the file is never reported as a failure to write it. That reload retires it as promoted, or, if the entry is no longer in the file by then, abandons the promotion with a warning and leaves the runtime object as it was. - If the file cannot be written at all (it is malformed on disk, an I/O
error), the response is
500(write_failed) and nothing changes. - If the entry would leave the file unloadable, the response is
422(unloadable) and nothing is written: the check runs before the file is replaced (#366), so this is a refusal likeconflict, not areload_failedwhere the change may already be on disk. - A promotion holds reloads off until it finishes. Someone declaring the same
name at that moment either wins the race to the file (the promotion answers
409and that declaration's reload removes the runtime object as a collision) or writes after it (and gets the web UI's "already exists").
A promoted password or client secret is written as it was sent, as the web UI forms do.
Access
/api/runtime follows every other management write: open by default, like
the rest of /api/* and the web UI forms, and gated by
management_secret when one is configured
(X-Management-Secret on POST and DELETE; reads stay open). Without a
secret, any host that can reach the IdP can already mint tokens for existing
users through /api, so a runtime identity adds no new capability; keep the
IdP on loopback or behind the secret when it is reachable from a network
(see Network Binding).
Several processes
Runtime identities live in the runtime store, which is the process's own by default: a second NanoIDP process does not see them. When a test setup needs two processes on one machine to share them, see Two processes, one runtime state.
Two processes, one runtime state
A test setup sometimes needs more than one NanoIDP process on one machine: an app server and a CLI that both talk to the same IdP, a worker beside the web process, two instances behind a proxy while you try a restart. With the default store each process has a runtime state of its own, so a code issued by one is unknown to the other, and a test identity created through one is invisible to the other.
The SQLite runtime store gives those processes one runtime state. It is still test state: NanoIDP does not promise to keep it, and nothing here turns NanoIDP into an identity provider for production.
Turn it on
# settings.yaml, in the configuration directory both processes read
runtime:
store: sqlite
path: ../state/nanoidp-runtime.db
Start the processes as usual, each with the same configuration directory:
PORT=8000 NANOIDP_CONFIG_DIR=./config python -m nanoidp &
PORT=8001 NANOIDP_CONFIG_DIR=./config python -m nanoidp &
path is relative to the configuration directory, not to the working
directory, so both processes name the same store however they were started.
Signing keys are not: jwt.keys_dir is relative to the working directory,
so give it an absolute path when the processes start from different places,
or they will sign with different keys.
The store is a family of files, named after the database: runtime.db, the
audit in runtime-audit.db, and the owner leases in runtime-owners/. They
hold what the protocols keep, including runtime users' passwords and client
secrets, so they are created 0600 and must lie outside the configuration
directory, which gets read, copied and committed. The full rules are in the
configuration reference.
What the two processes share, and what they do not
Shared, because it lives in the store:
- runtime users and clients, and promotions of them;
- authorization codes, device codes and user codes, each consumed once for both processes;
- refresh token families and revocations;
- the audit, which reads as one from either process.
Not shared, and process-local on purpose: the rate limiter's counters, the
client metadata fetch budget, and on_audit_event dispatch to plugins,
which each process makes for its own events.
The browser leg follows the store too. The login session is a cookie the
process signs with secret_key, which both read from the same
settings.yaml, and the transaction behind /authorize lives in the store,
so a login begun at one process can be finished at the other and the code
comes out the same. Two instances behind a proxy therefore need no sticky
sessions for it.
The declared configuration is shared because the files are: a process notices a change another one wrote, and a promotion made through one is declared for both.
What it does not promise
With store: memory runtime state is process-local and gone at restart.
With store: sqlite it outlives a restart technically, but NanoIDP makes no
durability promise about it: treat it as state you can rebuild or throw
away, never as a record you need to keep. It is also a one-host store: the
processes coordinate through file locks on one filesystem, so it is not a
way to run NanoIDP on several machines.
Start afresh
Stop every process that uses the store first. Removing files under a running process leaves it holding what is no longer there.
# with the processes stopped
rm -rf state/nanoidp-runtime.db state/nanoidp-runtime.db-wal state/nanoidp-runtime.db-shm \
state/nanoidp-runtime-audit.db state/nanoidp-runtime-audit.db-wal \
state/nanoidp-runtime-audit.db-shm state/nanoidp-runtime-owners
Naming another runtime.path is the other way, and needs no removal at all:
the processes then share a new, empty store. Changing the path or the store
of a running process is refused on reload, with a message that says to
restart, because processes that share a store may not disagree about which
one it is.
When it is held
One process at a time writes a decision, and the others wait briefly. A
process that waited longer than it should answers 503 with Retry-After
over HTTP, and MCP_RUNTIME_STORE_UNAVAILABLE with retryable: true in
MCP, rather than making up an answer. Both mean the same thing: ask again.
Configuration freshness
With a shared runtime store, a request that reads the configuration first
checks whether another process changed the files: a stat of the two files,
and a read under the configuration directory's lock only when they changed,
or were changed within the last two seconds. That is the configuration
directory's coordination lock: configuration writers such as a save from
the web UI, an MCP save_config or a promotion take it, and operations
such as the creation of a runtime identity may take it too, while they check
a declared name against the current files. One freshness check in a process may wait for it, for up to 10 seconds,
while another process holds it; the other requests of that process wait for
that check for up to 0.5 seconds and then answer 503 configuration_unavailable
(freshness_in_progress) with Retry-After: 1, and the check that waited
the whole 10 seconds answers the same with lock_timeout. A worker with one
thread cannot run those requests alongside the waiting one, so its requests
queue behind it instead. Health and static-file requests do not check.
Orders of magnitude measured on one host, not promises
(#426): a freshness
read holds the lock for about 0.1 ms, a small write for 7 to 9 ms, a write
of a users.yaml with five hundred users for about a third of a second. A
process that finds the lock taken tries again after 1 ms, then 2, 4, and so
on up to 50 ms between tries, within the same 10 seconds.
See also
- Disposable test identities, the identities this store lets two processes share.
- Configuration, for the
runtime:section rule by rule.
Dynamic client registration
An MCP host is often given nothing but a server URL. RFC 7591 is how it
turns that into a client_id: it posts the metadata it needs, and the
authorization server issues the client. nanoidp implements it so that such a
host can be exercised end to end against a test IdP, together with the
minimum of RFC 7592 needed to read and remove what was registered.
It is off by default, because it is an unauthenticated endpoint that creates clients:
oauth:
dynamic_registration:
enabled: true
max_clients: 100 # live registrations at once
There is no switch for this in the settings form or in the MCP
update_settings tool, on purpose: opening the endpoint is a decision for
the file. The management_secret does not gate /register either. With the
flag off, /register answers 404 and discovery does not advertise it.
Registering
curl -X POST http://localhost:8000/register \
-H 'Content-Type: application/json' \
-d '{
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
"client_name": "My MCP host",
"scope": "openid profile"
}'
{
"client_id": "dcr-8mF1s0T_",
"client_id_issued_at": 1789600000,
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"scope": "openid profile",
"client_name": "My MCP host",
"registration_access_token": "...",
"registration_client_uri": "http://localhost:8000/register/dcr-8mF1s0T_"
}
The client is usable immediately, on every flow a declared client can use.
Omitting token_endpoint_auth_method means client_secret_basic, per the
RFC, and the response then also carries client_secret and
client_secret_expires_at: 0.
The registration access token is shown once. It is the credential for the two management calls below, and the server keeps only its hash.
Reading and removing
curl http://localhost:8000/register/dcr-8mF1s0T_ \
-H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN"
curl -X DELETE http://localhost:8000/register/dcr-8mF1s0T_ \
-H "Authorization: Bearer $REGISTRATION_ACCESS_TOKEN"
A read returns the registration, the delete removes the client with it. A wrong credential and an unknown registration answer the same 401, so the endpoint cannot be used to discover which client ids exist.
What a registered client is
A runtime client, the same kind /api/runtime creates: it lives in the
server's memory, is listed by GET /api/runtime/clients with
"origin": "runtime" and "source": "dcr", and is gone when the process
restarts. It is never written to settings.yaml by registering.
An operator who wants to keep one promotes it, with the same call that promotes any runtime client:
curl -X POST http://localhost:8000/api/runtime/clients/dcr-8mF1s0T_/promote
The client becomes declared configuration, and RFC 7592 management of it ends there: the registration record is dropped, and its access token stops working. The same happens when the client is deleted or reset, or when a reload declares a client of that name.
What is accepted, and what is only recorded
The metadata nanoidp understands is redirect_uris,
token_endpoint_auth_method, grant_types, scope and client_name.
Anything else in the request is ignored rather than refused, as RFC 7591
asks of a server for metadata it does not understand.
redirect_urismust be present and must be absolute URIs, whatever the grant types say. A declared client may leave the list empty to mean "any redirect URI"; a registration may not, since the grant types it sends are recorded rather than enforced and would otherwise be a way around the requirement. Matching rules are a declared client's.scopeis narrowed to the server's vocabulary and the granted subset is echoed back. Ascopenaming nothing the server supports is refused rather than registered as "any scope". Asking for no scope at all grants the vocabulary as it stands, written out in the registration, so a scope added to the server later does not widen a client registered earlier.client_namebecomes the client's description.grant_typesare checked against what the server supports and returned as registered, but they do not restrict the client: nanoidp has no per-client grant enforcement, and registration does not add one.
Limits and errors
| Case | Answer |
|---|---|
| Disabled | 404 |
| Metadata nanoidp cannot use | 400 invalid_client_metadata |
Missing, empty or relative redirect_uris | 400 invalid_redirect_uri |
max_clients live registrations reached | 429 registration_limit_reached |
| Wrong or missing registration access token | 401 invalid_token |
Too many requests, with rate_limit_enabled | 429 rate_limit_exceeded |
registration_limit_reached is nanoidp's own name: RFC 7591's error codes
describe metadata, and none of them means "no more room". A promotion or a
delete frees a slot straight away.
The limit does not heal on its own. Registrations have no expiry, so
slots are freed only by deleting, promoting or resetting the clients that
hold them, or by restarting the process. On an instance anyone can reach,
that means anyone can fill it: with rate_limit_enabled, the rate
configured for /token applies to /register as well, which is what makes
filling it slow rather than instant. DELETE /api/runtime clears every
runtime client, registrations included.
The two responses that carry credentials, the registration and the RFC 7592
read, are sent with Cache-Control: no-store.
Client ID metadata documents
An MCP client can identify itself with an https URL instead of a name it
had to register. The URL is the client_id, and the document it points at
says what the client is: its redirect URIs, its name, the scopes it wants.
nanoidp reads that document so a developer can exercise the flow against a
test IdP.
It is off by default, because turning it on makes this server fetch a URL a client chose:
oauth:
client_id_metadata_documents:
enabled: true
allowed_hosts:
- client.example
allow_loopback: false
Like dynamic client registration, there is no switch for this in the settings form or in the MCP tools: it is a decision for the file.
What happens, and where
https client_id at /authorize
|
declared? runtime? -> that client is used, nothing is fetched
|
neither -> fetch the document, validate it, cache it
|
/token, /introspect, /revoke read the cache and never fetch
Only /authorize fetches. Everything else reads what is cached, so a
client whose document has not been fetched is simply unknown at the token
endpoint. That is deliberate: a token request must not wait on somebody
else's web server.
A client an operator declares, or one created through /api/runtime, wins
over a document published under the same name, and is answered without the
cache being consulted at all.
What the document must say
{
"client_id": "https://client.example/metadata.json",
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none",
"client_name": "My MCP client",
"scope": "openid profile"
}
client_idmust be the URL it was fetched from, compared literally.token_endpoint_auth_methodisnone, and onlynone: the specification forbids every shared-secret method, and of the three nanoidp supports two are those. PKCE S256 applies, as for any public client.redirect_urismust be present and are matched exactly, the same rules a declared client gets.scopeis narrowed to the server's vocabulary; asking for nothing the server supports is refused rather than granted as "any scope".- Anything else in the document is ignored.
The URL itself
https, no userinfo, no fragment, a path with no . or .. segments. A
query and a bare / path are discouraged by the specification rather than
forbidden, so nanoidp accepts them and logs that it did.
What the fetch will and will not do
This is the only outbound request nanoidp makes, so it is narrow on purpose:
| Hosts | only those in allowed_hosts, matched as exact DNS names, and only on the port named there (443 when none is) |
| Addresses | only globally routable ones. Every address the name answers with must be acceptable, or the name is refused |
| Loopback | only with allow_loopback, only when nanoidp is itself on loopback, only on the same address family |
| Redirects | never followed |
| Answer | 200 only, and JSON only |
| Size | 5 KiB, enforced while reading |
| Time | 5 seconds for the whole fetch, including DNS and the TLS handshake |
| Retries | none, and no second address after a failed connection |
allowed_hosts is empty by default, which means nothing is fetched at all
until an operator names a host. An entry may carry a port
(client.example:8443); one that does not means 443 and no other port, so
naming a host does not turn this server into a way to reach every port on
it. The path is the client's, so a host you name is one whose whole URL
space a client may point this server at.
The cache
A fetched document is kept in memory for what its Cache-Control allows,
within this server's own bounds, and at most 100 documents at a time. It is
lost on restart, and it is not shared between nanoidp processes.
Only successes are cached: a failed fetch or an invalid document is never remembered, so a bad answer cannot stick.
A document whose response says no-store or no-cache is refused at the
authorization request. The cache is the only place such a client exists
between /authorize and /token, so honouring the header would mean
issuing a code that could never be redeemed. If a client's host sets that
header globally, it cannot be identified this way.
An entry is kept at least as long as any authorization code issued against
it: the document is cached when it is fetched and the code is minted later,
when the login finishes, so a short max-age would otherwise leave a valid
code with no client behind it. The same promise holds against the cap: an
entry a live code depends on is not evicted to make room for a new client.
If every one of the 100 entries is holding up a live code, the new
authorization request is refused instead, because breaking a flow already
under way is the worse of the two outcomes.
An operator's Forget still drops such an entry, and any code issued against it stops being redeemable. That is a deliberate administrative act, and it is not treated as an accident.
The web UI lists cached clients read-only with a cimd badge, and a
Forget button drops one: that is how a developer re-fetches a document
they have just changed.
What a cached client may not do
The cache keeps an entry as long as the authorization code issued against it, so that is the longest anything is allowed to depend on it. Two consequences, both deliberate:
- No refresh token. A refresh token lives days; an entry does not. One
issued to a client that exists only in this cache would be a credential
the server can make useless long before its expiry, by expiring or
evicting the entry. The token response carries an access token and an ID
Token, and no
refresh_token(RFC 6749 §5.1 makes it optional). - No device grant. A device code is redeemed by a later request, so it
depends on the client still being there, and nothing keeps it there.
/device_authorizationanswers a client identifier URL exactly as it answers a name nobody knows.
Both are narrower than the draft allows, and narrow on purpose for a first implementation: the property "what depends on a cached client is bounded by the authorization code lifetime" is one a reader can check. Supporting these grants means letting each one take a lease on the client identity until its own expiry, which also changes what the cache must promise about capacity and freshness. That is a design of its own, not a footnote to this one.
When something is refused
Every refusal answers the authorization request with the same
invalid_client / Unknown client_id a name nobody knows would get.
Saying which rule refused it would tell whoever chose the URL whether a
host is allowed, whether it resolved, or what it answered. The reason is
in the server log only: GET /api/audit is readable by anyone who can
reach it, so the audit records that a document was refused and not why.
If the client cannot be held for the code that is about to be minted - it
has stopped resolving, or the cache has no room to promise anything about
it - no code is issued. The authorization request comes back through the
redirect URI with error=temporarily_unavailable, since retrying is the
thing that helps.
The body says nothing, but the clock still does: a host that is not allowed is refused before any I/O and answers at once, while an allowed one costs a lookup, a handshake and up to the five second budget. If that distinction matters to you, do not enable this on an instance strangers can reach - it is a developer tool, not a hardened gateway.
Running behind a TLS-terminating reverse proxy
This guide walks through deploying NanoIDP behind a reverse proxy (nginx,
Traefik, an API gateway, etc.) that terminates TLS and forwards plain HTTP
to NanoIDP - a common setup in containerized environments (Docker Compose,
Kubernetes). It composes six existing, independently-documented settings
into one end-to-end configuration, since getting this wrong can silently
produce a working-looking IdP that issues tokens with the wrong issuer.
None of this is required for local development - http://localhost:8000
with the defaults works out of the box. Use this guide once NanoIDP needs to
be reachable through a proxy under a public hostname.
The problem this solves
A discovery document's issuer, a token's iss claim, and the device flow's
verification_uri must all agree with whatever hostname the client actually
used to reach NanoIDP. Behind a reverse proxy, NanoIDP itself only ever sees
the proxy's own internal connection (e.g. http://nanoidp:8000), never the
public hostname the end user typed (e.g. https://idp.example.com) - unless
it's told how to recover that information from the request.
Getting this wrong shows up as: a discovery issuer that doesn't match the
URL it was fetched from, a token rejected because its iss doesn't match the
resource server's expected issuer, or a device-flow verification_uri that
points at a hostname the end user's browser can't resolve.
Option A: fixed public issuer (simplest)
If NanoIDP is only ever reachable under one public hostname, skip request-based
derivation entirely and set a fixed issuer, resolved from the environment so
the same settings.yaml works in every environment:
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000}
Set OAUTH_ISSUER=https://idp.example.com in the proxy's environment (or the
container's), and every environment that doesn't set it falls back to the
http://localhost:8000 default for local development. This is the
recommended starting point - it requires none of the request-derivation flags
below, and there's nothing for a spoofed header to influence.
Option B: multiple hostnames (request-derived issuer)
If NanoIDP must be reachable under more than one hostname at once (e.g. a
Docker Compose service name from other containers and localhost from the
host browser, or several public hostnames behind the same proxy), a single
fixed issuer can't satisfy all of them. Instead, derive it per request:
oauth:
issuer_from_request: true
With this on, discovery's issuer, a token's iss, and the device flow's
verification_uri all reflect the Host header of the request that fetched or
requested them - each hostname NanoIDP is reached under consistently
advertises and issues tokens against itself.
This introduces two trust problems, both of which have a corresponding flag:
1. The proxy terminates TLS
By default, NanoIDP builds the issuer from its own request.scheme and
host_url, which - behind a reverse proxy - reflect the proxy's own
internal, unencrypted connection to NanoIDP (http://), not the scheme and
host the end user's browser used (https://idp.example.com). Tell NanoIDP to
trust the proxy's forwarding headers instead:
oauth:
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_from_proxy_headers applies werkzeug's ProxyFix for a single
trusted proxy hop, so request.scheme / host_url follow
X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-For instead of the
proxy's own connection.
Security caveat: only enable this when NanoIDP sits directly behind exactly one trusted reverse proxy that sets or overwrites these headers itself. If a client can reach NanoIDP directly (bypassing the proxy) or the proxy blindly forwards client-supplied
X-Forwarded-*headers, this setting lets an untrusted client spoof its own scheme, host and IP - and the resulting effect isn't limited to the issuer:X-Forwarded-Foralso feeds rate-limit and audit-log client IP attribution, regardless of whetherissuer_from_requestis even on. Configure the proxy to strip any inboundX-Forwarded-*headers from clients before it sets its own.This is wired at app startup, so a value changed at runtime (Settings page or MCP
update_settings) only takes effect after a restart.
2. Trusting an arbitrary Host header
Even with the proxy hop trusted, issuer_from_request still reflects
whatever Host it's given. On a network where anything other than the proxy
could reach NanoIDP, restrict which hostnames are allowed to be reflected:
oauth:
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
Each entry is an origin: scheme://host[:port]. A request whose derived
origin isn't in the list falls back to the fixed oauth.issuer instead of
trusting the Host header. Leaving issuer_allowlist empty (the default)
allows any Host, matching pre-#issuer_allowlist behavior - only appropriate
on a fully trusted network where the proxy is the sole entry point.
Device flow: pinning a human-reachable URL
The device flow's verification_uri is opened by a human in their own
browser, but /device_authorization is typically called by a
backend/container/CLI on a different host than that browser - so reflecting
that caller's Host (e.g. Host: nanoidp:8000, an internal Docker Compose
service name) produces a URL the human can't actually reach.
Pin it to a fixed, human-reachable URL instead, independent of whatever
Host called /device_authorization:
oauth:
issuer_from_request: true
device_verification_base_url: "https://idp.example.com"
device_verification_base_url only overrides the device flow's
verification_uri; discovery's issuer and a token's iss are unaffected
and keep following the request that fetched/requested them. It's only
consulted when issuer_from_request is on.
Reloading configuration without a restart
ProxyFix (via issuer_from_proxy_headers) is wired once at app startup, so
a value changed at runtime only takes effect after a process restart.
Everything else in this guide - issuer, issuer_allowlist,
device_verification_base_url - can be changed in settings.yaml (or via the
Settings page / MCP update_settings) and picked up without a restart:
curl -X POST http://localhost:8000/api/config/reload
This re-reads both settings.yaml and users.yaml from
NANOIDP_CONFIG_DIR (default ./config) in place.
Putting it together
A full settings.yaml for NanoIDP behind a single trusted TLS-terminating
proxy, reachable under both its public hostname and an internal Docker
Compose service name, with the device flow pinned to the public hostname:
server:
host: "0.0.0.0"
port: 8000
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000} # fallback when the flags below don't match
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
device_verification_base_url: "https://idp.example.com"
Note on
host: "0.0.0.0". Since 2.6.0 the default bind address is127.0.0.1(GHSA-2473-px8h-rvg6): the/api/*management API is unauthenticated by design, so NanoIDP no longer listens on all interfaces unless you ask it to. Behind a reverse proxy you do want0.0.0.0- the proxy must reach NanoIDP over the container or host network - so setting it here is a deliberate opt-in. Only do this on a trusted network (for example a private Docker Compose network that publishes just the proxy's port), and expect a startup warning reminding you the management API is exposed to that network.
And the corresponding environment for the container:
environment:
- NANOIDP_CONFIG_DIR=/app/config
- OAUTH_ISSUER=https://idp.example.com
Checklist
-
Reverse proxy terminates TLS and sets
X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Foritself (strips any client-supplied values first) - NanoIDP is not reachable except through that proxy
-
issuer_from_proxy_headers: trueonly if the above two hold -
issuer_allowlistset to the exact origins NanoIDP should ever advertise as its issuer -
device_verification_base_urlset if/device_authorizationis ever called from a different host than the end user's own browser -
Restarted the process after changing
issuer_from_proxy_headers(ProxyFixis startup-only)
Related documentation
- Configuration reference - full
settings.yamlshape - Environment variables -
NANOIDP_CONFIG_DIRand MCP-related variables - Security guide: multi-hostname issuer -
the trust model behind
issuer_from_request - Endpoints reference -
GET /api/config,POST /api/config/reload
SAML metadata follows the issuer
saml.entity_id and saml.sso_url are optional. When they are absent from
settings.yaml, nanoidp derives them from the effective issuer as
<issuer>/saml and <issuer>/saml/sso, so /saml/metadata, the <Issuer>
in responses and assertions, and OIDC discovery all name the same origin under
every option above (fixed issuer, request-derived issuer, proxy headers,
allowlist). Nothing SAML-specific needs to be configured for a proxy.
Set them explicitly only when the SP expects a fixed, different value; an
explicit value always wins and does not follow the request. GET /api/config
reports the effective values together with entity_id_derived /
sso_url_derived so you can tell which case applies.
Extending nanoidp: hooks and plugins
nanoidp reads and writes two YAML files and nothing else. When those files need to come from, or go to, somewhere else (an S3 bucket per stage, a Vault path, a git repository, an audit sink), that integration lives outside the core, behind a small, versioned extension point: extension points, not backends. A hook observes what happens to the files and can provide them before they are read; it never replaces persistence, never sees an HTTP request and never touches token issuance, so a broken hook cannot change what nanoidp advertises or issues.
The contract (hook API version 1)
Three hooks, called synchronously:
| Hook | When | Arguments | Typical use |
|---|---|---|---|
on_before_load | before settings.yaml and users.yaml are read: startup and every explicit reload (POST /api/config/reload, MCP reload_config), never the refresh that follows a local write | plugin: config_dir; shell: {config_dir} | render the files from a store into the directory |
on_config_saved | after an atomic write of either file (web UI, MCP, save) | plugin: path, kind (settings or users); shell: {config_dir}, {path}, {kind} | push to a store, git commit, notify |
on_audit_event | after an audit entry is recorded | plugin: the event as a mapping; shell: {config_dir}, {event_type} plus the event as JSON on stdin | ship audit elsewhere |
Two ways to implement it, sharing one dispatcher (shell hook first, then plugins in declaration order):
Shell hooks, declared in settings.yaml, zero API:
hooks:
on_before_load: "aws s3 sync s3://idp-config-${STAGE} {config_dir}"
on_config_saved: "vault kv put secret/idp/{kind} @{path}"
on_audit_event: "jq -c . >> /var/log/nanoidp-audit.jsonl" # event JSON on stdin
strict: false # see the error policy below
timeout_seconds: 10 # shell hooks only
Placeholders are listed per hook in the table above. They are replaced
textually, so other braces (${VAR}, jq filters) are left alone. The
command runs through the shell with nanoidp's environment; for
on_audit_event the event is also written to the command's stdin as JSON.
A command is stored after ${VAR} expansion, so it may embed a token. It
is therefore never reported outside the process: GET /api/config and the
MCP get_settings tool show a hook's name, source and failure counter but
not its command, and the error a caller sees under strict names the hook
and its source only. The command and the hook's stderr go to the server
log at WARNING, and nanoidp plugins, which runs in your own terminal,
is the one place that prints commands.
Python plugins, packaged separately and discovered through the
nanoidp.plugins entry-point group:
plugins:
echo:
record: /tmp/nanoidp-hooks.jsonl # keys under a plugin's name belong to the plugin
plugins: is the only section of settings.yaml whose inner keys nanoidp
does not validate: the shape is name -> mapping, the keys are the plugin's.
A plugin's identity is its entry-point name, which is also that key; the
object itself carries only hook_api_version and the hooks it implements.
Error policy, per hook
The policy is the same for shell hooks and plugins. timeout_seconds
applies to shell hooks only, and a timeout is a failure; a Python plugin
must manage its own network timeouts and queueing, nanoidp does not
interrupt it.
| Hook | default | hooks.strict: true |
|---|---|---|
on_before_load | log, continue with whatever is in the directory | the load (startup or reload) fails with the hook's error |
on_config_saved | log | the error is propagated to the caller after the write. Disk and runtime stay aligned: the file on disk is what was written and the running configuration is reloaded from it before the error is raised, so only the mirror is behind. The web UI says "Settings saved locally; mirror hook failed". That refresh reads the local files only and does not run on_before_load: the disk is the newest state right after a write, and pulling a mirror that has not caught up yet would silently roll the write back |
on_audit_event | log | log; never propagates |
on_before_load is the only hook that can block an operation, because it
runs before any mutation. on_config_saved runs after the atomic write, so
the local save is always committed; under strict the web UI, /api and
MCP callers surface the failure so the operator learns the mirror did not
happen. ConfigManager.save() writes users.yaml and settings.yaml as
one coordinated, conflict-checked save (#229): both files are written,
both hooks then run, and the runtime is refreshed from disk once before a
strict failure is raised - a failing hook on one file does not leave
the other unwritten.
The web UI's settings page (YamlWriter) uses the same primitive now:
its one POST composes oauth, saml, identity classes and login mode
into a single write (#229 phase 4 - an earlier version ran four
separate writes chained by revision, which meant a conflict partway
through could leave the earlier sections already saved while the page
reported nothing had changed; composing them into one write closed
that), so a failing hook under strict there is the same shape as
ConfigManager.save()'s: the one write already landed, only the
mirror push failed. YamlWriter's other methods (save_user,
save_client, ...) each still make their own single write per call,
same as always. Make the hook idempotent: the
next save re-mirrors. on_audit_event never fails the request that
produced the event: a token already issued must not turn into a 500 because
an audit sink is down. Failures are counted per hook and shown by
nanoidp plugins and GET /api/config.
Bootstrap: hooks that run before settings.yaml exists
on_before_load runs before settings.yaml is read, but hooks: is
declared in settings.yaml: the main use case, rendering the files from a
store, cannot be configured by the file it fetches. A minimal surface
outside the normal config covers it:
| Surface | What it declares | Runs |
|---|---|---|
NANOIDP_BOOTSTRAP_HOOK="<command>" or nanoidp --bootstrap-hook "<command>" | an on_before_load shell command | once, before the first load |
NANOIDP_BOOTSTRAP_PLUGIN=<name> with NANOIDP_PLUGIN_<NAME>_<KEY>=<value> | a plugin and its settings (name upper-cased, - as _) | loaded once, then takes part in every hook |
bootstrap.yaml in the configuration directory | hooks: and plugins: only, same schema and same loader as settings.yaml: ${VAR} placeholders expand, an unknown key is warned with its path and ignored (under config_validation: strict it stops startup, like everywhere else), a wrong type stops startup | its on_before_load once, its other hooks and plugins always |
Precedence of the policy values (strict, timeout_seconds): the
bootstrap surface is the baseline; settings.yaml overrides a value only
when it declares it explicitly, and a settings.yaml that disappears (a
render that failed, a sync that left the directory half written) takes its
hooks, plugins and policy with it on the next reload, leaving the bootstrap
ones in force. A renderer that replaces or syncs the whole configuration
directory must preserve bootstrap.yaml, or that deployment should use the
env/CLI bootstrap instead.
After the first load, settings.yaml may declare the same hook for reloads
and saves. nanoidp plugins shows which surface each entry came from
(bootstrap-env, bootstrap.yaml, settings.yaml). Note that strict read
from settings.yaml cannot apply to the very first load (the file is not
known yet): to make a failed bootstrap render fatal, set strict: true in
bootstrap.yaml.
A plugin that cannot be loaded (its package is not installed, it declares
another hook_api_version, its configure() raises) follows the same
policy as on_before_load: logged at ERROR, listed under plugins_failed
by nanoidp plugins, GET /api/config and the MCP get_settings tool,
and skipped; under strict the load fails. The public reason is one of
nanoidp's own diagnoses (not installed, incompatible hook_api_version=N (expected 1), already loaded, initialization failed); the exception
text itself, which may embed the plugin's settings, goes to the local log
only. A plugin that failed in non-strict mode is retried only when the
hooks:/plugins: declaration changes or the process restarts: a reload
with an unchanged declaration does not re-apply it.
A failed on_before_load or plugin load under strict is reported by
POST /api/config/reload as a JSON 503 (kind names the phase:
on_before_load or plugin_load) and by the MCP reload_config tool as an
error result, never as an HTML error page. Such a reload fails without
commit: the running settings, the profile hardening and the registered
plugins stay exactly as they were until a later reload succeeds.
A reload whose files cannot be read or do not validate, or whose signing configuration
cannot be used (a jwt.keys_dir that cannot be created, for instance),
fails the same way, as a JSON 422 with kind invalid or activation
(an error result with the same kind from reload_config); at startup the
same condition prints error: configuration rejected: ... and exits 1.
Worked example: version every change in git
hooks:
on_config_saved: "git -C {config_dir} add {path} && git -C {config_dir} commit -q -m 'nanoidp: {kind} saved' || true"
Every save from the web UI or the MCP server becomes a commit; git log -p
is the change history, git revert the rollback, branches the stages. The
trailing || true keeps a "nothing to commit" from counting as a failure.
Worked example: render from a store before the load
With a shell hook, the store stays entirely outside nanoidp:
NANOIDP_BOOTSTRAP_HOOK='aws s3 sync "s3://idp-config-$STAGE" {config_dir}' \
NANOIDP_CONFIG_DIR=/var/lib/nanoidp/config \
nanoidp
and, to mirror changes back:
hooks:
on_config_saved: 'aws s3 cp {path} "s3://idp-config-$STAGE/"'
The same result without any hook: an init container (or Vault Agent, or a
docker compose dependency) renders the files into the directory before
nanoidp starts, and POST /api/config/reload (or the MCP reload_config
tool) picks up a later change. Hooks are for when the render must follow
nanoidp's own lifecycle (every reload) or when the mirror must follow every
save.
Writing a plugin
The reference plugin, examples/plugins/nanoidp-echo, logs every hook call
and records it to a file. It is the whole template:
# src/nanoidp_myvault/__init__.py
from pathlib import Path
class VaultPlugin:
hook_api_version = 1 # nanoidp refuses any other value; the
# plugin's name is its entry-point name
def configure(self, settings: dict) -> None: # receives plugins.myvault, optional
self.path = settings["path"]
def on_before_load(self, config_dir: Path) -> None:
... # read secret/<path>/settings and users, write them into config_dir
def on_config_saved(self, path: Path, kind: str) -> None:
... # write the file back
# on_audit_event(self, event: dict) is optional, like the other two
# pyproject.toml
[project.entry-points."nanoidp.plugins"]
myvault = "nanoidp_myvault:VaultPlugin"
Install it next to nanoidp (pip install nanoidp-myvault, or pip install -e examples/plugins/nanoidp-echo to try the reference one), declare it under
plugins: or through NANOIDP_BOOTSTRAP_PLUGIN, and check nanoidp plugins. Any method may raise: nanoidp counts the failure and applies the
policy of that hook. Keep plugins synchronous; if a store call must not
block a save, queue it on the plugin's side.
What is reported
nanoidp plugins [--config DIR], GET /api/config (hooks block) and the
MCP get_settings tool show the hook API version, strict, the timeout,
every shell hook with its source and failure counter (never its command,
see above), and every plugin with its hook_api_version, the hooks it
implements, its source and its failure counters per hook. hooks: and plugins: are YAML-only: the web UI's
settings page and the MCP update_settings tool report them but cannot
change them, because a command editable through the surface it observes
would be a remote-execution primitive.
Security
A Python plugin runs with the process's privileges; installing one is a
trust decision like any dependency. Shell hooks run whatever the YAML says,
with nanoidp's environment: the file is operator-owned by definition, the
same trust boundary as secret_key or management_secret. Neither surface
is reachable from the web UI or MCP.
Checking a configuration directory is not the same as loading one:
nanoidp validate-config and the MCP validate_config tool validate the
shape of hooks: and plugins: without dispatching a hook or importing a
plugin, so linting a directory someone else wrote executes nothing
(Configuration).
What hooks are not
There are no hooks on the protocol path (on_token_issued, on_login and
the like): that would be a change to what nanoidp is, decided in
VISION, not added by a plugin. And a plugin cannot
replace the YAML files as the source of truth: nanoidp never reads from or
writes to an external store itself.
NanoIDP MCP Workflow Guide
This guide shows how to use NanoIDP's MCP server with Claude Code for day-to-day development tasks.
Quick Setup
1. Start NanoIDP
# Start the HTTP server (optional, for web UI)
python -m nanoidp
# The MCP server is configured separately in Claude Code
2. Configure Claude Code
Add to your project's .claude/settings.json:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config"
}
}
}
}
Or with readonly mode (safer for shared environments):
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"args": ["--readonly"],
"env": {
"NANOIDP_CONFIG_DIR": "./config"
}
}
}
}
3. Verify Connection
In Claude Code, ask:
"List all users in nanoidp"
You should see your configured users.
Example Prompts for Claude Code
Copy and paste these prompts directly into Claude Code.
Token Generation
Generate a token for testing:
"Use nanoidp to generate a token for user 'admin' and show me the decoded claims"
Generate a token with custom expiry:
"Generate a token for user 'testuser' with 5 minute expiry using nanoidp"
User Management
Create a test user on the fly:
"Create a new user 'testuser' with password 'test123', roles ['USER', 'TESTER'], groups ['ENGINEERING'], and identity_class 'EXTERNAL' using nanoidp"
The new user exists in the MCP server's copy of the configuration: token
generation for it works right away, but a nanoidp server running
separately lets it log in only after save_config and a reload of that
server (see The MCP server and a running nanoidp server).
List users and their roles:
"List all nanoidp users and show their roles and groups"
Delete a temporary test user:
"Delete the user 'testuser' from nanoidp"
Token Inspection
Decode a token without verification:
"Decode this JWT token using nanoidp: eyJhbGciOiJSUzI1Ni..."
Verify a token's signature:
"Verify this token is valid using nanoidp: eyJhbGciOiJSUzI1Ni..."
Configuration
get_settings also reports the hooks block (loaded shell hooks and
plugins, #185). hooks: and plugins: are YAML-only: an agent can read
them, not change them, like secret_key and require_ui_login.
Check current settings:
"Show me the current nanoidp settings including issuer and token expiry"
Reload after manual config edit:
"Reload the nanoidp configuration"
Get OIDC discovery info:
"Get the OIDC discovery document from nanoidp"
OAuth Clients
List OAuth clients:
"List all OAuth clients configured in nanoidp"
Create a new client:
"Create a new OAuth client 'test-app' with secret 'test-secret' in nanoidp"
Common Workflows
1. Test an API Endpoint with Authentication
Prompt: "Generate a token for user 'admin' with nanoidp and use it to call
GET http://localhost:8080/api/protected with that token as Bearer auth"
Claude Code will:
- Call
generate_tokento get a JWT - Make the HTTP request with
Authorization: Bearer <token> - Show you the response
2. Debug Token Claims
Prompt: "Generate a token for user 'admin' and explain what Spring Security
authorities it will have"
Claude Code will:
- Generate the token
- Decode it to show claims
- Explain the
authoritiesarray mapping
3. Set Up Integration Test Users
Prompt: "Create these test users in nanoidp:
- 'admin-test' with roles ADMIN, USER
- 'user-test' with role USER
- 'readonly-test' with role VIEWER"
Claude Code will create all three users with appropriate settings.
4. Verify Token Flow
Prompt: "Generate a token for 'admin', decode it to show the claims,
then verify it's valid using nanoidp"
This tests the full token lifecycle.
5. Quick Role-Based Testing
Prompt: "I need to test role-based access. Create a user 'role-test' with
roles ['ADMIN', 'SPECIAL_ACCESS'], generate a token, and show me
what authorities it will have for Spring Security"
Tool Reference
Read-Only Tools (always available)
| Tool | Description | Example Prompt |
|---|---|---|
list_users | List all users | "List nanoidp users" |
get_user | Get user details | "Get user 'admin' from nanoidp" |
list_clients | List OAuth clients | "List OAuth clients" |
get_client | Get client details | "Get client 'demo-client'" |
decode_token | Decode JWT | "Decode this token: ..." |
verify_token | Verify JWT signature | "Verify this token: ..." |
get_settings | Get IdP settings | "Show nanoidp settings" |
reload_config | Reload from files | "Reload nanoidp config" |
get_oidc_discovery | Get OIDC discovery | "Get OIDC discovery" |
get_jwks | Get JWKS | "Get the JWKS from nanoidp" |
get_audit_log | Get audit entries | "Show the last 20 audit entries" |
get_audit_stats | Get audit statistics | "Show audit stats" |
get_keys_info | Get signing key info | "Which signing key is active?" |
Mutating Tools (disabled in --readonly mode)
| Tool | Description | Example Prompt |
|---|---|---|
create_user | Create new user | "Create user 'test' with password 'pass'" |
update_user | Update user | "Update user 'test' to add role 'ADMIN'" |
delete_user | Delete user | "Delete user 'test'" |
create_client | Create OAuth client | "Create client 'app' with secret 'secret'" |
update_client | Update client | "Update client 'app' description" |
delete_client | Delete client | "Delete client 'app'" |
generate_token | Generate JWT | "Generate token for 'admin'" |
update_settings | Update settings | "Set token expiry to 30 minutes" |
save_config | Save to YAML | "Save nanoidp config to files" |
clear_audit_log | Clear the audit log | "Clear the nanoidp audit log" |
rotate_keys | Rotate signing keys | "Rotate the signing keys" |
Security Notes
Admin Secret Protection
When management_secret is configured -
via settings.yaml's session.management_secret, the
NANOIDP_MANAGEMENT_SECRET env var, or the legacy NANOIDP_MCP_ADMIN_SECRET
env var (this setting's MCP-only predecessor, still supported as an alias) -
mutating tools require the secret as the admin_secret tool argument. The
same setting also gates /api/* and the web UI's mutating form actions -
see Management Secret for the full picture.
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config",
"NANOIDP_MANAGEMENT_SECRET": "your-secret-here"
}
}
}
}
Readonly Mode
For shared environments or when you only need introspection:
nanoidp-mcp --readonly
# or
NANOIDP_MCP_READONLY=true nanoidp-mcp
This completely disables all mutating tools.
Troubleshooting
"Tool not found" Error
Ensure NanoIDP is installed and nanoidp-mcp is in your PATH:
pip install nanoidp
which nanoidp-mcp
"User not found" Error
Check your config directory is correct:
ls $NANOIDP_CONFIG_DIR/users.yaml
MCP Server Not Starting
Check logs by running manually:
NANOIDP_CONFIG_DIR=./config nanoidp-mcp
Permission Denied for Mutating Tools
Either:
- Provide
admin_secretin tool arguments (ifmanagement_secretis configured) - Or unset
management_secret(settings.yaml,NANOIDP_MANAGEMENT_SECRET, or the legacyNANOIDP_MCP_ADMIN_SECRET) for development - Or check you're not running in
--readonlymode
Testing an MCP client against nanoidp
nanoidp is an OAuth 2.1 / OIDC authorization server. To test a real MCP
client's authorization code against it end to end, you need a protected MCP
resource server for the client to call. The repository ships a fixture for
exactly this: e2e/mock_mcp_server.py,
a minimal MCP Streamable HTTP server that validates bearer tokens against
nanoidp and exposes three scope-gated tools.
The loop
agent host / MCP client
| tools/call without a token
v
mock MCP server -- 401 + RFC 9728 metadata --> authorization_servers: nanoidp
|
| /authorize (PKCE, resource=mcp-server) -> /token -> access token aud=mcp-server
v
tools/call with the bearer -> scope check -> result
The client discovers the authorization server from the 401's
WWW-Authenticate: Bearer resource_metadata="..." header, fetches the
RFC 9728 /.well-known/oauth-protected-resource document (which names
nanoidp in authorization_servers), runs the authorization code flow with
PKCE and resource=<mcp server URL> (RFC 8707), and calls the tool with the
resulting audience-bound access token.
The tools and their scopes
| Tool | Scope required |
|---|---|
read_document | documents:read |
delete_document | documents:write |
admin_operation | admin |
Authorization happens in two distinct layers, and the fixture shows both:
- Resource-level scope floor (
documents:read), enforced by the SDK's bearer middleware before any tool runs. A token that lacks it gets the conformant MCP/RFC 9728 challenge:403withWWW-Authenticate: Bearer error="insufficient_scope", ..., resource_metadata="<the RFC 9728 URL>". This is the transport-level response an MCP client keys off. - Application-level per-tool check for the finer
documents:write/adminoperations, made inside each tool. A caller past the floor but lacking the elevated scope gets an in-band MCP tool error (isError), not an HTTP challenge - a second, application-defined authorization decision.
A token whose aud is a different resource is rejected at the transport with
401 + WWW-Authenticate: Bearer before any tool runs (RFC 8707 audience
binding).
Running it
nanoidp must advertise the three scopes in its vocabulary, and its issuer
must equal the URL it actually serves (the mock derives the JWKS URL from the
issuer). The delegated leg logs in as a PUBLIC client (PKCE, no secret): the
bundled config registers mcp-public-client with
token_endpoint_auth_method: none for exactly this, since a real MCP client
holds no client secret.
# settings.yaml
oauth:
issuer: "http://localhost:8003"
scopes_supported:
- openid
- profile
- email
- offline_access
- documents:read
- documents:write
- admin
# nanoidp
PORT=8003 python -m nanoidp
# the mock resource server
python e2e/mock_mcp_server.py \
--issuer http://localhost:8003 \
--resource http://localhost:9100/mcp \
--port 9100
# drive the whole loop deterministically (no LLM)
python e2e/test_agent.py --url http://localhost:8003 \
--mcp http://localhost:9100/mcp
e2e/test_agent.py --mcp acts as the MCP client itself, through the
mcp SDK, so a failure means PKCE, discovery, resource, audience, scope or
MCP framing is wrong - never that a model chose not to call a tool.
Validation is JWKS-only
The mock validates each token against nanoidp's JWKS (signature, iss,
aud, exp, scopes) and never calls /introspect. A self-contained JWT
carries no live revocation state, so a token revoked at nanoidp stays valid
at the resource server until it expires - the e2e asserts this, showing the
token reported inactive by nanoidp's /introspect while the mock still
accepts it. This is the difference between self-contained and introspected
tokens; the fixture demonstrates the self-contained side. A token still
verifies across a nanoidp key rotation, because nanoidp keeps the previous
keys in its JWKS.
Test MCP OAuth with n8n and NanoIDP
A real MCP host is the honest test of nanoidp's OAuth/MCP surface: it has its
own expectations about discovery, PKCE, resource and how a resource server
challenges it, and none of them are written by us. This guide runs n8n
against nanoidp and the mock MCP server from
Testing an MCP client against nanoidp on two
paths, with no LLM and no browser in the loop: the MCP Client node on its
own (path 1), and the AI Agent with the MCP Client Tool node inside
its tool-calling loop, driven by a mock chat model (path 2).
The stack lives in
examples/agentic-stack/
and the script that drives it in
e2e/n8n_e2e.py.
It is what .github/workflows/n8n-e2e.yml runs, manually and nightly.
Path 1: the MCP Client node
n8n (MCP Client node, mcpOAuth2Api credential)
| GET /.well-known/oauth-protected-resource/mcp -> mock MCP server (RFC 9728)
| GET /authorize?...code_challenge...&resource=... -> nanoidp (PKCE, public client)
| POST /token (code + code_verifier) -> nanoidp: access token, aud = the resource
| POST /mcp tools/call read_document, Bearer token -> mock MCP server: verifies the JWT against
| nanoidp's JWKS (iss, aud, exp, scope)
v
webhook response: {"structuredContent": {"result": "contents of document 'doc-42'"}}
Everything n8n does here is n8n's own code: the credential type, the
authorization URL, the token exchange, the MCP transport. nanoidp is
configured, not adapted (examples/agentic-stack/config/settings.yaml): the
issuer is its name on the Compose network, the scope vocabulary carries the
three scopes the mock server gates its tools on, and one public client,
n8n-public, has n8n's OAuth callback as its registered redirect_uri.
Running it
# from the repository root
docker compose -f examples/agentic-stack/docker-compose.yml up --build --wait
python e2e/n8n_e2e.py
The script bootstraps n8n (owner, login, API key), imports the three
credentials from examples/agentic-stack/n8n-import/, runs n8n's OAuth2
credential flow without a browser, creates the workflows through the public
API and triggers them through their webhooks. Path 1 first:
| check | what it shows |
|---|---|
the authorization URL n8n builds carries code_challenge_method=S256, client_id=n8n-public and resource=http://mcp:9100/mcp | n8n's request is what an MCP client is supposed to send |
read_document returns the document and the execution is success | the token n8n obtained is accepted by a resource server that only knows nanoidp's JWKS |
delete_document with a token holding only documents:read fails with insufficient_scope ... requires the 'documents:write' scope | the resource server's scope decision reaches the workflow as the node's error |
a credential whose resourceUrl names another resource gets a token nanoidp binds to that resource, and the mock server rejects it | audience binding works in the direction that matters: a token for one resource is not a token for another |
Then path 2, with the same credential (its token is already stored, so the OAuth is not repeated: path 2 is about what happens after it):
| check | what it shows |
|---|---|
the model is offered MCP_Client_Tool_read_document, ..._delete_document and ..._admin_operation | n8n propagates the OAuth-protected server's tool list into the agent; listing is not gated on scope, calling is |
asked to read doc-42, the model calls read_document, the tool result carries the document, and the agent's answer quotes it | the token n8n obtained does the same job from inside the loop as from the step node |
asked to delete, the model calls delete_document; the tool result is the server's insufficient_scope ... requires the 'documents:write' scope, quoted in the answer, and the execution is success | the resource server's decision reaches the agent loop as a tool result the model can react to, not as a crash; the tool node's run is marked as an error, the agent goes on |
with the wrong-audience credential the execution fails on tools/list with invalid_token, and the model is never called | a token for another resource is refused before the agent even knows what tools exist |
After the run, n8n is at http://localhost:5678 (owner@example.org /
E2eOwnerPassw0rd!): the workflows and credentials the script created are
there, and both loops can be repeated from the editor. docker compose ... down -v removes everything.
Path 2: the AI Agent's tool-calling loop
n8n (AI Agent; model = mock chat model; tool = MCP Client Tool, mcpOAuth2Api credential)
| POST /mcp tools/list, Bearer token -> mock MCP server: read/delete/admin
| POST /v1/chat/completions {messages, tools} -> mock chat model: "call MCP_Client_Tool_read_document"
| POST /mcp tools/call read_document, Bearer token -> mock MCP server: verifies the JWT, returns the document
| POST /v1/chat/completions {..., tool result} -> mock chat model: "The tool answered: ..."
v
webhook response: {"output": "The tool answered: [...\"contents of document 'doc-42'\"...]"}
The model is
e2e/mock_chat_model.py,
a fixture and deliberately a stupid one: an OpenAI-compatible
/v1/chat/completions that, offered tools, always answers with one tool call
(the tool picked from the user's words, read_document unless they say
"delete" or "admin"), and, handed a tool result, answers with a fixed sentence
quoting it. It proves nothing about language models; it makes the agent's
loop deterministic so that what is under test is n8n's plumbing between the
OAuth-protected tool and the model. It is not a general OpenAI emulation and
is meant to stay that way.
n8n's side is all standard nodes, created by the script through the public
API: an AI Agent (enableStreaming off, since a webhook answers with the
last node's output; maxIterations 3), an OpenAI Chat Model whose
OpenAI credential has the mock's URL as Base URL and the Use Responses
API toggle off (the mock speaks chat completions), and an MCP Client
Tool with the same MCP OAuth2 API credential as path 1 and All tools.
Three things measured on n8n 2.38.7 that the mock and the script encode:
- Tool names are prefixed with the node's name: the model sees
MCP_Client_Tool_read_document, and n8n maps the call back. The mock matches on the suffix. - The request is minimal:
model,messages,tools(each withstrict: false) andstream: false; notool_choice, noresponse_format. A tool result comes back to the model as a JSON string of the MCPcontentlist; a tool that answersisErrorcomes back as[{"error": "..."}]with the server's text, and the run continues. - A connection failure is fatal, a tool error is not. The wrong-audience
credential fails at
tools/list, the sub-node's error becomes the execution's, and the model is never called;insufficient_scopeontools/callis a result the model gets to see.
A real model, by hand
The automated run never uses an LLM. To watch path 2 with a model that
actually reads the prompt, the Compose file has an optional ollama profile:
docker compose -f examples/agentic-stack/docker-compose.yml --profile ollama up --build --wait
docker compose -f examples/agentic-stack/docker-compose.yml exec ollama ollama pull qwen2.5:3b
In the editor, open one of the agent workflows the script created, replace
the Mock Chat Model node with an Ollama Chat Model (credential base URL
http://ollama:11434, model qwen2.5:3b) and run it with a prompt of your
own. The model is pulled once into a volume; CPU inference is slow; nothing
in CI depends on it, and no hosted provider is configured anywhere in the
repository.
Three n8n facts the script encodes
All measured on n8n 2.38.7, the version the Compose file pins:
-
Readiness is JSON, not a 200. n8n starts in three phases:
/healthzanswers 200 from the first seconds while every other path,/rest/settingsincluded, gets a 200text/html"n8n is starting up" page; then the SPA answers 404 HTML while the REST routes are still unmounted; only then the JSON. The Compose healthcheck and the script both wait for/rest/settingsto answer the JSON envelope. The nightly once passed on the HTML page and ran the whole bootstrap against it. -
resourceUrlmust be explicit on theMCP OAuth2 APIcredential. n8n does discover the resource from the server's RFC 9728 metadata, but only an explicitresourceUrlmakes it sendresourceon/authorize. Without it the token's audience is nanoidp's default and the resource server rejects it withinvalid_token. -
The OAuth callback needs n8n's own session.
/rest/oauth2-credential/callbackanswersUnauthorizedwithout the logged-in cookie, so the script follows nanoidp's redirect with the session it obtained at bootstrap. In a browser this is invisible; headless it is the one thing to know.
A third one decides how the credentials get in: n8n's public API refuses an
empty clientSecret for this credential type, while a real MCP client holds
no secret. The script imports them with n8n import:credentials instead,
which accepts the public client as it is.
What is deliberately not here
- Dynamic client registration. n8n's
MCP OAuth2 APIcredential enables it by default; with it on, n8n discovers nanoidp from the resource metadata, asks for/.well-known/oauth-authorization-server(nanoidp serves it, the same document asopenid-configuration) and then looks for aregistration_endpoint. nanoidp implements registration, but it is opt-in and this stack does not enable it: see Dynamic client registration. The stack runs with n8n's toggle off and a pre-registered public client; n8n with registration switched on is not part of what this guide verifies. - OIDC login to n8n itself through nanoidp is an n8n Enterprise feature and is not part of this stack.
The n8n version is pinned on purpose: its MCP OAuth behaviour has changed across releases, and a failure must be attributable to nanoidp or to a known n8n version. Bump it deliberately and re-run the script.
NanoIDP Security Guide
Overview
NanoIDP is a development and testing tool designed for local development environments, integration testing, and CI/CD pipelines.
WARNING: Do NOT use NanoIDP in production environments.
By design, NanoIDP prioritizes developer convenience over security hardening. It is intended to help developers test OAuth2/OIDC and SAML integrations without the complexity of production identity providers.
Network Binding
NanoIDP binds to 127.0.0.1 (loopback only) by default, so out of the box it is reachable only from the local machine.
This matters because the management API (/api/*) is unauthenticated by design (see MCP Server Security for the equivalent concern on the MCP side). Those endpoints can mint a validly signed access token for any user, including admin, rotate the signing keys, clear the audit log, and create runtime users and clients that authenticate on every protocol surface (/api/runtime, see Disposable test identities). Loopback binding keeps that surface off the network. oauth.dynamic_registration (off by default) adds an endpoint of a different kind: POST /register is open by design when enabled, so that a client can register itself, and management_secret does not gate it. Enabling it on a reachable instance lets anyone create OAuth clients on it, bounded only by max_clients; the clients are runtime state and never reach settings.yaml unless an operator promotes one. max_clients does not expire on its own, so anyone who can reach an enabled /register can fill it until an operator frees the slots or restarts; enabling rate_limit_enabled applies the token endpoint's rate to /register too. oauth.client_id_metadata_documents (off by default) is the other outward-facing opt-in: with it on, /authorize fetches a URL a client chose. Only hosts named in allowed_hosts are fetched at all, and only on the port named with them (443 when none is), only globally routable addresses are connected to, redirects are never followed, the body is capped at 5 KiB and the whole fetch at 5 seconds, and this process will make at most 30 such fetches a minute. Treat an allowed_hosts entry as the decision it is: a host and port named there is one this server will make requests to, at any path a client chooses. The refusal a client sees is uniform, but its timing is not, so an instance strangers can reach can still be used to tell an allowed host from a refused one. If you need it reachable by more than one host, also consider management_secret, which requires a shared secret for these mutating calls specifically (reads stay open either way).
Exposing on a network
If you deliberately need NanoIDP reachable from other hosts or containers, set the host explicitly:
# CLI flag
python -m nanoidp --host 0.0.0.0
# or in settings.yaml
server:
host: "0.0.0.0"
The bundled Docker image already sets --host 0.0.0.0, because inside a container the isolation boundary is the container network rather than the host loopback. When you bind to all interfaces, NanoIDP logs a startup warning, since the unauthenticated management API then becomes reachable by any host that can route to the port. Only do this on a trusted, isolated network.
Config UI Login Gate
/login and /logout exist on the web UI, but by default they don't gate
anything - every dashboard page (users, clients, settings, keys, claims,
audit log, token tester) is reachable without a session, same as the rest of
the unauthenticated management surface described above. require_ui_login
makes /login real:
session:
require_ui_login: true # default: false
| Setting | Behavior |
|---|---|
false (default) | The config web UI is unauthenticated, like today |
true | Every web UI page except /login itself redirects to /login until a session exists |
/management/unlock (the management_secret unlock
form) is exempt from this redirect too, whether or not a login session
exists yet - management_secret is an independent axis (see below), and
gating it on a login session it doesn't depend on would make the unlock form
/login renders whenever management_secret is configured silently do
nothing.
What it does not protect: the separate management API (/api/*) stays
unauthenticated regardless of this setting - it's a distinct Flask blueprint
that a UI-only login gate structurally cannot reach. If you enable
require_ui_login because the dashboard is reachable by more than just you,
also keep /api/* off the network (see Network Binding
above), set management_secret to gate its mutations,
or put it behind your own auth layer; this setting does nothing for it.
Relationship to management_secret: these are independent axes.
require_ui_login is the UI's session front door - it controls who can view
the dashboard at all. management_secret (below) is the write guard - it
controls who can change anything, on all three management surfaces, whether
or not require_ui_login is on. With both off, nothing is enforced, same as
today.
Persona mode interaction: logging in via /login or via the SAML SSO
inline login at /saml/sso both satisfy this gate - they authenticate
through the same interactive_authenticate() call and set the same session.
If Persona Login Mode is also enabled
(login.mode: persona), that call is identity selection only, with no
credential check. In that combination, require_ui_login confirms a user
was picked from a list, not that anyone was verified - it is not protection
against anyone who can reach the port.
YAML-only: like secret_key and security_profile, this is not exposed
on the Settings page or the MCP update_settings tool. It's a fixed operator
decision about the trust boundary of the surface itself, not something meant
to be flipped from inside the surface it protects.
Session trust caveat: this gate's protection depends entirely on
secret_key being a real, private value - see
Session Cookie Trust below.
Session Cookie Trust (secret_key)
Both require_ui_login (above) and management_secret's web UI leg (below)
work by reading something out of the Flask session - a client-side cookie
Flask signs but does not encrypt, using secret_key. Anyone who knows
secret_key can construct arbitrary session content and have Flask accept it
as genuine, including session['user'] (what require_ui_login checks).
session:
secret_key: "a real, private value" # default: a public, well-known string
secret_key ships with a public default
(dev-secret-key-change-in-production, identical in every install unless
changed) so NanoIDP works out of the box. That's harmless as long as nothing
security-relevant depends on the session; require_ui_login and
management_secret both now do. NanoIDP logs a startup warning when
secret_key is left at its default while either is configured.
The two gates are not equally exposed by this. require_ui_login's
session['user'] is a bare value with no independent verification - with a
default secret_key, anyone who can reach the port can forge it and skip the
login gate entirely. management_secret's session flag
(session['management_verified']) is additionally bound to
management_secret itself via an HMAC (see
Management Secret), not stored as a bare boolean, so
knowing only the default secret_key is not enough to forge it - the forger
would also need to know management_secret, the thing being protected.
Set a real secret_key before relying on either gate beyond a single
trusted machine.
The session cookie is also set with SameSite=Lax. Once management_secret
is unlocked, the session authorizes /api/* mutations
(management_secret_required_for_api accepts an already-unlocked session,
above) in addition to ui_bp's own forms - so the
cross-site form-POST surface that already existed for the dashboard now
covers the management API too, on any browser that doesn't default new
cookies to Lax on its own. SameSite=Lax closes that: the cookie isn't
sent on a cross-site POST, only on top-level navigation.
Management Secret
management_secret is one shared secret that gates mutations across all
three management surfaces - the MCP server, /api/*, and the config web UI -
instead of each surface growing its own opt-in mechanism independently. It
does not gate reads: listing users, viewing settings, decoding tokens, and
the dashboard itself stay reachable exactly as they are today. Off by
default - unset, nothing is enforced, identical to before this setting
existed.
session:
management_secret: "your-secret-here" # default: unset
Must be printable ASCII - Werkzeug decodes request headers as latin-1, so a
non-ASCII secret could never be matched via the X-Management-Secret header
even though the form and MCP's JSON argument would see it correctly;
rejected at startup with a clear error rather than shipping a secret that
silently only half-works.
Also loadable from the NANOIDP_MANAGEMENT_SECRET environment variable.
NANOIDP_MCP_ADMIN_SECRET - this setting's MCP-only predecessor - keeps
working as an alias, so existing MCP-only setups aren't broken by upgrading.
Precedence: an explicit management_secret key in settings.yaml's session:
block wins over both env vars even when its value is empty or null -
presence in YAML is read as the operator deliberately stating "off", distinct
from the key being absent. Only when the key is absent entirely do the env
vars apply, NANOIDP_MANAGEMENT_SECRET before the legacy
NANOIDP_MCP_ADMIN_SECRET. Env vars are read once, when configuration loads (startup, or an explicit
reload_config) - not on every request, so changing one in the environment
has no effect until then.
Each surface proves knowledge of the secret differently, matching how that surface already talks to NanoIDP:
| Surface | How the secret is supplied |
|---|---|
| MCP server | The existing admin_secret tool argument on any mutating tool - see Admin Secret Protection |
/api/* | An X-Management-Secret request header on any POST/PUT/DELETE |
Web UI (ui_bp) | A one-time "Unlock management actions" form on /login; once submitted correctly, the session is trusted for the rest of its lifetime - the same trust model require_ui_login already uses for session['user'], so you aren't re-prompted on every click |
A UI mutation attempted before the secret has been unlocked for that session
redirects to /login with a prompt to unlock, rather than silently failing;
retry the action after unlocking. The web UI's own dashboard pages
(users, token tester, audit log) call /api/* from client-side JavaScript
using the browser's session cookie, not the header - an already-unlocked
session satisfies /api/*'s gate too, so those buttons keep working after
one unlock; a non-browser client still needs the header.
What's actually stored in session['management_verified'] is an HMAC of
management_secret itself (keyed by secret_key), not a bare boolean - see
Session Cookie Trust for why that
distinction matters.
Relationship to require_ui_login: independent axes - see
Config UI Login Gate above.
require_ui_login is the session front door (who can view the dashboard);
management_secret is the write guard (who can change anything). Either,
both, or neither can be enabled.
YAML-only: like secret_key, require_ui_login, and security_profile,
this is not exposed on the Settings page or the MCP update_settings tool -
a secret editable through the surface it protects isn't a secret.
What a read can see
Reads are not gated by management_secret, and what they can see is
bounded, with or without it: read-only management surfaces (the web UI
pages, GET /api/*, GET /api/runtime/*) expose client metadata and
configuration state, but render no material derived from a stored client
secret. On the clients page the secret cell is independent of both the
value and the length of the stored secret: every confidential client gets
the same fixed mask, whether it is declared in settings.yaml, created
through /api/runtime or registered through /register.
Where a client secret is returned, it goes to the party that holds it, not
to a reader: the response to POST /register and every RFC 7592 read
(GET /register/<client_id>), which needs that client's registration access
token; the "Regenerate Secret" message, shown to the session that performed
that gated mutation; and the new-client form, which proposes a freshly
generated secret that is stored only if the form is submitted. To read a
declared secret, read settings.yaml.
Two limits of this. require_ui_login is a login, not a role: any
users.yaml account passes it, and under login_mode: persona no
credential is checked at all, so it narrows who can read without changing
what a read shows. And the built-in test page (/test) prints the literal
demo-client / demo-secret pair that nanoidp init writes. Those are
public defaults, the same ones the quickstart uses: an instance where a
client secret matters should not keep them.
Security Profiles
NanoIDP supports three security profiles to balance convenience with basic security controls:
| Profile | Description |
|---|---|
dev (default) | Maximum convenience for development: plaintext passwords, permissive CORS, no rate limiting |
stricter-dev | Semi-hardened runtime: bcrypt passwords, restricted CORS, rate limiting, debug mode blocked |
oauth21 | Draft OAuth 2.1 protocol strictness (#68): PKCE required (S256 only), refresh token rotation on, password grant removed, registered redirect URIs mandatory at /authorize |
stricter-dev hardens the runtime; oauth21 hardens the protocol. They are deliberately orthogonal. The discovery document always reflects the active profile: under oauth21, password disappears from grant_types_supported and code_challenge_methods_supported is ["S256"].
Usage
# Run with default dev profile
python -m nanoidp
# Run with stricter-dev profile
python -m nanoidp --profile stricter-dev
# Run with draft OAuth 2.1 protocol strictness
python -m nanoidp --profile oauth21
--profile is a per-run override: when given (any of the three values,
including an explicit dev) it wins over security_profile in
settings.yaml, is never written back to the file, and is re-applied after
every configuration reload, including the reload that follows each web UI or
MCP save. The stricter-dev runtime hardening (require_pkce,
password_hashing, rate_limit_enabled, debug off) is derived from the
effective profile on every reload the same way, whether the profile came from
the flag or from YAML. GET /api/config reports the effective profile,
whether it came from an override, and the derived values (#172).
Feature Comparison
| Feature | dev | stricter-dev | oauth21 |
|---|---|---|---|
| Password storage | Plaintext | bcrypt hash | Plaintext |
CORS (unless cors_allowed_origins is set, which applies in every profile) | * (all origins) | localhost only | * (all origins) |
| Rate limiting | None | 10 req/min on /token | None |
| Debug mode | Allowed | Blocked | Allowed |
| PKCE | Optional | Required, S256 only | Required, S256 only |
| Refresh token rotation | Setting (off) | Setting (off) | Forced on |
password grant | Enabled | Enabled | Removed (and not advertised) |
Redirect URIs at /authorize | Any valid URI, or exact match if registered | Same as dev | Registration mandatory, exact match |
Invalid bcrypt hash fallback
When password_hashing is on, a stored users.yaml password that isn't a
valid bcrypt hash is not rejected by default - authenticate() catches the
format error and falls back to a plain string comparison, only logging a
warning. This exists so turning on stricter-dev/password_hashing doesn't
immediately lock out every user until each one is manually re-hashed.
To close that gap, opt in to:
session:
enforce_password_check: true # default: false
With this on, a user whose users.yaml password isn't already a bcrypt hash
simply can't log in - no plaintext fallback, no warning-and-continue. Has no
effect when password_hashing is off (that path is intentionally plaintext,
dev mode). Like secret_key and require_ui_login, this is YAML-only - not
on the Settings page or the MCP update_settings tool.
Persona Login Mode
Interactive logins can optionally skip password prompts entirely and let you sign in by picking a configured user from a list - handy for local testing when you just want to switch between users quickly, without looking up passwords.
Opt-in and off by default:
login:
mode: persona # default: password
| Mode | Behavior |
|---|---|
password (default) | Interactive logins require the configured password |
persona | Interactive logins list the configured users; sign in by selecting one, no password prompt |
Where it applies: every interactive login surface - the nanoidp dashboard's /login, OIDC's /authorize, SAML's /saml/sso, and the device authorization flow's /device verification page.
Where it doesn't apply: the OAuth2 password grant (grant_type=password at /token) is unaffected either way - it's a machine-to-machine credential exchange, not an interactive login. A user with no password configured (see below) simply can never authenticate through it, in either mode.
Password-less users: password is optional on a user in users.yaml. A user without one can only sign in via persona mode - they're rejected by password-mode login and the OAuth password grant alike.
users:
admin:
password: "admin" # still works with either login mode
alice:
email: "alice@example.org" # no password: persona-mode only
description: "Standard internal user" # optional, max 200 chars
Descriptions in the picker: an optional description per user is shown
next to their name in the picker on every surface above - e.g. "Finance
approver persona", "Read-only auditor". It's a display-only note, capped at
200 characters and plain text only: never a claim, never a SAML attribute,
never part of a token, and never treated as a custom attribute.
SAML detail: a persona login can't claim AuthnContextClassRef: PasswordProtectedTransport, since no password was used - NanoIDP emits urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified instead for sessions authenticated this way. A session that also completed the declarative TOTP second factor claims a third value, urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken, instead of PasswordProtectedTransport.
Orthogonal to security profiles: login.mode and security_profile are independent settings. security_profile governs OAuth/SAML protocol strictness; login.mode only changes how the interactive login UI authenticates the resource owner. Persona mode works the same under any profile.
Like the rest of NanoIDP, this is a local development/testing convenience - it is never intended as an authentication mode for a deployed environment.
Auto-Login (automated testing)
A further opt-in on top of persona mode, for driving a real OIDC client library in automated integration tests - no username/password entry, no HTTP calls of your own into nanoidp's login form:
login:
mode: persona
auto_login: true # default: false; has no effect unless mode is persona
With both set, an OIDC /authorize request whose login_hint is exactly persona-auto-login:USERNAME authenticates that user directly and issues the authorization code - no picker, no HTML page in the way. Any other login_hint is an ordinary hint outside this feature and is passed through unchanged; with the flag off, a prefixed hint is inert too, so the picker still shows exactly as before.
An unknown username in the hint reports through the same channel every other post-redirect_uri /authorize error uses: a redirect to the client with error=invalid_request and state preserved, never a bare 400 - a test asserting on a failed authorization sees the failure the same way any other rejected request does.
First implementation surface is OIDC /authorize only; the device flow and SAML have no defined transport for the hint today. It is only ever read from a GET request's own query string - never from the POST body of a login form submission, which the login page itself never sends it on anyway (#325).
Auto-login removes the one human click persona mode still required, so for any client used with it, register its redirect_uris rather than relying on the permissive dev default that accepts any redirect_uri - otherwise a plain GET link mints a code for any configured persona to anywhere.
Key Management
NanoIDP uses RSA keys for JWT signing. Keys can be auto-generated, imported from external files, or rotated dynamically.
Auto-generated Keys
By default, NanoIDP generates RSA keys on first startup and stores them in
jwt.keys_dir (default ./keys, relative to the working directory):
keys/
├── rsa_private.pem # RSA private key (signing)
├── rsa_public.pem # RSA public key (verification)
├── kid.txt # Key ID
├── idp-cert.pem # self-signed certificate for SAML
├── keys.json # previous keys kept for rotation
└── previous/ # public keys of the previous keys
The directory may be shared by several NanoIDP processes on one host. Together
the files are one bundle, and kid.txt says which: whoever starts first on an
empty directory generates it, under a lock file in the directory
(.nanoidp-write.lock), and the others load that bundle. The private key is
written with mode 0600. A rotation replaces the files under the same lock
and kid.txt last; if the process dies on the way, a .rotation/ directory
is what is left, and the next process that starts or rotates puts the
directory right from it: the bundle from before the rotation if kid.txt had
not been replaced yet, the new one if it had. Do not edit or remove
.rotation/ by hand while a NanoIDP process is running.
A keys directory that a process cannot write (a read-only mount, a volume owned by another user) still works for that process: it loads the published keys without the lock and cannot rotate them. It cannot settle an interrupted rotation either; if it finds one that does not end, it refuses to start and says so, and a NanoIDP process that can write the directory settles it by starting once.
External Keys
You can use your own RSA key pair instead of generated keys:
# settings.yaml
jwt:
keys_dir: ./keys # hosts the SAML certificate for the key
external_keys:
private_key: /path/to/private.pem
public_key: /path/to/public.pem
kid: "my-custom-key-id" # optional
Requirements:
- Private key: PEM format, PKCS8 encoding, unencrypted
- Public key: PEM format, SubjectPublicKeyInfo encoding
private_keyandpublic_keyare given together, and the public key must belong to the private key; a missing, unreadable or malformed file, or a mismatched pair, is a configuration error- Key ID (optional): if not provided, it is the RFC 7638 JWK thumbprint of the
public key, so the same pair keeps the same
kidacross restarts - Paths accept
${VAR}placeholders; relative paths resolve against the working directory, likekeys_dir
The SAML certificate for an external key is generated once into
keys_dir/external-cert-<thumbprint>.pem and reused on later starts, so an SP
that pins it keeps working; the generated keys' idp-cert.pem is left alone,
and a configuration that drops external_keys again signs SAML with a
certificate for the generated key.
A configuration that names keys nanoidp cannot use is never activated: at
startup the process exits with error: configuration rejected: ..., and
POST /api/config/reload (or MCP reload_config) answers 422, keeping the
running key. A reload that changes the key paths or the kid switches
signing to the new pair; the files are not watched, so a key replaced at the
same paths is read at the next start, not on reload.
External keys are not rotated by nanoidp: POST /api/keys/rotate, the keys
page and MCP rotate_keys refuse (409 on the API) and leave the key in
place. To change them, point external_keys at a new pair and reload.
Key Rotation
NanoIDP supports key rotation with multiple keys in JWKS for seamless token validation during rotation periods.
API Endpoints
# Rotate keys (generates new key, preserves old for validation)
curl -X POST http://localhost:8000/api/keys/rotate
# Get key information
curl http://localhost:8000/api/keys/info
How It Works
- Rotation: New key pair generated, old key moved to "previous" list
- JWKS: Returns both active and previous keys (configurable via
max_previous_keys, default 2) - Signing: New tokens signed with the active key
- Validation: Tokens signed with previous keys remain valid until those keys are rotated out. That holds for a resource server that reads the JWKS and for NanoIDP's own endpoints alike (
/userinfo,/introspect,/revoke, the refresh grant): a token is verified against the key itskidnames, among the active key and the previous ones kept. A token that names a key that is no longer kept, or not one of this issuer's, is invalid; a token with nokidis verified against the active key. External keys are one key with no history: a token signed with it verifies whateverkidit names, so changingjwt.external_key_idfor the same pair invalidates nothing. - Several processes: when NanoIDP processes share a keys directory, a rotation made by one is noticed by the others the next time they sign, verify or publish their JWKS: each looks at
kid.txt(without taking the lock, with astatfirst) and, when it names another key, loads that bundle whole; it never generates keys to answer a marker it cannot load, and says so in its log instead. Tokens they signed just before stay valid, their key being a previous one now.
Configuration
# settings.yaml
jwt:
max_previous_keys: 2 # Number of previous keys to keep in JWKS (0-10)
The value applies whenever the signing service is built, at startup and on a
reload that changes it: lowering it trims the JWKS immediately, keeping the
newest previous keys. Public-key files that are no longer retained may remain
in keys_dir/previous/; they are not served. The next rotation rewrites
keys.json with the retained set (until then, raising the value again serves
the dropped keys again).
Hooks and Plugins
hooks: (shell commands run before a configuration load, after a
configuration save and after an audit event) and plugins: (Python
packages loaded from the nanoidp.plugins entry-point group) extend
nanoidp from outside the core; see the
Extending nanoidp
guide. Two facts matter here:
- A Python plugin runs with the process's privileges. Installing one is a trust decision like any other dependency.
- A shell hook runs whatever the YAML says, through the shell, with
nanoidp's environment.
settings.yamlandbootstrap.yamlare operator-owned by definition, the same trust boundary assecret_keyandmanagement_secret, which is why hooks and plugins are YAML-only: the web UI and the MCPupdate_settingstool report them and cannot change them. A configuration surface that could set a command would be a remote-execution primitive.
Hooks never run on the protocol path and cannot fail a request: an
on_audit_event failure is counted and logged, never propagated.
MCP Server Security
The MCP (Model Context Protocol) server provides integration with Claude Code and other MCP-compatible tools.
Security Warning
The MCP server exposes powerful administrative tools and should ONLY be used:
- Locally on developer machines
- In isolated development environments
- Never exposed to network access
Mutating Tools
The following MCP tools modify configuration and require extra caution:
| Tool | Description |
|---|---|
create_user | Create a new user |
create_persona_user | Create a password-less user (persona login mode) |
update_user | Modify user attributes |
delete_user | Remove a user |
create_client | Create OAuth client |
update_client | Modify client settings |
delete_client | Remove OAuth client |
generate_token | Generate access tokens |
update_settings | Modify IdP settings |
save_config | Persist configuration changes |
Admin Secret Protection
When management_secret is configured, mutating
operations require the secret via the admin_secret tool argument.
NANOIDP_MCP_ADMIN_SECRET still works too - it's this setting's original,
MCP-only name, kept as an alias:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config",
"NANOIDP_MCP_ADMIN_SECRET": "your-secret-here"
}
}
}
}
Mutating tool calls without the correct secret will be rejected.
Readonly Mode
To completely disable mutating tools:
# Via CLI flag
nanoidp-mcp --readonly
# Via environment variable
NANOIDP_MCP_READONLY=true nanoidp-mcp
In Claude Code settings:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"args": ["--readonly"],
"env": {
"NANOIDP_CONFIG_DIR": "./config"
}
}
}
}
Use readonly mode when you only need introspection (listing users, decoding tokens, viewing settings) but want to prevent accidental modifications.
Audit Logging
All MCP tool calls are logged to the audit log, including:
- Tool name
- Parameters (secrets redacted)
- Timestamp
- Result status
Multi-hostname Issuer (issuer_from_request)
oauth.issuer_from_request (off by default) reflects each request's own
Host header as the discovery issuer, token iss, and device flow
verification_uri, so the same NanoIDP can be reached under more than one
hostname (e.g. a Docker Compose service name vs. localhost) without a
discovery/token issuer mismatch.
Trust caveats:
- The Host header is trusted as-is unless
oauth.issuer_allowlistis set to a non-empty list of allowed origins; a non-matching Host then falls back to the fixedissuerinstead. Only enableissuer_from_requestwithout an allowlist on trusted networks. - Behind a TLS-terminating reverse proxy, also enable
oauth.issuer_from_proxy_headerssorequest.scheme/host_urlreflectX-Forwarded-Proto/X-Forwarded-Hostinstead of the proxy's own HTTP connection - this only changes the derived issuer whenissuer_from_requestis also on (it always affects rate-limit client IP attribution regardless). Only enable this when NanoIDP sits directly behind exactly one trusted proxy- these headers are otherwise spoofable by any client.
- By default, the device flow's
verification_urireflects whichever Host called/device_authorization. If that caller is a backend/container (e.g.Host: nanoidp:9900) rather than the end user's own browser, the returned URL may not be reachable from the user's machine. Setoauth.device_verification_base_urlto a fixed, human-reachable URL (e.g.https://idp.example.com) to pinverification_uriregardless of the calling Host; discovery'sissuerand a token'sissare unaffected and keep following the request that fetched/requested them.
Environment Variables
| Variable | Description | Default |
|---|---|---|
NANOIDP_CONFIG_DIR | Configuration directory path | ./config |
NANOIDP_MANAGEMENT_SECRET | Secret required for mutations across MCP, /api/*, and the web UI | (none) |
NANOIDP_MCP_ADMIN_SECRET | Legacy alias for NANOIDP_MANAGEMENT_SECRET (MCP-only name, still honored) | (none) |
NANOIDP_MCP_READONLY | Disable mutating MCP tools when set to true | false |
PORT | Server port | 8000 |
Best Practices
- Use stricter-dev profile when sharing the instance with team members
- Enable readonly mode for MCP when only introspection is needed
- Set
management_secretif multiple developers share the same NanoIDP instance - it gates mutating calls on MCP,/api/*, and the web UI alike - Rotate keys periodically to test token validation with multiple keys
- Keep the default
127.0.0.1binding unless you specifically need network access; only override it on a trusted, isolated network (see Network Binding) - Never expose NanoIDP to public networks: it's designed for local/isolated use only
Related Documentation
- MCP Workflow - Detailed Claude Code integration examples
- README - Installation and configuration
Configuration
NanoIDP is configured through two YAML files in the config directory
(./config by default, --config or NANOIDP_CONFIG_DIR to change it).
Everything below can also be managed from the web UI at
http://localhost:8000:
- Dashboard: overview and quick stats
- Users: create, edit, delete users
- OAuth Clients: manage OAuth2 client credentials
- Settings: configure IdP settings (issuer, audience, SAML)
- Keys & Certs: view and regenerate RSA keys
- Claims: configure authority prefix mappings
- Audit Log: view and export authentication events
- Token Tester: generate and inspect tokens
Config schema version
Both files may declare, at the top level, the schema version they follow:
config_version: 1
The version belongs to the configuration directory's contract as a whole,
not to one file: settings.yaml and users.yaml declare the same number
(a mismatch refuses to start), each file is checked independently against
the version the running release supports, and a future bump applies to both files together with one loader
migration. The value must be a literal integer; it is checked before
${VAR} placeholders are expanded, so config_version: ${CONFIG_VERSION:1}
is rejected like any non-integer.
The contract is a single integer, not semver:
- Absent means 1. Existing files need no change;
nanoidp initand the wizard write the key into the files they create, and saves from the web UI or the MCP server preserve it if present and never add it. - Unknown keys are reported. A key nanoidp does not know (a typo such
as
oauth.isuer) is logged as a warning with its path and ignored; the file still loads, unless the directory is validated strictly (see Validating your configuration). Inside a user entry, unknown keys become that user'sattributes, as they always have. - Optional additions never bump it. New optional keys with defaults keep the version; only renames, removals or semantic changes of existing keys do, and such a bump ships with a migration step in the loader.
- A newer version than the running release understands is refused at startup with a message naming the file, the value found and the supported version, as is any value that is not a positive integer.
GET /api/config and the MCP get_settings tool report the effective
config_version, so external tools and agents know which contract to target.
The CHANGELOG carries a "Config schema" section whenever it changes.
The generated schema artifact
The machine-readable form of the contract lives in the repository at
docs/schema/config.v1.json:
one standalone JSON Schema per file, under the keys settings, users and
bootstrap, next to the config_version they describe. Point an editor's
YAML-schema support at the entry matching the file to get completion and
typo detection while writing it.
It is generated, never hand-written - that is the whole point, since a hand-written schema would be one more place the contract could disagree with itself:
nanoidp config-schema # the full document, on stdout
nanoidp config-schema --file users # one file's schema
nanoidp config-schema --write # regenerate docs/schema/config.v1.json
--write targets a path inside the repository and therefore works from a
source checkout only; from an installed package, redirect stdout instead. A
test fails when the committed file no longer matches the models, with the
command to run.
One thing the schema cannot express: a ${VAR} placeholder is a string
until it is expanded, and its expansion is a string too, which nanoidp then
coerces to the field's type. A plain JSON Schema check therefore flags
port: ${PORT:8000} against "type": "integer". Use nanoidp validate-config for that check - it runs the real loader.
Validating your configuration
Unknown keys are reported, not ignored. What "reported" means is a choice:
# settings.yaml
config_validation: warn # default: log the key and its path, keep loading
# config_validation: strict # refuse to start
The value belongs to the configuration directory as a whole: users.yaml
and bootstrap.yaml follow what settings.yaml declares. Wrong types and
refused values are errors in both modes and always have been; strict is
only about the unknown key. settings.yaml and users.yaml are validated
on startup and on every reload; bootstrap.yaml is validated when the
bootstrap surface is loaded, at startup (validate-config checks all
three, so for bootstrap.yaml it reports what would stop the NEXT
startup, not the next reload). A load that fails validates and commits
nothing: the running settings, users, validation mode and hooks stay
exactly as they were, in the same fail-without-commit contract as the
hook registry. The server flag --strict-config turns strict
on for one run, wins over the file, and is never written back:
nanoidp --strict-config # unknown key -> refuse to start
The same validation runs the other way round as well: before any surface
that writes configuration replaces a file, the document it composed is
parsed exactly as a load would parse it, and a document the models refuse
is refused instead of written. So the settings form, /api/runtime
promotion, the MCP save_config tool and nanoidp init cannot leave behind
a settings.yaml the next process would fail to start from. What you get is
an error naming the file and the key, with the file unchanged. The check
expands ${VAR} into a copy, the way a load does, so what is written keeps
its placeholders and what is checked is what the next load will read.
It is a check of the document, not of the running server: whether the configuration can be parsed into settings and users, not whether every service could be built from it. A configuration that parses but whose signing keys cannot be used is still refused at load, where it belongs.
To check a directory without starting anything:
$ nanoidp validate-config --config ./config
validate-config: ./config
warning: config/settings.yaml: unknown key oauth.isuer
0 error(s), 1 warning(s)
$ echo $?
0
$ nanoidp validate-config --config ./config --strict
validate-config: ./config (strict)
warning: config/settings.yaml: unknown key oauth.isuer
0 error(s), 1 warning(s) (strict: warnings fail)
$ echo $?
1
One line per finding, and three exit codes:
| Exit | Meaning |
|---|---|
| 0 | clean, or warnings only without --strict |
| 1 | any error, and any warning under --strict |
| 2 | the directory could not be observed, so no validation was performed |
A directory that declares config_validation: strict is strict either way,
since that is a directory the server would refuse to start on.
Exit 2 is not a verdict on the configuration (#246). It means another
process held the configuration directory long enough that this run could
not read it consistently, so the command answers UNAVAILABLE - validation not performed rather than reporting a directory nobody looked at. Nothing
is retried here: a CI gate that wants to retry can do so on 2 alone, and
tell it apart from 1, which really does say the configuration is wrong. A
file the run could not read is a different thing and stays an ordinary
error, exit 1, attributed to that file.
The command reads the three files through the same loaders the server uses,
and does nothing else: no server, no ConfigManager, no hook dispatched, no
plugin imported. bootstrap.yaml is checked for its shape only, because a
lint step that ran the commands a directory declares would be a remote
execution primitive triggered by looking at it. That is what makes it safe
as a pre-commit or CI step:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: nanoidp-validate-config
name: nanoidp validate-config
entry: nanoidp validate-config --config ./config --strict
language: system
files: ^config/.*\.yaml$
pass_filenames: false
MCP agents have the same check as the read-only validate_config tool,
which returns {valid, findings} for the running config directory.
Users (config/users.yaml)
users:
admin:
password: "admin"
description: "Full administrator" # optional, max 200 chars, display-only
email: "admin@example.org"
identity_class: "INTERNAL"
entitlements:
- "ADMIN_ACCESS"
- "USER_MANAGEMENT"
roles:
- "USER"
- "ADMIN"
groups:
- "ADMINISTRATORS"
- "EVERYONE"
tenant: "default"
source_acl:
- "ACL_READ"
- "ACL_WRITE"
description is optional and display-only: it's shown next to the username
in the persona login picker so you can tell
configured users apart at a glance (e.g. "Finance approver", "Read-only
auditor"). It is never a claim, never exported over SAML, and never part of
a token - just a note for the person picking a user, capped at 200
characters.
How these attributes end up in tokens, including the authority_prefixes
mapping below, is described in Tokens and claims.
Login mode (persona login)
password is optional on a user - a user without one can only sign in via
persona login mode, a local
dev/testing convenience (off by default) that lets every interactive login
surface (/login, /authorize, /saml/sso, /device) authenticate by
selecting a configured user instead of typing a password:
# settings.yaml
login:
mode: persona # default: password
# users.yaml
users:
admin:
password: "admin" # still works with either login mode
alice:
email: "alice@example.org" # no password: persona-mode only
description: "Standard internal user" # optional, shown in the picker
See the Security guide for the
full contract, including why the OAuth password grant is unaffected and
the SAML AuthnContextClassRef detail.
Auto-login
login.auto_login (default false) is a further opt-in on top of persona
mode, for driving a real OIDC client library in automated integration tests
(#250) - no username/password entry, no HTTP calls of your own into
nanoidp's login form:
# settings.yaml
login:
mode: persona
auto_login: true # default: false; has no effect unless mode is persona
With both set, an OIDC /authorize request whose login_hint is exactly
persona-auto-login:USERNAME logs that user in directly - no picker, no
HTML - straight to an authorization code. Any other login_hint is an
ordinary hint outside this feature and is left untouched, and with the flag
off a prefixed hint is inert too, so the picker still shows. An unknown
persona reports through the standard OAuth error redirect
(error=invalid_request, state preserved), never a bare 400. First
implementation surface is OIDC /authorize only - the device flow and SAML
have no equivalent transport for the hint today.
Two-step login
login.two_step (default false, #322/#323) collects the username on one
screen and the password on the next, for every interactive surface that
renders the combined form - /authorize, /login, /saml/sso and the
device flow's /device:
# settings.yaml
login:
two_step: true # default: false
A global setting, not a per-client one - it applies the same way to every
client. Inert under login.mode: persona above, which is passwordless and
has no password screen to split off. The step is stateless: the username
travels as a plain form field, carried forward as a hidden input on the
password screen, and whether a submission is the username-only step or a
real login attempt is derived from whether it carries a password - a
request that already has both authenticates directly, so a client written
against the combined form keeps working unchanged. "Change username"
returns to the first screen everywhere. It is a form post on every surface:
it discards a login waiting for its TOTP code, and on /saml/sso it
resubmits the in-progress SP request's hidden fields, since there is no GET
to link back to.
Second factor (TOTP)
login.totp (default false, #348) requires a time-based one-time code
after a successful password check, for any user carrying a totp_secret -
on the same four surfaces as two-step above. It is a declarative demo
factor, not IdP hardening (VISION principle 1): nanoidp is not a
production identity provider, and nothing here protects anything. It
exists so an application can be demoed and tested against a login that has
a real second screen and a real authenticator app, the way login.two_step
exists so a client can be tested against a split login form.
# settings.yaml
login:
totp: true # default: false
# users.yaml
users:
alice:
password: "alice-password"
totp_secret: "JBSWY3DPEHPK3PXP" # or ${ALICE_TOTP_SECRET}
Everything is data in the configuration files, nothing is managed at runtime, like every other property of a user:
- Enrolment is the secret's presence. There is no per-user
enabledflag, no enrolment screen, and no QR code in the UI - the operator writes the Base32 secret directly inusers.yaml(${VAR}placeholders work exactly as they do forpassword, including what a whole-entry save does to them: a form edit of an unrelated field or the MCPsave_configtool materializes an expanded${VAR}and rewrites a non-canonical spelling, such as spaces or lowercase letters, to the canonical one). To set the secret up in an authenticator app:- use its "enter a setup key" option with the same secret, or
- generate a QR code from the standard
otpauth://URI yourself:otpauth://totp/nanoidp:<username>?secret=<secret>&issuer=nanoidp&algorithm=SHA1&digits=6&period=30.
- Validation. The secret must be Base32 (case-insensitive, spaces and missing padding tolerated) or the configuration is rejected; a secret on a user with no password is rejected too - a factor has to follow a password.
- One rule, one place.
login.totprides the same phase machinery aslogin.two_step(routes/_auth.py): the code screen is a further phase after the password step. The verified password is never sent back to the browser: on/authorizeit is recorded on the authorization transaction (#346); on/login,/saml/ssoand/deviceit is recorded as a pending second factor (#373), bound to the browser, to the surface and to what the login is for there (the SAML request in flight, the deviceuser_code), valid for 5 minutes and used once. Either way the code screen carries only an opaque reference and the code, and the code is checked against the current secret of the user the record names. A user deleted or left without a secret, TOTP switched off or persona mode switched on in the meantime ends the login (access_deniedto the client on/authorize, an error on the page elsewhere) rather than completing it on the password alone. A form post carrying the password and the code together still completes both at once, with nothing stored. - Verification. RFC 6238, six digits, a 30-second period, HMAC-SHA1,
one step of clock skew either side - the parameters every authenticator
app assumes by default. Implemented with the standard library only
(
hmac,struct), no new dependency. - No replay memory. The same code is accepted more than once within
its 30-second step. A production TOTP implementation would track the
last accepted step per enrolment and refuse a repeat. A wrong code is
written to the audit log the way a wrong password is, and the code
screen is sent with
Cache-Control: no-store, since it carries the password forward. - Inert under persona mode, the same composition as
two_step: persona login checks no password, so there is nothing for a second factor to follow. - The OAuth password grant and client-credentials are unaffected - a
totp_secretonly changes the four interactive login surfaces above, nevergrant_type=password. - Claims follow the login, so a client can see the difference - see
Tokens for the
amrclaim and the security guide for theAuthnContextClassRefvalue (saml.mdcovers SAML options, not this per-method detail).
Out of scope, on purpose: enrolment/QR in the UI, encrypted secret storage,
a challenge store, replay protection, an administrator reset/recovery flow
(edit the file instead), and step-up on a session that logged in before
login.totp was turned on. A demo needs the visible flow. The rest is
what a production IdP needs.
Settings (config/settings.yaml)
server:
host: "127.0.0.1" # loopback by default; set 0.0.0.0 to expose on a network
port: 8000
rate_limit_enabled: false # optional; rate-limit POST /token (#304). Enforced
# for real since 3.0 - it used to log "enabled" while
# enforcing nothing. stricter-dev forces it on.
rate_limit_token_endpoint: "10/minute" # optional; flask-limiter notation
oauth:
issuer: "http://localhost:8000"
issuer_from_request: false # true: derive issuer/iss/verification_uri from
# each request's own Host header instead of the
# fixed issuer above - lets the same NanoIDP be
# reachable under more than one hostname (e.g. a
# Docker Compose service name vs. localhost) without
# a discovery/token issuer mismatch. MCP tools have
# no request of their own and always report the
# fixed issuer.
issuer_allowlist: [] # origins allowed to be reflected by issuer_from_request,
# e.g. ["http://localhost:8000", "http://nanoidp:9900"].
# Empty (default) allows any Host header; a non-matching
# Host falls back to the fixed issuer above.
device_verification_base_url: null # fixed, human-reachable URL (e.g.
# "https://idp.example.com") for the device flow's
# verification_uri, overriding issuer_from_request's
# derivation there - use this when a backend/container
# calls /device_authorization so the returned URL is
# still one a human's browser can open. Discovery's
# issuer and a token's iss are unaffected.
issuer_from_proxy_headers: false # true: trust X-Forwarded-Proto/Host/For from a
# single reverse-proxy hop in front of NanoIDP
# (applies werkzeug's ProxyFix). Only affects the
# issuer_from_request derivation above - has no
# visible effect unless that's also on - but always
# affects rate-limit client IP attribution. Only
# enable behind exactly one trusted proxy - these
# headers are otherwise spoofable.
audience: "my-app" # access token "aud" (resource audience, RFC 9068)
token_expiry_minutes: 60
refresh_token_expiry_minutes: 10080 # default 7 days, at most 30 - see "Lifetimes and CORS" below
refresh_token_rotation: false # true: each refresh invalidates the used refresh token
clients:
- client_id: "demo-client"
client_secret: "demo-secret"
description: "Default demo client"
- client_id: "multi-aud-client"
client_secret: "secret"
description: "Client whose ID Token carries extra audiences"
additional_audiences: # optional; makes the ID Token "aud" an array
- "https://api.example.com"
- "urn:service:billing"
- client_id: "registered-client"
client_secret: "secret"
description: "Client whose redirect_uri is pinned"
redirect_uris: # optional; when set, /authorize enforces
- "http://localhost:3000/callback" # exact string matching
- client_id: "branded-client"
client_secret: "secret"
description: "Demo client with custom login page branding"
background_color: "#2c3e50" # optional; hex only, behind the login card
header_color: "#3498db" # optional; hex only, the card's header band
footer_color: "#e8f4f8" # optional; hex only, the card's footer band
show_client_id: true # optional; default true
show_description: true # optional; default false
layout: "horizontal" # optional; "vertical" (default) or "horizontal" (#249):
# horizontal places the client info and login form side by
# side, collapsing back to a single column on narrow viewports
- client_id: "scoped-client"
client_secret: "secret"
description: "Client restricted to a scope subset"
allowed_scopes: # optional; see "Registered scopes" below
- "openid"
- "profile"
- client_id: "cli-client"
token_endpoint_auth_method: "none" # public client (#188): no secret
description: "CLI / SPA / MCP client that cannot keep a secret"
- client_id: "mcp-client"
client_secret: "secret"
description: "Client bound to specific MCP servers (RFC 8707, #187)"
allowed_resources: # optional; see "Resource indicators" below
- "https://mcp.example/server"
# logos_dir: "./static/logos" # optional; defaults to src/nanoidp/static/logos
# scopes_supported: # optional; the global scope vocabulary
# - openid # (default: openid, profile, email, offline_access)
# - profile
# - email
# - offline_access
# scope_enforcement: true # optional; false is a dev-only escape
# hatch back to "any scope string is
# accepted" - see "Registered scopes"
# dynamic_registration: # optional; RFC 7591/7592 at POST /register.
# enabled: false # Off by default, and set in this file only:
# max_clients: 100 # not in the settings form or the MCP tools.
# client_id_metadata_documents: # optional; a client_id that is an https URL
# enabled: false # is fetched at /authorize. File only, too.
# allowed_hosts: [] # exact host[:port]; nothing is fetched
# allow_loopback: false # until a host is named here
saml:
# Both optional: when absent they are derived from the effective issuer as
# <issuer>/saml and <issuer>/saml/sso, so they follow issuer_from_request and
# the reverse-proxy settings exactly like OIDC discovery does (#181). Set
# them only when the SP needs a different, fixed value.
# entity_id: "http://localhost:8000/saml"
# sso_url: "http://localhost:8000/saml/sso"
default_acs_url: "http://localhost:8080/login/saml2/sso/nanoidp"
sign_responses: true # Set to false for testing unsigned SAML flows
want_authn_requests_signed: false # verify AuthnRequest signatures (see SAML options)
# sp_certificates: # PEM files, required when the above is true
# - /path/to/sp-cert.pem
export_roles: false # include the user's roles as a SAML attribute
export_groups: false # include the user's groups as a SAML attribute
roles_attr_name: "roles" # attribute name used when export_roles is on
groups_attr_name: "groups" # attribute name used when export_groups is on
# Optional; also settable at startup with --profile, which wins over this value
# for that run only (any of the three, including an explicit dev), is never
# written back here and survives every reload (#172)
# security_profile: oauth21 # dev (default) | stricter-dev | oauth21
# Optional; absent, the security profile decides - see "Lifetimes and CORS" below
# cors_allowed_origins:
# - "http://localhost:5173"
# Optional; the device authorization grant's timing - see "Lifetimes and CORS" below
# device_flow:
# code_expiry_seconds: 600 # default; 1 to 3600
# polling_interval: 5 # default; 1 to 60
# Optional; local dev/testing convenience, off by default - see "Login mode" above
# login:
# mode: persona # password (default) | persona
# auto_login: true # default: false; requires mode: persona - see "Auto-login" above
# two_step: true # default: false; see "Two-step login" above
# totp: true # default: false; see "Second factor (TOTP)" above
# Optional; how an unknown key is reported - see "Validating your configuration"
# below. Also settable at startup with --strict-config, which wins over this
# value for that run only and is never written back
# config_validation: strict # warn (default) | strict
authority_prefixes:
roles: "ROLE_"
groups: "GROUP_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
# Optional; the identity-class values selectable when editing a user
# (web UI and user forms). Defaults to the four values below.
allowed_identity_classes:
- "INTERNAL"
- "EXTERNAL"
- "PARTNER"
- "SERVICE"
logging:
verbose_logging: true # Include usernames/client_ids in logs (default: true)
Registered redirect URIs: a client with a non-empty redirect_uris
list gets exact-string matching on /authorize (RFC 6749 §3.1.2.3,
OAuth 2.1 §4.1.1): no prefix, host or path normalization, and a mismatch
is answered with 400 invalid_request directly, never by redirecting to
the unvalidated URI (§3.1.2.4). Clients without the field keep accepting
any absolute URI, the permissive dev default.
Registered scopes (issue #186): oauth.scopes_supported is the global
scope vocabulary (default: openid, profile, email, offline_access,
also what discovery's scopes_supported advertises); a client's
allowed_scopes is an optional subset of it. A requested scope outside the
vocabulary is invalid_scope for every client (RFC 6749 §3.3, §4.1.2.1,
§5.2) - scopes_supported is a contract, not a suggestion. A requested
scope outside a client's own allowed_scopes, when set, is invalid_scope
for that client specifically; a client without the field may obtain any
vocabulary scope, the permissive dev default (same "empty = unrestricted"
convention as redirect_uris above). Enforced at /authorize, every
/token grant (including client_credentials, RFC 6749 §4.4), and
/device_authorization. oauth.scope_enforcement: false is a dev-only
escape hatch back to the pre-#186 behavior - any scope string accepted,
unchecked; refused outside the dev profile.
Public clients (issue #188): token_endpoint_auth_method: "none"
declares a client that cannot keep a secret - a CLI, desktop or native
app, SPA, or MCP client. client_secret becomes optional (and is ignored
if present: a stored secret never authenticates a public client);
/token identifies the client by client_id alone. In exchange, the
protections that replace client authentication are mandatory regardless
of profile: /authorize requires PKCE with S256 (OAuth 2.1 §7.5.1),
the client_credentials grant is refused with unauthorized_client
(it IS client authentication), and refresh tokens always rotate with
reuse detection (OAuth 2.1 §4.3.1/§6.1), whatever
refresh_token_rotation says. /revoke accepts a public client's
client_id with an ownership check - the token's own client_id claim
must match, otherwise the response is still 200 and nothing is revoked
(RFC 7009 §2.1 and its privacy guidance). /introspect stays
authenticated (RFC 7662): a public client_id is not authentication.
/device_authorization accepts a public client by client_id alone
(RFC 8628 §3.1, #255) - it presents the client_id as a parameter, not
via HTTP Basic or a secret, and the issued device_code is bound to it.
A confidential client authenticates on every grant, authorization_code
included (RFC 6749 §3.2.1); a missing or wrong secret is invalid_client.
The registered token_endpoint_auth_method is enforced (RFC 7591) at
every client-authenticated endpoint - /token, /introspect,
/revoke and /device_authorization (issue #262): the default
client_secret_basic requires HTTP Basic and rejects a body secret,
while client_secret_post requires client_id + client_secret as POST
body parameters and rejects Basic; presenting both HTTP Basic and a body
secret in one request is rejected (RFC 6749 §2.3). Applying the one
registered method across all of these endpoints is nanoidp's consistency
policy, not an obligation of every RFC: RFC 7009 and RFC 8628 do tie
/revoke and /device_authorization to the token-endpoint method, while
RFC 7662 requires some client authentication at /introspect but leaves
the method open - nanoidp reuses the registered method there too rather
than introduce a second field. A client that authenticated to these
endpoints over the other channel before must now use the channel its
token_endpoint_auth_method names (or be re-registered for the method it
actually uses).
Public-client handling differs by endpoint (each follows its own RFC), so here it is in one place:
| Endpoint | A public client (token_endpoint_auth_method: "none") |
|---|---|
/authorize | Accepted; PKCE with S256 is mandatory regardless of profile (OAuth 2.1 §7.5.1). |
/token - authorization_code, refresh_token, device_code | Identified by client_id alone; refresh tokens always rotate with reuse detection. |
/token - client_credentials | Refused with unauthorized_client (the grant IS client authentication). |
/device_authorization | Accepted by client_id alone (RFC 8628 §3.1); HTTP Basic or a client_secret is rejected; the device_code is bound to the client. |
/revoke | Accepted by client_id alone; an ownership check on the token's client_id claim stands in for authentication (RFC 7009 §2.1). |
/introspect | Refused - a public client_id is not authentication (RFC 7662); none is not in introspection_endpoint_auth_methods_supported. |
Authorization response iss (issue #189, RFC 9207): /authorize
returns iss=<effective issuer> on every response delivered through a
validated redirect_uri - success and error alike - so a client can
detect an authorization-server mix-up. iss is delivered exactly when
discovery advertises authorization_response_iss_parameter_supported: a
single condition drives both, so metadata and behaviour never disagree.
RFC 9207 requires the issuer to be an https URL with a host and no query
or fragment, so the default http://localhost:8000 dev issuer sends no
iss and advertises false; point the issuer at https (directly, or
reflected via issuer_from_request behind a TLS proxy) to turn RFC 9207
on. The value follows issuer_from_request. Errors are OAuth error
redirects (error, error_description, state, iss) once the
redirect_uri is validated; an error before that - an unknown client, a
malformed or unregistered redirect_uri - stays a local JSON response,
never a redirect to an unvalidated URI.
Resource indicators (issue #187, RFC 8707): a client may send one or
more resource parameters on /authorize, /token and
/device_authorization, and the access token's aud is bound to those
resources - a token minted for one MCP server is then rejected by
another. A resource must be an absolute URI without a fragment,
otherwise the request is invalid_target. Per-client allowed_resources
gates which resources a client may target (empty = any valid resource,
the dev default, same "empty = unrestricted" convention as
allowed_scopes). A /token request may narrow the resources a prior
step bound (an authorization code, a refresh token) but never widen them.
Sending no resource leaves aud at oauth.audience, unchanged for
existing clients. /introspect reports the token's aud; there is no
resource_indicators_supported discovery metadata (RFC 8707 defines
none). An MCP client sends resource on both /authorize and /token
(MCP Authorization).
Native apps (RFC 8252): two things a native client needs are built
in. A private-use scheme URI such as com.example.app:/oauth2redirect
(§7.1: a scheme and a path, no host) is a valid redirect_uri and can be
registered like any other. §7.1 requires such schemes to be a domain the
app controls, in reverse order; NanoIDP applies the minimum rule the RFC
asks of an authorization server, rejecting any non-http(s) scheme that
contains no period (myapp://callback is answered with 400 invalid_request naming the rule), and does not verify domain ownership.
A registered loopback URI,
http://127.0.0.1:{port}/callback or http://[::1]:{port}/callback,
matches any port (§7.3), because the app binds an ephemeral port at
runtime; register it with any placeholder port (:0 reads well). Only
the port is variable: scheme, host, path and query stay exact, and
localhost gets no port flexibility (§7.3 and §8.3 recommend the IP
literals precisely because localhost can be remapped). The oauth21
profile keeps the loopback exception, as the OAuth 2.1 draft does.
- client_id: "native-client"
client_secret: "secret"
redirect_uris:
- "com.example.app:/oauth2redirect" # private-use scheme, exact
- "http://127.0.0.1:0/callback" # loopback: any port matches
Login page branding: for demos and prototyping, a client can show its
client_id and description on the /authorize login page and use custom
colors, so testers can see which application they're signing in to. All
fields are optional and safe by construction: colors must be a plain
#rrggbb hex string (validated on save, rejected otherwise), never raw CSS
or markup, and a logo is a local file, never a remote URL. To add a logo,
drop an image at <logos_dir>/<client_id>.{svg,png,jpg,jpeg,webp} (default
logos_dir: src/nanoidp/static/logos, overridable via oauth.logos_dir);
it's picked up by filename, no config entry needed.
layout (#249) controls the card's composition: "vertical" (default) is
the single-column card - header, client info, then the login form, footer;
"horizontal" places the client info and the login form side by side, with
the header and footer still full width, collapsing back to the vertical
stack on narrow viewports. It's one of exactly two nanoidp-owned layouts,
not a general styling knob - there's no per-client CSS or column widths.
Two-step login (splitting the password form into a username screen and a
password screen) is a global login.two_step setting, not part of a
client's branding - see "Two-step login" above. The same is true of the
declarative TOTP second factor, login.totp - see "Second factor (TOTP)"
above.
To preview a client's branded login page, open /authorize with its
client_id and a redirect_uri (any syntactically valid URL works unless
the client has redirect_uris pinned - see above):
http://localhost:8000/authorize?response_type=code&client_id=branded-client&redirect_uri=http://localhost:3000/callback
The page won't complete the flow (there's no app listening at
redirect_uri to receive the code), but it renders the branding, which is
all a visual check needs. This only affects /authorize; the dashboard's
own /login and the SAML SSO login page are unbranded.
The SAML options (strict_binding, sign_responses, c14n_algorithm)
are covered in detail in SAML options. The two ways a client can
exist without being declared here have a guide each:
Dynamic client registration
(oauth.dynamic_registration) and
Client ID metadata documents
(oauth.client_id_metadata_documents). Security-related
settings are covered in the Security guide:
profiles, require_pkce, key management, jwt.external_keys, the
config UI's opt-in login gate
(session.require_ui_login), the invalid-bcrypt-hash
fallback removal
(session.enforce_password_check), and the
management secret write guard
shared by MCP, /api/*, and the web UI (session.management_secret) - all
YAML-only, like session.secret_key (see
Session Cookie Trust
for why that one matters here too).
Lifetimes and CORS
These settings default to what nanoidp did before they could be set (#441), so a file that does not mention them behaves as it always did.
oauth.refresh_token_expiry_minutes(default 10080, 7 days; 1 to 43200): the lifetime of every refresh token issued from now on, a rotated one included. A token already issued keeps the lifetime it was issued with. A short value is how a test sees a client handle an expired refresh token.device_flow.code_expiry_seconds(default 600; 1 to 3600) anddevice_flow.polling_interval(default 5; 1 to 60): what/device_authorizationannounces asexpires_inandinterval(RFC 8628), and how long a device code and its user code stay valid. Withcode_expiry_seconds: 1, a test seesexpired_tokena second later.cors_allowed_origins: the origins a browser may call nanoidp from. Absent, the security profile decides: every origin underdevandoauth21,localhostand127.0.0.1on any port understricter-dev. Present, it is the list in every profile:[]allows no origin,"*"every origin, and each entry is an origin written the way a browser sends it in an Origin header:httporhttps, a host (a name, an IPv4 address, or a compressed IPv6 address in brackets such as[::1]) and a port only when it is not the scheme's default. Entries are compared with the Origin header as strings, ignoring case, so one in another form would never match and is refused with the form to write instead (https://app.example:443becomeshttps://app.example). An internationalised host is written in the ASCII form the browser sends (https://xn--fa-hia.deforfaß.de); a path, credentials or a pattern such ashttp://localhost:*is refused as not an origin. A"*"understricter-devis logged as a warning at startup: the list wins over the profile. CORS is set up when the server starts, so a change takes effect at the next restart, not at a reload.
GET /api/config and the MCP get_settings tool report all four; the
settings page edits the refresh token lifetime, and MCP update_settings
the refresh token lifetime and the two device flow values.
Two keys that used to be accepted and ignored are not settings:
logging.format (the log format is fixed) and session.permanent (the
login session is always a permanent cookie). They are reported like any
unknown key: a warning, or a refusal under config_validation: strict.
Runtime store (runtime:)
runtime:
store: memory # the default
runtime:
store: sqlite
path: ../state/nanoidp-runtime.db # relative to this configuration directory
Where runtime state is kept: runtime users and clients, authorization codes, device codes, revocations, the audit, and the rest of what a running IdP records that is not declared in these files. For what sharing one buys and what it does not, see the guide Two processes, one runtime state.
memorykeeps it in the process and loses it on restart, as NanoIDP always has.sqlitekeeps it in files that several NanoIDP processes on one host share:pathnames the database, and the store is that file, an audit file next to it and a directory of owner leases, named after it (runtime.dbhasruntime-audit.dbandruntime-owners/).
path. It is required with sqlite and refused with memory. A relative
path is relative to the configuration directory, not to the working directory
of the process: it is the identity of a store several processes share, and
the same settings.yaml must name the same store however each process was
started. For the same reason a path starting with ~ is refused: it would
name a file of each process's HOME. None of the store's files may lie inside the configuration directory
(symlinks resolved): they are a secret, and the configuration directory is
read, copied and committed as configuration. The files are created 0600
(the leases directory 0700); delete all of them to start afresh.
Choosing and changing it. The store is chosen when the process starts. A
reload that asks for another store or another file is refused (422, kind
activation, with a message that says to restart), because processes that
share one would otherwise disagree about what exists. GET /api/config and
the MCP get_settings tool report the store in use as runtime.store, and
for sqlite the file the process opened as runtime.path. The section is YAML-only:
the settings form and MCP do not write it. Audit events recorded before the
store is activated (by a plugin's load hook) are kept in memory until then,
and are not carried into a SQLite store: a warning says how many.
When it is held. A store held by another process for longer than it waits
is 503 with Retry-After over HTTP, and MCP_RUNTIME_STORE_UNAVAILABLE
with retryable: true in MCP.
Logging
NanoIDP logs all authentication events to both the audit log (viewable in the web UI) and standard output.
logging:
level: INFO # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_token_requests: true # Log token endpoint requests
log_saml_requests: true # Log SAML endpoint requests
verbose_logging: true # Include usernames/client_ids in log messages
The format of the log lines is fixed; logging.format is not a setting.
Verbose logging (verbose_logging: true, default):
- Log messages include user and client identifiers for debugging
- Example:
[login] POST /token - success (user: admin) (client: demo-client)
Non-verbose logging (verbose_logging: false):
- Log messages omit sensitive identifiers
- Example:
[login] POST /token - success
Set verbose_logging: false if you're concerned about PII in log files,
though for a dev tool this is typically not an issue.
Hooks and plugins (hooks:, plugins:)
Two optional top-level sections, absent by default, declare the extension
points described in Extending nanoidp: hooks and plugins:
hooks: holds shell commands for on_before_load, on_config_saved and
on_audit_event plus strict and timeout_seconds (shell hooks only);
plugins: maps a plugin's entry-point name to its own settings (the only
section whose inner keys nanoidp does not validate). Both are YAML-only:
GET /api/config and the MCP get_settings tool report hook names,
sources and failure counters, never the commands (which may embed expanded
${VAR} secrets), and neither surface can change them. Policy values
declared here override bootstrap.yaml's; undeclared ones keep the
bootstrap value. Hooks
that must run before settings.yaml exists go in bootstrap.yaml (same two
keys) or in NANOIDP_BOOTSTRAP_HOOK / NANOIDP_BOOTSTRAP_PLUGIN.
hooks:
on_config_saved: "git -C {config_dir} add {path} && git -C {config_dir} commit -q -m 'nanoidp: {kind}' || true"
strict: false
timeout_seconds: 10
plugins:
echo:
record: /tmp/nanoidp-hooks.jsonl
Placeholders and the config directory as the interface
Both files accept ${VAR} and ${VAR:default} placeholders in any scalar
value except config_version (a literal integer, see above), expanded from
the environment when the file is loaded:
# settings.yaml
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000}
clients:
- client_id: my-app
client_secret: ${MY_APP_SECRET} # no default: empty when unset
# users.yaml
users:
alice:
password: ${ALICE_PASSWORD} # unset: load fails, a password cannot be empty
email: ${ALICE_EMAIL:alice@example.org}
What a save does to placeholders differs between the two files. In
settings.yaml a web UI or MCP save rewrites only the fields it changed, so
untouched placeholders survive. In users.yaml a save of one user rewrites
that user's entry from its loaded (expanded) values and leaves every other
user's text intact; the MCP save_config tool rewrites the whole user map
and therefore materializes every expanded placeholder in it. Keep
placeholder-backed users out of the UI/MCP edit path, or regenerate the
file from its source after editing.
This makes the config directory the whole interface between NanoIDP and whatever produces its configuration. Three use cases that need nothing beyond it:
- One file, many environments: commit
settings.yamlwith placeholders and set the variables per environment (shell, Composeenvironment:, a Kubernetesenv:block). - Secrets kept out of the file: point the placeholder at a variable that an init step renders from wherever the secret lives; NanoIDP only ever sees the environment.
- Files produced elsewhere: generate or copy both YAML files into
NANOIDP_CONFIG_DIRbefore start (an init container, a mounted volume, a script), thenPOST /api/config/reloador the MCPreload_configtool to pick up a later change without a restart. Reloading re-reads the files, re-expands placeholders and re-applies the CLI--profile. A reload that does not validate, or whose signing configuration cannot be used, answers422and leaves the running configuration in place.
NanoIDP does not read from or write to any store other than these files; a sync with an external system is the deploy's job, on either side of the directory.
Environment variables
The environment variables (NANOIDP_CONFIG_DIR, NANOIDP_MANAGEMENT_SECRET,
the legacy NANOIDP_MCP_ADMIN_SECRET alias, NANOIDP_MCP_READONLY, PORT)
are listed in the
Security guide.
Endpoints
OAuth2 / OIDC
| Endpoint | Description |
|---|---|
GET /.well-known/openid-configuration | OIDC Discovery |
GET /.well-known/oauth-authorization-server | The same document under the RFC 8414 name |
GET /.well-known/jwks.json | JSON Web Key Set |
GET/POST /authorize | Authorization endpoint (login page) |
POST /token | Token endpoint |
GET/POST /userinfo | UserInfo endpoint |
POST /introspect | Token Introspection (RFC 7662) |
POST /revoke | Token Revocation (RFC 7009) |
GET/POST /logout | OIDC End Session / Logout (alias: /end_session) |
GET /ui/logout | Dashboard session logout (the web UI's Logout button) |
POST /device_authorization | Device Authorization (RFC 8628; alias: /device/code) |
GET/POST /device | Device verification page |
POST /clients/forget | Drop one cached client ID metadata document (web UI) |
POST /register | Dynamic client registration (RFC 7591), opt-in |
GET/DELETE /register/<client_id> | Read or remove a registration (RFC 7592) |
The two discovery paths return the same document: nanoidp is one server with
one set of endpoints, and a client that speaks only OAuth looks under the
RFC 8414 name. Both derive the issuer the same way, so issuer_from_request
applies to both. The metadata is served at the root form of the name only,
which is the correct one for an issuer without a path component; an issuer
with a path would want RFC 8414's path-suffixed form, and nanoidp's OIDC
document has always assumed the root form too.
The two /register paths exist only while
oauth.dynamic_registration.enabled is set, and are open when they do:
the flag is the gate, not the management_secret. See
Dynamic client registration.
An accepted GET /authorize stores the validated request as an
authorization transaction on the server (#346), bound to the browser
through the session cookie, and renders a login page whose forms carry its
transaction_id. The request (response_type, client_id, redirect_uri,
scope, state, PKCE, nonce, claims, resource) is validated once, on
that GET; the POST that completes the login uses what the transaction
holds and never reads OAuth parameters from its body. A transaction expires
after 10 minutes, yields at most one code, and can only be used with the
cookie of the browser that created it. It is a snapshot: a configuration
change while it is open does not revalidate it, and only a client that no
longer exists ends it.
A POST names its transaction with transaction_id; when its own query
string also carries OAuth request parameters, they must be exactly those of
the request that created the transaction. A POST without transaction_id
whose query string carries a complete OAuth request is a direct entry
point, with or without a GET before it: that request is validated on the
POST like a GET's, and the login runs against a transaction that ends with
the POST unless a TOTP code is still to come. A POST with neither, such as
a script posting username/password after its GET with the same cookie
jar, continues the one pending transaction of that browser; with none or
several pending it is refused with invalid_request rather than guessing.
A GET without OAuth request parameters (other query parameters do not
count) follows the same "exactly one" rule, which is how a GET carrying
only login_hint applies the hint to the pending request
(#250/#325/#328/#346).
curl examples for every grant are in Requesting tokens.
What /token, /device_authorization, /userinfo, /introspect and
POST /api/users/{username}/token answer carries Cache-Control: no-store
and Pragma: no-cache, success and error alike (RFC 6749 §5.1 for the
responses that carry tokens or credentials; a policy of nanoidp's own for
user data and token state, so that nothing between a test client and the
IdP answers from a cache). The two registration responses that carry
credentials (#190) say the same. A response the endpoint itself did not
build, such as a 405, a 429 from the rate limiter or a 503 while the
configuration cannot be read, carries neither; nor do the discovery
documents and the JWKS, which are public and cacheable.
The standard OIDC profile / email claims (email, email_verified,
preferred_username, ...) are served from GET /userinfo, not embedded
in the tokens - see Tokens and claims.
SAML
| Endpoint | Description |
|---|---|
GET /saml/metadata | IdP Metadata |
GET /saml/cert.pem | IdP signing certificate (PEM) |
GET/POST /saml/sso | Single Sign-On (supports both HTTP-POST and HTTP-Redirect bindings) |
POST /saml/attribute-query | Attribute Query (SOAP, backend-to-backend) |
Bindings, strict-binding mode, response signing, and canonicalization are covered in SAML options.
/saml/attribute-query is unauthenticated by design - the same model as
the REST read surfaces (reads are never gated): nanoidp is a testing IdP and
its user directory is test data. On a shared instance, anyone who can reach
the endpoint can read any configured user's attributes; deploy accordingly.
An unknown NameID gets a SAML error status (Requester/UnknownPrincipal),
never a fabricated assertion. Every query it answers is audited, refusals
included (#309), so the same reachability that lets a stranger read
attributes also lets one fill the audit ring and push older entries out of
it; the recorded query id is truncated so a single request cannot store more
than a name's worth of text - in the audit entry only: the InResponseTo of
the answer carries the id exactly as it was sent.
REST API
| Endpoint | Description |
|---|---|
GET /api/health | Health check |
GET /api/users | List the effective users, declared and runtime, each with its origin |
GET /api/users/{username} | Get user details, declared or runtime (origin) |
POST /api/users/{username}/token | Generate a token for a user (testing); the user and client_id resolve like any login, runtime ones included. Optional JSON body: exp_minutes, and client_id (must name a real client) which binds the token and issues a spendable refresh_token; without client_id the response is an access token only (no refresh_token, since one with no client binding is refused since 3.0, #73). |
GET /api/audit | Get audit log |
GET /api/audit/stats | Audit log statistics |
POST /api/audit/clear | Clear the audit log |
GET /api/config | Get current configuration |
POST /api/config/reload | Reload configuration. A rejected reload answers a JSON 422 (kind: invalid for files that cannot be read or do not validate, activation for a signing configuration that cannot be used) and the running configuration stays in effect; a strict hook or plugin failure answers 503 |
POST /api/keys/rotate | Rotate cryptographic keys. A refusal is {"success": false, "error": <fixed message>, "kind": ...}: 409 when jwt.external_keys is configured, since the operator's key is not nanoidp's to replace (external_keys_not_rotatable), and when this process cannot write the keys directory (keys_directory_not_writable, or lock_namespace_unavailable when its lock file cannot even exist); 503 when the directory's lock, shared with other NanoIDP processes, could not be taken: lock_timeout (another process is starting or rotating) with Retry-After, lock_unsupported (the filesystem has no advisory locks) without, since a retry does not help. The directory a refusal is about is in the server log, not in the body |
GET /api/keys/info | Get key information |
Runtime identities
Disposable users and clients for a test run, created on the running IdP and never written to the declared files unless promoted; see Disposable test identities.
| Endpoint | Description |
|---|---|
POST /api/runtime/users | Create a runtime user: a users.yaml entry plus username |
GET /api/runtime/users | List runtime users |
GET /api/runtime/users/{username} | Get a runtime user |
DELETE /api/runtime/users/{username} | Delete a runtime user |
POST /api/runtime/users/{username}/promote | Write it into users.yaml and retire the runtime object |
POST /api/runtime/clients | Create a runtime client: an oauth.clients[] entry |
GET /api/runtime/clients | List runtime clients |
GET /api/runtime/clients/{client_id} | Get a runtime client |
DELETE /api/runtime/clients/{client_id} | Delete a runtime client |
POST /api/runtime/clients/{client_id}/promote | Write it into settings.yaml and retire the runtime object |
DELETE /api/runtime | Remove every runtime user and client; answers users_deleted and clients_deleted |
Tokens and claims
Audiences and the aud claim
NanoIDP follows the OpenID Connect / OAuth specs for the aud claim:
- ID Token:
audis the requesting client'sclient_id(OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configureadditional_audienceson a client to append extra audiences; if this produces more than one distinct audience value,audis emitted as an array and NanoIDP also emits anazpclaim equal to theclient_idso you can exercise authorized-party validation. - Access Token:
audis the resource audience fromoauth.audience(RFC 9068 §2.2), independent of the client.
Access Token
The access token aud is the resource audience (oauth.audience). The
user's attributes from users.yaml are carried both as individual claims
and flattened into authorities via the configured authority_prefixes:
{
"iss": "http://localhost:8000",
"sub": "admin",
"aud": "my-app",
"iat": 1704100000,
"exp": 1704103600,
"roles": ["USER", "ADMIN"],
"groups": ["ADMINISTRATORS", "EVERYONE"],
"tenant": "default",
"identity_class": "INTERNAL",
"entitlements": ["ADMIN_ACCESS", "USER_MANAGEMENT"],
"authorities": [
"ROLE_USER",
"ROLE_ADMIN",
"GROUP_ADMINISTRATORS",
"GROUP_EVERYONE",
"IDENTITY_INTERNAL",
"ENT_ADMIN_ACCESS",
"ENT_USER_MANAGEMENT",
"ACL_READ",
"ACL_WRITE"
]
}
The exp and extra parameters
Two form parameters of /token, on every grant, exist for testing and
have no counterpart in RFC 6749:
expis the lifetime in minutes, 1 to 1440, of the access token and of any ID token issued with it, in place ofoauth.token_expiry_minutes. It does not affect refresh tokens (oauth.refresh_token_expiry_minutes). It is not the JWTexpclaim: the claim is computed from it.extrais a JSON object of custom claims to add to the access token (never to the ID token or the refresh token). It adds, it never changes: a request that names a claim the grant or the user store decides is refused withinvalid_requestand the names, before the grant runs, so an authorization code, a device code or a refresh token is not consumed by it. Refused names: the registered claims (iss,sub,aud,exp,iat,nbf,jti), the protocol claims (client_id,scope,token_use,azp,nonce,auth_time,amr,at_hash,req_*), the claims the server reads back (token_type,rt_family,resource), and the claims about the user (roles,authorities,tenant,groups,entitlements,identity_class,source_acl,attributes,usernameand the OIDC standard claims such asemailorname). Soextra={"exp": ...}is refused whereexp=15is a lifetime. A token for a user with other roles is obtained by creating that user (a runtime user, or MCPcreate_user), not throughextra.
POST /token grant_type=password ... exp=15 extra={"tenant_plan":"trial"}
-> access token with tenant_plan and a 15 minute lifetime
POST /token grant_type=password ... extra={"sub":"root","roles":["ADMIN"]}
-> 400 {"error":"invalid_request","error_description":"'extra' cannot set: roles, sub"}
The MCP tool generate_token applies the same rule to its extra_claims
argument.
A client credentials token
A token from the client_credentials grant has no user behind it: the
client acts on its own behalf (RFC 6749 §4.4). Its subject is the client
(RFC 9068 §2.2), and it carries no user's attributes:
{
"iss": "http://localhost:8000",
"sub": "demo-client",
"client_id": "demo-client",
"aud": "my-app",
"scope": "profile",
"token_use": "access",
"iat": 1704100000,
"nbf": 1704100000,
"exp": 1704103600,
"jti": "..."
}
audis theresourcewhen the request names one, as for any access token. No refresh token and no ID token are issued.- These claims are the grant's: the
extraparameter can add other claims and nothing else, as on every grant (see above); a request that names one of them, or a claim about a user, is refused. /userinfoanswers such a token with{"sub": ...}alone, and/introspectnames nousername, nor theopenidscope it reports for a user token issued without one: a token whosesubequals itsclient_idis never taken as a user's, even if a user has the client's name. A user named like a client is reported with a warning at load.- Until #445 this token was issued for the
default_userofusers.yaml, with that user's roles;default_useris deprecated and has no effect.
ID Token
Issued when the openid scope is requested. Its aud is the client's
client_id:
{
"iss": "http://localhost:8000",
"sub": "admin",
"aud": "demo-client",
"iat": 1704100000,
"exp": 1704103600,
"nonce": "..."
}
ID Tokens also carry auth_time (when the end-user actually
authenticated, preserved across refreshes, OIDC Core §12.2) and at_hash
(binding to the access token issued alongside, §3.1.3.6). By default they
do not carry email or other profile claims - see Where do the
email / profile claims come from?
below.
amr
amr (Authentication Methods References, OIDC Core §2, values from RFC
8176 §2) is present on an ID Token once login.totp is on (#348, see
Second factor (TOTP)). Of the
interactive login surfaces, only /authorize and the device flow mint ID
Tokens, so those are the two surfaces that carry it; /login and
/saml/sso authenticate the same way
but feed the dashboard session and the SAML context instead, and
/authorize never reuses a /login session - it re-authenticates on its
own each time, TOTP check included:
["pwd"]- a password login with no second factor.["pwd", "otp"]- a password login followed by a valid TOTP code.
It is absent with login.totp off (the feature is opt-in on the wire too:
a deployment that never turned it on sees no new claim), under
login.mode: persona (identity selection checks no password, so pwd
would be a claim about a check that never happened - metadata never lies),
and on the OAuth password grant, which #348 leaves untouched. It is
preserved across a refresh (carried in the refresh token payload, the same
way auth_time is) and is never emitted on an access token, only the ID
Token. amr is a reserved claim name: a claims request parameter (see
below) cannot set it.
If additional_audiences produces more than one distinct audience value,
aud becomes an array and azp is added:
{
"iss": "http://localhost:8000",
"sub": "admin",
"aud": ["multi-aud-client", "https://api.example.com", "urn:service:billing"],
"azp": "multi-aud-client",
"iat": 1704100000,
"exp": 1704103600
}
Where do the email / profile claims come from?
A common surprise: you request scope=openid email, then look for an
email claim inside the ID Token or access token and don't find it.
That is expected. The standard OpenID Connect profile/email claims
(email, email_verified, preferred_username; plus NanoIDP-specific and
custom claims where configured) are not embedded in the tokens. For the
authorization code flow they are served from the UserInfo endpoint,
using the access token (OpenID Connect Core 1.0 §5.4). The discovery
document advertises what is available - scopes_supported (configurable,
default openid, profile, email, offline_access - see "Registered
scopes" in the configuration reference) and
claims_supported - but those scope-based claims are returned from
GET /userinfo, not from the tokens themselves.
curl 'http://localhost:8000/userinfo' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
{
"sub": "admin",
"email": "admin@example.org",
"email_verified": true,
"preferred_username": "admin",
"roles": ["USER", "ADMIN"],
"groups": ["ADMINISTRATORS", "EVERYONE"],
"tenant": "default",
"identity_class": "INTERNAL"
}
Scope gating
Under the default dev profile, /userinfo returns these claims
unconditionally. Under the stricter-dev and oauth21 profiles the
standard OIDC claims are gated by the granted scope (OIDC Core §5.4):
email / email_verified require the email scope and
preferred_username requires the profile scope. The granted scope is
carried on the access token as the scope claim (RFC 9068 §2.2.3).
NanoIDP-specific claims (roles, groups, tenant, identity_class,
attributes) have no standard scope and are always returned. entitlements
and source_acl are deliberately outside that list; which facts reach which
surface, and why, is in What each surface says about a
user.
This is a distinct mechanism from per-client scope enforcement (see the
configuration reference), issue #186: scope gating decides which claims an
already-granted scope unlocks at /userinfo; enforcement decides whether a
client may be granted that scope at all, at /authorize and /token. A
scope that fails enforcement is rejected outright (invalid_scope) long
before this gating would ever see it.
Requesting claims in the ID Token (claims parameter)
If your client specifically needs a claim inside the ID Token, use the
OpenID Connect claims request parameter at /authorize (OIDC Core §5.5)
- the standards-aligned way to ask for it. Discovery advertises
claims_parameter_supported: true.
GET /authorize?response_type=code&client_id=demo-client
&redirect_uri=http://localhost:3000/callback&scope=openid
&claims={"id_token":{"email":null,"email_verified":null}}
(URL-encode the claims value in a real request.) After the code
exchange, the ID Token then carries the requested claims:
{
"iss": "http://localhost:8000",
"sub": "admin",
"aud": "demo-client",
"iat": 1704100000,
"exp": 1704103600,
"email": "admin@example.org",
"email_verified": true
}
The userinfo member (e.g. {"userinfo":{"email":null}}) works the same
way for /userinfo and composes with the scope gating above, so a client
can pull a specific claim even under a stricter profile that would
otherwise gate it out. Claims are resolved from the user and added only
when available (voluntary form, §5.5.1); protocol claims are never
overwritten and unknown names are skipped. The claims parameter is
accepted on the authorization code grant, carried through the
refresh_token grant (below), and mirrored by the MCP generate_token
tool (id_token_claims / userinfo_claims).
The requested claim names are persisted in the refresh token (like the
granted scope and auth_time), so a refreshed ID Token keeps carrying
the requested claims and /userinfo keeps honouring the userinfo
member for the refreshed access token (OIDC Core §12.2). The nonce is
the deliberate exception: it binds the original authentication request
and is never re-issued on refresh. Note that a claims request binds to
the original authorization and is orthogonal to scope (§5.5), so it is
not shed by narrowing the scope on refresh: under a stricter profile,
a claim requested via the userinfo member keeps being returned by
/userinfo even after the client drops the scope that would otherwise
gate it. To shed a claims request, start a new authorization.
All tokens are signed with RS256; verify them against the JWKS at
/.well-known/jwks.json (see Endpoints).
What each surface says about a user
nanoidp asserts facts about a principal over two protocols and several surfaces, and they do not all say the same thing. Until #316 each surface documented only itself, so a difference between them existed only as a consequence of two independently written assemblers, and nothing said which differences were intended.
This page is that contract, and tests/test_user_fact_contract.py holds
it: each row below has a test that fails when the difference it states
appears or disappears. The one thing the tests do not re-derive is the
matrix itself, so a cell can still be written down wrongly - which is how
the roles/groups columns for the attribute query were wrong when this
page was first written, until a review read them against the resolver.
The matrix
| Fact | Access token | ID Token | /userinfo (dev) | /userinfo (gated) | SAML SSO | SAML attribute query |
|---|---|---|---|---|---|---|
email | no | no | yes | needs the email scope | yes | yes |
identity_class | yes | no | yes | yes | yes | yes |
entitlements | yes | no | no | no | yes | yes |
roles | yes | no | yes | yes | opt-in, under the SP's name | opt-in, under the SP's name |
groups | yes | no | yes | yes | opt-in, under the SP's name | opt-in, under the SP's name |
tenant | yes | no | yes | yes | never | never |
source_acl | yes | no | no | no | no | yes |
attributes | a map | no | a map | a map | one attribute per key | one attribute per key |
authorities | yes | no | no | no | no | no |
The ID Token carries sub, which is itself a Claim about the End-User. It
carries none of the application and profile facts in this matrix unless the
client asks for one through the claims request
parameter.
Opt-in means saml_export_roles and saml_export_groups, which are
false by default: out of the box a SAML assertion carries neither roles
nor groups while an access token always carries both. The two settings
govern both SAML surfaces, which share one resolver, so turning an
export on adds the attribute to the login assertion and to the attribute
query alike. source_acl is the only attribute-level difference between
those two surfaces, as the SAML reference states.
Why the differences are what they are
tenant is OIDC-only. It has been on the access token since the first
commit and has never been exported over SAML. That began as an omission
rather than a decision; it is a decision now, because adding it would
change the assertions every service provider under test already receives.
authorities exists only on the access token. It is a derived
convenience for resource servers: the roles, groups, identity class,
entitlements and ACL entries of a user flattened into one prefixed list,
plus any custom attribute that has a prefix configured for its name, so
authority_prefixes: {department: DEPT_} puts DEPT_IT in the list beside
ROLE_DEV. See Configuration for the prefixes. It is
not an identity claim, and no identity surface offers it.
source_acl is on the access token and the attribute query only. Both
are read by a backend deciding what a principal may reach. A login
assertion carries no document-level ACLs, and /userinfo describes a
person rather than their reach.
entitlements is not in the default /userinfo response, because the
ungated claim list is roles, groups, tenant, identity_class and
attributes (see Tokens and claims). A client that needs it
asks for it through the claims parameter, and then gets it.
email is on no OAuth token. Clients read it from /userinfo, which
is what OpenID Connect Core §5.4 expects and what the tokens reference
tells them.
Custom attributes have two shapes. On the OIDC surfaces they arrive as
one attributes map, a passthrough of what the operator wrote. On the SAML
surfaces each key becomes its own Attribute element, because an assertion
has no nested shape to carry a map in.
An empty value, and the three answers to it (#388)
An attribute the operator declared with an empty value - "", [], {} or
nothing at all - reaches the three projections differently, and all three
are right:
| Projection | An empty value under a key | An entirely empty map |
|---|---|---|
attributes (the OIDC map) | kept | the claim is omitted |
authorities (the derived list) | dropped | nothing to derive |
| SAML attributes | dropped | no attributes |
Composite and raw representations preserve explicitly configured empty values. Derived projections may omit them when their output representation cannot preserve the distinction usefully, or when that surface has an established omission policy.
The map keeps them, once the composite is there at all. A user with no
attributes carries no attributes claim: the map is emitted only when it
has something in it. Once it is emitted, the values stored under its keys
are preserved verbatim, "", [], {} and null included, so
{"x": ""} {"x": []} {"x": {}} {"x": null}
are four different documents and stay that way. For a testing IdP that is a useful property, since an operator can deliberately simulate an upstream that supplies an empty claim and watch what the consumer does. The key existing is itself something they wrote.
authorities drops them because a flat list of strings cannot say
"present but empty". Keeping an attribute x: "" under the prefix X_
would emit the bare string X_, which reads like an ordinary authority and
loses the distinction it was meant to carry. Representing it properly would
need an encoding invented for the purpose.
SAML drops them by policy, not by limitation. SAML 2.0 Core allows
<Attribute Name="x"/>, an attribute that exists with no values, and that
is semantically distinct from the attribute being absent. nanoidp could emit
it; #315 chose otherwise, so an absent or empty fact is an absent
attribute on both SAML surfaces.
So the disagreement between authorities and attributes inside one access
token is not a contradiction. They are two projections with different
representational capacity, and there is deliberately no shared
"is this a fact?" predicate: the answer depends on the projection.
claims_supported and what the resolver addresses
These are two different lists, and the difference is deliberate.
claims_supported in the discovery document is what OpenID Connect
Discovery 1.0 §3 defines it to be: the Claim Names this provider may be
able to supply values for. It is not a list of names the claims request
parameter accepts, and OpenID Connect Core §5.5 does not require a
requested claim to be returned at all.
On top of that definition nanoidp holds its own invariant: only a claim
that can appear in an ID Token or a UserInfo response is advertised here.
That is a choice, not something the specification derives, and it is the
rule #41 applied to response_types_supported.
source_aclandauthoritiesare not advertised, since #316. They appear in neither an ID Token nor a UserInfo response, so under that invariant they do not belong, whatever an access token carries.attributesis advertised, and it is a Claim Name: Core §5.6.1 says that for Normal Claims represented as JSON, the member name is the Claim Name, and/userinforeturns anattributesmember. What it is not is resolver-addressable:resolve_user_claimdoes not know the composite map, so aclaimsrequest forattributesis not answered by the resolver.- An individual custom attribute is resolver-addressable: a user
with a
departmentattribute answers aclaimsrequest fordepartment.
One name, two answers
A user who owns a custom attribute literally called attributes shows what
those two lists mean in practice:
attributes:
attributes: "custom"
ID Token, with {"id_token": {"attributes": null}} -> "custom"
/userinfo -> {"attributes": "custom"}
The ID Token gets the scalar, because the resolver answers a request for
attributes from the user's own attribute map. UserInfo sets the composite
member first, and a requested claim never overwrites one already present,
so the map wins there. Both are documented rather than fixed: renaming
either would break one of the two meanings, and a user with such an
attribute is a test fixture, not a deployment.
SAML options
Bindings
NanoIDP supports both standard SAML 2.0 bindings for the SSO endpoint:
| Binding | HTTP Method | SAMLRequest Encoding |
|---|---|---|
| HTTP-POST | POST | Base64 only (uncompressed) |
| HTTP-Redirect | GET | DEFLATE compressed + Base64 |
Both bindings are advertised in the SAML metadata (/saml/metadata).
Strict binding mode
By default, NanoIDP operates in lenient mode for developer convenience, accepting GET requests with uncompressed SAMLRequest data (non-compliant but useful for debugging).
To enforce strict SAML 2.0 binding compliance:
Via configuration file (settings.yaml):
saml:
strict_binding: true # Reject GET with uncompressed data
Via web UI: Settings → SAML Settings → toggle "Strict SAML Binding" → Save Settings.
| Mode | GET with uncompressed data | GET with DEFLATE | POST uncompressed |
|---|---|---|---|
| Lenient (default) | Accepted | Accepted | Accepted |
| Strict | Rejected (400) | Accepted | Accepted |
Response signing
By default, NanoIDP signs all SAML responses with an XML digital signature. You can disable signing for testing scenarios that require unsigned SAML flows:
Via configuration file (settings.yaml):
saml:
sign_responses: false # Disable SAML response signing
Via web UI: Settings → SAML Settings → toggle "Sign SAML Responses" → Save Settings.
When sign_responses: true (default), responses include:
<ds:Signature>element with RSA-SHA256 signature<ds:X509Certificate>with the IdP certificate
When sign_responses: false, responses are sent without any signature
elements.
Exporting roles and groups as attributes
Roles and groups are not included in SAML assertions by default. Enable them explicitly - and, because every SP expects a different attribute name, name them to match your SP:
Via configuration file (settings.yaml):
saml:
export_roles: false # default; true to include the roles attribute
export_groups: false # default; true to include the groups attribute
roles_attr_name: "roles" # name used when export_roles is on
groups_attr_name: "groups" # name used when export_groups is on
Via web UI: Settings → SAML Settings → toggle "Export Roles Attribute" / "Export Groups Attribute" and set the matching attribute name → Save Settings. Clearing a name field restores its default.
Each entry becomes its own AttributeValue, in both the SSO assertion and
the AttributeQuery response, so a value containing a comma stays a single
value. Configuring the same name for both exports (e.g. memberOf for roles
and groups alike) is supported: the two lists are merged into that one
attribute, roles first, deduplicated.
Any name works, including the URIs SPs commonly expect:
| SP | Typical roles attribute name |
|---|---|
| Spring Security | roles (whatever your AttributeConverter reads) |
| ADFS / Entra ID | http://schemas.microsoft.com/ws/2008/06/identity/claims/role |
| Shibboleth | urn:oid:1.3.6.1.4.1.5923.1.5.1.1 (groups / isMemberOf) |
| Generic LDAP-style | memberOf |
Both toggles apply to the SSO assertion and to the AttributeQuery endpoint,
and each entry becomes its own <AttributeValue>. A user with no roles (or
no groups) gets no attribute even when the export is on, and a custom user
attribute with the same name still takes precedence.
Note that these attributes are independent of the authority_prefixes
mapping, which only affects the authorities claim in OAuth/OIDC tokens.
Signed AuthnRequests
By default, nanoidp accepts unsigned AuthnRequests (and ignores
Signature/SigAlg query parameters). SPs that sign their requests can
turn on verification:
saml:
want_authn_requests_signed: true
sp_certificates:
- /path/to/sp-cert.pem # PEM; one entry per trusted SP
With the flag on, nanoidp requires and verifies the signature under
both bindings and rejects unsigned or invalid requests with 400:
- HTTP-Redirect: the query-string signature over the URL-encoded
SAMLRequest[&RelayState]&SigAlgfragment (SAML 2.0 Bindings §3.4.4.1);rsa-sha256,rsa-sha512and legacyrsa-sha1SigAlg values are supported. - HTTP-POST: the enveloped
<ds:Signature>inside the AuthnRequest (SAML 2.0 Core §5).
The metadata advertises WantAuthnRequestsSigned="true" if and only if
enforcement is on. A request verifies if any registered certificate
validates it.
A Redirect-binding signature exists only on the original URL, so it cannot
be re-verified when the inline login form posts the request back. nanoidp
therefore remembers, in the browser's own signed session, which Redirect
requests that browser had verified, and admits the login post only for one
of them, which is what keeps the hidden form fields from standing in for the
signature. Each request is remembered on its own (#375, where it used to be
a single slot, so a second signed request made the first non-continuable):
ten per browser, an eleventh making the oldest non-continuable, each kept
for 10 minutes of inactivity and refreshed while the login is being
continued. Nothing is shared between browsers, and the set survives a
restart or several workers as long as they share session.secret_key. The
set travels in the cookie, so two signed requests issued before either
response's cookie reaches the browser (two SP iframes on one page, a
prefetch, two tabs restored at once) still leave only the later one
continuable; requests that follow one another, which is what a person
opening two logins produces, are unaffected. The
POST binding needs none of this: its signature travels inside the
AuthnRequest and is verified again on every leg.
Every attribute query is recorded in the audit log as a
saml_attribute_query event - the successful ones, the unknown principals,
and the ones refused for their shape - with the query's own ID as
request_id and the request size, so an entry can be matched to the caller
that sent it (#309). The query body is written to the log only when
logging.verbose_logging is on, because it names a principal.
Need a test SP keypair? python e2e/gen_sp_keypair.py --out .
generates sp-key.pem/sp-cert.pem, and
e2e/test_agent.py --saml-signed exercises the whole behavior
against a running server.
XML canonicalization algorithm
By default, NanoIDP uses Exclusive C14N for XML canonicalization, which is the standard for SAML signatures and compatible with most modern SAML implementations. You can configure the algorithm based on your SP requirements:
Via configuration file (settings.yaml):
saml:
c14n_algorithm: exc_c14n # Default: Exclusive C14N 1.0 (standard for SAML)
# c14n_algorithm: c14n # C14N 1.0
# c14n_algorithm: c14n11 # C14N 1.1
Via web UI: Settings → SAML Settings → select the Canonicalization Algorithm from the dropdown → Save Settings.
| Value | Algorithm | Use Case |
|---|---|---|
exc_c14n (default) | Exclusive C14N 1.0 | Standard for SAML, handles namespace isolation |
c14n | C14N 1.0 | Legacy SAML implementations |
c14n11 | C14N 1.1 | Newer implementations |
Why Exclusive C14N is the default:
Exclusive C14N is recommended by the SAML 2.0 specification because it
only includes namespaces actually used in the signed element. This is
important when SPs extract the <Assertion> element from the <Response>
to verify the signature independently. With standard C14N, the signature
includes parent namespaces that break when the Assertion is extracted.
The two SAML surfaces: one resolver, declared differences (#302, #317)
Both the SSO assertion and the attribute-query assertion resolve a user's
attributes through the same service (services/saml_attributes.py), and
both build the identical part of the document, the Response envelope, the
Issuer pair, the Status element and the assertion's own head, through
services/saml_assertion.py (#317). So they cannot drift silently. Their
remaining differences are deliberate:
| Aspect | SSO assertion | Attribute-query assertion | Why |
|---|---|---|---|
source_acl | not exported | exported | the query surface exists for backend authorization lookups; a login assertion carries no document-level ACLs |
AuthnStatement / AuthnContextClassRef | present | absent | an attribute lookup is not an authentication event; asserting one would be false |
SubjectConfirmation | present (bearer, 5-minute window) | absent | ties an assertion to a login exchange the query never had |
AudienceRestriction | the requesting service provider: the AuthnRequest's Issuer (SAML Profiles §4.1.4.2); oauth.audience for a request that names none | absent | the query requester's audience is unknown (the endpoint is unauthenticated by design) |
Conditions validity | 5 minutes | 1 hour | a login assertion is spent immediately at an ACS; a backend lookup is not. The two windows have never been decided to be one policy, so they are stated here rather than shared behind an argument |
Response/@Destination | the ACS URL | absent | only a login assertion is delivered to an endpoint the IdP was told about |
InResponseTo | the AuthnRequest's ID when available; absent when no usable request ID is available | always | /saml/sso answers no login without an AuthnRequest; an attribute query always answers one |
ds namespace | declared on the envelope | absent | the SSO document is signed in place, and the prefix is declared whether or not signing runs |
| Serialization | bytes, with an XML declaration | unicode, pretty-printed | historical, and part of what the SP receives, so it is pinned rather than unified |
| Signing | inline in the builder | a separate _sign_attribute_query_response | the query surface signs after serializing, by reparsing its own output |
The last six rows were found by the #317 census, not declared anywhere
before it, and none of them were covered by a test. They are pinned
byte-for-byte in tests/test_saml_assertion_shapes.py.
There is a third builder on the query surface: the error Response for an
unknown principal (#275) shares the same envelope and Issuer, carries a
Requester / UnknownPrincipal status pair instead of Success, and has
no assertion at all.
How these attributes compare with what the OAuth and OIDC surfaces carry
for the same user, tenant and authorities included, is in What each
surface says about a user.
Shared rules on both surfaces: an absent or empty fact is an absent
attribute (no fabricated email, no empty Attribute elements - None and
empty list/tuple/set/dict/string alike). That last one is a policy, not a
limitation: SAML 2.0 Core allows <Attribute Name="x"/>, an attribute
that exists with no values, which is semantically distinct from the
attribute being absent. nanoidp could emit it and #315 decided not to, so
the two cases are reported alike here while the OIDC attributes map keeps
them apart (see What each surface says about a user); a list value becomes one
AttributeValue per entry and a string is never split on commas (#134);
roles/groups exports are opt-in under their configured names. When the
roles and groups exports target the SAME configured name, their lists are
merged (roles first, deduplicated); a collision between an export name and
a scalar core attribute is not merged - the export list replaces it.
MCP server
NanoIDP includes an MCP (Model Context Protocol) server for integration with Claude Code and other MCP-compatible tools. For a hands-on tour with prompts, workflows, and end-to-end examples, see MCP with Claude Code. For the admin secret, readonly mode, and the exposure warnings, see the Security guide.
Available tools
| Tool | Description |
|---|---|
list_users | List all configured users |
get_user | Get details of a specific user |
create_user | Create a new user |
create_persona_user | Create a password-less user for persona login mode (local dev/testing convenience) |
update_user | Update an existing user (password, email, roles, …) |
delete_user | Delete a user |
generate_token | Generate OAuth2 tokens for a user (pass scope with openid to also get an ID Token; id_token_claims/userinfo_claims mirror the OIDC claims request parameter; resource binds the access token aud to an RFC 8707 resource, #187; client_id (must name a real client) binds the token and issues a refresh token spendable by it - omit for an unbound access token with no refresh token, #73) |
decode_token | Decode JWT token (without verification) |
verify_token | Verify JWT token signature and expiration (pass audience to also require the token's aud to match, simulating a resource server; omit it to accept a resource-bound token and read its claims, #187) |
list_clients | List OAuth clients |
get_client | Get client details |
create_client | Create a new OAuth client |
update_client | Update an existing OAuth client |
delete_client | Delete an OAuth client |
get_settings | Get current IdP settings |
update_settings | Update IdP settings |
save_config | Persist the current configuration to the YAML files, optionally guarded by expected_users_revision / expected_settings_revision (see below) |
reload_config | Reload configuration from files (the response carries fresh users_revision / settings_revision) |
validate_config | Lint the running config directory without starting or executing anything (no hook, no plugin): {valid, findings, status}. status: unavailable means the directory could not be observed, so no validation was performed - not a verdict on the configuration, and the one case where retrying makes sense (#246) |
get_oidc_discovery | Get OIDC discovery document (same document as /.well-known/openid-configuration) |
get_jwks | Get JSON Web Key Set |
get_audit_log | Get audit log entries (filter by limit, event type, username) |
get_audit_stats | Get audit statistics |
clear_audit_log | Clear the audit log |
get_keys_info | Get signing key info (active kid, previous keys) |
rotate_keys | Rotate signing keys (old key stays valid for verification). A refusal answers success: false with a fixed error and a kind, the same as POST /api/keys/rotate: external_keys_not_rotatable, keys_directory_not_writable, lock_namespace_unavailable, lock_timeout (worth a retry) or lock_unsupported |
Tool arguments and the domain models
An argument that carries the value of a configuration field takes its shape
from that field: type, enum, bounds, length, pattern and item type come from
the model the server validates against. username and client_id carry
minLength, token_expiry_minutes its range, and token_endpoint_auth_method,
layout, login_mode and saml_c14n_algorithm their closed sets.
A field's shape is not the whole of what the server accepts: a rule that
reads more than one field, or a format a JSON Schema keyword cannot state,
stays with the model and answers from the handler - create_user still
rejects email: "nope", and a confidential client without a secret is still
refused after dispatch.
The tool keeps what is its own: the description of the argument, what the
operation requires (update_user patches a user, so only username is
required) and a wider vocabulary where it has one - an empty
client_secret, background_color, header_color or footer_color means
"none" or "clear it", and an empty get_audit_log username means "no
filter", so the handler answers those rather than the schema. An argument
that is not a field's value at all keeps its own shape: verify_token's
audience is the audience the caller wants a token tested against, not the
IdP's configured one.
One consequence for a client: an argument the schema can now reject comes
back as a dispatch refusal, {"error", "code": "MCP_INVALID_ARGUMENTS", "tool"} with isError, where it used to reach the tool. What the tool
answered varied - delete_user reported {"success": false}, get_user
reported {"found": false}, create_user returned the raw validation text,
and update_settings applied an out-of-range token_expiry_minutes and
reported success, because it writes its arguments onto a model without
validate_assignment. The two layers are the ones CONTRIBUTING describes
under "Error surfaces"; only the boundary between them moved.
The MCP server and a running nanoidp server
nanoidp-mcp (or python -m nanoidp.mcp_server) is its own process. It
loads its own copy of the configuration from NANOIDP_CONFIG_DIR and does
not talk to a nanoidp server started separately on the same directory. What
the tools change, and who sees it:
create_*,update_*,delete_*andupdate_settingschange the MCP server's in-memory copy only. The tools answering from that copy see the change immediately:list_users,get_user, andgenerate_tokenfor a user created a moment ago. Tokens are signed with the keys inkeys_dir, so a resource server that validates against the running server's JWKS accepts them.- A running nanoidp server does not see the change. A user created through
MCP cannot log in there, and the password grant answers
invalid_grant, untilsave_configwrites the YAML files and that server reloads them (POST /api/config/reload, or a restart). The server does not watch the files. Once saved, the change is declared configuration like any other entry. reload_configrebuilds the MCP server's copy from the files, dropping every change not saved yet.save_configwrites the whole copy: every pending user, client and setting at once, not only the last change.get_audit_log,get_audit_statsandclear_audit_logact on the MCP server's own audit log. The events of a running nanoidp server are in that server's/api/audit.
Conflict-checked saves
The declared configuration can have several writers at once: the web UI,
another agent, or a second nanoidp process (a Flask server and a
nanoidp-mcp companion) on the same directory. To catch a concurrent
change instead of silently overwriting it, the read tools return the
revision of the file the runtime was loaded from - list_users and
get_user carry users_revision; list_clients, get_client and
get_settings carry settings_revision - and save_config accepts them
back as expected_users_revision / expected_settings_revision. A save
whose revision no longer matches the file is refused with
{"success": false, "kind": "conflict"} before anything is written: call
reload_config, reapply the change on the fresh state, and save again
with the revisions from its response.
A save is refused the same way, with {"success": false, "kind": "unloadable"} and nothing written, when the configuration this server
holds would not load back. update_settings writes onto a model that does
not validate on assignment, so a value no tool schema can describe reaches
it: issuer is checked by a rule rather than by a bound on the field, and
update_settings(issuer="not-a-url") succeeds where save_config then
refuses. The message names the key.
save_config always writes both files, so there are exactly two modes.
Omitting both revisions keeps the save unconditional (last write wins),
same as before. Supplying either revision makes the whole save
conflict-checked: the omitted one defaults to the revision this runtime
was loaded from, so a save guarded on users.yaml cannot silently
overwrite a settings.yaml another writer changed in the meantime, or
vice versa - there is no mode where one file is guarded and the other is
overwritten from a stale snapshot.
Claude Code configuration
Add to your project's .claude/settings.json:
{
"mcpServers": {
"nanoidp": {
"command": "python",
"args": ["-m", "nanoidp.mcp_server"],
"env": {
"NANOIDP_CONFIG_DIR": "./config"
}
}
}
}
Or if NanoIDP is installed globally:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "/path/to/config"
}
}
}
}
Claude Desktop configuration
Add to ~/.claude/claude_desktop_config.json:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "/path/to/nanoidp/config"
}
}
}
}
Running standalone
# Run MCP server directly
python -m nanoidp.mcp_server
All MCP tool calls are logged to the audit log.
Vision
What nanoidp is
nanoidp is a test identity provider: a real OAuth2/OIDC and SAML 2.0
identity provider whose purpose is testing. It gives developers, and
through its MCP server AI agents, a spec-honest counterpart to integrate
against, without standing up Keycloak or wiring a cloud tenant:
pip install, two YAML files, go.
Two things it is not. It is not a mock: it does not imitate a provider's answers, it implements the protocols, keeps the state they require and refuses what they refuse, and on top of that it offers what a test needs and a production IdP has no reason to offer (identities declared in a file, disposable ones created for a single run, an audit log a test can read, profiles that switch strictness on). And it is not a production IdP: it never serves real users or protects real data. That second line, not size or feature count, is what bounds the project: it can grow as deep as testing requires.
The product is confidence: the behaviors nanoidp advertises and implements are grounded in the relevant specifications, so clients can test against them without depending on accidental or invented semantics.
Two secondary, supported uses: running local demos and prototyping a client's login experience, and serving as the identity provider of a shared development stack (a team's Docker Compose or Kubernetes dev environment on a trusted network, reachable by several developers and their agents). In both it stays a development and test tool: the users are developers and test personas, never real end users, and the instance is disposable.
Principles
These are the criteria every change is judged by. They have been applied implicitly throughout the project's history; this writes them down.
- A test tool, not a production IdP. Tradeoffs are primarily weighed by asking "would this mislead someone testing against it?", rather than "is this hardened enough to operate as a production identity provider?". Security behaviors (PKCE, rotation, client binding) are first-class precisely because clients need to test them; what nanoidp does not promise is production-grade operation. Convenience that doesn't distort spec behavior is welcome; hardening that costs convenience must be optional. The same test applies to the shared-dev-stack use: a change is in scope when it helps a team run and configure a disposable stack, and out of scope when its purpose is to protect real users or data.
- Metadata never lies. Discovery and documentation advertise exactly
what the endpoints implement: a missing feature is acceptable, a
pretended one is not (see #41:
response_type=tokenwas advertised but unimplemented, and was removed rather than implemented). - Hardening is opt-in, defaults stay permissive. Strictness lives in
security profiles (
stricter-dev) and explicit settings (require_pkce,refresh_token_rotation); the out-of-the-box experience favors getting a first token in under a minute. - MCP/HTTP parity. Every administrative and testing capability that is meaningful to agents is exposed through MCP, with shared builders and models wherever possible so equivalent surfaces cannot drift (see #40: the shared discovery builder). Protocol surfaces themselves, authorization redirects, SAML SSO, UserInfo, are exercised over HTTP, as a real client would. The same rule generalizes past MCP: a domain policy has ONE home, and routes/tools are adapters that delegate to it rather than reinterpret it - CONTRIBUTING's "Domain invariants have one home" (#285) is this principle stated as a review criterion.
- Features ship whole. A feature lands together with its MCP exposure
(where applicable), its
e2e/test_agent.pye2e coverage and its docs, in the same PR. - RFC-citable behavior. Token and protocol behaviors reference the spec paragraph that justifies them, in code comments and changelog entries alike. When a reviewer disagrees, the RFC arbitrates.
- Presentation is data, not code. Because nanoidp is also used for local demos and prototyping a login experience, some per-client presentation - the client's id and description, a logo - is in scope. But anything a user can set that ends up rendered in a page must be structured data or an operator-provided local asset, never free-form markup, CSS, or a remote URL fetched into the page. This rules out arbitrary per-client CSS (an injection surface on the auth UI) and remote logo URLs (attribute-injection plus a third-party beacon that sees every visitor), while allowing a client's id, description, or a locally-served logo file. Cosmetic customization must never become an injection or tracking vector on the authentication UI.
Non-goals
- Production or hosted use. No HA, no hardening guarantees, no real
user data. A shared dev stack is supported (above); an instance that
serves people who are not its operators is not. Opt-in management
guards (
require_ui_login,management_secret) are locks for a trusted network, not an access-control system: there are no roles, no per-user audit, no tenant isolation, and none are planned. - External configuration backends. Declared configuration remains
schema-versioned YAML you can read, edit and
git diff. Secrets and users reach nanoidp through YAML files and${VAR}placeholders rendered by the deploy (Vault Agent, External Secrets, an init container); nanoidp does not use a database, Vault or another service as a configuration source of truth, and there is no pluggable configuration backend. The hooks and plugins shipped in 2.7.0 are the way to react to configuration events from outside (mirror, notify, bootstrap): nanoidp provides the extension points, the deploy provides whatever sits behind them. - Production persistence and distributed state. Runtime protocol and test
state (authorization transactions and codes, device codes, token
revocations and refresh-token families, the audit log, runtime-created
clients and users) lives behind a runtime store: in memory by default,
ending with the process, or in local SQLite files that several nanoidp
processes on one host share, HTTP workers and a separate
nanoidp-mcpprocess alike. The SQLite store can let disposable test state survive a process restart, but carries no durability promise: it is state you can rebuild or throw away. Runtime-created users and clients stay separate from the declared configuration, which remains the files; one becomes declared only when explicitly promoted. Distributed databases, HA and multi-node state coordination are not goals. - Real identity backends. No LDAP/AD federation, no social login.
- Spec completeness for its own sake. Extensions are added when they help someone test a client, not to fill a compliance matrix.
Direction
Deliberately undated. Concrete work is tracked in GitHub issues attached to the corresponding milestones.
What has shipped
The themes this section used to announce, each tracked by the milestone named here:
- OAuth 2.1 profile:
the opt-in
oauth21profile aligns nanoidp's strictest behavior with draft OAuth 2.1: PKCE required everywhere, refresh token rotation, S256 only. - SAML hardening: optional validation of signed AuthnRequests, for testing SPs that sign their requests.
- Typing strictness
and CI quality gates:
an annotated
src/under a strict mypy baseline, and a coverage threshold enforced in CI. - 3.0 breaking cleanups: the deferred breaking changes went out with 3.0.0.
- Agentic OAuth / MCP interoperability: the auth cases of agentic systems, with MCP clients and servers as ordinary OAuth parties: per-client scopes, RFC 8707 resource indicators, public clients with mandatory PKCE, RFC 9207, RFC 8414 metadata, opt-in dynamic client registration (RFC 7591/7592) and client ID metadata documents, a mock protected MCP server as an e2e fixture, and a real agent host (n8n) tested end to end. nanoidp stays a dev/testing IdP extended to these cases, not "an IdP for AI".
- Disposable test identities: users and clients created on a running
instance over
/api/runtime, for one CI run. This is what the earlier plan of a config/state split for multi-agent use became once it was measured against a test case: no export/import and no actor taxonomy in the audit log, only runtime objects that are separate from the declared configuration, resolved by every protocol flow, removed on request and promoted into the files one at a time. - One home: principle 4 applied to the code that predated it, so that each protocol rule lives in one place and the routes, the UI and the MCP tools delegate to it.
- Shared runtime state: the protocol and test state that processes need to share lives behind one runtime-store boundary, with an optional SQLite store for processes on one host. The configuration files remain their common source of truth: freshness is established at the start of an operation, creations and promotions use a cross-process critical section, and claims left by a dead writer can be recovered. Generated signing keys are coherent across processes, and each operation reads one published configuration. The Streamable HTTP transport once planned as the third step of this milestone (#193) was closed without code: the shared store removed the architectural reason it had been scheduled, because a separate stdio MCP process can now observe the same runtime state as the HTTP server. A remote MCP transport remains a separate idea for a concrete use case.
What comes next
The shared store settled the direction that used to be written here as a chain. What follows from it is concrete and tracked:
- #432: the MCP surface catches up with the store: runtime identities created, promoted, removed and reset through MCP tools, which refuse when the store is process-local rather than operate on state nobody else can see.
- #402: stateful identity test scenarios: an identity evolves deterministically during a test in response to the application's own protocol operations, without turning nanoidp into a general request/response scripting engine.
Beyond these the project stays open to extension, and the test for an extension is principle 1 rather than a feature freeze: it belongs when it makes nanoidp a better instrument for testing a client, a server or an agent, and it does not when its purpose is to operate an identity provider for real users. Agent-to-agent identity (token exchange, DPoP, workload federation, #195) is the example of a theme that is in bounds and waiting for a concrete test case: the issue was closed without a scope and is kept as a decision record, and a use case reopens the subject with a new one.
Anything not covered here is fair game for discussion: open an issue. The principles above, not this list, are the contract.
Architecture
This page is the map: where code lives, which direction imports are allowed to flow, where state is kept, and the handful of modules you must know before changing anything. Everything stated as an invariant here is enforced by CI (import-linter, mypy, or a test), not aspired to; when this page and the code disagree, one of the two is a bug.
The shape
nanoidp is a layered Flask application with three entry points sharing one core:
__main__.py (CLI) mcp_server.py (MCP stdio) app.py (WSGI)
\______________________|______________________/
|
routes/ (HTTP surfaces)
|
services/ (protocol logic, runtime state)
|
config.py (ConfigManager)
|
serialization.py models.py config_documents.py
Two import contracts are enforced by lint-imports in CI (declared in
pyproject.toml under [tool.importlinter]):
- Layers:
routes->services->config. A route may import services and config; a service may import config; nothing imports upward. In practice:services/never imports Flask objects or route modules, so every service is testable without a request context. serialization.pyhas no runtime package imports. It is pure YAML-shaping code; its only tie to the rest of the package is type-checking-only annotations. If you make it import a package module at runtime, CI fails.
Everything under src/ is fully type-annotated
(disallow_untyped_defs in [tool.mypy]).
Package map
Entry points:
| Module | What it is |
|---|---|
app.py | create_app(): configures the process-wide Flask application (one instance per process, see CONTRIBUTING), blueprint registration, session cookie policy, startup warnings |
__main__.py | The nanoidp CLI: serve, init, wizard, validate-config, config-schema, plugins |
mcp_server/ | The nanoidp-mcp stdio server: tool declarations and handlers. It keeps no configuration of its own: the tools resolve the process's ConfigManager (see config.py) |
wizard.py | The nanoidp wizard interactive configuration builder |
HTTP surfaces (routes/), one blueprint per protocol surface:
| Module | What it is |
|---|---|
routes/oauth.py | OAuth2/OIDC: /authorize, /token (one _grant_* helper per grant), /userinfo, discovery, JWKS, introspection, revocation, device flow |
routes/saml.py | SAML 2.0 IdP: metadata, SSO, SLO, AttributeQuery |
routes/ui.py | The config web dashboard: HTML form flows for users, clients, settings, keys, claims, audit |
routes/api.py | JSON management API (/api/*): health, config, users, keys, audit |
Shared route infrastructure, all underscore-prefixed:
| Module | What it is |
|---|---|
routes/_auth.py | The login-session and management-gate choke point: establish_login_session is the single writer of the login session (session['user'] and session['auth_method'], which the SAML AuthnContextClassRef is derived from; every surface that establishes a UI session calls it, #301), plus the require_ui_login session gate and the opt-in management_secret write guard for /api/*, the UI unlock, and MCP |
routes/_issuer.py | Effective-issuer resolution, shared by every endpoint that mints a token or advertises the issuer (#133: they must never disagree) |
routes/_audit.py | audit_event(...): shared helper for route-level audit events (not yet universal: /api/keys/rotate still logs directly) |
Protocol logic and runtime state (services/), each module small and
single-purpose:
| Module | What it is |
|---|---|
services/token.py | JWT building: access tokens, ID Tokens, the /token response body |
services/userinfo.py | What the bearer of an access token may see at /userinfo: the scope-to-claim gating, the claims nanoidp has no standard scope to gate by, the raw attributes passthrough, the claims request parameter |
services/client_policy.py | How a client's token_endpoint_auth_method and secret go together: a public client has no secret, a confidential one cannot be without it, and the order an existing client is moved through (the model validates on assignment). The rest of what is_public decides stays at the endpoints that apply it, each with its own RFC (#300) |
services/introspection.py | What an introspection reports about a token (RFC 7662 §2.2), including which client it names and the scope it defaults to |
services/identities.py | The effective identities: declared users and clients composed with runtime ones. Every login, grant and client check resolves users and clients here (identities_for(config)), with the rules in one place: declared first, no runtime object under a declared name, declared wins on reload. The observation surfaces (/api/users, the persona picker, the UI lists) show declared and runtime objects with their origin; the edit forms and MCP work on the declared configuration. The lifecycle rules (promotion order, reconciliation with its audit) live here too. A promotion's claim is a hold on the object's entry in the store, not state of this process, so a delete, a reset or a reload in another process sharing the store sees it, and whoever retires the object records the promotion. A runtime client is created and deleted by instance (create_runtime_client_entry, delete_runtime_client(id, instance_id)), which is what lets a record kept about one, such as a dynamic registration, stay about that one and never about a later client of the same id |
routes/runtime.py | /api/runtime: create, read, delete, reset and promote runtime users and clients, mapped onto the resolver's rules; no update (the store is by value). Same management gate as /api |
services/runtime_repository.py | The contract all runtime state is kept behind, and its in-memory backend: objects of any type whose codec (copy, write down, read back) the owning service declares, by name and by value, each in an entry with an instance_id the store generates and never reuses, an optional hold and an optional time at which it becomes removable (so that a cleanup and a count never read a value), and transact(decide), which runs one decision against a view of one repository so that its changes appear together or not at all. replace, consume, delete_if, delete_where, delete_expired and create_within are written once on top of it, the last two on what the store knows about its entries, with no value read. The OAuth semantics stay in the services that write the decisions; a durable backend (#354) implements transact and inherits the rest |
services/runtime_store.py | The runtime store, the one owner of the state of a running IdP that is not declared in YAML: runtime users and clients, the repositories it lends to services that own a record type of their own (authorization codes, device grants, revocations, registrations, the CIMD cache), and the audit. Two concurrency domains: the repository state shares one lock, the audit has its own |
services/audit_store.py | The audit's own contract behind that boundary (AuditStore), its event and the in-memory backend: a bounded sequence in the order of the appends, the event and its counters kept as one step, by value, and never reached from inside a repository decision |
services/sqlite_runtime_store.py | The runtime store in a pair of SQLite files, for several NanoIDP processes on one host (#354): the repositories in one, the audit in another (SqliteAuditStore, one file has one writer and the audit must not wait for the repositories). One table for every repository, its rows in the order of creation; a decision is a BEGIN IMMEDIATE transaction; WAL with synchronous=NORMAL; one connection per thread, and none across a fork (an at-fork hook waits for the operations in progress and closes them all); the file and its -wal and -shm private, and a file that is not a NanoIDP runtime store refused. Chosen by runtime.store: sqlite and runtime.path (relative to the configuration directory) in settings.yaml. Owner leases (runtime-owners/) prove a promotion's writer dead, so that its claim can be recovered (#354) |
services/auth_code.py | Authorization codes (PKCE data rides on the code): a view, with no state of its own, over a repository the runtime store lends. Redeeming a code is one decision of that repository's |
services/device_code.py | Device authorization state (RFC 8628): a view, with no state of its own, over two repositories the runtime store lends, the grants by device code and an index from the user's code that names the grant's instance. Every transition is one decision on the grant; a poll resolves the user outside it |
services/revocation.py | Revoked token ids and refresh-rotation families: a view, with no state of its own, over ONE repository the runtime store lends. Both are markers under typed names (jti:<id>, family:<id>), because the refresh grant's check-and-claim reads and writes both and has to stay one decision; how long a marker is remembered is its entry's expires_at, and None there is for ever |
services/crypto.py | Key management: generation, rotation, JWKS, external key import |
services/key_directory.py | The generated keys' directory as state several processes share: one lock across processes, a bundle loaded whole as kid.txt names it, a cold start with one winner, and a rotation as a small journaled transaction whose commit point is the replacement of kid.txt and which the next process to take the lock recovers. Bytes in, bytes out: it knows nothing of JWT or SAML |
services/discovery.py | Single source of the OIDC discovery document (HTTP and MCP both render this; metadata never lies) |
services/redirect_uri.py | Redirect-URI registration matching, including RFC 8252 native-app rules |
services/saml_verification.py | Signed-AuthnRequest verification |
services/saml_assertion.py | The parts of a SAML Response every builder spells the same way: the envelope, the Issuer pair, the Status element and the assertion's head. Deliberately not the Conditions: the validity window differs by surface and is declared in the SAML reference instead (#317) |
services/audit.py | The audit log: a facade, with no state of its own, over the runtime store's AuditStore. Which counters an event increments, the shape of the statistics, and what follows an append, outside any lock: the Python log line and the on_audit_event hooks |
services/yaml_writer.py | Writes the YAML files back for the UI's per-field saves. Not the only write path: ConfigManager.save() persists whole documents through serialization.atomic_write_yaml too - both build their entries in serialization.py, but the read-modify-write itself has two owners today (a known debt, tracked for a single write pipeline with conflict detection) |
The config layer and the pure bottom:
| Module | What it is |
|---|---|
config.py | ConfigManager: owns loading, reloading and handing out Settings/users; loads are transactional (a failed reload commits nothing). One manager per process (init_config/get_config): the routes, the MCP tools and the token service all resolve it, and none of them keeps a manager, or a signing service derived from its settings, from the moment it was first built |
config_store.py | ConfigFileStore: filesystem access to ONE configuration directory. Bytes and their revisions come back as one observation, taken under the directory lock, for readers and writers alike (#246). It owns observations, not configuration semantics: parsing, environment expansion and the document models stay outside it, and outside the lock, because the atomic unit is the directory snapshot rather than the whole reload. A read never abandons an available protocol: contention and a filesystem without advisory locking both fail. A read-only mount keeps working by reopening the lock file read-only, so it still takes part; only a view that can hold no lock file at all, or a missing directory, reads unlocked, neither having a writer to be inconsistent with Each snapshot carries the fingerprint of the file its bytes came from (a fast negative for ConfigManager.refresh_if_changed, #354); locked() with read_snapshot_within_lock is how a caller observes and acts under one acquisition, the lock being non-reentrant |
config_documents.py | Pydantic document models mirroring the YAML sections one to one; to_settings() / to_users() build the domain objects |
config_schema.py | JSON Schema generated from the document models; docs/schema/config.v1.json is the committed artifact and a test fails when they diverge |
config_validation.py | nanoidp validate-config: lint a config directory without starting anything (hooks never execute) |
hooks.py | HookRegistry: the extension points (on_before_load, on_config_saved, on_audit_event), shell hooks, entry-point plugins, the bootstrap surface |
models.py | The domain dataclass-style models: Settings, User, OAuthClient |
serialization.py | Domain objects <-> YAML dicts, both directions, no runtime package imports; OWNED_SETTINGS is the one table of managed settings.yaml keys, pinned to the models by parity tests (#214) |
exceptions.py | The exception taxonomy |
branding.py | Per-client logo resolution (local assets only, containment-checked) |
Where state lives
There are exactly two kinds of state, and they never share a store:
- Declared configuration is the two YAML files (
settings.yaml,users.yaml, plus the optionalbootstrap.yamlfor hooks/plugins). It is schema-versioned (config_version), validated through the document models, editable by hand, and meant to be committed to git. The UI's per-field saves go throughservices/yaml_writer.py; the MCPsave_configtool and reloads persist whole documents throughConfigManager.save(). Both delegate their entry-building toserialization.py, so the two paths cannot drift on content, and both write through the sameconfig_writer.compare_and_replacepipeline (#229): each write can carry the revision the caller's state was read at, and a stale one is refused instead of overwriting a concurrent change - the UI's forms submit it as a hidden field, MCP callers pass it tosave_config. Without a revision a write stays unconditional (last write wins), stated as such. - Runtime state lives behind one boundary
(
services/runtime_store.py): authorization codes, device codes, revocation and rotation families, the audit log, runtime users and clients, whatever the protocols and the management surfaces keep. Outside it, and process-local on purpose: the rate limiter's counters and the CIMD fetch budget. The login session is not among them: it is a signed cookie, which processes sharing asecret_keyall accept, and the/authorizetransaction it names lives in the store. Which store holds it is configuration:memory, the default, keeps it in the process and loses it at restart;sqlitekeeps it in files that several processes on one host share (see Two processes, one runtime state). Neither is a database of record: an instance is disposable (see Vision), no durability is promised, and the SQLite store is for test state that outlives a restart, not for keeping anything.
ConfigManager.load() is transactional: parse and validate first,
swap the live objects only on success. A failed reload leaves the
previous configuration serving. The transaction includes the signing
service (#359): a configuration becomes active only if its signing
service can be built. The load prepares that service from the candidate
settings (reusing the running one when its inputs are unchanged), then
applies the hooks and plugins, the last step that can fail, then publishes
the service, then the settings. Readers take the settings first and the
signing service second (get_crypto_service() has no arguments), so a
request can pair older settings with a newer key but never newer settings
with an older key. create_app and the MCP server pass that activation
step to init_config; config itself never imports services.
The five modules to know before changing anything
routes/_auth.py: every management mutation (UI form,/api/*, MCP tool) funnels through here. If you add a mutating endpoint and do not touch this file, ask yourself why.routes/_issuer.py: any new endpoint that mints or advertises tokens resolves the issuer here, or it will disagree with discovery behind a reverse proxy.services/discovery.py: the discovery document is built once, here. Advertise a capability by implementing it and adding it here; never edit an endpoint's advertisement separately.hooks.py: the supported way to integrate external systems. New integration points should be hooks, not backends (a VISION non-goal).config_documents.py: the YAML contract. A config key exists when it is a field here; the schema, validation, andSettingswiring all follow from it.
Adding a field to OAuthClient: the full flow
This is the most-repeated multi-site change in the project, and the place regressions have historically come from (#32: the regenerate-secret leg was missed and the field silently dropped). A new client field touches all of these:
models.py: the field onOAuthClient, with its description.config_documents.py: the field onClientEntryand its coercion into_client().serialization.pyclient_to_yaml: write it when non-empty.serialization.pymerge_client_entry: merge it on edit.templates/clients_form.html: the form control.routes/ui.pyclient create: parse it.routes/ui.pyclient edit: parse it.routes/ui.pyregenerate-secret: carry it through (the #32 leg).mcp_server.py_client_to_dict: expose it.mcp_server.pycreate_clienttool schema.mcp_server.pyupdate_clienttool schema.mcp_server.pycreate/update handlers: normalize and apply it.nanoidp config-schema --write: regenerate the committed schema (a test fails if it is stale).
Then: tests for the YAML round-trip and the persist-through-edit and
regenerate-secret paths, MCP parity coverage, and an
e2e/test_agent.py scenario, in the same PR (features ship
whole). #214 settled how this flow is protected: no code-generating
registry - the declarative surfaces (the YAML load contract, the MCP
read surface and tool schemas, the form template) are pinned to
OAuthClient.model_fields by tests/test_client_field_parity.py, the
regenerate-secret leg is safe by construction (model_copy), and the
imperative legs (form parsers, MCP handler bodies, the YAML entry
builders) stay covered by the per-feature tests this list requires.
Tests
tests/(unit and integration through the Flask test client) runs in CI on Python 3.10/3.11/3.12, with coverage uploaded to Codecov.e2e/test_agent.pyis the end-to-end agent: it drives a real server over HTTP and MCP the way an agent would. CI runs it in thehttp-e2eandmcp-e2ejobs, ande2e/shared_store_e2e.pyin theshared-store-e2ejob, which is the only one that starts two servers, over one SQLite runtime store. When a test creates state through the UI it must use the shared session (self.session), or the management gates will silently redirect it.- Test isolation from the repo's own
config/directory is enforced bytests/conftest.py(it resets the config and yaml-writer singletons); a test must never read or write the repo's config.
Docs layout
The mdBook site under book/ is canonical for guides and reference.
docs/SECURITY.md, docs/MCP_WORKFLOW.md, CHANGELOG.md,
CONTRIBUTING.md and VISION.md are the canonical files for their
content and are symlinked into book/src/; edit the file at the repo
root or under docs/, never the symlink target's copy. The site
redeploys automatically on merges touching book/ or docs/.
docs/RELEASING.md is the maintainers' release manual and stays out
of the site on purpose.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
3.4.0 - 2026-09-30
Migration notes
This release stays a minor. Its largest addition, a runtime store that
several NanoIDP processes on one host share, is opt-in (runtime.store: sqlite) and changes nothing for a configuration that does not ask for
it. What changes for an existing deployment is a set of corrections:
tokens, assertions and settings that said or did something other than
what the specifications and the documentation promised. What may need a
hand on upgrade is collected here, with the details under the entries
named.
- A client credentials token is the client's own (#445, under
Changed). It was issued for the
default_userofusers.yaml, with that user'ssub, roles, groups, tenant and attributes; nowsubis theclient_idand the token carries no user claims. Migration: a resource server that read roles from these tokens sees none now: authorise onscopeand identify the caller bysuborclient_id.default_useris deprecated (under Deprecated): a file that carries it still loads, with a warning. extraadds claims to an access token and changes nothing the grant decided (#451, under Security). A request that names a registered claim, a protocol claim, a claim the server reads back or a claim about the user is refused withinvalid_request. Migration: a test that setrolesorexpthroughextragets a400now; create a user with the roles wanted instead, and use the form parameterexp(a lifetime in minutes) for the expiry.- A SAML assertion's
Audienceis the requesting service provider (#443, under Fixed), the AuthnRequest'sIssuer, where it wasoauth.audiencefor every provider. Migration: a provider whose entity ID had been set tooauth.audienceto make the check pass can use its own again; a request that names noIssueris answered as before. - Four settings keys that were accepted and ignored now take effect,
two are reported as unknown (#441, under Added). A
settings.yamlthat carriesoauth.refresh_token_expiry_minutes,device_flow.code_expiry_seconds,device_flow.polling_intervalorcors_allowed_originsstarts applying them on upgrade, and a value the loader used to accept and drop is now validated:"soon"as a lifetime, a bound exceeded, a blank entry or a pattern such ashttp://localhost:*in the origins fail the load.logging.formatandsession.permanentare unknown keys, a warning by default and a refusal underconfig_validation: strict. Migration: runnanoidp validate-configagainst the directory before upgrading, and check that acors_allowed_originslist names the origins meant (they apply in every profile now). A revoked refresh token family is remembered for 31 days, where it was 8. - A token minted before a key rotation stays valid after it, and a
rotation has a shape (#420, under Changed). Verification is by
kidamong the active key and the previous ones kept, as the documentation always said; the private key is written with mode0600;POST /api/keys/rotateand the MCProtate_keystool refuse with a fixed message and akind,Retry-Afteronly on the retryablelock_timeout503, not onlock_unsupported. Migration: a test that expected the refresh grant or/userinfoto fail right after a rotation expects the opposite now; a client that matched on the refusal's text matches onkind. - For code that embeds nanoidp, the runtime state moved behind one
store boundary and some names went with it (under Changed):
services/runtime_identities.pyisservices/runtime_store.py,MemoryRuntimeIdentityStoreisMemoryRuntimeStoreandget_runtime_identity_store()isget_runtime_store(), with no alias;AuditLog()no longer takesmax_entries;get_auth_code_store()returns a view each time;DeviceCodeGranttakes itsdevice_codeas a required keyword;IdentityResolver.runtime_client_lifecycle()is removed andcreate_runtime_client_entry()returns the stored entry with itsinstance_id;repository(name, key_of, codec)takes the codec;services.activate_servicesis the activation both the app and the MCP server pass toinit_config.create_app()configures the process and is not a factory of independent applications, which the docstring and CONTRIBUTING now state (#407).
Added
-
Several NanoIDP processes on one host can share one runtime state (#354, with #363, #404, #405, #420 and #406 behind it):
runtime.store: sqlitewithruntime.pathinsettings.yaml, for HTTP workers and a separatenanoidp-mcpprocess alike. Memory stays the default and behaves as before. The guide Two processes, one runtime state says when a second process is worth it and what is promised; theshared-store-e2ejob starts two servers over one configuration directory and one store and asserts through real HTTP that a runtime user created at one logs in at the other, that a code issued by one is redeemed at the other and refused the second time at both, that a token revoked at one is refused by the other, that a promotion made at one is declared for the other, and that the audit of both reads as one.- What the store holds. Runtime users and clients, authorization transactions and codes, device codes, pending second factors, the client metadata cache, refresh families and revocations, dynamic registration records and the audit, all behind one runtime store boundary (#363, #404); the browser leg too, since the login session is a signed cookie every process accepts. What stays each process's own: the rate limiter's counters, the metadata fetch budget, plugin dispatch.
- Choosing it.
pathis required withsqliteand refused withmemory; a relative one is relative to the configuration directory, not to the working directory, since it is the identity of a store the processes share, and one starting with~is refused for the same reason. None of the store's files may lie in the configuration directory. The store is chosen when the process starts: a reload that asks for another store or another file is refused (422, kindactivation).GET /api/configand MCPget_settingsreportruntime.storeand the file the process opened. Audit events recorded before the activation are not carried into a SQLite store, and a warning says how many. - The files. A pair,
runtime.dbandruntime-audit.db(the audit does not wait for the repositories, and a SQLite file has one writer), plus the owners' leases underruntime-owners/, all private (0600, the directory0700); a file that is not a NanoIDP runtime store, or one of another schema version, is refused and left as it was; SQLite 3.24 or later is required. WAL withsynchronous=NORMAL: atomicity, consistency and isolation, and only a commit's survival of a power loss given up, which a disposable file never promised. One connection per thread and none across a fork, with an at-fork hook that closes every connection first, so a pre-fork server started through Python (gunicorn--preload) can share the store. Processes creating one store together all get it. A store held by another process for longer than it waits is503runtime_store_unavailablewithRetry-Afterover HTTP andMCP_RUNTIME_STORE_UNAVAILABLEwithretryable: truein MCP, the first call included. - The configuration files are the truth the processes agree on.
When the store is shared, every request (
/health,/api/healthand static files excepted) and every MCP tool call looks at the two files first: astatis the fast negative, the revision of their bytes the answer, and files that changed are reloaded before the operation reads anything. One check at a time does that; the others wait for it up to 0.5 s and use what it established if it finishes, otherwise they answer503configuration_unavailable(freshness_in_progress,Retry-After: 1). The check itself may wait for the configuration directory's lock, up to 10 s. Files that changed and do not load leave the loaded configuration in force and are said once in the log; a creation of a runtime user or client checks its name against the files under the directory lock every writer of nanoidp takes, and against files that do not load it is refused (503configuration_unloadable, orconfiguration_unavailablewithRetry-Afterwhen what failed is outside the bytes). An editor that does not take the lock is noticed at the next operation, and nothing linearizable is promised against it. - A promotion whose writer died is recovered, and only then (#405).
A promotion's claim on its runtime object is a hold in the store, seen
from any process, and names its owner, which holds an exclusive OS
lock on a lease file for as long as it lives. A peer that can take
that lock, or finds the lease gone, has proved the owner dead; nothing
else is a proof, and a claim whose owner is alive is never recovered,
however old. The recovery is audited as
runtime_identity_promotion_recoveredwith its outcome, never as a promotion. With the in-memory store claims have no owner and nothing changes. - Measured, median per call, memory to SQLite on ext4: a login 0.27 to 0.34 ms, a password grant 73 to 74 ms (the same signing work in both), creating an authorization code 15 to 66 us, redeeming it 17 to 51 us, an audit append 2.3 to 20 us; four processes appending together reach 13,000 appends a second. The stat of the two configuration files costs 3.6 us, reading and hashing them 18 us.
-
Refresh token lifetime, device code timing and CORS origins can be set (#441).
oauth.refresh_token_expiry_minutes(default 10080, 7 days, the lifetime that was hardcoded; 1 to 43200),device_flow.code_expiry_seconds(default 600; 1 to 3600) anddevice_flow.polling_interval(default 5; 1 to 60) now set what they name, andcors_allowed_originssets the CORS origins in every profile (absent: the profile decides, as before;[]allows none). Each entry is an origin,http(s)://host[:port]and nothing else, written as a browser sends it: IPv6 in the URL Standard's form, IPv4 in dotted decimal, no default port, an internationalised host in its ASCII form; an entry in another form is refused with the form to write, and a path, a query, credentials or a pattern are refused as not an origin. A declared list answers only requests that carry an Origin header.GET /api/configand MCPget_settingsreport all four, and/api/configalso reportscors_applied_origins, the origins the server applies, since CORS is set up at startup; a"*"understricter-devis logged as a warning. The settings page edits the refresh token lifetime, MCPupdate_settingsthat and the two device flow values. A shortdevice_flow.code_expiry_secondsmakesexpired_tokentestable in a second, a short refresh lifetime an expired refresh token. A save from the web UI or the MCP server writes them only while they differ from the default. Noconfig_versionbump: none of the six keys loses a meaning it had. -
Four use-case pages, each with an example that CI runs.
- Test a CLI login with the device authorization
flow:
examples/cli-device-flowhas the CLI's side of the grant (cli_login.py) and tests that run its polling while a scripted browser step approves or denies, including the rotation and reuse detection of refresh tokens. The preset's CLI is a public client, where it held a client secret it cannot keep. - Test service-to-service auth with client
credentials:
examples/microservices-client-credentialsgives each client its own scopes and API audience (RFC 8707) and carries a Spring Boot 4 resource server (inventory-api/) with tests of the calls it accepts and refuses. The preset's README requested scopes the preset did not declare, and its tokens carriedadmin's identity throughdefault_user(#445). - Test a Spring Boot SAML service provider without a real
IdP:
examples/spring-boot-samlcarries a Spring Boot 4 SP (sp/) and tests that drive its SAML login through NanoIDP without a browser. The preset's README described single logout, which NanoIDP does not implement, and attributes the assertion does not carry; writing the page found #443. - Run a real OIDC provider in CI with GitHub
Actions:
examples/ci-github-actionssets up NanoIDP inside a job, from PyPI or as a service container, with a readiness check that refuses a port another process holds, a fixture that gives each test its own runtime user, and tests that pin what deleting a user does not revoke; the workflow runs it against the checkout and against the published image.
- Test a CLI login with the device authorization
flow:
Deprecated
default_userinusers.yaml(#445). It named the user a client credentials token was issued for, and nothing reads it any more. A file that carries it loads, instrictmode too, with a warning that it can be removed;validate-configreports it asinfo, which--strictdoes not fail on;nanoidp init, the wizard and the shipped presets no longer write it, a save no longer manages it, MCPlist_usersno longer reports it, and the JSON schema marks it deprecated.
Changed
-
A client credentials token is the client's own (#445). It was issued for the
default_userofusers.yaml, as if that user had logged in:subwas the user's name and the token carried the user'sroles,authorities,tenant,identity_class,entitlements,groups,source_aclandattributes; with the shipped configuration every service's token saidsub: adminwithROLE_ADMIN, and/userinfoanswered such a token with the admin's profile. Nowsubis theclient_id(RFC 9068 §2.2) and the token carries no user claims;/userinfoanswers a token whosesubequals itsclient_idwith the subject alone, and/introspectnames nousernamefor it, and noscopewhen none was requested, even when a user has the client's name, which is reported with a warning at load and on the runtime API. -
An operation reads one configuration, the one it began with (#406). A load published what it had read as eleven separate assignments, and a request read the manager again at each use, so a load landing between two reads gave an operation a configuration that never existed: measured at
/token, where a load between the read ofdefault_userand the lookup of that user minted for a subject neither configuration named, in a token that verifies. A loaded configuration is now one frozen value, assigned once; an externally visible operation chooses one published configuration right after freshness is established (an HTTP request in the hook that establishes it, an MCP tool call at the start of the call) and carries that reference through the identity resolver, the token service, the management gate, the declared clients, the issuer and the expiry it defaults to, the SAML builders, the web UI's pages and the listings of users and clients. The signing service stays outside the value and keeps its own order (#359): a request may pair older settings with the newer service, never the reverse. What stays deliberately current says so where it is: whether the client-metadata and dynamic-registration capabilities are offered at all, and the check that refuses a runtime name the files declare right now. A contract changes with it: a lookup, or a listing, that spans a load which declares a name and reconciles its runtime object away could once find one of the two, because both sides were read live; the declared side is now the operation's, with no fallback to the current declaration, since "neither has it" does not establish that a reconciliation happened. Such an operation answers that the name is unknown, or leaves it out of the listing; the operation after it reads the new declaration. Continuity across a reconciliation is tracked in #435. -
The protocol state lives in the runtime store, behind a contract for operations that must be whole (#363, #404).
AuthCodeStore,DeviceCodeStore,RevocationStoreandAuditLogkept dictionaries and locks of their own; each is now a view, with no state, over repositories the runtime store lends, and the authorization transactions, the pending second factors and the client metadata cache keep their operations whole through the repository rather than through a module lock, so that they hold for whoever shares the store and not only for the threads of one process.services/runtime_repository.pystates the contract: each object is kept in an entry with aninstance_idthe store generates and never reuses, so that an object and the one created under its name afterwards can be told apart, an optional hold, and anexpires_atthat means "removable by a cleanup" and nothing else;transact(decide)runs one decision against a view of one repository, and its changes become visible together or not at all;replace,consume,delete_ifandcreate_withinare written once on top of it. The suite runs every decision twice and takes every stored value through its codec and JSON and back. Deliberate corrections on the way:- the audit reads in the order events were recorded, where it sorted
by timestamp; a negative
limitis none at all, where-1dropped the last event; audit state is by value; the bound is the backend's (audit_store.MAX_AUDIT_ENTRIES) and everyAuditLog()is a view of the one audit. The details of an event are a JSON object. - a user code that is already taken is refused and another pair is
made, where the second grant silently took the code over; when no
pair can be made,
/device_authorizationanswers503withRetry-After, as for a full store. A grant past its time still answersexpired_tokenfor as long as it is there. - an expiry that is no time is remembered for ever (a NaN was
remembered until the next sweep, a number too large for a float raised
out of
/revoke); a revocation id that is not text is refused instead of becoming the text of itself, so a"jti": 5in a logout hint revokes nothing rather than the token"5". Revoking again never shortens. - a PKCE verifier that is not ASCII is the
invalid_grantany wrong verifier gets, where it was a500; a list given asamris kept as a tuple, so that a code written down by a backend reads back equal. - a cached metadata document that is fetched again keeps its place in the listing instead of moving to the end.
- dynamic registration rests on instance identity, not on a lock: see Security (#403).
- a promotion's outcome is kept with the object, not in the process
that started it (#405): the claim is a hold on the object's entry, so
a delete, a reset and a second promotion see it from any process,
whoever retires the object records the one
runtime_identity_promoted, and a name declared by somebody else while the object is claimed makes the promotion answer409, as before. - a cleanup of the in-memory repository costs what is due, not what is kept (#417): a lazy heap of the expiries, so that a write at ten thousand entries with one coming due costs 130 to 150 us where it cost 460 to 540, and a revocation or a refresh claim at ten thousand markers 0.055 ms.
- the audit reads in the order events were recorded, where it sorted
by timestamp; a negative
-
Generated signing keys are one bundle for every process that shares the keys directory, and a token is verified against the key its
kidnames (#420). Measured before: two processes cold-starting on one emptyjwt.keys_dirended with different signing keys, twelve times out of twelve; a process killed in the middle of a rotation leftkid.txtnaming one key over the private key of another; after one process rotated, it and a running peer refused each other's tokens for as long as the peer ran; and in a single process a token minted before a rotation was refused after it,verify_jwtdecoding against the active key whatever the token named, while the documentation said "old key stays valid for verification". Now a lock across processes covers a cold start and a rotation; a rotation is a small journaled transaction in the directory, withkid.txtas the commit point and a recovery for what a dead process left in.rotation/; the keys of a service are one value, replaced whole; verification is bykidamong the active key and the previous ones kept, no wider thanjwt.max_previous_keysand so the same set the JWKS shows, akidthat is nobody's being an invalid token and a token with nokidchecked against the active key, as before; external keys verify with their one key whateverkida token names. A peer's rotation is noticed inget_crypto_service(), through astatofkid.txt(about two microseconds when nothing changed) and a load of the bundle under the directory's lock only when the marker names another key. A keys directory this process cannot write still boots, and a rotation there is refused. The private key is written with mode0600(it followed the umask). The layout and the file names are unchanged. -
POST /api/keys/rotateand the MCProtate_keystool refuse with a fixed message and akind; the web UI's Regenerate keys shows the same fixed message:external_keys_not_rotatable(409, as before, the kind is new);keys_directory_not_writable, orlock_namespace_unavailablewhen the lock file cannot even exist, for a keys directory this process cannot write (409);lock_timeoutorlock_unsupportedfor a lock that could not be taken (503). The body used to carry the exception's text, which names the keys directory; that now goes to the server log.Retry-Afteron the503is sent forlock_timeoutonly, which a retry may help (CodeQL alerts 32 and 33). -
The
503s for a configuration or a store that cannot be used right now say so in fixed words.configuration_unavailable,configuration_unloadableandruntime_store_unavailablecarried the exception's text inerror_description: the configuration directory, the file that does not load with the reason, or the store's failure. The text now goes to the server log, where it already was, and the body carries a fixed sentence per error.error, status codes andRetry-Afterare unchanged. -
A process that finds the configuration directory's lock taken tries again after 1 ms, then 2, 4, up to 50 ms between tries (#426, point 3). It waited a fixed 50 ms after every miss, so a request that met a 7 ms write, or a write that met a 0.1 ms freshness read, paid the poll and not the section: measured on one Linux host, the p99 latency of a token request under two file writes a second was 82 ms against 31 ms at rest, and 157 ms under twenty; with the doubling pause, 45 ms and 59 ms, the p99 at rest unchanged. The "Waiting for the write lock" warning is logged once the pauses have reached 50 ms, not on the first miss. The 10 s deadline, the 0.5 s wait of the other requests and the error shapes are unchanged; the keys directory's lock is the same primitive. The measurements, and the policies decided against on their basis (a shorter reader timeout, a cooldown after a timeout, a backoff shared between processes), are recorded in #426.
-
The
react-spa-pkcepreset declares a real public client.spa-clientcarried a placeholder secret from before public clients existed (#188); it is nowtoken_endpoint_auth_method: "none"with its redirect URIs registered, so PKCES256is required and/authorizematches the redirect URI exactly. The access token'saudisspa-api, the API the SPA calls. Its walkthrough is a new page of the book, Test an SPA login with Authorization Code and PKCE.
Fixed
- A token response is never stored by a cache (#462). No response of
/tokencarriedCache-Control: no-storeorPragma: no-cache, which RFC 6749 §5.1 requires on every response carrying tokens, credentials or other sensitive information (the OpenID conformance suite'soidcc-refresh-tokenfailed on it). Every response returned by the views of/token,/device_authorization,/userinfo,/introspectandPOST /api/users/{username}/tokennow carries both, success and error alike (a 405, a 429 or a 503 the endpoint did not build keeps its own headers); the two registration responses that carry credentials and the second-factor screens, which already saidno-store, sayPragma: no-cachetoo. The discovery documents and the JWKS stay cacheable. - Discovery publishes
subject_types_supported(#464). The member is REQUIRED by OpenID Connect Discovery 1.0 section 3 and was the only one of the seven missing; the value is["public"], the one kind of subject NanoIDP issues (subis the username, or theclient_idfor client credentials). The MCPget_oidc_discoverytool serves the same document. - A SAML assertion's
Audienceis the requesting service provider (#443). Every SSO assertion carriedoauth.audience, whichever service provider asked, where the Web Browser SSO profile requires the provider's own identifier (SAML Profiles §4.1.4.2); a provider that checks the audience, Spring Security among them, rejected the response unless its entity ID happened to equal that setting. TheAudienceis now the AuthnRequest'sIssuer; a request that names none, outside the profile, is answered as before withoauth.audience. The audit event records the Issuer received and the Audience issued. stricter-devCORS is localhost, and nothing that starts like it (#441 review). The profile's default origins werehttp://localhost:*andhttp://127.0.0.1:*, which flask-cors reads as regular expressions and matches from the start only:http://localhost.evil.test,http://127.0.0.1.nip.ioandhttp://127a0b0c1.testwere allowed too. The patterns are now anchored at both ends, with the dots escaped.- The device verification page says "denied" when the user denies.
After Deny,
/deviceshowed "Device authorization denied" inside the success box, followed by the authorization's text. The route now passes the decision itself, and the page shows each with its own heading, icon and text. The polling answer was always right (access_denied); only the page was wrong.
Security
extracan no longer change what the grant decided (#451). Theextraparameter of/tokenwas merged into the access token after the registered claims on every grant except client credentials: a client could mint itself a token with anothersub,iss,aud, anexpin 2100, ajtiof its choosing, otherrolesortenant, and/introspectreported it active for that subject. The MCP toolgenerate_token, unbound, could add aclient_idno binding decided. Nowextraadds claims and nothing else: a request that names a registered claim, a protocol claim (client_idincluded), a claim the server reads back or a claim about the user is refused withinvalid_requestand the names, before the grant runs, so no code or refresh token is consumed; the token service strips the same set for any caller that reaches it directly.extraandexpare documented in the tokens reference.- An access token can no longer be spent as a refresh token (found in
#445's review). The refresh grant recognised a refresh token by its
token_typeclaim, whichextracould set on any access token: a client could stamptoken_type: refreshon its own access token and spend it for a new access token and a refresh token; with the client credentials grant of 3.3.0 and earlier that turned a service's token into the default user's, admin in the shipped configuration. The refresh grant now also requirestoken_use: refresh, which the server sets last on every token it issues andextracannot change. - A registration credential no longer reads or deletes a client recreated
under its id (#403). A dynamically registered client is two records,
the runtime client and the RFC 7592 credential that manages it, linked
by name, and a runtime client created under the same id at the wrong
moment was taken for the registered one:
GET /register/<id>with the first client's token answered with the second client'sclient_secret, orDELETE /register/<id>removed it. The record now carries the identity the runtime store gave its client (#404), which no later client of that id has, so a record about a client that is gone matches nothing;POST /registerlooks back at its client once the record is there and refuses, removing what it created, if a reset or a delete got in between (400, please retry) or a concurrent registration took the last slot (429). It needs a second actor reusing a server-generated id, so it was unlikely; it was reproduced deterministically for every window. No endpoint changes shape.
3.3.0 - 2026-09-19
Migration notes
This release stays a minor. The new protocol surfaces (/api/runtime,
dynamic client registration, client ID metadata documents) are additive, and
the last two are off until the file enables them. What changes for an
existing deployment is a set of corrections: answers that were wrong, silent
or unclassified are now refusals with a name. What may need a hand on
upgrade is collected here, with the details under the entries named.
- Rotate client secrets that a reader could see (details under
Security). Up to this release the clients page showed the first 8 and
the last 4 characters of every client secret, which is all of a secret
of 12 characters or fewer. Migration: on an instance whose web UI was
reachable by someone who was not meant to act as its clients (with
require_ui_loginon, that is anyone holding a user account), rotate the confidential clients' secrets after upgrading. An instance on loopback used by its operator alone needs nothing. jwt.external_keysandjwt.max_previous_keysnow take effect (#358). Both were documented and silently ignored. Asettings.yamlthat already carries them starts signing with the operator's key on upgrade: thekidand the JWKS change, tokens signed by the previously generated key stop verifying,POST /api/keys/rotateanswers409, and key files that are missing, malformed or not a pair reject the configuration. Migration: if the file names external keys, check that they are the ones you mean to sign with before upgrading, or remove the block to keep the generated keys.saml.c14n_algorithmis a closed set (#297). An unknown value used to sign silently with Exclusive C14N; it now fails the configuration load. Blank still means the default. Migration: runnanoidp validate-configagainst the directory before upgrading.- A rejected reload answers a JSON
422(#359) where it used to answer Flask's HTML 500, and a configuration whose signing service cannot be built is no longer activated with a 200. A script that reloads and checks only for 200 keeps working; one that matched on 500 needs the new status. At startup the same conditions exit 1 witherror: configuration rejected: ...instead of a traceback. validate-confighas a third exit code (#246): 2,UNAVAILABLE, when the directory could not be observed at all. 0 and 1 mean what they meant. A CI gate that fails on any non-zero status is unaffected; one that tests for exactly 1 should decide what a run that validated nothing means to it. A request that cannot observe the configuration answers503with the classified reason.claims_supportedno longer listssource_aclandauthorities(#316). Neither was ever supplied in an ID Token or by/userinfo; they are authorization facts of the access token, which is unchanged.- MCP arguments are checked against the domain models before dispatch
(#297). A value the model refuses, such as an empty name or
token_expiry_minutes: 0, is now a dispatch refusal (MCP_INVALID_ARGUMENTS);update_settingsused to apply some of them and report success. POST /authorizewithouttransaction_id(#346) still works while exactly one request is pending in that cookie jar. With several pending it is refused, where it used to complete whichever GET came last. Migration: a script that opens several authorization requests in one cookie jar posts thetransaction_idthe login page carries. Browser flows are unaffected.- For code embedding nanoidp as a library (#230, #235, #359):
TokenService()needs the manager,TokenService(config);nanoidp.mcp_server._configno longer exists;get_crypto_service()takes no argument andinit_crypto_serviceis replaced byactivate_crypto_service, passed asinit_config(..., activate=...);ConfigManager.authenticate,interactive_authenticateandcheck_clientmoved toIdentityResolver(identities_for(config)); a failed load raisesnanoidp.config.ConfigurationRejected, aValueError. These are internal Python entry points that no guide or reference documents, which is why they do not make the release a major.
Added
- Disposable runtime users and clients:
/api/runtime(#192). A CI job or an integration test creates users and clients on a running IdP, uses them in every protocol flow, and removes them without touchingusers.yamlorsettings.yaml:POST /api/runtime/usersand/api/runtime/clients(the body is a declared entry, validated by the same models),GETandDELETEper object,DELETE /api/runtimefor all of them (answers the counts), andPOST .../promoteto write one into the declared file through the web UI's writer and retire the runtime copy. There is no update. A name the configuration declares answers409; a reload that declares a runtime object's name removes it with a warning and an audit event; a promotion records exactly oneruntime_identity_promotedevent, holds reloads off while it runs, and a promotion whose entry reached the file but whose reload failed resolves on the next successful load (promoted, or abandoned with a warning). Runtime objects survive reloads, not restarts.GET /api/users,GET /api/users/{username}, the token endpoint, the persona picker and the web UI's users and clients pages now show the effective identities, runtime ones marked with their origin and read-only in the UI; the dashboard counts them separately;GET /api/configand the MCP server stay on the declared configuration. Writes follow themanagement_secretgate of/api. See the new guide, "Disposable test identities". - Dynamic client registration (#190), RFC 7591 with the read and delete
of RFC 7592, behind
oauth.dynamic_registration.enabled(off by default).POST /registerissues a client from the metadata a host sends and is open when enabled: the flag is the gate, notmanagement_secret, because a client that was handed only a server URL has nothing else to present. A registered client is a runtime client (#235): in memory, listed byGET /api/runtime/clientswith"source": "dcr", gone on restart, and written tosettings.yamlonly if an operator promotes it through/api/runtime- registering never writes the operator's file. The registration access token is shown once and stored only as a hash; promotion, deletion or a reload that declares the name ends RFC 7592 management of that client.oauth.dynamic_registration.max_clients(default 100) bounds live registrations and answers429 registration_limit_reached, a nanoidp name, since RFC 7591's error codes describe metadata.grant_typesare validated and echoed but do not restrict the client: nanoidp has no per-client grant enforcement, which is also whyredirect_urisare required for every registration and not only for the authorization code grant. Withrate_limit_enabled, the rate configured for/tokenapplies to/registeras well. The flag is deliberately absent from the settings form and from the MCPupdate_settingstool. - RFC 8414 authorization server metadata (#190).
/.well-known/oauth-authorization-serverserves the same document as/.well-known/openid-configuration, from the same builder and the same issuer resolution, soissuer_from_requestapplies to both and the two cannot drift. nanoidp is one server advertising one set of endpoints; a client that speaks only OAuth looks under this name and used to get a 404 and a longer route to the same answer.registration_endpointis advertised only while dynamic registration is enabled, so the metadata never promises an endpoint that answers 404. - A client can come from a metadata document it publishes (#196, first
part):
IdentityResolverresolves a third origin,cimd, after the two it already knew. Precedence is declared, then runtime, then a cached metadata document, and the first two are answered without the cache being consulted at all: anhttpsclient_id does not by itself make a client a CIMD one. The rules about what a client identifier URL is, and what a document must say to become a client, live inservices/client_metadata.pyas pure functions, with the cache as a repository the runtime store lends them. A document authenticates withnoneand nothing else, since the draft forbids every shared-secret method and those are two of the three nanoidp supports.oauth.client_id_metadata_documents.enabledis off by default and set in the file only, likedynamic_registration. Only successes are cached, at most 100 documents at a time with the oldest fetch evicted at the cap and expired entries swept on every write: the entries will come from client-chosen URLs on an unauthenticated endpoint, so a lifetime per entry is not a bound. There is deliberately no negative cache, which the draft forbids. The resolver reads the cache and never fills it, which is what keeps network I/O out of/tokenand the other fifteen places that resolve a client; the fetch and the one place that calls it are the next two entries. For code embedding nanoidp: the origin types areUserOriginandClientOrigin, because only a client can have this third one. - The metadata document is fetched (#196, second part), by the one
outbound request nanoidp makes.
oauth.client_id_metadata_documentsgainsallowed_hosts(exact DNS names, empty by default, so nothing is fetched until an operator names a host) andallow_loopback(the draft's development exception: loopback only, only when this server is itself on loopback, only for the family it is bound to; private, link-local and unique-local addresses stay refused). Built on the standard library rather than an HTTP client, so the rules are the shape rather than flags: the connection goes to an address this code resolved and checked while the hostname is kept for TLS andHost, which is what makes it not a DNS time-of-check-to-time-of-use; a name is refused unless every address it answers with is acceptable, so one that offers a public address and a loopback one cannot be raced; redirects cannot be followed; the body is bounded at 5 KiB while it is read, so a missing or dishonestContent-Lengthchanges nothing; and there is exactly one request, with no retry and no second address. The 5 second budget covers the whole fetch, not each operation, so a server sending a few bytes at a time cannot hold a worker: the timeout is recomputed from one deadline before every HTTP read, the name is resolved under the same deadline through a process-wide resolver pool, and the TLS handshake takes what is left of it as its own whole-handshake timeout, which is whatsslapplies it as. An address is judged by what it reaches, so an IPv4 address carried inside an IPv6 one (::ffff:169.254.169.254, NAT64) is read as the address it translates to, and the ranges CVE-2024-4032 affects are named in the code rather than left toipaddress: nanoidp supports Python 3.10, where older patch releases call several special-purpose ranges globally reachable, and raising the floor would not settle it either. Onlymax-ageis read fromCache-Control;no-storeandno-cacheanswer that the document is valid and must not be cached, rather than discarding it: what a caller can do with a document it may not keep is the caller's decision, and with the cache the only place a CIMD client exists between/authorizeand/token,/authorizerefuses such an authorization request rather than issue a code for a client/tokencould not resolve. - A client ID metadata document is fetched at
/authorize(#196, last part). Anhttpsclient_idthat no declared and no runtime client holds is looked up: the document is fetched, validated, cached, and the client resolves from then on withorigin: cimd.client_id_metadata_document_supportedis advertised while the feature is on. Only/authorizefetches:/tokenand every other surface read the cache, so a document that is not cached is an unknown client there, which is what keeps a token request from waiting on somebody else's web server. A document whose response says it must not be cached is refused at the authorization request rather than turned into a code no token request could redeem. Every refusal answers the sameinvalid_client/Unknown client_id, with the reason in the server log only: which rule refused it is not something to tell whoever chose the URL. The client list shows cached clients read-only with a Forget button, which is how a developer re-fetches a document they have just changed. Anallowed_hostsentry names a host and a port (443 when none is given), so opting a host in does not authorise every port on it. This process makes at most 30 metadata fetches a minute, all callers together: the limit is on the fetch rather than on/authorize, because that is where the cost is and the endpoint is where people log in. The audit records that a document was refused and not why, sinceGET /api/auditis readable by anyone who can reach it and the reason is exactly what the uniform error withholds. An authorization code keeps the cached client it was issued for resolvable until the code expires, so neither a shortmax-agenor the cache's own capacity can leave a valid code with no client behind it: such an entry is not evicted to make room, and when every entry is holding up a live code the new authorization request is refused rather than an existing flow broken. The client is held before the code is minted, so a code is never handed over for a client that is already gone; when it cannot be held the authorization request comes back astemporarily_unavailable. An operator's Forget still drops the entry and invalidates such a code, which is the point of it. A client identified this way gets no refresh token and cannot use the device grant: both outlive the authorization code that is the only thing the cache promises to keep an entry for, and a credential this server can invalidate long before its expiry is worse than one it never issued./device_authorizationanswers such a client_id exactly as it answers a name nobody knows.
Changed
-
/authorizekeeps each request as a server-side authorization transaction (#346). An accepted GET validates the request once and stores it, bound to the browser, instead of copying ten parameters into the session; the login page's forms name it withtransaction_id, and issuing the code consumes it. The TOTP code screen no longer sends the verified password back to the browser: the transaction records it, and the code is checked against the user's current secret. A POST withouttransaction_id(a script posting credentials after its GET) still works while exactly one request is pending in that cookie jar; with several it is now refused instead of completing whichever GET came last. A POST carrying the whole request in its query string keeps working with or without a GET before it, and still leaves the browser's pending requests alone. "Change username" is a form on the transaction rather than a link carrying the request. A request opened before a configuration change keeps the client it was validated against; only a client that no longer exists ends it. Pending requests are capped at 1000 and expire after 10 minutes; at the cap a new request getstemporarily_unavailablerather than any pending one being dropped. -
The TOTP code screen of
/login,/saml/ssoand/deviceno longer sends the verified password back to the browser (#373). The password check is recorded on the server as a pending second factor, bound to the browser, to the surface and to what the login is for there (the SAML request in flight, the deviceuser_code), valid for 5 minutes and used once, and the code screen carries only its id. The code is checked against the user's current secret; a user deleted or left without a secret, TOTP switched off or persona mode switched on in between ends the login with an error instead. "Change username" is a form post that discards it, and on/devicea deny from the code screen discards it too and still needs no credentials; theuser_codeis read-only on that screen. A post carrying the password and the code together works as before, with nothing stored. At most 1000 such logins wait at once. -
Users and clients resolve through one identity resolver (#235), which is what the runtime identities of
/api/runtime(#192, under Added) stand on. Every login, grant, client authentication and SAML lookup resolves users and clients throughnanoidp.services.identities, which composes the declared configuration with an in-memory runtime identity store, declared first; a reload that declares a name a runtime object holds removes the runtime one with a warning. Which management surfaces show the effective identities and which stay on the declared configuration is in the/api/runtimeentry. For code embedding nanoidp:ConfigManager.authenticate,interactive_authenticateandcheck_clientmoved toIdentityResolver(identities_for(config));ConfigManager.get_userandget_clientstay, and return declared objects only;init_configgainsafter_load, whichcreate_appuses for the reconciliation. -
One
ConfigManagerper process (#230). The MCP server no longer keeps a configuration global of its own next tonanoidp.config's; its tools, the HTTP routes and the token service resolve the same manager, andTokenServicetakes that manager explicitly instead of looking it up and caching it. No change to the MCP tools or to any HTTP surface. For code embedding nanoidp:nanoidp.mcp_server._configno longer exists (usenanoidp.config.init_config), andTokenService()now needs the manager,TokenService(config);get_token_service()is unchanged. -
A rejected reload answers a JSON
422(#359):{"status": "error", "error": ..., "kind": "invalid" | "activation"}fromPOST /api/config/reload, and an error result with the samekindfrom MCPreload_config, for files that cannot be read or do not validate as well as for a signing configuration that cannot be used. Such a file used to answer Flask's HTML 500. A strict hook or plugin failure keeps its503. At startup the same conditions printerror: configuration rejected: ...and exit 1 instead of a traceback. For code embedding nanoidp: a failed load raisesnanoidp.config.ConfigurationRejected(aValueError, with the original error as its cause);get_crypto_service()takes no argument and returns the service the configuration published;init_crypto_serviceis replaced by the activation stepactivate_crypto_service, passed asinit_config(..., activate=...). -
A configuration directory is read as one observation, not several (#246, first part).
settings.yamlandusers.yamlwere opened by two separate unlocked reads, so a save landing between them, from another process or another thread, was observed as a settings/users pair that never existed on disk, and the runtime was built from it. Reads now go throughConfigFileStore, which acquires the directory under the same lock the writer takes and returns each file's bytes together with the revision of exactly those bytes, so a precondition can no longer describe content nobody read. Only the acquisition is inside the lock: parsing, environment expansion, the document models and the profile hardening all run afterwards, because the atomic unit is the filesystem snapshot rather than the whole reload. The revision a form stamps into the page, which the save hands back as its precondition, is observed through the same boundary. A read never abandons an available protocol: contention fails, and so does a filesystem without advisory locking, because both mean the protocol is there and this process could not join it. A read-only configuration mount keeps working, which is a supported deployment here: the lock file is reopened read-only, so such a mount still takes part in the protocol rather than stepping outside it, and only a view that can hold no lock file at all, or a directory that does not exist, reads unlocked - neither has a writer to be inconsistent with. The whole acquisition shares one deadline, so waiting for another process is bounded and never leaves this one holding a lock indefinitely. Since a read can now fail, a request that cannot observe the configuration answers 503 with the classified reason instead of a 500 and a traceback. -
validate-configobserves the directory once (#246, second part). A run readsettings.yamlthree times - once for the findings, once for the strictness and once for the report header - and each other file on its own, all unlocked. So a run overlapping a save could pair the pre-savesettings.yamlwith the post-saveusers.yamland report a cross-fileconfig_versiondisagreement for a state that never existed on disk: a false failure in the tool used to gate a deploy. The whole run now derives from oneConfigFileStoresnapshot, and a directory that cannot be observed consistently is reported as an ERROR finding rather than quietly read anyway. The bootstrap read joins the same boundary, which matters more than its once-at-startup frequency suggests, sinceNANOIDP_BOOTSTRAP_HOOKexists precisely so something else can render that file. Startup now takes one observation for its whole pre-load phase - the strictness the hook registry runs under and the file it is built from - instead of two. No configuration read path opens a file on its own any more.validate-configgains a third outcome. A run that could not observe the directory at all, because a save held the lock past the timeout, isUNAVAILABLEwith exit code 2, notinvalidwith exit 1: no validation failed, no validation took place, and a CI gate that cannot tell those apart is the defect this work set out to remove rather than to relocate. The MCP tool carries the same distinction asstatus: unavailablebeside its existingvalid. A file the run could not READ stays an ordinary error finding attributed to that file, exactly as before. Nothing is retried automatically: the command says precisely what happened and the caller decides. -
claims_supportedno longer advertisessource_aclandauthorities(#316). OpenID Connect Discovery 1.0 §3 defines that field as the Claim Names a provider may be able to supply values for; on top of it nanoidp holds its own invariant, that only a claim which can appear in an ID Token or a UserInfo response is advertised. Those two appear in neither: they are authorization facts a resource server reads off an access token, and the claim resolver does not know them, so a client that read the document and asked for one got nothing back. The document now keeps the rule #41 set for it, and a test checks the rule rather than the two names.attributesstays advertised: it is a Claim Name and/userinfosupplies it as a composite member, even though the resolver does not address it, so aclaimsrequest for it is answered only when the user owns a custom attribute by that name. What every surface asserts about a user is now written down in the new reference page rather than being a consequence of two independently written assemblers. -
MCP tool arguments take their shape from the domain models (#297). An argument that carries a configuration field's value now derives its type, enum, bounds, length, pattern and item type from that field; the tool keeps its own description, its
requiredlist and the wider vocabulary it defines (an emptyclient_secretor branding colour still means "none"/"clear it", an emptyget_audit_logusername still means "no filter", and the handler answers those). The schemas gained 16 constraints the models already enforced and nobody advertised:minLengthon every name and secret that has no wider tool vocabulary, and the range oftoken_expiry_minutes. For a client this moves those rejections to a dispatch refusal (MCP_INVALID_ARGUMENTS), the two layers the MCP error model already describes. -
update_settingsno longer applies a value the model would refuse (#297). It writes its arguments ontoSettingswithsetattr, andSettingshas novalidate_assignment, so its tool schema was the only check between an argument and the running configuration - and that schema carried two enums and no bounds.update_settingswithtoken_expiry_minutes: 0or99999was applied and reported as a success; both are now refused before dispatch. -
saml.c14n_algorithmis a closed set (#297), in the models and in the configuration document. An unknown value used to reachroutes/saml.py, which silently signed with Exclusive C14N, so a typo changed the algorithm without a word; it now fails the configuration load. Blank keeps meaning "the default", so an unset${VAR}placeholder loads exactly as before, and the settings writer checks the value before it replaces the file. (login.modewas already a closed set on theSettingsmodel and only moves into the document type here.) -
Every
/saml/attribute-queryoutcome is attributable to its sender (#309). A query refused for its shape - not well-formed, noAttributeQuery, noSubject, noNameID- used to write no audit entry at all, and the query's ownIDwas read only after those three were found, so a refused request could not be matched to whoever sent it. Each outcome now writes asaml_attribute_queryentry carryingrequest_idandcontent_length, and the id is read as early as the body allows, including from a query posted without the SOAP envelope. The body itself is logged only underverbose_logging, since it names a principal, and the recorded id is truncated, since it comes from an unauthenticated caller and is kept in the audit ring - a shortening of the evidence only: what the protocol sends back inInResponseTois the id exactly as it arrived. Auditing refusals also means that reaching this endpoint is a way to push older audit entries out, which the endpoint reference now says. This is diagnosis for a flake that has not been reproduced, not a fix for it. -
SAML XML is parsed through a parser built per call (#378), not one shared by every request thread. Sharing one was never a correctness problem - an
lxmlparser owns a lock and holds it for each parse - but that lock serialized every SAML parse in the process: measured on 24000 parses of a 5 KB document, the shared parser took 3.0 s on one thread and 2.3 s on eight, while a parser per call took 3.2 s on one and 0.64 s on eight. The cost sits at the small end, about a microsecond per parse of a 300-byte AuthnRequest, against requests that take milliseconds. It also removes the shared object that twice stood as an alternative explanation for a surprising parse while #309 was being diagnosed. The parser options are unchanged and spelled out literally where the parser is built, which is what both a reader and a static analyser go by; they are now pinned by tests, where one of them used to be a comment block asserting nothing. -
The three SAML Response builders share the part of the document that is the same in all three (#317). The
Responseenvelope, theIssuerpair, theStatuselement and the assertion's head were written three times over, in the SSO login assertion, the attribute-query assertion and the error Response for an unknown principal, so a change to the NameID policy had to be made in each. They come fromservices/saml_assertion.pynow. What is NOT shared is what actually differs, and the census found six differences that were declared nowhere and covered by no test: theConditionswindow (five minutes against one hour),Destination, whetherInResponseTois conditional, thedsnamespace, the serialization and the signing path. They are documented in the SAML reference and pinned byte-for-byte, deliberately not unified: the two validity windows in particular have never been decided to be one policy, so folding them behind attlargument would have hidden a difference this work exists to declare. The bytes of all three documents are unchanged. -
A client's authentication method and secret are normalized in one place (#300). "
nonedrops the secret" was written four times - the UI create and edit forms, MCPcreate_clientandupdate_client- with the reasoning repeated at each site, and the order an existing client must be moved through (the model validates on assignment) was a comment in the one place that hit it. Both live inservices/client_policy.pynow. The difference between an omitted secret, which keeps the stored one, and an empty one, which a confidential client refuses, is unchanged, and the UI form's "blank means unchanged" stays in the route, where the convention belongs. The other consequences of a client being public - PKCE at/authorize, the refusal at/introspect, the ownership check at/revoke, the channel rule at/device_authorization, forced refresh rotation - deliberately stay where they are applied: they are different rules sharing a predicate, not one policy written ten times. -
What
/userinforeturns and what/introspectreports are services (#303), not response dicts assembled inside the routes. Which claims a token's bearer may see, and what an introspection says about a token, are protocol policy and are now testable without an HTTP request; the routes keep the adapter's work - the Bearer token, client authentication, the JWT checks, revocation, the user lookup and the audit entries. Behaviour is unchanged, including the two rules that read on key presence rather than truthiness: a token naming a null client reports null rather than the caller, and a token whose scope is empty is reported with an empty scope rather than the default. Which profiles gate the standard claims is now a property ofSettingsnext to the other profile-derived predicates. -
The settings keys written only when they differ from their default are table rows (#319).
security_profile,login.mode,login.auto_login,login.two_stepandlogin.totpwere hand-coded below the loop that drives every other key fromOWNED_SETTINGS, so the parity test that holds a setting to every surface did not cover them; a newlogin.*key had to be threaded by hand through six places, which is the shape of bug that table exists to prevent. They are rows with anomit_when_defaultmode now, one rule serves both a top-level key and a key in a section that disappears with its last entry, and the writer's four one-line helpers and its positional four-tuple of defaults are one helper and one mapping. The fallback defaultsserialization.pykeeps (it must not import the document models) are now pinned to those rows and to the real defaults. No behaviour changes: what a save writes, omits and leaves untouched is unchanged, and the writer keeps its API, including "blank mode means unchanged" and "an absent checkbox means unchanged".
Fixed
- Tokens are signed with the key the JWKS serves after a reload changes
jwt.keys_dir(#230). The token service kept the signing key it was built with, so afterPOST /api/config/reloadhad movedkeys_dir, every grant at/tokenandPOST /api/users/<username>/tokenkept signing with the old key while the JWKS, introspection and/userinfoalready used the new one: tokens issued after the reload failed verification until a restart. - A reload no longer activates a configuration whose signing service
cannot be built (#359).
POST /api/config/reloadaccepted ajwt.keys_dirthe process could not create and answered 200, after which the JWKS answered 500 and/tokenfailed (before #357 it kept signing with a key the JWKS no longer served). The load now prepares the signing service from the candidate settings before it commits anything: a configuration that cannot build it is rejected and the running one stays in effect, onPOST /api/config/reload, on MCPreload_configand on the refresh that follows a UI write. An unrelated reload reuses the running signing service instead of rebuilding it, and loads now run one at a time, so two concurrent reloads can no longer generate keys into a newkeys_dirover each other. jwt.external_keysandjwt.max_previous_keysare read fromsettings.yaml(#358). Both were documented in the security guide, but the loader ignored them as unknown keys, so an operator's own signing key was silently replaced by a generated one and the retention stayed at 2. They are now part of the settings document and the JSON schema, and they are signing inputs of #359's activation: the configured key signs tokens and is the only key the JWKS serves, the MCP server signs with it too, and a reload that changes either setting reinitialises the signing service.private_keyandpublic_keyare given together; a missing, unreadable or malformed key file, or a public key that does not belong to the private key, rejects the configuration. Withoutkid, the key id is the RFC 7638 thumbprint of the public key (the docs promised a fingerprint; a random id was generated on every start). Rotation is refused for external keys (409fromPOST /api/keys/rotate, an error on the keys page and from MCProtate_keys) instead of replacing the operator's key with a generated one. The key files are not watched: a key replaced at the same paths is read at the next start. The SAML certificate for an external key lives in its own file (external-cert-<thumbprint>.pem, stable across starts), so switching back to generated keys no longer leaves SAML signing with a certificate for the wrong key; a certificate that does not belong to the signing key is regenerated, SAML signing uses the published service's certificate instead of re-reading the file per request, and a loweredmax_previous_keystrims the JWKS as soon as it is applied.- A write that would leave a file unloadable is refused, not written
(#366). Every writer replaced the file and reloaded afterwards, so a
document the models refuse reached disk first and was discovered second,
leaving a
settings.yamlthe next process could not start from. The composed document is now parsed, exactly as a load parses it, before any file is replaced: the error names the file and the key and nothing is written. Reachable from the settings form (a blankoauth.audienceoroauth.issuer) and from the MCP tools, whereupdate_settingswrites onto a model withoutvalidate_assignmentandsave_configpersisted the result, so anissuerrefused by a validator rather than by a field constraint could be saved and then fail to load back.${VAR}placeholders are expanded into a copy for the check, so what is written keeps them. The check is of the document only, never of activation.nanoidp initand the setup wizard go through it too: they validated the document model but not the domain rules, so an issuer likelocalhost:8000finished the wizard and left a directory the server it had just configured could not start from. Both files are now checked before either is written, so a refused answer leaves no half-configured directory behind. The check follows the loader's order,config_versionincluded: that rule lives on the raw mapping rather than in the document models, so without it a candidate could pass every model and be refused by the very next load, and a batch holding both files also has to see them declare the same version. - Two signed Redirect AuthnRequests in one browser no longer interfere
(#375). With
saml.want_authn_requests_signed, a verifiedGET /saml/ssowas remembered in a single session key, so a second signed request overwrote the first and its login post was then refused as not matching a verified request. The browser's session now holds the set of requests it had verified: ten of them, an eleventh making the oldest non-continuable, each kept for 10 minutes of inactivity and refreshed while the login is continued, and remembered only when a login actually has to be continued. Two signed requests issued before either response's cookie reaches the browser still leave only the later one continuable, since the set travels in that cookie. What the login post must present is unchanged, and an expired verification now says so in the audit and the log instead of reporting the signature as invalid. - Switching a client to public through the UI edit form drops a secret
typed in the same submission (#300). The form only dropped it when the
field was left blank, so an operator who picked
token_endpoint_auth_method: nonewhile a secret sat in the input persisted a dead, ignored value - the state the create form has refused to write since #254. The two forms now apply the same rule. - A refused field on the users or clients edit form is answered, not
reported as a server failure (#298). An email without
@, a colour that is not a hex triplet or any other value the model refuses reached the catch-all on the edit routes: the operator was told "Failed to update user: 1 validation error for User ..." and the server logged a stack trace at ERROR for what is ordinary form input. The create routes had always answered the same input with the refusal itself, and both legs now build the record through one reader, so both answer it the same way. Nothing is written in either case. - A password field holding only spaces means "unchanged" on the users edit
form, as it already did on create (#386). The create leg stripped the
field before deciding it was blank and the edit leg did not, so
" "was no password on one and a real new password on the other: an operator who left stray spaces in the field, from a paste, an autofill or the spacebar, silently replaced that account's password with whitespace, with nothing flashed and nothing logged, and the account stopped authenticating with the password they believed it had. Both legs now share one notion of "the field was left blank". Stripping decides only that: a password that is not blank is stored exactly as typed, leading and trailing spaces included, since those may be deliberate.
Security
- The clients page no longer shows any part of a client secret. The
secret column rendered the first 8 and the last 4 characters of each
confidential client's secret. For a secret of 12 characters or fewer
that is the whole secret, the one
nanoidp initwrites included, and for a longer one it is 12 characters of it. The page is a read, andsession.management_secretgates mutations only: on an instance with the management gate on andrequire_ui_loginoff, a caller who could change nothing could read a client's secret off the page and authenticate as that client at/token,/introspectand/revoke. The column now shows one fixed mask for every confidential client, declared, created through/api/runtimeor dynamically registered, and the mask does not vary with the secret's length. The preview is in every release; the boundary it crossed exists sincemanagement_secretshipped in 2.8.0, so 2.8.0 and later are affected.require_ui_loginnarrows who could read the page and nothing more: it is a login, not a role, so anyusers.yamlaccount could, and underlogin_mode: personaanyone. A sweep now verifies that no management read available without additional client-specific authorization exposes stored client-secret material: it fetches every GET the application routes, without proof of the management secret and without any client's credentials, following redirects, and searches each response for every stored client secret. An RFC 7592 read (GET /register/<client_id>) does return that client's own secret, to the holder of its registration access token; that is the client's channel, not a management read. (The built-in/testpage prints the literaldemo-client/demo-secretpair ofnanoidp init, which is a public default and not a stored value; the Security guide now says so.) The guide gains "What a read can see". After upgrading: see Migration notes.
3.2.0 - 2026-09-15
Added
- Declarative TOTP second factor (#348, opt-in, off by default):
login.totp: truerequires a time-based one-time code after a successful password check, across every interactive login surface -/authorize,/login,/saml/ssoand the device flow's/device- the same surfaceslogin.two_stepcovers, riding its phase machinery. A declarative demo factor, not IdP hardening: the secret is a plaintotp_secretfield of the user entry inusers.yaml(its presence is the enrolment, no per-userenabledflag,${VAR}allowed like any value), never accepted or returned by the users form or any MCP user tool. Verification is RFC 6238 (6 digits, 30s period, HMAC-SHA1, one step of clock skew), standard library only, no new dependency, and deliberately has no replay protection. Inert underlogin.mode: persona, which checks no password; the OAuth password grant and client credentials are unaffected. Whilelogin.totpis on, the ID Token carries an OIDCamrclaim (RFC 8176):["pwd"]for a plain password login,["pwd", "otp"]once the code is verified, preserved across a refresh likeauth_time; with it off no new claim appears. A third SAMLAuthnContextClassRefvalue,urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken, next to the existing password/unspecified pair. Configurable through YAML, the Settings UI, and the MCP settings tools. Two side effects on existing configurations:totp_secretis now a reserved top-level user key (before, an unknown key of that name was folded into the user'sattributes), andamrjoined the reserved claim names, so a user attribute namedamrrequested through theclaimsparameter is no longer resolved, whetherlogin.totpis on or off (#110).
Fixed
- Pydantic configuration validation no longer echoes rejected secret
values (#352). The loader raises its own message from pydantic's
validation error, and that error rendered the offending input as
input_value=..., so the value reached every place that prints the chained traceback: the startup output, the server log onPOST /api/config/reloadand the MCP server's log onreload_config. Affected: aclient_secret,session.secret_keyorsession.management_secretthat YAML reads as a number, amanagement_secretrejected for a non-ASCII character (that one also appeared innanoidp validate-configand in both MCP tool results), and the settings of every plugin when oneplugins:entry is malformed, insettings.yamlorbootstrap.yaml. The three document roots and theSettingsandOAuthClientmodels now hide the input (UserEntrykeeps its own flag as defense in depth); the error still names the file, the field and the rule. #350 had closed the same gap forusers.yaml. The scope is pydantic's rendering of validation errors, not a general redaction of every exception that may carry configuration data.
3.1.0 - 2026-09-14
Migration notes
Nothing below changes a supported API. This release stays a minor: the
/authorize changes correct behaviour the protocol never allowed, and the
non-root image is a deployment-compatibility change. What needs a hand on
upgrade is collected here.
- The container image runs as a non-root user (#332): uid 1000, gid 0,
with
/app/keysand/app/configowned by that user and group-writable by group 0, so the image also runs where the platform assigns an arbitrary uid with gid 0. Every Kubernetes namespace enforcing therestrictedPod Security Standard rejected the previous image onrunAsNonRoot; the Helm chart's defaultsecurityContextnow setsrunAsNonRoot: trueand is admitted there. It pins norunAsUser(#339): the standard does not need one, and a platform that assigns its own uid per namespace (OpenShift'srestricted-v2) would reject a pinned1000while the image runs fine under the assigned uid. A plaindocker runwith nothing mounted is unaffected. Migration: a keys volume or bind mount first created by an earlier release is root-owned. The new image still starts on it, because the existing keys are readable, but the next key rotation fails withPermission denied: 'keys/previous'. Chown it once:docker run --rm -v nanoidp-keys:/k alpine chown -R 1000:0 /k(orchown -R 1000:0on the host directory of a bind mount). A read-only config mount needs nothing. To run an older image tag with the chart, setsecurityContext.runAsNonRoot=falseandsecurityContext.runAsUser=0. /authorizecontract corrections (#325, #328, #331, details under Fixed): the POST leg reads the OAuth parameters from its own query string, never from the form body; a request carrying any OAuth parameter is a complete request and no longer borrows missing fields from the session; a rejected request no longer replaces the pending one. Migration: a client that packed the OAuth parameters into a singlePOST /authorizebody with no preceding GET must send them on the query string instead. Browser flows are unaffected.
Added
- Two-step login (#322/#323, opt-in, off by default):
login.two_step: truecollects the username first and the password on a second screen, across every interactive login surface -/authorize,/login,/saml/ssoand the device flow's/device. A global setting alongsidelogin.mode/login.auto_login, not a per-client one; inert underlogin.mode: persona, which is passwordless and has no password screen to split off. The step is stateless: the username travels as a plain form field, carried forward as a hidden input on the password screen - a request that already carries both authenticates directly, so anything written against the combined form keeps working unchanged. Configurable through YAML, the Settings UI, and the MCP settings tools. - Helm chart (#327/#330) under
charts/nanoidp, published tooci://ghcr.io/cdelmonte-zg/charts/nanoidpon every release tag by the newPublish Helm chartworkflow, with the chart version taken from the tag. Single replica by design, configuration as wholeusers.yaml/settings.yamlfiles in a generated Secret (or one you manage), Ingress with${INGRESS_URL}for the issuer, achecksum/configannotation that rolls the pod on a configuration change, and a defaultsecurityContextadmitted by a namespace enforcing therestrictedPod Security Standard (with the non-root image, see Migration notes). The chart README states the limitations that hold by design: read-only config mount, signing keys regenerated on every pod restart.helm installwithout--versionresolves the newest final release; pre-releases need--develor an explicit version. - n8n end to end, path 1 (#194):
examples/agentic-stack/docker-compose.ymlruns nanoidp, the RFC 9728 mock MCP server and a pinned n8n (2.38.7) on one network, ande2e/n8n_e2e.pydrives it headless: n8n's own OAuth2 flow (PKCE, public client, RFC 8707resource) against nanoidp, then a workflow whose MCP Client node callsread_documentwith the token n8n obtained, plus the insufficient-scope and wrong-audience negatives. A manual and nightly workflow (n8n E2E) runs it. No change to nanoidp was needed; n8n's dynamic client registration mode stays out of scope until #190. - n8n end to end, path 2 (#194): the same stack now also runs n8n's AI
Agent with the MCP Client Tool node inside its tool-calling loop. The
agent's model is
e2e/mock_chat_model.py, an OpenAI-compatible fixture that always asks for the MCP tool and then quotes the tool's answer, so the run needs no LLM.e2e/n8n_e2e.pyasserts that the agent offers the model the tools the resource server lists, callsread_documentwith the token n8n obtained and quotes the document; that aninsufficient_scoperefusal comes back to the model as the tool's result rather than as a crash; and that a wrong-audience token stops the agent attools/list, before the model is called. The Compose file gains an optionalollamaprofile for running the same loop by hand with a real local model; CI never uses it. Still no change to nanoidp.
Changed
- Test suite hygiene (#305, #320): the shared
conftestreset now covers the three runtime stores (authorization codes, device codes, revocation) next to the service singletons, and the per-file cleanup fixtures that patched that gap are gone; the crypto leg of the singleton concurrency test uses a stub that carries whatget_crypto_servicereads since #281, and the test now asserts that every racing thread returned an instance, so it cannot go silently vacuous again. - The Helm chart is published only once its image is in the registry
(#340).
docker.ymland the chart workflow both start on the same release tag with nothing ordering them; the chart workflow now waits for the image's manifest (bounded at twenty minutes, well above the multi-arch build) beforehelm push, and fails instead of publishing a chart that runs nothing. On the chart-only dispatch path the same wait confirms the pinnedimage.tagexists. - The login session has a single writer (#301). The dashboard's
/loginand the SAML SSO inline login each used to setsession['user']andsession['auth_method']by hand, and the SAML assertion'sAuthnContextClassRefis derived from the latter. Both now call one helper,establish_login_session, which records the user and how it authenticated together. A future login surface is held to the helper by a contract test (direct forms only: a subscript store,session.updateorsession.setdefaultnaming the key, the'auth_method'literal anywhere), so recording a user without recording the method, which would have made its persona logins silently claimPasswordProtectedTransport, fails the suite rather than passing unnoticed. No behavior changes for existing logins.
Fixed
- Rejected
/authorizerequests no longer replace an earlier pending request in the same browser session (#331). The session capture now updates only after client, redirect URI, response type, scope, PKCE, and resource validation succeeds, and after persona auto-login remains inert. A later bare login submission can therefore complete the original request instead of inheriting a rejected request's client, callback, or state. - A fresh
/authorizerequest no longer inherits optional parameters left behind by an earlier, abandoned request in the same browser session (#328). A query string carrying any OAuth request parameter is now a complete request: omitted required parameters are rejected, while omittedstate,nonce, PKCE,claims,resource, andscopevalues remain omitted or use their normal defaults instead of falling back field by field to stale session values. A request carrying only unrelated query parameters still resumes the complete request captured by the preceding GET. Only a complete, accepted GET updates that capture; POST requests never do, so a failed direct POST carrying its own OAuth query string cannot rebind a later bare form submission. A valid GET in another tab still replaces the shared session capture. Contract changes: a partial request no longer borrows its missing required fields from the session, and a client that starts with a POST cannot retry it as a bare POST after failed credentials. Browser form retries are unaffected because the form resubmits to its full query-string URL. /authorizePOST leg no longer trusts OAuth params from the login form body (#325):client_id,redirect_uri,scope,state,code_challenge/code_challenge_method,nonce,claims, andresourceare now read from the query string on both legs - the POST's own (the login form has noaction, so it always submits back to the exact/authorize?...URL of the page it rendered), falling back to the session captured on the preceding GET when the query string has no OAuth request parameters of its own - and never from the POST body. Previously the POST leg fell back to the form body for these fields, so a forged hidden form field could override the request the user actually approved, breaking the binding between that approved request and the issued authorization code; a completed login or a mere page load in another browser tab sharing the same cookie jar could do the same by clearing or overwriting the session's copy out from under an in-flight tab. Binding each POST to its own page's query string closes all three. Contract change: a singlePOST /authorizethat packs the OAuth parameters into the body together with the credentials, with no preceding GET, is no longer honored - send those parameters on the query string instead (POST /authorize?client_id=...&redirect_uri=...), or do the GET first as before. The login form itself only ever legitimately carriesusername/password, and a POST already routed through the preceding GET's page (the common case) is unaffected.login_hintis no longer honored on the POST leg of/authorize(#325): the persona auto-login prefix (see "Auto-Login" below) it feeds only ever had a meaningful GET-time use, so a value posted with the login form - which the login page itself never sends - is now always ignored rather than read from the request.
3.0.0 - 2026-09-06
Breaking Changes
This release tightens and unifies nanoidp's OAuth client-authentication contract. A confidential client that already presents its registered method over the matching channel, and any public client, are unaffected; a client that authenticated over a different channel at one of these endpoints - which the previous leniency allowed - needs a one-time adjustment.
-
The registered
token_endpoint_auth_methodis now enforced at every client-authenticated endpoint (#188, #259, #262):/token,/introspect,/revokeand/device_authorization.- A confidential client must authenticate on
authorization_codetoo - the code exchange no longer has a client-authentication exemption (RFC 6749 §3.2.1). Migration: present the client secret on the code exchange, or re-register the client astoken_endpoint_auth_method: "none"if it cannot keep a secret (and use PKCE). - The registered method decides the channel: a
client_secret_basicclient must use HTTP Basic and aclient_secret_postclient must use the request body - the wrong channel is rejected withinvalid_client. Previously a body secret was accepted (or silently ignored) regardless of the registered method. Applying the one registered method across all four endpoints is nanoidp's consistency policy: RFC 7009 and RFC 8628 tie/revokeand/device_authorizationto the token-endpoint method, while RFC 7662 permits client authentication at/introspectbut does not mandate reusing that method - so a client that legitimately used a different channel for introspection stops working here by nanoidp's choice, not because it was non-compliant. Migration: send credentials over the channel the client is registered for; settoken_endpoint_auth_methodto match how your client actually authenticates. - Two authentication methods in one request are rejected (HTTP Basic and a
body
client_secrettogether, RFC 6749 §2.3) - Basic no longer silently wins. Migration: send credentials one way only. - Public-client policy:
/introspectrefuses public clients (RFC 7662),/revokekeeps its RFC 7009 §2.1 ownership relaxation, and/device_authorizationnow accepts a public client by client_id alone (RFC 8628, see the device-flow entry below).
- A confidential client must authenticate on
-
/authorizereports errors afterredirect_urivalidation by redirecting to the client (#189, RFC 6749 §4.1.2.1 / RFC 9207):unsupported_response_type,invalid_scope, PKCE errors andinvalid_targetare now302redirects carryingerror,error_description,stateandiss, not a local JSON400. Errors beforeredirect_uriis validated (unknown client, missing/malformed/unregisteredredirect_uri) still return JSON locally. Migration: a client that parsed the400body should read the error from the redirect query instead - which is what a spec-compliant client already does. -
A refresh token without a
client_idbinding claim is rejected (#73,invalid_grant, RFC 6749 §5.2). The binding was added in 2.2.0 (#56); tokens minted before it were still spendable by any authenticated client until they expired - a transitional compat deferred to the next major, now closed. Migration: discard refresh tokens minted before 2.2.0 and obtain new ones (every grant since 2.2.0 already binds them). The MCPgenerate_tokentool gains an optionalclient_id(which must name a real client) that binds the minted token and issues a refresh token spendable by it; without it the tool mints an unbound access token with no refresh token at all, rather than hand back one that could not be spent. The HTTP testing endpointPOST /api/users/<username>/tokengets the same treatment - a new optionalclient_idbinds the token, and it no longer returns arefresh_tokenwhen unbound - so all three token minters (a grant, the MCP tool, this endpoint) agree. -
One request, one client identity (#277).
/introspect,/revokeand/device_authorizationnow resolve the requesting client exactly as/tokenalways has: HTTP Basic naming client A plusclient_id=Bin the body is one request claiming two identities and is rejected withinvalid_client(previously the Basic username silently won and the body value was ignored). Migration: drop the contradictory bodyclient_id, or make it match the authenticated client. -
The
device_codegrant re-validates the stored scope at redemption (#276): a scope removed from the client'sallowed_scopes(or fromscopes_supported) between/device_authorizationand the poll now fails the poll withinvalid_scope, exactly as theauthorization_codeandrefresh_tokengrants already re-check theirs. Previously the stale scope was still minted. -
/saml/attribute-queryanswers an unknown NameID with a SAML error status (#275): top-levelRequesterwith subordinateUnknownPrincipal, no assertion. Previously an unknown user got a signed assertion with fabricated attributes (<user>@example.com,identity_class: INTERNAL,entitlements: DOCUMENT_READ) - an SP under test would pass with data nanoidp invented. The endpoint's docs now also state plainly that it is unauthenticated by design (the same read model as the REST API, #163): it previously claimed "after JWT authentication", which the code never did. -
The
expclaim is required on every token nanoidp accepts (#306).verify_jwtnow enforcesrequire: ["exp"]- a correctly signed JWT WITHOUT an expiry is rejected everywhere (/userinfo,/introspect,/revoke, the refresh grant, MCPverify_token). This is nanoidp's token-profile policy, not a JWT-spec rule (RFC 7519 leavesexpoptional; OIDC Core and RFC 9068 require it on the profiles that matter): a token accepted by an IdP should have a finite lifetime, and an eternal bearer token would let an integration test pass here and fail against any real IdP. Everything nanoidp mints has always carriedexp; only hand-crafted tokens signed with the nanoidp key are affected. Migration: add anexpto such fixtures. No other claim is newly required. -
An unverifiable refresh token now answers RFC 6749 §5.2 JSON (
invalid_grant, HTTP 400) instead of a Werkzeug 401 HTML page, per the "Error surfaces" rule (#287). -
Every
/tokenerror branch now answers RFC 6749 §5.2 JSON (#308): roughly twenty conditions across the endpoint shell and all five grant handlers used to answer Werkzeug HTML viaabort(). Now: missing or malformed parameters areinvalid_request(400); a bad, expired, revoked or foreign code/refresh-token - and invalid resource-owner credentials on the password grant - areinvalid_grant(400); an unknown or profile-disabled grant type isunsupported_grant_type(400); client-authentication failures areinvalid_client- 401 with theWWW-Authenticate: Basicchallenge when the client attempted HTTP Basic (the §5.2 MUST), 400 otherwise (§5.2's default; RFC 9110 §11.6.1 forbids a challenge-less 401, and a Basic challenge would be wrong for aclient_secret_postclient anyway). The attempt is detected from the rawAuthorizationheader (#311): a syntactically broken Basic header - which werkzeug parses to nothing - still counts as an attempted Basic and gets the 401 + challenge. Descriptions are fixed text (no library detail and no reflected caller input - the unsupported grant type's raw value lives in the audit event, not the response). Migration: branches that used to answer 401 for GRANT problems (revoked/foreign refresh token, unknown user, wrong password) now answer 400 witherror: invalid_grant- §5.2 reserves 401 for client authentication; readerrorfrom the JSON body instead of matching HTML.
Changed
mcp_serveris a package (#286): the 2,100-line module is nowmcp_server/-schemas.py(tool declarations and compiled validators),normalize.py(argument pre-validation),serializers.py,handlers_users/clients/tokens/config.py(the 25 tool handlers by domain), with dispatch, the guards, transport bootstrap and the mutable process state in__init__.py. Thenanoidp-mcpentry point and every explicitly re-exported name keep their import paths (from nanoidp.mcp_server import ...for everything tests and callers actually use;python -m nanoidp.mcp_servernow goes through the package's__main__.py) - arbitrary internal symbols of the old monolith are not a compatibility surface.verify_secretmoved to a new framework-freenanoidp.security(re-exported fromroutes/_auth.py), so the stdio MCP process no longer imports Flask at all - pinned by a subprocess test. Behavior-preserving: handler bodies and schemas are unchanged./saml/attribute-querytransport errors are SOAP 1.1 Faults (#287): a malformed query (missing AttributeQuery/Subject/NameID) or an internal failure now answers with a propersoap:Fault(HTTP 500,faultcodeClient/Server per SOAP 1.1 §6.2) instead of bare plain text with a 400 - a shape no SOAP stack could parse. Protocol-level conditions (unknown principal) keep answering inside the SAML Response as before.- The dead exception hierarchy is gone (#287):
exceptions.pydeclared 20 classes of which exactly one was ever raised; onlySAMLSignatureErrorremains (now a plainExceptionsubclass). Errors are shaped per surface- the model is written down in CONTRIBUTING ("Error surfaces"), including the deliberate two-layer MCP contract (dispatch refusals vs domain results), now documented where the shapes are defined.
TokenService.create_tokennow owns the #73 mint-side rule (#278):issue_refresh_tokendefaults to "only when the token is bound" (client_idgiven), and asking for a refresh token without a binding raises instead of minting a credential/tokenwould reject. The three minting surfaces (grants, MCPgenerate_token,POST /api/users/<username>/token) behave as before; the rule just lives in one place.- The MCP
generate_tokenandverify_tokentool descriptions now document their simulation boundary (#279):generate_tokenstampsscopeandresourceas given with no vocabulary check and no per-client ceiling (minting an out-of-ceiling token is how a resource server's rejection path gets tested), andverify_tokenchecks signature/expiry like a stateless resource server - revocation is/introspect's answer. Behavior is unchanged; both exemptions are now pinned by tests. - The end-to-end test harness moved from
examples/to a dedicatede2e/directory.test_agent.py,mock_mcp_server.py,mcp_smoke_test.pyandgen_sp_keypair.pynow live undere2e/;examples/keeps only the real usage examples (client integrations, plugins). The harness was never a usage example - it is the CI end-to-end suite - and mixing the two made the repository harder to read. Invocations change frompython examples/...topython e2e/...(CI, CONTRIBUTING and the docs are updated); no behaviour and no packaged code changed. - Resource indicators are validated per RFC 3986 component, not by a single
character whitelist (#257). A
resourceis still an absolute URI without a fragment (RFC 8707 §2), but each component is now checked against its own ABNF:[/]are accepted only inside an IP-literal host (sohttps://host/a[b]is rejected where it used to pass), a port is*DIGIT(no numeric-range limit, matching RFC 3986 §3.2.3), and IPv6 host literals are validated (a scoped[fe80::1%eth0]is rejected: RFC 3986 IPv6address has no ZoneID, per RFC 9844). Mostly a tightening on malformed input to an opt-in feature; it also stops rejecting a valid path-empty absolute URI (RFC 3986 §3, e.g.about:). No audience bypass or escalation.
Added
- Auto-login personas (#250, opt-in, off by default): with
login.mode: persona, a newlogin.auto_login: truelets an OIDC/authorizerequest log a configured user in directly - no picker, no HTML - by sendinglogin_hint: persona-auto-login:USERNAME, for driving a real OIDC client library in automated integration tests. Any otherlogin_hintis passed through unchanged, and with the flag off a prefixed hint is inert too, so the picker still shows exactly as before. An unknown persona reports through the standard OAuth error redirect (error=invalid_request,statepreserved), never a bare400. First implementation surface is OIDC/authorizeonly; the device flow and SAML have no defined transport for the hint yet. Ships with settings UI and MCP (get_settings/update_settings) exposure, an/api/configloginblock (which also picked up the pre-existinglogin_modefield it was missing since persona mode shipped), and anexamples/persona-loginwalkthrough. Like the rest of NanoIDP, a local development/testing convenience only - not an authentication mode for deployed environments. - Access-point parity contract for token issuance (#283,
tests/test_token_issuance_parity.py): the set of CALL SITES ofTokenService.create_token(file::function, AST-checked) must equal a declared registry, and every (surface, policy) pair - client binding, scope ceiling, resource ceiling - must declareenforced(behaviorally asserted) orexempt(the exemption itself pinned, with its documented reason). A new minting call site - even a second function in an already-registered module - fails the suite until it takes a stance; this mechanizes the #269/#272 bug class out of existence. - User-field parity test (#284,
tests/test_user_field_parity.py): the nine user shapes (model, YAML entry, MCP read/create/update surfaces, UI form, REST read) are held to field-set equality with documented, asserted exclusions - the guard whose absence let #280 drift silently. Found #291 - originally misfiled as "noattributesinput in the UI form" (the dynamicattr_key[]/attr_value[]widget was there all along, invisible to the single-name regex); the real defect behind it is fixed below. - "Domain invariants have one home" review rule (#285) in CONTRIBUTING,
echoed from VISION principle 4; five deferred imports claiming
nonexistent circular dependencies hoisted to module top, and the one
legitimately special case (
services/audit.py) now documents its real reason (never construct config from a log path). - Horizontal
/authorizelogin card composition (#249). New per-clientlayoutfield,"vertical"(default, unchanged) or"horizontal": the latter places the client info block and the login form side by side in a Bootstrap two-column split, with the header and footer still full width, collapsing back to the single-column stack on narrow viewports. One of exactly two nanoidp-owned layouts - no per-client CSS or column widths. Full support across settings.yaml, the UI client form, and the MCPcreate_client/update_clienttools; omitted (or"vertical") writes nothing to YAML, matching every other default-valued client field. - Public clients on the device flow (#255, RFC 8628). A public client
(
token_endpoint_auth_method: "none") can now use the device authorization grant: it presents itsclient_idalone at/device_authorization(§3.1) and again when polling/token(§3.4), with no secret. The issueddevice_codeis bound to thatclient_idand only it can redeem it, which stands in for client authentication (it presents client_id as a parameter, not via HTTP Basic or a secret, RFC 8628 §3.1; both are rejected). Confidential clients still authenticate as before; #188 shipped public clients for authorization_code + PKCE, and this completes the pair for CLI/TV/IoT-style clients. Because an unauthenticated device authorization request is cheaper to spam, the in-memory device-code store is now capacity-bounded: at the cap it returns a plain503(withRetry-After) rather than growing without bound or evicting a live authorization - not an OAuth error code, since RFC 6749 §5.2 has no registered code for server saturation. - Mock protected MCP server as an e2e fixture (#191).
e2e/mock_mcp_server.pyis a minimal MCP Streamable HTTP resource server (themcpSDK's resource-server mode) with three scope-gated tools (read_document/documents:read,delete_document/documents:write,admin_operation/admin). It validates bearer tokens JWKS-only against nanoidp (signature,iss,aud== its own resource URL,exp, scopes), serves the RFC 9728/.well-known/oauth-protected-resourcedocument naming nanoidp as its authorization server, and answers an unauthenticated call with401+WWW-Authenticatepointing at that metadata. It demonstrates two authorization layers, kept distinct: a resource-level scope floor (documents:read), enforced by the SDK's bearer middleware, which returns the conformant MCP/RFC 9728403WWW-Authenticate: Bearer error="insufficient_scope"+resource_metadatachallenge before any tool runs; and an application-level per-tool check inside each tool for the finerdocuments:write/adminoperations, which surfaces as an in-band MCP tool error.e2e/test_agent.pygains an--mcpsuite that drives the whole loop deterministically as the MCP client (401 -> RFC 9728 discovery ->/authorizewith PKCE andresource=->/token->tools/call): delegated login as a PUBLIC client (PKCE, no secret) yielding a resource-bound token accepted for a scoped tool; a wrong-audience token rejected with401at the transport (asserted at the HTTP layer); the conformant403insufficient-scope challenge; the application-level per-tool refusal; a refresh token that cannot be widened in scope on refresh (RFC 6749 §6); a client_credentials workload; a token revoked at nanoidp still accepted by the JWKS-only server untilexp(the documented consequence of self-contained tokens); and a pre-rotation token still verifying after a key rotation, with the test asserting nanoidp retains the previous key'skidin its published JWKS. Adds themcp-public-client(token_endpoint_auth_method: none) to the example config. New guide "Testing an MCP client against nanoidp". This is the deliverable that ties #186 (scopes), #187 (resource indicators) and #188 (public clients) together into a demonstrable OAuth/MCP round trip. - RFC 9207:
isson the authorization response (#189)./authorizereturnsiss=<effective issuer>on every response delivered through a validatedredirect_uri- success and error alike - so a client can detect an authorization-server mix-up (MCP 2026-07-28 recommends this). The value is the per-request effective issuer, so it stays correct underissuer_from_request(#126).issis delivered exactly when discovery advertisesauthorization_response_iss_parameter_supported: one condition drives both, so metadata and behaviour never disagree. RFC 9207 requires anhttpsissuer with a host and no query or fragment, so the defaulthttp://localhost:8000sends noissand advertisesfalse; point the issuer athttps(directly or reflected viaissuer_from_requestbehind a TLS proxy) to turn RFC 9207 on. Related behaviour change: authorization errors that occur after theredirect_uriis validated (invalid_scope, PKCE errors, invalid_target) are now OAuth error redirects to the client (error,error_description,state,iss) instead of a local JSON 400, per RFC 6749 §4.1.2.1 - completing the RFC 9207 "error responses too" requirement.unsupported_response_type(a non-coderesponse_type) is validated after theredirect_uritoo, so it redirects as well. Errors before theredirect_uriis trusted (unknown client, malformed or unregisteredredirect_uri) stay local JSON. Aredirect_urithat carries its own query keeps it: the response parameters are appended, never fold into an existing value. The device flow is unaffected. - RFC 8707 Resource Indicators:
resourcebinds the access token audience (#187). A client may send one or moreresourceparameters on/authorize,/token(every grant) and/device_authorization; the access token'saudis then those resources (a plain string for one, an array for several) instead of the globaloauth.audience, so a token minted for one MCP server is rejected by another and a wrong-audience test can finally be written. Aresourcemust be an absolute URI without a fragment or the request isinvalid_target(RFC 8707 §2). New per-clientallowed_resourcesgates which resources a client may target (empty = any valid resource, the dev default, same "empty = unrestricted" convention asallowed_scopes). The authorization code and refresh token remember the bound resources; a/tokenrequest may narrow them to a subset but never widen them. Narrowing the access token does not narrow the refresh token, which keeps the full original grant so a later refresh can still request any resource the authorization covered (RFC 8707 §2.2). Sending noresourceleavesaudatoauth.audience- no change for existing clients./introspectreports the token'saud, and now verifies a token's signature without pinning its audience (so a resource-bound token can be introspected and revoked);/userinfostill requires the OP audience. Noresource_indicators_supporteddiscovery metadata (RFC 8707 defines none). Full support across settings.yaml (allowed_resources), the UI client form and the MCPcreate_client/update_clienttools. - Public clients:
token_endpoint_auth_method: "none"with mandatory PKCE S256 (#188). A client that cannot keep a secret (CLI, desktop app, SPA, MCP client) can now be declared withtoken_endpoint_auth_method: "none":client_secretbecomes optional (ignored - and never a credential - if present), and/tokenidentifies the client byclient_idalone. The protections that stand in for client authentication are enforced regardless of profile:/authorizerequires PKCE withS256(OAuth 2.1 §7.5.1),client_credentialsis refused withunauthorized_client, and refresh tokens always rotate with reuse detection (OAuth 2.1 §4.3.1/§6.1) whateverrefresh_token_rotationsays./revokeaccepts a public client'sclient_idwith an ownership check (the token'sclient_idclaim must match; otherwise still200, nothing revoked - RFC 7009 §2.1 and its privacy guidance)./introspectdeliberately stays authenticated (RFC 7662) and its discovery list does not gainnone; the token and revocation lists do. Full support across settings.yaml, the UI client form, and the MCPcreate_client/update_clienttools (client_secretno longer required when the method isnone). At/tokenthe registered method is enforced, not just recorded (RFC 7591): aclient_secret_basicclient must present its secret over HTTP Basic and aclient_secret_postclient in the request body - the wrong channel is rejected withinvalid_client.client_secret_postis now validated at/token,/introspect,/revokeand/device_authorization; discovery had advertised it forever while the body secret was silently ignored. (The registered method is enforced at all four endpoints - see Breaking Changes, #262.) Confidential clients now authenticate on every grant, includingauthorization_code(RFC 6749 §3.2.1): the code exchange no longer had a client-authentication exemption - a confidential client doingauthorization_code+ PKCE must now present its secret or be re-registered astoken_endpoint_auth_method: none. And access tokens carry aclient_idclaim (RFC 9068 §2.2) binding them to the client they were issued to, as refresh tokens have since 2.2.0.
Fixed
- Rate limiting on
/tokenis enforced for real (#304): the limiter was constructed with no limits and no view was ever decorated, sorate_limit_enabled: truelogged "Rate limiting: enabled (10/minute on /token)" while enforcing nothing - a "metadata never lies" violation. The configuredrate_limit_token_endpointnow actually applies toPOST /token(429 with a JSON body andRetry-After/X-RateLimit-*headers; every other endpoint stays unlimited), and the rate string is VALIDATED at the config boundary: flask-limiter silently ignores a malformed one and falls back to the (empty) defaults, so an unparsablerate_limit_token_endpointnow refuses to load instead of silently recreating the enabled-but-unenforced lie. No fallback value either. The two settings are also configurable from YAML at last (server.rate_limit_enabled,server.rate_limit_token_endpoint) - the fields existed on Settings but no document section carried them, so only the profile could flip them. Behavior change forstricter-dev: that profile has always forcedrate_limit_enabled: true, so its instances now really throttle/tokenat the configured rate (default 10/minute) - the hardening the profile always claimed. - One resolver for SAML attributes; the query surface stops fabricating
and mangling values (#302). The SSO assertion and the attribute-query
assertion resolved a user's attributes through two independent
implementations that had drifted five ways; both now share
services/saml_attributes.py, and the emission of theAttributeStatementis one helper. Three visible corrections, each finishing an existing rule: the query no longer invents<user>@example.comfor a user without an email (#275 - an absent fact is an absent attribute); a custom LIST attribute reaches the XML as oneAttributeValueper entry and a comma-bearing STRING is never split (#134 - the query used to",".joinlists and re-split any string with a comma); an empty collection no longer emits an emptyAttributeelement. The differences that remain between the two surfaces (source_aclonly on the query; no AuthnStatement/SubjectConfirmation/ AudienceRestriction on the query assertion) are deliberate and now documented in a table in the SAML reference. RevocationStoreentries now expire (#288): revoked jtis and rotation family markers lived in two sets that were never swept - every revocation and every refresh rotation on a long-lived instance was a permanent memory increment. Entries now carry an expiry and the store sweeps opportunistically on the mutating paths. A VERIFIED token's ownexpis kept exactly - tokens minted via/api/users/<u>/tokenor MCPgenerate_tokentake arbitrary lifetimes, so no fixed cap is safe for them; a verified payload WITHOUT anexpclaim (whichverify_jwtaccepts) gets indefinite retention, since a token that never expires can never have its revocation forgotten; callers holding only unverified claims (the/logoutid_token_hint) pass nothing and get a bounded 8-day default; and writes are monotonic, so re-revoking a jti can only extend its retention, never shorten it.- The UI users form no longer corrupts non-string attributes on edit
(#291): a list- or mapping-valued custom attribute (settable via YAML and
MCP) rendered in the edit form as its Python repr, so an untouched edit
round-trip silently replaced
{"teams": ["alpha", "beta"]}with the string"['alpha', 'beta']". Each row now carries an explicitattr_encoding[](review round 1):stringvalues stay verbatim even when they LOOK like JSON (so the string'["a"]'survives an edit as a string),jsonrows (container values rendered as JSON) parse back, and rows typed fresh in the browser useauto- the[/{heuristic, with malformed JSON degrading to the literal string. The duplicated attribute-row parser in the create and edit routes is now one shared helper, and the user-field parity test recognizes the widget explicitly instead of excluding the field. - MCP
update_usercan now update customattributes(#280): the field was accepted bycreate_userand returned by every read surface, but theupdate_userschema and handler silently lacked it - an agent could set attributes at creation and never change them again. The new mapping replaces the wholeattributesobject, like every other field there. get_crypto_servicehonourskeys_diron every call (#281): once the singleton existed the argument was silently ignored, so a config reload that changedkeys_dirkept signing tokens and serving JWKS from the old directory. The service is now recreated when the requested directory differs; operator-provided external keys (init_crypto_service) stay authoritative and are never discarded over akeys_dirchange.- The wizard and
initwrite configuration atomically and validated (#282): both used to write raw template text withopen(), bypassing the temp-then-replace primitive every other config writer uses - a crash could leave a torn file, and a template error reached disk unvalidated. Both now validate through the document models before anything touches disk.
2.8.0 - 2026-08-29
Fixed
client_credentialsno longer returns a refresh token (#239). RFC 6749 §4.4.3: "A refresh token SHOULD NOT be included" - the client authenticates itself on every request, and the token handed out was a second, 7-day credential bound to the default user (or the synthetic service account) that the grant never authenticated, spendable atgrant_type=refresh_tokenfor user-context tokens. The response now has norefresh_tokenkey at all; every other grant is unchanged. A client that refreshed a client-credentials token must request a new one with its credentials, which is what the RFC asks of it.- /saml/sso rejects a request with nowhere to send the assertion (#227):
an AuthnRequest without
AssertionConsumerServiceURLagainst a config whosesaml.default_acs_urlis blank now gets a 400 naming both missing sources (with a failedsaml_requestaudit entry), instead of rendering an auto-submit form posting toaction=""- the IdP's own page. client_credentialsno longer 500s whendefault_usernames a missing user (#241): the syntheticservice-accountfallback was built with an empty password, whichUserrejects since #158 madepasswordoptional withmin_length=1. The fallback now carries no password, as it never authenticates with one, and the grant answers withsub=service-accountagain.- The dashboard's Logout button logs the UI session out again (#221).
The UI logout route registered the same
/logoutrule as the OIDC end-session endpoint and always lost: clicking Logout landed on the end-session confirmation page and the UI logout audit event was never written. The UI logout now lives atGET /ui/logout(the button follows automatically viaurl_for), redirects back to the dashboard, and writes itslogoutaudit event;/logout(alias/end_session) remains the OIDC endpoint, unchanged. - Regenerating a client secret no longer resets the client's branding
(#213 review):
/clients/<id>/regenerate-secretrebuilt the client with only five fields, silently droppingbackground_color,header_colorandfooter_colorand resettingshow_client_id/show_descriptionto their defaults - the same rebuild-by-hand shape that lostadditional_audiencesin #32. The route now copies the client and changes only the secret, so every field (present and future) is carried.
Added
- MCP callers can make
save_configconflict-checked (#229 phase 5, the MCP leg of the same loop the web UI's forms got in phase 4): the read tools return the revision of the file the runtime was loaded from (list_users/get_usercarryusers_revision;list_clients/get_client/get_settingscarrysettings_revision), andsave_configaccepts them back asexpected_users_revision/expected_settings_revision, refusing the save with{"success": false, "kind": "conflict"}- nothing written - when another writer (the web UI, another agent, a second nanoidp process on the same directory) moved a file since.reload_configand a successfulsave_configreturn fresh revisions, so the retry loop is reload, reapply, save. The revision is deliberately the one the runtime was LOADED from, not the file's hash at ask time: on a runtime that is stale against the directory, a fresh disk hash would pass the precondition exactly when the lost update is real. Becausesave_configalways writes both files, there are exactly two modes: omitting both revisions keeps the save unconditional (last write wins, same as before), and supplying either makes the whole save conflict-checked, with the omitted revision defaulting to the one this runtime was loaded from - a save guarded on one file can never silently overwrite the other from a stale snapshot. - Display-only
descriptionon users, shown in the persona login picker (#244): a user inusers.yamlcan carry an optionaldescription(max 200 characters, plain text) rendered next to the username on every interactive persona picker (/login,/authorize,/saml/sso, the device flow's/device), so a directory of test personas (admin,reader,tenant-a-manager, ...) is self-explanatory at the point of selection. It is a first-class field, not a custom attribute: never a claim, never a SAML attribute, never part of a token. Settable from the UI create/edit form and the MCPcreate_user/create_persona_user/update_usertools, and exposed by the read-only/api/usersresponses.Usernow validates on assignment as well (validate_assignment=True, the ruleOAuthClienthas followed since #37), and the MCPupdate_usertool applies every requested field to a scratch copy before replacing the live user, so one invalid field can no longer leave the user half-updated. Behaviour change for an existing file: adescription:key already present under a user used to fold into that user'sattributesand therefore shipped inside the token'sattributesclaim; it is now the display-only field and no longer appears in any token. - Per-client allowed scopes and
invalid_scope(#186).oauth.scopes_supportedis the global scope vocabulary (defaultopenid,profile,email,offline_access, also what discovery'sscopes_supportednow advertises instead of a hardcoded list); a client's newallowed_scopesis an optional subset of it. A requested scope outside the vocabulary isinvalid_scopefor every client - a small behavior change, since any scope string used to be accepted unchecked; a scope outside a client's ownallowed_scopes, when set, isinvalid_scopefor that client specifically. Enforced at/authorize, every/tokengrant (includingclient_credentials, RFC 6749 §4.4, which previously dropped any requested scope entirely) and/device_authorization; an omittedscopedefaults to the client's full allowed set, or today's default when unrestricted. Every/tokenrejection - including the pre-existing refresh-token scope-narrowing check - now returns the RFC 6749 §5.2 JSON error shape ({"error": "invalid_scope", ...}) instead of a bare 400.oauth.scope_enforcement: falseis a dev-only escape hatch back to the pre-#186 behavior (any scope string accepted, unchecked); refused outside thedevprofile.allowed_scopesis settable from the clients UI form and the MCPcreate_client/update_clienttools, same asadditional_audiences/redirect_uris.
Changed
ConfigManager.save()writesusers.yamlandsettings.yamlas one coordinated, conflict-checked save (#229): both files' preconditions (an optional content-hash revision per file, for a future caller that supplies one) are checked before either file is written, both are then written, both fire their ownon_config_savedhook, and the running configuration is refreshed from disk exactly once before anyhooks.strictfailure is raised - matching thewrite -> notify -> reload_local -> raisecontract the web UI's writer already had. Previously a hook failure onusers.yamlunderhooks.strictleftsettings.yamlunwritten even when nothing was actually wrong with it; now a hook failure on either file still raises, but by then both files are already saved and the runtime already reflects them - only the mirror push failed. This also closes a gap where MCP'ssave_configtool left the process holding its pre-save view of anything the save's read-modify-write cycle picked up from disk: it now sees the refreshed state too. The precondition revisions were unused when this entry was first written; MCP'ssave_confignow supplies them (see Added). The runtime refresh can itself fail (an in-memory value that bypassed field validation, sinceSettingshas novalidate_assignment, can reach the file and then fail to parse back in) -save()now tells that apart from every other outcome: a newReloadAfterSaveErrormeans both files ARE written but the runtime could not adopt them, and it never replaces or hides a pendinghooks.strictfailure, which keeps priority.save_config's MCP response carries akindfor all three failure shapes (conflict, the hook's own kind, orreload_after_save) so a caller can tell them apart without parsing the error text. The advisory cross-process lock now fails as a namedLockUnavailableError- instead of a bareOSErroror an indefinite hang - when the lock file's filesystem does not support advisory locks or a peer process holds it for more than 10 seconds.- The web UI's writer (
YamlWriter) now uses the same write primitive asConfigManager.save()(#229):save_user,delete_user,save_client,delete_clientand the rest of its write methods route throughcompare_and_replace, and each gained an optionalexpected_revisionprecondition. The practical effect: creating or deleting a user/client now checks "does this already exist / does this exist at all" against the same document it writes, under the same lock, instead of against a copy loaded before the write started. Previously, two near-simultaneous submissions creating the same new user or client could both pass their "already exists" check and the second would silently overwrite the first, with no error to either request; that lost update is now impossible - the second request correctly gets an "already exists" error instead. - Every web UI form that writes
users.yaml/settings.yamlnow carries the revision it was rendered with, and refuses a stale submission (#229 phase 4): create/edit/delete user, create/edit client, regenerate client secret, settings, and authority prefixes all send a hiddenexpected_revisionfield and get a clear "changed since it was last read - please reload and try again" flash instead of silently overwriting someone else's concurrent change (an edit based on a page loaded before another admin deleted or changed the same user/client, for instance). The settings page's OAuth, SAML, identity-classes and login-mode fields are now applied as one write instead of four separate ones, so a conflict there is all-or-nothing, the same guarantee every other form already had - an earlier version of this ran four writes chained by revision, which meant a conflict partway through could leave the earlier sections already saved while the page reported that nothing had changed. A submission with noexpected_revisionat all (an old cached page, a script, the e2e test agent) keeps today's unconditional last-write-wins - nothing about this requires an existing caller to opt in.
Security
- Opt-in
management_secretmutation gate (#163): one shared secret that gates state-changing calls across all three management surfaces - the MCP server (via the existingadmin_secrettool argument, which now reads from this setting instead of a standalone env var),/api/*(via a newX-Management-Secretrequest header on mutating calls), and the config web UI (a one-time "unlock" form at/loginthat then trusts the session for further mutating requests). Off by default - unset, nothing changes. Configurable viasettings.yaml'ssession.management_secretor theNANOIDP_MANAGEMENT_SECRETenv var; the previous MCP-onlyNANOIDP_MCP_ADMIN_SECRETstill works as an alias, though an explicitmanagement_secret: null/""insettings.yamlnow wins over either env var rather than falling through to it. Independent ofrequire_ui_login: that gate is the UI's session front door (who can view the dashboard), this is the write guard (who can change anything) - either, both, or neither can be enabled; the unlock form stays reachable even whenrequire_ui_loginis also on. YAML-only, same treatment asrequire_ui_login/secret_key. Hardened since first landing: the UI unlock flag is now an HMAC of the secret itself (not a bare session boolean), so it can't be forged just by knowingsecret_key's public default; a non-ASCII or non-string secret compares safely instead of 500ing; an unlocked UI session now also satisfies the/api/*gate, so the dashboard's own buttons (generate token, clear audit log) keep working after one unlock; and the MCP check now always reads theConfigManageractually serving the request.
2.7.0 - 2026-08-25
Changed
- Configuration files load through document models (#175, piece 2).
settings.yamlandusers.yamlare now parsed into Pydantic document models that mirror the YAML sections one to one (nanoidp.config_documents), and the domainSettings/Userobjects are built from them; the hand-written.get(key, default)mapping inconfig.pyis gone and the defaults live on the models, which the writer reads as well. No file format change and no behavioural change for files that loaded before. One visible improvement: an unknown key (a typo such asoauth.isuer, or a key nanoidp does not know) is now logged as a warning with its dotted path and ignored, instead of vanishing silently; keys that shipped presets carry but the loader never consumed (cors_allowed_origins,device_flow,logging.format,oauth.refresh_token_expiry_minutes,session.permanent) are declared so they do not warn. Fields inside a user entry keep folding intoattributes, as always. Stricter handling is a later piece.
Config schema
config_version1 introduced (#175, piece 1). No changes required to existing files: a file without the key is version 1. The version is the contract of the config directory as a whole: both files declare the same number, each is checked independently, and it must be a literal integer (checked before${VAR}expansion).
Added
- Generated config schema,
validate-configand strict validation (#175, pieces 3 and 4). Three additions to the config contract, none of which restates it a seventh time:nanoidp config-schemaprints the JSON Schema ofsettings.yaml,users.yamlandbootstrap.yaml, generated from the document models (--filefor one of them,--writeto regenerate the committed artifact from a source checkout). The artifact isdocs/schema/config.v1.json: one standalone schema per file under the keyssettings,usersandbootstrap, next to theconfig_versionthey describe, ready to point an editor's YAML-schema support at. A test fails when the committed file no longer matches the models, and parity tests fail when the MCPupdate_settingstool or the web UI's settings form grows a knob that is not a key of the contract - or offers one of the YAML-only fields (secret_key,require_ui_login,hooks,plugins).config_validation: warn|strict(top level ofsettings.yaml, defaultwarn) and the server flag--strict-configdecide what an unknown key does: log its path and keep loading, or refuse to start and refuse every later reload with the same message. The flag wins over the file for that run only and is never written back, like--profile(#172). One contract per directory:users.yamlandbootstrap.yamlfollow whatsettings.yamldeclares. Wrong types stay errors in both modes.nanoidp validate-config [--config DIR] [--strict]lints a configuration directory without starting anything: one line per finding, exit 0 when clean or with warnings only, exit 1 on errors and on warnings under--strict. It reads the three files through the same loaders the server uses and nothing else - noConfigManager, no hook dispatched, no plugin imported,bootstrap.yamlchecked for its shape only - so it is safe as a pre-commit or CI step on a directory whose hooks name commands. MCP agents get the same check as the read-onlyvalidate_configtool ({valid, findings}), which brings the MCP surface to 26 tools.
- Hooks and plugins v1 (#185): extension points for external
configuration stores, not backends. Three synchronous hooks with
HOOK_API_VERSION = 1:on_before_load(config_dir)before the files are read (startup and every reload),on_config_saved(path, kind)after an atomic write ofsettings.yamlorusers.yaml,on_audit_event(event)after an audit entry. Implement them as shell commands underhooks:insettings.yaml(placeholders{config_dir},{path},{kind},{event_type}, audit event JSON on stdin) or as Python plugins packaged separately and discovered through thenanoidp.pluginsentry-point group, configured underplugins.<name>:. Per-hook error policy:on_before_loadmay block underhooks.strict,on_config_savedis propagated to the caller understrictafter the write (the local save is always committed and the running configuration reloaded from it, so only the mirror is behind),on_audit_eventnever propagates. Commands are never reported by/api/configor MCP (they may embed expanded secrets) and a propagated error names the hook and its source only; the bootstrap surface is the baseline forstrict/timeout_seconds,settings.yamloverrides only what it declares. Bootstrap surface for hooks that must run beforesettings.yamlexists:NANOIDP_BOOTSTRAP_HOOK/--bootstrap-hook,NANOIDP_BOOTSTRAP_PLUGINwithNANOIDP_PLUGIN_<NAME>_<KEY>settings, andbootstrap.yamlin the config directory (hooks:andplugins:only).nanoidp plugins,GET /api/configand the MCPget_settingstool report what is loaded, from which surface, with failure counters and the plugins that could not be loaded (plugins_failed: a missing package or a wronghook_api_versionis reported, never fatal unlessstrict);hooks:/plugins:are YAML-only.bootstrap.yamlgoes through the same loader assettings.yaml(placeholders, unknown-key warnings). A stricton_before_loadfailure is a JSON503onPOST /api/config/reloadand an error result from the MCPreload_configtool. Audit logging never constructs the configuration and an audit event produced inside a load is not dispatched to hooks. An unchangedhooks:/plugins:declaration is not re-applied on the refresh that follows a local write, so plugins are not re-instantiated on every save. Reference pluginexamples/plugins/nanoidp-echo; guide "Extending nanoidp: hooks and plugins". - Import contracts enforced in CI (#149):
import-linternow pins the package layering (routes -> services -> config) and the invariant thatserialization.pyhas no runtime imports from the package (it is what letsconfig.pyimport it without a cycle). Both used to live only in comments;lint-importsruns next to ruff and mypy in the Tests workflow and fails when a change adds a forbidden import. config_versionfield (#175, piece 1):settings.yamlandusers.yamlaccept a top-level integerconfig_version: 1. Absent means 1, so existing files load unchanged; a value that is not a positive integer, or newer than the running release supports, is refused at startup with a message naming the file, the value and the supported version.nanoidp initand the wizard write it into the files they create; UI/MCP saves preserve an existing key and never add one.GET /api/configand the MCPget_settingstool expose the effective value; the e2e agent asserts it. Bumps only on renames, removals or semantic changes (with a loader migration), never on optional additions.- Native-app redirect URIs (#81, RFC 8252):
/authorizeaccepts private-use scheme redirect URIs such ascom.example.app:/oauth2redirect(§7.1: a scheme and a path, no authority) as absolute URIs and applies §7.1's minimum rule to them (a non-http(s)scheme without a period, such asmyapp://, is rejected with a message naming the rule; domain ownership is not verified), and a registered loopback URI (http://127.0.0.1:{port}/...,http://[::1]:{port}/...) matches any port (§7.3), since native apps bind an ephemeral port. Everything else keeps exact string matching (RFC 6749 §3.1.2.3): scheme, host, path and query of a loopback URI, every port of a non-loopback orlocalhostregistration. Fragments are now rejected explicitly (§3.1.2). One shared matcher,services/redirect_uri.py, serves both legs of/authorize; MCP tool descriptions andexamples/test_agent.pyupdated. - Persona login mode (#156): opt-in
login.mode: personalists the configured users on every interactive login surface (/login,/authorize,/saml/ssoand the device flow's verification page) and signs in by selecting one, with no password prompt - a local development/testing convenience, off by default.User.passwordis now optional: a password-less user can only authenticate via persona-mode interactive login, never via password-mode login or the OAuth password grant. Persona-authenticated sessions emit SAMLAuthnContextClassRef: unspecifiedinstead of falsely claimingPasswordProtectedTransport. New MCP toolcreate_persona_user, apersona-loginexample preset, settings-UI persistence and e2e coverage. - Per-client login page branding: optional per-client colors (background,
header, footer, all as validated hex values), show/hide client_id and
description on the
/authorizelogin page, and per-client logo images stored locally instatic/logos/keyed by client ID (no YAML config needed for logos; place the image file and it's served automatically; the directory is overridable viaoauth.logos_dir). Colours and toggles are editable from the OAuth client form in the UI and from the MCPcreate_client/update_client/get_clienttools, descriptions are already supported, and logos are deployed by the operator to the server filesystem. Designed for demos and prototyping; colours are structured (not free-form CSS) to prevent stored-XSS on the auth UI, and logos are local files only (no remote URLs) to avoid beacons.
Fixed
- The unit suite no longer rewrites the repo's
config/files. Tests that build an app without an explicit config directory used to load the committed preset through ConfigManager's./configfallback, and any save that followed rewroteconfig/settings.yamlorusers.yamlin the checkout - twice committed by accident during review.tests/conftest.pynow pointsNANOIDP_CONFIG_DIRat a fresh copy ofconfig/for every test and resets theyaml_writersingleton alongside the others. --profileoverrides settings.yaml for every value and survives reloads (#172). An explicit--profile devcould not bring a file configured withoauth21/stricter-devback todev(the flag defaulted todev, so the code could not tell "asked for dev" from "omitted"), and any CLI profile was dropped by the first configuration reload, i.e. by the first web UI or MCP save. Worse, thestricter-devruntime hardening (require_pkce,password_hashing,rate_limit_enabled, debug off) was applied once increate_app()and silently lost on that same first reload, even when the profile came fromsettings.yamlitself. The override now lives onConfigManager(--profiledefaults to none,init_config(..., profile_override=)), the effective profile and its hardening are re-derived after every settings load, and a save serializes the DECLARED state (ConfigManager.persistable_settings()), so neither the override nor the hardening it implies is ever written into the operator's file.GET /api/configand the MCPget_settingstool exposesecurity_profile,profile_overrideand the derivedeffectivevalues; the e2e agent checks they are stable across a reload.users.yamlnow expands${VAR}/${VAR:default}placeholders likesettings.yamlalways did (#175 review). Apassword: ${ALICE_PASSWORD}used to be taken literally, so the documented "secrets kept out of the file" use case only worked for settings. A UI/MCP save of one user still rewrites only that user's entry; the MCPsave_configtool rewrites the whole map and materializes expanded placeholders, as documented.- SAML
entity_id/sso_urlfollow the effective issuer (#181). Withoauth.issuer_from_requeston (or behind a proxy), OIDC discovery reflected the request host while/saml/metadata, the<Issuer>in responses and assertions and the SSO location kept the fixedhttp://localhost:8000/...strings. Both settings are now optional: absent (or blank in the UI/MCP) means derived as<effective issuer>/samland<effective issuer>/saml/ssothrough one helper shared by every SAML surface, an explicit value still wins, and a derived value is never written back tosettings.yaml. SAML 2.0 Metadata 2.3.2 requiresentityIDto be the value used as<Issuer>(Core 2.2.5)./api/configand the MCPget_settingstool report the effective values plusentity_id_derived/sso_url_derived;update_settingsgainssaml_entity_id/saml_sso_url(empty string clears); the e2e agent checks metadata against discovery and no longer posts derived values back as explicit ones. - Example presets now bind to
127.0.0.1(#164). All four pre-2.6.0 presets (cli-device-flow,microservices-client-credentials,react-spa-pkce,spring-boot-saml) still shipped an explicithost: "0.0.0.0", overriding the loopback default introduced with GHSA-2473-px8h-rvg6 for anyone who copied them. Each now ships loopback with a commented# host: "0.0.0.0"opt-in line, matching thepersona-loginpreset and the reverse-proxy guide's framing. /api/confignow exposessaml.default_acs_url(#165). The e2e agent rebuilds the settings form from that document, so the missing field was posted back blank on every run and the "present-but-blank = clear" contract (#131) silently wipeddefault_acs_urlfromsettings.yaml.
Security
- Opt-in login gate for the config web UI: new
session.require_ui_loginsetting (off by default) makes/loginactually enforce a logged-in session on the dashboard, users, clients, settings, keys, claims, audit log and token tester pages - previously/login//logoutexisted but nothing gated on them, so the login page implied protection it didn't provide. Does not affect the separate/api/*management API, which remains unauthenticated by design regardless. YAML-only for now, following thesecret_key/security_profileprecedent. Related to the network-binding hardening in GHSA-2473-px8h-rvg6. - Opt-in removal of the invalid-bcrypt-hash plaintext fallback: new
session.enforce_password_checksetting (off by default). Whenpassword_hashingis on, ausers.yamlpassword that isn't a valid bcrypt hash previously fell back to plaintext comparison with only a warning logged - this setting removes that fallback, rejecting the login outright instead. Default behavior (the fallback) is unchanged; opt-in only. YAML-only, same treatment asrequire_ui_login.
2.6.0 - 2026-08-21
Documentation
- New guide: Running behind a TLS-terminating reverse proxy,
walking through composing
oauth.issuer,issuer_from_request,issuer_from_proxy_headers,issuer_allowlist,device_verification_base_urlandPOST /api/config/reloadfor a proxied/containerized deployment, with the security caveats inline.
Added
- First-class group support: users gain a
groupslist alongsideroles, modelled exactly the same way. It is loaded from and persisted tousers.yaml(omitted when empty), emitted as agroupsclaim on the access token and from/userinfo, requestable in the ID Token via the OIDCclaimsparameter, advertised inclaims_supported, and flattened intoauthoritiesusing the newgroupsauthority prefix (defaultGROUP_, editable on the Claims page). Groups are editable from the user form, shown on the users list and user detail pages, exposed by/api/users, and settable through the MCPcreate_user/update_usertools. Users without groups behave exactly as before: no claim, no authorities, nothing written to YAML. - Optional SAML export of roles and groups: new
saml.export_roles/saml.export_groupstoggles (both off by default, so the previous behaviour is preserved) with companionsaml.roles_attr_name/saml.groups_attr_namesettings defaulting torolesandgroups. Roles and groups are not standard SAML attributes and every SP expects a different name, so the name is configurable; blanking it restores the default. Both toggles are on the Settings page and the MCPupdate_settingstool, and apply to both the SSO assertion and the AttributeQuery endpoint, with oneAttributeValueper entry. oauth.issuer_from_request(off by default): when enabled, the discovery document'sissuer, every minted token'siss, and the device flow'sverification_uriare derived from the incoming request's own Host header instead of the fixedoauth.issuer. Lets the same NanoIDP be reachable under more than one hostname (e.g. a Docker Compose service name from other containers andlocalhostfrom the host browser) without a discovery/token issuer mismatch - each hostname advertises and issues tokens against itself. The MCPget_oidc_discovery/get_settingstools have no request of their own and always report the fixedissuer.oauth.issuer_allowlist: restrictsissuer_from_requestto a list of allowed origins (e.g.["http://localhost:8000", "http://nanoidp:9900"]). Empty (default) allows any Host header, unchanged from before; when set, a request whose Host doesn't match falls back to the fixedoauth.issuerinstead of trusting an arbitrary Host header. Settable from the Settings page and the MCPupdate_settingstool.oauth.device_verification_base_url: pins the device flow'sverification_urito a fixed, human-reachable URL (e.g.https://idp.example.com), overridingissuer_from_request's derivation for that field only - discovery'sissuerand a token'sissare unaffected. Fixes a backend/container caller of/device_authorization(e.g.Host: nanoidp:9900) otherwise leaking its own Host into a URL the end user's browser can't open. Unset by default. Settable from the Settings page and the MCPupdate_settingstool.oauth.issuer_from_proxy_headers(off by default): trustsX-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Forfrom a single reverse-proxy hop (via werkzeug'sProxyFix), soissuer_from_requestand rate-limit and audit-log client IPs see the original scheme/host/client instead of the proxy's own connection when TLS is terminated upstream. Only changes the derived issuer/iss/verification_uriwhenissuer_from_requestis also on; the rate-limit effect applies regardless. Only enable this when NanoIDP is deployed directly behind exactly one trusted proxy - these headers are otherwise spoofable by any client. Readable/settable via the Settings page and the MCPget_settings/update_settingstools; sinceProxyFixis wired at app startup, a value changed at runtime only takes effect after a restart.
Changed
- Raised the
PyJWTfloor to>=2.13.0(was>=2.8.0)./userinfoand/introspectpass a client-supplied token tojwt.decode(), and 2.8.0-2.12.1 are affected by CVE-2026-48525 (unbounded Base64URL decoding of ab64=falsedetached JWS payload, a DoS vector). - Added a CI license gate (#148): the build fails if a dependency in the redistributed closure carries a GPL/LGPL/AGPL/SSPL/EUPL license, which the project's dependency-license policy blocks from redistribution without explicit review.
- Lowered the
cryptographyfloor from>=46.0.3to>=45.0.0(#140). The previous floor came from a generic dependency bump, not a real requirement: our own API usage needs nothing newer than ~3.1. The effective minimum is set bysignxml, which importsx509.verification.ExtensionPolicy(added in cryptography 45.0.0) at load time. This unblocks installs on environments pinned to acryptographybetween 45 and 46. - Consolidated the OAuth client YAML merge logic into a single
serialization.merge_client_entry()helper, shared by the settings save path (merge_oauth_clients()) andYamlWriter.save_client()'s web UI edit path, which previously duplicated the same field-by-field merge rules. Internal cleanup, no behavior change. - Migrated the MCP server to the mcp 2.0 SDK and pinned
mcp>=2,<3. mcp 2.0 replaced the lowlevelServerdecorators (@server.list_tools()/@server.call_tool()) withon_*constructor parameters, so a fresh install resolving to 2.0 could not importnanoidp.mcp_serverat all. Handlers now take(ctx, params)and returnListToolsResult/CallToolResultinstead of relying on the SDK's removed return-value wrapping. The tool set, tool schemas, readonly mode, and the admin-secret gate are unchanged, and the stdio transport andnanoidp-mcpentry point are untouched. - Rejected and failed MCP tool calls now set
is_error: true. mcp 2.0 no longer converts a handler exception into an error-flagged result, so nanoidp builds it explicitly for every case that previously came back as a successful result whose JSON body happened to carry anerrorkey: readonly-mode and admin-secret rejections, an unknown tool name, arguments that fail schema validation (see below), and tool-level failures such as "user not found" or "client already exists". The response body is unchanged. - Tool arguments are now validated against each tool's schema before
dispatch. mcp 1.x's
@server.call_tool(validate_input=True)did this automatically; mcp 2.0'son_call_tooldoes not, so nanoidp now runs the same check itself and returns anis_error: trueresult (code: "MCP_INVALID_ARGUMENTS") instead of letting a missing required field reach the tool implementation as a bareKeyError. - Consolidated the MCP
isErrorcontract (#120): the rule is now written once as a table in themcp_servermodule docstring (a negative query answer is not a failure) and the code follows it.verify_tokenon an invalid token returns{"valid": false, "reason": ...}(waserror) so a rejected token, the tool's designed answer, is no longer flaggedis_error. A domain-failure audit entry now records the failure reason instead of only the tool name; the uncaught-exception path now carries acode(MCP_INTERNAL_ERROR) andtoollike the guard rejections; and_execute_tool's unreachable unknown-tool fallback now raises rather than returning a divergent shape. The MCP auditdetailscodes are namespaced (MCP_READONLY_MODE,MCP_ADMIN_SECRET_REQUIRED,MCP_UNKNOWN_TOOL,MCP_INVALID_ARGUMENTS), observable viaget_audit_logand/api/audit. - Precompiled MCP tool-argument validators (#121): each tool's JSON Schema
is compiled once at import (
Draft202012Validator) instead of being recompiled on everytools/call, which also surfaces a malformed schema at import time. The directjsonschemafloor is raised to>=4.20.0to match what mcp 2.0 already resolves. - MCP tests drive the real protocol (#122): the test harness now calls tools through the mcp 2.0 in-memory client (real SDK dispatch and result serialization) instead of invoking the lowlevel handlers with a fake request context, so wire-level regressions fail the suite instead of only breaking a real client.
Fixed
- Duplicate OAuth
client_ids are rejected at load (#127). Two clients that resolve to the same effective id (including two${VAR}placeholders expanding to the same value) made client lookup ambiguous and caused the save-merge to match the wrong raw entry, materializing a secret; the loader now fails fast with a clear error instead. - Import no longer crashes when package metadata is absent (#139). Running
from an uninstalled source tree (vendored, or copied into an image without
pip install) raisedPackageNotFoundError; the version now falls back to readingpyproject.toml, then to a static string. - Default admin user's
identity_classisINTERNAL, notINTERN. The no-users.yamlfallback used a typo'd class that didn't match the generated template or the default allowed classes. - Env-backed
client_idplaceholders are preserved on save (#127). When a client'sclient_idwas itself a placeholder (client_id: ${CLIENT_ID:app1}), the settings save matched the raw entry against the expanded id, missed it, and rewrote the client from expanded values - losing the placeholders and materializing the client secret; the web UI path appended a duplicate entry, anddelete_clientcould not find the client at all. Client matching now expands the placeholder before comparing (client_id_matches()), used by the settings save,save_client()anddelete_client(). - Saving settings no longer discards comments, inline
#text or${VAR:default}placeholders insettings.yaml(#127): the settings writer now round-trips the file withruamel.yaml(comments and quote style survive) and only rewrites a field when its expanded on-disk value actually differs from the new one, so an untouched${PORT:8000}-style placeholder is no longer replaced by its resolved value on the next save. Free-form text (description,client_secret,password, attribute values) is now quoted on write so an embedded#can't be mistaken for a comment. Applies to both the web UI settings form and the MCPsave_configtool. - Env-backed client secrets and empty optional placeholders are preserved when
unrelated settings change. The OAuth client merge now updates entries by
client_idfield-by-field instead of rewriting the whole list, so an unchanged${APP1_SECRET:dev}secret stays in the raw file even when a sibling client is edited. Empty optional values such as${DEVICE_URL:}are also treated as unchanged when they still expand to an empty string, instead of being popped out of the YAML on a save that changed some other field. /api/confignow exposesissuer_allowlist,device_verification_base_urlandissuer_from_proxy_headersalongsideissuer_from_request. The config-agnostic e2e agent reads the allowlist from/api/configto predict the effective issuer; without the exposure it assumed an empty allowlist and failed on any server with one configured. The agent also takes its fixed-issuer baseline from/api/config'soauth.issuernow: a plain discovery response reflects the request's own Host when the flag is on, so it is only a valid baseline when the flag is off.examples/test_agent.pySAML export check honours the configured attribute names: it now readssaml.roles_attr_name/saml.groups_attr_namefrom/api/configinstead of assuming the defaultroles/groupsnames, so it no longer fails on servers exporting under custom names.- SAML export: colliding attribute names merge instead of overwriting, and
values are passed as lists (#134). With both exports enabled and
saml_roles_attr_nameequal tosaml_groups_attr_name(e.g. bothmemberOf), the groups list silently replaced the roles list; the two are now merged into the single shared attribute, roles first, deduplicated. The AttributeQuery path also passes roles/groups (and entitlements) to the response builder as lists instead of comma-joined strings, so a legitimate comma-bearing value like"Finance, EMEA"stays oneAttributeValue, as it already did in the SSO assertion. /api/users/<username>/tokennow honoursissuer_from_request(#133): the endpoint mints real JWTs but kept using the fixedsettings.issuer, so with the flag on its tokens carried anissthat failed validation against the discovery document the same hostname had just advertised. The effective-issuer resolution (including the allowlist fallback) now lives in a shared routes helper used by discovery,/token, the device flow and the API token endpoint alike; the MCP tools remain the documented exception. Also clarified in the setting descriptions thatissuer_from_proxy_headersaffects the audit log's recorded client IP as well as the rate limiter's.POST /settingsno longer resets settings that were not on the submitted form (#131). Previously every checkbox absent from the form was stored asfalseand every absent text field was cleared, so any partial form (a stale tab, a script, the e2e agent's c14n round-trip) silently wiped unrelated configuration - observed live asissuer_from_request,issuer_from_proxy_headersand the SAML export toggles flipping off and the allowlist, device verification URL and attribute names being deleted mid-test-run. The handler now follows an "absent = unchanged" contract: text fields and textareas are only applied when present (present-but-blank still clears), and each checkbox is paired with a hidden__on_formmarker so "rendered but unchecked" (persistfalse) is distinguishable from "not on this form" (leave unchanged).
Security
- Default server bind address is now
127.0.0.1(loopback) instead of0.0.0.0(GHSA-2473-px8h-rvg6, CWE-306). The unauthenticated/api/*management API (which can mint admin tokens, rotate signing keys and clear the audit log) is a deliberate dev-tool convenience, but the previous all-interfaces default exposed it to any network-reachable host without the operator choosing to. The out-of-the-box experience is unchanged for local development (clients still reachlocalhost:8000). To expose NanoIDP on a network, setserver.host(or--host 0.0.0.0) explicitly; a startup warning is logged whenever the bind address covers all interfaces. This alignsnanoidp initwith the valuenanoidp wizardalready wrote, and the bundled Docker image is unaffected (its entrypoint already passes--host 0.0.0.0).
2.5.0 - 2026-07-19
Added
claimsparameter requests persist across token refresh (#112, OIDC Core §12.2): the claim names requested via the OIDCclaimsparameter are now persisted in the refresh token (req_id_token_claims/req_userinfo_claims, alongsidescopeandauth_time), so a refreshed ID Token keeps the requested claims and/userinfokeeps honouring theuserinfomember for the refreshed access token. Refresh tokens minted before this change carry neither claim and refresh as before. Both names are reserved: they cannot be requested via theclaimsparameter nor injected through the/tokenextraparameter. Requested-claims values are sanitized at the token service (sanitize_claim_names): a hand-crafted refresh or access token carrying a non-list value (or non-string entries) refreshes and serves/userinfocleanly instead of failing token issuance after the refresh token was consumed. A claims request deliberately survives scope narrowing on refresh (OIDC Core §5.5 is orthogonal to scope); see the token reference docs.- MCP
generate_tokengainsuserinfo_claims(#113): parity with the HTTPclaimsflow'suserinfomember; the names are stamped on the access token asreq_userinfo_claimsand honoured by/userinfo. Bothid_token_claimsanduserinfo_claimsare now validated likeadditional_audiences: a non-list value is rejected with a clean error instead of being minted into the token.
Changed
/userinforeusesresolve_user_claimfor its default claim assembly (#113): the scope-gated standard claims and the nanoidp-specific claims now come from the same resolver that backs theclaimsrequest parameter, so the two mappings cannot diverge. No behavior change.
Fixed
claimsparameter could overwrite registered ID Token claims (#110): a requested claim name that collided with a user attribute (e.g. an attribute namedaudorexp) could hijack the corresponding registered claim, becausecreate_jwtappliesextraafter setting the registered claims and thesetdefaultguard only covered the protocol claims.resolve_user_claimnow refuses reserved registered/protocol names outright (iss,sub,aud,exp,iat,nbf,jti,token_use,auth_time,at_hash,azp,nonce,scope,req_userinfo_claims), protecting both the ID Token and/userinfopaths at a single choke point.
2.4.0 - 2026-07-08
Added
scopeclaim on access tokens (#102): access tokens now advertise the granted scope (RFC 9068 §2.2.3), letting resource endpoints reason about it. Set authoritatively inTokenService.create_token, so a caller-suppliedextra_claimscannot override it.- OIDC
claimsrequest parameter (#104, OIDC Core §5.5):/authorizeaccepts aclaimsparameter to request specific claims in the ID Token (id_tokenmember) or from UserInfo (userinfomember), e.g.claims={"id_token":{"email":null}}. Requested claims are resolved from the user and added when available (voluntary form, §5.5.1); protocol claims are never overwritten and unresolvable names are skipped. Malformed input is ignored with a warning rather than failing the flow. Discovery advertisesclaims_parameter_supported: true, and the MCPgenerate_tokentool gains anid_token_claimsargument. Scoped to the authorization code grant; the requested claims are not yet persisted across a refresh.TokenService.create_tokennow stripsscope/req_userinfo_claimsfrom a caller-suppliedextrabefore setting them authoritatively, so the/tokenextraparameter can never smuggle scope-gated claims past/userinfo(closes a spoofing gap in the #102 scope handling too).
Changed
/userinfogatesemail/profileclaims by granted scope (#102, OIDC Core §5.4):email/email_verifiedrequire theemailscope andpreferred_usernamerequires theprofilescope. Enforced only under thestricter-devandoauth21profiles; the defaultdevprofile keeps returning them unconditionally, so this is not a breaking change for existing setups. nanoidp-specific claims (roles,tenant,identity_class,attributes) have no standard scope and are always returned.
2.3.0 - 2026-07-08
Added
oauth21security profile (#68): opt-in draft-OAuth-2.1 protocol strictness alongsidedevandstricter-dev: PKCE required on the authorization code flow with S256 only (draft-ietf-oauth-v2-1 §4.1.1, §7.5.2), refresh token rotation forced on (§4.3.1), the password grant removed (RFC 6749 §5.2) and absent from discovery, and registered redirect URIs mandatory at/authorize. Protocol behavior lives in derivedSettingsproperties consumed by both the routes and the shared discovery builder, so the profile means the same thing from--profileorsettings.yamland discovery can never advertise what the endpoints refuse. Deliberately orthogonal tostricter-dev(runtime hardening).- Registered redirect URIs with exact matching (#67): clients gain an
optional
redirect_urislist; when non-empty,/authorizecompares the requestedredirect_uriwith simple string comparison (RFC 6749 §3.1.2.3, OAuth 2.1 §4.1.1) and answers a mismatch with400 invalid_requestdirectly, never by redirecting to the unvalidated URI (§3.1.2.4). Exposed in the web UI, MCP client tools and YAML. - Signed AuthnRequest verification (#69): with
saml.want_authn_requests_signed: trueand PEM certificates insaml.sp_certificates, nanoidp requires and verifies AuthnRequest signatures under both bindings: the HTTP-Redirect query-string signature over the raw transmitted fragment (SAML 2.0 Bindings §3.4.4.1; rsa-sha256/rsa-sha512/legacy rsa-sha1) and the HTTP-POST envelopedds:Signature(Core §5), rejecting unsigned or invalid requests with 400, failing closed without registered certificates. The verified Redirect request is bound server-side in the session, so the inline-login leg only accepts byte-identical values. Metadata advertisesWantAuthnRequestsSigned="true"if and only if enforcement is on.examples/gen_sp_keypair.pygenerates a test SP keypair. - E2E workflow in CI (#79): every PR now boots real servers and runs
examples/test_agent.pyagainst them (default profile,--oauth21,--saml-signedwith a generated SP keypair) plus an MCP stdio smoke test (examples/mcp_smoke_test.py) driving the real transport, the regression guard for the class of bug where the stdio entrypoint crashed unnoticed because unit tests bypass it (#56). - Coverage gate in CI (#71, #72):
--cov-fail-under, introduced at 70 and ratcheted to 75 after the wizard went from 0% to 99% coverage; measured coverage 78%. The dead Codecov upload (never configured, failed silently since inception) was removed in favor of in-CI enforcement. - Documentation site: mdBook on GitHub Pages (https://cdelmonte-zg.github.io/nanoidp/) with getting-started, guides and a full reference; canonical docs are symlinked so there is a single source of truth, and the README became a landing page.
- Web UI parity (#94):
require_pkceandrefresh_token_rotationtoggles on the settings page; SP-certificates and signed-AuthnRequests fields (#69);redirect_urison the client form (#67); the dashboard badge distinguishes theoauth21profile. - MCP:
get_settingsreportssecurity_profile;update_settingscovers the SAML verification fields; client tools carryredirect_uris.
Changed
src/is fully annotated and mypy runs with a globaldisallow_untyped_defs(#70): new unannotated code fails CI.- Internal architecture (behavior-invariant, #83–#86): one shared YAML
serialization path for
ConfigManagerand the UI writer; the token endpoint dispatches to per-grant handlers with device-flow and revocation state in dedicated services (DeviceCodeStore,RevocationStore); a singleaudit_eventhelper replaced 58 duplicated audit blocks (invariance proven by a before/after snapshot harness); the Pydantic models moved tomodels.pywith compatibility re-exports. security_profileis now read fromsettings.yaml(top-level key) and round-trips on save; the CLI--profilestill wins. A YAML-declaredstricter-devnow applies its runtime hardening (previously the YAML value was silently ignored).
Fixed
ConfigManager.save()was lossy (#87): the save path behind MCPsave_configrewrotesettings.yamlfrom scratch, silently deleting every section it didn't own:jwt(external keys!),session,logginglevels,server.debugand custom keys. Saving is now read-modify-write and preserves them, atomically and with a.bakbackup like the UI path always did.
2.2.0 - 2026-06-11
Added
- The refresh_token grant now re-issues an ID Token when the original grant
included the
openidscope (OIDC Core §12.2, #39). The granted scope is persisted in the refresh token claims and recovered on refresh; ascopeform parameter may narrow, but never broaden, the original grant (RFC 6749 §6: broadening is rejected with400). The refreshed ID Token carries nononce(it binds the original authentication request). Refresh tokens minted before this change have no persisted scope and keep the old behavior. - ID Tokens now carry
auth_timeandat_hash(#42).auth_timereflects when the end-user actually authenticated: the login page for the authorization code flow, the/deviceverification for the device flow, the request itself for the password grant. It is preserved unchanged across refreshes (OIDC Core §12.2), carried in the refresh token claims like the scope.at_hashbinds the ID Token to the access token issued alongside it (left half of SHA-256, base64url, §3.1.3.6). Discoveryclaims_supportednow also advertisesauth_time,nonceandat_hash. - Optional refresh token rotation (#46): with
oauth.refresh_token_rotation: true(default off), each refresh atomically invalidates the consumed refresh token, so its reuse fails with 401; reuse of a consumed token revokes its whole rotation family, including the live descendant (RFC 9700 §4.14.2). - PKCE enforcement (#47): new
require_pkcesetting (enabled by thestricter-devprofile, persisted insettings.yaml) rejects/authorizerequests without acode_challenge;stricter-devalso rejectscode_challenge_method=plain, whether explicit or implicit via the RFC 7636 §4.3 omitted-parameter default, and discovery only advertisesS256there. Unsupported methods are rejected at the authorization endpoint (§4.4.1). Default profile unchanged. - MCP audit & key tools (#48):
get_audit_log,get_audit_stats,clear_audit_log,get_keys_infoandrotate_keysmirror the HTTP API, so agent workflows can inspect what the IdP recorded and exercise JWKS refresh handling.clear_audit_log/rotate_keyscount as mutating tools (admin secret / readonly rules apply). MCPget_settings/update_settingsexpose the newrefresh_token_rotationandrequire_pkcesettings, andgenerate_tokenaccepts an optionalscopeargument and returns anid_tokenwhenopenidis included, matching the HTTP token endpoint. - CI now lints with ruff (#45) and type-checks
src/with mypy (#55, documented gradual-adoption baseline inpyproject.toml). The codebase is lint-clean, 153 findings fixed (#49): deprecateddatetime.utcnow()replaced (removes 80 DeprecationWarnings), unused imports/variables dropped, imports sorted and moved to module level,Optional[...]type hints in the crypto service,verify_jwtaccepts an array audience, exceptions re-raised withfrom e, and mypy-clean (40 baseline errors fixed; the baseline also surfaced the brokennanoidp-mcpentrypoint below).
Fixed
- The
nanoidp-mcpstdio entrypoint crashed at startup ("a coroutine was expected"):stdio_server()is an async context manager yielding the message streams, not a coroutine. Verified with a JSON-RPC initialize handshake. - Review follow-ups of the 2026-06-11 merge block (#56):
- Refresh tokens are bound to their client: the issuing
client_idis persisted in the refresh token claims and the refresh grant rejects any other client (RFC 9700 §4.14), which also guarantees the refreshed ID Token keeps the originalaud(OIDC Core §12.2). Tokens minted before the claim existed keep working. - Rotation is atomic and revokes families on reuse: the revocation
check and the claim of the consumed token now happen in one critical
section, so two concurrent refreshes of the same token can no longer
both succeed. Each grant starts a refresh-token family (
rt_familyclaim, stable across rotations); reusing an already-consumed token revokes the whole family, including the live descendant (RFC 9700 §4.14.2). - PKCE
plaincan no longer slip through stricter-dev by omitting the method: per RFC 7636 §4.3 an absentcode_challenge_methoddefaults toplain; the method is now normalized before validation, and unknown methods are rejected at the authorization endpoint (§4.4.1). require_pkceis persisted: it is now read from and written tosettings.yaml(oauth section), soupdate_settings→save_config→reload_configno longer silently reverts it.- The token response reports the scope actually granted (RFC 6749
§5.1) instead of a hardcoded
"openid"; when no scope was involved the parameter is omitted, and a narrowed refresh reports the narrowed scope.
- Refresh tokens are bound to their client: the issuing
- The token endpoint validates
expandextrabefore the grant dispatch: with rotation enabled, a malformed value can no longer consume the refresh token without delivering its replacement (the last tradeoff noted in the #56 review). Validation is semantic, not just syntactic:extramust be a JSON object (a scalar/array used to raise aTypeError500 later) andexpmust be an integer within the same1..1440bounds the Settings model enforces (non-numeric values used to be an unhandledValueError500; astronomical ones anOverflowError500). - Thread-safety hardening for shared in-memory state (#43): the authorization code store now performs its check-then-mark sequence under a lock (one-time use can no longer be defeated by concurrent redemptions), device codes are claimed/transitioned atomically and pruned when expired, and the lazily-created service singletons (config, token, crypto, audit, auth codes) use double-checked locking so concurrent first access creates exactly one instance.
- The MCP
get_oidc_discoverytool now returns the exact same document as the HTTP/.well-known/openid-configurationendpoint (#40). Both build it via a new shared helper (services.discovery.build_discovery_document), so the MCP tool now advertisesclaims_supported(includingazp),response_types_supported,id_token_signing_alg_values_supported,code_challenge_methods_supportedand the endpoint auth methods. The two documents can no longer drift apart. - Discovery no longer advertises the
tokenresponse type (#41): the implicit flow was never implemented (/authorizeonly acceptsresponse_type=code) and is deprecated by the OAuth 2.0 Security BCP, so advertising it misled clients.response_types_supportedis now["code"].
Documentation
- The MCP tools tables in the README and
docs/MCP_WORKFLOW.mdnow list all 24 tools (#44, #48); the README was missingcreate_client,update_client,delete_client,update_user,update_settingsandsave_config.
2.1.0 - 2026-05-26
Added
- ID Tokens are now issued for the password and device (RFC 8628) grants
when
openidscope is requested, not justauthorization_code(#36). These grants authenticate an end-user, so an ID Token is meaningful;client_credentialsstill never emits one (no end-user).
Fixed
- Friendlier loading of client
additional_audiencesfromsettings.yaml(#35): a scalar value (additional_audiences: api://x) is coerced to a one-element list, and an unsupported shape (e.g. a non-string item) now fails with a clear, client-scoped error instead of an opaque PydanticValidationErrorat startup. - Minor hardening/polish from the #32 review (#37):
OAuthClientnow validates on direct attribute assignment (validate_assignment), discovery advertisesazpinclaims_supported, and the MCP_normalize_audiencesrejects falsy non-list inputs instead of silently returning an empty list.
Security
- Harden the ID Token vs access-token boundary (#34). The resource audience
(
oauth.audience) is now filtered out of the ID Tokenaudeven if a client lists it inadditional_audiences, and every token carries atoken_usemarker (access/id/refresh)./userinforejects tokens marked as ID or refresh tokens and/introspectreports ID Tokens as inactive, so an ID Token can no longer be spent as an access token. (Refresh tokens stay introspectable per RFC 7662.)
2.0.0 - 2026-05-25
Changed
- ID Token
audnow contains the requesting client'sclient_id, as required by OpenID Connect Core 1.0 §2 (was previously the staticoauth.audience). This makes it possible to test multiple clients and brings nanoidp in line with the OIDC spec.- Breaking: relying parties that validated the ID Token
audagainst the old staticoauth.audiencevalue must now expect their ownclient_id. - The access token
audis unchanged and still reflectsoauth.audience(the resource audience, per RFC 9068 §2.2).
- Breaking: relying parties that validated the ID Token
Added
additional_audiencesper-client setting: extra audiences appended to the ID Tokenaud. If this produces more than one distinct audience value,audis emitted as an array and nanoidp also emitsazpequal to theclient_id, so clients can test authorized-party handling.
1.4.0 - 2026-04-28
Added
- Environment variable substitution in
settings.yamlusing${NAME}/${NAME:default}syntax PORTenv var honoured in the Docker image via shell expansion inCMD
1.3.3 - 2026-04-22
Fixed
- Return
id_tokenin /token response for Authorization Code Flow whenopenidscope is requested, as required by OIDC Core spec (Section 3.1.3.3) - Include
nonceclaim inid_tokenwhen provided by the client
Changed
- Use
pyproject.tomlas single source of truth for version number - Remove outdated version label from Dockerfile
1.3.2 - 2026-03-27
Fixed
- Token endpoint now rejects requests when
client_idcannot be determined from either the request body or theAuthorizationheader - Token endpoint now rejects requests where
client_idin the body conflicts with the authenticated client in theAuthorizationheader
Added
- Tests for client_id mismatch and missing client_id edge cases
1.3.1 - 2026-03-26
Fixed
- Allow authorization code flow without
Authorizationheader for PKCE public clients (RFC 6749 §2.1)- Libraries like authlib send
client_idin the request body instead of the header when no client secret exists - Auth header validation is now only enforced for grant types other than
authorization_code
- Libraries like authlib send
Added
- Test for PKCE plain flow without auth header (
test_pkce_plain_flow_no_auth_header)
1.3.0 - 2026-03-25
Added
- GitHub Actions workflow to build and publish Docker images to GitHub Container Registry (GHCR)
- Triggered on version tags (
v*), builds multi-platform images (linux/amd64,linux/arm64) latesttag published only for non-prerelease versions
- Triggered on version tags (
- Docker usage instructions in README (
docker pullanddocker runexamples)
Changed
- Dockerfile healthcheck switched from Python
urllibtocurlfor Podman compatibility and reduced overhead - Updated
actions/checkoutfrom v4 to v6 in publish workflow
1.2.3 - 2026-03-03
Fixed
- Dockerfile and docker-compose.yml: replaced
curlwith Python'surllibfor healthcheck: avoids addingcurlas a system dependency in the image
Docs
- Added mascotte/logo images to the project
1.2.2 - 2026-01-19
Added
- New
strict_saml_bindingsetting to enforce SAML 2.0 binding compliance- When
false(default): lenient mode accepts GET with uncompressed data (useful for debugging) - When
true: strict mode rejects non-compliant requests per SAML spec
- When
- Setting exposed in UI (Settings page), REST API (
/api/config), and MCP server - Exclusive C14N (
exc_c14n) is now the default XML canonicalization algorithm- Standard for SAML 2.0 signatures, handles namespace isolation correctly
- Available algorithms:
exc_c14n(Exclusive C14N 1.0, default),c14n(C14N 1.0),c14n11(C14N 1.1)
- UI select dropdown for C14N algorithm in Settings page
strict_saml_bindingandverbose_loggingnow persist correctly on save/reload- Comprehensive E2E test coverage for all SAML flows in
test_agent.py:test_saml_metadata_bindings- verifies both HTTP-POST and HTTP-Redirect advertisedtest_saml_sso_post_binding- SP-initiated SSO with HTTP-POST (InResponseTo verification)test_saml_sso_redirect_binding- SP-initiated SSO with HTTP-Redirect (InResponseTo verification)test_saml_idp_initiated_not_supported- documents IdP-initiated SSO is not supportedtest_saml_strict_binding_mode- tests strict/lenient binding behaviortest_saml_attribute_query_verification- verifies actual attributes returned
- Unit tests for inline login flow (
test_inline_login_flow_preserves_post/redirect_binding) - Unit test for strict mode + inline login (
test_strict_mode_inline_login_preserves_redirect_binding) - Unit test for Exclusive C14N configuration (
test_c14n_algorithm_configurable_to_exclusive)
Fixed
- SAML SSO now correctly handles both HTTP-POST and HTTP-Redirect bindings
- Parser always tries DEFLATE decompression first, falls back to raw XML (handles all edge cases)
- Strict mode now works with inline login by passing original HTTP verb via hidden field
- Fixes: GET compressed → login form → POST would fail in strict mode
- Stateless: no server-side session needed, works in CI/CD pipelines
- Explicit
|eescape filter in login template hidden fields (XSS defense-in-depth) - Normalized
original_verbhandling (uppercase, validated to GET/POST) - Quick-fill username buttons use
tojsonfilter to handle special characters safely
1.2.1 - 2026-01-16
Fixed
- SAML SSO now correctly handles HTTP-POST binding (uncompressed SAMLRequest)
- Previously,
_parse_saml_requestunconditionally attempted DEFLATE decompression, causing parsing to fail for POST requests - Now uses HTTP method to determine binding type: GET = HTTP-Redirect (compressed), POST = HTTP-POST (uncompressed)
Changed
- E2E test agent now verifies actual SAML parsing (InResponseTo matching) instead of just endpoint availability
- Added separate tests for HTTP-POST and HTTP-Redirect bindings in
test_agent.py
Changed (Architecture)
- Inline login for SAML SSO:
/saml/ssonow shows login form directly instead of redirecting to/login- This preserves SAML binding context naturally (no redirect = no method change)
- Follows the pattern used by Keycloak and other IdPs
- Removes the complex edge cases caused by redirect-based login
/loginendpoint simplified - now only used for direct web UI access, not SAML flows- Login form now posts to current URL (no hardcoded action) - works for both
/loginand/saml/sso
Changed
- SAML metadata now advertises both HTTP-POST and HTTP-Redirect bindings for SingleSignOnService
- Audit stats now track SAML SSO and Attribute Query separately (
saml_sso_requests,saml_attribute_queries) - Dashboard shows combined SAML total with SSO/AttrQuery breakdown
- E2E test agent expanded to 35 tests (was 28), now covering all SAML flows with parsing verification
1.2.0 - 2026-01-14
Added
- Configurable
verbose_loggingsetting to control sensitive data in logs verbose_loggingexposed in MCPget_settingsandupdate_settingstoolslogging.verbose_loggingexposed in REST API/api/configendpoint- MCP tests (
tests/test_mcp.py) with 8 tests for MCP functionality - Verbose logging test in E2E test agent
Changed
- Replaced deprecated
defusedxml.lxmlwith native lxml secure parser for XXE protection - Added
html.escapefor XSS prevention in SAML responses - Audit logging now respects
verbose_loggingsetting (usernames/client_ids only when enabled)
Security
- XXE (XML External Entity) protection using secure lxml parser configuration
- XSS prevention in SAML response forms
- Configurable sensitive data logging (verbose_logging defaults to true for dev convenience)
1.1.1 - 2026-01-14
Added
- Configurable XML canonicalization algorithm via
saml.c14n_algorithmsetting
1.1.0 - 2026-01-14
Added
- Configurable SAML response signing via
saml.sign_responsessetting - UI toggle for SAML signing in Settings page (
/settings) sign_responsesexposed in/api/configendpoint- Test agent (
examples/test_agent.py) for comprehensive endpoint testing
Changed
- SAML SSO and AttributeQuery endpoints now respect
sign_responsesconfiguration - Changed default XML canonicalization to C14N 1.0 for maximum compatibility
- Updated documentation with SAML signing configuration instructions
1.0.0 - 2025-12-04
Added
- Initial release
- OAuth2/OIDC support (Authorization Code, Password, Client Credentials, Refresh Token, Device Flow)
- PKCE support (S256 and plain methods)
- Token Introspection (RFC 7662) and Revocation (RFC 7009)
- OIDC Logout / End Session endpoint
- Device Authorization Grant (RFC 8628)
- SAML 2.0 SSO and AttributeQuery endpoints with signed assertions
- MCP Server integration for Claude Code
- Web UI for configuration (users, clients, settings, keys, audit log)
- YAML-based configuration
- Attribute-based access control with configurable authority prefixes
- Audit logging
- Docker support
- Security profiles (
devandstricter-dev) - Key rotation with JWKS support for multiple keys
- External key import support
Contributing to NanoIDP
Thank you for your interest in contributing to NanoIDP!
Before writing code, read the
Architecture page
(source: book/src/project/architecture.md): it maps the packages, the
import contracts CI enforces, where state lives, and the multi-site
flows (like adding a client field) that changes must follow.
How to Contribute
Reporting Bugs
- Check existing issues to avoid duplicates
- Create a new issue with:
- Clear title
- Steps to reproduce
- Expected vs actual behavior
- Environment details (Python version, OS)
Feature Requests
- Open an issue describing the feature
- Explain the use case
- Wait for discussion before implementing
Pull Requests
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Make your changes
- Run tests and linting
- Commit with clear messages
- Push to your fork
- Open a Pull Request
Development Setup
# Clone your fork
git clone https://github.com/YOUR_USERNAME/nanoidp.git
cd nanoidp
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
# Install in development mode with dev dependencies
pip install -e ".[dev]"
# Run locally
nanoidp --debug
Running Tests
# Run all tests
pytest
# Run with coverage
pytest --cov=nanoidp
# Run specific test
pytest tests/test_basic.py
End-to-End Test Agent
NanoIDP includes a comprehensive test agent that validates all functionality against a running server:
# Run against local server (default: http://localhost:8000)
python e2e/test_agent.py
# Run against custom URL
python e2e/test_agent.py --url http://localhost:9000
# Verbose output
python e2e/test_agent.py --verbose
# JSON output
python e2e/test_agent.py --json
The test agent covers:
- Core: Health check, OIDC discovery
- OAuth2/OIDC: All grant types, token introspection, revocation, logout
- SAML 2.0: Metadata, SSO (POST/Redirect bindings), Attribute Query, signing config
- Key Management: Key info, rotation, post-rotation token validation
- REST API: Users, config, audit log
Caution: a run against a live server persists configuration changes back to that server's config/settings.yaml. Since #127, unrelated fields (e.g. ${PORT} placeholders, comments) are left untouched, but any value the run genuinely changes (e.g. default_acs_url) is still written for real. If the server runs from a git checkout, restore the file before committing: git checkout -- config/settings.yaml.
Code Quality
# Format code
black src tests
# Lint code
ruff check src tests
# Fix linting issues automatically
ruff check --fix src tests
# Check architectural import contracts (#149)
lint-imports
lint-imports enforces the package layering routes -> services -> config
and keeps serialization.py free of runtime imports from the package (that is
what lets config.py import it without a cycle). The contracts live in
[tool.importlinter] in pyproject.toml; a type-checking-only import does not
count. If a change needs a new edge between layers, adjust the contract in the
same PR and say why.
Domain invariants have one home (#285)
OAuth/OIDC/SAML policy belongs in services and domain logic; routes and the
MCP server are adapters and must not independently reinterpret the same rule.
If a PR implements the same policy separately at two surfaces - say, a check
written once in /token and again, slightly differently, in an MCP tool -
that is an architectural smell to flag in review: extract the rule to one
place (or delegate to the existing one) instead of keeping the copies in
sync by hand. The bug class this prevents is real and recurring here: a
capability exposed by N entry points with a rule updated at N-1 of them
(#269/#272). tests/test_token_issuance_parity.py enforces it mechanically
for token issuance: a new surface that mints tokens fails the suite until it
is registered and declares a stance on every policy.
A related rule for comments: never claim a "circular dependency" (or any
structural constraint) a deferred import does not actually avoid - false
constraint comments teach the next contributor a wrong module graph. The one
real import constraint is the serialization contract above; everything
else imports normally at module top.
One instance per process (#230, #407)
A nanoidp process is one identity provider. create_app() configures the
process-wide application; it is not a factory of independent applications.
The ConfigManager, the signing service, the runtime store and the YAML
writer are module globals resolved at call time, so a successful second
create_app() in the same process replaces the configuration for all of
them, and the application returned first resolves the new configuration
from then on. The runtime store is chosen once per process: a later
configuration that requests different store inputs (kind or path) is
rejected, and the existing application remains in effect.
Two consequences for a contributor. A new process-wide singleton that can
survive one create_app() and affect the next test belongs in
_reset_process_singletons() in tests/conftest.py; update that inventory
when adding one (the rate limiter is process-global too, but every
create_app() makes a new one, so it needs no reset). And a test builds
one application: a second one in the same test would answer for both
(tests/test_dynamic_registration_api.py says so where it matters).
Handing create_app() an explicit context instead of the globals remains
the documented next step of #230, to be taken only if embedding several
instances in one process becomes a real need.
Error surfaces (#287)
Each surface class has ONE error shape; a new endpoint or tool uses its class's shape, never invents a seventh:
- OAuth/OIDC protocol endpoints: RFC 6749 §5.2 JSON
(
error/error_description), except/authorizeerrors afterredirect_urivalidation, which redirect witherror,stateandiss(RFC 6749 §4.1.2.1, RFC 9207). Never HTML, never bare text. - Browser/UI pages:
flash(message, "error")+ redirect; conflict and hook conditions render their message through the shared helpers. - SAML: protocol-level failures answer IN the protocol - a SAML
Status(e.g.Requester/UnknownPrincipal) inside a Response; transport-level failures on the SOAP endpoint answer with a SOAP 1.1 Fault (HTTP 500,faultcodeClient/Server per SOAP 1.1 §6.2). Browser-facing SSO steps may use Werkzeugabort()HTML, since the reader is a person mid-redirect. - MCP: two deliberate SHAPES - dispatch refusals
(
{"error", "code", "tool"}, theMCP_*code taxonomy) vs domain results ({"success": False, "error", ...}pluskindfor typed conditions). Both come backis_error=True(call_tool flagssuccess is False or "error" in result); what staysis_error=Falseare the NEGATIVE QUERIES -found: False,valid: False- because a miss is an answer, not a failed call. The full table lives in themcp_servermodule docstring; the layer contract on_reject.
Typed exceptions are not the error channel: exceptions.py deliberately
holds only what is actually raised (SAMLSignatureError); a taxonomy
nothing raises is documentation that lies.
Code Style
- Follow PEP 8 (enforced by Black and Ruff)
- Use meaningful variable and function names
- Add docstrings to functions and classes
- Keep functions focused and small
- Type hints are encouraged
Commit Messages
Use clear, descriptive commit messages:
feat: Add OAuth client management UIfix: Correct token expiry calculationdocs: Update README with Docker instructionsrefactor: Simplify user authentication flowtest: Add tests for SAML endpoint
Project Structure
nanoidp/
├── src/nanoidp/ # Main package
│ ├── routes/ # Flask route handlers
│ ├── services/ # Business logic
│ └── templates/ # Jinja2 templates
├── tests/ # Test files
├── config/ # Default configuration
└── docs/ # Documentation
Releasing (maintainers)
Releases are cut by pushing a v* tag; two workflows publish to PyPI and
GHCR and a wheel-smoke job exercises the built artifact before anything is
published. The full process, with the exact commands, the verification
checklist and the recovery procedures, lives in
docs/RELEASING.md. In short: bump pyproject.toml
through a PR, tag the merged commit (v2.7.0-rc5 for a pre-release with a
hyphen, v2.7.0 for a final), create the GitHub release, then verify that
every workflow job ran and that the published artifacts install and behave
(pre-releases reach PyPI too and need pip install --pre; :latest on GHCR
moves only on final tags).
License
By contributing, you agree that your contributions will be licensed under the MIT License.