Skip to content

Capability URIs: scoped, time-bound, shareable access tokens #506

Description

@melvincarvalho

Problem

WAC (src/wac/) authorizes by WebID: to grant access, the resource owner needs to know the recipient's identity and write it into the ACL. This works poorly for:

  1. Inbox / drop-box sharing — Alice wants a one-shot URL she can hand to a stranger that lets them write one file into her inbox, no WebID required.
  2. Time-bound delegated reads — Alice wants to share a private resource with a colleague for 24 hours without provisioning them in her ACL.
  3. Account recovery handoff — pair a capability URI with an OTP (see sibling issue) so the recovery link is the capability and the OTP proves channel control.

What's missing is a way to grant access by URI possession rather than by WebID. This is the capability-URL pattern (RFC-spirit: a URL is a token if it's unguessable; here we go further and make it cryptographically scoped).

Proposed design

A capability URI is a normal HTTPS URL with an opaque token segment that carries a signed claim. The server verifies the signature, checks scope/expiry/use-count, and treats the request as authorized for exactly the scope the token grants.

URI shape

https://pod.example.com/{resource}?cap=v1.{base64url(JSON)}.{base64url(sig)}

Or as a path-style token for shareability:

https://pod.example.com/cap/{base64url(JSON)}.{base64url(sig)}

The token payload (JSON):

{
  "v": 1,
  "iss": "https://alice.example.com/profile/card.jsonld#me",
  "res": "https://alice.example.com/private/report.pdf",
  "scope": ["GET"],
  "exp": 1700000000,
  "nbf": 1699990000,
  "uses": 1,
  "jti": "random-32-bytes",
  "constraints": {
    "max_bytes": 1048576,
    "content_type": ["application/pdf"]
  }
}

Signed with the issuer's Nostr key (same key the WebID's authentication set already declares, see src/auth/nostr-keys.js).

Verification path

Plugs in alongside the existing auth middleware (src/auth/middleware.js):

  1. Detect ?cap= query param or /cap/ path segment.
  2. Parse payload, verify signature against iss's declared keys.
  3. Check nbf <= now < exp, requested method in scope, request URL matches res (exact or container-prefix).
  4. Atomically decrement uses in a server-side cap-token ledger (single-use enforced even under concurrent reads).
  5. Check constraints against the actual request (body size, content type).
  6. If all pass: synthesize a virtual agent for the duration of this request with exactly the scope's permissions. WAC check is bypassed — the capability is the authorization.

Storage

A cap-token ledger per pod (/.cap/ledger.json or similar) tracks jti -> uses_remaining, revoked_at. Tokens that have exhausted uses or been revoked stay in the ledger until exp for replay protection.

Issuance API

POST /.well-known/cap/issue
Authorization: <pod owner's normal auth>
{ "res": "...", "scope": ["GET"], "ttl": 86400, "uses": 1, "constraints": {...} }
→ { "uri": "https://.../cap/...", "jti": "..." }

Revocation

DELETE /.well-known/cap/{jti}
Authorization: <pod owner's normal auth>

Use cases

  • Inbox / drop-box: Alice POSTs { res: "/inbox/", scope: ["POST"], uses: 1, constraints: { max_bytes: 5MB } } → hands the resulting URI to a stranger → they upload one file → cap auto-expires.
  • Time-bound delegated read: { res: "/private/q3-report.pdf", scope: ["GET"], ttl: 86400, uses: 10 } → share via email; expires after 24h or 10 fetches.
  • Account recovery: paired with sibling OTP issue (One-time passwords (OTP) for low-friction auth + account recovery #505) — cap URI lets you reach the recovery endpoint; OTP proves channel control before mint.

Security

  • Unguessable: payload includes 32-byte jti, plus the whole token is signature-bound — forging is as hard as forging the issuer's signature.
  • Scope minimality: token must declare exact resource, method set, byte ceiling, content type. No wildcards in v1.
  • Single use by default: uses: 1 unless explicitly bumped, atomic decrement on the ledger.
  • No bearer leakage in logs: server must strip ?cap= from access logs; recommend path-form tokens with a documented log-scrubbing config snippet.
  • Referer leak: warn callers that path-form tokens can be leaked via Referer if the cap'd resource embeds external links; recommend Referrer-Policy: no-referrer on cap responses.
  • Revocation list: ledger keeps revoked jtis until original exp to prevent reuse via cached copies.
  • Issuer-key rotation: if the issuer rotates their key, all outstanding caps signed by the old key are invalidated. Document this.
  • No subdomain confusion: res must be an absolute URL and the server compares full origin, not just path — defends against the path-mode/subdomain-mode mismatch issues we've already hit (In subdomain mode, files at the base-domain root are unreachable (401) #307 lineage).

Out of scope (follow-ups)

  • Macaroons-style attenuated delegation (recipient further restricts a cap).
  • Group-scoped caps (anyone in {webid set} can use).
  • Cap chaining (B issues a sub-cap from A's cap).
  • UI in mashlib for managing/revoking caps.

Open questions

  • Path-form vs query-form? Path is more share-friendly but harder to strip from logs; query is easier to strip but uglier.
  • Should the cap require the recipient to authenticate (e.g. via OTP) as a second factor for write capabilities, or is possession alone sufficient?
  • Where in the pod tree does the cap ledger live? Needs to be writable by the cap-verification layer but not exposed via normal LDP read.
  • Interaction with WAC: when a cap grants access to a container, does it cascade to children by default, or must each child have its own cap? (Lean: no cascade in v1; explicit container-cap with a separate flag in v2.)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    pluginCould be implemented as a plugin (#206); core/plugin line defined in #564

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions