Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Auth, account and admin surfaces

This page sets out what each of SourceOrigin’s three hosts serves, and how administrators act on accounts without signing in as their owners. Parts not built yet are marked (planned).

HostServesUsed by
auth.sourceorigin.devOIDC/OAuth endpoints, ceremony pages, account settings, the developer console and the JSON APIs; security center (planned)every application and user
sourceorigin.devIts own web server: the homepage and SourceOrigin’s policies, and a redirect to auth.sourceorigin.dev that keeps every other path; public profiles (planned)visitors, old links and bookmarks
admin.sourceorigin.devThe admin dashboard, which auth serves on a listener of its ownadministrators

auth.sourceorigin.dev

The auth executable owns everything that authenticates someone or issues a credential: discovery, JWKS, authorization, token, userinfo, logout, and the pages a ceremony needs (login, signup, consent, passkey confirmation, account recovery at /recover and /recover/undo, device verification (planned)). It also serves the account settings at /settings, an overview first, then the inbox at /settings/notifications, the sign-in methods at /settings/sign-in and the other pages on the paths sourceorigin.dev used, and redirects its root there, and the developer console, where accounts register their own OIDC clients, at /developers. The admin dashboard is the same executable too, on another listener and host (see admin.sourceorigin.dev below).

The account API is routes in the same executable. It is small next to the pages that use it, and keeping it there keeps one binary, one database owner and one policy layer per listener. Auth keeps its state in PostgreSQL, so it runs as several replicas (see running several replicas). Every page it serves, sign-in, consent, logout, account recovery, the settings and the developer console, is server-rendered in the executable, with plain forms, and never cached. Their scripts are the passkey script, which runs the passkey ceremonies their forms need and keeps a form from posting twice, and the picture script, which lets the picture form choose its crop.

Passkeys are bound to the auth.sourceorigin.dev RP ID, so passkey ceremonies always run on auth. A settings change that needs a recent passkey confirmation asks for it on its page before the form posts. Users never unlock settings explicitly.

sourceorigin.dev

sourceorigin.dev is its own product, served by its own web server, apps/web. It relies on auth only to sign people in: it links auth’s pages and never shares auth’s database, keys or process. Today it serves the homepage, which says what SourceOrigin’s sign-in is and links each surface, the privacy policy at /privacy and the terms of service at /terms. They are static, with no session, form or script, and the only pages search engines may index. Auth links the policies there (AUTH_WEB_ORIGIN): every auth page’s footer does, the dashboard’s included, and so does the registration form, which agrees to them. OIDC discovery advertises them as op_policy_uri and op_tos_uri, which the SDK reads with the rest of the metadata.

Every other path redirects to the same one on auth.sourceorigin.dev, with a temporary 307 keeping the path and query; browsers keep the fragment, so links to sourceorigin.dev/settings/… open the same page on auth. The security notices among them never expire, so the redirect stays until sourceorigin.dev serves pages of its own at those paths, such as public profiles (planned).

admin.sourceorigin.dev

Auth serves the admin dashboard itself, on its admin listener (AUTH_ADMIN_LISTENER), for the dashboard’s own origin (AUTH_ADMIN_ORIGIN). Only a private route reaches that listener, so the dashboard is reachable only on the tailnet. Its pages are server-rendered in apps/auth-service/src/web/admin, render the views of sourceorigin_common::admin with sourceorigin-ui’s dashboard components, and call AdminService in-process, as the CLI does, and AccountService for an account’s settings (see below): there is no admin API. The network path is a second wall, not the authorization.

The admin listener has a security layer of its own. It answers only requests that name the dashboard’s host, takes a form only from the dashboard’s own pages (the same-origin rule auth’s pages follow), spends a request budget of its own, and limits bodies to 512 KiB, which an announcement’s text and accounts need. Every page loads only the stylesheet and the dashboard’s own script, which its Content-Security-Policy names alone, and its forms post only to the dashboard, whose answer may go on to auth’s handoff page.

