Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NanoIDP

A lightweight identity provider for development and testing.

You are building or testing a client that speaks OAuth2/OIDC or SAML 2.0. You need a real identity provider to integrate against — but standing up Keycloak or wiring a cloud tenant is a project in itself. NanoIDP is the alternative: pip install, two YAML files, go.

pip install nanoidp
python -m nanoidp init && python -m nanoidp
$ curl -s -X POST 'http://localhost:8000/token' \
    -u 'demo-client:demo-secret' \
    -d 'grant_type=password&username=admin&password=admin&scope=openid'
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  ...
}

The product is confidence: the behaviors NanoIDP advertises and implements are grounded in the relevant specifications, so clients can test against them without depending on accidental or invented semantics.

What it is for

  • Test OAuth2/OIDC flows. Authorization Code with PKCE, Password, Client Credentials, Refresh Token (with optional rotation), and Device Authorization grants, plus introspection, revocation, and RP-initiated logout. See the Quickstart.
  • Test against draft OAuth 2.1. The oauth21 profile enforces the draft's strictness — PKCE required with S256 only, rotation on, no password grant, 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 default admin/admin user)
  • settings.yaml — OAuth/SAML settings (a default demo-client with secret demo-secret)
  • keys/ — RSA keys, auto-generated on first startup

Prefer a guided setup? The interactive wizard walks through server configuration, OAuth clients, admin user, and token settings:

python -m nanoidp wizard

2. Run the server

# Default config directory (./config)
python -m nanoidp

# Custom config directory
python -m nanoidp --config ./my-idp-config

# Or via environment variable
NANOIDP_CONFIG_DIR=./my-idp-config python -m nanoidp

The server listens on http://localhost:8000 (--port to change it).

3. Get a token

curl -X POST 'http://localhost:8000/token' \
  -u 'demo-client:demo-secret' \
  -d 'grant_type=password&username=admin&password=admin&scope=openid'

