Repository navigation
v0.3.0 — auth baseline provider 0.15 / verifier 0.12; audit and revocation wired; DID tokens bound to their client
A minor number, unlike 0.2.1's patch: this release carries a breaking
configuration change (audit.sink.type = "none" no longer boots) and new
public exports, and it must not reach consumers that track the moving v0.2
image tag. provin.oss's quickstart pins v0.2 and moves to v0.3 on its own
schedule.
The auth baseline is now the latest upstream (provider 0.15.0, verifier
0.12.0). The provider composition records audit events and can revoke tokens —
by RFC 7009 for the client a DID token was issued to, and by subject for every
token already issued to a DID — and deployment.mode = "multi" refuses to boot
on in-process stores. The OWNER login contracts are wired into the DID grant.
Added
-
The provider composition wires every security capability upstream can
read, instead of declaring them absent. Previously a DID-issued token could
not be revoked before it expired, and no audit event was recorded.
buildModulesnow includes:auditSinkModule(new export): the sink named byaudit.sink.type,
"console"by default.token.issuedandtoken.issued.failureevents
now reach stdout.createAuditSinkModule({ registerSinks })(new export)
builds the same module with additional sinks foraudit.sink.typeto
select.- core's
memoryAccessTokenDenylistModule: RFC 7009 revocation of an access
token writes to it, and the provider's verification and introspection
consult it. With the client binding (see Changed), the client a DID token
was issued to can now revoke it. inMemorySubjectRevocationModule(new export): thesubjectRevocation/
subjectSessionIndexpair.subjectRevocation.revokeBefore(did, …)
invalidates every token already issued to that DID.
Both kinds of revocation take effect where the provider verifies a token.
A resource server that verifies JWTs locally against the JWKS, such as the
generated policy-verifier, consults neither store; the token lifetime is
still its bound. The two in-memory stores are single-process and declare
themselves replica-unsafe.
DplaaxBuildModulesOverridesgainsauditSinkModule,
accessTokenDenylistModuleandsubjectRevocationModule, so a
multi-replica deployment can pass shared implementations. -
The OWNER login contracts (
OWNER_AUTHENTICATION_LOGIN@1,
OWNER_ASSERTION_CONTROL_LOGIN@1) are now wired into the DID grant's
request handler.handle()dispatches on the configuredauthContract:
LEGACY_DID_LOGIN@1is unchanged; eitherOWNER_*value now requires and
enforces a versioned login transcript (login-transcript-v1, now eleven
required fields — see Changed, below) — the three-way match between the
JWS headerkid, the transcript'sverification_method, and the
resolver-selected method id (auth.grant.kid-match), explicitly checked
against the crypto-verified method too; the Fork-Y relationship check
(authenticationforOWNER_AUTHENTICATION_LOGIN@1,assertionMethod
forOWNER_ASSERTION_CONTROL_LOGIN@1); and a requiredaudienceclaim.
A minted OWNER token always carriesaudand its actually-enforced
auth_contract_id. Three boot-time asserts oncreateDidGrant, all
mirroring the existingallowedAudiences/revocationLatencyBoundSec
fail-closed style, now gate an OWNERauthContract:
oauth.grants.did.tokenEndpointmust be configured;
ownerMigrationRatifiedmust betrue(ruleauth.migration.enable-gate
— re-asserts whatdidConfigSchemaalready requires at parse time, for a
hand-built config that skips it); and every configured
supportedAlgorithmsentry must be header-bearing (JWS-family —
ed25519_raw/ed25519_prehashsign no JWS protected header at all, so
the three-way kid match is unsatisfiable on them; this includes the
didConfigSchemadefault of["ed25519_raw"]). See
README.md § P0 Auth Contract.
Changed
-
@o3co/auth.utilsis no longer emitted into generatedpackage.json.
Its two helpers live in the scaffold (above). This was the package's last
consumer across the auth family. -
BREAKING (config): the provider composition no longer accepts security
capabilities declared absent.buildModulesnow wires an audit sink, an
access-token denylist and the subject-revocation pair (see Added), so
audit.sink.type = "none"fails boot. That refusal is this composition's
policy: core would accept"none"as a declared absence, but the slot is
always filled here.DplaaxConfigSchemanow takes any sink name instead of
only"none". The generatedapplication.confselects
audit.sink.type = "console"(override:AUDIT_SINK_TYPE) and
oauth.revocation.accessToken = "denylist", and drops
revocation.subject = "unsupported". For instances generated earlier, only
the"none"line breaks boot; the migration is in
upstream compatibility. -
DID-issued tokens are bound to the authenticated client. The DID grant
used to stampazpwith the token's audience and mint noclient_id, so
a token named no owning client. It now setsclient_idandazpto the
client that/tokenauthenticated (RFC 9068 §2.2) — never the request
body'sclient_id— and mints neither when there is none.audstill
carries the audience. No consumer in the provin family readsazp. -
deployment.mode = "multi"is enforced.DplaaxConfigSchemaused to
stripdeployment, so core's replica-safety guard never saw it and an
instance booted multi-replica on in-process stores. The schema now keeps
it,DplaaxAppConfigcarries it, and the template reads it from
DEPLOYMENT_MODE.inMemoryCodeRepositoryModule, andoauthDidModulewhen
it falls back to its in-process nonce store, now declare themselves
replica-unsafe as well, so"multi"refuses to boot until every in-process
store is replaced. -
The auth baseline is the released upstream, not a 0.3.x / 0.5.x pin with a
compatibility shim. Every workspace package now requires
@o3co/auth.policy-verifier.{core,builtins,server}^0.12.0and
@o3co/auth-provider-{core,oauth}^0.15.0— the latest releases — and the
generators emit the same as exact pins (DEFAULT_DEP_VERSIONS: 0.12.0 /
0.15.0), with both generators bumped to 0.2.0 per create-app.md § 3.3.
create-provideralso emitsexpress-session, a peer of
@o3co/auth-provider-oauthsince 0.11.0, as an exact pin. Upstream core
evaluate()is async since verifier 0.10.0; no production code here called
it. The dual-path shim that let the collectors
readpayloadorsubjectand reachreadUntrustedRequestContextby
reflection (collectors/context.mts) is removed: collectors read
context.subjectand callreadUntrustedRequestContextdirectly, and the
policy-verifier template and the integration test import
builtinKeyResolversModulerather than probing for it. The code had already
crossed the intervening upstream BREAKING changes; what changes here is that
the released-0.3.x branch of each dual path is gone.@o3co/ts.hoconstays
at its current pin — its 0.1 → 1.x move is a separate migration. -
Refresh vulnerable transitive lockfile entries: js-yaml 4.3.2, qs 6.16.0,
nanoid 3.3.18 and brace-expansion 5.0.9. CI audits the dependency graph.
vitest and @vitest/coverage-v8 move to^4.1.11(GHSA-82fw-gwwq-j7x9,
path traversal via the @vitest/mocker redirect mock), and both generators
emit vitest 4.1.11. -
Prepare generated instances for current upstream auth: align Zod 4.5.4, wire
separated JWKS/key-resolver modules, update required configuration and support
verified subject bags plus explicitly untrusted request context. -
Generated Verifiers now require an explicit Owner DID rule on the declared
surface instead of relying on empty-rule allow. Scopeless DID tokens skip only
the scope group; undeclared operations and non-Owner subjects remain denied.
See upstream compatibility for migration. -
login-transcript-v1(unreleased) gains an eleventh required field,
did, alongside the existingsubject_did—validateOwnerLoginnow
also checkstranscript.did === transcript.subject_did.didis the
field name every built-in signature verifier's own internal payload-binding
check already reads (parsedMessage.did !== did); the transcript needed
its own copy under that exact name so a real signed request can satisfy
both that check andparseLoginTranscript's field requirements from a
single signed payload — previously, no signed payload could actually be
simultaneously valid for both, so the wired-in Added item above shipped
correct only against test fixtures that added the field without it being
part of the documented schema.
Fixed
-
Generated instances never logged NDJSON. Both scaffolds took their logger
from@o3co/auth.utils, which treats pino as an optional peer and falls
back toconsolewhen the import fails — and the generator never emitted
pino, so every instance created bycreate-provideror
create-policy-verifierlogged bare[name] …console lines that no
aggregator parses. The scaffold now ships its ownsrc/logger.mtson pino
(a direct runtime dependency, exact-pinned like the rest), honouring
logging.levelfrom the application config withLOG_LEVELas the
environment override, and serialisingerrso an Error keeps its stack. -
Generated instances could hang on SIGTERM and always exited zero. The
same package'sgracefulShutdowncalledserver.close()with no deadline,
so one stuck request meant the process never exited on its own and the
orchestrator's SIGKILL cut it down mid-flight; cleanup failures went to
console.error. The scaffold now shipssrc/shutdown.mtswith the contract
auth.provider (#290), auth.proxy (#81) and auth.policy-verifier (#210) each
adopted: drain fordrainTimeoutMs(default 10s), boundcleanupby
cleanupTimeoutMs, force-close past the deadline and exit non-zero, log
through the instance logger, and yield the loop once before exiting so the
last lines flush. Both files come with tests that run under the instance's
ownpnpm run test.
Security
- Removed the OWNER contract fail-closed construction-time stopgap
introduced in[0.2.1]:createDidGrantno longer refuses to construct a
grant for anOWNER_*authContract, now that the OWNER validation path
is enforced in the request handler (see Added, above). A DID Document
with more than one controller-matchedverificationMethodis still
rejected on the OWNER path (MethodSelectionError
"ambiguous-legacy-selection", inherited from the shared crypto-key
selection step) — genuine multi-key-per-DID OWNER selection remains
unsupported and is tracked as follow-up work. ed25519Raw.mts/ed25519Prehash.mtsnow strip anyheaderKidmember a
signed payload tries to smuggle in, rather than let it flow through to
parsedMessage.headerKidunfiltered. Neither raw-signature format has a
JWS protected header —headerKidmust only ever come from a real one
(jws.mts) — so an unfiltered pass-through would have let a signed
top-levelheaderKidpayload member satisfy the OWNER path's three-way
kid match without any protected header at all, including under the
supportedAlgorithmsdefault. Closed at both layers: the verifiers no
longer trust the payload's ownheaderKid, andcreateDidGrantnow
additionally refuses at boot to configure a non-header-bearing algorithm
for an OWNERauthContractin the first place (see Added, above).