OAuth

Harn ships a complete OAuth 2.x client stack as part of the standard library. One handle covers the human authorization-code dance, headless device flow, transparent refresh, server-side dynamic client registration, pluggable token storage, and token-shape redaction. Every piece is RFC-shaped so it slots into existing identity providers without provider-specific Rust code.

ModuleRFCPurpose
std/oauth/providersCatalogue of preconfigured providers + custom(...) factory
std/oauth/storageFive interchangeable token stores (memory, file, harn-cloud session/org, custom)
std/oauth/client6749 + 7636 + 9700Authorization-code flow with PKCE S256, transparent refresh, 401-retry
std/oauth/device_flow8628Headless device authorization grant
std/oauth/dynamic_registration7591 + 8414Worker-side metadata + dynamic client registration
std/oauth/redactionOAuth-token catalog + HARN-OAU-001 audit ring

The CLI surface (harn connect <provider>) is documented separately in Connector OAuth; this page covers the scripting API for code that runs inside a Harn pipeline.

The 30-second tour

import { providers } from "std/oauth/providers"
import { memory } from "std/oauth/storage"
import { client, exchange_code, request, start_authorization, token } from "std/oauth/client"

let cli = client(
  providers().github,
  {
    client_id: env("GITHUB_OAUTH_CLIENT_ID"),
    client_secret: env("GITHUB_OAUTH_CLIENT_SECRET"),
    scopes: ["read:user", "user:email"],
    redirect_uri: "http://127.0.0.1:8765/callback",
    storage: memory(),
  },
)

// One-time: send a human through the browser dance.
let pkce = start_authorization(cli)
// (host: open pkce.url, capture redirected code+state, then)
let _ = exchange_code(cli, pkce, code, state)

// Steady state: client owns refresh + 1x retry on 401.
let res = request(cli, "GET", "https://api.github.com/user")

Providers (std/oauth/providers)

A Provider is a dict that captures every endpoint, scope default, and documented quirk the rest of the OAuth stack needs to drive a real IdP. Built-in providers (returned by named factories or the providers() namespace) are validated against vendor docs, and you can override any field per-call:

import { atlassian, github, github_enterprise, providers } from "std/oauth/providers"

let gh = github()                                              // public github.com
let ghe = github_enterprise("https://ghe.example.com")         // enterprise server
let conf = atlassian({default_scopes: ["read:jira-work", "offline_access"]})

let all = providers()                                          // {github, slack, ..., custom, github_enterprise}

Built-ins: github, github_enterprise, slack, linear, notion, google, microsoft, atlassian, discord, gitlab, bitbucket. Plus custom(config, overrides?) for in-house IdPs.

A provider record carries:

FieldNotes
id / labelUsed as the default storage key and for diagnostics
auth_url / token_urlAuthorization-code endpoints
device_code_url?Present iff the provider supports RFC 8628 device flow
revoke_url?RFC 7009 best-effort; the client always discards locally
userinfo_url?OIDC /userinfo analogue when one exists
default_scopesUsed when opts.scopes is not set
pkce_requiredInformational; PKCE is unconditionally used by the client
refresh_handling{strategy, refresh_grant, rotates_refresh_token, notes}
documented_quirksProvider-specific constraints and gotchas
documentation_urlVendor doc link for "go read the source"

provider("github", overrides?) is a string-keyed factory for the same records, and provider_catalog(overrides?) returns all ten built-ins keyed by id.

Storage (std/oauth/storage)

Token storage is a single three-closure protocol:

storage.get(key) -> TokenSet | nil
storage.set(key, token_set, ttl_seconds = nil) -> nil
storage.delete(key) -> nil

Pick a backend; the OAuth client never knows the difference.

ConstructorPersists?Best for
memory()notests, short-lived scripts
file(path, key)yes (AES-256-GCM)local development, single-host operators
harn_cloud_session()yes (host cap)one user's cloud-managed agent
harn_cloud_org()yes (host cap)shared org credentials ("the org's GitHub bot")
custom({get, set, delete, id?})dependsvaults, KMS, platform keychains
import { custom, file, harn_cloud_org, memory } from "std/oauth/storage"

