routed_auth_cloudflare 0.1.1 copy "routed_auth_cloudflare: ^0.1.1" to clipboard
routed_auth_cloudflare: ^0.1.1 copied to clipboard

Durable Cloudflare D1 AuthStore persistence for Routed applications.

routed_auth_cloudflare #

Durable Cloudflare D1 persistence for package:server_auth. The package is a small host adapter: server_auth remains framework-neutral, while this package depends explicitly on server_auth and the host-neutral D1 types from routed_node.

import 'package:routed_auth_cloudflare/routed_auth_cloudflare.dart';
import 'package:routed_node/cloudflare.dart';
import 'package:server_auth/server_auth.dart';

Future<CloudflareD1AuthStore> authStore(CloudflareEnvironment env) {
  return CloudflareD1AuthStore.open(
    env.d1('AUTH_DB'),
    schema: const CloudflareD1AuthSchema(
      tablePrefix: 'my_app_auth',
    ),
  );
}

CloudflareD1AuthStore.open applies typed, idempotent migrations. There is no YAML configuration and application code does not import package:web or use dart:js_interop.

For a complete Worker entrypoint, Wrangler configuration, local SQLite-backed smoke test, and curl walkthrough, see the runnable Cloudflare D1 auth example.

When an external authentication plugin is removed from a deployment, preserve its namespace in the durable safety inventory until its records have a backend-owned cleanup path:

final store = await CloudflareD1AuthStore.open(
  env.d1('AUTH_DB'),
  historicalAuthenticationMethodNamespaces: const ['legacy_device'],
);

If a historical namespace is absent from the active authoritative contributors, safe authentication-method removal fails closed. This prevents a removed plugin's credentials from being invisible during fallback checks.

For hard deletion of user-owned plugin records, configure the matching storage namespace in AuthOptions.historicalUserDataNamespaces. The D1 deletion coordinator supports this capability and fails closed when a previously deployed contributor is absent.

Anonymous authentication is selected like any other server plugin; the root D1 adapter itself supplies the required mutation capability:

final store = await authStore(env);
final options = AuthOptions<MyRequestContext>(
  store: store,
  plugins: [AnonymousPlugin<MyRequestContext>()],
);

Creation, authenticated deletion, and upgrade finalization execute as D1 transactions. Operation IDs, user bindings, and target bindings are persisted only as non-reversible digests. Creation receipts are scrubbed by typed or generic hard deletion; retained delete/upgrade receipts never contain an AuthUser payload. Replay retention defaults to one day and 10,000 records and is configured with the typed anonymousReplayTtl and anonymousMaxReceipts arguments to CloudflareD1AuthStore.open. Once a receipt expires or is evicted, the operation is no longer replayable; hard-deleted user IDs remain unavailable.

API keys use the separately selected D1 sub-store; the root adapter does not enable the plugin implicitly:

final store = await CloudflareD1AuthStore.open(
  env.d1('AUTH_DB'),
  schema: const CloudflareD1AuthSchema(tablePrefix: 'my_app_auth'),
  apiKeyMaxRecords: 20_000,
);

final apiKeys = AuthApiKeyPlugin<MyRequestContext>(
  store: store.apiKeys,
  countsAsPrimaryAuthenticationMethod: false,
  sessionExchangeEnabled: false,
);

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

Migration v9 stores only the secret digest and safe key metadata: owner, name, prefix, scopes, expiry, last-use, and revocation timestamps. The raw key remains in the one-time create or rotate result and never enters D1. Issue, touch, revoke, rotate, resolve, and list operations are bounded by apiKeyMaxRecords; rotation may replace its old row at capacity but never leaves the table above the configured bound. Coordinated hard deletion compiles the API-key cleanup plan into the same D1 batch as core user deletion. Because the table is owned by the root adapter, core deletion also scrubs it when a previously installed API-key plugin is no longer composed.

When countsAsPrimaryAuthenticationMethod is enabled, safe revocation is available only if the exact store.apiKeys instance and every fallback method belong to the authoritative D1 topology. Mixed or foreign stores fail closed; there is no process-local callback transaction or compatibility fallback.

Phone authentication uses the root adapter's typed backend. Add the plugin and keep SMS delivery as an application-owned post-commit callback:

final store = await authStore(env);
final phone = PhoneNumberPlugin<MyRequestContext>(
  sendCode: (delivery) => sms.send(
    delivery.phoneNumber,
    delivery.code,
  ),
  codeHashKey: authPhoneCodeHashKey,
  allowSignUp: true,
);

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

Append-only migration v11 adds bounded phone challenges, verified phone identities, and idempotency receipts. Challenge codes and issuance IDs cross the adapter only as digests; D1 stores no deliverable SMS code or raw operation ID. Verification updates attempts or lockout, consumes the challenge, creates or resolves the user, binds the phone identity, and projects the verified phone attributes in one D1 batch. Configure the global challenge bound with phoneNumberMaxVerifications. Hard deletion removes phone identities, challenges, and receipts with the user, including when the phone plugin is no longer composed.

WebAuthn uses the root adapter's optional typed capability. Install only the plugin and its provider; application code does not construct D1 substores:

final store = await authStore(env);
final passkeys = WebAuthnPlugin<MyRequestContext>(
  provider: WebAuthnProvider(
    getUserInfo: resolvePasskeyUser,
    getRelyingParty: (_, _) => const WebAuthnRelyingParty(
      id: 'example.com',
      name: 'Example',
      origin: 'https://example.com',
    ),
  ),
);

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

Append-only migration v10 adds bounded challenge and authenticator tables. Challenges persist only a SHA-256 digest and exact ceremony bindings and are deleted by one atomic consume. Credential IDs are globally unique, signature counters use compare-and-set updates, and public-key, transport, name, and identifier fields are bounded. Configure global limits with webAuthnChallengeMaxRecords and webAuthnAuthenticatorMaxRecords when opening the store. Raw challenges and attestation objects are never persisted.

Passkey removal rechecks same-domain password, OAuth, email, API-key, and other passkey fallbacks in the conditional D1 mutation. Mixed or future unsupported stores fail closed. Hard deletion removes bound challenges and passkeys in the root batch, rolls back with the user, and still cleans the backend-owned tables after the WebAuthn plugin is removed. A deletion receipt prevents a deleted user ID from reactivating retained or concurrently created credentials.

Provider mode must use all three OAuth stores from the same opened adapter:

final store = await authStore(env);
final oauthProvider = OAuthProviderModePlugin<MyRequestContext>(
  clientStore: store.oauthClientStore,
  authorizationCodeExchangeStore:
      store.oauthAuthorizationCodeExchangeStore,
);

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

The adapter persists client-secret, authorization-code, access-token, and refresh-token digests only. Authorization-code exchange uses one D1 atomic batch to revalidate every binding, insert the prepared token-digest record, and consume the code. A durable runtime rejects in-memory, foreign-database, or otherwise split OAuth store topologies instead of falling back.

Managed SCIM is an independently selected server plugin. Its D1 store is obtained from the same opened adapter, so connection management, bearer resolution, replay metadata, and coordinated user deletion share one durable transaction domain:

final store = await authStore(env);
final scimConnections = AuthScimConnectionPlugin<MyRequestContext>(
  store: store.scimConnectionStore,
  authorize: (request) async {
    final user = request.invocation.user;
    if (user == null) return null;
    final access = await loadDirectoryAccess(
      request.invocation.context,
      request.organizationId,
    );
    if (access == null) return null;
    return AuthScimConnectionManagementPrincipal(
      tenantId: access.tenantId,
      organizationId: access.organizationId,
      subjectId: user.id,
    );
  },
);

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

Only bearer digests and safe credential metadata are stored. Connection creation, issuance, and rotation write their replay identity in the same D1 batch, so a committed retry returns metadata without reconstructing the raw secret. The default replay lifetime is one day and can be changed with the typed scimReplayTtl argument to CloudflareD1AuthStore.open.

The local tests run AuthStoreConformanceSuite, AuthAnonymousStoreConformanceSuite, AuthApiKeyStoreConformanceSuite, and verifyOAuthAuthorizationCodeExchangeStoreConformance against a deterministic SQLite-backed implementation of the public CloudflareD1Database API. They inject mid-batch faults to prove that anonymous identities, replay receipts, API-key rotation, OAuth code consumption, and token persistence roll back with their transaction.

