Repository navigation
Phase 3: build Microsoft OIDC mock and protocol fixture kit #5
Description
A deterministic in-process Microsoft OIDC mock authority ships in @authfx/microsoft as the protocol fixture kit
Context
This issue delivers MicrosoftOidcFixture: a deterministic, in-process mock of the Microsoft Entra authority that lets every AuthFX test — and later the Plus-One migration acceptance flow — exercise the Microsoft authenticator without live credentials or network access. The kit covers discovery metadata, the authorization redirect/callback pair, one-time code exchange with PKCE verification, JWKS publication and rotation, valid organizational identities, every adversarial variant (wrong tenant, issuer, audience, nonce, signature, expiry, replay), and configurable latency and provider failures.
The load-bearing design property: the fixture provides its own effect/unstable/http HttpClient routing requests by URL to in-memory handlers, so the production Discovery/Jwks/TokenClient modules (#11) run unmodified against it — the code path the independent security review (#6) audits is the one under test.
Spec grounding (relative paths in this repo):
docs/spec/08-microsoft-oidc-provider.md— "support a configurable authority for deterministic local testing" (theAUTHFX_MICROSOFT_AUTHORITYoverride is the seam this kit plugs into).docs/spec/03-non-functional-requirements.md(Testability) — "Provider packages include protocol fixtures for success, denial, malformed responses, key rotation, replay, and timeout."docs/spec/12-testing-and-security-verification.md— mandates "Microsoft OIDC protocol fixture tests" and "end-to-end local mock-provider tests"; "No test waits on wall-clock time."docs/spec/decisions/ADR-0005 — Microsoft test fixtures and mock-authority support are required from the first implementation, not deferred.docs/spec/13-plusone-migration.md— the kit is reused by the Plus-One migration acceptance flow (Phase 6: create Plus-One AuthFX migration adapter #8).
Governing decisions
- D3 (foundations) — protocol code lives in the provider package, never core;
joseis a package-boundary dependency wrapped behind Effect services; PKCE S256 only, 32-bytestate/nonce— the fixture enforces exactly these algorithm choices at its endpoints. - D8 (foundations) — sign-in registration only: the fixture token endpoint serves only the
authorization_codegrant, and its response carries decoyaccess_token/refresh_tokenvalues so downstream conformance proves production code drops them. - D9 (foundations) — naming law: service tag
"@authfx/microsoft/MicrosoftOidcFixture", env prefixAUTHFX_, kebab-case files, one namespace per file,Schema.TaggedErrorClasserrors, zero code comments. - D10 (foundations) — provider fixtures live inside the provider package; a hard environment gate keeps mock authorities out of production composition (the
m365-mockdiscipline); determinism via theTokenCryptotest layer andTestClock. - D13 (foundations) — designed from the Microsoft case only; no generic multi-provider fixture abstraction.
- Architecture §5.7 — normative fixture design: the
Interface/Service/FixtureTampershapes, the dual-provisionfixtureLayer,fixture-keys.tskeypair ownership, theAUTHFX_MICROSOFT_FIXTURE=1gate, andserveLayerfor out-of-process use. - Architecture §1.5 — package layout and the constraint that
joseappears in exactly twosrc/call sites:oidc/jwks.ts(Phase 3: implement Microsoft Entra OIDC authenticator #11) andfixture/fixture-keys.ts(this issue). - Architecture §5.4/§5.6 + review log feat(docs): add FoldKit documentation site #13/Phase 1: scaffold workspace packages and @authfx/testing foundations #14/#19/#20 — the fixture must let tests exercise the amended verification semantics: RS256-only signing, exact single-audience
aud(andazpwhen present), per-tenant issuer substitution viatid,xms_edovas the only verified-email signal, and retryable-outage classification distinct from terminal proof failure. - Architecture §9.2/§9.3/§10.2 — the authenticator conformance harness runs under this fixture; determinism comes from
DeterministicTokenCrypto; the all-memory composition wiresMicrosoftOidcFixture.fixtureLayeras the only HttpClient in test graphs.
Deliverables
packages/microsoft/
package.json
tsconfig.json
AGENTS.md
src/
index.ts
fixture/
fixture.ts
fixture-keys.ts
fixture-http-client.ts
test/
fixture.test.ts
Modified files:
- root
package.json— addjoseto the workspace catalog, pinned exact (architecture §12 open question 3: exact pin, renovate review lane). packages/microsoft/package.json— dependencieseffect(catalog),@authfx/core(workspace),jose(catalog); devDependencies@authfx/testing(workspace),@effect/vitest(catalog).packages/microsoft/src/index.ts— barrel addsexport * as MicrosoftOidcFixture from "./fixture/fixture"and themicrosoftPackagemarker constant (full barrel shape per architecture §1.5; the other namespace exports land with Phase 3: implement Microsoft Entra OIDC authenticator #11 — do not create their files here).
This issue owns src/fixture/** and test/fixture.test.ts only. It must not create src/oidc/**, src/authenticator.ts, src/microsoft-config.ts, src/tenant-policy.ts, or src/errors.ts — those are #11 deliverables in the same package.
Contracts
The fixture service, copied exactly from the architecture doc (§5.7):
export interface Interface {
readonly issueLogin: (input: {
readonly authorize_url: string
readonly subject: string
readonly tenant_id: string
readonly email?: string | undefined
readonly display_name?: string | undefined
readonly email_verified?: boolean | undefined
}) => Effect.Effect<{ readonly state: string; readonly code: string }, FixtureError>
readonly tamper: (mutation: FixtureTamper) => Effect.Effect<void>
readonly rotateSigningKey: (options: { readonly publishPrior: boolean }) => Effect.Effect<void>
readonly failNext: (target: "discovery" | "jwks" | "token", failure: FixtureFailure) => Effect.Effect<void>
readonly latency: (target: "discovery" | "jwks" | "token", duration: Duration.Duration) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@authfx/microsoft/MicrosoftOidcFixture") {}
export type FixtureTamper =
| { readonly _tag: "WrongIssuer" }
| { readonly _tag: "WrongAudience" }
| { readonly _tag: "WrongNonce" }
| { readonly _tag: "WrongTenant"; readonly tenant_id: string }
| { readonly _tag: "ExpiredToken"; readonly expiredByMillis: number }
| { readonly _tag: "BadSignature" }The layer signature, copied exactly (§5.7) — it provides both the fixture service and the HttpClient the production protocol modules consume:
export const fixtureLayer: Layer.Layer<Service | HttpClient.HttpClient, never, TokenCrypto.Service> =
Layer.effect(...)FixtureError and FixtureFailure are referenced but not defined in the architecture doc; complete them minimally, house style (Schema.TaggedErrorClass, fully qualified tag):
export class FixtureError extends Schema.TaggedErrorClass<FixtureError>()(
"@authfx/microsoft/FixtureError",
{ detail: Schema.String },
) {}
export type FixtureFailure =
| { readonly _tag: "Unavailable"; readonly status: number }
| { readonly _tag: "NetworkError" }
| { readonly _tag: "MalformedBody" }The dependency this kit consumes — the @authfx/core TokenCrypto port (D6, architecture §4.1, already shipped by earlier phases; copied exactly, do not re-derive):
export interface Interface {
readonly randomBytes: (size: number) => Effect.Effect<Uint8Array, TokenCryptoError>
readonly sha256Base64Url: (input: Redacted.Redacted<string>) => Effect.Effect<string, TokenCryptoError>
readonly constantTimeEquals: (a: string, b: string) => Effect.Effect<boolean>
readonly randomUUIDv7: Effect.Effect<string, TokenCryptoError>
}
export class Service extends Context.Service<Service, Interface>()("@authfx/core/TokenCrypto") {}Beta.93 API facts the implementation relies on (verified against node_modules/.bun/effect@4.0.0-beta.93/.../effect/src): HttpClient.make takes a request runner (request, url, signal, fiber) => Effect<HttpClientResponse, HttpClientError>; synthetic responses are built with HttpClientResponse.fromWeb(request, new Response(...)); HttpRouter (for serveLayer) lives in effect/unstable/http.
Implementation steps
- Catalog and package wiring. Add
joseto the rootpackage.jsonworkspace catalog pinned to an exact version. Inpackages/microsoft/package.jsondeclare dependencieseffect,@authfx/core,joseand devDependencies@authfx/testing,@effect/vitest. Verify:bun installsucceeds;bun run buildstill passes. src/fixture/fixture-keys.ts— key custody and the onlyjosecall site in this issue. Generate an RS256 keypair per fixture instance viajose.generateKeyPair("RS256")(RSA keygen cannot be seeded; determinism comes from the fixture owning both signing and publication, per §5.7). Export:make(scoped key-state construction with a generatedkid), a JWKS-document builder (jose.exportJWKon active plus optionally prior public keys, each carrying itskid), and asignIdTokenhelper (jose.SignJWT,alg: "RS256", honoring an option to sign with a corrupted signature or an unpublished key for theBadSignaturetamper). No other fixture file may importjose(§1.5 constraint; enforceable by ast-grep).src/fixture/fixture.ts— authority state. Model mutable authority state inRefs insideLayer.effect: recorded logins keyed bystate(holdingnonce,code_challenge,client_id,redirect_uri, and theissueLoginidentity inputs), a one-time code registry (code → login, consumed flag), the signing-key state from step 2, a one-shot tamper queue, per-target one-shot failure injections, and per-target latencies. Export afixtureAuthorityconstant (a fixed non-resolvable base such ashttps://login.fixture.authfx.test) that consumers use to build theirMicrosoftConfigtest layer; the discovery document, issuer claims, and URL routing all derive from it.issueLogin. Parseinput.authorize_urlwithURL; failFixtureErrorunless it targets the fixture authority's authorize path and carriesresponse_type=code,client_id,redirect_uri,response_mode=query,scopeexactlyopenid profile email,state,nonce,code_challenge, andcode_challenge_method=S256(the D3 normative table). Record the login, mint a single-usecodefromTokenCrypto.randomBytes(base64url), and return{ state, code }— exactly what a browser redirect would deliver to the callback.- Protocol handlers. Implement three pure-ish handlers over the state from step 3:
- Discovery: serves
${fixtureAuthority}/${tenant}/v2.0/.well-known/openid-configurationreturningissuer(template form with the tenant segment),authorization_endpoint,token_endpoint,jwks_uri, all underfixtureAuthority(matches the productionDiscoveryfetch shape in §5.4). - JWKS: serves the document from step 2; after
rotateSigningKey({ publishPrior: true })it contains the prior public key alongside the new one; withpublishPrior: falsethe prior key is absent (drives R-SEC-25 both ways). - Token: accepts form-encoded POSTs; validates
grant_type === "authorization_code", known single-usecode(second use answers HTTP 400{"error":"invalid_grant"}— replay),client_idandredirect_urimatching the recorded login, and PKCE:SHA-256(code_verifier)base64url viaTokenCrypto.sha256Base64Urlmust equal the recordedcode_challenge. On success mint an id_token with claimsiss(${fixtureAuthority}/${tid}/v2.0),aud(recordedclient_id),sub,tid,oid(derived stably fromsubject),nonce(recorded),iat/nbf/expfromClock.currentTimeMillis, plusemail/namewhen provided, andxms_edov: trueonly whenemail_verified: truewas requested (§5.6 claims mapping, review log Phase 1: scaffold workspace packages and @authfx/testing foundations #14). The JSON response carriesid_tokenplus decoyaccess_token,refresh_token, andexpires_infields — productionTokenClientmust discard them (D8), and the §9.2 conformance case asserts they never surface.
- Discovery: serves
- Tamper application. Before signing, drain the one-shot tamper queue and apply mutations:
WrongIssuersetsissto a value that fails the tid-template substitution check;WrongAudiencesetsaudto a different client id;WrongNoncereplaces the nonce claim;WrongTenantre-mints the token formutation.tenant_idwith consistenttidandiss(the token stays protocol-valid so it reaches the tenant policy and exercisesAssertionRejectedByPolicy, distinct from protocol failure — R-SEC-11);ExpiredTokensetsexpto now minusexpiredByMillis;BadSignaturesigns via the corrupted-signature path from step 2. Tampers affect exactly one issuance, then clear. - Failure and latency controls.
failNext(target, failure)arms a one-shot response for that endpoint:Unavailableanswers the given 5xx status,NetworkErrorfails the HttpClient effect with a request-transport error,MalformedBodyreturns 200 with undecodable JSON.latency(target, duration)prependsEffect.sleep(duration)to the handler soTestClock.adjustcontrols it — no wall-clock waits (spec 12). src/fixture/fixture-http-client.ts— in-memory transport. Export a route table mapping URL prefixes underfixtureAuthorityto the step-5 handlers, and a constructor building theHttpClientviaHttpClient.makewhose runner matchesurlagainst the table and returnsHttpClientResponse.fromWeb(request, new Response(body, { status, headers })). Unmatched URLs fail with a request error (the fixture never proxies to the network). Also export the same handler table shaped aseffect/unstable/httpHttpRouterroutes;serveLayer(exported fromfixture.ts, composing this module) mounts them for out-of-process mode — the consumer supplies the platform server layer (BunHttpServerin the Plus-One harness, Phase 6: create Plus-One AuthFX migration adapter #8), keeping@authfx/microsoft's dependency list ateffect+@authfx/core+jose(§1.5).fixtureLayerwith the hard environment gate.Layer.effectreadsAUTHFX_MICROSOFT_FIXTUREfrom configuration andEffect.dies unless it equals"1"(D10m365-mockdiscipline: a mock authority can never build in a production composition by accident). On success it constructs the state, the service implementation, and the in-memoryHttpClient, providing both tags per the §5.7 signature.- Barrel.
src/index.tsexportsMicrosoftOidcFixtureandexport const microsoftPackage = "@authfx/microsoft". Verify the wordmark/naming law (D9): noAuthFx/authFXspellings, tag strings fully qualified. test/fixture.test.ts— self-tests. See Testing below. SetAUTHFX_MICROSOFT_FIXTURE=1inside the test setup, provideDeterministicTokenCrypto.layer(seed)from@authfx/testing, and useit.effectthroughout (noit.scopedin beta.93).
Testing
All tests live in packages/microsoft/test/fixture.test.ts, run with @effect/vitest idioms locked by architecture §9.5: it.effect everywhere, Effect.flip for error-channel assertions, layers via .pipe(Effect.provide(...)), TestClock.adjust for every timing case. Tests may import jose directly to verify wire-level effects (the §1.5 two-call-site constraint governs src/, and jose is already a package dependency).
Required cases:
issueLoginaccepts a compliant authorize URL and returns{ state, code }; it failsFixtureErrorfor a missingcode_challenge, a non-S256 method, a wrong scope list, or a foreign authority.- Happy path: exchanging the code at the token handler returns an id_token that verifies against the fixture JWKS with expected
iss/aud/sub/tid/nonceclaims;xms_edovpresent only whenemail_verified: truewas requested; decoyaccess_token/refresh_token/expires_infields present in the raw response. - Replay: a second exchange with the same code answers
invalid_grant(one-time enforcement). - PKCE: a wrong
code_verifieris rejected; the recordedcode_challengeequalsSHA-256(code_verifier)base64url. - Each
FixtureTamperproduces exactly its intended defect and only for the next issuance:WrongIssuer(iss/tid template mismatch),WrongAudience(aud ≠ client_id),WrongNonce,WrongTenant(protocol-valid token, different consistent tid+iss),ExpiredToken(exp in the past byexpiredByMillisagainst the virtual clock),BadSignature(fails JWKS verification). rotateSigningKey({ publishPrior: true }): JWKS serves both keys; tokens minted before rotation still verify. WithpublishPrior: false: prior key absent; pre-rotation tokens no longer verify against the served set (the R-SEC-25 raw material).failNextfor each ofdiscovery/jwks/tokenproduces the armed failure exactly once, then the endpoint recovers; all threeFixtureFailurevariants covered at least once.latencydelays a handler by the configured duration underTestClock.adjust; no test sleeps in real time.- Environment gate: building
fixtureLayerwithoutAUTHFX_MICROSOFT_FIXTURE=1dies (assert viaEffect.exiton layer construction). - Determinism: two runs with the same
DeterministicTokenCrypto.layer(seed)produce identicalstateandcodesequences.
Security catalog note (D10, architecture §9.4): R-SEC-23 (nonce mismatch), R-SEC-24 (wrong issuer or audience), and R-SEC-25 (stale signing keys) are owned by @authfx/microsoft but their named adversarial tests exercise the authenticator's complete path and therefore land with #11, consuming the WrongNonce/WrongIssuer/WrongAudience tampers and rotateSigningKey controls this issue ships. This issue must leave those controls behaving exactly as the §9.4 test names assume.
Commands (repo AGENTS.md gates — all must pass at root):
bun install
bun run format:check
bun run lint
bun run typecheck
bun run test
bun run test:coverage
bun run buildCoverage thresholds 90/65/90/90 apply to this package from its first source file (D10).
Acceptance criteria
-
packages/microsoft/src/fixture/{fixture,fixture-keys,fixture-http-client}.tsexist with the exactInterface,Servicetag"@authfx/microsoft/MicrosoftOidcFixture",FixtureTamperunion, andfixtureLayer: Layer.Layer<Service | HttpClient.HttpClient, never, TokenCrypto.Service>signatures from architecture §5.7. -
fixtureLayerprovides a working in-memoryHttpClientserving discovery, JWKS, and token endpoints under the exportedfixtureAuthority, with zero network sockets opened in the in-process mode. - The token endpoint enforces single-use codes,
client_id/redirect_uribinding, and PKCE S256 verification, and answers OAuth-shaped errors (invalid_granton replay). - Issued id_tokens are RS256-signed, carry
iss/aud/sub/tid/oid/nonce/iat/nbf/exp(plusemail/name/xms_edovas requested), and verify against the fixture JWKS. - All six
FixtureTampervariants,rotateSigningKeywith and without prior publication,failNextfor all three targets, andTestClock-drivenlatencyare implemented and covered bytest/fixture.test.ts. -
WrongTenantyields a protocol-valid token (consistenttid/iss) so tenant-policy rejection is distinguishable from protocol failure (R-SEC-11). - The token response includes decoy
access_token/refresh_token/expires_infields so D8 discard behavior is testable downstream. -
fixtureLayerdies at construction unlessAUTHFX_MICROSOFT_FIXTURE=1. -
serveLayerexposes the same handlers overeffect/unstable/httprouting with the platform server supplied by the consumer;@authfx/microsoftdependencies remain exactlyeffect,@authfx/core,jose. -
joseappears insrc/only insidefixture/fixture-keys.ts; it is pinned exact in the root catalog. - With the same
DeterministicTokenCryptoseed,state/codesequences are reproducible across runs; no test waits on wall-clock time. - No files owned by Phase 3: implement Microsoft Entra OIDC authenticator #11 (
src/oidc/**,authenticator.ts,microsoft-config.ts,tenant-policy.ts,errors.ts) are created; no Plus-One vocabulary appears in any exported name. -
bun run format:check,bun run lint,bun run typecheck,bun run test,bun run test:coverage, andbun run buildall pass at the repo root.
Dependencies
Blocked by:
- Phase 1: scaffold workspace packages and @authfx/testing foundations #14 — provides the
packages/microsoftworkspace skeleton and@authfx/testingwithDeterministicTokenCrypto. - Phase 1: define core schemas, ids, and tagged errors #1 — core branded ids and shared scalar schemas the package imports.
- Phase 2: implement opaque session service #4 — the
@authfx/coreTokenCryptoport (architecture §4.1, D6),fixtureLayer's only requirement.
Blocks:
- Phase 3: implement Microsoft Entra OIDC authenticator #11 — the Microsoft authenticator's fixture-driven protocol tests and the R-SEC-23/24/25 adversarial suite consume this kit (inventory: Phase 3: implement Microsoft Entra OIDC authenticator #11 depends on Phase 3: build Microsoft OIDC mock and protocol fixture kit #5).
- Phase 6: create Plus-One AuthFX migration adapter #8 — Plus-One migration acceptance points
AUTHFX_MICROSOFT_AUTHORITYat aserveLayerinstance. - Phase 7: complete independent authentication security review #6 — the security review audits fixture-driven coverage of the Microsoft adversarial catalog rows.
- Add an AuthFX + ConnectFX co-embedding smoke test #18 — its login leg runs against this fixture.
Coordination constraint: this issue and #11 modify the same package. The file-ownership split above is the contract; land this issue first so #11's tests can consume the kit from day one.
Out of scope
- The Microsoft authenticator itself and all protocol modules (
oidc/discovery.ts,oidc/jwks.ts,oidc/pkce.ts,oidc/token-client.ts,oidc/challenge.ts,oidc/claims.ts,microsoft-config.ts,tenant-policy.ts,errors.ts) — Phase 3: implement Microsoft Entra OIDC authenticator #11 perdocs/spec/08-microsoft-oidc-provider.mdand ADR-0005. - The named R-SEC-23/24/25 adversarial tests — they exercise
Authenticator.completeand land in Phase 3: implement Microsoft Entra OIDC authenticator #11'stest/security/microsoft-adversarial.test.ts(D10 placement, architecture §9.4). - HTTP begin/callback endpoint testing —
@authfx/http, Phase 4: implement Effect HTTP authentication adapter #7 (docs/spec/07-http-security-and-framework-boundaries.md, ADR-0006). - The Plus-One migration harness and cutover flow — Phase 6: create Plus-One AuthFX migration adapter #8 (
docs/spec/13-plusone-migration.md); this issue only ships the reusableserveLayer. - Live Microsoft integration tests — opt-in only and never in the default suite (D10;
docs/spec/12-testing-and-security-verification.md). - Any refresh-token, client-credentials, or admin-consent grant support in the fixture — the sign-in registration exchanges a code exactly once (D8); operational Microsoft flows belong to ConnectFX (
CONTEXT.mdboundary). - A generic multi-provider fixture framework or shared standards package — D3/D13 and ADR-0003 forbid pre-generalizing from one provider;
@connectfx/microsoftcarries its own fixtures. - Deterministic RSA key seeding — impossible for RSA keygen; determinism is scoped to token/state/code material via
TokenCrypto(architecture §5.7 rationale).
Activity