server_auth 0.2.1 copy "server_auth: ^0.2.1" to clipboard
server_auth: ^0.2.1 copied to clipboard

Framework-agnostic authentication runtime capabilities for server ecosystems.

server_auth #

Framework-agnostic authentication runtime primitives and provider implementations.

Includes built-in providers for Google, Discord, Microsoft Entra, Apple, Twitter/X, Facebook, GitLab, Slack, Spotify, LinkedIn, Twitch, Dropbox, and Telegram.

server_auth is designed to be consumed by framework adapters. It provides auth building blocks (providers, JWT, CSRF, gates/authorization, callbacks, token utilities) without requiring Routed-specific runtime types.

Installation #

dependencies:
  server_auth: ^0.2.0

Entry points #

  • package:server_auth/server_auth.dart (umbrella export)

Use the package umbrella library for the public API.

Package Selection #

  • Use server_auth for auth runtime primitives and provider implementations.
  • Use server_contracts for contract-only abstractions.
  • Use adapter packages (routed, shelf_auth, etc.) for framework-specific HTTP/session wiring.

Durable store conformance #

Production AuthStore adapters should run the framework-neutral contract from package:server_auth/testing.dart against a fresh database or namespace for every case:

final suite = AuthStoreConformanceSuite.fromStoreFactory(
  createStore: openIsolatedStore,
  disposeStore: (store) => (store as MyAuthStore).close(),
);

for (final conformanceCase in suite.cases) {
  test(conformanceCase.id, () async {
    final result = await conformanceCase.run();
    if (result.isSkipped) markTestSkipped(result.skippedReason!);
  });
}

The public case IDs cover canonical identity and credential races, globally unique account links, exactly-one-winner session rotation with no persisted losers, verification/password-reset/OAuth replay, monotonic JWT rotation, device authorization, email OTPs, and account-deletion rollback. The suite has no dependency on package:test; adapters may register its cases with any test runner. Optional capabilities are reported as explicit skips, while every core AuthStore contract is required.

Plugin conformance #

Plugin authors can validate a composed runtime with the framework-neutral suite exported by package:server_auth/testing.dart:

final suite = AuthPluginConformanceSuite.fromRuntime(
  runtime,
  publicEndpointClientExceptions: {
    'mcpAuth.protectedResourceMetadata':
        'Protocol metadata is consumed by generic HTTP clients.',
  },
);

for (final conformanceCase in suite.filtered(
  include: {
    'endpoints.typed-contracts',
    'endpoints.mutation-protection',
    'endpoints.operation-semantics',
  },
)) {
  test(conformanceCase.id, conformanceCase.run);
}

The cases check stable composition and route identifiers, typed operation contracts, rate-limit references, bidirectional public client-operation mapping, safe origin/CSRF metadata, and explicit persistence/replay semantics for every portable or host-owned mutation. Every non-server-only endpoint needs a matching client operation unless the suite is given a non-empty explanation for a deliberate protocol/discovery omission. Rate-limited anonymous verification may omit session CSRF with either browser-origin validation or an explicit non-browser policy. The reusable suite itself has no dependency on package:test.

Endpoint authors must choose read-only or mutation semantics directly on the descriptor. Mutations identify their durable, session, bounded-ephemeral, or external persistence boundary and whether replay is idempotent, single-use, intentionally repeatable, or currently unguarded. Atomic durable claims must reference an operation declared by the composed plugin's public persistence schema:

import 'package:server_auth/server_auth.dart';

final rotateEndpoint = TypedAuthEndpointDescriptor<
  Object,
  Map<String, dynamic>,
  Object?
>(
  id: 'keys.rotate',
  method: AuthOperationMethod.post,
  path: const AuthRoutePath('/keys/rotate'),
  semantics: const AuthOperationSemantics.mutation(
    persistence: AuthMutationPersistence.durable(
      atomicity: AuthMutationAtomicity.atomic,
      reference: AuthPersistenceOperationReference(
        schemaId: 'keys',
        atomicOperationId: 'keys.rotate',
      ),
    ),
    replaySafety: AuthMutationReplaySafety.singleUse,
  ),
  requestCodec: requestCodec,
  responseCodec: responseCodec,
  handler: rotateKey,
);

Dynamic segments are declared once and stay separate from query and body input. Framework adapters bind them and clients encode each value as one path segment:

const keyId = AuthRouteParameterKey('keyId');
const keyRoute = AuthRoutePath(
  '/keys/{keyId}',
  parameters: [keyId],
);

final id = invocation.request.requirePath(keyId);

Conformance resolves both reference IDs against the frozen plugin topology. A multi-step handler must use nonAtomic unless its adapter exposes one real transactional operation covering the entire advertised mutation.

Every server plugin also publishes its data ownership explicitly. Plugins that own a login method or user records must declare the matching typed inventory or deletion namespace. Credential-removal and rotation routes are listed by ID and must be recent-authenticated mutations owned by that plugin:

final class DeviceKeyPlugin implements
    AuthServerPlugin<Object>,
    AuthAuthenticationMethodInventoryContributor,
    AuthEndpointContributor<Object> {
  @override
  String get id => 'device_keys';

  @override
  AuthServerPluginDataContract get dataContract =>
      const AuthServerPluginDataContract(
        authenticationMethodNamespace: 'device_keys',
        removalEndpointIds: <String>['device_keys.remove'],
      );

  // configure, authenticationMethodsForUser, and endpoints...
}

Plugins with no user-owned authentication data return const AuthServerPluginDataContract.none(). The registry rejects missing or mismatched declarations, unknown removal routes, and removal routes without the recent-authentication boundary before the topology is frozen.

Quick start #

import 'package:server_auth/server_auth.dart';

final google = googleProvider(
  GoogleProviderOptions(
    clientId: 'google-client-id',
    clientSecret: 'google-client-secret',
    redirectUri: 'https://example.com/auth/callback/google',
  ),
);

final deployment = AuthDeploymentPresets.localDevelopment<MyRequestContext>(
  providers: <AuthProvider>[google],
  trustedOrigins: [Uri.parse('http://localhost:3000')],
);

Bind deployment.options to the framework adapter, pass deployment.configuration to its auth provider, and apply deployment.proxyPolicy at the HTTP boundary. Production applications should instead choose secureSessionProduction, jwtApiProduction, or serviceApiKeyProduction; those presets require the durable store, trusted origin/proxy decision, rate limiter, verified-email policy, and lifecycle delivery choices explicitly. Providers and plugins remain opt-in inputs to every preset.

Low-level AuthOptions usage carries the same typed runtime posture. Durable options are production options: they require an exact HTTPS origin set, an explicit direct or trusted-proxy decision, secure browser and cookie policy, an explicit rate limiter, and an algorithm-sized secret when JWT sessions are enabled. Ephemeral local development is an explicit opt-out:

final productionBoundary = AuthProductionBoundary(
  trustedOrigins: [Uri.parse('https://app.example.com')],
  proxyPolicy: const AuthProxyPolicy.direct(),
);

final localOptions = AuthOptions<MyRequestContext>(
  providers: [CredentialsProvider()],
  store: InMemoryAuthStore(),
  storeMode: AuthStoreMode.ephemeral,
);

AuthRuntime revalidates production posture automatically. Framework adapters must apply AuthProductionBoundary.proxyPolicy at their HTTP boundary; Routed does this through AuthDeployment.engineConfig().

One-time email authentication is opt-in. Select only the server plugins the application exposes, and supply an auth store whose durable adapter implements the typed email command backends:

import 'package:server_auth/server_auth.dart';

final magicLink = MagicLinkPlugin<MyRequestContext>(
  sendMagicLink: (delivery) async {
    await mailer.sendMagicLink(
      to: delivery.email,
      token: delivery.token,
      callbackUrl: delivery.callbackUrl,
      expiresAt: delivery.expiresAt,
    );
  },
);