The dashboard is no OIDC client. An administrator signs in by a handoff from auth:

  1. A browser without a dashboard session, or whose change needs a confirmation, is sent to auth’s handoff page, /admin, with a state the dashboard keeps in a cookie of its own origin.
  2. The handoff page needs a session signed in with a passkey, of an account that still holds administrator rights: anyone else is told they have no access, and a social sign-in signs in with a passkey first. It sends the browser back with a code that works once, for 60 seconds, bound to that session and to the state.
  3. The dashboard redeems the code in the browser holding the state, and starts a session of its own, storing only its cookie’s digest, with the auth session and its own passkey confirmation.

The dashboard’s session rests on the auth session: signing out of auth, revoking that session or replacing it ends it, and every request checks the administrator’s rights again. Signing out of the dashboard ends only its session. The admin dashboard’s session describes how it is stored.

A change needs the dashboard’s own passkey confirmation from the last five minutes, which a confirmation for auth’s pages never gives. A page that changes something sends the browser to the handoff page to confirm before showing its form: there, a passkey proves the handoff in a ceremony started for it, and the dashboard keeps that time as its confirmation. The auth session’s own confirmation stays as it was. Once confirmed, a short change such as revoking a session confirms in a dialog on the list page itself, which the browser opens and closes with no script (popovertarget), posting to the change’s own page; a dashboard not confirmed yet links that page instead, which confirms first.

The dashboard’s script spares the browser that trip: before following a link to a page that confirms first, or posting a change once the confirmation is too old, it asks for a passkey in a dialog on the page, then goes on to the change. Each page’s frame says how long its confirmation lasts. The dialog runs the ceremony for auth’s RP ID on the dashboard’s own origin, which auth’s /.well-known/webauthn lists as a related origin (WebAuthn Level 3 §5.11), against the dashboard’s /confirm routes. The proof is the handoff’s kind: started by the dashboard session’s auth session, bound to the browser, signed for the dashboard’s origin, which the handoff refuses, and kept as the dashboard’s confirmation only. Without the script, or in a browser without related origins, the browser goes to the handoff page and back as before.

Lists page by number, with their totals, as the CLI’s --page does.

Administrators also send announcements, such as a security incident or a change to the privacy policy, to every account or to chosen ones. An announcement starts as a draft with a page of its own, showing its email as it will be sent. Drafting, editing and discarding one need only what a read needs, since nobody else sees a draft: a confirmation that runs out while the text is written sends the browser to confirm and back to the saved draft, with nothing lost. Sending it needs the dashboard’s recent confirmation, like any change, and names the draft’s revision its page showed, so a draft changed meanwhile is checked again rather than sent unseen; its text never changes afterwards. Each recipient gets it as a notification in their inbox, and each with a primary verified address is emailed it, unless they turned announcements’ email off and it is not essential, which its sender chooses on the draft (see the delivery queue).

Administrators also follow alerts: conditions auth’s background jobs find that need their attention, such as a broken audit chain. The dashboard’s Alerts page lists the current ones, on which administrators post status updates until each clears, and links those that cleared; auth-service alerts does the same (see respond to an alert). Raising an alert notifies every active administrator, in their inbox and by email. While an alert has no update yet, the overview’s banner and the sidebar’s dot point to the page.

Administrators manage the administrator list too, but never their own rights or the last active administrator’s, and the dashboard never changes an administrator’s account. Nothing, the CLI included, disables or deletes the last active administrator’s account either. Every admin operation is also an auth-service CLI command; granting the first administrator is one.

Changing an account on its owner’s behalf

