effect-auth

Accept a Partner Assertion on Cloudflare

Exchange a one-time partner assertion for an effect-auth session with Cloudflare Workers, D1, and Alchemy v2.

A procurement marketplace lets employees enter through a regulated partner portal. The portal returns a short-lived opaque assertion; the marketplace's Cloudflare Worker introspects it, binds it to the browser's login attempt, maps the partner subject to an existing user, consumes the assertion once in D1, and hands trusted evidence to effect-auth.

This is a concrete deployment of the Custom methods contract. The partner protocol, subject mapping, nonce, replay ledger, and failure policy remain application-owned. effect-auth owns assurance derivation, configured MFA or login approval, session creation, and cookie commitment.

A one-time partner assertion enters the maintained authentication pipeline

Fix the protocol contract

Do not infer semantics from arbitrary partner output. Agree on exact values and reject everything else:

FieldRequired policy
activeExactly true; the partner has checked signature, revocation, and credential state
issuerExactly https://login.partner.example
audienceExactly marketplace-auth
purposeExactly primary-sign-in
issuedAt, expiresAtInteger Unix seconds; ordered, at most 120 seconds apart, no more than 30 seconds of issue-time clock skew, and not expired
nonceEquals the nonce in a verified, short-lived, HttpOnly login-attempt cookie
assertionIdBounded opaque identifier consumed once in D1
subjectResolves through an explicit partner-to-user mapping; never treat it as a user ID or email
authenticationContextBounded partner value retained only as evidence context; it does not set local AAL

Apply an endpoint body limit before introspection, use a fixed introspection URL, set a short outbound timeout, and never put the assertion in a URL, log, metric, database, or error response.

Add app-owned D1 state

Place this migration after the effect-auth migrations in the Alchemy migrationsDir. Provision mappings through an authenticated administrative workflow, not during sign-in.

migrations/0031_partner_assertion.sql
create table app_partner_identity (
  issuer text not null,
  subject text not null,
  user_id text not null references auth_user (id) on delete cascade,
  created_at integer not null,
  primary key (issuer, subject)
);

create index app_partner_identity_user_id_idx
  on app_partner_identity (user_id);

create table app_consumed_partner_assertion (
  issuer text not null,
  assertion_id text not null,
  user_id text,
  expires_at integer not null,
  consumed_at integer not null,
  primary key (issuer, assertion_id)
);

create index app_consumed_partner_assertion_expires_at_idx
  on app_consumed_partner_assertion (expires_at);

The replay table intentionally has no user foreign key: hard-deleting a user must not delete the replay authority while the assertion could still be accepted. user_id is nullable so a valid assertion is also burned when its subject is unknown or disabled. Delete expired rows from a scheduled Worker only after the partner's maximum assertion lifetime plus clock-skew and retry windows.

Define trusted evidence

The assertion is a primary method, but its partner authentication context is only an input to local policy. This deployment deliberately assigns local aal1.

src/vendor-assertion.ts
import {
  CustomEvidencePoliciesLive,
  defineCustomEvidence,
} from "@effect-auth/core/Assurance";
import { AuthFlow } from "@effect-auth/core/AuthFlow";
import type { AuthMethod } from "@effect-auth/core/AuthFlow";
import { UnixMillis, UserId } from "@effect-auth/core/Identifiers";
import type { LoginRequestContext } from "@effect-auth/core/LoginRisk";
import { Clock, Context, Data, Effect, Schema } from "effect";

export const vendorAssertionMethod =
  "vendor-assertion" as const satisfies AuthMethod;

export const VendorAssertionEvidence = defineCustomEvidence({
  policyId: "app.vendor-assertion",
  policyVersion: 1,
  kind: "vendor-assertion",
  properties: Schema.Struct({
    issuer: Schema.String,
    authenticationContext: Schema.String,
  }),
  evaluate: () => ({
    role: "primary",
    level: "aal1",
    amr: "app:vendor_assertion",
  }),
});

export const AppCustomEvidencePoliciesLive = CustomEvidencePoliciesLive([
  VendorAssertionEvidence.policy,
]);

export class VendorAssertionInvalidError extends Data.TaggedError(
  "VendorAssertionInvalidError"
)<{ readonly message: string }> {}

export class VendorAssertionUnavailableError extends Data.TaggedError(
  "VendorAssertionUnavailableError"
)<{ readonly message: string; readonly cause?: unknown }> {}

