Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
client_credentialsno longer returns a refresh token (#239). RFC 6749 §4.4.3: "A refresh token SHOULD NOT be included" - the client authenticates itself on every request, and the token handed out was a second, 7-day credential bound to the default user (or the synthetic service account) that the grant never authenticated, spendable atgrant_type=refresh_tokenfor user-context tokens. The response now has norefresh_tokenkey at all; every other grant is unchanged. A client that refreshed a client-credentials token must request a new one with its credentials, which is what the RFC asks of it.- /saml/sso rejects a request with nowhere to send the assertion (#227):
an AuthnRequest without
AssertionConsumerServiceURLagainst a config whosesaml.default_acs_urlis blank now gets a 400 naming both missing sources (with a failedsaml_requestaudit entry), instead of rendering an auto-submit form posting toaction=""- the IdP's own page. client_credentialsno longer 500s whendefault_usernames a missing user (#241): the syntheticservice-accountfallback was built with an empty password, whichUserrejects since #158 madepasswordoptional withmin_length=1. The fallback now carries no password, as it never authenticates with one, and the grant answers withsub=service-accountagain.- The dashboard's Logout button logs the UI session out again (#221).
The UI logout route registered the same
/logoutrule as the OIDC end-session endpoint and always lost: clicking Logout landed on the end-session confirmation page and the UI logout audit event was never written. The UI logout now lives atGET /ui/logout(the button follows automatically viaurl_for), redirects back to the dashboard, and writes itslogoutaudit event;/logout(alias/end_session) remains the OIDC endpoint, unchanged. - Regenerating a client secret no longer resets the client's branding
(#213 review):
/clients/<id>/regenerate-secretrebuilt the client with only five fields, silently droppingbackground_color,header_colorandfooter_colorand resettingshow_client_id/show_descriptionto their defaults - the same rebuild-by-hand shape that lostadditional_audiencesin #32. The route now copies the client and changes only the secret, so every field (present and future) is carried.
Added
- Per-client allowed scopes and
invalid_scope(#186).oauth.scopes_supportedis the global scope vocabulary (defaultopenid,profile,email,offline_access, also what discovery'sscopes_supportednow advertises instead of a hardcoded list); a client's newallowed_scopesis an optional subset of it. A requested scope outside the vocabulary isinvalid_scopefor every client - a small behavior change, since any scope string used to be accepted unchecked; a scope outside a client's ownallowed_scopes, when set, isinvalid_scopefor that client specifically. Enforced at/authorize, every/tokengrant (includingclient_credentials, RFC 6749 §4.4, which previously dropped any requested scope entirely) and/device_authorization; an omittedscopedefaults to the client's full allowed set, or today's default when unrestricted. Every/tokenrejection - including the pre-existing refresh-token scope-narrowing check - now returns the RFC 6749 §5.2 JSON error shape ({"error": "invalid_scope", ...}) instead of a bare 400.oauth.scope_enforcement: falseis a dev-only escape hatch back to the pre-#186 behavior (any scope string accepted, unchecked); refused outside thedevprofile.allowed_scopesis settable from the clients UI form and the MCPcreate_client/update_clienttools, same asadditional_audiences/redirect_uris.
Security
- Opt-in
management_secretmutation gate (#163): one shared secret that gates state-changing calls across all three management surfaces - the MCP server (via the existingadmin_secrettool argument, which now reads from this setting instead of a standalone env var),/api/*(via a newX-Management-Secretrequest header on mutating calls), and the config web UI (a one-time "unlock" form at/loginthat then trusts the session for further mutating requests). Off by default - unset, nothing changes. Configurable viasettings.yaml'ssession.management_secretor theNANOIDP_MANAGEMENT_SECRETenv var; the previous MCP-onlyNANOIDP_MCP_ADMIN_SECRETstill works as an alias, though an explicitmanagement_secret: null/""insettings.yamlnow wins over either env var rather than falling through to it. Independent ofrequire_ui_login: that gate is the UI's session front door (who can view the dashboard), this is the write guard (who can change anything) - either, both, or neither can be enabled; the unlock form stays reachable even whenrequire_ui_loginis also on. YAML-only, same treatment asrequire_ui_login/secret_key. Hardened since first landing: the UI unlock flag is now an HMAC of the secret itself (not a bare session boolean), so it can't be forged just by knowingsecret_key's public default; a non-ASCII or non-string secret compares safely instead of 500ing; an unlocked UI session now also satisfies the/api/*gate, so the dashboard's own buttons (generate token, clear audit log) keep working after one unlock; and the MCP check now always reads theConfigManageractually serving the request.
2.7.0 - 2026-08-25
Changed
- Configuration files load through document models (#175, piece 2).
settings.yamlandusers.yamlare now parsed into Pydantic document models that mirror the YAML sections one to one (nanoidp.config_documents), and the domainSettings/Userobjects are built from them; the hand-written.get(key, default)mapping inconfig.pyis gone and the defaults live on the models, which the writer reads as well. No file format change and no behavioural change for files that loaded before. One visible improvement: an unknown key (a typo such asoauth.isuer, or a key nanoidp does not know) is now logged as a warning with its dotted path and ignored, instead of vanishing silently; keys that shipped presets carry but the loader never consumed (cors_allowed_origins,device_flow,logging.format,oauth.refresh_token_expiry_minutes,session.permanent) are declared so they do not warn. Fields inside a user entry keep folding intoattributes, as always. Stricter handling is a later piece.
Config schema
config_version1 introduced (#175, piece 1). No changes required to existing files: a file without the key is version 1. The version is the contract of the config directory as a whole: both files declare the same number, each is checked independently, and it must be a literal integer (checked before${VAR}expansion).
Added
- Generated config schema,
validate-configand strict validation (#175, pieces 3 and 4). Three additions to the config contract, none of which restates it a seventh time:nanoidp config-schemaprints the JSON Schema ofsettings.yaml,users.yamlandbootstrap.yaml, generated from the document models (--filefor one of them,--writeto regenerate the committed artifact from a source checkout). The artifact isdocs/schema/config.v1.json: one standalone schema per file under the keyssettings,usersandbootstrap, next to theconfig_versionthey describe, ready to point an editor's YAML-schema support at. A test fails when the committed file no longer matches the models, and parity tests fail when the MCPupdate_settingstool or the web UI's settings form grows a knob that is not a key of the contract - or offers one of the YAML-only fields (secret_key,require_ui_login,hooks,plugins).config_validation: warn|strict(top level ofsettings.yaml, defaultwarn) and the server flag--strict-configdecide what an unknown key does: log its path and keep loading, or refuse to start and refuse every later reload with the same message. The flag wins over the file for that run only and is never written back, like--profile(#172). One contract per directory:users.yamlandbootstrap.yamlfollow whatsettings.yamldeclares. Wrong types stay errors in both modes.nanoidp validate-config [--config DIR] [--strict]lints a configuration directory without starting anything: one line per finding, exit 0 when clean or with warnings only, exit 1 on errors and on warnings under--strict. It reads the three files through the same loaders the server uses and nothing else - noConfigManager, no hook dispatched, no plugin imported,bootstrap.yamlchecked for its shape only - so it is safe as a pre-commit or CI step on a directory whose hooks name commands. MCP agents get the same check as the read-onlyvalidate_configtool ({valid, findings}), which brings the MCP surface to 26 tools.
- Hooks and plugins v1 (#185): extension points for external
configuration stores, not backends. Three synchronous hooks with
HOOK_API_VERSION = 1:on_before_load(config_dir)before the files are read (startup and every reload),on_config_saved(path, kind)after an atomic write ofsettings.yamlorusers.yaml,on_audit_event(event)after an audit entry. Implement them as shell commands underhooks:insettings.yaml(placeholders{config_dir},{path},{kind},{event_type}, audit event JSON on stdin) or as Python plugins packaged separately and discovered through thenanoidp.pluginsentry-point group, configured underplugins.<name>:. Per-hook error policy:on_before_loadmay block underhooks.strict,on_config_savedis propagated to the caller understrictafter the write (the local save is always committed and the running configuration reloaded from it, so only the mirror is behind),on_audit_eventnever propagates. Commands are never reported by/api/configor MCP (they may embed expanded secrets) and a propagated error names the hook and its source only; the bootstrap surface is the baseline forstrict/timeout_seconds,settings.yamloverrides only what it declares. Bootstrap surface for hooks that must run beforesettings.yamlexists:NANOIDP_BOOTSTRAP_HOOK/--bootstrap-hook,NANOIDP_BOOTSTRAP_PLUGINwithNANOIDP_PLUGIN_<NAME>_<KEY>settings, andbootstrap.yamlin the config directory (hooks:andplugins:only).nanoidp plugins,GET /api/configand the MCPget_settingstool report what is loaded, from which surface, with failure counters and the plugins that could not be loaded (plugins_failed: a missing package or a wronghook_api_versionis reported, never fatal unlessstrict);hooks:/plugins:are YAML-only.bootstrap.yamlgoes through the same loader assettings.yaml(placeholders, unknown-key warnings). A stricton_before_loadfailure is a JSON503onPOST /api/config/reloadand an error result from the MCPreload_configtool. Audit logging never constructs the configuration and an audit event produced inside a load is not dispatched to hooks. An unchangedhooks:/plugins:declaration is not re-applied on the refresh that follows a local write, so plugins are not re-instantiated on every save. Reference pluginexamples/plugins/nanoidp-echo; guide "Extending nanoidp: hooks and plugins". - Import contracts enforced in CI (#149):
import-linternow pins the package layering (routes -> services -> config) and the invariant thatserialization.pyhas no runtime imports from the package (it is what letsconfig.pyimport it without a cycle). Both used to live only in comments;lint-importsruns next to ruff and mypy in the Tests workflow and fails when a change adds a forbidden import. config_versionfield (#175, piece 1):settings.yamlandusers.yamlaccept a top-level integerconfig_version: 1. Absent means 1, so existing files load unchanged; a value that is not a positive integer, or newer than the running release supports, is refused at startup with a message naming the file, the value and the supported version.nanoidp initand the wizard write it into the files they create; UI/MCP saves preserve an existing key and never add one.GET /api/configand the MCPget_settingstool expose the effective value; the e2e agent asserts it. Bumps only on renames, removals or semantic changes (with a loader migration), never on optional additions.- Native-app redirect URIs (#81, RFC 8252):
/authorizeaccepts private-use scheme redirect URIs such ascom.example.app:/oauth2redirect(§7.1: a scheme and a path, no authority) as absolute URIs and applies §7.1's minimum rule to them (a non-http(s)scheme without a period, such asmyapp://, is rejected with a message naming the rule; domain ownership is not verified), and a registered loopback URI (http://127.0.0.1:{port}/...,http://[::1]:{port}/...) matches any port (§7.3), since native apps bind an ephemeral port. Everything else keeps exact string matching (RFC 6749 §3.1.2.3): scheme, host, path and query of a loopback URI, every port of a non-loopback orlocalhostregistration. Fragments are now rejected explicitly (§3.1.2). One shared matcher,services/redirect_uri.py, serves both legs of/authorize; MCP tool descriptions andexamples/test_agent.pyupdated. - Persona login mode (#156): opt-in
login.mode: personalists the configured users on every interactive login surface (/login,/authorize,/saml/ssoand the device flow's verification page) and signs in by selecting one, with no password prompt - a local development/testing convenience, off by default.User.passwordis now optional: a password-less user can only authenticate via persona-mode interactive login, never via password-mode login or the OAuth password grant. Persona-authenticated sessions emit SAMLAuthnContextClassRef: unspecifiedinstead of falsely claimingPasswordProtectedTransport. New MCP toolcreate_persona_user, apersona-loginexample preset, settings-UI persistence and e2e coverage. - Per-client login page branding: optional per-client colors (background,
header, footer, all as validated hex values), show/hide client_id and
description on the
/authorizelogin page, and per-client logo images stored locally instatic/logos/keyed by client ID (no YAML config needed for logos; place the image file and it's served automatically; the directory is overridable viaoauth.logos_dir). Colours and toggles are editable from the OAuth client form in the UI and from the MCPcreate_client/update_client/get_clienttools, descriptions are already supported, and logos are deployed by the operator to the server filesystem. Designed for demos and prototyping; colours are structured (not free-form CSS) to prevent stored-XSS on the auth UI, and logos are local files only (no remote URLs) to avoid beacons.
Fixed
- The unit suite no longer rewrites the repo's
config/files. Tests that build an app without an explicit config directory used to load the committed preset through ConfigManager's./configfallback, and any save that followed rewroteconfig/settings.yamlorusers.yamlin the checkout - twice committed by accident during review.tests/conftest.pynow pointsNANOIDP_CONFIG_DIRat a fresh copy ofconfig/for every test and resets theyaml_writersingleton alongside the others. --profileoverrides settings.yaml for every value and survives reloads (#172). An explicit--profile devcould not bring a file configured withoauth21/stricter-devback todev(the flag defaulted todev, so the code could not tell "asked for dev" from "omitted"), and any CLI profile was dropped by the first configuration reload, i.e. by the first web UI or MCP save. Worse, thestricter-devruntime hardening (require_pkce,password_hashing,rate_limit_enabled, debug off) was applied once increate_app()and silently lost on that same first reload, even when the profile came fromsettings.yamlitself. The override now lives onConfigManager(--profiledefaults to none,init_config(..., profile_override=)), the effective profile and its hardening are re-derived after every settings load, and a save serializes the DECLARED state (ConfigManager.persistable_settings()), so neither the override nor the hardening it implies is ever written into the operator's file.GET /api/configand the MCPget_settingstool exposesecurity_profile,profile_overrideand the derivedeffectivevalues; the e2e agent checks they are stable across a reload.users.yamlnow expands${VAR}/${VAR:default}placeholders likesettings.yamlalways did (#175 review). Apassword: ${ALICE_PASSWORD}used to be taken literally, so the documented "secrets kept out of the file" use case only worked for settings. A UI/MCP save of one user still rewrites only that user's entry; the MCPsave_configtool rewrites the whole map and materializes expanded placeholders, as documented.- SAML
entity_id/sso_urlfollow the effective issuer (#181). Withoauth.issuer_from_requeston (or behind a proxy), OIDC discovery reflected the request host while/saml/metadata, the<Issuer>in responses and assertions and the SSO location kept the fixedhttp://localhost:8000/...strings. Both settings are now optional: absent (or blank in the UI/MCP) means derived as<effective issuer>/samland<effective issuer>/saml/ssothrough one helper shared by every SAML surface, an explicit value still wins, and a derived value is never written back tosettings.yaml. SAML 2.0 Metadata 2.3.2 requiresentityIDto be the value used as<Issuer>(Core 2.2.5)./api/configand the MCPget_settingstool report the effective values plusentity_id_derived/sso_url_derived;update_settingsgainssaml_entity_id/saml_sso_url(empty string clears); the e2e agent checks metadata against discovery and no longer posts derived values back as explicit ones. - Example presets now bind to
127.0.0.1(#164). All four pre-2.6.0 presets (cli-device-flow,microservices-client-credentials,react-spa-pkce,spring-boot-saml) still shipped an explicithost: "0.0.0.0", overriding the loopback default introduced with GHSA-2473-px8h-rvg6 for anyone who copied them. Each now ships loopback with a commented# host: "0.0.0.0"opt-in line, matching thepersona-loginpreset and the reverse-proxy guide's framing. /api/confignow exposessaml.default_acs_url(#165). The e2e agent rebuilds the settings form from that document, so the missing field was posted back blank on every run and the "present-but-blank = clear" contract (#131) silently wipeddefault_acs_urlfromsettings.yaml.
Security
- Opt-in login gate for the config web UI: new
session.require_ui_loginsetting (off by default) makes/loginactually enforce a logged-in session on the dashboard, users, clients, settings, keys, claims, audit log and token tester pages - previously/login//logoutexisted but nothing gated on them, so the login page implied protection it didn't provide. Does not affect the separate/api/*management API, which remains unauthenticated by design regardless. YAML-only for now, following thesecret_key/security_profileprecedent. Related to the network-binding hardening in GHSA-2473-px8h-rvg6. - Opt-in removal of the invalid-bcrypt-hash plaintext fallback: new
session.enforce_password_checksetting (off by default). Whenpassword_hashingis on, ausers.yamlpassword that isn't a valid bcrypt hash previously fell back to plaintext comparison with only a warning logged - this setting removes that fallback, rejecting the login outright instead. Default behavior (the fallback) is unchanged; opt-in only. YAML-only, same treatment asrequire_ui_login.
2.6.0 - 2026-08-21
Documentation
- New guide: Running behind a TLS-terminating reverse proxy,
walking through composing
oauth.issuer,issuer_from_request,issuer_from_proxy_headers,issuer_allowlist,device_verification_base_urlandPOST /api/config/reloadfor a proxied/containerized deployment, with the security caveats inline.
Added
- First-class group support: users gain a
groupslist alongsideroles, modelled exactly the same way. It is loaded from and persisted tousers.yaml(omitted when empty), emitted as agroupsclaim on the access token and from/userinfo, requestable in the ID Token via the OIDCclaimsparameter, advertised inclaims_supported, and flattened intoauthoritiesusing the newgroupsauthority prefix (defaultGROUP_, editable on the Claims page). Groups are editable from the user form, shown on the users list and user detail pages, exposed by/api/users, and settable through the MCPcreate_user/update_usertools. Users without groups behave exactly as before: no claim, no authorities, nothing written to YAML. - Optional SAML export of roles and groups: new
saml.export_roles/saml.export_groupstoggles (both off by default, so the previous behaviour is preserved) with companionsaml.roles_attr_name/saml.groups_attr_namesettings defaulting torolesandgroups. Roles and groups are not standard SAML attributes and every SP expects a different name, so the name is configurable; blanking it restores the default. Both toggles are on the Settings page and the MCPupdate_settingstool, and apply to both the SSO assertion and the AttributeQuery endpoint, with oneAttributeValueper entry. oauth.issuer_from_request(off by default): when enabled, the discovery document'sissuer, every minted token'siss, and the device flow'sverification_uriare derived from the incoming request's own Host header instead of the fixedoauth.issuer. Lets the same NanoIDP be reachable under more than one hostname (e.g. a Docker Compose service name from other containers andlocalhostfrom the host browser) without a discovery/token issuer mismatch - each hostname advertises and issues tokens against itself. The MCPget_oidc_discovery/get_settingstools have no request of their own and always report the fixedissuer.oauth.issuer_allowlist: restrictsissuer_from_requestto a list of allowed origins (e.g.["http://localhost:8000", "http://nanoidp:9900"]). Empty (default) allows any Host header, unchanged from before; when set, a request whose Host doesn't match falls back to the fixedoauth.issuerinstead of trusting an arbitrary Host header. Settable from the Settings page and the MCPupdate_settingstool.oauth.device_verification_base_url: pins the device flow'sverification_urito a fixed, human-reachable URL (e.g.https://idp.example.com), overridingissuer_from_request's derivation for that field only - discovery'sissuerand a token'sissare unaffected. Fixes a backend/container caller of/device_authorization(e.g.Host: nanoidp:9900) otherwise leaking its own Host into a URL the end user's browser can't open. Unset by default. Settable from the Settings page and the MCPupdate_settingstool.oauth.issuer_from_proxy_headers(off by default): trustsX-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Forfrom a single reverse-proxy hop (via werkzeug'sProxyFix), soissuer_from_requestand rate-limit and audit-log client IPs see the original scheme/host/client instead of the proxy's own connection when TLS is terminated upstream. Only changes the derived issuer/iss/verification_uriwhenissuer_from_requestis also on; the rate-limit effect applies regardless. Only enable this when NanoIDP is deployed directly behind exactly one trusted proxy - these headers are otherwise spoofable by any client. Readable/settable via the Settings page and the MCPget_settings/update_settingstools; sinceProxyFixis wired at app startup, a value changed at runtime only takes effect after a restart.
Changed
- Raised the
PyJWTfloor to>=2.13.0(was>=2.8.0)./userinfoand/introspectpass a client-supplied token tojwt.decode(), and 2.8.0-2.12.1 are affected by CVE-2026-48525 (unbounded Base64URL decoding of ab64=falsedetached JWS payload, a DoS vector). - Added a CI license gate (#148): the build fails if a dependency in the redistributed closure carries a GPL/LGPL/AGPL/SSPL/EUPL license, which the project's dependency-license policy blocks from redistribution without explicit review.
- Lowered the
cryptographyfloor from>=46.0.3to>=45.0.0(#140). The previous floor came from a generic dependency bump, not a real requirement: our own API usage needs nothing newer than ~3.1. The effective minimum is set bysignxml, which importsx509.verification.ExtensionPolicy(added in cryptography 45.0.0) at load time. This unblocks installs on environments pinned to acryptographybetween 45 and 46. - Consolidated the OAuth client YAML merge logic into a single
serialization.merge_client_entry()helper, shared by the settings save path (merge_oauth_clients()) andYamlWriter.save_client()'s web UI edit path, which previously duplicated the same field-by-field merge rules. Internal cleanup, no behavior change. - Migrated the MCP server to the mcp 2.0 SDK and pinned
mcp>=2,<3. mcp 2.0 replaced the lowlevelServerdecorators (@server.list_tools()/@server.call_tool()) withon_*constructor parameters, so a fresh install resolving to 2.0 could not importnanoidp.mcp_serverat all. Handlers now take(ctx, params)and returnListToolsResult/CallToolResultinstead of relying on the SDK's removed return-value wrapping. The tool set, tool schemas, readonly mode, and the admin-secret gate are unchanged, and the stdio transport andnanoidp-mcpentry point are untouched. - Rejected and failed MCP tool calls now set
is_error: true. mcp 2.0 no longer converts a handler exception into an error-flagged result, so nanoidp builds it explicitly for every case that previously came back as a successful result whose JSON body happened to carry anerrorkey: readonly-mode and admin-secret rejections, an unknown tool name, arguments that fail schema validation (see below), and tool-level failures such as "user not found" or "client already exists". The response body is unchanged. - Tool arguments are now validated against each tool's schema before
dispatch. mcp 1.x's
@server.call_tool(validate_input=True)did this automatically; mcp 2.0'son_call_tooldoes not, so nanoidp now runs the same check itself and returns anis_error: trueresult (code: "MCP_INVALID_ARGUMENTS") instead of letting a missing required field reach the tool implementation as a bareKeyError. - Consolidated the MCP
isErrorcontract (#120): the rule is now written once as a table in themcp_servermodule docstring (a negative query answer is not a failure) and the code follows it.verify_tokenon an invalid token returns{"valid": false, "reason": ...}(waserror) so a rejected token, the tool's designed answer, is no longer flaggedis_error. A domain-failure audit entry now records the failure reason instead of only the tool name; the uncaught-exception path now carries acode(MCP_INTERNAL_ERROR) andtoollike the guard rejections; and_execute_tool's unreachable unknown-tool fallback now raises rather than returning a divergent shape. The MCP auditdetailscodes are namespaced (MCP_READONLY_MODE,MCP_ADMIN_SECRET_REQUIRED,MCP_UNKNOWN_TOOL,MCP_INVALID_ARGUMENTS), observable viaget_audit_logand/api/audit. - Precompiled MCP tool-argument validators (#121): each tool's JSON Schema
is compiled once at import (
Draft202012Validator) instead of being recompiled on everytools/call, which also surfaces a malformed schema at import time. The directjsonschemafloor is raised to>=4.20.0to match what mcp 2.0 already resolves. - MCP tests drive the real protocol (#122): the test harness now calls tools through the mcp 2.0 in-memory client (real SDK dispatch and result serialization) instead of invoking the lowlevel handlers with a fake request context, so wire-level regressions fail the suite instead of only breaking a real client.
Fixed
- Duplicate OAuth
client_ids are rejected at load (#127). Two clients that resolve to the same effective id (including two${VAR}placeholders expanding to the same value) made client lookup ambiguous and caused the save-merge to match the wrong raw entry, materializing a secret; the loader now fails fast with a clear error instead. - Import no longer crashes when package metadata is absent (#139). Running
from an uninstalled source tree (vendored, or copied into an image without
pip install) raisedPackageNotFoundError; the version now falls back to readingpyproject.toml, then to a static string. - Default admin user's
identity_classisINTERNAL, notINTERN. The no-users.yamlfallback used a typo'd class that didn't match the generated template or the default allowed classes. - Env-backed
client_idplaceholders are preserved on save (#127). When a client'sclient_idwas itself a placeholder (client_id: ${CLIENT_ID:app1}), the settings save matched the raw entry against the expanded id, missed it, and rewrote the client from expanded values - losing the placeholders and materializing the client secret; the web UI path appended a duplicate entry, anddelete_clientcould not find the client at all. Client matching now expands the placeholder before comparing (client_id_matches()), used by the settings save,save_client()anddelete_client(). - Saving settings no longer discards comments, inline
#text or${VAR:default}placeholders insettings.yaml(#127): the settings writer now round-trips the file withruamel.yaml(comments and quote style survive) and only rewrites a field when its expanded on-disk value actually differs from the new one, so an untouched${PORT:8000}-style placeholder is no longer replaced by its resolved value on the next save. Free-form text (description,client_secret,password, attribute values) is now quoted on write so an embedded#can't be mistaken for a comment. Applies to both the web UI settings form and the MCPsave_configtool. - Env-backed client secrets and empty optional placeholders are preserved when
unrelated settings change. The OAuth client merge now updates entries by
client_idfield-by-field instead of rewriting the whole list, so an unchanged${APP1_SECRET:dev}secret stays in the raw file even when a sibling client is edited. Empty optional values such as${DEVICE_URL:}are also treated as unchanged when they still expand to an empty string, instead of being popped out of the YAML on a save that changed some other field. /api/confignow exposesissuer_allowlist,device_verification_base_urlandissuer_from_proxy_headersalongsideissuer_from_request. The config-agnostic e2e agent reads the allowlist from/api/configto predict the effective issuer; without the exposure it assumed an empty allowlist and failed on any server with one configured. The agent also takes its fixed-issuer baseline from/api/config'soauth.issuernow: a plain discovery response reflects the request's own Host when the flag is on, so it is only a valid baseline when the flag is off.examples/test_agent.pySAML export check honours the configured attribute names: it now readssaml.roles_attr_name/saml.groups_attr_namefrom/api/configinstead of assuming the defaultroles/groupsnames, so it no longer fails on servers exporting under custom names.- SAML export: colliding attribute names merge instead of overwriting, and
values are passed as lists (#134). With both exports enabled and
saml_roles_attr_nameequal tosaml_groups_attr_name(e.g. bothmemberOf), the groups list silently replaced the roles list; the two are now merged into the single shared attribute, roles first, deduplicated. The AttributeQuery path also passes roles/groups (and entitlements) to the response builder as lists instead of comma-joined strings, so a legitimate comma-bearing value like"Finance, EMEA"stays oneAttributeValue, as it already did in the SSO assertion. /api/users/<username>/tokennow honoursissuer_from_request(#133): the endpoint mints real JWTs but kept using the fixedsettings.issuer, so with the flag on its tokens carried anissthat failed validation against the discovery document the same hostname had just advertised. The effective-issuer resolution (including the allowlist fallback) now lives in a shared routes helper used by discovery,/token, the device flow and the API token endpoint alike; the MCP tools remain the documented exception. Also clarified in the setting descriptions thatissuer_from_proxy_headersaffects the audit log's recorded client IP as well as the rate limiter's.POST /settingsno longer resets settings that were not on the submitted form (#131). Previously every checkbox absent from the form was stored asfalseand every absent text field was cleared, so any partial form (a stale tab, a script, the e2e agent's c14n round-trip) silently wiped unrelated configuration - observed live asissuer_from_request,issuer_from_proxy_headersand the SAML export toggles flipping off and the allowlist, device verification URL and attribute names being deleted mid-test-run. The handler now follows an "absent = unchanged" contract: text fields and textareas are only applied when present (present-but-blank still clears), and each checkbox is paired with a hidden__on_formmarker so "rendered but unchecked" (persistfalse) is distinguishable from "not on this form" (leave unchanged).
Security
- Default server bind address is now
127.0.0.1(loopback) instead of0.0.0.0(GHSA-2473-px8h-rvg6, CWE-306). The unauthenticated/api/*management API (which can mint admin tokens, rotate signing keys and clear the audit log) is a deliberate dev-tool convenience, but the previous all-interfaces default exposed it to any network-reachable host without the operator choosing to. The out-of-the-box experience is unchanged for local development (clients still reachlocalhost:8000). To expose NanoIDP on a network, setserver.host(or--host 0.0.0.0) explicitly; a startup warning is logged whenever the bind address covers all interfaces. This alignsnanoidp initwith the valuenanoidp wizardalready wrote, and the bundled Docker image is unaffected (its entrypoint already passes--host 0.0.0.0).
2.5.0 - 2026-07-19
Added
claimsparameter requests persist across token refresh (#112, OIDC Core §12.2): the claim names requested via the OIDCclaimsparameter are now persisted in the refresh token (req_id_token_claims/req_userinfo_claims, alongsidescopeandauth_time), so a refreshed ID Token keeps the requested claims and/userinfokeeps honouring theuserinfomember for the refreshed access token. Refresh tokens minted before this change carry neither claim and refresh as before. Both names are reserved: they cannot be requested via theclaimsparameter nor injected through the/tokenextraparameter. Requested-claims values are sanitized at the token service (sanitize_claim_names): a hand-crafted refresh or access token carrying a non-list value (or non-string entries) refreshes and serves/userinfocleanly instead of failing token issuance after the refresh token was consumed. A claims request deliberately survives scope narrowing on refresh (OIDC Core §5.5 is orthogonal to scope); see the token reference docs.- MCP
generate_tokengainsuserinfo_claims(#113): parity with the HTTPclaimsflow'suserinfomember; the names are stamped on the access token asreq_userinfo_claimsand honoured by/userinfo. Bothid_token_claimsanduserinfo_claimsare now validated likeadditional_audiences: a non-list value is rejected with a clean error instead of being minted into the token.
Changed
/userinforeusesresolve_user_claimfor its default claim assembly (#113): the scope-gated standard claims and the nanoidp-specific claims now come from the same resolver that backs theclaimsrequest parameter, so the two mappings cannot diverge. No behavior change.
Fixed
claimsparameter could overwrite registered ID Token claims (#110): a requested claim name that collided with a user attribute (e.g. an attribute namedaudorexp) could hijack the corresponding registered claim, becausecreate_jwtappliesextraafter setting the registered claims and thesetdefaultguard only covered the protocol claims.resolve_user_claimnow refuses reserved registered/protocol names outright (iss,sub,aud,exp,iat,nbf,jti,token_use,auth_time,at_hash,azp,nonce,scope,req_userinfo_claims), protecting both the ID Token and/userinfopaths at a single choke point.
2.4.0 - 2026-07-08
Added
scopeclaim on access tokens (#102): access tokens now advertise the granted scope (RFC 9068 §2.2.3), letting resource endpoints reason about it. Set authoritatively inTokenService.create_token, so a caller-suppliedextra_claimscannot override it.- OIDC
claimsrequest parameter (#104, OIDC Core §5.5):/authorizeaccepts aclaimsparameter to request specific claims in the ID Token (id_tokenmember) or from UserInfo (userinfomember), e.g.claims={"id_token":{"email":null}}. Requested claims are resolved from the user and added when available (voluntary form, §5.5.1); protocol claims are never overwritten and unresolvable names are skipped. Malformed input is ignored with a warning rather than failing the flow. Discovery advertisesclaims_parameter_supported: true, and the MCPgenerate_tokentool gains anid_token_claimsargument. Scoped to the authorization code grant; the requested claims are not yet persisted across a refresh.TokenService.create_tokennow stripsscope/req_userinfo_claimsfrom a caller-suppliedextrabefore setting them authoritatively, so the/tokenextraparameter can never smuggle scope-gated claims past/userinfo(closes a spoofing gap in the #102 scope handling too).
Changed
/userinfogatesemail/profileclaims by granted scope (#102, OIDC Core §5.4):email/email_verifiedrequire theemailscope andpreferred_usernamerequires theprofilescope. Enforced only under thestricter-devandoauth21profiles; the defaultdevprofile keeps returning them unconditionally, so this is not a breaking change for existing setups. nanoidp-specific claims (roles,tenant,identity_class,attributes) have no standard scope and are always returned.
2.3.0 - 2026-07-08
Added
oauth21security profile (#68): opt-in draft-OAuth-2.1 protocol strictness alongsidedevandstricter-dev: PKCE required on the authorization code flow with S256 only (draft-ietf-oauth-v2-1 §4.1.1, §7.5.2), refresh token rotation forced on (§4.3.1), the password grant removed (RFC 6749 §5.2) and absent from discovery, and registered redirect URIs mandatory at/authorize. Protocol behavior lives in derivedSettingsproperties consumed by both the routes and the shared discovery builder, so the profile means the same thing from--profileorsettings.yamland discovery can never advertise what the endpoints refuse. Deliberately orthogonal tostricter-dev(runtime hardening).- Registered redirect URIs with exact matching (#67): clients gain an
optional
redirect_urislist; when non-empty,/authorizecompares the requestedredirect_uriwith simple string comparison (RFC 6749 §3.1.2.3, OAuth 2.1 §4.1.1) and answers a mismatch with400 invalid_requestdirectly, never by redirecting to the unvalidated URI (§3.1.2.4). Exposed in the web UI, MCP client tools and YAML. - Signed AuthnRequest verification (#69): with
saml.want_authn_requests_signed: trueand PEM certificates insaml.sp_certificates, nanoidp requires and verifies AuthnRequest signatures under both bindings: the HTTP-Redirect query-string signature over the raw transmitted fragment (SAML 2.0 Bindings §3.4.4.1; rsa-sha256/rsa-sha512/legacy rsa-sha1) and the HTTP-POST envelopedds:Signature(Core §5), rejecting unsigned or invalid requests with 400, failing closed without registered certificates. The verified Redirect request is bound server-side in the session, so the inline-login leg only accepts byte-identical values. Metadata advertisesWantAuthnRequestsSigned="true"if and only if enforcement is on.examples/gen_sp_keypair.pygenerates a test SP keypair. - E2E workflow in CI (#79): every PR now boots real servers and runs
examples/test_agent.pyagainst them (default profile,--oauth21,--saml-signedwith a generated SP keypair) plus an MCP stdio smoke test (examples/mcp_smoke_test.py) driving the real transport, the regression guard for the class of bug where the stdio entrypoint crashed unnoticed because unit tests bypass it (#56). - Coverage gate in CI (#71, #72):
--cov-fail-under, introduced at 70 and ratcheted to 75 after the wizard went from 0% to 99% coverage; measured coverage 78%. The dead Codecov upload (never configured, failed silently since inception) was removed in favor of in-CI enforcement. - Documentation site: mdBook on GitHub Pages (https://cdelmonte-zg.github.io/nanoidp/) with getting-started, guides and a full reference; canonical docs are symlinked so there is a single source of truth, and the README became a landing page.
- Web UI parity (#94):
require_pkceandrefresh_token_rotationtoggles on the settings page; SP-certificates and signed-AuthnRequests fields (#69);redirect_urison the client form (#67); the dashboard badge distinguishes theoauth21profile. - MCP:
get_settingsreportssecurity_profile;update_settingscovers the SAML verification fields; client tools carryredirect_uris.
Changed
src/is fully annotated and mypy runs with a globaldisallow_untyped_defs(#70): new unannotated code fails CI.- Internal architecture (behavior-invariant, #83–#86): one shared YAML
serialization path for
ConfigManagerand the UI writer; the token endpoint dispatches to per-grant handlers with device-flow and revocation state in dedicated services (DeviceCodeStore,RevocationStore); a singleaudit_eventhelper replaced 58 duplicated audit blocks (invariance proven by a before/after snapshot harness); the Pydantic models moved tomodels.pywith compatibility re-exports. security_profileis now read fromsettings.yaml(top-level key) and round-trips on save; the CLI--profilestill wins. A YAML-declaredstricter-devnow applies its runtime hardening (previously the YAML value was silently ignored).
Fixed
ConfigManager.save()was lossy (#87): the save path behind MCPsave_configrewrotesettings.yamlfrom scratch, silently deleting every section it didn't own:jwt(external keys!),session,logginglevels,server.debugand custom keys. Saving is now read-modify-write and preserves them, atomically and with a.bakbackup like the UI path always did.
2.2.0 - 2026-06-11
Added
- The refresh_token grant now re-issues an ID Token when the original grant
included the
openidscope (OIDC Core §12.2, #39). The granted scope is persisted in the refresh token claims and recovered on refresh; ascopeform parameter may narrow, but never broaden, the original grant (RFC 6749 §6: broadening is rejected with400). The refreshed ID Token carries nononce(it binds the original authentication request). Refresh tokens minted before this change have no persisted scope and keep the old behavior. - ID Tokens now carry
auth_timeandat_hash(#42).auth_timereflects when the end-user actually authenticated: the login page for the authorization code flow, the/deviceverification for the device flow, the request itself for the password grant. It is preserved unchanged across refreshes (OIDC Core §12.2), carried in the refresh token claims like the scope.at_hashbinds the ID Token to the access token issued alongside it (left half of SHA-256, base64url, §3.1.3.6). Discoveryclaims_supportednow also advertisesauth_time,nonceandat_hash. - Optional refresh token rotation (#46): with
oauth.refresh_token_rotation: true(default off), each refresh atomically invalidates the consumed refresh token, so its reuse fails with 401; reuse of a consumed token revokes its whole rotation family, including the live descendant (RFC 9700 §4.14.2). - PKCE enforcement (#47): new
require_pkcesetting (enabled by thestricter-devprofile, persisted insettings.yaml) rejects/authorizerequests without acode_challenge;stricter-devalso rejectscode_challenge_method=plain, whether explicit or implicit via the RFC 7636 §4.3 omitted-parameter default, and discovery only advertisesS256there. Unsupported methods are rejected at the authorization endpoint (§4.4.1). Default profile unchanged. - MCP audit & key tools (#48):
get_audit_log,get_audit_stats,clear_audit_log,get_keys_infoandrotate_keysmirror the HTTP API, so agent workflows can inspect what the IdP recorded and exercise JWKS refresh handling.clear_audit_log/rotate_keyscount as mutating tools (admin secret / readonly rules apply). MCPget_settings/update_settingsexpose the newrefresh_token_rotationandrequire_pkcesettings, andgenerate_tokenaccepts an optionalscopeargument and returns anid_tokenwhenopenidis included, matching the HTTP token endpoint. - CI now lints with ruff (#45) and type-checks
src/with mypy (#55, documented gradual-adoption baseline inpyproject.toml). The codebase is lint-clean, 153 findings fixed (#49): deprecateddatetime.utcnow()replaced (removes 80 DeprecationWarnings), unused imports/variables dropped, imports sorted and moved to module level,Optional[...]type hints in the crypto service,verify_jwtaccepts an array audience, exceptions re-raised withfrom e, and mypy-clean (40 baseline errors fixed; the baseline also surfaced the brokennanoidp-mcpentrypoint below).
Fixed
- The
nanoidp-mcpstdio entrypoint crashed at startup ("a coroutine was expected"):stdio_server()is an async context manager yielding the message streams, not a coroutine. Verified with a JSON-RPC initialize handshake. - Review follow-ups of the 2026-06-11 merge block (#56):
- Refresh tokens are bound to their client: the issuing
client_idis persisted in the refresh token claims and the refresh grant rejects any other client (RFC 9700 §4.14), which also guarantees the refreshed ID Token keeps the originalaud(OIDC Core §12.2). Tokens minted before the claim existed keep working. - Rotation is atomic and revokes families on reuse: the revocation
check and the claim of the consumed token now happen in one critical
section, so two concurrent refreshes of the same token can no longer
both succeed. Each grant starts a refresh-token family (
rt_familyclaim, stable across rotations); reusing an already-consumed token revokes the whole family, including the live descendant (RFC 9700 §4.14.2). - PKCE
plaincan no longer slip through stricter-dev by omitting the method: per RFC 7636 §4.3 an absentcode_challenge_methoddefaults toplain; the method is now normalized before validation, and unknown methods are rejected at the authorization endpoint (§4.4.1). require_pkceis persisted: it is now read from and written tosettings.yaml(oauth section), soupdate_settings→save_config→reload_configno longer silently reverts it.- The token response reports the scope actually granted (RFC 6749
§5.1) instead of a hardcoded
"openid"; when no scope was involved the parameter is omitted, and a narrowed refresh reports the narrowed scope.
- Refresh tokens are bound to their client: the issuing
- The token endpoint validates
expandextrabefore the grant dispatch: with rotation enabled, a malformed value can no longer consume the refresh token without delivering its replacement (the last tradeoff noted in the #56 review). Validation is semantic, not just syntactic:extramust be a JSON object (a scalar/array used to raise aTypeError500 later) andexpmust be an integer within the same1..1440bounds the Settings model enforces (non-numeric values used to be an unhandledValueError500; astronomical ones anOverflowError500). - Thread-safety hardening for shared in-memory state (#43): the authorization code store now performs its check-then-mark sequence under a lock (one-time use can no longer be defeated by concurrent redemptions), device codes are claimed/transitioned atomically and pruned when expired, and the lazily-created service singletons (config, token, crypto, audit, auth codes) use double-checked locking so concurrent first access creates exactly one instance.
- The MCP
get_oidc_discoverytool now returns the exact same document as the HTTP/.well-known/openid-configurationendpoint (#40). Both build it via a new shared helper (services.discovery.build_discovery_document), so the MCP tool now advertisesclaims_supported(includingazp),response_types_supported,id_token_signing_alg_values_supported,code_challenge_methods_supportedand the endpoint auth methods. The two documents can no longer drift apart. - Discovery no longer advertises the
tokenresponse type (#41): the implicit flow was never implemented (/authorizeonly acceptsresponse_type=code) and is deprecated by the OAuth 2.0 Security BCP, so advertising it misled clients.response_types_supportedis now["code"].
Documentation
- The MCP tools tables in the README and
docs/MCP_WORKFLOW.mdnow list all 24 tools (#44, #48); the README was missingcreate_client,update_client,delete_client,update_user,update_settingsandsave_config.
2.1.0 - 2026-05-26
Added
- ID Tokens are now issued for the password and device (RFC 8628) grants
when
openidscope is requested, not justauthorization_code(#36). These grants authenticate an end-user, so an ID Token is meaningful;client_credentialsstill never emits one (no end-user).
Fixed
- Friendlier loading of client
additional_audiencesfromsettings.yaml(#35): a scalar value (additional_audiences: api://x) is coerced to a one-element list, and an unsupported shape (e.g. a non-string item) now fails with a clear, client-scoped error instead of an opaque PydanticValidationErrorat startup. - Minor hardening/polish from the #32 review (#37):
OAuthClientnow validates on direct attribute assignment (validate_assignment), discovery advertisesazpinclaims_supported, and the MCP_normalize_audiencesrejects falsy non-list inputs instead of silently returning an empty list.
Security
- Harden the ID Token vs access-token boundary (#34). The resource audience
(
oauth.audience) is now filtered out of the ID Tokenaudeven if a client lists it inadditional_audiences, and every token carries atoken_usemarker (access/id/refresh)./userinforejects tokens marked as ID or refresh tokens and/introspectreports ID Tokens as inactive, so an ID Token can no longer be spent as an access token. (Refresh tokens stay introspectable per RFC 7662.)
2.0.0 - 2026-05-25
Changed
- ID Token
audnow contains the requesting client'sclient_id, as required by OpenID Connect Core 1.0 §2 (was previously the staticoauth.audience). This makes it possible to test multiple clients and brings nanoidp in line with the OIDC spec.- Breaking: relying parties that validated the ID Token
audagainst the old staticoauth.audiencevalue must now expect their ownclient_id. - The access token
audis unchanged and still reflectsoauth.audience(the resource audience, per RFC 9068 §2.2).
- Breaking: relying parties that validated the ID Token
Added
additional_audiencesper-client setting: extra audiences appended to the ID Tokenaud. If this produces more than one distinct audience value,audis emitted as an array and nanoidp also emitsazpequal to theclient_id, so clients can test authorized-party handling.
1.4.0 - 2026-04-28
Added
- Environment variable substitution in
settings.yamlusing${NAME}/${NAME:default}syntax PORTenv var honoured in the Docker image via shell expansion inCMD
1.3.3 - 2026-04-22
Fixed
- Return
id_tokenin /token response for Authorization Code Flow whenopenidscope is requested, as required by OIDC Core spec (Section 3.1.3.3) - Include
nonceclaim inid_tokenwhen provided by the client
Changed
- Use
pyproject.tomlas single source of truth for version number - Remove outdated version label from Dockerfile
1.3.2 - 2026-03-27
Fixed
- Token endpoint now rejects requests when
client_idcannot be determined from either the request body or theAuthorizationheader - Token endpoint now rejects requests where
client_idin the body conflicts with the authenticated client in theAuthorizationheader
Added
- Tests for client_id mismatch and missing client_id edge cases
1.3.1 - 2026-03-26
Fixed
- Allow authorization code flow without
Authorizationheader for PKCE public clients (RFC 6749 §2.1)- Libraries like authlib send
client_idin the request body instead of the header when no client secret exists - Auth header validation is now only enforced for grant types other than
authorization_code
- Libraries like authlib send
Added
- Test for PKCE plain flow without auth header (
test_pkce_plain_flow_no_auth_header)
1.3.0 - 2026-03-25
Added
- GitHub Actions workflow to build and publish Docker images to GitHub Container Registry (GHCR)
- Triggered on version tags (
v*), builds multi-platform images (linux/amd64,linux/arm64) latesttag published only for non-prerelease versions
- Triggered on version tags (
- Docker usage instructions in README (
docker pullanddocker runexamples)
Changed
- Dockerfile healthcheck switched from Python
urllibtocurlfor Podman compatibility and reduced overhead - Updated
actions/checkoutfrom v4 to v6 in publish workflow
1.2.3 - 2026-03-03
Fixed
- Dockerfile and docker-compose.yml: replaced
curlwith Python'surllibfor healthcheck: avoids addingcurlas a system dependency in the image
Docs
- Added mascotte/logo images to the project
1.2.2 - 2026-01-19
Added
- New
strict_saml_bindingsetting to enforce SAML 2.0 binding compliance- When
false(default): lenient mode accepts GET with uncompressed data (useful for debugging) - When
true: strict mode rejects non-compliant requests per SAML spec
- When
- Setting exposed in UI (Settings page), REST API (
/api/config), and MCP server - Exclusive C14N (
exc_c14n) is now the default XML canonicalization algorithm- Standard for SAML 2.0 signatures, handles namespace isolation correctly
- Available algorithms:
exc_c14n(Exclusive C14N 1.0, default),c14n(C14N 1.0),c14n11(C14N 1.1)
- UI select dropdown for C14N algorithm in Settings page
strict_saml_bindingandverbose_loggingnow persist correctly on save/reload- Comprehensive E2E test coverage for all SAML flows in
test_agent.py:test_saml_metadata_bindings- verifies both HTTP-POST and HTTP-Redirect advertisedtest_saml_sso_post_binding- SP-initiated SSO with HTTP-POST (InResponseTo verification)test_saml_sso_redirect_binding- SP-initiated SSO with HTTP-Redirect (InResponseTo verification)test_saml_idp_initiated_not_supported- documents IdP-initiated SSO is not supportedtest_saml_strict_binding_mode- tests strict/lenient binding behaviortest_saml_attribute_query_verification- verifies actual attributes returned
- Unit tests for inline login flow (
test_inline_login_flow_preserves_post/redirect_binding) - Unit test for strict mode + inline login (
test_strict_mode_inline_login_preserves_redirect_binding) - Unit test for Exclusive C14N configuration (
test_c14n_algorithm_configurable_to_exclusive)
Fixed
- SAML SSO now correctly handles both HTTP-POST and HTTP-Redirect bindings
- Parser always tries DEFLATE decompression first, falls back to raw XML (handles all edge cases)
- Strict mode now works with inline login by passing original HTTP verb via hidden field
- Fixes: GET compressed → login form → POST would fail in strict mode
- Stateless: no server-side session needed, works in CI/CD pipelines
- Explicit
|eescape filter in login template hidden fields (XSS defense-in-depth) - Normalized
original_verbhandling (uppercase, validated to GET/POST) - Quick-fill username buttons use
tojsonfilter to handle special characters safely
1.2.1 - 2026-01-16
Fixed
- SAML SSO now correctly handles HTTP-POST binding (uncompressed SAMLRequest)
- Previously,
_parse_saml_requestunconditionally attempted DEFLATE decompression, causing parsing to fail for POST requests - Now uses HTTP method to determine binding type: GET = HTTP-Redirect (compressed), POST = HTTP-POST (uncompressed)
Changed
- E2E test agent now verifies actual SAML parsing (InResponseTo matching) instead of just endpoint availability
- Added separate tests for HTTP-POST and HTTP-Redirect bindings in
test_agent.py
Changed (Architecture)
- Inline login for SAML SSO:
/saml/ssonow shows login form directly instead of redirecting to/login- This preserves SAML binding context naturally (no redirect = no method change)
- Follows the pattern used by Keycloak and other IdPs
- Removes the complex edge cases caused by redirect-based login
/loginendpoint simplified - now only used for direct web UI access, not SAML flows- Login form now posts to current URL (no hardcoded action) - works for both
/loginand/saml/sso
Changed
- SAML metadata now advertises both HTTP-POST and HTTP-Redirect bindings for SingleSignOnService
- Audit stats now track SAML SSO and Attribute Query separately (
saml_sso_requests,saml_attribute_queries) - Dashboard shows combined SAML total with SSO/AttrQuery breakdown
- E2E test agent expanded to 35 tests (was 28), now covering all SAML flows with parsing verification
1.2.0 - 2026-01-14
Added
- Configurable
verbose_loggingsetting to control sensitive data in logs verbose_loggingexposed in MCPget_settingsandupdate_settingstoolslogging.verbose_loggingexposed in REST API/api/configendpoint- MCP tests (
tests/test_mcp.py) with 8 tests for MCP functionality - Verbose logging test in E2E test agent
Changed
- Replaced deprecated
defusedxml.lxmlwith native lxml secure parser for XXE protection - Added
html.escapefor XSS prevention in SAML responses - Audit logging now respects
verbose_loggingsetting (usernames/client_ids only when enabled)
Security
- XXE (XML External Entity) protection using secure lxml parser configuration
- XSS prevention in SAML response forms
- Configurable sensitive data logging (verbose_logging defaults to true for dev convenience)
1.1.1 - 2026-01-14
Added
- Configurable XML canonicalization algorithm via
saml.c14n_algorithmsetting
1.1.0 - 2026-01-14
Added
- Configurable SAML response signing via
saml.sign_responsessetting - UI toggle for SAML signing in Settings page (
/settings) sign_responsesexposed in/api/configendpoint- Test agent (
examples/test_agent.py) for comprehensive endpoint testing
Changed
- SAML SSO and AttributeQuery endpoints now respect
sign_responsesconfiguration - Changed default XML canonicalization to C14N 1.0 for maximum compatibility
- Updated documentation with SAML signing configuration instructions
1.0.0 - 2025-12-04
Added
- Initial release
- OAuth2/OIDC support (Authorization Code, Password, Client Credentials, Refresh Token, Device Flow)
- PKCE support (S256 and plain methods)
- Token Introspection (RFC 7662) and Revocation (RFC 7009)
- OIDC Logout / End Session endpoint
- Device Authorization Grant (RFC 8628)
- SAML 2.0 SSO and AttributeQuery endpoints with signed assertions
- MCP Server integration for Claude Code
- Web UI for configuration (users, clients, settings, keys, audit log)
- YAML-based configuration
- Attribute-based access control with configurable authority prefixes
- Audit logging
- Docker support
- Security profiles (
devandstricter-dev) - Key rotation with JWKS support for multiple keys
- External key import support