OAuth storage stdlib
std/oauth/storage is the token-store abstraction shared by the OAuth
client (OAuth.client(...), RFC 6749 + 7636, RFC 8628 device flow). One
storage handle backs every grant type, every refresh, and every revoke
the client performs. A handle is just a dict with storage closures —
get, set, delete, and with_refresh_lock — so the client never
needs to know whether a token lives in process memory, on disk, in a cloud
platform, or in a vault.
The five backends are:
| Constructor | Backing | Persists past harn run? | Best for |
|---|---|---|---|
memory() | per-VM BTreeMap | no | short-lived agents, ephemeral scripts |
file(path, encryption_key) | one AES-256-GCM-sealed file | yes | local development, single-host operators |
harn_cloud_session() | host capability, per-session | yes (cloud) | a single user's cloud-managed agent |
harn_cloud_org() | host capability, org-scoped | yes (cloud) | shared org credentials ("the org's GitHub bot") |
custom(handlers) | caller-supplied closures | depends | vaults, KMS-backed key/value stores |
The shape of a stored entry is a TokenSet:
type TokenSet = {
access_token: string,
refresh_token?: string,
expires_at_unix?: int,
token_type?: string,
scopes?: list<string>,
metadata?: dict,
}
Calling the API
import { memory } from "std/oauth/storage"
pipeline default() {
const store = memory()
store.set(
"github",
{access_token: "abc", refresh_token: "rfr", expires_at_unix: 17000000000},
3600,
)
const token = store.get("github") // -> TokenSet | nil
if token != nil {
log("auth: Bearer " + token.access_token)
}
store.delete("github")
}
The OAuth client takes a storage option that accepts any of these
handles unchanged:
import { Providers } from "std/oauth/providers"
import { harn_cloud_org } from "std/oauth/storage"
const client = OAuth.client(Providers.github, {
client_id: env("GITHUB_OAUTH_CLIENT_ID"),
scopes: ["repo"],
storage: harn_cloud_org(),
})
storage() returns the namespace dict matching the OAuth epic's
OAuth.Storage.* shape, with memory / harn_cloud_* as ready
handles and file / custom as factory closures.
File backend
file(path, encryption_key) writes a single envelope to path:
{ "version": 1, "nonce": "<base64>", "ciphertext": "<base64>" }
The 32-byte AES-256-GCM key is derived from encryption_key via
HKDF-SHA256 (info = "harn-oauth-storage-v1"). Pass high-entropy bytes
or a string sourced from a KMS or crypto.random_bytes(32) — not a
user passphrase. A fresh random nonce is sampled on every write, and
the file is renamed into place from a sibling .tmp so partial writes
never corrupt existing tokens.
Decryption with the wrong key raises an error rather than returning a
plausible-looking but wrong TokenSet. Deleting the final entry removes
the file entirely so an ls on the storage directory reflects the
absence of tokens.
Cloud backends
harn_cloud_session() and harn_cloud_org() route every call through
the oauth_storage host capability:
| Operation | Params | Returns |
|---|---|---|
oauth_storage.cloud_get | {scope, key} | TokenSet or nil |
oauth_storage.cloud_set | {scope, key, token, ttl_seconds?} | nil |
oauth_storage.cloud_delete | {scope, key} | nil |
oauth_storage.cloud_acquire_refresh_lock | {scope, key, timeout_seconds} | lock lease |
oauth_storage.cloud_release_refresh_lock | {scope, key, lock} | nil |
scope is "session" for harn_cloud_session() and "org" for
harn_cloud_org(). The cloud-platform embedder is responsible for
tenant-scoped storage (RLS), refresh metadata, and a backend-native lock
lease so rotating refresh tokens are single-flight across every worker
sharing the same cloud storage key.
Tests can substitute the host with host_mock("oauth_storage", "cloud_get", {result: ...}) and the matching refresh-lock operations.
With no embedder and no mock, the missing oauth_storage.cloud_*
operation raises a deterministic "not configured" signal.
Custom backend hook protocol
custom(handlers) lets callers plug in a vault, KMS, or
platform-native keychain. handlers is a dict with these keys:
import { custom } from "std/oauth/storage"
fn vault_get(_path) {
return nil
}
fn vault_put(_path, _token_set, _options) {
return nil
}
fn vault_delete(_path) {
return nil
}
fn vault_with_lock(_path, body) {
return body()
}
const store = custom({
get: { key -> vault_get("oauth/" + key) },
set: { key, token_set, ttl_seconds = nil ->
vault_put("oauth/" + key, token_set, {ttl: ttl_seconds})
},
delete: { key -> vault_delete("oauth/" + key) },
with_refresh_lock: { key, body -> vault_with_lock("oauth/" + key, body) },
id: "my-vault", // optional, surfaces in diagnostics
})
if store.id != "my-vault" {
throw "custom backend id mismatch"
}
Contract for each closure:
| Closure | Signature | Returns |
|---|---|---|
get | fn(key: string) -> TokenSet | nil | The stored TokenSet, or nil if absent. Must not raise for a missing key. |
set | fn(key: string, token_set: TokenSet, ttl_seconds: int | nil) -> nil | Persists the TokenSet. ttl_seconds is a hint; backends may ignore it. |
delete | fn(key: string) -> nil | Idempotent removal. Must not raise for a missing key. |
with_refresh_lock | fn(key: string, body: fn() -> any) -> any | Optional. Runs body() while holding a backend-native lock for key. |
custom validates that required closures are functions before returning
the handle. When with_refresh_lock is omitted, Harn supplies an
in-process lock for the custom handle id. Durable backends should provide
their own hook so refresh-token rotation is single-flight across every
worker that shares the backend. The optional id field defaults to
"custom" and is exposed as store.id so diagnostics can distinguish
multiple custom backends.
Closure capture is by value in Harn, so the closures cannot mutate outer scope to track state. Delegate to a real backend (a cloud platform, the file backend, an HTTP API, an MCP tool) inside the closures instead.
Acceptance criteria mapping
This module satisfies the OA-03 acceptance criteria from issue #1904:
- Five backends implemented (
memory,file,harn_cloud_session,harn_cloud_org,custom). - File backend encrypts at rest with AES-256-GCM.
- Cloud backends route through
oauth_storage.cloud_*host capabilities so embedders enforce RLS / tenant isolation and refresh-token single-flight. - Custom backend hook protocol documented above.
- Conformance per backend (store / retrieve / refresh / delete) lives
in
conformance/tests/stdlib/oauth/oauth_storage.harn.