Authentication and authorization stack for the provin wire profile of the
dPLaaX protocol: libraries plus scaffold generators that produce per-deployment
composition roots of auth.provider and auth.policy-verifier.
See docs/requirements.md for what this repository provides.
Lineage: this repository's history starts at the public cut, not at the start of the work. The code grew up in a private PoC auth stack for dPLaaS, and was carried over when the project moved to the dplaax protocol namespace (
did:dplaax, the DID grant — nowhttps://dplaax.dev/oauth/grant-type/did) and the@provin-linenpm scope. That predecessor was retired rather than published, so there is no upstream repository to link to; the earliest commit here is the snapshot the public line begins from. See CHANGELOG.md for what each release since then contains.
This repository does not operate services. Each dPLaaX deployment generates its own composition roots with the scaffold generators (docs/create-app.md):
| Generator | Generates | Default port |
|---|---|---|
| create-auth-provider | DID-grant-only OAuth provider with did:dplaax resolver |
3000 |
| create-policy-verifier | Scope-based ABAC policy engine | 3001 |
| Package | Description |
|---|---|
| packages/provider-did | @provin-line/auth-provider-did — DID authentication grant for OAuth 2.0 providers |
This repo is a pnpm monorepo:
auth/
├── packages/ # Libraries + scaffold generators (npm)
├── integration/ # Cross-package integration tests (private)
└── instances/ # Generated dev instances (git-ignored; `make instances`)
Use pnpm install at the repo root to bootstrap all workspaces.
make instances # generate dev composition roots into instances/
docker compose uppnpm install # bootstrap the workspace
pnpm -r test # unit + integration tests
make instances # regenerate dev instances from the templates
make smoke # build, typecheck, boot + health-check both instancesClient
-> auth-provider instance (DID auth -> JWT)
-> gRPC service with protobuf.interceptors
-> policy-verifier instance (POST /verify -> allow/deny)
The DID grant (@provin-line/auth-provider-did, composed into the resolver
DplaaxDidResolver from @provin-line/auth-provider-dplaax-module) issues
tokens under a P0 auth contract (dplaax.spec). This section documents
what actually ships today, not the full target contract.
| Contract id | Status | Notes |
|---|---|---|
LEGACY_DID_LOGIN@1 |
Default, active | Relationship-blind (no authentication/assertionMethod check); controller-matched key selection only; capped by legacyMaxTtlSec |
OWNER_AUTHENTICATION_LOGIN@1 |
Built, fail-closed | Grant construction throws — see below |
OWNER_ASSERTION_CONTROL_LOGIN@1 |
Built, fail-closed | Grant construction throws — see below |
OWNER contracts do not work end-to-end today. createDidGrant throws
at construction time (before any request is served) if authContract is
set to either OWNER_* value. The OWNER validation path — a versioned login
transcript (login-transcript-v1), the three-way kid match between the JWS
header kid, the signed transcript's verification_method, and the
resolver-selected method id, and the Fork-Y (authentication) relationship
check — is implemented and unit-tested (validateOwnerLogin in
packages/provider-did/src/transcript.mts), but is not wired into
the request handler (did.mts's handle(), which always runs the LEGACY
flow). Wiring it in and lifting this guard is tracked as follow-up work.
Selecting authContract: OWNER_* is refused at boot rather than silently
minting a token that claims OWNER-level assurance while only LEGACY
validation ran. Use LEGACY_DID_LOGIN@1 until the OWNER path is enforced.
(An OWNER_* authContract also requires ownerMigrationRatified: true
and tokenEndpoint to even pass config-schema validation — but grant
construction refuses OWNER regardless of that gate.)
Every minted token carries these six claims:
| Claim | Value |
|---|---|
auth_contract_id |
The configured authContract (LEGACY_DID_LOGIN@1 only, today) |
verification_method |
The selected verificationMethod's id |
did_document_snapshot |
sha256:<64-hex> — digest of the exact bytes the registry served for the DID Document |
lifecycle_state_ref |
registry:<origin>#<digest> — a stable pointer to that exact resolution snapshot |
lifecycle_freshness_ref |
RFC 3339 UTC instant the resolution was performed |
authorization_scope |
Always AUTHORIZATION_AT_ISSUANCE_WITH_MAX_AGE@1 — the only scope this package ever mints |
lifecycle_state_ref / lifecycle_freshness_ref are a documented P0
projection — the registry snapshot digest plus the retrieval instant —
standing in until a real lifecycle service exists. There is no live/positive
freshness cache behind them.
| Key | Required / Default | Notes |
|---|---|---|
allowedAudiences |
Required, non-empty | An empty or absent allowlist fails closed at construction (no "accept any audience" fallback) |
revocationLatencyBoundSec |
Required, no default | oauth.accessToken.expiresIn must be ≤ this bound, or grant construction throws |
legacyMaxTtlSec |
Default 900 |
For LEGACY_DID_LOGIN@1, expiresIn must also be ≤ this bound |
authContract |
Default LEGACY_DID_LOGIN@1 |
See Contract ids above |
ownerMigrationRatified |
Default false |
Must be true before an OWNER_* authContract even parses; construction still refuses OWNER regardless |
The create-auth-provider scaffold ships secure-by-default: its generated
application.conf sets oauth.accessToken.expiresIn,
oauth.grants.did.revocationLatencyBoundSec, and
oauth.grants.did.legacyMaxTtlSec all to 900 (15 minutes) out of the box.
Audience-absent requests (LEGACY path only, intentional). allowedAudiences
governs the server-side allowlist — it must be configured non-empty
(above). It does NOT force every request to carry an audience claim: on
the LEGACY path, a request that omits audience entirely is accepted and
mints a token with no aud restriction. This is intentional, not an
oversight — the spec's audience-required rule binds the strict OWNER
profile (OWNER_AUTHENTICATION_LOGIN@1 / OWNER_ASSERTION_CONTROL_LOGIN@1),
which is fail-closed and not wired into the request handler yet (see
Contract ids above); LEGACY was never bound by that rule. An empty or absent
allowlist still fails closed regardless — this only concerns a request
that omits the claim.
DplaaxDidResolver's transport (createBoundedFetch) enforces a "resource
floor" every outbound DID resolution request must clear before its response
bytes are trusted:
- A finite timeout, default
5000ms - A response body cap, default 1 MiB (
1_048_576bytes), checked while streaming, never after full buffering - A concurrency limit, default
8, shared across all requests through one resolver instance - Strict JSON decoding — rejects duplicate object keys and trailing data
after the root value (
JSON.parsesilently accepts both); unknown document members, including__proto__, are preserved as an own data property rather than stripped or used to pollute the prototype - Byte-exact id equality — the resolved document's
idmust equal the requested DID exactly, with no normalization - The resolver never follows redirects (
redirect: "error") — the connection that ultimately serves the bytes is always the requested URL itself, never wherever a redirect chain would have sent it (origin-pin)
- Resolver outage (registry unreachable, or reachable but failing
transiently — network error, HTTP 5xx) → HTTP 503
temporarily_unavailable(INDETERMINATE: the DID may still be valid; a client can retry) - Resolver or method-selection rejection (DID not found, malformed
document, id mismatch, method not found, duplicate method id, etc.) → HTTP
400
invalid_grant(FAILED). The same mapping also covers a transcript rejection, for when the OWNER path is wired in — not reachable yet on today's LEGACY-only request flow. - Neither outcome ever mints a token.
- Note: a cryptographic signature-verification failure returns HTTP
401
invalid_grant— this is pre-existing behavior, unchanged by the P0 auth-contract work, and is a separate (tracked, not fixed here) OAuth-conformance question of its own.
- No positive lifecycle cache exists.
lifecycle_state_ref/lifecycle_freshness_refare a snapshot-at-resolution-time projection, not a live freshness service — any spec rule that expects a positive, continuously-refreshed liveness signal is vacuously satisfied (nothing claims fresher than "resolved at this instant"). - Config is fail-closed by construction — the audience allowlist, lifetime bounds, and OWNER contract gate all reject insecure or missing values at boot rather than defaulting open.
- The 503-vs-400 split cleanly separates outage (retryable, INDETERMINATE) from rejection (FAILED); neither path mints a token.
- No degraded mode is configurable — resolution either succeeds within the bounds above, or the request fails.
- Only
AUTHORIZATION_AT_ISSUANCE_WITH_MAX_AGE@1is ever minted. Spec rules that bind a claim to "current authorization as of the request" are satisfied by this package simply never minting that kind of claim at P0 (the spec'sCURRENT_AUTHORIZATION_AT_REQUEST@1scope is not implemented here). - The OWNER contracts' fail-closed posture is covered under Contract ids above.
Cross-repo E2E tests (including auth integration) live in provin-line/e2e,
an internal compose harness (not published).
Apache-2.0. Copyright 2026 1o1 Co. Ltd.