The response carries an access_token (its aud is the resource audience from oauth.audience) and — because the request included the openid scope — an id_token (its aud is the client's client_id).

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

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

4. Open the web UI

The admin UI at http://localhost:8000 covers the rest: users, OAuth clients, settings, keys and certificates, claims mappings, an audit log, and a token tester for generating and inspecting tokens interactively.

Next steps

Requesting tokens

curl examples for every supported grant, against the default demo-client / demo-secret client from a fresh Quickstart setup.

Password grant

curl -X POST 'http://localhost:8000/token' \
  -u 'demo-client:demo-secret' \
  -d 'grant_type=password&username=admin&password=admin'

Add &scope=openid to also receive an ID Token — see Tokens and claims for what comes back.

Under the oauth21 profile this grant is rejected with 400 and does not appear in grant_types_supported — OAuth 2.1 removes it entirely.

Client credentials grant

curl -X POST 'http://localhost:8000/token' \
  -u 'demo-client:demo-secret' \
  -d 'grant_type=client_credentials'

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:

  1. Call generate_token to get a JWT
  2. Make the HTTP request with Authorization: Bearer <token>
  3. Show you the response

2. Debug Token Claims

Prompt: "Generate a token for user 'admin' and explain what Spring Security
         authorities it will have"

Claude Code will:

  1. Generate the token
  2. Decode it to show claims
  3. Explain the authorities array mapping

3. Set Up Integration Test Users

Prompt: "Create these test users in nanoidp:
         - 'admin-test' with roles ADMIN, USER
         - 'user-test' with role USER
         - 'readonly-test' with role VIEWER"

Claude Code will create all three users with appropriate settings.

4. Verify Token Flow

Prompt: "Generate a token for 'admin', decode it to show the claims,
         then verify it's valid using nanoidp"

This tests the full token lifecycle.

5. Quick Role-Based Testing

Prompt: "I need to test role-based access. Create a user 'role-test' with
         roles ['ADMIN', 'SPECIAL_ACCESS'], generate a token, and show me
         what authorities it will have for Spring Security"

Tool Reference

Read-Only Tools (always available)

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

Mutating Tools (disabled in --readonly mode)

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

Security Notes

Admin Secret Protection

When 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:

  1. Provide admin_secret in tool arguments (if NANOIDP_MCP_ADMIN_SECRET is set)
  2. Or remove NANOIDP_MCP_ADMIN_SECRET env var for development
  3. Or check you're not running in --readonly mode

NanoIDP Security Guide

Overview

NanoIDP is a development and testing tool designed for local development environments, integration testing, and CI/CD pipelines.

WARNING: Do NOT use NanoIDP in production environments.

By design, NanoIDP prioritizes developer convenience over security hardening. It is intended to help developers test OAuth2/OIDC and SAML integrations without the complexity of production identity providers.


Security Profiles

NanoIDP supports three security profiles to balance convenience with basic security controls:

ProfileDescription
dev (default)Maximum convenience for development: plaintext passwords, permissive CORS, no rate limiting
stricter-devSemi-hardened runtime: bcrypt passwords, restricted CORS, rate limiting, debug mode blocked
oauth21Draft OAuth 2.1 protocol strictness (#68): PKCE required (S256 only), refresh token rotation on, password grant removed, registered redirect URIs mandatory at /authorize

stricter-dev hardens the runtime; oauth21 hardens the protocol — they are deliberately orthogonal. The discovery document always reflects the active profile: under oauth21, password disappears from grant_types_supported and code_challenge_methods_supported is ["S256"].

Usage

# Run with default dev profile
python -m nanoidp

# Run with stricter-dev profile
python -m nanoidp --profile stricter-dev

# Run with draft OAuth 2.1 protocol strictness
python -m nanoidp --profile oauth21

Feature Comparison

Featuredevstricter-devoauth21
Password storagePlaintextbcrypt hashPlaintext
CORS* (all origins)localhost only* (all origins)
Rate limitingNone10 req/min on /tokenNone
Debug modeAllowedBlockedAllowed
PKCEOptionalRequired, S256 onlyRequired, S256 only
Refresh token rotationSetting (off)Setting (off)Forced on
password grantEnabledEnabledRemoved (and not advertised)
Redirect URIs at /authorizeAny valid URI, or exact match if registeredSame as devRegistration mandatory, exact match

Key Management

NanoIDP uses RSA keys for JWT signing. Keys can be auto-generated, imported from external files, or rotated dynamically.

Auto-generated Keys

By default, NanoIDP generates RSA keys on first startup and stores them in the keys/ directory:

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

External Keys

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

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

Requirements:

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

Key Rotation

NanoIDP supports key rotation with multiple keys in JWKS for seamless token validation during rotation periods.

API Endpoints

# Rotate keys (generates new key, preserves old for validation)
curl -X POST http://localhost:8000/api/keys/rotate

# Get key information
curl http://localhost:8000/api/keys/info

How It Works

  1. Rotation: New key pair generated, old key moved to "previous" list
  2. JWKS: Returns both active and previous keys (configurable via max_previous_keys, default 2)
  3. Signing: New tokens signed with the active key
  4. Validation: Tokens signed with previous keys remain valid until those keys are rotated out

Configuration

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

MCP Server Security

The MCP (Model Context Protocol) server provides integration with Claude Code and other MCP-compatible tools.

Security Warning

The MCP server exposes powerful administrative tools and should ONLY be used:

  • Locally on developer machines
  • In isolated development environments
  • Never exposed to network access

Mutating Tools

The following MCP tools modify configuration and require extra caution:

ToolDescription
create_userCreate a new user
update_userModify user attributes
delete_userRemove a user
create_clientCreate OAuth client
update_clientModify client settings
delete_clientRemove OAuth client
generate_tokenGenerate access tokens
update_settingsModify IdP settings
save_configPersist configuration changes

Admin Secret Protection

When 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

VariableDescriptionDefault
NANOIDP_CONFIG_DIRConfiguration directory path./config
NANOIDP_MCP_ADMIN_SECRETSecret required for mutating MCP operations(none)
NANOIDP_MCP_READONLYDisable mutating MCP tools when set to truefalse
PORTServer port8000

Best Practices

  1. Use stricter-dev profile when sharing the instance with team members
  2. Enable readonly mode for MCP when only introspection is needed
  3. Set MCP admin secret if multiple developers share the same NanoIDP instance
  4. Rotate keys periodically to test token validation with multiple keys
  5. Never expose NanoIDP to public networks - it's designed for local/isolated use only

  • MCP Workflow - Detailed Claude Code integration examples
  • README - Installation and configuration

Configuration

NanoIDP is configured through two YAML files in the config directory (./config by default, --config or NANOIDP_CONFIG_DIR to change it). Everything below can also be managed from the web UI at http://localhost:8000:

  • Dashboard — overview and quick stats
  • Users — create, edit, delete users
  • OAuth Clients — manage OAuth2 client credentials
  • Settings — configure IdP settings (issuer, audience, SAML)
  • Keys & Certs — view and regenerate RSA keys
  • Claims — configure authority prefix mappings
  • Audit Log — view and export authentication events
  • Token Tester — generate and inspect tokens

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

EndpointDescription
GET /.well-known/openid-configurationOIDC Discovery
GET /.well-known/jwks.jsonJSON Web Key Set
GET /authorizeAuthorization endpoint (login page)
POST /tokenToken endpoint
GET/POST /userinfoUserInfo endpoint
POST /introspectToken Introspection (RFC 7662)
POST /revokeToken Revocation (RFC 7009)
GET/POST /logoutOIDC End Session / Logout
POST /device_authorizationDevice Authorization (RFC 8628)
GET/POST /deviceDevice verification page

curl examples for every grant are in Requesting tokens.

SAML

EndpointDescription
GET /saml/metadataIdP Metadata
GET/POST /saml/ssoSingle Sign-On (supports both HTTP-POST and HTTP-Redirect bindings)
POST /saml/attribute-queryAttribute Query

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

REST API

EndpointDescription
GET /api/healthHealth check
GET /api/usersList users
GET /api/users/{username}Get user details
POST /api/users/{username}/tokenGenerate token
GET /api/auditGet audit log
POST /api/config/reloadReload configuration
POST /api/keys/rotateRotate cryptographic keys
GET /api/keys/infoGet key information

Tokens and claims

Audiences and the aud claim

NanoIDP follows the OpenID Connect / OAuth specs for the aud claim:

  • ID Tokenaud is the requesting client's client_id (OpenID Connect Core 1.0 §2). This lets you test multiple clients independently. Configure additional_audiences on a client to append extra audiences; if this produces more than one distinct audience value, aud is emitted as an array and NanoIDP also emits an azp claim equal to the client_id so you can exercise authorized-party validation.
  • Access Tokenaud is the resource audience from oauth.audience (RFC 9068 §2.2), independent of the client.

Access Token

The access token aud is the resource audience (oauth.audience). The user's attributes from users.yaml are carried both as individual claims and flattened into authorities via the configured authority_prefixes:

{
  "iss": "http://localhost:8000",
  "sub": "admin",
  "aud": "my-app",
  "iat": 1704100000,
  "exp": 1704103600,
  "roles": ["USER", "ADMIN"],
  "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:

BindingHTTP MethodSAMLRequest Encoding
HTTP-POSTPOSTBase64 only (uncompressed)
HTTP-RedirectGETDEFLATE compressed + Base64

Both bindings are advertised in the SAML metadata (/saml/metadata).

Strict binding mode

By default, NanoIDP operates in lenient mode for developer convenience, accepting GET requests with uncompressed SAMLRequest data (non-compliant but useful for debugging).

To enforce strict SAML 2.0 binding compliance:

Via configuration file (settings.yaml):

saml:
  strict_binding: true  # Reject GET with uncompressed data

Via web UI: Settings → SAML Settings → toggle "Strict SAML Binding" → Save Settings.

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

Response signing

By default, NanoIDP signs all SAML responses with an XML digital signature. You can disable signing for testing scenarios that require unsigned SAML flows:

Via configuration file (settings.yaml):

saml:
  sign_responses: false  # Disable SAML response signing

Via web UI: Settings → SAML Settings → toggle "Sign SAML Responses" → Save Settings.

When sign_responses: true (default), responses include:

  • <ds:Signature> element with RSA-SHA256 signature
  • <ds:X509Certificate> with the IdP certificate

When sign_responses: false, responses are sent without any signature elements.

Signed AuthnRequests

By default, nanoidp accepts unsigned AuthnRequests (and ignores Signature/SigAlg query parameters). SPs that sign their requests can turn on verification:

saml:
  want_authn_requests_signed: true
  sp_certificates:
    - /path/to/sp-cert.pem   # PEM; one entry per trusted SP

With the flag on, nanoidp requires and verifies the signature under both bindings and rejects unsigned or invalid requests with 400:

  • HTTP-Redirect — the query-string signature over the URL-encoded SAMLRequest[&RelayState]&SigAlg fragment (SAML 2.0 Bindings §3.4.4.1); rsa-sha256, rsa-sha512 and legacy rsa-sha1 SigAlg values are supported.
  • HTTP-POST — the enveloped <ds:Signature> inside the AuthnRequest (SAML 2.0 Core §5).

The metadata advertises WantAuthnRequestsSigned="true" if and only if enforcement is on. A request verifies if any registered certificate validates it.

Need a test SP keypair? python examples/gen_sp_keypair.py --out . generates sp-key.pem/sp-cert.pem, and examples/test_agent.py --saml-signed exercises the whole behavior against a running server.

XML canonicalization algorithm

By default, NanoIDP uses Exclusive C14N for XML canonicalization, which is the standard for SAML signatures and compatible with most modern SAML implementations. You can configure the algorithm based on your SP requirements:

Via configuration file (settings.yaml):

saml:
  c14n_algorithm: exc_c14n  # Default: Exclusive C14N 1.0 (standard for SAML)
  # c14n_algorithm: c14n    # C14N 1.0
  # c14n_algorithm: c14n11  # C14N 1.1

Via web UI: Settings → SAML Settings → select the Canonicalization Algorithm from the dropdown → Save Settings.

ValueAlgorithmUse Case
exc_c14n (default)Exclusive C14N 1.0Standard for SAML, handles namespace isolation
c14nC14N 1.0Legacy SAML implementations
c14n11C14N 1.1Newer implementations

Why Exclusive C14N is the default:

Exclusive C14N is recommended by the SAML 2.0 specification because it only includes namespaces actually used in the signed element. This is important when SPs extract the <Assertion> element from the <Response> to verify the signature independently. With standard C14N, the signature includes parent namespaces that break when the Assertion is extracted.

MCP server

NanoIDP includes an MCP (Model Context Protocol) server for integration with Claude Code and other MCP-compatible tools. For a hands-on tour — 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

ToolDescription
list_usersList all configured users
get_userGet details of a specific user
create_userCreate a new user
update_userUpdate an existing user (password, email, roles, …)
delete_userDelete a user
generate_tokenGenerate OAuth2 tokens for a user (pass scope with openid to also get an ID Token)
decode_tokenDecode JWT token (without verification)
verify_tokenVerify JWT token signature and expiration
list_clientsList OAuth clients
get_clientGet client details
create_clientCreate a new OAuth client
update_clientUpdate an existing OAuth client
delete_clientDelete an OAuth client
get_settingsGet current IdP settings
update_settingsUpdate IdP settings
save_configPersist the current configuration to the YAML files
reload_configReload configuration from files
get_oidc_discoveryGet OIDC discovery document (same document as /.well-known/openid-configuration)
get_jwksGet JSON Web Key Set
get_audit_logGet audit log entries (filter by limit, event type, username)
get_audit_statsGet audit statistics
clear_audit_logClear the audit log
get_keys_infoGet signing key info (active kid, previous keys)
rotate_keysRotate signing keys (old key stays valid for verification)

Claude Code configuration

Add to your project's .claude/settings.json:

{
  "mcpServers": {
    "nanoidp": {
      "command": "python",
      "args": ["-m", "nanoidp.mcp_server"],
      "env": {
        "NANOIDP_CONFIG_DIR": "./config"
      }
    }
  }
}

Or if NanoIDP is installed globally:

{
  "mcpServers": {
    "nanoidp": {
      "command": "nanoidp-mcp",
      "env": {
        "NANOIDP_CONFIG_DIR": "/path/to/config"
      }
    }
  }
}

Claude Desktop configuration

Add to ~/.claude/claude_desktop_config.json:

{
  "mcpServers": {
    "nanoidp": {
      "command": "nanoidp-mcp",
      "env": {
        "NANOIDP_CONFIG_DIR": "/path/to/nanoidp/config"
      }
    }
  }
}

Running standalone

# Run MCP server directly
python -m nanoidp.mcp_server

All MCP tool calls are logged to the audit log.

Vision

What nanoidp is

nanoidp is a lightweight identity provider for development and testing. 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.

  1. A dev tool, not a production IdP. Tradeoffs are primarily weighed by asking "would this mislead someone testing against it?", rather than "is this hardened enough to operate as a production identity provider?". Security behaviors (PKCE, rotation, client binding) are first-class precisely because clients need to test them; what nanoidp does not promise is production-grade operation. Convenience that doesn't distort spec behavior is welcome; hardening that costs convenience must be optional.
  2. Metadata never lies. Discovery and documentation advertise exactly what the endpoints implement — a missing feature is acceptable, a pretended one is not (see #41: response_type=token was advertised but unimplemented, and was removed rather than implemented).
  3. Hardening is opt-in, defaults stay permissive. Strictness lives in security profiles (stricter-dev) and explicit settings (require_pkce, refresh_token_rotation); the out-of-the-box experience favors getting a first token in under a minute.
  4. MCP/HTTP parity. Every administrative and testing capability that is meaningful to agents is exposed through MCP, with shared builders and models wherever possible so equivalent surfaces cannot drift (see #40: the shared discovery builder). Protocol surfaces themselves — authorization redirects, SAML SSO, UserInfo — are exercised over HTTP, as a real client would.
  5. Features ship whole. A feature lands together with its MCP exposure (where applicable), its examples/test_agent.py e2e coverage and its docs — in the same PR.
  6. RFC-citable behavior. Token and protocol behaviors reference the spec paragraph that justifies them, in code comments and changelog entries alike. When a reviewer disagrees, the RFC arbitrates.

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:

  1. OAuth 2.1 profile — an opt-in profile aligning nanoidp's strictest behavior with draft OAuth 2.1: PKCE required everywhere, refresh token rotation on by default, S256 only.
  2. SAML hardening — optional validation of signed AuthnRequests, for testing SPs that sign their requests.
  3. Typing strictness — tighten the mypy baseline module by module (disallow_untyped_defs) until src/ is fully annotated.
  4. CI quality gates — enforce a coverage threshold in CI and fail on Codecov upload errors.
  5. 3.0 breaking cleanups — deferred breaking changes for the next major; first entry: refresh tokens without a client_id binding claim stop being accepted (transitional compatibility introduced in 2.2.0).

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

  • oauth21 security profile (#68): opt-in draft-OAuth-2.1 protocol strictness alongside dev and stricter-dev — PKCE required on the authorization code flow with S256 only (draft-ietf-oauth-v2-1 §4.1.1, §7.5.2), refresh token rotation forced on (§4.3.1), the password grant removed (RFC 6749 §5.2) and absent from discovery, and registered redirect URIs mandatory at /authorize. Protocol behavior lives in derived Settings properties consumed by both the routes and the shared discovery builder, so the profile means the same thing from --profile or settings.yaml and discovery can never advertise what the endpoints refuse. Deliberately orthogonal to stricter-dev (runtime hardening).
  • Registered redirect URIs with exact matching (#67): clients gain an optional redirect_uris list; when non-empty, /authorize compares the requested redirect_uri with simple string comparison (RFC 6749 §3.1.2.3, OAuth 2.1 §4.1.1) and answers a mismatch with 400 invalid_request directly — never by redirecting to the unvalidated URI (§3.1.2.4). Exposed in the web UI, MCP client tools and YAML.
  • Signed AuthnRequest verification (#69): with saml.want_authn_requests_signed: true and PEM certificates in saml.sp_certificates, nanoidp requires and verifies AuthnRequest signatures under both bindings — the HTTP-Redirect query-string signature over the raw transmitted fragment (SAML 2.0 Bindings §3.4.4.1; rsa-sha256/rsa-sha512/legacy rsa-sha1) and the HTTP-POST enveloped ds:Signature (Core §5) — rejecting unsigned or invalid requests with 400, failing closed without registered certificates. The verified Redirect request is bound server-side in the session, so the inline-login leg only accepts byte-identical values. Metadata advertises WantAuthnRequestsSigned="true" if and only if enforcement is on. examples/gen_sp_keypair.py generates a test SP keypair.
  • E2E workflow in CI (#79): every PR now boots real servers and runs examples/test_agent.py against them (default profile, --oauth21, --saml-signed with a generated SP keypair) plus an MCP stdio smoke test (examples/mcp_smoke_test.py) driving the real transport — the regression guard for the class of bug where the stdio entrypoint crashed unnoticed because unit tests bypass it (#56).
  • Coverage gate in CI (#71, #72): --cov-fail-under, introduced at 70 and ratcheted to 75 after the wizard went from 0% to 99% coverage; measured coverage 78%. The dead Codecov upload (never configured, failed silently since inception) was removed in favor of in-CI enforcement.
  • Documentation site: mdBook on GitHub Pages (https://cdelmonte-zg.github.io/nanoidp/) with getting-started, guides and a full reference; canonical docs are symlinked so there is a single source of truth, and the README became a landing page.
  • Web UI parity (#94): require_pkce and refresh_token_rotation toggles on the settings page; SP-certificates and signed-AuthnRequests fields (#69); redirect_uris on the client form (#67); the dashboard badge distinguishes the oauth21 profile.
  • MCP: get_settings reports security_profile; update_settings covers the SAML verification fields; client tools carry redirect_uris.

Changed

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

Fixed

  • ConfigManager.save() was lossy (#87): the save path behind MCP save_config rewrote settings.yaml from scratch, silently deleting every section it didn't own — jwt (external keys!), session, logging levels, server.debug and custom keys. Saving is now read-modify-write and preserves them, atomically and with a .bak backup like the UI path always did.

2.2.0 - 2026-06-11

Added

  • The refresh_token grant now re-issues an ID Token when the original grant included the openid scope (OIDC Core §12.2, #39). The granted scope is persisted in the refresh token claims and recovered on refresh; a scope form parameter may narrow, but never broaden, the original grant (RFC 6749 §6 — broadening is rejected with 400). The refreshed ID Token carries no nonce (it binds the original authentication request). Refresh tokens minted before this change have no persisted scope and keep the old behavior.
  • ID Tokens now carry auth_time and at_hash (#42). auth_time reflects when the end-user actually authenticated: the login page for the authorization code flow, the /device verification for the device flow, the request itself for the password grant — and it is preserved unchanged across refreshes (OIDC Core §12.2), carried in the refresh token claims like the scope. at_hash binds the ID Token to the access token issued alongside it (left half of SHA-256, base64url — §3.1.3.6). Discovery claims_supported now also advertises auth_time, nonce and at_hash.
  • Optional refresh token rotation (#46): with oauth.refresh_token_rotation: true (default off), each refresh atomically invalidates the consumed refresh token, so its reuse fails with 401; reuse of a consumed token revokes its whole rotation family, including the live descendant (RFC 9700 §4.14.2).
  • PKCE enforcement (#47): new require_pkce setting (enabled by the stricter-dev profile, persisted in settings.yaml) rejects /authorize requests without a code_challenge; stricter-dev also rejects code_challenge_method=plain — explicit or implicit via the RFC 7636 §4.3 omitted-parameter default — and discovery only advertises S256 there. Unsupported methods are rejected at the authorization endpoint (§4.4.1). Default profile unchanged.
  • MCP audit & key tools (#48): get_audit_log, get_audit_stats, clear_audit_log, get_keys_info and rotate_keys mirror the HTTP API, so agent workflows can inspect what the IdP recorded and exercise JWKS refresh handling. clear_audit_log/rotate_keys count as mutating tools (admin secret / readonly rules apply). MCP get_settings/update_settings expose the new refresh_token_rotation and require_pkce settings, and generate_token accepts an optional scope argument and returns an id_token when openid is included, matching the HTTP token endpoint.
  • CI now lints with ruff (#45) and type-checks src/ with mypy (#55, documented gradual-adoption baseline in pyproject.toml). The codebase is lint-clean — 153 findings fixed (#49): deprecated datetime.utcnow() replaced (removes 80 DeprecationWarnings), unused imports/variables dropped, imports sorted and moved to module level, Optional[...] type hints in the crypto service, verify_jwt accepts an array audience, exceptions re-raised with from e — and mypy-clean (40 baseline errors fixed; the baseline also surfaced the broken nanoidp-mcp entrypoint below).

Fixed

  • The nanoidp-mcp stdio entrypoint crashed at startup ("a coroutine was expected"): stdio_server() is an async context manager yielding the message streams, not a coroutine. Verified with a JSON-RPC initialize handshake.
  • Review follow-ups of the 2026-06-11 merge block (#56):
    • Refresh tokens are bound to their client: the issuing client_id is persisted in the refresh token claims and the refresh grant rejects any other client (RFC 9700 §4.14) — which also guarantees the refreshed ID Token keeps the original aud (OIDC Core §12.2). Tokens minted before the claim existed keep working.
    • Rotation is atomic and revokes families on reuse: the revocation check and the claim of the consumed token now happen in one critical section, so two concurrent refreshes of the same token can no longer both succeed. Each grant starts a refresh-token family (rt_family claim, stable across rotations); reusing an already-consumed token revokes the whole family, including the live descendant (RFC 9700 §4.14.2).
    • PKCE plain can no longer slip through stricter-dev by omitting the method: per RFC 7636 §4.3 an absent code_challenge_method defaults to plain; the method is now normalized before validation, and unknown methods are rejected at the authorization endpoint (§4.4.1).
    • require_pkce is persisted: it is now read from and written to settings.yaml (oauth section), so update_settingssave_configreload_config no longer silently reverts it.
    • The token response reports the scope actually granted (RFC 6749 §5.1) instead of a hardcoded "openid"; when no scope was involved the parameter is omitted, and a narrowed refresh reports the narrowed scope.
  • The token endpoint validates exp and extra before the grant dispatch: with rotation enabled, a malformed value can no longer consume the refresh token without delivering its replacement (the last tradeoff noted in the #56 review). Validation is semantic, not just syntactic: extra must be a JSON object (a scalar/array used to raise a TypeError 500 later) and exp must be an integer within the same 1..1440 bounds the Settings model enforces (non-numeric values used to be an unhandled ValueError 500; astronomical ones an OverflowError 500).
  • Thread-safety hardening for shared in-memory state (#43): the authorization code store now performs its check-then-mark sequence under a lock (one-time use can no longer be defeated by concurrent redemptions), device codes are claimed/transitioned atomically and pruned when expired, and the lazily-created service singletons (config, token, crypto, audit, auth codes) use double-checked locking so concurrent first access creates exactly one instance.
  • The MCP get_oidc_discovery tool now returns the exact same document as the HTTP /.well-known/openid-configuration endpoint (#40). Both build it via a new shared helper (services.discovery.build_discovery_document), so the MCP tool now advertises claims_supported (including azp), response_types_supported, id_token_signing_alg_values_supported, code_challenge_methods_supported and the endpoint auth methods — and the two documents can no longer drift apart.
  • Discovery no longer advertises the token response type (#41): the implicit flow was never implemented (/authorize only accepts response_type=code) and is deprecated by the OAuth 2.0 Security BCP, so advertising it misled clients. response_types_supported is now ["code"].

Documentation

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

2.1.0 - 2026-05-26

Added

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

Fixed

  • Friendlier loading of client additional_audiences from settings.yaml (#35): a scalar value (additional_audiences: api://x) is coerced to a one-element list, and an unsupported shape (e.g. a non-string item) now fails with a clear, client-scoped error instead of an opaque Pydantic ValidationError at startup.
  • Minor hardening/polish from the #32 review (#37): OAuthClient now validates on direct attribute assignment (validate_assignment), discovery advertises azp in claims_supported, and the MCP _normalize_audiences rejects falsy non-list inputs instead of silently returning an empty list.

Security

  • Harden the ID Token vs access-token boundary (#34). The resource audience (oauth.audience) is now filtered out of the ID Token aud even if a client lists it in additional_audiences, and every token carries a token_use marker (access / id / refresh). /userinfo rejects tokens marked as ID or refresh tokens and /introspect reports ID Tokens as inactive, so an ID Token can no longer be spent as an access token. (Refresh tokens stay introspectable per RFC 7662.)

2.0.0 - 2026-05-25

Changed

  • ID Token aud now contains the requesting client's client_id, as required by OpenID Connect Core 1.0 §2 (was previously the static oauth.audience). This makes it possible to test multiple clients and brings nanoidp in line with the OIDC spec.
    • Breaking: relying parties that validated the ID Token aud against the old static oauth.audience value must now expect their own client_id.
    • The access token aud is unchanged and still reflects oauth.audience (the resource audience, per RFC 9068 §2.2).

Added

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

1.4.0 - 2026-04-28

Added

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

1.3.3 - 2026-04-22

Fixed

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

Changed

  • Use pyproject.toml as single source of truth for version number
  • Remove outdated version label from Dockerfile

1.3.2 - 2026-03-27

Fixed

  • Token endpoint now rejects requests when client_id cannot be determined from either the request body or the Authorization header
  • Token endpoint now rejects requests where client_id in the body conflicts with the authenticated client in the Authorization header

Added

  • Tests for client_id mismatch and missing client_id edge cases

1.3.1 - 2026-03-26

Fixed

  • Allow authorization code flow without Authorization header for PKCE public clients (RFC 6749 §2.1)
    • Libraries like authlib send client_id in the request body instead of the header when no client secret exists
    • Auth header validation is now only enforced for grant types other than authorization_code

Added

  • Test for PKCE plain flow without auth header (test_pkce_plain_flow_no_auth_header)

1.3.0 - 2026-03-25

Added

  • GitHub Actions workflow to build and publish Docker images to GitHub Container Registry (GHCR)
    • Triggered on version tags (v*), builds multi-platform images (linux/amd64, linux/arm64)
    • latest tag published only for non-prerelease versions
  • Docker usage instructions in README (docker pull and docker run examples)

Changed

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

1.2.3 - 2026-03-03

Fixed

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

Docs

  • Added mascotte/logo images to the project

1.2.2 - 2026-01-19

Added

  • New strict_saml_binding setting to enforce SAML 2.0 binding compliance
    • When false (default): lenient mode accepts GET with uncompressed data (useful for debugging)
    • When true: strict mode rejects non-compliant requests per SAML spec
  • Setting exposed in UI (Settings page), REST API (/api/config), and MCP server
  • Exclusive C14N (exc_c14n) is now the default XML canonicalization algorithm
    • Standard for SAML 2.0 signatures, handles namespace isolation correctly
    • Available algorithms: exc_c14n (Exclusive C14N 1.0, default), c14n (C14N 1.0), c14n11 (C14N 1.1)
  • UI select dropdown for C14N algorithm in Settings page
  • strict_saml_binding and verbose_logging now persist correctly on save/reload
  • Comprehensive E2E test coverage for all SAML flows in test_agent.py:
    • test_saml_metadata_bindings - verifies both HTTP-POST and HTTP-Redirect advertised
    • test_saml_sso_post_binding - SP-initiated SSO with HTTP-POST (InResponseTo verification)
    • test_saml_sso_redirect_binding - SP-initiated SSO with HTTP-Redirect (InResponseTo verification)
    • test_saml_idp_initiated_not_supported - documents IdP-initiated SSO is not supported
    • test_saml_strict_binding_mode - tests strict/lenient binding behavior
    • test_saml_attribute_query_verification - verifies actual attributes returned
  • Unit tests for inline login flow (test_inline_login_flow_preserves_post/redirect_binding)
  • Unit test for strict mode + inline login (test_strict_mode_inline_login_preserves_redirect_binding)
  • Unit test for Exclusive C14N configuration (test_c14n_algorithm_configurable_to_exclusive)

Fixed

  • SAML SSO now correctly handles both HTTP-POST and HTTP-Redirect bindings
  • Parser always tries DEFLATE decompression first, falls back to raw XML (handles all edge cases)
  • Strict mode now works with inline login by passing original HTTP verb via hidden field
    • Fixes: GET compressed → login form → POST would fail in strict mode
    • Stateless: no server-side session needed, works in CI/CD pipelines
  • Explicit |e escape filter in login template hidden fields (XSS defense-in-depth)
  • Normalized original_verb handling (uppercase, validated to GET/POST)
  • Quick-fill username buttons use tojson filter to handle special characters safely

1.2.1 - 2026-01-16

Fixed

  • SAML SSO now correctly handles HTTP-POST binding (uncompressed SAMLRequest)
  • Previously, _parse_saml_request unconditionally attempted DEFLATE decompression, causing parsing to fail for POST requests
  • Now uses HTTP method to determine binding type: GET = HTTP-Redirect (compressed), POST = HTTP-POST (uncompressed)

Changed

  • E2E test agent now verifies actual SAML parsing (InResponseTo matching) instead of just endpoint availability
  • Added separate tests for HTTP-POST and HTTP-Redirect bindings in test_agent.py

Changed (Architecture)

  • Inline login for SAML SSO: /saml/sso now shows login form directly instead of redirecting to /login
    • This preserves SAML binding context naturally (no redirect = no method change)
    • Follows the pattern used by Keycloak and other IdPs
    • Removes the complex edge cases caused by redirect-based login
  • /login endpoint simplified - now only used for direct web UI access, not SAML flows
  • Login form now posts to current URL (no hardcoded action) - works for both /login and /saml/sso

Changed

  • SAML metadata now advertises both HTTP-POST and HTTP-Redirect bindings for SingleSignOnService
  • Audit stats now track SAML SSO and Attribute Query separately (saml_sso_requests, saml_attribute_queries)
  • Dashboard shows combined SAML total with SSO/AttrQuery breakdown
  • E2E test agent expanded to 35 tests (was 28), now covering all SAML flows with parsing verification

1.2.0 - 2026-01-14

Added

  • Configurable verbose_logging setting to control sensitive data in logs
  • verbose_logging exposed in MCP get_settings and update_settings tools
  • logging.verbose_logging exposed in REST API /api/config endpoint
  • MCP tests (tests/test_mcp.py) with 8 tests for MCP functionality
  • Verbose logging test in E2E test agent

Changed

  • Replaced deprecated defusedxml.lxml with native lxml secure parser for XXE protection
  • Added html.escape for XSS prevention in SAML responses
  • Audit logging now respects verbose_logging setting (usernames/client_ids only when enabled)

Security

  • XXE (XML External Entity) protection using secure lxml parser configuration
  • XSS prevention in SAML response forms
  • Configurable sensitive data logging (verbose_logging defaults to true for dev convenience)

1.1.1 - 2026-01-14

Added

  • Configurable XML canonicalization algorithm via saml.c14n_algorithm setting

1.1.0 - 2026-01-14

Added

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

Changed

  • SAML SSO and AttributeQuery endpoints now respect sign_responses configuration
  • Changed default XML canonicalization to C14N 1.0 for maximum compatibility
  • Updated documentation with SAML signing configuration instructions

1.0.0 - 2025-12-04

Added

  • Initial release
  • OAuth2/OIDC support (Authorization Code, Password, Client Credentials, Refresh Token, Device Flow)
  • PKCE support (S256 and plain methods)
  • Token Introspection (RFC 7662) and Revocation (RFC 7009)
  • OIDC Logout / End Session endpoint
  • Device Authorization Grant (RFC 8628)
  • SAML 2.0 SSO and AttributeQuery endpoints with signed assertions
  • MCP Server integration for Claude Code
  • Web UI for configuration (users, clients, settings, keys, audit log)
  • YAML-based configuration
  • Attribute-based access control with configurable authority prefixes
  • Audit logging
  • Docker support
  • Security profiles (dev and stricter-dev)
  • Key rotation with JWKS support for multiple keys
  • External key import support

Contributing to NanoIDP

Thank you for your interest in contributing to NanoIDP!

How to Contribute

Reporting Bugs

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

Feature Requests

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

Pull Requests

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/your-feature
  3. Make your changes
  4. Run tests and linting
  5. Commit with clear messages
  6. Push to your fork
  7. Open a Pull Request

Development Setup

# Clone your fork
git clone https://github.com/YOUR_USERNAME/nanoidp.git
cd nanoidp

# Create virtual environment
python -m venv .venv
source .venv/bin/activate  # or .venv\Scripts\activate on Windows

# Install in development mode with dev dependencies
pip install -e ".[dev]"

# Run locally
nanoidp --debug

Running Tests

# Run all tests
pytest

# Run with coverage
pytest --cov=nanoidp

# Run specific test
pytest tests/test_basic.py

End-to-End Test Agent

NanoIDP includes a comprehensive test agent that validates all functionality against a running server:

# Run against local server (default: http://localhost:8000)
python examples/test_agent.py

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

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

# JSON output
python examples/test_agent.py --json

The test agent covers:

  • Core: Health check, OIDC discovery
  • OAuth2/OIDC: All grant types, token introspection, revocation, logout
  • SAML 2.0: Metadata, SSO (POST/Redirect bindings), Attribute Query, signing config
  • Key Management: Key info, rotation, post-rotation token validation
  • REST API: Users, config, audit log

Caution: a run against a live server persists configuration changes back to that server's config/settings.yaml (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 UI
  • fix: Correct token expiry calculation
  • docs: Update README with Docker instructions
  • refactor: Simplify user authentication flow
  • test: Add tests for SAML endpoint

Project Structure

nanoidp/
├── src/nanoidp/       # Main package
│   ├── routes/        # Flask route handlers
│   ├── services/      # Business logic
│   └── templates/     # Jinja2 templates
├── tests/             # Test files
├── config/            # Default configuration
└── docs/              # Documentation

Releasing (maintainers)

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:

  1. Runs all tests
  2. Builds the package
  3. Publishes to TestPyPI
  4. Publishes to PyPI (only for non-prerelease tags)
  5. 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.