The root adapter implements AuthUsernameStore: normalized username registration and rename commit the user projection and password credential in one D1 batch, and contention relies on D1 uniqueness constraints. Safe username removal rechecks every supported fallback inside that same store-owned batch and fails closed for mixed or unsupported authentication-method stores.

The local tests also run verifyAuthUsernameStoreConformance against that deterministic public D1 fake. The separate live harness is intentionally not part of the default test command. On 2026-08-21, a disposable live D1 run passed the core, anonymous, managed-SCIM, phone v11, API-key v9, WebAuthn v10, username, OAuth-exchange, rollback, and prefix-isolation cases. Fault-injection cases remain local-only. The harness deleted its owned database, and a separate Wrangler listing confirmed that no matching resource remained.

The adapter also implements AuthMagicLinkBackend and AuthEmailOtpBackend. Magic-link replacement and consume-plus-user resolution, and OTP attempt/ consume-plus-user transitions, execute in guarded D1 batches. Only token digests and keyed OTP digests cross the persistence boundary. Adapter tests run verifyAuthEmailBackendConformance from package:server_auth/testing.dart and inject a failure at every statement in each consume batch to prove rollback. Email delivery and Routed session/cookie issuance remain postcommit host boundaries and are not represented as D1 transactions.

Rate limiting remains an application concern. Use Routed's built-in adapter with the existing server_rate_limit service rather than coupling this D1 auth adapter to a second persistence schema:

final backend = CacheRateLimiterBackend(
  repository: RepositoryImpl(ArrayStore(), 'rate-limit', ''),
);
final service = RateLimitService(
  compileRateLimitPolicies(
    specs: const [
      RateLimitPolicySpec(
        name: 'auth-ip',
        match: '/auth/**',
        method: null,
        strategy: RateLimitStrategy.slidingWindow,
        capacity: 30,
        interval: Duration.zero,
        window: Duration(minutes: 1),
        period: Duration.zero,
        burstMultiplier: null,
        key: RateLimitKeySpec.ip(),
      ),
    ],
    backend: backend,
    defaultFailover: RateLimitFailoverMode.block,
  ),
);

final deployment = AuthDeploymentPresets.secureSessionProduction<EngineContext>(
  rateLimiter: RoutedAuthRateLimiter(service),
  // ... store, providers, and boundary
);

ArrayStore is suitable for tests and a single process/isolate. Applications that need a shared limit should provide a shared Repository to the same built-in CacheRateLimiterBackend; this package does not silently turn the D1 auth store into a rate-limit store.

The independent deployed Worker auth harness verifies Routed's session, JWT, plugin, external-provider, and browser-shaped WebAuthn contracts inside an already-deployed Cloudflare Worker. It is also opt-in and does not create, update, or delete Cloudflare resources.

The full suite was run against a temporary Worker on 2026-08-21. The Worker and its conformance secret were deleted after the run. The external-provider browser-binding case uses a distinct framework-session cookie; the separate auxiliary OAuth state-cookie fallback is covered by Routed's route tests.

Cloudflare D1 exposes atomic statement batches, but not a transaction that can span the arbitrary callback required by AuthAccountDeletionStore. The adapter therefore fails closed by not advertising that optional capability; the conformance case is reported as skipped. Core records are never partially deleted under a false transaction guarantee. The separate typed user-deletion coordinator does compose guarded plugin and core cleanup into one D1 batch, including OAuth authorization-code and token records. It accepts only backend-bound typed mutation plans it can compile into that batch; supported authentication-method removal uses those plans, while mixed external stores fail closed instead of advertising a false transaction guarantee.

0
likes
120
points
157
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Durable Cloudflare D1 AuthStore persistence for Routed applications.

Repository (GitHub)
View/report issues

Funding

Consider supporting this project:

www.buymeacoffee.com

License

MIT (license)

Dependencies

ormed_d1, routed_node, server_auth

More

Packages that depend on routed_auth_cloudflare