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.
Fix the protocol contract
Do not infer semantics from arbitrary partner output. Agree on exact values and reject everything else:
| Field | Required policy |
|---|---|
active | Exactly true; the partner has checked signature, revocation, and credential state |
issuer | Exactly https://login.partner.example |
audience | Exactly marketplace-auth |
purpose | Exactly primary-sign-in |
issuedAt, expiresAt | Integer Unix seconds; ordered, at most 120 seconds apart, no more than 30 seconds of issue-time clock skew, and not expired |
nonce | Equals the nonce in a verified, short-lived, HttpOnly login-attempt cookie |
assertionId | Bounded opaque identifier consumed once in D1 |
subject | Resolves through an explicit partner-to-user mapping; never treat it as a user ID or email |
authenticationContext | Bounded 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.
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.
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.
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:
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:
| Boundary | Required implementation |
|---|---|
| Start route | Generate 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 input | Verify cookie integrity and expiry, then derive LoginRequestContext from the server request and Cloudflare metadata |
| Middleware | Schema errors, an explicitly mounted exact secure origin policy, and endpoint-specific rate limit before introspection |
| Domain call | signInWithVendorAssertion({ assertion, expectedNonce, request }) |
| Commit | AuthHttp.commitPrimaryFactorResult(result) |
| Outgoing cookies | Expire the attempt cookie without replacing any effect-auth Set-Cookie headers from the committer |
| Continuation routes | Mount the maintained MFA and login-approval APIs for every continuation enabled by policy |
| Public invalid result | Generic invalid_credentials; never distinguish replay, mapping, disabled user, nonce, or claim failures |
| Public dependency result | Sanitized internal_error; log only an allow-listed error tag and partner status |
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.
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.
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
| Test | Required observation |
|---|---|
| Inactive or well-formed claims with wrong issuer, audience, purpose, nonce, or time | Generic invalid credentials; no replay row for pre-validation failures |
| First valid assertion | One replay row; mapped user enters normal AuthFlow policy |
| Concurrent duplicate assertions | Exactly one D1 insert and at most one AuthFlow attempt |
| Unknown mapping, deleted mapping, or disabled user | Same generic invalid result; replay key burned; no session |
| Hard-delete user, remap subject, retry unexpired assertion | Replay row survives; same generic invalid result |
| Partner timeout, non-success status, malformed, excess-field, or oversized response; D1 error | Fail closed; sanitized internal error; no assertion in telemetry |
| Built-in MFA or login approval required | Standard continuation, then one final session cookie |
| Origin missing or untrusted, request body oversized, rate limit exceeded | Rejected before partner introspection |
| Policy version removed while referenced | Typed 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.