Skip to content

Tokens, signed URLs, and session URLs

The HTTP access token lifecycle and the two temporary-sharing URL schemes. The security semantics are in 10-security; storage is in 13-data-storage.

Tokens and revocation

HTTP access tokens are Branca tokens signed with the Site master token; their meaning at the proxy is in 10-security. The API contract:

  • POST /token/ requires duration (seconds, 1..2147483647) and identifier ([A-Za-z0-9._@-]+); permission is r or rw, compared after lowercasing. The code's default r only applies to a body that fails to parse, and such a request then fails the duration check: every successful creation carries an explicit permission. The response is {token} with the expiry embedded in the Branca payload.
  • POST /token/decode/ checks revocation first (403 for a revoked token), then returns {expiration, identifier, permission, valid} and records a last-use entry asynchronously.
  • DELETE /token/ accepts {token} or a 64-character {hash}; an unknown hash answers 404.
  • GET /token/history/ returns a per-token summary (not an event log), soonest expiry first, with status in active | expiring | expired | revoked; identifier, permission, and include_expired filter the query, while expiring_within only feeds the expiring computation of status and filters nothing. DELETE /token/history/ removes one entry by hash.
  • POST /token/history/backup/ writes a gzipped JSON snapshot beside the database and reports row count and checksum; POST /token/history/restore/ reads that file, replaces all rows, and rebuilds the filter (the read is capped at 64 MiB after decompression). The storage and recovery contract is in 13-data-storage.

Revocation is checked on every token decode: an in-memory Bloom filter (10 000 capacity, 0.1% false-positive rate) seeded from the SQLite history database at startup filters most lookups, and the database gives the authoritative answer (api/token_revocation_bloom.go, api/token_storage_sqlite.go). Only the decode path's last-use write is asynchronous: it goes through a bounded queue of 256 entries (four workers) and overflows are dropped with a warning. Token creation records nothing in the history; revocation writes its history row synchronously inside the request. A background job purges records older than a 7-day grace after expiry; it runs once at startup and then daily.

Deliberate trade-off: when the history database is unavailable, revocation fails closed — a loaded Bloom filter that does not contain the token hash still accepts the token without a database read, but a Bloom hit or a missing filter needs the database and the decode answers 500 — while the history, backup, and revoke endpoints answer 503. The cost is that a token which cannot be confirmed is refused during the outage; a revoked token cannot be used. The refusal window does not persist: the API retries the database in the background while it is unavailable (StartTokenDatabaseRecovery, every 30 s) and rebuilds the revocation filter and the history endpoints when it opens, so a transient start-up failure recovers on its own. A snapshot of the history is written to token-history-backup.json.gz beside the database at startup and once per day (StartTokenHistoryBackup), so POST /token/history/restore/ has a source when the database must be rebuilt. The restore path reinitializes a database that does not open and reloads the snapshot.

Signed and session URLs

  • POST /signed-urls/ takes duration, url, and permission (r/rw), and returns the same URL with the s/signed/r/ or s/signed/rw/ prefix inserted and a base58 expiry signature (query changes do not invalidate the signature). POST /signed-urls/verify/ answers 204 for a valid URL and 403 for a tampered or expired one. The proxy's Lua layer calls verify during the access phase. The proxy reads the permission from the signed path (the r/rw segment) and not from the request URI, because the signature does not cover the query; a read signed URL stays read-only even when the query string contains s/signed/rw/.
  • POST /session-urls/ takes duration, permission, a required identifier, and an optional path scope (default /, .. rejected), and returns a URL carrying the Branca token under s/session/. The scheme follows X-Forwarded-Proto with an https default. Verify answers 403 for a bad token, an expired token, or a request path outside the scope (sibling prefixes are rejected, not just .. escapes); on success it returns the token payload. The proxy calls verify for every session URL request. Verify checks the scope on the path that the proxy acts on, and not on the text of the request URI. It removes the query, decodes the request path once, and resolves the dot segments and repeated slashes (the steps of the nginx $uri). A write request goes to the file API, which decodes the path once more (path handling), so the path must also be inside the scope after that second decode. Thus /A/%2e%2e/B, /A/x%2f..%2fB, and /A/%252e%252e/B are outside the scope /A/. A path with an invalid encoding or a NUL byte answers 400. The bare session URL (.../s/session/{token}, without a path) is a request for the site root, because the proxy forwards it there. It is inside the scope only for a session without a scope. The Hub always adds the scope to the URL it creates, so a scoped session URL is not a bare URL. The scope applies to the request path only: the file API refuses a match pattern with a .. segment, so that a pattern cannot match outside of the path. Verify also checks the resolved physical path (the path with its symlinks resolved), so a symlink inside the scope that points to a file outside it is refused, for a read and for a write.