let dev   = memory()
let disk  = file("/var/lib/harn/oauth.bin", env("HARN_OAUTH_KEY"))
let cloud = harn_cloud_org()                                   // org-shared bot
let vault = custom({
  get:    { key -> vault_get("oauth/" + key) },
  set:    { key, token_set, ttl_seconds = nil -> vault_put("oauth/" + key, token_set) },
  delete: { key -> vault_delete("oauth/" + key) },
})

Closure capture in Harn is by-value: a custom backend MUST delegate to a real store (HTTP/MCP/vault) inside its closures. See the full reference in OAuth storage stdlib.

Authorization-code client (std/oauth/client)

client(provider, opts) builds a handle that owns the token lifecycle.

import { client, exchange_code, refresh, request, revoke, start_authorization, token } from "std/oauth/client"

let cli = client(provider, {
  client_id:          string,
  storage:            <storage handle>,
  client_secret?:     string,                       // confidential clients only
  scopes?:            list<string>,                 // defaults to provider.default_scopes
  redirect_uri?:      string,                       // required for start_authorization
  storage_key?:       string,                       // defaults to provider.id
  token_auth_method?: "none" | "client_secret_post" | "client_secret_basic",
  audience?:          string,                       // appended to /authorize and /device
  extra_auth_params?: dict,                         // raw passthrough on /authorize
})

The handle exposes seven operations. Each one is also re-exported as a standalone helper that takes the handle as the first argument:

HelperBehavior
start_authorization(cli)Returns {url, state, code_verifier, code_challenge, ...}
exchange_code(cli, pkce, code, state)Validates PKCE + state, persists the TokenSet
token(cli)Returns a valid access token (refresh on >=75% TTL)
refresh(cli)Forces a refresh, ignoring TTL
request(cli, method, url, opts?)Token-bearing HTTP with 1x 401 retry
revoke(cli)RFC 7009 best-effort + local storage delete
cli.current_token()Reads the stored TokenSet without refresh

What the client guarantees

  • PKCE S256 is unconditional. start_authorization generates a 64-byte CSPRNG verifier (base64url-no-pad, ~86 chars) and a SHA-256 S256 challenge. code_challenge_method=S256 is hardcoded.
  • State always enforced. exchange_code raises on state mismatch before touching the token endpoint.
  • Transparent refresh. token(cli) re-reads storage every call and refreshes if the stored TokenSet is past 75% TTL or already expired.
  • One retry on 401. request(cli, ...) performs a forced refresh and replays the request exactly once when the server returns 401.
  • Refresh-token preservation. Token responses that omit a fresh refresh_token keep the prior one (relevant for Google + Slack + Discord, which only rotate on consent).
  • Audit log without secrets. Refresh / exchange / revoke each emit oauth.client.audit (token_refreshed / token_exchanged / token_revoked) with presence flags + expiry timestamps. The access token never lands in the audit payload.
  • Storage is the source of truth. Two concurrent token(cli) calls may both observe staleness and both refresh; the second set wins and both callers see the same token. The 75% pre-refresh window keeps the race narrow.
  • Storage key defaults to provider.id. Pass storage_key to fan out multiple installations of the same provider (e.g. one GitHub OAuth app per tenant).

Diagnostic codes

CodeSourceMeaning
HARN-OAU-001std/oauth/redactionA persisted sink redacted an OAuth-shaped token
HARN-OAU-002std/oauth/clientNo refresh_token available; re-run authorization
HARN-OAU-005std/oauth/dynamic_registrationRFC 7591 metadata validation rejected a candidate

HARN-OAU-002 is the signal to drive a fresh start_authorization (or device_flow) — refresh failure is terminal until human consent runs again.

Device flow (std/oauth/device_flow)

