Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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:

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() {
  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:

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

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.
with_refresh_lockfn(key: string, body: fn() -> any) -> anyOptional. 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.