v0.1.0
Refuse to choose the in-process replay store in production
It bounds replay within ONE process, so behind a second replica the same
assertion is accepted again on each. Nothing here can see how many are
running, so the deployment now says which store it wants, at construction
rather than at the first sign-in. NODE_ENV is normalised so NODE_ENV=prod
does not read as 'not production'.
Declare the environment variables the generated config reads
Checked against @adonisjs/mail 10.4.0, whose configure() publishes the config
AND calls defineEnvVariables beside it. Mine wrote a config full of
env.get('QUEUE_STORE') and declared nothing, which is the half-installation
the hook exists to prevent: the application boots, the config asks the
environment for something nothing ever put there, and the fallback answers.
addEnvVars was already on ream's codemods and simply went unused.
Say that ream add sets this up, because it does now
Ship the configure hook ream add expects
ream add <pkg> installs, then imports <pkg>/configure and runs it. Nine
packages provided that hook and this one did not, so ream add left an
application with a provider registered and no config file for it to read —
falling back to a default that is rarely the one anybody wanted, silently.
The hook registers the provider and writes the config stub beside it, because
the two are one step: a provider without its file is not installed, it is half
installed.
Close what a review found: PKCE, the optional peer, and two seams
An external review of the package. Three of its five findings were real.
The verifier was sent on every token exchange, including to a provider that
had said it does not take S256 and therefore never received a challenge. At
best that is noise; a strict provider refuses the exchange over it. Whether
PKCE was used now travels in secret, and the verifier goes only when a
challenge went.
@c9up/quasar is reached at runtime and never imported, so it is an optional
peer — which bay, echo, ream and warden all declare and this did not. Runtime
was fine; npm and pnpm simply told the user nothing.
test:coverage failed: branches at 78.55% against a threshold of 80, and the
coverage provider was borrowed from the workspace root rather than declared —
which a standalone clone of this repository does not have. Both fixed, and the
gap was where the review said it was: the service accessor and the quasar
bridge, the two seams that break at integration while the protocol code is
covered. The accessor's answer to then is now pinned, since a proxy that
throws there takes down the import rather than the first call.
Two findings are not defects. The vitest and coverage versions resolve to
4.1.6 together here. And files carrying src is deliberate and repo-wide:
the build emits declaration maps and source maps that point into it, so
go-to-definition and stack traces work in a consumer.
Let the test double stand in for a directory too
FakeTransit covered the redirect providers and nothing else, so an
application signing in against LDAP had no way to test it — the directory
contract arrived after the double did.
authenticate now answers the same way, and refuses an empty password for the
same reason the double refuses a missing state: a bind with no password is an
anonymous bind the directory accepts, and a fake that let it through would
teach an application to submit one. willAccept declares the credentials a
test wants checked, for the tests that are about a wrong password rather than
about what happens after a right one.
Sign in against LDAP and Active Directory
A directory is a different shape from everything else here: nothing is
redirected. The application already holds the credentials, and the directory is
the authority that says whether they are right. So it gets its own contract —
DirectoryDriver, reached with authenticate — and asking for one through
begin(), or a redirect provider through authenticate(), says which of the
two it is rather than failing on a missing method.
Two things here are the difference between an LDAP sign-in that works and one
that lets anybody through.
An empty password is refused before a socket is opened. A simple bind with no
password is an ANONYMOUS bind, and the directory answers success — a login form
that passes a blank one straight through therefore signs in as whoever was
named. It is the oldest bug in LDAP authentication and it is one if.
And there is no filter string in this API. A filter is a structure, and BER
writes its values as length-prefixed octets, so a login of *)(uid=admin is a
login containing those characters. Injection is not escaped away; it is
unrepresentable.
Two connections per sign-in: one to find the person's DN, and one to bind AS
them — which is what verifies the password — closed immediately, because a
connection carries the identity of whatever last bound on it. ldaps:// unless
told otherwise, since a simple bind sends the password as it was typed.
The tests run over real sockets against a directory that decodes what this
encodes, so a mistake in the wire format shows up here rather than against a
live server.
Ship SAML 2.0
The fifth layer, and the one that makes the other four usable: the walk between
them, and the two bindings a browser sign-in uses — the request leaves deflated
in a query string, the response comes back base64 in a form post.
The mapping onto the driver contract needed no new shape, the same way OAuth1
needed none. The SAMLResponse is the code, the RelayState is the state, and the
request id begin() minted is the secret. One controller serves OAuth2, PKCE,
OAuth1 and now SAML.
The order is the security. The signature is verified first, and everything read
afterwards comes out of the element it covers. The one exception is the status,
read once from the unsigned response and deliberately: a refusal arrives
unsigned when the assertion is what carries the signature, and it exists to be
reported rather than trusted — flipping it the other way cannot produce an
assertion that verifies.
Certificates come from the provider's metadata and nowhere else, several of
them so a rotation is prepared rather than survived. A driver with none refuses
to exist, at boot rather than at the first sign-in.
Attributes keep every value: SAML attributes are multi-valued, and collapsing
them loses the group memberships an application usually authorises on.
That completes the chain — XML reader, exclusive canonicalization, signature
verification, assertion validation, binding — with no dependency at any layer.
Validate a SAML assertion, and refuse a second use of it
The fourth layer. A valid signature says the provider wrote this. It says
nothing about whether the statement was meant for THIS application, at THIS
moment, in answer to THIS request — and a response that is genuine but none of
those is what a replay or a misdirected sign-in looks like.
So each condition answers one of those questions: the issuer is the provider
the metadata names, the audience is this application, the Recipient is the URL
this application answers at, the InResponseTo is the request this sign-in made,
and both windows — the assertion's and the bearer confirmation's — are open,
with a minute of tolerance for clock drift against the provider.
Absences are refused as firmly as mismatches. An assertion that restricts no
audience does not say who it is for; one with no expiry never stops being
usable; one answering a request this application never made is one obtained
somewhere else and posted here.
Everything works on the element the signature covers, and nothing searches the
document again — that is the guarantee the layer below hands over.
An assertion is also a bearer token: captured once and posted twice it is valid
both times, and no condition on it changes that. AssertionReplayStore
remembers the id until it expires. Both backends ship, because a memory-only
one accepts the same assertion once per replica — the Redis store decides with
SET … NX, in one atomic round trip, so two concurrent posts cannot both win.
The binding and the driver are what remain; SAML is still not usable.
Verify XML signatures, and hand back what they cover
The third layer. The danger in XML-DSig is not the cryptography: a signature
can be perfectly valid over one part of a document while the application reads
another. That is XML Signature Wrapping, and it has broken SAML
implementations repeatedly.
The mitigation is structural rather than a check. verifyXmlSignature returns
the element the signature actually covers, and the caller reads that object —
a reader that never searches the document again cannot be looking at a part the
signature does not cover.
Everything else exists so that guarantee holds. Exactly one signature and one
reference, because several multiply what "the signed element" could mean. The
reference must resolve to exactly ONE element: a second element carrying the
same id is the classic wrapping payload, and the signature stays valid while a
reader that looks the id up finds the attacker's copy. The signature must sit
inside what it references. Only the enveloped-signature and exclusive-c14n
transforms are accepted — XPath can make the digest cover something other than
the element, and XSLT is executable.
SHA-1 is refused, for signatures and for digests. Collisions against it are
practical, and a signature over attacker-influenced XML is where that matters.
The key comes from the caller, out of the provider's metadata. A certificate
embedded in the document is checked against that list and never trusted on its
own: verifying a document against a key it carries is verifying it against
itself. Several certificates are accepted so a rotation can be absorbed
without an outage.
The tests sign real documents with a throwaway certificate and then attack
them — a duplicated id, a relocated signature, a second signature, an edited
subject, an edited SignedInfo.
Assertion validation is the layer above; SAML is still not usable.
Read and canonicalize XML, without a dependency
The first two layers SAML needs. Both are written rather than pulled in,
because a signature library is adherence in the worst place: it is load-bearing
for security, and a version that diverges cannot be left behind.
The reader refuses more than it parses. No DOCTYPE — one rule closing external
entities and expansion bombs together, and a signed assertion has no
legitimate reason to carry one. No entity it does not define, no unbound
prefix, no repeated attribute, nothing malformed repaired: a parser that
guesses hands a verifier a document the sender never wrote, and the two then
disagree about what was signed.
Exclusive canonicalization follows the specification, and the awkward rules are
the ones that decide it. Only namespaces an element visibly utilizes are
written, which is what lets a declaration travel down to the element that
actually uses it and a signed fragment stay verifiable after being moved. An
unprefixed attribute is in no namespace, so it never makes the default one
utilized. The xml prefix is never declared.
The tests found a real one on the way: attribute-value normalisation was
flattening 	 to a space. A literal tab becomes a space, a character
reference stays a tab — unescaping first loses that distinction, and the
canonical form then differs from the signer's by exactly that byte.
Signature verification and assertion validation are the layers above; SAML is
not usable until they land.
Ship a test double, as every package with an outside does
A sign-in cannot be exercised against a real provider in a test, and Transit
was the only package here with an external side and no ./testing entry point.
FakeTransit extends the manager, so a container binding needs no cast, and
answers the callback with whatever the test declared.
It is deliberately not lenient: the state round trip and the value begin()
asks callers to keep are enforced exactly as the real drivers enforce them. A
fake that let a forgetful controller pass would teach applications to ship one,
and what passes in the test is a session fixation in production.
Also documents the providers that need no driver at all — anything speaking
OpenID Connect is reached with its issuer alone.
Add Sign in with Apple
Required on iOS as soon as an application offers another social sign-in. Apple
speaks OpenID Connect, so it is the generic driver with three differences —
each of them a place implementations get caught.
There is no client secret: Apple takes a short-lived JWT signed with the .p8,
so clientSecret now also accepts a function and is resolved per token
request. The signature is JOSE-encoded, not DER, which is what usually turns
into an unhelpful invalid_client.
The callback arrives as a POST, because asking for a name forces
response_mode=form_post. And the name is sent exactly once, in that first
consent body — parseAppleUser reads it, and answers undefined for a returning
user rather than treating the normal case as an error.
Apple also sends its booleans as the strings "true" / "false". Read as
booleans, a verified address reports unverified, and an application gating
account linking on that would refuse every Apple sign-in.
mapUser became a protected method so a provider can refine how it spells a
standard claim.
Add generic OpenID Connect
One driver for every provider that conforms — Keycloak, Auth0, Okta, Entra ID,
Authentik, Zitadel, Ping. It is given an issuer and reads the rest from what
the provider publishes about itself, so there is nothing to reconfigure when it
rotates a key.
What separates this from a plain OAuth2 driver is that the answer is a signed
statement about who the user is, not just a token to go asking with. It is
worth nothing unverified, and most of the code exists to check it:
The algorithm is chosen from what the provider DECLARED it signs with, never
read from the token. A verifier that trusts the header accepts alg: none, and
accepts an RS256 public key replayed as an HS256 shared secret. Symmetric
families are refused outright: they sign with the client secret, so every copy
of the config could mint a token for any user.
The key must be the kind the algorithm is defined over, and on the right curve.
Node ignores the EC options when handed an RSA key, so an "ES256" token would
otherwise verify as RSA and report success for a path it never took — the tests
caught that, and it is now refused.
Then every claim binding the token to this exchange: issuer, audience (with
azp when there are several), expiry, an issued-at that is not in the future,
and the nonce — which is what stops a token captured from an earlier sign-in
being replayed into a fresh session. It travels in the secret that begin()
already asks callers to keep.
The discovery document is cached for an hour and the keys until one is missing,
which is how rotation is picked up. That refetch is rate-limited, so tokens
naming invented keys cannot turn an application into a load generator against
its own provider.
Federated sign-in for Ream
Everything that lets a person prove who they are through an authority the
application does not own. Today: OAuth1, OAuth2, and nine providers that speak
them — Discord, Facebook, GitHub, Google, LinkedIn on both of its flows,
Spotify, and X on both of its protocols.
Extracted from @c9up/warden, which owns the people an application already has.
The split was already true in the code, and it is what makes room for OpenID
Connect, SAML and directory lookups without an application that only uses a
JWT guard shipping an XML parser.
begin() is the one path every provider answers: it mints the state, performs
a request-token round trip where the protocol needs one, and hands back the
secret to keep — undefined for plain OAuth2, the PKCE verifier where one is
mandated, the request-token secret for OAuth1. One controller serves all three.
The state check cannot be turned off. A callback that trusts whatever code
arrives is how an attacker links their own provider account to a signed-in
victim's session.