Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture

This page is the map: where code lives, which direction imports are allowed to flow, where state is kept, and the handful of modules you must know before changing anything. Everything stated as an invariant here is enforced by CI (import-linter, mypy, or a test), not aspired to; when this page and the code disagree, one of the two is a bug.

The shape

nanoidp is a layered Flask application with three entry points sharing one core:

 __main__.py (CLI)      mcp_server.py (MCP stdio)      app.py (WSGI)
        \______________________|______________________/
                               |
                        routes/  (HTTP surfaces)
                               |
                       services/  (protocol logic, runtime state)
                               |
                        config.py  (ConfigManager)
                               |
              serialization.py   models.py   config_documents.py

Two import contracts are enforced by lint-imports in CI (declared in pyproject.toml under [tool.importlinter]):

  1. Layers: routes -> services -> config. A route may import services and config; a service may import config; nothing imports upward. In practice: services/ never imports Flask objects or route modules, so every service is testable without a request context.
  2. serialization.py has no runtime package imports. It is pure YAML-shaping code; its only tie to the rest of the package is type-checking-only annotations. If you make it import a package module at runtime, CI fails.

Everything under src/ is fully type-annotated (disallow_untyped_defs in [tool.mypy]).

Package map

Entry points:

ModuleWhat it is
app.pycreate_app(): configures the process-wide Flask application (one instance per process, see CONTRIBUTING), blueprint registration, session cookie policy, startup warnings
__main__.pyThe nanoidp CLI: serve, init, wizard, validate-config, config-schema, plugins
mcp_server/The nanoidp-mcp stdio server: tool declarations and handlers. It keeps no configuration of its own: the tools resolve the process's ConfigManager (see config.py)
wizard.pyThe nanoidp wizard interactive configuration builder

HTTP surfaces (routes/), one blueprint per protocol surface:

ModuleWhat it is
routes/oauth.pyOAuth2/OIDC: /authorize, /token (one _grant_* helper per grant), /userinfo, discovery, JWKS, introspection, revocation, device flow
routes/saml.pySAML 2.0 IdP: metadata, SSO, SLO, AttributeQuery
routes/ui.pyThe config web dashboard: HTML form flows for users, clients, settings, keys, claims, audit
routes/api.pyJSON management API (/api/*): health, config, users, keys, audit

Shared route infrastructure, all underscore-prefixed:

ModuleWhat it is
routes/_auth.pyThe login-session and management-gate choke point: establish_login_session is the single writer of the login session (session['user'] and session['auth_method'], which the SAML AuthnContextClassRef is derived from; every surface that establishes a UI session calls it, #301), plus the require_ui_login session gate and the opt-in management_secret write guard for /api/*, the UI unlock, and MCP
routes/_issuer.pyEffective-issuer resolution, shared by every endpoint that mints a token or advertises the issuer (#133: they must never disagree)
routes/_audit.pyaudit_event(...): shared helper for route-level audit events (not yet universal: /api/keys/rotate still logs directly)

Protocol logic and runtime state (services/), each module small and single-purpose:

ModuleWhat it is
services/token.pyJWT building: access tokens, ID Tokens, the /token response body
services/userinfo.pyWhat the bearer of an access token may see at /userinfo: the scope-to-claim gating, the claims nanoidp has no standard scope to gate by, the raw attributes passthrough, the claims request parameter
services/client_policy.pyHow a client's token_endpoint_auth_method and secret go together: a public client has no secret, a confidential one cannot be without it, and the order an existing client is moved through (the model validates on assignment). The rest of what is_public decides stays at the endpoints that apply it, each with its own RFC (#300)
services/introspection.pyWhat an introspection reports about a token (RFC 7662 §2.2), including which client it names and the scope it defaults to
services/identities.pyThe effective identities: declared users and clients composed with runtime ones. Every login, grant and client check resolves users and clients here (identities_for(config)), with the rules in one place: declared first, no runtime object under a declared name, declared wins on reload. The observation surfaces (/api/users, the persona picker, the UI lists) show declared and runtime objects with their origin; the edit forms and MCP work on the declared configuration. The lifecycle rules (promotion order, reconciliation with its audit) live here too. A promotion's claim is a hold on the object's entry in the store, not state of this process, so a delete, a reset or a reload in another process sharing the store sees it, and whoever retires the object records the promotion. A runtime client is created and deleted by instance (create_runtime_client_entry, delete_runtime_client(id, instance_id)), which is what lets a record kept about one, such as a dynamic registration, stay about that one and never about a later client of the same id
routes/runtime.py/api/runtime: create, read, delete, reset and promote runtime users and clients, mapped onto the resolver's rules; no update (the store is by value). Same management gate as /api
services/runtime_repository.pyThe contract all runtime state is kept behind, and its in-memory backend: objects of any type whose codec (copy, write down, read back) the owning service declares, by name and by value, each in an entry with an instance_id the store generates and never reuses, an optional hold and an optional time at which it becomes removable (so that a cleanup and a count never read a value), and transact(decide), which runs one decision against a view of one repository so that its changes appear together or not at all. replace, consume, delete_if, delete_where, delete_expired and create_within are written once on top of it, the last two on what the store knows about its entries, with no value read. The OAuth semantics stay in the services that write the decisions; a durable backend (#354) implements transact and inherits the rest
services/runtime_store.pyThe runtime store, the one owner of the state of a running IdP that is not declared in YAML: runtime users and clients, the repositories it lends to services that own a record type of their own (authorization codes, device grants, revocations, registrations, the CIMD cache), and the audit. Two concurrency domains: the repository state shares one lock, the audit has its own
services/audit_store.pyThe audit's own contract behind that boundary (AuditStore), its event and the in-memory backend: a bounded sequence in the order of the appends, the event and its counters kept as one step, by value, and never reached from inside a repository decision
services/sqlite_runtime_store.pyThe runtime store in a pair of SQLite files, for several NanoIDP processes on one host (#354): the repositories in one, the audit in another (SqliteAuditStore, one file has one writer and the audit must not wait for the repositories). One table for every repository, its rows in the order of creation; a decision is a BEGIN IMMEDIATE transaction; WAL with synchronous=NORMAL; one connection per thread, and none across a fork (an at-fork hook waits for the operations in progress and closes them all); the file and its -wal and -shm private, and a file that is not a NanoIDP runtime store refused. Chosen by runtime.store: sqlite and runtime.path (relative to the configuration directory) in settings.yaml. Owner leases (runtime-owners/) prove a promotion's writer dead, so that its claim can be recovered (#354)
services/auth_code.pyAuthorization codes (PKCE data rides on the code): a view, with no state of its own, over a repository the runtime store lends. Redeeming a code is one decision of that repository's
services/device_code.pyDevice authorization state (RFC 8628): a view, with no state of its own, over two repositories the runtime store lends, the grants by device code and an index from the user's code that names the grant's instance. Every transition is one decision on the grant; a poll resolves the user outside it
services/revocation.pyRevoked token ids and refresh-rotation families: a view, with no state of its own, over ONE repository the runtime store lends. Both are markers under typed names (jti:<id>, family:<id>), because the refresh grant's check-and-claim reads and writes both and has to stay one decision; how long a marker is remembered is its entry's expires_at, and None there is for ever
services/crypto.pyKey management: generation, rotation, JWKS, external key import
services/key_directory.pyThe generated keys' directory as state several processes share: one lock across processes, a bundle loaded whole as kid.txt names it, a cold start with one winner, and a rotation as a small journaled transaction whose commit point is the replacement of kid.txt and which the next process to take the lock recovers. Bytes in, bytes out: it knows nothing of JWT or SAML
services/discovery.pySingle source of the OIDC discovery document (HTTP and MCP both render this; metadata never lies)
services/redirect_uri.pyRedirect-URI registration matching, including RFC 8252 native-app rules
services/saml_verification.pySigned-AuthnRequest verification
services/saml_assertion.pyThe parts of a SAML Response every builder spells the same way: the envelope, the Issuer pair, the Status element and the assertion's head. Deliberately not the Conditions: the validity window differs by surface and is declared in the SAML reference instead (#317)
services/audit.pyThe audit log: a facade, with no state of its own, over the runtime store's AuditStore. Which counters an event increments, the shape of the statistics, and what follows an append, outside any lock: the Python log line and the on_audit_event hooks
services/yaml_writer.pyWrites the YAML files back for the UI's per-field saves. Not the only write path: ConfigManager.save() persists whole documents through serialization.atomic_write_yaml too - both build their entries in serialization.py, but the read-modify-write itself has two owners today (a known debt, tracked for a single write pipeline with conflict detection)

The config layer and the pure bottom:

ModuleWhat it is
config.pyConfigManager: owns loading, reloading and handing out Settings/users; loads are transactional (a failed reload commits nothing). One manager per process (init_config/get_config): the routes, the MCP tools and the token service all resolve it, and none of them keeps a manager, or a signing service derived from its settings, from the moment it was first built
config_store.pyConfigFileStore: filesystem access to ONE configuration directory. Bytes and their revisions come back as one observation, taken under the directory lock, for readers and writers alike (#246). It owns observations, not configuration semantics: parsing, environment expansion and the document models stay outside it, and outside the lock, because the atomic unit is the directory snapshot rather than the whole reload. A read never abandons an available protocol: contention and a filesystem without advisory locking both fail. A read-only mount keeps working by reopening the lock file read-only, so it still takes part; only a view that can hold no lock file at all, or a missing directory, reads unlocked, neither having a writer to be inconsistent with Each snapshot carries the fingerprint of the file its bytes came from (a fast negative for ConfigManager.refresh_if_changed, #354); locked() with read_snapshot_within_lock is how a caller observes and acts under one acquisition, the lock being non-reentrant
config_documents.pyPydantic document models mirroring the YAML sections one to one; to_settings() / to_users() build the domain objects
config_schema.pyJSON Schema generated from the document models; docs/schema/config.v1.json is the committed artifact and a test fails when they diverge
config_validation.pynanoidp validate-config: lint a config directory without starting anything (hooks never execute)
hooks.pyHookRegistry: the extension points (on_before_load, on_config_saved, on_audit_event), shell hooks, entry-point plugins, the bootstrap surface
models.pyThe domain dataclass-style models: Settings, User, OAuthClient
serialization.pyDomain objects <-> YAML dicts, both directions, no runtime package imports; OWNED_SETTINGS is the one table of managed settings.yaml keys, pinned to the models by parity tests (#214)
exceptions.pyThe exception taxonomy
branding.pyPer-client logo resolution (local assets only, containment-checked)

Where state lives

There are exactly two kinds of state, and they never share a store:

  • Declared configuration is the two YAML files (settings.yaml, users.yaml, plus the optional bootstrap.yaml for hooks/plugins). It is schema-versioned (config_version), validated through the document models, editable by hand, and meant to be committed to git. The UI's per-field saves go through services/yaml_writer.py; the MCP save_config tool and reloads persist whole documents through ConfigManager.save(). Both delegate their entry-building to serialization.py, so the two paths cannot drift on content, and both write through the same config_writer.compare_and_replace pipeline (#229): each write can carry the revision the caller's state was read at, and a stale one is refused instead of overwriting a concurrent change - the UI's forms submit it as a hidden field, MCP callers pass it to save_config. Without a revision a write stays unconditional (last write wins), stated as such.
  • Runtime state lives behind one boundary (services/runtime_store.py): authorization codes, device codes, revocation and rotation families, the audit log, runtime users and clients, whatever the protocols and the management surfaces keep. Outside it, and process-local on purpose: the rate limiter's counters and the CIMD fetch budget. The login session is not among them: it is a signed cookie, which processes sharing a secret_key all accept, and the /authorize transaction it names lives in the store. Which store holds it is configuration: memory, the default, keeps it in the process and loses it at restart; sqlite keeps it in files that several processes on one host share (see Two processes, one runtime state). Neither is a database of record: an instance is disposable (see Vision), no durability is promised, and the SQLite store is for test state that outlives a restart, not for keeping anything.

ConfigManager.load() is transactional: parse and validate first, swap the live objects only on success. A failed reload leaves the previous configuration serving. The transaction includes the signing service (#359): a configuration becomes active only if its signing service can be built. The load prepares that service from the candidate settings (reusing the running one when its inputs are unchanged), then applies the hooks and plugins, the last step that can fail, then publishes the service, then the settings. Readers take the settings first and the signing service second (get_crypto_service() has no arguments), so a request can pair older settings with a newer key but never newer settings with an older key. create_app and the MCP server pass that activation step to init_config; config itself never imports services.

The five modules to know before changing anything

  • routes/_auth.py: every management mutation (UI form, /api/*, MCP tool) funnels through here. If you add a mutating endpoint and do not touch this file, ask yourself why.
  • routes/_issuer.py: any new endpoint that mints or advertises tokens resolves the issuer here, or it will disagree with discovery behind a reverse proxy.
  • services/discovery.py: the discovery document is built once, here. Advertise a capability by implementing it and adding it here; never edit an endpoint's advertisement separately.
  • hooks.py: the supported way to integrate external systems. New integration points should be hooks, not backends (a VISION non-goal).
  • config_documents.py: the YAML contract. A config key exists when it is a field here; the schema, validation, and Settings wiring all follow from it.

Adding a field to OAuthClient: the full flow

This is the most-repeated multi-site change in the project, and the place regressions have historically come from (#32: the regenerate-secret leg was missed and the field silently dropped). A new client field touches all of these:

  1. models.py: the field on OAuthClient, with its description.
  2. config_documents.py: the field on ClientEntry and its coercion in to_client().
  3. serialization.py client_to_yaml: write it when non-empty.
  4. serialization.py merge_client_entry: merge it on edit.
  5. templates/clients_form.html: the form control.
  6. routes/ui.py client create: parse it.
  7. routes/ui.py client edit: parse it.
  8. routes/ui.py regenerate-secret: carry it through (the #32 leg).
  9. mcp_server.py _client_to_dict: expose it.
  10. mcp_server.py create_client tool schema.
  11. mcp_server.py update_client tool schema.
  12. mcp_server.py create/update handlers: normalize and apply it.
  13. nanoidp config-schema --write: regenerate the committed schema (a test fails if it is stale).

Then: tests for the YAML round-trip and the persist-through-edit and regenerate-secret paths, MCP parity coverage, and an e2e/test_agent.py scenario, in the same PR (features ship whole). #214 settled how this flow is protected: no code-generating registry - the declarative surfaces (the YAML load contract, the MCP read surface and tool schemas, the form template) are pinned to OAuthClient.model_fields by tests/test_client_field_parity.py, the regenerate-secret leg is safe by construction (model_copy), and the imperative legs (form parsers, MCP handler bodies, the YAML entry builders) stay covered by the per-feature tests this list requires.

Tests

  • tests/ (unit and integration through the Flask test client) runs in CI on Python 3.10/3.11/3.12, with coverage uploaded to Codecov.
  • e2e/test_agent.py is the end-to-end agent: it drives a real server over HTTP and MCP the way an agent would. CI runs it in the http-e2e and mcp-e2e jobs, and e2e/shared_store_e2e.py in the shared-store-e2e job, which is the only one that starts two servers, over one SQLite runtime store. When a test creates state through the UI it must use the shared session (self.session), or the management gates will silently redirect it.
  • Test isolation from the repo's own config/ directory is enforced by tests/conftest.py (it resets the config and yaml-writer singletons); a test must never read or write the repo's config.

Docs layout

The mdBook site under book/ is canonical for guides and reference. docs/SECURITY.md, docs/MCP_WORKFLOW.md, CHANGELOG.md, CONTRIBUTING.md and VISION.md are the canonical files for their content and are symlinked into book/src/; edit the file at the repo root or under docs/, never the symlink target's copy. The site redeploys automatically on merges touching book/ or docs/. docs/RELEASING.md is the maintainers' release manual and stays out of the site on purpose.