For CI runners, daemons, and IDE side panes that cannot redirect a browser, device_flow(provider, opts) runs the full RFC 8628 dance and persists the resulting TokenSet into the same storage backend the authorization-code client uses.

import { device_flow } from "std/oauth/device_flow"
import { providers } from "std/oauth/providers"
import { memory } from "std/oauth/storage"

let token_set = device_flow(
  providers().github,
  {
    client_id: env("GITHUB_OAUTH_CLIENT_ID"),
    scopes: ["read:user"],
    storage: memory(),
    on_user_code: { user_code, verification_uri ->
      log("Open " + verification_uri + " and enter " + user_code)
    },
  },
)

on_user_code is optional — the default writes the URL and code to stderr so an operator can complete the dance manually. The poll loop honors the server-supplied interval, treats authorization_pending as a soft retry, adds 5s on slow_down, and raises on expired_token or access_denied. The TokenSet is persisted before device_flow returns, so the very next client(...) handle that targets the same storage + storage_key picks it up without further authorization.

Audit: every successful exchange emits oauth.device_flow.audit token_obtained. The device_code / user_code are never persisted or logged.

Dynamic registration (std/oauth/dynamic_registration)

This module is the server side of OAuth — covers the case where Harn acts as a resource (or auxiliary service) that other agents register clients against. It does not itself host HTTP; embedders (harn-cloud, harn serve, custom hosts) mount the returned metadata documents and the registration handler.

import {
  authorization_server_metadata,
  client_metadata,
  dynamic_registration_store,
  register_client,
  validate_metadata,
  well_known_paths,
  well_known_response,
} from "std/oauth/dynamic_registration"
import { providers } from "std/oauth/providers"

let paths = well_known_paths()                              // {client_metadata, authorization_server_metadata, registration}
let oas = authorization_server_metadata(
  providers().github,
  {registration_endpoint: paths.registration},
)
let oas_response = well_known_response(oas)                 // {status, content_type, headers, body}

let store = dynamic_registration_store()
let body = register_client(store, {
  redirect_uris: ["https://app.example/cb"],
  client_name: "Acme Agent",
})
// body.client_id, body.client_secret (returned ONCE), body.client_id_issued_at, ...

validate_metadata(metadata) returns {ok, errors} against RFC 7591 §2; each error is prefixed HARN-OAU-005: for stable pattern matching. Validation is strict by default — redirect_uris must be absolute https:// or loopback http:// per RFC 8252 §7.3, and grant / response types and token_endpoint_auth_method are restricted to the spec-blessed enums.

get_client(store, client_id) reads a registration back without client_secret — the secret is only ever returned by the original register_client call.

Redaction (std/oauth/redaction)

The redaction module recognizes a catalog of high-confidence token patterns (JWT, GitHub PAT classic + fine-grained, Slack xox*, AWS AKIA, OpenAI sk-, Stripe sk_live_/sk_test_, GitLab glpat-, npm npm_, Authorization: Bearer ...). Persisted transcripts, audit receipts, OTel span attributes, and system reminders run every string through the catalog and replace matches with <redacted:<pattern>:<len>>. The original token still flows to the underlying tool — redaction is display-only.

import {
  clear_custom_patterns,
  custom_patterns,
  default_patterns,
  drain_audit,
  redact,
  register_pattern,
} from "std/oauth/redaction"

register_pattern("acme_api_key", "\\bACME-[A-Z0-9]{12}\\b")
let display = redact("ACME-DEADBEEF1234 calling")
for entry in drain_audit() {
  // entry.code == "HARN-OAU-001"
  // entry.pattern, entry.match_count, entry.bytes_redacted
}

drain_audit() is the authoritative compliance contract — it works on every execution backend. Audit entries are also forwarded to the live event-sink pipeline and (when a multi-threaded Tokio runtime is available) appended to the audit.token_redaction event-log topic.

Provider cookbook

