harn-stdlib 0.8.28

Embedded Harn standard library source catalog
Documentation
/**
 * std/oauth/device_flow — RFC 8628 device authorization grant.
 *
 * `device_flow(provider, opts)` performs the full RFC 8628 dance for
 * headless contexts (CI runners, daemons, IDE side panes) where a
 * browser redirect is impractical. The function:
 *
 *   1. POSTs to the provider's device authorization endpoint and
 *      receives a `{device_code, user_code, verification_uri, interval,
 *      expires_in}` envelope (`verification_uri_complete` is accepted
 *      and surfaced when present).
 *   2. Hands `(user_code, verification_uri)` to `opts.on_user_code` —
 *      defaults to a `stderr` message instructing the operator to open
 *      the URL and enter the code.
 *   3. Polls the token endpoint at the cadence the server returned
 *      (`interval`, default 5s). Honors `slow_down` by adding 5s to the
 *      interval, treats `authorization_pending` as a soft retry, and
 *      raises on `expired_token`, `access_denied`, or any other terminal
 *      error.
 *   4. On success, builds a `TokenSet` (mirroring
 *      `std/oauth/client.__build_token_set`) and persists it through the
 *      caller-supplied storage handle so subsequent
 *      `OAuth.client(...)` calls see the same token + refresh metadata.
 *
 *     import { providers } from "std/oauth/providers"
 *     import { memory } from "std/oauth/storage"
 *     import { device_flow } from "std/oauth/device_flow"
 *     import { write_stderr } from "std/io"
 *
 *     let token = device_flow(
 *       providers().github,
 *       {
 *         client_id: env("GITHUB_CLIENT_ID"),
 *         scopes: ["read:user"],
 *         storage: memory(),
 *         on_user_code: { user_code, verification_uri ->
 *           write_stderr("Open " + verification_uri + " and enter " + user_code + "\n")
 *         },
 *       },
 *     )
 *
 * Audit: every successful exchange emits an `oauth.device_flow.audit`
 * `token_obtained` event log entry containing the provider id, storage
 * key, and token presence flags. The `device_code` and `user_code` are
 * never persisted or logged.
 *
 * Cancellation: polling honors VM cancellation between intervals —
 * each `sleep(...)` between polls is itself cancellable, so a parent
 * fiber that aborts the script will not be stuck waiting for an
 * outstanding poll.
 *
 * Concurrency / time mocking: the polling sleep routes through the
 * standard `sleep(ms)` builtin and so honors `mock_time(...)` /
 * `advance_time(...)` in tests.
 */
let __DEFAULT_INTERVAL_SECONDS = 5

let __SLOW_DOWN_BUMP_SECONDS = 5

let __DEFAULT_TOKEN_KEY = "access-token"

let __AUDIT_TOPIC = "oauth.device_flow.audit"

let __AUDIT_KIND_OBTAINED = "token_obtained"

let __ERROR_AUTHORIZATION_PENDING = "authorization_pending"

let __ERROR_SLOW_DOWN = "slow_down"

let __ERROR_EXPIRED_TOKEN = "expired_token"

let __ERROR_ACCESS_DENIED = "access_denied"

fn __require_string(value, field) {
  let text = to_string(value ?? "")
  if text == "" {
    throw "std/oauth/device_flow: " + field + " is required"
  }
  return text
}

fn __require_provider(provider) {
  if type_of(provider) != "dict" {
    throw "std/oauth/device_flow: provider must be a dict (use std/oauth/providers)"
  }
  let device_url = provider?.device_code_url
  if device_url == nil || to_string(device_url) == "" {
    let id = to_string(provider?.id ?? "custom")
    throw "std/oauth/device_flow: provider `" + id + "` does not declare a device_code_url"
  }
  let token_url = provider?.token_url
  if token_url == nil || to_string(token_url) == "" {
    throw "std/oauth/device_flow: provider.token_url is required"
  }
  return provider
}

fn __require_storage(storage) {
  if type_of(storage) != "dict" {
    throw "std/oauth/device_flow: opts.storage is required (use std/oauth/storage)"
  }
  let g = storage?.get
  let s = storage?.set
  let d = storage?.delete
  if type_of(g) != "closure" && type_of(g) != "builtin" {
    throw "std/oauth/device_flow: opts.storage.get must be a function"
  }
  if type_of(s) != "closure" && type_of(s) != "builtin" {
    throw "std/oauth/device_flow: opts.storage.set must be a function"
  }
  if type_of(d) != "closure" && type_of(d) != "builtin" {
    throw "std/oauth/device_flow: opts.storage.delete must be a function"
  }
  return storage
}

fn __scope_separator(provider) {
  let id = lowercase(to_string(provider?.id ?? ""))
  if id == "linear" {
    return ","
  }
  return " "
}

fn __join_scopes(scopes, separator) {
  var parts = []
  if scopes != nil {
    for raw in scopes {
      let text = trim(to_string(raw ?? ""))
      if text != "" {
        parts = parts + [text]
      }
    }
  }
  if len(parts) == 0 {
    return nil
  }
  return join(parts, separator)
}

fn __url_encode_value(value) {
  return url_encode(to_string(value ?? ""))
}

fn __form_body(params) {
  var parts = []
  for entry in params {
    parts = parts + [url_encode(entry.key) + "=" + __url_encode_value(entry.value)]
  }
  return join(parts, "&")
}

fn __safe_json_parse(text) {
  let s = to_string(text ?? "")
  if s == "" {
    return nil
  }
  try {
    return json_parse(s)
  } catch (_err) {
    return nil
  }
}

fn __now_seconds(harness: Harness) {
  return to_int(harness.clock.timestamp())
}

fn __default_on_user_code(user_code, verification_uri) {
  let _ = __io_write_stderr(
    "To authorize this device, open " + to_string(verification_uri) + " and enter code: "
      + to_string(user_code)
      + "\n",
  )
  return nil
}

fn __invoke_user_code_handler(handler, user_code, verification_uri) {
  if handler == nil {
    return __default_on_user_code(user_code, verification_uri)
  }
  if type_of(handler) != "closure" && type_of(handler) != "builtin" {
    throw "std/oauth/device_flow: opts.on_user_code must be a function"
  }
  return handler(to_string(user_code), to_string(verification_uri))
}

fn __redact_token(token) {
  if token == nil {
    return nil
  }
  return {
    has_access_token: token?.access_token != nil && to_string(token?.access_token) != "",
    has_refresh_token: token?.refresh_token != nil && to_string(token?.refresh_token) != "",
    expires_at_unix: token?.expires_at_unix,
    issued_at_unix: token?.issued_at_unix,
    scopes: token?.scopes,
    token_type: token?.token_type,
  }
}

fn __build_token_set(parsed, scopes, issued_at) {
  var token_set = {access_token: to_string(parsed.access_token), issued_at_unix: issued_at}
  let token_type = parsed?.token_type
  if token_type != nil {
    token_set = token_set + {token_type: to_string(token_type)}
  }
  let expires_in = parsed?.expires_in
  if expires_in != nil {
    let ttl_s = to_int(expires_in)
    token_set = token_set + {expires_in: ttl_s, expires_at_unix: issued_at + ttl_s}
  } else if parsed?.expires_at_unix != nil {
    token_set = token_set + {expires_at_unix: to_int(parsed.expires_at_unix)}
  }
  let refresh = parsed?.refresh_token
  if refresh != nil && to_string(refresh) != "" {
    token_set = token_set + {refresh_token: to_string(refresh)}
  }
  let returned_scope = parsed?.scope
  if returned_scope != nil && to_string(returned_scope) != "" {
    token_set = token_set + {scopes: split(to_string(returned_scope), " ")}
  } else if scopes != nil && len(scopes) > 0 {
    token_set = token_set + {scopes: scopes}
  }
  return token_set
}

