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.
3.4.0 - 2026-09-30
Migration notes
This release stays a minor. Its largest addition, a runtime store that
several NanoIDP processes on one host share, is opt-in (runtime.store: sqlite) and changes nothing for a configuration that does not ask for
it. What changes for an existing deployment is a set of corrections:
tokens, assertions and settings that said or did something other than
what the specifications and the documentation promised. What may need a
hand on upgrade is collected here, with the details under the entries
named.
- A client credentials token is the client's own (#445, under
Changed). It was issued for the
default_userofusers.yaml, with that user'ssub, roles, groups, tenant and attributes; nowsubis theclient_idand the token carries no user claims. Migration: a resource server that read roles from these tokens sees none now: authorise onscopeand identify the caller bysuborclient_id.default_useris deprecated (under Deprecated): a file that carries it still loads, with a warning. extraadds claims to an access token and changes nothing the grant decided (#451, under Security). A request that names a registered claim, a protocol claim, a claim the server reads back or a claim about the user is refused withinvalid_request. Migration: a test that setrolesorexpthroughextragets a400now; create a user with the roles wanted instead, and use the form parameterexp(a lifetime in minutes) for the expiry.- A SAML assertion's
Audienceis the requesting service provider (#443, under Fixed), the AuthnRequest'sIssuer, where it wasoauth.audiencefor every provider. Migration: a provider whose entity ID had been set tooauth.audienceto make the check pass can use its own again; a request that names noIssueris answered as before. - Four settings keys that were accepted and ignored now take effect,
two are reported as unknown (#441, under Added). A
settings.yamlthat carriesoauth.refresh_token_expiry_minutes,device_flow.code_expiry_seconds,device_flow.polling_intervalorcors_allowed_originsstarts applying them on upgrade, and a value the loader used to accept and drop is now validated:"soon"as a lifetime, a bound exceeded, a blank entry or a pattern such ashttp://localhost:*in the origins fail the load.logging.formatandsession.permanentare unknown keys, a warning by default and a refusal underconfig_validation: strict. Migration: runnanoidp validate-configagainst the directory before upgrading, and check that acors_allowed_originslist names the origins meant (they apply in every profile now). A revoked refresh token family is remembered for 31 days, where it was 8. - A token minted before a key rotation stays valid after it, and a
rotation has a shape (#420, under Changed). Verification is by
kidamong the active key and the previous ones kept, as the documentation always said; the private key is written with mode0600;POST /api/keys/rotateand the MCProtate_keystool refuse with a fixed message and akind,Retry-Afteronly on the retryablelock_timeout503, not onlock_unsupported. Migration: a test that expected the refresh grant or/userinfoto fail right after a rotation expects the opposite now; a client that matched on the refusal's text matches onkind. - For code that embeds nanoidp, the runtime state moved behind one
store boundary and some names went with it (under Changed):
services/runtime_identities.pyisservices/runtime_store.py,MemoryRuntimeIdentityStoreisMemoryRuntimeStoreandget_runtime_identity_store()isget_runtime_store(), with no alias;AuditLog()no longer takesmax_entries;get_auth_code_store()returns a view each time;DeviceCodeGranttakes itsdevice_codeas a required keyword;IdentityResolver.runtime_client_lifecycle()is removed andcreate_runtime_client_entry()returns the stored entry with itsinstance_id;repository(name, key_of, codec)takes the codec;services.activate_servicesis the activation both the app and the MCP server pass toinit_config.create_app()configures the process and is not a factory of independent applications, which the docstring and CONTRIBUTING now state (#407).
Added
-
Several NanoIDP processes on one host can share one runtime state (#354, with #363, #404, #405, #420 and #406 behind it):
runtime.store: sqlitewithruntime.pathinsettings.yaml, for HTTP workers and a separatenanoidp-mcpprocess alike. Memory stays the default and behaves as before. The guide Two processes, one runtime state says when a second process is worth it and what is promised; theshared-store-e2ejob starts two servers over one configuration directory and one store and asserts through real HTTP that a runtime user created at one logs in at the other, that a code issued by one is redeemed at the other and refused the second time at both, that a token revoked at one is refused by the other, that a promotion made at one is declared for the other, and that the audit of both reads as one.- What the store holds. Runtime users and clients, authorization transactions and codes, device codes, pending second factors, the client metadata cache, refresh families and revocations, dynamic registration records and the audit, all behind one runtime store boundary (#363, #404); the browser leg too, since the login session is a signed cookie every process accepts. What stays each process's own: the rate limiter's counters, the metadata fetch budget, plugin dispatch.
- Choosing it.
pathis required withsqliteand refused withmemory; a relative one is relative to the configuration directory, not to the working directory, since it is the identity of a store the processes share, and one starting with~is refused for the same reason. None of the store's files may lie in the configuration directory. The store is chosen when the process starts: a reload that asks for another store or another file is refused (422, kindactivation).GET /api/configand MCPget_settingsreportruntime.storeand the file the process opened. Audit events recorded before the activation are not carried into a SQLite store, and a warning says how many. - The files. A pair,
runtime.dbandruntime-audit.db(the audit does not wait for the repositories, and a SQLite file has one writer), plus the owners' leases underruntime-owners/, all private (0600, the directory0700); a file that is not a NanoIDP runtime store, or one of another schema version, is refused and left as it was; SQLite 3.24 or later is required. WAL withsynchronous=NORMAL: atomicity, consistency and isolation, and only a commit's survival of a power loss given up, which a disposable file never promised. One connection per thread and none across a fork, with an at-fork hook that closes every connection first, so a pre-fork server started through Python (gunicorn--preload) can share the store. Processes creating one store together all get it. A store held by another process for longer than it waits is503runtime_store_unavailablewithRetry-Afterover HTTP andMCP_RUNTIME_STORE_UNAVAILABLEwithretryable: truein MCP, the first call included. - The configuration files are the truth the processes agree on.
When the store is shared, every request (
/health,/api/healthand static files excepted) and every MCP tool call looks at the two files first: astatis the fast negative, the revision of their bytes the answer, and files that changed are reloaded before the operation reads anything. One check at a time does that; the others wait for it up to 0.5 s and use what it established if it finishes, otherwise they answer503configuration_unavailable(freshness_in_progress,Retry-After: 1). The check itself may wait for the configuration directory's lock, up to 10 s. Files that changed and do not load leave the loaded configuration in force and are said once in the log; a creation of a runtime user or client checks its name against the files under the directory lock every writer of nanoidp takes, and against files that do not load it is refused (503configuration_unloadable, orconfiguration_unavailablewithRetry-Afterwhen what failed is outside the bytes). An editor that does not take the lock is noticed at the next operation, and nothing linearizable is promised against it. - A promotion whose writer died is recovered, and only then (#405).
A promotion's claim on its runtime object is a hold in the store, seen
from any process, and names its owner, which holds an exclusive OS
lock on a lease file for as long as it lives. A peer that can take
that lock, or finds the lease gone, has proved the owner dead; nothing
else is a proof, and a claim whose owner is alive is never recovered,
however old. The recovery is audited as
runtime_identity_promotion_recoveredwith its outcome, never as a promotion. With the in-memory store claims have no owner and nothing changes. - Measured, median per call, memory to SQLite on ext4: a login 0.27 to 0.34 ms, a password grant 73 to 74 ms (the same signing work in both), creating an authorization code 15 to 66 us, redeeming it 17 to 51 us, an audit append 2.3 to 20 us; four processes appending together reach 13,000 appends a second. The stat of the two configuration files costs 3.6 us, reading and hashing them 18 us.
-
Refresh token lifetime, device code timing and CORS origins can be set (#441).
oauth.refresh_token_expiry_minutes(default 10080, 7 days, the lifetime that was hardcoded; 1 to 43200),device_flow.code_expiry_seconds(default 600; 1 to 3600) anddevice_flow.polling_interval(default 5; 1 to 60) now set what they name, andcors_allowed_originssets the CORS origins in every profile (absent: the profile decides, as before;[]allows none). Each entry is an origin,http(s)://host[:port]and nothing else, written as a browser sends it: IPv6 in the URL Standard's form, IPv4 in dotted decimal, no default port, an internationalised host in its ASCII form; an entry in another form is refused with the form to write, and a path, a query, credentials or a pattern are refused as not an origin. A declared list answers only requests that carry an Origin header.GET /api/configand MCPget_settingsreport all four, and/api/configalso reportscors_applied_origins, the origins the server applies, since CORS is set up at startup; a"*"understricter-devis logged as a warning. The settings page edits the refresh token lifetime, MCPupdate_settingsthat and the two device flow values. A shortdevice_flow.code_expiry_secondsmakesexpired_tokentestable in a second, a short refresh lifetime an expired refresh token. A save from the web UI or the MCP server writes them only while they differ from the default. Noconfig_versionbump: none of the six keys loses a meaning it had. -
Four use-case pages, each with an example that CI runs.
- Test a CLI login with the device authorization
flow:
examples/cli-device-flowhas the CLI's side of the grant (cli_login.py) and tests that run its polling while a scripted browser step approves or denies, including the rotation and reuse detection of refresh tokens. The preset's CLI is a public client, where it held a client secret it cannot keep. - Test service-to-service auth with client
credentials:
examples/microservices-client-credentialsgives each client its own scopes and API audience (RFC 8707) and carries a Spring Boot 4 resource server (inventory-api/) with tests of the calls it accepts and refuses. The preset's README requested scopes the preset did not declare, and its tokens carriedadmin's identity throughdefault_user(#445). - Test a Spring Boot SAML service provider without a real
IdP:
examples/spring-boot-samlcarries a Spring Boot 4 SP (sp/) and tests that drive its SAML login through NanoIDP without a browser. The preset's README described single logout, which NanoIDP does not implement, and attributes the assertion does not carry; writing the page found #443. - Run a real OIDC provider in CI with GitHub
Actions:
examples/ci-github-actionssets up NanoIDP inside a job, from PyPI or as a service container, with a readiness check that refuses a port another process holds, a fixture that gives each test its own runtime user, and tests that pin what deleting a user does not revoke; the workflow runs it against the checkout and against the published image.
- Test a CLI login with the device authorization
flow:
Deprecated
default_userinusers.yaml(#445). It named the user a client credentials token was issued for, and nothing reads it any more. A file that carries it loads, instrictmode too, with a warning that it can be removed;validate-configreports it asinfo, which--strictdoes not fail on;nanoidp init, the wizard and the shipped presets no longer write it, a save no longer manages it, MCPlist_usersno longer reports it, and the JSON schema marks it deprecated.
Changed
-
A client credentials token is the client's own (#445). It was issued for the
default_userofusers.yaml, as if that user had logged in:subwas the user's name and the token carried the user'sroles,authorities,tenant,identity_class,entitlements,groups,source_aclandattributes; with the shipped configuration every service's token saidsub: adminwithROLE_ADMIN, and/userinfoanswered such a token with the admin's profile. Nowsubis theclient_id(RFC 9068 §2.2) and the token carries no user claims;/userinfoanswers a token whosesubequals itsclient_idwith the subject alone, and/introspectnames nousernamefor it, and noscopewhen none was requested, even when a user has the client's name, which is reported with a warning at load and on the runtime API. -
An operation reads one configuration, the one it began with (#406). A load published what it had read as eleven separate assignments, and a request read the manager again at each use, so a load landing between two reads gave an operation a configuration that never existed: measured at
/token, where a load between the read ofdefault_userand the lookup of that user minted for a subject neither configuration named, in a token that verifies. A loaded configuration is now one frozen value, assigned once; an externally visible operation chooses one published configuration right after freshness is established (an HTTP request in the hook that establishes it, an MCP tool call at the start of the call) and carries that reference through the identity resolver, the token service, the management gate, the declared clients, the issuer and the expiry it defaults to, the SAML builders, the web UI's pages and the listings of users and clients. The signing service stays outside the value and keeps its own order (#359): a request may pair older settings with the newer service, never the reverse. What stays deliberately current says so where it is: whether the client-metadata and dynamic-registration capabilities are offered at all, and the check that refuses a runtime name the files declare right now. A contract changes with it: a lookup, or a listing, that spans a load which declares a name and reconciles its runtime object away could once find one of the two, because both sides were read live; the declared side is now the operation's, with no fallback to the current declaration, since "neither has it" does not establish that a reconciliation happened. Such an operation answers that the name is unknown, or leaves it out of the listing; the operation after it reads the new declaration. Continuity across a reconciliation is tracked in #435. -
The protocol state lives in the runtime store, behind a contract for operations that must be whole (#363, #404).
AuthCodeStore,DeviceCodeStore,RevocationStoreandAuditLogkept dictionaries and locks of their own; each is now a view, with no state, over repositories the runtime store lends, and the authorization transactions, the pending second factors and the client metadata cache keep their operations whole through the repository rather than through a module lock, so that they hold for whoever shares the store and not only for the threads of one process.services/runtime_repository.pystates the contract: each object is kept in an entry with aninstance_idthe store generates and never reuses, so that an object and the one created under its name afterwards can be told apart, an optional hold, and anexpires_atthat means "removable by a cleanup" and nothing else;transact(decide)runs one decision against a view of one repository, and its changes become visible together or not at all;replace,consume,delete_ifandcreate_withinare written once on top of it. The suite runs every decision twice and takes every stored value through its codec and JSON and back. Deliberate corrections on the way:- the audit reads in the order events were recorded, where it sorted
by timestamp; a negative
limitis none at all, where-1dropped the last event; audit state is by value; the bound is the backend's (audit_store.MAX_AUDIT_ENTRIES) and everyAuditLog()is a view of the one audit. The details of an event are a JSON object. - a user code that is already taken is refused and another pair is
made, where the second grant silently took the code over; when no
pair can be made,
/device_authorizationanswers503withRetry-After, as for a full store. A grant past its time still answersexpired_tokenfor as long as it is there. - an expiry that is no time is remembered for ever (a NaN was
remembered until the next sweep, a number too large for a float raised
out of
/revoke); a revocation id that is not text is refused instead of becoming the text of itself, so a"jti": 5in a logout hint revokes nothing rather than the token"5". Revoking again never shortens. - a PKCE verifier that is not ASCII is the
invalid_grantany wrong verifier gets, where it was a500; a list given asamris kept as a tuple, so that a code written down by a backend reads back equal. - a cached metadata document that is fetched again keeps its place in the listing instead of moving to the end.
- dynamic registration rests on instance identity, not on a lock: see Security (#403).
- a promotion's outcome is kept with the object, not in the process
that started it (#405): the claim is a hold on the object's entry, so
a delete, a reset and a second promotion see it from any process,
whoever retires the object records the one
runtime_identity_promoted, and a name declared by somebody else while the object is claimed makes the promotion answer409, as before. - a cleanup of the in-memory repository costs what is due, not what is kept (#417): a lazy heap of the expiries, so that a write at ten thousand entries with one coming due costs 130 to 150 us where it cost 460 to 540, and a revocation or a refresh claim at ten thousand markers 0.055 ms.
- the audit reads in the order events were recorded, where it sorted
by timestamp; a negative
-
Generated signing keys are one bundle for every process that shares the keys directory, and a token is verified against the key its
kidnames (#420). Measured before: two processes cold-starting on one emptyjwt.keys_dirended with different signing keys, twelve times out of twelve; a process killed in the middle of a rotation leftkid.txtnaming one key over the private key of another; after one process rotated, it and a running peer refused each other's tokens for as long as the peer ran; and in a single process a token minted before a rotation was refused after it,verify_jwtdecoding against the active key whatever the token named, while the documentation said "old key stays valid for verification". Now a lock across processes covers a cold start and a rotation; a rotation is a small journaled transaction in the directory, withkid.txtas the commit point and a recovery for what a dead process left in.rotation/; the keys of a service are one value, replaced whole; verification is bykidamong the active key and the previous ones kept, no wider thanjwt.max_previous_keysand so the same set the JWKS shows, akidthat is nobody's being an invalid token and a token with nokidchecked against the active key, as before; external keys verify with their one key whateverkida token names. A peer's rotation is noticed inget_crypto_service(), through astatofkid.txt(about two microseconds when nothing changed) and a load of the bundle under the directory's lock only when the marker names another key. A keys directory this process cannot write still boots, and a rotation there is refused. The private key is written with mode0600(it followed the umask). The layout and the file names are unchanged. -
POST /api/keys/rotateand the MCProtate_keystool refuse with a fixed message and akind; the web UI's Regenerate keys shows the same fixed message:external_keys_not_rotatable(409, as before, the kind is new);keys_directory_not_writable, orlock_namespace_unavailablewhen the lock file cannot even exist, for a keys directory this process cannot write (409);lock_timeoutorlock_unsupportedfor a lock that could not be taken (503). The body used to carry the exception's text, which names the keys directory; that now goes to the server log.Retry-Afteron the503is sent forlock_timeoutonly, which a retry may help (CodeQL alerts 32 and 33). -
The
503s for a configuration or a store that cannot be used right now say so in fixed words.configuration_unavailable,configuration_unloadableandruntime_store_unavailablecarried the exception's text inerror_description: the configuration directory, the file that does not load with the reason, or the store's failure. The text now goes to the server log, where it already was, and the body carries a fixed sentence per error.error, status codes andRetry-Afterare unchanged. -
A process that finds the configuration directory's lock taken tries again after 1 ms, then 2, 4, up to 50 ms between tries (#426, point 3). It waited a fixed 50 ms after every miss, so a request that met a 7 ms write, or a write that met a 0.1 ms freshness read, paid the poll and not the section: measured on one Linux host, the p99 latency of a token request under two file writes a second was 82 ms against 31 ms at rest, and 157 ms under twenty; with the doubling pause, 45 ms and 59 ms, the p99 at rest unchanged. The "Waiting for the write lock" warning is logged once the pauses have reached 50 ms, not on the first miss. The 10 s deadline, the 0.5 s wait of the other requests and the error shapes are unchanged; the keys directory's lock is the same primitive. The measurements, and the policies decided against on their basis (a shorter reader timeout, a cooldown after a timeout, a backoff shared between processes), are recorded in #426.
-
The
react-spa-pkcepreset declares a real public client.spa-clientcarried a placeholder secret from before public clients existed (#188); it is nowtoken_endpoint_auth_method: "none"with its redirect URIs registered, so PKCES256is required and/authorizematches the redirect URI exactly. The access token'saudisspa-api, the API the SPA calls. Its walkthrough is a new page of the book, Test an SPA login with Authorization Code and PKCE.
Fixed
- A token response is never stored by a cache (#462). No response of
/tokencarriedCache-Control: no-storeorPragma: no-cache, which RFC 6749 §5.1 requires on every response carrying tokens, credentials or other sensitive information (the OpenID conformance suite'soidcc-refresh-tokenfailed on it). Every response returned by the views of/token,/device_authorization,/userinfo,/introspectandPOST /api/users/{username}/tokennow carries both, success and error alike (a 405, a 429 or a 503 the endpoint did not build keeps its own headers); the two registration responses that carry credentials and the second-factor screens, which already saidno-store, sayPragma: no-cachetoo. The discovery documents and the JWKS stay cacheable. - Discovery publishes
subject_types_supported(#464). The member is REQUIRED by OpenID Connect Discovery 1.0 section 3 and was the only one of the seven missing; the value is["public"], the one kind of subject NanoIDP issues (subis the username, or theclient_idfor client credentials). The MCPget_oidc_discoverytool serves the same document. - A SAML assertion's
Audienceis the requesting service provider (#443). Every SSO assertion carriedoauth.audience, whichever service provider asked, where the Web Browser SSO profile requires the provider's own identifier (SAML Profiles §4.1.4.2); a provider that checks the audience, Spring Security among them, rejected the response unless its entity ID happened to equal that setting. TheAudienceis now the AuthnRequest'sIssuer; a request that names none, outside the profile, is answered as before withoauth.audience. The audit event records the Issuer received and the Audience issued. stricter-devCORS is localhost, and nothing that starts like it (#441 review). The profile's default origins werehttp://localhost:*andhttp://127.0.0.1:*, which flask-cors reads as regular expressions and matches from the start only:http://localhost.evil.test,http://127.0.0.1.nip.ioandhttp://127a0b0c1.testwere allowed too. The patterns are now anchored at both ends, with the dots escaped.- The device verification page says "denied" when the user denies.
After Deny,
/deviceshowed "Device authorization denied" inside the success box, followed by the authorization's text. The route now passes the decision itself, and the page shows each with its own heading, icon and text. The polling answer was always right (access_denied); only the page was wrong.
Security
extracan no longer change what the grant decided (#451). Theextraparameter of/tokenwas merged into the access token after the registered claims on every grant except client credentials: a client could mint itself a token with anothersub,iss,aud, anexpin 2100, ajtiof its choosing, otherrolesortenant, and/introspectreported it active for that subject. The MCP toolgenerate_token, unbound, could add aclient_idno binding decided. Nowextraadds claims and nothing else: a request that names a registered claim, a protocol claim (client_idincluded), a claim the server reads back or a claim about the user is refused withinvalid_requestand the names, before the grant runs, so no code or refresh token is consumed; the token service strips the same set for any caller that reaches it directly.extraandexpare documented in the tokens reference.- An access token can no longer be spent as a refresh token (found in
#445's review). The refresh grant recognised a refresh token by its
token_typeclaim, whichextracould set on any access token: a client could stamptoken_type: refreshon its own access token and spend it for a new access token and a refresh token; with the client credentials grant of 3.3.0 and earlier that turned a service's token into the default user's, admin in the shipped configuration. The refresh grant now also requirestoken_use: refresh, which the server sets last on every token it issues andextracannot change. - A registration credential no longer reads or deletes a client recreated
under its id (#403). A dynamically registered client is two records,
the runtime client and the RFC 7592 credential that manages it, linked
by name, and a runtime client created under the same id at the wrong
moment was taken for the registered one:
GET /register/<id>with the first client's token answered with the second client'sclient_secret, orDELETE /register/<id>removed it. The record now carries the identity the runtime store gave its client (#404), which no later client of that id has, so a record about a client that is gone matches nothing;POST /registerlooks back at its client once the record is there and refuses, removing what it created, if a reset or a delete got in between (400, please retry) or a concurrent registration took the last slot (429). It needs a second actor reusing a server-generated id, so it was unlikely; it was reproduced deterministically for every window. No endpoint changes shape.
3.3.0 - 2026-09-19
Migration notes
This release stays a minor. The new protocol surfaces (/api/runtime,
dynamic client registration, client ID metadata documents) are additive, and
the last two are off until the file enables them. What changes for an
existing deployment is a set of corrections: answers that were wrong, silent
or unclassified are now refusals with a name. What may need a hand on
upgrade is collected here, with the details under the entries named.
- Rotate client secrets that a reader could see (details under
Security). Up to this release the clients page showed the first 8 and
the last 4 characters of every client secret, which is all of a secret
of 12 characters or fewer. Migration: on an instance whose web UI was
reachable by someone who was not meant to act as its clients (with
require_ui_loginon, that is anyone holding a user account), rotate the confidential clients' secrets after upgrading. An instance on loopback used by its operator alone needs nothing. jwt.external_keysandjwt.max_previous_keysnow take effect (#358). Both were documented and silently ignored. Asettings.yamlthat already carries them starts signing with the operator's key on upgrade: thekidand the JWKS change, tokens signed by the previously generated key stop verifying,POST /api/keys/rotateanswers409, and key files that are missing, malformed or not a pair reject the configuration. Migration: if the file names external keys, check that they are the ones you mean to sign with before upgrading, or remove the block to keep the generated keys.saml.c14n_algorithmis a closed set (#297). An unknown value used to sign silently with Exclusive C14N; it now fails the configuration load. Blank still means the default. Migration: runnanoidp validate-configagainst the directory before upgrading.- A rejected reload answers a JSON
422(#359) where it used to answer Flask's HTML 500, and a configuration whose signing service cannot be built is no longer activated with a 200. A script that reloads and checks only for 200 keeps working; one that matched on 500 needs the new status. At startup the same conditions exit 1 witherror: configuration rejected: ...instead of a traceback. validate-confighas a third exit code (#246): 2,UNAVAILABLE, when the directory could not be observed at all. 0 and 1 mean what they meant. A CI gate that fails on any non-zero status is unaffected; one that tests for exactly 1 should decide what a run that validated nothing means to it. A request that cannot observe the configuration answers503with the classified reason.claims_supportedno longer listssource_aclandauthorities(#316). Neither was ever supplied in an ID Token or by/userinfo; they are authorization facts of the access token, which is unchanged.- MCP arguments are checked against the domain models before dispatch
(#297). A value the model refuses, such as an empty name or
token_expiry_minutes: 0, is now a dispatch refusal (MCP_INVALID_ARGUMENTS);update_settingsused to apply some of them and report success. POST /authorizewithouttransaction_id(#346) still works while exactly one request is pending in that cookie jar. With several pending it is refused, where it used to complete whichever GET came last. Migration: a script that opens several authorization requests in one cookie jar posts thetransaction_idthe login page carries. Browser flows are unaffected.- For code embedding nanoidp as a library (#230, #235, #359):
TokenService()needs the manager,TokenService(config);nanoidp.mcp_server._configno longer exists;get_crypto_service()takes no argument andinit_crypto_serviceis replaced byactivate_crypto_service, passed asinit_config(..., activate=...);ConfigManager.authenticate,interactive_authenticateandcheck_clientmoved toIdentityResolver(identities_for(config)); a failed load raisesnanoidp.config.ConfigurationRejected, aValueError. These are internal Python entry points that no guide or reference documents, which is why they do not make the release a major.
Added
- Disposable runtime users and clients:
/api/runtime(#192). A CI job or an integration test creates users and clients on a running IdP, uses them in every protocol flow, and removes them without touchingusers.yamlorsettings.yaml:POST /api/runtime/usersand/api/runtime/clients(the body is a declared entry, validated by the same models),GETandDELETEper object,DELETE /api/runtimefor all of them (answers the counts), andPOST .../promoteto write one into the declared file through the web UI's writer and retire the runtime copy. There is no update. A name the configuration declares answers409; a reload that declares a runtime object's name removes it with a warning and an audit event; a promotion records exactly oneruntime_identity_promotedevent, holds reloads off while it runs, and a promotion whose entry reached the file but whose reload failed resolves on the next successful load (promoted, or abandoned with a warning). Runtime objects survive reloads, not restarts.GET /api/users,GET /api/users/{username}, the token endpoint, the persona picker and the web UI's users and clients pages now show the effective identities, runtime ones marked with their origin and read-only in the UI; the dashboard counts them separately;GET /api/configand the MCP server stay on the declared configuration. Writes follow themanagement_secretgate of/api. See the new guide, "Disposable test identities". - Dynamic client registration (#190), RFC 7591 with the read and delete
of RFC 7592, behind
oauth.dynamic_registration.enabled(off by default).POST /registerissues a client from the metadata a host sends and is open when enabled: the flag is the gate, notmanagement_secret, because a client that was handed only a server URL has nothing else to present. A registered client is a runtime client (#235): in memory, listed byGET /api/runtime/clientswith"source": "dcr", gone on restart, and written tosettings.yamlonly if an operator promotes it through/api/runtime- registering never writes the operator's file. The registration access token is shown once and stored only as a hash; promotion, deletion or a reload that declares the name ends RFC 7592 management of that client.oauth.dynamic_registration.max_clients(default 100) bounds live registrations and answers429 registration_limit_reached, a nanoidp name, since RFC 7591's error codes describe metadata.grant_typesare validated and echoed but do not restrict the client: nanoidp has no per-client grant enforcement, which is also whyredirect_urisare required for every registration and not only for the authorization code grant. Withrate_limit_enabled, the rate configured for/tokenapplies to/registeras well. The flag is deliberately absent from the settings form and from the MCPupdate_settingstool. - RFC 8414 authorization server metadata (#190).
/.well-known/oauth-authorization-serverserves the same document as/.well-known/openid-configuration, from the same builder and the same issuer resolution, soissuer_from_requestapplies to both and the two cannot drift. nanoidp is one server advertising one set of endpoints; a client that speaks only OAuth looks under this name and used to get a 404 and a longer route to the same answer.registration_endpointis advertised only while dynamic registration is enabled, so the metadata never promises an endpoint that answers 404. - A client can come from a metadata document it publishes (#196, first
part):
IdentityResolverresolves a third origin,cimd, after the two it already knew. Precedence is declared, then runtime, then a cached metadata document, and the first two are answered without the cache being consulted at all: anhttpsclient_id does not by itself make a client a CIMD one. The rules about what a client identifier URL is, and what a document must say to become a client, live inservices/client_metadata.pyas pure functions, with the cache as a repository the runtime store lends them. A document authenticates withnoneand nothing else, since the draft forbids every shared-secret method and those are two of the three nanoidp supports.oauth.client_id_metadata_documents.enabledis off by default and set in the file only, likedynamic_registration. Only successes are cached, at most 100 documents at a time with the oldest fetch evicted at the cap and expired entries swept on every write: the entries will come from client-chosen URLs on an unauthenticated endpoint, so a lifetime per entry is not a bound. There is deliberately no negative cache, which the draft forbids. The resolver reads the cache and never fills it, which is what keeps network I/O out of/tokenand the other fifteen places that resolve a client; the fetch and the one place that calls it are the next two entries. For code embedding nanoidp: the origin types areUserOriginandClientOrigin, because only a client can have this third one. - The metadata document is fetched (#196, second part), by the one
outbound request nanoidp makes.
oauth.client_id_metadata_documentsgainsallowed_hosts(exact DNS names, empty by default, so nothing is fetched until an operator names a host) andallow_loopback(the draft's development exception: loopback only, only when this server is itself on loopback, only for the family it is bound to; private, link-local and unique-local addresses stay refused). Built on the standard library rather than an HTTP client, so the rules are the shape rather than flags: the connection goes to an address this code resolved and checked while the hostname is kept for TLS andHost, which is what makes it not a DNS time-of-check-to-time-of-use; a name is refused unless every address it answers with is acceptable, so one that offers a public address and a loopback one cannot be raced; redirects cannot be followed; the body is bounded at 5 KiB while it is read, so a missing or dishonestContent-Lengthchanges nothing; and there is exactly one request, with no retry and no second address. The 5 second budget covers the whole fetch, not each operation, so a server sending a few bytes at a time cannot hold a worker: the timeout is recomputed from one deadline before every HTTP read, the name is resolved under the same deadline through a process-wide resolver pool, and the TLS handshake takes what is left of it as its own whole-handshake timeout, which is whatsslapplies it as. An address is judged by what it reaches, so an IPv4 address carried inside an IPv6 one (::ffff:169.254.169.254, NAT64) is read as the address it translates to, and the ranges CVE-2024-4032 affects are named in the code rather than left toipaddress: nanoidp supports Python 3.10, where older patch releases call several special-purpose ranges globally reachable, and raising the floor would not settle it either. Onlymax-ageis read fromCache-Control;no-storeandno-cacheanswer that the document is valid and must not be cached, rather than discarding it: what a caller can do with a document it may not keep is the caller's decision, and with the cache the only place a CIMD client exists between/authorizeand/token,/authorizerefuses such an authorization request rather than issue a code for a client/tokencould not resolve. - A client ID metadata document is fetched at
/authorize(#196, last part). Anhttpsclient_idthat no declared and no runtime client holds is looked up: the document is fetched, validated, cached, and the client resolves from then on withorigin: cimd.client_id_metadata_document_supportedis advertised while the feature is on. Only/authorizefetches:/tokenand every other surface read the cache, so a document that is not cached is an unknown client there, which is what keeps a token request from waiting on somebody else's web server. A document whose response says it must not be cached is refused at the authorization request rather than turned into a code no token request could redeem. Every refusal answers the sameinvalid_client/Unknown client_id, with the reason in the server log only: which rule refused it is not something to tell whoever chose the URL. The client list shows cached clients read-only with a Forget button, which is how a developer re-fetches a document they have just changed. Anallowed_hostsentry names a host and a port (443 when none is given), so opting a host in does not authorise every port on it. This process makes at most 30 metadata fetches a minute, all callers together: the limit is on the fetch rather than on/authorize, because that is where the cost is and the endpoint is where people log in. The audit records that a document was refused and not why, sinceGET /api/auditis readable by anyone who can reach it and the reason is exactly what the uniform error withholds. An authorization code keeps the cached client it was issued for resolvable until the code expires, so neither a shortmax-agenor the cache's own capacity can leave a valid code with no client behind it: such an entry is not evicted to make room, and when every entry is holding up a live code the new authorization request is refused rather than an existing flow broken. The client is held before the code is minted, so a code is never handed over for a client that is already gone; when it cannot be held the authorization request comes back astemporarily_unavailable. An operator's Forget still drops the entry and invalidates such a code, which is the point of it. A client identified this way gets no refresh token and cannot use the device grant: both outlive the authorization code that is the only thing the cache promises to keep an entry for, and a credential this server can invalidate long before its expiry is worse than one it never issued./device_authorizationanswers such a client_id exactly as it answers a name nobody knows.
Changed
-
/authorizekeeps each request as a server-side authorization transaction (#346). An accepted GET validates the request once and stores it, bound to the browser, instead of copying ten parameters into the session; the login page's forms name it withtransaction_id, and issuing the code consumes it. The TOTP code screen no longer sends the verified password back to the browser: the transaction records it, and the code is checked against the user's current secret. A POST withouttransaction_id(a script posting credentials after its GET) still works while exactly one request is pending in that cookie jar; with several it is now refused instead of completing whichever GET came last. A POST carrying the whole request in its query string keeps working with or without a GET before it, and still leaves the browser's pending requests alone. "Change username" is a form on the transaction rather than a link carrying the request. A request opened before a configuration change keeps the client it was validated against; only a client that no longer exists ends it. Pending requests are capped at 1000 and expire after 10 minutes; at the cap a new request getstemporarily_unavailablerather than any pending one being dropped. -
The TOTP code screen of
/login,/saml/ssoand/deviceno longer sends the verified password back to the browser (#373). The password check is recorded on the server as a pending second factor, bound to the browser, to the surface and to what the login is for there (the SAML request in flight, the deviceuser_code), valid for 5 minutes and used once, and the code screen carries only its id. The code is checked against the user's current secret; a user deleted or left without a secret, TOTP switched off or persona mode switched on in between ends the login with an error instead. "Change username" is a form post that discards it, and on/devicea deny from the code screen discards it too and still needs no credentials; theuser_codeis read-only on that screen. A post carrying the password and the code together works as before, with nothing stored. At most 1000 such logins wait at once. -
Users and clients resolve through one identity resolver (#235), which is what the runtime identities of
/api/runtime(#192, under Added) stand on. Every login, grant, client authentication and SAML lookup resolves users and clients throughnanoidp.services.identities, which composes the declared configuration with an in-memory runtime identity store, declared first; a reload that declares a name a runtime object holds removes the runtime one with a warning. Which management surfaces show the effective identities and which stay on the declared configuration is in the/api/runtimeentry. For code embedding nanoidp:ConfigManager.authenticate,interactive_authenticateandcheck_clientmoved toIdentityResolver(identities_for(config));ConfigManager.get_userandget_clientstay, and return declared objects only;init_configgainsafter_load, whichcreate_appuses for the reconciliation. -
One
ConfigManagerper process (#230). The MCP server no longer keeps a configuration global of its own next tonanoidp.config's; its tools, the HTTP routes and the token service resolve the same manager, andTokenServicetakes that manager explicitly instead of looking it up and caching it. No change to the MCP tools or to any HTTP surface. For code embedding nanoidp:nanoidp.mcp_server._configno longer exists (usenanoidp.config.init_config), andTokenService()now needs the manager,TokenService(config);get_token_service()is unchanged. -
A rejected reload answers a JSON
422(#359):{"status": "error", "error": ..., "kind": "invalid" | "activation"}fromPOST /api/config/reload, and an error result with the samekindfrom MCPreload_config, for files that cannot be read or do not validate as well as for a signing configuration that cannot be used. Such a file used to answer Flask's HTML 500. A strict hook or plugin failure keeps its503. At startup the same conditions printerror: configuration rejected: ...and exit 1 instead of a traceback. For code embedding nanoidp: a failed load raisesnanoidp.config.ConfigurationRejected(aValueError, with the original error as its cause);get_crypto_service()takes no argument and returns the service the configuration published;init_crypto_serviceis replaced by the activation stepactivate_crypto_service, passed asinit_config(..., activate=...). -
A configuration directory is read as one observation, not several (#246, first part).
settings.yamlandusers.yamlwere opened by two separate unlocked reads, so a save landing between them, from another process or another thread, was observed as a settings/users pair that never existed on disk, and the runtime was built from it. Reads now go throughConfigFileStore, which acquires the directory under the same lock the writer takes and returns each file's bytes together with the revision of exactly those bytes, so a precondition can no longer describe content nobody read. Only the acquisition is inside the lock: parsing, environment expansion, the document models and the profile hardening all run afterwards, because the atomic unit is the filesystem snapshot rather than the whole reload. The revision a form stamps into the page, which the save hands back as its precondition, is observed through the same boundary. A read never abandons an available protocol: contention fails, and so does a filesystem without advisory locking, because both mean the protocol is there and this process could not join it. A read-only configuration mount keeps working, which is a supported deployment here: the lock file is reopened read-only, so such a mount still takes part in the protocol rather than stepping outside it, and only a view that can hold no lock file at all, or a directory that does not exist, reads unlocked - neither has a writer to be inconsistent with. The whole acquisition shares one deadline, so waiting for another process is bounded and never leaves this one holding a lock indefinitely. Since a read can now fail, a request that cannot observe the configuration answers 503 with the classified reason instead of a 500 and a traceback. -
validate-configobserves the directory once (#246, second part). A run readsettings.yamlthree times - once for the findings, once for the strictness and once for the report header - and each other file on its own, all unlocked. So a run overlapping a save could pair the pre-savesettings.yamlwith the post-saveusers.yamland report a cross-fileconfig_versiondisagreement for a state that never existed on disk: a false failure in the tool used to gate a deploy. The whole run now derives from oneConfigFileStoresnapshot, and a directory that cannot be observed consistently is reported as an ERROR finding rather than quietly read anyway. The bootstrap read joins the same boundary, which matters more than its once-at-startup frequency suggests, sinceNANOIDP_BOOTSTRAP_HOOKexists precisely so something else can render that file. Startup now takes one observation for its whole pre-load phase - the strictness the hook registry runs under and the file it is built from - instead of two. No configuration read path opens a file on its own any more.validate-configgains a third outcome. A run that could not observe the directory at all, because a save held the lock past the timeout, isUNAVAILABLEwith exit code 2, notinvalidwith exit 1: no validation failed, no validation took place, and a CI gate that cannot tell those apart is the defect this work set out to remove rather than to relocate. The MCP tool carries the same distinction asstatus: unavailablebeside its existingvalid. A file the run could not READ stays an ordinary error finding attributed to that file, exactly as before. Nothing is retried automatically: the command says precisely what happened and the caller decides. -
claims_supportedno longer advertisessource_aclandauthorities(#316). OpenID Connect Discovery 1.0 §3 defines that field as the Claim Names a provider may be able to supply values for; on top of it nanoidp holds its own invariant, that only a claim which can appear in an ID Token or a UserInfo response is advertised. Those two appear in neither: they are authorization facts a resource server reads off an access token, and the claim resolver does not know them, so a client that read the document and asked for one got nothing back. The document now keeps the rule #41 set for it, and a test checks the rule rather than the two names.attributesstays advertised: it is a Claim Name and/userinfosupplies it as a composite member, even though the resolver does not address it, so aclaimsrequest for it is answered only when the user owns a custom attribute by that name. What every surface asserts about a user is now written down in the new reference page rather than being a consequence of two independently written assemblers. -
MCP tool arguments take their shape from the domain models (#297). An argument that carries a configuration field's value now derives its type, enum, bounds, length, pattern and item type from that field; the tool keeps its own description, its
requiredlist and the wider vocabulary it defines (an emptyclient_secretor branding colour still means "none"/"clear it", an emptyget_audit_logusername still means "no filter", and the handler answers those). The schemas gained 16 constraints the models already enforced and nobody advertised:minLengthon every name and secret that has no wider tool vocabulary, and the range oftoken_expiry_minutes. For a client this moves those rejections to a dispatch refusal (MCP_INVALID_ARGUMENTS), the two layers the MCP error model already describes. -
update_settingsno longer applies a value the model would refuse (#297). It writes its arguments ontoSettingswithsetattr, andSettingshas novalidate_assignment, so its tool schema was the only check between an argument and the running configuration - and that schema carried two enums and no bounds.update_settingswithtoken_expiry_minutes: 0or99999was applied and reported as a success; both are now refused before dispatch. -
saml.c14n_algorithmis a closed set (#297), in the models and in the configuration document. An unknown value used to reachroutes/saml.py, which silently signed with Exclusive C14N, so a typo changed the algorithm without a word; it now fails the configuration load. Blank keeps meaning "the default", so an unset${VAR}placeholder loads exactly as before, and the settings writer checks the value before it replaces the file. (login.modewas already a closed set on theSettingsmodel and only moves into the document type here.) -
Every
/saml/attribute-queryoutcome is attributable to its sender (#309). A query refused for its shape - not well-formed, noAttributeQuery, noSubject, noNameID- used to write no audit entry at all, and the query's ownIDwas read only after those three were found, so a refused request could not be matched to whoever sent it. Each outcome now writes asaml_attribute_queryentry carryingrequest_idandcontent_length, and the id is read as early as the body allows, including from a query posted without the SOAP envelope. The body itself is logged only underverbose_logging, since it names a principal, and the recorded id is truncated, since it comes from an unauthenticated caller and is kept in the audit ring - a shortening of the evidence only: what the protocol sends back inInResponseTois the id exactly as it arrived. Auditing refusals also means that reaching this endpoint is a way to push older audit entries out, which the endpoint reference now says. This is diagnosis for a flake that has not been reproduced, not a fix for it. -
SAML XML is parsed through a parser built per call (#378), not one shared by every request thread. Sharing one was never a correctness problem - an
lxmlparser owns a lock and holds it for each parse - but that lock serialized every SAML parse in the process: measured on 24000 parses of a 5 KB document, the shared parser took 3.0 s on one thread and 2.3 s on eight, while a parser per call took 3.2 s on one and 0.64 s on eight. The cost sits at the small end, about a microsecond per parse of a 300-byte AuthnRequest, against requests that take milliseconds. It also removes the shared object that twice stood as an alternative explanation for a surprising parse while #309 was being diagnosed. The parser options are unchanged and spelled out literally where the parser is built, which is what both a reader and a static analyser go by; they are now pinned by tests, where one of them used to be a comment block asserting nothing. -
The three SAML Response builders share the part of the document that is the same in all three (#317). The
Responseenvelope, theIssuerpair, theStatuselement and the assertion's head were written three times over, in the SSO login assertion, the attribute-query assertion and the error Response for an unknown principal, so a change to the NameID policy had to be made in each. They come fromservices/saml_assertion.pynow. What is NOT shared is what actually differs, and the census found six differences that were declared nowhere and covered by no test: theConditionswindow (five minutes against one hour),Destination, whetherInResponseTois conditional, thedsnamespace, the serialization and the signing path. They are documented in the SAML reference and pinned byte-for-byte, deliberately not unified: the two validity windows in particular have never been decided to be one policy, so folding them behind attlargument would have hidden a difference this work exists to declare. The bytes of all three documents are unchanged. -
A client's authentication method and secret are normalized in one place (#300). "
nonedrops the secret" was written four times - the UI create and edit forms, MCPcreate_clientandupdate_client- with the reasoning repeated at each site, and the order an existing client must be moved through (the model validates on assignment) was a comment in the one place that hit it. Both live inservices/client_policy.pynow. The difference between an omitted secret, which keeps the stored one, and an empty one, which a confidential client refuses, is unchanged, and the UI form's "blank means unchanged" stays in the route, where the convention belongs. The other consequences of a client being public - PKCE at/authorize, the refusal at/introspect, the ownership check at/revoke, the channel rule at/device_authorization, forced refresh rotation - deliberately stay where they are applied: they are different rules sharing a predicate, not one policy written ten times. -
What
/userinforeturns and what/introspectreports are services (#303), not response dicts assembled inside the routes. Which claims a token's bearer may see, and what an introspection says about a token, are protocol policy and are now testable without an HTTP request; the routes keep the adapter's work - the Bearer token, client authentication, the JWT checks, revocation, the user lookup and the audit entries. Behaviour is unchanged, including the two rules that read on key presence rather than truthiness: a token naming a null client reports null rather than the caller, and a token whose scope is empty is reported with an empty scope rather than the default. Which profiles gate the standard claims is now a property ofSettingsnext to the other profile-derived predicates. -
The settings keys written only when they differ from their default are table rows (#319).
security_profile,login.mode,login.auto_login,login.two_stepandlogin.totpwere hand-coded below the loop that drives every other key fromOWNED_SETTINGS, so the parity test that holds a setting to every surface did not cover them; a newlogin.*key had to be threaded by hand through six places, which is the shape of bug that table exists to prevent. They are rows with anomit_when_defaultmode now, one rule serves both a top-level key and a key in a section that disappears with its last entry, and the writer's four one-line helpers and its positional four-tuple of defaults are one helper and one mapping. The fallback defaultsserialization.pykeeps (it must not import the document models) are now pinned to those rows and to the real defaults. No behaviour changes: what a save writes, omits and leaves untouched is unchanged, and the writer keeps its API, including "blank mode means unchanged" and "an absent checkbox means unchanged".
Fixed
- Tokens are signed with the key the JWKS serves after a reload changes
jwt.keys_dir(#230). The token service kept the signing key it was built with, so afterPOST /api/config/reloadhad movedkeys_dir, every grant at/tokenandPOST /api/users/<username>/tokenkept signing with the old key while the JWKS, introspection and/userinfoalready used the new one: tokens issued after the reload failed verification until a restart. - A reload no longer activates a configuration whose signing service
cannot be built (#359).
POST /api/config/reloadaccepted ajwt.keys_dirthe process could not create and answered 200, after which the JWKS answered 500 and/tokenfailed (before #357 it kept signing with a key the JWKS no longer served). The load now prepares the signing service from the candidate settings before it commits anything: a configuration that cannot build it is rejected and the running one stays in effect, onPOST /api/config/reload, on MCPreload_configand on the refresh that follows a UI write. An unrelated reload reuses the running signing service instead of rebuilding it, and loads now run one at a time, so two concurrent reloads can no longer generate keys into a newkeys_dirover each other. jwt.external_keysandjwt.max_previous_keysare read fromsettings.yaml(#358). Both were documented in the security guide, but the loader ignored them as unknown keys, so an operator's own signing key was silently replaced by a generated one and the retention stayed at 2. They are now part of the settings document and the JSON schema, and they are signing inputs of #359's activation: the configured key signs tokens and is the only key the JWKS serves, the MCP server signs with it too, and a reload that changes either setting reinitialises the signing service.private_keyandpublic_keyare given together; a missing, unreadable or malformed key file, or a public key that does not belong to the private key, rejects the configuration. Withoutkid, the key id is the RFC 7638 thumbprint of the public key (the docs promised a fingerprint; a random id was generated on every start). Rotation is refused for external keys (409fromPOST /api/keys/rotate, an error on the keys page and from MCProtate_keys) instead of replacing the operator's key with a generated one. The key files are not watched: a key replaced at the same paths is read at the next start. The SAML certificate for an external key lives in its own file (external-cert-<thumbprint>.pem, stable across starts), so switching back to generated keys no longer leaves SAML signing with a certificate for the wrong key; a certificate that does not belong to the signing key is regenerated, SAML signing uses the published service's certificate instead of re-reading the file per request, and a loweredmax_previous_keystrims the JWKS as soon as it is applied.- A write that would leave a file unloadable is refused, not written
(#366). Every writer replaced the file and reloaded afterwards, so a
document the models refuse reached disk first and was discovered second,
leaving a
settings.yamlthe next process could not start from. The composed document is now parsed, exactly as a load parses it, before any file is replaced: the error names the file and the key and nothing is written. Reachable from the settings form (a blankoauth.audienceoroauth.issuer) and from the MCP tools, whereupdate_settingswrites onto a model withoutvalidate_assignmentandsave_configpersisted the result, so anissuerrefused by a validator rather than by a field constraint could be saved and then fail to load back.${VAR}placeholders are expanded into a copy for the check, so what is written keeps them. The check is of the document only, never of activation.nanoidp initand the setup wizard go through it too: they validated the document model but not the domain rules, so an issuer likelocalhost:8000finished the wizard and left a directory the server it had just configured could not start from. Both files are now checked before either is written, so a refused answer leaves no half-configured directory behind. The check follows the loader's order,config_versionincluded: that rule lives on the raw mapping rather than in the document models, so without it a candidate could pass every model and be refused by the very next load, and a batch holding both files also has to see them declare the same version. - Two signed Redirect AuthnRequests in one browser no longer interfere
(#375). With
saml.want_authn_requests_signed, a verifiedGET /saml/ssowas remembered in a single session key, so a second signed request overwrote the first and its login post was then refused as not matching a verified request. The browser's session now holds the set of requests it had verified: ten of them, an eleventh making the oldest non-continuable, each kept for 10 minutes of inactivity and refreshed while the login is continued, and remembered only when a login actually has to be continued. Two signed requests issued before either response's cookie reaches the browser still leave only the later one continuable, since the set travels in that cookie. What the login post must present is unchanged, and an expired verification now says so in the audit and the log instead of reporting the signature as invalid. - Switching a client to public through the UI edit form drops a secret
typed in the same submission (#300). The form only dropped it when the
field was left blank, so an operator who picked
token_endpoint_auth_method: nonewhile a secret sat in the input persisted a dead, ignored value - the state the create form has refused to write since #254. The two forms now apply the same rule. - A refused field on the users or clients edit form is answered, not
reported as a server failure (#298). An email without
@, a colour that is not a hex triplet or any other value the model refuses reached the catch-all on the edit routes: the operator was told "Failed to update user: 1 validation error for User ..." and the server logged a stack trace at ERROR for what is ordinary form input. The create routes had always answered the same input with the refusal itself, and both legs now build the record through one reader, so both answer it the same way. Nothing is written in either case. - A password field holding only spaces means "unchanged" on the users edit
form, as it already did on create (#386). The create leg stripped the
field before deciding it was blank and the edit leg did not, so
" "was no password on one and a real new password on the other: an operator who left stray spaces in the field, from a paste, an autofill or the spacebar, silently replaced that account's password with whitespace, with nothing flashed and nothing logged, and the account stopped authenticating with the password they believed it had. Both legs now share one notion of "the field was left blank". Stripping decides only that: a password that is not blank is stored exactly as typed, leading and trailing spaces included, since those may be deliberate.
Security
- The clients page no longer shows any part of a client secret. The
secret column rendered the first 8 and the last 4 characters of each
confidential client's secret. For a secret of 12 characters or fewer
that is the whole secret, the one
nanoidp initwrites included, and for a longer one it is 12 characters of it. The page is a read, andsession.management_secretgates mutations only: on an instance with the management gate on andrequire_ui_loginoff, a caller who could change nothing could read a client's secret off the page and authenticate as that client at/token,/introspectand/revoke. The column now shows one fixed mask for every confidential client, declared, created through/api/runtimeor dynamically registered, and the mask does not vary with the secret's length. The preview is in every release; the boundary it crossed exists sincemanagement_secretshipped in 2.8.0, so 2.8.0 and later are affected.require_ui_loginnarrows who could read the page and nothing more: it is a login, not a role, so anyusers.yamlaccount could, and underlogin_mode: personaanyone. A sweep now verifies that no management read available without additional client-specific authorization exposes stored client-secret material: it fetches every GET the application routes, without proof of the management secret and without any client's credentials, following redirects, and searches each response for every stored client secret. An RFC 7592 read (GET /register/<client_id>) does return that client's own secret, to the holder of its registration access token; that is the client's channel, not a management read. (The built-in/testpage prints the literaldemo-client/demo-secretpair ofnanoidp init, which is a public default and not a stored value; the Security guide now says so.) The guide gains "What a read can see". After upgrading: see Migration notes.
3.2.0 - 2026-09-15
Added
- Declarative TOTP second factor (#348, opt-in, off by default):
login.totp: truerequires a time-based one-time code after a successful password check, across every interactive login surface -/authorize,/login,/saml/ssoand the device flow's/device- the same surfaceslogin.two_stepcovers, riding its phase machinery. A declarative demo factor, not IdP hardening: the secret is a plaintotp_secretfield of the user entry inusers.yaml(its presence is the enrolment, no per-userenabledflag,${VAR}allowed like any value), never accepted or returned by the users form or any MCP user tool. Verification is RFC 6238 (6 digits, 30s period, HMAC-SHA1, one step of clock skew), standard library only, no new dependency, and deliberately has no replay protection. Inert underlogin.mode: persona, which checks no password; the OAuth password grant and client credentials are unaffected. Whilelogin.totpis on, the ID Token carries an OIDCamrclaim (RFC 8176):["pwd"]for a plain password login,["pwd", "otp"]once the code is verified, preserved across a refresh likeauth_time; with it off no new claim appears. A third SAMLAuthnContextClassRefvalue,urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken, next to the existing password/unspecified pair. Configurable through YAML, the Settings UI, and the MCP settings tools. Two side effects on existing configurations:totp_secretis now a reserved top-level user key (before, an unknown key of that name was folded into the user'sattributes), andamrjoined the reserved claim names, so a user attribute namedamrrequested through theclaimsparameter is no longer resolved, whetherlogin.totpis on or off (#110).
Fixed
- Pydantic configuration validation no longer echoes rejected secret
values (#352). The loader raises its own message from pydantic's
validation error, and that error rendered the offending input as
input_value=..., so the value reached every place that prints the chained traceback: the startup output, the server log onPOST /api/config/reloadand the MCP server's log onreload_config. Affected: aclient_secret,session.secret_keyorsession.management_secretthat YAML reads as a number, amanagement_secretrejected for a non-ASCII character (that one also appeared innanoidp validate-configand in both MCP tool results), and the settings of every plugin when oneplugins:entry is malformed, insettings.yamlorbootstrap.yaml. The three document roots and theSettingsandOAuthClientmodels now hide the input (UserEntrykeeps its own flag as defense in depth); the error still names the file, the field and the rule. #350 had closed the same gap forusers.yaml. The scope is pydantic's rendering of validation errors, not a general redaction of every exception that may carry configuration data.
3.1.0 - 2026-09-14
Migration notes
Nothing below changes a supported API. This release stays a minor: the
/authorize changes correct behaviour the protocol never allowed, and the
non-root image is a deployment-compatibility change. What needs a hand on
upgrade is collected here.
- The container image runs as a non-root user (#332): uid 1000, gid 0,
with
/app/keysand/app/configowned by that user and group-writable by group 0, so the image also runs where the platform assigns an arbitrary uid with gid 0. Every Kubernetes namespace enforcing therestrictedPod Security Standard rejected the previous image onrunAsNonRoot; the Helm chart's defaultsecurityContextnow setsrunAsNonRoot: trueand is admitted there. It pins norunAsUser(#339): the standard does not need one, and a platform that assigns its own uid per namespace (OpenShift'srestricted-v2) would reject a pinned1000while the image runs fine under the assigned uid. A plaindocker runwith nothing mounted is unaffected. Migration: a keys volume or bind mount first created by an earlier release is root-owned. The new image still starts on it, because the existing keys are readable, but the next key rotation fails withPermission denied: 'keys/previous'. Chown it once:docker run --rm -v nanoidp-keys:/k alpine chown -R 1000:0 /k(orchown -R 1000:0on the host directory of a bind mount). A read-only config mount needs nothing. To run an older image tag with the chart, setsecurityContext.runAsNonRoot=falseandsecurityContext.runAsUser=0. /authorizecontract corrections (#325, #328, #331, details under Fixed): the POST leg reads the OAuth parameters from its own query string, never from the form body; a request carrying any OAuth parameter is a complete request and no longer borrows missing fields from the session; a rejected request no longer replaces the pending one. Migration: a client that packed the OAuth parameters into a singlePOST /authorizebody with no preceding GET must send them on the query string instead. Browser flows are unaffected.
Added
- Two-step login (#322/#323, opt-in, off by default):
login.two_step: truecollects the username first and the password on a second screen, across every interactive login surface -/authorize,/login,/saml/ssoand the device flow's/device. A global setting alongsidelogin.mode/login.auto_login, not a per-client one; inert underlogin.mode: persona, which is passwordless and has no password screen to split off. The step is stateless: the username travels as a plain form field, carried forward as a hidden input on the password screen - a request that already carries both authenticates directly, so anything written against the combined form keeps working unchanged. Configurable through YAML, the Settings UI, and the MCP settings tools. - Helm chart (#327/#330) under
charts/nanoidp, published tooci://ghcr.io/cdelmonte-zg/charts/nanoidpon every release tag by the newPublish Helm chartworkflow, with the chart version taken from the tag. Single replica by design, configuration as wholeusers.yaml/settings.yamlfiles in a generated Secret (or one you manage), Ingress with${INGRESS_URL}for the issuer, achecksum/configannotation that rolls the pod on a configuration change, and a defaultsecurityContextadmitted by a namespace enforcing therestrictedPod Security Standard (with the non-root image, see Migration notes). The chart README states the limitations that hold by design: read-only config mount, signing keys regenerated on every pod restart.helm installwithout--versionresolves the newest final release; pre-releases need--develor an explicit version. - n8n end to end, path 1 (#194):
examples/agentic-stack/docker-compose.ymlruns nanoidp, the RFC 9728 mock MCP server and a pinned n8n (2.38.7) on one network, ande2e/n8n_e2e.pydrives it headless: n8n's own OAuth2 flow (PKCE, public client, RFC 8707resource) against nanoidp, then a workflow whose MCP Client node callsread_documentwith the token n8n obtained, plus the insufficient-scope and wrong-audience negatives. A manual and nightly workflow (n8n E2E) runs it. No change to nanoidp was needed; n8n's dynamic client registration mode stays out of scope until #190. - n8n end to end, path 2 (#194): the same stack now also runs n8n's AI
Agent with the MCP Client Tool node inside its tool-calling loop. The
agent's model is
e2e/mock_chat_model.py, an OpenAI-compatible fixture that always asks for the MCP tool and then quotes the tool's answer, so the run needs no LLM.e2e/n8n_e2e.pyasserts that the agent offers the model the tools the resource server lists, callsread_documentwith the token n8n obtained and quotes the document; that aninsufficient_scoperefusal comes back to the model as the tool's result rather than as a crash; and that a wrong-audience token stops the agent attools/list, before the model is called. The Compose file gains an optionalollamaprofile for running the same loop by hand with a real local model; CI never uses it. Still no change to nanoidp.
Changed
- Test suite hygiene (#305, #320): the shared
conftestreset now covers the three runtime stores (authorization codes, device codes, revocation) next to the service singletons, and the per-file cleanup fixtures that patched that gap are gone; the crypto leg of the singleton concurrency test uses a stub that carries whatget_crypto_servicereads since #281, and the test now asserts that every racing thread returned an instance, so it cannot go silently vacuous again. - The Helm chart is published only once its image is in the registry
(#340).
docker.ymland the chart workflow both start on the same release tag with nothing ordering them; the chart workflow now waits for the image's manifest (bounded at twenty minutes, well above the multi-arch build) beforehelm push, and fails instead of publishing a chart that runs nothing. On the chart-only dispatch path the same wait confirms the pinnedimage.tagexists. - The login session has a single writer (#301). The dashboard's
/loginand the SAML SSO inline login each used to setsession['user']andsession['auth_method']by hand, and the SAML assertion'sAuthnContextClassRefis derived from the latter. Both now call one helper,establish_login_session, which records the user and how it authenticated together. A future login surface is held to the helper by a contract test (direct forms only: a subscript store,session.updateorsession.setdefaultnaming the key, the'auth_method'literal anywhere), so recording a user without recording the method, which would have made its persona logins silently claimPasswordProtectedTransport, fails the suite rather than passing unnoticed. No behavior changes for existing logins.
Fixed
- Rejected
/authorizerequests no longer replace an earlier pending request in the same browser session (#331). The session capture now updates only after client, redirect URI, response type, scope, PKCE, and resource validation succeeds, and after persona auto-login remains inert. A later bare login submission can therefore complete the original request instead of inheriting a rejected request's client, callback, or state. - A fresh
/authorizerequest no longer inherits optional parameters left behind by an earlier, abandoned request in the same browser session (#328). A query string carrying any OAuth request parameter is now a complete request: omitted required parameters are rejected, while omittedstate,nonce, PKCE,claims,resource, andscopevalues remain omitted or use their normal defaults instead of falling back field by field to stale session values. A request carrying only unrelated query parameters still resumes the complete request captured by the preceding GET. Only a complete, accepted GET updates that capture; POST requests never do, so a failed direct POST carrying its own OAuth query string cannot rebind a later bare form submission. A valid GET in another tab still replaces the shared session capture. Contract changes: a partial request no longer borrows its missing required fields from the session, and a client that starts with a POST cannot retry it as a bare POST after failed credentials. Browser form retries are unaffected because the form resubmits to its full query-string URL. /authorizePOST leg no longer trusts OAuth params from the login form body (#325):client_id,redirect_uri,scope,state,code_challenge/code_challenge_method,nonce,claims, andresourceare now read from the query string on both legs - the POST's own (the login form has noaction, so it always submits back to the exact/authorize?...URL of the page it rendered), falling back to the session captured on the preceding GET when the query string has no OAuth request parameters of its own - and never from the POST body. Previously the POST leg fell back to the form body for these fields, so a forged hidden form field could override the request the user actually approved, breaking the binding between that approved request and the issued authorization code; a completed login or a mere page load in another browser tab sharing the same cookie jar could do the same by clearing or overwriting the session's copy out from under an in-flight tab. Binding each POST to its own page's query string closes all three. Contract change: a singlePOST /authorizethat packs the OAuth parameters into the body together with the credentials, with no preceding GET, is no longer honored - send those parameters on the query string instead (POST /authorize?client_id=...&redirect_uri=...), or do the GET first as before. The login form itself only ever legitimately carriesusername/password, and a POST already routed through the preceding GET's page (the common case) is unaffected.login_hintis no longer honored on the POST leg of/authorize(#325): the persona auto-login prefix (see "Auto-Login" below) it feeds only ever had a meaningful GET-time use, so a value posted with the login form - which the login page itself never sends - is now always ignored rather than read from the request.
3.0.0 - 2026-09-06
Breaking Changes
This release tightens and unifies nanoidp's OAuth client-authentication contract. A confidential client that already presents its registered method over the matching channel, and any public client, are unaffected; a client that authenticated over a different channel at one of these endpoints - which the previous leniency allowed - needs a one-time adjustment.
-
The registered
token_endpoint_auth_methodis now enforced at every client-authenticated endpoint (#188, #259, #262):/token,/introspect,/revokeand/device_authorization.- A confidential client must authenticate on
authorization_codetoo - the code exchange no longer has a client-authentication exemption (RFC 6749 §3.2.1). Migration: present the client secret on the code exchange, or re-register the client astoken_endpoint_auth_method: "none"if it cannot keep a secret (and use PKCE). - The registered method decides the channel: a
client_secret_basicclient must use HTTP Basic and aclient_secret_postclient must use the request body - the wrong channel is rejected withinvalid_client. Previously a body secret was accepted (or silently ignored) regardless of the registered method. Applying the one registered method across all four endpoints is nanoidp's consistency policy: RFC 7009 and RFC 8628 tie/revokeand/device_authorizationto the token-endpoint method, while RFC 7662 permits client authentication at/introspectbut does not mandate reusing that method - so a client that legitimately used a different channel for introspection stops working here by nanoidp's choice, not because it was non-compliant. Migration: send credentials over the channel the client is registered for; settoken_endpoint_auth_methodto match how your client actually authenticates. - Two authentication methods in one request are rejected (HTTP Basic and a
body
client_secrettogether, RFC 6749 §2.3) - Basic no longer silently wins. Migration: send credentials one way only. - Public-client policy:
/introspectrefuses public clients (RFC 7662),/revokekeeps its RFC 7009 §2.1 ownership relaxation, and/device_authorizationnow accepts a public client by client_id alone (RFC 8628, see the device-flow entry below).
- A confidential client must authenticate on
-
/authorizereports errors afterredirect_urivalidation by redirecting to the client (#189, RFC 6749 §4.1.2.1 / RFC 9207):unsupported_response_type,invalid_scope, PKCE errors andinvalid_targetare now302redirects carryingerror,error_description,stateandiss, not a local JSON400. Errors beforeredirect_uriis validated (unknown client, missing/malformed/unregisteredredirect_uri) still return JSON locally. Migration: a client that parsed the400body should read the error from the redirect query instead - which is what a spec-compliant client already does. -
A refresh token without a
client_idbinding claim is rejected (#73,invalid_grant, RFC 6749 §5.2). The binding was added in 2.2.0 (#56); tokens minted before it were still spendable by any authenticated client until they expired - a transitional compat deferred to the next major, now closed. Migration: discard refresh tokens minted before 2.2.0 and obtain new ones (every grant since 2.2.0 already binds them). The MCPgenerate_tokentool gains an optionalclient_id(which must name a real client) that binds the minted token and issues a refresh token spendable by it; without it the tool mints an unbound access token with no refresh token at all, rather than hand back one that could not be spent. The HTTP testing endpointPOST /api/users/<username>/tokengets the same treatment - a new optionalclient_idbinds the token, and it no longer returns arefresh_tokenwhen unbound - so all three token minters (a grant, the MCP tool, this endpoint) agree. -
One request, one client identity (#277).
/introspect,/revokeand/device_authorizationnow resolve the requesting client exactly as/tokenalways has: HTTP Basic naming client A plusclient_id=Bin the body is one request claiming two identities and is rejected withinvalid_client(previously the Basic username silently won and the body value was ignored). Migration: drop the contradictory bodyclient_id, or make it match the authenticated client. -
The
device_codegrant re-validates the stored scope at redemption (#276): a scope removed from the client'sallowed_scopes(or fromscopes_supported) between/device_authorizationand the poll now fails the poll withinvalid_scope, exactly as theauthorization_codeandrefresh_tokengrants already re-check theirs. Previously the stale scope was still minted. -
/saml/attribute-queryanswers an unknown NameID with a SAML error status (#275): top-levelRequesterwith subordinateUnknownPrincipal, no assertion. Previously an unknown user got a signed assertion with fabricated attributes (<user>@example.com,identity_class: INTERNAL,entitlements: DOCUMENT_READ) - an SP under test would pass with data nanoidp invented. The endpoint's docs now also state plainly that it is unauthenticated by design (the same read model as the REST API, #163): it previously claimed "after JWT authentication", which the code never did. -
The
expclaim is required on every token nanoidp accepts (#306).verify_jwtnow enforcesrequire: ["exp"]- a correctly signed JWT WITHOUT an expiry is rejected everywhere (/userinfo,/introspect,/revoke, the refresh grant, MCPverify_token). This is nanoidp's token-profile policy, not a JWT-spec rule (RFC 7519 leavesexpoptional; OIDC Core and RFC 9068 require it on the profiles that matter): a token accepted by an IdP should have a finite lifetime, and an eternal bearer token would let an integration test pass here and fail against any real IdP. Everything nanoidp mints has always carriedexp; only hand-crafted tokens signed with the nanoidp key are affected. Migration: add anexpto such fixtures. No other claim is newly required. -
An unverifiable refresh token now answers RFC 6749 §5.2 JSON (
invalid_grant, HTTP 400) instead of a Werkzeug 401 HTML page, per the "Error surfaces" rule (#287). -
Every
/tokenerror branch now answers RFC 6749 §5.2 JSON (#308): roughly twenty conditions across the endpoint shell and all five grant handlers used to answer Werkzeug HTML viaabort(). Now: missing or malformed parameters areinvalid_request(400); a bad, expired, revoked or foreign code/refresh-token - and invalid resource-owner credentials on the password grant - areinvalid_grant(400); an unknown or profile-disabled grant type isunsupported_grant_type(400); client-authentication failures areinvalid_client- 401 with theWWW-Authenticate: Basicchallenge when the client attempted HTTP Basic (the §5.2 MUST), 400 otherwise (§5.2's default; RFC 9110 §11.6.1 forbids a challenge-less 401, and a Basic challenge would be wrong for aclient_secret_postclient anyway). The attempt is detected from the rawAuthorizationheader (#311): a syntactically broken Basic header - which werkzeug parses to nothing - still counts as an attempted Basic and gets the 401 + challenge. Descriptions are fixed text (no library detail and no reflected caller input - the unsupported grant type's raw value lives in the audit event, not the response). Migration: branches that used to answer 401 for GRANT problems (revoked/foreign refresh token, unknown user, wrong password) now answer 400 witherror: invalid_grant- §5.2 reserves 401 for client authentication; readerrorfrom the JSON body instead of matching HTML.
Changed
mcp_serveris a package (#286): the 2,100-line module is nowmcp_server/-schemas.py(tool declarations and compiled validators),normalize.py(argument pre-validation),serializers.py,handlers_users/clients/tokens/config.py(the 25 tool handlers by domain), with dispatch, the guards, transport bootstrap and the mutable process state in__init__.py. Thenanoidp-mcpentry point and every explicitly re-exported name keep their import paths (from nanoidp.mcp_server import ...for everything tests and callers actually use;python -m nanoidp.mcp_servernow goes through the package's__main__.py) - arbitrary internal symbols of the old monolith are not a compatibility surface.verify_secretmoved to a new framework-freenanoidp.security(re-exported fromroutes/_auth.py), so the stdio MCP process no longer imports Flask at all - pinned by a subprocess test. Behavior-preserving: handler bodies and schemas are unchanged./saml/attribute-querytransport errors are SOAP 1.1 Faults (#287): a malformed query (missing AttributeQuery/Subject/NameID) or an internal failure now answers with a propersoap:Fault(HTTP 500,faultcodeClient/Server per SOAP 1.1 §6.2) instead of bare plain text with a 400 - a shape no SOAP stack could parse. Protocol-level conditions (unknown principal) keep answering inside the SAML Response as before.- The dead exception hierarchy is gone (#287):
exceptions.pydeclared 20 classes of which exactly one was ever raised; onlySAMLSignatureErrorremains (now a plainExceptionsubclass). Errors are shaped per surface- the model is written down in CONTRIBUTING ("Error surfaces"), including the deliberate two-layer MCP contract (dispatch refusals vs domain results), now documented where the shapes are defined.
TokenService.create_tokennow owns the #73 mint-side rule (#278):issue_refresh_tokendefaults to "only when the token is bound" (client_idgiven), and asking for a refresh token without a binding raises instead of minting a credential/tokenwould reject. The three minting surfaces (grants, MCPgenerate_token,POST /api/users/<username>/token) behave as before; the rule just lives in one place.- The MCP
generate_tokenandverify_tokentool descriptions now document their simulation boundary (#279):generate_tokenstampsscopeandresourceas given with no vocabulary check and no per-client ceiling (minting an out-of-ceiling token is how a resource server's rejection path gets tested), andverify_tokenchecks signature/expiry like a stateless resource server - revocation is/introspect's answer. Behavior is unchanged; both exemptions are now pinned by tests. - The end-to-end test harness moved from
examples/to a dedicatede2e/directory.test_agent.py,mock_mcp_server.py,mcp_smoke_test.pyandgen_sp_keypair.pynow live undere2e/;examples/keeps only the real usage examples (client integrations, plugins). The harness was never a usage example - it is the CI end-to-end suite - and mixing the two made the repository harder to read. Invocations change frompython examples/...topython e2e/...(CI, CONTRIBUTING and the docs are updated); no behaviour and no packaged code changed. - Resource indicators are validated per RFC 3986 component, not by a single
character whitelist (#257). A
resourceis still an absolute URI without a fragment (RFC 8707 §2), but each component is now checked against its own ABNF:[/]are accepted only inside an IP-literal host (sohttps://host/a[b]is rejected where it used to pass), a port is*DIGIT(no numeric-range limit, matching RFC 3986 §3.2.3), and IPv6 host literals are validated (a scoped[fe80::1%eth0]is rejected: RFC 3986 IPv6address has no ZoneID, per RFC 9844). Mostly a tightening on malformed input to an opt-in feature; it also stops rejecting a valid path-empty absolute URI (RFC 3986 §3, e.g.about:). No audience bypass or escalation.
Added
- Auto-login personas (#250, opt-in, off by default): with
login.mode: persona, a newlogin.auto_login: truelets an OIDC/authorizerequest log a configured user in directly - no picker, no HTML - by sendinglogin_hint: persona-auto-login:USERNAME, for driving a real OIDC client library in automated integration tests. Any otherlogin_hintis passed through unchanged, and with the flag off a prefixed hint is inert too, so the picker still shows exactly as before. An unknown persona reports through the standard OAuth error redirect (error=invalid_request,statepreserved), never a bare400. First implementation surface is OIDC/authorizeonly; the device flow and SAML have no defined transport for the hint yet. Ships with settings UI and MCP (get_settings/update_settings) exposure, an/api/configloginblock (which also picked up the pre-existinglogin_modefield it was missing since persona mode shipped), and anexamples/persona-loginwalkthrough. Like the rest of NanoIDP, a local development/testing convenience only - not an authentication mode for deployed environments. - Access-point parity contract for token issuance (#283,
tests/test_token_issuance_parity.py): the set of CALL SITES ofTokenService.create_token(file::function, AST-checked) must equal a declared registry, and every (surface, policy) pair - client binding, scope ceiling, resource ceiling - must declareenforced(behaviorally asserted) orexempt(the exemption itself pinned, with its documented reason). A new minting call site - even a second function in an already-registered module - fails the suite until it takes a stance; this mechanizes the #269/#272 bug class out of existence. - User-field parity test (#284,
tests/test_user_field_parity.py): the nine user shapes (model, YAML entry, MCP read/create/update surfaces, UI form, REST read) are held to field-set equality with documented, asserted exclusions - the guard whose absence let #280 drift silently. Found #291 - originally misfiled as "noattributesinput in the UI form" (the dynamicattr_key[]/attr_value[]widget was there all along, invisible to the single-name regex); the real defect behind it is fixed below. - "Domain invariants have one home" review rule (#285) in CONTRIBUTING,
echoed from VISION principle 4; five deferred imports claiming
nonexistent circular dependencies hoisted to module top, and the one
legitimately special case (
services/audit.py) now documents its real reason (never construct config from a log path). - Horizontal
/authorizelogin card composition (#249). New per-clientlayoutfield,"vertical"(default, unchanged) or"horizontal": the latter places the client info block and the login form side by side in a Bootstrap two-column split, with the header and footer still full width, collapsing back to the single-column stack on narrow viewports. One of exactly two nanoidp-owned layouts - no per-client CSS or column widths. Full support across settings.yaml, the UI client form, and the MCPcreate_client/update_clienttools; omitted (or"vertical") writes nothing to YAML, matching every other default-valued client field. - Public clients on the device flow (#255, RFC 8628). A public client
(
token_endpoint_auth_method: "none") can now use the device authorization grant: it presents itsclient_idalone at/device_authorization(§3.1) and again when polling/token(§3.4), with no secret. The issueddevice_codeis bound to thatclient_idand only it can redeem it, which stands in for client authentication (it presents client_id as a parameter, not via HTTP Basic or a secret, RFC 8628 §3.1; both are rejected). Confidential clients still authenticate as before; #188 shipped public clients for authorization_code + PKCE, and this completes the pair for CLI/TV/IoT-style clients. Because an unauthenticated device authorization request is cheaper to spam, the in-memory device-code store is now capacity-bounded: at the cap it returns a plain503(withRetry-After) rather than growing without bound or evicting a live authorization - not an OAuth error code, since RFC 6749 §5.2 has no registered code for server saturation. - Mock protected MCP server as an e2e fixture (#191).
e2e/mock_mcp_server.pyis a minimal MCP Streamable HTTP resource server (themcpSDK's resource-server mode) with three scope-gated tools (read_document/documents:read,delete_document/documents:write,admin_operation/admin). It validates bearer tokens JWKS-only against nanoidp (signature,iss,aud== its own resource URL,exp, scopes), serves the RFC 9728/.well-known/oauth-protected-resourcedocument naming nanoidp as its authorization server, and answers an unauthenticated call with401+WWW-Authenticatepointing at that metadata. It demonstrates two authorization layers, kept distinct: a resource-level scope floor (documents:read), enforced by the SDK's bearer middleware, which returns the conformant MCP/RFC 9728403WWW-Authenticate: Bearer error="insufficient_scope"+resource_metadatachallenge before any tool runs; and an application-level per-tool check inside each tool for the finerdocuments:write/adminoperations, which surfaces as an in-band MCP tool error.e2e/test_agent.pygains an--mcpsuite that drives the whole loop deterministically as the MCP client (401 -> RFC 9728 discovery ->/authorizewith PKCE andresource=->/token->tools/call): delegated login as a PUBLIC client (PKCE, no secret) yielding a resource-bound token accepted for a scoped tool; a wrong-audience token rejected with401at the transport (asserted at the HTTP layer); the conformant403insufficient-scope challenge; the application-level per-tool refusal; a refresh token that cannot be widened in scope on refresh (RFC 6749 §6); a client_credentials workload; a token revoked at nanoidp still accepted by the JWKS-only server untilexp(the documented consequence of self-contained tokens); and a pre-rotation token still verifying after a key rotation, with the test asserting nanoidp retains the previous key'skidin its published JWKS. Adds themcp-public-client(token_endpoint_auth_method: none) to the example config. New guide "Testing an MCP client against nanoidp". This is the deliverable that ties #186 (scopes), #187 (resource indicators) and #188 (public clients) together into a demonstrable OAuth/MCP round trip. - RFC 9207:
isson the authorization response (#189)./authorizereturnsiss=<effective issuer>on every response delivered through a validatedredirect_uri- success and error alike - so a client can detect an authorization-server mix-up (MCP 2026-07-28 recommends this). The value is the per-request effective issuer, so it stays correct underissuer_from_request(#126).issis delivered exactly when discovery advertisesauthorization_response_iss_parameter_supported: one condition drives both, so metadata and behaviour never disagree. RFC 9207 requires anhttpsissuer with a host and no query or fragment, so the defaulthttp://localhost:8000sends noissand advertisesfalse; point the issuer athttps(directly or reflected viaissuer_from_requestbehind a TLS proxy) to turn RFC 9207 on. Related behaviour change: authorization errors that occur after theredirect_uriis validated (invalid_scope, PKCE errors, invalid_target) are now OAuth error redirects to the client (error,error_description,state,iss) instead of a local JSON 400, per RFC 6749 §4.1.2.1 - completing the RFC 9207 "error responses too" requirement.unsupported_response_type(a non-coderesponse_type) is validated after theredirect_uritoo, so it redirects as well. Errors before theredirect_uriis trusted (unknown client, malformed or unregisteredredirect_uri) stay local JSON. Aredirect_urithat carries its own query keeps it: the response parameters are appended, never fold into an existing value. The device flow is unaffected. - RFC 8707 Resource Indicators:
resourcebinds the access token audience (#187). A client may send one or moreresourceparameters on/authorize,/token(every grant) and/device_authorization; the access token'saudis then those resources (a plain string for one, an array for several) instead of the globaloauth.audience, so a token minted for one MCP server is rejected by another and a wrong-audience test can finally be written. Aresourcemust be an absolute URI without a fragment or the request isinvalid_target(RFC 8707 §2). New per-clientallowed_resourcesgates which resources a client may target (empty = any valid resource, the dev default, same "empty = unrestricted" convention asallowed_scopes). The authorization code and refresh token remember the bound resources; a/tokenrequest may narrow them to a subset but never widen them. Narrowing the access token does not narrow the refresh token, which keeps the full original grant so a later refresh can still request any resource the authorization covered (RFC 8707 §2.2). Sending noresourceleavesaudatoauth.audience- no change for existing clients./introspectreports the token'saud, and now verifies a token's signature without pinning its audience (so a resource-bound token can be introspected and revoked);/userinfostill requires the OP audience. Noresource_indicators_supporteddiscovery metadata (RFC 8707 defines none). Full support across settings.yaml (allowed_resources), the UI client form and the MCPcreate_client/update_clienttools. - Public clients:
token_endpoint_auth_method: "none"with mandatory PKCE S256 (#188). A client that cannot keep a secret (CLI, desktop app, SPA, MCP client) can now be declared withtoken_endpoint_auth_method: "none":client_secretbecomes optional (ignored - and never a credential - if present), and/tokenidentifies the client byclient_idalone. The protections that stand in for client authentication are enforced regardless of profile:/authorizerequires PKCE withS256(OAuth 2.1 §7.5.1),client_credentialsis refused withunauthorized_client, and refresh tokens always rotate with reuse detection (OAuth 2.1 §4.3.1/§6.1) whateverrefresh_token_rotationsays./revokeaccepts a public client'sclient_idwith an ownership check (the token'sclient_idclaim must match; otherwise still200, nothing revoked - RFC 7009 §2.1 and its privacy guidance)./introspectdeliberately stays authenticated (RFC 7662) and its discovery list does not gainnone; the token and revocation lists do. Full support across settings.yaml, the UI client form, and the MCPcreate_client/update_clienttools (client_secretno longer required when the method isnone). At/tokenthe registered method is enforced, not just recorded (RFC 7591): aclient_secret_basicclient must present its secret over HTTP Basic and aclient_secret_postclient in the request body - the wrong channel is rejected withinvalid_client.client_secret_postis now validated at/token,/introspect,/revokeand/device_authorization; discovery had advertised it forever while the body secret was silently ignored. (The registered method is enforced at all four endpoints - see Breaking Changes, #262.) Confidential clients now authenticate on every grant, includingauthorization_code(RFC 6749 §3.2.1): the code exchange no longer had a client-authentication exemption - a confidential client doingauthorization_code+ PKCE must now present its secret or be re-registered astoken_endpoint_auth_method: none. And access tokens carry aclient_idclaim (RFC 9068 §2.2) binding them to the client they were issued to, as refresh tokens have since 2.2.0.
Fixed
- Rate limiting on
/tokenis enforced for real (#304): the limiter was constructed with no limits and no view was ever decorated, sorate_limit_enabled: truelogged "Rate limiting: enabled (10/minute on /token)" while enforcing nothing - a "metadata never lies" violation. The configuredrate_limit_token_endpointnow actually applies toPOST /token(429 with a JSON body andRetry-After/X-RateLimit-*headers; every other endpoint stays unlimited), and the rate string is VALIDATED at the config boundary: flask-limiter silently ignores a malformed one and falls back to the (empty) defaults, so an unparsablerate_limit_token_endpointnow refuses to load instead of silently recreating the enabled-but-unenforced lie. No fallback value either. The two settings are also configurable from YAML at last (server.rate_limit_enabled,server.rate_limit_token_endpoint) - the fields existed on Settings but no document section carried them, so only the profile could flip them. Behavior change forstricter-dev: that profile has always forcedrate_limit_enabled: true, so its instances now really throttle/tokenat the configured rate (default 10/minute) - the hardening the profile always claimed. - One resolver for SAML attributes; the query surface stops fabricating
and mangling values (#302). The SSO assertion and the attribute-query
assertion resolved a user's attributes through two independent
implementations that had drifted five ways; both now share
services/saml_attributes.py, and the emission of theAttributeStatementis one helper. Three visible corrections, each finishing an existing rule: the query no longer invents<user>@example.comfor a user without an email (#275 - an absent fact is an absent attribute); a custom LIST attribute reaches the XML as oneAttributeValueper entry and a comma-bearing STRING is never split (#134 - the query used to",".joinlists and re-split any string with a comma); an empty collection no longer emits an emptyAttributeelement. The differences that remain between the two surfaces (source_aclonly on the query; no AuthnStatement/SubjectConfirmation/ AudienceRestriction on the query assertion) are deliberate and now documented in a table in the SAML reference. RevocationStoreentries now expire (#288): revoked jtis and rotation family markers lived in two sets that were never swept - every revocation and every refresh rotation on a long-lived instance was a permanent memory increment. Entries now carry an expiry and the store sweeps opportunistically on the mutating paths. A VERIFIED token's ownexpis kept exactly - tokens minted via/api/users/<u>/tokenor MCPgenerate_tokentake arbitrary lifetimes, so no fixed cap is safe for them; a verified payload WITHOUT anexpclaim (whichverify_jwtaccepts) gets indefinite retention, since a token that never expires can never have its revocation forgotten; callers holding only unverified claims (the/logoutid_token_hint) pass nothing and get a bounded 8-day default; and writes are monotonic, so re-revoking a jti can only extend its retention, never shorten it.- The UI users form no longer corrupts non-string attributes on edit
(#291): a list- or mapping-valued custom attribute (settable via YAML and
MCP) rendered in the edit form as its Python repr, so an untouched edit
round-trip silently replaced
{"teams": ["alpha", "beta"]}with the string"['alpha', 'beta']". Each row now carries an explicitattr_encoding[](review round 1):stringvalues stay verbatim even when they LOOK like JSON (so the string'["a"]'survives an edit as a string),jsonrows (container values rendered as JSON) parse back, and rows typed fresh in the browser useauto- the[/{heuristic, with malformed JSON degrading to the literal string. The duplicated attribute-row parser in the create and edit routes is now one shared helper, and the user-field parity test recognizes the widget explicitly instead of excluding the field. - MCP
update_usercan now update customattributes(#280): the field was accepted bycreate_userand returned by every read surface, but theupdate_userschema and handler silently lacked it - an agent could set attributes at creation and never change them again. The new mapping replaces the wholeattributesobject, like every other field there. get_crypto_servicehonourskeys_diron every call (#281): once the singleton existed the argument was silently ignored, so a config reload that changedkeys_dirkept signing tokens and serving JWKS from the old directory. The service is now recreated when the requested directory differs; operator-provided external keys (init_crypto_service) stay authoritative and are never discarded over akeys_dirchange.- The wizard and
initwrite configuration atomically and validated (#282): both used to write raw template text withopen(), bypassing the temp-then-replace primitive every other config writer uses - a crash could leave a torn file, and a template error reached disk unvalidated. Both now validate through the document models before anything touches disk.
2.8.0 - 2026-08-29
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
- MCP callers can make
save_configconflict-checked (#229 phase 5, the MCP leg of the same loop the web UI's forms got in phase 4): the read tools return the revision of the file the runtime was loaded from (list_users/get_usercarryusers_revision;list_clients/get_client/get_settingscarrysettings_revision), andsave_configaccepts them back asexpected_users_revision/expected_settings_revision, refusing the save with{"success": false, "kind": "conflict"}- nothing written - when another writer (the web UI, another agent, a second nanoidp process on the same directory) moved a file since.reload_configand a successfulsave_configreturn fresh revisions, so the retry loop is reload, reapply, save. The revision is deliberately the one the runtime was LOADED from, not the file's hash at ask time: on a runtime that is stale against the directory, a fresh disk hash would pass the precondition exactly when the lost update is real. Becausesave_configalways writes both files, there are exactly two modes: omitting both revisions keeps the save unconditional (last write wins, same as before), and supplying either makes the whole save conflict-checked, with the omitted revision defaulting to the one this runtime was loaded from - a save guarded on one file can never silently overwrite the other from a stale snapshot. - Display-only
descriptionon users, shown in the persona login picker (#244): a user inusers.yamlcan carry an optionaldescription(max 200 characters, plain text) rendered next to the username on every interactive persona picker (/login,/authorize,/saml/sso, the device flow's/device), so a directory of test personas (admin,reader,tenant-a-manager, ...) is self-explanatory at the point of selection. It is a first-class field, not a custom attribute: never a claim, never a SAML attribute, never part of a token. Settable from the UI create/edit form and the MCPcreate_user/create_persona_user/update_usertools, and exposed by the read-only/api/usersresponses.Usernow validates on assignment as well (validate_assignment=True, the ruleOAuthClienthas followed since #37), and the MCPupdate_usertool applies every requested field to a scratch copy before replacing the live user, so one invalid field can no longer leave the user half-updated. Behaviour change for an existing file: adescription:key already present under a user used to fold into that user'sattributesand therefore shipped inside the token'sattributesclaim; it is now the display-only field and no longer appears in any token. - 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.
Changed
ConfigManager.save()writesusers.yamlandsettings.yamlas one coordinated, conflict-checked save (#229): both files' preconditions (an optional content-hash revision per file, for a future caller that supplies one) are checked before either file is written, both are then written, both fire their ownon_config_savedhook, and the running configuration is refreshed from disk exactly once before anyhooks.strictfailure is raised - matching thewrite -> notify -> reload_local -> raisecontract the web UI's writer already had. Previously a hook failure onusers.yamlunderhooks.strictleftsettings.yamlunwritten even when nothing was actually wrong with it; now a hook failure on either file still raises, but by then both files are already saved and the runtime already reflects them - only the mirror push failed. This also closes a gap where MCP'ssave_configtool left the process holding its pre-save view of anything the save's read-modify-write cycle picked up from disk: it now sees the refreshed state too. The precondition revisions were unused when this entry was first written; MCP'ssave_confignow supplies them (see Added). The runtime refresh can itself fail (an in-memory value that bypassed field validation, sinceSettingshas novalidate_assignment, can reach the file and then fail to parse back in) -save()now tells that apart from every other outcome: a newReloadAfterSaveErrormeans both files ARE written but the runtime could not adopt them, and it never replaces or hides a pendinghooks.strictfailure, which keeps priority.save_config's MCP response carries akindfor all three failure shapes (conflict, the hook's own kind, orreload_after_save) so a caller can tell them apart without parsing the error text. The advisory cross-process lock now fails as a namedLockUnavailableError- instead of a bareOSErroror an indefinite hang - when the lock file's filesystem does not support advisory locks or a peer process holds it for more than 10 seconds.- The web UI's writer (
YamlWriter) now uses the same write primitive asConfigManager.save()(#229):save_user,delete_user,save_client,delete_clientand the rest of its write methods route throughcompare_and_replace, and each gained an optionalexpected_revisionprecondition. The practical effect: creating or deleting a user/client now checks "does this already exist / does this exist at all" against the same document it writes, under the same lock, instead of against a copy loaded before the write started. Previously, two near-simultaneous submissions creating the same new user or client could both pass their "already exists" check and the second would silently overwrite the first, with no error to either request; that lost update is now impossible - the second request correctly gets an "already exists" error instead. - Every web UI form that writes
users.yaml/settings.yamlnow carries the revision it was rendered with, and refuses a stale submission (#229 phase 4): create/edit/delete user, create/edit client, regenerate client secret, settings, and authority prefixes all send a hiddenexpected_revisionfield and get a clear "changed since it was last read - please reload and try again" flash instead of silently overwriting someone else's concurrent change (an edit based on a page loaded before another admin deleted or changed the same user/client, for instance). The settings page's OAuth, SAML, identity-classes and login-mode fields are now applied as one write instead of four separate ones, so a conflict there is all-or-nothing, the same guarantee every other form already had - an earlier version of this ran four writes chained by revision, which meant a conflict partway through could leave the earlier sections already saved while the page reported that nothing had changed. A submission with noexpected_revisionat all (an old cached page, a script, the e2e test agent) keeps today's unconditional last-write-wins - nothing about this requires an existing caller to opt in.
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