Each recipe is a complete authorization-code or device-flow snippet plus the provider-specific gotcha you usually only discover by reading the vendor docs.

GitHub

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { memory } from "std/oauth/storage"

let cli = client(
  providers().github,
  {
    client_id: env("GITHUB_OAUTH_CLIENT_ID"),
    client_secret: env("GITHUB_OAUTH_CLIENT_SECRET"),
    scopes: ["read:user", "user:email", "repo"],
    redirect_uri: "http://127.0.0.1:8765/callback",
    storage: memory(),
  },
)
let pkce = start_authorization(cli)
// host: open pkce.url, capture code + state from the redirect
let _ = exchange_code(cli, pkce, code, state)
let user = request(cli, "GET", "https://api.github.com/user")

GitHub OAuth-app access tokens may be long-lived without a refresh_token. If you need explicit expiry + refresh, register an expiring user-to-server token under the OAuth app settings; the catalog handles both shapes transparently and falls back to the "no-refresh" branch when the token response omits expires_in.

GitHub device flow uses the same client — pass providers().github into device_flow(...) instead of client(...). Note: device flow must be enabled on the app registration (Developer settings → OAuth Apps → "Enable Device Flow") before the device endpoint will issue codes.

Slack

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let cli = client(
  providers().slack,
  {
    client_id: env("SLACK_CLIENT_ID"),
    client_secret: env("SLACK_CLIENT_SECRET"),
    scopes: ["app_mentions:read", "chat:write"],
    redirect_uri: "https://app.example/oauth/slack/callback",
    storage: file("/var/lib/harn/slack.bin", env("HARN_OAUTH_KEY")),
  },
)

Token rotation gotcha: when Slack token rotation is enabled, the issued refresh_token is single-use. Two concurrent request(cli, ...) calls that both decide to refresh will race on the storage set — the second writer wins and the first refresh is effectively wasted. The client's 75% TTL pre-refresh window keeps the race narrow, but pin refresh to a single worker if you have a high-fanout deployment.

Slack's bot scopes and user scopes are separate from the "Sign in with Slack" identity scopes — pick the right scope family before requesting consent.

Linear

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { harn_cloud_org } from "std/oauth/storage"

let cli = client(
  providers().linear,
  {
    client_id: env("LINEAR_CLIENT_ID"),
    client_secret: env("LINEAR_CLIENT_SECRET"),
    scopes: ["read", "write", "issues:create"],
    redirect_uri: "https://app.example/oauth/linear/callback",
    storage: harn_cloud_org(),
    storage_key: "linear:" + team_id,                       // per-team isolation
  },
)

Two Linear quirks the catalog handles for you:

  • Scopes are comma-separated in Linear's authorization URL (not space-separated like the rest of OAuth-land). The provider record sets the scope separator automatically.
  • User info is a GraphQL query, not a REST endpoint. After the exchange, run request(cli, "POST", "https://api.linear.app/graphql", {body: "{\"query\":\"{viewer{id name}}\"}"}) instead of GET-ing a /me URL.

Use storage_key: "linear:" + team_id to keep per-team installations isolated under the same provider record.

Notion

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { harn_cloud_session } from "std/oauth/storage"

let cli = client(
  providers().notion,
  {
    client_id: env("NOTION_CLIENT_ID"),
    client_secret: env("NOTION_CLIENT_SECRET"),
    redirect_uri: "https://app.example/oauth/notion/callback",
    storage: harn_cloud_session(),
    extra_auth_params: {owner: "user"},                       // user-owned public connection
  },
)
let pages = request(
  cli,
  "GET",
  "https://api.notion.com/v1/users/me",
  {headers: {"Notion-Version": "2022-06-28"}},
)

Two Notion-specific things to remember:

  • Database / page access is not OAuth scopes. The user picks pages during the Notion page-picker flow on /authorize; subsequent API calls can only see what the user granted. Plan your UX around the picker, not around incremental scope upgrades.
  • Notion-Version is required on every API call after the OAuth dance completes. Add it to the opts.headers you pass into request(...) or set it inside a tiny wrapper.

