Skip to content

App Token Format

The relay is an OIDC resource server. It does not mint tokens. It verifies tokens issued by whatever external issuer it has been pointed at.

Operators choose an OIDC issuer — Auth0, Cognito, Keycloak, dex, Authelia, ZITADEL, or a hosted Supabase-style backend — and point the relay at its JWKS URL. The relay does the rest.

Configuration

relay.toml [auth] section. Requiredrelay serve refuses to start until issuer, jwks_url, and audience are set. There is no default signing secret; the relay never mints tokens.

toml
[auth]
issuer = "https://auth.example.com"
jwks_url = "https://auth.example.com/.well-known/jwks.json"
audience = "docushark-relay"
# Optional, RFC 8707. This pod's resource URI — the value advertised by
# /.well-known/oauth-protected-resource (typically "{origin}/mcp").
resource = "https://relay.example.com/mcp"
  • issuer is checked against the JWT iss claim.
  • jwks_url is fetched on startup, cached in memory, refreshed in the background. See JWKS caching + failure mode below.
  • audience is checked against the JWT aud claim.
  • resource (optional, RFC 8707) — when set, a token is accepted if its aud matches either audience or resource. This lets an issuer resource-bind a token to a specific pod (minting aud = [resource, audience]) while remaining accepted by relays that only know the shared audience. Unset means only audience is accepted. Env override: RELAY_JWT_RESOURCE.

Signing algorithm

RS256. The JWKS endpoint must publish RSA public keys.

Other algorithms are not accepted. In particular:

  • HS256 is rejected (no shared secret model in the modern surface).
  • none is rejected (obviously).
  • ES256 / EdDSA are not currently accepted; if you need them, open an issue with the use case.

Claim shape

json
{
  "iss": "https://auth.example.com",
  "sub": "user-or-account-id",
  "aud": "docushark-relay",
  "wsp": [
    { "id": "ws_01H...", "role": "owner",  "region": "yyz" },
    { "id": "ws_02H...", "role": "member", "region": "yyz" }
  ],
  "iat": 1716240000,
  "exp": 1718832000,
  "jti": "tok_01H..."
}

Required claims

ClaimMeaning
issMust equal the configured issuer.
subOpaque user identifier from the issuer. The relay stores this on documents the user creates.
audMust match the configured audience (default docushark-relay) or, if set, the configured resource (RFC 8707). May be a single string or an array; an array is accepted if any element matches.
iat, expStandard issued-at + expiry. Tokens past exp are rejected.
jtiUnique token identifier. Used for revocation lookups (see Token Revocation).

Workspace claim (wsp)

wsp is an array of workspace memberships. Each entry has:

  • id — workspace identifier the user belongs to.
  • role — one of owner, member, viewer. Relay enforces role on document operations.
  • region — region code the workspace is bound to (e.g. yyz, ord, nrt, fra). The relay refuses connections whose region does not match the relay's configured region.

Each entry may also carry optional per-workspace limit fields. When present they are authoritative; when absent the relay falls back to its [tenancy.limits] config (see Per-workspace limits). The relay enforces the raw numbers — it has no notion of plans or tiers.

  • quota_bytes — integer storage-byte quota for the workspace. A growing blob upload / document save past this returns HTTP 507.
  • editor_limit — integer cap on concurrent editor (role owner/member) connections. Viewers are never counted against it.
  • max_doc_bytes — integer ceiling on a single document's serialized JSON. A save over it returns HTTP 413 (DOC_TOO_LARGE) over REST, ERR_DOC_TOO_LARGE over MCP.
json
{ "id": "ws_01H...", "role": "owner", "region": "yyz", "quota_bytes": 262144000, "editor_limit": 2, "max_doc_bytes": 10485760 }

Single-workspace deployments may still ship a one-entry wsp array — the shape is fixed.

Optional claims

  • org_id — string identifier for an organization (parent of multiple workspaces). Optional; useful for organizations migrating from the legacy auth surface.
  • Issuer-specific claims (e.g. email, name) are tolerated but ignored.

Validation order

For each incoming request bearing a token, the relay:

  1. Parses the JWT (rejects malformed tokens with 400).
  2. Looks up the signing key in the JWKS cache by kid. Refuses unknown kid (401).
  3. Verifies the RS256 signature.
  4. Checks iss and aud against configured values.
  5. Checks exp is in the future and iat is not absurdly in the future (60s skew tolerance).
  6. Checks jti is not in the revocation set (see Token Revocation).
  7. For workspace-scoped operations: matches the requested workspace against wsp[].id and applies role.
  8. For region-scoped connections (WebSocket): matches the relay's configured region against wsp[].region for the requested workspace.

A failure at any step results in a 401 (or 403 if the workspace/region check fails).

JWKS caching + failure mode

  • In-memory cache, 5-minute TTL.
  • Background refresh before TTL expiry; never blocks a request.
  • Fail-open with last known good key for a 1-hour grace window if the JWKS endpoint is unreachable. This prevents an issuer outage from knocking the relay offline.

After the 1-hour grace expires, all token verifications fail closed.

Tenancy modes

The relay has two operating modes selected in relay.toml [tenancy]:

toml
[tenancy]
# "shared" | "dedicated" — default "dedicated".
mode = "dedicated"
# Required when mode = "dedicated" and you need to pin to a
# specific workspace id. Blank in the default `relay init` template
# (which pins to the legacy "default" workspace).
workspace_id = ""

[tenancy.limits]
writes_per_sec = 40
writes_burst = 80
max_ws_connections_per_workspace = 25
max_ws_payload_bytes = 262144
  • mode = "shared" — multi-tenant. Routes by the JWT workspace claim. Used by hosted deployments serving many workspaces from one process.
  • mode = "dedicated" — single-tenant. The relay refuses any token whose workspace claim does not match the configured workspace_id (or the legacy "default" value when workspace_id is blank). Mismatches return HTTP 403 with body "forbidden" — no leak of the expected workspace.

relay init defaults to dedicated with the workspace id blank — safe-by-default for self-hosters.

--tenancy=shared|dedicated and --tenancy-workspace=<id> on relay serve override the [tenancy] block.

Per-workspace limits

The [tenancy.limits] block configures per-workspace traffic limits, enforced for every authenticated connection:

FieldMeaning
writes_per_secToken-bucket refill rate. Applies to WS CRDT sync frames and MCP write tools. Over-quota WS frames are silently dropped with an ERR_RATE_LIMIT reply; over-quota MCP calls return HTTP 429 with Retry-After.
writes_burstToken-bucket capacity. Short spikes up to this size pass through without throttling.
max_ws_connections_per_workspaceAuthenticated WS connection cap per workspace (editors + viewers — the total-connection safety ceiling that also guards pure-viewer flooding). The Nth + 1 connect fails with ERR_WORKSPACE_CONNECTION_LIMIT on the AUTH_RESPONSE.
max_ws_payload_bytesPer-frame payload size cap. Pathologically large WS frames are rejected before dispatch; the connection is dropped.
storage_quota_bytesPer-workspace storage-byte quota. 0 = unlimited. Fallback for the JWT quota_bytes claim.
max_editors_per_workspacePer-workspace concurrent-editor cap. 0 = unlimited. Fallback for the JWT editor_limit claim.
max_doc_bytesPer-document serialized-JSON size ceiling. 0 = no ceiling. Fallback for the JWT max_doc_bytes claim. Also raises the doc-route HTTP body limit (128 MiB floor + 25% slack over the configured cap), so keep pod config ≥ the largest claim you mint.

Defaults match the project's free-tier reference values; self-hosters can override any field. storage_quota_bytes and max_editors_per_workspace default to 0 (unlimited) so a self-host deploy is unconstrained out of the box.

Effective limit resolution

For quota_bytes / editor_limit / max_doc_bytes, the effective limit is the JWT claim value if present, else the config fallback. A resolved 0 (from either source) means unlimited. This lets a control plane mint absolute per-workspace numbers in the token while self-hosters rely on [tenancy.limits].

Storage enforcement (507)

Storage is a level read live from server state — no persisted counter. The metered total is blob bytes (full-size-per-grant attribution) + document JSON bytes (each document's serialized size, recorded at write time; binary sidecars and recovery points are operational copies and are never metered). A blob upload (POST /api/blobs/:hash) or document save (PUT /api/docs/:id) returns HTTP 507 Insufficient Storage when the projected workspace total would exceed the effective quota_bytes.

The document gate is delta-aware: only a save that grows the document and lands over quota is refused — shrinking or equal-size saves and deletes always succeed, so a caller can always dig out of an over-quota state by removing content. A re-upload of an already-stored blob hash adds 0 (dedup) and is never refused. Collaborative (CRDT) flushes are never refused — the edits are already merged; an over-ceiling flush is only logged and counted (relay_doc_over_cap_snapshots_total). Existing data stays readable when over quota (GET is unaffected) — only new writes are refused.

Editor cap (ERR_EDITOR_LIMIT)

When a workspace has an effective editor_limit, the Nth + 1 editor (role owner/member) WS connection is refused at auth with ERR_EDITOR_LIMIT on the AUTH_RESPONSE. Viewers (role viewer) are never refused on this axis. The total-connection ceiling above still applies to everyone.

Usage endpoint

GET /api/v1/usage returns the calling token's own workspace usage and effective limits (JSON, camelCase). The workspace is resolved from the validated JWT exactly like /api/docs, so a caller can never read another workspace's numbers. null quota/limit means unlimited.

json
{ "storageBytes": 12345678, "docBytes": 345678, "blobBytes": 12000000, "storageQuota": 262144000, "maxDocBytes": 10485760, "activeEditors": 1, "editorLimit": 2 }

storageBytes is the combined meter (docBytes + blobBytes); the halves ride alongside so clients can render a split. maxDocBytes is the effective per-document ceiling. The response carries counts only — no document ids and no content.

Token lifetime guidance

  • App tokens issued by an external control plane should have a TTL of 30 days or less. The relay does not impose its own ceiling, but the revocation window grows with token TTL.
  • Refresh-token flow is the issuer's responsibility — the relay never sees refresh tokens.

No legacy / password auth

The relay does not mint tokens, store passwords, or expose /api/auth/login-style endpoints. Self-hosters point [auth].issuer / [auth].jwks_url at any OIDC provider (Keycloak, dex, Authelia, ZITADEL, Supabase, or a hosted control plane) — see Self-Hosting.

Released under the AGPL-3.0 License.