harn-stdlib 0.8.33

Embedded Harn standard library source catalog
Documentation
/**
 * std/oauth/dynamic_registration (OA-05) — worker-side OAuth metadata
 * publishing + RFC 7591 dynamic client registration.
 *
 * This module is the dual of `std/oauth/client`: it covers the side of
 * the OAuth handshake where Harn *is* the resource (or auxiliary
 * service) that other agents / services / humans register clients
 * against. It does NOT itself host an HTTP server — it produces the
 * RFC 7591 / RFC 8414 metadata documents, validates incoming client
 * registrations, and stores them in an in-process catalogue. Embedders
 * (harn-cloud, `harn serve`, custom hosts) plug those outputs into the
 * routes they already serve.
 *
 * ## Surface
 *
 *   - `client_metadata(opts)` — build & validate an RFC 7591 client
 *     metadata document. Returned dict is safe to serve at
 *     `/.well-known/oauth-client.json`.
 *   - `authorization_server_metadata(provider, overrides?)` — build an
 *     RFC 8414 authorization-server metadata document from a provider
 *     record (see `std/oauth/providers`). Returned dict is safe to
 *     serve at `/.well-known/oauth-authorization-server.json`.
 *   - `validate_metadata(metadata)` — check candidate metadata against
 *     the RFC 7591 §2 conformance rules. Returns
 *     `{ok: bool, errors: list<string>}`. Each error is prefixed
 *     `HARN-OAU-005:` so callers can pattern-match.
 *   - `dynamic_registration_store()` — opens an in-process store handle
 *     for issuing client_ids. Backed by a thread-safe `BTreeMap` keyed
 *     by handle id. Suitable for harn-serve and conformance fixtures;
 *     production deployments should layer a durable backend on top via
 *     the embedder host capabilities.
 *   - `register_client(store, metadata)` — validate + register a
 *     client. Returns the RFC 7591 §3.2.1 success body including the
 *     freshly minted `client_id` and `client_secret`. Emits
 *     `oauth.dynreg.audit` `client_registered` with no credential
 *     material — counts + the registered redirect_uris only.
 *   - `get_client(store, client_id)` — read back the registration
 *     without `client_secret`. Returns nil for unknown ids.
 *   - `list_clients(store)` — list registered client_ids.
 *   - `well_known_paths(opts?)` — the canonical well-known URL paths;
 *     embedders use this to mount the metadata documents.
 *   - `well_known_response(metadata)` — wraps a metadata dict in the
 *     `{content_type, body}` shape an HTTP layer can return without
 *     re-serializing.
 *
 * ## Security
 *
 * Validation is strict by default per RFC 7591 §5.1 — the server is
 * explicitly allowed to reject otherwise-conforming registrations:
 *
 *   - `redirect_uris` must be a non-empty list of absolute `https://`
 *     URIs, or loopback `http://localhost` / `http://127.0.0.1` per
 *     RFC 8252 §7.3, and may not carry a fragment.
 *   - `grant_types`, `response_types`, `token_endpoint_auth_method`
 *     are restricted to the spec-blessed enums.
 *
 * The `client_secret` is only returned from the original
 * `register_client` call. Subsequent `get_client` reads omit it, so a
 * stray log of the read result cannot leak credentials. Audit events
 * carry only a counted shape (`{has_client_secret, redirect_uri_count,
 * grant_types}`) — never the secret material.
 *
 * ## Disable on local runs
 *
 * Local `harn run` scripts that do not act as a resource server should
 * simply not call into this module. Embedders that host the well-known
 * endpoints (harn-cloud, `harn serve --oauth-resource ...`) read a
 * boolean toggle from their own configuration before mounting; this
 * module deliberately does not introduce a global on/off switch
 * because the source of truth lives at the transport layer.
 */
let __AUDIT_TOPIC = "oauth.dynreg.audit"

let __AUDIT_KIND_REGISTERED = "client_registered"

let __DEFAULT_CLIENT_METADATA_PATH = "/.well-known/oauth-client.json"

let __DEFAULT_AUTH_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server.json"

let __DEFAULT_REGISTRATION_PATH = "/register"

let __CONTENT_TYPE_JSON = "application/json"

fn __require_dict(value, field) {
  if type_of(value) != "dict" {
    throw "std/oauth/dynamic_registration: " + field + " must be a dict, got " + type_of(value)
  }
  return value
}

fn __safe_get(dict, key) {
  let keys = dict.keys()
  if keys.contains(key) {
    return dict[key]
  }
  return nil
}

/**
 * validate_metadata checks an RFC 7591 candidate metadata dict and
 * returns `{ok: bool, errors: list<string>}`. Each error is prefixed
 * `HARN-OAU-005:` so callers can match on a stable diagnostic code.
 *
 * @effects: []
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: validate_metadata({redirect_uris: ["https://app.example/cb"]})
 */
pub fn validate_metadata(metadata) -> dict {
  return __oauth_dynreg_validate_metadata(__require_dict(metadata, "metadata"))
}

/**
 * client_metadata returns a fully-defaulted RFC 7591 client metadata
 * dict for serving at `/.well-known/oauth-client.json`. Required field:
 * `redirect_uris` (list of absolute https URIs, or loopback http).
 * Optional fields are passed through verbatim. Raises an error if any
 * field violates RFC 7591 §2.
 *
 * @effects: []
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: client_metadata({redirect_uris: ["https://app.example/cb"], client_name: "Harn"})
 */
