Skip to content

v3.0.0

Latest

Choose a tag to compare

@cdelmonte-zg cdelmonte-zg released this 06 Sep 06:56
· 1 commit to main since this release
a570033

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 on authorization_code too; the registered token_endpoint_auth_method decides the channel (Basic vs body); presenting two methods in one request is rejected. Per-endpoint public-client policy: /introspect refuses public clients (RFC 7662), /revoke keeps its RFC 7009 ownership relaxation, /device_authorization accepts a public client by client_id alone (RFC 8628).
  • One request, one client identity (#277): /introspect, /revoke and /device_authorization reject a Basic-vs-body client_id contradiction, as /token always did.
  • /authorize reports post-redirect_uri-validation errors by redirecting to the client (error, state, iss) instead of a local JSON 400 (#189).
  • A refresh token with no client_id binding is rejected (invalid_grant, #73); tokens minted before 2.2.0 must be re-obtained. The MCP generate_token tool and POST /api/users/<u>/token gain an optional client_id; without it they issue no refresh token.
  • The device_code grant re-validates scope at redemption (#276), like authorization_code and refresh_token already did.
  • The exp claim is required on every token nanoidp accepts (#306): a correctly signed JWT without an expiry is rejected everywhere. Everything nanoidp mints has always carried exp; only hand-crafted tokens signed with the nanoidp key are affected.
  • Every /token error 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 now invalid_grant 400; invalid_client is 401 + WWW-Authenticate: Basic only when Basic was attempted (detected from the raw header), 400 otherwise (RFC 9110 forbids a challenge-less 401).
  • /saml/attribute-query stops inventing principals (#275): an unknown NameID gets a SAML Requester/UnknownPrincipal status, 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 one AttributeValue per 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-dev now 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 and Retry-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_user can update custom attributes (#280); the UI edit form no longer corrupts non-string attributes on an untouched round-trip (#291).
  • get_crypto_service honours a changed keys_dir on reload (#281); the wizard and init write configuration atomically and validated (#282).
  • RevocationStore entries expire under a three-state trust contract and the store sweeps itself (#288).

Architecture (behavior-preserving)

  • mcp_server is a package (#286): schemas, normalizers, serializers and handlers split by domain; the stdio MCP process no longer imports Flask (verify_secret lives in nanoidp.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_token owns the #73 mint-side rule (#278); the MCP generate_token/verify_token simulation boundary is documented and pinned (#279).
  • The e2e harness moved from examples/ to e2e/; the /token grants live in oauth_grants.py behind 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