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
oauth21profile enforces the draft's strictness: PKCE required with S256 only, rotation on, no password grant, and registered redirect URIs with exact matching. The discovery document reflects it. - Test SAML 2.0. SSO over HTTP-POST and HTTP-Redirect bindings and AttributeQuery, with configurable response signing, strict-binding mode, canonicalization algorithms, and opt-in verification of signed AuthnRequests against registered SP certificates.
- 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 defaultadmin/adminuser)settings.yaml: OAuth/SAML settings (a defaultdemo-clientwith secretdemo-secret)keys/: RSA keys, auto-generated on first startup
Prefer a guided setup? The interactive wizard walks through server configuration, OAuth clients, admin user, and token settings:
python -m nanoidp wizard
2. Run the server
# Default config directory (./config)
python -m nanoidp
# Custom config directory
python -m nanoidp --config ./my-idp-config
# Or via environment variable
NANOIDP_CONFIG_DIR=./my-idp-config python -m nanoidp
The server listens on http://localhost:8000 (--port to change it).
3. Get a token
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin&scope=openid'
The response carries an access_token (its aud is the resource audience
from oauth.audience) and, because the request included the openid
scope, an id_token (its aud is the client's client_id).
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
- Exercise every grant with curl: Requesting tokens
- Drive it from Claude Code: MCP with Claude Code
- Understand the trade-offs and the
stricter-devprofile: Security guide - The full reference: Configuration, Endpoints, Tokens and claims, SAML options
Requesting tokens
curl examples for every supported grant, against the default
demo-client / demo-secret client from a fresh
Quickstart setup.
Password grant
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=password&username=admin&password=admin'
Add &scope=openid to also receive an ID Token: see
Tokens and claims for what comes back.
Under the
oauth21profile this grant is rejected with400and does not appear ingrant_types_supported: OAuth 2.1 removes it entirely.
Client credentials grant
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=client_credentials'
The response carries no refresh_token: the client authenticates itself
on every request, so there is nothing a refresh token could stand in for
(RFC 6749 §4.4.3, "A refresh token SHOULD NOT be included"). Request a
new access token the same way when the current one expires.
Refresh token
curl -X POST 'http://localhost:8000/token' \
-u 'demo-client:demo-secret' \
-d 'grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN'
If the original grant included the openid scope, the refresh re-issues
an ID Token as well. A scope parameter may narrow, but never broaden,
the originally granted scope (RFC 6749 §6). Claims requested via the
OIDC claims parameter persist across the refresh, including a
narrowed one; see
Tokens and claims.
With oauth.refresh_token_rotation: true, each refresh invalidates the
consumed refresh token; reuse revokes the whole rotation family
(RFC 9700 §4.14.2).
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-Foralso feeds rate-limit and audit-log client IP attribution, regardless of whetherissuer_from_requestis even on. Configure the proxy to strip any inboundX-Forwarded-*headers from clients before it sets its own.This is wired at app startup, so a value changed at runtime (Settings page or MCP
update_settings) only takes effect after a restart.
2. Trusting an arbitrary Host header
Even with the proxy hop trusted, issuer_from_request still reflects
whatever Host it's given. On a network where anything other than the proxy
could reach NanoIDP, restrict which hostnames are allowed to be reflected:
oauth:
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
Each entry is an origin: scheme://host[:port]. A request whose derived
origin isn't in the list falls back to the fixed oauth.issuer instead of
trusting the Host header. Leaving issuer_allowlist empty (the default)
allows any Host, matching pre-#issuer_allowlist behavior - only appropriate
on a fully trusted network where the proxy is the sole entry point.
Device flow: pinning a human-reachable URL
The device flow's verification_uri is opened by a human in their own
browser, but /device_authorization is typically called by a
backend/container/CLI on a different host than that browser - so reflecting
that caller's Host (e.g. Host: nanoidp:8000, an internal Docker Compose
service name) produces a URL the human can't actually reach.
Pin it to a fixed, human-reachable URL instead, independent of whatever
Host called /device_authorization:
oauth:
issuer_from_request: true
device_verification_base_url: "https://idp.example.com"
device_verification_base_url only overrides the device flow's
verification_uri; discovery's issuer and a token's iss are unaffected
and keep following the request that fetched/requested them. It's only
consulted when issuer_from_request is on.
Reloading configuration without a restart
ProxyFix (via issuer_from_proxy_headers) is wired once at app startup, so
a value changed at runtime only takes effect after a process restart.
Everything else in this guide - issuer, issuer_allowlist,
device_verification_base_url - can be changed in settings.yaml (or via the
Settings page / MCP update_settings) and picked up without a restart:
curl -X POST http://localhost:8000/api/config/reload
This re-reads both settings.yaml and users.yaml from
NANOIDP_CONFIG_DIR (default ./config) in place.
Putting it together
A full settings.yaml for NanoIDP behind a single trusted TLS-terminating
proxy, reachable under both its public hostname and an internal Docker
Compose service name, with the device flow pinned to the public hostname:
server:
host: "0.0.0.0"
port: 8000
oauth:
issuer: ${OAUTH_ISSUER:http://localhost:8000} # fallback when the flags below don't match
issuer_from_request: true
issuer_from_proxy_headers: true
issuer_allowlist:
- "https://idp.example.com"
- "http://nanoidp:8000"
device_verification_base_url: "https://idp.example.com"
Note on
host: "0.0.0.0". Since 2.6.0 the default bind address is127.0.0.1(GHSA-2473-px8h-rvg6): the/api/*management API is unauthenticated by design, so NanoIDP no longer listens on all interfaces unless you ask it to. Behind a reverse proxy you do want0.0.0.0- the proxy must reach NanoIDP over the container or host network - so setting it here is a deliberate opt-in. Only do this on a trusted network (for example a private Docker Compose network that publishes just the proxy's port), and expect a startup warning reminding you the management API is exposed to that network.
And the corresponding environment for the container:
environment:
- NANOIDP_CONFIG_DIR=/app/config
- OAUTH_ISSUER=https://idp.example.com
Checklist
-
Reverse proxy terminates TLS and sets
X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Foritself (strips any client-supplied values first) - NanoIDP is not reachable except through that proxy
-
issuer_from_proxy_headers: trueonly if the above two hold -
issuer_allowlistset to the exact origins NanoIDP should ever advertise as its issuer -
device_verification_base_urlset if/device_authorizationis ever called from a different host than the end user's own browser -
Restarted the process after changing
issuer_from_proxy_headers(ProxyFixis startup-only)
Related documentation
- Configuration reference - full
settings.yamlshape - Environment variables -
NANOIDP_CONFIG_DIRand MCP-related variables - Security guide: multi-hostname issuer -
the trust model behind
issuer_from_request - Endpoints reference -
GET /api/config,POST /api/config/reload
SAML metadata follows the issuer
saml.entity_id and saml.sso_url are optional. When they are absent from
settings.yaml, nanoidp derives them from the effective issuer as
<issuer>/saml and <issuer>/saml/sso, so /saml/metadata, the <Issuer>
in responses and assertions, and OIDC discovery all name the same origin under
every option above (fixed issuer, request-derived issuer, proxy headers,
allowlist). Nothing SAML-specific needs to be configured for a proxy.
Set them explicitly only when the SP expects a fixed, different value; an
explicit value always wins and does not follow the request. GET /api/config
reports the effective values together with entity_id_derived /
sso_url_derived so you can tell which case applies.
Extending nanoidp: hooks and plugins
nanoidp reads and writes two YAML files and nothing else. When those files need to come from, or go to, somewhere else (an S3 bucket per stage, a Vault path, a git repository, an audit sink), that integration lives outside the core, behind a small, versioned extension point: extension points, not backends. A hook observes what happens to the files and can provide them before they are read; it never replaces persistence, never sees an HTTP request and never touches token issuance, so a broken hook cannot change what nanoidp advertises or issues.
The contract (hook API version 1)
Three hooks, called synchronously:
| Hook | When | Arguments | Typical use |
|---|---|---|---|
on_before_load | before settings.yaml and users.yaml are read: startup and every explicit reload (POST /api/config/reload, MCP reload_config), never the refresh that follows a local write | plugin: config_dir; shell: {config_dir} | render the files from a store into the directory |
on_config_saved | after an atomic write of either file (web UI, MCP, save) | plugin: path, kind (settings or users); shell: {config_dir}, {path}, {kind} | push to a store, git commit, notify |
on_audit_event | after an audit entry is recorded | plugin: the event as a mapping; shell: {config_dir}, {event_type} plus the event as JSON on stdin | ship audit elsewhere |
Two ways to implement it, sharing one dispatcher (shell hook first, then plugins in declaration order):
Shell hooks, declared in settings.yaml, zero API:
hooks:
on_before_load: "aws s3 sync s3://idp-config-${STAGE} {config_dir}"
on_config_saved: "vault kv put secret/idp/{kind} @{path}"
on_audit_event: "jq -c . >> /var/log/nanoidp-audit.jsonl" # event JSON on stdin
strict: false # see the error policy below
timeout_seconds: 10 # shell hooks only
Placeholders are listed per hook in the table above. They are replaced
textually, so other braces (${VAR}, jq filters) are left alone. The
command runs through the shell with nanoidp's environment; for
on_audit_event the event is also written to the command's stdin as JSON.
A command is stored after ${VAR} expansion, so it may embed a token. It
is therefore never reported outside the process: GET /api/config and the
MCP get_settings tool show a hook's name, source and failure counter but
not its command, and the error a caller sees under strict names the hook
and its source only. The command and the hook's stderr go to the server
log at WARNING, and nanoidp plugins, which runs in your own terminal,
is the one place that prints commands.
Python plugins, packaged separately and discovered through the
nanoidp.plugins entry-point group:
plugins:
echo:
record: /tmp/nanoidp-hooks.jsonl # keys under a plugin's name belong to the plugin
plugins: is the only section of settings.yaml whose inner keys nanoidp
does not validate: the shape is name -> mapping, the keys are the plugin's.
A plugin's identity is its entry-point name, which is also that key; the
object itself carries only hook_api_version and the hooks it implements.
Error policy, per hook
The policy is the same for shell hooks and plugins. timeout_seconds
applies to shell hooks only, and a timeout is a failure; a Python plugin
must manage its own network timeouts and queueing, nanoidp does not
interrupt it.
| Hook | default | hooks.strict: true |
|---|---|---|
on_before_load | log, continue with whatever is in the directory | the load (startup or reload) fails with the hook's error |
on_config_saved | log | the error is propagated to the caller after the write. Disk and runtime stay aligned: the file on disk is what was written and the running configuration is reloaded from it before the error is raised, so only the mirror is behind. The web UI says "Settings saved locally; mirror hook failed". That refresh reads the local files only and does not run on_before_load: the disk is the newest state right after a write, and pulling a mirror that has not caught up yet would silently roll the write back |
on_audit_event | log | log; never propagates |
on_before_load is the only hook that can block an operation, because it
runs before any mutation. on_config_saved runs after the atomic write, so
the local save is always committed; under strict the web UI, /api and
MCP callers surface the failure so the operator learns the mirror did not
happen, 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:
| Surface | What it declares | Runs |
|---|---|---|
NANOIDP_BOOTSTRAP_HOOK="<command>" or nanoidp --bootstrap-hook "<command>" | an on_before_load shell command | once, before the first load |
NANOIDP_BOOTSTRAP_PLUGIN=<name> with NANOIDP_PLUGIN_<NAME>_<KEY>=<value> | a plugin and its settings (name upper-cased, - as _) | loaded once, then takes part in every hook |
bootstrap.yaml in the configuration directory | hooks: and plugins: only, same schema and same loader as settings.yaml: ${VAR} placeholders expand, an unknown key is warned with its path and ignored (under config_validation: strict it stops startup, like everywhere else), a wrong type stops startup | its on_before_load once, its other hooks and plugins always |
Precedence of the policy values (strict, timeout_seconds): the
bootstrap surface is the baseline; settings.yaml overrides a value only
when it declares it explicitly, and a settings.yaml that disappears (a
render that failed, a sync that left the directory half written) takes its
hooks, plugins and policy with it on the next reload, leaving the bootstrap
ones in force. A renderer that replaces or syncs the whole configuration
directory must preserve bootstrap.yaml, or that deployment should use the
env/CLI bootstrap instead.
After the first load, settings.yaml may declare the same hook for reloads
and saves. nanoidp plugins shows which surface each entry came from
(bootstrap-env, bootstrap.yaml, settings.yaml). Note that strict read
from settings.yaml cannot apply to the very first load (the file is not
known yet): to make a failed bootstrap render fatal, set strict: true in
bootstrap.yaml.
A plugin that cannot be loaded (its package is not installed, it declares
another hook_api_version, its configure() raises) follows the same
policy as on_before_load: logged at ERROR, listed under plugins_failed
by nanoidp plugins, GET /api/config and the MCP get_settings tool,
and skipped; under strict the load fails. The public reason is one of
nanoidp's own diagnoses (not installed, incompatible hook_api_version=N (expected 1), already loaded, initialization failed); the exception
text itself, which may embed the plugin's settings, goes to the local log
only. A plugin that failed in non-strict mode is retried only when the
hooks:/plugins: declaration changes or the process restarts: a reload
with an unchanged declaration does not re-apply it.
A failed on_before_load or plugin load under strict is reported by
POST /api/config/reload as a JSON 503 (kind names the phase:
on_before_load or plugin_load) and by the MCP reload_config tool as an
error result, never as an HTML error page. Such a reload fails without
commit: the running settings, the profile hardening and the registered
plugins stay exactly as they were until a later reload succeeds.
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:
- Call
generate_tokento get a JWT - Make the HTTP request with
Authorization: Bearer <token> - Show you the response
2. Debug Token Claims
Prompt: "Generate a token for user 'admin' and explain what Spring Security
authorities it will have"
Claude Code will:
- Generate the token
- Decode it to show claims
- Explain the
authoritiesarray mapping
3. Set Up Integration Test Users
Prompt: "Create these test users in nanoidp:
- 'admin-test' with roles ADMIN, USER
- 'user-test' with role USER
- 'readonly-test' with role VIEWER"
Claude Code will create all three users with appropriate settings.
4. Verify Token Flow
Prompt: "Generate a token for 'admin', decode it to show the claims,
then verify it's valid using nanoidp"
This tests the full token lifecycle.
5. Quick Role-Based Testing
Prompt: "I need to test role-based access. Create a user 'role-test' with
roles ['ADMIN', 'SPECIAL_ACCESS'], generate a token, and show me
what authorities it will have for Spring Security"
Tool Reference
Read-Only Tools (always available)
| Tool | Description | Example Prompt |
|---|---|---|
list_users | List all users | "List nanoidp users" |
get_user | Get user details | "Get user 'admin' from nanoidp" |
list_clients | List OAuth clients | "List OAuth clients" |
get_client | Get client details | "Get client 'demo-client'" |
decode_token | Decode JWT | "Decode this token: ..." |
verify_token | Verify JWT signature | "Verify this token: ..." |
get_settings | Get IdP settings | "Show nanoidp settings" |
reload_config | Reload from files | "Reload nanoidp config" |
get_oidc_discovery | Get OIDC discovery | "Get OIDC discovery" |
get_jwks | Get JWKS | "Get the JWKS from nanoidp" |
get_audit_log | Get audit entries | "Show the last 20 audit entries" |
get_audit_stats | Get audit statistics | "Show audit stats" |
get_keys_info | Get signing key info | "Which signing key is active?" |
Mutating Tools (disabled in --readonly mode)
| Tool | Description | Example Prompt |
|---|---|---|
create_user | Create new user | "Create user 'test' with password 'pass'" |
update_user | Update user | "Update user 'test' to add role 'ADMIN'" |
delete_user | Delete user | "Delete user 'test'" |
create_client | Create OAuth client | "Create client 'app' with secret 'secret'" |
update_client | Update client | "Update client 'app' description" |
delete_client | Delete client | "Delete client 'app'" |
generate_token | Generate JWT | "Generate token for 'admin'" |
update_settings | Update settings | "Set token expiry to 30 minutes" |
save_config | Save to YAML | "Save nanoidp config to files" |
clear_audit_log | Clear the audit log | "Clear the nanoidp audit log" |
rotate_keys | Rotate signing keys | "Rotate the signing keys" |
Security Notes
Admin Secret Protection
When management_secret is configured -
via settings.yaml's session.management_secret, the
NANOIDP_MANAGEMENT_SECRET env var, or the legacy NANOIDP_MCP_ADMIN_SECRET
env var (this setting's MCP-only predecessor, still supported as an alias) -
mutating tools require the secret as the admin_secret tool argument. The
same setting also gates /api/* and the web UI's mutating form actions -
see Management Secret for the full picture.
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config",
"NANOIDP_MANAGEMENT_SECRET": "your-secret-here"
}
}
}
}
Readonly Mode
For shared environments or when you only need introspection:
nanoidp-mcp --readonly
# or
NANOIDP_MCP_READONLY=true nanoidp-mcp
This completely disables all mutating tools.
Troubleshooting
"Tool not found" Error
Ensure NanoIDP is installed and nanoidp-mcp is in your PATH:
pip install nanoidp
which nanoidp-mcp
"User not found" Error
Check your config directory is correct:
ls $NANOIDP_CONFIG_DIR/users.yaml
MCP Server Not Starting
Check logs by running manually:
NANOIDP_CONFIG_DIR=./config nanoidp-mcp
Permission Denied for Mutating Tools
Either:
- Provide
admin_secretin tool arguments (ifmanagement_secretis configured) - Or unset
management_secret(settings.yaml,NANOIDP_MANAGEMENT_SECRET, or the legacyNANOIDP_MCP_ADMIN_SECRET) for development - Or check you're not running in
--readonlymode
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
| Setting | Behavior |
|---|---|
false (default) | The config web UI is unauthenticated, like today |
true | Every web UI page except /login itself redirects to /login until a session exists |
/management/unlock (the management_secret unlock
form) is exempt from this redirect too, whether or not a login session
exists yet - management_secret is an independent axis (see below), and
gating it on a login session it doesn't depend on would make the unlock form
/login renders whenever management_secret is configured silently do
nothing.
What it does not protect: the separate management API (/api/*) stays
unauthenticated regardless of this setting - it's a distinct Flask blueprint
that a UI-only login gate structurally cannot reach. If you enable
require_ui_login because the dashboard is reachable by more than just you,
also keep /api/* off the network (see Network Binding
above), set management_secret to gate its mutations,
or put it behind your own auth layer; this setting does nothing for it.
Relationship to management_secret: these are independent axes.
require_ui_login is the UI's session front door - it controls who can view
the dashboard at all. management_secret (below) is the write guard - it
controls who can change anything, on all three management surfaces, whether
or not require_ui_login is on. With both off, nothing is enforced, same as
today.
Persona mode interaction: logging in via /login or via the SAML SSO
inline login at /saml/sso both satisfy this gate - they authenticate
through the same interactive_authenticate() call and set the same session.
If Persona Login Mode is also enabled
(login.mode: persona), that call is identity selection only, with no
credential check. In that combination, require_ui_login confirms a user
was picked from a list, not that anyone was verified - it is not protection
against anyone who can reach the port.
YAML-only: like secret_key and security_profile, this is not exposed
on the Settings page or the MCP update_settings tool. It's a fixed operator
decision about the trust boundary of the surface itself, not something meant
to be flipped from inside the surface it protects.
Session trust caveat: this gate's protection depends entirely on
secret_key being a real, private value - see
Session Cookie Trust below.
Session Cookie Trust (secret_key)
Both require_ui_login (above) and management_secret's web UI leg (below)
work by reading something out of the Flask session - a client-side cookie
Flask signs but does not encrypt, using secret_key. Anyone who knows
secret_key can construct arbitrary session content and have Flask accept it
as genuine, including session['user'] (what require_ui_login checks).
session:
secret_key: "a real, private value" # default: a public, well-known string
secret_key ships with a public default
(dev-secret-key-change-in-production, identical in every install unless
changed) so NanoIDP works out of the box. That's harmless as long as nothing
security-relevant depends on the session; require_ui_login and
management_secret both now do. NanoIDP logs a startup warning when
secret_key is left at its default while either is configured.
The two gates are not equally exposed by this. require_ui_login's
session['user'] is a bare value with no independent verification - with a
default secret_key, anyone who can reach the port can forge it and skip the
login gate entirely. management_secret's session flag
(session['management_verified']) is additionally bound to
management_secret itself via an HMAC (see
Management Secret), not stored as a bare boolean, so
knowing only the default secret_key is not enough to forge it - the forger
would also need to know management_secret, the thing being protected.
Set a real secret_key before relying on either gate beyond a single
trusted machine.
The session cookie is also set with SameSite=Lax. Once management_secret
is unlocked, the session authorizes /api/* mutations
(management_secret_required_for_api accepts an already-unlocked session,
above) in addition to ui_bp's own forms - so the
cross-site form-POST surface that already existed for the dashboard now
covers the management API too, on any browser that doesn't default new
cookies to Lax on its own. SameSite=Lax closes that: the cookie isn't
sent on a cross-site POST, only on top-level navigation.
Management Secret
management_secret is one shared secret that gates mutations across all
three management surfaces - the MCP server, /api/*, and the config web UI -
instead of each surface growing its own opt-in mechanism independently. It
does not gate reads: listing users, viewing settings, decoding tokens, and
the dashboard itself stay reachable exactly as they are today. Off by
default - unset, nothing is enforced, identical to before this setting
existed.
session:
management_secret: "your-secret-here" # default: unset
Must be printable ASCII - Werkzeug decodes request headers as latin-1, so a
non-ASCII secret could never be matched via the X-Management-Secret header
even though the form and MCP's JSON argument would see it correctly;
rejected at startup with a clear error rather than shipping a secret that
silently only half-works.
Also loadable from the NANOIDP_MANAGEMENT_SECRET environment variable.
NANOIDP_MCP_ADMIN_SECRET - this setting's MCP-only predecessor - keeps
working as an alias, so existing MCP-only setups aren't broken by upgrading.
Precedence: an explicit management_secret key in settings.yaml's session:
block wins over both env vars even when its value is empty or null -
presence in YAML is read as the operator deliberately stating "off", distinct
from the key being absent. Only when the key is absent entirely do the env
vars apply, NANOIDP_MANAGEMENT_SECRET before the legacy
NANOIDP_MCP_ADMIN_SECRET. Env vars are read once, when configuration loads (startup, or an explicit
reload_config) - not on every request, so changing one in the environment
has no effect until then.
Each surface proves knowledge of the secret differently, matching how that surface already talks to NanoIDP:
| Surface | How the secret is supplied |
|---|---|
| MCP server | The existing admin_secret tool argument on any mutating tool - see Admin Secret Protection |
/api/* | An X-Management-Secret request header on any POST/PUT/DELETE |
Web UI (ui_bp) | A one-time "Unlock management actions" form on /login; once submitted correctly, the session is trusted for the rest of its lifetime - the same trust model require_ui_login already uses for session['user'], so you aren't re-prompted on every click |
A UI mutation attempted before the secret has been unlocked for that session
redirects to /login with a prompt to unlock, rather than silently failing;
retry the action after unlocking. The web UI's own dashboard pages
(users, token tester, audit log) call /api/* from client-side JavaScript
using the browser's session cookie, not the header - an already-unlocked
session satisfies /api/*'s gate too, so those buttons keep working after
one unlock; a non-browser client still needs the header.
What's actually stored in session['management_verified'] is an HMAC of
management_secret itself (keyed by secret_key), not a bare boolean - see
Session Cookie Trust for why that
distinction matters.
Relationship to require_ui_login: independent axes - see
Config UI Login Gate above.
require_ui_login is the session front door (who can view the dashboard);
management_secret is the write guard (who can change anything). Either,
both, or neither can be enabled.
YAML-only: like secret_key, require_ui_login, and security_profile,
this is not exposed on the Settings page or the MCP update_settings tool -
a secret editable through the surface it protects isn't a secret.
Security Profiles
NanoIDP supports three security profiles to balance convenience with basic security controls:
| Profile | Description |
|---|---|
dev (default) | Maximum convenience for development: plaintext passwords, permissive CORS, no rate limiting |
stricter-dev | Semi-hardened runtime: bcrypt passwords, restricted CORS, rate limiting, debug mode blocked |
oauth21 | Draft OAuth 2.1 protocol strictness (#68): PKCE required (S256 only), refresh token rotation on, password grant removed, registered redirect URIs mandatory at /authorize |
stricter-dev hardens the runtime; oauth21 hardens the protocol. They are deliberately orthogonal. The discovery document always reflects the active profile: under oauth21, password disappears from grant_types_supported and code_challenge_methods_supported is ["S256"].
Usage
# Run with default dev profile
python -m nanoidp
# Run with stricter-dev profile
python -m nanoidp --profile stricter-dev
# Run with draft OAuth 2.1 protocol strictness
python -m nanoidp --profile oauth21
--profile is a per-run override: when given (any of the three values,
including an explicit dev) it wins over security_profile in
settings.yaml, is never written back to the file, and is re-applied after
every configuration reload, including the reload that follows each web UI or
MCP save. The stricter-dev runtime hardening (require_pkce,
password_hashing, rate_limit_enabled, debug off) is derived from the
effective profile on every reload the same way, whether the profile came from
the flag or from YAML. GET /api/config reports the effective profile,
whether it came from an override, and the derived values (#172).
Feature Comparison
| Feature | dev | stricter-dev | oauth21 |
|---|---|---|---|
| Password storage | Plaintext | bcrypt hash | Plaintext |
| CORS | * (all origins) | localhost only | * (all origins) |
| Rate limiting | None | 10 req/min on /token | None |
| Debug mode | Allowed | Blocked | Allowed |
| PKCE | Optional | Required, S256 only | Required, S256 only |
| Refresh token rotation | Setting (off) | Setting (off) | Forced on |
password grant | Enabled | Enabled | Removed (and not advertised) |
Redirect URIs at /authorize | Any valid URI, or exact match if registered | Same as dev | Registration mandatory, exact match |
Invalid bcrypt hash fallback
When password_hashing is on, a stored users.yaml password that isn't a
valid bcrypt hash is not rejected by default - authenticate() catches the
format error and falls back to a plain string comparison, only logging a
warning. This exists so turning on stricter-dev/password_hashing doesn't
immediately lock out every user until each one is manually re-hashed.
To close that gap, opt in to:
session:
enforce_password_check: true # default: false
With this on, a user whose users.yaml password isn't already a bcrypt hash
simply can't log in - no plaintext fallback, no warning-and-continue. Has no
effect when password_hashing is off (that path is intentionally plaintext,
dev mode). Like secret_key and require_ui_login, this is YAML-only - not
on the Settings page or the MCP update_settings tool.
Persona Login Mode
Interactive logins can optionally skip password prompts entirely and let you sign in by picking a configured user from a list - handy for local testing when you just want to switch between users quickly, without looking up passwords.
Opt-in and off by default:
login:
mode: persona # default: password
| Mode | Behavior |
|---|---|
password (default) | Interactive logins require the configured password |
persona | Interactive logins list the configured users; sign in by selecting one, no password prompt |
Where it applies: every interactive login surface - the nanoidp dashboard's /login, OIDC's /authorize, SAML's /saml/sso, and the device authorization flow's /device verification page.
Where it doesn't apply: the OAuth2 password grant (grant_type=password at /token) is unaffected either way - it's a machine-to-machine credential exchange, not an interactive login. A user with no password configured (see below) simply can never authenticate through it, in either mode.
Password-less users: password is optional on a user in users.yaml. A user without one can only sign in via persona mode - they're rejected by password-mode login and the OAuth password grant alike.
users:
admin:
password: "admin" # still works with either login mode
alice:
email: "alice@example.org" # no password: persona-mode only
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
- Rotation: New key pair generated, old key moved to "previous" list
- JWKS: Returns both active and previous keys (configurable via
max_previous_keys, default 2) - Signing: New tokens signed with the active key
- Validation: Tokens signed with previous keys remain valid until those keys are rotated out
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.yamlandbootstrap.yamlare operator-owned by definition, the same trust boundary assecret_keyandmanagement_secret, which is why hooks and plugins are YAML-only: the web UI and the MCPupdate_settingstool report them and cannot change them. A configuration surface that could set a command would be a remote-execution primitive.
Hooks never run on the protocol path and cannot fail a request: an
on_audit_event failure is counted and logged, never propagated.
MCP Server Security
The MCP (Model Context Protocol) server provides integration with Claude Code and other MCP-compatible tools.
Security Warning
The MCP server exposes powerful administrative tools and should ONLY be used:
- Locally on developer machines
- In isolated development environments
- Never exposed to network access
Mutating Tools
The following MCP tools modify configuration and require extra caution:
| Tool | Description |
|---|---|
create_user | Create a new user |
create_persona_user | Create a password-less user (persona login mode) |
update_user | Modify user attributes |
delete_user | Remove a user |
create_client | Create OAuth client |
update_client | Modify client settings |
delete_client | Remove OAuth client |
generate_token | Generate access tokens |
update_settings | Modify IdP settings |
save_config | Persist configuration changes |
Admin Secret Protection
When management_secret is configured, mutating
operations require the secret via the admin_secret tool argument.
NANOIDP_MCP_ADMIN_SECRET still works too - it's this setting's original,
MCP-only name, kept as an alias:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config",
"NANOIDP_MCP_ADMIN_SECRET": "your-secret-here"
}
}
}
}
Mutating tool calls without the correct secret will be rejected.
Readonly Mode
To completely disable mutating tools:
# Via CLI flag
nanoidp-mcp --readonly
# Via environment variable
NANOIDP_MCP_READONLY=true nanoidp-mcp
In Claude Code settings:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"args": ["--readonly"],
"env": {
"NANOIDP_CONFIG_DIR": "./config"
}
}
}
}
Use readonly mode when you only need introspection (listing users, decoding tokens, viewing settings) but want to prevent accidental modifications.
Audit Logging
All MCP tool calls are logged to the audit log, including:
- Tool name
- Parameters (secrets redacted)
- Timestamp
- Result status
Multi-hostname Issuer (issuer_from_request)
oauth.issuer_from_request (off by default) reflects each request's own
Host header as the discovery issuer, token iss, and device flow
verification_uri, so the same NanoIDP can be reached under more than one
hostname (e.g. a Docker Compose service name vs. localhost) without a
discovery/token issuer mismatch.
Trust caveats:
- The Host header is trusted as-is unless
oauth.issuer_allowlistis set to a non-empty list of allowed origins; a non-matching Host then falls back to the fixedissuerinstead. Only enableissuer_from_requestwithout an allowlist on trusted networks. - Behind a TLS-terminating reverse proxy, also enable
oauth.issuer_from_proxy_headerssorequest.scheme/host_urlreflectX-Forwarded-Proto/X-Forwarded-Hostinstead of the proxy's own HTTP connection - this only changes the derived issuer whenissuer_from_requestis also on (it always affects rate-limit client IP attribution regardless). Only enable this when NanoIDP sits directly behind exactly one trusted proxy- these headers are otherwise spoofable by any client.
- By default, the device flow's
verification_urireflects whichever Host called/device_authorization. If that caller is a backend/container (e.g.Host: nanoidp:9900) rather than the end user's own browser, the returned URL may not be reachable from the user's machine. Setoauth.device_verification_base_urlto a fixed, human-reachable URL (e.g.https://idp.example.com) to pinverification_uriregardless of the calling Host; discovery'sissuerand a token'sissare unaffected and keep following the request that fetched/requested them.
Environment Variables
| Variable | Description | Default |
|---|---|---|
NANOIDP_CONFIG_DIR | Configuration directory path | ./config |
NANOIDP_MANAGEMENT_SECRET | Secret required for mutations across MCP, /api/*, and the web UI | (none) |
NANOIDP_MCP_ADMIN_SECRET | Legacy alias for NANOIDP_MANAGEMENT_SECRET (MCP-only name, still honored) | (none) |
NANOIDP_MCP_READONLY | Disable mutating MCP tools when set to true | false |
PORT | Server port | 8000 |
Best Practices
- Use stricter-dev profile when sharing the instance with team members
- Enable readonly mode for MCP when only introspection is needed
- Set
management_secretif multiple developers share the same NanoIDP instance - it gates mutating calls on MCP,/api/*, and the web UI alike - Rotate keys periodically to test token validation with multiple keys
- Keep the default
127.0.0.1binding unless you specifically need network access; only override it on a trusted, isolated network (see Network Binding) - Never expose NanoIDP to public networks: it's designed for local/isolated use only
Related Documentation
- MCP Workflow - Detailed Claude Code integration examples
- README - Installation and configuration
Configuration
NanoIDP is configured through two YAML files in the config directory
(./config by default, --config or NANOIDP_CONFIG_DIR to change it).
Everything below can also be managed from the web UI at
http://localhost:8000:
- Dashboard: overview and quick stats
- Users: create, edit, delete users
- OAuth Clients: manage OAuth2 client credentials
- Settings: configure IdP settings (issuer, audience, SAML)
- Keys & Certs: view and regenerate RSA keys
- Claims: configure authority prefix mappings
- Audit Log: view and export authentication events
- Token Tester: generate and inspect tokens
Config schema version
Both files may declare, at the top level, the schema version they follow:
config_version: 1
The version belongs to the configuration directory's contract as a whole,
not to one file: settings.yaml and users.yaml declare the same number
(a mismatch refuses to start), each file is checked independently against
the version the running release supports, and a future bump applies to both files together with one loader
migration. The value must be a literal integer; it is checked before
${VAR} placeholders are expanded, so config_version: ${CONFIG_VERSION:1}
is rejected like any non-integer.
The contract is a single integer, not semver:
- Absent means 1. Existing files need no change;
nanoidp initand the wizard write the key into the files they create, and saves from the web UI or the MCP server preserve it if present and never add it. - Unknown keys are reported. A key nanoidp does not know (a typo such
as
oauth.isuer) is logged as a warning with its path and ignored; the file still loads, unless the directory is validated strictly (see Validating your configuration). Inside a user entry, unknown keys become that user'sattributes, as they always have. - Optional additions never bump it. New optional keys with defaults keep the version; only renames, removals or semantic changes of existing keys do, and such a bump ships with a migration step in the loader.
- A newer version than the running release understands is refused at startup with a message naming the file, the value found and the supported version, as is any value that is not a positive integer.
GET /api/config and the MCP get_settings tool report the effective
config_version, so external tools and agents know which contract to target.
The CHANGELOG carries a "Config schema" section whenever it changes.
The generated schema artifact
The machine-readable form of the contract lives in the repository at
docs/schema/config.v1.json:
one standalone JSON Schema per file, under the keys settings, users and
bootstrap, next to the config_version they describe. Point an editor's
YAML-schema support at the entry matching the file to get completion and
typo detection while writing it.
It is generated, never hand-written - that is the whole point, since a hand-written schema would be one more place the contract could disagree with itself:
nanoidp config-schema # the full document, on stdout
nanoidp config-schema --file users # one file's schema
nanoidp config-schema --write # regenerate docs/schema/config.v1.json
--write targets a path inside the repository and therefore works from a
source checkout only; from an installed package, redirect stdout instead. A
test fails when the committed file no longer matches the models, with the
command to run.
One thing the schema cannot express: a ${VAR} placeholder is a string
until it is expanded, and its expansion is a string too, which nanoidp then
coerces to the field's type. A plain JSON Schema check therefore flags
port: ${PORT:8000} against "type": "integer". Use nanoidp validate-config for that check - it runs the real loader.
Validating your configuration
Unknown keys are reported, not ignored. What "reported" means is a choice:
# settings.yaml
config_validation: warn # default: log the key and its path, keep loading
# config_validation: strict # refuse to start
The value belongs to the configuration directory as a whole: users.yaml
and bootstrap.yaml follow what settings.yaml declares. Wrong types and
refused values are errors in both modes and always have been; strict is
only about the unknown key. settings.yaml and users.yaml are validated
on startup and on every reload; bootstrap.yaml is validated when the
bootstrap surface is loaded, at startup (validate-config checks all
three, so for bootstrap.yaml it reports what would stop the NEXT
startup, not the next reload). A load that fails validates and commits
nothing: the running settings, users, validation mode and hooks stay
exactly as they were, in the same fail-without-commit contract as the
hook registry. The server flag --strict-config turns strict
on for one run, wins over the file, and is never written back:
nanoidp --strict-config # unknown key -> refuse to start
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.yamlwith placeholders and set the variables per environment (shell, Composeenvironment:, a Kubernetesenv:block). - Secrets kept out of the file: point the placeholder at a variable that an init step renders from wherever the secret lives; NanoIDP only ever sees the environment.
- Files produced elsewhere: generate or copy both YAML files into
NANOIDP_CONFIG_DIRbefore start (an init container, a mounted volume, a script), thenPOST /api/config/reloador the MCPreload_configtool to pick up a later change without a restart. Reloading re-reads the files, re-expands placeholders and re-applies the CLI--profile.
NanoIDP does not read from or write to any store other than these files; a sync with an external system is the deploy's job, on either side of the directory.
Environment variables
The environment variables (NANOIDP_CONFIG_DIR, NANOIDP_MANAGEMENT_SECRET,
the legacy NANOIDP_MCP_ADMIN_SECRET alias, NANOIDP_MCP_READONLY, PORT)
are listed in the
Security guide.
Endpoints
OAuth2 / OIDC
| Endpoint | Description |
|---|---|
GET /.well-known/openid-configuration | OIDC Discovery |
GET /.well-known/jwks.json | JSON Web Key Set |
GET/POST /authorize | Authorization endpoint (login page) |
POST /token | Token endpoint |
GET/POST /userinfo | UserInfo endpoint |
POST /introspect | Token Introspection (RFC 7662) |
POST /revoke | Token Revocation (RFC 7009) |
GET/POST /logout | OIDC End Session / Logout (alias: /end_session) |
GET /ui/logout | Dashboard session logout (the web UI's Logout button) |
POST /device_authorization | Device Authorization (RFC 8628; alias: /device/code) |
GET/POST /device | Device verification page |
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
| Endpoint | Description |
|---|---|
GET /saml/metadata | IdP Metadata |
GET /saml/cert.pem | IdP signing certificate (PEM) |
GET/POST /saml/sso | Single Sign-On (supports both HTTP-POST and HTTP-Redirect bindings) |
POST /saml/attribute-query | Attribute Query |
Bindings, strict-binding mode, response signing, and canonicalization are covered in SAML options.
REST API
| Endpoint | Description |
|---|---|
GET /api/health | Health check |
GET /api/users | List users |
GET /api/users/{username} | Get user details |
POST /api/users/{username}/token | Generate token |
GET /api/audit | Get audit log |
GET /api/audit/stats | Audit log statistics |
POST /api/audit/clear | Clear the audit log |
GET /api/config | Get current configuration |
POST /api/config/reload | Reload configuration |
POST /api/keys/rotate | Rotate cryptographic keys |
GET /api/keys/info | Get key information |
Tokens and claims
Audiences and the aud claim
NanoIDP follows the OpenID Connect / OAuth specs for the aud claim:
- ID Token:
audis the requesting client'sclient_id(OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configureadditional_audienceson a client to append extra audiences; if this produces more than one distinct audience value,audis emitted as an array and NanoIDP also emits anazpclaim equal to theclient_idso you can exercise authorized-party validation. - Access Token:
audis the resource audience fromoauth.audience(RFC 9068 §2.2), independent of the client.
Access Token
The access token aud is the resource audience (oauth.audience). The
user's attributes from users.yaml are carried both as individual claims
and flattened into authorities via the configured authority_prefixes:
{
"iss": "http://localhost:8000",
"sub": "admin",
"aud": "my-app",
"iat": 1704100000,
"exp": 1704103600,
"roles": ["USER", "ADMIN"],
"groups": ["ADMINISTRATORS", "EVERYONE"],
"tenant": "default",
"identity_class": "INTERNAL",
"entitlements": ["ADMIN_ACCESS", "USER_MANAGEMENT"],
"authorities": [
"ROLE_USER",
"ROLE_ADMIN",
"GROUP_ADMINISTRATORS",
"GROUP_EVERYONE",
"IDENTITY_INTERNAL",
"ENT_ADMIN_ACCESS",
"ENT_USER_MANAGEMENT",
"ACL_READ",
"ACL_WRITE"
]
}
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:
| Binding | HTTP Method | SAMLRequest Encoding |
|---|---|---|
| HTTP-POST | POST | Base64 only (uncompressed) |
| HTTP-Redirect | GET | DEFLATE compressed + Base64 |
Both bindings are advertised in the SAML metadata (/saml/metadata).
Strict binding mode
By default, NanoIDP operates in lenient mode for developer convenience, accepting GET requests with uncompressed SAMLRequest data (non-compliant but useful for debugging).
To enforce strict SAML 2.0 binding compliance:
Via configuration file (settings.yaml):
saml:
strict_binding: true # Reject GET with uncompressed data
Via web UI: Settings → SAML Settings → toggle "Strict SAML Binding" → Save Settings.
| Mode | GET with uncompressed data | GET with DEFLATE | POST uncompressed |
|---|---|---|---|
| Lenient (default) | Accepted | Accepted | Accepted |
| Strict | Rejected (400) | Accepted | Accepted |
Response signing
By default, NanoIDP signs all SAML responses with an XML digital signature. You can disable signing for testing scenarios that require unsigned SAML flows:
Via configuration file (settings.yaml):
saml:
sign_responses: false # Disable SAML response signing
Via web UI: Settings → SAML Settings → toggle "Sign SAML Responses" → Save Settings.
When sign_responses: true (default), responses include:
<ds:Signature>element with RSA-SHA256 signature<ds:X509Certificate>with the IdP certificate
When sign_responses: false, responses are sent without any signature
elements.
Exporting roles and groups as attributes
Roles and groups are not included in SAML assertions by default. Enable them explicitly - and, because every SP expects a different attribute name, name them to match your SP:
Via configuration file (settings.yaml):
saml:
export_roles: false # default; true to include the roles attribute
export_groups: false # default; true to include the groups attribute
roles_attr_name: "roles" # name used when export_roles is on
groups_attr_name: "groups" # name used when export_groups is on
Via web UI: Settings → SAML Settings → toggle "Export Roles Attribute" / "Export Groups Attribute" and set the matching attribute name → Save Settings. Clearing a name field restores its default.
Each entry becomes its own AttributeValue, in both the SSO assertion and
the AttributeQuery response, so a value containing a comma stays a single
value. Configuring the same name for both exports (e.g. memberOf for roles
and groups alike) is supported: the two lists are merged into that one
attribute, roles first, deduplicated.
Any name works, including the URIs SPs commonly expect:
| SP | Typical roles attribute name |
|---|---|
| Spring Security | roles (whatever your AttributeConverter reads) |
| ADFS / Entra ID | http://schemas.microsoft.com/ws/2008/06/identity/claims/role |
| Shibboleth | urn:oid:1.3.6.1.4.1.5923.1.5.1.1 (groups / isMemberOf) |
| Generic LDAP-style | memberOf |
Both toggles apply to the SSO assertion and to the AttributeQuery endpoint,
and each entry becomes its own <AttributeValue>. A user with no roles (or
no groups) gets no attribute even when the export is on, and a custom user
attribute with the same name still takes precedence.
Note that these attributes are independent of the authority_prefixes
mapping, which only affects the authorities claim in OAuth/OIDC tokens.
Signed AuthnRequests
By default, nanoidp accepts unsigned AuthnRequests (and ignores
Signature/SigAlg query parameters). SPs that sign their requests can
turn on verification:
saml:
want_authn_requests_signed: true
sp_certificates:
- /path/to/sp-cert.pem # PEM; one entry per trusted SP
With the flag on, nanoidp requires and verifies the signature under
both bindings and rejects unsigned or invalid requests with 400:
- HTTP-Redirect: the query-string signature over the URL-encoded
SAMLRequest[&RelayState]&SigAlgfragment (SAML 2.0 Bindings §3.4.4.1);rsa-sha256,rsa-sha512and legacyrsa-sha1SigAlg values are supported. - HTTP-POST: the enveloped
<ds:Signature>inside the AuthnRequest (SAML 2.0 Core §5).
The metadata advertises WantAuthnRequestsSigned="true" if and only if
enforcement is on. A request verifies if any registered certificate
validates it.
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.
| Value | Algorithm | Use Case |
|---|---|---|
exc_c14n (default) | Exclusive C14N 1.0 | Standard for SAML, handles namespace isolation |
c14n | C14N 1.0 | Legacy SAML implementations |
c14n11 | C14N 1.1 | Newer implementations |
Why Exclusive C14N is the default:
Exclusive C14N is recommended by the SAML 2.0 specification because it
only includes namespaces actually used in the signed element. This is
important when SPs extract the <Assertion> element from the <Response>
to verify the signature independently. With standard C14N, the signature
includes parent namespaces that break when the Assertion is extracted.
MCP server
NanoIDP includes an MCP (Model Context Protocol) server for integration with Claude Code and other MCP-compatible tools. For a hands-on tour with prompts, workflows, and end-to-end examples, see MCP with Claude Code. For the admin secret, readonly mode, and the exposure warnings, see the Security guide.
Available tools
| Tool | Description |
|---|---|
list_users | List all configured users |
get_user | Get details of a specific user |
create_user | Create a new user |
create_persona_user | Create a password-less user for persona login mode (local dev/testing convenience) |
update_user | Update an existing user (password, email, roles, …) |
delete_user | Delete a user |
generate_token | Generate OAuth2 tokens for a user (pass scope with openid to also get an ID Token; id_token_claims/userinfo_claims mirror the OIDC claims request parameter) |
decode_token | Decode JWT token (without verification) |
verify_token | Verify JWT token signature and expiration |
list_clients | List OAuth clients |
get_client | Get client details |
create_client | Create a new OAuth client |
update_client | Update an existing OAuth client |
delete_client | Delete an OAuth client |
get_settings | Get current IdP settings |
update_settings | Update IdP settings |
save_config | Persist the current configuration to the YAML files |
reload_config | Reload configuration from files |
validate_config | Lint the running config directory without starting or executing anything (no hook, no plugin): {valid, findings} |
get_oidc_discovery | Get OIDC discovery document (same document as /.well-known/openid-configuration) |
get_jwks | Get JSON Web Key Set |
get_audit_log | Get audit log entries (filter by limit, event type, username) |
get_audit_stats | Get audit statistics |
clear_audit_log | Clear the audit log |
get_keys_info | Get signing key info (active kid, previous keys) |
rotate_keys | Rotate signing keys (old key stays valid for verification) |
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.
- 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.
- Metadata never lies. Discovery and documentation advertise exactly
what the endpoints implement: a missing feature is acceptable, a
pretended one is not (see #41:
response_type=tokenwas advertised but unimplemented, and was removed rather than implemented). - Hardening is opt-in, defaults stay permissive. Strictness lives in
security profiles (
stricter-dev) and explicit settings (require_pkce,refresh_token_rotation); the out-of-the-box experience favors getting a first token in under a minute. - MCP/HTTP parity. Every administrative and testing capability that is meaningful to agents is exposed through MCP, with shared builders and models wherever possible so equivalent surfaces cannot drift (see #40: the shared discovery builder). Protocol surfaces themselves, authorization redirects, SAML SSO, UserInfo, are exercised over HTTP, as a real client would.
- Features ship whole. A feature lands together with its MCP exposure
(where applicable), its
examples/test_agent.pye2e coverage and its docs, in the same PR. - RFC-citable behavior. Token and protocol behaviors reference the spec paragraph that justifies them, in code comments and changelog entries alike. When a reviewer disagrees, the RFC arbitrates.
- Presentation is data, not code. Because nanoidp is also used for local demos and prototyping a login experience, some per-client presentation - the client's id and description, a logo - is in scope. But anything a user can set that ends up rendered in a page must be structured data or an operator-provided local asset, never free-form markup, CSS, or a remote URL fetched into the page. This rules out arbitrary per-client CSS (an injection surface on the auth UI) and remote logo URLs (attribute-injection plus a third-party beacon that sees every visitor), while allowing a client's id, description, or a locally-served logo file. Cosmetic customization must never become an injection or tracking vector on the authentication UI.
Non-goals
- Production or hosted use. No HA, no hardening guarantees, no real
user data. A shared dev stack is supported (above); an instance that
serves people who are not its operators is not. Opt-in management
guards (
require_ui_login,management_secret) are locks for a trusted network, not an access-control system: there are no roles, no per-user audit, no tenant isolation, and none are planned. - External configuration backends. Declared configuration remains
schema-versioned YAML you can read, edit and
git diff. Secrets and users reach nanoidp through YAML files and${VAR}placeholders rendered by the deploy (Vault Agent, External Secrets, an init container); nanoidp does not use a database, Vault or another service as a configuration source of truth, and there is no pluggable configuration backend. The hooks and plugins shipped in 2.7.0 are the way to react to configuration events from outside (mirror, notify, bootstrap): nanoidp provides the extension points, the deploy provides whatever sits behind them. - Production persistence and distributed state. Runtime 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-mcpprocess 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:
- 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.
- SAML hardening: optional validation of signed AuthnRequests, for testing SPs that sign their requests.
- Typing strictness:
tighten the mypy baseline module by module (
disallow_untyped_defs) untilsrc/is fully annotated. - CI quality gates: enforce a coverage threshold in CI and fail on Codecov upload errors.
- 3.0 breaking cleanups:
deferred breaking changes for the next major; first entry: refresh
tokens without a
client_idbinding claim stop being accepted (transitional compatibility introduced in 2.2.0). - Agentic OAuth / MCP interoperability: the auth cases of agentic systems, with MCP clients and servers as ordinary OAuth parties: per-client scopes, RFC 8707 resource indicators, public clients with mandatory PKCE, RFC 9207, 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]):
- Layers:
routes->services->config. A route may import services and config; a service may import config; nothing imports upward. In practice:services/never imports Flask objects or route modules, so every service is testable without a request context. serialization.pyhas no runtime package imports. It is pure YAML-shaping code; its only tie to the rest of the package is type-checking-only annotations. If you make it import a package module at runtime, CI fails.
Everything under src/ is fully type-annotated
(disallow_untyped_defs in [tool.mypy]).
Package map
Entry points:
| Module | What it is |
|---|---|
app.py | create_app(): Flask app factory, blueprint registration, session cookie policy, startup warnings |
__main__.py | The nanoidp CLI: serve, init, wizard, validate-config, config-schema, plugins |
mcp_server.py | The nanoidp-mcp stdio server: tool declarations, handlers, and its own ConfigManager |
wizard.py | The nanoidp wizard interactive configuration builder |
HTTP surfaces (routes/), one blueprint per protocol surface:
| Module | What it is |
|---|---|
routes/oauth.py | OAuth2/OIDC: /authorize, /token (one _grant_* helper per grant), /userinfo, discovery, JWKS, introspection, revocation, device flow |
routes/saml.py | SAML 2.0 IdP: metadata, SSO, SLO, AttributeQuery |
routes/ui.py | The config web dashboard: HTML form flows for users, clients, settings, keys, claims, audit |
routes/api.py | JSON management API (/api/*): health, config, users, keys, audit |
Shared route infrastructure, all underscore-prefixed:
| Module | What it is |
|---|---|
routes/_auth.py | The 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.py | Effective-issuer resolution, shared by every endpoint that mints a token or advertises the issuer (#133: they must never disagree) |
routes/_audit.py | audit_event(...): shared helper for route-level audit events (not yet universal: /api/keys/rotate still logs directly) |
Protocol logic and runtime state (services/), each module small and
single-purpose:
| Module | What it is |
|---|---|
services/token.py | JWT building: access tokens, ID Tokens, the /token response body |
services/auth_code.py | Authorization-code store (PKCE data rides on the code) |
services/device_code.py | Device-flow code store |
services/revocation.py | In-memory revocation and refresh-rotation family state |
services/crypto.py | Key management: generation, rotation, JWKS, external key import |
services/discovery.py | Single source of the OIDC discovery document (HTTP and MCP both render this; metadata never lies) |
services/redirect_uri.py | Redirect-URI registration matching, including RFC 8252 native-app rules |
services/saml_verification.py | Signed-AuthnRequest verification |
services/audit.py | The audit log (in-memory ring, export) |
services/yaml_writer.py | Writes the YAML files back for the UI's per-field saves. Not the only write path: ConfigManager.save() persists whole documents through serialization.atomic_write_yaml too - both build their entries in serialization.py, but the read-modify-write itself has two owners today (a known debt, tracked for a single write pipeline with conflict detection) |
The config layer and the pure bottom:
| Module | What it is |
|---|---|
config.py | ConfigManager: owns loading, reloading and handing out Settings/users; loads are transactional (a failed reload commits nothing) |
config_documents.py | Pydantic document models mirroring the YAML sections one to one; to_settings() / to_users() build the domain objects |
config_schema.py | JSON Schema generated from the document models; docs/schema/config.v1.json is the committed artifact and a test fails when they diverge |
config_validation.py | nanoidp validate-config: lint a config directory without starting anything (hooks never execute) |
hooks.py | HookRegistry: the extension points (on_before_load, on_config_saved, on_audit_event), shell hooks, entry-point plugins, the bootstrap surface |
models.py | The domain dataclass-style models: Settings, User, OAuthClient |
serialization.py | Domain objects <-> YAML dicts, both directions, no runtime package imports; OWNED_SETTINGS is the one table of managed settings.yaml keys, pinned to the models by parity tests (#214) |
exceptions.py | The exception taxonomy |
branding.py | Per-client logo resolution (local assets only, containment-checked) |
Where state lives
There are exactly two kinds of state, and they never share a store:
- Declared configuration is the two YAML files (
settings.yaml,users.yaml, plus the optionalbootstrap.yamlfor hooks/plugins). It is schema-versioned (config_version), validated through the document models, editable by hand, and meant to be committed to git. The UI's per-field saves go throughservices/yaml_writer.py; the MCPsave_configtool and reloads persist whole documents throughConfigManager.save(). Both delegate their entry-building toserialization.py, so the two paths cannot drift on content - 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, andSettingswiring all follow from it.
Adding a field to OAuthClient: the full flow
This is the most-repeated multi-site change in the project, and the place regressions have historically come from (#32: the regenerate-secret leg was missed and the field silently dropped). A new client field touches all of these:
models.py: the field onOAuthClient, with its description.config_documents.py: the field onClientEntryand its coercion into_client().serialization.pyclient_to_yaml: write it when non-empty.serialization.pymerge_client_entry: merge it on edit.templates/clients_form.html: the form control.routes/ui.pyclient create: parse it.routes/ui.pyclient edit: parse it.routes/ui.pyregenerate-secret: carry it through (the #32 leg).mcp_server.py_client_to_dict: expose it.mcp_server.pycreate_clienttool schema.mcp_server.pyupdate_clienttool schema.mcp_server.pycreate/update handlers: normalize and apply it.nanoidp config-schema --write: regenerate the committed schema (a test fails if it is stale).
Then: tests for the YAML round-trip and the persist-through-edit and
regenerate-secret paths, MCP parity coverage, and an
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.pyis the end-to-end agent: it drives a real server over HTTP and MCP the way an agent would. CI runs it in thehttp-e2eandmcp-e2ejobs. When a test creates state through the UI it must use the shared session (self.session), or the management gates will silently redirect it.- Test isolation from the repo's own
config/directory is enforced bytests/conftest.py(it resets the config and yaml-writer singletons); a test must never read or write the repo's config.
Docs layout
The mdBook site under book/ is canonical for guides and reference.
docs/SECURITY.md, docs/MCP_WORKFLOW.md, CHANGELOG.md,
CONTRIBUTING.md and VISION.md are the canonical files for their
content and are symlinked into book/src/; edit the file at the repo
root or under docs/, never the symlink target's copy. The site
redeploys automatically on merges touching book/ or docs/.
docs/RELEASING.md is the maintainers' release manual and stays out
of the site on purpose.
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
client_credentialsno longer returns a refresh token (#239). RFC 6749 §4.4.3: "A refresh token SHOULD NOT be included" - the client authenticates itself on every request, and the token handed out was a second, 7-day credential bound to the default user (or the synthetic service account) that the grant never authenticated, spendable atgrant_type=refresh_tokenfor user-context tokens. The response now has norefresh_tokenkey at all; every other grant is unchanged. A client that refreshed a client-credentials token must request a new one with its credentials, which is what the RFC asks of it.- /saml/sso rejects a request with nowhere to send the assertion (#227):
an AuthnRequest without
AssertionConsumerServiceURLagainst a config whosesaml.default_acs_urlis blank now gets a 400 naming both missing sources (with a failedsaml_requestaudit entry), instead of rendering an auto-submit form posting toaction=""- the IdP's own page. client_credentialsno longer 500s whendefault_usernames a missing user (#241): the syntheticservice-accountfallback was built with an empty password, whichUserrejects since #158 madepasswordoptional withmin_length=1. The fallback now carries no password, as it never authenticates with one, and the grant answers withsub=service-accountagain.- The dashboard's Logout button logs the UI session out again (#221).
The UI logout route registered the same
/logoutrule as the OIDC end-session endpoint and always lost: clicking Logout landed on the end-session confirmation page and the UI logout audit event was never written. The UI logout now lives atGET /ui/logout(the button follows automatically viaurl_for), redirects back to the dashboard, and writes itslogoutaudit event;/logout(alias/end_session) remains the OIDC endpoint, unchanged. - Regenerating a client secret no longer resets the client's branding
(#213 review):
/clients/<id>/regenerate-secretrebuilt the client with only five fields, silently droppingbackground_color,header_colorandfooter_colorand resettingshow_client_id/show_descriptionto their defaults - the same rebuild-by-hand shape that lostadditional_audiencesin #32. The route now copies the client and changes only the secret, so every field (present and future) is carried.
Added
- Per-client allowed scopes and
invalid_scope(#186).oauth.scopes_supportedis the global scope vocabulary (defaultopenid,profile,email,offline_access, also what discovery'sscopes_supportednow advertises instead of a hardcoded list); a client's newallowed_scopesis an optional subset of it. A requested scope outside the vocabulary isinvalid_scopefor every client - a small behavior change, since any scope string used to be accepted unchecked; a scope outside a client's ownallowed_scopes, when set, isinvalid_scopefor that client specifically. Enforced at/authorize, every/tokengrant (includingclient_credentials, RFC 6749 §4.4, which previously dropped any requested scope entirely) and/device_authorization; an omittedscopedefaults to the client's full allowed set, or today's default when unrestricted. Every/tokenrejection - including the pre-existing refresh-token scope-narrowing check - now returns the RFC 6749 §5.2 JSON error shape ({"error": "invalid_scope", ...}) instead of a bare 400.oauth.scope_enforcement: falseis a dev-only escape hatch back to the pre-#186 behavior (any scope string accepted, unchecked); refused outside thedevprofile.allowed_scopesis settable from the clients UI form and the MCPcreate_client/update_clienttools, same asadditional_audiences/redirect_uris.
Security
- Opt-in
management_secretmutation gate (#163): one shared secret that gates state-changing calls across all three management surfaces - the MCP server (via the existingadmin_secrettool argument, which now reads from this setting instead of a standalone env var),/api/*(via a newX-Management-Secretrequest header on mutating calls), and the config web UI (a one-time "unlock" form at/loginthat then trusts the session for further mutating requests). Off by default - unset, nothing changes. Configurable viasettings.yaml'ssession.management_secretor theNANOIDP_MANAGEMENT_SECRETenv var; the previous MCP-onlyNANOIDP_MCP_ADMIN_SECRETstill works as an alias, though an explicitmanagement_secret: null/""insettings.yamlnow wins over either env var rather than falling through to it. Independent ofrequire_ui_login: that gate is the UI's session front door (who can view the dashboard), this is the write guard (who can change anything) - either, both, or neither can be enabled; the unlock form stays reachable even whenrequire_ui_loginis also on. YAML-only, same treatment asrequire_ui_login/secret_key. Hardened since first landing: the UI unlock flag is now an HMAC of the secret itself (not a bare session boolean), so it can't be forged just by knowingsecret_key's public default; a non-ASCII or non-string secret compares safely instead of 500ing; an unlocked UI session now also satisfies the/api/*gate, so the dashboard's own buttons (generate token, clear audit log) keep working after one unlock; and the MCP check now always reads theConfigManageractually serving the request.
2.7.0 - 2026-08-25
Changed
- Configuration files load through document models (#175, piece 2).
settings.yamlandusers.yamlare now parsed into Pydantic document models that mirror the YAML sections one to one (nanoidp.config_documents), and the domainSettings/Userobjects are built from them; the hand-written.get(key, default)mapping inconfig.pyis gone and the defaults live on the models, which the writer reads as well. No file format change and no behavioural change for files that loaded before. One visible improvement: an unknown key (a typo such asoauth.isuer, or a key nanoidp does not know) is now logged as a warning with its dotted path and ignored, instead of vanishing silently; keys that shipped presets carry but the loader never consumed (cors_allowed_origins,device_flow,logging.format,oauth.refresh_token_expiry_minutes,session.permanent) are declared so they do not warn. Fields inside a user entry keep folding intoattributes, as always. Stricter handling is a later piece.
Config schema
config_version1 introduced (#175, piece 1). No changes required to existing files: a file without the key is version 1. The version is the contract of the config directory as a whole: both files declare the same number, each is checked independently, and it must be a literal integer (checked before${VAR}expansion).
Added
- Generated config schema,
validate-configand strict validation (#175, pieces 3 and 4). Three additions to the config contract, none of which restates it a seventh time:nanoidp config-schemaprints the JSON Schema ofsettings.yaml,users.yamlandbootstrap.yaml, generated from the document models (--filefor one of them,--writeto regenerate the committed artifact from a source checkout). The artifact isdocs/schema/config.v1.json: one standalone schema per file under the keyssettings,usersandbootstrap, next to theconfig_versionthey describe, ready to point an editor's YAML-schema support at. A test fails when the committed file no longer matches the models, and parity tests fail when the MCPupdate_settingstool or the web UI's settings form grows a knob that is not a key of the contract - or offers one of the YAML-only fields (secret_key,require_ui_login,hooks,plugins).config_validation: warn|strict(top level ofsettings.yaml, defaultwarn) and the server flag--strict-configdecide what an unknown key does: log its path and keep loading, or refuse to start and refuse every later reload with the same message. The flag wins over the file for that run only and is never written back, like--profile(#172). One contract per directory:users.yamlandbootstrap.yamlfollow whatsettings.yamldeclares. Wrong types stay errors in both modes.nanoidp validate-config [--config DIR] [--strict]lints a configuration directory without starting anything: one line per finding, exit 0 when clean or with warnings only, exit 1 on errors and on warnings under--strict. It reads the three files through the same loaders the server uses and nothing else - noConfigManager, no hook dispatched, no plugin imported,bootstrap.yamlchecked for its shape only - so it is safe as a pre-commit or CI step on a directory whose hooks name commands. MCP agents get the same check as the read-onlyvalidate_configtool ({valid, findings}), which brings the MCP surface to 26 tools.
- Hooks and plugins v1 (#185): extension points for external
configuration stores, not backends. Three synchronous hooks with
HOOK_API_VERSION = 1:on_before_load(config_dir)before the files are read (startup and every reload),on_config_saved(path, kind)after an atomic write ofsettings.yamlorusers.yaml,on_audit_event(event)after an audit entry. Implement them as shell commands underhooks:insettings.yaml(placeholders{config_dir},{path},{kind},{event_type}, audit event JSON on stdin) or as Python plugins packaged separately and discovered through thenanoidp.pluginsentry-point group, configured underplugins.<name>:. Per-hook error policy:on_before_loadmay block underhooks.strict,on_config_savedis propagated to the caller understrictafter the write (the local save is always committed and the running configuration reloaded from it, so only the mirror is behind),on_audit_eventnever propagates. Commands are never reported by/api/configor MCP (they may embed expanded secrets) and a propagated error names the hook and its source only; the bootstrap surface is the baseline forstrict/timeout_seconds,settings.yamloverrides only what it declares. Bootstrap surface for hooks that must run beforesettings.yamlexists:NANOIDP_BOOTSTRAP_HOOK/--bootstrap-hook,NANOIDP_BOOTSTRAP_PLUGINwithNANOIDP_PLUGIN_<NAME>_<KEY>settings, andbootstrap.yamlin the config directory (hooks:andplugins:only).nanoidp plugins,GET /api/configand the MCPget_settingstool report what is loaded, from which surface, with failure counters and the plugins that could not be loaded (plugins_failed: a missing package or a wronghook_api_versionis reported, never fatal unlessstrict);hooks:/plugins:are YAML-only.bootstrap.yamlgoes through the same loader assettings.yaml(placeholders, unknown-key warnings). A stricton_before_loadfailure is a JSON503onPOST /api/config/reloadand an error result from the MCPreload_configtool. Audit logging never constructs the configuration and an audit event produced inside a load is not dispatched to hooks. An unchangedhooks:/plugins:declaration is not re-applied on the refresh that follows a local write, so plugins are not re-instantiated on every save. Reference pluginexamples/plugins/nanoidp-echo; guide "Extending nanoidp: hooks and plugins". - Import contracts enforced in CI (#149):
import-linternow pins the package layering (routes -> services -> config) and the invariant thatserialization.pyhas no runtime imports from the package (it is what letsconfig.pyimport it without a cycle). Both used to live only in comments;lint-importsruns next to ruff and mypy in the Tests workflow and fails when a change adds a forbidden import. config_versionfield (#175, piece 1):settings.yamlandusers.yamlaccept a top-level integerconfig_version: 1. Absent means 1, so existing files load unchanged; a value that is not a positive integer, or newer than the running release supports, is refused at startup with a message naming the file, the value and the supported version.nanoidp initand the wizard write it into the files they create; UI/MCP saves preserve an existing key and never add one.GET /api/configand the MCPget_settingstool expose the effective value; the e2e agent asserts it. Bumps only on renames, removals or semantic changes (with a loader migration), never on optional additions.- Native-app redirect URIs (#81, RFC 8252):
/authorizeaccepts private-use scheme redirect URIs such ascom.example.app:/oauth2redirect(§7.1: a scheme and a path, no authority) as absolute URIs and applies §7.1's minimum rule to them (a non-http(s)scheme without a period, such asmyapp://, is rejected with a message naming the rule; domain ownership is not verified), and a registered loopback URI (http://127.0.0.1:{port}/...,http://[::1]:{port}/...) matches any port (§7.3), since native apps bind an ephemeral port. Everything else keeps exact string matching (RFC 6749 §3.1.2.3): scheme, host, path and query of a loopback URI, every port of a non-loopback orlocalhostregistration. Fragments are now rejected explicitly (§3.1.2). One shared matcher,services/redirect_uri.py, serves both legs of/authorize; MCP tool descriptions andexamples/test_agent.pyupdated. - Persona login mode (#156): opt-in
login.mode: personalists the configured users on every interactive login surface (/login,/authorize,/saml/ssoand the device flow's verification page) and signs in by selecting one, with no password prompt - a local development/testing convenience, off by default.User.passwordis now optional: a password-less user can only authenticate via persona-mode interactive login, never via password-mode login or the OAuth password grant. Persona-authenticated sessions emit SAMLAuthnContextClassRef: unspecifiedinstead of falsely claimingPasswordProtectedTransport. New MCP toolcreate_persona_user, apersona-loginexample preset, settings-UI persistence and e2e coverage. - Per-client login page branding: optional per-client colors (background,
header, footer, all as validated hex values), show/hide client_id and
description on the
/authorizelogin page, and per-client logo images stored locally instatic/logos/keyed by client ID (no YAML config needed for logos; place the image file and it's served automatically; the directory is overridable viaoauth.logos_dir). Colours and toggles are editable from the OAuth client form in the UI and from the MCPcreate_client/update_client/get_clienttools, descriptions are already supported, and logos are deployed by the operator to the server filesystem. Designed for demos and prototyping; colours are structured (not free-form CSS) to prevent stored-XSS on the auth UI, and logos are local files only (no remote URLs) to avoid beacons.
Fixed
- The unit suite no longer rewrites the repo's
config/files. Tests that build an app without an explicit config directory used to load the committed preset through ConfigManager's./configfallback, and any save that followed rewroteconfig/settings.yamlorusers.yamlin the checkout - twice committed by accident during review.tests/conftest.pynow pointsNANOIDP_CONFIG_DIRat a fresh copy ofconfig/for every test and resets theyaml_writersingleton alongside the others. --profileoverrides settings.yaml for every value and survives reloads (#172). An explicit--profile devcould not bring a file configured withoauth21/stricter-devback todev(the flag defaulted todev, so the code could not tell "asked for dev" from "omitted"), and any CLI profile was dropped by the first configuration reload, i.e. by the first web UI or MCP save. Worse, thestricter-devruntime hardening (require_pkce,password_hashing,rate_limit_enabled, debug off) was applied once increate_app()and silently lost on that same first reload, even when the profile came fromsettings.yamlitself. The override now lives onConfigManager(--profiledefaults to none,init_config(..., profile_override=)), the effective profile and its hardening are re-derived after every settings load, and a save serializes the DECLARED state (ConfigManager.persistable_settings()), so neither the override nor the hardening it implies is ever written into the operator's file.GET /api/configand the MCPget_settingstool exposesecurity_profile,profile_overrideand the derivedeffectivevalues; the e2e agent checks they are stable across a reload.users.yamlnow expands${VAR}/${VAR:default}placeholders likesettings.yamlalways did (#175 review). Apassword: ${ALICE_PASSWORD}used to be taken literally, so the documented "secrets kept out of the file" use case only worked for settings. A UI/MCP save of one user still rewrites only that user's entry; the MCPsave_configtool rewrites the whole map and materializes expanded placeholders, as documented.- SAML
entity_id/sso_urlfollow the effective issuer (#181). Withoauth.issuer_from_requeston (or behind a proxy), OIDC discovery reflected the request host while/saml/metadata, the<Issuer>in responses and assertions and the SSO location kept the fixedhttp://localhost:8000/...strings. Both settings are now optional: absent (or blank in the UI/MCP) means derived as<effective issuer>/samland<effective issuer>/saml/ssothrough one helper shared by every SAML surface, an explicit value still wins, and a derived value is never written back tosettings.yaml. SAML 2.0 Metadata 2.3.2 requiresentityIDto be the value used as<Issuer>(Core 2.2.5)./api/configand the MCPget_settingstool report the effective values plusentity_id_derived/sso_url_derived;update_settingsgainssaml_entity_id/saml_sso_url(empty string clears); the e2e agent checks metadata against discovery and no longer posts derived values back as explicit ones. - Example presets now bind to
127.0.0.1(#164). All four pre-2.6.0 presets (cli-device-flow,microservices-client-credentials,react-spa-pkce,spring-boot-saml) still shipped an explicithost: "0.0.0.0", overriding the loopback default introduced with GHSA-2473-px8h-rvg6 for anyone who copied them. Each now ships loopback with a commented# host: "0.0.0.0"opt-in line, matching thepersona-loginpreset and the reverse-proxy guide's framing. /api/confignow exposessaml.default_acs_url(#165). The e2e agent rebuilds the settings form from that document, so the missing field was posted back blank on every run and the "present-but-blank = clear" contract (#131) silently wipeddefault_acs_urlfromsettings.yaml.
Security
- Opt-in login gate for the config web UI: new
session.require_ui_loginsetting (off by default) makes/loginactually enforce a logged-in session on the dashboard, users, clients, settings, keys, claims, audit log and token tester pages - previously/login//logoutexisted but nothing gated on them, so the login page implied protection it didn't provide. Does not affect the separate/api/*management API, which remains unauthenticated by design regardless. YAML-only for now, following thesecret_key/security_profileprecedent. Related to the network-binding hardening in GHSA-2473-px8h-rvg6. - Opt-in removal of the invalid-bcrypt-hash plaintext fallback: new
session.enforce_password_checksetting (off by default). Whenpassword_hashingis on, ausers.yamlpassword that isn't a valid bcrypt hash previously fell back to plaintext comparison with only a warning logged - this setting removes that fallback, rejecting the login outright instead. Default behavior (the fallback) is unchanged; opt-in only. YAML-only, same treatment asrequire_ui_login.
2.6.0 - 2026-08-21
Documentation
- New guide: Running behind a TLS-terminating reverse proxy,
walking through composing
oauth.issuer,issuer_from_request,issuer_from_proxy_headers,issuer_allowlist,device_verification_base_urlandPOST /api/config/reloadfor a proxied/containerized deployment, with the security caveats inline.
Added
- First-class group support: users gain a
groupslist alongsideroles, modelled exactly the same way. It is loaded from and persisted tousers.yaml(omitted when empty), emitted as agroupsclaim on the access token and from/userinfo, requestable in the ID Token via the OIDCclaimsparameter, advertised inclaims_supported, and flattened intoauthoritiesusing the newgroupsauthority prefix (defaultGROUP_, editable on the Claims page). Groups are editable from the user form, shown on the users list and user detail pages, exposed by/api/users, and settable through the MCPcreate_user/update_usertools. Users without groups behave exactly as before: no claim, no authorities, nothing written to YAML. - Optional SAML export of roles and groups: new
saml.export_roles/saml.export_groupstoggles (both off by default, so the previous behaviour is preserved) with companionsaml.roles_attr_name/saml.groups_attr_namesettings defaulting torolesandgroups. Roles and groups are not standard SAML attributes and every SP expects a different name, so the name is configurable; blanking it restores the default. Both toggles are on the Settings page and the MCPupdate_settingstool, and apply to both the SSO assertion and the AttributeQuery endpoint, with oneAttributeValueper entry. oauth.issuer_from_request(off by default): when enabled, the discovery document'sissuer, every minted token'siss, and the device flow'sverification_uriare derived from the incoming request's own Host header instead of the fixedoauth.issuer. Lets the same NanoIDP be reachable under more than one hostname (e.g. a Docker Compose service name from other containers andlocalhostfrom the host browser) without a discovery/token issuer mismatch - each hostname advertises and issues tokens against itself. The MCPget_oidc_discovery/get_settingstools have no request of their own and always report the fixedissuer.oauth.issuer_allowlist: restrictsissuer_from_requestto a list of allowed origins (e.g.["http://localhost:8000", "http://nanoidp:9900"]). Empty (default) allows any Host header, unchanged from before; when set, a request whose Host doesn't match falls back to the fixedoauth.issuerinstead of trusting an arbitrary Host header. Settable from the Settings page and the MCPupdate_settingstool.oauth.device_verification_base_url: pins the device flow'sverification_urito a fixed, human-reachable URL (e.g.https://idp.example.com), overridingissuer_from_request's derivation for that field only - discovery'sissuerand a token'sissare unaffected. Fixes a backend/container caller of/device_authorization(e.g.Host: nanoidp:9900) otherwise leaking its own Host into a URL the end user's browser can't open. Unset by default. Settable from the Settings page and the MCPupdate_settingstool.oauth.issuer_from_proxy_headers(off by default): trustsX-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Forfrom a single reverse-proxy hop (via werkzeug'sProxyFix), soissuer_from_requestand rate-limit and audit-log client IPs see the original scheme/host/client instead of the proxy's own connection when TLS is terminated upstream. Only changes the derived issuer/iss/verification_uriwhenissuer_from_requestis also on; the rate-limit effect applies regardless. Only enable this when NanoIDP is deployed directly behind exactly one trusted proxy - these headers are otherwise spoofable by any client. Readable/settable via the Settings page and the MCPget_settings/update_settingstools; sinceProxyFixis wired at app startup, a value changed at runtime only takes effect after a restart.
Changed
- Raised the
PyJWTfloor to>=2.13.0(was>=2.8.0)./userinfoand/introspectpass a client-supplied token tojwt.decode(), and 2.8.0-2.12.1 are affected by CVE-2026-48525 (unbounded Base64URL decoding of ab64=falsedetached JWS payload, a DoS vector). - Added a CI license gate (#148): the build fails if a dependency in the redistributed closure carries a GPL/LGPL/AGPL/SSPL/EUPL license, which the project's dependency-license policy blocks from redistribution without explicit review.
- Lowered the
cryptographyfloor from>=46.0.3to>=45.0.0(#140). The previous floor came from a generic dependency bump, not a real requirement: our own API usage needs nothing newer than ~3.1. The effective minimum is set bysignxml, which importsx509.verification.ExtensionPolicy(added in cryptography 45.0.0) at load time. This unblocks installs on environments pinned to acryptographybetween 45 and 46. - Consolidated the OAuth client YAML merge logic into a single
serialization.merge_client_entry()helper, shared by the settings save path (merge_oauth_clients()) andYamlWriter.save_client()'s web UI edit path, which previously duplicated the same field-by-field merge rules. Internal cleanup, no behavior change. - Migrated the MCP server to the mcp 2.0 SDK and pinned
mcp>=2,<3. mcp 2.0 replaced the lowlevelServerdecorators (@server.list_tools()/@server.call_tool()) withon_*constructor parameters, so a fresh install resolving to 2.0 could not importnanoidp.mcp_serverat all. Handlers now take(ctx, params)and returnListToolsResult/CallToolResultinstead of relying on the SDK's removed return-value wrapping. The tool set, tool schemas, readonly mode, and the admin-secret gate are unchanged, and the stdio transport andnanoidp-mcpentry point are untouched. - Rejected and failed MCP tool calls now set
is_error: true. mcp 2.0 no longer converts a handler exception into an error-flagged result, so nanoidp builds it explicitly for every case that previously came back as a successful result whose JSON body happened to carry anerrorkey: readonly-mode and admin-secret rejections, an unknown tool name, arguments that fail schema validation (see below), and tool-level failures such as "user not found" or "client already exists". The response body is unchanged. - Tool arguments are now validated against each tool's schema before
dispatch. mcp 1.x's
@server.call_tool(validate_input=True)did this automatically; mcp 2.0'son_call_tooldoes not, so nanoidp now runs the same check itself and returns anis_error: trueresult (code: "MCP_INVALID_ARGUMENTS") instead of letting a missing required field reach the tool implementation as a bareKeyError. - Consolidated the MCP
isErrorcontract (#120): the rule is now written once as a table in themcp_servermodule docstring (a negative query answer is not a failure) and the code follows it.verify_tokenon an invalid token returns{"valid": false, "reason": ...}(waserror) so a rejected token, the tool's designed answer, is no longer flaggedis_error. A domain-failure audit entry now records the failure reason instead of only the tool name; the uncaught-exception path now carries acode(MCP_INTERNAL_ERROR) andtoollike the guard rejections; and_execute_tool's unreachable unknown-tool fallback now raises rather than returning a divergent shape. The MCP auditdetailscodes are namespaced (MCP_READONLY_MODE,MCP_ADMIN_SECRET_REQUIRED,MCP_UNKNOWN_TOOL,MCP_INVALID_ARGUMENTS), observable viaget_audit_logand/api/audit. - Precompiled MCP tool-argument validators (#121): each tool's JSON Schema
is compiled once at import (
Draft202012Validator) instead of being recompiled on everytools/call, which also surfaces a malformed schema at import time. The directjsonschemafloor is raised to>=4.20.0to match what mcp 2.0 already resolves. - MCP tests drive the real protocol (#122): the test harness now calls tools through the mcp 2.0 in-memory client (real SDK dispatch and result serialization) instead of invoking the lowlevel handlers with a fake request context, so wire-level regressions fail the suite instead of only breaking a real client.
Fixed
- Duplicate OAuth
client_ids are rejected at load (#127). Two clients that resolve to the same effective id (including two${VAR}placeholders expanding to the same value) made client lookup ambiguous and caused the save-merge to match the wrong raw entry, materializing a secret; the loader now fails fast with a clear error instead. - Import no longer crashes when package metadata is absent (#139). Running
from an uninstalled source tree (vendored, or copied into an image without
pip install) raisedPackageNotFoundError; the version now falls back to readingpyproject.toml, then to a static string. - Default admin user's
identity_classisINTERNAL, notINTERN. The no-users.yamlfallback used a typo'd class that didn't match the generated template or the default allowed classes. - Env-backed
client_idplaceholders are preserved on save (#127). When a client'sclient_idwas itself a placeholder (client_id: ${CLIENT_ID:app1}), the settings save matched the raw entry against the expanded id, missed it, and rewrote the client from expanded values - losing the placeholders and materializing the client secret; the web UI path appended a duplicate entry, anddelete_clientcould not find the client at all. Client matching now expands the placeholder before comparing (client_id_matches()), used by the settings save,save_client()anddelete_client(). - Saving settings no longer discards comments, inline
#text or${VAR:default}placeholders insettings.yaml(#127): the settings writer now round-trips the file withruamel.yaml(comments and quote style survive) and only rewrites a field when its expanded on-disk value actually differs from the new one, so an untouched${PORT:8000}-style placeholder is no longer replaced by its resolved value on the next save. Free-form text (description,client_secret,password, attribute values) is now quoted on write so an embedded#can't be mistaken for a comment. Applies to both the web UI settings form and the MCPsave_configtool. - Env-backed client secrets and empty optional placeholders are preserved when
unrelated settings change. The OAuth client merge now updates entries by
client_idfield-by-field instead of rewriting the whole list, so an unchanged${APP1_SECRET:dev}secret stays in the raw file even when a sibling client is edited. Empty optional values such as${DEVICE_URL:}are also treated as unchanged when they still expand to an empty string, instead of being popped out of the YAML on a save that changed some other field. /api/confignow exposesissuer_allowlist,device_verification_base_urlandissuer_from_proxy_headersalongsideissuer_from_request. The config-agnostic e2e agent reads the allowlist from/api/configto predict the effective issuer; without the exposure it assumed an empty allowlist and failed on any server with one configured. The agent also takes its fixed-issuer baseline from/api/config'soauth.issuernow: a plain discovery response reflects the request's own Host when the flag is on, so it is only a valid baseline when the flag is off.examples/test_agent.pySAML export check honours the configured attribute names: it now readssaml.roles_attr_name/saml.groups_attr_namefrom/api/configinstead of assuming the defaultroles/groupsnames, so it no longer fails on servers exporting under custom names.- SAML export: colliding attribute names merge instead of overwriting, and
values are passed as lists (#134). With both exports enabled and
saml_roles_attr_nameequal tosaml_groups_attr_name(e.g. bothmemberOf), the groups list silently replaced the roles list; the two are now merged into the single shared attribute, roles first, deduplicated. The AttributeQuery path also passes roles/groups (and entitlements) to the response builder as lists instead of comma-joined strings, so a legitimate comma-bearing value like"Finance, EMEA"stays oneAttributeValue, as it already did in the SSO assertion. /api/users/<username>/tokennow honoursissuer_from_request(#133): the endpoint mints real JWTs but kept using the fixedsettings.issuer, so with the flag on its tokens carried anissthat failed validation against the discovery document the same hostname had just advertised. The effective-issuer resolution (including the allowlist fallback) now lives in a shared routes helper used by discovery,/token, the device flow and the API token endpoint alike; the MCP tools remain the documented exception. Also clarified in the setting descriptions thatissuer_from_proxy_headersaffects the audit log's recorded client IP as well as the rate limiter's.POST /settingsno longer resets settings that were not on the submitted form (#131). Previously every checkbox absent from the form was stored asfalseand every absent text field was cleared, so any partial form (a stale tab, a script, the e2e agent's c14n round-trip) silently wiped unrelated configuration - observed live asissuer_from_request,issuer_from_proxy_headersand the SAML export toggles flipping off and the allowlist, device verification URL and attribute names being deleted mid-test-run. The handler now follows an "absent = unchanged" contract: text fields and textareas are only applied when present (present-but-blank still clears), and each checkbox is paired with a hidden__on_formmarker so "rendered but unchecked" (persistfalse) is distinguishable from "not on this form" (leave unchanged).
Security
- Default server bind address is now
127.0.0.1(loopback) instead of0.0.0.0(GHSA-2473-px8h-rvg6, CWE-306). The unauthenticated/api/*management API (which can mint admin tokens, rotate signing keys and clear the audit log) is a deliberate dev-tool convenience, but the previous all-interfaces default exposed it to any network-reachable host without the operator choosing to. The out-of-the-box experience is unchanged for local development (clients still reachlocalhost:8000). To expose NanoIDP on a network, setserver.host(or--host 0.0.0.0) explicitly; a startup warning is logged whenever the bind address covers all interfaces. This alignsnanoidp initwith the valuenanoidp wizardalready wrote, and the bundled Docker image is unaffected (its entrypoint already passes--host 0.0.0.0).
2.5.0 - 2026-07-19
Added
claimsparameter requests persist across token refresh (#112, OIDC Core §12.2): the claim names requested via the OIDCclaimsparameter are now persisted in the refresh token (req_id_token_claims/req_userinfo_claims, alongsidescopeandauth_time), so a refreshed ID Token keeps the requested claims and/userinfokeeps honouring theuserinfomember for the refreshed access token. Refresh tokens minted before this change carry neither claim and refresh as before. Both names are reserved: they cannot be requested via theclaimsparameter nor injected through the/tokenextraparameter. Requested-claims values are sanitized at the token service (sanitize_claim_names): a hand-crafted refresh or access token carrying a non-list value (or non-string entries) refreshes and serves/userinfocleanly instead of failing token issuance after the refresh token was consumed. A claims request deliberately survives scope narrowing on refresh (OIDC Core §5.5 is orthogonal to scope); see the token reference docs.- MCP
generate_tokengainsuserinfo_claims(#113): parity with the HTTPclaimsflow'suserinfomember; the names are stamped on the access token asreq_userinfo_claimsand honoured by/userinfo. Bothid_token_claimsanduserinfo_claimsare now validated likeadditional_audiences: a non-list value is rejected with a clean error instead of being minted into the token.
Changed
/userinforeusesresolve_user_claimfor its default claim assembly (#113): the scope-gated standard claims and the nanoidp-specific claims now come from the same resolver that backs theclaimsrequest parameter, so the two mappings cannot diverge. No behavior change.
Fixed
claimsparameter could overwrite registered ID Token claims (#110): a requested claim name that collided with a user attribute (e.g. an attribute namedaudorexp) could hijack the corresponding registered claim, becausecreate_jwtappliesextraafter setting the registered claims and thesetdefaultguard only covered the protocol claims.resolve_user_claimnow refuses reserved registered/protocol names outright (iss,sub,aud,exp,iat,nbf,jti,token_use,auth_time,at_hash,azp,nonce,scope,req_userinfo_claims), protecting both the ID Token and/userinfopaths at a single choke point.
2.4.0 - 2026-07-08
Added
scopeclaim on access tokens (#102): access tokens now advertise the granted scope (RFC 9068 §2.2.3), letting resource endpoints reason about it. Set authoritatively inTokenService.create_token, so a caller-suppliedextra_claimscannot override it.- OIDC
claimsrequest parameter (#104, OIDC Core §5.5):/authorizeaccepts aclaimsparameter to request specific claims in the ID Token (id_tokenmember) or from UserInfo (userinfomember), e.g.claims={"id_token":{"email":null}}. Requested claims are resolved from the user and added when available (voluntary form, §5.5.1); protocol claims are never overwritten and unresolvable names are skipped. Malformed input is ignored with a warning rather than failing the flow. Discovery advertisesclaims_parameter_supported: true, and the MCPgenerate_tokentool gains anid_token_claimsargument. Scoped to the authorization code grant; the requested claims are not yet persisted across a refresh.TokenService.create_tokennow stripsscope/req_userinfo_claimsfrom a caller-suppliedextrabefore setting them authoritatively, so the/tokenextraparameter can never smuggle scope-gated claims past/userinfo(closes a spoofing gap in the #102 scope handling too).
Changed
/userinfogatesemail/profileclaims by granted scope (#102, OIDC Core §5.4):email/email_verifiedrequire theemailscope andpreferred_usernamerequires theprofilescope. Enforced only under thestricter-devandoauth21profiles; the defaultdevprofile keeps returning them unconditionally, so this is not a breaking change for existing setups. nanoidp-specific claims (roles,tenant,identity_class,attributes) have no standard scope and are always returned.
2.3.0 - 2026-07-08
Added
oauth21security profile (#68): opt-in draft-OAuth-2.1 protocol strictness alongsidedevandstricter-dev: PKCE required on the authorization code flow with S256 only (draft-ietf-oauth-v2-1 §4.1.1, §7.5.2), refresh token rotation forced on (§4.3.1), the password grant removed (RFC 6749 §5.2) and absent from discovery, and registered redirect URIs mandatory at/authorize. Protocol behavior lives in derivedSettingsproperties consumed by both the routes and the shared discovery builder, so the profile means the same thing from--profileorsettings.yamland discovery can never advertise what the endpoints refuse. Deliberately orthogonal tostricter-dev(runtime hardening).- Registered redirect URIs with exact matching (#67): clients gain an
optional
redirect_urislist; when non-empty,/authorizecompares the requestedredirect_uriwith simple string comparison (RFC 6749 §3.1.2.3, OAuth 2.1 §4.1.1) and answers a mismatch with400 invalid_requestdirectly, never by redirecting to the unvalidated URI (§3.1.2.4). Exposed in the web UI, MCP client tools and YAML. - Signed AuthnRequest verification (#69): with
saml.want_authn_requests_signed: trueand PEM certificates insaml.sp_certificates, nanoidp requires and verifies AuthnRequest signatures under both bindings: the HTTP-Redirect query-string signature over the raw transmitted fragment (SAML 2.0 Bindings §3.4.4.1; rsa-sha256/rsa-sha512/legacy rsa-sha1) and the HTTP-POST envelopedds:Signature(Core §5), rejecting unsigned or invalid requests with 400, failing closed without registered certificates. The verified Redirect request is bound server-side in the session, so the inline-login leg only accepts byte-identical values. Metadata advertisesWantAuthnRequestsSigned="true"if and only if enforcement is on.examples/gen_sp_keypair.pygenerates a test SP keypair. - E2E workflow in CI (#79): every PR now boots real servers and runs
examples/test_agent.pyagainst them (default profile,--oauth21,--saml-signedwith a generated SP keypair) plus an MCP stdio smoke test (examples/mcp_smoke_test.py) driving the real transport, the regression guard for the class of bug where the stdio entrypoint crashed unnoticed because unit tests bypass it (#56). - Coverage gate in CI (#71, #72):
--cov-fail-under, introduced at 70 and ratcheted to 75 after the wizard went from 0% to 99% coverage; measured coverage 78%. The dead Codecov upload (never configured, failed silently since inception) was removed in favor of in-CI enforcement. - Documentation site: mdBook on GitHub Pages (https://cdelmonte-zg.github.io/nanoidp/) with getting-started, guides and a full reference; canonical docs are symlinked so there is a single source of truth, and the README became a landing page.
- Web UI parity (#94):
require_pkceandrefresh_token_rotationtoggles on the settings page; SP-certificates and signed-AuthnRequests fields (#69);redirect_urison the client form (#67); the dashboard badge distinguishes theoauth21profile. - MCP:
get_settingsreportssecurity_profile;update_settingscovers the SAML verification fields; client tools carryredirect_uris.
Changed
src/is fully annotated and mypy runs with a globaldisallow_untyped_defs(#70): new unannotated code fails CI.- Internal architecture (behavior-invariant, #83–#86): one shared YAML
serialization path for
ConfigManagerand the UI writer; the token endpoint dispatches to per-grant handlers with device-flow and revocation state in dedicated services (DeviceCodeStore,RevocationStore); a singleaudit_eventhelper replaced 58 duplicated audit blocks (invariance proven by a before/after snapshot harness); the Pydantic models moved tomodels.pywith compatibility re-exports. security_profileis now read fromsettings.yaml(top-level key) and round-trips on save; the CLI--profilestill wins. A YAML-declaredstricter-devnow applies its runtime hardening (previously the YAML value was silently ignored).
Fixed
ConfigManager.save()was lossy (#87): the save path behind MCPsave_configrewrotesettings.yamlfrom scratch, silently deleting every section it didn't own:jwt(external keys!),session,logginglevels,server.debugand custom keys. Saving is now read-modify-write and preserves them, atomically and with a.bakbackup like the UI path always did.
2.2.0 - 2026-06-11
Added
- The refresh_token grant now re-issues an ID Token when the original grant
included the
openidscope (OIDC Core §12.2, #39). The granted scope is persisted in the refresh token claims and recovered on refresh; ascopeform parameter may narrow, but never broaden, the original grant (RFC 6749 §6: broadening is rejected with400). The refreshed ID Token carries nononce(it binds the original authentication request). Refresh tokens minted before this change have no persisted scope and keep the old behavior. - ID Tokens now carry
auth_timeandat_hash(#42).auth_timereflects when the end-user actually authenticated: the login page for the authorization code flow, the/deviceverification for the device flow, the request itself for the password grant. It is preserved unchanged across refreshes (OIDC Core §12.2), carried in the refresh token claims like the scope.at_hashbinds the ID Token to the access token issued alongside it (left half of SHA-256, base64url, §3.1.3.6). Discoveryclaims_supportednow also advertisesauth_time,nonceandat_hash. - Optional refresh token rotation (#46): with
oauth.refresh_token_rotation: true(default off), each refresh atomically invalidates the consumed refresh token, so its reuse fails with 401; reuse of a consumed token revokes its whole rotation family, including the live descendant (RFC 9700 §4.14.2). - PKCE enforcement (#47): new
require_pkcesetting (enabled by thestricter-devprofile, persisted insettings.yaml) rejects/authorizerequests without acode_challenge;stricter-devalso rejectscode_challenge_method=plain, whether explicit or implicit via the RFC 7636 §4.3 omitted-parameter default, and discovery only advertisesS256there. Unsupported methods are rejected at the authorization endpoint (§4.4.1). Default profile unchanged. - MCP audit & key tools (#48):
get_audit_log,get_audit_stats,clear_audit_log,get_keys_infoandrotate_keysmirror the HTTP API, so agent workflows can inspect what the IdP recorded and exercise JWKS refresh handling.clear_audit_log/rotate_keyscount as mutating tools (admin secret / readonly rules apply). MCPget_settings/update_settingsexpose the newrefresh_token_rotationandrequire_pkcesettings, andgenerate_tokenaccepts an optionalscopeargument and returns anid_tokenwhenopenidis included, matching the HTTP token endpoint. - CI now lints with ruff (#45) and type-checks
src/with mypy (#55, documented gradual-adoption baseline inpyproject.toml). The codebase is lint-clean, 153 findings fixed (#49): deprecateddatetime.utcnow()replaced (removes 80 DeprecationWarnings), unused imports/variables dropped, imports sorted and moved to module level,Optional[...]type hints in the crypto service,verify_jwtaccepts an array audience, exceptions re-raised withfrom e, and mypy-clean (40 baseline errors fixed; the baseline also surfaced the brokennanoidp-mcpentrypoint below).
Fixed
- The
nanoidp-mcpstdio entrypoint crashed at startup ("a coroutine was expected"):stdio_server()is an async context manager yielding the message streams, not a coroutine. Verified with a JSON-RPC initialize handshake. - Review follow-ups of the 2026-06-11 merge block (#56):
- Refresh tokens are bound to their client: the issuing
client_idis persisted in the refresh token claims and the refresh grant rejects any other client (RFC 9700 §4.14), which also guarantees the refreshed ID Token keeps the originalaud(OIDC Core §12.2). Tokens minted before the claim existed keep working. - Rotation is atomic and revokes families on reuse: the revocation
check and the claim of the consumed token now happen in one critical
section, so two concurrent refreshes of the same token can no longer
both succeed. Each grant starts a refresh-token family (
rt_familyclaim, stable across rotations); reusing an already-consumed token revokes the whole family, including the live descendant (RFC 9700 §4.14.2). - PKCE
plaincan no longer slip through stricter-dev by omitting the method: per RFC 7636 §4.3 an absentcode_challenge_methoddefaults toplain; the method is now normalized before validation, and unknown methods are rejected at the authorization endpoint (§4.4.1). require_pkceis persisted: it is now read from and written tosettings.yaml(oauth section), soupdate_settings→save_config→reload_configno longer silently reverts it.- The token response reports the scope actually granted (RFC 6749
§5.1) instead of a hardcoded
"openid"; when no scope was involved the parameter is omitted, and a narrowed refresh reports the narrowed scope.
- Refresh tokens are bound to their client: the issuing
- The token endpoint validates
expandextrabefore the grant dispatch: with rotation enabled, a malformed value can no longer consume the refresh token without delivering its replacement (the last tradeoff noted in the #56 review). Validation is semantic, not just syntactic:extramust be a JSON object (a scalar/array used to raise aTypeError500 later) andexpmust be an integer within the same1..1440bounds the Settings model enforces (non-numeric values used to be an unhandledValueError500; astronomical ones anOverflowError500). - Thread-safety hardening for shared in-memory state (#43): the authorization code store now performs its check-then-mark sequence under a lock (one-time use can no longer be defeated by concurrent redemptions), device codes are claimed/transitioned atomically and pruned when expired, and the lazily-created service singletons (config, token, crypto, audit, auth codes) use double-checked locking so concurrent first access creates exactly one instance.
- The MCP
get_oidc_discoverytool now returns the exact same document as the HTTP/.well-known/openid-configurationendpoint (#40). Both build it via a new shared helper (services.discovery.build_discovery_document), so the MCP tool now advertisesclaims_supported(includingazp),response_types_supported,id_token_signing_alg_values_supported,code_challenge_methods_supportedand the endpoint auth methods. The two documents can no longer drift apart. - Discovery no longer advertises the
tokenresponse type (#41): the implicit flow was never implemented (/authorizeonly acceptsresponse_type=code) and is deprecated by the OAuth 2.0 Security BCP, so advertising it misled clients.response_types_supportedis now["code"].
Documentation
- The MCP tools tables in the README and
docs/MCP_WORKFLOW.mdnow list all 24 tools (#44, #48); the README was missingcreate_client,update_client,delete_client,update_user,update_settingsandsave_config.
2.1.0 - 2026-05-26
Added
- ID Tokens are now issued for the password and device (RFC 8628) grants
when
openidscope is requested, not justauthorization_code(#36). These grants authenticate an end-user, so an ID Token is meaningful;client_credentialsstill never emits one (no end-user).
Fixed
- Friendlier loading of client
additional_audiencesfromsettings.yaml(#35): a scalar value (additional_audiences: api://x) is coerced to a one-element list, and an unsupported shape (e.g. a non-string item) now fails with a clear, client-scoped error instead of an opaque PydanticValidationErrorat startup. - Minor hardening/polish from the #32 review (#37):
OAuthClientnow validates on direct attribute assignment (validate_assignment), discovery advertisesazpinclaims_supported, and the MCP_normalize_audiencesrejects falsy non-list inputs instead of silently returning an empty list.
Security
- Harden the ID Token vs access-token boundary (#34). The resource audience
(
oauth.audience) is now filtered out of the ID Tokenaudeven if a client lists it inadditional_audiences, and every token carries atoken_usemarker (access/id/refresh)./userinforejects tokens marked as ID or refresh tokens and/introspectreports ID Tokens as inactive, so an ID Token can no longer be spent as an access token. (Refresh tokens stay introspectable per RFC 7662.)
2.0.0 - 2026-05-25
Changed
- ID Token
audnow contains the requesting client'sclient_id, as required by OpenID Connect Core 1.0 §2 (was previously the staticoauth.audience). This makes it possible to test multiple clients and brings nanoidp in line with the OIDC spec.- Breaking: relying parties that validated the ID Token
audagainst the old staticoauth.audiencevalue must now expect their ownclient_id. - The access token
audis unchanged and still reflectsoauth.audience(the resource audience, per RFC 9068 §2.2).
- Breaking: relying parties that validated the ID Token
Added
additional_audiencesper-client setting: extra audiences appended to the ID Tokenaud. If this produces more than one distinct audience value,audis emitted as an array and nanoidp also emitsazpequal to theclient_id, so clients can test authorized-party handling.
1.4.0 - 2026-04-28
Added
- Environment variable substitution in
settings.yamlusing${NAME}/${NAME:default}syntax PORTenv var honoured in the Docker image via shell expansion inCMD
1.3.3 - 2026-04-22
Fixed
- Return
id_tokenin /token response for Authorization Code Flow whenopenidscope is requested, as required by OIDC Core spec (Section 3.1.3.3) - Include
nonceclaim inid_tokenwhen provided by the client
Changed
- Use
pyproject.tomlas single source of truth for version number - Remove outdated version label from Dockerfile
1.3.2 - 2026-03-27
Fixed
- Token endpoint now rejects requests when
client_idcannot be determined from either the request body or theAuthorizationheader - Token endpoint now rejects requests where
client_idin the body conflicts with the authenticated client in theAuthorizationheader
Added
- Tests for client_id mismatch and missing client_id edge cases
1.3.1 - 2026-03-26
Fixed
- Allow authorization code flow without
Authorizationheader for PKCE public clients (RFC 6749 §2.1)- Libraries like authlib send
client_idin the request body instead of the header when no client secret exists - Auth header validation is now only enforced for grant types other than
authorization_code
- Libraries like authlib send
Added
- Test for PKCE plain flow without auth header (
test_pkce_plain_flow_no_auth_header)
1.3.0 - 2026-03-25
Added
- GitHub Actions workflow to build and publish Docker images to GitHub Container Registry (GHCR)
- Triggered on version tags (
v*), builds multi-platform images (linux/amd64,linux/arm64) latesttag published only for non-prerelease versions
- Triggered on version tags (
- Docker usage instructions in README (
docker pullanddocker runexamples)
Changed
- Dockerfile healthcheck switched from Python
urllibtocurlfor Podman compatibility and reduced overhead - Updated
actions/checkoutfrom v4 to v6 in publish workflow
1.2.3 - 2026-03-03
Fixed
- Dockerfile and docker-compose.yml: replaced
curlwith Python'surllibfor healthcheck: avoids addingcurlas a system dependency in the image
Docs
- Added mascotte/logo images to the project
1.2.2 - 2026-01-19
Added
- New
strict_saml_bindingsetting to enforce SAML 2.0 binding compliance- When
false(default): lenient mode accepts GET with uncompressed data (useful for debugging) - When
true: strict mode rejects non-compliant requests per SAML spec
- When
- Setting exposed in UI (Settings page), REST API (
/api/config), and MCP server - Exclusive C14N (
exc_c14n) is now the default XML canonicalization algorithm- Standard for SAML 2.0 signatures, handles namespace isolation correctly
- Available algorithms:
exc_c14n(Exclusive C14N 1.0, default),c14n(C14N 1.0),c14n11(C14N 1.1)
- UI select dropdown for C14N algorithm in Settings page
strict_saml_bindingandverbose_loggingnow persist correctly on save/reload- Comprehensive E2E test coverage for all SAML flows in
test_agent.py:test_saml_metadata_bindings- verifies both HTTP-POST and HTTP-Redirect advertisedtest_saml_sso_post_binding- SP-initiated SSO with HTTP-POST (InResponseTo verification)test_saml_sso_redirect_binding- SP-initiated SSO with HTTP-Redirect (InResponseTo verification)test_saml_idp_initiated_not_supported- documents IdP-initiated SSO is not supportedtest_saml_strict_binding_mode- tests strict/lenient binding behaviortest_saml_attribute_query_verification- verifies actual attributes returned
- Unit tests for inline login flow (
test_inline_login_flow_preserves_post/redirect_binding) - Unit test for strict mode + inline login (
test_strict_mode_inline_login_preserves_redirect_binding) - Unit test for Exclusive C14N configuration (
test_c14n_algorithm_configurable_to_exclusive)
Fixed
- SAML SSO now correctly handles both HTTP-POST and HTTP-Redirect bindings
- Parser always tries DEFLATE decompression first, falls back to raw XML (handles all edge cases)
- Strict mode now works with inline login by passing original HTTP verb via hidden field
- Fixes: GET compressed → login form → POST would fail in strict mode
- Stateless: no server-side session needed, works in CI/CD pipelines
- Explicit
|eescape filter in login template hidden fields (XSS defense-in-depth) - Normalized
original_verbhandling (uppercase, validated to GET/POST) - Quick-fill username buttons use
tojsonfilter to handle special characters safely
1.2.1 - 2026-01-16
Fixed
- SAML SSO now correctly handles HTTP-POST binding (uncompressed SAMLRequest)
- Previously,
_parse_saml_requestunconditionally attempted DEFLATE decompression, causing parsing to fail for POST requests - Now uses HTTP method to determine binding type: GET = HTTP-Redirect (compressed), POST = HTTP-POST (uncompressed)
Changed
- E2E test agent now verifies actual SAML parsing (InResponseTo matching) instead of just endpoint availability
- Added separate tests for HTTP-POST and HTTP-Redirect bindings in
test_agent.py
Changed (Architecture)
- Inline login for SAML SSO:
/saml/ssonow shows login form directly instead of redirecting to/login- This preserves SAML binding context naturally (no redirect = no method change)
- Follows the pattern used by Keycloak and other IdPs
- Removes the complex edge cases caused by redirect-based login
/loginendpoint simplified - now only used for direct web UI access, not SAML flows- Login form now posts to current URL (no hardcoded action) - works for both
/loginand/saml/sso
Changed
- SAML metadata now advertises both HTTP-POST and HTTP-Redirect bindings for SingleSignOnService
- Audit stats now track SAML SSO and Attribute Query separately (
saml_sso_requests,saml_attribute_queries) - Dashboard shows combined SAML total with SSO/AttrQuery breakdown
- E2E test agent expanded to 35 tests (was 28), now covering all SAML flows with parsing verification
1.2.0 - 2026-01-14
Added
- Configurable
verbose_loggingsetting to control sensitive data in logs verbose_loggingexposed in MCPget_settingsandupdate_settingstoolslogging.verbose_loggingexposed in REST API/api/configendpoint- MCP tests (
tests/test_mcp.py) with 8 tests for MCP functionality - Verbose logging test in E2E test agent
Changed
- Replaced deprecated
defusedxml.lxmlwith native lxml secure parser for XXE protection - Added
html.escapefor XSS prevention in SAML responses - Audit logging now respects
verbose_loggingsetting (usernames/client_ids only when enabled)
Security
- XXE (XML External Entity) protection using secure lxml parser configuration
- XSS prevention in SAML response forms
- Configurable sensitive data logging (verbose_logging defaults to true for dev convenience)
1.1.1 - 2026-01-14
Added
- Configurable XML canonicalization algorithm via
saml.c14n_algorithmsetting
1.1.0 - 2026-01-14
Added
- Configurable SAML response signing via
saml.sign_responsessetting - UI toggle for SAML signing in Settings page (
/settings) sign_responsesexposed in/api/configendpoint- Test agent (
examples/test_agent.py) for comprehensive endpoint testing
Changed
- SAML SSO and AttributeQuery endpoints now respect
sign_responsesconfiguration - Changed default XML canonicalization to C14N 1.0 for maximum compatibility
- Updated documentation with SAML signing configuration instructions
1.0.0 - 2025-12-04
Added
- Initial release
- OAuth2/OIDC support (Authorization Code, Password, Client Credentials, Refresh Token, Device Flow)
- PKCE support (S256 and plain methods)
- Token Introspection (RFC 7662) and Revocation (RFC 7009)
- OIDC Logout / End Session endpoint
- Device Authorization Grant (RFC 8628)
- SAML 2.0 SSO and AttributeQuery endpoints with signed assertions
- MCP Server integration for Claude Code
- Web UI for configuration (users, clients, settings, keys, audit log)
- YAML-based configuration
- Attribute-based access control with configurable authority prefixes
- Audit logging
- Docker support
- Security profiles (
devandstricter-dev) - Key rotation with JWKS support for multiple keys
- External key import support
Contributing to NanoIDP
Thank you for your interest in contributing to NanoIDP!
Before writing code, read the
Architecture page
(source: book/src/project/architecture.md): it maps the packages, the
import contracts CI enforces, where state lives, and the multi-site
flows (like adding a client field) that changes must follow.
How to Contribute
Reporting Bugs
- Check existing issues to avoid duplicates
- Create a new issue with:
- Clear title
- Steps to reproduce
- Expected vs actual behavior
- Environment details (Python version, OS)
Feature Requests
- Open an issue describing the feature
- Explain the use case
- Wait for discussion before implementing
Pull Requests
- Fork the repository
- Create a feature branch:
git checkout -b feature/your-feature - Make your changes
- Run tests and linting
- Commit with clear messages
- Push to your fork
- Open a Pull Request
Development Setup
# Clone your fork
git clone https://github.com/YOUR_USERNAME/nanoidp.git
cd nanoidp
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
# Install in development mode with dev dependencies
pip install -e ".[dev]"
# Run locally
nanoidp --debug
Running Tests
# Run all tests
pytest
# Run with coverage
pytest --cov=nanoidp
# Run specific test
pytest tests/test_basic.py
End-to-End Test Agent
NanoIDP includes a comprehensive test agent that validates all functionality against a running server:
# Run against local server (default: http://localhost:8000)
python 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 UIfix: Correct token expiry calculationdocs: Update README with Docker instructionsrefactor: Simplify user authentication flowtest: Add tests for SAML endpoint
Project Structure
nanoidp/
├── src/nanoidp/ # Main package
│ ├── routes/ # Flask route handlers
│ ├── services/ # Business logic
│ └── templates/ # Jinja2 templates
├── tests/ # Test files
├── config/ # Default configuration
└── docs/ # Documentation
Releasing (maintainers)
Releases are cut by pushing a v* tag; two workflows publish to PyPI and
GHCR and a wheel-smoke job exercises the built artifact before anything is
published. The full process, with the exact commands, the verification
checklist and the recovery procedures, lives in
docs/RELEASING.md. In short: bump pyproject.toml
through a PR, tag the merged commit (v2.7.0-rc5 for a pre-release with a
hyphen, v2.7.0 for a final), create the GitHub release, then verify that
every workflow job ran and that the published artifacts install and behave
(pre-releases reach PyPI too and need pip install --pre; :latest on GHCR
moves only on final tags).
License
By contributing, you agree that your contributions will be licensed under the MIT License.