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 three closures — get, set, delete — so the client never needs to know whether a token lives in process memory, on disk, in harn-cloud, or in a vault.

The five backends are:

ConstructorBackingPersists past harn run?Best for
memory()per-VM BTreeMapnoshort-lived agents, ephemeral scripts
file(path, encryption_key)one AES-256-GCM-sealed fileyeslocal development, single-host operators
harn_cloud_session()host capability, per-sessionyes (cloud)a single user's cloud-managed agent
harn_cloud_org()host capability, org-scopedyes (cloud)shared org credentials ("the org's GitHub bot")
custom(handlers)caller-supplied closuresdependsvaults, 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() {
  let store = memory()
  store.set(
    "github",
    {access_token: "abc", refresh_token: "rfr", expires_at_unix: 17000000000},
    3600,
  )
  let 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"

let 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:

OperationParamsReturns
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

scope is "session" for harn_cloud_session() and "org" for harn_cloud_org(). The harn-cloud embedder is responsible for tenant-scoped storage (RLS) and refresh metadata.

Tests can substitute the host with host_mock("oauth_storage", "cloud_get", {result: ...}). With no embedder and no mock, the call raises oauth_storage.cloud_get is not available, which is the 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 three 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
}

let _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) },
  id: "my-vault",          // optional, surfaces in diagnostics
})

Contract for each closure:

ClosureSignatureReturns
getfn(key: string) -> TokenSet | nilThe stored TokenSet, or nil if absent. Must not raise for a missing key.
setfn(key: string, token_set: TokenSet, ttl_seconds: int | nil) -> nilPersists the TokenSet. ttl_seconds is a hint; backends may ignore it.
deletefn(key: string) -> nilIdempotent removal. Must not raise for a missing key.

custom validates that all three closures are functions before returning the handle. 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 (harn-cloud, 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.
  • Custom backend hook protocol documented above.
  • Conformance per backend (store / retrieve / refresh / delete) lives in conformance/tests/stdlib/oauth/oauth_storage.harn.