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 lightweight identity provider for development and 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,
  ...
}

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.
  • Drive it from an agent. An 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.

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 verified token 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).

Verify it the way your client would, via discovery and JWKS:

curl http://localhost:8000/.well-known/openid-configuration
curl http://localhost:8000/.well-known/jwks.json

4. 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

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).

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'

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, and a multi-step save stops at the first failed write: ConfigManager.save() writes users.yaml then settings.yaml, and the web UI's settings page writes its sections one atomic write at a time, so under strict a failing hook leaves the steps after it unsaved (the flash message says which write failed). 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.

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"

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

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, and clear the audit log. Loopback binding keeps that surface off the network. 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.


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* (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

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.

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.


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 the keys/ directory:

config/
└── keys/
    ├── private.pem      # RSA private key (signing)
    ├── public.pem       # RSA public key (verification)
    └── kid.txt          # Key ID

External Keys

You can use your own RSA keys instead of auto-generated ones:

# settings.yaml
jwt:
  external_keys:
    private_key: /path/to/private.pem
    public_key: /path/to/public.pem
    kid: "my-custom-key-id"

Requirements:

  • Private key: PEM format, PKCS8 encoding
  • Public key: PEM format, SubjectPublicKeyInfo encoding
  • Key ID (optional): If not provided, one is generated from the key fingerprint

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

Configuration

# settings.yaml
jwt:
  max_previous_keys: 2  # Number of previous keys to keep in JWKS

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

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; exit 0 when clean, or with warnings only and no --strict; exit 1 on any error, and on any warning under --strict (a directory that declares config_validation: strict is strict either way, since that is a directory the server would refuse to start on).

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"
    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"

default_user: "admin"

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

See the Security guide for the full contract, including why the OAuth password grant is unaffected and the SAML AuthnContextClassRef detail.

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

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_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
    - client_id: "scoped-client"
      client_secret: "secret"
      description: "Client restricted to a scope subset"
      allowed_scopes:           # optional; see "Registered scopes" below
        - "openid"
        - "profile"
  # 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"

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; local dev/testing convenience, off by default - see "Login mode" above
# login:
#   mode: persona   # password (default) | persona

# 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.

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.

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. 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).

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

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.

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/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

curl examples for every grant are in Requesting tokens.

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

Bindings, strict-binding mode, response signing, and canonicalization are covered in SAML options.

REST API

EndpointDescription
GET /api/healthHealth check
GET /api/usersList users
GET /api/users/{username}Get user details
POST /api/users/{username}/tokenGenerate token
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
POST /api/keys/rotateRotate cryptographic keys
GET /api/keys/infoGet key information

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"
  ]
}

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.

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.

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).

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.

Need a test SP keypair? python examples/gen_sp_keypair.py --out . generates sp-key.pem/sp-cert.pem, and examples/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.

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)
decode_tokenDecode JWT token (without verification)
verify_tokenVerify JWT token signature and expiration
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
reload_configReload configuration from files
validate_configLint the running config directory without starting or executing anything (no hook, no plugin): {valid, findings}
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)

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 lightweight identity provider for development and testing: an identity test environment for applications and agentic systems. It gives developers, and through its MCP server AI agents, a real, spec-honest OAuth2/OIDC and SAML 2.0 counterpart to integrate against, without standing up Keycloak or wiring a cloud tenant: pip install, two YAML files, go.

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 dev 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 dev 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.
  5. Features ship whole. A feature lands together with its MCP exposure (where applicable), its examples/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 state (authorization and device codes, token revocations and refresh-token families, the audit log, runtime-created clients and users) is in memory by default. An optional local SQLite runtime store gives durable runtime state and lets several nanoidp processes on one host share it (HTTP workers and the separate nanoidp-mcp process alike); it is not a distributed store. Distributed databases, HA and multi-node state coordination are not goals. Durable is not declared: a runtime-created object can survive a restart without becoming part of the operator's configuration unless it is explicitly saved to it.
  • 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

Medium-term themes, deliberately undated. Concrete work is tracked in GitHub issues attached to the corresponding milestones:

  1. OAuth 2.1 profile: an opt-in profile aligning nanoidp's strictest behavior with draft OAuth 2.1: PKCE required everywhere, refresh token rotation on by default, S256 only.
  2. SAML hardening: optional validation of signed AuthnRequests, for testing SPs that sign their requests.
  3. Typing strictness: tighten the mypy baseline module by module (disallow_untyped_defs) until src/ is fully annotated.
  4. CI quality gates: enforce a coverage threshold in CI and fail on Codecov upload errors.
  5. 3.0 breaking cleanups: deferred breaking changes for the next major; first entry: refresh tokens without a client_id binding claim stop being accepted (transitional compatibility introduced in 2.2.0).
  6. 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, opt-in Dynamic Client Registration with a CIMD-ready registry, a mock protected MCP server as an e2e fixture, a runtime store as the boundary for execution state (memory by default, SQLite opt-in for several workers on one host), and a config/state split for multi-agent use (runtime-created objects with conflict detection, export/import, and an actor recorded in the audit log). nanoidp stays a dev/testing IdP extended to these cases, not "an IdP for AI".

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(): Flask app factory, blueprint registration, session cookie policy, startup warnings
__main__.pyThe nanoidp CLI: serve, init, wizard, validate-config, config-schema, plugins
mcp_server.pyThe nanoidp-mcp stdio server: tool declarations, handlers, and its own ConfigManager
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 management-gate choke point: 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/auth_code.pyAuthorization-code store (PKCE data rides on the code)
services/device_code.pyDevice-flow code store
services/revocation.pyIn-memory revocation and refresh-rotation family state
services/crypto.pyKey management: generation, rotation, JWKS, external key import
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/audit.pyThe audit log (in-memory ring, export)
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)
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 - but they are two separate read-modify-write owners, with no cross-request conflict detection (last write wins).
  • Runtime state lives in memory inside services/: authorization codes, device codes, revocation and rotation families, the audit log, and Flask sessions. It is lost on restart by design; an instance is disposable (see Vision). If you are about to persist runtime state to disk, stop and re-read the database-persistence non-goal.

ConfigManager.load() is transactional: parse and validate first, swap the live objects only on success. A failed reload leaves the previous configuration serving.

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 examples/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.
  • examples/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. 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.

[Unreleased]

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

  • 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.

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_settingssave_configreload_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 examples/test_agent.py

# Run against custom URL
python examples/test_agent.py --url http://localhost:9000

# Verbose output
python examples/test_agent.py --verbose

# JSON output
python examples/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.

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.