pub fn client_metadata(opts) -> dict {
  return __oauth_dynreg_build_client_metadata(__require_dict(opts, "opts"))
}

/**
 * authorization_server_metadata returns an RFC 8414 authorization-server
 * metadata document derived from a provider record (see
 * `std/oauth/providers`). Pass `overrides` to set e.g. the
 * `registration_endpoint` for the dynamic registration URL the
 * embedding HTTP server will mount.
 *
 * @effects: []
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: authorization_server_metadata(providers().github, {registration_endpoint: "/register"})
 */
pub fn authorization_server_metadata(provider, overrides = nil) -> dict {
  let provider_dict = __require_dict(provider, "provider")
  if overrides == nil {
    return __oauth_dynreg_build_authorization_server_metadata(provider_dict, nil)
  }
  return __oauth_dynreg_build_authorization_server_metadata(
    provider_dict,
    __require_dict(overrides, "overrides"),
  )
}

/**
 * dynamic_registration_store opens an in-process store handle for
 * issuing RFC 7591 client registrations. Returned dict carries
 * `kind: "oauth_dynreg_store"`, an opaque `id`, and three convenience
 * closures (`register`, `get`, `list`).
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: experimental
 * @example: dynamic_registration_store()
 */
pub fn dynamic_registration_store() -> dict {
  let inner = __oauth_dynreg_store_handle()
  return inner
    + {
    _namespace: "oauth_dynreg",
    register: { metadata -> register_client(inner, metadata) },
    get: { client_id -> get_client(inner, client_id) },
    list: { -> list_clients(inner) },
  }
}

fn __redact_shape(metadata, response) {
  let redirect_uris = __safe_get(metadata, "redirect_uris") ?? []
  let grant_types = __safe_get(response, "grant_types") ?? []
  let has_secret = __safe_get(response, "client_secret") != nil
  return {
    redirect_uri_count: len(redirect_uris),
    grant_types: grant_types,
    has_client_secret: has_secret,
    token_endpoint_auth_method: __safe_get(response, "token_endpoint_auth_method"),
  }
}

/**
 * register_client validates a candidate RFC 7591 metadata dict and, if
 * acceptable, registers it on the supplied store. Returns the RFC 7591
 * §3.2.1 success body — `{client_id, client_secret,
 * client_id_issued_at, client_secret_expires_at, ...metadata}`. Emits
 * an `oauth.dynreg.audit` `client_registered` event log entry with the
 * registration *shape* (`redirect_uri_count`, `grant_types`,
 * `has_client_secret`) — never the secret material itself.
 *
 * @effects: []
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: register_client(store, {redirect_uris: ["https://app.example/cb"]})
 */
pub fn register_client(store, metadata) -> dict {
  let store_dict = __require_dict(store, "store")
  let metadata_dict = __require_dict(metadata, "metadata")
  let response = __oauth_dynreg_register(store_dict, metadata_dict)
  let payload = {
    client_id: __safe_get(response, "client_id"),
    issued_at: __safe_get(response, "client_id_issued_at"),
    redacted: __redact_shape(metadata_dict, response),
  }
  let _ = event_log.emit(__AUDIT_TOPIC, __AUDIT_KIND_REGISTERED, payload, {dynreg: "true"})
  return response
}

/**
 * get_client reads a previously registered client from the store. The
 * returned dict does NOT include `client_secret` — that is only ever
 * returned once, by `register_client`. Returns nil for unknown ids.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: experimental
 * @example: get_client(store, "abc123")
 */
pub fn get_client(store, client_id) -> dict {
  return __oauth_dynreg_get(__require_dict(store, "store"), client_id)
}

/**
 * list_clients returns the list of registered client ids on the store.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: experimental
 * @example: list_clients(store)
 */
pub fn list_clients(store) -> list<string> {
  return __oauth_dynreg_list(__require_dict(store, "store"))
}

/**
 * well_known_paths returns the canonical well-known URL paths used by
 * `client_metadata` and `authorization_server_metadata`. Pass overrides
 * to swap the default `/.well-known/oauth-*` paths for embeddings that
 * mount under a different prefix.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: experimental
 * @example: well_known_paths()
 */
pub fn well_known_paths(overrides = nil) -> dict {
  let defaults = {
    client_metadata: __DEFAULT_CLIENT_METADATA_PATH,
    authorization_server_metadata: __DEFAULT_AUTH_SERVER_METADATA_PATH,
    registration: __DEFAULT_REGISTRATION_PATH,
  }
  if overrides == nil {
    return defaults
  }
  return defaults + __require_dict(overrides, "overrides")
}

/**
 * well_known_response wraps a metadata dict as an HTTP response envelope
 * with `content_type: "application/json"` and a `body` string. This is
 * the shape embedders feed into harn-serve or harn-cloud route
 * handlers — no extra serialization on the embedder side.
 *
 * @effects: []
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: well_known_response(client_metadata({redirect_uris: ["https://a/cb"]}))
 */
pub fn well_known_response(metadata) -> dict {
  let dict = __require_dict(metadata, "metadata")
  return {
    status: 200,
    content_type: __CONTENT_TYPE_JSON,
    headers: {"content-type": __CONTENT_TYPE_JSON, "cache-control": "no-store"},
    body: json_stringify(dict),
  }
}