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, registered redirect URIs with exact matching — and 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.
- 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.2.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'
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). 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'
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'
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'], and identity_class 'EXTERNAL' using nanoidp"
List users and their roles:
"List all nanoidp users and show their roles"
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
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 NANOIDP_MCP_ADMIN_SECRET is set, mutating tools require the secret:
{
"mcpServers": {
"nanoidp": {
"command": "nanoidp-mcp",
"env": {
"NANOIDP_CONFIG_DIR": "./config",
"NANOIDP_MCP_ADMIN_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 (ifNANOIDP_MCP_ADMIN_SECRETis set) - Or remove
NANOIDP_MCP_ADMIN_SECRETenv var 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.
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
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 |
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
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 |
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 NANOIDP_MCP_ADMIN_SECRET is set, mutating operations require the secret:
{
"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
Environment Variables
| Variable | Description | Default |
|---|---|---|
NANOIDP_CONFIG_DIR | Configuration directory path | ./config |
NANOIDP_MCP_ADMIN_SECRET | Secret required for mutating MCP operations | (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 MCP admin secret if multiple developers share the same NanoIDP instance
- Rotate keys periodically to test token validation with multiple keys
- 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
Users (config/users.yaml)
users:
admin:
password: "admin"
email: "admin@example.org"
identity_class: "INTERNAL"
entitlements:
- "ADMIN_ACCESS"
- "USER_MANAGEMENT"
roles:
- "USER"
- "ADMIN"
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.
Settings (config/settings.yaml)
server:
host: "0.0.0.0"
port: 8000
oauth:
issuer: "http://localhost:8000"
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
saml:
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
# Optional; also settable at startup with --profile (which wins over YAML)
# security_profile: oauth21 # dev (default) | stricter-dev | oauth21
authority_prefixes:
roles: "ROLE_"
identity_class: "IDENTITY_"
entitlements: "ENT_"
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 syntactically valid URI, the permissive dev default.
The SAML options (strict_binding, sign_responses, c14n_algorithm)
are covered in detail in SAML options. Security-related
settings — profiles, require_pkce, key management, jwt.external_keys —
are covered in the Security guide.
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.
Environment variables
The environment variables (NANOIDP_CONFIG_DIR, NANOIDP_MCP_ADMIN_SECRET,
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 /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 |
POST /device_authorization | Device Authorization (RFC 8628) |
GET/POST /device | Device verification page |
curl examples for every grant are in Requesting tokens.
SAML
| Endpoint | Description |
|---|---|
GET /saml/metadata | IdP Metadata |
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 |
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"],
"tenant": "default",
"identity_class": "INTERNAL",
"entitlements": ["ADMIN_ACCESS", "USER_MANAGEMENT"],
"authorities": [
"ROLE_USER",
"ROLE_ADMIN",
"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).
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
}
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.
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 — prompts, workflows, 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 |
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) |
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 |
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.
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.
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.
- 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.
Non-goals
- Production use. No HA, no hardening guarantees, no real user data.
- Database persistence. YAML files plus in-memory runtime state are a
feature: state you can read, edit and
git diff. - 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).
Anything not covered here is fair game for discussion — open an issue. The principles above, not this list, are the contract.
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.
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 — and 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— 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 — and 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!
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 (resolved ${PORT} placeholders, cleared default_acs_url). 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
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)
NanoIDP uses GitHub Actions for automated releases to both PyPI and GHCR.
# 1. Update version in pyproject.toml
# 2. Update CHANGELOG.md
# 3. Commit changes
git add -A && git commit -m "Release v1.0.1"
# 4. Create and push tag
git tag v1.0.1
git push origin main --tags
The workflow automatically:
- Runs all tests
- Builds the package
- Publishes to TestPyPI
- Publishes to PyPI (only for non-prerelease tags)
- Builds and publishes Docker images to GHCR
Container tags are derived from the git tag (for example v1.0.1); the latest tag is only published for non-prerelease tags.
Pre-release Testing
For testing releases before publishing to PyPI:
# Create a pre-release tag (publishes to TestPyPI and GHCR)
git tag v1.0.1-rc1
git push origin v1.0.1-rc1
# Install from TestPyPI to verify
pip install -i https://test.pypi.org/simple/ nanoidp==1.0.1rc1
License
By contributing, you agree that your contributions will be licensed under the MIT License.