export interface VendorAssertionVerifierService {
  readonly verify: (input: {
    readonly assertion: string;
    readonly expectedNonce: string;
  }) => Effect.Effect<
    {
      readonly userId: UserId;
      readonly evidence: {
        readonly issuer: string;
        readonly authenticationContext: string;
      };
    },
    VendorAssertionInvalidError | VendorAssertionUnavailableError
  >;
}

export class VendorAssertionVerifier extends Context.Service<
  VendorAssertionVerifier,
  VendorAssertionVerifierService
>()("app/VendorAssertionVerifier") {}

export const signInWithVendorAssertion = Effect.fn(
  "app.auth.vendor_assertion.sign_in"
)(function* (input: {
  readonly assertion: string;
  readonly expectedNonce: string;
  readonly request: LoginRequestContext;
}) {
  const verifier = yield* VendorAssertionVerifier;
  const authFlow = yield* AuthFlow;
  const verified = yield* verifier.verify({
    assertion: input.assertion,
    expectedNonce: input.expectedNonce,
  });
  const verifiedAt = UnixMillis(yield* Clock.currentTimeMillis);

  return yield* authFlow.completePrimaryFactor({
    userId: verified.userId,
    intent: "sign-in",
    method: vendorAssertionMethod,
    evidence: [
      VendorAssertionEvidence.make({
        verifiedAt,
        properties: verified.evidence,
      }),
    ],
    request: input.request,
  });
});

Increment policyVersion when evidence validation or assurance semantics change. Keep every version referenced by a live session or pending flow registered until that state expires or is revoked. The verifier must bind trusted evidence to the resolved internal user and active credential. ActivePrincipalGate then centrally rechecks current user activity at AuthFlow entry, continuation, and terminal issuance boundaries.

Implement the Cloudflare verifier

The introspection response is decoded into a closed, bounded schema. After claim checks, one D1 statement both resolves an enabled internal user and inserts the replay key. A duplicate assertion, missing mapping, or disabled user all produce the same invalid-credential result.

src/vendor-assertion-cloudflare.ts
import type { D1Database } from "@cloudflare/workers-types";
import { UserId } from "@effect-auth/core/Identifiers";
import { Clock, Effect, Layer, Schema } from "effect";
import {
  VendorAssertionInvalidError,
  VendorAssertionUnavailableError,
  VendorAssertionVerifier,
} from "./vendor-assertion";

export interface VendorAssertionEnv {
  readonly DB: D1Database;
  readonly PARTNER_INTROSPECTION_TOKEN: string;
}

const BoundedPartnerString = Schema.String.check(
  Schema.isMinLength(1),
  Schema.isMaxLength(256)
);

const PartnerClaims = Schema.Struct({
  active: Schema.Boolean,
  issuer: BoundedPartnerString,
  subject: BoundedPartnerString,
  audience: BoundedPartnerString,
  purpose: BoundedPartnerString,
  issuedAt: Schema.Int.check(Schema.isGreaterThan(0)),
  expiresAt: Schema.Int.check(Schema.isGreaterThan(0)),
  nonce: BoundedPartnerString,
  assertionId: BoundedPartnerString,
  authenticationContext: BoundedPartnerString,
});

const decodePartnerClaims = Schema.decodeUnknownEffect(PartnerClaims, {
  errors: "all",
  onExcessProperty: "error",
});

const invalid = (message: string) =>
  new VendorAssertionInvalidError({ message });

const unavailable = (message: string, cause?: unknown) =>
  new VendorAssertionUnavailableError({ message, cause });

const readBoundedJson = (response: Response, maxBytes: number) =>
  Effect.tryPromise({
    try: async (): Promise<unknown> => {
      const declaredLength = response.headers.get("content-length");
      if (
        declaredLength !== null &&
        Number.parseInt(declaredLength, 10) > maxBytes
      ) {
        throw new TypeError("Partner response exceeded the byte limit");
      }
      if (response.body === null) {
        throw new TypeError("Partner response body was missing");
      }

      const reader = response.body.getReader();
      const chunks: Uint8Array[] = [];
      let length = 0;
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) {
            break;
          }
          length += value.byteLength;
          if (length > maxBytes) {
            await reader.cancel();
            throw new TypeError("Partner response exceeded the byte limit");
          }
          chunks.push(value);
        }
      } finally {
        reader.releaseLock();
      }

      const bytes = new Uint8Array(length);
      let offset = 0;
      for (const chunk of chunks) {
        bytes.set(chunk, offset);
        offset += chunk.byteLength;
      }
      return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
    },
    catch: (cause) => unavailable("Partner response was invalid", cause),
  });

