Skip to content

v0.3.0 — auth baseline provider 0.15 / verifier 0.12; audit and revocation wired; DID tokens bound to their client

Choose a tag to compare

@y1o1 y1o1 released this 23 Sep 15:21
· 26 commits to main since this release
d7e9324

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.
    buildModules now includes:

    • auditSinkModule (new export): the sink named by audit.sink.type,
      "console" by default. token.issued and token.issued.failure events
      now reach stdout. createAuditSinkModule({ registerSinks }) (new export)
      builds the same module with additional sinks for audit.sink.type to
      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): the subjectRevocation /
      subjectSessionIndex pair. 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.
    DplaaxBuildModulesOverrides gains auditSinkModule,
    accessTokenDenylistModule and subjectRevocationModule, 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 configured authContract:
    LEGACY_DID_LOGIN@1 is unchanged; either OWNER_* 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 header kid, the transcript's verification_method, and the
    resolver-selected method id (auth.grant.kid-match), explicitly checked
    against the crypto-verified method too; the Fork-Y relationship check
    (authentication for OWNER_AUTHENTICATION_LOGIN@1, assertionMethod
    for OWNER_ASSERTION_CONTROL_LOGIN@1); and a required audience claim.
    A minted OWNER token always carries aud and its actually-enforced
    auth_contract_id. Three boot-time asserts on createDidGrant, all
    mirroring the existing allowedAudiences / revocationLatencyBoundSec
    fail-closed style, now gate an OWNER authContract:
    oauth.grants.did.tokenEndpoint must be configured;
    ownerMigrationRatified must be true (rule auth.migration.enable-gate
    — re-asserts what didConfigSchema already requires at parse time, for a
    hand-built config that skips it); and every configured
    supportedAlgorithms entry must be header-bearing (JWS-family —
    ed25519_raw / ed25519_prehash sign no JWS protected header at all, so
    the three-way kid match is unsatisfiable on them; this includes the
    didConfigSchema default of ["ed25519_raw"]). See
    README.md § P0 Auth Contract.

Changed

  • @o3co/auth.utils is no longer emitted into generated package.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.
    buildModules now 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. DplaaxConfigSchema now takes any sink name instead of
    only "none". The generated application.conf selects
    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 stamp azp with the token's audience and mint no client_id, so
    a token named no owning client. It now sets client_id and azp to the
    client that /token authenticated (RFC 9068 §2.2) — never the request
    body's client_id — and mints neither when there is none. aud still
    carries the audience. No consumer in the provin family reads azp.

  • deployment.mode = "multi" is enforced. DplaaxConfigSchema used to
    strip deployment, so core's replica-safety guard never saw it and an
    instance booted multi-replica on in-process stores. The schema now keeps
    it, DplaaxAppConfig carries it, and the template reads it from
    DEPLOYMENT_MODE. inMemoryCodeRepositoryModule, and oauthDidModule when
    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.0 and
    @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-provider also emits express-session, a peer of
    @o3co/auth-provider-oauth since 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
    read payload or subject and reach readUntrustedRequestContext by
    reflection (collectors/context.mts) is removed: collectors read
    context.subject and call readUntrustedRequestContext directly, and the
    policy-verifier template and the integration test import
    builtinKeyResolversModule rather 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.hocon stays
    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 existing subject_did — validateOwnerLogin now
    also checks transcript.did === transcript.subject_did. did is 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 and parseLoginTranscript'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 to console when the import fails — and the generator never emitted
    pino, so every instance created by create-provider or
    create-policy-verifier logged bare [name] … console lines that no
    aggregator parses. The scaffold now ships its own src/logger.mts on pino
    (a direct runtime dependency, exact-pinned like the rest), honouring
    logging.level from the application config with LOG_LEVEL as the
    environment override, and serialising err so an Error keeps its stack.

  • Generated instances could hang on SIGTERM and always exited zero. The
    same package's gracefulShutdown called server.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 ships src/shutdown.mts with the contract
    auth.provider (#290), auth.proxy (#81) and auth.policy-verifier (#210) each
    adopted: drain for drainTimeoutMs (default 10s), bound cleanup by
    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
    own pnpm run test.

Security

  • Removed the OWNER contract fail-closed construction-time stopgap
    introduced in [0.2.1]: createDidGrant no longer refuses to construct a
    grant for an OWNER_* authContract, now that the OWNER validation path
    is enforced in the request handler (see Added, above). A DID Document
    with more than one controller-matched verificationMethod is 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.mts now strip any headerKid member a
    signed payload tries to smuggle in, rather than let it flow through to
    parsedMessage.headerKid unfiltered. Neither raw-signature format has a
    JWS protected header — headerKid must only ever come from a real one
    (jws.mts) — so an unfiltered pass-through would have let a signed
    top-level headerKid payload member satisfy the OWNER path's three-way
    kid match without any protected header at all, including under the
    supportedAlgorithms default. Closed at both layers: the verifiers no
    longer trust the payload's own headerKid, and createDidGrant now
    additionally refuses at boot to configure a non-header-bearing algorithm
    for an OWNER authContract in the first place (see Added, above).