Google

import { client, exchange_code, refresh, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let cli = client(
  providers().google,
  {
    client_id: env("GOOGLE_CLIENT_ID"),
    client_secret: env("GOOGLE_CLIENT_SECRET"),
    scopes: ["openid", "email", "profile", "https://www.googleapis.com/auth/drive.readonly"],
    redirect_uri: "http://127.0.0.1:8765/callback",
    storage: file("/var/lib/harn/google.bin", env("HARN_OAUTH_KEY")),
    extra_auth_params: {access_type: "offline", prompt: "consent"},
  },
)

Google's refresh-token model is the most surprising one in the catalog:

  • Refresh tokens are only issued on first consent (or when prompt=consent is forced). Subsequent grants reuse the existing refresh token. The catalog preserves the prior refresh_token across refreshes that don't include one — but if you delete the storage entry and re-run authorization without prompt=consent, you will get back an access token with no refresh capability.
  • Workspace consent screens require the OAuth client app to be marked "Internal" or to go through verification before users outside the publishing project can grant the scopes.
  • Incremental authorization is preferred for product-specific scopes (Gmail, Drive, Calendar). Set extra_auth_params: {include_granted_scopes: "true"} and request additional scopes via fresh authorization rounds instead of asking for everything up front.

Microsoft

import { client, request, start_authorization } from "std/oauth/client"
import { microsoft } from "std/oauth/providers"
import { harn_cloud_org } from "std/oauth/storage"

let tenant = env("MS_TENANT_ID")
let cli = client(
  microsoft({
    auth_url:  "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
    token_url: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
  }),
  {
    client_id: env("MS_CLIENT_ID"),
    client_secret: env("MS_CLIENT_SECRET"),
    scopes: ["openid", "profile", "email", "offline_access", "User.Read", "Mail.Read"],
    redirect_uri: "https://app.example/oauth/microsoft/callback",
    storage: harn_cloud_org(),
  },
)
let me = request(cli, "GET", "https://graph.microsoft.com/v1.0/me")

Microsoft Identity has two ergonomic traps:

  • Graph delegated scopes are not OIDC claims. openid, profile, email, offline_access are claim scopes (your access token still needs them for refresh + ID-token contents). User.Read, Mail.Read, etc. are resource permissions on Microsoft Graph — granting one without the matching resource permission yields a token Graph cannot use.
  • Audience claim ≠ access token target. The token returned for the Graph audience is not valid against custom-API audiences. If you also need to call a custom resource, run a second client(...) with the per-resource scopes (Microsoft does not issue multi-audience tokens). Use storage_key to keep the two TokenSets distinct.

Use a tenant-specific URL (above) when an app is single-tenant. The default /common route is the right choice for multi-tenant apps; it only resolves the user's home tenant at consent time.

Atlassian (Jira + Confluence)

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { harn_cloud_org } from "std/oauth/storage"

let cli = client(
  providers().atlassian,
  {
    client_id: env("ATLASSIAN_CLIENT_ID"),
    client_secret: env("ATLASSIAN_CLIENT_SECRET"),
    scopes: ["read:jira-work", "read:confluence-content.summary", "offline_access"],
    redirect_uri: "https://app.example/oauth/atlassian/callback",
    storage: harn_cloud_org(),
    audience: "api.atlassian.com",
  },
)
// 1) Resolve accessible cloud sites (one token covers Jira AND Confluence on each).
let sites = request(cli, "GET", "https://api.atlassian.com/oauth/token/accessible-resources")
// 2) Use the returned cloudid for product calls:
//    https://api.atlassian.com/ex/jira/<cloudid>/rest/api/3/myself
//    https://api.atlassian.com/ex/confluence/<cloudid>/wiki/rest/api/user/current

Atlassian's 3LO flow is one OAuth client for both products — Jira and Confluence share scopes, the same audience=api.atlassian.com, and the same token. The provider record sets the audience for you; you only need to request the union of scopes the agent will use across both products.

Refresh tokens rotate on every refresh — the prior refresh token is invalidated as soon as the new one is issued. The OAuth client persists the new refresh on each successful refresh; do not cache a copy in your own code.

Discord

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let cli = client(
  providers().discord,
  {
    client_id: env("DISCORD_CLIENT_ID"),
    client_secret: env("DISCORD_CLIENT_SECRET"),
    scopes: ["identify", "email"],                            // user scope
    redirect_uri: "https://app.example/oauth/discord/callback",
    storage: file("/var/lib/harn/discord.bin", env("HARN_OAUTH_KEY")),
  },
)

Discord has a sharp split between bot tokens (long-lived, scoped to a guild via the bot scope on a single one-time installation) and user tokens (the OAuth dance above, with identify / email / guilds user scopes). Mixing them throws off intent: a bot token does not respond to user-API endpoints, and a user token cannot drive bot gateway events.

If you need both (e.g. an OAuth-authorized agent that also runs as a bot), use two client(...) instances with different storage_keys and keep the token tracks separate.

GitLab (cloud + self-hosted)

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { custom, providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

// Cloud:
let cloud_cli = client(
  providers().gitlab,
  {
    client_id: env("GITLAB_CLIENT_ID"),
    client_secret: env("GITLAB_CLIENT_SECRET"),
    scopes: ["read_api", "read_user", "openid", "profile", "email"],
    redirect_uri: "https://app.example/oauth/gitlab/callback",
    storage: file("/var/lib/harn/gitlab.bin", env("HARN_OAUTH_KEY")),
  },
)

// Self-hosted: same /oauth paths under your instance base URL.
let base = "https://gitlab.acme.example"
let self_hosted = custom({
  id: "gitlab",
  label: "GitLab (self-hosted)",
  auth_url:        base + "/oauth/authorize",
  token_url:       base + "/oauth/token",
  device_code_url: base + "/oauth/authorize_device",
  revoke_url:      base + "/oauth/revoke",
  userinfo_url:    base + "/oauth/userinfo",
  default_scopes:  ["read_api", "openid"],
  pkce_required:   true,
})

GitLab's refresh response rotates both tokens — the prior access token is invalidated alongside the prior refresh token. This is the RFC-spec-strict behavior; the client handles it transparently. The catch: if a refresh succeeds but the caller crashes before persisting the new TokenSet, the old refresh token is gone. Use a durable storage backend (file(...) or harn_cloud_*()) in production rather than memory().

Device flow is available on GitLab 17.1+ (generally available in 17.9+); the catalog enables it on providers().gitlab and on the custom self-hosted record above when you target an instance that's new enough.

Bitbucket (workspace-scoped)

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let cli = client(
  providers().bitbucket,
  {
    client_id: env("BITBUCKET_CLIENT_KEY"),
    client_secret: env("BITBUCKET_CLIENT_SECRET"),
    scopes: ["account", "repository", "issue"],
    redirect_uri: "https://app.example/oauth/bitbucket/callback",
    storage: file("/var/lib/harn/bitbucket.bin", env("HARN_OAUTH_KEY")),
  },
)
let workspaces = request(cli, "GET", "https://api.bitbucket.org/2.0/workspaces")

Bitbucket Cloud OAuth supports authorization-code and client-credentials grants only — there is no device flow. For workspace-scoped automation (running as a service account against a single workspace), prefer client credentials over a long-lived user OAuth token: register the OAuth consumer with the workspace, then exchange client_credentials outside Harn's authorization-code helpers since the grant has no human in the loop.

A refreshed Bitbucket token response includes a new refresh token that the catalog stores automatically; the old one expires shortly after use.

Cross-cutting cookbook

Headless CI agent (device flow)

import { device_flow } from "std/oauth/device_flow"
import { providers } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let store = file("/var/lib/harn/ci-token.bin", env("HARN_OAUTH_KEY"))
let token_set = device_flow(
  providers().github,
  {
    client_id: env("GH_OAUTH_CLIENT_ID"),
    scopes: ["read:user", "repo"],
    storage: store,
    on_user_code: { user_code, verification_uri ->
      // Surface to the CI log + a chat webhook so an operator can complete the dance.
      let _ = log("Visit " + verification_uri + " and enter " + user_code)
      let _ = http_post(env("SLACK_WEBHOOK_URL"), json_stringify({
        text: "CI auth pending: open " + verification_uri + " and enter `" + user_code + "`",
      }), {headers: {"Content-Type": "application/json"}})
      nil
    },
  },
)
// On subsequent CI runs the file backend already has the token; skip device_flow.

The same pattern works for Google, Microsoft, and GitLab. Slack, Linear, Notion, Atlassian, Discord, and Bitbucket do not advertise device endpoints — device_flow(...) raises on construction if provider.device_code_url is nil.

Org-shared GitHub bot (harn_cloud_org)

import { client, request } from "std/oauth/client"
import { providers } from "std/oauth/providers"
import { harn_cloud_org } from "std/oauth/storage"

let cli = client(
  providers().github,
  {
    client_id: env("ORG_GITHUB_CLIENT_ID"),
    client_secret: env("ORG_GITHUB_CLIENT_SECRET"),
    scopes: ["read:org", "repo"],
    redirect_uri: env("ORG_REDIRECT_URI"),
    storage: harn_cloud_org(),
    storage_key: "github:org-bot",
  },
)
let issues = request(cli, "GET", "https://api.github.com/orgs/burin-labs/issues")

harn_cloud_org() routes through the oauth_storage.cloud_* host capability with scope = "org". harn-cloud is responsible for tenant-scoped storage (RLS), so two agents running in the same org share the same authenticated client without either of them being able to read tokens for a different org. The storage_key is per-purpose, not per-user: one entry covers every consumer of the bot.

Custom enterprise OIDC provider

import { client, exchange_code, request, start_authorization } from "std/oauth/client"
import { custom } from "std/oauth/providers"
import { file } from "std/oauth/storage"

let acme = custom({
  id: "acme-oidc",
  label: "Acme Enterprise OIDC",
  auth_url:     "https://idp.acme.example/oauth2/authorize",
  token_url:    "https://idp.acme.example/oauth2/token",
  revoke_url:   "https://idp.acme.example/oauth2/revoke",
  userinfo_url: "https://idp.acme.example/oauth2/userinfo",
  default_scopes: ["openid", "profile", "email", "offline_access"],
  pkce_required: true,
})
let cli = client(
  acme,
  {
    client_id: env("ACME_OIDC_CLIENT_ID"),
    client_secret: env("ACME_OIDC_CLIENT_SECRET"),
    scopes: ["openid", "profile", "email", "offline_access", "acme.api.read"],
    redirect_uri: "https://app.acme.internal/oauth/callback",
    storage: file("/var/lib/harn/acme.bin", env("HARN_OAUTH_KEY")),
    audience: "https://api.acme.example",
    extra_auth_params: {prompt: "select_account"},
  },
)

The fields on custom({...}) mirror the built-in provider records. If your IdP advertises .well-known/openid-configuration, copy the URLs from there verbatim; the refresh_handling record can stay at the default unless your IdP does something unusual (mTLS, JAR/JARM, custom grant types).

For inhouse IdPs that don't speak OAuth 2.x (e.g. legacy SAML), use a custom(...) storage backend to wrap your existing token broker instead of teaching the OAuth client about a non-OAuth protocol.

  • OAuth storage stdlib — full storage reference.
  • Connector OAuth — the harn connect CLI on top of this stack.
  • Redaction policy — what's automatically scrubbed from persisted transcripts and receipts.
  • Conformance fixtures: conformance/tests/stdlib/oauth/oauth_*.harn.