Administrators change other users’ settings with the owner’s own operations, never by signing in as them. Each setting an administrator may share with the owner is an AccountService operation: a change takes a Subject, the account and who acts on it, and a read a Viewer, the account and who reads it. The owner’s settings pages and /account routes run them as the owner of the session cookie, the only way to act as an owner. The auth-service account <user> … command runs them as the operator at the terminal, and the dashboard’s display name, sign-in methods, profile picture, email addresses, connected apps, security history and notifications pages call them in-process as an administrator, rendering the owner’s own settings sections without what adds a way in, and asking for a reason with each change. All run the same safety rules: for example, one active passkey always remains, whoever removes the others.

The rule for what is shared: administrators may view, edit and remove, but never add a way to sign in or to receive the account’s email, since that would let them take the account over. The operations only the owner may run take an Owner, which only the session cookie builds, or for a client the OwnedClient built from one, so calling one for an administrator does not compile. Three owner-only actions are authorized otherwise: verifying an address by its link’s token, so it works signed out, turning announcements’ email off by an email’s unsubscribe link, likewise, and confirming with a passkey by the session it confirms.

AreaSharedOwner only
Display namechange
Passkeyslist, rename, removeadd, confirm
Profile pictureview, removeset
Social identitieslist, unlinklink
Email addresseslist, choose the primary among verified, removeadd, verify
Sessionslist, sign out one, sign out all
Connected appslist, remove an app’s access
Security historyreadreport an entry
Notificationslist, read, view email preferencesmark read or unread, change email preferences
Account datadownload
OIDC clients (the developer console)all; administrators use AdminService’s

Downloading the account’s data stays the owner’s although administrators may read most of it: the export copies every IP address and the whole history out of auth in one file, which only the owner’s recently confirmed session receives. Support never hands one over; an owner who lost every passkey is recovered first, and downloads it themselves.

When an administrator or operator acts:

  • Every change to a shared setting needs their reason, renaming the handle included. It is stored with each event as personal data, and the owner sees it in their security history, next to “By a SourceOrigin administrator”.
  • Events record the administrator or operator as their actor. Their reads are recorded as a view of the account, as user show is, once an hour per administrator session or operator. The owner’s history leaves views out: it shows changes, with their reasons, not reads. The dashboard’s event lists and audit list leave them out too, unless “Show views” or --views asks for them, or the kind filter is account_viewed.
  • An administrator may not change another administrator’s account: it is refused before waiting for the account lock and again under it. A change checks the administrator’s rights, session and passkey confirmation once more before it commits. The CLI may change administrators’ accounts.
  • A change that signs the owner out elsewhere, such as removing a passkey, signs out every session, since an administrator has none of the owner’s to keep.

Recovering an account

An owner who lost every passkey cannot add one: adding a way to sign in needs a passkey confirmation. AdminService::recover_account helps without giving the administrator a way in. It signs the account out everywhere, removes its unverified addresses, and emails its current primary verified address a link to /recover, which enrolls one passkey, once, within a day, then signs that browser in with it. The link’s token alone authorizes it; only its digest is stored, and the email’s delivery stores neither. Only an operator at the terminal sees the link (user recover), which also reaches an owner without an address. An administrator never sees it, so they may only start a recovery that mails it, from the dashboard’s Recover page; one that also revokes every way in (--revoke) prints its link, so it stays at the terminal.

For 15 days after the link is used, the recovery is in probation: its passkey signs in like a social login and never confirms the session. Every change that needs a confirmation refuses it, and so do the dashboard and relying parties that ask for phr, so whoever used the link can use the account but add no way into it. Undoing the recovery takes back its passkey, every session and refresh grant, and every app consent. The owner undoes it with any other passkey on /recover/undo, which the recovery’s emails link to, in a ceremony of its own that needs no session and works on a disabled account (/account/recovery/undo/start and /finish). Until it settles, nobody removes a passkey but the recovery’s own, administrators and operators included, so nothing takes that away. Support undoes it for an owner with no other passkey, from the dashboard’s Undo recovery page or with user undo-recovery (AdminService::undo_recovery). While a recovery can be undone, every settings and developer console page, and a passkey sign-in that continues to an app, asks “Was this you?” and links to the undo page.

