routed_auth
Routed-specific auth integration on top of server_auth.
This package contains the HTTP/session/router glue for auth in Routed:
AuthServiceProviderAuthRoutesAuthManagerSessionAuth+ guard middleware- Routed JWT/OAuth middleware wrappers
- Haigate middleware bridge
Use this when you want auth routes and middleware in a Routed app.
Install
dependencies:
routed: ^0.5.0
server_auth: ^0.2.0
routed_auth: ^0.2.0
routed_rate_limit: ^0.1.0
Usage
import 'package:routed/routed.dart';
import 'package:routed_auth/routed_auth.dart';
void main() async {
final auth = AuthDeploymentPresets.localDevelopment<EngineContext>(
providers: [CredentialsProvider()],
trustedOrigins: [Uri.parse('http://localhost:8080')],
);
final engine = Engine(
config: auth.engineConfig(),
providers: [
...Engine.defaultProviders,
auth.serviceProvider(),
],
);
auth.bindTo(engine);
await engine.initialize();
engine.get(
'/account',
(ctx) => ctx.json({'authenticated': true}),
middlewares: [requireAuthenticated()],
);
await engine.serve(port: 8080);
}
localDevelopment deliberately uses ephemeral storage and development cookie
settings. For production, choose secureSessionProduction,
jwtApiProduction, or serviceApiKeyProduction. Those typed presets require a
durable AuthStore, a rate limiter, exact HTTPS origins, an explicit direct or
trusted-proxy policy, a verified-email choice, and explicit lifecycle delivery
callbacks (or AuthLifecycleDelivery.disabled()). engineConfig() applies the
proxy choice to Routed, serviceProvider() carries the typed auth
configuration, and bindTo() installs the matching runtime options before
provider boot.
For a slim local-development composition, direct AuthOptions must select
AuthStoreMode.ephemeral; AuthServiceProvider() then validates that explicit
posture at boot. Production options intentionally cannot boot through a bare
service provider because that would leave Routed's proxy handling detached
from the auth boundary. Use a typed deployment's serviceProvider(),
bindTo(), and engineConfig() together. No configuration manifest or auth
provider registry is required. Server plugins remain ordinary typed inputs:
import 'package:routed_auth/routed_auth.dart';
import 'package:routed_core/routed_core.dart';
import 'package:routed_rate_limit/routed_rate_limit.dart';
import 'package:server_auth/server_auth.dart';
import 'package:server_rate_limit/server_rate_limit.dart';
final authRateLimitService = RateLimitService(const []);
final auth = AuthDeploymentPresets.localDevelopment<EngineContext>(
providers: [CredentialsProvider()],
trustedOrigins: [Uri.parse('http://localhost:3000')],
rateLimiter: RoutedAuthRateLimiter(authRateLimitService),
);
final engine = Engine(
config: auth.engineConfig(),
providers: [
...Engine.defaultProviders,
auth.serviceProvider(),
RoutedRateLimitProvider(
RateLimitConfig(service: authRateLimitService),
),
],
);
auth.bindTo(engine);
await engine.initialize();
Compose only the auth plugins you use
Server plugins are ordinary typed constructor arguments. Provider callbacks, stores, and secrets are supplied by the application; Routed does not discover them from YAML or a global registry. This local-development factory enables username and phone sign-in, captcha, and breached-password checks explicitly:
AuthDeployment<EngineContext> buildAuth({
required String phoneCodeHashKey,
required AuthPhoneNumberCodeSender<EngineContext> sendPhoneCode,
required AuthCaptchaVerifier<EngineContext> captchaVerifier,
required AuthBreachedPasswordLookup<EngineContext> breachedPasswordLookup,
}) {
final phone = PhoneNumberPlugin<EngineContext>(
sendCode: sendPhoneCode,
codeHashKey: phoneCodeHashKey,
allowSignUp: true,
);
return AuthDeploymentPresets.localDevelopment<EngineContext>(
providers: [CredentialsProvider()],
plugins: [
UsernamePlugin<EngineContext>(),
phone,
CaptchaPlugin<EngineContext>(verifier: captchaVerifier),
BreachedPasswordPlugin<EngineContext>(
lookup: breachedPasswordLookup,
),
],
trustedOrigins: [Uri.parse('http://localhost:3000')],
);
}
Client APIs are selected independently. A client that needs username, phone, and captcha-aware credentials installs only those client plugins:
const usernameClient = AuthUsernameClientPlugin();
const phoneClient = AuthPhoneNumberClientPlugin();
const captchaClient = AuthCaptchaClientPlugin();
final authClient = AuthClient(
baseUrl: Uri.parse('https://api.example.com'),
plugins: const [usernameClient, phoneClient, captchaClient],
);
await authClient.plugins.use(usernameClient).register(
username: 'ada',
email: 'ada@example.com',
password: password,
);
await authClient.plugins.use(usernameClient).change(
username: 'ada-lovelace',
);
await authClient.plugins.use(usernameClient).remove();
await authClient.plugins.use(phoneClient).sendCode(
phoneNumber: '+18765551234',
);
final phoneSession = await authClient.plugins.use(phoneClient).verifyCode(
phoneNumber: '+18765551234',
code: deliveredCode,
);
await authClient.plugins.use(phoneClient).remove();
await authClient.plugins.use(captchaClient).signIn(
email: 'ada@example.com',
password: password,
captchaToken: captchaToken,
);
Installing a server plugin does not add unrelated client methods, and omitting a server plugin means its routes are not mounted.
PhoneNumberPlugin requires the deployment's root store to implement
AuthPhoneNumberBackend. InMemoryAuthStore does so for local development.
A production adapter must atomically own challenge attempts/lockout,
single-use consumption, user creation/projection, phone binding, and hard
deletion; the plugin never installs a process-local fallback. SMS delivery and
Routed's session/cookie response happen after that transaction commits. If
either delivery boundary fails, the committed OTP state is not compensated.
The phone removal route is CSRF protected and requires recent original
authentication or a valid two-factor step-up proof. It clears the phone
identity only when another usable authentication method remains.
Anonymous auth follows the same composition rule:
final store = MyDurableAuthStore(database); // Implements the anonymous mutation and deletion contracts.
final anonymous = AnonymousPlugin<EngineContext>();
final deployment = AuthDeploymentPresets.secureSessionProduction<EngineContext>(
store: store,
providers: const [],
boundary: productionBoundary,
lifecycleDelivery: const AuthLifecycleDelivery.disabled(),
rateLimiter: authRateLimiter,
requireVerifiedEmail: false,
plugins: [anonymous],
);
Routed issues the normal session or JWT. When an authenticated anonymous user
signs in as a permanent identity, Routed issues the replacement session first
and then submits the typed replay-bound upgrade finalizer. Stores lacking
AuthAnonymousAccountMutationStore fail at boot. The Cloudflare D1 adapter
implements that capability through its append-only v7 migration; Routed never
substitutes InMemoryAuthStore for a durable topology.
Magic links and email OTP follow the same plugin-first composition. Their durable state transitions are owned by the configured auth store; Routed owns only HTTP validation and the later session or JWT response:
final magicLink = MagicLinkPlugin<EngineContext>(
sendMagicLink: (delivery) => mailer.sendMagicLink(
email: delivery.email,
token: delivery.token,
callbackUrl: delivery.callbackUrl,
),
);
final emailOtp = EmailOtpPlugin<EngineContext>(
secret: environment.emailOtpDigestSecret,
sendCode: (delivery) => mailer.sendOtp(
email: delivery.email,
code: delivery.code,
),
);
final auth = AuthDeploymentPresets.secureSessionProduction(
store: durableAuthStore,
providers: const [],
plugins: [magicLink, emailOtp],
boundary: productionBoundary,
lifecycleDelivery: lifecycleDelivery,
rateLimiter: authRateLimiter,
requireVerifiedEmail: true,
);
The store commits a digest-only issuance record before calling the mailer, and
atomically consumes the credential with user creation or email verification.
Routed issues the host session/JWT and response cookie only after that commit.
A mail failure does not roll back issuance; a session/cookie failure does not
make a consumed token or OTP replayable. Retry delivery by issuing a fresh
credential. Never log or persist delivery.token or delivery.code.
Clients opt into the matching APIs independently:
const magicLinkClient = AuthMagicLinkClientPlugin();
const emailOtpClient = AuthEmailOtpClientPlugin();
final client = AuthClient(
baseUrl: Uri.parse('https://app.example.com'),
plugins: const [magicLinkClient, emailOtpClient],
);
await client.plugins.use(magicLinkClient).send(email: 'ada@example.com');
await client.plugins.use(emailOtpClient).signIn(
email: 'ada@example.com',
otp: codeFromUser,
);
SAML follows the same composition rule. Add AuthSamlPlugin<EngineContext> to
the deployment's plugins list after supplying an application-owned
connection catalog, durable replay store, assertion verifier, identity
resolver, and a browser binding derived from the Routed session:
final saml = AuthSamlPlugin<EngineContext>(
connections: samlConnections,
replayStore: durableSamlReplayStore,
assertionVerifier: samlAssertionVerifier,
identityResolver: samlIdentityResolver,
browserBindingResolver: (context) => context.sessionId,
);
final auth = AuthDeploymentPresets.secureSessionProduction(
store: authStore,
providers: const [],
boundary: productionBoundary,
lifecycleDelivery: const AuthLifecycleDelivery.disabled(),
rateLimiter: authRateLimiter,
requireVerifiedEmail: true,
plugins: [saml],
);
Routed mounts /auth/sso/saml/metadata/{providerId},
/auth/sso/saml/sign-in, and /auth/sso/saml/acs/{providerId} only when that
plugin is present. ACS form posts flow through the same host-owned account
policy, session or JWT issuance, callback, lifecycle, and safe-redirect path as
other plugin authentication. Client applications independently install
AuthSamlClientPlugin; no SAML operations appear on clients that omit it.
For a real signed-assertion interoperability check, run the pinned local Keycloak harness from this package. It requires Docker and OpenSSL, starts a temporary HTTPS IdP with a test realm, verifies the SAML POST and host session, then removes the container and generated certificates:
./tool/run_saml_keycloak_interop.sh
The harness is intentionally separate from dart test: it tests the live
browser-shaped IdP exchange rather than only a synthesized XML fixture.
To let a browser-facing sign-in screen read only the most recently successful method, compose the portable plugin with Routed's cookie adapter and install its client plugin separately:
final lastMethod = AuthLastAuthenticationMethodPlugin<EngineContext>(
signingKey: lastMethodSigningKey,
browserStore: const RoutedAuthLastAuthenticationMethodBrowserStore(),
policy: AuthLastAuthenticationMethodPolicy(
allowedMethods: const {
AuthLastAuthenticationMethodId.credentials,
AuthLastAuthenticationMethodId.passkey,
},
),
);
const lastMethodClient = AuthLastAuthenticationMethodClientPlugin();
final authClient = AuthClient(
baseUrl: Uri.parse('https://api.example.com/auth'),
plugins: const [lastMethodClient],
);
final previous = await authClient.plugins.use(lastMethodClient).read();
The cookie is Secure, HttpOnly, SameSite, signed, and cleared by Routed after sign-out or account deletion. Failed authentication never replaces it.
The public auth endpoint security contract
catalogues every core and opt-in operation with its authentication, browser
Origin, CSRF, rate-limit key, redirect, session/JWT, and generic-error
behavior. Two-factor routes are mounted only when TwoFactorPlugin is
composed.
AuthServiceProvider creates the AuthRuntime and exposes it through the
AuthManager. Applications provide a typed AuthStore; persistence is not
created implicitly by the framework. RoutedAuthRateLimiter is exported by
routed_rate_limit and adapts its existing method/path/IP policies to auth
operations. Configure EngineConfig.trustedProxies (or the typed routed
security provider) before relying on forwarded client-IP headers.
OAuth state, PKCE verifiers, OIDC nonces, and callback URLs are stored in the
typed AuthStore.oauthChallenges boundary and consumed atomically during the
callback. A durable store must implement that operation transactionally and
protect the short-lived challenge values at rest. Routed also binds each OAuth
challenge to a short-lived, HTTP-only browser state cookie, so a callback
started in another browser is rejected before the durable challenge is
consumed. The cookie uses SameSite=None over HTTPS to support provider
form_post callbacks and SameSite=Lax during local HTTP development.
Email magic-link verification is bound to a separate short-lived, HTTP-only browser cookie as well. This prevents a user from being silently signed into another person’s account by opening that person’s magic link. Applications that intentionally support cross-device magic-link completion should expose an explicit confirmation step rather than disabling this binding implicitly.
Password-reset tokens use the separate AuthStore.passwordResetTokens
boundary. Store implementations must hash them and consume them atomically;
server_auth also provides framework-agnostic helpers for issuing tokens,
replacing password credentials, rotating per-user JWT versions, and revoking
server sessions. HTTP delivery is supplied through
AuthOptions.passwordResetSender. When that sender is configured, Routed
registers:
POST /auth/password-reset/requestPOST /auth/password-reset/confirm
Authenticated sessions can change their password through
POST /auth/password/change. The request requires the current password and
the new password; a successful change revokes all server sessions or rotates
the JWT version and expires the current auth cookie.
The request route always returns the same accepted response for known and unknown emails. JWTs without the current per-user version are rejected.
Server-side session management is available through:
GET /auth/sessionsPOST /auth/sessions/revokePOST /auth/sessions/revoke-others
Session responses include safe device metadata and never include the persisted session-token digest. These routes are not registered for JWT sessions.
Routed exposes the following 2FA route shell; composing TwoFactorPlugin
enables it, while an absent plugin returns two_factor_unavailable:
GET /auth/2fa/statusPOST /auth/2fa/enrollPOST /auth/2fa/enroll/verifyPOST /auth/2fa/verifyPOST /auth/2fa/recovery-codePOST /auth/2fa/recovery-codes/regeneratePOST /auth/2fa/disablePOST /auth/2fa/challenge/verifyPOST /auth/2fa/challenge/recovery-codePOST /auth/2fa/trusted-devices/revokePOST /auth/2fa/step-upPOST /auth/2fa/step-up/revoke
The account-management routes require an authenticated session and all
state-changing routes use the existing browser and CSRF protections.
Credential sign-ins for enabled users return a 202 two_factor_required
response until the challenge route verifies TOTP.
The challenge request may include trustDevice: true; after successful TOTP,
Routed sets an expiring HTTP-only trusted-device cookie. The revoke route
invalidates all trusted devices for the current user. The plugin's required
AuthTwoFactorBackend atomically consumes pending TOTP or recovery challenges,
updates bounded attempts, and persists any trusted-device or step-up record.
POST /auth/2fa/step-up verifies a fresh TOTP code and sets a short-lived,
session-bound HTTP-only proof cookie. Routed consumers can enforce it with
AuthManager.requireTwoFactorStepUp before sensitive actions;
POST /auth/2fa/step-up/revoke clears the proof.
Routed issues the server session and writes response cookies after the backend command commits. Session/cookie delivery is therefore not part of the durable two-factor transaction. If host delivery fails, the consumed one-time challenge remains consumed and the caller must restart sign-in.
Password change and reset revoke trusted devices through the two-factor backend, but that command is separate from Routed's host-owned credential and session mutations. Durable deployments that need one transaction across those stores must provide that wider boundary in their auth persistence adapter.
API-key authentication
Compose AuthApiKeyPlugin in AuthOptions.plugins to add:
GET /auth/api-keys/listPOST /auth/api-keys/createPOST /auth/api-keys/rotatePOST /auth/api-keys/revokePOST /auth/api-keys/exchangewhen session exchange is explicitly enabled
The raw secret is returned only by create and rotate. To authenticate service requests, add the middleware after the plugin is composed:
final apiKeys = AuthApiKeyPlugin<EngineContext>(store: myApiKeyStore);
final apiKeyMiddleware = apiKeyAuthentication(
plugin: apiKeys,
userStore: myAuthStore.users,
);
It accepts X-API-Key and Authorization: ApiKey .... A verified key becomes
the request principal without creating a browser session. If
sessionExchangeEnabled: true is configured, the same header can be posted to
/auth/api-keys/exchange to create a normal server session. Use
currentApiKey to inspect scopes in application middleware or handlers.
Managed SCIM connections
Compose AuthScimConnectionPlugin<EngineContext> only in deployments that
want an authenticated management surface. Routed automatically mounts the
typed connection and credential operations under /auth/scim/connections.
The application-provided authorizer must return the exact tenant,
organization, and management subject for each request; returning null denies
the operation.
import 'package:routed_auth/routed_auth.dart';
final connectionStore = MyDurableScimConnectionStore(database);
final managedScim = AuthScimConnectionPlugin<EngineContext>(
store: connectionStore,
authorize: (request) async {
final user = request.invocation.user;
if (user == null ||
!await canManageScim(user.id, request.organizationId)) {
return null;
}
return AuthScimConnectionManagementPrincipal(
tenantId: await tenantFor(user.id),
organizationId: request.organizationId,
subjectId: user.id,
);
},
);
final provisioning = ScimPlugin<EngineContext>(
store: MyScimProvisioningStore(database),
tokenResolver: AuthScimManagedBearerTokenResolver(
store: connectionStore,
),
);
final auth = AuthDeploymentPresets.localDevelopment<EngineContext>(
providers: [CredentialsProvider()],
plugins: [managedScim, provisioning],
trustedOrigins: [Uri.parse('http://localhost:3000')],
);
Management reads require a session. Mutations also inherit Routed's browser Origin, Fetch Metadata, CSRF, rate-limit, and generic-error handling. Creating a connection, issuing a credential, and rotating a credential require an idempotency key. The first committed response contains the raw bearer secret; a replay contains only safe metadata. Disabling a connection atomically revokes every credential, and user/tenant deletion removes credentials with their connections.
Install the management client independently:
const scimConnections = AuthScimConnectionClientPlugin();
final authClient = AuthClient(
baseUrl: Uri.parse('https://api.example.com'),
plugins: const [scimConnections],
);
final created = await authClient.plugins.use(scimConnections).create(
organizationId: organizationId,
name: 'Workforce directory',
provisioningDomainId: 'employees',
scopes: const [AuthScimScope.usersWrite, AuthScimScope.groupsWrite],
credentialName: 'Identity provider',
idempotencyKey: createRequestId,
);
// Store or deliver this value now. It is never returned by a replay.
final bearerSecret = created.issuance.secret;
Production deployments need a durable AuthScimConnectionStore; the bundled
in-memory store is bounded and intended for tests and local development.
Application-owned SCIM projections
package:routed_auth/routed_auth.dart also exports the server-neutral
AuthScimApplicationProjectionStore API. It adds no Routed endpoint and no
client plugin. Use it from an application outbox consumer or a transaction the
application owns:
final scope = AuthScimApplicationProjectionScope(
connectionId: connection.id,
tenantId: connection.tenantId,
organizationId: connection.organizationId,
provisioningDomainId: connection.provisioningDomainId,
);
final subject = AuthScimApplicationProjectionSubject(
scope: scope,
resourceId: scimUser.id,
kind: AuthScimApplicationSubjectKind.user,
);
await projectionStore.apply(
AuthScimApplicationProjectionCommand(
operationId: projectionEventId,
mutation: AuthScimApplicationProjectionMutation.create,
desired: AuthScimApplicationProjectionSnapshot(
subject: subject,
sourceVersion: scimUser.meta.version ??
scimUser.meta.lastModified.toIso8601String(),
sourceDigest: await digestCanonicalDirectoryProfile(scimUser),
state: scimUser.data.active
? AuthScimApplicationProjectionState.active
: AuthScimApplicationProjectionState.disabled,
),
),
);
The identity key is the exact connection binding plus SCIM resource ID and
kind. Never search by email, userName, display name, or externalId, and
never create a Routed session merely because the projection exists. Retire a
connection with the snapshot-checked deleteScope command; its deletion fence
rejects delayed projection events. Durable adapters should run the projection
store conformance suite exported by package:server_auth/testing.dart.
Organizations
Compose OrganizationPlugin<EngineContext> to opt in. AuthRoutes discovers
its portable descriptors and automatically mounts the organization API; there
is no separate route-registration call. If the plugin is absent, every
organization route is absent.
The API lives under /auth/organization/ and covers create/check/list/get/
update/delete/set-active; member list/remove/role/leave; invitation invite/
accept/reject/cancel/get/list; permission checks and dynamic-role CRUD; and
team/team-member CRUD plus active-team selection. Read operations use GET;
mutations use POST and inherit Routed's authentication, origin, Fetch
Metadata, CSRF, rate-limit, generic-error, and retry-header handling from the
operation descriptor.
Create, invite, role/team create, and team-member add requests require a
bounded, non-secret idempotencyKey. Retrying the same key and exact request
returns the original durable result; reusing it across an actor, organization,
operation, or changed payload fails closed. Organization stores recheck scoped
membership snapshots inside atomic capacity, uniqueness, creator-preservation,
and cascade commands. Store-confirmed replays skip delivery and post-commit
lifecycle/event callbacks. Pre-commit transformation hooks remain a host
boundary and may run again before the store identifies a retry.
Server-session deployments may persist active organization/team convenience state. Membership is still revalidated on every scoped request, and stale selection is cleared. JWT clients keep selection locally and send explicit organization/team IDs; setting active context never reissues a JWT.
Use Haigate.organizationContext, Haigate.canInOrganization, and
Haigate.ownsOrCanInOrganization for explicit tenant membership, permission,
and resource-owner checks. These helpers do not merge organization roles into
the user's global principal.
Administration
Compose AdminPlugin<EngineContext> to opt in. AuthRoutes automatically
mounts its portable descriptors under /auth/admin/; without the plugin,
those routes do not exist. The API covers user create/list/get/update/delete,
role/password changes, bans, permission checks, target-session administration,
and guarded server-session impersonation.
All operations require an authenticated session. Mutations use POST and
inherit Routed's origin, Fetch Metadata, CSRF, namespaced rate-limit, generic
error, and retry-header handling. User reads and session reads return safe
projections: password hashes, raw session tokens, token hashes, provider
tokens, and internal hook errors are never serialized.
Routed consults plugin authentication policies before a two-factor challenge, before issuing a session, and whenever a session is resolved. Consequently an active Admin ban blocks credentials, email, OAuth, two-factor completion, server-session reuse, and Routed-issued JWT reuse. Sensitive mutations revoke server sessions and rotate JWT versions through the backend-owned admin command. Revoking one named server session does not revoke a JWT; revoking all sessions rotates the user's JWT version.
Impersonation is intentionally unavailable with JWT sessions. With server sessions the admin store consumes the current session as a single-use transition, records the original administrator in server-side metadata, defaults to one hour, prevents chaining, and restores a fresh administrator session when stopped. The impersonated identity receives only the target user's roles and permissions. Replacement session creation remains a Routed host operation after the store commit; a host failure leaves the consumed session revoked rather than replaying the transition.
See the documentation site's Administrative Auth guide for setup, custom permissions, adapter requirements, typed-client usage, and the complete route catalogue.
Libraries
- routed_auth
- Routed authentication middleware, guards, providers, and session helpers.
- testing
- Public test support for validating Routed auth host transports.