Two windows of pacod-id's Accounts page at id.demo.pacod.io, one light and one dark, overlapping, each headed pacod-gw · AI Gateway, with account and administration links on the left, a search over all accounts, one row for admin@pacod.demo with a Send reset link button, and a Create an account form.
The accounts screen of pacod-id's admin pages by day and by night: find any account, see when it last signed in, send a reset link or create one. The pages wear the brand of their realm, and the demo's one realm for every product has the gateway's, hence the pacod-gw mark.

pacod-id is the OpenID Connect provider behind the PACOD products: one pool of accounts, hosted sign-in and account pages, organisations and role bindings. Its tokens say who someone is, not what they may do: each product follows a feed of the role bindings and decides access itself, with one rules engine written in Rust, TypeScript and Python.

Identity and access

  • Licence: Apache-2.0
  • Version: 0.1.0 · Preview
  • Written in: Rust, TypeScript, Python
  • Tech stack: Rust, axum, PostgreSQL, OpenID Connect, SvelteKit, Docker, Helm
  • View on GitHub · Open an issue

Features

OpenID Connect provider

The authorization code flow with PKCE for every client, clients listed in configuration and no consent screen, run locally against the OpenID Foundation's conformance suite.

One account pool

People, service accounts, organisations, invitations and role bindings shared by every product; a person's sub is the same everywhere.

Authorization feed

Products load a snapshot of the bindings and long-poll the changes, so a lost role or an ended session reaches them without a call per request.

One rules engine, three ports

Manifests, scopes, allowed, has_role, touches and can_grant in Rust, TypeScript and Python, all held to the same YAML test vectors.

Keys and tokens

RS256 or ES256 on ring, signing keys sealed under a key-encryption key and rotated every 90 days, refresh tokens that rotate and catch reuse.

Hosted pages

Sign-in, sign-up, account and admin pages in the realm's brand, in four languages, light and dark, under a strict Content-Security-Policy.

Drop-in clients

A Rust crate, a Python package with FastAPI dependencies, and a backend-for-frontend sign-in for SvelteKit 2 and 3.

Ready to run

A static image on distroless, a development stack with a mail sink and a mock Google, and a Helm chart.

The platform diagram with pacod-id lit and the other projects dimmed; gold dotted lines join it to pacod-ap, pacod-cms, pacod-kb and pacod-gw.
Where pacod-id sits. pacod-ap, pacod-cms, pacod-kb and the pacod-gw console sign people in through it and take their roles from it (gold dots). Dimmed are the rest of the platform: LLM calls through pacod-gw (green), the people who use the products (grey) and pacod-benchmark measuring the gateway (coral).

Screenshots

A light sign-in card at id.demo.pacod.io with the GW mark, the words Sign in and Welcome back, email and password fields, a Keep me signed in box, a green Sign in button and a Forgot your password link; below it the language and theme choices and the words PACOD-GW · AI GATEWAY.
The hosted sign-in page, where every product sends its people, in four languages and light, dark or the system's theme. It wears its realm's brand: the demo has one realm for every product, in the gateway's brand, and no sign-up link, since its accounts come by invitation.
The light Organisations and roles page with a Your roles table of seven rows, each Everywhere and dated Oct 27, 2026; the roles are admin, superuser, admin, platform admin, platform admin, admin and admin, for the products id, pacod, pacod, gw, cms, kb and bench.
Organisations and roles, as a person sees their own. The demo's admin holds pacod-id's own admin role and an administrator's role in every product (pacod is pacod-ap's, bench pacod-benchmark's), each at the platform, shown as Everywhere. Every product reads these bindings from the feed.
The dark Audit log page headed pacod-gw · AI Gateway, with Action, By and Concerning filters over a list of login_succeeded entries, each with its time, an account id, a client address and the account concerned.
The audit log in the dark theme: sign-ins and failures, sign-outs, account, role, invitation, token and key changes, each with who, about whom and from which address. It is kept 400 days, filtered by action, actor and subject, and read by holders of id.admin and id.auditor.
The light Password and security page headed pacod-gw · AI Gateway, with a Change your password form of current and new password and a Save button, and a Delete your account panel that asks for the address admin@pacod.demo to be typed to confirm.
Password and security, one of the account pages every person has. A new password signs the other devices out; deleting the account, after a sign-in in the last ten minutes, removes it from pacod-id and tells every product to erase what it holds about the person.

Why it exists

Every product that lets people in ends up with its own passwords, account mails, admin pages and role tables, and every person with one account per product. pacod-id takes all of that out of the products. People sign in once and reach the gateway console, the agent platform, the CMS studio, the knowledge base and the benchmarks with the same account. Machines sign in too, as service accounts.