final emailOtp = EmailOtpPlugin<MyRequestContext>(
  secret: environment.emailOtpDigestSecret,
  sendCode: (delivery) async {
    await mailer.sendOtp(
      to: delivery.email,
      code: delivery.code,
      expiresAt: delivery.expiresAt,
    );
  },
);

final options = AuthOptions<MyRequestContext>(
  store: durableAuthStore,
  providers: const [],
  plugins: [magicLink, emailOtp],
  productionBoundary: productionBoundary,
  rateLimiter: authRateLimiter,
);

MagicLinkPlugin requires AuthMagicLinkBackend; EmailOtpPlugin requires AuthEmailOtpBackend. Those typed commands make replacement, attempt accounting, one-time consumption, and user creation or verification one backend-owned transaction. Magic-link tokens are stored only as SHA-256 digests. Low-entropy OTPs are stored only as domain-separated HMAC-SHA-256 digests using the application secret. The raw token or code exists only in its delivery callback and must not be logged or persisted.

Delivery is deliberately postcommit: if the mail provider fails, the newly committed digest remains authoritative and a retry should issue a replacement. Likewise, a successful consume/user transition commits before a framework host issues its session or cookie. If that host step fails, the credential remains consumed and cannot be replayed. Email delivery and host session issuance therefore cannot honestly be part of the database transaction.

Durable adapters should run both AuthStoreConformanceSuite and verifyAuthEmailBackendConformance from package:server_auth/testing.dart, plus adapter-native fault injection at every transaction statement.

When an authentication plugin is removed from a deployment, retain its namespace in AuthOptions.historicalAuthenticationMethodNamespaces until the adapter has a backend-owned cleanup path. The runtime then marks the inventory incomplete and fails destructive authentication-method mutations closed instead of treating the removed plugin's credentials as absent. For plugin-owned user data, retain its storage namespace in AuthOptions.historicalUserDataNamespaces; coordinators with historical topology support fail hard deletion closed until that contributor is active again.

Typed Dart client #

AuthClient owns the shared transport and installs only the optional client plugins selected by the application:

final magicLinkPlugin = AuthMagicLinkClientPlugin();
final emailOtpPlugin = AuthEmailOtpClientPlugin();
final organizationPlugin = AuthOrganizationClientPlugin();
final auth = AuthClient(
  baseUrl: Uri.parse('https://example.com'),
  cookieStore: myPersistentCookieStore, // Optional for mobile persistence.
  plugins: [magicLinkPlugin, emailOtpPlugin, organizationPlugin],
);

final magicLink = auth.plugins.use(magicLinkPlugin);
await magicLink.send(
  email: 'ada@example.com',
  callbackUrl: 'https://example.com/welcome',
);
final emailOtp = auth.plugins.use(emailOtpPlugin);
await emailOtp.sendVerificationOtp(
  email: 'ada@example.com',
  type: AuthEmailOtpType.signIn,
);
final organizations = auth.plugins.use(organizationPlugin);
final values = await organizations.list();

The default InMemoryAuthClientCookieStore is suitable for tests and short-lived clients. Browser, mobile, and desktop applications can implement AuthClientCookieStore with their platform's secure persistence. Use setBearerToken when the server is configured for bearer/JWT sessions.

Each optional client plugin returns one typed API and is inactive until selected. Built-in plugins cover providers, credentials, sessions, OAuth, magic links, email OTP, anonymous accounts, device authorization, API keys, WebAuthn, two-factor auth, accounts, passwords, organizations, and admin operations. The client does not persist secrets or silently provision local accounts.

The public auth endpoint security contract maps the core and opt-in HTTP operations to authentication, browser Origin, CSRF, rate-limit keys, redirects, session/JWT projection, and generic public errors. server_auth defines the portable descriptors; a framework adapter is responsible for enforcing that contract.

Phone-number authentication #

Phone sign-in is an opt-in server plugin backed by one required root-store transaction capability:

final store = MyDurableAuthStore(database); // implements AuthPhoneNumberBackend
final phone = PhoneNumberPlugin<MyRequestContext>(
  sendCode: sendPhoneCode,
  codeHashKey: phoneCodeHashKey,
  allowSignUp: true,
);

final options = AuthOptions<MyRequestContext>(
  providers: const [],
  store: store,
  plugins: [phone],
);

AuthPhoneNumberBackend accepts digest-only issue and verification commands. The verification transaction owns bounded attempts and lockout, expiry, one-time challenge consumption, user availability, optional user creation, unique E.164 ownership, the verified-phone user projection, and hard-deletion cleanup. Durable adapters can run AuthPhoneNumberBackendConformanceSuite from package:server_auth/testing.dart against their real database and fault injection.

InMemoryAuthStore implements the same commands for tests and local development. A durable store without the capability fails plugin configuration; no compatibility store or process-local fallback is used. The raw code exists only in AuthPhoneNumberCodeDelivery. SMS delivery, onVerified, and framework session/cookie issuance happen after the backend transaction commits, so failures at those boundaries do not roll it back.

The plugin also contributes POST /phone-number/remove and the matching AuthPhoneNumberClient.remove() operation. It removes only the current phone identity, never the last usable authentication method, and clears the verified-phone projection and challenge artifacts in the backend transaction. Framework adapters must enforce the endpoint's recent-authentication or step-up metadata before invoking it; the persistence plugin does not treat an active session alone as sufficient proof.

Anonymous accounts #

Anonymous authentication is an explicit server plugin. The root store must implement both AuthAnonymousAccountMutationStore and AuthUserDeletionCoordinatorHost in the same persistence topology:

final store = MyDurableAuthStore(database);

final options = AuthOptions<MyRequestContext>(
  providers: const [],
  store: store,
  plugins: [AnonymousPlugin<MyRequestContext>()],
);

The mutation store owns typed transactions for anonymous creation, authenticated deletion, and upgrade finalization. It persists the operation receipt in the same transaction so retries cannot duplicate creation or bind an upgrade to another target. Deletion and upgrade scrub creation receipts that contain user data and retain only digest-bound completion receipts. The framework host authenticates the target and issues its replacement session before upgrade finalization; the plugin never creates or serializes sessions.

InMemoryAuthStore implements the contract for tests and local development. A durable adapter that does not implement it fails during plugin configuration. package:routed_auth_cloudflare implements the contract with D1 transactions and digest-only bounded replay receipts; adapters that have not implemented it still fail closed without a process-local fallback.

Optional SCIM 2.0 provisioning plugin #

SCIM is a server-to-server protocol, so selecting the server plugin does not add anything to AuthClient. The application owns both bearer-token verification and durable provisioning storage:

import 'package:server_auth/server_auth.dart';