user recover --revoke is for an account that was in someone else’s hands: it also revokes every passkey the owner doesn’t vouch for, unlinks every identity and withdraws every consent, and only prints the link. No account is recovered while it holds administrator rights, and none is made an administrator while a recovery of it can be undone. Account recovery states the policy and support’s evidence policy.

Profile pictures

An account may have one profile picture, which apps receive as the picture claim under the profile scope, in ID tokens, access tokens and UserInfo, and which every avatar shows in place of the initials: the account’s sidebar and top bar, and the dashboard’s account lists and pages. The SDKs read it as SignedIn::picture() in Rust and user.picture in TypeScript, and the admin views of accounts and administrators (sourceorigin_common::admin), which the dashboard shows and the CLI prints as JSON, carry its ID. The owner sets it on the settings’ Profile tab, which shows the account as apps do, with its handle, display name and picture, each changed in a dialog the popover API opens with no script; the picture’s is a multipart/form-data form. There the picture script shows the chosen image under a circle, as apps show it, to drag and zoom; the form sends the square under the circle in the image’s upright pixels (crop_x, crop_y, crop_size), and without the script auth keeps the centred square. PUT /account/picture sets it too, taking the square as ?x=…&y=…&size=…. Administrators may view and remove it, with a reason, such as one that impersonates someone, but never set one; their Profile tab links the dashboard’s own pages for the name and the handle.

  • Only auth’s own image. Auth decodes an upload of at most 10 MiB, a PNG, JPEG or GIF recognized by its content, one at a time per replica. It refuses one whose header declares more than 8192 pixels on a side or 24 megapixels, or whose decoded image is over 96 MiB, before decoding it, and bounds what the decoder allocates. It turns the image upright from its EXIF orientation, crops it to the square the owner chose, or its centred square, before scaling it to 512 pixels, flattens transparency onto white and encodes a JPEG of at most 128 KiB. The upload itself is never stored, so no metadata, such as a photo’s location, survives. JPEG because every consumer of picture reads it, and the image crate’s WebP encoder is lossless only, several times larger. WebP uploads are refused until its decoder checks a frame’s size against the canvas it declares before decoding it.
  • Stored in PostgreSQL, in profile_pictures, one row per account, which the account’s purge removes with it. At about 50 KiB a picture, auth needs no object store until pictures dominate the database.
  • Served at /pictures/{id}, by the ID of its upload: a new picture gets a new ID and URL, and setting or removing one bumps profile_version. The image at a URL never changes, so it is cached as immutable, for a year, with Access-Control-Allow-Origin: *. The path has no file extension, so the edge keeps no copy of its own: once replaced or removed, or once its account is disabled or scheduled for deletion, a picture’s URL answers 404 at once. Its requests have a rate budget of their own. A random ID rather than the content’s hash, since a hash would let anyone holding an image check whether an account uses it, and re-encoding leaves nothing to deduplicate.
  • Imported at a social signup. An account created with GitHub or Google starts with the picture the provider reported: GitHub’s avatar, or Google’s picture claim, asked for at 512 pixels. Only a URL on the provider’s own picture host is kept, with the signup’s pending evidence. Once the account is committed, auth fetches it once, off the signup’s request, with the client deliveries use, which reaches only public addresses and follows no redirect, asking only for the formats it reads, and encodes it as an upload. A picture that cannot be fetched or encoded is logged; the signup has already succeeded. Later sign-ins import nothing, so a removed picture stays removed.

Contracts

Third-party applications integrate only through open standards: OIDC, OAuth and their registration, discovery and token RFCs. The account API is first-party; it changes together with auth’s pages in this repository.

Recovering auth

Nothing auth runs on may require auth to log in: piqueld, server access, the tailnet, PostgreSQL, DNS and the tunnel. If auth or its frontends are down, the CLI, given database access, can still do every admin operation and repair accounts: see administration. Recovering an account whose owner lost every passkey follows account recovery, including support’s evidence policy.