fn __audit_obtained(provider_id, storage_key, token_set) {
  let payload = {
    provider: to_string(provider_id),
    storage_key: to_string(storage_key),
    next: __redact_token(token_set),
  }
  let _ = event_log.emit(__AUDIT_TOPIC, __AUDIT_KIND_OBTAINED, payload, {provider: to_string(provider_id)})
}

fn __request_device_code(harness: Harness, cfg) {
  var form = [{key: "client_id", value: cfg.client_id}]
  let scopes_str = __join_scopes(cfg.scopes, cfg.scope_separator)
  if scopes_str != nil {
    form = form + [{key: "scope", value: scopes_str}]
  }
  if cfg.audience != nil && to_string(cfg.audience) != "" {
    form = form + [{key: "audience", value: cfg.audience}]
  }
  let headers = {"Content-Type": "application/x-www-form-urlencoded", Accept: "application/json"}
  let response = harness.net.post(cfg.device_code_url, __form_body(form), {headers: headers})
  if !(response?.ok ?? false) {
    let parsed = __safe_json_parse(response?.body ?? "")
    let detail = parsed?.error_description ?? parsed?.error ?? "device authorization endpoint returned status "
      + to_string(response?.status ?? 0)
    throw "std/oauth/device_flow: device authorization request failed: " + to_string(detail)
  }
  let parsed = __safe_json_parse(response?.body ?? "")
  if parsed == nil {
    throw "std/oauth/device_flow: device authorization endpoint did not return JSON"
  }
  let device_code = to_string(parsed?.device_code ?? "")
  let user_code = to_string(parsed?.user_code ?? "")
  let verification_uri = to_string(parsed?.verification_uri ?? parsed?.verification_url ?? "")
  if device_code == "" || user_code == "" || verification_uri == "" {
    throw "std/oauth/device_flow: device authorization response missing required fields"
  }
  let verification_uri_complete = parsed?.verification_uri_complete
  let interval_raw = parsed?.interval ?? __DEFAULT_INTERVAL_SECONDS
  let interval_seconds = to_int(interval_raw)
  let expires_in_raw = parsed?.expires_in ?? 0
  let expires_in_seconds = to_int(expires_in_raw)
  return {
    device_code: device_code,
    user_code: user_code,
    verification_uri: verification_uri,
    verification_uri_complete: verification_uri_complete == nil ? nil : to_string(verification_uri_complete),
    interval_seconds: interval_seconds <= 0 ? __DEFAULT_INTERVAL_SECONDS : interval_seconds,
    expires_in_seconds: expires_in_seconds,
  }
}

fn __poll_once(harness: Harness, cfg, device_code) {
  var form = [
    {key: "grant_type", value: "urn:ietf:params:oauth:grant-type:device_code"},
    {key: "device_code", value: device_code},
    {key: "client_id", value: cfg.client_id},
  ]
  if cfg.client_secret != nil && to_string(cfg.client_secret) != "" {
    form = form + [{key: "client_secret", value: cfg.client_secret}]
  }
  let headers = {"Content-Type": "application/x-www-form-urlencoded", Accept: "application/json"}
  let response = harness.net.post(cfg.token_url, __form_body(form), {headers: headers})
  let status = to_int(response?.status ?? 0)
  let parsed = __safe_json_parse(response?.body ?? "") ?? {}
  if response?.ok ?? false && parsed?.access_token != nil {
    return {kind: "ok", parsed: parsed}
  }
  let error_code = to_string(parsed?.error ?? "")
  if error_code == __ERROR_AUTHORIZATION_PENDING {
    return {kind: "pending"}
  }
  if error_code == __ERROR_SLOW_DOWN {
    return {kind: "slow_down"}
  }
  if error_code == __ERROR_EXPIRED_TOKEN {
    throw "std/oauth/device_flow: device code expired before authorization completed"
  }
  if error_code == __ERROR_ACCESS_DENIED {
    throw "std/oauth/device_flow: user denied the device authorization request"
  }
  let detail = parsed?.error_description ?? error_code ?? "token endpoint returned status " + to_string(status)
  let prefix = error_code == "" ? "token endpoint error: " : "token endpoint error `" + error_code + "`: "
  throw "std/oauth/device_flow: " + prefix + to_string(detail)
}

