diff --git a/.env.example b/.env.example index 8fdb7e0..43cde0f 100644 --- a/.env.example +++ b/.env.example @@ -72,12 +72,31 @@ SIGNER_REMOTE_DISCOVERY=0 # ORCH_WEBHOOK_URL= # LIVE_AI_CAP_REPORT_INTERVAL= -# --- openmeter-collector --- +# --- openmeter-collector / builder-api --- +# Single-org fallback when KONNECT_CREDENTIALS_URL is unset / tenant unbound. # Konnect base: https://.api.konghq.com/v3/openmeter # Self-hosted base: https:// OPENMETER_URL=https://us.api.konghq.com/v3/openmeter OPENMETER_API_KEY= +OPENMETER_DEFAULT_PLAN_KEY=clearinghouse_default_ppu +# Trial grant on first session provision (0 disables). Enforced before signer JWT mint. +OPENMETER_TRIAL_FEATURE_KEY=billable_spend +OPENMETER_TRIAL_GRANT_USD_MICROS=0 +OPENMETER_ENFORCE_ALLOWANCE=true +# builder-api delegates end-user JWT verification to identity-webhook (POST /authorize). +# Reuses WEBHOOK_SECRET above; defaults the URL to REMOTE_SIGNER_WEBHOOK_URL when unset. +# IDENTITY_WEBHOOK_URL=http://identity-webhook:8090 # Optional (collector defaults shown) PRICE_ORACLE_URL=https://api.coinbase.com/v2/prices/ETH-USD/spot PRICE_ORACLE_REFRESH=5m + +# --- konnect-credentials (+ collector multi-tenant ingest + builder-api) --- +# M2M secret for bind/credentials APIs and internal lookups. +PLATFORM_API_SECRET=dev-platform-secret-change-me +# AES key material: 64-char hex, 32-byte base64, or passphrase (SHA-256). +CREDENTIALS_ENCRYPTION_KEY=dev-credentials-encryption-change-me +# Compose sets KONNECT_CREDENTIALS_URL=http://konnect-credentials:8091 for collector/builder-api. +# Leave unset outside Compose to use OPENMETER_URL + OPENMETER_API_KEY only. +# KONNECT_CREDENTIALS_URL= +# KONNECT_IDENTITY_BASE=https://global.api.konghq.com/v2 diff --git a/.gitignore b/.gitignore index 2b40760..be9c47a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ # Editor / OS .vscode/ .DS_Store +auth0-provisioner/provision/.env.livepeer diff --git a/README.md b/README.md index 81df48d..4658288 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,11 @@ Docker Compose stack for the clearinghouse runtime: | Service | Role | Docs | | --- | --- | --- | | **identity-webhook** (`identity-webhook`) | Resolves end-user credentials (API keys and/or OAuth/OIDC JWTs) to `auth_id` for go-livepeer's `/authorize` hook. Self-contained: implements the go-livepeer webhook wire protocol in-repo, verifying JWTs with `jose`. | [jose](https://github.com/panva/jose) | +| **konnect-credentials** (`konnect-credentials`) | Binds a per-tenant Konnect org and issues Provisioner / Usage SPATs (Ingest SPAT retained for the collector). Tenants call Konnect Metering & Billing directly — no API mirror. | [konnect-credentials/README.md](konnect-credentials/README.md) | | **Redpanda** (`kafka`) | Kafka-compatible event bus. The signer publishes gateway events; the collector consumes them. | [Redpanda docs](https://docs.redpanda.com/) | | **go-livepeer remote signer** (`remote-signer`) | Signs Livepeer payment tickets and emits `create_signed_ticket` events to Kafka. | [go-livepeer](https://github.com/livepeer/go-livepeer) | -| **OpenMeter collector** (`openmeter-collector`) | Benthos pipeline: filters Kafka events, converts fees to USD micros, POSTs CloudEvents to OpenMeter ingest. | [OpenMeter collector](https://openmeter.io/docs/collectors) | -| **Konnect / OpenMeter** (external) | Hosted metering and billing API. Set `OPENMETER_URL` to your OpenMeter API base; the collector appends the events path. | [Konnect OpenMeter](https://docs.konghq.com/konnect/openmeter/), [self-hosted OpenMeter](https://openmeter.io/docs/deploy/kubernetes) | +| **OpenMeter collector** (`openmeter-collector`) | Benthos pipeline: filters Kafka events, converts fees to USD micros, POSTs CloudEvents to OpenMeter ingest (per-tenant Ingest SPAT when bound). | [OpenMeter collector](https://openmeter.io/docs/collectors) | +| **Konnect / OpenMeter** (external) | Hosted metering and billing API. One org per platform tenant for SPAT isolation. | [Konnect OpenMeter](https://docs.konghq.com/konnect/openmeter/), [self-hosted OpenMeter](https://openmeter.io/docs/deploy/kubernetes) | Data flow: @@ -216,6 +217,19 @@ The collector emits `billable_usd_micros` as an interim passthrough equal to markup rules (network × pipeline/model multiplier) are not applied yet — until then `billable_usd_micros == network_fee_usd_micros`. +### Per-tenant Konnect SPATs + +For multi-tenant isolation, bind one Konnect org per `client_id` and issue SPATs +via [`konnect-credentials`](konnect-credentials/README.md): + +1. `POST /v1/tenants/{clientId}/konnect/bind` — BYO org admin PAT +2. `POST /v1/tenants/{clientId}/konnect/credentials` — Provisioner + Usage SPATs (once) +3. `POST /v1/tenants/{clientId}/konnect/catalog` — meters / features / default plan +4. Collector looks up Ingest SPAT at `GET /v1/internal/tenants/{clientId}/ingest` + +Single-org local stacks can still set `OPENMETER_URL` + `OPENMETER_API_KEY` and leave +tenants unbound; the collector falls back to those env vars when the lookup misses. + ### Identity contract (collector) Three layers — each owns a different piece of the identity story: diff --git a/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md new file mode 100644 index 0000000..2749698 --- /dev/null +++ b/auth0-provisioner/provision/README.md @@ -0,0 +1,112 @@ +# Auth0 client scaffolding (CLI-login bash) + +A thin, idempotent bash scaffolder for the clearinghouse Auth0 objects — the resource +server (API), the **public/M2M client pair**, and their client grants — driven entirely by +your **`auth0 login` session**. It is the Auth0 analog of the OpenMeter +[`provision/bootstrap.sh`](../../openmeter-collector/provision): the data lives in +[`apps.json`](apps.json); the script is thin and re-runnable. + +Unlike the Go [`auth0ctl`](../README.md) tool, this needs **no Management API client +id/secret** — every call rides your CLI session via `auth0 api` (the authenticated +Management API v2 passthrough), the same way the OpenMeter script uses `kongctl api`. + +## Prerequisites + +- The [Auth0 CLI](https://github.com/auth0/auth0-cli) and `jq` on `PATH`. +- An authenticated session against the target tenant: + + ```bash + auth0 login # interactive, one-time + auth0 tenants use # if you have more than one + ``` + +## Usage + +```bash +cd auth0-provisioner/provision +./bootstrap.sh +``` + +The script is safe to re-run: it matches the API by `identifier` and clients by `name` +(or optional `public.client_id` / `m2m.client_id` in `apps.json`), reusing existing +ids and updating grant scopes in place — never duplicating. If provisioning an app +fails partway through, only objects created during that run are deleted (rollback); +pre-existing clients and grants are left intact. It writes the resulting ids and M2M +secret to `.env.livepeer` (gitignored). + +## What it provisions + +From [`apps.json`](apps.json): + +| Kind | Identity | Notes | +| --- | --- | --- | +| Resource server | `livepeer-clearinghouse` | API; `identifier` = audience; RS256; `allow_offline_access`; full scope set. | +| Tenant settings | — | `default_audience` + `device_flow` (RFC 8628). Best-effort; skipped with a warning if the session lacks `update:tenant_settings`. | +| Public client | ` Public` | `native`, `token_endpoint_auth_method: none`, grants `device_code` + `refresh_token`. | +| M2M client | ` M2M` | `non_interactive`, `client_secret_post`, grant `client_credentials`. | +| Management M2M | `Clearinghouse Builder Management` | Auth0 Management API (`create:users`, `read:users`, `update:users`) for Builder API user provisioning. | +| Client grants | per client | Public + M2M each granted their configured scopes against the audience. | + +## How it maps to the `auth0` CLI + +Each step is a Management API v2 call through the CLI passthrough: + +```bash +auth0 api get "resource-servers?per_page=100" +auth0 api post "resource-servers" --data '{ "identifier": "...", "scopes": [ ... ] }' +auth0 api get "clients?per_page=100&include_fields=true&fields=client_id,name" +auth0 api post "clients" --data '{ "name": "Demo App Public", "app_type": "native", ... }' +auth0 api get "client-grants?client_id=...&audience=..." +auth0 api post "client-grants" --data '{ "client_id": "...", "audience": "...", "scope": [ ... ] }' +``` + +## Verify the device flow (RFC 8628) + +### curl + +```bash +set -a; source .env.livepeer; set +a +PUB=$DEMO_APP_AUTH0_PUBLIC_CLIENT_ID +curl -s -X POST "${AUTH0_ISSUER}oauth/device/code" \ + -d "client_id=$PUB" -d "audience=$DEMO_APP_AUTH0_AUDIENCE" \ + -d "scope=openid sign:job offline_access" +# open verification_uri_complete, approve, then poll /oauth/token with the device_code grant. +``` + +### Python (livepeer-gateway) + +From the [livepeer-gateway](https://github.com/livepeer/livepeer-gateway) repo (device code → cached bearer → optional `write_frames`): + +```bash +uv run examples/device_login.py \ + --issuer https://pymthouse.us.auth0.com \ + --client-id "$DEMO_APP_AUTH0_PUBLIC_CLIENT_ID" \ + --audience "$DEMO_APP_AUTH0_AUDIENCE" \ + --run-frames --signer http://localhost:8081 +``` + +Requires the clearinghouse stack (`identity-webhook`, `remote-signer`, `openmeter-collector`). Pass `--billing-url` and `--client-id` (public Auth0 client) so device login exchanges the Auth0 user token via RFC 8693 `POST …/api/v1/apps/{clientId}/oidc/token` — that upserts the OpenMeter customer (`{clientId}:{sub}`) and returns a minted signer JWT with `signer_url` / `discovery_url`. + +## Limitations + +- Client lookup pages up to 100 clients and matches by name (the clients endpoint has no + name filter); for very large tenants, extend the pagination loop. +- Scaffolds and updates only — it never deletes clients/grants removed from `apps.json`. +- Relies on the CLI session's permissions; reading the M2M secret needs the session to + hold `read:client_keys` (the default interactive `auth0 login` does). + +## Builder API follow-up + +The Go **Builder API** in `openmeter-collector` provisions Auth0 **end-users**. Re-run `./bootstrap.sh` to +ensure the **Clearinghouse Builder Management** M2M client is created and +`AUTH0_MGMT_CLIENT_ID` / `AUTH0_MGMT_CLIENT_SECRET` are written to `.env.livepeer` +(alongside the Demo App M2M used for signer-token mint). + +You still need a **Credentials Exchange Action** that copies `external_user_id` and `client_id` +into minted access tokens: + +```bash +./bootstrap-credentials-exchange-action.sh # idempotent; requires auth0 login +``` + +See [openmeter-collector/builder-api/README.md](../../openmeter-collector/builder-api/README.md) for claim details and [identity-webhook/.env.example](../../identity-webhook/.env.example) for OIDC verifier env. diff --git a/auth0-provisioner/provision/apps.json b/auth0-provisioner/provision/apps.json new file mode 100644 index 0000000..51fbd42 --- /dev/null +++ b/auth0-provisioner/provision/apps.json @@ -0,0 +1,34 @@ +{ + "resourceServer": { + "name": "Livepeer Clearinghouse API", + "identifier": "livepeer-clearinghouse", + "signing_alg": "RS256", + "scopes": [ + { "value": "sign:job", "description": "Sign payment tickets for the Livepeer remote signer" }, + { "value": "sign:mint_user_token", "description": "Mint short-lived user signer JWTs (M2M only)" }, + { "value": "users:read", "description": "Read provisioned application users" }, + { "value": "users:write", "description": "Create, update, and deactivate application users" }, + { "value": "users:token", "description": "Issue per-user access tokens (enables per-user billing)" }, + { "value": "device:approve", "description": "RFC 8693 token exchange to bind device codes (M2M only)" }, + { "value": "admin", "description": "Administrative access to provider surfaces" } + ] + }, + "apps": [ + { + "name": "Demo App", + "audience": "livepeer-clearinghouse", + "public": { + "grant_scopes": ["sign:job"], + "callbacks": [], + "initiate_login_uri": "" + }, + "m2m": { + "grant_scopes": ["users:write", "users:token", "device:approve", "sign:job", "sign:mint_user_token"] + } + } + ], + "managementClient": { + "name": "Clearinghouse Builder Management", + "grant_scopes": ["create:users", "read:users", "update:users"] + } +} diff --git a/auth0-provisioner/provision/bootstrap-credentials-exchange-action.sh b/auth0-provisioner/provision/bootstrap-credentials-exchange-action.sh new file mode 100755 index 0000000..c7d845c --- /dev/null +++ b/auth0-provisioner/provision/bootstrap-credentials-exchange-action.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Idempotent: create/deploy/bind the Credentials Exchange Action for signer JWT claims. +# Requires: auth0 login session (same as bootstrap.sh). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ACTION_NAME="Clearinghouse Signer Claims" +ACTION_CODE_FILE="${ACTION_CODE_FILE:-$SCRIPT_DIR/credentials-exchange-action.js}" +TRIGGER="credentials-exchange" + +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +info() { printf '%s\n' "$*" >&2; } + +[ -f "$ACTION_CODE_FILE" ] || die "action code not found: $ACTION_CODE_FILE" + +AUTH0_BIN="${AUTH0_BIN:-auth0}" +command -v "$AUTH0_BIN" >/dev/null 2>&1 || AUTH0_BIN="$SCRIPT_DIR/auth0" +[ -x "$AUTH0_BIN" ] || die "auth0 CLI required (run bootstrap.sh or install auth0 CLI)" + +ACTION_ID="$("$AUTH0_BIN" actions list --json 2>/dev/null | jq -r --arg n "$ACTION_NAME" '.[] | select(.name == $n) | .id' | head -n1)" + +if [ -z "$ACTION_ID" ]; then + info "creating action \"$ACTION_NAME\" ..." + ACTION_ID="$("$AUTH0_BIN" actions create -n "$ACTION_NAME" -t "$TRIGGER" -c "$(cat "$ACTION_CODE_FILE")" -r node22 --json | jq -r '.id')" + [ -n "$ACTION_ID" ] || die "failed to create action" + info "created action $ACTION_ID" +else + info "action \"$ACTION_NAME\" exists ($ACTION_ID)" +fi + +info "deploying action ..." +"$AUTH0_BIN" actions deploy "$ACTION_ID" >/dev/null + +BIND_BODY="$(jq -nc --arg id "$ACTION_ID" '{bindings:[{ref:{type:"action_id",value:$id}}]}')" +printf '%s' "$BIND_BODY" | "$AUTH0_BIN" api patch "actions/triggers/$TRIGGER/bindings" >/dev/null +info "bound action to $TRIGGER trigger" diff --git a/auth0-provisioner/provision/bootstrap.sh b/auth0-provisioner/provision/bootstrap.sh new file mode 100755 index 0000000..91e585e --- /dev/null +++ b/auth0-provisioner/provision/bootstrap.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# +# Scaffold the clearinghouse Auth0 client pair(s) using your `auth0 login` session. +# +# Idempotent: creates only what is missing — the resource server (API), the public +# (native, device-flow) + M2M (confidential) client pair, and their client grants. +# Matches existing objects by identifier (API) and name (clients), or by optional +# public.client_id / m2m.client_id in apps.json. Re-running reuses existing ids, +# updates grant scopes in place, and never duplicates. If provisioning an app +# fails mid-way, objects created during that run are rolled back (pre-existing +# clients/grants are left untouched). +# +# Requires: jq. The Auth0 CLI (https://github.com/auth0/auth0-cli) too — if it is +# missing the script offers to install it locally (or set AUTH0_INSTALL=1 +# to auto-install, AUTH0_BIN_DIR to choose where). +# Auth: your active `auth0 login` session. Run `auth0 login` first (and +# `auth0 tenants use ` to pick the tenant). No Management API +# client id/secret needed — every call rides the CLI session via `auth0 api`. +# +# Usage: +# auth0 login # one-time, interactive +# ./bootstrap.sh # scaffold everything in apps.json; writes .env.livepeer +# +# Config: apps.json (override with APPS=/path). Output: .env.livepeer (override OUTPUT=). +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APPS="${APPS:-$SCRIPT_DIR/apps.json}" +OUTPUT="${OUTPUT:-$SCRIPT_DIR/.env.livepeer}" + +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +info() { printf '%s\n' "$*" >&2; } +warn() { printf 'warning: %s\n' "$*" >&2; } + +command -v jq >/dev/null 2>&1 || die "jq not found" +[ -f "$APPS" ] || die "apps config not found: $APPS" + +# --- resolve the Auth0 CLI (PATH → local copy → offer to install) ----------- +# The official installer drops the binary into the chosen dir (it is NOT added to +# PATH), so we always invoke it by path via $AUTH0_BIN once resolved. +AUTH0_BIN_DIR="${AUTH0_BIN_DIR:-$SCRIPT_DIR}" +AUTH0_INSTALL_URL="https://raw.githubusercontent.com/auth0/auth0-cli/main/install.sh" + +confirm() { # confirm — true on yes + local prompt="$1" default="${2:-n}" reply="" + [ -t 0 ] || { [ "$default" = "y" ]; return; } + printf '%s ' "$prompt" >&2 + read -r reply + reply="${reply:-$default}" + case "$reply" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac +} + +resolve_auth0_cli() { + if command -v auth0 >/dev/null 2>&1; then AUTH0_BIN="auth0"; return; fi + if [ -x "$AUTH0_BIN_DIR/auth0" ]; then AUTH0_BIN="$AUTH0_BIN_DIR/auth0"; return; fi + + info "auth0 CLI not found (https://github.com/auth0/auth0-cli)." + local install="${AUTH0_INSTALL:-}" + if [ -z "$install" ]; then + confirm "Install the Auth0 CLI into $AUTH0_BIN_DIR now? [y/N]" n && install=1 || install=0 + fi + [ "$install" = "1" ] || die "auth0 CLI required. Install it with: + curl -sSfL $AUTH0_INSTALL_URL | sh -s -- -b \"$AUTH0_BIN_DIR\" +(or see https://github.com/auth0/auth0-cli), then re-run. Set AUTH0_INSTALL=1 to auto-install." + + command -v curl >/dev/null 2>&1 || die "curl not found — needed to install the auth0 CLI" + info "installing auth0 CLI into $AUTH0_BIN_DIR ..." + curl -sSfL "$AUTH0_INSTALL_URL" | sh -s -- -b "$AUTH0_BIN_DIR" >&2 || die "auth0 CLI install failed" + [ -x "$AUTH0_BIN_DIR/auth0" ] || die "auth0 CLI install did not produce $AUTH0_BIN_DIR/auth0" + AUTH0_BIN="$AUTH0_BIN_DIR/auth0" + info "installed $("$AUTH0_BIN" --version 2>/dev/null | head -n1)" +} + +ensure_auth0_session() { + "$AUTH0_BIN" api get "clients?per_page=1&include_fields=true&fields=client_id" >/dev/null 2>&1 && return + info "no active Auth0 session for '$AUTH0_BIN'." + if [ -t 0 ] && confirm "Run '$AUTH0_BIN login' now? [Y/n]" y; then + "$AUTH0_BIN" login || die "auth0 login failed" + "$AUTH0_BIN" api get "clients?per_page=1&include_fields=true&fields=client_id" >/dev/null 2>&1 \ + || die "still no active session after login — check 'auth0 tenants use '" + return + fi + die "run '$AUTH0_BIN login' (and '$AUTH0_BIN tenants use '), then re-run" +} + +resolve_auth0_cli +ensure_auth0_session + +# --- auth0 api helpers (Management API v2 passthrough; JSON body on stdout) --- +aapi_get() { "$AUTH0_BIN" api get "$1"; } +aapi_post() { printf '%s' "$2" | "$AUTH0_BIN" api post "$1"; } +aapi_patch() { printf '%s' "$2" | "$AUTH0_BIN" api patch "$1"; } +aapi_delete() { "$AUTH0_BIN" api delete "$1" --force >/dev/null 2>&1 || true; } + +# --- resource server (API) ------------------------------------------------- +ensure_resource_server() { + local identifier="$1" name="$2" signing_alg="$3" scopes_json="$4" existing_id body + existing_id="$(aapi_get "resource-servers?per_page=100" \ + | jq -r --arg id "$identifier" '.[] | select(.identifier == $id) | .id' | head -n1)" + if [ -n "$existing_id" ]; then + body="$(jq -nc --argjson s "$scopes_json" '{scopes: $s}')" + aapi_patch "resource-servers/$existing_id" "$body" >/dev/null + info "resource server $identifier: updated" + return + fi + body="$(jq -nc --arg name "$name" --arg id "$identifier" --arg alg "$signing_alg" --argjson s "$scopes_json" \ + '{name: $name, identifier: $id, signing_alg: $alg, allow_offline_access: true, + skip_consent_for_verifiable_first_party_clients: true, scopes: $s}')" + aapi_post "resource-servers" "$body" >/dev/null + info "resource server $identifier: created" +} + +# --- tenant device flow (RFC 8628) — best-effort --------------------------- +ensure_device_flow() { + local audience="$1" body + body="$(jq -nc --arg aud "$audience" \ + '{default_audience: $aud, device_flow: {charset: "base20", mask: "****-****"}}')" + if aapi_patch "tenants/settings" "$body" >/dev/null 2>&1; then + info "tenant: device flow enabled (default_audience=$audience)" + else + warn "tenant: could not set device-flow settings (session may lack update:tenant_settings) — skipping" + fi +} + +# --- clients --------------------------------------------------------------- +client_id_by_name() { + aapi_get "clients?per_page=100&include_fields=true&fields=client_id,name" \ + | jq -r --arg n "$1" '.[] | select(.name == $n) | .client_id' | head -n1 +} + +client_id_exists() { + aapi_get "clients/$1?include_fields=true&fields=client_id" \ + | jq -e -r '.client_id // empty' >/dev/null 2>&1 +} + +# ensure_client [extra_json] [configured_id] +# Sets ENSURED_CLIENT_ID and ENSURED_CLIENT_CREATED (0=reused, 1=created). Returns 1 on failure. +ensure_client() { + local name="$1" app_type="$2" auth_method="$3" grants="$4" extra="${5:-"{}"}" configured_id="${6:-}" cid body + ENSURED_CLIENT_CREATED=0 + + if [ -n "$configured_id" ]; then + if client_id_exists "$configured_id"; then + cid="$configured_id" + info "client \"$name\": using configured id $cid" + ENSURED_CLIENT_ID="$cid" + return 0 + fi + warn "client \"$name\": configured id $configured_id not found — falling back to name lookup" + fi + + cid="$(client_id_by_name "$name")" + if [ -n "$cid" ]; then + info "client \"$name\": exists ($cid)" + ENSURED_CLIENT_ID="$cid" + return 0 + fi + + body="$(jq -nc --arg name "$name" --arg t "$app_type" --arg am "$auth_method" \ + --argjson g "$grants" --argjson x "$extra" \ + '{name: $name, app_type: $t, token_endpoint_auth_method: $am, oidc_conformant: true, grant_types: $g} + $x')" \ + || return 1 + cid="$(aapi_post "clients" "$body" | jq -r '.client_id // empty')" + [ -n "$cid" ] || return 1 + info "client \"$name\": created ($cid)" + ENSURED_CLIENT_ID="$cid" + ENSURED_CLIENT_CREATED=1 + return 0 +} + +client_secret() { + aapi_get "clients/$1?include_fields=true&fields=client_secret" | jq -r '.client_secret // empty' +} + +# --- client grants --------------------------------------------------------- +# Sets ENSURED_GRANT_ID and ENSURED_GRANT_CREATED (0=reused, 1=created). Returns 1 on failure. +ensure_client_grant() { + local client_id="$1" audience="$2" scopes_json="$3" gid body resp + ENSURED_GRANT_CREATED=0 + gid="$(aapi_get "client-grants?client_id=${client_id}&audience=${audience}" | jq -r '.[0].id // empty')" + if [ -n "$gid" ]; then + body="$(jq -nc --argjson s "$scopes_json" '{scope: $s}')" || return 1 + aapi_patch "client-grants/$gid" "$body" >/dev/null || return 1 + info "grant ($client_id -> $audience): updated" + ENSURED_GRANT_ID="$gid" + return 0 + fi + body="$(jq -nc --arg c "$client_id" --arg a "$audience" --argjson s "$scopes_json" \ + '{client_id: $c, audience: $a, scope: $s}')" || return 1 + resp="$(aapi_post "client-grants" "$body")" || return 1 + gid="$(jq -r '.id // empty' <<<"$resp")" + [ -n "$gid" ] || return 1 + info "grant ($client_id -> $audience): created" + ENSURED_GRANT_ID="$gid" + ENSURED_GRANT_CREATED=1 + return 0 +} + +# provision_app — idempotent per app; rolls back objects created this run on failure. +provision_app() { + local app="$1" name audience pub_scopes pub_callbacks pub_initiate m2m_scopes pub_extra + local pub_id="" m2m_id="" m2m_secret="" pub_grant_id="" m2m_grant_id="" + local created_pub=0 created_m2m=0 created_pub_grant=0 created_m2m_grant=0 + local pub_configured_id="" m2m_configured_id="" + + rollback_app() { + info "rolling back \"$name\" (objects created this run only)" + [ "$created_m2m_grant" = 1 ] && [ -n "$m2m_grant_id" ] && aapi_delete "client-grants/$m2m_grant_id" + [ "$created_pub_grant" = 1 ] && [ -n "$pub_grant_id" ] && aapi_delete "client-grants/$pub_grant_id" + [ "$created_m2m" = 1 ] && [ -n "$m2m_id" ] && aapi_delete "clients/$m2m_id" + [ "$created_pub" = 1 ] && [ -n "$pub_id" ] && aapi_delete "clients/$pub_id" + } + + name="$(jq -r '.name' <<<"$app")" + audience="$(jq -r '.audience // empty' <<<"$app")" + [ -n "$audience" ] || audience="$RS_ID" + pub_scopes="$(jq -c '.public.grant_scopes' <<<"$app")" + pub_callbacks="$(jq -c '.public.callbacks // []' <<<"$app")" + pub_initiate="$(jq -r '.public.initiate_login_uri // ""' <<<"$app")" + m2m_scopes="$(jq -c '.m2m.grant_scopes' <<<"$app")" + pub_configured_id="$(jq -r '.public.client_id // empty' <<<"$app")" + m2m_configured_id="$(jq -r '.m2m.client_id // empty' <<<"$app")" + + info "=== $name ===" + + pub_extra="$(jq -nc --argjson cb "$pub_callbacks" --arg iu "$pub_initiate" \ + '{is_first_party: true, callbacks: $cb} + (if $iu == "" then {} else {initiate_login_uri: $iu} end)')" \ + || { rollback_app; die "failed to build public client config for \"$name\""; } + + ensure_client "$name Public" native none \ + '["urn:ietf:params:oauth:grant-type:device_code","refresh_token"]' "$pub_extra" "$pub_configured_id" \ + || { rollback_app; die "failed to ensure public client for \"$name\""; } + pub_id="$ENSURED_CLIENT_ID" + created_pub="$ENSURED_CLIENT_CREATED" + + ensure_client "$name M2M" non_interactive client_secret_post '["client_credentials"]' '{}' "$m2m_configured_id" \ + || { rollback_app; die "failed to ensure M2M client for \"$name\""; } + m2m_id="$ENSURED_CLIENT_ID" + created_m2m="$ENSURED_CLIENT_CREATED" + + m2m_secret="$(client_secret "$m2m_id")" + [ -n "$m2m_secret" ] || { rollback_app; die "failed to read M2M secret for \"$name\""; } + + ensure_client_grant "$pub_id" "$audience" "$pub_scopes" \ + || { rollback_app; die "failed to ensure public client grant for \"$name\""; } + pub_grant_id="$ENSURED_GRANT_ID" + created_pub_grant="$ENSURED_GRANT_CREATED" + + ensure_client_grant "$m2m_id" "$audience" "$m2m_scopes" \ + || { rollback_app; die "failed to ensure M2M client grant for \"$name\""; } + m2m_grant_id="$ENSURED_GRANT_ID" + created_m2m_grant="$ENSURED_GRANT_CREATED" + + P="$(env_prefix "$name")" + { + printf '# %s\n' "$name" + printf '%s_AUTH0_AUDIENCE=%s\n' "$P" "$audience" + printf '%s_AUTH0_PUBLIC_CLIENT_ID=%s\n' "$P" "$pub_id" + printf '%s_AUTH0_M2M_CLIENT_ID=%s\n' "$P" "$m2m_id" + printf '%s_AUTH0_M2M_CLIENT_SECRET=%s\n\n' "$P" "$m2m_secret" + } >> "$OUTPUT" +} + +env_prefix() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | sed -E 's/[^A-Z0-9]+/_/g; s/^_+|_+$//g'; } + +# ensure_management_client — M2M for Builder API user provisioning. +ensure_management_client() { + local domain="$1" name scopes_json mgmt_id mgmt_secret audience created + if [ "$(jq -r '.managementClient.enabled // true' "$APPS")" != "true" ]; then + info "management client: disabled in apps.json — skipping" + return 0 + fi + name="$(jq -r '.managementClient.name // "Clearinghouse Builder Management"' "$APPS")" + scopes_json="$(jq -c '.managementClient.grant_scopes // ["create:users","read:users","update:users"]' "$APPS")" + audience="https://${domain}/api/v2/" + + info "=== $name (Management API) ===" + ensure_client "$name" non_interactive client_secret_post '["client_credentials"]' '{}' \ + || die "failed to ensure management client" + mgmt_id="$ENSURED_CLIENT_ID" + created="$ENSURED_CLIENT_CREATED" + + mgmt_secret="$(client_secret "$mgmt_id")" + [ -n "$mgmt_secret" ] || die "failed to read management client secret" + + ensure_client_grant "$mgmt_id" "$audience" "$scopes_json" \ + || die "failed to ensure management client grant" + + { + printf '# Builder API — Auth0 Management API M2M\n' + printf 'AUTH0_MGMT_CLIENT_ID=%s\n' "$mgmt_id" + printf 'AUTH0_MGMT_CLIENT_SECRET=%s\n\n' "$mgmt_secret" + } >> "$OUTPUT" + if [ "$created" = 1 ]; then + info "management client: created ($mgmt_id)" + else + info "management client: exists ($mgmt_id)" + fi +} + +# --- main ------------------------------------------------------------------ +RS_ID="$(jq -r '.resourceServer.identifier' "$APPS")" +RS_NAME="$(jq -r '.resourceServer.name' "$APPS")" +RS_ALG="$(jq -r '.resourceServer.signing_alg // "RS256"' "$APPS")" +RS_SCOPES="$(jq -c '.resourceServer.scopes' "$APPS")" +[ "$RS_ID" != "null" ] || die "apps.json: resourceServer.identifier is required" + +ensure_resource_server "$RS_ID" "$RS_NAME" "$RS_ALG" "$RS_SCOPES" +ensure_device_flow "$RS_ID" + +DOMAIN="$("$AUTH0_BIN" tenants list --json 2>/dev/null | jq -r '.[] | select(.active == true) | .name' | head -n1)" + +{ + printf '# Generated by auth0-provisioner/provision/bootstrap.sh — contains secrets, do not commit\n\n' + [ -n "$DOMAIN" ] && printf 'AUTH0_DOMAIN=%s\nAUTH0_ISSUER=https://%s/\nAUTH0_JWKS_URL=https://%s/.well-known/jwks.json\n\n' "$DOMAIN" "$DOMAIN" "$DOMAIN" +} > "$OUTPUT" + +[ -n "$DOMAIN" ] && ensure_management_client "$DOMAIN" + +NAPPS="$(jq '.apps | length' "$APPS")" +for i in $(seq 0 $((NAPPS - 1))); do + APP="$(jq -c ".apps[$i]" "$APPS")" + provision_app "$APP" +done + +info "wrote $OUTPUT ($NAPPS app pair(s))" diff --git a/auth0-provisioner/provision/credentials-exchange-action.js b/auth0-provisioner/provision/credentials-exchange-action.js new file mode 100644 index 0000000..039091e --- /dev/null +++ b/auth0-provisioner/provision/credentials-exchange-action.js @@ -0,0 +1,13 @@ +/** + * Auth0 Credentials Exchange Action — copy claims from token request into access token. + * Deploy: auth0-provisioner/provision/bootstrap-credentials-exchange-action.sh + */ +exports.onExecuteCredentialsExchange = async (event, api) => { + const externalUserId = event.request?.body?.external_user_id; + const clientId = event.request?.body?.client_id; + if (!externalUserId || !clientId) { + return; + } + api.accessToken.setCustomClaim("external_user_id", externalUserId); + api.accessToken.setCustomClaim("app_client_id", clientId); +}; diff --git a/docker-compose.yml b/docker-compose.yml index 5012e23..ee1d78c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,25 @@ services: - ./.env:/service/.env:ro - ./remote-signer/data:/data + konnect-credentials: + build: + context: . + dockerfile: konnect-credentials/Dockerfile + restart: unless-stopped + env_file: + - ./.env + environment: + PORT: "8091" + DATA_DIR: /data + volumes: + - konnect-credentials-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8091/health"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + openmeter-collector: build: context: . @@ -72,8 +91,18 @@ services: depends_on: kafka: condition: service_healthy + konnect-credentials: + condition: service_healthy + ports: + - "127.0.0.1:8095:8095" + environment: + KONNECT_CREDENTIALS_URL: http://konnect-credentials:8091 volumes: - ./.env:/service/.env:ro + - ./auth0-provisioner/provision/.env.livepeer:/service/.env.livepeer:ro # Signer datadir is bind-mounted from remote-signer/data. # Copy keystore + .eth-password into remote-signer/data before first run. + +volumes: + konnect-credentials-data: diff --git a/identity-webhook/.env.example b/identity-webhook/.env.example new file mode 100644 index 0000000..474e403 --- /dev/null +++ b/identity-webhook/.env.example @@ -0,0 +1,27 @@ +# Identity webhook service env — copied to identity-webhook/.env at runtime. +# +# cp identity-webhook/.env.example identity-webhook/.env + +# Local dev only — change before any shared or production use. +WEBHOOK_SECRET=dev-webhook-secret-change-me +IDENTITY_ISSUER=http://identity-webhook:8090 + +# Demo API key resolved by the webhook (must match remote-signer end-user Bearer token). +# DEMO_CLIENT_ID should match the Auth0 public client id (DEMO_APP_AUTH0_PUBLIC_CLIENT_ID). +DEMO_API_KEY=sk_demo_local_key +DEMO_CLIENT_ID=xEJfZBtEP0JLJtlXm9UnJrDrA9bwepLx +DEMO_USER_ID=demo-user +USAGE_SUBJECT_TYPE=api_key_user +# API_KEY_PREFIX=sk_ +# DEMO_API_KEYS={"sk_other":{"clientId":"app-b","userId":"user-b"}} + +# --- OAuth / OIDC (Auth0 signer JWTs from Builder API exchange) --- +# When both OIDC and API keys are configured, JWTs are tried first, then sk_ keys. +OIDC_ISSUER=https://pymthouse.us.auth0.com/ +OIDC_AUDIENCE=livepeer-clearinghouse +# OIDC_JWKS_URI= # defaults to ${OIDC_ISSUER}/.well-known/jwks.json +OIDC_CLIENT_CLAIM=app_client_id +OIDC_SUBJECT_CLAIM=external_user_id +# Device-code user tokens lack the custom claims above; verifier falls back to sub / azp. +OIDC_SUBJECT_TYPE=external_user_id +OIDC_REQUIRED_SCOPES=sign:job diff --git a/identity-webhook/scripts/bootstrap-publish.sh b/identity-webhook/scripts/bootstrap-publish.sh new file mode 100755 index 0000000..b0f9ef4 --- /dev/null +++ b/identity-webhook/scripts/bootstrap-publish.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# One-time first publish for @pymthouse/clearinghouse-identity-webhook. +# npm OIDC cannot create a new package; use this once, then configure trusted publishing. +set -euo pipefail + +cd "$(dirname "$0")/../identity-webhook" + +npm ci +npm test + +if [[ -z "${NPM_OTP:-}" ]]; then + echo "Enter the 6-digit code from your authenticator app for npm 2FA:" + read -r NPM_OTP +fi + +npm publish --access public --ignore-scripts --otp="${NPM_OTP}" + +echo "" +echo "Published. Next steps:" +echo "1. https://www.npmjs.com/package/@pymthouse/clearinghouse-identity-webhook/access" +echo " → Trusted publishing → GitHub Actions" +echo " → Repository: pymthouse/clearinghouse-runtime" +echo " → Workflow: release.yml" +echo "2. Future releases: push v*.*.* tags (release.yml uses OIDC)" diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 968c6bc..22f10ed 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -125,9 +125,9 @@ export function createOidcVerifier({ } } - const usageSubject = payload[subjectClaim]; + const usageSubject = payload[subjectClaim] ?? payload.sub; if (!usageSubject) { - throw new WebhookError(`token missing ${subjectClaim} claim`, { + throw new WebhookError(`token missing ${subjectClaim} or sub claim`, { status: 401, code: "invalid_token", }); @@ -135,7 +135,7 @@ export function createOidcVerifier({ const identity = { issuer: identityIssuer, - client_id: String(payload[clientClaim] ?? jwtAudience), + client_id: String(payload[clientClaim] ?? payload.azp ?? jwtAudience), usage_subject: String(usageSubject), usage_subject_type: subjectTypeValue, }; diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 3993ae7..a021366 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -117,6 +117,37 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { }); await assert.rejects(() => verifier.verify({ authorization: "Bearer sk_not_a_jwt" }), /not a JWT/); }); + + it("prefers custom subject/client claims with sub/azp fallback (device vs exchange)", async () => { + const { privateKey, jwks } = await setup(); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks, + clientClaim: "app_client_id", + subjectClaim: "external_user_id", + }); + + const exchangeToken = await mint(privateKey, { + sub: "auth0|ignored", + azp: "ignored", + external_user_id: "demo-user", + app_client_id: "pub-client", + scope: "sign:job", + }); + const exchange = await verifier.verify({ authorization: `Bearer ${exchangeToken}` }); + assert.equal(exchange.identity.client_id, "pub-client"); + assert.equal(exchange.identity.usage_subject, "demo-user"); + + const deviceToken = await mint(privateKey, { + sub: "auth0|device-user", + azp: "pub-client", + scope: "sign:job", + }); + const device = await verifier.verify({ authorization: `Bearer ${deviceToken}` }); + assert.equal(device.identity.client_id, "pub-client"); + assert.equal(device.identity.usage_subject, "auth0|device-user"); + }); }); describe("createEndUserVerifierFromEnv", () => { diff --git a/konnect-credentials/Dockerfile b/konnect-credentials/Dockerfile new file mode 100644 index 0000000..6d495d2 --- /dev/null +++ b/konnect-credentials/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine + +RUN apk add --no-cache curl + +WORKDIR /app + +COPY konnect-credentials/package.json ./ +RUN npm install --omit=dev + +COPY konnect-credentials/*.mjs ./ +COPY konnect-credentials/catalog.json ./ + +ENV PORT=8091 +ENV DATA_DIR=/data + +VOLUME ["/data"] +EXPOSE 8091 + +CMD ["node", "server.mjs"] diff --git a/konnect-credentials/README.md b/konnect-credentials/README.md new file mode 100644 index 0000000..662f63b --- /dev/null +++ b/konnect-credentials/README.md @@ -0,0 +1,120 @@ +# Konnect credentials service + +Minimal clearinghouse API that **binds a per-tenant Konnect organization** and +issues two SPATs for the tenant (Provisioner + Usage), while retaining an +**Ingest** SPAT for the openmeter-collector. Tenants call the Konnect Metering & +Billing API directly — this service does **not** mirror OpenMeter. + +## Why per-tenant orgs? + +Konnect Metering roles (`Metering Viewer`, `Billing Admin`, …) are **org-wide**. +A SPAT in a shared org can see every customer. Isolation requires one Konnect +org per platform `client_id`. + +Konnect has **no public Create Organization API**. Tenants (or platform ops) +create the org out-of-band, then **bind** it here with an admin PAT. + +## Access levels + +| Credential | Roles (`entity_type_name: Metering`) | Holder | +| --- | --- | --- | +| Provisioner SPAT | Billing Admin, Product Catalog Admin, Metering Admin | Tenant | +| Usage SPAT | Metering Viewer, Billing Viewer | Tenant | +| Ingest SPAT | Ingest | Clearinghouse only | + +## API + +All routes (except `/health`) require `Authorization: Bearer ` +or `x-api-key: `. + +### Bind org + +```http +POST /v1/tenants/{clientId}/konnect/bind +Content-Type: application/json + +{ "region": "us", "admin_token": "kpat_…" } +``` + +Validates the token via `GET /organizations/me`, stores encrypted admin token. + +### Issue SPATs + +```http +POST /v1/tenants/{clientId}/konnect/credentials +``` + +Creates `ch-provisioner`, `ch-usage`, `ch-ingest` system accounts, assigns roles, +mints SPATs. **Provisioner and Usage secrets are returned once.** Ingest is stored +encrypted for the collector and is not returned. + +### Bootstrap catalog + +```http +POST /v1/tenants/{clientId}/konnect/catalog +``` + +Idempotently creates meters / features / default plan from `catalog.json` +(same catalog as `openmeter-collector/provision`). + +### Rotate / revoke + +```http +POST /v1/tenants/{clientId}/konnect/credentials/rotate +{ "kind": "provisioner" | "usage" | "ingest" } + +DELETE /v1/tenants/{clientId}/konnect/credentials/{kind} +``` + +### Collector ingest lookup + +```http +GET /v1/internal/tenants/{clientId}/ingest +``` + +Returns `{ url, token, region, org_id }` for CloudEvent POST. + +### Builder-api OpenMeter lookup + +```http +GET /v1/internal/tenants/{clientId}/openmeter +``` + +Returns `{ openmeter_base, token, region, org_id }` where `token` is the bound +org admin PAT (for thin customer/subscription/balance calls). Does **not** return +Provisioner or Usage SPATs. + +## Env + +| Variable | Purpose | +| --- | --- | +| `PLATFORM_API_SECRET` | M2M auth for this API | +| `CREDENTIALS_ENCRYPTION_KEY` | AES key (64-hex, 32-byte base64, or passphrase) | +| `DATA_DIR` | Tenant JSON store (default `./data`) | +| `PORT` | Listen port (default `8091`) | +| `CATALOG_PATH` | Optional override for catalog.json | +| `KONNECT_IDENTITY_BASE` | Default `https://global.api.konghq.com/v2` | + +## Local + +```bash +export PLATFORM_API_SECRET=dev-platform-secret +export CREDENTIALS_ENCRYPTION_KEY=dev-encryption-passphrase +node server.mjs +``` + +Tests: + +```bash +npm test +``` + +## BYO org bind flow + +1. Tenant creates a Konnect org (signup UI) in the desired region. +2. Tenant generates an org admin PAT (`kpat_…`) with Metering & Billing access. +3. Platform calls `POST …/konnect/bind` with that PAT. +4. Platform calls `POST …/konnect/credentials` and delivers Provisioner + Usage SPATs to the tenant (store securely; shown once). +5. Platform calls `POST …/konnect/catalog`. +6. Tenant uses SPATs against `https://{region}.api.konghq.com/v3/openmeter/*`. +7. Collector resolves ingest via `GET …/internal/tenants/{clientId}/ingest`. diff --git a/konnect-credentials/catalog.json b/konnect-credentials/catalog.json new file mode 100644 index 0000000..1b7bccc --- /dev/null +++ b/konnect-credentials/catalog.json @@ -0,0 +1,83 @@ +{ + "$comment": "OpenMeter/Konnect metering catalog for the clearinghouse collector. Provisioned idempotently by bootstrap.sh / bootstrap.ps1. Meters are immutable in OpenMeter — the scripts only create missing entries, they never update or delete. Dimensions/value_property must match the CloudEvent fields emitted by openmeter-collector/collector.yaml.", + "meters": [ + { + "key": "network_fee_usd_micros", + "name": "Network fee (USD micros)", + "description": "Raw network cost in USD micros from the signer (observability).", + "event_type": "create_signed_ticket", + "aggregation": "sum", + "value_property": "$.network_fee_usd_micros", + "dimensions": { + "client_id": "$.client_id", + "external_user_id": "$.external_user_id", + "model_id": "$.model_id", + "pipeline": "$.pipeline" + } + }, + { + "key": "billable_usd_micros", + "name": "Billable usage (USD micros)", + "description": "Post-markup billable amount in USD micros for pay-per-use billing. Not emitted by the collector until phase-2 markup; meter stays empty until then.", + "event_type": "create_signed_ticket", + "aggregation": "sum", + "value_property": "$.billable_usd_micros", + "dimensions": { + "client_id": "$.client_id", + "external_user_id": "$.external_user_id", + "model_id": "$.model_id", + "pipeline": "$.pipeline" + } + }, + { + "key": "signed_ticket_count", + "name": "Signed ticket count", + "description": "Count of create_signed_ticket events (request volume).", + "event_type": "create_signed_ticket", + "aggregation": "count", + "dimensions": { + "client_id": "$.client_id", + "external_user_id": "$.external_user_id", + "model_id": "$.model_id", + "pipeline": "$.pipeline" + } + } + ], + "features": [ + { + "key": "network_spend", + "name": "Network spend", + "meter_key": "network_fee_usd_micros" + }, + { + "key": "billable_spend", + "name": "Billable spend", + "meter_key": "billable_usd_micros" + } + ], + "plan": { + "key": "clearinghouse_default_ppu", + "name": "Clearinghouse default pay-per-use", + "description": "Default pay-per-use plan billed on billable_usd_micros usage (1 USD micro = $0.000001).", + "currency": "USD", + "billing_cadence": "P1M", + "phases": [ + { + "key": "default", + "name": "Default", + "rate_cards": [ + { + "key": "billable_spend", + "name": "Billable spend", + "feature_key": "billable_spend", + "billing_cadence": "P1M", + "price": { + "type": "unit", + "amount": "0.000001" + } + } + ] + } + ] + } +} diff --git a/konnect-credentials/catalog.mjs b/konnect-credentials/catalog.mjs new file mode 100644 index 0000000..3b4690d --- /dev/null +++ b/konnect-credentials/catalog.mjs @@ -0,0 +1,189 @@ +import { readFile } from "node:fs/promises"; +import { unwrapList, unwrapOne } from "./konnect-client.mjs"; + +/** + * Idempotent catalog bootstrap against a tenant org (mirrors bootstrap.sh catalog). + */ +export async function bootstrapCatalog(client, catalogPath) { + const catalog = JSON.parse(await readFile(catalogPath, "utf8")); + const meters = await ensureMeters(client, catalog.meters || []); + const features = await ensureFeatures(client, catalog.features || []); + const plan = catalog.plan + ? await ensurePlan(client, catalog.plan) + : null; + return { + meters, + features, + plan, + }; +} + +async function ensureMeters(client, meters) { + const existing = unwrapList(await client.listMeters()); + const byKey = new Map(existing.map((m) => [m.key || m.slug, m])); + const results = []; + for (const meter of meters) { + if (byKey.has(meter.key)) { + results.push({ key: meter.key, status: "exists", id: byKey.get(meter.key).id }); + continue; + } + const body = { + slug: meter.key, + name: meter.name, + description: meter.description, + eventType: meter.event_type, + aggregation: meter.aggregation, + valueProperty: meter.value_property, + groupBy: meter.dimensions || undefined, + }; + // Konnect may expect snake_case — try camel first, fall back. + try { + const created = unwrapOne(await client.createMeter(body)); + results.push({ key: meter.key, status: "created", id: created.id }); + } catch (err) { + if (err.status === 400 || err.status === 422) { + const snake = { + slug: meter.key, + name: meter.name, + description: meter.description, + event_type: meter.event_type, + aggregation: meter.aggregation, + value_property: meter.value_property, + group_by: meter.dimensions || undefined, + }; + const created = unwrapOne(await client.createMeter(snake)); + results.push({ key: meter.key, status: "created", id: created.id }); + } else if (err.status === 409) { + results.push({ key: meter.key, status: "exists" }); + } else { + throw err; + } + } + } + return results; +} + +async function ensureFeatures(client, features) { + const existing = unwrapList(await client.listFeatures()); + const byKey = new Map(existing.map((f) => [f.key, f])); + const results = []; + for (const feature of features) { + if (byKey.has(feature.key)) { + results.push({ key: feature.key, status: "exists", id: byKey.get(feature.key).id }); + continue; + } + const body = { + key: feature.key, + name: feature.name, + meterSlug: feature.meter_key, + }; + try { + const created = unwrapOne(await client.createFeature(body)); + results.push({ key: feature.key, status: "created", id: created.id }); + } catch (err) { + if (err.status === 400 || err.status === 422) { + const snake = { + key: feature.key, + name: feature.name, + meter_slug: feature.meter_key, + }; + const created = unwrapOne(await client.createFeature(snake)); + results.push({ key: feature.key, status: "created", id: created.id }); + } else if (err.status === 409) { + results.push({ key: feature.key, status: "exists" }); + } else { + throw err; + } + } + } + return results; +} + +async function ensurePlan(client, plan) { + const existing = unwrapList(await client.listPlans()); + const found = existing.find((p) => p.key === plan.key); + if (found) { + const version = found.version || found.versions?.[0]; + const status = found.status || version?.status; + if (status === "active" || status === "published") { + return { key: plan.key, status: "exists", id: found.id }; + } + // Try publish if draft + if (found.id) { + try { + await client.publishPlan(found.id); + return { key: plan.key, status: "published", id: found.id }; + } catch { + return { key: plan.key, status: "exists", id: found.id }; + } + } + return { key: plan.key, status: "exists", id: found.id }; + } + + const phase = plan.phases?.[0]; + const rateCards = (phase?.rate_cards || []).map((rc) => ({ + type: "usage_based", + key: rc.key, + name: rc.name, + featureKey: rc.feature_key, + billingCadence: rc.billing_cadence || plan.billing_cadence, + price: rc.price, + })); + + const body = { + key: plan.key, + name: plan.name, + description: plan.description, + currency: plan.currency || "USD", + billingCadence: plan.billing_cadence, + phases: [ + { + key: phase?.key || "default", + name: phase?.name || "Default", + rateCards, + }, + ], + }; + + let created; + try { + created = unwrapOne(await client.createPlan(body)); + } catch (err) { + if (err.status === 400 || err.status === 422) { + const snake = { + key: plan.key, + name: plan.name, + description: plan.description, + currency: plan.currency || "USD", + billing_cadence: plan.billing_cadence, + phases: [ + { + key: phase?.key || "default", + name: phase?.name || "Default", + rate_cards: (phase?.rate_cards || []).map((rc) => ({ + type: "usage_based", + key: rc.key, + name: rc.name, + feature_key: rc.feature_key, + billing_cadence: rc.billing_cadence || plan.billing_cadence, + price: rc.price, + })), + }, + ], + }; + created = unwrapOne(await client.createPlan(snake)); + } else { + throw err; + } + } + + if (created?.id) { + try { + await client.publishPlan(created.id); + return { key: plan.key, status: "created_published", id: created.id }; + } catch { + return { key: plan.key, status: "created", id: created.id }; + } + } + return { key: plan.key, status: "created", id: created?.id }; +} diff --git a/konnect-credentials/crypto.mjs b/konnect-credentials/crypto.mjs new file mode 100644 index 0000000..66849a8 --- /dev/null +++ b/konnect-credentials/crypto.mjs @@ -0,0 +1,56 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +const ALGO = "aes-256-gcm"; +const IV_LEN = 12; +const TAG_LEN = 16; +const KEY_LEN = 32; + +/** + * Derive a 32-byte key from CREDENTIALS_ENCRYPTION_KEY. + * Accepts 64-char hex, base64 (32 bytes), or any passphrase (SHA-256). + */ +export function deriveKey(secret) { + const raw = String(secret || "").trim(); + if (!raw) { + throw new Error("CREDENTIALS_ENCRYPTION_KEY is required"); + } + if (/^[0-9a-fA-F]{64}$/.test(raw)) { + return Buffer.from(raw, "hex"); + } + try { + const b64 = Buffer.from(raw, "base64"); + if (b64.length === KEY_LEN) { + return b64; + } + } catch { + // fall through to hash + } + return createHash("sha256").update(raw, "utf8").digest(); +} + +export function encrypt(plaintext, key) { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv(ALGO, key, iv); + const enc = Buffer.concat([ + cipher.update(String(plaintext), "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString("base64"); +} + +export function decrypt(payload, key) { + const buf = Buffer.from(String(payload), "base64"); + if (buf.length < IV_LEN + TAG_LEN + 1) { + throw new Error("invalid ciphertext"); + } + const iv = buf.subarray(0, IV_LEN); + const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN); + const data = buf.subarray(IV_LEN + TAG_LEN); + const decipher = createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([ + decipher.update(data), + decipher.final(), + ]).toString("utf8"); +} diff --git a/konnect-credentials/konnect-client.mjs b/konnect-credentials/konnect-client.mjs new file mode 100644 index 0000000..dae91d1 --- /dev/null +++ b/konnect-credentials/konnect-client.mjs @@ -0,0 +1,179 @@ +/** + * Thin Konnect Identity + OpenMeter HTTP client. + * Identity: https://global.api.konghq.com/v2 + * OpenMeter: https://{region}.api.konghq.com/v3/openmeter + */ + +const DEFAULT_IDENTITY_BASE = "https://global.api.konghq.com/v2"; + +export function openMeterBase(region) { + const r = String(region || "us").trim().toLowerCase(); + return `https://${r}.api.konghq.com/v3/openmeter`; +} + +export function ingestUrl(region) { + return `${openMeterBase(region)}/events`; +} + +export function createKonnectClient({ + token, + region = "us", + identityBase = DEFAULT_IDENTITY_BASE, + fetchImpl = globalThis.fetch, +}) { + if (!token) { + throw new Error("Konnect token is required"); + } + + async function request(base, method, path, body) { + const url = `${base.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }; + let payload; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + payload = JSON.stringify(body); + } + const res = await fetchImpl(url, { + method, + headers, + body: payload, + }); + const text = await res.text(); + let json = null; + if (text) { + try { + json = JSON.parse(text); + } catch { + json = { raw: text }; + } + } + if (!res.ok) { + const err = new Error( + `Konnect ${method} ${path} failed: ${res.status} ${summarizeError(json)}`, + ); + err.status = res.status; + err.body = json; + throw err; + } + return json; + } + + const identity = (method, path, body) => request(identityBase, method, path, body); + const openmeter = (method, path, body) => + request(openMeterBase(region), method, path, body); + + return { + region, + identityBase, + openMeterBase: openMeterBase(region), + ingestUrl: ingestUrl(region), + + getOrganizationMe() { + return identity("GET", "/organizations/me"); + }, + + listSystemAccounts() { + return identity("GET", "/system-accounts"); + }, + + createSystemAccount({ + name, + description, + }) { + return identity("POST", "/system-accounts", { + name, + description, + }); + }, + + deleteSystemAccount(accountId) { + return identity("DELETE", `/system-accounts/${accountId}`); + }, + + listAssignedRoles(accountId) { + return identity("GET", `/system-accounts/${accountId}/assigned-roles`); + }, + + assignRole(accountId, assignment) { + return identity("POST", `/system-accounts/${accountId}/assigned-roles`, assignment); + }, + + createAccessToken(accountId, { + name, + }) { + return identity("POST", `/system-accounts/${accountId}/access-tokens`, { + name, + }); + }, + + deleteAccessToken(accountId, tokenId) { + return identity("DELETE", `/system-accounts/${accountId}/access-tokens/${tokenId}`); + }, + + listAccessTokens(accountId) { + return identity("GET", `/system-accounts/${accountId}/access-tokens`); + }, + + listMeters() { + return openmeter("GET", "/meters"); + }, + + createMeter(body) { + return openmeter("POST", "/meters", body); + }, + + listFeatures() { + return openmeter("GET", "/features"); + }, + + createFeature(body) { + return openmeter("POST", "/features", body); + }, + + listPlans() { + return openmeter("GET", "/plans"); + }, + + createPlan(body) { + return openmeter("POST", "/plans", body); + }, + + publishPlan(planId) { + return openmeter("POST", `/plans/${planId}/publish`, {}); + }, + }; +} + +function summarizeError(json) { + if (!json || typeof json !== "object") { + return ""; + } + return json.message || json.detail || json.title || JSON.stringify(json).slice(0, 200); +} + +/** Unwrap Konnect list envelopes: { data: [...] } or bare array. */ +export function unwrapList(payload) { + if (Array.isArray(payload)) { + return payload; + } + if (payload && Array.isArray(payload.data)) { + return payload.data; + } + if (payload && Array.isArray(payload.items)) { + return payload.items; + } + return []; +} + +export function unwrapOne(payload) { + if (!payload || typeof payload !== "object") { + return payload; + } + if (payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)) { + return payload.data; + } + return payload; +} diff --git a/konnect-credentials/package.json b/konnect-credentials/package.json new file mode 100644 index 0000000..ac6a466 --- /dev/null +++ b/konnect-credentials/package.json @@ -0,0 +1,25 @@ +{ + "name": "@pymthouse/clearinghouse-konnect-credentials", + "version": "0.1.0", + "description": "Bind per-tenant Konnect orgs and issue Provisioner / Usage / Ingest SPATs", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node server.mjs", + "test": "node --test" + }, + "files": [ + "*.mjs", + "catalog.json" + ], + "keywords": [ + "livepeer", + "clearinghouse", + "konnect", + "openmeter", + "spat" + ] +} diff --git a/konnect-credentials/provision.mjs b/konnect-credentials/provision.mjs new file mode 100644 index 0000000..29781da --- /dev/null +++ b/konnect-credentials/provision.mjs @@ -0,0 +1,137 @@ +import { + ACCOUNT_NAMES, + roleAssignment, + rolesForKind, +} from "./roles.mjs"; +import { unwrapList, unwrapOne } from "./konnect-client.mjs"; + +/** + * Ensure three system accounts exist, assign roles, mint SPATs. + * Returns provisioner + usage secrets (once) and ingest secret for platform storage. + */ +export async function provisionCredentials(client, { + tokenNameSuffix = "default", +}) { + const accounts = {}; + for (const kind of ["provisioner", "usage", "ingest"]) { + accounts[kind] = await ensureSystemAccount(client, kind); + await ensureRoles(client, accounts[kind].id, rolesForKind(kind)); + } + + const tokens = {}; + for (const kind of ["provisioner", "usage", "ingest"]) { + tokens[kind] = await mintToken(client, accounts[kind].id, `${kind}-${tokenNameSuffix}`); + } + + return { + accounts: { + provisioner: { id: accounts.provisioner.id, name: accounts.provisioner.name }, + usage: { id: accounts.usage.id, name: accounts.usage.name }, + ingest: { id: accounts.ingest.id, name: accounts.ingest.name }, + }, + tokens: { + provisioner: { + id: tokens.provisioner.id, + name: tokens.provisioner.name, + token: tokens.provisioner.token, + }, + usage: { + id: tokens.usage.id, + name: tokens.usage.name, + token: tokens.usage.token, + }, + ingest: { + id: tokens.ingest.id, + name: tokens.ingest.name, + token: tokens.ingest.token, + }, + }, + }; +} + +/** + * Rotate one credential kind: delete old token (if known), mint a new SPAT. + */ +export async function rotateCredential(client, { + kind, + accountId, + previousTokenId, + tokenNameSuffix = `rot-${Date.now()}`, +}) { + if (previousTokenId) { + try { + await client.deleteAccessToken(accountId, previousTokenId); + } catch (err) { + if (err.status !== 404) { + throw err; + } + } + } + const minted = await mintToken(client, accountId, `${kind}-${tokenNameSuffix}`); + return { + kind, + account_id: accountId, + id: minted.id, + name: minted.name, + token: minted.token, + }; +} + +async function ensureSystemAccount(client, kind) { + const name = ACCOUNT_NAMES[kind]; + const listed = unwrapList(await client.listSystemAccounts()); + const existing = listed.find((a) => a.name === name); + if (existing) { + return existing; + } + try { + const created = unwrapOne(await client.createSystemAccount({ + name, + description: `Clearinghouse ${kind} system account`, + })); + return created; + } catch (err) { + if (err.status === 409) { + const again = unwrapList(await client.listSystemAccounts()); + const found = again.find((a) => a.name === name); + if (found) { + return found; + } + } + throw err; + } +} + +async function ensureRoles(client, accountId, roleNames) { + const existing = unwrapList(await client.listAssignedRoles(accountId)); + const have = new Set( + existing.map((r) => `${r.role_name}|${r.entity_type_name}|${r.entity_id}`), + ); + for (const roleName of roleNames) { + const assignment = roleAssignment(roleName); + const key = `${assignment.role_name}|${assignment.entity_type_name}|${assignment.entity_id}`; + if (have.has(key)) { + continue; + } + try { + await client.assignRole(accountId, assignment); + } catch (err) { + if (err.status !== 409) { + throw err; + } + } + } +} + +async function mintToken(client, accountId, name) { + const created = unwrapOne(await client.createAccessToken(accountId, { name })); + const token = created.token || created.access_token || created.value; + if (!token) { + throw new Error(`Konnect access token response missing secret for ${name}`); + } + return { + id: created.id, + name: created.name || name, + token, + }; +} diff --git a/konnect-credentials/roles.mjs b/konnect-credentials/roles.mjs new file mode 100644 index 0000000..37d78d4 --- /dev/null +++ b/konnect-credentials/roles.mjs @@ -0,0 +1,56 @@ +/** + * Konnect Metering & Billing RBAC payloads for system-account role assignment. + * + * Spiked against a live org: entity_type_name is "Metering", entity_id "*", + * entity_region "*". See Konnect teams-and-roles docs. + */ + +export const METERING_ENTITY = { + entity_type_name: "Metering", + entity_id: "*", + entity_region: "*", +}; + +/** Roles handed to the tenant Provisioner SPAT. */ +export const PROVISIONER_ROLES = [ + "Billing Admin", + "Product Catalog Admin", + "Metering Admin", +]; + +/** Roles handed to the tenant Usage SPAT. */ +export const USAGE_ROLES = [ + "Metering Viewer", + "Billing Viewer", +]; + +/** Roles for the platform-held Ingest SPAT (never returned to tenants). */ +export const INGEST_ROLES = [ + "Ingest", +]; + +export const ACCOUNT_NAMES = { + provisioner: "ch-provisioner", + usage: "ch-usage", + ingest: "ch-ingest", +}; + +export function roleAssignment(roleName) { + return { + role_name: roleName, + ...METERING_ENTITY, + }; +} + +export function rolesForKind(kind) { + switch (kind) { + case "provisioner": + return PROVISIONER_ROLES; + case "usage": + return USAGE_ROLES; + case "ingest": + return INGEST_ROLES; + default: + throw new Error(`unknown credential kind: ${kind}`); + } +} diff --git a/konnect-credentials/routes.mjs b/konnect-credentials/routes.mjs new file mode 100644 index 0000000..c0bde3b --- /dev/null +++ b/konnect-credentials/routes.mjs @@ -0,0 +1,431 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createKonnectClient, ingestUrl, openMeterBase, unwrapOne } from "./konnect-client.mjs"; +import { bootstrapCatalog } from "./catalog.mjs"; +import { provisionCredentials, rotateCredential } from "./provision.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_CATALOG = path.join(__dirname, "catalog.json"); + +const REGIONS = new Set(["us", "eu", "au", "in", "me", "sg"]); + +/** + * Route HTTP requests for the konnect-credentials service. + * Returns a Response or null if unmatched. + */ +export async function routeRequest(request, { + store, + platformSecret, + catalogPath = DEFAULT_CATALOG, + fetchImpl = globalThis.fetch, + identityBase, +}) { + const url = new URL(request.url); + const { pathname } = url; + + if (request.method === "GET" && pathname === "/health") { + return text("ok", 200); + } + + if (!authorizePlatform(request, platformSecret)) { + return json({ error: "unauthorized" }, 401); + } + + // GET /v1/internal/tenants/:clientId/ingest — collector lookup + { + const m = pathname.match(/^\/v1\/internal\/tenants\/([^/]+)\/ingest$/); + if (m && request.method === "GET") { + return getIngest(store, decodeURIComponent(m[1])); + } + } + + // GET /v1/internal/tenants/:clientId/openmeter — builder-api session provision + { + const m = pathname.match(/^\/v1\/internal\/tenants\/([^/]+)\/openmeter$/); + if (m && request.method === "GET") { + return getOpenMeter(store, decodeURIComponent(m[1])); + } + } + + // POST /v1/tenants/:clientId/konnect/bind + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)\/konnect\/bind$/); + if (m && request.method === "POST") { + return bindTenant(store, decodeURIComponent(m[1]), request, { + fetchImpl, + identityBase, + }); + } + } + + // POST /v1/tenants/:clientId/konnect/credentials + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)\/konnect\/credentials$/); + if (m && request.method === "POST") { + return issueCredentials(store, decodeURIComponent(m[1]), { + fetchImpl, + identityBase, + }); + } + } + + // POST /v1/tenants/:clientId/konnect/credentials/rotate + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)\/konnect\/credentials\/rotate$/); + if (m && request.method === "POST") { + return rotate(store, decodeURIComponent(m[1]), request, { + fetchImpl, + identityBase, + }); + } + } + + // DELETE /v1/tenants/:clientId/konnect/credentials/:kind + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)\/konnect\/credentials\/(provisioner|usage|ingest)$/); + if (m && request.method === "DELETE") { + return revoke(store, decodeURIComponent(m[1]), m[2], { + fetchImpl, + identityBase, + }); + } + } + + // POST /v1/tenants/:clientId/konnect/catalog + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)\/konnect\/catalog$/); + if (m && request.method === "POST") { + return runCatalog(store, decodeURIComponent(m[1]), catalogPath, { + fetchImpl, + identityBase, + }); + } + } + + // GET /v1/tenants/:clientId + { + const m = pathname.match(/^\/v1\/tenants\/([^/]+)$/); + if (m && request.method === "GET") { + return getTenantPublic(store, decodeURIComponent(m[1])); + } + } + + return null; +} + +function authorizePlatform(request, platformSecret) { + const header = request.headers.get("authorization") || ""; + const apiKey = request.headers.get("x-api-key") || ""; + const bearer = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + const presented = bearer || apiKey; + if (!platformSecret || !presented) { + return false; + } + return timingSafeEqual(presented, platformSecret); +} + +function timingSafeEqual(a, b) { + if (a.length !== b.length) { + return false; + } + let out = 0; + for (let i = 0; i < a.length; i++) { + out |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return out === 0; +} + +async function bindTenant(store, clientId, request, opts) { + let body; + try { + body = await request.json(); + } catch { + return json({ error: "invalid_json" }, 400); + } + const region = String(body.region || "us").trim().toLowerCase(); + const adminToken = String(body.admin_token || "").trim(); + if (!REGIONS.has(region)) { + return json({ error: "invalid_region", allowed: [...REGIONS] }, 400); + } + if (!adminToken) { + return json({ error: "admin_token_required" }, 400); + } + + const client = createKonnectClient({ + token: adminToken, + region, + identityBase: opts.identityBase, + fetchImpl: opts.fetchImpl, + }); + + let org; + try { + org = unwrapOne(await client.getOrganizationMe()); + } catch (err) { + return json({ + error: "konnect_auth_failed", + detail: err.message, + }, err.status === 401 || err.status === 403 ? 401 : 502); + } + + const tenant = await store.upsertTenant(clientId, { + region, + org_id: org.id, + org_name: org.name, + admin_token: adminToken, + bound_at: new Date().toISOString(), + }); + + return json({ + client_id: tenant.client_id, + org_id: tenant.org_id, + org_name: tenant.org_name, + region: tenant.region, + bound_at: tenant.bound_at, + }, 200); +} + +async function issueCredentials(store, clientId, opts) { + const tenant = await store.getTenant(clientId); + if (!tenant?.admin_token) { + return json({ error: "tenant_not_bound" }, 404); + } + + const client = createKonnectClient({ + token: tenant.admin_token, + region: tenant.region, + identityBase: opts.identityBase, + fetchImpl: opts.fetchImpl, + }); + + let result; + try { + result = await provisionCredentials(client, { + tokenNameSuffix: clientId.slice(0, 24), + }); + } catch (err) { + return json({ error: "provision_failed", detail: err.message }, 502); + } + + await store.upsertTenant(clientId, { + accounts: result.accounts, + token_ids: { + provisioner: result.tokens.provisioner.id, + usage: result.tokens.usage.id, + ingest: result.tokens.ingest.id, + }, + ingest_spat: result.tokens.ingest.token, + credentials_issued_at: new Date().toISOString(), + }); + + return json({ + client_id: clientId, + org_id: tenant.org_id, + region: tenant.region, + openmeter_base: client.openMeterBase, + accounts: result.accounts, + // Secrets returned once — not stored for provisioner/usage. + credentials: { + provisioner: { + token_id: result.tokens.provisioner.id, + token: result.tokens.provisioner.token, + roles: ["Billing Admin", "Product Catalog Admin", "Metering Admin"], + }, + usage: { + token_id: result.tokens.usage.id, + token: result.tokens.usage.token, + roles: ["Metering Viewer", "Billing Viewer"], + }, + }, + // Ingest is platform-only; acknowledge issuance without returning secret. + ingest: { + token_id: result.tokens.ingest.id, + stored: true, + }, + }, 201); +} + +async function rotate(store, clientId, request, opts) { + let body; + try { + body = await request.json(); + } catch { + return json({ error: "invalid_json" }, 400); + } + const kind = String(body.kind || "").trim(); + if (!["provisioner", "usage", "ingest"].includes(kind)) { + return json({ error: "invalid_kind", allowed: ["provisioner", "usage", "ingest"] }, 400); + } + + const tenant = await store.getTenant(clientId); + if (!tenant?.admin_token || !tenant.accounts?.[kind]?.id) { + return json({ error: "credentials_not_issued" }, 404); + } + + const client = createKonnectClient({ + token: tenant.admin_token, + region: tenant.region, + identityBase: opts.identityBase, + fetchImpl: opts.fetchImpl, + }); + + let rotated; + try { + rotated = await rotateCredential(client, { + kind, + accountId: tenant.accounts[kind].id, + previousTokenId: tenant.token_ids?.[kind], + }); + } catch (err) { + return json({ error: "rotate_failed", detail: err.message }, 502); + } + + const tokenIds = { ...(tenant.token_ids || {}), [kind]: rotated.id }; + const patch = { token_ids: tokenIds }; + if (kind === "ingest") { + patch.ingest_spat = rotated.token; + } + await store.upsertTenant(clientId, patch); + + if (kind === "ingest") { + return json({ + client_id: clientId, + kind, + token_id: rotated.id, + stored: true, + }, 200); + } + + return json({ + client_id: clientId, + kind, + token_id: rotated.id, + token: rotated.token, + }, 200); +} + +async function revoke(store, clientId, kind, opts) { + const tenant = await store.getTenant(clientId); + if (!tenant?.admin_token || !tenant.accounts?.[kind]?.id) { + return json({ error: "credentials_not_issued" }, 404); + } + const tokenId = tenant.token_ids?.[kind]; + if (!tokenId) { + return json({ error: "token_id_unknown" }, 404); + } + + const client = createKonnectClient({ + token: tenant.admin_token, + region: tenant.region, + identityBase: opts.identityBase, + fetchImpl: opts.fetchImpl, + }); + + try { + await client.deleteAccessToken(tenant.accounts[kind].id, tokenId); + } catch (err) { + if (err.status !== 404) { + return json({ error: "revoke_failed", detail: err.message }, 502); + } + } + + const tokenIds = { ...(tenant.token_ids || {}) }; + delete tokenIds[kind]; + const patch = { token_ids: tokenIds }; + if (kind === "ingest") { + patch.ingest_spat = null; + } + await store.upsertTenant(clientId, patch); + + return json({ client_id: clientId, kind, revoked: true }, 200); +} + +async function runCatalog(store, clientId, catalogPath, opts) { + const tenant = await store.getTenant(clientId); + if (!tenant?.admin_token) { + return json({ error: "tenant_not_bound" }, 404); + } + + const client = createKonnectClient({ + token: tenant.admin_token, + region: tenant.region, + identityBase: opts.identityBase, + fetchImpl: opts.fetchImpl, + }); + + try { + const result = await bootstrapCatalog(client, catalogPath); + await store.upsertTenant(clientId, { + catalog_bootstrapped_at: new Date().toISOString(), + }); + return json({ + client_id: clientId, + org_id: tenant.org_id, + region: tenant.region, + catalog: result, + }, 200); + } catch (err) { + return json({ error: "catalog_failed", detail: err.message }, 502); + } +} + +async function getIngest(store, clientId) { + const tenant = await store.getTenant(clientId); + if (!tenant?.ingest_spat) { + return json({ error: "ingest_unavailable" }, 404); + } + return json({ + client_id: clientId, + region: tenant.region, + org_id: tenant.org_id, + url: ingestUrl(tenant.region), + token: tenant.ingest_spat, + }, 200); +} + +async function getOpenMeter(store, clientId) { + const tenant = await store.getTenant(clientId); + if (!tenant?.admin_token) { + return json({ error: "tenant_not_bound" }, 404); + } + return json({ + client_id: clientId, + region: tenant.region, + org_id: tenant.org_id, + openmeter_base: openMeterBase(tenant.region), + token: tenant.admin_token, + }, 200); +} + +async function getTenantPublic(store, clientId) { + const tenant = await store.getTenant(clientId); + if (!tenant) { + return json({ error: "not_found" }, 404); + } + return json({ + client_id: tenant.client_id, + org_id: tenant.org_id, + org_name: tenant.org_name, + region: tenant.region, + bound_at: tenant.bound_at, + credentials_issued_at: tenant.credentials_issued_at, + catalog_bootstrapped_at: tenant.catalog_bootstrapped_at, + accounts: tenant.accounts || null, + token_ids: tenant.token_ids || null, + has_ingest: Boolean(tenant.ingest_spat), + }, 200); +} + +function json(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function text(body, status = 200) { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain" }, + }); +} diff --git a/konnect-credentials/server.mjs b/konnect-credentials/server.mjs new file mode 100644 index 0000000..6ecf9f2 --- /dev/null +++ b/konnect-credentials/server.mjs @@ -0,0 +1,116 @@ +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { deriveKey } from "./crypto.mjs"; +import { createStore } from "./store.mjs"; +import { routeRequest } from "./routes.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const port = Number(process.env.PORT || 8091); +const MAX_BODY_BYTES = 256 * 1024; + +function required(name) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +const platformSecret = required("PLATFORM_API_SECRET"); +const encryptionKey = deriveKey(required("CREDENTIALS_ENCRYPTION_KEY")); +const dataDir = process.env.DATA_DIR?.trim() || path.join(__dirname, "data"); +const catalogPath = process.env.CATALOG_PATH?.trim() || path.join(__dirname, "catalog.json"); +const identityBase = process.env.KONNECT_IDENTITY_BASE?.trim() || undefined; + +const store = createStore({ + dataDir, + encryptionKey, +}); + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on("data", (chunk) => { + total += chunk.length; + if (total > MAX_BODY_BYTES) { + req.destroy(); + reject(new Error("payload too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +async function handleRequest(req, res) { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + + let body; + if (req.method !== "GET" && req.method !== "HEAD") { + try { + body = await readBody(req); + } catch (err) { + if (err.message === "payload too large") { + res.writeHead(413, { "Content-Type": "text/plain" }); + res.end("payload too large"); + return; + } + throw err; + } + } + + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value === undefined) { + continue; + } + headers.set(key, Array.isArray(value) ? value.join(", ") : value); + } + + const request = new Request(new URL(req.url, `http://localhost:${port}`), { + method: req.method, + headers, + body: body?.length ? body : undefined, + }); + + const response = await routeRequest(request, { + store, + platformSecret, + catalogPath, + identityBase, + }); + + if (!response) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); + return; + } + + const buf = Buffer.from(await response.arrayBuffer()); + const outHeaders = {}; + response.headers.forEach((v, k) => { + outHeaders[k] = v; + }); + res.writeHead(response.status, outHeaders); + res.end(buf); +} + +createServer((req, res) => { + handleRequest(req, res).catch((err) => { + console.error(err); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "internal_error" })); + } + }); +}).listen(port, () => { + console.log(`konnect-credentials listening on :${port}`); +}); diff --git a/konnect-credentials/store.mjs b/konnect-credentials/store.mjs new file mode 100644 index 0000000..4a65e62 --- /dev/null +++ b/konnect-credentials/store.mjs @@ -0,0 +1,120 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { decrypt, encrypt } from "./crypto.mjs"; + +/** + * File-backed tenant store. Secrets (admin_token, ingest_spat) are encrypted at rest. + * Tenant provisioner/usage SPATs are never persisted after handoff — only token IDs. + */ +export function createStore({ + dataDir, + encryptionKey, +}) { + const filePath = path.join(dataDir, "tenants.json"); + let cache = null; + + async function ensureLoaded() { + if (cache) { + return cache; + } + await mkdir(dataDir, { recursive: true }); + try { + const raw = await readFile(filePath, "utf8"); + cache = JSON.parse(raw); + } catch (err) { + if (err.code === "ENOENT") { + cache = { tenants: {} }; + } else { + throw err; + } + } + return cache; + } + + async function persist() { + const data = await ensureLoaded(); + const tmp = `${filePath}.${process.pid}.tmp`; + await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + await rename(tmp, filePath); + } + + function seal(value) { + if (value == null || value === "") { + return null; + } + return encrypt(value, encryptionKey); + } + + function open(value) { + if (value == null || value === "") { + return null; + } + return decrypt(value, encryptionKey); + } + + return { + async getTenant(clientId) { + const data = await ensureLoaded(); + const row = data.tenants[clientId]; + if (!row) { + return null; + } + return hydrate(row, open); + }, + + async listTenants() { + const data = await ensureLoaded(); + return Object.keys(data.tenants).sort().map((id) => hydrate(data.tenants[id], open)); + }, + + async upsertTenant(clientId, patch) { + const data = await ensureLoaded(); + const prev = data.tenants[clientId] || { + client_id: clientId, + created_at: new Date().toISOString(), + }; + const next = { + ...prev, + ...stripSecrets(patch), + client_id: clientId, + updated_at: new Date().toISOString(), + }; + if (Object.prototype.hasOwnProperty.call(patch, "admin_token")) { + next.admin_token_enc = seal(patch.admin_token); + } + if (Object.prototype.hasOwnProperty.call(patch, "ingest_spat")) { + next.ingest_spat_enc = seal(patch.ingest_spat); + } + data.tenants[clientId] = next; + await persist(); + return hydrate(next, open); + }, + + async deleteTenant(clientId) { + const data = await ensureLoaded(); + if (!data.tenants[clientId]) { + return false; + } + delete data.tenants[clientId]; + await persist(); + return true; + }, + }; +} + +function stripSecrets(patch) { + const { + admin_token: _a, + ingest_spat: _i, + ...rest + } = patch; + return rest; +} + +function hydrate(row, open) { + return { + ...row, + admin_token: open(row.admin_token_enc), + ingest_spat: open(row.ingest_spat_enc), + }; +} diff --git a/konnect-credentials/tests/isolation.test.mjs b/konnect-credentials/tests/isolation.test.mjs new file mode 100644 index 0000000..e7882fc --- /dev/null +++ b/konnect-credentials/tests/isolation.test.mjs @@ -0,0 +1,227 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { deriveKey } from "../crypto.mjs"; +import { createStore } from "../store.mjs"; +import { routeRequest } from "../routes.mjs"; + +const SECRET = "platform-test-secret"; + +function mockFetchFactory(handlers) { + return async (url, init = {}) => { + const u = String(url); + const method = (init.method || "GET").toUpperCase(); + for (const h of handlers) { + if (h.match(u, method, init)) { + return h.respond(u, method, init); + } + } + return new Response(JSON.stringify({ message: `unhandled ${method} ${u}` }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("routes isolation and handoff", () => { + let dir; + let store; + + before(async () => { + dir = await mkdtemp(path.join(tmpdir(), "ch-routes-")); + store = createStore({ + dataDir: dir, + encryptionKey: deriveKey("route-test-key"), + }); + }); + + after(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("rejects unauthenticated requests", async () => { + const res = await routeRequest( + new Request("http://localhost/v1/tenants/x", { method: "GET" }), + { store, platformSecret: SECRET }, + ); + assert.equal(res.status, 401); + }); + + it("binds two tenants to different orgs and keeps ingest isolated", async () => { + const fetchImpl = mockFetchFactory([ + { + match: (u, m) => m === "GET" && u.includes("/organizations/me"), + respond: (_u, _m, init) => { + const auth = init.headers.Authorization || ""; + if (auth.includes("token-a")) { + return jsonResponse(200, { id: "org-aaa", name: "Tenant A Org" }); + } + if (auth.includes("token-b")) { + return jsonResponse(200, { id: "org-bbb", name: "Tenant B Org" }); + } + return jsonResponse(401, { message: "bad token" }); + }, + }, + ]); + + const bindA = await routeRequest( + new Request("http://localhost/v1/tenants/client-a/konnect/bind", { + method: "POST", + headers: { + Authorization: `Bearer ${SECRET}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ region: "us", admin_token: "kpat_token-a" }), + }), + { store, platformSecret: SECRET, fetchImpl }, + ); + assert.equal(bindA.status, 200); + const bodyA = await bindA.json(); + assert.equal(bodyA.org_id, "org-aaa"); + + const bindB = await routeRequest( + new Request("http://localhost/v1/tenants/client-b/konnect/bind", { + method: "POST", + headers: { + Authorization: `Bearer ${SECRET}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ region: "eu", admin_token: "kpat_token-b" }), + }), + { store, platformSecret: SECRET, fetchImpl }, + ); + assert.equal(bindB.status, 200); + const bodyB = await bindB.json(); + assert.equal(bodyB.org_id, "org-bbb"); + assert.equal(bodyB.region, "eu"); + + // Simulate credentials issued with distinct ingest SPATs + await store.upsertTenant("client-a", { ingest_spat: "spat_ingest_a" }); + await store.upsertTenant("client-b", { ingest_spat: "spat_ingest_b" }); + + const ingestA = await routeRequest( + new Request("http://localhost/v1/internal/tenants/client-a/ingest", { + method: "GET", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET }, + ); + const ingestB = await routeRequest( + new Request("http://localhost/v1/internal/tenants/client-b/ingest", { + method: "GET", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET }, + ); + const ia = await ingestA.json(); + const ib = await ingestB.json(); + assert.equal(ia.token, "spat_ingest_a"); + assert.equal(ib.token, "spat_ingest_b"); + assert.equal(ia.url, "https://us.api.konghq.com/v3/openmeter/events"); + assert.equal(ib.url, "https://eu.api.konghq.com/v3/openmeter/events"); + assert.notEqual(ia.token, ib.token); + + const omA = await routeRequest( + new Request("http://localhost/v1/internal/tenants/client-a/openmeter", { + method: "GET", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET }, + ); + const omB = await routeRequest( + new Request("http://localhost/v1/internal/tenants/client-b/openmeter", { + method: "GET", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET }, + ); + assert.equal(omA.status, 200); + assert.equal(omB.status, 200); + const oa = await omA.json(); + const ob = await omB.json(); + assert.equal(oa.token, "kpat_token-a"); + assert.equal(ob.token, "kpat_token-b"); + assert.equal(oa.openmeter_base, "https://us.api.konghq.com/v3/openmeter"); + assert.equal(ob.openmeter_base, "https://eu.api.konghq.com/v3/openmeter"); + + const unbound = await routeRequest( + new Request("http://localhost/v1/internal/tenants/missing/openmeter", { + method: "GET", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET }, + ); + assert.equal(unbound.status, 404); + }); + + it("issues credentials and does not return ingest secret", async () => { + const accounts = []; + const fetchImpl = mockFetchFactory([ + { + match: (u, m) => m === "GET" && u.includes("/system-accounts") && !u.includes("assigned-roles") && !u.includes("access-tokens"), + respond: () => jsonResponse(200, { data: accounts }), + }, + { + match: (u, m) => m === "POST" && /\/system-accounts$/.test(u.replace(/\?.*/, "")), + respond: async (_u, _m, init) => { + const body = JSON.parse(init.body); + const row = { id: `sa-${body.name}`, name: body.name }; + accounts.push(row); + return jsonResponse(201, row); + }, + }, + { + match: (u, m) => m === "GET" && u.includes("/assigned-roles"), + respond: () => jsonResponse(200, { data: [] }), + }, + { + match: (u, m) => m === "POST" && u.includes("/assigned-roles"), + respond: () => jsonResponse(201, { id: "role-1" }), + }, + { + match: (u, m) => m === "POST" && u.includes("/access-tokens"), + respond: async (u) => { + const accountId = u.match(/system-accounts\/([^/]+)/)[1]; + return jsonResponse(201, { + id: `tok-${accountId}`, + name: "t", + token: `spat_${accountId}`, + }); + }, + }, + ]); + + await store.upsertTenant("client-c", { + region: "us", + org_id: "org-c", + admin_token: "kpat_c", + }); + + const res = await routeRequest( + new Request("http://localhost/v1/tenants/client-c/konnect/credentials", { + method: "POST", + headers: { Authorization: `Bearer ${SECRET}` }, + }), + { store, platformSecret: SECRET, fetchImpl }, + ); + assert.equal(res.status, 201); + const body = await res.json(); + assert.ok(body.credentials.provisioner.token.startsWith("spat_")); + assert.ok(body.credentials.usage.token.startsWith("spat_")); + assert.equal(body.ingest.stored, true); + assert.equal(body.credentials.ingest, undefined); + + const tenant = await store.getTenant("client-c"); + assert.ok(tenant.ingest_spat.startsWith("spat_")); + assert.ok(tenant.token_ids.provisioner); + }); +}); diff --git a/konnect-credentials/tests/roles.test.mjs b/konnect-credentials/tests/roles.test.mjs new file mode 100644 index 0000000..709323b --- /dev/null +++ b/konnect-credentials/tests/roles.test.mjs @@ -0,0 +1,35 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + ACCOUNT_NAMES, + INGEST_ROLES, + METERING_ENTITY, + PROVISIONER_ROLES, + USAGE_ROLES, + roleAssignment, + rolesForKind, +} from "../roles.mjs"; + +describe("roles", () => { + it("uses Metering entity_type_name with wildcard id/region", () => { + assert.equal(METERING_ENTITY.entity_type_name, "Metering"); + assert.equal(METERING_ENTITY.entity_id, "*"); + assert.equal(METERING_ENTITY.entity_region, "*"); + }); + + it("builds role assignment payloads", () => { + assert.deepEqual(roleAssignment("Ingest"), { + role_name: "Ingest", + entity_type_name: "Metering", + entity_id: "*", + entity_region: "*", + }); + }); + + it("maps kinds to role sets", () => { + assert.deepEqual(rolesForKind("provisioner"), PROVISIONER_ROLES); + assert.deepEqual(rolesForKind("usage"), USAGE_ROLES); + assert.deepEqual(rolesForKind("ingest"), INGEST_ROLES); + assert.equal(ACCOUNT_NAMES.provisioner, "ch-provisioner"); + }); +}); diff --git a/konnect-credentials/tests/store.test.mjs b/konnect-credentials/tests/store.test.mjs new file mode 100644 index 0000000..29477c7 --- /dev/null +++ b/konnect-credentials/tests/store.test.mjs @@ -0,0 +1,71 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { deriveKey } from "../crypto.mjs"; +import { createStore } from "../store.mjs"; + +describe("store encryption and isolation", () => { + let dir; + let store; + + before(async () => { + dir = await mkdtemp(path.join(tmpdir(), "ch-konnect-")); + store = createStore({ + dataDir: dir, + encryptionKey: deriveKey("test-encryption-passphrase"), + }); + }); + + after(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("encrypts admin_token and ingest_spat at rest", async () => { + await store.upsertTenant("app-a", { + region: "us", + org_id: "org-a", + admin_token: "kpat_secret_a", + ingest_spat: "spat_ingest_a", + }); + const row = await store.getTenant("app-a"); + assert.equal(row.admin_token, "kpat_secret_a"); + assert.equal(row.ingest_spat, "spat_ingest_a"); + assert.ok(row.admin_token_enc); + assert.notEqual(row.admin_token_enc, "kpat_secret_a"); + }); + + it("isolates tenants by client_id", async () => { + await store.upsertTenant("app-a", { + region: "us", + org_id: "org-a", + admin_token: "kpat_a", + ingest_spat: "spat_a", + }); + await store.upsertTenant("app-b", { + region: "eu", + org_id: "org-b", + admin_token: "kpat_b", + ingest_spat: "spat_b", + }); + + const a = await store.getTenant("app-a"); + const b = await store.getTenant("app-b"); + assert.equal(a.org_id, "org-a"); + assert.equal(b.org_id, "org-b"); + assert.equal(a.ingest_spat, "spat_a"); + assert.equal(b.ingest_spat, "spat_b"); + assert.notEqual(a.admin_token, b.admin_token); + }); + + it("does not leak secrets across tenants on list", async () => { + const listed = await store.listTenants(); + const ids = listed.map((t) => t.client_id).sort(); + assert.deepEqual(ids, ["app-a", "app-b"]); + for (const t of listed) { + assert.ok(t.ingest_spat.startsWith("spat_")); + assert.ok(t.org_id.startsWith("org-")); + } + }); +}); diff --git a/openmeter-collector/Dockerfile b/openmeter-collector/Dockerfile index 878989c..37390cb 100644 --- a/openmeter-collector/Dockerfile +++ b/openmeter-collector/Dockerfile @@ -1,6 +1,13 @@ -# OpenMeter Benthos collector: Kafka create_signed_ticket events -> Konnect/OpenMeter ingest. +# OpenMeter Benthos collector + Clearinghouse Builder API (Go). +FROM golang:1.25-bookworm AS builder-api-build +WORKDIR /src +COPY openmeter-collector/builder-api/ ./openmeter-collector/builder-api/ +WORKDIR /src/openmeter-collector/builder-api +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /builder-api ./cmd/builder-api + FROM ghcr.io/openmeterio/benthos-collector:main-6b60ab6-1782310960 +COPY --from=builder-api-build /builder-api /usr/local/bin/builder-api COPY openmeter-collector/collector.yaml /config.yaml COPY openmeter-collector/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md new file mode 100644 index 0000000..8b7e565 --- /dev/null +++ b/openmeter-collector/builder-api/README.md @@ -0,0 +1,207 @@ +# Clearinghouse Builder API + +Go HTTP service co-located in the `openmeter-collector` container. Provisions **Auth0 end-users**, thin **OpenMeter session** (customer + subscription + optional trial grant), and mints **signer session JWTs** via Auth0. + +Scalar docs: `GET /api/v1/docs` (spec at `/api/v1/openapi.json`). + +**Not a Konnect billing facade.** Catalog, plans, and usage queries belong to tenant Provisioner/Usage SPATs from [`konnect-credentials`](../../konnect-credentials/README.md). Builder-api only does session glue + pre-mint allowance gate. + +## Token exchange flow + +```text +resolve subject (JWT via identity-webhook, or sk_* API key) + → ensure OpenMeter customer + default subscription (+ optional trial grant) + → read credits/entitlement balance + → if OPENMETER_ENFORCE_ALLOWANCE and !has_access: HTTP 402 insufficient_allowance + → mint Auth0 signer JWT + → response includes access_token + has_access + balance_usd_micros +``` + +Per-tenant OpenMeter credentials are loaded from +`GET {KONNECT_CREDENTIALS_URL}/v1/internal/tenants/{clientId}/openmeter` +(bound admin PAT). Falls back to `OPENMETER_URL` + `OPENMETER_API_KEY` for single-org local stacks. + +## Endpoints + +| Method | Path | Auth | Purpose | +| --- | --- | --- | --- | +| `POST` | `/api/v1/apps/{clientId}/users` | M2M Basic | Create/upsert Auth0 user + OpenMeter customer; returns `apiKey` once | +| `POST` | `/api/v1/apps/{clientId}/oidc/token` | RFC 8693 form + subject token | Exchange Auth0 user JWT or `sk_*` API key for signer JWT; 402 if allowance exhausted | + +## Auth0 prerequisites + +Most credentials are written to `auth0-provisioner/provision/.env.livepeer` by `./bootstrap.sh` and mounted into the collector at `/service/.env.livepeer`. + +### 1. Management API M2M application + +`bootstrap.sh` creates **Clearinghouse Builder Management** (M2M) with Management API scopes `create:users`, `read:users`, `update:users`, and writes: + +```bash +AUTH0_MGMT_CLIENT_ID=... +AUTH0_MGMT_CLIENT_SECRET=... +``` + +Re-run `./auth0-provisioner/provision/bootstrap.sh` if these are missing from `.env.livepeer`. Set `managementClient.enabled: false` in `apps.json` to skip. + +Tenant domain, issuer, audience, and signer M2M credentials come from the same file. The entrypoint maps `DEMO_APP_AUTH0_M2M_*` → `AUTH0_SIGNER_M2M_*` automatically. + +### 2. Database connection + +Enable a Database connection (default: `Username-Password-Authentication`) for end-user records. Set `AUTH0_DB_CONNECTION` if you use a different connection name. + +### 3. Credentials-exchange Action (`external_user_id` + `client_id` claims) + +Signer-token mint uses M2M `client_credentials` with `scope=sign:mint_user_token`, form fields `external_user_id`, and `client_id` (the **public** app client id from the Builder API path). Auth0 does not pass custom form fields into access tokens unless an **Action** adds them. + +**Deploy manually** in the Auth0 dashboard (Actions → Library → Build Custom → Credentials Exchange trigger): + +```javascript +exports.onExecuteCredentialsExchange = async (event, api) => { + const externalUserId = event.request?.body?.external_user_id; + const clientId = event.request?.body?.client_id; + if (!externalUserId || !clientId) { + return; + } + api.accessToken.setCustomClaim("external_user_id", externalUserId); + api.accessToken.setCustomClaim("app_client_id", clientId); +}; +``` + +1. Deploy the Action. +2. Bind it to the **Credentials Exchange** flow for your tenant. +3. Re-test token exchange — the minted JWT must include `external_user_id` and `app_client_id` for [identity-webhook](../../identity-webhook) OIDC verification (`OIDC_SUBJECT_CLAIM=external_user_id`, `OIDC_CLIENT_CLAIM=app_client_id`). Auth0 rejects the reserved claim name `client_id`; use `app_client_id` instead. + +Without this Action, minted tokens verify at Auth0 but lack identity claims and the webhook rejects them. + +### 5. Identity-webhook (JWT subject-token verification) + +JWT `subject_token`s are **not** verified in-process. The Builder API forwards them to the +[identity-webhook](../../identity-webhook) `POST /authorize` contract, which owns Auth0 JWKS +verification and claim extraction. `sk_*` API-key subject tokens are still resolved directly +against Auth0 `app_metadata` by the Builder API. + +Set both to enable JWT exchange (defaults `IDENTITY_WEBHOOK_URL` to `REMOTE_SIGNER_WEBHOOK_URL`): + +```bash +IDENTITY_WEBHOOK_URL=http://identity-webhook:8090 +WEBHOOK_SECRET=... # shared with the identity-webhook +``` + +The webhook must run in `IDENTITY_AUTH_MODE=oidc` to verify JWTs. If unset, JWT subject tokens +are rejected with `invalid_grant` (API-key exchange still works). + +### 4. Signer M2M (from bootstrap) + +Provided automatically via mounted `.env.livepeer` (`DEMO_APP_AUTH0_M2M_CLIENT_ID` / +`DEMO_APP_AUTH0_M2M_CLIENT_SECRET`). Override in `openmeter-collector/.env` only if needed. + +## Example: create user + +```bash +set -a; source openmeter-collector/.env; set +a +CLIENT_ID="$AUTH0_SIGNER_M2M_CLIENT_ID" # or your public client id path param +curl -sS -u "$AUTH0_SIGNER_M2M_CLIENT_ID:$AUTH0_SIGNER_M2M_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"externalUserId":"user-123","email":"user@example.com"}' \ + "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/users" +``` + +Use the public client id from `.env.livepeer` as the `{clientId}` path segment (e.g. `DEMO_APP_AUTH0_PUBLIC_CLIENT_ID`). + +## Example: RFC 8693 signer session exchange + +Signer-session issuance uses **RFC 8693** at `POST /api/v1/apps/{clientId}/oidc/token` with `application/x-www-form-urlencoded` body fields: + +- `{clientId}` — public Auth0 client id for the integrator app (same path segment as `/users`) +- `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` +- `subject_token` — Auth0 user access token (device code) **or** end-user API key (`sk_*`) +- `subject_token_type=urn:ietf:params:oauth:token-type:access_token` +- `audience=livepeer-clearinghouse` (or omit; must match configured audience when provided) + +The subject token must belong to the public client named in the path (`azp` for JWTs, or API key issued for that client). Optional HTTP Basic auth with the signer M2M client is supported for server-side callers. + +API key: + +```bash +set -a; source openmeter-collector/.env; set +a +PUBLIC_CLIENT_ID="$DEMO_APP_AUTH0_PUBLIC_CLIENT_ID" +API_KEY=sk_... +curl -sS \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + --data-urlencode "subject_token=$API_KEY" \ + --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ + --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \ + --data-urlencode "audience=livepeer-clearinghouse" \ + "http://localhost:8095/api/v1/apps/${PUBLIC_CLIENT_ID}/oidc/token" +``` + +Device code (user JWT as `subject_token`): + +```bash +PUBLIC_CLIENT_ID="$DEMO_APP_AUTH0_PUBLIC_CLIENT_ID" +OIDC_TOKEN=... # access_token from device code flow +curl -sS \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + --data-urlencode "subject_token=$OIDC_TOKEN" \ + --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ + --data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \ + --data-urlencode "audience=livepeer-clearinghouse" \ + "http://localhost:8095/api/v1/apps/${PUBLIC_CLIENT_ID}/oidc/token" +``` + +The OpenMeter customer key is `{clientId}:{sub}` (e.g. `pub:google-oauth2|…`), matching the CloudEvent `subject`. + +Successful exchange JSON includes: + +```json +{ + "access_token": "...", + "token_type": "Bearer", + "expires_in": 300, + "scope": "sign:job", + "has_access": true, + "balance_usd_micros": 1000000 +} +``` + +When allowance is exhausted (`OPENMETER_ENFORCE_ALLOWANCE=true`, default): + +```json +HTTP 402 +{ "error": "insufficient_allowance", "error_description": "trial credits exhausted; ..." } +``` + +## OpenMeter customer key + +Customers are upserted with: + +- `key`: `{clientId}:{externalUserId}` +- `usage_attribution.subject_keys`: `["{clientId}:{externalUserId}"]` + +This matches the collector CloudEvent `subject` / `auth_id` contract. + +Plans, meters, and usage queries: use tenant SPATs from +[`konnect-credentials`](../../konnect-credentials/README.md) against Kong directly. + +## Env (metering) + +| Variable | Purpose | +| --- | --- | +| `KONNECT_CREDENTIALS_URL` | Per-tenant OpenMeter cred lookup | +| `PLATFORM_API_SECRET` | Auth to credentials service | +| `OPENMETER_URL` / `OPENMETER_API_KEY` | Single-org fallback | +| `OPENMETER_DEFAULT_PLAN_KEY` | Default `clearinghouse_default_ppu` | +| `OPENMETER_TRIAL_FEATURE_KEY` | Default `billable_spend` | +| `OPENMETER_TRIAL_GRANT_USD_MICROS` | `0` disables auto trial grant | +| `OPENMETER_ENFORCE_ALLOWANCE` | Default `true` | + +## Local development + +```bash +cd openmeter-collector/builder-api +go run ./cmd/builder-api +``` + +Requires the same env vars as the container (`openmeter-collector/.env`). diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go new file mode 100644 index 0000000..c2065e9 --- /dev/null +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + _ "embed" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + auth0mgmt "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mgmt" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mint" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/config" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/httpapi" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/webhookverify" +) + +//go:embed openapi.json +var openAPISpec []byte + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + auth0Client, err := auth0mgmt.New(cfg.Auth0Domain, cfg.MgmtClientID, cfg.MgmtClientSecret, cfg.DBConnection) + if err != nil { + log.Fatalf("auth0: %v", err) + } + + minter := auth0mint.New(cfg.Auth0Issuer, cfg.Auth0Audience, cfg.SignerM2MClientID, cfg.SignerM2MSecret) + resolver := openmeter.NewResolver( + cfg.KonnectCredentialsURL, + cfg.PlatformAPISecret, + cfg.OpenMeterURL, + cfg.OpenMeterAPIKey, + ) + session := openmeter.NewSessionService(resolver) + + // End-user JWT verification is delegated to the identity-webhook (POST /authorize). + var verifier tokenexchange.UserTokenVerifier + if cfg.IdentityWebhookURL != "" && cfg.WebhookSecret != "" { + verifier = webhookverify.New(cfg.IdentityWebhookURL, cfg.WebhookSecret) + } else { + log.Printf("identity-webhook not configured; JWT subject tokens will be rejected (set IDENTITY_WEBHOOK_URL + WEBHOOK_SECRET)") + } + + demoKeys, err := apikey.LoadDemoStore(cfg.DemoAPIKeys) + if err != nil { + log.Fatalf("demo api keys: %v", err) + } + + keyStore := &apikey.Store{ + Prefix: cfg.APIKeyPrefix, + Demo: demoKeys, + Auth0: auth0Client, + } + tokenHandler := tokenexchange.NewHandler(cfg, verifier, keyStore, minter, session) + + srv := httpapi.NewServer(cfg, auth0Client, minter, session, tokenHandler, openAPISpec) + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("builder-api listening on :%s", cfg.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json new file mode 100644 index 0000000..29038d8 --- /dev/null +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -0,0 +1,190 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Clearinghouse Builder API", + "version": "1.0.0", + "description": "Machine-facing Builder API for clearinghouse integrators. Provisions Auth0 end-users, OpenMeter customers, and mints short-lived signer JWTs." + }, + "servers": [ + { + "url": "http://localhost:8095", + "description": "Local builder-api (openmeter-collector container)" + } + ], + "paths": { + "/health": { + "get": { + "summary": "Health check", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/apps/{clientId}/users": { + "post": { + "summary": "Create or upsert an end-user", + "description": "Creates an Auth0 Database user, upserts the OpenMeter customer, and optionally issues an end-user API key.", + "security": [{ "m2mBasic": [] }], + "parameters": [ + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Public Auth0 client id for the integrator app" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserRequest" } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserResponse" } + } + } + }, + "200": { + "description": "User updated", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateUserResponse" } + } + } + }, + "400": { "description": "Bad request" }, + "401": { "description": "Unauthorized" } + } + } + }, + "/api/v1/apps/{clientId}/oidc/token": { + "post": { + "summary": "RFC 8693 signer session token exchange", + "description": "Exchanges an Auth0 end-user access token (device code / authorization code) or end-user API key (`sk_*`) for a short-lived signer JWT. The `{clientId}` path segment is the public Auth0 app client id. Authenticate with the end-user `subject_token`; optional HTTP Basic with the signer M2M client is supported for server-side callers.", + "parameters": [ + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Public Auth0 client id for the integrator app" + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/TokenExchangeRequest" } + } + } + }, + "responses": { + "200": { + "description": "Signer session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SignerSession" } + } + } + }, + "400": { "description": "Invalid request, grant, target, or unsupported token type" }, + "401": { "description": "Invalid client credentials" } + } + } + } + }, + "components": { + "securitySchemes": { + "m2mBasic": { + "type": "http", + "scheme": "basic", + "description": "M2M client id and secret (users:write, RFC 8693 token exchange)" + } + }, + "schemas": { + "CreateUserRequest": { + "type": "object", + "required": ["externalUserId"], + "properties": { + "externalUserId": { "type": "string" }, + "email": { "type": "string", "format": "email" }, + "connection": { "type": "string" }, + "issueApiKey": { "type": "boolean", "default": true } + } + }, + "CreateUserResponse": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "clientId": { "type": "string" }, + "externalUserId": { "type": "string" }, + "email": { "type": "string" }, + "status": { "type": "string" }, + "apiKey": { "type": "string", "description": "Returned once when issueApiKey is true" } + } + }, + "TokenExchangeRequest": { + "type": "object", + "required": [ + "grant_type", + "subject_token", + "subject_token_type" + ], + "properties": { + "grant_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:grant-type:token-exchange"] + }, + "subject_token": { + "type": "string", + "description": "Auth0 user access token (JWT) or end-user API key (sk_*)" + }, + "subject_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:access_token"] + }, + "requested_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:access_token"] + }, + "audience": { + "type": "string", + "description": "Must match configured signer audience when provided" + }, + "resource": { + "type": "string", + "description": "Must match configured signer audience when provided" + } + } + }, + "SignerSession": { + "type": "object", + "required": [ + "access_token", + "token_type", + "expires_in", + "scope" + ], + "properties": { + "access_token": { "type": "string" }, + "token_type": { "type": "string", "enum": ["Bearer"] }, + "expires_in": { "type": "integer" }, + "scope": { "type": "string" }, + "signer_url": { "type": "string", "format": "uri" }, + "discovery_url": { "type": "string", "format": "uri" }, + "issued_token_type": { "type": "string" }, + "correlation_id": { "type": "string" } + } + } + } + } +} diff --git a/openmeter-collector/builder-api/go.mod b/openmeter-collector/builder-api/go.mod new file mode 100644 index 0000000..0914df1 --- /dev/null +++ b/openmeter-collector/builder-api/go.mod @@ -0,0 +1,24 @@ +module github.com/livepeer/clearinghouse/openmeter-collector/builder-api + +go 1.25.0 + +require ( + github.com/auth0/go-auth0/v2 v2.14.0 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/PuerkitoBio/rehttp v1.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/openmeter-collector/builder-api/go.sum b/openmeter-collector/builder-api/go.sum new file mode 100644 index 0000000..0011460 --- /dev/null +++ b/openmeter-collector/builder-api/go.sum @@ -0,0 +1,58 @@ +github.com/PuerkitoBio/rehttp v1.4.0 h1:rIN7A2s+O9fmHUM1vUcInvlHj9Ysql4hE+Y0wcl/xk8= +github.com/PuerkitoBio/rehttp v1.4.0/go.mod h1:LUwKPoDbDIA2RL5wYZCNsQ90cx4OJ4AWBmq6KzWZL1s= +github.com/auth0/go-auth0/v2 v2.14.0 h1:zDxwRHGAt6gLK/OG6wAkB5ScQEJ8WW/ex1EnJig8fFc= +github.com/auth0/go-auth0/v2 v2.14.0/go.mod h1:Q/Y3VZVoI3sw87VyTPhx2TQL6Sq4Q/iCP67rW2gcn+M= +github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0 h1:0NmehRCgyk5rljDQLKUO+cRJCnduDyn11+zGZIc9Z48= +github.com/aybabtme/iocontrol v0.0.0-20150809002002-ad15bcfc95a0/go.mod h1:6L7zgvqo0idzI7IO8de6ZC051AfXb5ipkIJ7bIA2tGA= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/openmeter-collector/builder-api/internal/apikey/apikey.go b/openmeter-collector/builder-api/internal/apikey/apikey.go new file mode 100644 index 0000000..518a03d --- /dev/null +++ b/openmeter-collector/builder-api/internal/apikey/apikey.go @@ -0,0 +1,128 @@ +package apikey + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +const secretBytes = 24 + +// StoredKey is persisted in Auth0 app_metadata.builder_api_key. +type StoredKey struct { + Salt string `json:"salt"` + Hash string `json:"hash"` +} + +// Generate creates a new API key and its stored hash record. +// Format: {prefix}{encodedUserID}_{secret} +func Generate(prefix, auth0UserID string) (plaintext string, stored StoredKey, err error) { + secret := make([]byte, secretBytes) + if _, err = rand.Read(secret); err != nil { + return "", StoredKey{}, err + } + secretHex := hex.EncodeToString(secret) + + salt := make([]byte, 16) + if _, err = rand.Read(salt); err != nil { + return "", StoredKey{}, err + } + + encodedUserID := base64.RawURLEncoding.EncodeToString([]byte(auth0UserID)) + plaintext = fmt.Sprintf("%s%s_%s", prefix, encodedUserID, secretHex) + stored = StoredKey{ + Salt: hex.EncodeToString(salt), + Hash: hashSecret(salt, secret), + } + return plaintext, stored, nil +} + +// ParseUserID extracts the Auth0 user id embedded in an API key. +func ParseUserID(prefix, apiKey string) (string, error) { + if !strings.HasPrefix(apiKey, prefix) { + return "", fmt.Errorf("invalid api key prefix") + } + rest := strings.TrimPrefix(apiKey, prefix) + parts := strings.SplitN(rest, "_", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("invalid api key format") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", fmt.Errorf("invalid api key encoding") + } + return string(raw), nil +} + +// Verify checks a plaintext API key against stored salt/hash. +func Verify(apiKey string, stored StoredKey) bool { + secretHex := apiKey[strings.LastIndex(apiKey, "_")+1:] + secret, err := hex.DecodeString(secretHex) + if err != nil { + return false + } + salt, err := hex.DecodeString(stored.Salt) + if err != nil { + return false + } + return stored.Hash == hashSecret(salt, secret) +} + +func hashSecret(salt, secret []byte) string { + h := sha256.New() + h.Write(salt) + h.Write(secret) + return hex.EncodeToString(h.Sum(nil)) +} + +// DemoEntry is one env-backed demo API key mapping. +type DemoEntry struct { + ClientID string `json:"clientId"` + UserID string `json:"userId"` + UsageSubjectType string `json:"usageSubjectType"` +} + +// LoadDemoStore parses DEMO_API_KEYS JSON into a map of apiKey -> entry. +func LoadDemoStore(raw string) (map[string]DemoEntry, error) { + store := make(map[string]DemoEntry) + if raw == "" { + return store, nil + } + var parsed map[string]DemoEntry + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, fmt.Errorf("DEMO_API_KEYS must be valid JSON: %w", err) + } + for key, entry := range parsed { + key = strings.TrimSpace(key) + if key == "" { + continue + } + userID := strings.TrimSpace(entry.UserID) + if userID == "" { + continue + } + clientID := strings.TrimSpace(entry.ClientID) + if clientID == "" { + clientID = "demo-client" + } + usageType := strings.TrimSpace(entry.UsageSubjectType) + if usageType == "" { + usageType = "api_key_user" + } + store[key] = DemoEntry{ + ClientID: clientID, + UserID: userID, + UsageSubjectType: usageType, + } + } + return store, nil +} + +// IsM2MSecret returns true when the bearer token looks like an M2M client secret. +func IsM2MSecret(token string) bool { + return strings.HasPrefix(token, "pmth_cs_") || strings.Contains(token, "secret") +} diff --git a/openmeter-collector/builder-api/internal/apikey/apikey_test.go b/openmeter-collector/builder-api/internal/apikey/apikey_test.go new file mode 100644 index 0000000..aa1c541 --- /dev/null +++ b/openmeter-collector/builder-api/internal/apikey/apikey_test.go @@ -0,0 +1,37 @@ +package apikey_test + +import ( + "testing" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" +) + +func TestGenerateParseVerify(t *testing.T) { + prefix := "sk_" + userID := "auth0|abc123" + plaintext, stored, err := apikey.Generate(prefix, userID) + if err != nil { + t.Fatal(err) + } + parsed, err := apikey.ParseUserID(prefix, plaintext) + if err != nil { + t.Fatal(err) + } + if parsed != userID { + t.Fatalf("parsed user id = %q, want %q", parsed, userID) + } + if !apikey.Verify(plaintext, stored) { + t.Fatal("verify failed") + } +} + +func TestLoadDemoStore(t *testing.T) { + store, err := apikey.LoadDemoStore(`{"sk_demo":{"clientId":"c1","userId":"u1"}}`) + if err != nil { + t.Fatal(err) + } + entry, ok := store["sk_demo"] + if !ok || entry.ClientID != "c1" || entry.UserID != "u1" { + t.Fatalf("unexpected entry: %+v ok=%v", entry, ok) + } +} diff --git a/openmeter-collector/builder-api/internal/apikey/store.go b/openmeter-collector/builder-api/internal/apikey/store.go new file mode 100644 index 0000000..aa1fea2 --- /dev/null +++ b/openmeter-collector/builder-api/internal/apikey/store.go @@ -0,0 +1,35 @@ +package apikey + +import ( + "context" + "errors" +) + +// ErrClientMismatch is returned when an API key belongs to a different client. +var ErrClientMismatch = errors.New("api key client mismatch") + +// Auth0Resolver validates API keys stored in Auth0 app_metadata. +type Auth0Resolver interface { + ResolveAPIKeyUser(ctx context.Context, apiKey, keyPrefix, expectedClientID string) (clientID, externalUserID string, err error) +} + +// Store resolves bearer API keys from demo env mappings or Auth0. +type Store struct { + Prefix string + Demo map[string]DemoEntry + Auth0 Auth0Resolver +} + +// Resolve returns client and external user ids for a plaintext API key. +func (s *Store) Resolve(ctx context.Context, token, expectedClientID string) (clientID, externalUserID string, err error) { + if entry, ok := s.Demo[token]; ok { + if expectedClientID != "" && entry.ClientID != expectedClientID { + return "", "", ErrClientMismatch + } + return entry.ClientID, entry.UserID, nil + } + if s.Auth0 == nil { + return "", "", errors.New("invalid api key") + } + return s.Auth0.ResolveAPIKeyUser(ctx, token, s.Prefix, expectedClientID) +} diff --git a/openmeter-collector/builder-api/internal/apikey/store_test.go b/openmeter-collector/builder-api/internal/apikey/store_test.go new file mode 100644 index 0000000..0d4e942 --- /dev/null +++ b/openmeter-collector/builder-api/internal/apikey/store_test.go @@ -0,0 +1,76 @@ +package apikey + +import ( + "context" + "errors" + "testing" +) + +type stubAuth0 struct { + clientID string + userID string + err error +} + +func (s stubAuth0) ResolveAPIKeyUser(_ context.Context, _, _, _ string) (string, string, error) { + if s.err != nil { + return "", "", s.err + } + return s.clientID, s.userID, nil +} + +func TestStoreResolveDemoKey(t *testing.T) { + t.Parallel() + + store := &Store{ + Prefix: "sk_", + Demo: map[string]DemoEntry{ + "sk_demo": { + ClientID: "demo-client", + UserID: "demo-user", + }, + }, + } + + clientID, userID, err := store.Resolve(context.Background(), "sk_demo", "demo-client") + if err != nil { + t.Fatal(err) + } + if clientID != "demo-client" || userID != "demo-user" { + t.Fatalf("got %q %q", clientID, userID) + } +} + +func TestStoreResolveClientMismatch(t *testing.T) { + t.Parallel() + + store := &Store{ + Prefix: "sk_", + Demo: map[string]DemoEntry{ + "sk_demo": {ClientID: "demo-client", UserID: "demo-user"}, + }, + } + _, _, err := store.Resolve(context.Background(), "sk_demo", "other-client") + if !errors.Is(err, ErrClientMismatch) { + t.Fatalf("expected ErrClientMismatch, got %v", err) + } +} + +func TestStoreResolveAuth0(t *testing.T) { + t.Parallel() + + store := &Store{ + Prefix: "sk_", + Auth0: stubAuth0{ + clientID: "app-1", + userID: "auth0|u1", + }, + } + clientID, userID, err := store.Resolve(context.Background(), "sk_live", "app-1") + if err != nil { + t.Fatal(err) + } + if clientID != "app-1" || userID != "auth0|u1" { + t.Fatalf("got %q %q", clientID, userID) + } +} diff --git a/openmeter-collector/builder-api/internal/auth0mgmt/client.go b/openmeter-collector/builder-api/internal/auth0mgmt/client.go new file mode 100644 index 0000000..f9a9cf4 --- /dev/null +++ b/openmeter-collector/builder-api/internal/auth0mgmt/client.go @@ -0,0 +1,239 @@ +package auth0mgmt + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/auth0/go-auth0/v2/management" + auth0client "github.com/auth0/go-auth0/v2/management/client" + "github.com/auth0/go-auth0/v2/management/option" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" +) + +// Client wraps Auth0 Management API user operations. +type Client struct { + api *auth0client.Management + dbConnection string +} + +// UserRecord is a provisioned Auth0 end-user. +type UserRecord struct { + ID string + Email string + Connection string + ClientID string + ExternalUserID string + Created bool + APIKey string +} + +// New creates a Management API client. +func New(domain, clientID, clientSecret, dbConnection string) (*Client, error) { + api, err := auth0client.New( + domain, + option.WithClientCredentialsAndAudience( + context.Background(), + clientID, + clientSecret, + "https://"+strings.TrimSuffix(domain, "/")+"/api/v2/", + ), + ) + if err != nil { + return nil, fmt.Errorf("auth0 management client: %w", err) + } + return &Client{ + api: api, + dbConnection: dbConnection, + }, nil +} + +// UpsertUser creates or updates an Auth0 Database user for an integrator end-user. +func (c *Client) UpsertUser(ctx context.Context, publicClientID, externalUserID, email, connection string, issueAPIKey bool, keyPrefix string) (*UserRecord, error) { + if connection == "" { + connection = c.dbConnection + } + + existing, err := c.findByMetadata(ctx, publicClientID, externalUserID) + if err != nil { + return nil, err + } + + if existing != nil { + rec := userRecordFromAuth0(existing, publicClientID, externalUserID, false) + if email != "" && existing.GetEmail() != email { + appMeta := cloneAppMetadata(existing.GetAppMetadata()) + _, err = c.api.Users.Update(ctx, existing.GetUserID(), &management.UpdateUserRequestContent{ + Email: management.String(email), + AppMetadata: &appMeta, + }) + if err != nil { + return nil, fmt.Errorf("update auth0 user email: %w", err) + } + rec.Email = email + } + return rec, nil + } + + username := sanitizeUsername(externalUserID) + password, err := randomPassword() + if err != nil { + return nil, err + } + + appMeta := management.AppMetadata{ + "clientId": publicClientID, + "externalUserId": externalUserID, + } + + create := &management.CreateUserRequestContent{ + Connection: connection, + Password: management.String(password), + AppMetadata: &appMeta, + VerifyEmail: management.Bool(false), + } + if email != "" { + create.Email = management.String(email) + } else { + create.Username = management.String(username) + } + + created, err := c.api.Users.Create(ctx, create) + if err != nil { + return nil, fmt.Errorf("create auth0 user: %w", err) + } + + rec := userRecordFromAuth0(created, publicClientID, externalUserID, true) + if issueAPIKey { + plaintext, stored, err := apikey.Generate(keyPrefix, created.GetUserID()) + if err != nil { + return nil, err + } + appMeta["builder_api_key"] = stored + meta := appMeta + _, err = c.api.Users.Update(ctx, created.GetUserID(), &management.UpdateUserRequestContent{ + AppMetadata: &meta, + }) + if err != nil { + return nil, fmt.Errorf("store api key metadata: %w", err) + } + rec.APIKey = plaintext + } + return rec, nil +} + +// ResolveAPIKeyUser validates an API key and returns client/external user ids. +func (c *Client) ResolveAPIKeyUser(ctx context.Context, apiKey, keyPrefix, expectedClientID string) (clientID, externalUserID string, err error) { + userID, err := apikey.ParseUserID(keyPrefix, apiKey) + if err != nil { + return "", "", err + } + + u, err := c.api.Users.Get(ctx, userID, &management.GetUserRequestParameters{}) + if err != nil { + return "", "", fmt.Errorf("load auth0 user: %w", err) + } + + meta, err := parseAppMetadata(u.GetAppMetadata()) + if err != nil { + return "", "", err + } + if meta.BuilderAPIKey == nil || !apikey.Verify(apiKey, *meta.BuilderAPIKey) { + return "", "", fmt.Errorf("invalid api key") + } + if expectedClientID != "" && meta.ClientID != expectedClientID { + return "", "", fmt.Errorf("api key client mismatch") + } + return meta.ClientID, meta.ExternalUserID, nil +} + +func (c *Client) findByMetadata(ctx context.Context, clientID, externalUserID string) (*management.UserResponseSchema, error) { + query := fmt.Sprintf(`app_metadata.clientId:"%s" AND app_metadata.externalUserId:"%s"`, + escapeQuery(clientID), escapeQuery(externalUserID)) + page, err := c.api.Users.List(ctx, &management.ListUsersRequestParameters{ + Q: management.String(query), + PerPage: management.Int(5), + }) + if err != nil { + return nil, fmt.Errorf("search auth0 users: %w", err) + } + if page == nil || len(page.Results) == 0 { + return nil, nil + } + return page.Results[0], nil +} + +func userRecordFromAuth0(u interface { + GetUserID() string + GetEmail() string + GetIdentities() []*management.UserIdentitySchema +}, clientID, externalUserID string, created bool) *UserRecord { + connection := "" + if ids := u.GetIdentities(); len(ids) > 0 && ids[0] != nil { + connection = ids[0].GetConnection() + } + return &UserRecord{ + ID: u.GetUserID(), + Email: u.GetEmail(), + Connection: connection, + ClientID: clientID, + ExternalUserID: externalUserID, + Created: created, + } +} + +type appMetadata struct { + ClientID string `json:"clientId"` + ExternalUserID string `json:"externalUserId"` + BuilderAPIKey *apikey.StoredKey `json:"builder_api_key,omitempty"` +} + +func parseAppMetadata(raw management.UserAppMetadataSchema) (appMetadata, error) { + if raw == nil { + return appMetadata{}, fmt.Errorf("missing app_metadata") + } + b, err := json.Marshal(raw) + if err != nil { + return appMetadata{}, err + } + var meta appMetadata + if err := json.Unmarshal(b, &meta); err != nil { + return appMetadata{}, err + } + if meta.ClientID == "" || meta.ExternalUserID == "" { + return appMetadata{}, fmt.Errorf("incomplete app_metadata") + } + return meta, nil +} + +func sanitizeUsername(externalUserID string) string { + s := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-', r == '.': + return r + default: + return '_' + } + }, externalUserID) + if s == "" { + return "user" + } + if len(s) > 60 { + return s[:60] + } + return s +} + +func escapeQuery(value string) string { + return strings.ReplaceAll(value, `"`, `\"`) +} + +func cloneAppMetadata(raw management.UserAppMetadataSchema) management.AppMetadata { + out := management.AppMetadata{} + for k, v := range raw { + out[k] = v + } + return out +} diff --git a/openmeter-collector/builder-api/internal/auth0mgmt/password.go b/openmeter-collector/builder-api/internal/auth0mgmt/password.go new file mode 100644 index 0000000..7141918 --- /dev/null +++ b/openmeter-collector/builder-api/internal/auth0mgmt/password.go @@ -0,0 +1,14 @@ +package auth0mgmt + +import ( + "crypto/rand" + "encoding/base64" +) + +func randomPassword() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b) + "Aa1!", nil +} diff --git a/openmeter-collector/builder-api/internal/auth0mint/minter.go b/openmeter-collector/builder-api/internal/auth0mint/minter.go new file mode 100644 index 0000000..66cb64a --- /dev/null +++ b/openmeter-collector/builder-api/internal/auth0mint/minter.go @@ -0,0 +1,97 @@ +package auth0mint + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const signMintUserTokenScope = "sign:mint_user_token" + +// TokenResponse is the Auth0 token endpoint response for signer mint. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +// Minter mints short-lived signer JWTs via Auth0 client_credentials. +type Minter struct { + issuerURL string + audience string + clientID string + secret string + http *http.Client +} + +// New creates a signer-token minter. +func New(issuerURL, audience, clientID, secret string) *Minter { + return &Minter{ + issuerURL: strings.TrimSuffix(issuerURL, "/"), + audience: audience, + clientID: clientID, + secret: secret, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// MintSignerToken requests a signer JWT for an app end-user. +// publicClientID is the integrator's public Auth0 client id (path param to Builder API). +func (m *Minter) MintSignerToken(ctx context.Context, publicClientID, externalUserID string) (*TokenResponse, error) { + tokenURL := m.issuerURL + "/oauth/token" + form := url.Values{ + "grant_type": {"client_credentials"}, + "scope": {signMintUserTokenScope}, + "external_user_id": {externalUserID}, + "client_id": {publicClientID}, + "audience": {m.audience}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(m.clientID, m.secret) + + resp, err := m.http.Do(req) + if err != nil { + return nil, fmt.Errorf("auth0 token request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("auth0 token endpoint %d: %s", resp.StatusCode, string(body)) + } + + var parsed TokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("decode token response: %w", err) + } + if parsed.AccessToken == "" { + return nil, fmt.Errorf("token response missing access_token") + } + if parsed.ExpiresIn <= 0 { + parsed.ExpiresIn = 300 + } + if parsed.TokenType == "" { + parsed.TokenType = "Bearer" + } + if parsed.Scope == "" { + parsed.Scope = "sign:job" + } + return &parsed, nil +} diff --git a/openmeter-collector/builder-api/internal/config/config.go b/openmeter-collector/builder-api/internal/config/config.go new file mode 100644 index 0000000..98b4b3f --- /dev/null +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -0,0 +1,144 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds runtime configuration for the Builder API. +type Config struct { + Port string + Auth0Domain string + Auth0Issuer string + Auth0Audience string + MgmtClientID string + MgmtClientSecret string + SignerM2MClientID string + SignerM2MSecret string + DBConnection string + OpenMeterURL string + OpenMeterAPIKey string + OpenMeterDefaultPlanKey string + KonnectCredentialsURL string + PlatformAPISecret string + OpenMeterTrialFeatureKey string + OpenMeterTrialGrantUSDMicros int64 + OpenMeterEnforceAllowance bool + SignerURL string + DiscoveryURL string + APIKeyPrefix string + DemoAPIKeys string + // IdentityWebhookURL and WebhookSecret delegate end-user JWT verification to the + // identity-webhook service (POST /authorize). JWT subject tokens require both. + IdentityWebhookURL string + WebhookSecret string +} + +// Load reads configuration from environment variables. +func Load() (Config, error) { + cfg := Config{ + Port: envOr("BUILDER_API_PORT", "8095"), + Auth0Domain: firstEnv("AUTH0_DOMAIN"), + Auth0Issuer: firstEnv("AUTH0_ISSUER"), + Auth0Audience: envOr(firstEnv("AUTH0_AUDIENCE", "DEMO_APP_AUTH0_AUDIENCE"), "livepeer-clearinghouse"), + MgmtClientID: firstEnv("AUTH0_MGMT_CLIENT_ID"), + MgmtClientSecret: firstEnv("AUTH0_MGMT_CLIENT_SECRET"), + SignerM2MClientID: firstEnv("AUTH0_SIGNER_M2M_CLIENT_ID", "DEMO_APP_AUTH0_M2M_CLIENT_ID"), + SignerM2MSecret: firstEnv("AUTH0_SIGNER_M2M_CLIENT_SECRET", "DEMO_APP_AUTH0_M2M_CLIENT_SECRET"), + DBConnection: envOr("AUTH0_DB_CONNECTION", "Username-Password-Authentication"), + OpenMeterURL: envOr("OPENMETER_URL", "https://us.api.konghq.com/v3/openmeter"), + OpenMeterAPIKey: strings.TrimSpace(os.Getenv("OPENMETER_API_KEY")), + OpenMeterDefaultPlanKey: envOr("OPENMETER_DEFAULT_PLAN_KEY", "clearinghouse_default_ppu"), + KonnectCredentialsURL: strings.TrimSpace(os.Getenv("KONNECT_CREDENTIALS_URL")), + PlatformAPISecret: strings.TrimSpace(os.Getenv("PLATFORM_API_SECRET")), + OpenMeterTrialFeatureKey: envOr("OPENMETER_TRIAL_FEATURE_KEY", "billable_spend"), + OpenMeterEnforceAllowance: envBool("OPENMETER_ENFORCE_ALLOWANCE", true), + SignerURL: strings.TrimSpace(os.Getenv("SIGNER_URL")), + DiscoveryURL: envOr( + "DISCOVERY_URL", + "https://discovery-service-production-8955.up.railway.app/v1/discovery/raw?serviceType=legacy", + ), + APIKeyPrefix: envOr("API_KEY_PREFIX", "sk_"), + DemoAPIKeys: strings.TrimSpace(os.Getenv("DEMO_API_KEYS")), + IdentityWebhookURL: firstEnv("IDENTITY_WEBHOOK_URL", "REMOTE_SIGNER_WEBHOOK_URL"), + WebhookSecret: strings.TrimSpace(os.Getenv("WEBHOOK_SECRET")), + } + + if v := strings.TrimSpace(os.Getenv("OPENMETER_TRIAL_GRANT_USD_MICROS")); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return cfg, fmt.Errorf("OPENMETER_TRIAL_GRANT_USD_MICROS must be an integer: %w", err) + } + cfg.OpenMeterTrialGrantUSDMicros = n + } + + if cfg.Auth0Issuer == "" && cfg.Auth0Domain != "" { + cfg.Auth0Issuer = "https://" + strings.TrimSuffix(cfg.Auth0Domain, "/") + "/" + } + cfg.Auth0Issuer = strings.TrimSuffix(cfg.Auth0Issuer, "/") + + missing := make([]string, 0) + if cfg.Auth0Domain == "" { + missing = append(missing, "AUTH0_DOMAIN") + } + if cfg.MgmtClientID == "" { + missing = append(missing, "AUTH0_MGMT_CLIENT_ID") + } + if cfg.MgmtClientSecret == "" { + missing = append(missing, "AUTH0_MGMT_CLIENT_SECRET") + } + if cfg.SignerM2MClientID == "" { + missing = append(missing, "AUTH0_SIGNER_M2M_CLIENT_ID") + } + if cfg.SignerM2MSecret == "" { + missing = append(missing, "AUTH0_SIGNER_M2M_CLIENT_SECRET") + } + if cfg.KonnectCredentialsURL == "" && cfg.OpenMeterAPIKey == "" { + missing = append(missing, "OPENMETER_API_KEY or KONNECT_CREDENTIALS_URL") + } + if cfg.KonnectCredentialsURL != "" && cfg.PlatformAPISecret == "" { + missing = append(missing, "PLATFORM_API_SECRET") + } + if len(missing) > 0 { + return cfg, fmt.Errorf("missing required env: %s", strings.Join(missing, ", ")) + } + + if _, err := strconv.Atoi(cfg.Port); err != nil { + return cfg, fmt.Errorf("BUILDER_API_PORT must be numeric: %w", err) + } + + return cfg, nil +} + +func envOr(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func firstEnv(keys ...string) string { + for _, key := range keys { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + } + return "" +} + +func envBool(key string, fallback bool) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return fallback + } +} diff --git a/openmeter-collector/builder-api/internal/httpapi/auth.go b/openmeter-collector/builder-api/internal/httpapi/auth.go new file mode 100644 index 0000000..72de06a --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/auth.go @@ -0,0 +1,60 @@ +package httpapi + +import ( + "encoding/base64" + "net/http" + "net/url" + "strings" +) + +// M2MAuth validates HTTP Basic auth against the configured signer M2M client. +func M2MAuth(r *http.Request, expectedClientID, expectedSecret string) bool { + clientID, secret, ok := ClientCredentialsFromRequest(r, nil) + if !ok { + return false + } + return clientID == expectedClientID && secret == expectedSecret +} + +// ClientCredentialsFromRequest extracts OAuth client credentials from Basic auth +// or application/x-www-form-urlencoded body fields. +func ClientCredentialsFromRequest(r *http.Request, form url.Values) (clientID, clientSecret string, ok bool) { + if username, password, basicOK := r.BasicAuth(); basicOK { + return username, password, true + } + + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(auth), "basic ") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(auth[6:])) + if err == nil { + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return parts[0], parts[1], true + } + } + } + + if form != nil { + clientID = strings.TrimSpace(form.Get("client_id")) + clientSecret = strings.TrimSpace(form.Get("client_secret")) + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, true + } + } + return "", "", false +} + +// BearerToken extracts the bearer token from Authorization header. +func BearerToken(r *http.Request) string { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if len(auth) < 7 || !strings.EqualFold(auth[:6], "bearer") { + return "" + } + if len(auth) == 6 { + return "" + } + if auth[6] != ' ' { + return "" + } + return strings.TrimSpace(auth[7:]) +} diff --git a/openmeter-collector/builder-api/internal/httpapi/errors.go b/openmeter-collector/builder-api/internal/httpapi/errors.go new file mode 100644 index 0000000..32baae4 --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/errors.go @@ -0,0 +1,82 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" +) + +// OAuthError is an OAuth 2.0-style error response body. +type OAuthError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +// APIError is a simple JSON error for non-OAuth routes. +type APIError struct { + Error string `json:"error"` +} + +func newCorrelationID() string { + return uuid.NewString() +} + +func setNoStoreHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") +} + +func writeOAuthError(w http.ResponseWriter, status int, code, description, correlationID string) { + w.Header().Set("Content-Type", "application/json") + setNoStoreHeaders(w) + if status == http.StatusUnauthorized && code == "invalid_client" { + w.Header().Set("WWW-Authenticate", `Basic realm="token", charset="UTF-8"`) + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(OAuthError{ + Error: code, + ErrorDescription: description, + CorrelationID: correlationID, + }) +} + +func writeTokenExchangeError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Content-Type", "application/json") + setNoStoreHeaders(w) + if status == http.StatusUnauthorized && code == "invalid_client" { + w.Header().Set("WWW-Authenticate", `Basic realm="token", charset="UTF-8"`) + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(OAuthError{ + Error: code, + ErrorDescription: description, + }) +} + +func writeAPIError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(APIError{Error: message}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeTokenJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + setNoStoreHeaders(w) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func oauthDescription(code, description string) string { + if description != "" { + return description + } + return code +} diff --git a/openmeter-collector/builder-api/internal/httpapi/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go new file mode 100644 index 0000000..f252bd0 --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -0,0 +1,170 @@ +package httpapi + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + auth0mgmt "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mgmt" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mint" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/config" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" +) + +// Server wires Builder API routes and dependencies. +type Server struct { + cfg config.Config + auth0 *auth0mgmt.Client + minter *auth0mint.Minter + openmeter openmeterSession + tokenExchange *tokenexchange.Handler + openAPISpec []byte +} + +type openmeterSession interface { + ProvisionSession(ctx context.Context, cfg openmeter.ProvisionConfig, clientID, externalUserID string) (*openmeter.SessionProvision, error) +} + +// NewServer constructs the HTTP API server. +func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om openmeterSession, tokenExchange *tokenexchange.Handler, openAPISpec []byte) *Server { + return &Server{ + cfg: cfg, + auth0: auth0, + minter: minter, + openmeter: om, + tokenExchange: tokenExchange, + openAPISpec: openAPISpec, + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI) + mux.HandleFunc("GET /api/v1/docs", s.handleDocs) + mux.HandleFunc("POST /api/v1/apps/{clientId}/users", s.handleCreateUser) + mux.HandleFunc("POST /api/v1/apps/{clientId}/oidc/token", s.handleOIDCToken) + return mux +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(s.openAPISpec) +} + +func (s *Server) handleDocs(w http.ResponseWriter, _ *http.Request) { + html := ` + + + Clearinghouse Builder API + + + + + + +` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(html)) +} + +type createUserRequest struct { + ExternalUserID string `json:"externalUserId"` + Email string `json:"email"` + Connection string `json:"connection"` + IssueAPIKey *bool `json:"issueApiKey"` +} + +type createUserResponse struct { + ID string `json:"id"` + ClientID string `json:"clientId"` + ExternalUserID string `json:"externalUserId"` + Email string `json:"email,omitempty"` + Status string `json:"status"` + APIKey string `json:"apiKey,omitempty"` +} + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + clientID := strings.TrimSpace(r.PathValue("clientId")) + if clientID == "" { + writeAPIError(w, http.StatusBadRequest, "clientId is required") + return + } + if !M2MAuth(r, s.cfg.SignerM2MClientID, s.cfg.SignerM2MSecret) { + writeAPIError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + body, err := readJSONBody[createUserRequest](r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + externalUserID := strings.TrimSpace(body.ExternalUserID) + if externalUserID == "" { + writeAPIError(w, http.StatusBadRequest, "externalUserId is required") + return + } + + issueKey := true + if body.IssueAPIKey != nil { + issueKey = *body.IssueAPIKey + } + + ctx := r.Context() + user, err := s.auth0.UpsertUser(ctx, clientID, externalUserID, strings.TrimSpace(body.Email), strings.TrimSpace(body.Connection), issueKey, s.cfg.APIKeyPrefix) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + if _, err := s.openmeter.ProvisionSession(ctx, openmeter.ProvisionConfig{ + DefaultPlanKey: s.cfg.OpenMeterDefaultPlanKey, + TrialFeatureKey: s.cfg.OpenMeterTrialFeatureKey, + TrialGrantUSDMicros: s.cfg.OpenMeterTrialGrantUSDMicros, + }, clientID, externalUserID); err != nil { + writeAPIError(w, http.StatusBadGateway, "openmeter customer provisioning failed") + return + } + + status := http.StatusOK + if user.Created { + status = http.StatusCreated + } + writeJSON(w, status, createUserResponse{ + ID: user.ID, + ClientID: clientID, + ExternalUserID: externalUserID, + Email: user.Email, + Status: "active", + APIKey: user.APIKey, + }) +} + +func readJSONBody[T any](r *http.Request) (T, error) { + var zero T + defer r.Body.Close() + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return zero, err + } + if len(strings.TrimSpace(string(raw))) == 0 { + return zero, nil + } + var out T + if err := json.Unmarshal(raw, &out); err != nil { + return zero, err + } + return out, nil +} diff --git a/openmeter-collector/builder-api/internal/httpapi/token.go b/openmeter-collector/builder-api/internal/httpapi/token.go new file mode 100644 index 0000000..0e9dea3 --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/token.go @@ -0,0 +1,94 @@ +package httpapi + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" +) + +type tokenExchangeResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + SignerURL string `json:"signer_url,omitempty"` + DiscoveryURL string `json:"discovery_url,omitempty"` + IssuedTokenType string `json:"issued_token_type,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + HasAccess bool `json:"has_access"` + BalanceUSDMicros int64 `json:"balance_usd_micros"` +} + +func (s *Server) handleOIDCToken(w http.ResponseWriter, r *http.Request) { + correlationID := newCorrelationID() + if r.Method != http.MethodPost { + writeTokenExchangeError(w, http.StatusMethodNotAllowed, "invalid_request", "method not allowed") + return + } + + contentType := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) + if !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { + writeTokenExchangeError(w, http.StatusBadRequest, "invalid_request", "content-type must be application/x-www-form-urlencoded") + return + } + + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeTokenExchangeError(w, http.StatusBadRequest, "invalid_request", "unable to read request body") + return + } + defer r.Body.Close() + + form, err := url.ParseQuery(string(raw)) + if err != nil { + writeTokenExchangeError(w, http.StatusBadRequest, "invalid_request", "malformed form body") + return + } + + publicClientID := strings.TrimSpace(r.PathValue("clientId")) + if publicClientID == "" { + writeTokenExchangeError(w, http.StatusBadRequest, "invalid_request", "clientId is required") + return + } + + clientID, clientSecret, _ := ClientCredentialsFromRequest(r, form) + req := tokenexchange.Request{ + PublicClientID: publicClientID, + ClientID: clientID, + ClientSecret: clientSecret, + GrantType: form.Get("grant_type"), + SubjectToken: form.Get("subject_token"), + SubjectTokenType: form.Get("subject_token_type"), + RequestedTokenType: form.Get("requested_token_type"), + Resource: form.Get("resource"), + Audiences: form["audience"], + } + + result, err := s.tokenExchange.Exchange(r.Context(), req, correlationID) + if err != nil { + var te *tokenexchange.Error + if errors.As(err, &te) { + writeTokenExchangeError(w, te.Status, te.Code, oauthDescription(te.Code, te.Error())) + return + } + writeTokenExchangeError(w, http.StatusInternalServerError, "server_error", "token exchange failed") + return + } + + writeTokenJSON(w, http.StatusOK, tokenExchangeResponse{ + AccessToken: result.AccessToken, + TokenType: result.TokenType, + ExpiresIn: result.ExpiresIn, + Scope: result.Scope, + SignerURL: result.SignerURL, + DiscoveryURL: result.DiscoveryURL, + IssuedTokenType: result.IssuedTokenType, + CorrelationID: result.CorrelationID, + HasAccess: result.HasAccess, + BalanceUSDMicros: result.BalanceUSDMicros, + }) +} diff --git a/openmeter-collector/builder-api/internal/httpapi/token_test.go b/openmeter-collector/builder-api/internal/httpapi/token_test.go new file mode 100644 index 0000000..6ade5c6 --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/token_test.go @@ -0,0 +1,132 @@ +package httpapi_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + auth0mint "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mint" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/config" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/httpapi" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" +) + +type stubMinter struct{} + +func (stubMinter) MintSignerToken(context.Context, string, string) (*auth0mint.TokenResponse, error) { + return &auth0mint.TokenResponse{ + AccessToken: "minted", + TokenType: "Bearer", + ExpiresIn: 300, + Scope: "sign:job", + }, nil +} + +type stubProvisioner struct { + provision *openmeter.SessionProvision + err error +} + +func (s stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionConfig, string, string) (*openmeter.SessionProvision, error) { + if s.err != nil { + return nil, s.err + } + if s.provision != nil { + return s.provision, nil + } + return &openmeter.SessionProvision{ + Customer: &openmeter.Customer{}, + CustomerKey: "pub-client:demo-user", + }, nil +} + +func TestHandleOIDCTokenRejectsUnsupportedGrantType(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + } + handler := tokenexchange.NewHandler( + cfg, + nil, + &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, + }, + }, + stubMinter{}, + stubProvisioner{}, + ) + srv := httpapi.NewServer(cfg, nil, nil, nil, handler, nil) + + body := "grant_type=client_credentials&subject_token=sk_demo&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token" + req := httptest.NewRequest(http.MethodPost, "/api/v1/apps/pub-client/oidc/token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid_request") { + t.Fatalf("body = %s", rec.Body.String()) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("expected Cache-Control: no-store") + } +} + +func TestHandleOIDCTokenAPIKeyExchange(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + } + handler := tokenexchange.NewHandler( + cfg, + nil, + &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, + }, + }, + stubMinter{}, + stubProvisioner{}, + ) + srv := httpapi.NewServer(cfg, nil, nil, nil, handler, nil) + + body := strings.Join([]string{ + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", + "subject_token=sk_demo", + "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", + "audience=livepeer-clearinghouse", + }, "&") + req := httptest.NewRequest(http.MethodPost, "/api/v1/apps/pub-client/oidc/token", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"access_token":"minted"`) { + t.Fatalf("body = %s", rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"issued_token_type":"urn:ietf:params:oauth:token-type:access_token"`) { + t.Fatalf("body = %s", rec.Body.String()) + } +} diff --git a/openmeter-collector/builder-api/internal/openmeter/access.go b/openmeter-collector/builder-api/internal/openmeter/access.go new file mode 100644 index 0000000..1ca7412 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/access.go @@ -0,0 +1,324 @@ +package openmeter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" +) + +const microsPerDollar = 1_000_000 + +// Access is the customer's prepaid / entitlement balance snapshot. +type Access struct { + HasAccess bool + BalanceUSDMicros int64 + Source string // "credits" | "entitlement" | "none" +} + +type creditBalanceResponse struct { + Balances []creditBalanceRow `json:"balances"` +} + +type creditBalanceRow struct { + Currency string `json:"currency"` + Live string `json:"live"` +} + +type creditGrantsResponse struct { + Data []creditGrantRow `json:"data"` +} + +type creditGrantRow struct { + ID string `json:"id"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + Key string `json:"key"` + Name string `json:"name"` +} + +type entitlementAccessResponse struct { + Entitlements []entitlementAccess `json:"entitlements"` + Data []entitlementAccess `json:"data"` +} + +type entitlementAccess struct { + FeatureKey string `json:"featureKey"` + Feature string `json:"feature_key"` + HasAccess *bool `json:"hasAccess"` + HasAccess2 *bool `json:"has_access"` + Balance *float64 `json:"balance"` + Value json.RawMessage `json:"value"` +} + +// GetAccess reads credits balance first, then entitlement-access as fallback. +func (c *Client) GetAccess(ctx context.Context, customerID, featureKey string) (*Access, error) { + customerID = strings.TrimSpace(customerID) + if customerID == "" { + return nil, fmt.Errorf("customer id is required") + } + + credits, err := c.getCreditsBalance(ctx, customerID) + if err != nil { + return nil, err + } + if credits != nil { + return credits, nil + } + + return c.getEntitlementAccess(ctx, customerID, featureKey) +} + +func (c *Client) getCreditsBalance(ctx context.Context, customerID string) (*Access, error) { + q := url.Values{} + q.Set("filter[currency][eq]", "USD") + path := fmt.Sprintf("/customers/%s/credits/balance?%s", url.PathEscape(customerID), q.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter credits balance: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusNotFound { + return &Access{HasAccess: false, BalanceUSDMicros: 0, Source: "credits"}, nil + } + if resp.StatusCode == http.StatusNotImplemented || resp.StatusCode == http.StatusBadRequest { + return nil, nil // signal fallback + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Some orgs may not enable credits; fall back to entitlements. + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, fmt.Errorf("openmeter credits balance %d: %s", resp.StatusCode, string(body)) + } + + var parsed creditBalanceResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + var live string + for _, row := range parsed.Balances { + if strings.EqualFold(row.Currency, "USD") { + live = row.Live + break + } + } + if live == "" && len(parsed.Balances) > 0 { + live = parsed.Balances[0].Live + } + micros, err := decimalDollarsToUSDMicros(live) + if err != nil { + micros = 0 + } + if micros < 0 { + micros = 0 + } + return &Access{ + HasAccess: micros > 0, + BalanceUSDMicros: micros, + Source: "credits", + }, nil +} + +func (c *Client) getEntitlementAccess(ctx context.Context, customerID, featureKey string) (*Access, error) { + path := fmt.Sprintf("/customers/%s/entitlement-access", url.PathEscape(customerID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter entitlement-access: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return &Access{HasAccess: false, BalanceUSDMicros: 0, Source: "entitlement"}, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openmeter entitlement-access %d: %s", resp.StatusCode, string(body)) + } + + var parsed entitlementAccessResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + rows := parsed.Entitlements + if len(rows) == 0 { + rows = parsed.Data + } + featureKey = strings.TrimSpace(featureKey) + for _, row := range rows { + key := row.FeatureKey + if key == "" { + key = row.Feature + } + if featureKey != "" && key != "" && key != featureKey { + continue + } + has := false + if row.HasAccess != nil { + has = *row.HasAccess + } else if row.HasAccess2 != nil { + has = *row.HasAccess2 + } + var balance int64 + if row.Balance != nil { + balance = int64(*row.Balance) + if balance < 0 { + balance = 0 + } + if balance > 0 { + has = true + } + } + return &Access{ + HasAccess: has && (balance > 0 || row.Balance == nil), + BalanceUSDMicros: balance, + Source: "entitlement", + }, nil + } + return &Access{HasAccess: false, BalanceUSDMicros: 0, Source: "entitlement"}, nil +} + +// EnsureTrialGrant creates a one-time credit grant when amountMicros > 0 and no matching grant exists. +func (c *Client) EnsureTrialGrant(ctx context.Context, customerID, featureKey, grantKey string, amountMicros int64) error { + if amountMicros <= 0 { + return nil + } + customerID = strings.TrimSpace(customerID) + grantKey = strings.TrimSpace(grantKey) + if customerID == "" || grantKey == "" { + return fmt.Errorf("customer id and grant key are required") + } + + existing, err := c.listCreditGrants(ctx, customerID) + if err != nil { + return err + } + for _, g := range existing { + if g.Key == grantKey { + return nil + } + } + + payload := map[string]any{ + "name": "Clearinghouse trial", + "funding_method": "none", + "currency": "USD", + "amount": usdMicrosToDecimalDollars(amountMicros), + "priority": 1, + "expires_after": "P1Y", + "key": grantKey, + } + if fk := strings.TrimSpace(featureKey); fk != "" { + payload["filters"] = map[string]any{"features": []string{fk}} + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + path := fmt.Sprintf("/customers/%s/credits/grants", url.PathEscape(customerID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("openmeter create credit grant: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode == http.StatusConflict { + return nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("openmeter create credit grant %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) listCreditGrants(ctx context.Context, customerID string) ([]creditGrantRow, error) { + path := fmt.Sprintf("/customers/%s/credits/grants?page[size]=100", url.PathEscape(customerID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, err + } + c.setHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter list credit grants: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openmeter list credit grants %d: %s", resp.StatusCode, string(body)) + } + var parsed creditGrantsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + return parsed.Data, nil +} + +func decimalDollarsToUSDMicros(raw string) (int64, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, nil + } + r := new(big.Rat) + if _, ok := r.SetString(raw); !ok { + return 0, fmt.Errorf("invalid decimal amount %q", raw) + } + r.Mul(r, big.NewRat(microsPerDollar, 1)) + f := new(big.Float).SetRat(r) + i, _ := f.Int64() + return i, nil +} + +func usdMicrosToDecimalDollars(micros int64) string { + if micros < 0 { + micros = 0 + } + whole := micros / microsPerDollar + frac := micros % microsPerDollar + if frac == 0 { + return fmt.Sprintf("%d", whole) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d.%06d", whole, frac), "0"), ".") +} diff --git a/openmeter-collector/builder-api/internal/openmeter/access_test.go b/openmeter-collector/builder-api/internal/openmeter/access_test.go new file mode 100644 index 0000000..ebedad6 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/access_test.go @@ -0,0 +1,123 @@ +package openmeter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDecimalDollarsToUSDMicros(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want int64 + }{ + {"0", 0}, + {"1", 1_000_000}, + {"5.00", 5_000_000}, + {"0.000001", 1}, + {"1.5", 1_500_000}, + } + for _, tc := range cases { + got, err := decimalDollarsToUSDMicros(tc.in) + if err != nil { + t.Fatalf("%q: %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("%q: got %d want %d", tc.in, got, tc.want) + } + } +} + +func TestUSDMicrosToDecimalDollars(t *testing.T) { + t.Parallel() + if got := usdMicrosToDecimalDollars(5_000_000); got != "5" { + t.Fatalf("got %q", got) + } + if got := usdMicrosToDecimalDollars(1_500_000); got != "1.5" { + t.Fatalf("got %q", got) + } +} + +func TestGetAccessCreditsBalance(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/customers/cust-1/credits/balance" { + t.Fatalf("path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(creditBalanceResponse{ + Balances: []creditBalanceRow{{Currency: "USD", Live: "2.5"}}, + }) + })) + t.Cleanup(srv.Close) + + client := New(srv.URL, "token") + access, err := client.GetAccess(context.Background(), "cust-1", "billable_spend") + if err != nil { + t.Fatal(err) + } + if !access.HasAccess || access.BalanceUSDMicros != 2_500_000 || access.Source != "credits" { + t.Fatalf("%+v", access) + } +} + +func TestGetAccessFallsBackToEntitlement(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/customers/cust-1/credits/balance": + http.Error(w, "forbidden", http.StatusForbidden) + case "/customers/cust-1/entitlement-access": + has := true + _ = json.NewEncoder(w).Encode(entitlementAccessResponse{ + Entitlements: []entitlementAccess{{ + FeatureKey: "billable_spend", + HasAccess: &has, + Balance: floatPtr(42), + }}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + client := New(srv.URL, "token") + access, err := client.GetAccess(context.Background(), "cust-1", "billable_spend") + if err != nil { + t.Fatal(err) + } + if !access.HasAccess || access.BalanceUSDMicros != 42 || access.Source != "entitlement" { + t.Fatalf("%+v", access) + } +} + +func TestEnsureTrialGrantIdempotent(t *testing.T) { + t.Parallel() + posts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/customers/cust-1/credits/grants": + _ = json.NewEncoder(w).Encode(creditGrantsResponse{Data: []creditGrantRow{}}) + case r.Method == http.MethodPost && r.URL.Path == "/customers/cust-1/credits/grants": + posts++ + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"g1"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + client := New(srv.URL, "token") + if err := client.EnsureTrialGrant(context.Background(), "cust-1", "billable_spend", "trial:a:b", 1_000_000); err != nil { + t.Fatal(err) + } + if posts != 1 { + t.Fatalf("posts = %d", posts) + } +} + +func floatPtr(v float64) *float64 { return &v } diff --git a/openmeter-collector/builder-api/internal/openmeter/customer.go b/openmeter-collector/builder-api/internal/openmeter/customer.go new file mode 100644 index 0000000..6532716 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/customer.go @@ -0,0 +1,155 @@ +package openmeter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Customer is an OpenMeter customer record. +type Customer struct { + ID string `json:"id"` + Key string `json:"key"` +} + +type customerPage struct { + Data []Customer `json:"data"` +} + +type createCustomerRequest struct { + Key string `json:"key"` + Name string `json:"name"` + UsageAttribution usageAttribution `json:"usage_attribution"` +} + +type usageAttribution struct { + SubjectKeys []string `json:"subject_keys"` +} + +// Client upserts OpenMeter customers via Konnect REST API. +type Client struct { + baseURL string + apiKey string + http *http.Client +} + +// New creates an OpenMeter HTTP client. +func New(baseURL, apiKey string) *Client { + baseURL = strings.TrimSuffix(baseURL, "/") + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + http: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// EnsureCustomer creates a customer when missing; idempotent on key. +func (c *Client) EnsureCustomer(ctx context.Context, clientID, externalUserID, displayName string) (*Customer, error) { + key := CustomerKey(clientID, externalUserID) + if displayName == "" { + displayName = key + } + + existing, err := c.findByKey(ctx, key) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + payload := createCustomerRequest{ + Key: key, + Name: displayName, + UsageAttribution: usageAttribution{ + SubjectKeys: []string{key}, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/customers", bytes.NewReader(body)) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter create customer: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openmeter create customer %d: %s", resp.StatusCode, string(respBody)) + } + + var created Customer + if err := json.Unmarshal(respBody, &created); err != nil { + return nil, err + } + return &created, nil +} + +func (c *Client) findByKey(ctx context.Context, key string) (*Customer, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/customers", nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + q.Set("filter[key]", key) + req.URL.RawQuery = q.Encode() + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter list customers: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openmeter list customers %d: %s", resp.StatusCode, string(respBody)) + } + + var page customerPage + if err := json.Unmarshal(respBody, &page); err == nil && len(page.Data) > 0 { + for _, cust := range page.Data { + if cust.Key == key { + return &cust, nil + } + } + } + + var list []Customer + if err := json.Unmarshal(respBody, &list); err == nil { + for _, cust := range list { + if cust.Key == key { + return &cust, nil + } + } + } + return nil, nil +} + +func (c *Client) setHeaders(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") +} diff --git a/openmeter-collector/builder-api/internal/openmeter/customer_key.go b/openmeter-collector/builder-api/internal/openmeter/customer_key.go new file mode 100644 index 0000000..8e5c59a --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/customer_key.go @@ -0,0 +1,9 @@ +package openmeter + +import "strings" + +// CustomerKey returns the deterministic OpenMeter customer / usage subject key. +// This must match the CloudEvent subject and identity-webhook auth_id compound id. +func CustomerKey(clientID, externalUserID string) string { + return strings.TrimSpace(clientID) + ":" + strings.TrimSpace(externalUserID) +} diff --git a/openmeter-collector/builder-api/internal/openmeter/provision.go b/openmeter-collector/builder-api/internal/openmeter/provision.go new file mode 100644 index 0000000..3facf89 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/provision.go @@ -0,0 +1,22 @@ +package openmeter + +import "context" + +// ProvisionSession upserts the customer and ensures a default-plan subscription. +func (c *Client) ProvisionSession(ctx context.Context, cfg ProvisionConfig, clientID, externalUserID string) (*SessionProvision, error) { + customerKey := CustomerKey(clientID, externalUserID) + + customer, err := c.EnsureCustomer(ctx, clientID, externalUserID, externalUserID) + if err != nil { + return nil, err + } + + if err := c.EnsureDefaultSubscription(ctx, customer.ID, customerKey, cfg.DefaultPlanKey); err != nil { + return nil, err + } + + return &SessionProvision{ + Customer: customer, + CustomerKey: customerKey, + }, nil +} diff --git a/openmeter-collector/builder-api/internal/openmeter/provision_test.go b/openmeter-collector/builder-api/internal/openmeter/provision_test.go new file mode 100644 index 0000000..c05d0af --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/provision_test.go @@ -0,0 +1,92 @@ +package openmeter_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" +) + +func TestCustomerKey(t *testing.T) { + t.Parallel() + got := openmeter.CustomerKey(" pub-client ", " demo-user ") + if got != "pub-client:demo-user" { + t.Fatalf("CustomerKey() = %q", got) + } +} + +func TestProvisionSessionCreatesCustomerAndSubscription(t *testing.T) { + t.Parallel() + + var ( + customerCreated bool + subscriptionCreated bool + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/customers": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/customers": + customerCreated = true + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cust-1","key":"pub-client:demo-user"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/subscriptions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost && r.URL.Path == "/subscriptions": + subscriptionCreated = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"sub-1"}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(server.Close) + + client := openmeter.New(server.URL, "test-key") + result, err := client.ProvisionSession(context.Background(), openmeter.ProvisionConfig{ + DefaultPlanKey: "clearinghouse_default_ppu", + }, "pub-client", "demo-user") + if err != nil { + t.Fatal(err) + } + if result.CustomerKey != "pub-client:demo-user" { + t.Fatalf("customer key = %q", result.CustomerKey) + } + if !customerCreated || !subscriptionCreated { + t.Fatalf("customer=%v subscription=%v", customerCreated, subscriptionCreated) + } +} + +func TestProvisionSessionReusesExistingCustomer(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/customers": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"cust-1","key":"pub-client:demo-user"}]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/subscriptions": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"sub-1","customer_id":"cust-1","status":"active"}]}`)) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + t.Cleanup(server.Close) + + client := openmeter.New(server.URL, "test-key") + result, err := client.ProvisionSession(context.Background(), openmeter.ProvisionConfig{ + DefaultPlanKey: "clearinghouse_default_ppu", + }, "pub-client", "demo-user") + if err != nil { + t.Fatal(err) + } + if result.Customer.ID != "cust-1" { + t.Fatalf("customer id = %q", result.Customer.ID) + } +} diff --git a/openmeter-collector/builder-api/internal/openmeter/resolver.go b/openmeter-collector/builder-api/internal/openmeter/resolver.go new file mode 100644 index 0000000..af78032 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/resolver.go @@ -0,0 +1,140 @@ +package openmeter + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// TenantCreds are OpenMeter management credentials for one platform client_id. +type TenantCreds struct { + BaseURL string + Token string + Region string + OrgID string +} + +// Resolver loads per-tenant OpenMeter credentials from konnect-credentials, +// falling back to a process-wide URL/key for single-org local stacks. +type Resolver struct { + CredentialsURL string + PlatformSecret string + FallbackURL string + FallbackToken string + HTTP *http.Client +} + +// NewResolver constructs a credential resolver. +func NewResolver(credentialsURL, platformSecret, fallbackURL, fallbackToken string) *Resolver { + return &Resolver{ + CredentialsURL: strings.TrimRight(strings.TrimSpace(credentialsURL), "/"), + PlatformSecret: strings.TrimSpace(platformSecret), + FallbackURL: strings.TrimRight(strings.TrimSpace(fallbackURL), "/"), + FallbackToken: strings.TrimSpace(fallbackToken), + HTTP: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +// Resolve returns OpenMeter base URL + token for clientID. +func (r *Resolver) Resolve(ctx context.Context, clientID string) (*TenantCreds, error) { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return nil, fmt.Errorf("client id is required") + } + + if r.CredentialsURL != "" && r.PlatformSecret != "" { + creds, err := r.lookup(ctx, clientID) + if err == nil { + return creds, nil + } + // Unbound tenant: fall through to global fallback when configured. + if !isNotFound(err) { + return nil, err + } + } + + if r.FallbackURL == "" || r.FallbackToken == "" { + return nil, fmt.Errorf("openmeter credentials unavailable for client %q (bind tenant or set OPENMETER_URL/OPENMETER_API_KEY)", clientID) + } + return &TenantCreds{ + BaseURL: r.FallbackURL, + Token: r.FallbackToken, + }, nil +} + +type openmeterLookupResponse struct { + ClientID string `json:"client_id"` + Region string `json:"region"` + OrgID string `json:"org_id"` + OpenMeterBase string `json:"openmeter_base"` + Token string `json:"token"` + Error string `json:"error"` +} + +func (r *Resolver) lookup(ctx context.Context, clientID string) (*TenantCreds, error) { + url := fmt.Sprintf("%s/v1/internal/tenants/%s/openmeter", r.CredentialsURL, urlPathEscape(clientID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+r.PlatformSecret) + req.Header.Set("Accept", "application/json") + + resp, err := r.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("konnect-credentials lookup: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + return nil, errNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("konnect-credentials lookup %d: %s", resp.StatusCode, string(body)) + } + + var parsed openmeterLookupResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + base := strings.TrimRight(strings.TrimSpace(parsed.OpenMeterBase), "/") + token := strings.TrimSpace(parsed.Token) + if base == "" || token == "" { + return nil, fmt.Errorf("konnect-credentials lookup returned empty openmeter_base/token") + } + return &TenantCreds{ + BaseURL: base, + Token: token, + Region: parsed.Region, + OrgID: parsed.OrgID, + }, nil +} + +var errNotFound = fmt.Errorf("tenant not bound") + +func isNotFound(err error) bool { + return err == errNotFound +} + +func urlPathEscape(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "/", "%2F"), " ", "%20") +} + +// ClientFor builds an OpenMeter client from resolved credentials. +func (r *Resolver) ClientFor(ctx context.Context, clientID string) (*Client, error) { + creds, err := r.Resolve(ctx, clientID) + if err != nil { + return nil, err + } + return New(creds.BaseURL, creds.Token), nil +} diff --git a/openmeter-collector/builder-api/internal/openmeter/resolver_test.go b/openmeter-collector/builder-api/internal/openmeter/resolver_test.go new file mode 100644 index 0000000..ecf2c8e --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/resolver_test.go @@ -0,0 +1,55 @@ +package openmeter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolverUsesCredentialsService(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/internal/tenants/app-1/openmeter" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer plat-secret" { + t.Fatalf("auth = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "client_id": "app-1", + "region": "us", + "org_id": "org-1", + "openmeter_base": "https://us.api.konghq.com/v3/openmeter", + "token": "kpat_tenant", + }) + })) + t.Cleanup(srv.Close) + + r := NewResolver(srv.URL, "plat-secret", "https://fallback.example/v3/openmeter", "fallback-key") + creds, err := r.Resolve(context.Background(), "app-1") + if err != nil { + t.Fatal(err) + } + if creds.Token != "kpat_tenant" || creds.BaseURL != "https://us.api.konghq.com/v3/openmeter" { + t.Fatalf("%+v", creds) + } +} + +func TestResolverFallsBackWhenUnbound(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"tenant_not_bound"}`, http.StatusNotFound) + })) + t.Cleanup(srv.Close) + + r := NewResolver(srv.URL, "plat-secret", "https://fallback.example/v3/openmeter", "fallback-key") + creds, err := r.Resolve(context.Background(), "missing") + if err != nil { + t.Fatal(err) + } + if creds.Token != "fallback-key" { + t.Fatalf("%+v", creds) + } +} diff --git a/openmeter-collector/builder-api/internal/openmeter/session.go b/openmeter-collector/builder-api/internal/openmeter/session.go new file mode 100644 index 0000000..1b91c7c --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/session.go @@ -0,0 +1,67 @@ +package openmeter + +import ( + "context" + "fmt" + "strings" +) + +// SessionService provisions customers/subscriptions and reads balance using +// per-tenant Konnect credentials (or a single-org fallback). +type SessionService struct { + Resolver *Resolver +} + +// NewSessionService wraps a credential resolver. +func NewSessionService(resolver *Resolver) *SessionService { + return &SessionService{Resolver: resolver} +} + +// ProvisionSession upserts customer + subscription, optionally grants trial credits, +// and returns the current access snapshot. +func (s *SessionService) ProvisionSession(ctx context.Context, cfg ProvisionConfig, clientID, externalUserID string) (*SessionProvision, error) { + if s == nil || s.Resolver == nil { + return nil, fmt.Errorf("openmeter session service is not configured") + } + client, err := s.Resolver.ClientFor(ctx, clientID) + if err != nil { + return nil, err + } + + customer, err := client.EnsureCustomer(ctx, clientID, externalUserID, externalUserID) + if err != nil { + return nil, err + } + customerKey := CustomerKey(clientID, externalUserID) + if err := client.EnsureDefaultSubscription(ctx, customer.ID, customerKey, cfg.DefaultPlanKey); err != nil { + return nil, err + } + + featureKey := strings.TrimSpace(cfg.TrialFeatureKey) + if featureKey == "" { + featureKey = "billable_spend" + } + + if cfg.TrialGrantUSDMicros > 0 { + grantKey := fmt.Sprintf("trial:%s", customerKey) + if err := client.EnsureTrialGrant(ctx, customer.ID, featureKey, grantKey, cfg.TrialGrantUSDMicros); err != nil { + return nil, err + } + } + + access, err := client.GetAccess(ctx, customer.ID, featureKey) + if err != nil { + return nil, err + } + if access == nil { + access = &Access{HasAccess: false, BalanceUSDMicros: 0, Source: "none"} + } + + return &SessionProvision{ + Customer: customer, + CustomerKey: customerKey, + HasAccess: access.HasAccess, + BalanceUSDMicros: access.BalanceUSDMicros, + BalanceSource: access.Source, + }, nil +} diff --git a/openmeter-collector/builder-api/internal/openmeter/subscription.go b/openmeter-collector/builder-api/internal/openmeter/subscription.go new file mode 100644 index 0000000..e839973 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/subscription.go @@ -0,0 +1,122 @@ +package openmeter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +type subscriptionPage struct { + Data []subscription `json:"data"` +} + +type subscription struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + Status string `json:"status"` +} + +type createSubscriptionRequest struct { + Customer customerRef `json:"customer"` + Plan planRef `json:"plan"` +} + +type customerRef struct { + Key string `json:"key"` +} + +type planRef struct { + Key string `json:"key"` +} + +// EnsureDefaultSubscription ensures the customer has a subscription on the default plan. +func (c *Client) EnsureDefaultSubscription(ctx context.Context, customerID, customerKey, planKey string) error { + planKey = strings.TrimSpace(planKey) + if planKey == "" { + return nil + } + customerKey = strings.TrimSpace(customerKey) + if customerKey == "" { + return fmt.Errorf("customer key is required for subscription ensure") + } + + existing, err := c.listSubscriptions(ctx, customerID) + if err != nil { + return err + } + if len(existing) > 0 { + return nil + } + + payload := createSubscriptionRequest{ + Customer: customerRef{Key: customerKey}, + Plan: planRef{Key: planKey}, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/subscriptions", bytes.NewReader(body)) + if err != nil { + return err + } + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("openmeter create subscription: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("openmeter create subscription %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) listSubscriptions(ctx context.Context, customerID string) ([]subscription, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/subscriptions", nil) + if err != nil { + return nil, err + } + q := req.URL.Query() + // Konnect only honors the bracketed filter form; a bare `customer_id` param is + // ignored and returns unrelated subscriptions (false-positive idempotency skip). + q.Set("filter[customer_id]", customerID) + req.URL.RawQuery = q.Encode() + c.setHeaders(req) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("openmeter list subscriptions: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("openmeter list subscriptions %d: %s", resp.StatusCode, string(respBody)) + } + + var page subscriptionPage + if err := json.Unmarshal(respBody, &page); err == nil && len(page.Data) > 0 { + return page.Data, nil + } + + var list []subscription + if err := json.Unmarshal(respBody, &list); err == nil { + return list, nil + } + return nil, nil +} diff --git a/openmeter-collector/builder-api/internal/openmeter/types.go b/openmeter-collector/builder-api/internal/openmeter/types.go new file mode 100644 index 0000000..4fedcbc --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/types.go @@ -0,0 +1,18 @@ +package openmeter + +// SessionProvision is the result of no-database OpenMeter provisioning for exchange. +type SessionProvision struct { + Customer *Customer + CustomerKey string + HasAccess bool + BalanceUSDMicros int64 + BalanceSource string +} + +// ProvisionConfig controls default-plan subscription and trial grant provisioning. +type ProvisionConfig struct { + DefaultPlanKey string + TrialFeatureKey string + TrialGrantUSDMicros int64 + EnforceAllowance bool +} diff --git a/openmeter-collector/builder-api/internal/tokenexchange/constants.go b/openmeter-collector/builder-api/internal/tokenexchange/constants.go new file mode 100644 index 0000000..a9da349 --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/constants.go @@ -0,0 +1,8 @@ +package tokenexchange + +const ( + GrantType = "urn:ietf:params:oauth:grant-type:token-exchange" + SubjectAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" + IssuedAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" + DefaultScope = "sign:job" +) diff --git a/openmeter-collector/builder-api/internal/tokenexchange/errors.go b/openmeter-collector/builder-api/internal/tokenexchange/errors.go new file mode 100644 index 0000000..514a477 --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/errors.go @@ -0,0 +1,62 @@ +package tokenexchange + +import "fmt" + +// Error is an RFC 6749 / RFC 8693 token endpoint error. +type Error struct { + Code string + Description string + PublicDescription string + Status int +} + +func (e *Error) Error() string { + if e.PublicDescription != "" { + return e.PublicDescription + } + if e.Description != "" { + return e.Description + } + return e.Code +} + +func newError(code, description, public string, status int) *Error { + return &Error{ + Code: code, + Description: description, + PublicDescription: public, + Status: status, + } +} + +func invalidRequest(description string) *Error { + return newError("invalid_request", description, description, 400) +} + +func invalidClient(description string) *Error { + return newError("invalid_client", description, description, 401) +} + +func invalidGrant(description string) *Error { + return newError("invalid_grant", description, description, 400) +} + +func invalidTarget(description string) *Error { + return newError("invalid_target", description, description, 400) +} + +func unsupportedTokenType(description string) *Error { + return newError("unsupported_token_type", description, description, 400) +} + +func insufficientAllowance(description string) *Error { + return newError("insufficient_allowance", description, description, 402) +} + +func serverError(description string) *Error { + return newError("server_error", description, description, 500) +} + +func wrapServerError(err error) *Error { + return serverError(fmt.Sprintf("internal error: %v", err)) +} diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler.go b/openmeter-collector/builder-api/internal/tokenexchange/handler.go new file mode 100644 index 0000000..b76b853 --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler.go @@ -0,0 +1,250 @@ +package tokenexchange + +import ( + "context" + "strings" + + auth0mint "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mint" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/config" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" +) + +// Request is a parsed RFC 8693 token exchange request. +type Request struct { + PublicClientID string + ClientID string + ClientSecret string + GrantType string + SubjectToken string + SubjectTokenType string + RequestedTokenType string + Resource string + Audiences []string +} + +// Result is a signer-session token exchange response. +type Result struct { + AccessToken string + TokenType string + ExpiresIn int + Scope string + SignerURL string + DiscoveryURL string + IssuedTokenType string + CorrelationID string + HasAccess bool + BalanceUSDMicros int64 +} + +// SignerMinter mints short-lived signer JWTs. +type SignerMinter interface { + MintSignerToken(ctx context.Context, publicClientID, externalUserID string) (*auth0mint.TokenResponse, error) +} + +// SessionProvisioner upserts the OpenMeter customer and default-plan subscription. +type SessionProvisioner interface { + ProvisionSession(ctx context.Context, cfg openmeter.ProvisionConfig, clientID, externalUserID string) (*openmeter.SessionProvision, error) +} + +// UserTokenVerifier verifies an end-user access token (JWT) and returns the tenant +// client id and external user id. Implemented by the identity-webhook client. +type UserTokenVerifier interface { + VerifyUserAccessToken(ctx context.Context, token, expectedClientID string) (clientID, externalUserID string, err error) +} + +// Handler performs RFC 8693 signer JWT token exchange. +type Handler struct { + cfg config.Config + verifier UserTokenVerifier + apiKeys *apikey.Store + minter SignerMinter + openmeter SessionProvisioner +} + +// NewHandler constructs a token exchange handler. +func NewHandler( + cfg config.Config, + verifier UserTokenVerifier, + apiKeys *apikey.Store, + minter SignerMinter, + om SessionProvisioner, +) *Handler { + return &Handler{ + cfg: cfg, + verifier: verifier, + apiKeys: apiKeys, + minter: minter, + openmeter: om, + } +} + +// Exchange validates the request and mints a signer session. +func (h *Handler) Exchange(ctx context.Context, req Request, correlationID string) (*Result, error) { + if strings.TrimSpace(req.GrantType) != GrantType { + return nil, invalidRequest("grant_type must be " + GrantType) + } + if strings.TrimSpace(req.SubjectToken) == "" { + return nil, invalidRequest("subject_token is required") + } + if strings.TrimSpace(req.SubjectTokenType) != SubjectAccessTokenType { + return nil, unsupportedTokenType("subject_token_type must be " + SubjectAccessTokenType) + } + + if err := h.validateClient(req.ClientID, req.ClientSecret); err != nil { + return nil, err + } + if err := h.validateRequestedTokenType(req.RequestedTokenType); err != nil { + return nil, err + } + if err := h.validateTarget(req.Resource, req.Audiences); err != nil { + return nil, err + } + + publicClientID := strings.TrimSpace(req.PublicClientID) + if publicClientID == "" { + return nil, invalidRequest("clientId is required") + } + + clientID, externalUserID, err := h.resolveSubject(ctx, req.SubjectToken, publicClientID) + if err != nil { + return nil, err + } + + session, err := h.openmeter.ProvisionSession(ctx, h.provisionConfig(), clientID, externalUserID) + if err != nil { + return nil, wrapServerError(err) + } + if h.cfg.OpenMeterEnforceAllowance && (session == nil || !session.HasAccess) { + return nil, insufficientAllowance("trial credits exhausted; top up allowance before minting a signer session") + } + + minted, err := h.minter.MintSignerToken(ctx, clientID, externalUserID) + if err != nil { + return nil, wrapServerError(err) + } + + scope := strings.TrimSpace(minted.Scope) + if scope == "" { + scope = DefaultScope + } + + result := &Result{ + AccessToken: minted.AccessToken, + TokenType: "Bearer", + ExpiresIn: minted.ExpiresIn, + Scope: scope, + IssuedTokenType: IssuedAccessTokenType, + CorrelationID: correlationID, + HasAccess: session != nil && session.HasAccess, + BalanceUSDMicros: 0, + } + if session != nil { + result.BalanceUSDMicros = session.BalanceUSDMicros + result.HasAccess = session.HasAccess + } + if h.cfg.SignerURL != "" { + result.SignerURL = h.cfg.SignerURL + } + if h.cfg.DiscoveryURL != "" { + result.DiscoveryURL = h.cfg.DiscoveryURL + } + return result, nil +} + +func (h *Handler) provisionConfig() openmeter.ProvisionConfig { + return openmeter.ProvisionConfig{ + DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, + TrialFeatureKey: h.cfg.OpenMeterTrialFeatureKey, + TrialGrantUSDMicros: h.cfg.OpenMeterTrialGrantUSDMicros, + EnforceAllowance: h.cfg.OpenMeterEnforceAllowance, + } +} + +func (h *Handler) validateClient(clientID, clientSecret string) error { + clientID = strings.TrimSpace(clientID) + clientSecret = strings.TrimSpace(clientSecret) + if clientID == "" && clientSecret == "" { + return nil + } + if clientID == "" || clientSecret == "" { + return invalidClient("client authentication requires both client id and secret") + } + if clientID != h.cfg.SignerM2MClientID || clientSecret != h.cfg.SignerM2MSecret { + return invalidClient("invalid client credentials") + } + return nil +} + +func (h *Handler) validateRequestedTokenType(requested string) error { + requested = strings.TrimSpace(requested) + if requested == "" || requested == IssuedAccessTokenType { + return nil + } + return invalidRequest("requested_token_type must be " + IssuedAccessTokenType + " or omitted") +} + +func (h *Handler) validateTarget(resource string, audiences []string) error { + expected := normalizeURI(h.cfg.Auth0Audience) + if expected == "" { + return serverError("audience is not configured") + } + + resource = strings.TrimSpace(resource) + if resource != "" { + if normalizeURI(resource) != expected { + return invalidTarget("resource must be omitted or name the signer audience") + } + return nil + } + + nonEmpty := make([]string, 0, len(audiences)) + for _, aud := range audiences { + aud = strings.TrimSpace(aud) + if aud != "" { + nonEmpty = append(nonEmpty, aud) + } + } + if len(nonEmpty) == 0 { + return nil + } + for _, aud := range nonEmpty { + if normalizeURI(aud) != expected { + return invalidTarget("audience must be omitted or name the signer audience") + } + } + return nil +} + +func (h *Handler) resolveSubject(ctx context.Context, subjectToken, publicClientID string) (clientID, externalUserID string, err error) { + subjectToken = strings.TrimSpace(subjectToken) + if strings.Count(subjectToken, ".") == 2 { + if h.verifier == nil { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + verifiedClientID, verifiedUserID, verifyErr := h.verifier.VerifyUserAccessToken(ctx, subjectToken, publicClientID) + if verifyErr != nil { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + return verifiedClientID, verifiedUserID, nil + } + + if !strings.HasPrefix(subjectToken, h.cfg.APIKeyPrefix) { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + if h.apiKeys == nil { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + clientID, externalUserID, resolveErr := h.apiKeys.Resolve(ctx, subjectToken, publicClientID) + if resolveErr != nil { + if resolveErr == apikey.ErrClientMismatch { + return "", "", invalidGrant("subject_token client does not match this app") + } + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + return clientID, externalUserID, nil +} + +func normalizeURI(value string) string { + return strings.TrimSuffix(strings.TrimSpace(value), "/") +} diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go new file mode 100644 index 0000000..6c7adca --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go @@ -0,0 +1,402 @@ +package tokenexchange_test + +import ( + "context" + "errors" + "testing" + + auth0mint "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/auth0mint" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/config" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" +) + +type stubMinter struct { + response *auth0mint.TokenResponse + err error +} + +func (s stubMinter) MintSignerToken(_ context.Context, _, _ string) (*auth0mint.TokenResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.response, nil +} + +func (s *stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionConfig, string, string) (*openmeter.SessionProvision, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + hasAccess := true + balance := int64(1_000_000) + if s.hasAccess != nil { + hasAccess = *s.hasAccess + } + if s.balance != nil { + balance = *s.balance + } + return &openmeter.SessionProvision{ + Customer: &openmeter.Customer{ID: "cust-1", Key: "pub-client:demo-user"}, + CustomerKey: "pub-client:demo-user", + HasAccess: hasAccess, + BalanceUSDMicros: balance, + BalanceSource: "credits", + }, nil +} + +type stubProvisioner struct { + err error + calls int + hasAccess *bool + balance *int64 +} + +// stubVerifier stands in for the identity-webhook client. +type stubVerifier struct { + clientID string + externalUserID string + err error +} + +func (s stubVerifier) VerifyUserAccessToken(_ context.Context, _, expectedClientID string) (string, string, error) { + if s.err != nil { + return "", "", s.err + } + if expectedClientID != "" && s.clientID != expectedClientID { + return "", "", errors.New("token client does not match clientId") + } + return s.clientID, s.externalUserID, nil +} + +func testHandler(t *testing.T, verifier tokenexchange.UserTokenVerifier) *tokenexchange.Handler { + t.Helper() + return testHandlerWith(t, verifier, &stubProvisioner{}, config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + SignerURL: "http://localhost:8081", + DiscoveryURL: "http://localhost/discovery", + OpenMeterEnforceAllowance: true, + }) +} + +func testHandlerWith(t *testing.T, verifier tokenexchange.UserTokenVerifier, provisioner tokenexchange.SessionProvisioner, cfg config.Config) *tokenexchange.Handler { + t.Helper() + if cfg.APIKeyPrefix == "" { + cfg.APIKeyPrefix = "sk_" + } + return tokenexchange.NewHandler( + cfg, + verifier, + &apikey.Store{ + Prefix: cfg.APIKeyPrefix, + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, + }, + }, + stubMinter{response: &auth0mint.TokenResponse{ + AccessToken: "minted-jwt", + TokenType: "Bearer", + ExpiresIn: 300, + Scope: "sign:job", + }}, + provisioner, + ) +} + +func TestExchangeRejectsMissingPublicClientID(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_request" { + t.Fatalf("expected invalid_request, got %v", err) + } +} + +func TestExchangeRejectsInvalidGrantType(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + ClientID: "m2m-client", + ClientSecret: "m2m-secret", + GrantType: "client_credentials", + SubjectToken: "token", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_request" { + t.Fatalf("expected invalid_request, got %v", err) + } +} + +func TestExchangeRejectsInvalidClient(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + ClientID: "wrong", + ClientSecret: "secret", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_client" { + t.Fatalf("expected invalid_client, got %v", err) + } +} + +func TestExchangeRejectsUnsupportedSubjectTokenType(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + ClientID: "m2m-client", + ClientSecret: "m2m-secret", + GrantType: tokenexchange.GrantType, + SubjectToken: "token", + SubjectTokenType: "urn:ietf:params:oauth:token-type:id_token", + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "unsupported_token_type" { + t.Fatalf("expected unsupported_token_type, got %v", err) + } +} + +func TestExchangeRejectsInvalidTargetAudience(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + ClientID: "m2m-client", + ClientSecret: "m2m-secret", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + Audiences: []string{"other-audience"}, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_target" { + t.Fatalf("expected invalid_target, got %v", err) + } +} + +func TestExchangeAPIKeyHappyPathWithoutM2M(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + result, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + Audiences: []string{"livepeer-clearinghouse"}, + }, "corr-0") + if err != nil { + t.Fatal(err) + } + if result.AccessToken != "minted-jwt" { + t.Fatalf("access_token = %q", result.AccessToken) + } +} + +func TestExchangeRejectsClientMismatch(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "other-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_grant" { + t.Fatalf("expected invalid_grant, got %v", err) + } +} + +func TestExchangeRepeatMintReusesProvisioner(t *testing.T) { + t.Parallel() + provisioner := &stubProvisioner{} + h := tokenexchange.NewHandler( + config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + }, + nil, + &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, + }, + }, + stubMinter{response: &auth0mint.TokenResponse{AccessToken: "minted-jwt", ExpiresIn: 300, Scope: "sign:job"}}, + provisioner, + ) + for i := 0; i < 2; i++ { + result, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err != nil { + t.Fatal(err) + } + if result.AccessToken != "minted-jwt" { + t.Fatalf("access_token = %q", result.AccessToken) + } + } + if provisioner.calls != 2 { + t.Fatalf("provision calls = %d", provisioner.calls) + } +} + +func TestExchangeAPIKeyHappyPath(t *testing.T) { + t.Parallel() + h := testHandler(t, nil) + result, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + ClientID: "m2m-client", + ClientSecret: "m2m-secret", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + Audiences: []string{"livepeer-clearinghouse"}, + }, "corr-1") + if err != nil { + t.Fatal(err) + } + if result.AccessToken != "minted-jwt" { + t.Fatalf("access_token = %q", result.AccessToken) + } + if result.Scope != "sign:job" { + t.Fatalf("scope = %q", result.Scope) + } + if result.IssuedTokenType != tokenexchange.IssuedAccessTokenType { + t.Fatalf("issued_token_type = %q", result.IssuedTokenType) + } + if !result.HasAccess || result.BalanceUSDMicros != 1_000_000 { + t.Fatalf("balance fields = has_access=%v balance=%d", result.HasAccess, result.BalanceUSDMicros) + } +} + +func TestExchangeBlocksWhenAllowanceExhausted(t *testing.T) { + t.Parallel() + falseVal := false + zero := int64(0) + provisioner := &stubProvisioner{hasAccess: &falseVal, balance: &zero} + minterCalls := 0 + h := tokenexchange.NewHandler( + config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + OpenMeterEnforceAllowance: true, + }, + nil, + &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, + }, + }, + countingMinter{fn: func() { minterCalls++ }, response: &auth0mint.TokenResponse{AccessToken: "x", ExpiresIn: 1}}, + provisioner, + ) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "insufficient_allowance" { + t.Fatalf("expected insufficient_allowance, got %v", err) + } + if err.(*tokenexchange.Error).Status != 402 { + t.Fatalf("expected status 402, got %d", err.(*tokenexchange.Error).Status) + } + if minterCalls != 0 { + t.Fatalf("minter should not be called, got %d", minterCalls) + } + if provisioner.calls != 1 { + t.Fatalf("provisioner calls = %d", provisioner.calls) + } +} + +func TestExchangeSkipsGateWhenEnforceDisabled(t *testing.T) { + t.Parallel() + falseVal := false + provisioner := &stubProvisioner{hasAccess: &falseVal} + h := testHandlerWith(t, nil, provisioner, config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + OpenMeterEnforceAllowance: false, + }) + result, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "sk_demo", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err != nil { + t.Fatal(err) + } + if result.AccessToken != "minted-jwt" { + t.Fatalf("access_token = %q", result.AccessToken) + } +} + +type countingMinter struct { + fn func() + response *auth0mint.TokenResponse +} + +func (c countingMinter) MintSignerToken(context.Context, string, string) (*auth0mint.TokenResponse, error) { + if c.fn != nil { + c.fn() + } + return c.response, nil +} + +func TestExchangeJWTHappyPath(t *testing.T) { + t.Parallel() + verifier := stubVerifier{clientID: "pub-client", externalUserID: "user-1"} + h := testHandler(t, verifier) + result, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + ClientID: "m2m-client", + ClientSecret: "m2m-secret", + GrantType: tokenexchange.GrantType, + SubjectToken: "header.payload.signature", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + Resource: "livepeer-clearinghouse", + }, "corr-2") + if err != nil { + t.Fatal(err) + } + if result.AccessToken != "minted-jwt" { + t.Fatalf("access_token = %q", result.AccessToken) + } +} + +func TestExchangeJWTRejectedByVerifier(t *testing.T) { + t.Parallel() + verifier := stubVerifier{err: errors.New("invalid token")} + h := testHandler(t, verifier) + _, err := h.Exchange(context.Background(), tokenexchange.Request{ + PublicClientID: "pub-client", + GrantType: tokenexchange.GrantType, + SubjectToken: "header.payload.signature", + SubjectTokenType: tokenexchange.SubjectAccessTokenType, + }, "corr") + if err == nil || err.(*tokenexchange.Error).Code != "invalid_grant" { + t.Fatalf("expected invalid_grant, got %v", err) + } +} diff --git a/openmeter-collector/builder-api/internal/webhookverify/client.go b/openmeter-collector/builder-api/internal/webhookverify/client.go new file mode 100644 index 0000000..2bf122d --- /dev/null +++ b/openmeter-collector/builder-api/internal/webhookverify/client.go @@ -0,0 +1,110 @@ +// Package webhookverify delegates end-user JWT verification to the identity-webhook. +// +// The identity-webhook (Node) already verifies Auth0 access tokens against the +// issuer JWKS and returns a UsageIdentity. Rather than duplicate JWKS handling and +// claim extraction in Go, the Builder API forwards the subject token to the +// webhook's POST /authorize contract and reads back {client_id, usage_subject}. +package webhookverify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client verifies user access tokens via the identity-webhook /authorize endpoint. +type Client struct { + baseURL string + secret string + http *http.Client +} + +// New creates a webhook verification client. baseURL points at the identity-webhook +// service (e.g. http://identity-webhook:8090); secret is the shared WEBHOOK_SECRET. +// A trailing /authorize is tolerated so REMOTE_SIGNER_WEBHOOK_URL can be reused directly. +func New(baseURL, secret string) *Client { + base := strings.TrimSuffix(strings.TrimSpace(baseURL), "/") + base = strings.TrimSuffix(base, "/authorize") + return &Client{ + baseURL: strings.TrimSuffix(base, "/"), + secret: strings.TrimSpace(secret), + http: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +type authorizePayload struct { + Headers map[string][]string `json:"headers"` +} + +type authorizeResponse struct { + Status int `json:"status"` + Reason string `json:"reason"` + AuthID string `json:"auth_id"` + Identity struct { + ClientID string `json:"client_id"` + UsageSubject string `json:"usage_subject"` + } `json:"identity"` +} + +// VerifyUserAccessToken forwards a user JWT to the identity-webhook and returns the +// resolved tenant client id and external user id. It enforces that the token's +// client id matches expectedClientID (the app's public Auth0 client id). +func (c *Client) VerifyUserAccessToken(ctx context.Context, token, expectedClientID string) (clientID, externalUserID string, err error) { + body, err := json.Marshal(authorizePayload{ + Headers: map[string][]string{"Authorization": {"Bearer " + token}}, + }) + if err != nil { + return "", "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/authorize", bytes.NewReader(body)) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+c.secret) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", "", fmt.Errorf("identity-webhook request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", "", fmt.Errorf("identity-webhook %d: %s", resp.StatusCode, string(raw)) + } + + var parsed authorizeResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", "", fmt.Errorf("decode identity-webhook response: %w", err) + } + // Rejects ride back on HTTP 200 with the real status in the body. + if parsed.Status != http.StatusOK { + reason := parsed.Reason + if reason == "" { + reason = "verification rejected" + } + return "", "", fmt.Errorf("identity-webhook rejected token: %s", reason) + } + if parsed.Identity.ClientID == "" || parsed.Identity.UsageSubject == "" { + return "", "", fmt.Errorf("identity-webhook returned incomplete identity") + } + + expectedClientID = strings.TrimSpace(expectedClientID) + if expectedClientID != "" && parsed.Identity.ClientID != expectedClientID { + return "", "", fmt.Errorf("token client does not match clientId") + } + return parsed.Identity.ClientID, parsed.Identity.UsageSubject, nil +} diff --git a/openmeter-collector/collector.yaml b/openmeter-collector/collector.yaml index 17dfdec..afc66a7 100644 --- a/openmeter-collector/collector.yaml +++ b/openmeter-collector/collector.yaml @@ -159,6 +159,52 @@ pipeline: } } + - mapping: | + meta client_id = this.data.client_id.or("") + + # Per-tenant ingest: look up Ingest SPAT from konnect-credentials when configured. + # Falls back to OPENMETER_URL + OPENMETER_API_KEY for single-org / local stacks. + - branch: + request_map: | + root = if env("KONNECT_CREDENTIALS_URL").or("") == "" { + deleted() + } else { + {} + } + processors: + - try: + - http: + url: '${KONNECT_CREDENTIALS_URL}/v1/internal/tenants/${! meta("client_id")}/ingest' + verb: GET + headers: + Authorization: "Bearer ${PLATFORM_API_SECRET}" + Accept: application/json + - catch: + - log: + level: WARN + message: "konnect ingest lookup failed for client_id=${! meta(\"client_id\") }: ${! error() }" + - mapping: root = deleted() + result_map: | + meta openmeter_url = if this.exists("url") { this.url } else { meta("openmeter_url") } + meta openmeter_token = if this.exists("token") { this.token } else { meta("openmeter_token") } + + - mapping: | + meta openmeter_url = if meta("openmeter_url") != null && meta("openmeter_url") != "" { + meta("openmeter_url") + } else { + env("OPENMETER_URL") + } + meta openmeter_token = if meta("openmeter_token") != null && meta("openmeter_token") != "" { + meta("openmeter_token") + } else { + env("OPENMETER_API_KEY") + } + root = if meta("openmeter_url") == null || meta("openmeter_url") == "" || meta("openmeter_token") == null || meta("openmeter_token") == "" { + throw("openmeter ingest url/token unresolved for client_id=" + meta("client_id").or("")) + } else { + this + } + - catch: - log: level: ERROR @@ -167,10 +213,10 @@ pipeline: output: http_client: - url: ${OPENMETER_URL} + url: '${! meta("openmeter_url") }' verb: POST headers: - Authorization: "Bearer ${OPENMETER_API_KEY}" + Authorization: 'Bearer ${! meta("openmeter_token") }' Content-Type: application/cloudevents+json successful_on: - 200 diff --git a/openmeter-collector/entrypoint.sh b/openmeter-collector/entrypoint.sh index 3e36085..a862477 100644 --- a/openmeter-collector/entrypoint.sh +++ b/openmeter-collector/entrypoint.sh @@ -1,30 +1,100 @@ #!/bin/sh set -eu -if [ -f /service/.env ]; then - set -a - # shellcheck disable=SC1091 - . /service/.env - set +a -fi +load_env() { + if [ -f "$1" ]; then + # .env files may contain empty assignments or comments; do not use set -u while sourcing. + set +u + set -a + # shellcheck disable=SC1090 + . "$1" + set +a + set -u + fi +} + +# Collector config first; auth0-provisioner output second (bootstrap writes .env.livepeer). +load_env /service/.env +load_env "${ENV_LIVEPEER_FILE:-/service/.env.livepeer}" + +# Map auth0-provisioner names → builder-api env (only when unset). +: "${AUTH0_SIGNER_M2M_CLIENT_ID:=${DEMO_APP_AUTH0_M2M_CLIENT_ID:-}}" +: "${AUTH0_SIGNER_M2M_CLIENT_SECRET:=${DEMO_APP_AUTH0_M2M_CLIENT_SECRET:-}}" +: "${AUTH0_AUDIENCE:=${DEMO_APP_AUTH0_AUDIENCE:-livepeer-clearinghouse}}" +export AUTH0_SIGNER_M2M_CLIENT_ID AUTH0_SIGNER_M2M_CLIENT_SECRET AUTH0_AUDIENCE -if [ -z "${OPENMETER_URL:-}" ]; then - echo "entrypoint: OPENMETER_URL is required" >&2 +# Single-org stacks set OPENMETER_URL; multi-tenant stacks may use only +# KONNECT_CREDENTIALS_URL (collector looks up per-client Ingest SPATs). +if [ -z "${OPENMETER_URL:-}" ] && [ -z "${KONNECT_CREDENTIALS_URL:-}" ]; then + echo "entrypoint: OPENMETER_URL or KONNECT_CREDENTIALS_URL is required" >&2 exit 1 fi -base="${OPENMETER_URL%/}" -case "$base" in - */events) - export OPENMETER_URL="$base" - ;; - *) - if printf '%s' "$base" | grep -Eq '(^|\.)konghq\.com'; then - export OPENMETER_URL="${base}/events" +openmeter_mgmt_url="" +if [ -n "${OPENMETER_URL:-}" ]; then + base="${OPENMETER_URL%/}" + + # Management API base for builder-api (customers/subscriptions/entitlements) — never the + # events ingestion path. Strip any events suffix so REST calls hit `/customers`. + openmeter_mgmt_url="$base" + case "$base" in + */api/v1/events) openmeter_mgmt_url="${base%/api/v1/events}" ;; + */events) openmeter_mgmt_url="${base%/events}" ;; + esac + + # Events ingestion URL for the benthos collector. + case "$base" in + */events) + export OPENMETER_URL="$base" + ;; + *) + if printf '%s' "$base" | grep -Eq '(^|\.)konghq\.com'; then + export OPENMETER_URL="${base}/events" + else + export OPENMETER_URL="${base}/api/v1/events" + fi + ;; + esac +fi + +benthos_pid="" +builder_pid="" + +cleanup() { + [ -n "$builder_pid" ] && kill "$builder_pid" 2>/dev/null || true + [ -n "$benthos_pid" ] && kill "$benthos_pid" 2>/dev/null || true + wait 2>/dev/null || true +} + +trap cleanup INT TERM + +/usr/local/bin/benthos -c /config.yaml & +benthos_pid=$! + +if [ -x /usr/local/bin/builder-api ]; then + if [ -n "${AUTH0_MGMT_CLIENT_ID:-}" ] && [ -n "${AUTH0_MGMT_CLIENT_SECRET:-}" ]; then + if [ -n "$openmeter_mgmt_url" ]; then + OPENMETER_URL="$openmeter_mgmt_url" /usr/local/bin/builder-api & + builder_pid=$! else - export OPENMETER_URL="${base}/api/v1/events" + echo "builder-api: skipped — OPENMETER_URL unset (management API base required)" >&2 fi - ;; -esac + else + echo "builder-api: skipped — AUTH0_MGMT_CLIENT_ID/SECRET not set (re-run auth0-provisioner/provision/bootstrap.sh)" >&2 + fi +fi -exec /usr/local/bin/benthos -c /config.yaml +# Exit when either child exits; tear down the sibling. +while :; do + if ! kill -0 "$benthos_pid" 2>/dev/null; then + wait "$benthos_pid" 2>/dev/null || true + cleanup + exit 1 + fi + if [ -n "$builder_pid" ] && ! kill -0 "$builder_pid" 2>/dev/null; then + wait "$builder_pid" 2>/dev/null || true + cleanup + exit 1 + fi + sleep 1 +done