final scim = ScimPlugin<MyRequestContext>(
  tokenResolver: MyScimTokenResolver(connectionStore),
  store: MyScimProvisioningStore(database),
  options: AuthScimOptions(
    defaultPageSize: 50,
    maximumPageSize: 200,
    maximumPatchOperations: 16,
    maximumGroupMembers: 500,
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: const [],
  store: authStore,
  storeMode: AuthStoreMode.durable,
  plugins: [scim],
);

The resolver must digest the presented token immediately and atomically return one immutable AuthScimConnectionIdentity: connection ID, credential ID, tenant ID, organization ID, provisioning-domain ID, subject, expiry, and exact User or Group read/write scopes. It must reject revoked or expired credentials and must never persist, log, or return a raw token.

Applications that want the auth runtime to manage that catalog can compose the separate management plugin and connect its digest resolver to the protocol plugin:

import 'package:server_auth/server_auth.dart';

final connectionStore = MyDurableScimConnectionStore(database);

final connections = AuthScimConnectionPlugin<MyRequestContext>(
  store: connectionStore,
  authorize: (request) async {
    final user = request.invocation.user;
    if (user == null ||
        !await canManageDirectory(user.id, request.organizationId)) {
      return null;
    }
    return AuthScimConnectionManagementPrincipal(
      tenantId: await tenantFor(user.id),
      organizationId: request.organizationId,
      subjectId: user.id,
    );
  },
);

final scim = ScimPlugin<MyRequestContext>(
  tokenResolver: AuthScimManagedBearerTokenResolver(
    store: connectionStore,
  ),
  store: MyScimProvisioningStore(database),
);

final options = AuthOptions<MyRequestContext>(
  providers: const [],
  store: authStore,
  storeMode: AuthStoreMode.durable,
  plugins: [connections, scim],
);

AuthScimConnectionPlugin creates the connection and initial credential in one transaction, supports bounded connection/credential catalogs, optimistic connection updates, disablement, issuance, rotation, revocation, and expiry, and contributes user-deletion plans. Tenant deletion is an explicit trusted operation on the plugin. A durable adapter must implement AuthScimConnectionStore and should run the reusable AuthScimConnectionStoreConformanceSuite.

Raw bearer credentials are returned only by the first successful create, issue, or rotate response. Required idempotency keys bind each issuance to its normalized payload. A replay returns the same safe connection and credential metadata with replayed: true, but never returns or reconstructs the raw secret. Therefore response delivery remains intentionally at-most-once: after an ambiguous network failure, rotate the credential instead of retrying until a raw value appears. Persistence contains only a strong digest, safe prefix, scopes, expiry/revocation timestamps, and exact connection/tenant/organization binding.

Each provisioning-store call receives the resolved connection context and must enforce the exact connection, tenant, organization, and provisioning domain in persistence. Create, replace, patch, uniqueness checks, and tombstoning each require a real atomic store mutation. Do not advertise an adapter as SCIM-safe when its backend cannot provide those semantics; in particular, this package does not claim interactive multi-step transaction support for Cloudflare D1.

SCIM Users and Groups are directory resources, not authentication users, application roles, or organization memberships. The plugin never creates a sign-in method, grants application access, or links an existing auth user by email. Directory lifecycle is explicit (active, inactive, or tombstoned).

Groups contain only bounded direct references to stable SCIM User or Group resource IDs. The provisioning store atomically owns group create, replace, patch, direct membership mutation, and tombstoning. It must validate every reference against the same connection, tenant, organization, and provisioning domain, reject duplicate or conflicting members, and remove live memberships when tombstoning. No role or access grant is inferred from a display name, email, or Group membership.

Applications that need a local directory projection use the separate AuthScimApplicationProjectionStore. Its key contains only the exact connection, tenant, organization, provisioning domain, SCIM resource ID, and resource kind. Email, userName, externalId, auth-user IDs, roles, sessions, and credentials are deliberately absent from the identity key.

import 'package:server_auth/server_auth.dart';

final projectionStore = MyDurableScimApplicationProjectionStore(database);
final scope = AuthScimApplicationProjectionScope(
  connectionId: connection.id,
  tenantId: connection.tenantId,
  organizationId: connection.organizationId,
  provisioningDomainId: connection.provisioningDomainId,
);

final subject = AuthScimApplicationProjectionSubject(
  scope: scope,
  resourceId: directoryUser.id,
  kind: AuthScimApplicationSubjectKind.user,
);
final desired = AuthScimApplicationProjectionSnapshot(
  subject: subject,
  sourceVersion: directoryUser.meta.version ??
      directoryUser.meta.lastModified.toIso8601String(),
  // Application-defined SHA-256 over the canonical fields it projects.
  sourceDigest: await digestCanonicalDirectoryProfile(directoryUser),
  state: directoryUser.data.active
      ? AuthScimApplicationProjectionState.active
      : AuthScimApplicationProjectionState.disabled,
);

final current = await projectionStore.list(
  AuthScimApplicationProjectionQuery(scope: scope),
);
final result = await projectionStore.reconcile(
  AuthScimApplicationReconciliationCommand(
    operationId: reconciliationJobId,
    scope: scope,
    expectedProjectionSnapshotId: current.projectionSnapshotId,
    // This must be the complete authoritative snapshot for the scope.
    authoritative: [desired, ...otherDirectorySubjects],
  ),
);

The store derives idempotency payload digests from the typed command. It rejects stale projection snapshots, cross-scope members, missing members, operation rebinding, and attempts to reactivate tombstoned resources. Group members are stable SCIM User or Group resource IDs only. A projection record still does not authorize the subject and cannot be exchanged for a session; the application must make any access decision through a separate explicit policy.

ScimPlugin does not invoke the projection store automatically. Coordinate a directory commit and projection through a durable outbox, or implement both in one application-owned backend transaction. Routed does not claim atomicity across unrelated databases or external systems. Durable adapters should run AuthScimApplicationProjectionStoreConformanceSuite from package:server_auth/testing.dart.

When permanently retiring a managed connection, delete its projection with the last observed snapshot ID:

await projectionStore.deleteScope(
  AuthScimApplicationProjectionScopeDeletionCommand(
    operationId: connectionDeletionJobId,
    scope: scope,
    expectedProjectionSnapshotId: current.projectionSnapshotId,
  ),
);

That operation atomically removes the scope and installs a permanent deletion fence, so delayed events cannot recreate it. This is intentionally separate from auth-user hard deletion: projection records contain no auth-user key and the projection store contributes no authentication inventory or user-deletion plan. AuthScimConnectionPlugin continues to own deletion of managed connection credentials through its existing coordinated deletion contract.

The selected framework adapter mounts Bearer-authenticated ServiceProviderConfig, ResourceTypes, Schemas, and bounded User and Group list/get/create/replace/patch/delete operations under its auth base path (for Routed, /auth/scim/v2). Group endpoints require exact Group scopes and all responses use application/scim+json. Public failures use generic SCIM error documents and never include bearer tokens or persistence exception details. The protocol surface has no SCIM client plugin because directories consume it directly. Connection administration is independently selectable through AuthScimConnectionClientPlugin; clients that do not install it receive no management methods. Application projection orchestration remains explicitly application-owned.

Device authorization issuance #

Device authorization uses a bounded, digest-only issuance lease. The application token service must implement the typed idempotent issuer and use the stable authorization ID as its durable idempotency key:

final class ApplicationDeviceTokenIssuer
    implements AuthDeviceAuthorizationTokenIssuer<MyRequestContext> {
  ApplicationDeviceTokenIssuer(this.tokens);

  final ApplicationTokenService tokens;

  @override
  Future<AuthDeviceAccessToken> issue(
    AuthDeviceAuthorizationTokenIssuanceRequest<MyRequestContext> request,
  ) {
    return tokens.issueIdempotently(
      idempotencyKey: request.authorizationId,
      userId: request.user.id,
      clientId: request.clientId,
      scopes: request.scopes,
    );
  }
}

final deviceAuthorization = DeviceAuthorizationPlugin<MyRequestContext>(
  verificationUri: 'https://example.com/device',
  validateClient: validateDeviceClient,
  tokenIssuer: ApplicationDeviceTokenIssuer(applicationTokens),
);

The token service must reject reuse of an authorization ID with a different user, client, or scope binding and return the same logical grant after an ambiguous failure. Routed stores no raw device code, user code, lease value, access token, or refresh token. An issuer failure releases only its matching lease; a process crash is recoverable after lease expiry.

There is one deliberate at-most-once delivery boundary: after the application issuer succeeds and Routed commits the authorization as consumed, loss of the HTTP response cannot be replayed from Routed because Routed does not retain token material. Applications requiring retryable delivery must implement that delivery/result reference in their own token service. Routed guarantees that retry before completion uses the same authorization ID; it does not claim exactly-once token delivery.

Optional SAML SSO plugin #

SAML is opt-in on both sides. The application owns the immutable connection catalog, durable replay transaction, XMLDSig implementation, and the mapping from a signed external account key to an application user:

final saml = AuthSamlPlugin<MyRequestContext>(
  connections: mySamlConnectionCatalog,
  replayStore: myDurableSamlReplayStore,
  assertionVerifier: AuthPortableSamlXmlDsigVerifier(),
  identityResolver: mySamlIdentityResolver,
  browserBindingResolver: (context) => context.browserSessionId,
  options: AuthSamlOptions(
    redirectPolicy: AuthSamlRedirectPolicy(
      trustedOrigins: {Uri.parse('https://app.example.com')},
    ),
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: const [],
  store: authStore,
  storeMode: AuthStoreMode.durable,
  productionBoundary: productionBoundary,
  plugins: [saml],
);

Each AuthSamlConnection pins the provider ID, IdP entity ID and signing certificate, SP entity ID, HTTPS SSO and ACS endpoints, NameID format, verified domains, and optional organization slug. Domain selection uses only the catalog's verified-domain index. Authentication identity is always the signed NameID plus pinned IdP entity and provider IDs; an email attribute is never an implicit account-link key. AuthSamlIdentityResolver is the explicit application seam for resolving or provisioning users and can later share an identity mapping with SCIM without coupling the plugins.

The server emits SP metadata and HTTP-POST AuthnRequests, then accepts HTTP-POST ACS responses. AuthnRequest, RelayState, browser binding, provider binding, and assertion replay are consumed by one durable atomic store operation. IdP-initiated SSO is disabled unless a fixed callback is selected. Final sessions or JWTs, callbacks, lifecycle events, and redirects remain host-owned.

The client adds only SAML operations:

const samlClientPlugin = AuthSamlClientPlugin();
final auth = AuthClient(
  baseUrl: Uri.parse('https://api.example.com'),
  plugins: const [samlClientPlugin],
);
final form = await auth.plugins.use(samlClientPlugin).signIn(
  AuthSamlSignInRequest(
    providerId: 'acme',
    callbackUrl: Uri(path: '/dashboard'),
  ),
);
// Submit form.fields to form.destination using an HTML POST form.

AuthPortableSamlXmlDsigVerifier is the built-in pure-Dart verifier. Its default policy accepts only RSA-SHA256 signatures, SHA-256 digests, exclusive canonicalization without comments, and the exact enveloped-signature then exclusive-canonicalization transform sequence. Trust comes only from the one certificate pinned by AuthSamlConnection; an embedded certificate is ignored as a trust source and must match the pin when present. Every local #ID reference must name the signature's direct Response or Assertion parent.

RSA-SHA384/SHA-384 and RSA-SHA512/SHA-512 are implemented but require explicit opt-in:

final verifier = AuthPortableSamlXmlDsigVerifier(
  policy: AuthSamlXmlDsigPolicy(
    signatureAlgorithms: {
      AuthSamlXmlDsigSignatureAlgorithm.rsaSha384,
    },
    digestAlgorithms: {
      AuthSamlXmlDsigDigestAlgorithm.sha384,
    },
  ),
);

SHA-1, ECDSA, RSA-PSS, comments canonicalization, InclusiveNamespaces prefix lists, XPath/XPointer, external or empty references, implicit transforms, encrypted assertions, and certificate chains are unsupported by the built-in profile. Applications needing another profile can still implement the application-owned AuthSamlAssertionVerifier seam and must run AuthSamlVerifierConformanceSuite with independently signed and hostile fixtures. Dynamic connection administration, OIDC enterprise connections, single logout, and group or role provisioning are also outside this slice.

Optional last-authentication-method plugin #

Install this plugin only when the sign-in UI needs to remember which method a browser used most recently. It stores an allowlisted method ID in an expiring, HMAC-protected cookie; it never stores a user ID, provider payload, credential, or token:

final lastMethod = AuthLastAuthenticationMethodPlugin<MyRequestContext>(
  signingKey: lastMethodSigningKey,
  browserStore: myLastMethodBrowserStore,
  policy: AuthLastAuthenticationMethodPolicy(
    allowedMethods: const {
      AuthLastAuthenticationMethodId.credentials,
      AuthLastAuthenticationMethodId.passkey,
    },
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: [CredentialsProvider()],
  store: authStore,
  storeMode: AuthStoreMode.durable,
  productionBoundary: productionBoundary,
  plugins: [lastMethod],
);

The host updates the cookie only after successful session or JWT issuance and clears it on sign-out and account deletion. Client access is independently opted in with AuthLastAuthenticationMethodClientPlugin; the HttpOnly cookie itself is never exposed.

Optional username plugin #

Username-first authentication is opt-in and keeps its client API separately selectable. The server plugin owns normalization and treats values containing @ only as email identifiers, avoiding ambiguous username fallback:

final usernamePlugin = UsernamePlugin<MyRequestContext>(
  identifierPolicy: AuthUsernameIdentifierPolicy(
    minimumLength: 3,
    maximumLength: 32,
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: [CredentialsProvider()],
  store: authStore,
  storeMode: AuthStoreMode.durable,
  productionBoundary: productionBoundary,
  plugins: [usernamePlugin],
);

The selected root store must implement AuthUsernameStore. That capability owns one transaction spanning the normalized username reservation, user projection, and password credential. Registration and username changes fail closed when the root store cannot provide that boundary; the plugin never emulates it with separate lookup, remove, and write calls. InMemoryAuthStore implements the contract for local development and tests. Durable adapters can verify their implementation with the public helper from package:server_auth/testing.dart:

await verifyAuthUsernameStoreConformance(
  AuthUsernameStoreConformanceFixture(store: myDurableAuthStore),
);

Install only the matching client plugin when the application needs username registration, sign-in, rename, or safe removal:

const usernameClient = AuthUsernameClientPlugin();
final auth = AuthClient(
  baseUrl: Uri.parse('https://api.example.com/auth'),
  plugins: const [usernameClient],
);

await auth.plugins.use(usernameClient).signIn(
  identifier: 'ada',
  password: password,
);

await auth.plugins.use(usernameClient).change(username: 'ada-lovelace');
await auth.plugins.use(usernameClient).remove();

Rename is idempotent for the same normalized target. Removal is also idempotent, but succeeds only when the composed authentication-method inventory proves that another usable method remains in the same store-owned transaction. Conflicts, unavailable users, and persistence faults stay behind generic public failures.

Optional two-factor plugin #

Compose TwoFactorPlugin when an application needs TOTP and recovery codes:

final twoFactor = TwoFactorPlugin<void>(
  backend: myTwoFactorBackend,
  secretProtector: mySecretProtector,
);

AuthTwoFactorBackend is a required, two-factor-specific persistence boundary. Durable adapters implement its typed commands with backend-native transactions; the plugin does not accept transaction callbacks or coordinate fallback writes. The commands atomically cover enrollment activation, bounded TOTP attempts, one-time recovery use and regeneration, pending challenge completion, disablement, trusted-device lifecycle, and session-bound step-up proofs. InMemoryAuthTwoFactorBackend provides per-user serialization, rollback, and deterministic fault injection for tests. Adapter packages can run verifyAuthTwoFactorBackendConformance from package:server_auth/testing.dart against their durable implementation.

Recovery codes and opaque challenge, trusted-device, step-up, and session binding material cross persistence only as digests. Explicit trusted-device issuance requires a fresh TOTP code. AuthTwoFactorSecretProtector remains required so durable applications can use their own key-management system; the plaintext protector is intended only for tests and ephemeral examples.

Pending sign-in commands commit before the framework host creates a session or delivers cookies. Those host-owned effects are intentionally outside the two-factor transaction: a session-delivery failure can leave a consumed challenge, and clients must restart sign-in rather than replay it.

Password change and reset flows also belong to the host auth store. Their trusted-device revocation command can commit separately from credential and session changes unless a durable adapter provides a wider store-owned transaction; the two-factor plugin does not claim cross-backend atomicity.

Optional WebAuthn and passkeys #

Passkeys are installed as a server plugin. The root store may provide the optional typed WebAuthn capability, or the plugin may receive an explicit capability owner through storage. Durable stores must atomically consume challenge digests, enforce globally unique credential IDs, and compare and set signature counters.

final passkeyProvider = WebAuthnProvider(
  getUserInfo: resolvePasskeyUser,
  getRelyingParty: (_, _) => const WebAuthnRelyingParty(
    id: 'example.com',
    name: 'Example',
    origin: 'https://example.com',
  ),
);

final passkeys = WebAuthnPlugin<MyRequestContext>(
  provider: passkeyProvider,
);

final options = AuthOptions<MyRequestContext>(
  store: authStore,
  providers: providers,
  productionBoundary: productionBoundary,
  plugins: [passkeys],
);

The plugin fails configuration when no typed WebAuthn capability exists. Safe passkey removal also fails closed unless the backend can recheck every composed fallback method inside its own mutation boundary. The Cloudflare adapter provides this capability directly on CloudflareD1AuthStore; no WebAuthn sub-store or JavaScript interop needs to be supplied by application code.

Optional FIDO metadata trust evaluation #

FidoMetadataDownloader is an opt-in MDS 3.1.1 client. It accepts only HTTPS, follows a bounded number of same-origin redirects, bounds response bodies and headers, gives connect + headers + full body one perRequestTimeout budget, and gives the complete multi-hop refresh one non-resetting totalRefreshTimeout budget. It uses ETag validators, rejects stale or foreign 304 cache state, and requires every downloaded blob number to increase. Its built-in ES256/RS256 verifier terminates at an exact pinned trust anchor and validates certificate signatures, names, validity, CA constraints, key usage, path length, supported critical extensions, and metadata-signing extended key usage.

Certificate revocation remains application-owned and fail-closed. The checker is called for every non-anchor certificate and must return good; revoked, unknown, exceptions, and timeouts reject the blob. Copy the current MDS trust anchor from the FIDO Alliance into application-owned DER bytes and keep the revocation data current:

final mdsTrust = FidoMetadataPkixTrust(
  trustAnchors: [globalSignR3Der],
  checkRevocation: checkMdsCertificateRevocation,
);
final mds = FidoMetadataDownloader(trust: mdsTrust);

FidoMetadataRefreshResult? cachedMds;
cachedMds = await mds.refresh(previous: cachedMds);

final metadata = FidoMetadataWebAuthnTrustEvaluator(blob: cachedMds.blob);
final passkeys = WebAuthnPlugin<MyRequestContext>(
  provider: WebAuthnProvider(
    getUserInfo: resolvePasskeyUser,
    getRelyingParty: (_, _) => const WebAuthnRelyingParty(
      id: 'example.com',
      name: 'Example',
      origin: 'https://example.com',
    ),
  ),
  attestationTrustPolicy: metadata.asWebAuthnTrustPolicy(),
);

// Close the downloader when the application shuts down.
mds.close();

The refresh result is immutable in-memory cache state bound to its source and trust anchors. Persisting and restoring compact blobs is still application-owned; re-verify restored bytes before use. Remote x5u certificate-chain discovery is intentionally rejected, so the built-in client accepts the inline x5c profile used by the official service. Applications with a different metadata source can continue to use FidoMetadataBlobLoader with an application-owned FidoMetadataJwsVerifier.

Optional admin plugin #

Administrative APIs are opt-in. Create a plugin-owned admin store over the same core auth store, then include AdminPlugin in AuthOptions.plugins:

final authStore = InMemoryAuthStore();
final adminStore = InMemoryAuthAdminStore(authStore);

final admin = AdminPlugin<MyRequestContext>(
  store: adminStore,
  options: const AuthAdminOptions(
    adminRoles: {'admin'},
    adminUserIds: {'bootstrap-user-id'},
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: providers,
  store: authStore,
  storeMode: AuthStoreMode.ephemeral,
  plugins: [admin],
);

adminUserIds is a bootstrap mechanism; normal administrators are recognized from authoritative global user roles. The default admin role can manage users and sessions but cannot impersonate another administrator. Grant the separate user:impersonate-admins permission only through an explicit custom role. Self-ban, self-delete, self-impersonation, and removal of your own admin access are rejected.

The plugin supplies user creation/list/get/update, role and password changes, bans, session listing/revocation, hard deletion, permission checks, and server-session impersonation. Email, password, and role changes, plus ban and disable operations, revoke all target server sessions and rotate the target JWT version. Revoking all sessions also rotates the JWT version; revoking one server session cannot revoke one already-issued JWT. Active bans are checked before two-factor challenges, session issuance, and session reuse.

AuthAdminStore.execute accepts typed commands rather than independent write callbacks. A durable implementation must reload the administrator and evaluate the command's exact resource/action requirements after entering its backend transaction. It must then update the same records exposed through AuthStore, preserve normalized email uniqueness and the last effective administrator, and commit sensitive state, credential, server-session, and JWT-version changes together with a secret-free operation/initiator/target audit fact. Role-based administrators keep only their configured permissions; only IDs in adminUserIds receive the synthesized bootstrap admin role.

Hard deletion continues through the backend-owned deletion coordinator and must include credentials, provider accounts, tokens, sessions, and every composed user-owned plugin namespace. Use AuthAdminOptions.validateDeletion to reject application invariants such as deleting the last owner of an organization. Run AuthAdminStoreConformanceSuite from package:server_auth/testing.dart against every durable adapter, including a test-only transaction fault point. InMemoryAuthAdminStore and InMemoryAuthStore are for tests and local development, not production persistence.

The in-memory Admin store discovers composed user-data deletion contributors when plugin topology freezes. With OrganizationPlugin, it automatically enforces last-owner protection and clears organization/team membership data. Durable adapters must provide the equivalent cross-plugin transaction.

Impersonation consumes the current server session inside the admin command, so start and stop are single-use even under concurrent requests. Routed creates the replacement session afterward. If host session creation fails, the old session stays revoked and the caller must sign in again; the plugin does not claim an exactly-once cross-host transaction. Application hooks, lifecycle events, and audit delivery also run after commit and can return warnings. Revocation contributed by separately persisted plugins cannot share the core admin transaction; ban and disable therefore advertise nonAtomic semantics and fail visibly if that follow-up revocation is incomplete.

The admin client is installed explicitly and shares the host transport:

final transport = AuthClientTransport(
  baseUrl: Uri.parse('https://example.com'),
  cookieStore: myCookieStore,
);
final auth = AuthClient(
  baseUrl: Uri.parse('https://example.com'),
  transport: transport,
  plugins: const [AuthAdminClientPlugin()],
);
final admin = auth.plugins.use(const AuthAdminClientPlugin());

The shared transport owns cookies, bearer tokens, CSRF refresh, timeouts, and bounded error parsing. Mutation results retain committed data and expose typed warnings when an after-commit hook or audit sink fails.

Optional organization plugin #

Organizations are opt-in. Nothing is registered unless an OrganizationPlugin is included in AuthOptions.plugins:

final organizations = OrganizationPlugin<MyRequestContext>(
  store: myOrganizationStore,
  options: const AuthOrganizationOptions(
    teams: AuthOrganizationTeamsOptions(enabled: true),
    dynamicRoles: true,
  ),
);

final options = AuthOptions<MyRequestContext>(
  providers: providers,
  store: myAuthStore,
  productionBoundary: productionBoundary,
  plugins: [organizations],
);

The plugin owns organizations, memberships, email-bound invitations, organization-scoped permissions, dynamic roles, teams, typed lifecycle hooks, redacted audit events, and post-commit warnings. Organization roles remain separate from global AuthPrincipal.roles.

AuthOrganizationStore is a logical persistence contract. High-risk writes also require AuthOrganizationAtomicMutationStore: the store rechecks actor, target, invitation, role, and team snapshots inside the same transaction that enforces capacity, uniqueness, last-creator, and cascade invariants. Production adapters should run verifyAuthOrganizationStoreOwnershipConformance to prove contention, deterministic replay, and rollback behavior. The contract contains no SQL, D1 bindings, table names, migrations, or query builders.

Organization, invitation, role, team, and team-member creation requires a caller-generated idempotencyKey (8-128 ASCII letters, digits, ., _, :, or -). It is a non-secret retry/correlation value. The store binds it exactly to the organization, actor, operation, and request fingerprint and returns the original immutable result on a matching retry; conflicting reuse fails closed. Pre-commit transformation hooks run outside the durable transaction and may be called again when a client retries. A store-confirmed replay skips delivery, post-commit hooks, and audit/event sinks; failures from those callbacks on the first commit surface warnings rather than rolling back durable state.

Teams and dynamic roles are separately disabled by default. Default limits match the Better Auth benchmark: 100 members, 100 pending invitations, and a 48-hour invitation lifetime. A built-in opaque ID generator is used unless an application supplies one. Custom non-opaque invitation IDs require a verified session email before they can be acted upon.

The organization client shares AuthClientTransport for cookies, bearer tokens, CSRF refresh, timeouts, redirects, and bounded error parsing:

final transport = AuthClientTransport(
  baseUrl: Uri.parse('https://example.com'),
  cookieStore: myCookieStore,
);
final auth = AuthClient(
  baseUrl: Uri.parse('https://example.com'),
  transport: transport,
  plugins: const [AuthOrganizationClientPlugin()],
);
final organization = auth.plugins.use(const AuthOrganizationClientPlugin());
final created = await organization.create(
  name: 'Acme',
  slug: 'acme',
  idempotencyKey: requestId,
);

AuthOrganizationClient keeps active organization/team selection locally and sends the selected IDs explicitly. This works with JWTs without silently reissuing tokens; Routed server sessions may additionally persist the same selection as a convenience.

Optional API-key plugin #

Compose AuthApiKeyPlugin for service-client credentials:

final apiKeys = AuthApiKeyPlugin<MyRequestContext>(
  store: myApiKeyStore,
  sessionExchangeEnabled: true, // Only when API keys may create sessions.
);

final options = AuthOptions<MyRequestContext>(
  store: myAuthStore,
  providers: providers,
  productionBoundary: productionBoundary,
  plugins: [apiKeys],
);

Only a digest is persisted. The raw key is returned once when it is created or rotated; listing returns metadata, scopes, expiry, last-use, and revocation state. Production applications should provide a durable AuthApiKeyStore and implement its atomic touch, revoke, and rotate operations transactionally. Stores that support countsAsPrimaryAuthenticationMethod must additionally implement the typed AuthApiKeyPrimaryMutationStore command and recheck the complete supported fallback topology in their own transaction. Unsupported or mixed persistence fails closed. package:routed_auth_cloudflare exposes the matching D1 store as CloudflareD1AuthStore.apiKeys; its migration and plugin setup are documented in that package's README. Routed can additionally expose POST /auth/api-keys/exchange when sessionExchangeEnabled is true. It accepts the API key from an auth header and creates a normal server-side session; it is disabled by default.

Optional OAuth/OIDC provider mode #

Applications acting as an authorization server install OAuthProviderModePlugin with one authoritative authorization-code exchange store. The exchange store owns both the authorization-code and access-token stores, which prevents provider mode from composing a code-consume operation with a separate token write.

import 'package:server_auth/server_auth.dart';

final exchangeStore = InMemoryOAuthAuthorizationCodeExchangeStore();

final oauthProvider = OAuthProviderModePlugin<MyRequestContext>(
  clientStore: InMemoryOAuthClientStore(),
  authorizationCodeExchangeStore: exchangeStore,
  options: const OAuthProviderModeOptions(
    supportedGrantTypes: ['authorization_code'],
  ),
);

The in-memory exchange store is for tests and local development. A production adapter implements OAuthAuthorizationCodeExchangeStore with one backend transaction that revalidates the code digest, stable authorization ID, client, redirect URI, S256 verifier, and expiry, consumes the code, and persists the prepared token-digest record. Its OAuth client and exchange stores also expose the same backend-owned OAuthProviderPersistenceTopology domain. Durable auth runtimes reject split domains or an in-memory store instead of falling back. Run verifyOAuthAuthorizationCodeExchangeStoreConformance from package:server_auth/testing.dart in every durable adapter test suite.

Raw authorization codes, access tokens, and refresh tokens are delivery-only. They are generated before commit and never enter persistence, diagnostics, or replay state. If the transaction commits but the HTTP response is lost, the client must restart authorization: the server reports invalid_grant and does not mint a second grant or persist raw tokens to replay the response.

The token endpoint advertises atomic, single-use mutation semantics only when authorization code is its sole grant. A mixed endpoint remains explicitly non-atomic and unguarded because client credentials, refresh, or contributed grants cannot share one truthful replay contract.

Auth runtime and typed stores #

Integrations compose an AuthRuntime from typed domain stores and server plugins. AuthStore is the required persistence boundary; there is no legacy adapter bridge or implicit fallback:

final options = AuthOptions<void>(
  providers: [google],
  store: InMemoryAuthStore(), // Use a database-backed AuthStore in production.
  storeMode: AuthStoreMode.ephemeral,
  plugins: [MyAuthServerPlugin()],
);

final runtime = AuthRuntime<void>(options: options);
final user = await runtime.store.users.findByEmail('user@example.com');

InMemoryAuthStore stores explicit password-credential records but is volatile and intended only for tests and local development. The built-in credentials flow uses AuthOptions.passwordHasher (Argon2id by default) and AuthOptions.passwordPolicy (12–1,024 characters by default). Durable stores must persist only encoded password hashes and apply their own database transaction policy around registration and login. Providers that supply custom authorize or register callbacks own validation for those callback inputs.

AuthOptions defaults to production posture and rejects an unannotated InMemoryAuthStore. If ephemeral storage is intentional for a test or local demo, set storeMode: AuthStoreMode.ephemeral; that selects coherent local cookie, browser, and account defaults. Production construction and runtime boot validate durable persistence, the HTTPS/proxy boundary, browser policy, cookies, and JWT secret strength automatically.

Authentication rate limits are configured as a typed policy on AuthOptions.rateLimiter. The policy receives the operation, provider ID, framework context, and optional login identifier; it never receives a password, OAuth code, bearer token, or verification token. Routed invokes the policy before its built-in auth operations and custom callback providers:

final options = AuthOptions<MyRequestContext>(
  providers: providers,
  store: myStore,
  productionBoundary: productionBoundary,
  rateLimiter: MyAuthRateLimiter(),
);

Return AuthRateLimitDecision.block(retryAfter: ...) to produce a stable rate_limited failure in an adapter. The adapter owns the client identity used for the limit, including any trusted-proxy policy.

Plugins receive the shared AuthStore during configuration. They own one auth concern at a time and may contribute portable endpoint, logical schema, typed-client, and namespaced rate-limit descriptors. The registry rejects duplicate plugin IDs and endpoint method/path pairs, then freezes the plugin topology after runtime boot.

For Routed applications, use routed_auth and a typed AuthDeployment; its service provider, option binding, and engine configuration keep the runtime posture and proxy boundary together. server_auth itself never registers framework providers.

Using with Shelf #

Use shelf_auth for Shelf-specific middleware while keeping providers and auth contracts in server_auth.

dependencies:
  server_auth: ^0.2.0
  shelf_auth: ^0.1.0
import 'package:server_auth/server_auth.dart';
import 'package:shelf_auth/shelf_auth.dart';

final providers = <AuthProvider>[
  const AuthProvider(
    id: 'credentials',
    name: 'Credentials',
    type: AuthProviderType.credentials,
  ),
];

final middleware = authProvidersEndpoint(providers: providers);

JWT issue + verify example #

import 'package:server_auth/server_auth.dart';

final options = const JwtSessionOptions(
  secret: 'replace-with-a-strong-secret',
  issuer: 'example-app',
  audience: <String>['example-api'],
  maxAge: Duration(minutes: 30),
);

final issued = issueAuthJwtToken(
  options: options,
  claims: <String, dynamic>{'sub': 'user_42', 'roles': <String>['admin']},
);

final verifier = JwtVerifier(options: options.toVerifierOptions());
final payload = await verifier.verifyToken(issued.token);
print(payload.subject);

final refreshed = await refreshAuthJwtTokenIfNeeded(
  options: options,
  claims: payload.claims,
  updateAge: const Duration(minutes: 15),
  resolveClaims: (claims) => claims,
);

final verifiedSession = await verifyAuthJwtSessionToken(
  token: issued.token,
  options: options,
);
print(verifiedSession?.user.id);

final resolvedSession = await resolveAuthJwtSessionWithRefresh(
  token: issued.token,
  options: options,
  updateAge: const Duration(minutes: 15),
  resolveClaims: (claims, user) => claims,
);
print(resolvedSession?.refreshCookie != null); // refreshed or not

Auth JWTs include the standard numeric auth_time claim. Automatic refresh changes iat and expiry but preserves auth_time, including after a JWT callback changes application claims. Sensitive actions must use jwtAuthenticationTimeUtc(payload.claims) rather than iat; malformed auth_time fails closed, while tokens issued before this claim existed fall back to their original iat until first refresh.

Bearer validation helpers for adapters #

Use verifyJwtBearerAuthorizationAndWriteAttributes in framework middleware to share JWT bearer auth orchestration (token extraction, verification, request attribute writes, and optional callback):

final result = await verifyJwtBearerAuthorizationAndWriteAttributes<MyContext>(
  authorizationHeader: request.header('authorization'),
  verifier: jwtVerifier,
  setAttribute: request.setAttribute,
  context: context,
  onVerified: (payload, ctx) async {
    if (payload.claims['scope'] == null) {
      throw JwtAuthException('missing_scope');
    }
  },
);

print(result.payload.subject);

For OAuth introspection middleware, use validateOAuthBearerAuthorizationAndWriteAttributes:

final validation =
    await validateOAuthBearerAuthorizationAndWriteAttributes<MyContext>(
      authorizationHeader: request.header('authorization'),
      introspector: oauthIntrospector,
      setAttribute: request.setAttribute,
      context: context,
      onValidated: (result, ctx) async {
        if (result.scope?.contains('api:read') != true) {
          throw OAuth2Exception('insufficient_scope');
        }
      },
    );

print(validation.result.raw);

Email verification orchestration helpers #

Use startAuthEmailSignIn to share the email verification start flow:

final payload = await startAuthEmailSignIn<MyContext>(
  store: store,
  provider: emailProvider,
  context: context,
  email: 'user@example.com',
  callbackUrl: '/dashboard',
  sessionStrategy: AuthSessionStrategy.session,
  callbackKey: '_auth.callback',
  writeSession: (key, value) => sessionStore[key] = value,
);

print(payload.pendingResult.session.strategy);

Use resolveAuthEmailVerificationSignIn in callback handlers:

final resolved = await resolveAuthEmailVerificationSignIn(
  store: store,
  email: email,
  token: token,
  callbackKey: '_auth.callback',
  readSession: (key) => sessionStore[key],
);

Credentials orchestration helpers #

Use requireAuthorizedCredentialsSignIn and requireAuthorizedCredentialsRegistration to perform provider/adapter credential resolution with standard auth errors:

final user = await requireAuthorizedCredentialsSignIn(
  store: store,
  provider: credentialsProvider,
  context: context,
  credentials: credentials,
);

OAuth callback orchestration helper #

Use resolveOAuthSignInForProvider in framework adapters to share OAuth callback exchange/profile/user resolution logic without depending on Routed.

final resolved = await resolveOAuthSignInForProvider<MyContext, Map<String, dynamic>>(
  store: store,
  context: context,
  provider: oauthProvider,
  code: authorizationCode,
  codeVerifier: pkceVerifier,
  httpClient: httpClient,
);

print(resolved.user.id);
print(resolved.profile);

resolveOAuthSignInForProvider links the account through the atomic store boundary before returning. A conflicting canonical owner fails with account_link_conflict.

OAuth email linking requires an explicit verified or email_verified claim from the provider. Unverified email values are not persisted as the local user's email and cannot be used as the external account identifier; providers must supply a stable profile subject or the adapter's fallback account ID.

For framework adapters, prefer the typed OAuth challenge store on AuthStore. Its consume operation must atomically delete and return a matching challenge; this prevents concurrent callback replay across processes. Routed uses this path by default. Durable stores must protect the PKCE verifier, nonce, and callback URL as short-lived secrets.

final start = await resolveOAuthAuthorizationStart<MyContext, Map<String, dynamic>>(
  context: context,
  provider: oauthProvider,
  stateKey: '_auth.state',
  pkceKey: '_auth.pkce',
  callbackKey: '_auth.callback',
  challengeStore: store.oauthChallenges,
  callbackUrl: '/dashboard',
  writeSession: (key, value) => sessionStore[key] = value,
);

The session-backed form remains available for adapters that have not adopted the typed challenge contract:

final start = await resolveOAuthAuthorizationStart<MyContext, Map<String, dynamic>>(
  context: context,
  provider: oauthProvider,
  stateKey: '_auth.state',
  pkceKey: '_auth.pkce',
  callbackKey: '_auth.callback',
  callbackUrl: '/dashboard',
  writeSession: (key, value) => sessionStore[key] = value,
);

return start.authorizationUri;

For callback handlers, resolveOAuthCallbackSessionValues reads state/verifier/ callback URL using the same key contract:

final values = resolveOAuthCallbackSessionValues(
  providerId: oauthProvider.id,
  stateKey: '_auth.state',
  pkceKey: '_auth.pkce',
  callbackKey: '_auth.callback',
  readSession: (key) => sessionStore[key],
);

Or use the higher-level callback helper to validate state, resolve sign-in, and persist linked accounts in one call:

final callback = await resolveOAuthCallbackSignInForProvider<MyContext, Map<String, dynamic>>(
  store: store,
  context: context,
  provider: oauthProvider,
  code: authorizationCode,
  receivedState: stateFromRequest,
  stateKey: '_auth.state',
  pkceKey: '_auth.pkce',
  callbackKey: '_auth.callback',
  readSession: (key) => sessionStore[key],
  httpClient: httpClient,
);

print(callback.signIn.user.id);
print(callback.callbackUrl);

Adapter attribute mapping helpers #

When writing framework adapters, use these helpers to store verified auth payloads with consistent attribute keys:

import 'package:server_auth/server_auth.dart';

final attributes = <String, Object?>{};

writeJwtPayloadAttributes(
  payload,
  setAttribute: (key, value) => attributes[key] = value,
);

writeOAuthValidationAttributes(
  oauthValidation,
  setAttribute: (key, value) => attributes[key] = value,
);

final callbackUrl = resolveAndSanitizeRedirectCandidate(
  payload,
  queryParameters,
  requestUri: requestUri,
  fallbackHost: 'app.test',
  fallbackScheme: 'https',
);

final callbackFromResolver = await resolveAndSanitizeRedirectWithResolver(
  payload,
  queryParameters,
  requestUri: requestUri,
  fallbackHost: 'app.test',
  fallbackScheme: 'https',
  resolveRedirect: (candidate) async => candidate,
);

Redirect helpers return only rooted-relative or same-origin URLs. They reject cross-origin, executable-scheme, protocol-relative, and user-info URLs; keep the final sanitization step in framework adapters before writing Location.

Callback helper for redirect fallbacks #

Use resolveAuthRedirectTargetWithFallback when your adapter supports redirect callbacks but should preserve a framework-provided fallback URL.

final resolvedUrl = await resolveAuthRedirectTargetWithFallback<MyContext>(
  callback: callbacks.redirect,
  context: AuthRedirectCallbackContext<MyContext>(
    context: context,
    url: '/requested',
    baseUrl: 'https://app.test',
  ),
  fallbackUrl: '/requested',
);

When you already have an AuthCallbacks<TContext> instance, use the compact wrapper:

final resolvedFromCallbacks = await resolveAuthRedirectWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  url: '/requested',
  baseUrl: 'https://app.test',
);

The same pattern exists for JWT/session callback orchestration:

final signInRedirect = await resolveAuthSignInRedirectWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  user: user,
  strategy: AuthSessionStrategy.session,
  callbackUrl: '/requested',
);

// Throws AuthFlowException('sign_in_blocked') if callbacks.signIn denies.

final finalRedirect = await resolveAuthSignInRedirectTarget<MyContext>(
  callbacks: callbacks,
  context: context,
  user: user,
  strategy: AuthSessionStrategy.session,
  callbackUrl: '/requested',
  resolveRedirect: (candidate) => adapterResolveRedirect(candidate),
);

final claims = await resolveAuthJwtClaimsWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  user: user,
  strategy: AuthSessionStrategy.jwt,
);

final sessionPayload = await resolveAuthSessionPayloadWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  session: session,
  strategy: AuthSessionStrategy.session,
);

final jwtIssue = await issueAuthJwtSessionWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  options: jwtOptions,
  user: user,
  strategy: AuthSessionStrategy.jwt,
);