export const VendorAssertionVerifierCloudflareLive = (
  env: VendorAssertionEnv
) =>
  Layer.succeed(VendorAssertionVerifier, {
    verify: Effect.fn("app.vendor_assertion.verify")(function* ({
      assertion,
      expectedNonce,
    }) {
      const response = yield* Effect.tryPromise({
        try: () =>
          fetch("https://login.partner.example/oauth/introspect", {
            method: "POST",
            headers: {
              authorization: `Bearer ${env.PARTNER_INTROSPECTION_TOKEN}`,
              "content-type": "application/json",
            },
            body: JSON.stringify({ assertion }),
            signal: AbortSignal.timeout(3000),
          }),
        catch: (cause) => unavailable("Partner introspection failed", cause),
      });

      if (!response.ok) {
        return yield* Effect.fail(
          unavailable(`Partner introspection returned ${response.status}`)
        );
      }

      const body = yield* readBoundedJson(response, 16_384);
      const claims = yield* decodePartnerClaims(body).pipe(
        Effect.mapError((cause) =>
          unavailable("Partner response failed schema validation", cause)
        )
      );
      const now = yield* Clock.currentTimeMillis;
      const nowSeconds = Math.floor(now / 1000);

      if (
        !claims.active ||
        claims.issuer !== "https://login.partner.example" ||
        claims.audience !== "marketplace-auth" ||
        claims.purpose !== "primary-sign-in" ||
        claims.nonce !== expectedNonce ||
        claims.issuedAt > nowSeconds + 30 ||
        claims.expiresAt <= nowSeconds ||
        claims.expiresAt <= claims.issuedAt ||
        claims.expiresAt - claims.issuedAt > 120
      ) {
        return yield* Effect.fail(invalid("Partner assertion was rejected"));
      }

      const consumed = yield* Effect.tryPromise({
        try: () =>
          env.DB.prepare(
            `insert or ignore into app_consumed_partner_assertion
              (issuer, assertion_id, user_id, expires_at, consumed_at)
             values (
               ?,
               ?,
               (select identity.user_id
                from app_partner_identity as identity
                join auth_user as user on user.id = identity.user_id
                where identity.issuer = ?
                  and identity.subject = ?
                  and user.disabled_at is null),
               ?,
               ?
             )
             returning user_id`
          )
            .bind(
              claims.issuer,
              claims.assertionId,
              claims.issuer,
              claims.subject,
              claims.expiresAt * 1000,
              now
            )
            .first<{ readonly user_id: string | null }>(),
        catch: (cause) => unavailable("D1 assertion consume failed", cause),
      });

      if (consumed === null || consumed.user_id === null) {
        return yield* Effect.fail(invalid("Partner assertion was rejected"));
      }

      return {
        userId: UserId(consumed.user_id),
        evidence: {
          issuer: claims.issuer,
          authenticationContext: claims.authenticationContext,
        },
      };
    }),
  });

The assertion is burned before AuthFlow starts. A later policy or infrastructure failure therefore requires a fresh partner assertion; this avoids retrying a credential whose one-time status is ambiguous.

Compose AuthFlow and the route

Register the exact evidence policy while constructing Sessions. Add the custom method to the MFA policy without replacing the built-in method list:

src/custom-auth-live.ts
import {
  AuthFlowLive,
  defaultMfaRequirementMethods,
  MfaRequirementPolicyLive,
} from "@effect-auth/core/AuthFlow";
import { SessionsLive } from "@effect-auth/core/Sessions";
import { Layer } from "effect";
import {
  AppCustomEvidencePoliciesLive,
  vendorAssertionMethod,
} from "./vendor-assertion";

const CustomMethodMfaPolicyLive = MfaRequirementPolicyLive({
  methods: [...defaultMfaRequirementMethods, vendorAssertionMethod],
});

const SessionsWithCustomEvidenceLive = SessionsLive().pipe(
  Layer.provide(AppCustomEvidencePoliciesLive)
);

