The auth feature
architect-auth — the session RPC surface, the server middleware, the client session kit, and the engine underneath.
Auth lives in features/auth/ as its own crate family — there is no
architect::auth re-export (it would cycle the dependency graph;
depend on the auth crates directly). The split follows the
monorepo layout:
| Crate | Role |
|---|---|
auth-proto | wire types (AuthUser, AuthSession, AuthSessionBundle, payloads, AuthFlowError) + the #[architect::service] traits AuthService, OrganizationService and DeviceService — vox and HTTP faces, both clients |
auth | the engine: ArchitectAuth<S> over a pluggable AuthStorage, ~30 plugins’ worth of flows, the vox + axum transports |
auth-db | SeaORM storage + migrations |
auth-client | the client session kit — tiny, wasm-clean, no engine dependency |
architect-auth | the umbrella: pub use auth::*, plus db / client modules behind features (db, axum, vox, client, full) |
The RPC surface#
AuthService covers the session lifecycle and the account,
OrganizationService the org, and DeviceService the account’s
devices — all-async #[architect::service] traits, so each mounts
like any other architect service and comes with every face: the vox
client and dispatcher, the axum router (POST /auth/<method>,
POST /organization/<method>, POST /auth/devices/<method>), and the
HTTP client (AuthServiceHttpClient, OrganizationServiceHttpClient,
DeviceServiceHttpClient). See the HTTP face.
AuthService has 14 methods (features/auth/auth-proto/src/service.rs
AuthService; argument types abbreviated here):
#[architect::service]
pub trait AuthService {
// Ways in: email and password, and a device with no browser (a CLI)
// that its person approves from a signed-in browser.
async fn sign_up_email_password(&self, input: SignUpEmailPassword) -> Result<AuthSessionBundle, AuthFlowError>;
async fn sign_in_email_password(&self, input: SignInEmailPassword) -> Result<AuthSessionBundle, AuthFlowError>;
async fn start_device_sign_in(&self, client_id: String) -> Result<DeviceSignIn, AuthFlowError>;
async fn poll_device_sign_in(&self, device_code: String, user_agent: Option<String>) -> Result<AuthSessionBundle, AuthFlowError>;
// The session.
async fn current_session(&self, token: String) -> Result<AuthSessionBundle, AuthFlowError>;
async fn refresh_session(&self, token: String) -> Result<AuthSessionBundle, AuthFlowError>;
async fn whoami(&self, token: String) -> Result<AuthUser, AuthFlowError>;
async fn sign_out(&self, token: String) -> Result<(), AuthFlowError>;
// The org's members, and an operator moving someone's email.
async fn list_org_members(&self, token: String) -> Result<Vec<OrgMember>, AuthFlowError>;
async fn migrate_user_email(&self, input: MigrateUserEmailRequest) -> Result<AuthUser, AuthFlowError>;
async fn list_email_history(&self, input: EmailHistoryRequest) -> Result<Vec<AuthEmailChange>, AuthFlowError>;
// Self-service: the session names the account.
async fn change_password(&self, input: ChangePasswordRequest) -> Result<(), AuthFlowError>;
async fn change_email(&self, input: ChangeEmailRequest) -> Result<AuthUser, AuthFlowError>;
async fn update_profile(&self, input: UpdateProfileRequest) -> Result<AuthUser, AuthFlowError>;
}
DeviceService is the account’s device address book, each call scoped
to the account the token names (features/auth/auth-proto/src/device.rs
DeviceService): register_device (this install, with its iroh
endpoint id; returns the DeviceDirectory), list_devices and
forget_device. auth_proto::ensure_registered registers a device
once for every app on it; the tests are
features/auth/tests/native/src/devices_tests.rs.
The AuthSessionBundle a sign-up/sign-in/refresh returns carries the
raw token (only its hash is stored server-side), the AuthUser, and
the AuthSession. Refresh rotates: new token, new expiry, old
token dead. Sign-out is idempotent and doesn’t reveal token existence.
Server: build the engine, mount the service#
AuthVoxService adapts the engine to the trait; the rpc-emitted
auth_service_layer binds it into a LayerRouter. This is verbatim
how the integration tests mount it
(features/auth/tests/native, over the
in-process transport — full wire, no
socket):
use architect::{LayerRouter, LocalServer, Scope};
use auth::transport::vox::AuthVoxService;
use auth::{AuthServiceClient, SignUpEmailPassword};
use auth::backend_db::{AuthSeaOrmStorage, Migrator};
let db = Database::connect("sqlite::memory:").await?;
Migrator::up(&db, None).await?;
let auth = auth::ArchitectAuth::builder()
.secret("a-secret-at-least-32-bytes-long!!") // < 32 bytes is rejected
.storage(AuthSeaOrmStorage::new(db))
.build()?;
let router = LayerRouter::new()
.merge(auth::auth_service_layer(AuthVoxService::new(auth)));
// in production: serve over axum_ws; here, in-process
let scope = Scope::new();
let local = LocalServer::serve(router, scope.clone());
let client: AuthServiceClient = local.establish().await?;
let bundle = client.sign_up_email_password(SignUpEmailPassword {
email: "rpc@example.com".into(),
password: "correct horse battery staple".into(),
..Default::default() // schematic — spell the remaining fields out
}).await?;
let me = client.whoami(bundle.token.clone()).await?;
assert_eq!(me.id, bundle.user.id);
Protecting other services: AuthServerMiddleware#
Auth crosses the wire as vox string metadata —
authorization: Bearer <token> under AUTHORIZATION_METADATA_KEY.
AuthServerMiddleware parses it on every request and stashes an
AuthVoxContext { token: Option<String> } in the request extensions;
your service reads it via #[vox::context] and validates with
auth.current_session(...):
use auth::transport::vox::{AuthServerMiddleware, AuthVoxContext};
let router = LayerRouter::new().with(
tasks_service_descriptor(),
TasksDispatcher::new(tasks).with_middleware(AuthServerMiddleware),
);
// inside a #[vox::context] method:
async fn list(&self, cx: &vox::RequestContext<'_>) -> Result<Vec<Task>, TaskError> {
let token = cx.extensions()
.get_cloned::<AuthVoxContext>()
.and_then(|c| c.token)
.ok_or(TaskError::Unauthenticated)?;
let session = self.auth.current_session(CurrentSession { token }).await?;
// … session.user.id is your caller …
}
A missing header is an unauthenticated call, not a failed one — the
context’s token is simply None, and the policy is the service’s.
Client: the session kit (auth-client)#
Everything a downstream app needs to hold a session and present it on later calls — wasm-clean, engine-free:
StoredSession— token + optional user id / email / expiry / refresh token.architect_auth::client::stored_session(&bundle)builds one from a sign-in’sAuthSessionBundle; an OIDC sign-in builds one withoidc::token_response_from(body)?.into_session(now), which keeps theoffline_accessrefresh token. Session files saved beforerefresh_tokenexisted load withNone.- Refresh —
oidc::refresh_if_needed(&store, client_id, now, send)renews the access token when it is withinREFRESH_MARGIN_SECSof expiry;oidc::refresh_nowrenews it after a 401. Either postsoidc::refresh_request_body(a public client’sgrant_type=refresh_token) through the app’ssendclosure and saves the rotated session. The issuer rotates refresh tokens, so serialize refreshes per store; aRefusedanswer means sign in again. TokenStore— save/load/clear behind a trait.FileTokenStore(native): one JSON file, atomic write (temp + rename),0600on unix — the token is a bearer credential.MemoryTokenStore: wasm and tests.TokenStoreMiddleware— avox::ClientMiddlewarethat loads the store per call (a re-login is picked up without rebuilding clients) and attachesBearer <token>exactly asAuthServerMiddlewareparses it, flaggedSENSITIVE | NO_PROPAGATE.
use auth_client::{FileTokenStore, TokenStoreMiddleware};
use architect_auth::client::stored_session;
let store = Arc::new(FileTokenStore::new(dirs.data_dir().join("session.json")));
// after login:
store.save(&stored_session(&bundle))?;
// every typed vox client picks the session up automatically:
let tasks = tasks_client.with_middleware(TokenStoreMiddleware::new(store.clone()));
// logout:
store.clear()?;
The wire format is pinned by an integration test that round-trips
TokenStoreMiddleware against the real AuthServerMiddleware over a
LocalServer — empty store → server sees no token; saved token →
server sees it verbatim; cleared → none again, same clients.
The server#
apps/auth-server mounts exactly that: services().provide(svc) for
vox (served at /vox and, with AUTH_IROH_KEY_FILE, over iroh),
services().provide_http(&svc) for HTTP, and the OAuth/OIDC flows and
the hosted pages as plugins on an architect::host::EngineHost. Nothing
in the server hand-writes a route that mirrors a trait method.
The host is built EngineHost::ungated, on purpose: this server is the
identity provider. Signing up and signing in are anonymous by nature, and
every other method takes the session token as an argument and checks it
itself, on every face. An app’s server is the other way round: it passes
its PermissionsGate to EngineHost::new(router, gate, bind), and the
host puts it in front of every vox lane it owns, /vox (the caller from
the upgrade’s bearer), the iroh lane (the endpoint id the QUIC handshake
proved, Principal::Host) and every vox_at lane
(crates/architect/src/host.rs EngineHost,
crates/architect/tests/host_gate.rs). See
EngineHost.
apps/auth-server/tests/surfaces.rs runs one suite over every
transport.
Beyond the RPC surface#
The traits are deliberately minimal. The engine underneath
speaks a much larger command vocabulary — engine-level only, called
on ArchitectAuth directly (server-side), not mounted on the wire:
- Two-factor:
StartTwoFactorSetup/ConfirmTwoFactor/VerifyTwoFactor/DisableTwoFactor(TOTP + backup codes). - API keys:
CreateApiKey/VerifyApiKey/AuthenticateApiKey/RevokeApiKey— machine credentials with their own authorize path. - Invitations & organizations:
CreateInvitation/AcceptInvitation, organizations, teams, roles, member management. - Plus passkeys, OAuth/OIDC, magic links, email/phone OTP, device
authorization, anonymous sign-in, impersonation, JWT issuance, … —
see the
AUTH_PLUGIN_DESCRIPTORStable inauth::plugins.
Exposing any of these to clients later is purely additive — new trait
methods (or a second #[architect::rpc] trait), no re-mount of what
exists.
There’s also an axum HTTP guard for plain HTTP routes
(auth::transport::axum): require_session as a route_layer
extracts a bearer token or session cookie and injects an
AuthenticatedSession extension — see
features/auth/architect-auth/examples/axum_http.rs. The vox path
above is the architect-idiomatic one; reach for the axum guard only on
routes that genuinely live outside vox.
Linked from
- The backendImplement a service and its repo, add storage and migrations, compose layers, read the caller, and record each call as one wide event.
- The serverEngineHost and its transports: vox over WebSocket, iroh and in-process; the HTTP face with verbs, parameters, streamed bodies, ranges and SSE; protocol mounts; health.
- Sign-in, permissions and organizationsSign a person in against the one identity server, keep the session and register the device; gate every service call with PermissionsGate; and make the organization the scope of everything, on the client and confined on the server.
- AuthAccounts and sessions: the auth engine, its services on every transport, the client kit (token store, OIDC with PKCE, device flow), and apps/auth-server.
- ArchitectureThe patterns the architect template is showcasing.
- Organizations: the spine everything is scoped byThe organization (workspace) is architect's, not an app's: files, documents, settings, extensions, presence, permissions and the shell's switcher all know which org they are in, so every app gets multi-org behaviour without writing it.
- ReferenceCrate map, derive attributes, and macro outputs.