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 |
Active, wired | Fork-Y — signing key must be string-referenced in the DID Document's authentication array |
OWNER_ASSERTION_CONTROL_LOGIN@1 |
Active, wired | Signing key must be string-referenced in the DID Document's assertionMethod array |
OWNER contracts are wired end-to-end. handle() dispatches on the
configured authContract: LEGACY_DID_LOGIN@1 runs the pre-existing
relationship-blind flow unchanged; either OWNER_* value runs the OWNER
validation path (validateOwnerLogin in
packages/provider-did/src/transcript.mts) against the request:
- Versioned login transcript. The signed request payload is parsed as
a
login-transcript-v1transcript (parseLoginTranscript) — all eleven fields required, non-empty, anddomain_separation_tagpinned todplaax-owner-login-v1so a transcript signed for a different purpose (e.g. a future delegation flow) cannot be replayed here. A request whose signed payload is not a valid transcript — including the pre-existing LEGACY message shape — is rejected. The transcript carries bothdid(the field name every built-in signature verifier's own internal binding check reads off the signed payload) andsubject_did(this transcript's own name for the authenticated subject); the two must agree, or the request is rejected. - Exact method-id selection + relationship check. The transcript's
self-declared
verification_methodis looked up viaselectVerificationMethod(doc, { did, methodId, relationship }), whererelationshipisauthenticationforOWNER_AUTHENTICATION_LOGIN@1andassertionMethodforOWNER_ASSERTION_CONTROL_LOGIN@1(Fork-Y). A method that exists but isn't string-referenced in the required relationship array is rejected — an embedded/inline method object never satisfies this (ruleauth.forky.authentication-login). This methodId-based selection must also agree with the DID document's controller-matched key that actually produced the crypto verification (checked explicitly, not left as an unchecked coincidence) — today the two are structurally guaranteed to name the same method, since the crypto-verification step (shared with LEGACY) rejects any DID Document with more than one controller-matchedverificationMethodbefore this check is ever reached; see the multi-key limitation below. - Three-way kid match. The JWS protected header's
kid, the transcript's ownverification_methodfield, and the resolver-selected method id must all agree, or the request is rejected (ruleauth.grant.kid-match). This requires a real JWS protected header:ed25519_raw/ed25519_prehashsign a bare JSON message with no header at all, socreateDidGrantrefuses to construct an OWNER grant configured with either of them — see Config keys below. - Audience required.
audienceis one of the transcript's eleven required fields, so an OWNER request that omits it is rejected before any other transcript check runs — unlike LEGACY (see "Audience-absent requests" below). A minted OWNER token always carriesaud. - Issuer / token-endpoint binding. The transcript's
issuerandtoken_endpointfields must equal the request's issuer and this grant's configuredtokenEndpoint.
A DID Document with more than one verificationMethod whose controller
matches the authenticating DID is not yet supported on the OWNER path: the
crypto-verification key selection step (shared with LEGACY) rejects it as
ambiguous (MethodSelectionError "ambiguous-legacy-selection") before the
OWNER-specific relationship check ever runs. Genuine multi-key-per-DID
OWNER selection is tracked as follow-up work.
(An OWNER_* authContract also requires ownerMigrationRatified: true,
tokenEndpoint, and an all-JWS-family supportedAlgorithms — each
enforced both at the config-schema level and as a createDidGrant
boot-time assert; a hand-built config that selects an OWNER contract
without any of them fails closed at construction, mirroring the
allowedAudiences / revocationLatencyBoundSec asserts below.)
Every minted token carries these six claims:
| Claim | Value |
|---|---|
auth_contract_id |
The configured authContract (LEGACY_DID_LOGIN@1 or either OWNER_* contract) |
verification_method |
The selected verificationMethod's id — the OWNER-certified method on the OWNER path, the controller-matched method on LEGACY |
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 (rule auth.migration.enable-gate); also re-asserted at createDidGrant boot time |
tokenEndpoint |
Required when authContract is OWNER_* |
Checked against the transcript's token_endpoint field; required both at config-schema parse time and as a createDidGrant boot-time assert |
supportedAlgorithms |
Default ["ed25519_raw"] |
When authContract is OWNER_*, every entry must be header-bearing (JWS-family: ed25519_jws / es256_jws / es256k_jws) — createDidGrant refuses to construct otherwise, including under the default |
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,
see Contract ids above), which fails closed on a missing audience; LEGACY
was never bound by that rule. An empty or absent allowlist still fails
closed regardless, on both paths — 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 an OWNER-path transcript rejection (malformed transcript, three-way kid mismatch, relationship violation, audience/issuer/token_endpoint mismatch). - 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's
tokenEndpointgate 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). - OWNER-path multi-key documents are not yet supported. See Contract ids
above — a DID Document with more than one controller-matched
verificationMethodis rejected before the OWNER-specific relationship check runs, tracked as follow-up work.
Cross-repo E2E scenarios live in provin-line/e2e
(public). They run every node against cmd/pdpstub (allow-all) with a fixed
harness bearer, so they cover the node's credential gate and wireauth-signed
peer calls — never JWT issuance or a policy-decision deny. The real
three-layer auth stack (auth.provider + o3co policy-verifier) is exercised by
provin-line/oss's deploy/quickstart, which is today's only working
verification path for auth integration.
Apache-2.0. Copyright 2026 1o1 Co. Ltd.