export const AuthFlowWithCustomEvidenceLive = AuthFlowLive.pipe(
  Layer.provide(CustomMethodMfaPolicyLive),
  Layer.provide(SessionsWithCustomEvidenceLive)
);

Expose the route as an application HttpApi extension. The declarations below intentionally stop at the endpoint contract and operation: login-attempt cookies, Cloudflare request metadata, and rate-limit policy are app-owned boundaries, not effect-auth implementations. Do not expose the endpoint until its handler implements every boundary in this table:

BoundaryRequired implementation
Start routeGenerate at least 128 random bits; send the nonce through the authenticated partner protocol; set a signed or AEAD-protected __Host-partner_login cookie with HttpOnly, Secure, SameSite=Lax, Path=/, no Domain, and at most five minutes of lifetime
Public body{ assertion: string }, length 1..16384; never accept nonce, user, evidence, request context, or redirect policy
Trusted handler inputVerify cookie integrity and expiry, then derive LoginRequestContext from the server request and Cloudflare metadata
MiddlewareSchema errors, an explicitly mounted exact secure origin policy, and endpoint-specific rate limit before introspection
Domain callsignInWithVendorAssertion({ assertion, expectedNonce, request })
CommitAuthHttp.commitPrimaryFactorResult(result)
Outgoing cookiesExpire the attempt cookie without replacing any effect-auth Set-Cookie headers from the committer
Continuation routesMount the maintained MFA and login-approval APIs for every continuation enabled by policy
Public invalid resultGeneric invalid_credentials; never distinguish replay, mapping, disabled user, nonce, or claim failures
Public dependency resultSanitized internal_error; log only an allow-listed error tag and partner status
src/vendor-assertion-api.ts
import type { AuthFlowPrimaryFactorError } from "@effect-auth/core/AuthFlow";
import {
  AuthHttp,
  AuthInternalError,
  AuthInvalidCredentialsError,
  AuthOriginCheckMiddleware,
  AuthPolicyDeniedError,
  AuthRateLimitedError,
  AuthSchemaErrorMiddleware,
  PrimaryAuthSuccess,
} from "@effect-auth/core/HttpApi";
import type { LoginRequestContext } from "@effect-auth/core/LoginRisk";
import { Effect, Schema } from "effect";
import {
  HttpApi,
  HttpApiEndpoint,
  HttpApiGroup,
} from "effect/unstable/httpapi";
import {
  signInWithVendorAssertion,
  VendorAssertionInvalidError,
  VendorAssertionUnavailableError,
} from "./vendor-assertion";

export const VendorAssertionSignInBody = Schema.Struct({
  assertion: Schema.String.check(
    Schema.isMinLength(1),
    Schema.isMaxLength(16_384)
  ),
});

export const vendorAssertionSignInEndpoint = HttpApiEndpoint.post(
  "signIn",
  "/sign-in",
  {
    payload: VendorAssertionSignInBody,
    success: PrimaryAuthSuccess,
    error: [
      AuthInvalidCredentialsError,
      AuthPolicyDeniedError,
      AuthRateLimitedError,
      AuthInternalError,
    ],
  }
);

export class VendorAssertionHttpApiGroup extends HttpApiGroup.make(
  "vendorAssertion"
)
  .add(vendorAssertionSignInEndpoint)
  .prefix("/auth/vendor-assertion")
  .middleware(AuthSchemaErrorMiddleware)
  .middleware(AuthOriginCheckMiddleware) {}

export class VendorAssertionHttpApi extends HttpApi.make(
  "VendorAssertionHttpApi"
).add(VendorAssertionHttpApiGroup) {}

const mapVendorAssertionHttpError = (
  error:
    | VendorAssertionInvalidError
    | VendorAssertionUnavailableError
    | AuthFlowPrimaryFactorError
): AuthInvalidCredentialsError | AuthInternalError =>
  error._tag === "VendorAssertionInvalidError"
    ? new AuthInvalidCredentialsError({
        code: "invalid_credentials",
        message: "Invalid credentials",
      })
    : new AuthInternalError({
        code: "internal_error",
        message: "Failed to sign in with vendor assertion",
      });

export const vendorAssertionSignInOperation = Effect.fn(
  "app.http.vendor_assertion.sign_in"
)(function* (input: {
  readonly assertion: string;
  readonly expectedNonce: string;
  readonly request: LoginRequestContext;
}) {
  const authHttp = yield* AuthHttp;
  const result = yield* signInWithVendorAssertion(input).pipe(
    Effect.mapError(mapVendorAssertionHttpError)
  );

  return yield* authHttp.commitPrimaryFactorResult(result);
});

