Core Concepts
The two planes
Dodil separates who builds the app from who uses the app:
| Platform plane | App plane (AppID) | |
|---|---|---|
| Service | Dodil IAM | Dodil AppID |
| Principals | org users, service accounts, dk_ API keys | your app’s end users |
| Issuer | id.dodil.io | appid.dodil.io/{org}/{pool} |
| A token can… | drive Dodil products (K3, Ignite, Git, …) | drive your app — nothing else |
The planes never share an issuer, and nothing AppID mints is accepted by any Dodil data plane. Both sides verify tokens by iss and aud, so a token from one plane presented to the other fails closed.
Pool
The unit of app identity. Creating a pool mints an Ed25519 keypair and derives everything an OIDC relying party needs:
| Field | Example |
|---|---|
issuer | https://appid.dodil.io/acme/my-app |
jwks_uri | {issuer}/.well-known/jwks.json |
| discovery | {issuer}/.well-known/openid-configuration |
audience | pool:my-app |
A pool is rows plus a keypair — there is no per-pool server or realm. Key rotation (dodil appid pool rotate-keys) publishes the new key in the JWKS alongside the old one, so cached verifiers keep working.
Pool state is active, migrating, or migration_failed. While a user-store migration is in its locked phase, sign-in and admin writes answer 503 — retry after cutover.
Connections
A connection is how users prove who they are. Today exactly one kind is implemented:
local— email + password. argon2id hashing, timing-equalized verification, rate-limited attempts.
oauth:<provider> (Google, GitHub, …), generic oidc, saml, and anonymous are reserved names on the wire but not implemented yet — configuring them does nothing today.
Tokens
| Token | Form | Notes |
|---|---|---|
| Access token | JWT, EdDSA (Ed25519) | Short-lived (default 15 min; per-pool access_ttl_secs). Verify against the pool JWKS. |
| Refresh token | Opaque, 256-bit | Rotates on every use. Default 30-day family lifetime (refresh_ttl_secs). |
| ID token | JWT | Minted when the code flow requests scope=openid; aud = your client_id, nonce echoed. |
| Reset / verify tokens | Opaque, single-use | Password-reset links (30 min TTL) and email-verification links (3 days). |
Access-token claims: iss, aud, sub, email, connection, app_roles[], permissions[], tenants (slug → roles map), tenant (when pinned), amr (["pwd"]), iat, exp.
Refresh rotation and reuse detection. Every refresh returns a new refresh token and invalidates the old one. Presenting an already-used token is treated as theft: the entire session family is revoked. A short, configurable grace window (refresh_reuse_grace_secs, default 10 s, max 60, 0 = strict) forgives the double-submit race — a genuine retry inside the window gets the same successor pair back — but never resurrects a session revoked by logout, password change, or an admin.
Roles, permissions, and the catalog
Each pool carries a role → permission catalog (dodil appid roles set my-app admin=users.write,billing.read editor=posts.write). Users hold app roles; at mint time the catalog expands them into a flat permissions[] claim. Your backend checks permissions — the catalog is how you rename or re-scope a role without touching every user.
App tenants
Tenants are sub-organizations inside a pool — workspaces, subsidiaries, departments. A user can belong to many tenants with different roles in each:
- Tokens always carry the user’s
tenantsmap (up to 32 memberships). - Passing
tenant=<slug>at sign-in pins the token to one tenant and merges that tenant’s roles into the token’s permissions. - Non-membership fails exactly like a wrong password — tenant existence is never disclosed.
Tenant slugs are 1–63 chars of [a-z0-9-] and immutable after creation.
User stores
Where the pool’s users, sessions, and tenants physically live. Three options:
| Store | What it is | When |
|---|---|---|
| Managed (default) | AppID’s control-plane Postgres | Zero setup — start here |
| Your K3 bucket | Tables in a K3 bucket you own (_appid_users, _appid_sessions, _appid_email_claims, _appid_tenants, _appid_tenant_members) | You want to query/join your users like any other data |
| Your Postgres | Any postgres:// DSN you bring | Compliance / data-residency needs |
Switch stores live with dodil appid user-store migrate: warm copy → short locked window (issuer answers 503) → verify counts → cutover → verified purge of the source (unless --keep-source). A count mismatch aborts and rolls back automatically.
Credentials never live in the pool record: a K3 store is addressed as k3+sa://<service-account>@host:port/bucket and AppID mints short-lived tokens for it on demand.
Settings, policies, branding
Three JSON documents on the pool, all editable via CLI or API:
- Settings —
allow_signup,require_email_verification,password_min_length(8–128),access_ttl_secs,refresh_ttl_secs,refresh_reuse_grace_secs,redirect_uris. - Policies — rate limits (per-email login 10/min, signup 60/min, recover 5/h, authorize 120/min per client IP) and
cors_allowed_origins(exact-match origin allowlist for the JSON API). - Branding — logo, colors (light + dark), layout, fonts, copy, links, custom CSS for the hosted pages, plus an
emailblock for the transactional mails. See the hosted login recipe.
Where the control plane lives
Pools and users are managed through the standard Dodil surface: console, dodil appid CLI, or REST under https://api.dodil.io/v1/appid/… — all authenticated with your platform identity, never with pool tokens. Requests carry no organization; the gateway derives your org from your token. See Auth and Access.