The 3.0.0 final. pip install nanoidp now resolves 3.0.0, and GHCR :latest moves to it.
pip install nanoidp==3.0.0
docker pull ghcr.io/cdelmonte-zg/nanoidp:v3.0.0 # or :latest
3.0.0 is the rc3 candidate (rc1 2026-08-29, rc2 2026-08-30, rc3 2026-08-31), soaked clean. It is a major because it tightens the OAuth client-authentication contract and a few other protocol edges; the CHANGELOG's Breaking Changes section is the migration guide. In practice: a confidential client that already presents its registered method over the matching channel, and any public client, are unaffected.
Breaking changes (read before upgrading)
- Client authentication is enforced at every client-authenticated endpoint (
/token,/introspect,/revoke,/device_authorization; #188, #259, #262). A confidential client must authenticate onauthorization_codetoo; the registeredtoken_endpoint_auth_methoddecides the channel (Basic vs body); presenting two methods in one request is rejected. Per-endpoint public-client policy:/introspectrefuses public clients (RFC 7662),/revokekeeps its RFC 7009 ownership relaxation,/device_authorizationaccepts a public client byclient_idalone (RFC 8628). - One request, one client identity (#277):
/introspect,/revokeand/device_authorizationreject a Basic-vs-bodyclient_idcontradiction, as/tokenalways did. /authorizereports post-redirect_uri-validation errors by redirecting to the client (error,state,iss) instead of a local JSON400(#189).- A refresh token with no
client_idbinding is rejected (invalid_grant, #73); tokens minted before 2.2.0 must be re-obtained. The MCPgenerate_tokentool andPOST /api/users/<u>/tokengain an optionalclient_id; without it they issue no refresh token. - The
device_codegrant re-validates scope at redemption (#276), likeauthorization_codeandrefresh_tokenalready did. - The
expclaim is required on every token nanoidp accepts (#306): a correctly signed JWT without an expiry is rejected everywhere. Everything nanoidp mints has always carriedexp; only hand-crafted tokens signed with the nanoidp key are affected. - Every
/tokenerror answers RFC 6749 §5.2 JSON (#308, #311): roughly twenty branches used to answer Werkzeug HTML. Grant failures that used to 401 (revoked or foreign refresh token, unknown user, wrong password) are nowinvalid_grant400;invalid_clientis 401 +WWW-Authenticate: Basiconly when Basic was attempted (detected from the raw header), 400 otherwise (RFC 9110 forbids a challenge-less 401). /saml/attribute-querystops inventing principals (#275): an unknown NameID gets a SAMLRequester/UnknownPrincipalstatus, never a fabricated assertion; malformed requests answer SOAP 1.1 Faults (#287). The endpoint's docs now state plainly that it is unauthenticated by design.- SAML attribute surfaces share one resolver (#302): no fabricated
<user>@example.com, list attributes emit oneAttributeValueper entry, comma-bearing strings are never split (#134 completed), empty facts are absent. The deliberate differences between SSO and query assertions are documented in a table in the SAML reference. stricter-devnow really rate-limits/token(#304): the profile always claimed it;rate_limit_token_endpoint(default 10/minute) is now enforced with a 429 JSON body andRetry-After. Both settings are YAML-configurable at last (server.rate_limit_enabled,server.rate_limit_token_endpoint), and an unparsable rate string refuses to boot instead of silently disabling the limit.
Highlights
The OAuth/MCP interoperability foundation. RFC 8707 resource indicators bind the access token audience (resource= on /authorize, every /token grant and /device_authorization; per-client allowed_resources; #187, with per-component RFC 3986 validation in #257). RFC 9207 iss on every authorization response delivered through a validated redirect_uri, advertised in discovery exactly when it is sent (#189). Public clients (token_endpoint_auth_method: "none") with mandatory PKCE S256 for authorization_code (#188) and the device flow (#255, RFC 8628; the device-code store is now capacity-bounded). Access tokens carry a client_id claim (RFC 9068).
A mock protected MCP server as an e2e fixture (#191). e2e/mock_mcp_server.py is a minimal MCP Streamable HTTP resource server validating bearer tokens JWKS-only against nanoidp, serving RFC 9728 protected-resource metadata, with scope-gated tools; e2e/test_agent.py --mcp drives the whole loop as the MCP client (401 -> discovery -> /authorize with PKCE and resource= -> /token -> tools/call), including wrong audience, insufficient scope, refresh narrowing, revocation-until-exp and key rotation. New guide: "Testing an MCP client against nanoidp".
Auto-login personas (#250, #318 by @gprossliner). With login.mode: persona and login.auto_login: true, an OIDC /authorize request whose login_hint is persona-auto-login:USERNAME authenticates that persona directly and returns the code - no picker, no HTML - so a real OIDC client library can be driven in automated integration tests. Off by default; three stacked opt-ins gate it; an unknown persona surfaces as a standard OAuth error redirect. Ships with settings UI, /api/config (new login block), MCP get_settings/update_settings, docs and an examples/persona-login walkthrough.
Per-client horizontal /authorize layout (#249, #274 by @dshvedchenko): a new layout: "horizontal" client field places the client info block and the login form side by side; "vertical" (default) is unchanged.
Contract tests that make a class of bugs impossible. Token-issuance access-point parity (#283: every create_token call site must declare a stance on client binding, scope and resource ceilings), user-field parity across the nine user shapes (#284), a /token error-body table and rate-limit enforcement pins. A "domain invariants have one home" review rule in CONTRIBUTING (#285).
Fixes
- MCP
update_usercan update customattributes(#280); the UI edit form no longer corrupts non-string attributes on an untouched round-trip (#291). get_crypto_servicehonours a changedkeys_diron reload (#281); the wizard andinitwrite configuration atomically and validated (#282).RevocationStoreentries expire under a three-state trust contract and the store sweeps itself (#288).
Architecture (behavior-preserving)
mcp_serveris a package (#286): schemas, normalizers, serializers and handlers split by domain; the stdio MCP process no longer imports Flask (verify_secretlives innanoidp.security). Tool surface proven byte-identical.- One error shape per surface class (#287): the dead 20-class exception hierarchy is gone; the "Error surfaces" contract is in CONTRIBUTING.
TokenService.create_tokenowns the #73 mint-side rule (#278); the MCPgenerate_token/verify_tokensimulation boundary is documented and pinned (#279).- The e2e harness moved from
examples/toe2e/; the/tokengrants live inoauth_grants.pybehind one shared client-authentication boundary.
Known limitation
Concurrent configuration readers on Windows can transiently fail to open a file while a write replaces it (#246, carried over from 2.8.0, unchanged in this release). Not data corruption; POSIX is unaffected.
What's next
The 3.1 quick wins, the 3.3 one-home wave (#300, #297, #303) and the agentic roadmap (#230 -> #235 -> #192 -> #190/#196 -> #193) are tracked in the milestones. None affect this release.
Thanks
Thanks to @gprossliner (#318) and @dshvedchenko (#274) for the external contributions in this release, and to both for soaking the release candidates.
Full changelog: v2.8.0...v3.0.0