Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 oauth21 profile 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 default admin/admin user)
  • settings.yaml: OAuth/SAML settings (a default demo-client with secret demo-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

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:

WhereWhat is in itWho reads it
ID tokensub, aud = spa-client, auth_time, at_hash: who logged in, for this appthe SPA's OIDC library, to confirm the login
Access tokenaud = spa-api, scope, roles, authorities and the other user attributesyour API, on every request
/userinfoemail, email_verified, profile claims, per granted scopethe 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 doesNanoIDP's answer, with this preset
/authorize without code_challengeredirect with error=invalid_request: PKCE with S256 is required for a public client
code_challenge_method=plainredirect with error=invalid_request: must be S256
a redirect_uri not in redirect_uris400 invalid_request answered directly, never a redirect to the unregistered URI
the code redeemed with the wrong code_verifier400 invalid_grant
the same code redeemed twice400 invalid_grant the second time
a wrong passwordthe 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-alice collide: the second POST answers 409. A random suffix makes every name unique.
  • Delete by name. DELETE /api/runtime removes 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, and 404 for 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/runtime is 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:

WhereValue
principal name (the NameID)admin@example.org: NanoIDP uses the user's email
roles attributeADMIN, USER
groups attributeADMINISTRATORS
other attributesemail, identity_class, entitlements
authorities, with the converterROLE_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 happensResult with this example
user opens /admin403: the login works, the role is missing
a wrong passwordNanoIDP shows its form again; no SAMLResponse reaches the SP

Limits to know

  • No single logout. NanoIDP's metadata has no SingleLogoutService, so saml2Logout() has nothing to talk to. Log out locally with Spring's ordinary logout().

Takeaways

  • Spring Boot 4 needs the spring-boot-starter-security-saml2 starter 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 Audience with the SP's entity ID: NanoIDP takes it from the AuthnRequest's Issuer, 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_supported is the vocabulary: NanoIDP refuses a scope outside it with invalid_scope, for every client.
  • allowed_scopes narrows what one client may ask for.
  • allowed_resources lists the APIs a client may get a token for. A token requested with resource=https://inventory.internal has that as its aud; without a resource, its aud is oauth.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_user of users.yaml, with that user's roles (#445): sub was that user's name, not the service. default_user is 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
CaseAnswer
order-service, inventory:read, audience https://inventory.internal, GET /stock200, the caller is order-service
the same token on POST /reservations403: the scope is missing
no token401
a token requested without resource (aud is microservices)401
notification-service's token for https://orders.internal401: issued for another API
order-service asks for orders:readNanoIDP: invalid_scope
order-service asks for https://orders.internalNanoIDP: invalid_target
a wrong client secretNanoIDP: 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 its client_id. A client credentials token carries no user's roles.
  • Declare scopes in scopes_supported and per client in allowed_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 pollWhat the CLI does
authorization_pendingwaits and polls again
slow_downwaits 5 seconds longer from then on (RFC 8628 §3.5; NanoIDP does not send it today, a CLI should handle it anyway)
tokensdone
access_deniedthe user chose Deny: stop
invalid_grantthe device code is unknown or already used: stop
expired_token, or its own deadlinethe 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 on slow_down with 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 oauth21 profile this grant is rejected with 400 and does not appear in grant_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

EndpointWhat it does
POST /api/runtime/usersCreate 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}/promoteWrite it into users.yaml and retire the runtime object
POST /api/runtime/clients, GET /api/runtime/clients[/{client_id}], DELETE ..., POST .../promoteThe same for clients, promoted into settings.yaml
DELETE /api/runtimeRemove 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_reload audit event.
  • Reloads keep runtime state. POST /api/config/reload and 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 409 and the runtime object stays.
  • While a promotion runs, deleting or promoting the same object answers 409.
  • A strict on_config_saved hook that fails after the write does not undo it: the response carries mirror_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 like conflict, not a reload_failed where 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 409 and 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

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_uris must 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.
  • scope is narrowed to the server's vocabulary and the granted subset is echoed back. A scope naming 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_name becomes the client's description.
  • grant_types are 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

CaseAnswer
Disabled404
Metadata nanoidp cannot use400 invalid_client_metadata
Missing, empty or relative redirect_uris400 invalid_redirect_uri
max_clients live registrations reached429 registration_limit_reached
Wrong or missing registration access token401 invalid_token
Too many requests, with rate_limit_enabled429 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_id must be the URL it was fetched from, compared literally.
  • token_endpoint_auth_method is none, and only none: 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_uris must be present and are matched exactly, the same rules a declared client gets.
  • scope is 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:

Hostsonly those in allowed_hosts, matched as exact DNS names, and only on the port named there (443 when none is)
Addressesonly globally routable ones. Every address the name answers with must be acceptable, or the name is refused
Loopbackonly with allow_loopback, only when nanoidp is itself on loopback, only on the same address family
Redirectsnever followed
Answer200 only, and JSON only
Size5 KiB, enforced while reading
Time5 seconds for the whole fetch, including DNS and the TLS handshake
Retriesnone, 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_authorization answers 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-For also feeds rate-limit and audit-log client IP attribution, regardless of whether issuer_from_request is even on. Configure the proxy to strip any inbound X-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 is 127.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 want 0.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-For itself (strips any client-supplied values first)
  • NanoIDP is not reachable except through that proxy
  • issuer_from_proxy_headers: true only if the above two hold
  • issuer_allowlist set to the exact origins NanoIDP should ever advertise as its issuer
  • device_verification_base_url set if /device_authorization is ever called from a different host than the end user's own browser
  • Restarted the process after changing issuer_from_proxy_headers (ProxyFix is startup-only)

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:

HookWhenArgumentsTypical use
on_before_loadbefore 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 writeplugin: config_dir; shell: {config_dir}render the files from a store into the directory
on_config_savedafter 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_eventafter an audit entry is recordedplugin: the event as a mapping; shell: {config_dir}, {event_type} plus the event as JSON on stdinship 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.

Hookdefaulthooks.strict: true
on_before_loadlog, continue with whatever is in the directorythe load (startup or reload) fails with the hook's error
on_config_savedlogthe 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_eventloglog; 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:

SurfaceWhat it declaresRuns
NANOIDP_BOOTSTRAP_HOOK="<command>" or nanoidp --bootstrap-hook "<command>"an on_before_load shell commandonce, 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 directoryhooks: 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 startupits 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:

  1. Call generate_token to get a JWT
  2. Make the HTTP request with Authorization: Bearer <token>
  3. 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:

  1. Generate the token
  2. Decode it to show claims
  3. Explain the authorities array 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)

ToolDescriptionExample Prompt
list_usersList all users"List nanoidp users"
get_userGet user details"Get user 'admin' from nanoidp"
list_clientsList OAuth clients"List OAuth clients"
get_clientGet client details"Get client 'demo-client'"
decode_tokenDecode JWT"Decode this token: ..."
verify_tokenVerify JWT signature"Verify this token: ..."
get_settingsGet IdP settings"Show nanoidp settings"
reload_configReload from files"Reload nanoidp config"
get_oidc_discoveryGet OIDC discovery"Get OIDC discovery"
get_jwksGet JWKS"Get the JWKS from nanoidp"
get_audit_logGet audit entries"Show the last 20 audit entries"
get_audit_statsGet audit statistics"Show audit stats"
get_keys_infoGet signing key info"Which signing key is active?"

Mutating Tools (disabled in --readonly mode)

ToolDescriptionExample Prompt
create_userCreate new user"Create user 'test' with password 'pass'"
update_userUpdate user"Update user 'test' to add role 'ADMIN'"
delete_userDelete user"Delete user 'test'"
create_clientCreate OAuth client"Create client 'app' with secret 'secret'"
update_clientUpdate client"Update client 'app' description"
delete_clientDelete client"Delete client 'app'"
generate_tokenGenerate JWT"Generate token for 'admin'"
update_settingsUpdate settings"Set token expiry to 30 minutes"
save_configSave to YAML"Save nanoidp config to files"
clear_audit_logClear the audit log"Clear the nanoidp audit log"
rotate_keysRotate 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:

  1. Provide admin_secret in tool arguments (if management_secret is configured)
  2. Or unset management_secret (settings.yaml, NANOIDP_MANAGEMENT_SECRET, or the legacy NANOIDP_MCP_ADMIN_SECRET) for development
  3. Or check you're not running in --readonly mode

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

ToolScope required
read_documentdocuments:read
delete_documentdocuments:write
admin_operationadmin

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: 403 with WWW-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 / admin operations, 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:

checkwhat it shows
the authorization URL n8n builds carries code_challenge_method=S256, client_id=n8n-public and resource=http://mcp:9100/mcpn8n's request is what an MCP client is supposed to send
read_document returns the document and the execution is successthe 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' scopethe 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 itaudience 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):

checkwhat it shows
the model is offered MCP_Client_Tool_read_document, ..._delete_document and ..._admin_operationn8n 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 itthe 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 successthe 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 calleda 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 with strict: false) and stream: false; no tool_choice, no response_format. A tool result comes back to the model as a JSON string of the MCP content list; a tool that answers isError comes 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_scope on tools/call is 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: /healthz answers 200 from the first seconds while every other path, /rest/settings included, gets a 200 text/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/settings to answer the JSON envelope. The nightly once passed on the HTML page and ran the whole bootstrap against it.

  • resourceUrl must be explicit on the MCP OAuth2 API credential. n8n does discover the resource from the server's RFC 9728 metadata, but only an explicit resourceUrl makes it send resource on /authorize. Without it the token's audience is nanoidp's default and the resource server rejects it with invalid_token.

  • The OAuth callback needs n8n's own session. /rest/oauth2-credential/callback answers Unauthorized without 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 API credential 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 as openid-configuration) and then looks for a registration_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
SettingBehavior
false (default)The config web UI is unauthenticated, like today
trueEvery 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.


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:

SurfaceHow the secret is supplied
MCP serverThe 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:

ProfileDescription
dev (default)Maximum convenience for development: plaintext passwords, permissive CORS, no rate limiting
stricter-devSemi-hardened runtime: bcrypt passwords, restricted CORS, rate limiting, debug mode blocked
oauth21Draft 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

Featuredevstricter-devoauth21
Password storagePlaintextbcrypt hashPlaintext
CORS (unless cors_allowed_origins is set, which applies in every profile)* (all origins)localhost only* (all origins)
Rate limitingNone10 req/min on /tokenNone
Debug modeAllowedBlockedAllowed
PKCEOptionalRequired, S256 onlyRequired, S256 only
Refresh token rotationSetting (off)Setting (off)Forced on
password grantEnabledEnabledRemoved (and not advertised)
Redirect URIs at /authorizeAny valid URI, or exact match if registeredSame as devRegistration 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
ModeBehavior
password (default)Interactive logins require the configured password
personaInteractive 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_key and public_key are 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 kid across restarts
  • Paths accept ${VAR} placeholders; relative paths resolve against the working directory, like keys_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

  1. Rotation: New key pair generated, old key moved to "previous" list
  2. JWKS: Returns both active and previous keys (configurable via max_previous_keys, default 2)
  3. Signing: New tokens signed with the active key
  4. 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 its kid names, 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 no kid is verified against the active key. External keys are one key with no history: a token signed with it verifies whatever kid it names, so changing jwt.external_key_id for the same pair invalidates nothing.
  5. 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 a stat first) 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.yaml and bootstrap.yaml are operator-owned by definition, the same trust boundary as secret_key and management_secret, which is why hooks and plugins are YAML-only: the web UI and the MCP update_settings tool 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:

ToolDescription
create_userCreate a new user
create_persona_userCreate a password-less user (persona login mode)
update_userModify user attributes
delete_userRemove a user
create_clientCreate OAuth client
update_clientModify client settings
delete_clientRemove OAuth client
generate_tokenGenerate access tokens
update_settingsModify IdP settings
save_configPersist 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_allowlist is set to a non-empty list of allowed origins; a non-matching Host then falls back to the fixed issuer instead. Only enable issuer_from_request without an allowlist on trusted networks.
  • Behind a TLS-terminating reverse proxy, also enable oauth.issuer_from_proxy_headers so request.scheme/host_url reflect X-Forwarded-Proto/X-Forwarded-Host instead of the proxy's own HTTP connection - this only changes the derived issuer when issuer_from_request is 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_uri reflects 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. Set oauth.device_verification_base_url to a fixed, human-reachable URL (e.g. https://idp.example.com) to pin verification_uri regardless of the calling Host; discovery's issuer and a token's iss are unaffected and keep following the request that fetched/requested them.

Environment Variables

VariableDescriptionDefault
NANOIDP_CONFIG_DIRConfiguration directory path./config
NANOIDP_MANAGEMENT_SECRETSecret required for mutations across MCP, /api/*, and the web UI(none)
NANOIDP_MCP_ADMIN_SECRETLegacy alias for NANOIDP_MANAGEMENT_SECRET (MCP-only name, still honored)(none)
NANOIDP_MCP_READONLYDisable mutating MCP tools when set to truefalse
PORTServer port8000

Best Practices

  1. Use stricter-dev profile when sharing the instance with team members
  2. Enable readonly mode for MCP when only introspection is needed
  3. Set management_secret if multiple developers share the same NanoIDP instance - it gates mutating calls on MCP, /api/*, and the web UI alike
  4. Rotate keys periodically to test token validation with multiple keys
  5. Keep the default 127.0.0.1 binding unless you specifically need network access; only override it on a trusted, isolated network (see Network Binding)
  6. Never expose NanoIDP to public networks: it's designed for local/isolated use only

  • 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 init and 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's attributes, 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:

ExitMeaning
0clean, or warnings only without --strict
1any error, and any warning under --strict
2the 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 enabled flag, no enrolment screen, and no QR code in the UI - the operator writes the Base32 secret directly in users.yaml (${VAR} placeholders work exactly as they do for password, including what a whole-entry save does to them: a form edit of an unrelated field or the MCP save_config tool 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.totp rides the same phase machinery as login.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 /authorize it is recorded on the authorization transaction (#346); on /login, /saml/sso and /device it 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 device user_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_denied to 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_secret only changes the four interactive login surfaces above, never grant_type=password.
  • Claims follow the login, so a client can see the difference - see Tokens for the amr claim and the security guide for the AuthnContextClassRef value (saml.md covers 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:

EndpointA public client (token_endpoint_auth_method: "none")
/authorizeAccepted; PKCE with S256 is mandatory regardless of profile (OAuth 2.1 §7.5.1).
/token - authorization_code, refresh_token, device_codeIdentified by client_id alone; refresh tokens always rotate with reuse detection.
/token - client_credentialsRefused with unauthorized_client (the grant IS client authentication).
/device_authorizationAccepted by client_id alone (RFC 8628 §3.1); HTTP Basic or a client_secret is rejected; the device_code is bound to the client.
/revokeAccepted by client_id alone; an ownership check on the token's client_id claim stands in for authentication (RFC 7009 §2.1).
/introspectRefused - 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) and device_flow.polling_interval (default 5; 1 to 60): what /device_authorization announces as expires_in and interval (RFC 8628), and how long a device code and its user code stay valid. With code_expiry_seconds: 1, a test sees expired_token a second later.
  • cors_allowed_origins: the origins a browser may call nanoidp from. Absent, the security profile decides: every origin under dev and oauth21, localhost and 127.0.0.1 on any port under stricter-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: http or https, 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:443 becomes https://app.example). An internationalised host is written in the ASCII form the browser sends (https://xn--fa-hia.de for faß.de); a path, credentials or a pattern such as http://localhost:* is refused as not an origin. A "*" under stricter-dev is 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.

  • memory keeps it in the process and loses it on restart, as NanoIDP always has.
  • sqlite keeps it in files that several NanoIDP processes on one host share: path names the database, and the store is that file, an audit file next to it and a directory of owner leases, named after it (runtime.db has runtime-audit.db and runtime-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.yaml with placeholders and set the variables per environment (shell, Compose environment:, a Kubernetes env: 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_DIR before start (an init container, a mounted volume, a script), then POST /api/config/reload or the MCP reload_config tool 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, answers 422 and 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

EndpointDescription
GET /.well-known/openid-configurationOIDC Discovery
GET /.well-known/oauth-authorization-serverThe same document under the RFC 8414 name
GET /.well-known/jwks.jsonJSON Web Key Set
GET/POST /authorizeAuthorization endpoint (login page)
POST /tokenToken endpoint
GET/POST /userinfoUserInfo endpoint
POST /introspectToken Introspection (RFC 7662)
POST /revokeToken Revocation (RFC 7009)
GET/POST /logoutOIDC End Session / Logout (alias: /end_session)
GET /ui/logoutDashboard session logout (the web UI's Logout button)
POST /device_authorizationDevice Authorization (RFC 8628; alias: /device/code)
GET/POST /deviceDevice verification page
POST /clients/forgetDrop one cached client ID metadata document (web UI)
POST /registerDynamic 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

EndpointDescription
GET /saml/metadataIdP Metadata
GET /saml/cert.pemIdP signing certificate (PEM)
GET/POST /saml/ssoSingle Sign-On (supports both HTTP-POST and HTTP-Redirect bindings)
POST /saml/attribute-queryAttribute 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

EndpointDescription
GET /api/healthHealth check
GET /api/usersList 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}/tokenGenerate 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/auditGet audit log
GET /api/audit/statsAudit log statistics
POST /api/audit/clearClear the audit log
GET /api/configGet current configuration
POST /api/config/reloadReload 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/rotateRotate 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/infoGet 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.

EndpointDescription
POST /api/runtime/usersCreate a runtime user: a users.yaml entry plus username
GET /api/runtime/usersList 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}/promoteWrite it into users.yaml and retire the runtime object
POST /api/runtime/clientsCreate a runtime client: an oauth.clients[] entry
GET /api/runtime/clientsList 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}/promoteWrite it into settings.yaml and retire the runtime object
DELETE /api/runtimeRemove 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: aud is the requesting client's client_id (OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configure additional_audiences on a client to append extra audiences; if this produces more than one distinct audience value, aud is emitted as an array and NanoIDP also emits an azp claim equal to the client_id so you can exercise authorized-party validation.
  • Access Token: aud is the resource audience from oauth.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:

  • exp is the lifetime in minutes, 1 to 1440, of the access token and of any ID token issued with it, in place of oauth.token_expiry_minutes. It does not affect refresh tokens (oauth.refresh_token_expiry_minutes). It is not the JWT exp claim: the claim is computed from it.
  • extra is 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 with invalid_request and 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, username and the OIDC standard claims such as email or name). So extra={"exp": ...} is refused where exp=15 is a lifetime. A token for a user with other roles is obtained by creating that user (a runtime user, or MCP create_user), not through extra.
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": "..."
}
  • aud is the resource when 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 extra parameter 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.
  • /userinfo answers such a token with {"sub": ...} alone, and /introspect names no username, nor the openid scope it reports for a user token issued without one: a token whose sub equals its client_id is 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_user of users.yaml, with that user's roles; default_user is 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

FactAccess tokenID Token/userinfo (dev)/userinfo (gated)SAML SSOSAML attribute query
emailnonoyesneeds the email scopeyesyes
identity_classyesnoyesyesyesyes
entitlementsyesnononoyesyes
rolesyesnoyesyesopt-in, under the SP's nameopt-in, under the SP's name
groupsyesnoyesyesopt-in, under the SP's nameopt-in, under the SP's name
tenantyesnoyesyesnevernever
source_aclyesnonononoyes
attributesa mapnoa mapa mapone attribute per keyone attribute per key
authoritiesyesnonononono

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:

ProjectionAn empty value under a keyAn entirely empty map
attributes (the OIDC map)keptthe claim is omitted
authorities (the derived list)droppednothing to derive
SAML attributesdroppedno 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_acl and authorities are 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.
  • attributes is 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 /userinfo returns an attributes member. What it is not is resolver-addressable: resolve_user_claim does not know the composite map, so a claims request for attributes is not answered by the resolver.
  • An individual custom attribute is resolver-addressable: a user with a department attribute answers a claims request for department.

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:

BindingHTTP MethodSAMLRequest Encoding
HTTP-POSTPOSTBase64 only (uncompressed)
HTTP-RedirectGETDEFLATE 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.

ModeGET with uncompressed dataGET with DEFLATEPOST uncompressed
Lenient (default)AcceptedAcceptedAccepted
StrictRejected (400)AcceptedAccepted

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:

SPTypical roles attribute name
Spring Securityroles (whatever your AttributeConverter reads)
ADFS / Entra IDhttp://schemas.microsoft.com/ws/2008/06/identity/claims/role
Shibbolethurn:oid:1.3.6.1.4.1.5923.1.5.1.1 (groups / isMemberOf)
Generic LDAP-stylememberOf

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]&SigAlg fragment (SAML 2.0 Bindings §3.4.4.1); rsa-sha256, rsa-sha512 and legacy rsa-sha1 SigAlg 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.

ValueAlgorithmUse Case
exc_c14n (default)Exclusive C14N 1.0Standard for SAML, handles namespace isolation
c14nC14N 1.0Legacy SAML implementations
c14n11C14N 1.1Newer 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:

AspectSSO assertionAttribute-query assertionWhy
source_aclnot exportedexportedthe query surface exists for backend authorization lookups; a login assertion carries no document-level ACLs
AuthnStatement / AuthnContextClassRefpresentabsentan attribute lookup is not an authentication event; asserting one would be false
SubjectConfirmationpresent (bearer, 5-minute window)absentties an assertion to a login exchange the query never had
AudienceRestrictionthe requesting service provider: the AuthnRequest's Issuer (SAML Profiles §4.1.4.2); oauth.audience for a request that names noneabsentthe query requester's audience is unknown (the endpoint is unauthenticated by design)
Conditions validity5 minutes1 houra 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/@Destinationthe ACS URLabsentonly a login assertion is delivered to an endpoint the IdP was told about
InResponseTothe AuthnRequest's ID when available; absent when no usable request ID is availablealways/saml/sso answers no login without an AuthnRequest; an attribute query always answers one
ds namespacedeclared on the envelopeabsentthe SSO document is signed in place, and the prefix is declared whether or not signing runs
Serializationbytes, with an XML declarationunicode, pretty-printedhistorical, and part of what the SP receives, so it is pinned rather than unified
Signinginline in the buildera separate _sign_attribute_query_responsethe 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

ToolDescription
list_usersList all configured users
get_userGet details of a specific user
create_userCreate a new user
create_persona_userCreate a password-less user for persona login mode (local dev/testing convenience)
update_userUpdate an existing user (password, email, roles, …)
delete_userDelete a user
generate_tokenGenerate 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_tokenDecode JWT token (without verification)
verify_tokenVerify 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_clientsList OAuth clients
get_clientGet client details
create_clientCreate a new OAuth client
update_clientUpdate an existing OAuth client
delete_clientDelete an OAuth client
get_settingsGet current IdP settings
update_settingsUpdate IdP settings
save_configPersist the current configuration to the YAML files, optionally guarded by expected_users_revision / expected_settings_revision (see below)
reload_configReload configuration from files (the response carries fresh users_revision / settings_revision)
validate_configLint 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_discoveryGet OIDC discovery document (same document as /.well-known/openid-configuration)
get_jwksGet JSON Web Key Set
get_audit_logGet audit log entries (filter by limit, event type, username)
get_audit_statsGet audit statistics
clear_audit_logClear the audit log
get_keys_infoGet signing key info (active kid, previous keys)
rotate_keysRotate 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_* and update_settings change the MCP server's in-memory copy only. The tools answering from that copy see the change immediately: list_users, get_user, and generate_token for a user created a moment ago. Tokens are signed with the keys in keys_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, until save_config writes 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_config rebuilds the MCP server's copy from the files, dropping every change not saved yet. save_config writes the whole copy: every pending user, client and setting at once, not only the last change.
  • get_audit_log, get_audit_stats and clear_audit_log act 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.

  1. 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.
  2. 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=token was advertised but unimplemented, and was removed rather than implemented).
  3. 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.
  4. 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.
  5. Features ship whole. A feature lands together with its MCP exposure (where applicable), its e2e/test_agent.py e2e coverage and its docs, in the same PR.
  6. 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.
  7. 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-mcp process 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:

  1. OAuth 2.1 profile: the opt-in oauth21 profile aligns nanoidp's strictest behavior with draft OAuth 2.1: PKCE required everywhere, refresh token rotation, S256 only.
  2. SAML hardening: optional validation of signed AuthnRequests, for testing SPs that sign their requests.
  3. Typing strictness and CI quality gates: an annotated src/ under a strict mypy baseline, and a coverage threshold enforced in CI.
  4. 3.0 breaking cleanups: the deferred breaking changes went out with 3.0.0.
  5. 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".
  6. 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.
  7. 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.
  8. 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]):

  1. 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.
  2. serialization.py has 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:

ModuleWhat it is
app.pycreate_app(): configures the process-wide Flask application (one instance per process, see CONTRIBUTING), blueprint registration, session cookie policy, startup warnings
__main__.pyThe 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.pyThe nanoidp wizard interactive configuration builder

HTTP surfaces (routes/), one blueprint per protocol surface:

ModuleWhat it is
routes/oauth.pyOAuth2/OIDC: /authorize, /token (one _grant_* helper per grant), /userinfo, discovery, JWKS, introspection, revocation, device flow
routes/saml.pySAML 2.0 IdP: metadata, SSO, SLO, AttributeQuery
routes/ui.pyThe config web dashboard: HTML form flows for users, clients, settings, keys, claims, audit
routes/api.pyJSON management API (/api/*): health, config, users, keys, audit

Shared route infrastructure, all underscore-prefixed:

ModuleWhat it is
routes/_auth.pyThe 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.pyEffective-issuer resolution, shared by every endpoint that mints a token or advertises the issuer (#133: they must never disagree)
routes/_audit.pyaudit_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:

ModuleWhat it is
services/token.pyJWT building: access tokens, ID Tokens, the /token response body
services/userinfo.pyWhat 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.pyHow 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.pyWhat an introspection reports about a token (RFC 7662 §2.2), including which client it names and the scope it defaults to
services/identities.pyThe 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.pyThe 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.pyThe 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.pyThe 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.pyThe 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.pyAuthorization 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.pyDevice 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.pyRevoked 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.pyKey management: generation, rotation, JWKS, external key import
services/key_directory.pyThe 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.pySingle source of the OIDC discovery document (HTTP and MCP both render this; metadata never lies)
services/redirect_uri.pyRedirect-URI registration matching, including RFC 8252 native-app rules
services/saml_verification.pySigned-AuthnRequest verification
services/saml_assertion.pyThe 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.pyThe 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.pyWrites 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:

ModuleWhat it is
config.pyConfigManager: 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.pyConfigFileStore: 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.pyPydantic document models mirroring the YAML sections one to one; to_settings() / to_users() build the domain objects
config_schema.pyJSON Schema generated from the document models; docs/schema/config.v1.json is the committed artifact and a test fails when they diverge
config_validation.pynanoidp validate-config: lint a config directory without starting anything (hooks never execute)
hooks.pyHookRegistry: the extension points (on_before_load, on_config_saved, on_audit_event), shell hooks, entry-point plugins, the bootstrap surface
models.pyThe domain dataclass-style models: Settings, User, OAuthClient
serialization.pyDomain 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.pyThe exception taxonomy
branding.pyPer-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 optional bootstrap.yaml for 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 through services/yaml_writer.py; the MCP save_config tool and reloads persist whole documents through ConfigManager.save(). Both delegate their entry-building to serialization.py, so the two paths cannot drift on content, and both write through the same config_writer.compare_and_replace pipeline (#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 to save_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 a secret_key all accept, and the /authorize transaction 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; sqlite keeps 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, and Settings wiring 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:

  1. models.py: the field on OAuthClient, with its description.
  2. config_documents.py: the field on ClientEntry and its coercion in to_client().
  3. serialization.py client_to_yaml: write it when non-empty.
  4. serialization.py merge_client_entry: merge it on edit.
  5. templates/clients_form.html: the form control.
  6. routes/ui.py client create: parse it.
  7. routes/ui.py client edit: parse it.
  8. routes/ui.py regenerate-secret: carry it through (the #32 leg).
  9. mcp_server.py _client_to_dict: expose it.
  10. mcp_server.py create_client tool schema.
  11. mcp_server.py update_client tool schema.
  12. mcp_server.py create/update handlers: normalize and apply it.
  13. 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.py is the end-to-end agent: it drives a real server over HTTP and MCP the way an agent would. CI runs it in the http-e2e and mcp-e2e jobs, and e2e/shared_store_e2e.py in the shared-store-e2e job, 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 by tests/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_user of users.yaml, with that user's sub, roles, groups, tenant and attributes; now sub is the client_id and the token carries no user claims. Migration: a resource server that read roles from these tokens sees none now: authorise on scope and identify the caller by sub or client_id. default_user is deprecated (under Deprecated): a file that carries it still loads, with a warning.
  • extra adds 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 with invalid_request. Migration: a test that set roles or exp through extra gets a 400 now; create a user with the roles wanted instead, and use the form parameter exp (a lifetime in minutes) for the expiry.
  • A SAML assertion's Audience is the requesting service provider (#443, under Fixed), the AuthnRequest's Issuer, where it was oauth.audience for every provider. Migration: a provider whose entity ID had been set to oauth.audience to make the check pass can use its own again; a request that names no Issuer is answered as before.
  • Four settings keys that were accepted and ignored now take effect, two are reported as unknown (#441, under Added). A settings.yaml that carries oauth.refresh_token_expiry_minutes, device_flow.code_expiry_seconds, device_flow.polling_interval or cors_allowed_origins starts 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 as http://localhost:* in the origins fail the load. logging.format and session.permanent are unknown keys, a warning by default and a refusal under config_validation: strict. Migration: run nanoidp validate-config against the directory before upgrading, and check that a cors_allowed_origins list 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 kid among the active key and the previous ones kept, as the documentation always said; the private key is written with mode 0600; POST /api/keys/rotate and the MCP rotate_keys tool refuse with a fixed message and a kind, Retry-After only on the retryable lock_timeout 503, not on lock_unsupported. Migration: a test that expected the refresh grant or /userinfo to fail right after a rotation expects the opposite now; a client that matched on the refusal's text matches on kind.
  • For code that embeds nanoidp, the runtime state moved behind one store boundary and some names went with it (under Changed): services/runtime_identities.py is services/runtime_store.py, MemoryRuntimeIdentityStore is MemoryRuntimeStore and get_runtime_identity_store() is get_runtime_store(), with no alias; AuditLog() no longer takes max_entries; get_auth_code_store() returns a view each time; DeviceCodeGrant takes its device_code as a required keyword; IdentityResolver.runtime_client_lifecycle() is removed and create_runtime_client_entry() returns the stored entry with its instance_id; repository(name, key_of, codec) takes the codec; services.activate_services is the activation both the app and the MCP server pass to init_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: sqlite with runtime.path in settings.yaml, for HTTP workers and a separate nanoidp-mcp process 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; the shared-store-e2e job 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. path is required with sqlite and refused with memory; 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, kind activation). GET /api/config and MCP get_settings report runtime.store and 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.db and runtime-audit.db (the audit does not wait for the repositories, and a SQLite file has one writer), plus the owners' leases under runtime-owners/, all private (0600, the directory 0700); 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 with synchronous=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 is 503 runtime_store_unavailable with Retry-After over HTTP and MCP_RUNTIME_STORE_UNAVAILABLE with retryable: true in MCP, the first call included.
    • The configuration files are the truth the processes agree on. When the store is shared, every request (/health, /api/health and static files excepted) and every MCP tool call looks at the two files first: a stat is 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 answer 503 configuration_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 (503 configuration_unloadable, or configuration_unavailable with Retry-After when 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_recovered with 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) and device_flow.polling_interval (default 5; 1 to 60) now set what they name, and cors_allowed_origins sets 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/config and MCP get_settings report all four, and /api/config also reports cors_applied_origins, the origins the server applies, since CORS is set up at startup; a "*" under stricter-dev is logged as a warning. The settings page edits the refresh token lifetime, MCP update_settings that and the two device flow values. A short device_flow.code_expiry_seconds makes expired_token testable 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. No config_version bump: 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-flow has 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-credentials gives 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 carried admin's identity through default_user (#445).
    • Test a Spring Boot SAML service provider without a real IdP: examples/spring-boot-saml carries 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-actions sets 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.

Deprecated

  • default_user in users.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, in strict mode too, with a warning that it can be removed; validate-config reports it as info, which --strict does not fail on; nanoidp init, the wizard and the shipped presets no longer write it, a save no longer manages it, MCP list_users no 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_user of users.yaml, as if that user had logged in: sub was the user's name and the token carried the user's roles, authorities, tenant, identity_class, entitlements, groups, source_acl and attributes; with the shipped configuration every service's token said sub: admin with ROLE_ADMIN, and /userinfo answered such a token with the admin's profile. Now sub is the client_id (RFC 9068 §2.2) and the token carries no user claims; /userinfo answers a token whose sub equals its client_id with the subject alone, and /introspect names no username for it, and no scope when 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 of default_user and 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, RevocationStore and AuditLog kept 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.py states the contract: each object is kept in an entry with an instance_id the 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 an expires_at that 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_if and create_within are 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 limit is none at all, where -1 dropped the last event; audit state is by value; the bound is the backend's (audit_store.MAX_AUDIT_ENTRIES) and every AuditLog() 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_authorization answers 503 with Retry-After, as for a full store. A grant past its time still answers expired_token for 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": 5 in a logout hint revokes nothing rather than the token "5". Revoking again never shortens.
    • a PKCE verifier that is not ASCII is the invalid_grant any wrong verifier gets, where it was a 500; a list given as amr is 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 answer 409, 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.
  • Generated signing keys are one bundle for every process that shares the keys directory, and a token is verified against the key its kid names (#420). Measured before: two processes cold-starting on one empty jwt.keys_dir ended with different signing keys, twelve times out of twelve; a process killed in the middle of a rotation left kid.txt naming 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_jwt decoding 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, with kid.txt as 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 by kid among the active key and the previous ones kept, no wider than jwt.max_previous_keys and so the same set the JWKS shows, a kid that is nobody's being an invalid token and a token with no kid checked against the active key, as before; external keys verify with their one key whatever kid a token names. A peer's rotation is noticed in get_crypto_service(), through a stat of kid.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 mode 0600 (it followed the umask). The layout and the file names are unchanged.

  • POST /api/keys/rotate and the MCP rotate_keys tool refuse with a fixed message and a kind; 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, or lock_namespace_unavailable when the lock file cannot even exist, for a keys directory this process cannot write (409); lock_timeout or lock_unsupported for 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-After on the 503 is sent for lock_timeout only, 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_unloadable and runtime_store_unavailable carried the exception's text in error_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 and Retry-After are 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-pkce preset declares a real public client. spa-client carried a placeholder secret from before public clients existed (#188); it is now token_endpoint_auth_method: "none" with its redirect URIs registered, so PKCE S256 is required and /authorize matches the redirect URI exactly. The access token's aud is spa-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 /token carried Cache-Control: no-store or Pragma: no-cache, which RFC 6749 §5.1 requires on every response carrying tokens, credentials or other sensitive information (the OpenID conformance suite's oidcc-refresh-token failed on it). Every response returned by the views of /token, /device_authorization, /userinfo, /introspect and POST /api/users/{username}/token now 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 said no-store, say Pragma: no-cache too. 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 (sub is the username, or the client_id for client credentials). The MCP get_oidc_discovery tool serves the same document.
  • A SAML assertion's Audience is the requesting service provider (#443). Every SSO assertion carried oauth.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. The Audience is now the AuthnRequest's Issuer; a request that names none, outside the profile, is answered as before with oauth.audience. The audit event records the Issuer received and the Audience issued.
  • stricter-dev CORS is localhost, and nothing that starts like it (#441 review). The profile's default origins were http://localhost:* and http://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.io and http://127a0b0c1.test were 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, /device showed "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

  • extra can no longer change what the grant decided (#451). The extra parameter of /token was merged into the access token after the registered claims on every grant except client credentials: a client could mint itself a token with another sub, iss, aud, an exp in 2100, a jti of its choosing, other roles or tenant, and /introspect reported it active for that subject. The MCP tool generate_token, unbound, could add a client_id no binding decided. Now extra adds claims and nothing else: a request that names a registered claim, a protocol claim (client_id included), a claim the server reads back or a claim about the user is refused with invalid_request and 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. extra and exp are 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_type claim, which extra could set on any access token: a client could stamp token_type: refresh on 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 requires token_use: refresh, which the server sets last on every token it issues and extra cannot 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's client_secret, or DELETE /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 /register looks 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_login on, 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_keys and jwt.max_previous_keys now take effect (#358). Both were documented and silently ignored. A settings.yaml that already carries them starts signing with the operator's key on upgrade: the kid and the JWKS change, tokens signed by the previously generated key stop verifying, POST /api/keys/rotate answers 409, 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_algorithm is 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: run nanoidp validate-config against 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 with error: configuration rejected: ... instead of a traceback.
  • validate-config has 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 answers 503 with the classified reason.
  • claims_supported no longer lists source_acl and authorities (#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_settings used to apply some of them and report success.
  • POST /authorize without transaction_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 the transaction_id the 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._config no longer exists; get_crypto_service() takes no argument and init_crypto_service is replaced by activate_crypto_service, passed as init_config(..., activate=...); ConfigManager.authenticate, interactive_authenticate and check_client moved to IdentityResolver (identities_for(config)); a failed load raises nanoidp.config.ConfigurationRejected, a ValueError. 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 touching users.yaml or settings.yaml: POST /api/runtime/users and /api/runtime/clients (the body is a declared entry, validated by the same models), GET and DELETE per object, DELETE /api/runtime for all of them (answers the counts), and POST .../promote to 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 answers 409; a reload that declares a runtime object's name removes it with a warning and an audit event; a promotion records exactly one runtime_identity_promoted event, 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/config and the MCP server stay on the declared configuration. Writes follow the management_secret gate 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 /register issues a client from the metadata a host sends and is open when enabled: the flag is the gate, not management_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 by GET /api/runtime/clients with "source": "dcr", gone on restart, and written to settings.yaml only 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 answers 429 registration_limit_reached, a nanoidp name, since RFC 7591's error codes describe metadata. grant_types are validated and echoed but do not restrict the client: nanoidp has no per-client grant enforcement, which is also why redirect_uris are required for every registration and not only for the authorization code grant. With rate_limit_enabled, the rate configured for /token applies to /register as well. The flag is deliberately absent from the settings form and from the MCP update_settings tool.
  • RFC 8414 authorization server metadata (#190). /.well-known/oauth-authorization-server serves the same document as /.well-known/openid-configuration, from the same builder and the same issuer resolution, so issuer_from_request applies 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_endpoint is 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): IdentityResolver resolves 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: an https client_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 in services/client_metadata.py as pure functions, with the cache as a repository the runtime store lends them. A document authenticates with none and nothing else, since the draft forbids every shared-secret method and those are two of the three nanoidp supports. oauth.client_id_metadata_documents.enabled is off by default and set in the file only, like dynamic_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 /token and 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 are UserOrigin and ClientOrigin, 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_documents gains allowed_hosts (exact DNS names, empty by default, so nothing is fetched until an operator names a host) and allow_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 and Host, 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 dishonest Content-Length changes 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 what ssl applies 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 to ipaddress: 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. Only max-age is read from Cache-Control; no-store and no-cache answer 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 /authorize and /token, /authorize refuses such an authorization request rather than issue a code for a client /token could not resolve.
  • A client ID metadata document is fetched at /authorize (#196, last part). An https client_id that no declared and no runtime client holds is looked up: the document is fetched, validated, cached, and the client resolves from then on with origin: cimd. client_id_metadata_document_supported is advertised while the feature is on. Only /authorize fetches: /token and 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 same invalid_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. An allowed_hosts entry 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, since GET /api/audit is 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 short max-age nor 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 as temporarily_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_authorization answers such a client_id exactly as it answers a name nobody knows.

Changed

  • /authorize keeps 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 with transaction_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 without transaction_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 gets temporarily_unavailable rather than any pending one being dropped.

  • The TOTP code screen of /login, /saml/sso and /device no 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 device user_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 /device a deny from the code screen discards it too and still needs no credentials; the user_code is 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 through nanoidp.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/runtime entry. For code embedding nanoidp: ConfigManager.authenticate, interactive_authenticate and check_client moved to IdentityResolver (identities_for(config)); ConfigManager.get_user and get_client stay, and return declared objects only; init_config gains after_load, which create_app uses for the reconciliation.

  • One ConfigManager per process (#230). The MCP server no longer keeps a configuration global of its own next to nanoidp.config's; its tools, the HTTP routes and the token service resolve the same manager, and TokenService takes 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._config no longer exists (use nanoidp.config.init_config), and TokenService() 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"} from POST /api/config/reload, and an error result with the same kind from MCP reload_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 its 503. At startup the same conditions print error: configuration rejected: ... and exit 1 instead of a traceback. For code embedding nanoidp: a failed load raises nanoidp.config.ConfigurationRejected (a ValueError, with the original error as its cause); get_crypto_service() takes no argument and returns the service the configuration published; init_crypto_service is replaced by the activation step activate_crypto_service, passed as init_config(..., activate=...).

  • A configuration directory is read as one observation, not several (#246, first part). settings.yaml and users.yaml were 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 through ConfigFileStore, 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-config observes the directory once (#246, second part). A run read settings.yaml three 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-save settings.yaml with the post-save users.yaml and report a cross-file config_version disagreement 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 one ConfigFileStore snapshot, 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, since NANOIDP_BOOTSTRAP_HOOK exists 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-config gains a third outcome. A run that could not observe the directory at all, because a save held the lock past the timeout, is UNAVAILABLE with exit code 2, not invalid with 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 as status: unavailable beside its existing valid. 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_supported no longer advertises source_acl and authorities (#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. attributes stays advertised: it is a Claim Name and /userinfo supplies it as a composite member, even though the resolver does not address it, so a claims request 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 required list and the wider vocabulary it defines (an empty client_secret or branding colour still means "none"/"clear it", an empty get_audit_log username still means "no filter", and the handler answers those). The schemas gained 16 constraints the models already enforced and nobody advertised: minLength on every name and secret that has no wider tool vocabulary, and the range of token_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_settings no longer applies a value the model would refuse (#297). It writes its arguments onto Settings with setattr, and Settings has no validate_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_settings with token_expiry_minutes: 0 or 99999 was applied and reported as a success; both are now refused before dispatch.

  • saml.c14n_algorithm is a closed set (#297), in the models and in the configuration document. An unknown value used to reach routes/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.mode was already a closed set on the Settings model and only moves into the document type here.)

  • Every /saml/attribute-query outcome is attributable to its sender (#309). A query refused for its shape - not well-formed, no AttributeQuery, no Subject, no NameID - used to write no audit entry at all, and the query's own ID was read only after those three were found, so a refused request could not be matched to whoever sent it. Each outcome now writes a saml_attribute_query entry carrying request_id and content_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 under verbose_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 in InResponseTo is 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 lxml parser 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 Response envelope, the Issuer pair, the Status element 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 from services/saml_assertion.py now. What is NOT shared is what actually differs, and the census found six differences that were declared nowhere and covered by no test: the Conditions window (five minutes against one hour), Destination, whether InResponseTo is conditional, the ds namespace, 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 a ttl argument 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). "none drops the secret" was written four times - the UI create and edit forms, MCP create_client and update_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 in services/client_policy.py now. 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 /userinfo returns and what /introspect reports 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 of Settings next 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_step and login.totp were hand-coded below the loop that drives every other key from OWNED_SETTINGS, so the parity test that holds a setting to every surface did not cover them; a new login.* 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 an omit_when_default mode 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 defaults serialization.py keeps (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 after POST /api/config/reload had moved keys_dir, every grant at /token and POST /api/users/<username>/token kept signing with the old key while the JWKS, introspection and /userinfo already 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/reload accepted a jwt.keys_dir the process could not create and answered 200, after which the JWKS answered 500 and /token failed (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, on POST /api/config/reload, on MCP reload_config and 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 new keys_dir over each other.
  • jwt.external_keys and jwt.max_previous_keys are read from settings.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_key and public_key are given together; a missing, unreadable or malformed key file, or a public key that does not belong to the private key, rejects the configuration. Without kid, 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 (409 from POST /api/keys/rotate, an error on the keys page and from MCP rotate_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 lowered max_previous_keys trims 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.yaml the 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 blank oauth.audience or oauth.issuer) and from the MCP tools, where update_settings writes onto a model without validate_assignment and save_config persisted the result, so an issuer refused 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 init and the setup wizard go through it too: they validated the document model but not the domain rules, so an issuer like localhost:8000 finished 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_version included: 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 verified GET /saml/sso was 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: none while 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 init writes included, and for a longer one it is 12 characters of it. The page is a read, and session.management_secret gates mutations only: on an instance with the management gate on and require_ui_login off, a caller who could change nothing could read a client's secret off the page and authenticate as that client at /token, /introspect and /revoke. The column now shows one fixed mask for every confidential client, declared, created through /api/runtime or dynamically registered, and the mask does not vary with the secret's length. The preview is in every release; the boundary it crossed exists since management_secret shipped in 2.8.0, so 2.8.0 and later are affected. require_ui_login narrows who could read the page and nothing more: it is a login, not a role, so any users.yaml account could, and under login_mode: persona anyone. 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 /test page prints the literal demo-client / demo-secret pair of nanoidp 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: true requires a time-based one-time code after a successful password check, across every interactive login surface - /authorize, /login, /saml/sso and the device flow's /device - the same surfaces login.two_step covers, riding its phase machinery. A declarative demo factor, not IdP hardening: the secret is a plain totp_secret field of the user entry in users.yaml (its presence is the enrolment, no per-user enabled flag, ${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 under login.mode: persona, which checks no password; the OAuth password grant and client credentials are unaffected. While login.totp is on, the ID Token carries an OIDC amr claim (RFC 8176): ["pwd"] for a plain password login, ["pwd", "otp"] once the code is verified, preserved across a refresh like auth_time; with it off no new claim appears. A third SAML AuthnContextClassRef value, 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_secret is now a reserved top-level user key (before, an unknown key of that name was folded into the user's attributes), and amr joined the reserved claim names, so a user attribute named amr requested through the claims parameter is no longer resolved, whether login.totp is 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 on POST /api/config/reload and the MCP server's log on reload_config. Affected: a client_secret, session.secret_key or session.management_secret that YAML reads as a number, a management_secret rejected for a non-ASCII character (that one also appeared in nanoidp validate-config and in both MCP tool results), and the settings of every plugin when one plugins: entry is malformed, in settings.yaml or bootstrap.yaml. The three document roots and the Settings and OAuthClient models now hide the input (UserEntry keeps its own flag as defense in depth); the error still names the file, the field and the rule. #350 had closed the same gap for users.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/keys and /app/config owned 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 the restricted Pod Security Standard rejected the previous image on runAsNonRoot; the Helm chart's default securityContext now sets runAsNonRoot: true and is admitted there. It pins no runAsUser (#339): the standard does not need one, and a platform that assigns its own uid per namespace (OpenShift's restricted-v2) would reject a pinned 1000 while the image runs fine under the assigned uid. A plain docker run with 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 with Permission denied: 'keys/previous'. Chown it once: docker run --rm -v nanoidp-keys:/k alpine chown -R 1000:0 /k (or chown -R 1000:0 on the host directory of a bind mount). A read-only config mount needs nothing. To run an older image tag with the chart, set securityContext.runAsNonRoot=false and securityContext.runAsUser=0.
  • /authorize contract 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 single POST /authorize body 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: true collects the username first and the password on a second screen, across every interactive login surface - /authorize, /login, /saml/sso and the device flow's /device. A global setting alongside login.mode/login.auto_login, not a per-client one; inert under login.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 to oci://ghcr.io/cdelmonte-zg/charts/nanoidp on every release tag by the new Publish Helm chart workflow, with the chart version taken from the tag. Single replica by design, configuration as whole users.yaml/ settings.yaml files in a generated Secret (or one you manage), Ingress with ${INGRESS_URL} for the issuer, a checksum/config annotation that rolls the pod on a configuration change, and a default securityContext admitted by a namespace enforcing the restricted Pod 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 install without --version resolves the newest final release; pre-releases need --devel or an explicit version.
  • n8n end to end, path 1 (#194): examples/agentic-stack/docker-compose.yml runs nanoidp, the RFC 9728 mock MCP server and a pinned n8n (2.38.7) on one network, and e2e/n8n_e2e.py drives it headless: n8n's own OAuth2 flow (PKCE, public client, RFC 8707 resource) against nanoidp, then a workflow whose MCP Client node calls read_document with 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.py asserts that the agent offers the model the tools the resource server lists, calls read_document with the token n8n obtained and quotes the document; that an insufficient_scope refusal comes back to the model as the tool's result rather than as a crash; and that a wrong-audience token stops the agent at tools/list, before the model is called. The Compose file gains an optional ollama profile 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 conftest reset 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 what get_crypto_service reads 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.yml and 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) before helm push, and fails instead of publishing a chart that runs nothing. On the chart-only dispatch path the same wait confirms the pinned image.tag exists.
  • The login session has a single writer (#301). The dashboard's /login and the SAML SSO inline login each used to set session['user'] and session['auth_method'] by hand, and the SAML assertion's AuthnContextClassRef is 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.update or session.setdefault naming the key, the 'auth_method' literal anywhere), so recording a user without recording the method, which would have made its persona logins silently claim PasswordProtectedTransport, fails the suite rather than passing unnoticed. No behavior changes for existing logins.

Fixed

  • Rejected /authorize requests 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 /authorize request 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 omitted state, nonce, PKCE, claims, resource, and scope values 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.
  • /authorize POST 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, and resource are now read from the query string on both legs - the POST's own (the login form has no action, 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 single POST /authorize that 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 carries username/password, and a POST already routed through the preceding GET's page (the common case) is unaffected.
  • login_hint is 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_method is now enforced at every client-authenticated endpoint (#188, #259, #262): /token, /introspect, /revoke and /device_authorization.

    • A confidential client must authenticate on authorization_code too - 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 as token_endpoint_auth_method: "none" if it cannot keep a secret (and use PKCE).
    • The registered method decides the channel: a client_secret_basic client must use HTTP Basic and a client_secret_post client must use the request body - the wrong channel is rejected with invalid_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 /revoke and /device_authorization to the token-endpoint method, while RFC 7662 permits client authentication at /introspect but 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; set token_endpoint_auth_method to match how your client actually authenticates.
    • Two authentication methods in one request are rejected (HTTP Basic and a body client_secret together, RFC 6749 §2.3) - Basic no longer silently wins. Migration: send credentials one way only.
    • Public-client policy: /introspect refuses public clients (RFC 7662), /revoke keeps its RFC 7009 §2.1 ownership relaxation, and /device_authorization now accepts a public client by client_id alone (RFC 8628, see the device-flow entry below).
  • /authorize reports errors after redirect_uri validation by redirecting to the client (#189, RFC 6749 §4.1.2.1 / RFC 9207): unsupported_response_type, invalid_scope, PKCE errors and invalid_target are now 302 redirects carrying error, error_description, state and iss, not a local JSON 400. Errors before redirect_uri is validated (unknown client, missing/malformed/unregistered redirect_uri) still return JSON locally. Migration: a client that parsed the 400 body should read the error from the redirect query instead - which is what a spec-compliant client already does.

  • A refresh token without a client_id binding 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 MCP generate_token tool gains an optional client_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 endpoint POST /api/users/<username>/token gets the same treatment - a new optional client_id binds the token, and it no longer returns a refresh_token when unbound - so all three token minters (a grant, the MCP tool, this endpoint) agree.

  • One request, one client identity (#277). /introspect, /revoke and /device_authorization now resolve the requesting client exactly as /token always has: HTTP Basic naming client A plus client_id=B in the body is one request claiming two identities and is rejected with invalid_client (previously the Basic username silently won and the body value was ignored). Migration: drop the contradictory body client_id, or make it match the authenticated client.

  • The device_code grant re-validates the stored scope at redemption (#276): a scope removed from the client's allowed_scopes (or from scopes_supported) between /device_authorization and the poll now fails the poll with invalid_scope, exactly as the authorization_code and refresh_token grants already re-check theirs. Previously the stale scope was still minted.

  • /saml/attribute-query answers an unknown NameID with a SAML error status (#275): top-level Requester with subordinate UnknownPrincipal, 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 exp claim is required on every token nanoidp accepts (#306). verify_jwt now enforces require: ["exp"] - a correctly signed JWT WITHOUT an expiry is rejected everywhere (/userinfo, /introspect, /revoke, the refresh grant, MCP verify_token). This is nanoidp's token-profile policy, not a JWT-spec rule (RFC 7519 leaves exp optional; 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 carried exp; only hand-crafted tokens signed with the nanoidp key are affected. Migration: add an exp to 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 /token error 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 via abort(). Now: missing or malformed parameters are invalid_request (400); a bad, expired, revoked or foreign code/refresh-token - and invalid resource-owner credentials on the password grant - are invalid_grant (400); an unknown or profile-disabled grant type is unsupported_grant_type (400); client-authentication failures are invalid_client - 401 with the WWW-Authenticate: Basic challenge 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 a client_secret_post client anyway). The attempt is detected from the raw Authorization header (#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 with error: invalid_grant - §5.2 reserves 401 for client authentication; read error from the JSON body instead of matching HTML.

Changed

  • mcp_server is a package (#286): the 2,100-line module is now mcp_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. The nanoidp-mcp entry 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_server now goes through the package's __main__.py) - arbitrary internal symbols of the old monolith are not a compatibility surface. verify_secret moved to a new framework-free nanoidp.security (re-exported from routes/_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-query transport errors are SOAP 1.1 Faults (#287): a malformed query (missing AttributeQuery/Subject/NameID) or an internal failure now answers with a proper soap:Fault (HTTP 500, faultcode Client/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.py declared 20 classes of which exactly one was ever raised; only SAMLSignatureError remains (now a plain Exception subclass). 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_token now owns the #73 mint-side rule (#278): issue_refresh_token defaults to "only when the token is bound" (client_id given), and asking for a refresh token without a binding raises instead of minting a credential /token would reject. The three minting surfaces (grants, MCP generate_token, POST /api/users/<username>/token) behave as before; the rule just lives in one place.
  • The MCP generate_token and verify_token tool descriptions now document their simulation boundary (#279): generate_token stamps scope and resource as 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), and verify_token checks 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 dedicated e2e/ directory. test_agent.py, mock_mcp_server.py, mcp_smoke_test.py and gen_sp_keypair.py now live under e2e/; 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 from python examples/... to python 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 resource is 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 (so https://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 new login.auto_login: true lets an OIDC /authorize request log a configured user in directly - no picker, no HTML - by sending login_hint: persona-auto-login:USERNAME, for driving a real OIDC client library in automated integration tests. Any other login_hint is 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, state preserved), never a bare 400. First implementation surface is OIDC /authorize only; 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/config login block (which also picked up the pre-existing login_mode field it was missing since persona mode shipped), and an examples/persona-login walkthrough. 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 of TokenService.create_token (file::function, AST-checked) must equal a declared registry, and every (surface, policy) pair - client binding, scope ceiling, resource ceiling - must declare enforced (behaviorally asserted) or exempt (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 "no attributes input in the UI form" (the dynamic attr_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 /authorize login card composition (#249). New per-client layout field, "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 MCP create_client/update_client tools; 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 its client_id alone at /device_authorization (§3.1) and again when polling /token (§3.4), with no secret. The issued device_code is bound to that client_id and 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 plain 503 (with Retry-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.py is a minimal MCP Streamable HTTP resource server (the mcp SDK'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-resource document naming nanoidp as its authorization server, and answers an unauthenticated call with 401 + WWW-Authenticate pointing 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 9728 403 WWW-Authenticate: Bearer error="insufficient_scope" + resource_metadata challenge before any tool runs; and an application-level per-tool check inside each tool for the finer documents:write / admin operations, which surfaces as an in-band MCP tool error. e2e/test_agent.py gains an --mcp suite that drives the whole loop deterministically as the MCP client (401 -> RFC 9728 discovery -> /authorize with PKCE and resource= -> /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 with 401 at the transport (asserted at the HTTP layer); the conformant 403 insufficient-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 until exp (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's kid in its published JWKS. Adds the mcp-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: iss on the authorization response (#189). /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 (MCP 2026-07-28 recommends this). The value is the per-request effective issuer, so it stays correct under issuer_from_request (#126). iss is delivered exactly when discovery advertises authorization_response_iss_parameter_supported: one condition drives both, so metadata and behaviour never disagree. RFC 9207 requires an https issuer with a host and no query or fragment, so the default http://localhost:8000 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. Related behaviour change: authorization errors that occur after the redirect_uri is 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-code response_type) is validated after the redirect_uri too, so it redirects as well. Errors before the redirect_uri is trusted (unknown client, malformed or unregistered redirect_uri) stay local JSON. A redirect_uri that 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: resource binds the access token audience (#187). A client may send one or more resource parameters on /authorize, /token (every grant) and /device_authorization; the access token's aud is then those resources (a plain string for one, an array for several) instead of the global oauth.audience, so a token minted for one MCP server is rejected by another and a wrong-audience test can finally be written. A resource must be an absolute URI without a fragment or the request is invalid_target (RFC 8707 §2). New 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). The authorization code and refresh token remember the bound resources; a /token request 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 no resource leaves aud at oauth.audience - no change for existing clients. /introspect reports the token's aud, and now verifies a token's signature without pinning its audience (so a resource-bound token can be introspected and revoked); /userinfo still requires the OP audience. No resource_indicators_supported discovery metadata (RFC 8707 defines none). Full support across settings.yaml (allowed_resources), the UI client form and the MCP create_client/update_client tools.
  • 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 with token_endpoint_auth_method: "none": client_secret becomes optional (ignored - and never a credential - if present), and /token identifies the client by client_id alone. The protections that stand in for client authentication are enforced regardless of profile: /authorize requires PKCE with S256 (OAuth 2.1 §7.5.1), client_credentials is refused with unauthorized_client, 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 client_id claim must match; otherwise still 200, nothing revoked - RFC 7009 §2.1 and its privacy guidance). /introspect deliberately stays authenticated (RFC 7662) and its discovery list does not gain none; the token and revocation lists do. Full support across settings.yaml, the UI client form, and the MCP create_client/update_client tools (client_secret no longer required when the method is none). At /token the registered method is enforced, not just recorded (RFC 7591): a client_secret_basic client must present its secret over HTTP Basic and a client_secret_post client in the request body - the wrong channel is rejected with invalid_client. client_secret_post is now validated at /token, /introspect, /revoke and /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, including authorization_code (RFC 6749 §3.2.1): the code exchange no longer had a client-authentication exemption - a confidential client doing authorization_code + PKCE must now present its secret or be re-registered as token_endpoint_auth_method: none. And access tokens carry a client_id claim (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 /token is enforced for real (#304): the limiter was constructed with no limits and no view was ever decorated, so rate_limit_enabled: true logged "Rate limiting: enabled (10/minute on /token)" while enforcing nothing - a "metadata never lies" violation. The configured rate_limit_token_endpoint now actually applies to POST /token (429 with a JSON body and Retry-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 unparsable rate_limit_token_endpoint now 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 for stricter-dev: that profile has always forced rate_limit_enabled: true, so its instances now really throttle /token at 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 the AttributeStatement is one helper. Three visible corrections, each finishing an existing rule: the query no longer invents <user>@example.com for a user without an email (#275 - an absent fact is an absent attribute); a custom LIST attribute reaches the XML as one AttributeValue per entry and a comma-bearing STRING is never split (#134 - the query used to ",".join lists and re-split any string with a comma); an empty collection no longer emits an empty Attribute element. The differences that remain between the two surfaces (source_acl only on the query; no AuthnStatement/SubjectConfirmation/ AudienceRestriction on the query assertion) are deliberate and now documented in a table in the SAML reference.
  • RevocationStore entries 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 own exp is kept exactly - tokens minted via /api/users/<u>/token or MCP generate_token take arbitrary lifetimes, so no fixed cap is safe for them; a verified payload WITHOUT an exp claim (which verify_jwt accepts) gets indefinite retention, since a token that never expires can never have its revocation forgotten; callers holding only unverified claims (the /logout id_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 explicit attr_encoding[] (review round 1): string values stay verbatim even when they LOOK like JSON (so the string '["a"]' survives an edit as a string), json rows (container values rendered as JSON) parse back, and rows typed fresh in the browser use auto - 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_user can now update custom attributes (#280): the field was accepted by create_user and returned by every read surface, but the update_user schema and handler silently lacked it - an agent could set attributes at creation and never change them again. The new mapping replaces the whole attributes object, like every other field there.
  • get_crypto_service honours keys_dir on every call (#281): once the singleton existed the argument was silently ignored, so a config reload that changed keys_dir kept 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 a keys_dir change.
  • The wizard and init write configuration atomically and validated (#282): both used to write raw template text with open(), 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_credentials no 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 at grant_type=refresh_token for user-context tokens. The response now has no refresh_token key 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 AssertionConsumerServiceURL against a config whose saml.default_acs_url is blank now gets a 400 naming both missing sources (with a failed saml_request audit entry), instead of rendering an auto-submit form posting to action="" - the IdP's own page.
  • client_credentials no longer 500s when default_user names a missing user (#241): the synthetic service-account fallback was built with an empty password, which User rejects since #158 made password optional with min_length=1. The fallback now carries no password, as it never authenticates with one, and the grant answers with sub=service-account again.
  • The dashboard's Logout button logs the UI session out again (#221). The UI logout route registered the same /logout rule 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 at GET /ui/logout (the button follows automatically via url_for), redirects back to the dashboard, and writes its logout audit 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-secret rebuilt the client with only five fields, silently dropping background_color, header_color and footer_color and resetting show_client_id/show_description to their defaults - the same rebuild-by-hand shape that lost additional_audiences in #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_config conflict-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_user carry users_revision; list_clients/get_client/get_settings carry settings_revision), and save_config accepts them back as expected_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_config and a successful save_config return 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. Because save_config always 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 description on users, shown in the persona login picker (#244): a user in users.yaml can carry an optional description (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 MCP create_user/create_persona_user/update_user tools, and exposed by the read-only /api/users responses. User now validates on assignment as well (validate_assignment=True, the rule OAuthClient has followed since #37), and the MCP update_user tool 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: a description: key already present under a user used to fold into that user's attributes and therefore shipped inside the token's attributes claim; it is now the display-only field and no longer appears in any token.
  • Per-client allowed scopes and invalid_scope (#186). oauth.scopes_supported is the global scope vocabulary (default openid, profile, email, offline_access, also what discovery's scopes_supported now advertises instead of a hardcoded list); a client's new allowed_scopes is an optional subset of it. A requested scope outside the vocabulary is invalid_scope for every client - a small behavior change, since any scope string used to be accepted unchecked; a scope outside a client's own allowed_scopes, when set, is invalid_scope for that client specifically. Enforced at /authorize, every /token grant (including client_credentials, RFC 6749 §4.4, which previously dropped any requested scope entirely) and /device_authorization; an omitted scope defaults to the client's full allowed set, or today's default when unrestricted. Every /token rejection - 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: false is a dev-only escape hatch back to the pre-#186 behavior (any scope string accepted, unchecked); refused outside the dev profile. allowed_scopes is settable from the clients UI form and the MCP create_client/update_client tools, same as additional_audiences/redirect_uris.

Changed

  • ConfigManager.save() writes users.yaml and settings.yaml as 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 own on_config_saved hook, and the running configuration is refreshed from disk exactly once before any hooks.strict failure is raised - matching the write -> notify -> reload_local -> raise contract the web UI's writer already had. Previously a hook failure on users.yaml under hooks.strict left settings.yaml unwritten 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's save_config tool 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's save_config now supplies them (see Added). The runtime refresh can itself fail (an in-memory value that bypassed field validation, since Settings has no validate_assignment, can reach the file and then fail to parse back in) - save() now tells that apart from every other outcome: a new ReloadAfterSaveError means both files ARE written but the runtime could not adopt them, and it never replaces or hides a pending hooks.strict failure, which keeps priority. save_config's MCP response carries a kind for all three failure shapes (conflict, the hook's own kind, or reload_after_save) so a caller can tell them apart without parsing the error text. The advisory cross-process lock now fails as a named LockUnavailableError - instead of a bare OSError or 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 as ConfigManager.save() (#229): save_user, delete_user, save_client, delete_client and the rest of its write methods route through compare_and_replace, and each gained an optional expected_revision precondition. 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.yaml now 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 hidden expected_revision field 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 no expected_revision at 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_secret mutation gate (#163): one shared secret that gates state-changing calls across all three management surfaces - the MCP server (via the existing admin_secret tool argument, which now reads from this setting instead of a standalone env var), /api/* (via a new X-Management-Secret request header on mutating calls), and the config web UI (a one-time "unlock" form at /login that then trusts the session for further mutating requests). Off by default - unset, nothing changes. Configurable via settings.yaml's session.management_secret or the NANOIDP_MANAGEMENT_SECRET env var; the previous MCP-only NANOIDP_MCP_ADMIN_SECRET still works as an alias, though an explicit management_secret: null/"" in settings.yaml now wins over either env var rather than falling through to it. Independent of require_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 when require_ui_login is also on. YAML-only, same treatment as require_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 knowing secret_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 the ConfigManager actually serving the request.

2.7.0 - 2026-08-25

Changed

  • Configuration files load through document models (#175, piece 2). settings.yaml and users.yaml are now parsed into Pydantic document models that mirror the YAML sections one to one (nanoidp.config_documents), and the domain Settings / User objects are built from them; the hand-written .get(key, default) mapping in config.py is 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 as oauth.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 into attributes, as always. Stricter handling is a later piece.

Config schema

  • config_version 1 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-config and strict validation (#175, pieces 3 and 4). Three additions to the config contract, none of which restates it a seventh time:
    • nanoidp config-schema prints the JSON Schema of settings.yaml, users.yaml and bootstrap.yaml, generated from the document models (--file for one of them, --write to regenerate the committed artifact from a source checkout). The artifact is docs/schema/config.v1.json: one standalone schema per file under the keys settings, users and bootstrap, next to the config_version they 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 MCP update_settings tool 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 of settings.yaml, default warn) and the server flag --strict-config decide 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.yaml and bootstrap.yaml follow what settings.yaml declares. 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 - no ConfigManager, no hook dispatched, no plugin imported, bootstrap.yaml checked 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-only validate_config tool ({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 of settings.yaml or users.yaml, on_audit_event(event) after an audit entry. Implement them as shell commands under hooks: in settings.yaml (placeholders {config_dir}, {path}, {kind}, {event_type}, audit event JSON on stdin) or as Python plugins packaged separately and discovered through the nanoidp.plugins entry-point group, configured under plugins.<name>:. Per-hook error policy: on_before_load may block under hooks.strict, on_config_saved is propagated to the caller under strict after the write (the local save is always committed and the running configuration reloaded from it, so only the mirror is behind), on_audit_event never propagates. Commands are never reported by /api/config or MCP (they may embed expanded secrets) and a propagated error names the hook and its source only; the bootstrap surface is the baseline for strict/timeout_seconds, settings.yaml overrides only what it declares. Bootstrap surface for hooks that must run before settings.yaml exists: NANOIDP_BOOTSTRAP_HOOK / --bootstrap-hook, NANOIDP_BOOTSTRAP_PLUGIN with NANOIDP_PLUGIN_<NAME>_<KEY> settings, and bootstrap.yaml in the config directory (hooks: and plugins: only). nanoidp plugins, GET /api/config and the MCP get_settings tool 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 wrong hook_api_version is reported, never fatal unless strict); hooks:/plugins: are YAML-only. bootstrap.yaml goes through the same loader as settings.yaml (placeholders, unknown-key warnings). A strict on_before_load failure is a JSON 503 on POST /api/config/reload and an error result from the MCP reload_config tool. Audit logging never constructs the configuration and an audit event produced inside a load is not dispatched to hooks. An unchanged hooks:/plugins: declaration is not re-applied on the refresh that follows a local write, so plugins are not re-instantiated on every save. Reference plugin examples/plugins/nanoidp-echo; guide "Extending nanoidp: hooks and plugins".
  • Import contracts enforced in CI (#149): import-linter now pins the package layering (routes -> services -> config) and the invariant that serialization.py has no runtime imports from the package (it is what lets config.py import it without a cycle). Both used to live only in comments; lint-imports runs next to ruff and mypy in the Tests workflow and fails when a change adds a forbidden import.
  • config_version field (#175, piece 1): settings.yaml and users.yaml accept a top-level integer config_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 init and the wizard write it into the files they create; UI/MCP saves preserve an existing key and never add one. GET /api/config and the MCP get_settings tool 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): /authorize accepts private-use scheme redirect URIs such as com.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 as myapp://, 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 or localhost registration. Fragments are now rejected explicitly (§3.1.2). One shared matcher, services/redirect_uri.py, serves both legs of /authorize; MCP tool descriptions and examples/test_agent.py updated.
  • Persona login mode (#156): opt-in login.mode: persona lists the configured users on every interactive login surface (/login, /authorize, /saml/sso and 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.password is 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 SAML AuthnContextClassRef: unspecified instead of falsely claiming PasswordProtectedTransport. New MCP tool create_persona_user, a persona-login example 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 /authorize login page, and per-client logo images stored locally in static/logos/ keyed by client ID (no YAML config needed for logos; place the image file and it's served automatically; the directory is overridable via oauth.logos_dir). Colours and toggles are editable from the OAuth client form in the UI and from the MCP create_client/update_client/get_client tools, 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 ./config fallback, and any save that followed rewrote config/settings.yaml or users.yaml in the checkout - twice committed by accident during review. tests/conftest.py now points NANOIDP_CONFIG_DIR at a fresh copy of config/ for every test and resets the yaml_writer singleton alongside the others.
  • --profile overrides settings.yaml for every value and survives reloads (#172). An explicit --profile dev could not bring a file configured with oauth21/stricter-dev back to dev (the flag defaulted to dev, 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, the stricter-dev runtime hardening (require_pkce, password_hashing, rate_limit_enabled, debug off) was applied once in create_app() and silently lost on that same first reload, even when the profile came from settings.yaml itself. The override now lives on ConfigManager (--profile defaults 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/config and the MCP get_settings tool expose security_profile, profile_override and the derived effective values; the e2e agent checks they are stable across a reload.
  • users.yaml now expands ${VAR} / ${VAR:default} placeholders like settings.yaml always did (#175 review). A password: ${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 MCP save_config tool rewrites the whole map and materializes expanded placeholders, as documented.
  • SAML entity_id/sso_url follow the effective issuer (#181). With oauth.issuer_from_request on (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 fixed http://localhost:8000/... strings. Both settings are now optional: absent (or blank in the UI/MCP) means derived as <effective issuer>/saml and <effective issuer>/saml/sso through one helper shared by every SAML surface, an explicit value still wins, and a derived value is never written back to settings.yaml. SAML 2.0 Metadata 2.3.2 requires entityID to be the value used as <Issuer> (Core 2.2.5). /api/config and the MCP get_settings tool report the effective values plus entity_id_derived/sso_url_derived; update_settings gains saml_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 explicit host: "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 the persona-login preset and the reverse-proxy guide's framing.
  • /api/config now exposes saml.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 wiped default_acs_url from settings.yaml.

Security

  • Opt-in login gate for the config web UI: new session.require_ui_login setting (off by default) makes /login actually enforce a logged-in session on the dashboard, users, clients, settings, keys, claims, audit log and token tester pages - previously /login//logout existed 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 the secret_key/security_profile precedent. 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_check setting (off by default). When password_hashing is on, a users.yaml password 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 as require_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_url and POST /api/config/reload for a proxied/containerized deployment, with the security caveats inline.

Added

  • First-class group support: users gain a groups list alongside roles, modelled exactly the same way. It is loaded from and persisted to users.yaml (omitted when empty), emitted as a groups claim on the access token and from /userinfo, requestable in the ID Token via the OIDC claims parameter, advertised in claims_supported, and flattened into authorities using the new groups authority prefix (default GROUP_, 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 MCP create_user / update_user tools. 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_groups toggles (both off by default, so the previous behaviour is preserved) with companion saml.roles_attr_name / saml.groups_attr_name settings defaulting to roles and groups. 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 MCP update_settings tool, and apply to both the SSO assertion and the AttributeQuery endpoint, with one AttributeValue per entry.
  • oauth.issuer_from_request (off by default): when enabled, the discovery document's issuer, every minted token's iss, and the device flow's verification_uri are derived from the incoming request's own Host header instead of the fixed oauth.issuer. Lets the same NanoIDP be reachable under more than one hostname (e.g. a Docker Compose service name from other containers and localhost from the host browser) without a discovery/token issuer mismatch - each hostname advertises and issues tokens against itself. The MCP get_oidc_discovery/get_settings tools have no request of their own and always report the fixed issuer.
  • oauth.issuer_allowlist: restricts issuer_from_request to 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 fixed oauth.issuer instead of trusting an arbitrary Host header. Settable from the Settings page and the MCP update_settings tool.
  • oauth.device_verification_base_url: pins the device flow's verification_uri to a fixed, human-reachable URL (e.g. https://idp.example.com), overriding issuer_from_request's derivation for that field only - discovery's issuer and a token's iss are 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 MCP update_settings tool.
  • oauth.issuer_from_proxy_headers (off by default): trusts X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-For from a single reverse-proxy hop (via werkzeug's ProxyFix), so issuer_from_request and 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_uri when issuer_from_request is 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 MCP get_settings/update_settings tools; since ProxyFix is wired at app startup, a value changed at runtime only takes effect after a restart.

Changed

  • Raised the PyJWT floor to >=2.13.0 (was >=2.8.0). /userinfo and /introspect pass a client-supplied token to jwt.decode(), and 2.8.0-2.12.1 are affected by CVE-2026-48525 (unbounded Base64URL decoding of a b64=false detached 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 cryptography floor from >=46.0.3 to >=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 by signxml, which imports x509.verification.ExtensionPolicy (added in cryptography 45.0.0) at load time. This unblocks installs on environments pinned to a cryptography between 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()) and YamlWriter.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 lowlevel Server decorators (@server.list_tools() / @server.call_tool()) with on_* constructor parameters, so a fresh install resolving to 2.0 could not import nanoidp.mcp_server at all. Handlers now take (ctx, params) and return ListToolsResult / CallToolResult instead 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 and nanoidp-mcp entry 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 an error key: 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's on_call_tool does not, so nanoidp now runs the same check itself and returns an is_error: true result (code: "MCP_INVALID_ARGUMENTS") instead of letting a missing required field reach the tool implementation as a bare KeyError.
  • Consolidated the MCP isError contract (#120): the rule is now written once as a table in the mcp_server module docstring (a negative query answer is not a failure) and the code follows it. verify_token on an invalid token returns {"valid": false, "reason": ...} (was error) so a rejected token, the tool's designed answer, is no longer flagged is_error. A domain-failure audit entry now records the failure reason instead of only the tool name; the uncaught-exception path now carries a code (MCP_INTERNAL_ERROR) and tool like the guard rejections; and _execute_tool's unreachable unknown-tool fallback now raises rather than returning a divergent shape. The MCP audit details codes are namespaced (MCP_READONLY_MODE, MCP_ADMIN_SECRET_REQUIRED, MCP_UNKNOWN_TOOL, MCP_INVALID_ARGUMENTS), observable via get_audit_log and /api/audit.
  • Precompiled MCP tool-argument validators (#121): each tool's JSON Schema is compiled once at import (Draft202012Validator) instead of being recompiled on every tools/call, which also surfaces a malformed schema at import time. The direct jsonschema floor is raised to >=4.20.0 to 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) raised PackageNotFoundError; the version now falls back to reading pyproject.toml, then to a static string.
  • Default admin user's identity_class is INTERNAL, not INTERN. The no-users.yaml fallback used a typo'd class that didn't match the generated template or the default allowed classes.
  • Env-backed client_id placeholders are preserved on save (#127). When a client's client_id was 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, and delete_client could not find the client at all. Client matching now expands the placeholder before comparing (client_id_matches()), used by the settings save, save_client() and delete_client().
  • Saving settings no longer discards comments, inline # text or ${VAR:default} placeholders in settings.yaml (#127): the settings writer now round-trips the file with ruamel.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 MCP save_config tool.
  • Env-backed client secrets and empty optional placeholders are preserved when unrelated settings change. The OAuth client merge now updates entries by client_id field-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/config now exposes issuer_allowlist, device_verification_base_url and issuer_from_proxy_headers alongside issuer_from_request. The config-agnostic e2e agent reads the allowlist from /api/config to 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's oauth.issuer now: 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.py SAML export check honours the configured attribute names: it now reads saml.roles_attr_name / saml.groups_attr_name from /api/config instead of assuming the default roles / groups names, 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_name equal to saml_groups_attr_name (e.g. both memberOf), 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 one AttributeValue, as it already did in the SSO assertion.
  • /api/users/<username>/token now honours issuer_from_request (#133): the endpoint mints real JWTs but kept using the fixed settings.issuer, so with the flag on its tokens carried an iss that 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 that issuer_from_proxy_headers affects the audit log's recorded client IP as well as the rate limiter's.
  • POST /settings no longer resets settings that were not on the submitted form (#131). Previously every checkbox absent from the form was stored as false and 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 as issuer_from_request, issuer_from_proxy_headers and 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_form marker so "rendered but unchecked" (persist false) is distinguishable from "not on this form" (leave unchanged).

Security

  • Default server bind address is now 127.0.0.1 (loopback) instead of 0.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 reach localhost:8000). To expose NanoIDP on a network, set server.host (or --host 0.0.0.0) explicitly; a startup warning is logged whenever the bind address covers all interfaces. This aligns nanoidp init with the value nanoidp wizard already wrote, and the bundled Docker image is unaffected (its entrypoint already passes --host 0.0.0.0).

2.5.0 - 2026-07-19

Added

  • claims parameter requests persist across token refresh (#112, OIDC Core §12.2): the claim names requested via the OIDC claims parameter are now persisted in the refresh token (req_id_token_claims / req_userinfo_claims, alongside scope and auth_time), so a refreshed ID Token keeps the requested claims and /userinfo keeps honouring the userinfo member 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 the claims parameter nor injected through the /token extra parameter. 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 /userinfo cleanly 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_token gains userinfo_claims (#113): parity with the HTTP claims flow's userinfo member; the names are stamped on the access token as req_userinfo_claims and honoured by /userinfo. Both id_token_claims and userinfo_claims are now validated like additional_audiences: a non-list value is rejected with a clean error instead of being minted into the token.

Changed

  • /userinfo reuses resolve_user_claim for its default claim assembly (#113): the scope-gated standard claims and the nanoidp-specific claims now come from the same resolver that backs the claims request parameter, so the two mappings cannot diverge. No behavior change.

Fixed

  • claims parameter could overwrite registered ID Token claims (#110): a requested claim name that collided with a user attribute (e.g. an attribute named aud or exp) could hijack the corresponding registered claim, because create_jwt applies extra after setting the registered claims and the setdefault guard only covered the protocol claims. resolve_user_claim now 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 /userinfo paths at a single choke point.

2.4.0 - 2026-07-08

Added

  • scope claim on access tokens (#102): access tokens now advertise the granted scope (RFC 9068 §2.2.3), letting resource endpoints reason about it. Set authoritatively in TokenService.create_token, so a caller-supplied extra_claims cannot override it.
  • OIDC claims request parameter (#104, OIDC Core §5.5): /authorize accepts a claims parameter to request specific claims in the ID Token (id_token member) or from UserInfo (userinfo member), 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 advertises claims_parameter_supported: true, and the MCP generate_token tool gains an id_token_claims argument. Scoped to the authorization code grant; the requested claims are not yet persisted across a refresh. TokenService.create_token now strips scope/req_userinfo_claims from a caller-supplied extra before setting them authoritatively, so the /token extra parameter can never smuggle scope-gated claims past /userinfo (closes a spoofing gap in the #102 scope handling too).

Changed

  • /userinfo gates email/profile claims by granted scope (#102, OIDC Core §5.4): email/email_verified require the email scope and preferred_username requires the profile scope. Enforced only under the stricter-dev and oauth21 profiles; the default dev profile 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

  • oauth21 security profile (#68): opt-in draft-OAuth-2.1 protocol strictness alongside dev and stricter-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 derived Settings properties consumed by both the routes and the shared discovery builder, so the profile means the same thing from --profile or settings.yaml and discovery can never advertise what the endpoints refuse. Deliberately orthogonal to stricter-dev (runtime hardening).
  • Registered redirect URIs with exact matching (#67): clients gain an optional redirect_uris list; when non-empty, /authorize compares the requested redirect_uri with simple string comparison (RFC 6749 §3.1.2.3, OAuth 2.1 §4.1.1) and answers a mismatch with 400 invalid_request directly, 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: true and PEM certificates in saml.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 enveloped ds: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 advertises WantAuthnRequestsSigned="true" if and only if enforcement is on. examples/gen_sp_keypair.py generates a test SP keypair.
  • E2E workflow in CI (#79): every PR now boots real servers and runs examples/test_agent.py against them (default profile, --oauth21, --saml-signed with 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_pkce and refresh_token_rotation toggles on the settings page; SP-certificates and signed-AuthnRequests fields (#69); redirect_uris on the client form (#67); the dashboard badge distinguishes the oauth21 profile.
  • MCP: get_settings reports security_profile; update_settings covers the SAML verification fields; client tools carry redirect_uris.

Changed

  • src/ is fully annotated and mypy runs with a global disallow_untyped_defs (#70): new unannotated code fails CI.
  • Internal architecture (behavior-invariant, #83–#86): one shared YAML serialization path for ConfigManager and the UI writer; the token endpoint dispatches to per-grant handlers with device-flow and revocation state in dedicated services (DeviceCodeStore, RevocationStore); a single audit_event helper replaced 58 duplicated audit blocks (invariance proven by a before/after snapshot harness); the Pydantic models moved to models.py with compatibility re-exports.
  • security_profile is now read from settings.yaml (top-level key) and round-trips on save; the CLI --profile still wins. A YAML-declared stricter-dev now applies its runtime hardening (previously the YAML value was silently ignored).

Fixed

  • ConfigManager.save() was lossy (#87): the save path behind MCP save_config rewrote settings.yaml from scratch, silently deleting every section it didn't own: jwt (external keys!), session, logging levels, server.debug and custom keys. Saving is now read-modify-write and preserves them, atomically and with a .bak backup 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 openid scope (OIDC Core §12.2, #39). The granted scope is persisted in the refresh token claims and recovered on refresh; a scope form parameter may narrow, but never broaden, the original grant (RFC 6749 §6: broadening is rejected with 400). The refreshed ID Token carries no nonce (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_time and at_hash (#42). auth_time reflects when the end-user actually authenticated: the login page for the authorization code flow, the /device verification 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_hash binds the ID Token to the access token issued alongside it (left half of SHA-256, base64url, §3.1.3.6). Discovery claims_supported now also advertises auth_time, nonce and at_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_pkce setting (enabled by the stricter-dev profile, persisted in settings.yaml) rejects /authorize requests without a code_challenge; stricter-dev also rejects code_challenge_method=plain, whether explicit or implicit via the RFC 7636 §4.3 omitted-parameter default, and discovery only advertises S256 there. 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_info and rotate_keys mirror the HTTP API, so agent workflows can inspect what the IdP recorded and exercise JWKS refresh handling. clear_audit_log/rotate_keys count as mutating tools (admin secret / readonly rules apply). MCP get_settings/update_settings expose the new refresh_token_rotation and require_pkce settings, and generate_token accepts an optional scope argument and returns an id_token when openid is included, matching the HTTP token endpoint.
  • CI now lints with ruff (#45) and type-checks src/ with mypy (#55, documented gradual-adoption baseline in pyproject.toml). The codebase is lint-clean, 153 findings fixed (#49): deprecated datetime.utcnow() replaced (removes 80 DeprecationWarnings), unused imports/variables dropped, imports sorted and moved to module level, Optional[...] type hints in the crypto service, verify_jwt accepts an array audience, exceptions re-raised with from e, and mypy-clean (40 baseline errors fixed; the baseline also surfaced the broken nanoidp-mcp entrypoint below).

Fixed

  • The nanoidp-mcp stdio 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_id is 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 original aud (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_family claim, stable across rotations); reusing an already-consumed token revokes the whole family, including the live descendant (RFC 9700 §4.14.2).
    • PKCE plain can no longer slip through stricter-dev by omitting the method: per RFC 7636 §4.3 an absent code_challenge_method defaults to plain; the method is now normalized before validation, and unknown methods are rejected at the authorization endpoint (§4.4.1).
    • require_pkce is persisted: it is now read from and written to settings.yaml (oauth section), so update_settings → save_config → reload_config no 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.
  • The token endpoint validates exp and extra before 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: extra must be a JSON object (a scalar/array used to raise a TypeError 500 later) and exp must be an integer within the same 1..1440 bounds the Settings model enforces (non-numeric values used to be an unhandled ValueError 500; astronomical ones an OverflowError 500).
  • 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_discovery tool now returns the exact same document as the HTTP /.well-known/openid-configuration endpoint (#40). Both build it via a new shared helper (services.discovery.build_discovery_document), so the MCP tool now advertises claims_supported (including azp), response_types_supported, id_token_signing_alg_values_supported, code_challenge_methods_supported and the endpoint auth methods. The two documents can no longer drift apart.
  • Discovery no longer advertises the token response type (#41): the implicit flow was never implemented (/authorize only accepts response_type=code) and is deprecated by the OAuth 2.0 Security BCP, so advertising it misled clients. response_types_supported is now ["code"].

Documentation

  • The MCP tools tables in the README and docs/MCP_WORKFLOW.md now list all 24 tools (#44, #48); the README was missing create_client, update_client, delete_client, update_user, update_settings and save_config.

2.1.0 - 2026-05-26

Added

  • ID Tokens are now issued for the password and device (RFC 8628) grants when openid scope is requested, not just authorization_code (#36). These grants authenticate an end-user, so an ID Token is meaningful; client_credentials still never emits one (no end-user).

Fixed

  • Friendlier loading of client additional_audiences from settings.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 Pydantic ValidationError at startup.
  • Minor hardening/polish from the #32 review (#37): OAuthClient now validates on direct attribute assignment (validate_assignment), discovery advertises azp in claims_supported, and the MCP _normalize_audiences rejects 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 Token aud even if a client lists it in additional_audiences, and every token carries a token_use marker (access / id / refresh). /userinfo rejects tokens marked as ID or refresh tokens and /introspect reports 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 aud now contains the requesting client's client_id, as required by OpenID Connect Core 1.0 §2 (was previously the static oauth.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 aud against the old static oauth.audience value must now expect their own client_id.
    • The access token aud is unchanged and still reflects oauth.audience (the resource audience, per RFC 9068 §2.2).

Added

  • additional_audiences per-client setting: extra audiences appended to the ID Token aud. If this produces more than one distinct audience value, aud is emitted as an array and nanoidp also emits azp equal to the client_id, so clients can test authorized-party handling.

1.4.0 - 2026-04-28

Added

  • Environment variable substitution in settings.yaml using ${NAME} / ${NAME:default} syntax
  • PORT env var honoured in the Docker image via shell expansion in CMD

1.3.3 - 2026-04-22

Fixed

  • Return id_token in /token response for Authorization Code Flow when openid scope is requested, as required by OIDC Core spec (Section 3.1.3.3)
  • Include nonce claim in id_token when provided by the client

Changed

  • Use pyproject.toml as 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_id cannot be determined from either the request body or the Authorization header
  • Token endpoint now rejects requests where client_id in the body conflicts with the authenticated client in the Authorization header

Added

  • Tests for client_id mismatch and missing client_id edge cases

1.3.1 - 2026-03-26

Fixed

  • Allow authorization code flow without Authorization header for PKCE public clients (RFC 6749 §2.1)
    • Libraries like authlib send client_id in 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

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)
    • latest tag published only for non-prerelease versions
  • Docker usage instructions in README (docker pull and docker run examples)

Changed

  • Dockerfile healthcheck switched from Python urllib to curl for Podman compatibility and reduced overhead
  • Updated actions/checkout from v4 to v6 in publish workflow

1.2.3 - 2026-03-03

Fixed

  • Dockerfile and docker-compose.yml: replaced curl with Python's urllib for healthcheck: avoids adding curl as a system dependency in the image

Docs

  • Added mascotte/logo images to the project

1.2.2 - 2026-01-19

Added

  • New strict_saml_binding setting 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
  • 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_binding and verbose_logging now 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 advertised
    • test_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 supported
    • test_saml_strict_binding_mode - tests strict/lenient binding behavior
    • test_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 |e escape filter in login template hidden fields (XSS defense-in-depth)
  • Normalized original_verb handling (uppercase, validated to GET/POST)
  • Quick-fill username buttons use tojson filter 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_request unconditionally 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/sso now 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
  • /login endpoint 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 /login and /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_logging setting to control sensitive data in logs
  • verbose_logging exposed in MCP get_settings and update_settings tools
  • logging.verbose_logging exposed in REST API /api/config endpoint
  • MCP tests (tests/test_mcp.py) with 8 tests for MCP functionality
  • Verbose logging test in E2E test agent

Changed

  • Replaced deprecated defusedxml.lxml with native lxml secure parser for XXE protection
  • Added html.escape for XSS prevention in SAML responses
  • Audit logging now respects verbose_logging setting (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_algorithm setting

1.1.0 - 2026-01-14

Added

  • Configurable SAML response signing via saml.sign_responses setting
  • UI toggle for SAML signing in Settings page (/settings)
  • sign_responses exposed in /api/config endpoint
  • Test agent (examples/test_agent.py) for comprehensive endpoint testing

Changed

  • SAML SSO and AttributeQuery endpoints now respect sign_responses configuration
  • 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 (dev and stricter-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

  1. Check existing issues to avoid duplicates
  2. Create a new issue with:
    • Clear title
    • Steps to reproduce
    • Expected vs actual behavior
    • Environment details (Python version, OS)

Feature Requests

  1. Open an issue describing the feature
  2. Explain the use case
  3. Wait for discussion before implementing

Pull Requests

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/your-feature
  3. Make your changes
  4. Run tests and linting
  5. Commit with clear messages
  6. Push to your fork
  7. 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 /authorize errors after redirect_uri validation, which redirect with error, state and iss (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, faultcode Client/Server per SOAP 1.1 §6.2). Browser-facing SSO steps may use Werkzeug abort() HTML, since the reader is a person mid-redirect.
  • MCP: two deliberate SHAPES - dispatch refusals ({"error", "code", "tool"}, the MCP_* code taxonomy) vs domain results ({"success": False, "error", ...} plus kind for typed conditions). Both come back is_error=True (call_tool flags success is False or "error" in result); what stays is_error=False are the NEGATIVE QUERIES - found: False, valid: False - because a miss is an answer, not a failed call. The full table lives in the mcp_server module 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 UI
  • fix: Correct token expiry calculation
  • docs: Update README with Docker instructions
  • refactor: Simplify user authentication flow
  • test: 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.