One database per client¶
Every site that signs Omxus identities in gets its own small database: a
Durable Object with SQLite, named by its client_id. Registering a site adds
no binding, runs no migration and needs no deploy. The database appears the
first time someone signs into that site.
What is shared, and why it must be¶
Three things stay in the one shared D1 database for everyone.
The trust graph. identities and vouches are global. A vouch made on one
site counts on every other site. Split it and each site holds its own private
opinion of who is real, which is what Omxus exists to replace.
The session. auth_sessions, auth_nonces and auth_attempts belong to
the provider. Landing on a second site already signed in is single sign-on;
per-site sessions would be the opposite.
The registry. oidc_clients says who the clients are, so it cannot live
inside any one of them.
What each client owns¶
The authorization codes and access tokens minted for it. No other client can read them.
| Table | Where it lives |
|---|---|
identities, vouches |
shared D1 |
auth_sessions, auth_nonces, auth_attempts |
shared D1 |
oidc_clients |
shared D1 |
oidc_codes, oidc_access_tokens |
that client's database |
client_data |
that client's database |
client_data is a key and JSON value table. The provider can write to it, but
no route reads or writes it yet, so a site cannot use it today.
Standard-door sites are different. A site whose people sign in with email or phone and a password (the standard door) keeps its codes, access tokens and refresh tokens in the shared accounts tables, not in a per-client database. Its tokens still carry the client prefix below, and the provider checks it on revoke.
How a token finds its own database¶
A bearer token arriving at /oidc/userinfo is otherwise opaque random hex. It
says nothing about who minted it, so there would be no database to open.
Codes and tokens therefore carry the client that issued them:
The part before the first ~ names the database. It works like sk_live_…:
a secret that knows where it came from.
Nothing trusts the prefix. The random half (32 random bytes) is the whole secret. A forged prefix opens a different client's database, finds no such row, and the token is refused, which is what an unknown token gets anyway. A valid token with another client's prefix is worthless.
A token with no ~ was minted before the split. It falls back to the shared
D1, where it was written; access tokens last an hour, so these are gone.
Lifetimes¶
| Secret | Lifetime | When the row goes |
|---|---|---|
| Authorization code | 60 seconds | Deleted when spent. An unspent code stays as a dead row. |
| Access token | 1 hour | Deleted the next time it is presented after expiry. |
| Revoked access token | until expiry | Marked revoked, not deleted, so a replay still reads as revoked. |
The hourly clean-up job prunes the shared D1 only. Per-client databases are cleaned on read, as above, so each one holds kilobytes.
What it costs¶
Durable Objects are billed per Cloudflare account, across every client. Each
Omxus sign-in makes three calls to the client's database (store the code,
spend it, store the token), plus one for each /oidc/userinfo call. Workers
Paid includes, each month:
| Included | |
|---|---|
| Requests | 1 million |
| Duration | 400,000 GB-s |
| SQLite rows read | 25 billion |
| SQLite rows written | 50 million |
| SQLite storage | 5 GB-month |
So about 250,000 sign-ins a month (with a userinfo call each) fit in the included requests; past that, Cloudflare bills per million. Current figures: Durable Objects pricing.
Adding a client¶
Register at auth.omxus.com/developers or
with POST /oidc/register: see Registering a site.
The database is created on first use.
On your own provider an operator can also add a row
to oidc_clients by hand. Registered client_ids are base64url, which never
contains ~. A hand-picked id with a ~ still works: it is written as %7E
inside the prefix and decoded back.
Running without Durable Objects¶
The worker uses per-client databases when the CLIENT_DB Durable Object
namespace is bound (wrangler.toml binds it, class ClientDB). Without the
binding, codes and tokens go to the shared D1, and everything else works the
same.
Checked against server/client-db/, worker/client-db.js, server/oidc.js
and server/accounts/accounts-oidc.js; the routing tests
(per-client.test.mjs, 8 of 8) cover the cross-client refusal.