final signInResult = await resolveAuthSignInResultForStrategyWithCallbacks<MyContext>(
  callbacks: callbacks,
  context: context,
  strategy: AuthSessionStrategy.jwt,
  user: user,
  redirectUrl: finalRedirect,
  jwtOptions: jwtOptions,
);

final authResult = signInResult.result;
final issuedJwtCookie = signInResult.issuedJwt?.cookie;

Authorization and gates example #

import 'package:server_auth/server_auth.dart';

final gates = AuthGateService<Map<String, dynamic>>();
gates.register('posts.update', rolesGate(<String>['editor', 'admin'], any: true));

final principal = AuthPrincipal(id: 'user_42', roles: <String>['admin']);
final allowed = await gates.can(
  'posts.update',
  context: <String, dynamic>{'resourceId': 'post_1'},
  principal: principal,
);
print(allowed); // true

// Managed gate registration that preserves unmanaged entries:
final managed = <String>{};
final registered = registerGateCallbacksSafely<Map<String, dynamic>>(
  gates.registry,
  <String, AuthGateCallback<Map<String, dynamic>>>{
    'posts.publish': rolesGate(<String>['editor']),
  },
  managed: managed,
);
managed
  ..clear()
  ..addAll(registered);

Framework Adapter Session Runtime #