It is an OpenID Connect provider for your own, first-party applications. Every client is listed in its configuration: there is no dynamic registration and no consent screen. There is one account pool, and a person's sub is the same in every realm. What differs from product to product is how sign-in looks and who may do what there, and pacod-id records both.

Realms, brands and clients

A realm is one issuer host with its own brand, sign-in cookie, languages and policies: password minimums, session length, whether anyone may sign up, and which roles may manage accounts. pacod-id picks the realm from the request's Host, so two realms can share one account pool and still look like two products.

A realm's brand is one of five built in: gateway, cms and kb for the PACOD products, and shop and bazaar for the fictional product the documentation and tests use. It sets the pages' mark, wordmark and colours, and the layout of the mails. The brand belongs to the realm, not to the client, so every application of a realm shows the same look. pacod-demo runs one realm, pacod, for every product and gives it the gateway's brand: that is why its sign-in and account pages carry the pacod-gw mark, whichever product sent the person.

A client is one application, of one of three kinds:

Kind Typical use Grants
confidential A product's backend-for-frontend, which keeps a secret authorization_code, refresh_token
public A native or mobile app; PKCE alone protects its codes authorization_code, refresh_token
service A product's server talking to pacod-id client_credentials, token exchange

A sign-in, step by step

  1. The product starts it. Its server sends the browser to /oidc/authorize with state, a nonce and a PKCE challenge. Only the authorization code flow is offered, and PKCE (S256) is required of every client, confidential ones included.
  2. pacod-id checks the request. The realm comes from the Host; the client must belong to it, and the redirect URI must match a registered one exactly. Until both are known to be right, a mistake shows an error page and never redirects.
  3. Single sign-on, or a flow. A live session of the realm gets a code at once. Otherwise pacod-id stores the request as a sign-in flow for 15 minutes, binds it to the browser with a cookie, and sends the browser to its hosted /login page.
  4. The person signs in, with a password or, where the realm has it, with Google. Each attempt takes its place in throttles kept in PostgreSQL before the password is hashed, and an unknown address gets the same answer as a wrong password. When the address must be confirmed first, or the password is due for a change, the flow goes there.
  5. A session and a code. The sign-in creates a new SSO session (12 hours, or 7 days with "keep me signed in") and a code that lives 60 seconds, works once, and is bound to the client, the redirect URI and the PKCE challenge. The browser returns to the product with code, state and iss.
  6. The product redeems it at /oidc/token, usually on the internal listener, and receives an access token, an id_token and a refresh token. Their issuer is still the realm's public URL.

Tokens

  • Access tokens are JWTs (RFC 9068), RS256 or ES256 per client, valid 5 minutes by default (2 to 15). They say who, in which session and with which scope, but carry no roles.
  • id_tokens live 5 minutes and carry the address and name when the scopes ask for them.
  • Refresh tokens are opaque and rotate on every use. A token presented again within 30 seconds gets the same successor, so two tabs refreshing at once both succeed. Presenting an older one ends every session of the account, tells every product, and mails the owner.
  • Service tokens (pidt_…) belong to service accounts, are shown once and stored as HMACs.
  • Token exchange (RFC 8693) lets a product's service act for a person, within that person's rights, with act naming the service. A delegated grant ends with the person's session.

When a session ends, every client that had a grant in it and has a back-channel logout URI receives a logout token, retried for ten minutes.

Roles stay out of tokens

Roles are kept out of access tokens on purpose: a role taken away should stop counting at once, not when a token expires. Each product pushes a manifest at every start: its scope types, its roles and their permissions, and who may grant what. Scopes form a tree, from platform, optionally through organisation:<id>, down to the product's own types. This is the gateway's, abridged:

product: gw
scope_types:
  workspace: { parent: organisation }
roles:
  platform_admin:
    at: [platform]
    permissions: ["*"]
    protected: true
  org_admin:
    at: [organisation]
    permissions: [org.manage, org.read, workspace.manage, workspace.read,
                  audit_records.read, audit_log.read, members.manage]
    protected: { bypass: platform.manage }
  viewer:
    at: [platform, organisation, workspace]
    permissions: [org.read, workspace.read]
grant:
  default: members.manage
  platform_admin: platform.manage

A binding is a role held at one scope, such as gw.viewer at gw.workspace:ws_1, and its permissions apply downward: a role held at an organisation counts in every workspace below it. pacod-id stores the bindings and checks every change. Giving a role needs the manifest's grant permission at that scope (here an organisation admin gives roles inside the organisation, and only a platform admin makes platform admins), and a protected role never loses its last holder. Where a manifest allows them, the product's administrators can add custom roles, which give no permission of their own but count for has_role.