fn __poll_until_done(
  harness: Harness,
  cfg,
  device_code,
  initial_interval_seconds,
  expires_in_seconds,
) {
  var interval_seconds = initial_interval_seconds
  var elapsed_seconds = 0
  while true {
    let outcome = __poll_once(harness, cfg, device_code)
    if outcome.kind == "ok" {
      return outcome.parsed
    }
    if outcome.kind == "slow_down" {
      interval_seconds = interval_seconds + __SLOW_DOWN_BUMP_SECONDS
    }
    // Sleep for the current interval and re-poll.
    if expires_in_seconds > 0 && elapsed_seconds + interval_seconds > expires_in_seconds {
      throw "std/oauth/device_flow: device code expired before authorization completed"
    }
    sleep(interval_seconds * 1000)
    elapsed_seconds = elapsed_seconds + interval_seconds
  }
  throw "std/oauth/device_flow: unreachable polling exit"
}

/**
 * device_flow runs the RFC 8628 device authorization grant against
 * `provider` and returns the resulting TokenSet. The token is persisted
 * to `opts.storage` under `opts.storage_key` (defaults to the provider
 * id) so the same client / storage pair used by `OAuth.client(...)` can
 * read it back for downstream HTTP calls.
 *
 * Required `opts`:
 *   - client_id:    OAuth client identifier.
 *   - storage:      a handle from `std/oauth/storage`.
 *
 * Optional `opts`:
 *   - client_secret:    confidential-client device flows.
 *   - scopes:           list<string>; defaults to provider.default_scopes.
 *   - storage_key:      per-installation key; defaults to provider id.
 *   - audience:         extra `audience=` parameter on /device.
 *   - on_user_code:     fn(user_code, verification_uri[, verification_uri_complete]) -> any.
 *                       Defaults to `stderr` output with the URL and code.
 *
 * @effects: [time, net, stderr]
 * @allocation: heap
 * @errors: [invalid_argument]
 * @api_stability: experimental
 * @example: device_flow(providers().github, {client_id: id, storage: memory()})
 */
pub fn device_flow(provider, opts) {
  if type_of(opts) != "dict" {
    throw "std/oauth/device_flow: opts must be a dict"
  }
  let provider_record = __require_provider(provider)
  let storage = __require_storage(opts?.storage)
  let client_id = __require_string(opts?.client_id, "opts.client_id")
  let client_secret = opts?.client_secret
  let scopes = opts?.scopes ?? provider_record?.default_scopes ?? []
  let scope_separator = __scope_separator(provider_record)
  let storage_key = to_string(opts?.storage_key ?? provider_record?.id ?? __DEFAULT_TOKEN_KEY)
  let cfg = {
    provider_id: to_string(provider_record?.id ?? "custom"),
    device_code_url: to_string(provider_record.device_code_url),
    token_url: to_string(provider_record.token_url),
    client_id: client_id,
    client_secret: client_secret == nil ? nil : to_string(client_secret),
    scopes: scopes,
    scope_separator: scope_separator,
    audience: opts?.audience,
  }
  let device = __request_device_code(harness, cfg)
  let _ = __invoke_user_code_handler(opts?.on_user_code, device.user_code, device.verification_uri)
  let parsed = __poll_until_done(
    harness,
    cfg,
    device.device_code,
    device.interval_seconds,
    device.expires_in_seconds,
  )
  let issued_at = __now_seconds(harness)
  let token_set = __build_token_set(parsed, scopes, issued_at)
  storage.set(storage_key, token_set)
  __audit_obtained(cfg.provider_id, storage_key, token_set)
  return token_set
}