RememberSessionAuthRuntime<TContext> provides framework-agnostic remember-me/session principal logic. Frameworks supply an AuthSessionRuntimeAdapter<TContext> to map request/session/cookie behavior.

import 'package:server_auth/server_auth.dart';

final runtime = RememberSessionAuthRuntime<MyContext>(
  adapter: myAdapter,
  rememberCookieName: 'remember_token',
  defaultRememberDuration: const Duration(days: 30),
  sessionPrincipalKey: '__auth.principal',
);

await runtime.login(
  context,
  AuthPrincipal(id: 'user-1', roles: const <String>['user']),
  rememberMe: true,
);

await runtime.hydrate(context); // restore/rotate remember token when needed
await runtime.logout(context);

For adapters that keep issued-at metadata, use syncAuthSessionRefresh to apply initialize/refresh/keep behavior:

syncAuthSessionRefresh(
  issuedAtValue: session['__auth.session.issued_at'] as String?,
  updateAge: const Duration(minutes: 5),
  writeIssuedAt: (value) {
    session['__auth.session.issued_at'] = serializeAuthSessionIssuedAt(value);
  },
  touchSession: () => sessionTouch(),
);

Minimal store composition #

Implement the eight typed store domains against your persistence layer. The aggregate store only composes those domains:

class MyAuthStore implements AuthStore {
  MyAuthStore({
    required this.users,
    required this.credentials,
    required this.accounts,
    required this.sessions,
    required this.oauthChallenges,
    required this.passwordResetTokens,
    required this.jwtVersions,
    required this.verificationTokens,
  });