pacod-id's own manifest is built in: id.admin may do everything in pacod-id and is protected, and id.auditor reads accounts and the audit log. A realm can also name product roles whose holders manage the accounts that signed up there; the demo names the products' administrators. Such an administrator manages only accounts all of whose roles it could have given itself, so a product's admin cannot take over an identity admin.

The rules engine answers allowed, has_role, touches and can_grant, among others. It exists in Rust, TypeScript and Python, and all three pass the same YAML test vectors (policy/engine-vectors/). The vectors are the specification, so a decision is the same wherever it is made.

The authorization feed

Each product keeps its own copy of the bindings and follows the changes:

  1. At start it loads a snapshot: its catalogue, the organisations and its own scopes, the bindings of its roles and of id.* roles, disabled and deleted accounts, and the sessions revoked in the last 15 minutes, all as of one sequence number, seq.
  2. It then long-polls the changes after that seq, waiting up to 30 seconds per call. The answer comes as soon as something changes: PostgreSQL LISTEN wakes the waiting request.
  3. Changes are kept for 7 days. A cursor older than that gets a 410, and the product loads the snapshot again.
{
  "seq": 1843,
  "events": [
    { "seq": 1843, "kind": "binding.removed", "principal": "usr_0191…", "role": "gw.viewer",
      "path": ["platform", "organisation:org_1", "gw.workspace:ws_1"] }
  ]
}

Events cover bindings, accounts (disabled, enabled, deleted, changed), sessions, service tokens, scopes and catalogues. Writes answer with the feed's seq, so a product that must read its own write waits until its copy has caught up. The Rust and Python client libraries do all of this in their bindings cache. Access tokens carry the session's sid, which a product refuses as soon as the feed carries its revocation.

Measured with make measure on one developer machine (Docker Desktop on Apple silicon), a change reached a waiting follower with a p99 of about 10 ms, against a target of under a second. That is pacod-id's own share: the products' target of two seconds from sign-out to refusal depends on each product's follower.

The pieces

