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]):
- 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. serialization.pyhas 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:
| Module | What it is |
|---|---|
app.py | create_app(): configures the process-wide Flask application (one instance per process, see CONTRIBUTING), blueprint registration, session cookie policy, startup warnings |
__main__.py | The 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.py | The nanoidp wizard interactive configuration builder |
HTTP surfaces (routes/), one blueprint per protocol surface:
| Module | What it is |
|---|---|
routes/oauth.py | OAuth2/OIDC: /authorize, /token (one _grant_* helper per grant), /userinfo, discovery, JWKS, introspection, revocation, device flow |
routes/saml.py | SAML 2.0 IdP: metadata, SSO, SLO, AttributeQuery |
routes/ui.py | The config web dashboard: HTML form flows for users, clients, settings, keys, claims, audit |
routes/api.py | JSON management API (/api/*): health, config, users, keys, audit |
Shared route infrastructure, all underscore-prefixed:
| Module | What it is |
|---|---|
routes/_auth.py | The 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.py | Effective-issuer resolution, shared by every endpoint that mints a token or advertises the issuer (#133: they must never disagree) |
routes/_audit.py | audit_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:
| Module | What it is |
|---|---|
services/token.py | JWT building: access tokens, ID Tokens, the /token response body |
services/userinfo.py | What 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.py | How 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.py | What an introspection reports about a token (RFC 7662 §2.2), including which client it names and the scope it defaults to |
services/identities.py | The 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.py | The 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.py | The 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.py | The 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.py | The 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.py | Authorization 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.py | Device 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.py | Revoked 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.py | Key management: generation, rotation, JWKS, external key import |
services/key_directory.py | The 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.py | Single source of the OIDC discovery document (HTTP and MCP both render this; metadata never lies) |
services/redirect_uri.py | Redirect-URI registration matching, including RFC 8252 native-app rules |
services/saml_verification.py | Signed-AuthnRequest verification |
services/saml_assertion.py | The 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.py | The 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.py | Writes 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:
| Module | What it is |
|---|---|
config.py | ConfigManager: 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.py | ConfigFileStore: 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.py | Pydantic document models mirroring the YAML sections one to one; to_settings() / to_users() build the domain objects |
config_schema.py | JSON Schema generated from the document models; docs/schema/config.v1.json is the committed artifact and a test fails when they diverge |
config_validation.py | nanoidp validate-config: lint a config directory without starting anything (hooks never execute) |
hooks.py | HookRegistry: the extension points (on_before_load, on_config_saved, on_audit_event), shell hooks, entry-point plugins, the bootstrap surface |
models.py | The domain dataclass-style models: Settings, User, OAuthClient |
serialization.py | Domain 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.py | The exception taxonomy |
branding.py | Per-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 optionalbootstrap.yamlfor 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 throughservices/yaml_writer.py; the MCPsave_configtool and reloads persist whole documents throughConfigManager.save(). Both delegate their entry-building toserialization.py, so the two paths cannot drift on content, and both write through the sameconfig_writer.compare_and_replacepipeline (#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 tosave_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 asecret_keyall accept, and the/authorizetransaction 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;sqlitekeeps 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, andSettingswiring 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:
models.py: the field onOAuthClient, with its description.config_documents.py: the field onClientEntryand its coercion into_client().serialization.pyclient_to_yaml: write it when non-empty.serialization.pymerge_client_entry: merge it on edit.templates/clients_form.html: the form control.routes/ui.pyclient create: parse it.routes/ui.pyclient edit: parse it.routes/ui.pyregenerate-secret: carry it through (the #32 leg).mcp_server.py_client_to_dict: expose it.mcp_server.pycreate_clienttool schema.mcp_server.pyupdate_clienttool schema.mcp_server.pycreate/update handlers: normalize and apply it.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.pyis the end-to-end agent: it drives a real server over HTTP and MCP the way an agent would. CI runs it in thehttp-e2eandmcp-e2ejobs, ande2e/shared_store_e2e.pyin theshared-store-e2ejob, 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 bytests/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.