  @override
  final AuthUserStore users;
  @override
  final AuthCredentialStore credentials;
  @override
  final AuthAccountStore accounts;
  @override
  final AuthSessionStore sessions;
  @override
  final AuthOAuthChallengeStore oauthChallenges;
  @override
  final AuthPasswordResetTokenStore passwordResetTokens;
  @override
  final AuthJwtVersionStore jwtVersions;
  @override
  final AuthVerificationTokenStore verificationTokens;
}

final store = MyAuthStore(
  users: myUserStore,
  credentials: myCredentialStore,
  accounts: myAccountStore,
  sessions: mySessionStore,
  oauthChallenges: myOAuthChallengeStore,
  passwordResetTokens: myPasswordResetTokenStore,
  jwtVersions: myJwtVersionStore,
  verificationTokens: myVerificationTokenStore,
);

AuthOAuthChallengeStore.consume must atomically remove and return the matching, unexpired challenge. Protect its PKCE verifier, nonce, and callback URL fields as short-lived secrets in durable storage.

AuthAccountStore.link must also be atomic and create-if-absent for each (providerId, providerAccountId) pair. It returns the canonical linked account and must never replace an existing link owned by another user.

AuthUserStore.createOrFindByEmail must atomically enforce uniqueness for a normalized email and report whether the returned user was created. Use this operation for verified OAuth email linking and email sign-in callbacks.