Part What
crates/pacod-id The service: OpenID Connect provider, accounts, organisations, role bindings, the authorization feed, mail, audit
crates/pacod-id-authz The rules engine
crates/pacod-id-jose Compact JWS (RS256, ES256) on ring, and JWKS
crates/pacod-id-client Token checks, the bindings cache, introspection, token exchange for Rust products
python/ The pacod_id package: rules engine, token checks, bindings cache, FastAPI dependencies
packages/* @pacod/id-authz, @pacod/ui, @pacod/id-client, @pacod/id-sveltekit
ui/ The hosted sign-in, account and admin pages

The service is one Rust binary on axum with three listeners:

Listener Port For
public 8070 Browsers and apps, through a TLS reverse proxy: the OpenID Connect endpoints and the hosted pages
internal 8071 Products' servers only: tokens, the feed, introspection, session revocation, the admin API
metrics 9170 /healthz, /readyz and /metrics

The hosted pages are a static SvelteKit build that the binary serves itself, with the realm's brand, language and theme filled in before the first paint. People find their profile, password, devices and sessions, sign-in methods, and organisations and roles there; administrators find accounts, service accounts and the audit log. @pacod/ui carries the design tokens, brand themes and sign-in components, for the products that use pacod-id to share.

Security model

The main controls, from the threat model:

  • Passwords: Argon2id at OWASP's minimum (19 MiB, 2 passes), 12 characters by default, and a blocklist of 46,528 common passwords. Sign-in throttles live in PostgreSQL, shared by every instance: per account, per known browser and per IP address.
  • No account probing: sign-in, password reset and sign-up answer alike whether or not an address has an account.
  • Signing keys: made by pacod-id, sealed in the database under a key-encryption key (PID_KEK), rotated every 90 days with the next key published a day early. An emergency rotation is one command.
  • Stored secrets: refresh tokens and mail links as SHA-256, client secrets and service tokens as HMACs under a separate pepper. pacod-id's own log lines never carry a password, token or key, and it writes no line per request.
  • Browser flows: PKCE, state, nonce and iss on every redirect; exact redirect URIs; a Content-Security-Policy built from the hashes of the pages' scripts; writes accepted only from the realm's own origin.
  • Exposure: the public listener serves no internal or admin routes. In the full pacod stack the internal listener binds to pacod-id-internal, an alias pacod-id has only on the internal network pacod-admin-net, which only products' servers join. The Helm chart's NetworkPolicy admits the internal port only from the sources you list.
  • Image: a static musl binary on distroless, run as non-root, without a shell.

The threat model also lists what remains, each marked open or accepted: the internal listener speaks plain HTTP, any product's service may end any session, and organisations are shared between products.

Running it

Realms and clients live in one TOML file; secrets never do. A client's secret comes from the variable its secret_env names, and ${NAME} takes a value from the environment.

[[realm]]
name = "gateway"
issuer = "https://id.example.com"
brand = "gateway"
locales = ["en", "bg", "de", "ru"]
self_signup = false

[[client]]
id = "pacod-gw-console"
realm = "gateway"
kind = "confidential"
secret_env = "PGW_UI_CLIENT_SECRET"
redirect_uris = ["https://console.example.com/auth/callback"]
audience = "pacod-gw"

pacod-id check-config checks the file without a database. Beyond it, the service needs PID_DATABASE_URL, PID_KEK and PID_TOKEN_PEPPER, each given as a variable, a file or a secrets directory.

  • Database: PostgreSQL, with a role of its own that is not a superuser. Migrations only add, and run at every start under an advisory lock.
  • Instances: any number share one database. Each sends due mails and logouts and reloads keys and catalogues; one, holding a lease, rotates keys and removes old rows.
  • Mail: no request waits for SMTP. Mails go to an outbox, in the realm's brand and languages, and are retried for about eight hours.
  • First administrator: created at start from PID_BOOTSTRAP_ADMIN_*, with id.admin by default.
  • Watching: /readyz checks the database and the signing key; /metrics has request counts and durations per listener. Logs follow the shared pacod format, one JSON or text line per event. Sign-ins, role changes and administrators' actions go to an audit log, kept 400 days and readable on the admin pages.

A gateway that signed people in itself can hand them over with pacod-id import gw: passwords, admin tokens and invitation links keep working, and until it finalises, the gateway can return to its own sign-in as long as nothing has changed here since.

In the pacod stack

make compose-up starts a development stack: a realm shop at http://127.0.0.1:26070 with sign-up open, a mail sink and a stand-in for Google, all published on 127.0.0.1 only. Like every pacod project, pacod-id joins the shared Docker network pacod-net, and its compose setup has two files. docker-compose.yml holds pacod-id alone; docker-compose.standalone.yml adds its own copies of the shared components: PostgreSQL (pacod-postgres), pacod-db-init, which creates the role pacod_id and its database when they are missing, a mail sink (pacod-mailpit) and the mock Google (id-mock-idp).

The full pacod stack, pacod-demo, loads only the first file. It runs one PostgreSQL, one mail sink, one edge and one pacod-id for every product, with a single realm and the products' own clients: a sign-in client and a service client each for the app, the gateway, the studio, the knowledge base and the benchmarks. platform/ holds pacod-id's share of that configuration: its database script, its route on the shared Caddy and its Prometheus job.

What has been tested, and what is not there yet

  • make verify runs formatting, lints, type checks and every test in Rust, TypeScript and Python. CI also audits the dependencies, builds the image, starts the development stack, checks the latency targets and lints the Helm chart.
  • make e2e runs the hosted pages and three fixture applications in a real browser, with three realms, the mail sink and the mock Google, and tours every brand in light and dark. It runs locally, not in CI.
  • The OpenID Foundation's conformance suite (v5.3.2) was run locally: the Config, Basic and RP-Initiated Logout plans with no failed condition among 2,370 checked, while one Back-Channel Logout test was blocked by the suite's own self-signed certificate. Local runs are not a certification.
  • pacod-id 0.1.0 has not been released. Upgrades, backup and restore have not been exercised.
  • The Helm chart passes helm lint and schema checks but has not been installed on a cluster. A real SMTP server and the real Google have not been tried; the development stack's mock has.
  • No MFA or passkeys yet, and so no acr claim. Until they come, the hosted admin pages act on the session cookie alone, without asking for a recent sign-in.
  • No address or phone claims; the profile scope gives name and locale.
  • There is no general rate limit beyond the sign-in throttles, and images are not yet signed or shipped with an SBOM.

Further reading

Quick start

Develop and test

make test-services
make verify
make e2e

Build and run the image

make image
make compose-up

Works with

  • pacod-gw: With PGW_AUTH=oidc the console signs people in through pacod-id; the core pushes the gw roles, registers organisations and workspaces, and follows the feed.
  • pacod-ap: The app and its admin console sign in through pacod-id; the API pushes the pacod roles and follows the feed, so the app and the gateway console share one account.
  • pacod-cms: The studio signs people in through pacod-id, which holds their roles per organisation and per website.
  • pacod-kb: The UI signs people in through pacod-id; its viewer, editor and admin roles are granted there.
  • pacod-benchmark: The UI signs people in through pacod-id, and the API checks every caller against its viewer, operator and admin roles, which it pushes at every start.