Repository navigation
Phase 3: implement Microsoft Entra OIDC authenticator #11
Description
The Microsoft Entra OIDC authenticator ships in @authfx/microsoft
Context
This issue delivers the first (and only v1) concrete Authenticator: Microsoft Entra OIDC sign-in via authorization code with PKCE. It implements docs/spec/08-microsoft-oidc-provider.md and ADR-0005 as the @authfx/microsoft package: configuration, the tenant-admission policy seam, the duplicated OAuth/OIDC protocol module (PKCE, state, nonce, discovery, JWKS, token client), id_token validation, claims mapping into a verified ExternalIdentityAssertion, and the composed Authenticator.Interface registered through the core registry.
The package proves who the user is at Microsoft and nothing more: it requests exactly openid profile email, exchanges the authorization code once, validates issuer/audience/signature/expiration/timing/nonce, asks the consumer's tenant policy whether the proven tid may proceed, and emits claims — never a provider access or refresh token. All state and nonce material lives on the core authentication transaction row as opaque ChallengeData; core never learns Microsoft vocabulary.
Spec grounding (relative paths in this repo):
docs/spec/08-microsoft-oidc-provider.md— the verbatim musts: authorization code with PKCE; generate and validate OIDC nonce; validate issuer, audience, signature, expiration, and token timing; tenant configuration includingorganizationsand explicit tenant ids; verified External identity assertion exposing tenant id, issuer, subject, email claims, display name, profile metadata; tenant rejection classified separately from protocol failure; configurable authority for deterministic local testing.docs/spec/04-authentication-transactions-and-authenticators.md— authenticator-owned challenge data; assertion is the only value an Authenticator produces toward core.docs/spec/03-non-functional-requirements.md— R-SEC-09 (state/PKCE/nonce/issuer/audience/timestamps/signatures), R-SEC-10 (Microsoft-specific validation list), R-SEC-11 (tenant rejection distinct from protocol failure), R-SEC-15 (no secrets in telemetry/errors), R-SEC-18 (typed failures preserve retryability).docs/spec/12-testing-and-security-verification.md— Microsoft OIDC protocol fixture tests; deterministic JWKS rotation and malformed-token tests; no wall-clock waits.docs/spec/decisions/ADR-0003 (authenticators are provider packages), ADR-0005 (Microsoft OIDC first provider; contract stays provisional), ADR-0007 (AuthFX/ConnectFX separation — no operational token custody here).
Governing decisions
- D3 (foundations) — no shared standards package: PKCE/state/nonce/discovery/JWKS/token-client live inside this package, following the normative algorithm table exactly (PKCE S256 only with 32-byte verifier, 32-byte
statebound to the transaction row, 32-bytenoncevalidated against the id_token, discovery + JWKS caches).joseis a package-boundary dependency, wrapped immediately behind Effect services with typed errors, never visible from core. - D8 (foundations) — sign-in registration only: scope list is the constant
"openid profile email"(no scope config key exists, so operational scopes are structurally impossible); anyaccess_token/refresh_tokenfrom the code exchange is discarded insidetoken-client.ts; env surface isAUTHFX_MICROSOFT_CLIENT_ID/AUTHFX_MICROSOFT_CLIENT_SECRET/AUTHFX_MICROSOFT_TENANT/AUTHFX_MICROSOFT_AUTHORITY/AUTHFX_MICROSOFT_REDIRECT_URI. - D6 (foundations) — all randomness, hashing, and constant-time comparison go through the core
TokenCryptoport; no directnode:cryptoorMath.randomanywhere in this package. - D7 (foundations) —
tidandoidare exposed insideclaimsfor the host's Subject resolver to interpret; this package never decides tenancy, it only proves the claim and consultsMicrosoftTenantPolicy. - D9 (foundations) — naming law: tag strings
"@authfx/microsoft/<Name>", kebab-case files, one namespace per file,Schema.TaggedErrorClasserrors,Effect.fn("Module.method")on methods, zero code comments. - D10 (foundations) — this package owns adversarial catalog rows R-SEC-23 (nonce mismatch), R-SEC-24 (wrong issuer or audience), R-SEC-25 (stale signing keys) under
test/security/, with requirement ids in test names; it must pass the@authfx/testingauthenticator conformance harness under the fixture layer. - D13 (foundations) — the Authenticator contract is designed from this case only and stays
@experimental; no speculative surface for other providers. - Architecture §5.1–§5.6, §5.8 — normative module designs copied below; §4.4/§4.5 — the core contract and registry this package implements against; §1.5 — package layout and the constraint that
joseappears insrc/only insideoidc/jwks.ts(plusfixture/fixture-keys.ts, owned by Phase 3: build Microsoft OIDC mock and protocol fixture kit #5). - Architecture review log feat(docs): add FoldKit documentation site #13 — JWKS outage routes to retryable
ProviderUnavailable, never terminalProofInvalid; token endpoint failures split intoMicrosoftTokenEndpointFailed(network/5xx, retryable) andMicrosoftTokenEndpointRejected(4xx, terminal). - Architecture review log Phase 1: scaffold workspace packages and @authfx/testing foundations #14 —
emailmaps as unverified profile data withemail_verifiedomitted;email_verified: trueonly on Entra's explicitxms_edov: truesignal. - Architecture review log Add an AuthFX + ConnectFX co-embedding smoke test #18 — error
detailfields carry only a cause_tagor HTTP status; no stringified requests, bodies, or rows. - Architecture review log #19/#20 —
jose.jwtVerifypinsalgorithms: ["RS256"]; audience validation is exact single-audience withazp === clientIdwhen present. - Architecture §0.5 decision A5 —
completeruns all protocol validation before core's atomic consume, so a provider outage never burns a pending transaction (core-side sequencing lands with Phase 2: implement authentication transaction service #12; this package's contribution is correct failure classification).
Deliverables
packages/microsoft/
package.json
tsconfig.json
AGENTS.md
src/
index.ts
microsoft-config.ts
authenticator.ts
tenant-policy.ts
errors.ts
oidc/
challenge.ts
pkce.ts
discovery.ts
jwks.ts
token-client.ts
claims.ts
test/
authenticator.test.ts
discovery.test.ts
security/
microsoft-adversarial.test.ts
Modified files:
packages/microsoft/src/index.ts— full barrel per architecture §1.5 (theMicrosoftOidcFixtureexport andmicrosoftPackagemarker already exist from Phase 3: build Microsoft OIDC mock and protocol fixture kit #5 / the scaffold issue):
export * as MicrosoftConfig from "./microsoft-config"
export * as MicrosoftOidcAuthenticator from "./authenticator"
export * as MicrosoftTenantPolicy from "./tenant-policy"
export * as MicrosoftErrors from "./errors"
export * as MicrosoftOidcFixture from "./fixture/fixture"
export const microsoftPackage = "@authfx/microsoft"packages/microsoft/package.json— dependencieseffect(catalog),@authfx/core(workspace),jose(catalog, pinned exact); devDependencies@authfx/testing(workspace),@effect/vitest(catalog).
This issue owns everything above except src/fixture/** and test/fixture.test.ts, which are #5 deliverables in the same package. Do not modify fixture behavior; the adversarial suite consumes its tamper controls as shipped.
Contracts
The core Authenticator contract this package implements — @authfx/core authenticator.ts, copied exactly from architecture §4.4 (it lands with #12 together with the six AuthenticatorFailure tagged-error classes — AuthenticationDenied { authenticator_id, reason? }, ProofInvalid { authenticator_id, detail }, ProviderUnavailable { authenticator_id, retryable }, ProtocolViolation { authenticator_id, detail }, AssertionRejectedByPolicy { authenticator_id, rejection }, AuthenticatorConfigurationInvalid { authenticator_id, detail } — import them from @authfx/core, do not re-derive):
export type AuthenticatorFailure =
| AuthenticationDenied
| ProofInvalid
| ProviderUnavailable
| ProtocolViolation
| AssertionRejectedByPolicy
| AuthenticatorConfigurationInvalid
export interface BeginInput {
readonly transaction_id: AuthenticationTransactionId
}
export interface BeginResult {
readonly instruction: AuthenticationInstruction
readonly challenge: ChallengeData
}
export interface CompleteInput {
readonly transaction_id: AuthenticationTransactionId
readonly challenge: ChallengeData
readonly proof: Proof
}
export interface Interface {
readonly id: AuthenticatorId
readonly method: AuthenticationMethod
readonly begin: (input: BeginInput) => Effect.Effect<BeginResult, AuthenticatorFailure>
readonly complete: (input: CompleteInput) => Effect.Effect<ExternalIdentityAssertion, AuthenticatorFailure>
}src/microsoft-config.ts, copied exactly from architecture §5.1:
import { Config, Context, Layer, Redacted, Schema } from "effect"
export interface Interface {
readonly clientId: string
readonly clientSecret: Redacted.Redacted<string>
readonly tenant: string
readonly authority: string
readonly redirectUri: string
}
export class Service extends Context.Service<Service, Interface>()("@authfx/microsoft/MicrosoftConfig") {}
export const layer: Layer.Layer<Service, Config.ConfigError> = Layer.effect(
Service,
Config.all({
clientId: Config.string("AUTHFX_MICROSOFT_CLIENT_ID"),
clientSecret: Config.redacted("AUTHFX_MICROSOFT_CLIENT_SECRET"),
tenant: Config.string("AUTHFX_MICROSOFT_TENANT").pipe(Config.withDefault("organizations")),
authority: Config.string("AUTHFX_MICROSOFT_AUTHORITY").pipe(
Config.withDefault("https://login.microsoftonline.com"),
),
redirectUri: Config.string("AUTHFX_MICROSOFT_REDIRECT_URI"),
}),
)
export const testLayer = (implementation: Interface): Layer.Layer<Service> =>
Layer.succeed(Service, Service.of(implementation))src/errors.ts, copied exactly from architecture §5.2:
export class MicrosoftTenantRejected extends Schema.TaggedErrorClass<MicrosoftTenantRejected>()(
"@authfx/microsoft/MicrosoftTenantRejected",
{ tenant_id: Schema.String },
) {}
export class MicrosoftDiscoveryFailed extends Schema.TaggedErrorClass<MicrosoftDiscoveryFailed>()(
"@authfx/microsoft/MicrosoftDiscoveryFailed",
{ detail: Schema.String, retryable: Schema.Boolean },
) {}
export class MicrosoftJwksUnavailable extends Schema.TaggedErrorClass<MicrosoftJwksUnavailable>()(
"@authfx/microsoft/MicrosoftJwksUnavailable",
{ detail: Schema.String, retryable: Schema.Boolean },
) {}
export class MicrosoftTokenEndpointFailed extends Schema.TaggedErrorClass<MicrosoftTokenEndpointFailed>()(
"@authfx/microsoft/MicrosoftTokenEndpointFailed",
{ detail: Schema.String, retryable: Schema.Boolean },
) {}
export class MicrosoftTokenEndpointRejected extends Schema.TaggedErrorClass<MicrosoftTokenEndpointRejected>()(
"@authfx/microsoft/MicrosoftTokenEndpointRejected",
{ status: Schema.Int },
) {}
export class MicrosoftIdTokenInvalid extends Schema.TaggedErrorClass<MicrosoftIdTokenInvalid>()(
"@authfx/microsoft/MicrosoftIdTokenInvalid",
{ check: Schema.Literals(["signature", "issuer", "audience", "expiration", "timing", "nonce", "claims"]) },
) {}src/tenant-policy.ts, copied exactly from architecture §5.2:
export interface Interface {
readonly admit: (tenant_id: string) => Effect.Effect<void, MicrosoftTenantRejected>
}
export class Service extends Context.Service<Service, Interface>()("@authfx/microsoft/MicrosoftTenantPolicy") {}
export const layerAdmitAll: Layer.Layer<Service> = Layer.succeed(
Service,
Service.of({ admit: () => Effect.void }),
)
export const layerAllowList = (tenantIds: ReadonlyArray<string>): Layer.Layer<Service> =>
Layer.succeed(
Service,
Service.of({
admit: (tenant_id) =>
tenantIds.includes(tenant_id)
? Effect.void
: Effect.fail(new MicrosoftTenantRejected({ tenant_id })),
}),
)
export const testLayer = (implementation: Interface): Layer.Layer<Service> =>
Layer.succeed(Service, Service.of(implementation))src/oidc/challenge.ts, copied exactly from architecture §5.3:
import { Effect, Schema } from "effect"
import { AuthenticationTransaction } from "@authfx/core"
export const MicrosoftChallenge = Schema.Struct({
state: Schema.String,
nonce: Schema.String,
code_verifier: Schema.String,
redirect_uri: Schema.String,
}).annotate({ identifier: "Microsoft.Challenge" })
export interface MicrosoftChallenge extends Schema.Schema.Type<typeof MicrosoftChallenge> {}
export const encode = (challenge: MicrosoftChallenge): AuthenticationTransaction.ChallengeData =>
AuthenticationTransaction.challengeData(JSON.stringify(challenge))
export const decode = (data: AuthenticationTransaction.ChallengeData) =>
Effect.try({
try: () => JSON.parse(data) as unknown,
catch: () => new ChallengeCorrupted({}),
}).pipe(
Effect.flatMap((parsed) =>
Schema.decodeUnknownEffect(MicrosoftChallenge)(parsed).pipe(
Effect.mapError(() => new ChallengeCorrupted({})),
),
),
)
export class ChallengeCorrupted extends Schema.TaggedErrorClass<ChallengeCorrupted>()(
"@authfx/microsoft/ChallengeCorrupted",
{},
) {}src/oidc/discovery.ts contract, copied exactly from architecture §5.4 (the layer body is specified in implementation step 7; note the beta.93 fact: Cache has no instance get, retrieval is the free function Cache.get(cache, key), verified against effect/src/Cache.ts):
export const DiscoveryDocument = Schema.Struct({
issuer: Schema.String,
authorization_endpoint: Schema.String,
token_endpoint: Schema.String,
jwks_uri: Schema.String,
}).annotate({ identifier: "Microsoft.DiscoveryDocument" })
export interface DiscoveryDocument extends Schema.Schema.Type<typeof DiscoveryDocument> {}
export interface Interface {
readonly document: Effect.Effect<DiscoveryDocument, MicrosoftDiscoveryFailed>
}
export class Service extends Context.Service<Service, Interface>()("@authfx/microsoft/Discovery") {}
export const layer: Layer.Layer<Service, never, MicrosoftConfig.Service | HttpClient.HttpClient> = Layer.effect(...)src/oidc/jwks.ts service surface, copied exactly from architecture §5.4:
export interface Interface {
readonly verifyIdToken: (input: {
readonly id_token: Redacted.Redacted<string>
readonly audience: string
readonly nonce: string
}) => Effect.Effect<MicrosoftIdTokenClaims, MicrosoftIdTokenInvalid | MicrosoftJwksUnavailable>
}
export class Service extends Context.Service<Service, Interface>()("@authfx/microsoft/Jwks") {}src/oidc/pkce.ts, copied exactly from architecture §5.5:
import { Effect, Encoding, Redacted } from "effect"
import { TokenCrypto } from "@authfx/core"
export const generate = Effect.fn("Pkce.generate")(function* () {
const crypto = yield* TokenCrypto.Service
const verifierBytes = yield* crypto.randomBytes(32)
const code_verifier = Encoding.encodeBase64Url(verifierBytes)
const code_challenge = yield* crypto.sha256Base64Url(Redacted.make(code_verifier))
return { code_verifier, code_challenge, code_challenge_method: "S256" as const }
})src/oidc/token-client.ts, copied exactly from architecture §5.5:
export const exchangeCode = Effect.fn("TokenClient.exchangeCode")(function* (input: {
readonly token_endpoint: string
readonly code: string
readonly code_verifier: string
readonly redirect_uri: string
}) {
const config = yield* MicrosoftConfig.Service
const client = yield* HttpClient.HttpClient
const response = yield* client
.execute(
HttpClientRequest.post(input.token_endpoint).pipe(
HttpClientRequest.bodyUrlParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: Redacted.value(config.clientSecret),
code: input.code,
code_verifier: input.code_verifier,
redirect_uri: input.redirect_uri,
}),
),
)
.pipe(Effect.mapError((cause) => new MicrosoftTokenEndpointFailed({ detail: cause._tag, retryable: true })))
if (response.status >= 500) {
return yield* Effect.fail(
new MicrosoftTokenEndpointFailed({ detail: `status ${response.status}`, retryable: true }),
)
}
if (response.status >= 400) {
return yield* Effect.fail(new MicrosoftTokenEndpointRejected({ status: response.status }))
}
const body = yield* HttpClientResponse.schemaBodyJson(TokenResponse)(response).pipe(
Effect.mapError(() => new MicrosoftTokenEndpointRejected({ status: response.status })),
)
return { id_token: Redacted.make(body.id_token) }
})src/authenticator.ts, copied exactly from architecture §5.6 (one correction: toAssertionFields imports from "./oidc/claims" — the §1.5 layout places claims.ts under oidc/; the architecture code block's "./claims" path predates the layout and is superseded by §1.5):
import { Effect, Encoding, Schema } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Authenticator, ExternalIdentity, Ids, Shared, TokenCrypto } from "@authfx/core"
import * as Challenge from "./oidc/challenge"
import * as Discovery from "./oidc/discovery"
import * as Jwks from "./oidc/jwks"
import * as Pkce from "./oidc/pkce"
import * as TokenClient from "./oidc/token-client"
import * as MicrosoftConfig from "./microsoft-config"
import * as MicrosoftTenantPolicy from "./tenant-policy"
import { toAssertionFields } from "./oidc/claims"
export const microsoftAuthenticatorId = Ids.AuthenticatorId.make("microsoft-oidc")
const CallbackProof = Schema.Struct({
state: Schema.String,
code: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.String),
error_description: Schema.optionalKey(Schema.String),
}).annotate({ identifier: "Microsoft.CallbackProof" })
const protocolViolation = (detail: string) =>
new Authenticator.ProtocolViolation({ authenticator_id: microsoftAuthenticatorId, detail })
const toProtocolViolation = (cause: { readonly _tag: string }) => protocolViolation(cause._tag)
const toProofInvalid = (cause: { readonly _tag: string }) =>
new Authenticator.ProofInvalid({ authenticator_id: microsoftAuthenticatorId, detail: cause._tag })
const toProviderUnavailable = (cause: { readonly retryable?: boolean }) =>
new Authenticator.ProviderUnavailable({
authenticator_id: microsoftAuthenticatorId,
retryable: cause.retryable ?? true,
})
const toConfigInvalid = (cause: { readonly _tag: string }) =>
new Authenticator.AuthenticatorConfigurationInvalid({
authenticator_id: microsoftAuthenticatorId,
detail: cause._tag,
})
export const make: Effect.Effect<
Authenticator.Interface,
never,
| MicrosoftConfig.Service
| MicrosoftTenantPolicy.Service
| Discovery.Service
| Jwks.Service
| TokenCrypto.Service
| HttpClient.HttpClient
> = Effect.gen(function* () {
const config = yield* MicrosoftConfig.Service
const tenantPolicy = yield* MicrosoftTenantPolicy.Service
const discovery = yield* Discovery.Service
const jwks = yield* Jwks.Service
const crypto = yield* TokenCrypto.Service
const services = yield* Effect.context<HttpClient.HttpClient | MicrosoftConfig.Service | TokenCrypto.Service>()
const randomToken = (size: number) =>
crypto.randomBytes(size).pipe(Effect.map(Encoding.encodeBase64Url), Effect.mapError(toConfigInvalid))
const begin = Effect.fn("MicrosoftOidcAuthenticator.begin")(function* () {
const document = yield* discovery.document.pipe(Effect.mapError(toProviderUnavailable))
const state = yield* randomToken(32)
const nonce = yield* randomToken(32)
const pkce = yield* Pkce.generate().pipe(Effect.provideContext(services), Effect.mapError(toConfigInvalid))
const url = new URL(document.authorization_endpoint)
url.searchParams.set("client_id", config.clientId)
url.searchParams.set("response_type", "code")
url.searchParams.set("redirect_uri", config.redirectUri)
url.searchParams.set("response_mode", "query")
url.searchParams.set("scope", "openid profile email")
url.searchParams.set("state", state)
url.searchParams.set("nonce", nonce)
url.searchParams.set("code_challenge", pkce.code_challenge)
url.searchParams.set("code_challenge_method", pkce.code_challenge_method)
return {
instruction: { _tag: "Redirect" as const, url: Shared.NonEmptyString.make(url.toString()) },
challenge: Challenge.encode({ state, nonce, code_verifier: pkce.code_verifier, redirect_uri: config.redirectUri }),
}
})
const complete = Effect.fn("MicrosoftOidcAuthenticator.complete")(function* (
input: Authenticator.CompleteInput,
) {
const stored = yield* Challenge.decode(input.challenge).pipe(Effect.mapError(toProtocolViolation))
const proof = yield* Schema.decodeUnknownEffect(CallbackProof)(input.proof).pipe(
Effect.mapError(() => protocolViolation("callback shape")),
)
if (proof.error !== undefined) {
return yield* Effect.fail(
new Authenticator.AuthenticationDenied({
authenticator_id: microsoftAuthenticatorId,
reason: proof.error,
}),
)
}
const stateMatches = yield* crypto.constantTimeEquals(proof.state, stored.state)
if (!stateMatches || proof.code === undefined) {
return yield* Effect.fail(protocolViolation("state mismatch"))
}
const exchanged = yield* TokenClient.exchangeCode({
token_endpoint: (yield* discovery.document.pipe(Effect.mapError(toProviderUnavailable))).token_endpoint,
code: proof.code,
code_verifier: stored.code_verifier,
redirect_uri: stored.redirect_uri,
}).pipe(
Effect.provideContext(services),
Effect.mapError((failure) =>
failure._tag === "@authfx/microsoft/MicrosoftTokenEndpointRejected"
? toProofInvalid(failure)
: toProviderUnavailable(failure),
),
)
const claims = yield* jwks
.verifyIdToken({ id_token: exchanged.id_token, audience: config.clientId, nonce: stored.nonce })
.pipe(
Effect.mapError((failure) =>
failure._tag === "@authfx/microsoft/MicrosoftJwksUnavailable"
? toProviderUnavailable(failure)
: toProofInvalid(failure),
),
)
yield* tenantPolicy.admit(claims.tid).pipe(
Effect.mapError(
() =>
new Authenticator.AssertionRejectedByPolicy({
authenticator_id: microsoftAuthenticatorId,
rejection: Shared.NonEmptyString.make("microsoft_tenant_not_admitted"),
}),
),
)
return {
authenticator_id: microsoftAuthenticatorId,
method: Shared.oidcMethod,
issuer: Shared.NonEmptyString.make(claims.iss),
provider_subject: Shared.NonEmptyString.make(claims.sub),
...toAssertionFields(claims),
} satisfies ExternalIdentity.ExternalIdentityAssertion
})
return { id: microsoftAuthenticatorId, method: Shared.oidcMethod, begin, complete }
})The registration layer, copied exactly from architecture §5.8 — the public requirement surface must match this type verbatim:
export const registryLayer: Layer.Layer<
AuthenticatorRegistry.Service,
never,
MicrosoftConfig.Service | MicrosoftTenantPolicy.Service | TokenCrypto.Service | HttpClient.HttpClient
> = AuthenticatorRegistry.layerFrom([make]).pipe(
Layer.provide(Discovery.layer),
Layer.provide(Jwks.layer),
)Implementation steps
- Package wiring. Confirm the
packages/microsoftskeleton from Phase 1: scaffold workspace packages and @authfx/testing foundations #14 and the fixture files from Phase 3: build Microsoft OIDC mock and protocol fixture kit #5 exist. Add any missing dependencies (effect,@authfx/core,josefrom the exact-pinned catalog; devDeps@authfx/testing,@effect/vitest). Verify:bun installandbun run buildpass. src/microsoft-config.ts. Implement exactly as in Contracts. No scope configuration key may exist anywhere in the package. Verify:bun run typecheck; a unit assertion thattenantdefaults to"organizations"andauthorityto"https://login.microsoftonline.com".src/errors.ts. Implement the six error classes exactly as in Contracts.detailfields must only ever be assigned a cause_tagor an HTTP-status string at call sites (review log Add an AuthFX + ConnectFX co-embedding smoke test #18).src/tenant-policy.ts. ImplementInterface,Service,layerAdmitAll,layerAllowList,testLayerexactly as in Contracts. Default composition useslayerAdmitAll— tenant admission is consumer policy (spec 08).src/oidc/challenge.ts. Implement exactly as in Contracts. The PKCE verifier, nonce, and expected state live only inside the encodedChallengeData, which core persists on the transaction row and returns verbatim atcomplete— they appear in no other storage and no log.src/oidc/pkce.ts. Implement exactly as in Contracts: S256 only, 32-byte verifier viaTokenCrypto.randomBytes, challenge viaTokenCrypto.sha256Base64Url. Noplainmethod exists.src/oidc/discovery.ts. Implement the §5.4 layer body: resolveMicrosoftConfig.ServiceandHttpClient.HttpClient; fetch${config.authority}/${config.tenant}/v2.0/.well-known/openid-configurationviaHttpClientRequest.get+HttpClientResponse.schemaBodyJson(DiscoveryDocument), mapping any failure toMicrosoftDiscoveryFailed({ detail: cause._tag, retryable: true }); wrap inCache.make({ lookup: () => fetchDocument, capacity: 1, timeToLive: Duration.hours(12) })and exposedocumentasCache.get(cache, void 0).src/oidc/claims.ts. DefineMicrosoftIdTokenClaims(aSchema.Structdecoded from the verified JWT payload; not inlined in the architecture doc — derive minimally from §5.4/§5.6: requirediss,sub,aud,tid,oid,nonce,exp,iat;Schema.optionalKeyfornbf,email,name,preferred_username,xms_edov,azp) andtoAssertionFields(claims)returning the assertion's optional fields:emailmapped as unverified profile data withemail_verifiedomitted,email_verified: trueonly whenxms_edov === true(review log Phase 1: scaffold workspace packages and @authfx/testing foundations #14);preferred_usernamenever treated as email;name→display_name;tidandoidplaced insideclaimsmetadata for the host's Subject resolver (D7). Verify with unit tests covering all four email cases (absent, present withoutxms_edov, present withxms_edov: true,preferred_usernameonly).src/oidc/jwks.ts— the package's only productionjosecall site. Fetchjwks_uri(fromDiscovery.Service) through the EffectHttpClient, hold the key-set JSON in aCachewith 12-hour TTL, feed it tojose.createLocalJWKSet, and implementverifyIdTokenper §5.4 exactly:jose.jwtVerifywith{ audience: input.audience, clockTolerance: 300, algorithms: ["RS256"] }(review log #19).- On
ERR_JWKS_NO_MATCHING_KEY: invalidate the cache entry, refetch exactly once, retry verification; then failMicrosoftIdTokenInvalid({ check: "signature" })(R-SEC-25 rotation tolerance). - Audience: exact single-audience match against the client id; a present
azpclaim not equal to the client id failsMicrosoftIdTokenInvalid({ check: "audience" })(review log #20). - Issuer: substitute the token's
tidinto the discovery issuer template; the result must equal the token'sissexactly, elseMicrosoftIdTokenInvalid({ check: "issuer" }). expviolations map tocheck: "expiration";iat/nbfviolations tocheck: "timing"; undecodable claims tocheck: "claims".- Nonce: constant-time compare via core
TokenCrypto.constantTimeEquals; mismatch failsMicrosoftIdTokenInvalid({ check: "nonce" }). - JWKS fetch/network failures fail
MicrosoftJwksUnavailablewithretryable: trueand only a cause_tagindetail.
src/oidc/token-client.ts. ImplementexchangeCodeexactly as in Contracts, plus the privateTokenResponseschema decodingid_token: Schema.Stringandaccess_token/refresh_token/expires_inasSchema.optionalKey(Schema.String)— the function's return type contains onlyid_tokenand the other fields never leave the function (D8 hard rule). Network/transport failures and 5xx →MicrosoftTokenEndpointFailed(retryable); 4xx →MicrosoftTokenEndpointRejected(terminal).src/authenticator.ts. Implementmakeand the error-mapping helpers exactly as in Contracts, thenregistryLayerper §5.8 so the exported layer type is verbatimLayer.Layer<AuthenticatorRegistry.Service, never, MicrosoftConfig.Service | MicrosoftTenantPolicy.Service | TokenCrypto.Service | HttpClient.HttpClient>—Discovery/Jwksare provided internally and hidden. Failure routing invariants:MicrosoftJwksUnavailable,MicrosoftDiscoveryFailed, andMicrosoftTokenEndpointFailedall reach core asProviderUnavailablewithretryablepreserved (transaction stays pending, A5/R-SEC-18);MicrosoftTokenEndpointRejectedand everyMicrosoftIdTokenInvalidreach core asProofInvalid(single-attempt terminal); tenant rejection reaches core only asAssertionRejectedByPolicywith rejection"microsoft_tenant_not_admitted"(R-SEC-11); a callbackerrorparameter maps toAuthenticationDeniedbefore any exchange.- Barrel. Complete
src/index.tsper §1.5. Verify D9: noAuthFx/authFXspellings, all tag strings fully qualified, noutils/directories, zero code comments. - Tests. Write
test/discovery.test.ts,test/authenticator.test.ts, andtest/security/microsoft-adversarial.test.tsper the Testing section, all againstMicrosoftOidcFixture.fixtureLayer+DeterministicTokenCrypto.layer(seed)+MicrosoftConfig.testLayerpointing at the fixture authority, withAUTHFX_MICROSOFT_FIXTURE=1set in test setup.
Testing
All tests run the production protocol modules unmodified against the in-process fixture (#5): fixtureLayer provides both the fixture service and the HttpClient, so live-vs-test wiring differs only in which layers satisfy make's requirements. Idioms locked by architecture §9.5: it.effect everywhere (no it.scoped in beta.93), Effect.flip for error-channel assertions, layers via .pipe(Effect.provide(...)), TestClock.adjust for every timing case, no wall-clock waits.
Conformance (test/authenticator.test.ts). Run authenticatorConformance from @authfx/testing (shipped by #12 alongside the Authenticator contract it verifies, architecture §9.2) against make under the fixture layer. Its final case is the automated D8 custody check: it greps the decoded assertion and its claims for access_token/refresh_token keys and JWT-shaped values.
Protocol tests (test/authenticator.test.ts, test/discovery.test.ts).
beginproduces a redirect URL carryingclient_id,response_type=code,redirect_uri,response_mode=query,scopeexactlyopenid profile email,state,nonce,code_challenge,code_challenge_method=S256; the challenge round-trips throughChallenge.decode.- Happy path:
fixture.issueLoginon the begin URL, thencompletewith{ state, code }yields anExternalIdentityAssertionwithissuer = claims.iss,provider_subject = claims.sub,method = oidc,tid/oidinsideclaims, and no token-shaped values. - Email mapping: assertion omits
email_verifiedfor a plainemail; carriesemail_verified: trueonly when the fixture mintedxms_edov: true. - Denial: a callback proof with
errorset failsAuthenticationDeniedcarrying the provider reason, without hitting the token endpoint. - State mismatch and missing code each fail
ProtocolViolation; a tampered/truncated challenge failsProtocolViolationviaChallengeCorrupted. - Replayed code: second
completewith the same one-time code gets the fixture'sinvalid_grant400 and failsProofInvalid(core-level replay via atomic consume is Phase 2: implement authentication transaction service #12's suite). - Outage classification:
fixture.failNextondiscovery/jwks/token(5xx and network variants) each failProviderUnavailablewithretryable: true; a token-endpoint 400 failsProofInvalid. Latency injection underTestClock.adjustcompletes without real waiting. - Wrong tenant:
WrongTenanttamper (protocol-valid token) underlayerAllowListfailsAssertionRejectedByPolicy, and underlayerAdmitAllsucceeds — proving tenant rejection is policy, not protocol (R-SEC-11). - Discovery: document is fetched once and cached (12h TTL); the URL embeds the configured authority and tenant; an explicit tenant id config builds the matching URL.
Adversarial catalog (test/security/microsoft-adversarial.test.ts, D10 placement). Exact test names from architecture §9.4 — the CI grep gate wired by #15 requires each id to appear in at least one test name:
R-SEC-23 nonce mismatch: fixture WrongNonce tamper fails ProofInvalid before tenant policy runsR-SEC-24 wrong issuer or audience: fixture WrongIssuer and WrongAudience tampers each fail ProofInvalidR-SEC-25 stale signing keys: rotateSigningKey without prior publication fails signature; with publication verifies via refetch
Also cover: ExpiredToken tamper fails ProofInvalid (expiration), BadSignature fails ProofInvalid (signature), and the R-SEC-23 case asserts via a spying MicrosoftTenantPolicy.testLayer that admit was never called.
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 (D10). Live Microsoft tests are opt-in only and never in the default suite.
Acceptance criteria
-
packages/microsoft/src/{microsoft-config,errors,tenant-policy,authenticator}.tsandsrc/oidc/{challenge,pkce,discovery,jwks,token-client,claims}.tsexist with the exact service tags, error classes, and signatures from architecture §5.1–§5.6 as inlined above. -
beginbuilds the authorize URL with PKCE S256 (32-byte verifier), 32-bytestateandnonce, and the constant scopeopenid profile email; all secret challenge material round-trips only through coreChallengeData(spec 04, D3 normative table). -
verifyIdTokenvalidates signature (RS256 allowlist), exact single-audienceaudplusazpwhen present, per-tenant issuer viatidtemplate substitution,exp/iat/nbfwith 300s tolerance, and nonce via constant-time compare — each failure mapped to its distinctMicrosoftIdTokenInvalid.check. - JWKS key rotation is tolerated by exactly one invalidate-and-refetch on
ERR_JWKS_NO_MATCHING_KEY; discovery and JWKS documents are cached with 12-hour TTLs viaCache.get(cache, void 0). -
exchangeCodereturns only{ id_token };access_token/refresh_token/expires_inare decoded and discarded inside the function; no scope configuration key exists in the package (D8). - Failure classification: outages (
discovery/jwks/token network/5xx) surface asProviderUnavailablewithretryablepreserved; token-endpoint 4xx and all id_token defects surface asProofInvalid; provider denial asAuthenticationDenied; tenant rejection only asAssertionRejectedByPolicy(R-SEC-11, R-SEC-18, review log feat(docs): add FoldKit documentation site #13). -
email_verifiedis never fabricated: omitted for plainemail,trueonly onxms_edov: true;preferred_usernameis never treated as email;tid/oidare exposed insideclaims(review log Phase 1: scaffold workspace packages and @authfx/testing foundations #14, D7). - Error
detailfields carry only cause_tags or HTTP statuses; theclient_secret-bearing request body is unreachable from any error value; noRedactedvalue is ever logged (R-SEC-15, review log Add an AuthFX + ConnectFX co-embedding smoke test #18). -
registryLayer's exported type is verbatimLayer.Layer<AuthenticatorRegistry.Service, never, MicrosoftConfig.Service | MicrosoftTenantPolicy.Service | TokenCrypto.Service | HttpClient.HttpClient>withDiscovery/Jwkshidden. -
joseappears insrc/only insideoidc/jwks.ts(fixture-keys aside, owned by Phase 3: build Microsoft OIDC mock and protocol fixture kit #5) and is pinned exact in the catalog. - The
@authfx/testingauthenticatorConformanceharness passes under the fixture layer, including the automated D8 custody grep. -
test/security/microsoft-adversarial.test.tscontains the three §9.4 test names verbatim (R-SEC-23/24/25) and they pass deterministically withDeterministicTokenCryptoandTestClock— no wall-clock waits. - No Microsoft claims, ids, or vocabulary were added to
@authfx/core; no Plus-One vocabulary appears anywhere; zero code comments. -
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: define core schemas, ids, and tagged errors #1 — core schemas this package consumes:
Ids.AuthenticatorId,Sharedscalars (NonEmptyString,oidcMethod),ExternalIdentity.ExternalIdentityAssertion,AuthenticationTransaction.ChallengeData, and theTokenCryptoport. - Phase 2: implement authentication transaction service #12 — the core
Authenticatorcontract (§4.4),AuthenticatorRegistry(§4.5), the@authfx/testingauthenticatorConformanceharness (§9.2) minted against that contract (Phase 1: add memory stores and conformance suites #2 disclaims it — the harness importsAuthenticator.Interface, which does not exist until Phase 2: implement authentication transaction service #12), and the transaction service whosecompletesequencing (A5) this package's failure classification feeds. - Phase 3: build Microsoft OIDC mock and protocol fixture kit #5 — the
MicrosoftOidcFixturekit in this same package (tamper controls,rotateSigningKey,failNext,latency,issueLogin) that every test here consumes. - Phase 1: scaffold workspace packages and @authfx/testing foundations #14 — the
packages/microsoftworkspace skeleton, catalog pins (joseexact), andDeterministicTokenCrypto.
Blocks:
- Epic: implement AuthFX production foundation #3 — the production-foundation epic requires this child.
- Phase 7: complete independent authentication security review #6 — the security review audits OAuth/OIDC state, PKCE, nonce, issuer, audience, and JWKS handling implemented here.
- Phase 6: create Plus-One AuthFX migration adapter #8 — Plus-One replaces Better Auth Microsoft sign-in with this authenticator; its tenant allow-list ships as a
MicrosoftTenantPolicylayer in the Plus-One adapter. - Align the docs site with the shipped v1 API #17 — the Microsoft OIDC guide documents this config surface and tenant policy.
- Add an AuthFX + ConnectFX co-embedding smoke test #18 — its login leg drives this authenticator against the fixture.
Coordination constraints: this issue and #5 modify the same package — land #5 first; the file-ownership split in Deliverables is the contract. Cross-repo (D8): the sign-in registration configured here is distinct from @connectfx/microsoft's operational registrations (CONNECTFX_MICROSOFT_*, In-Time-Tec/connectfx#7/#12); the two packages share the D3 normative algorithm table but never code, and there is no cross-repo blocking edge for this issue.
Out of scope
- Any Graph scope, provider access/refresh token custody, or hand-off to ConnectFX — D8 and ADR-0007: operational credentials belong to
@connectfx/microsoft(Phase 4: extract Microsoft Graph application credential and client runtime connectfx#7); tokens returned by the exchange are dropped at this package boundary. - The fixture kit (
src/fixture/**,test/fixture.test.ts) — Phase 3: build Microsoft OIDC mock and protocol fixture kit #5, same package, already landed; this issue only consumes it. - The core
Authenticatorcontract, registry, transaction service, and the consume→link→issue atomicity boundary — Phase 2: implement authentication transaction service #12 and Phase 1: define core schemas, ids, and tagged errors #1 (docs/spec/04, A5/A11); this issue implements against them. - HTTP begin/callback endpoints, cookies, trusted-origin/CSRF, and the
authfx_txnbinding cookie —@authfx/http, Phase 4: implement Effect HTTP authentication adapter #7 (docs/spec/07, ADR-0006, R-SEC-22). - Core-level replay tests (
TransactionAlreadyConsumed, R-SEC-21) — owned by@authfx/coreper D10 catalog placement. - A shared OAuth/OIDC standards package or any cross-import with ConnectFX — D3 forbids it until both frameworks have second OAuth implementations.
- A second authenticator (email-link is the named post-v1 default), passwords, passkeys, MFA, SAML — D13 and repo
AGENTS.md: each requires its own spec + ADR first. - Microsoft claims or ids in
@authfx/core— the issue body's own hard rule, ADR-0003 provider-neutrality. - Tenant admission decisions — spec 08: consumer policy; this package ships
layerAdmitAll/layerAllowListseams only, and Plus-One's allow-list lands with Phase 6: create Plus-One AuthFX migration adapter #8. - Live-tenant integration tests in the default suite — D10: opt-in only.
Activity