AuthPasswordResetTokenStore must hash raw reset tokens, invalidate older tokens for the same user, and consume tokens atomically. The store boundary is available now. The framework-agnostic issueAuthPasswordResetTokenForUser and resetAuthPasswordWithToken helpers also cover token issuance, password replacement, JWT-version rotation, and server-session revocation. Adapters can use AuthPasswordResetSender to deliver the raw token without persisting it. AuthJwtVersionStore must atomically increment a per-user version so old JWTs fail validation after password reset or password change.

Keep persistence focused on the store contracts and keep provider/JWT/gate logic in server_auth.

AuthSessionStore persists AuthSessionRecord values, not public session payloads. The tokenHash field must be derived from the client-held opaque token with hashOpaqueToken; implementations should use touch, revoke, and rotate atomically and never store the raw token.

Typed Profiles #

Every OAuth provider includes a typed profile model and serializer/parsers, so user info mapping can stay type-safe.

Telegram (Non-OAuth) #

Telegram uses widget-based auth with HMAC verification via telegramProvider.

Runnable example #

dart run example/main.dart

See example/main.dart for provider registration, JWT flows, and gate checks. See example/README.md for run instructions and expected output.

Migration Notes #

If older code imported provider factories or auth primitives from Routed entrypoints, switch to direct server_auth imports to keep auth logic reusable across frameworks.

Validation #

dart analyze
dart test
dart run example/main.dart

License #

MIT

0
likes
150
points
685
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Framework-agnostic authentication runtime capabilities for server ecosystems.

Repository (GitHub)
View/report issues

Funding

Consider supporting this project:

www.buymeacoffee.com

License

MIT (license)

Dependencies

cbor, crypto, crypto_keys, cryptography, einvoicesign, hashlib, http, http_parser, jose, pointycastle, server_contracts, x509, xml

More

Packages that depend on server_auth