Migrating an existing login¶
Your site already has accounts: emails and password hashes in your own database. This page moves those people onto the standard door at auth.omxus.com without anyone resetting a password, and hands your old user id back to you on every sign-in so your own tables keep working.
Optional. Nothing here runs unless you run it.
What moves, what stays¶
| Moves | Stays with you |
|---|---|
| Email, and whether you had verified it | Every other column and table |
| Password hash (replaced at first sign-in) | Usernames: each person picks one the first time |
Your user id, returned as legacy_id |
What "admin" means on your site |
An admin flag, returned as roles: ["admin"] |
Hashes the provider can check: pbkdf2$<iterations>$<salt base64>$<hash base64>,
PBKDF2-SHA256, a 32-byte hash, salt at least 8 bytes, 100,000 iterations or
fewer (Workers caps WebCrypto PBKDF2 there). Anyone with another format,
bcrypt or a higher iteration count is still imported, without a password:
they sign in with a code sent to their email, then set one.
1. Register on the standard door¶
Register a site with "door": "standard". For a browser app:
curl -s https://auth.omxus.com/oidc/register \
-H 'content-type: application/json' \
-d '{
"client_name": "Your Site",
"door": "standard",
"username_scope": "site",
"redirect_uris": ["https://yoursite.example/auth/callback"],
"token_endpoint_auth_method": "none"
}'
Untested against production (each call makes a real client). Keep the
client_id.
2. Export your users¶
One row per person. Columns read, the rest ignored:
| Column | Required | Meaning |
|---|---|---|
id |
yes | Your user id. Becomes legacy_id. Up to 128 characters. |
email |
yes | Lowercased and trimmed on import. |
password_hash |
no | The format above. |
email_verified |
no | 1, true, 0, false. Default false. |
is_admin |
no | 1 or true gives role admin for your client. |
created_at |
no | YYYY-MM-DD HH:MM:SS UTC, or epoch seconds. |
From Cloudflare D1:
wrangler d1 execute YOUR_DB --remote --json \
--command "SELECT id, email, password_hash, is_admin, email_verified, created_at FROM users" > users.json
The importer reads that output as it is, a plain JSON array, or one JSON object per line. The file holds password hashes: keep it off shared drives and delete it when you are done.
3. Turn it into SQL¶
node server/accounts/import/import-users.mjs \
--client-id YOUR_CLIENT_ID --in users.json --out import.sql
The importer lives in the auth repository, which is private today. Ask the operator for it, or have them run this step with you.
It prints a report and never prints an address or a hash. A run over five sample rows:
rows 5, imported 3 (1 with a password, 1 verified, 1 admin)
row 2: duplicate email (first row kept)
row 3: password hash is not in the PBKDF2 format; imported without a password
row 4: password hash has 600000 iterations (max 100000); imported without a password
row 5: no usable id
Rows dropped outright: no usable id, an invalid email, or an email already seen earlier in the file. The SQL is idempotent: running it twice adds nothing twice. Account ids come from your client id and old id, so the same row always lands on the same account.
4. Load it¶
The provider's database belongs to its operator. On auth.omxus.com, send
import.sql to the operator. On your own provider, run it
yourself:
wrangler d1 execute <your-db> --remote --file=server/accounts/legacy-import.sql # once
wrangler d1 execute <your-db> --remote --file=server/accounts/accounts-claims.sql # once
wrangler d1 execute <your-db> --remote --file=import.sql
Who a row lands on¶
Only a verified email decides which account a row belongs to. A bare match on the address never does.
- Someone already verified that email on the standard door, on any site:
your
legacy_idand role attach to their account. Their current password stays; your old hash is not stored for them. - Nobody has, and your row says
email_verified = 1: the import makes its own account with that email verified. Any sign-up still waiting on a code for that address is dropped. - Nobody has, and your row says
email_verified = 0: the import makes an account holding only a claim on the address. Nobody, you included, sees itslegacy_idor roles, and the old password cannot be used, until the person proves the address: a code sign-in, or a fresh sign-up that verifies. - A sign-up that typed the email but never entered its code gets nothing.
Two sites importing the same unverified email each make their own claim.
Whoever verifies the address first becomes the one account; every waiting
import's legacy_id and role move to it, and its old hash too if the winner
has no password. Each site still sees only its own legacy_id and roles.
Imported accounts are exempt from the 30-day clean-up that removes idle unverified sign-ups. They stay until verified or removed (see Undo).
What your users see¶
They sign in with their old email and password, then pick a username. That is all they notice.
Underneath, two calls to POST /accounts/login:
- The SDK sends the usual stretched password. There is no current hash to
match, so the provider answers
401 legacy_password_requiredwith a one-timelegacy_ticket(valid 300 seconds). - The SDK sends the plain password once over HTTPS, the stretched one as
new_password, and the ticket. The provider checks the old hash in constant time, stores the new one, deletes the old one in the same step, and signs the person in.
Every later sign-in is step 1 only. The hosted sign-in page and
omxus-accounts.mjs both do this for you (login() retries on
legacy_password_required). If you call the HTTP routes yourself, handle that
error the same way.
Once a client has imported anyone, it answers every failed password
sign-in with legacy_password_required, real addresses and made-up ones
alike, so the answer never says who is in your import. A wrong password at
step 2 is a plain 401 bad_login.
What your site gets¶
On every sign-in, for your client only, and only once the account holds a verified contact:
legacy_id: your old user id. Look up your own rows with it.roles:["admin"]for rows that hadis_admin.
They arrive in the id_token and userinfo over OIDC, and on
account.legacy_id and account.roles from
omxus-accounts.mjs. No other site ever sees them.
const account = await accounts.login({ email, password });
const user = account.legacy_id
? await db.users.get(account.legacy_id) // your old row
: await db.users.createFor(account.id); // someone new
const isAdmin = (account.roles || []).includes('admin');
Untested sketch: accounts is the client from Your own sign-in page; db stands for your own data layer.
Changing email or mobile later¶
Same rule as every account: changing a verified contact needs the current password when the account has one. An imported person who has not signed in yet has no current password (only the old hash), and one with no password changes contact with a code alone. See Sessions, activity and the account.
Undo¶
Before anyone has signed in, delete the imported accounts by id. Each id is
the first 32 hex characters of sha256("omxus-import:v1|<client_id>|<old id>").
On auth.omxus.com, ask the operator to use
/accounts/operator/delete per account. Running the import
again brings back an account you removed.