Bind vendorAssertionSignInOperation with HttpApiBuilder.group as described in Custom Auth API. The handler must obtain the nonce and request context from trusted services, not from payload.

Because the assertion is consumed once, follow the attempt-cookie and outgoing header rules in the boundary table for every result. Do not turn the partner assertion into a custom MFA descriptor.

src/vendor-assertion-client.ts
import {
  createAuthClient,
  defineAuthHttpApiExtension,
} from "@effect-auth/core/Client";
import { Schema } from "effect";
import {
  VendorAssertionHttpApi,
  VendorAssertionSignInBody,
} from "./vendor-assertion-api";

type VendorAssertionSignInRequest = Schema.Schema.Type<
  typeof VendorAssertionSignInBody
>;

const vendorAssertionExtension = defineAuthHttpApiExtension(
  VendorAssertionHttpApi,
  ({ run }) => ({
    signIn: (input: VendorAssertionSignInRequest) =>
      run((client) => client.vendorAssertion.signIn({ payload: input })),
  })
);

export const auth = createAuthClient({
  protocol: {
    extensions: { vendorAssertion: vendorAssertionExtension },
  },
});

const result = await auth.extensions.vendorAssertion.signIn({ assertion });

if (result.type === "requires_mfa") {
  await auth.mfa.totp.verify({ flowId: result.flowId, code });
}

The continuation APIs listed in the boundary table must be mounted at the paths configured by this client.

Provision with Alchemy v2

Reuse the D1 database and Durable Object rate limiter from the auth stack. The only new Worker binding is the partner introspection credential.

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import type { RATE_LIMITER as RateLimitDurableObject } from "./src/worker";

export const Database = Cloudflare.D1.Database("AuthDatabase", {
  migrationsDir: "./migrations",
});

export const RateLimiter = Cloudflare.DurableObject<RateLimitDurableObject>(
  "RATE_LIMITER",
  {
    className: "RATE_LIMITER",
  }
);

export const AuthWorker = Cloudflare.Worker("AuthWorker", {
  main: "./src/worker.ts",
  compatibility: { flags: ["nodejs_compat"] },
  env: {
    DB: Database,
    RATE_LIMITER: RateLimiter,
    PARTNER_INTROSPECTION_TOKEN: Config.redacted("PARTNER_INTROSPECTION_TOKEN"),
  },
});

export default Alchemy.Stack(
  "MarketplaceAuth",
  {
    providers: Cloudflare.providers(),
    state: Cloudflare.state(),
  },
  Effect.gen(function* () {
    const worker = yield* AuthWorker;
    return { url: worker.url.as<string>() };
  })
);

Use the release-tested alchemy@2.0.0-beta.63. Config.redacted makes a missing token fail deployment and keeps its value redacted during planning. In beta.63, any effect/Config value placed in Worker.env becomes Cloudflare secret_text; a literal string, including a resolved process.env value, becomes plain_text. Separate D1 and credentials per stage, protect Alchemy state, and rotate the token with an overlap window accepted by the partner.

Verify failure behavior

TestRequired observation
Inactive or well-formed claims with wrong issuer, audience, purpose, nonce, or timeGeneric invalid credentials; no replay row for pre-validation failures
First valid assertionOne replay row; mapped user enters normal AuthFlow policy
Concurrent duplicate assertionsExactly one D1 insert and at most one AuthFlow attempt
Unknown mapping, deleted mapping, or disabled userSame generic invalid result; replay key burned; no session
Hard-delete user, remap subject, retry unexpired assertionReplay row survives; same generic invalid result
Partner timeout, non-success status, malformed, excess-field, or oversized response; D1 errorFail closed; sanitized internal error; no assertion in telemetry
Built-in MFA or login approval requiredStandard continuation, then one final session cookie
Origin missing or untrusted, request body oversized, rate limit exceededRejected before partner introspection
Policy version removed while referencedTyped deployment-integrity failure mapped to internal error

Run these cases against workerd and a disposable D1 database, then exercise concurrent duplicates in the deployed stage. Continue with Cloudflare Workers, Alchemy v2, and the production checklist.

On this page