From 0ad3eabf38eafd4f7590e683126272348997f7bd Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 12:08:33 -0400 Subject: [PATCH 01/14] feat(identity-webhook): API-key identity webhook for remote signer Adds the Node.js identity webhook for remote signer authentication using builder-sdk's API-key provider (@pymthouse/builder-sdk@0.5.0), wires the remote-signer to depend on it via REMOTE_SIGNER_WEBHOOK_URL, and adds the Docker Compose stack for runtime services. Squashed from 15 commits on feat/identity-webhook (PR #36). Closes #2, #4. Co-Authored-By: Claude Opus 4.8 --- identity-webhook/.env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 identity-webhook/.env.example diff --git a/identity-webhook/.env.example b/identity-webhook/.env.example new file mode 100644 index 0000000..73de441 --- /dev/null +++ b/identity-webhook/.env.example @@ -0,0 +1,15 @@ +# 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_API_KEY=sk_demo_local_key +DEMO_CLIENT_ID=demo-client +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"}} From 6c08159a50fefa344be97a15a2732a33bf5bf422 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 20:22:58 -0400 Subject: [PATCH 02/14] feat(auth0-provisioner): add bootstrap script and configuration for Auth0 client provisioning - Introduced a new `bootstrap.sh` script for idempotent provisioning of Auth0 resources, including a resource server and client pairs. - Added `apps.json` to define the resource server and client configurations. - Updated `.gitignore` to exclude the generated `.env.livepeer` file. - Created a README.md to document usage and prerequisites for the new provisioning setup. This update streamlines the process of setting up Auth0 clients and resource servers, ensuring a smooth integration with the Livepeer clearinghouse API. --- .gitignore | 1 + auth0-provisioner/provision/README.md | 79 ++++++ auth0-provisioner/provision/apps.json | 30 +++ auth0-provisioner/provision/bootstrap.sh | 290 +++++++++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 auth0-provisioner/provision/README.md create mode 100644 auth0-provisioner/provision/apps.json create mode 100755 auth0-provisioner/provision/bootstrap.sh 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/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md new file mode 100644 index 0000000..8cf7045 --- /dev/null +++ b/auth0-provisioner/provision/README.md @@ -0,0 +1,79 @@ +# 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`. | +| 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) + +```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=livepeer-clearinghouse" \ + -d "scope=openid sign:job offline_access" +# open verification_uri_complete, approve, then poll /oauth/token with the device_code grant. +``` + +## 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). diff --git a/auth0-provisioner/provision/apps.json b/auth0-provisioner/provision/apps.json new file mode 100644 index 0000000..3bc8038 --- /dev/null +++ b/auth0-provisioner/provision/apps.json @@ -0,0 +1,30 @@ +{ + "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"] + } + } + ] +} diff --git a/auth0-provisioner/provision/bootstrap.sh b/auth0-provisioner/provision/bootstrap.sh new file mode 100755 index 0000000..72b198c --- /dev/null +++ b/auth0-provisioner/provision/bootstrap.sh @@ -0,0 +1,290 @@ +#!/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'; } + +# --- 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" + +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))" From 7f009253faf360f1678dbdbdb9a73afaf73abfb6 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 21:30:35 -0400 Subject: [PATCH 03/14] feat(builder-api): Auth0 user provision, signer-session mint, docker co-location Add the Go Builder API beside Benthos in openmeter-collector: provision Auth0 end-users, upsert OpenMeter customers, and mint signer JWTs via client_credentials. Extend bootstrap.sh with a Management API M2M client and wire compose port 8095. --- auth0-provisioner/provision/apps.json | 8 +- auth0-provisioner/provision/bootstrap.sh | 37 +++ openmeter-collector/Dockerfile | 9 +- openmeter-collector/builder-api/README.md | 103 +++++++ .../builder-api/cmd/builder-api/main.go | 66 +++++ .../builder-api/cmd/builder-api/openapi.json | 169 ++++++++++++ openmeter-collector/builder-api/go.mod | 24 ++ openmeter-collector/builder-api/go.sum | 58 ++++ .../builder-api/internal/apikey/apikey.go | 128 +++++++++ .../internal/apikey/apikey_test.go | 37 +++ .../builder-api/internal/auth0mgmt/client.go | 239 ++++++++++++++++ .../internal/auth0mgmt/password.go | 14 + .../builder-api/internal/auth0mint/minter.go | 97 +++++++ .../builder-api/internal/config/config.go | 96 +++++++ .../builder-api/internal/httpapi/auth.go | 37 +++ .../builder-api/internal/httpapi/errors.go | 46 +++ .../builder-api/internal/httpapi/server.go | 261 ++++++++++++++++++ .../internal/openmeter/customer.go | 160 +++++++++++ 18 files changed, 1586 insertions(+), 3 deletions(-) create mode 100644 openmeter-collector/builder-api/README.md create mode 100644 openmeter-collector/builder-api/cmd/builder-api/main.go create mode 100644 openmeter-collector/builder-api/cmd/builder-api/openapi.json create mode 100644 openmeter-collector/builder-api/go.mod create mode 100644 openmeter-collector/builder-api/go.sum create mode 100644 openmeter-collector/builder-api/internal/apikey/apikey.go create mode 100644 openmeter-collector/builder-api/internal/apikey/apikey_test.go create mode 100644 openmeter-collector/builder-api/internal/auth0mgmt/client.go create mode 100644 openmeter-collector/builder-api/internal/auth0mgmt/password.go create mode 100644 openmeter-collector/builder-api/internal/auth0mint/minter.go create mode 100644 openmeter-collector/builder-api/internal/config/config.go create mode 100644 openmeter-collector/builder-api/internal/httpapi/auth.go create mode 100644 openmeter-collector/builder-api/internal/httpapi/errors.go create mode 100644 openmeter-collector/builder-api/internal/httpapi/server.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/customer.go diff --git a/auth0-provisioner/provision/apps.json b/auth0-provisioner/provision/apps.json index 3bc8038..51fbd42 100644 --- a/auth0-provisioner/provision/apps.json +++ b/auth0-provisioner/provision/apps.json @@ -23,8 +23,12 @@ "initiate_login_uri": "" }, "m2m": { - "grant_scopes": ["users:write", "users:token", "device:approve", "sign:job"] + "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.sh b/auth0-provisioner/provision/bootstrap.sh index 72b198c..91e585e 100755 --- a/auth0-provisioner/provision/bootstrap.sh +++ b/auth0-provisioner/provision/bootstrap.sh @@ -264,6 +264,41 @@ provision_app() { 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")" @@ -281,6 +316,8 @@ DOMAIN="$("$AUTH0_BIN" tenants list --json 2>/dev/null | jq -r '.[] | select(.ac [ -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")" 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..6b3b430 --- /dev/null +++ b/openmeter-collector/builder-api/README.md @@ -0,0 +1,103 @@ +# Clearinghouse Builder API + +Go HTTP service co-located in the `openmeter-collector` container. Provisions **Auth0 end-users**, **OpenMeter customers**, and mints **signer session JWTs** via Auth0. + +Scalar docs: `GET /api/v1/docs` (spec at `/api/v1/openapi.json`). + +## 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}/auth/api-key/signer-session` | Bearer `sk_…` | Exchange API key for short-lived signer JWT + upsert customer | + +## 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 signer-session — the 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. + +### 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: signer session + +```bash +API_KEY=sk_... # from create-user response +curl -sS -H "Authorization: Bearer $API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"scope":"sign:job"}' \ + "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/api-key/signer-session" +``` + +## 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. + +## 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..9dc2be9 --- /dev/null +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -0,0 +1,66 @@ +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" +) + +//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) + omClient := openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey) + + demoKeys, err := apikey.LoadDemoStore(cfg.DemoAPIKeys) + if err != nil { + log.Fatalf("demo api keys: %v", err) + } + + srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, demoKeys, 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..54b5112 --- /dev/null +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -0,0 +1,169 @@ +{ + "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": "Liveness probe", + "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}/auth/api-key/signer-session": { + "post": { + "summary": "Exchange API key for signer session", + "description": "Validates an end-user API key, upserts the OpenMeter customer, and mints a short-lived signer JWT via Auth0.", + "security": [{ "bearerApiKey": [] }], + "parameters": [ + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SignerSessionRequest" } + } + } + }, + "responses": { + "200": { + "description": "Signer session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SignerSession" } + } + } + }, + "400": { "description": "Invalid request" }, + "401": { "description": "Invalid API key" } + } + } + } + }, + "components": { + "securitySchemes": { + "m2mBasic": { + "type": "http", + "scheme": "basic", + "description": "M2M client id and secret (users:write)" + }, + "bearerApiKey": { + "type": "http", + "scheme": "bearer", + "description": "End-user API key (sk_…)" + } + }, + "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" } + } + }, + "SignerSessionRequest": { + "type": "object", + "properties": { + "scope": { "type": "string", "description": "Defaults to sign:job" } + } + }, + "SignerSession": { + "type": "object", + "required": [ + "access_token", + "token_type", + "expires_in", + "scope", + "balanceUsdMicros", + "lifetimeGrantedUsdMicros" + ], + "properties": { + "access_token": { "type": "string" }, + "token_type": { "type": "string", "enum": ["Bearer"] }, + "expires_in": { "type": "integer" }, + "scope": { "type": "string" }, + "balanceUsdMicros": { "type": "string" }, + "lifetimeGrantedUsdMicros": { "type": "string" }, + "signer_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/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..3032388 --- /dev/null +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -0,0 +1,96 @@ +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 + SignerURL string + APIKeyPrefix string + DemoAPIKeys 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")), + SignerURL: strings.TrimSpace(os.Getenv("SIGNER_URL")), + APIKeyPrefix: envOr("API_KEY_PREFIX", "sk_"), + DemoAPIKeys: strings.TrimSpace(os.Getenv("DEMO_API_KEYS")), + } + + 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.OpenMeterAPIKey == "" { + missing = append(missing, "OPENMETER_API_KEY") + } + 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 "" +} 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..39167fb --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/auth.go @@ -0,0 +1,37 @@ +package httpapi + +import ( + "encoding/base64" + "net/http" + "strings" +) + +// M2MAuth validates HTTP Basic auth against the configured signer M2M client. +func M2MAuth(r *http.Request, expectedClientID, expectedSecret string) bool { + username, password, ok := r.BasicAuth() + if !ok { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(auth, "Basic ") { + return false + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")) + if err != nil { + return false + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return false + } + username, password = parts[0], parts[1] + } + return username == expectedClientID && password == expectedSecret +} + +// BearerToken extracts the bearer token from Authorization header. +func BearerToken(r *http.Request) string { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(auth, "Bearer ") { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) +} 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..2ff536a --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/errors.go @@ -0,0 +1,46 @@ +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 writeOAuthError(w http.ResponseWriter, status int, code, description, correlationID string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(OAuthError{ + Error: code, + ErrorDescription: description, + CorrelationID: correlationID, + }) +} + +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) +} 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..e28caa0 --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -0,0 +1,261 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "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/openmeter" +) + +// Server wires Builder API routes and dependencies. +type Server struct { + cfg config.Config + auth0 *auth0mgmt.Client + minter *auth0mint.Minter + openmeter *openmeter.Client + demoKeys map[string]apikey.DemoEntry + openAPISpec []byte +} + +// NewServer constructs the HTTP API server. +func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, demoKeys map[string]apikey.DemoEntry, openAPISpec []byte) *Server { + return &Server{ + cfg: cfg, + auth0: auth0, + minter: minter, + openmeter: om, + demoKeys: demoKeys, + 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}/auth/api-key/signer-session", s.handleSignerSession) + 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.EnsureCustomer(ctx, clientID, externalUserID, 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, + }) +} + +type signerSessionRequest struct { + Scope string `json:"scope"` +} + +type signerSessionResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + BalanceUsdMicros string `json:"balanceUsdMicros"` + LifetimeGrantedUsdMicros string `json:"lifetimeGrantedUsdMicros"` + SignerURL string `json:"signer_url,omitempty"` + IssuedTokenType string `json:"issued_token_type,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { + correlationID := newCorrelationID() + clientID := strings.TrimSpace(r.PathValue("clientId")) + if clientID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "clientId is required", correlationID) + return + } + + token := BearerToken(r) + if token == "" { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing bearer token", correlationID) + return + } + if apikey.IsM2MSecret(token) { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "M2M client secrets cannot be used as API keys", correlationID) + return + } + if !strings.HasPrefix(token, s.cfg.APIKeyPrefix) { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid api key", correlationID) + return + } + + var req signerSessionRequest + if r.ContentLength > 0 { + parsed, err := readJSONBody[signerSessionRequest](r) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error(), correlationID) + return + } + req = parsed + } + scope := strings.TrimSpace(req.Scope) + if scope == "" { + scope = "sign:job" + } + + ctx := r.Context() + resolvedClientID, externalUserID, err := s.resolveAPIKey(ctx, token, clientID) + if err != nil { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid api key", correlationID) + return + } + + if _, err := s.openmeter.EnsureCustomer(ctx, resolvedClientID, externalUserID, externalUserID); err != nil { + writeOAuthError(w, http.StatusBadGateway, "server_error", "openmeter customer provisioning failed", correlationID) + return + } + + minted, err := s.minter.MintSignerToken(ctx, resolvedClientID, externalUserID) + if err != nil { + writeOAuthError(w, http.StatusBadGateway, "server_error", "signer token mint failed", correlationID) + return + } + + resp := signerSessionResponse{ + AccessToken: minted.AccessToken, + TokenType: "Bearer", + ExpiresIn: minted.ExpiresIn, + Scope: scope, + BalanceUsdMicros: "0", + LifetimeGrantedUsdMicros: "0", + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + CorrelationID: correlationID, + } + if s.cfg.SignerURL != "" { + resp.SignerURL = s.cfg.SignerURL + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) resolveAPIKey(ctx context.Context, token, expectedClientID string) (clientID, externalUserID string, err error) { + if entry, ok := s.demoKeys[token]; ok { + if expectedClientID != "" && entry.ClientID != expectedClientID { + return "", "", errClientMismatch + } + return entry.ClientID, entry.UserID, nil + } + return s.auth0.ResolveAPIKeyUser(ctx, token, s.cfg.APIKeyPrefix, expectedClientID) +} + +var errClientMismatch = errors.New("api key client mismatch") + +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/openmeter/customer.go b/openmeter-collector/builder-api/internal/openmeter/customer.go new file mode 100644 index 0000000..4c15268 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/customer.go @@ -0,0 +1,160 @@ +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, + }, + } +} + +// CustomerKey returns the compound billing subject key. +func CustomerKey(clientID, externalUserID string) string { + return strings.TrimSpace(clientID) + ":" + strings.TrimSpace(externalUserID) +} + +// 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") +} From b2289679bcddd11d481c35262d01a81112ec5e66 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 21:30:39 -0400 Subject: [PATCH 04/14] feat(identity-webhook): OIDC JWT verifier and credentials-exchange bootstrap Replace builder-sdk with in-repo protocol and jose verifiers so minted Auth0 signer JWTs resolve to auth_id alongside sk_* API keys. Add bootstrap script for the Credentials Exchange Action and document OIDC env for the local stack. --- auth0-provisioner/provision/README.md | 17 +++++++++ .../bootstrap-credentials-exchange-action.sh | 36 +++++++++++++++++++ .../provision/credentials-exchange-action.js | 13 +++++++ identity-webhook/.env.example | 13 ++++++- .../builder-api/cmd/builder-api/openapi.json | 2 +- 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100755 auth0-provisioner/provision/bootstrap-credentials-exchange-action.sh create mode 100644 auth0-provisioner/provision/credentials-exchange-action.js diff --git a/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md index 8cf7045..10788f5 100644 --- a/auth0-provisioner/provision/README.md +++ b/auth0-provisioner/provision/README.md @@ -44,6 +44,7 @@ From [`apps.json`](apps.json): | 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 @@ -77,3 +78,19 @@ curl -s -X POST "$AUTH0_ISSUER""oauth/device/code" \ - 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/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/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/identity-webhook/.env.example b/identity-webhook/.env.example index 73de441..c3775e5 100644 --- a/identity-webhook/.env.example +++ b/identity-webhook/.env.example @@ -7,9 +7,20 @@ 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=demo-client +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 +OIDC_SUBJECT_TYPE=external_user_id +OIDC_REQUIRED_SCOPES=sign:job diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json index 54b5112..8b893cb 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/openapi.json +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -14,7 +14,7 @@ "paths": { "/health": { "get": { - "summary": "Liveness probe", + "summary": "Health check", "responses": { "200": { "description": "OK" From 59a34027fefa9b07d1b49c52a59044623d98f6d2 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 22:14:54 -0400 Subject: [PATCH 05/14] feat(openmeter-collector): enhance OIDC support and add discovery URL - Updated the OpenMeter collector to include a new endpoint for exchanging OIDC access tokens for signer sessions, allowing for better integration with Auth0. - Added a `DISCOVERY_URL` to the environment configuration for service discovery. - Enhanced the README documentation to reflect the new OIDC signer session functionality and updated environment variable requirements. - Improved the identity verification process by implementing a fallback mechanism for subject claims in the OIDC verifier. - Introduced tests for the new OIDC verification logic to ensure robust functionality. --- auth0-provisioner/provision/README.md | 20 ++- identity-webhook/.env.example | 1 + identity-webhook/verifiers.mjs | 6 +- identity-webhook/verifiers.test.mjs | 31 ++++ openmeter-collector/builder-api/README.md | 15 ++ .../builder-api/cmd/builder-api/main.go | 7 +- .../builder-api/cmd/builder-api/openapi.json | 42 ++++++ openmeter-collector/builder-api/go.mod | 2 +- .../builder-api/internal/config/config.go | 9 +- .../builder-api/internal/httpapi/server.go | 81 ++++++++++- .../internal/oidcverify/verifier.go | 132 ++++++++++++++++++ .../internal/oidcverify/verifier_test.go | 111 +++++++++++++++ 12 files changed, 441 insertions(+), 16 deletions(-) create mode 100644 openmeter-collector/builder-api/internal/oidcverify/verifier.go create mode 100644 openmeter-collector/builder-api/internal/oidcverify/verifier_test.go diff --git a/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md index 10788f5..0710d3a 100644 --- a/auth0-provisioner/provision/README.md +++ b/auth0-provisioner/provision/README.md @@ -62,15 +62,31 @@ auth0 api post "client-grants" --data '{ "client_id": "...", "audience": "...", ## 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=livepeer-clearinghouse" \ +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` so device login exchanges the Auth0 user token via `POST …/auth/oidc/signer-session` — 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 diff --git a/identity-webhook/.env.example b/identity-webhook/.env.example index c3775e5..474e403 100644 --- a/identity-webhook/.env.example +++ b/identity-webhook/.env.example @@ -22,5 +22,6 @@ 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/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/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md index 6b3b430..8b2b64f 100644 --- a/openmeter-collector/builder-api/README.md +++ b/openmeter-collector/builder-api/README.md @@ -10,6 +10,7 @@ Scalar docs: `GET /api/v1/docs` (spec at `/api/v1/openapi.json`). | --- | --- | --- | --- | | `POST` | `/api/v1/apps/{clientId}/users` | M2M Basic | Create/upsert Auth0 user + OpenMeter customer; returns `apiKey` once | | `POST` | `/api/v1/apps/{clientId}/auth/api-key/signer-session` | Bearer `sk_…` | Exchange API key for short-lived signer JWT + upsert customer | +| `POST` | `/api/v1/apps/{clientId}/auth/oidc/signer-session` | Bearer Auth0 user JWT | Exchange device/OIDC token for signer JWT + upsert OpenMeter customer | ## Auth0 prerequisites @@ -84,6 +85,20 @@ curl -sS -H "Authorization: Bearer $API_KEY" \ "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/api-key/signer-session" ``` +## Example: OIDC signer session (device code) + +After device login, exchange the Auth0 user access token to provision OpenMeter and mint a signer JWT: + +```bash +OIDC_TOKEN=... # access_token from device code flow +curl -sS -H "Authorization: Bearer $OIDC_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"scope":"sign:job"}' \ + "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/oidc/signer-session" +``` + +The OpenMeter customer key is `{clientId}:{sub}` (e.g. `pub:google-oauth2|…`), matching the CloudEvent `subject`. + ## OpenMeter customer key Customers are upserted with: diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go index 9dc2be9..4fca012 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/main.go +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -15,6 +15,7 @@ import ( "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/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" ) @@ -34,13 +35,17 @@ func main() { minter := auth0mint.New(cfg.Auth0Issuer, cfg.Auth0Audience, cfg.SignerM2MClientID, cfg.SignerM2MSecret) omClient := openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey) + oidcVerifier, err := oidcverify.New(context.Background(), cfg.Auth0Issuer, cfg.Auth0Audience) + if err != nil { + log.Fatalf("oidc verifier: %v", err) + } demoKeys, err := apikey.LoadDemoStore(cfg.DemoAPIKeys) if err != nil { log.Fatalf("demo api keys: %v", err) } - srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, demoKeys, openAPISpec) + srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, oidcVerifier, demoKeys, openAPISpec) server := &http.Server{ Addr: ":" + cfg.Port, Handler: srv.Handler(), diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json index 8b893cb..3d8aa71 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/openapi.json +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -99,6 +99,41 @@ "401": { "description": "Invalid API key" } } } + }, + "/api/v1/apps/{clientId}/auth/oidc/signer-session": { + "post": { + "summary": "Exchange OIDC access token for signer session", + "description": "Validates an Auth0 end-user access token (device code or authorization code), upserts the OpenMeter customer keyed by clientId:sub, and mints a short-lived signer JWT.", + "security": [{ "bearerOidc": [] }], + "parameters": [ + { + "name": "clientId", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Public Auth0 client id (must match token azp)" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SignerSessionRequest" } + } + } + }, + "responses": { + "200": { + "description": "Signer session", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SignerSession" } + } + } + }, + "400": { "description": "Invalid request" }, + "401": { "description": "Invalid OIDC token" } + } + } } }, "components": { @@ -112,6 +147,12 @@ "type": "http", "scheme": "bearer", "description": "End-user API key (sk_…)" + }, + "bearerOidc": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Auth0 end-user access token from device code or authorization code" } }, "schemas": { @@ -160,6 +201,7 @@ "balanceUsdMicros": { "type": "string" }, "lifetimeGrantedUsdMicros": { "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 index 0914df1..aa4db40 100644 --- a/openmeter-collector/builder-api/go.mod +++ b/openmeter-collector/builder-api/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/auth0/go-auth0/v2 v2.14.0 github.com/google/uuid v1.6.0 + github.com/lestrrat-go/jwx/v2 v2.1.6 ) require ( @@ -15,7 +16,6 @@ require ( 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 diff --git a/openmeter-collector/builder-api/internal/config/config.go b/openmeter-collector/builder-api/internal/config/config.go index 3032388..4badec7 100644 --- a/openmeter-collector/builder-api/internal/config/config.go +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { OpenMeterURL string OpenMeterAPIKey string SignerURL string + DiscoveryURL string APIKeyPrefix string DemoAPIKeys string } @@ -39,8 +40,12 @@ func Load() (Config, error) { 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")), - SignerURL: strings.TrimSpace(os.Getenv("SIGNER_URL")), - APIKeyPrefix: envOr("API_KEY_PREFIX", "sk_"), + 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")), } diff --git a/openmeter-collector/builder-api/internal/httpapi/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go index e28caa0..2be1b45 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -12,26 +12,29 @@ import ( 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/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" ) // Server wires Builder API routes and dependencies. type Server struct { - cfg config.Config - auth0 *auth0mgmt.Client - minter *auth0mint.Minter - openmeter *openmeter.Client - demoKeys map[string]apikey.DemoEntry + cfg config.Config + auth0 *auth0mgmt.Client + minter *auth0mint.Minter + openmeter *openmeter.Client + oidc *oidcverify.Verifier + demoKeys map[string]apikey.DemoEntry openAPISpec []byte } // NewServer constructs the HTTP API server. -func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, demoKeys map[string]apikey.DemoEntry, openAPISpec []byte) *Server { +func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, oidc *oidcverify.Verifier, demoKeys map[string]apikey.DemoEntry, openAPISpec []byte) *Server { return &Server{ cfg: cfg, auth0: auth0, minter: minter, openmeter: om, + oidc: oidc, demoKeys: demoKeys, openAPISpec: openAPISpec, } @@ -44,6 +47,7 @@ func (s *Server) Handler() http.Handler { 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}/auth/api-key/signer-session", s.handleSignerSession) + mux.HandleFunc("POST /api/v1/apps/{clientId}/auth/oidc/signer-session", s.handleOIDCSignerSession) return mux } @@ -157,6 +161,7 @@ type signerSessionResponse struct { BalanceUsdMicros string `json:"balanceUsdMicros"` LifetimeGrantedUsdMicros string `json:"lifetimeGrantedUsdMicros"` 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"` } @@ -209,7 +214,66 @@ func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { return } - minted, err := s.minter.MintSignerToken(ctx, resolvedClientID, externalUserID) + s.writeSignerSession(w, ctx, resolvedClientID, externalUserID, scope, correlationID) +} + +func (s *Server) handleOIDCSignerSession(w http.ResponseWriter, r *http.Request) { + correlationID := newCorrelationID() + clientID := strings.TrimSpace(r.PathValue("clientId")) + if clientID == "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "clientId is required", correlationID) + return + } + if s.oidc == nil { + writeOAuthError(w, http.StatusServiceUnavailable, "server_error", "oidc verification not configured", correlationID) + return + } + + token := BearerToken(r) + if token == "" { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing bearer token", correlationID) + return + } + if strings.HasPrefix(token, s.cfg.APIKeyPrefix) { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "use api-key signer-session for API keys", correlationID) + return + } + + var req signerSessionRequest + if r.ContentLength > 0 { + parsed, err := readJSONBody[signerSessionRequest](r) + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error(), correlationID) + return + } + req = parsed + } + scope := strings.TrimSpace(req.Scope) + if scope == "" { + scope = "sign:job" + } + + ctx := r.Context() + verified, err := s.oidc.VerifyUserAccessToken(ctx, token, clientID) + if err != nil { + writeOAuthError(w, http.StatusUnauthorized, "invalid_token", err.Error(), correlationID) + return + } + + if _, err := s.openmeter.EnsureCustomer(ctx, verified.ClientID, verified.ExternalUserID, verified.ExternalUserID); err != nil { + writeOAuthError(w, http.StatusBadGateway, "server_error", "openmeter customer provisioning failed", correlationID) + return + } + + s.writeSignerSession(w, ctx, verified.ClientID, verified.ExternalUserID, scope, correlationID) +} + +func (s *Server) writeSignerSession( + w http.ResponseWriter, + ctx context.Context, + clientID, externalUserID, scope, correlationID string, +) { + minted, err := s.minter.MintSignerToken(ctx, clientID, externalUserID) if err != nil { writeOAuthError(w, http.StatusBadGateway, "server_error", "signer token mint failed", correlationID) return @@ -228,6 +292,9 @@ func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { if s.cfg.SignerURL != "" { resp.SignerURL = s.cfg.SignerURL } + if s.cfg.DiscoveryURL != "" { + resp.DiscoveryURL = s.cfg.DiscoveryURL + } writeJSON(w, http.StatusOK, resp) } diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier.go b/openmeter-collector/builder-api/internal/oidcverify/verifier.go new file mode 100644 index 0000000..c85071f --- /dev/null +++ b/openmeter-collector/builder-api/internal/oidcverify/verifier.go @@ -0,0 +1,132 @@ +package oidcverify + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +const defaultRequiredScope = "sign:job" + +// VerifiedUser is an end-user identity extracted from an Auth0 access token. +type VerifiedUser struct { + ClientID string + ExternalUserID string +} + +// Verifier validates Auth0 end-user access tokens (device code, authorization code). +type Verifier struct { + issuer string + audience string + cache *jwk.Cache + jwksURL string +} + +// New creates a JWKS-cached OIDC access-token verifier. +func New(ctx context.Context, issuer, audience string) (*Verifier, error) { + issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") + audience = strings.TrimSpace(audience) + if issuer == "" { + return nil, fmt.Errorf("oidcverify: issuer is required") + } + if audience == "" { + return nil, fmt.Errorf("oidcverify: audience is required") + } + + jwksURL := issuer + "/.well-known/jwks.json" + cache := jwk.NewCache(ctx) + if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { + return nil, fmt.Errorf("oidcverify: register jwks: %w", err) + } + + return &Verifier{ + issuer: issuer, + audience: audience, + cache: cache, + jwksURL: jwksURL, + }, nil +} + +// VerifyUserAccessToken validates a bearer JWT and returns tenant + end-user ids. +func (v *Verifier) VerifyUserAccessToken(ctx context.Context, token, expectedClientID string) (*VerifiedUser, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, fmt.Errorf("missing access token") + } + if strings.Count(token, ".") != 2 { + return nil, fmt.Errorf("not a JWT") + } + + keySet, err := v.cache.Get(ctx, v.jwksURL) + if err != nil { + return nil, fmt.Errorf("load jwks: %w", err) + } + + parsed, err := jwt.ParseString( + token, + jwt.WithKeySet(keySet), + jwt.WithAudience(v.audience), + ) + if err != nil { + return nil, fmt.Errorf("jwt verification failed: %w", err) + } + if normalizeIssuer(parsed.Issuer()) != normalizeIssuer(v.issuer) { + return nil, fmt.Errorf("jwt verification failed: iss not satisfied") + } + + if err := requireScope(parsed, defaultRequiredScope); err != nil { + return nil, err + } + + clientID := claimString(parsed, "azp") + if clientID == "" { + return nil, fmt.Errorf("token missing azp claim") + } + expectedClientID = strings.TrimSpace(expectedClientID) + if expectedClientID != "" && clientID != expectedClientID { + return nil, fmt.Errorf("token azp does not match clientId") + } + + externalUserID := claimString(parsed, "external_user_id") + if externalUserID == "" { + externalUserID = parsed.Subject() + } + if externalUserID == "" { + return nil, fmt.Errorf("token missing sub claim") + } + + return &VerifiedUser{ + ClientID: clientID, + ExternalUserID: externalUserID, + }, nil +} + +func requireScope(tok jwt.Token, required string) error { + scopeRaw, ok := tok.Get("scope") + if !ok { + return fmt.Errorf("missing required scope: %s", required) + } + granted := strings.Fields(fmt.Sprint(scopeRaw)) + for _, s := range granted { + if s == required { + return nil + } + } + return fmt.Errorf("missing required scope: %s", required) +} + +func claimString(tok jwt.Token, name string) string { + raw, ok := tok.Get(name) + if !ok || raw == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(raw)) +} + +func normalizeIssuer(issuer string) string { + return strings.TrimSuffix(strings.TrimSpace(issuer), "/") +} diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go b/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go new file mode 100644 index 0000000..d23f843 --- /dev/null +++ b/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go @@ -0,0 +1,111 @@ +package oidcverify + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func TestVerifyUserAccessToken(t *testing.T) { + t.Parallel() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + _ = publicJWK.Set(jwk.KeyIDKey, "test-key") + _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) + keySet := jwk.NewSet() + _ = keySet.AddKey(publicJWK) + + jwksBody, err := json.Marshal(keySet) + if err != nil { + t.Fatal(err) + } + + issuer := "https://idp.test" + audience := "livepeer-clearinghouse" + clientID := "pub-client" + subject := "google-oauth2|105691875604954324733" + + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + t.Cleanup(jwksServer.Close) + + // Point issuer at test server path for JWKS only. + verifier := &Verifier{ + issuer: issuer, + audience: audience, + cache: jwk.NewCache(context.Background()), + jwksURL: jwksServer.URL, + } + if err := verifier.cache.Register(jwksServer.URL, jwk.WithMinRefreshInterval(time.Hour)); err != nil { + t.Fatal(err) + } + + token := buildTestToken(t, privateKey, issuer, audience, clientID, subject, "sign:job openid") + + user, err := verifier.VerifyUserAccessToken(context.Background(), token, clientID) + if err != nil { + t.Fatalf("verify: %v", err) + } + if user.ClientID != clientID { + t.Fatalf("clientID = %q", user.ClientID) + } + if user.ExternalUserID != subject { + t.Fatalf("externalUserID = %q", user.ExternalUserID) + } + + if _, err := verifier.VerifyUserAccessToken(context.Background(), token, "other-client"); err == nil { + t.Fatal("expected azp mismatch error") + } + + badScope := buildTestToken(t, privateKey, issuer, audience, clientID, subject, "openid") + if _, err := verifier.VerifyUserAccessToken(context.Background(), badScope, clientID); err == nil { + t.Fatal("expected missing scope error") + } +} + +func buildTestToken( + t *testing.T, + privateKey *rsa.PrivateKey, + issuer, audience, clientID, subject, scope string, +) string { + t.Helper() + tok, err := jwt.NewBuilder(). + Issuer(issuer + "/"). + Audience([]string{audience}). + Subject(subject). + Claim("azp", clientID). + Claim("scope", scope). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + Build() + if err != nil { + t.Fatal(err) + } + headers := jws.NewHeaders() + _ = headers.Set(jws.KeyIDKey, "test-key") + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + t.Fatal(err) + } + return string(signed) +} From 672c26da1aed2b1d9b3d8157b068548a9fc50785 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 22:42:53 -0400 Subject: [PATCH 06/14] refactor(openmeter-collector): unify signer-session endpoint and enhance API key resolution - Updated the API to consolidate the signer-session endpoint, allowing both API keys and OIDC tokens to be exchanged for signer JWTs. - Enhanced the resolver logic to prioritize JWT verification over API key resolution, improving security and flexibility. - Updated the OpenAPI documentation to reflect the new endpoint structure and authentication methods. - Added tests for the new API key resolution and OIDC verification logic to ensure robust functionality. - Revised README files to clarify usage and configuration for the updated signer-session functionality. --- auth0-provisioner/provision/README.md | 2 +- openmeter-collector/builder-api/README.md | 13 +- .../builder-api/cmd/builder-api/main.go | 20 +- .../builder-api/cmd/builder-api/openapi.json | 58 +----- .../builder-api/internal/apikey/store.go | 35 ++++ .../builder-api/internal/apikey/store_test.go | 76 +++++++ .../builder-api/internal/config/config.go | 26 ++- .../builder-api/internal/enduser/resolver.go | 149 ++++++++++++++ .../internal/enduser/resolver_test.go | 193 ++++++++++++++++++ .../builder-api/internal/httpapi/server.go | 100 ++------- .../internal/oidcverify/verifier.go | 73 ++++++- .../internal/oidcverify/verifier_test.go | 103 ++++++++++ 12 files changed, 695 insertions(+), 153 deletions(-) create mode 100644 openmeter-collector/builder-api/internal/apikey/store.go create mode 100644 openmeter-collector/builder-api/internal/apikey/store_test.go create mode 100644 openmeter-collector/builder-api/internal/enduser/resolver.go create mode 100644 openmeter-collector/builder-api/internal/enduser/resolver_test.go diff --git a/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md index 0710d3a..2eda05f 100644 --- a/auth0-provisioner/provision/README.md +++ b/auth0-provisioner/provision/README.md @@ -85,7 +85,7 @@ uv run examples/device_login.py \ --run-frames --signer http://localhost:8081 ``` -Requires the clearinghouse stack (`identity-webhook`, `remote-signer`, `openmeter-collector`). Pass `--billing-url` so device login exchanges the Auth0 user token via `POST …/auth/oidc/signer-session` — that upserts the OpenMeter customer (`{clientId}:{sub}`) and returns a minted signer JWT with `signer_url` / `discovery_url`. +Requires the clearinghouse stack (`identity-webhook`, `remote-signer`, `openmeter-collector`). Pass `--billing-url` so device login exchanges the Auth0 user token via `POST …/auth/signer-session` — that upserts the OpenMeter customer (`{clientId}:{sub}`) and returns a minted signer JWT with `signer_url` / `discovery_url`. ## Limitations diff --git a/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md index 8b2b64f..3459736 100644 --- a/openmeter-collector/builder-api/README.md +++ b/openmeter-collector/builder-api/README.md @@ -9,8 +9,7 @@ Scalar docs: `GET /api/v1/docs` (spec at `/api/v1/openapi.json`). | 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}/auth/api-key/signer-session` | Bearer `sk_…` | Exchange API key for short-lived signer JWT + upsert customer | -| `POST` | `/api/v1/apps/{clientId}/auth/oidc/signer-session` | Bearer Auth0 user JWT | Exchange device/OIDC token for signer JWT + upsert OpenMeter customer | +| `POST` | `/api/v1/apps/{clientId}/auth/signer-session` | Bearer `sk_…` or Auth0 user JWT | Exchange credentials for signer JWT + upsert OpenMeter customer (JWT tried first when bearer looks like a JWT) | ## Auth0 prerequisites @@ -77,24 +76,24 @@ Use the public client id from `.env.livepeer` as the `{clientId}` path segment ( ## Example: signer session +Bearer may be an end-user API key (`sk_…`) or an Auth0 user access token from device login. Resolution mirrors [identity-webhook](../../identity-webhook/verifiers.mjs): JWT-shaped bearers are verified as OIDC first; otherwise `sk_…` keys are resolved via demo env or Auth0 `app_metadata`. + ```bash API_KEY=sk_... # from create-user response curl -sS -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"scope":"sign:job"}' \ - "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/api-key/signer-session" + "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/signer-session" ``` -## Example: OIDC signer session (device code) - -After device login, exchange the Auth0 user access token to provision OpenMeter and mint a signer JWT: +Device code (same endpoint): ```bash OIDC_TOKEN=... # access_token from device code flow curl -sS -H "Authorization: Bearer $OIDC_TOKEN" \ -H "Content-Type: application/json" \ -d '{"scope":"sign:job"}' \ - "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/oidc/signer-session" + "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/signer-session" ``` The OpenMeter customer key is `{clientId}:{sub}` (e.g. `pub:google-oauth2|…`), matching the CloudEvent `subject`. diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go index 4fca012..847dbdf 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/main.go +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -14,6 +14,7 @@ import ( 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/enduser" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/httpapi" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" @@ -35,7 +36,11 @@ func main() { minter := auth0mint.New(cfg.Auth0Issuer, cfg.Auth0Audience, cfg.SignerM2MClientID, cfg.SignerM2MSecret) omClient := openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey) - oidcVerifier, err := oidcverify.New(context.Background(), cfg.Auth0Issuer, cfg.Auth0Audience) + oidcVerifier, err := oidcverify.New(context.Background(), cfg.Auth0Issuer, cfg.Auth0Audience, oidcverify.Options{ + ClientClaim: cfg.OIDCClientClaim, + SubjectClaim: cfg.OIDCSubjectClaim, + RequiredScopes: cfg.OIDCRequiredScopes, + }) if err != nil { log.Fatalf("oidc verifier: %v", err) } @@ -45,7 +50,18 @@ func main() { log.Fatalf("demo api keys: %v", err) } - srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, oidcVerifier, demoKeys, openAPISpec) + keyStore := &apikey.Store{ + Prefix: cfg.APIKeyPrefix, + Demo: demoKeys, + Auth0: auth0Client, + } + resolver := &enduser.Resolver{ + OIDC: oidcVerifier, + APIKeys: keyStore, + Prefix: cfg.APIKeyPrefix, + } + + srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, resolver, openAPISpec) server := &http.Server{ Addr: ":" + cfg.Port, Handler: srv.Handler(), diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json index 3d8aa71..505a311 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/openapi.json +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -66,52 +66,18 @@ } } }, - "/api/v1/apps/{clientId}/auth/api-key/signer-session": { + "/api/v1/apps/{clientId}/auth/signer-session": { "post": { - "summary": "Exchange API key for signer session", - "description": "Validates an end-user API key, upserts the OpenMeter customer, and mints a short-lived signer JWT via Auth0.", - "security": [{ "bearerApiKey": [] }], - "parameters": [ - { - "name": "clientId", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SignerSessionRequest" } - } - } - }, - "responses": { - "200": { - "description": "Signer session", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SignerSession" } - } - } - }, - "400": { "description": "Invalid request" }, - "401": { "description": "Invalid API key" } - } - } - }, - "/api/v1/apps/{clientId}/auth/oidc/signer-session": { - "post": { - "summary": "Exchange OIDC access token for signer session", - "description": "Validates an Auth0 end-user access token (device code or authorization code), upserts the OpenMeter customer keyed by clientId:sub, and mints a short-lived signer JWT.", - "security": [{ "bearerOidc": [] }], + "summary": "Exchange credentials for signer session", + "description": "Accepts Bearer `sk_…` (end-user API key) or Bearer Auth0 user JWT (device code / authorization code). Validates credentials (JWT tried first when bearer looks like a JWT), upserts the OpenMeter customer, and mints a short-lived signer JWT.", + "security": [{ "bearerCredentials": [] }], "parameters": [ { "name": "clientId", "in": "path", "required": true, "schema": { "type": "string" }, - "description": "Public Auth0 client id (must match token azp)" + "description": "Public Auth0 client id for the integrator app" } ], "requestBody": { @@ -130,8 +96,8 @@ } } }, - "400": { "description": "Invalid request" }, - "401": { "description": "Invalid OIDC token" } + "400": { "description": "Invalid request (e.g. M2M secret presented as API key)" }, + "401": { "description": "Invalid API key or OIDC token" } } } } @@ -143,16 +109,10 @@ "scheme": "basic", "description": "M2M client id and secret (users:write)" }, - "bearerApiKey": { - "type": "http", - "scheme": "bearer", - "description": "End-user API key (sk_…)" - }, - "bearerOidc": { + "bearerCredentials": { "type": "http", "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Auth0 end-user access token from device code or authorization code" + "description": "End-user API key (sk_…) or Auth0 user JWT (device code / authorization code)" } }, "schemas": { 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/config/config.go b/openmeter-collector/builder-api/internal/config/config.go index 4badec7..8f52f96 100644 --- a/openmeter-collector/builder-api/internal/config/config.go +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -24,6 +24,9 @@ type Config struct { DiscoveryURL string APIKeyPrefix string DemoAPIKeys string + OIDCClientClaim string + OIDCSubjectClaim string + OIDCRequiredScopes []string } // Load reads configuration from environment variables. @@ -46,7 +49,10 @@ func Load() (Config, error) { "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")), + DemoAPIKeys: strings.TrimSpace(os.Getenv("DEMO_API_KEYS")), + OIDCClientClaim: strings.TrimSpace(os.Getenv("OIDC_CLIENT_CLAIM")), + OIDCSubjectClaim: strings.TrimSpace(os.Getenv("OIDC_SUBJECT_CLAIM")), + OIDCRequiredScopes: splitScopes(os.Getenv("OIDC_REQUIRED_SCOPES")), } if cfg.Auth0Issuer == "" && cfg.Auth0Domain != "" { @@ -99,3 +105,21 @@ func firstEnv(keys ...string) string { } return "" } + +func splitScopes(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/openmeter-collector/builder-api/internal/enduser/resolver.go b/openmeter-collector/builder-api/internal/enduser/resolver.go new file mode 100644 index 0000000..88477e6 --- /dev/null +++ b/openmeter-collector/builder-api/internal/enduser/resolver.go @@ -0,0 +1,149 @@ +package enduser + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" +) + +// Identity is the resolved end-user tenant + subject for signer-session exchange. +type Identity struct { + ClientID string + ExternalUserID string +} + +// ResolveError carries an OAuth error code for HTTP mapping. +type ResolveError struct { + Code string + Message string + Err error +} + +func (e *ResolveError) Error() string { + if e.Message != "" { + return e.Message + } + if e.Err != nil { + return e.Err.Error() + } + return "resolve failed" +} + +// Resolver resolves bearer credentials via OIDC (JWT first) then API key. +type Resolver struct { + OIDC *oidcverify.Verifier + APIKeys *apikey.Store + Prefix string +} + +// ResolveBearer mirrors identity-webhook createFirstMatchVerifier: JWT when shaped like +// a JWT, otherwise sk_* API keys. JWT-shaped tokens never fall through to API key lookup. +func (r *Resolver) ResolveBearer(ctx context.Context, bearer, pathClientID string) (Identity, error) { + bearer = strings.TrimSpace(bearer) + if bearer == "" { + return Identity{}, &ResolveError{ + Code: "invalid_client", + Message: "missing bearer token", + Err: errors.New("missing bearer token"), + } + } + if apikey.IsM2MSecret(bearer) { + return Identity{}, &ResolveError{ + Code: "invalid_request", + Message: "M2M client secrets cannot be used as API keys", + Err: errors.New("m2m secret"), + } + } + + if strings.Count(bearer, ".") == 2 { + if r.OIDC == nil { + return Identity{}, &ResolveError{ + Code: "invalid_token", + Message: "oidc verification not configured", + Err: errors.New("oidc verification not configured"), + } + } + verified, err := r.OIDC.VerifyUserAccessToken(ctx, bearer, pathClientID) + if err != nil { + return Identity{}, &ResolveError{ + Code: "invalid_token", + Message: err.Error(), + Err: err, + } + } + return Identity{ + ClientID: verified.ClientID, + ExternalUserID: verified.ExternalUserID, + }, nil + } + + if !strings.HasPrefix(bearer, r.Prefix) { + return Identity{}, &ResolveError{ + Code: "invalid_client", + Message: "invalid api key", + Err: errors.New("invalid api key"), + } + } + if r.APIKeys == nil { + return Identity{}, &ResolveError{ + Code: "invalid_client", + Message: "invalid api key", + Err: errors.New("invalid api key"), + } + } + + clientID, externalUserID, err := r.APIKeys.Resolve(ctx, bearer, pathClientID) + if err != nil { + msg := "invalid api key" + if errors.Is(err, apikey.ErrClientMismatch) { + msg = "api key client mismatch" + } + return Identity{}, &ResolveError{ + Code: "invalid_client", + Message: msg, + Err: err, + } + } + return Identity{ + ClientID: clientID, + ExternalUserID: externalUserID, + }, nil +} + +// OAuthHTTPStatus maps a ResolveError to an HTTP status code. +func OAuthHTTPStatus(err error) int { + var re *ResolveError + if errors.As(err, &re) { + switch re.Code { + case "invalid_request": + return 400 + case "invalid_token": + return 401 + default: + return 401 + } + } + return 401 +} + +// OAuthErrorCode returns the OAuth error code from a ResolveError. +func OAuthErrorCode(err error) string { + var re *ResolveError + if errors.As(err, &re) && re.Code != "" { + return re.Code + } + return "invalid_client" +} + +// OAuthErrorDescription returns a client-facing error description. +func OAuthErrorDescription(err error) string { + var re *ResolveError + if errors.As(err, &re) && re.Message != "" { + return re.Message + } + return fmt.Sprintf("%v", err) +} diff --git a/openmeter-collector/builder-api/internal/enduser/resolver_test.go b/openmeter-collector/builder-api/internal/enduser/resolver_test.go new file mode 100644 index 0000000..50537aa --- /dev/null +++ b/openmeter-collector/builder-api/internal/enduser/resolver_test.go @@ -0,0 +1,193 @@ +package enduser + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" +) + +func TestResolverAPIKeyWhenNotJWT(t *testing.T) { + t.Parallel() + + resolver := &Resolver{ + APIKeys: &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "demo-client", UserID: "demo-user"}, + }, + }, + Prefix: "sk_", + } + + identity, err := resolver.ResolveBearer(context.Background(), "sk_demo", "demo-client") + if err != nil { + t.Fatal(err) + } + if identity.ExternalUserID != "demo-user" { + t.Fatalf("usage_subject = %q", identity.ExternalUserID) + } +} + +func TestResolverJWTWinsOverAPIKeyStore(t *testing.T) { + t.Parallel() + + privateKey, jwksServer := testJWKS(t) + t.Cleanup(jwksServer.Close) + + verifier, err := oidcverify.NewWithJWKSURL( + context.Background(), + "https://idp.test", + "clearinghouse", + jwksServer.URL, + oidcverify.Options{RequiredScopes: []string{"sign:job"}}, + ) + if err != nil { + t.Fatal(err) + } + + clientID := "app-b" + subject := "user-b" + token := signTestJWT(t, privateKey, "https://idp.test", "clearinghouse", map[string]any{ + "azp": clientID, + "scope": "sign:job", + }, subject) + + resolver := &Resolver{ + OIDC: verifier, + APIKeys: &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_demo": {ClientID: "demo-client", UserID: "demo-user"}, + }, + }, + Prefix: "sk_", + } + + identity, err := resolver.ResolveBearer(context.Background(), token, clientID) + if err != nil { + t.Fatal(err) + } + if identity.ExternalUserID != subject { + t.Fatalf("usage_subject = %q", identity.ExternalUserID) + } +} + +func TestResolverRejectsM2MSecret(t *testing.T) { + t.Parallel() + + resolver := &Resolver{Prefix: "sk_"} + _, err := resolver.ResolveBearer(context.Background(), "pmth_cs_secret", "demo-client") + if err == nil { + t.Fatal("expected error") + } + if OAuthErrorCode(err) != "invalid_request" { + t.Fatalf("code = %q", OAuthErrorCode(err)) + } +} + +func TestResolverJWTDoesNotFallThroughToAPIKey(t *testing.T) { + t.Parallel() + + _, jwksServer := testJWKS(t) + t.Cleanup(jwksServer.Close) + + verifier, err := oidcverify.NewWithJWKSURL( + context.Background(), + "https://idp.test", + "clearinghouse", + jwksServer.URL, + oidcverify.Options{RequiredScopes: []string{"sign:job"}}, + ) + if err != nil { + t.Fatal(err) + } + + resolver := &Resolver{ + OIDC: verifier, + APIKeys: &apikey.Store{ + Prefix: "sk_", + Demo: map[string]apikey.DemoEntry{ + "sk_notused": {ClientID: "demo-client", UserID: "demo-user"}, + }, + }, + Prefix: "sk_", + } + + _, err = resolver.ResolveBearer(context.Background(), "a.b.c", "demo-client") + if err == nil { + t.Fatal("expected invalid token") + } + if OAuthErrorCode(err) != "invalid_token" { + t.Fatalf("code = %q", OAuthErrorCode(err)) + } +} + +func testJWKS(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + _ = publicJWK.Set(jwk.KeyIDKey, "k") + _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) + keySet := jwk.NewSet() + _ = keySet.AddKey(publicJWK) + + jwksBody, err := json.Marshal(keySet) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + return privateKey, server +} + +func signTestJWT( + t *testing.T, + privateKey *rsa.PrivateKey, + issuer, audience string, + claims map[string]any, + subject string, +) string { + t.Helper() + builder := jwt.NewBuilder(). + Issuer(issuer + "/"). + Audience([]string{audience}). + Subject(subject). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)) + for k, v := range claims { + builder = builder.Claim(k, v) + } + tok, err := builder.Build() + if err != nil { + t.Fatal(err) + } + headers := jws.NewHeaders() + _ = headers.Set(jws.KeyIDKey, "k") + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + t.Fatal(err) + } + return string(signed) +} diff --git a/openmeter-collector/builder-api/internal/httpapi/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go index 2be1b45..543f7ae 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -3,16 +3,14 @@ package httpapi import ( "context" "encoding/json" - "errors" "io" "net/http" "strings" - "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/oidcverify" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/enduser" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" ) @@ -22,20 +20,18 @@ type Server struct { auth0 *auth0mgmt.Client minter *auth0mint.Minter openmeter *openmeter.Client - oidc *oidcverify.Verifier - demoKeys map[string]apikey.DemoEntry + enduser *enduser.Resolver openAPISpec []byte } // NewServer constructs the HTTP API server. -func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, oidc *oidcverify.Verifier, demoKeys map[string]apikey.DemoEntry, openAPISpec []byte) *Server { +func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, resolver *enduser.Resolver, openAPISpec []byte) *Server { return &Server{ cfg: cfg, auth0: auth0, minter: minter, openmeter: om, - oidc: oidc, - demoKeys: demoKeys, + enduser: resolver, openAPISpec: openAPISpec, } } @@ -46,8 +42,7 @@ func (s *Server) Handler() http.Handler { 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}/auth/api-key/signer-session", s.handleSignerSession) - mux.HandleFunc("POST /api/v1/apps/{clientId}/auth/oidc/signer-session", s.handleOIDCSignerSession) + mux.HandleFunc("POST /api/v1/apps/{clientId}/auth/signer-session", s.handleSignerSession) return mux } @@ -179,14 +174,6 @@ func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing bearer token", correlationID) return } - if apikey.IsM2MSecret(token) { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "M2M client secrets cannot be used as API keys", correlationID) - return - } - if !strings.HasPrefix(token, s.cfg.APIKeyPrefix) { - writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid api key", correlationID) - return - } var req signerSessionRequest if r.ContentLength > 0 { @@ -203,69 +190,24 @@ func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - resolvedClientID, externalUserID, err := s.resolveAPIKey(ctx, token, clientID) + identity, err := s.enduser.ResolveBearer(ctx, token, clientID) if err != nil { - writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid api key", correlationID) + writeOAuthError( + w, + enduser.OAuthHTTPStatus(err), + enduser.OAuthErrorCode(err), + enduser.OAuthErrorDescription(err), + correlationID, + ) return } - if _, err := s.openmeter.EnsureCustomer(ctx, resolvedClientID, externalUserID, externalUserID); err != nil { + if _, err := s.openmeter.EnsureCustomer(ctx, identity.ClientID, identity.ExternalUserID, identity.ExternalUserID); err != nil { writeOAuthError(w, http.StatusBadGateway, "server_error", "openmeter customer provisioning failed", correlationID) return } - s.writeSignerSession(w, ctx, resolvedClientID, externalUserID, scope, correlationID) -} - -func (s *Server) handleOIDCSignerSession(w http.ResponseWriter, r *http.Request) { - correlationID := newCorrelationID() - clientID := strings.TrimSpace(r.PathValue("clientId")) - if clientID == "" { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "clientId is required", correlationID) - return - } - if s.oidc == nil { - writeOAuthError(w, http.StatusServiceUnavailable, "server_error", "oidc verification not configured", correlationID) - return - } - - token := BearerToken(r) - if token == "" { - writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing bearer token", correlationID) - return - } - if strings.HasPrefix(token, s.cfg.APIKeyPrefix) { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "use api-key signer-session for API keys", correlationID) - return - } - - var req signerSessionRequest - if r.ContentLength > 0 { - parsed, err := readJSONBody[signerSessionRequest](r) - if err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error(), correlationID) - return - } - req = parsed - } - scope := strings.TrimSpace(req.Scope) - if scope == "" { - scope = "sign:job" - } - - ctx := r.Context() - verified, err := s.oidc.VerifyUserAccessToken(ctx, token, clientID) - if err != nil { - writeOAuthError(w, http.StatusUnauthorized, "invalid_token", err.Error(), correlationID) - return - } - - if _, err := s.openmeter.EnsureCustomer(ctx, verified.ClientID, verified.ExternalUserID, verified.ExternalUserID); err != nil { - writeOAuthError(w, http.StatusBadGateway, "server_error", "openmeter customer provisioning failed", correlationID) - return - } - - s.writeSignerSession(w, ctx, verified.ClientID, verified.ExternalUserID, scope, correlationID) + s.writeSignerSession(w, ctx, identity.ClientID, identity.ExternalUserID, scope, correlationID) } func (s *Server) writeSignerSession( @@ -298,18 +240,6 @@ func (s *Server) writeSignerSession( writeJSON(w, http.StatusOK, resp) } -func (s *Server) resolveAPIKey(ctx context.Context, token, expectedClientID string) (clientID, externalUserID string, err error) { - if entry, ok := s.demoKeys[token]; ok { - if expectedClientID != "" && entry.ClientID != expectedClientID { - return "", "", errClientMismatch - } - return entry.ClientID, entry.UserID, nil - } - return s.auth0.ResolveAPIKeyUser(ctx, token, s.cfg.APIKeyPrefix, expectedClientID) -} - -var errClientMismatch = errors.New("api key client mismatch") - func readJSONBody[T any](r *http.Request) (T, error) { var zero T defer r.Body.Close() diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier.go b/openmeter-collector/builder-api/internal/oidcverify/verifier.go index c85071f..57604e1 100644 --- a/openmeter-collector/builder-api/internal/oidcverify/verifier.go +++ b/openmeter-collector/builder-api/internal/oidcverify/verifier.go @@ -10,7 +10,25 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" ) -const defaultRequiredScope = "sign:job" +// Options configures claim extraction and scope requirements for incoming user JWTs. +type Options struct { + ClientClaim string + SubjectClaim string + RequiredScopes []string +} + +func (o Options) withDefaults() Options { + if strings.TrimSpace(o.ClientClaim) == "" { + o.ClientClaim = "azp" + } + if strings.TrimSpace(o.SubjectClaim) == "" { + o.SubjectClaim = "sub" + } + if len(o.RequiredScopes) == 0 { + o.RequiredScopes = []string{"sign:job"} + } + return o +} // VerifiedUser is an end-user identity extracted from an Auth0 access token. type VerifiedUser struct { @@ -22,12 +40,13 @@ type VerifiedUser struct { type Verifier struct { issuer string audience string + opts Options cache *jwk.Cache jwksURL string } // New creates a JWKS-cached OIDC access-token verifier. -func New(ctx context.Context, issuer, audience string) (*Verifier, error) { +func New(ctx context.Context, issuer, audience string, opts Options) (*Verifier, error) { issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") audience = strings.TrimSpace(audience) if issuer == "" { @@ -46,6 +65,36 @@ func New(ctx context.Context, issuer, audience string) (*Verifier, error) { return &Verifier{ issuer: issuer, audience: audience, + opts: opts.withDefaults(), + cache: cache, + jwksURL: jwksURL, + }, nil +} + +// NewWithJWKSURL creates a verifier that loads keys from an explicit JWKS URL (tests or OIDC_JWKS_URI overrides). +func NewWithJWKSURL(ctx context.Context, issuer, audience, jwksURL string, opts Options) (*Verifier, error) { + issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") + audience = strings.TrimSpace(audience) + jwksURL = strings.TrimSpace(jwksURL) + if issuer == "" { + return nil, fmt.Errorf("oidcverify: issuer is required") + } + if audience == "" { + return nil, fmt.Errorf("oidcverify: audience is required") + } + if jwksURL == "" { + return nil, fmt.Errorf("oidcverify: jwksURL is required") + } + + cache := jwk.NewCache(ctx) + if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { + return nil, fmt.Errorf("oidcverify: register jwks: %w", err) + } + + return &Verifier{ + issuer: issuer, + audience: audience, + opts: opts.withDefaults(), cache: cache, jwksURL: jwksURL, }, nil @@ -78,25 +127,33 @@ func (v *Verifier) VerifyUserAccessToken(ctx context.Context, token, expectedCli return nil, fmt.Errorf("jwt verification failed: iss not satisfied") } - if err := requireScope(parsed, defaultRequiredScope); err != nil { - return nil, err + for _, scope := range v.opts.RequiredScopes { + if err := requireScope(parsed, scope); err != nil { + return nil, err + } } - clientID := claimString(parsed, "azp") + clientID := claimString(parsed, v.opts.ClientClaim) if clientID == "" { - return nil, fmt.Errorf("token missing azp claim") + clientID = claimString(parsed, "azp") + } + if clientID == "" { + return nil, fmt.Errorf("token missing %s claim", v.opts.ClientClaim) } expectedClientID = strings.TrimSpace(expectedClientID) if expectedClientID != "" && clientID != expectedClientID { return nil, fmt.Errorf("token azp does not match clientId") } - externalUserID := claimString(parsed, "external_user_id") + externalUserID := claimString(parsed, v.opts.SubjectClaim) + if externalUserID == "" { + externalUserID = claimString(parsed, "external_user_id") + } if externalUserID == "" { externalUserID = parsed.Subject() } if externalUserID == "" { - return nil, fmt.Errorf("token missing sub claim") + return nil, fmt.Errorf("token missing %s claim", v.opts.SubjectClaim) } return &VerifiedUser{ diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go b/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go index d23f843..3d24777 100644 --- a/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go +++ b/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go @@ -53,6 +53,7 @@ func TestVerifyUserAccessToken(t *testing.T) { verifier := &Verifier{ issuer: issuer, audience: audience, + opts: Options{}.withDefaults(), cache: jwk.NewCache(context.Background()), jwksURL: jwksServer.URL, } @@ -83,6 +84,108 @@ func TestVerifyUserAccessToken(t *testing.T) { } } +func TestVerifyUserAccessTokenClaimFallbacks(t *testing.T) { + t.Parallel() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + _ = publicJWK.Set(jwk.KeyIDKey, "test-key") + _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) + keySet := jwk.NewSet() + _ = keySet.AddKey(publicJWK) + + jwksBody, err := json.Marshal(keySet) + if err != nil { + t.Fatal(err) + } + + issuer := "https://idp.test" + audience := "livepeer-clearinghouse" + clientID := "pub-client" + + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + t.Cleanup(jwksServer.Close) + + verifier := &Verifier{ + issuer: issuer, + audience: audience, + opts: Options{ + ClientClaim: "app_client_id", + SubjectClaim: "external_user_id", + RequiredScopes: []string{"sign:job"}, + }.withDefaults(), + cache: jwk.NewCache(context.Background()), + jwksURL: jwksServer.URL, + } + if err := verifier.cache.Register(jwksServer.URL, jwk.WithMinRefreshInterval(time.Hour)); err != nil { + t.Fatal(err) + } + + token := buildTestTokenWithClaims( + t, + privateKey, + issuer, + audience, + map[string]any{ + "azp": clientID, + "external_user_id": "ext-user-1", + "scope": "sign:job", + }, + "ignored-sub", + ) + + user, err := verifier.VerifyUserAccessToken(context.Background(), token, clientID) + if err != nil { + t.Fatalf("verify: %v", err) + } + if user.ClientID != clientID { + t.Fatalf("clientID = %q, want azp fallback", user.ClientID) + } + if user.ExternalUserID != "ext-user-1" { + t.Fatalf("externalUserID = %q", user.ExternalUserID) + } +} + +func buildTestTokenWithClaims( + t *testing.T, + privateKey *rsa.PrivateKey, + issuer, audience string, + claims map[string]any, + subject string, +) string { + t.Helper() + builder := jwt.NewBuilder(). + Issuer(issuer + "/"). + Audience([]string{audience}). + Subject(subject). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)) + for k, v := range claims { + builder = builder.Claim(k, v) + } + tok, err := builder.Build() + if err != nil { + t.Fatal(err) + } + headers := jws.NewHeaders() + _ = headers.Set(jws.KeyIDKey, "test-key") + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + t.Fatal(err) + } + return string(signed) +} + func buildTestToken( t *testing.T, privateKey *rsa.PrivateKey, From 72c6dc7f8ae4b01b8127fb1d20a1e5e26d59d58c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 29 Jun 2026 23:41:28 -0400 Subject: [PATCH 07/14] feat(openmeter-collector): implement RFC 8693 token exchange for signer JWTs - Introduced a new endpoint for exchanging OIDC access tokens and API keys for signer JWTs, enhancing integration with Auth0. - Updated the OpenAPI documentation to reflect the new token exchange functionality and its requirements. - Enhanced error handling for invalid requests and improved client credential validation. - Added comprehensive tests for the token exchange logic to ensure robust functionality and compliance with RFC 8693. - Revised README files to clarify usage and configuration for the new token exchange feature. --- auth0-provisioner/provision/README.md | 2 +- openmeter-collector/builder-api/README.md | 49 +++- .../builder-api/cmd/builder-api/main.go | 10 +- .../builder-api/cmd/builder-api/openapi.json | 55 +++- .../builder-api/internal/httpapi/auth.go | 53 +++- .../builder-api/internal/httpapi/errors.go | 36 +++ .../builder-api/internal/httpapi/server.go | 127 +------- .../builder-api/internal/httpapi/token.go | 94 ++++++ .../internal/httpapi/token_test.go | 120 ++++++++ .../internal/tokenexchange/constants.go | 8 + .../internal/tokenexchange/errors.go | 58 ++++ .../internal/tokenexchange/handler.go | 228 +++++++++++++++ .../internal/tokenexchange/handler_test.go | 274 ++++++++++++++++++ 13 files changed, 949 insertions(+), 165 deletions(-) create mode 100644 openmeter-collector/builder-api/internal/httpapi/token.go create mode 100644 openmeter-collector/builder-api/internal/httpapi/token_test.go create mode 100644 openmeter-collector/builder-api/internal/tokenexchange/constants.go create mode 100644 openmeter-collector/builder-api/internal/tokenexchange/errors.go create mode 100644 openmeter-collector/builder-api/internal/tokenexchange/handler.go create mode 100644 openmeter-collector/builder-api/internal/tokenexchange/handler_test.go diff --git a/auth0-provisioner/provision/README.md b/auth0-provisioner/provision/README.md index 2eda05f..2749698 100644 --- a/auth0-provisioner/provision/README.md +++ b/auth0-provisioner/provision/README.md @@ -85,7 +85,7 @@ uv run examples/device_login.py \ --run-frames --signer http://localhost:8081 ``` -Requires the clearinghouse stack (`identity-webhook`, `remote-signer`, `openmeter-collector`). Pass `--billing-url` so device login exchanges the Auth0 user token via `POST …/auth/signer-session` — that upserts the OpenMeter customer (`{clientId}:{sub}`) and returns a minted signer JWT with `signer_url` / `discovery_url`. +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 diff --git a/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md index 3459736..a4d0641 100644 --- a/openmeter-collector/builder-api/README.md +++ b/openmeter-collector/builder-api/README.md @@ -9,7 +9,7 @@ Scalar docs: `GET /api/v1/docs` (spec at `/api/v1/openapi.json`). | 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}/auth/signer-session` | Bearer `sk_…` or Auth0 user JWT | Exchange credentials for signer JWT + upsert OpenMeter customer (JWT tried first when bearer looks like a JWT) | +| `POST` | `/api/v1/apps/{clientId}/oidc/token` | RFC 8693 form + subject token | Exchange Auth0 user JWT or `sk_*` API key for signer JWT + upsert OpenMeter customer | ## Auth0 prerequisites @@ -52,7 +52,7 @@ exports.onExecuteCredentialsExchange = async (event, api) => { 1. Deploy the Action. 2. Bind it to the **Credentials Exchange** flow for your tenant. -3. Re-test signer-session — the 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. +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. @@ -74,26 +74,47 @@ curl -sS -u "$AUTH0_SIGNER_M2M_CLIENT_ID:$AUTH0_SIGNER_M2M_CLIENT_SECRET" \ Use the public client id from `.env.livepeer` as the `{clientId}` path segment (e.g. `DEMO_APP_AUTH0_PUBLIC_CLIENT_ID`). -## Example: signer session +## Example: RFC 8693 signer session exchange -Bearer may be an end-user API key (`sk_…`) or an Auth0 user access token from device login. Resolution mirrors [identity-webhook](../../identity-webhook/verifiers.mjs): JWT-shaped bearers are verified as OIDC first; otherwise `sk_…` keys are resolved via demo env or Auth0 `app_metadata`. +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 -API_KEY=sk_... # from create-user response -curl -sS -H "Authorization: Bearer $API_KEY" \ - -H "Content-Type: application/json" \ - -d '{"scope":"sign:job"}' \ - "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/signer-session" +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 (same endpoint): +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 "Authorization: Bearer $OIDC_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"scope":"sign:job"}' \ - "http://localhost:8095/api/v1/apps/${DEMO_APP_AUTH0_PUBLIC_CLIENT_ID}/auth/signer-session" +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`. diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go index 847dbdf..1c85885 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/main.go +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -14,10 +14,10 @@ import ( 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/enduser" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/httpapi" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" + "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" ) //go:embed openapi.json @@ -55,13 +55,9 @@ func main() { Demo: demoKeys, Auth0: auth0Client, } - resolver := &enduser.Resolver{ - OIDC: oidcVerifier, - APIKeys: keyStore, - Prefix: cfg.APIKeyPrefix, - } + tokenHandler := tokenexchange.NewHandler(cfg, oidcVerifier, keyStore, minter, omClient) - srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, resolver, openAPISpec) + srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, tokenHandler, openAPISpec) server := &http.Server{ Addr: ":" + cfg.Port, Handler: srv.Handler(), diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json index 505a311..1787e44 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/openapi.json +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -66,11 +66,10 @@ } } }, - "/api/v1/apps/{clientId}/auth/signer-session": { + "/api/v1/apps/{clientId}/oidc/token": { "post": { - "summary": "Exchange credentials for signer session", - "description": "Accepts Bearer `sk_…` (end-user API key) or Bearer Auth0 user JWT (device code / authorization code). Validates credentials (JWT tried first when bearer looks like a JWT), upserts the OpenMeter customer, and mints a short-lived signer JWT.", - "security": [{ "bearerCredentials": [] }], + "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", @@ -81,9 +80,10 @@ } ], "requestBody": { + "required": true, "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/SignerSessionRequest" } + "application/x-www-form-urlencoded": { + "schema": { "$ref": "#/components/schemas/TokenExchangeRequest" } } } }, @@ -96,8 +96,8 @@ } } }, - "400": { "description": "Invalid request (e.g. M2M secret presented as API key)" }, - "401": { "description": "Invalid API key or OIDC token" } + "400": { "description": "Invalid request, grant, target, or unsupported token type" }, + "401": { "description": "Invalid client credentials" } } } } @@ -107,12 +107,7 @@ "m2mBasic": { "type": "http", "scheme": "basic", - "description": "M2M client id and secret (users:write)" - }, - "bearerCredentials": { - "type": "http", - "scheme": "bearer", - "description": "End-user API key (sk_…) or Auth0 user JWT (device code / authorization code)" + "description": "M2M client id and secret (users:write, RFC 8693 token exchange)" } }, "schemas": { @@ -137,10 +132,38 @@ "apiKey": { "type": "string", "description": "Returned once when issueApiKey is true" } } }, - "SignerSessionRequest": { + "TokenExchangeRequest": { "type": "object", + "required": [ + "grant_type", + "subject_token", + "subject_token_type" + ], "properties": { - "scope": { "type": "string", "description": "Defaults to sign:job" } + "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": { diff --git a/openmeter-collector/builder-api/internal/httpapi/auth.go b/openmeter-collector/builder-api/internal/httpapi/auth.go index 39167fb..72de06a 100644 --- a/openmeter-collector/builder-api/internal/httpapi/auth.go +++ b/openmeter-collector/builder-api/internal/httpapi/auth.go @@ -3,35 +3,58 @@ 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 { - username, password, ok := r.BasicAuth() + clientID, secret, ok := ClientCredentialsFromRequest(r, nil) if !ok { - auth := strings.TrimSpace(r.Header.Get("Authorization")) - if !strings.HasPrefix(auth, "Basic ") { - return false - } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")) - if err != nil { - return false + 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 + } } - parts := strings.SplitN(string(decoded), ":", 2) - if len(parts) != 2 { - return false + } + + if form != nil { + clientID = strings.TrimSpace(form.Get("client_id")) + clientSecret = strings.TrimSpace(form.Get("client_secret")) + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, true } - username, password = parts[0], parts[1] } - return username == expectedClientID && password == expectedSecret + return "", "", false } // BearerToken extracts the bearer token from Authorization header. func BearerToken(r *http.Request) string { auth := strings.TrimSpace(r.Header.Get("Authorization")) - if !strings.HasPrefix(auth, "Bearer ") { + if len(auth) < 7 || !strings.EqualFold(auth[:6], "bearer") { + return "" + } + if len(auth) == 6 { + return "" + } + if auth[6] != ' ' { return "" } - return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + 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 index 2ff536a..32baae4 100644 --- a/openmeter-collector/builder-api/internal/httpapi/errors.go +++ b/openmeter-collector/builder-api/internal/httpapi/errors.go @@ -23,8 +23,17 @@ 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, @@ -33,6 +42,19 @@ func writeOAuthError(w http.ResponseWriter, status int, code, description, corre }) } +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) @@ -44,3 +66,17 @@ func writeJSON(w http.ResponseWriter, status int, body any) { 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 index 543f7ae..cc64857 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -1,7 +1,6 @@ package httpapi import ( - "context" "encoding/json" "io" "net/http" @@ -10,29 +9,29 @@ import ( 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/enduser" "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 *openmeter.Client - enduser *enduser.Resolver - openAPISpec []byte + cfg config.Config + auth0 *auth0mgmt.Client + minter *auth0mint.Minter + openmeter *openmeter.Client + tokenExchange *tokenexchange.Handler + openAPISpec []byte } // NewServer constructs the HTTP API server. -func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, resolver *enduser.Resolver, openAPISpec []byte) *Server { +func NewServer(cfg config.Config, auth0 *auth0mgmt.Client, minter *auth0mint.Minter, om *openmeter.Client, tokenExchange *tokenexchange.Handler, openAPISpec []byte) *Server { return &Server{ - cfg: cfg, - auth0: auth0, - minter: minter, - openmeter: om, - enduser: resolver, - openAPISpec: openAPISpec, + cfg: cfg, + auth0: auth0, + minter: minter, + openmeter: om, + tokenExchange: tokenExchange, + openAPISpec: openAPISpec, } } @@ -42,7 +41,7 @@ func (s *Server) Handler() http.Handler { 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}/auth/signer-session", s.handleSignerSession) + mux.HandleFunc("POST /api/v1/apps/{clientId}/oidc/token", s.handleOIDCToken) return mux } @@ -144,102 +143,6 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { }) } -type signerSessionRequest struct { - Scope string `json:"scope"` -} - -type signerSessionResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - BalanceUsdMicros string `json:"balanceUsdMicros"` - LifetimeGrantedUsdMicros string `json:"lifetimeGrantedUsdMicros"` - 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"` -} - -func (s *Server) handleSignerSession(w http.ResponseWriter, r *http.Request) { - correlationID := newCorrelationID() - clientID := strings.TrimSpace(r.PathValue("clientId")) - if clientID == "" { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", "clientId is required", correlationID) - return - } - - token := BearerToken(r) - if token == "" { - writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing bearer token", correlationID) - return - } - - var req signerSessionRequest - if r.ContentLength > 0 { - parsed, err := readJSONBody[signerSessionRequest](r) - if err != nil { - writeOAuthError(w, http.StatusBadRequest, "invalid_request", err.Error(), correlationID) - return - } - req = parsed - } - scope := strings.TrimSpace(req.Scope) - if scope == "" { - scope = "sign:job" - } - - ctx := r.Context() - identity, err := s.enduser.ResolveBearer(ctx, token, clientID) - if err != nil { - writeOAuthError( - w, - enduser.OAuthHTTPStatus(err), - enduser.OAuthErrorCode(err), - enduser.OAuthErrorDescription(err), - correlationID, - ) - return - } - - if _, err := s.openmeter.EnsureCustomer(ctx, identity.ClientID, identity.ExternalUserID, identity.ExternalUserID); err != nil { - writeOAuthError(w, http.StatusBadGateway, "server_error", "openmeter customer provisioning failed", correlationID) - return - } - - s.writeSignerSession(w, ctx, identity.ClientID, identity.ExternalUserID, scope, correlationID) -} - -func (s *Server) writeSignerSession( - w http.ResponseWriter, - ctx context.Context, - clientID, externalUserID, scope, correlationID string, -) { - minted, err := s.minter.MintSignerToken(ctx, clientID, externalUserID) - if err != nil { - writeOAuthError(w, http.StatusBadGateway, "server_error", "signer token mint failed", correlationID) - return - } - - resp := signerSessionResponse{ - AccessToken: minted.AccessToken, - TokenType: "Bearer", - ExpiresIn: minted.ExpiresIn, - Scope: scope, - BalanceUsdMicros: "0", - LifetimeGrantedUsdMicros: "0", - IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", - CorrelationID: correlationID, - } - if s.cfg.SignerURL != "" { - resp.SignerURL = s.cfg.SignerURL - } - if s.cfg.DiscoveryURL != "" { - resp.DiscoveryURL = s.cfg.DiscoveryURL - } - writeJSON(w, http.StatusOK, resp) -} - func readJSONBody[T any](r *http.Request) (T, error) { var zero T defer r.Body.Close() 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..4465bb0 --- /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"` + BalanceUsdMicros string `json:"balanceUsdMicros"` + LifetimeGrantedUsdMicros string `json:"lifetimeGrantedUsdMicros"` + 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"` +} + +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, + BalanceUsdMicros: result.BalanceUsdMicros, + LifetimeGrantedUsdMicros: result.LifetimeGrantedUsdMicros, + SignerURL: result.SignerURL, + DiscoveryURL: result.DiscoveryURL, + IssuedTokenType: result.IssuedTokenType, + CorrelationID: result.CorrelationID, + }) +} 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..c1013de --- /dev/null +++ b/openmeter-collector/builder-api/internal/httpapi/token_test.go @@ -0,0 +1,120 @@ +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 stubOpenMeter struct{} + +func (stubOpenMeter) EnsureCustomer(context.Context, string, string, string) (*openmeter.Customer, error) { + return &openmeter.Customer{}, 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{}, + stubOpenMeter{}, + ) + 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{}, + stubOpenMeter{}, + ) + 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/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..00a8b62 --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/errors.go @@ -0,0 +1,58 @@ +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 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..491f490 --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler.go @@ -0,0 +1,228 @@ +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/oidcverify" + "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 + BalanceUsdMicros string + LifetimeGrantedUsdMicros string + SignerURL string + DiscoveryURL string + IssuedTokenType string + CorrelationID string +} + +// SignerMinter mints short-lived signer JWTs. +type SignerMinter interface { + MintSignerToken(ctx context.Context, publicClientID, externalUserID string) (*auth0mint.TokenResponse, error) +} + +// CustomerProvisioner upserts OpenMeter customers. +type CustomerProvisioner interface { + EnsureCustomer(ctx context.Context, clientID, externalUserID, displayName string) (*openmeter.Customer, error) +} + +// Handler performs RFC 8693 signer JWT token exchange. +type Handler struct { + cfg config.Config + oidc *oidcverify.Verifier + apiKeys *apikey.Store + minter SignerMinter + openmeter CustomerProvisioner +} + +// NewHandler constructs a token exchange handler. +func NewHandler( + cfg config.Config, + oidc *oidcverify.Verifier, + apiKeys *apikey.Store, + minter SignerMinter, + om CustomerProvisioner, +) *Handler { + return &Handler{ + cfg: cfg, + oidc: oidc, + 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 + } + + if _, err := h.openmeter.EnsureCustomer(ctx, clientID, externalUserID, externalUserID); err != nil { + return nil, wrapServerError(err) + } + + 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, + BalanceUsdMicros: "0", + LifetimeGrantedUsdMicros: "0", + IssuedTokenType: IssuedAccessTokenType, + CorrelationID: correlationID, + } + if h.cfg.SignerURL != "" { + result.SignerURL = h.cfg.SignerURL + } + if h.cfg.DiscoveryURL != "" { + result.DiscoveryURL = h.cfg.DiscoveryURL + } + return result, nil +} + +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.oidc == nil { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + verified, verifyErr := h.oidc.VerifyUserAccessToken(ctx, subjectToken, publicClientID) + if verifyErr != nil { + return "", "", invalidGrant("subject_token is not a valid access token for this issuer") + } + return verified.ClientID, verified.ExternalUserID, 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..e379dcc --- /dev/null +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go @@ -0,0 +1,274 @@ +package tokenexchange_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + 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/oidcverify" + "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 +} + +type stubOpenMeter struct{} + +func (stubOpenMeter) EnsureCustomer(context.Context, string, string, string) (*openmeter.Customer, error) { + return &openmeter.Customer{}, nil +} + +func testHandler(t *testing.T, oidc *oidcverify.Verifier) *tokenexchange.Handler { + t.Helper() + cfg := config.Config{ + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + SignerURL: "http://localhost:8081", + DiscoveryURL: "http://localhost/discovery", + } + return tokenexchange.NewHandler( + cfg, + oidc, + &apikey.Store{ + Prefix: "sk_", + 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", + }}, + stubOpenMeter{}, + ) +} + +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 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) + } +} + +func TestExchangeJWTHappyPath(t *testing.T) { + t.Parallel() + privateKey, jwksServer := testJWKS(t) + t.Cleanup(jwksServer.Close) + + verifier, err := oidcverify.NewWithJWKSURL( + context.Background(), + "https://idp.test", + "livepeer-clearinghouse", + jwksServer.URL, + oidcverify.Options{RequiredScopes: []string{"sign:job"}}, + ) + if err != nil { + t.Fatal(err) + } + + token := signJWT(t, privateKey, "pub-client", "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: token, + 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 testJWKS(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + _ = publicJWK.Set(jwk.KeyIDKey, "k") + _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) + keySet := jwk.NewSet() + _ = keySet.AddKey(publicJWK) + jwksBody, err := json.Marshal(keySet) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + return privateKey, server +} + +func signJWT(t *testing.T, privateKey *rsa.PrivateKey, clientID, subject string) string { + t.Helper() + tok, err := jwt.NewBuilder(). + Issuer("https://idp.test/"). + Audience([]string{"livepeer-clearinghouse"}). + Subject(subject). + Claim("azp", clientID). + Claim("scope", "sign:job"). + IssuedAt(time.Now()). + Expiration(time.Now().Add(5 * time.Minute)). + Build() + if err != nil { + t.Fatal(err) + } + headers := jws.NewHeaders() + _ = headers.Set(jws.KeyIDKey, "k") + signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) + if err != nil { + t.Fatal(err) + } + return string(signed) +} From 850ead2b9a30c18f54c33898ebf288be3d72e66e Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 30 Jun 2026 17:58:52 -0400 Subject: [PATCH 08/14] refactor(identity-webhook): simplify end-user verifier setup - Replaced the previous buildEndUserVerifier function with createEndUserVerifierFromEnv to streamline the configuration of verifiers based on the IDENTITY_AUTH_MODE environment variable. - Removed the first-match verifier logic, enforcing a strict selection between OIDC and API key verifiers. - Updated server initialization to log the selected authentication mode. - Enhanced error handling for missing required environment variables in both API key and OIDC modes. - Added tests for the new verifier setup, ensuring proper functionality and error management. --- openmeter-collector/builder-api/internal/enduser/resolver.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmeter-collector/builder-api/internal/enduser/resolver.go b/openmeter-collector/builder-api/internal/enduser/resolver.go index 88477e6..9b98fe5 100644 --- a/openmeter-collector/builder-api/internal/enduser/resolver.go +++ b/openmeter-collector/builder-api/internal/enduser/resolver.go @@ -40,8 +40,8 @@ type Resolver struct { Prefix string } -// ResolveBearer mirrors identity-webhook createFirstMatchVerifier: JWT when shaped like -// a JWT, otherwise sk_* API keys. JWT-shaped tokens never fall through to API key lookup. +// ResolveBearer accepts a subject token for RFC 8693 exchange: JWT-shaped tokens +// are verified via OIDC; otherwise sk_* API keys are resolved from the key store. func (r *Resolver) ResolveBearer(ctx context.Context, bearer, pathClientID string) (Identity, error) { bearer = strings.TrimSpace(bearer) if bearer == "" { From 7f9c425ef53cdd314a20f94f52ee2c4880f3ecfc Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 1 Jul 2026 13:09:39 -0400 Subject: [PATCH 09/14] feat(builder-api): provision OpenMeter customer, subscription, and allowance on session exchange Add no-database session provisioning keyed by clientId:externalUserId so user-scoped usage and trial credits are enforced before signer JWT mint. --- .env.example | 3 + .../builder-api/internal/config/config.go | 21 ++ .../builder-api/internal/httpapi/server.go | 6 +- .../internal/httpapi/token_test.go | 27 ++- .../internal/openmeter/allowance.go | 191 ++++++++++++++++++ .../internal/openmeter/customer.go | 5 - .../internal/openmeter/customer_key.go | 9 + .../internal/openmeter/provision.go | 33 +++ .../internal/openmeter/provision_test.go | 166 +++++++++++++++ .../internal/openmeter/subscription.go | 120 +++++++++++ .../builder-api/internal/openmeter/types.go | 23 +++ .../internal/tokenexchange/errors.go | 4 + .../internal/tokenexchange/handler.go | 28 ++- .../internal/tokenexchange/handler_test.go | 121 ++++++++++- 14 files changed, 734 insertions(+), 23 deletions(-) create mode 100644 openmeter-collector/builder-api/internal/openmeter/allowance.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/customer_key.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/provision.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/provision_test.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/subscription.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/types.go diff --git a/.env.example b/.env.example index 8fdb7e0..7eeefb3 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,9 @@ SIGNER_REMOTE_DISCOVERY=0 # Self-hosted base: https:// OPENMETER_URL=https://us.api.konghq.com/v3/openmeter OPENMETER_API_KEY= +OPENMETER_DEFAULT_PLAN_KEY=clearinghouse_default_ppu +OPENMETER_TRIAL_FEATURE_KEY=network_spend +OPENMETER_DEFAULT_STARTER_INCLUDED_USD_MICROS=5000000 # Optional (collector defaults shown) PRICE_ORACLE_URL=https://api.coinbase.com/v2/prices/ETH-USD/spot diff --git a/openmeter-collector/builder-api/internal/config/config.go b/openmeter-collector/builder-api/internal/config/config.go index 8f52f96..943b0a5 100644 --- a/openmeter-collector/builder-api/internal/config/config.go +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -20,6 +20,9 @@ type Config struct { DBConnection string OpenMeterURL string OpenMeterAPIKey string + OpenMeterDefaultPlanKey string + OpenMeterTrialFeatureKey string + OpenMeterDefaultStarterIncludedUsdMicros int64 SignerURL string DiscoveryURL string APIKeyPrefix string @@ -43,6 +46,12 @@ func Load() (Config, error) { 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"), + OpenMeterTrialFeatureKey: envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), + OpenMeterDefaultStarterIncludedUsdMicros: envInt64Or( + "OPENMETER_DEFAULT_STARTER_INCLUDED_USD_MICROS", + 5_000_000, + ), SignerURL: strings.TrimSpace(os.Getenv("SIGNER_URL")), DiscoveryURL: envOr( "DISCOVERY_URL", @@ -106,6 +115,18 @@ func firstEnv(keys ...string) string { return "" } +func envInt64Or(key string, fallback int64) int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fallback + } + return v +} + func splitScopes(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/openmeter-collector/builder-api/internal/httpapi/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go index cc64857..10f636f 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -124,7 +124,11 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } - if _, err := s.openmeter.EnsureCustomer(ctx, clientID, externalUserID, externalUserID); err != nil { + if _, err := s.openmeter.ProvisionSession(ctx, openmeter.ProvisionConfig{ + DefaultPlanKey: s.cfg.OpenMeterDefaultPlanKey, + TrialFeatureKey: s.cfg.OpenMeterTrialFeatureKey, + DefaultStarterIncludedMicros: s.cfg.OpenMeterDefaultStarterIncludedUsdMicros, + }, clientID, externalUserID); err != nil { writeAPIError(w, http.StatusBadGateway, "openmeter customer provisioning failed") return } diff --git a/openmeter-collector/builder-api/internal/httpapi/token_test.go b/openmeter-collector/builder-api/internal/httpapi/token_test.go index c1013de..779b404 100644 --- a/openmeter-collector/builder-api/internal/httpapi/token_test.go +++ b/openmeter-collector/builder-api/internal/httpapi/token_test.go @@ -26,10 +26,27 @@ func (stubMinter) MintSignerToken(context.Context, string, string) (*auth0mint.T }, nil } -type stubOpenMeter struct{} +type stubProvisioner struct { + provision *openmeter.SessionProvision + err error +} -func (stubOpenMeter) EnsureCustomer(context.Context, string, string, string) (*openmeter.Customer, error) { - return &openmeter.Customer{}, nil +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", + Balance: openmeter.TrialCreditBalance{ + HasAccess: true, + BalanceUsdMicros: "5000000", + LifetimeGrantedUsdMicros: "5000000", + }, + }, nil } func TestHandleOIDCTokenRejectsUnsupportedGrantType(t *testing.T) { @@ -51,7 +68,7 @@ func TestHandleOIDCTokenRejectsUnsupportedGrantType(t *testing.T) { }, }, stubMinter{}, - stubOpenMeter{}, + stubProvisioner{}, ) srv := httpapi.NewServer(cfg, nil, nil, nil, handler, nil) @@ -92,7 +109,7 @@ func TestHandleOIDCTokenAPIKeyExchange(t *testing.T) { }, }, stubMinter{}, - stubOpenMeter{}, + stubProvisioner{}, ) srv := httpapi.NewServer(cfg, nil, nil, nil, handler, nil) diff --git a/openmeter-collector/builder-api/internal/openmeter/allowance.go b/openmeter-collector/builder-api/internal/openmeter/allowance.go new file mode 100644 index 0000000..0069985 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/allowance.go @@ -0,0 +1,191 @@ +package openmeter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +type entitlementValue struct { + HasAccess bool `json:"hasAccess"` + Balance float64 `json:"balance"` + Usage float64 `json:"usage"` + TotalAvailableGrantAmount float64 `json:"totalAvailableGrantAmount"` +} + +type createGrantRequest struct { + Amount int64 `json:"amount"` + Priority int `json:"priority"` + EffectiveAt time.Time `json:"effectiveAt"` + Expiration grantExpiration `json:"expiration"` +} + +type grantExpiration struct { + Duration string `json:"duration"` + Count int `json:"count"` +} + +// GetTrialCreditBalance reads the user-scoped trial allowance for a customer key. +func (c *Client) GetTrialCreditBalance(ctx context.Context, customerKey, featureKey string, defaultGrantMicros int64) (TrialCreditBalance, error) { + featureKey = strings.TrimSpace(featureKey) + if featureKey == "" { + featureKey = "network_spend" + } + + value, err := c.getEntitlementValue(ctx, customerKey, featureKey) + if err != nil { + return TrialCreditBalance{ + HasAccess: false, + BalanceUsdMicros: "0", + ConsumedUsdMicros: "0", + LifetimeGrantedUsdMicros: "0", + }, nil + } + + balance := int64(max(0, value.Balance)) + usage := int64(max(0, value.Usage)) + granted := int64(max(0, value.TotalAvailableGrantAmount)) + if granted == 0 { + granted = balance + usage + } + if granted == 0 && defaultGrantMicros > 0 { + granted = defaultGrantMicros + } + + return TrialCreditBalance{ + HasAccess: value.HasAccess && balance > 0, + BalanceUsdMicros: strconv.FormatInt(balance, 10), + ConsumedUsdMicros: strconv.FormatInt(usage, 10), + LifetimeGrantedUsdMicros: strconv.FormatInt(granted, 10), + }, nil +} + +// GetTrialCreditBalanceWithFallback returns entitlement balance when available, +// otherwise falls back to the configured starter grant for Konnect-style deployments. +func (c *Client) GetTrialCreditBalanceWithFallback(ctx context.Context, customerKey, featureKey string, defaultGrantMicros int64) (TrialCreditBalance, error) { + balance, err := c.GetTrialCreditBalance(ctx, customerKey, featureKey, defaultGrantMicros) + if err != nil { + return balance, err + } + if balance.HasAccess || defaultGrantMicros <= 0 { + return balance, nil + } + + if _, entErr := c.getEntitlementValue(ctx, customerKey, featureKey); entErr == nil { + return balance, nil + } + + defaultGrant := strconv.FormatInt(defaultGrantMicros, 10) + return TrialCreditBalance{ + HasAccess: true, + BalanceUsdMicros: defaultGrant, + ConsumedUsdMicros: "0", + LifetimeGrantedUsdMicros: defaultGrant, + }, nil +} + +// EnsureTrialGrant creates a one-year trial grant when the customer has no allowance yet. +func (c *Client) EnsureTrialGrant(ctx context.Context, customerKey, featureKey string, amountMicros int64) error { + if amountMicros <= 0 { + return nil + } + featureKey = strings.TrimSpace(featureKey) + if featureKey == "" { + featureKey = "network_spend" + } + + balance, err := c.GetTrialCreditBalance(ctx, customerKey, featureKey, amountMicros) + if err != nil { + return err + } + if balance.HasAccess { + return nil + } + + payload := createGrantRequest{ + Amount: amountMicros, + Priority: 1, + EffectiveAt: time.Now().UTC(), + Expiration: grantExpiration{Duration: "YEAR", Count: 1}, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + path := fmt.Sprintf("%s/customers/%s/entitlements/%s/grants", + c.baseURL, + urlPathEscape(customerKey), + urlPathEscape(featureKey), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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 grant: %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 grant %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) getEntitlementValue(ctx context.Context, customerKey, featureKey string) (*entitlementValue, error) { + path := fmt.Sprintf("%s/customers/%s/entitlements/%s/value", + c.baseURL, + urlPathEscape(customerKey), + urlPathEscape(featureKey), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, 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 value: %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 entitlement value %d: %s", resp.StatusCode, string(respBody)) + } + + var value entitlementValue + if err := json.Unmarshal(respBody, &value); err != nil { + return nil, err + } + return &value, nil +} + +func urlPathEscape(value string) string { + return strings.ReplaceAll(value, ":", "%3A") +} + +func max(a, b float64) float64 { + if a > b { + return a + } + return b +} diff --git a/openmeter-collector/builder-api/internal/openmeter/customer.go b/openmeter-collector/builder-api/internal/openmeter/customer.go index 4c15268..6532716 100644 --- a/openmeter-collector/builder-api/internal/openmeter/customer.go +++ b/openmeter-collector/builder-api/internal/openmeter/customer.go @@ -50,11 +50,6 @@ func New(baseURL, apiKey string) *Client { } } -// CustomerKey returns the compound billing subject key. -func CustomerKey(clientID, externalUserID string) string { - return strings.TrimSpace(clientID) + ":" + strings.TrimSpace(externalUserID) -} - // 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) 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..6ee2e53 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/provision.go @@ -0,0 +1,33 @@ +package openmeter + +import "context" + +// ProvisionSession upserts customer, default subscription, trial grant, and returns allowance. +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 + } + + if err := c.EnsureTrialGrant(ctx, customerKey, cfg.TrialFeatureKey, cfg.DefaultStarterIncludedMicros); err != nil { + // Konnect may not support explicit grants; continue with subscription-only allowance. + _ = err + } + + balance, err := c.GetTrialCreditBalanceWithFallback(ctx, customerKey, cfg.TrialFeatureKey, cfg.DefaultStarterIncludedMicros) + if err != nil { + return nil, err + } + + return &SessionProvision{ + Customer: customer, + CustomerKey: customerKey, + Balance: balance, + }, 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..eff27fa --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/provision_test.go @@ -0,0 +1,166 @@ +package openmeter_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "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 TestProvisionSessionCreatesCustomerSubscriptionAndGrant(t *testing.T) { + t.Parallel() + + var ( + customerCreated bool + subscriptionCreated bool + grantCreated 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") + if subscriptionCreated { + _, _ = w.Write([]byte(`{"data":[{"id":"sub-1","customer_id":"cust-1","status":"active"}]}`)) + return + } + _, _ = 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"}`)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/value"): + w.Header().Set("Content-Type", "application/json") + if grantCreated { + _, _ = w.Write([]byte(`{"hasAccess":true,"balance":5000000,"usage":0,"totalAvailableGrantAmount":5000000}`)) + return + } + w.WriteHeader(http.StatusNotFound) + return + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/grants"): + grantCreated = true + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"grant-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", + TrialFeatureKey: "network_spend", + DefaultStarterIncludedMicros: 5_000_000, + }, "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 || !grantCreated { + t.Fatalf("customer=%v subscription=%v grant=%v", customerCreated, subscriptionCreated, grantCreated) + } + if !result.Balance.HasAccess { + t.Fatalf("expected access, got %+v", result.Balance) + } + if result.Balance.BalanceUsdMicros != "5000000" { + t.Fatalf("balance = %q", result.Balance.BalanceUsdMicros) + } +} + +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"}]}`)) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/value"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"hasAccess":true,"balance":1000000,"usage":4000000,"totalAvailableGrantAmount":5000000}`)) + 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", + TrialFeatureKey: "network_spend", + DefaultStarterIncludedMicros: 5_000_000, + }, "pub-client", "demo-user") + if err != nil { + t.Fatal(err) + } + if result.Balance.BalanceUsdMicros != "1000000" { + t.Fatalf("balance = %q", result.Balance.BalanceUsdMicros) + } +} + +func TestGetTrialCreditBalanceFallsBackToConfiguredGrant(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + client := openmeter.New(server.URL, "test-key") + balance, err := client.GetTrialCreditBalanceWithFallback(context.Background(), "pub-client:demo-user", "network_spend", 5_000_000) + if err != nil { + t.Fatal(err) + } + if !balance.HasAccess || balance.BalanceUsdMicros != "5000000" { + t.Fatalf("balance = %+v", balance) + } +} + +func TestGetTrialCreditBalanceExhausted(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "hasAccess": false, + "balance": 0, + "usage": 5_000_000, + "totalAvailableGrantAmount": 5_000_000, + }) + })) + t.Cleanup(server.Close) + + client := openmeter.New(server.URL, "test-key") + balance, err := client.GetTrialCreditBalance(context.Background(), "pub-client:demo-user", "network_spend", 5_000_000) + if err != nil { + t.Fatal(err) + } + if balance.HasAccess { + t.Fatalf("expected exhausted balance, got %+v", balance) + } +} 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..7ddc90a --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/subscription.go @@ -0,0 +1,120 @@ +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() + q.Set("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..1f09d28 --- /dev/null +++ b/openmeter-collector/builder-api/internal/openmeter/types.go @@ -0,0 +1,23 @@ +package openmeter + +// TrialCreditBalance is the user-scoped allowance snapshot for session minting. +type TrialCreditBalance struct { + HasAccess bool + BalanceUsdMicros string + ConsumedUsdMicros string + LifetimeGrantedUsdMicros string +} + +// SessionProvision is the result of no-database OpenMeter provisioning for exchange. +type SessionProvision struct { + Customer *Customer + CustomerKey string + Balance TrialCreditBalance +} + +// ProvisionConfig controls subscription and allowance provisioning. +type ProvisionConfig struct { + DefaultPlanKey string + TrialFeatureKey string + DefaultStarterIncludedMicros int64 +} diff --git a/openmeter-collector/builder-api/internal/tokenexchange/errors.go b/openmeter-collector/builder-api/internal/tokenexchange/errors.go index 00a8b62..35b8965 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/errors.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/errors.go @@ -53,6 +53,10 @@ func serverError(description string) *Error { return newError("server_error", description, description, 500) } +func insufficientAllowance(description string) *Error { + return newError("insufficient_allowance", description, description, 402) +} + 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 index 491f490..a860cbb 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler.go @@ -43,9 +43,9 @@ type SignerMinter interface { MintSignerToken(ctx context.Context, publicClientID, externalUserID string) (*auth0mint.TokenResponse, error) } -// CustomerProvisioner upserts OpenMeter customers. -type CustomerProvisioner interface { - EnsureCustomer(ctx context.Context, clientID, externalUserID, displayName string) (*openmeter.Customer, error) +// SessionProvisioner upserts OpenMeter customer, subscription, and allowance. +type SessionProvisioner interface { + ProvisionSession(ctx context.Context, cfg openmeter.ProvisionConfig, clientID, externalUserID string) (*openmeter.SessionProvision, error) } // Handler performs RFC 8693 signer JWT token exchange. @@ -54,7 +54,7 @@ type Handler struct { oidc *oidcverify.Verifier apiKeys *apikey.Store minter SignerMinter - openmeter CustomerProvisioner + openmeter SessionProvisioner } // NewHandler constructs a token exchange handler. @@ -63,7 +63,7 @@ func NewHandler( oidc *oidcverify.Verifier, apiKeys *apikey.Store, minter SignerMinter, - om CustomerProvisioner, + om SessionProvisioner, ) *Handler { return &Handler{ cfg: cfg, @@ -106,9 +106,13 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin return nil, err } - if _, err := h.openmeter.EnsureCustomer(ctx, clientID, externalUserID, externalUserID); err != nil { + provision, err := h.openmeter.ProvisionSession(ctx, h.provisionConfig(), clientID, externalUserID) + if err != nil { return nil, wrapServerError(err) } + if !provision.Balance.HasAccess { + return nil, insufficientAllowance("trial credits exhausted") + } minted, err := h.minter.MintSignerToken(ctx, clientID, externalUserID) if err != nil { @@ -125,8 +129,8 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin TokenType: "Bearer", ExpiresIn: minted.ExpiresIn, Scope: scope, - BalanceUsdMicros: "0", - LifetimeGrantedUsdMicros: "0", + BalanceUsdMicros: provision.Balance.BalanceUsdMicros, + LifetimeGrantedUsdMicros: provision.Balance.LifetimeGrantedUsdMicros, IssuedTokenType: IssuedAccessTokenType, CorrelationID: correlationID, } @@ -139,6 +143,14 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin return result, nil } +func (h *Handler) provisionConfig() openmeter.ProvisionConfig { + return openmeter.ProvisionConfig{ + DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, + TrialFeatureKey: h.cfg.OpenMeterTrialFeatureKey, + DefaultStarterIncludedMicros: h.cfg.OpenMeterDefaultStarterIncludedUsdMicros, + } +} + func (h *Handler) validateClient(clientID, clientSecret string) error { clientID = strings.TrimSpace(clientID) clientSecret = strings.TrimSpace(clientSecret) diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go index e379dcc..39a6156 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go @@ -34,10 +34,30 @@ func (s stubMinter) MintSignerToken(_ context.Context, _, _ string) (*auth0mint. return s.response, nil } -type stubOpenMeter struct{} +type stubProvisioner struct { + provision *openmeter.SessionProvision + err error + calls int +} -func (stubOpenMeter) EnsureCustomer(context.Context, string, string, string) (*openmeter.Customer, error) { - return &openmeter.Customer{}, nil +func (s *stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionConfig, string, string) (*openmeter.SessionProvision, error) { + s.calls++ + if s.err != nil { + return nil, s.err + } + if s.provision != nil { + return s.provision, nil + } + return &openmeter.SessionProvision{ + Customer: &openmeter.Customer{ID: "cust-1", Key: "pub-client:demo-user"}, + CustomerKey: "pub-client:demo-user", + Balance: openmeter.TrialCreditBalance{ + HasAccess: true, + BalanceUsdMicros: "5000000", + ConsumedUsdMicros: "0", + LifetimeGrantedUsdMicros: "5000000", + }, + }, nil } func testHandler(t *testing.T, oidc *oidcverify.Verifier) *tokenexchange.Handler { @@ -65,7 +85,7 @@ func testHandler(t *testing.T, oidc *oidcverify.Verifier) *tokenexchange.Handler ExpiresIn: 300, Scope: "sign:job", }}, - stubOpenMeter{}, + &stubProvisioner{}, ) } @@ -162,6 +182,99 @@ func TestExchangeAPIKeyHappyPathWithoutM2M(t *testing.T) { if result.AccessToken != "minted-jwt" { t.Fatalf("access_token = %q", result.AccessToken) } + if result.BalanceUsdMicros != "5000000" { + t.Fatalf("balance = %q", result.BalanceUsdMicros) + } +} + +func TestExchangeRejectsExhaustedAllowance(t *testing.T) { + t.Parallel() + 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"}}, + &stubProvisioner{provision: &openmeter.SessionProvision{ + CustomerKey: "pub-client:demo-user", + Balance: openmeter.TrialCreditBalance{ + HasAccess: false, + BalanceUsdMicros: "0", + ConsumedUsdMicros: "5000000", + }, + }}, + ) + _, 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) + } +} + +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) { From c96b897798a7eee0d9c79260e9e2e8eef24b7c28 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 1 Jul 2026 13:27:25 -0400 Subject: [PATCH 10/14] refactor(builder-api): delegate end-user JWT verification to identity-webhook - Updated the configuration to support identity-webhook for verifying end-user JWTs, enhancing security and flexibility. - Removed deprecated trial feature keys and related configurations from the environment setup. - Simplified the provisioning logic by eliminating unnecessary trial grant handling. - Revised OpenAPI documentation to reflect changes in the token exchange process and updated environment variable requirements. - Enhanced README documentation to clarify the new identity verification process and configuration options. --- .env.example | 5 +- openmeter-collector/builder-api/README.md | 17 ++ .../builder-api/cmd/builder-api/main.go | 18 +- .../builder-api/cmd/builder-api/openapi.json | 6 +- openmeter-collector/builder-api/go.mod | 2 +- .../builder-api/internal/config/config.go | 50 +--- .../builder-api/internal/enduser/resolver.go | 149 ------------ .../internal/enduser/resolver_test.go | 193 ---------------- .../builder-api/internal/httpapi/server.go | 4 +- .../builder-api/internal/httpapi/token.go | 36 ++- .../internal/httpapi/token_test.go | 5 - .../internal/oidcverify/verifier.go | 189 ---------------- .../internal/oidcverify/verifier_test.go | 214 ------------------ .../internal/openmeter/allowance.go | 191 ---------------- .../internal/openmeter/provision.go | 13 +- .../internal/openmeter/provision_test.go | 90 +------- .../builder-api/internal/openmeter/types.go | 15 +- .../internal/tokenexchange/errors.go | 4 - .../internal/tokenexchange/handler.go | 63 +++--- .../internal/tokenexchange/handler_test.go | 174 ++++---------- .../internal/webhookverify/client.go | 110 +++++++++ 21 files changed, 247 insertions(+), 1301 deletions(-) delete mode 100644 openmeter-collector/builder-api/internal/enduser/resolver.go delete mode 100644 openmeter-collector/builder-api/internal/enduser/resolver_test.go delete mode 100644 openmeter-collector/builder-api/internal/oidcverify/verifier.go delete mode 100644 openmeter-collector/builder-api/internal/oidcverify/verifier_test.go delete mode 100644 openmeter-collector/builder-api/internal/openmeter/allowance.go create mode 100644 openmeter-collector/builder-api/internal/webhookverify/client.go diff --git a/.env.example b/.env.example index 7eeefb3..ff7ed12 100644 --- a/.env.example +++ b/.env.example @@ -78,8 +78,9 @@ SIGNER_REMOTE_DISCOVERY=0 OPENMETER_URL=https://us.api.konghq.com/v3/openmeter OPENMETER_API_KEY= OPENMETER_DEFAULT_PLAN_KEY=clearinghouse_default_ppu -OPENMETER_TRIAL_FEATURE_KEY=network_spend -OPENMETER_DEFAULT_STARTER_INCLUDED_USD_MICROS=5000000 +# 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 diff --git a/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md index a4d0641..5cad333 100644 --- a/openmeter-collector/builder-api/README.md +++ b/openmeter-collector/builder-api/README.md @@ -56,6 +56,23 @@ exports.onExecuteCredentialsExchange = async (event, api) => { 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` / diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go index 1c85885..4a4945a 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/main.go +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -15,9 +15,9 @@ import ( "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/oidcverify" "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 @@ -36,13 +36,13 @@ func main() { minter := auth0mint.New(cfg.Auth0Issuer, cfg.Auth0Audience, cfg.SignerM2MClientID, cfg.SignerM2MSecret) omClient := openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey) - oidcVerifier, err := oidcverify.New(context.Background(), cfg.Auth0Issuer, cfg.Auth0Audience, oidcverify.Options{ - ClientClaim: cfg.OIDCClientClaim, - SubjectClaim: cfg.OIDCSubjectClaim, - RequiredScopes: cfg.OIDCRequiredScopes, - }) - if err != nil { - log.Fatalf("oidc verifier: %v", err) + + // 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) @@ -55,7 +55,7 @@ func main() { Demo: demoKeys, Auth0: auth0Client, } - tokenHandler := tokenexchange.NewHandler(cfg, oidcVerifier, keyStore, minter, omClient) + tokenHandler := tokenexchange.NewHandler(cfg, verifier, keyStore, minter, omClient) srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, tokenHandler, openAPISpec) server := &http.Server{ diff --git a/openmeter-collector/builder-api/cmd/builder-api/openapi.json b/openmeter-collector/builder-api/cmd/builder-api/openapi.json index 1787e44..29038d8 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/openapi.json +++ b/openmeter-collector/builder-api/cmd/builder-api/openapi.json @@ -172,17 +172,13 @@ "access_token", "token_type", "expires_in", - "scope", - "balanceUsdMicros", - "lifetimeGrantedUsdMicros" + "scope" ], "properties": { "access_token": { "type": "string" }, "token_type": { "type": "string", "enum": ["Bearer"] }, "expires_in": { "type": "integer" }, "scope": { "type": "string" }, - "balanceUsdMicros": { "type": "string" }, - "lifetimeGrantedUsdMicros": { "type": "string" }, "signer_url": { "type": "string", "format": "uri" }, "discovery_url": { "type": "string", "format": "uri" }, "issued_token_type": { "type": "string" }, diff --git a/openmeter-collector/builder-api/go.mod b/openmeter-collector/builder-api/go.mod index aa4db40..0914df1 100644 --- a/openmeter-collector/builder-api/go.mod +++ b/openmeter-collector/builder-api/go.mod @@ -5,7 +5,6 @@ go 1.25.0 require ( github.com/auth0/go-auth0/v2 v2.14.0 github.com/google/uuid v1.6.0 - github.com/lestrrat-go/jwx/v2 v2.1.6 ) require ( @@ -16,6 +15,7 @@ require ( 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 diff --git a/openmeter-collector/builder-api/internal/config/config.go b/openmeter-collector/builder-api/internal/config/config.go index 943b0a5..063fc19 100644 --- a/openmeter-collector/builder-api/internal/config/config.go +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -21,15 +21,14 @@ type Config struct { OpenMeterURL string OpenMeterAPIKey string OpenMeterDefaultPlanKey string - OpenMeterTrialFeatureKey string - OpenMeterDefaultStarterIncludedUsdMicros int64 SignerURL string DiscoveryURL string APIKeyPrefix string DemoAPIKeys string - OIDCClientClaim string - OIDCSubjectClaim string - OIDCRequiredScopes []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. @@ -47,21 +46,15 @@ func Load() (Config, error) { 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"), - OpenMeterTrialFeatureKey: envOr("OPENMETER_TRIAL_FEATURE_KEY", "network_spend"), - OpenMeterDefaultStarterIncludedUsdMicros: envInt64Or( - "OPENMETER_DEFAULT_STARTER_INCLUDED_USD_MICROS", - 5_000_000, - ), 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_"), + APIKeyPrefix: envOr("API_KEY_PREFIX", "sk_"), DemoAPIKeys: strings.TrimSpace(os.Getenv("DEMO_API_KEYS")), - OIDCClientClaim: strings.TrimSpace(os.Getenv("OIDC_CLIENT_CLAIM")), - OIDCSubjectClaim: strings.TrimSpace(os.Getenv("OIDC_SUBJECT_CLAIM")), - OIDCRequiredScopes: splitScopes(os.Getenv("OIDC_REQUIRED_SCOPES")), + IdentityWebhookURL: firstEnv("IDENTITY_WEBHOOK_URL", "REMOTE_SIGNER_WEBHOOK_URL"), + WebhookSecret: strings.TrimSpace(os.Getenv("WEBHOOK_SECRET")), } if cfg.Auth0Issuer == "" && cfg.Auth0Domain != "" { @@ -115,32 +108,3 @@ func firstEnv(keys ...string) string { return "" } -func envInt64Or(key string, fallback int64) int64 { - raw := strings.TrimSpace(os.Getenv(key)) - if raw == "" { - return fallback - } - v, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return fallback - } - return v -} - -func splitScopes(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - parts := strings.FieldsFunc(raw, func(r rune) bool { - return r == ',' || r == ' ' || r == '\t' - }) - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} diff --git a/openmeter-collector/builder-api/internal/enduser/resolver.go b/openmeter-collector/builder-api/internal/enduser/resolver.go deleted file mode 100644 index 9b98fe5..0000000 --- a/openmeter-collector/builder-api/internal/enduser/resolver.go +++ /dev/null @@ -1,149 +0,0 @@ -package enduser - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" - "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" -) - -// Identity is the resolved end-user tenant + subject for signer-session exchange. -type Identity struct { - ClientID string - ExternalUserID string -} - -// ResolveError carries an OAuth error code for HTTP mapping. -type ResolveError struct { - Code string - Message string - Err error -} - -func (e *ResolveError) Error() string { - if e.Message != "" { - return e.Message - } - if e.Err != nil { - return e.Err.Error() - } - return "resolve failed" -} - -// Resolver resolves bearer credentials via OIDC (JWT first) then API key. -type Resolver struct { - OIDC *oidcverify.Verifier - APIKeys *apikey.Store - Prefix string -} - -// ResolveBearer accepts a subject token for RFC 8693 exchange: JWT-shaped tokens -// are verified via OIDC; otherwise sk_* API keys are resolved from the key store. -func (r *Resolver) ResolveBearer(ctx context.Context, bearer, pathClientID string) (Identity, error) { - bearer = strings.TrimSpace(bearer) - if bearer == "" { - return Identity{}, &ResolveError{ - Code: "invalid_client", - Message: "missing bearer token", - Err: errors.New("missing bearer token"), - } - } - if apikey.IsM2MSecret(bearer) { - return Identity{}, &ResolveError{ - Code: "invalid_request", - Message: "M2M client secrets cannot be used as API keys", - Err: errors.New("m2m secret"), - } - } - - if strings.Count(bearer, ".") == 2 { - if r.OIDC == nil { - return Identity{}, &ResolveError{ - Code: "invalid_token", - Message: "oidc verification not configured", - Err: errors.New("oidc verification not configured"), - } - } - verified, err := r.OIDC.VerifyUserAccessToken(ctx, bearer, pathClientID) - if err != nil { - return Identity{}, &ResolveError{ - Code: "invalid_token", - Message: err.Error(), - Err: err, - } - } - return Identity{ - ClientID: verified.ClientID, - ExternalUserID: verified.ExternalUserID, - }, nil - } - - if !strings.HasPrefix(bearer, r.Prefix) { - return Identity{}, &ResolveError{ - Code: "invalid_client", - Message: "invalid api key", - Err: errors.New("invalid api key"), - } - } - if r.APIKeys == nil { - return Identity{}, &ResolveError{ - Code: "invalid_client", - Message: "invalid api key", - Err: errors.New("invalid api key"), - } - } - - clientID, externalUserID, err := r.APIKeys.Resolve(ctx, bearer, pathClientID) - if err != nil { - msg := "invalid api key" - if errors.Is(err, apikey.ErrClientMismatch) { - msg = "api key client mismatch" - } - return Identity{}, &ResolveError{ - Code: "invalid_client", - Message: msg, - Err: err, - } - } - return Identity{ - ClientID: clientID, - ExternalUserID: externalUserID, - }, nil -} - -// OAuthHTTPStatus maps a ResolveError to an HTTP status code. -func OAuthHTTPStatus(err error) int { - var re *ResolveError - if errors.As(err, &re) { - switch re.Code { - case "invalid_request": - return 400 - case "invalid_token": - return 401 - default: - return 401 - } - } - return 401 -} - -// OAuthErrorCode returns the OAuth error code from a ResolveError. -func OAuthErrorCode(err error) string { - var re *ResolveError - if errors.As(err, &re) && re.Code != "" { - return re.Code - } - return "invalid_client" -} - -// OAuthErrorDescription returns a client-facing error description. -func OAuthErrorDescription(err error) string { - var re *ResolveError - if errors.As(err, &re) && re.Message != "" { - return re.Message - } - return fmt.Sprintf("%v", err) -} diff --git a/openmeter-collector/builder-api/internal/enduser/resolver_test.go b/openmeter-collector/builder-api/internal/enduser/resolver_test.go deleted file mode 100644 index 50537aa..0000000 --- a/openmeter-collector/builder-api/internal/enduser/resolver_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package enduser - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/apikey" - "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/oidcverify" -) - -func TestResolverAPIKeyWhenNotJWT(t *testing.T) { - t.Parallel() - - resolver := &Resolver{ - APIKeys: &apikey.Store{ - Prefix: "sk_", - Demo: map[string]apikey.DemoEntry{ - "sk_demo": {ClientID: "demo-client", UserID: "demo-user"}, - }, - }, - Prefix: "sk_", - } - - identity, err := resolver.ResolveBearer(context.Background(), "sk_demo", "demo-client") - if err != nil { - t.Fatal(err) - } - if identity.ExternalUserID != "demo-user" { - t.Fatalf("usage_subject = %q", identity.ExternalUserID) - } -} - -func TestResolverJWTWinsOverAPIKeyStore(t *testing.T) { - t.Parallel() - - privateKey, jwksServer := testJWKS(t) - t.Cleanup(jwksServer.Close) - - verifier, err := oidcverify.NewWithJWKSURL( - context.Background(), - "https://idp.test", - "clearinghouse", - jwksServer.URL, - oidcverify.Options{RequiredScopes: []string{"sign:job"}}, - ) - if err != nil { - t.Fatal(err) - } - - clientID := "app-b" - subject := "user-b" - token := signTestJWT(t, privateKey, "https://idp.test", "clearinghouse", map[string]any{ - "azp": clientID, - "scope": "sign:job", - }, subject) - - resolver := &Resolver{ - OIDC: verifier, - APIKeys: &apikey.Store{ - Prefix: "sk_", - Demo: map[string]apikey.DemoEntry{ - "sk_demo": {ClientID: "demo-client", UserID: "demo-user"}, - }, - }, - Prefix: "sk_", - } - - identity, err := resolver.ResolveBearer(context.Background(), token, clientID) - if err != nil { - t.Fatal(err) - } - if identity.ExternalUserID != subject { - t.Fatalf("usage_subject = %q", identity.ExternalUserID) - } -} - -func TestResolverRejectsM2MSecret(t *testing.T) { - t.Parallel() - - resolver := &Resolver{Prefix: "sk_"} - _, err := resolver.ResolveBearer(context.Background(), "pmth_cs_secret", "demo-client") - if err == nil { - t.Fatal("expected error") - } - if OAuthErrorCode(err) != "invalid_request" { - t.Fatalf("code = %q", OAuthErrorCode(err)) - } -} - -func TestResolverJWTDoesNotFallThroughToAPIKey(t *testing.T) { - t.Parallel() - - _, jwksServer := testJWKS(t) - t.Cleanup(jwksServer.Close) - - verifier, err := oidcverify.NewWithJWKSURL( - context.Background(), - "https://idp.test", - "clearinghouse", - jwksServer.URL, - oidcverify.Options{RequiredScopes: []string{"sign:job"}}, - ) - if err != nil { - t.Fatal(err) - } - - resolver := &Resolver{ - OIDC: verifier, - APIKeys: &apikey.Store{ - Prefix: "sk_", - Demo: map[string]apikey.DemoEntry{ - "sk_notused": {ClientID: "demo-client", UserID: "demo-user"}, - }, - }, - Prefix: "sk_", - } - - _, err = resolver.ResolveBearer(context.Background(), "a.b.c", "demo-client") - if err == nil { - t.Fatal("expected invalid token") - } - if OAuthErrorCode(err) != "invalid_token" { - t.Fatalf("code = %q", OAuthErrorCode(err)) - } -} - -func testJWKS(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - - publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) - if err != nil { - t.Fatal(err) - } - _ = publicJWK.Set(jwk.KeyIDKey, "k") - _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) - keySet := jwk.NewSet() - _ = keySet.AddKey(publicJWK) - - jwksBody, err := json.Marshal(keySet) - if err != nil { - t.Fatal(err) - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - return privateKey, server -} - -func signTestJWT( - t *testing.T, - privateKey *rsa.PrivateKey, - issuer, audience string, - claims map[string]any, - subject string, -) string { - t.Helper() - builder := jwt.NewBuilder(). - Issuer(issuer + "/"). - Audience([]string{audience}). - Subject(subject). - IssuedAt(time.Now()). - Expiration(time.Now().Add(5 * time.Minute)) - for k, v := range claims { - builder = builder.Claim(k, v) - } - tok, err := builder.Build() - if err != nil { - t.Fatal(err) - } - headers := jws.NewHeaders() - _ = headers.Set(jws.KeyIDKey, "k") - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) - if err != nil { - t.Fatal(err) - } - return string(signed) -} diff --git a/openmeter-collector/builder-api/internal/httpapi/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go index 10f636f..9257ce5 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -125,9 +125,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { } if _, err := s.openmeter.ProvisionSession(ctx, openmeter.ProvisionConfig{ - DefaultPlanKey: s.cfg.OpenMeterDefaultPlanKey, - TrialFeatureKey: s.cfg.OpenMeterTrialFeatureKey, - DefaultStarterIncludedMicros: s.cfg.OpenMeterDefaultStarterIncludedUsdMicros, + DefaultPlanKey: s.cfg.OpenMeterDefaultPlanKey, }, clientID, externalUserID); err != nil { writeAPIError(w, http.StatusBadGateway, "openmeter customer provisioning failed") return diff --git a/openmeter-collector/builder-api/internal/httpapi/token.go b/openmeter-collector/builder-api/internal/httpapi/token.go index 4465bb0..7d2b146 100644 --- a/openmeter-collector/builder-api/internal/httpapi/token.go +++ b/openmeter-collector/builder-api/internal/httpapi/token.go @@ -11,16 +11,14 @@ import ( ) type tokenExchangeResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope"` - BalanceUsdMicros string `json:"balanceUsdMicros"` - LifetimeGrantedUsdMicros string `json:"lifetimeGrantedUsdMicros"` - 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"` + 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"` } func (s *Server) handleOIDCToken(w http.ResponseWriter, r *http.Request) { @@ -80,15 +78,13 @@ func (s *Server) handleOIDCToken(w http.ResponseWriter, r *http.Request) { } writeTokenJSON(w, http.StatusOK, tokenExchangeResponse{ - AccessToken: result.AccessToken, - TokenType: result.TokenType, - ExpiresIn: result.ExpiresIn, - Scope: result.Scope, - BalanceUsdMicros: result.BalanceUsdMicros, - LifetimeGrantedUsdMicros: result.LifetimeGrantedUsdMicros, - SignerURL: result.SignerURL, - DiscoveryURL: result.DiscoveryURL, - IssuedTokenType: result.IssuedTokenType, - CorrelationID: result.CorrelationID, + AccessToken: result.AccessToken, + TokenType: result.TokenType, + ExpiresIn: result.ExpiresIn, + Scope: result.Scope, + SignerURL: result.SignerURL, + DiscoveryURL: result.DiscoveryURL, + IssuedTokenType: result.IssuedTokenType, + CorrelationID: result.CorrelationID, }) } diff --git a/openmeter-collector/builder-api/internal/httpapi/token_test.go b/openmeter-collector/builder-api/internal/httpapi/token_test.go index 779b404..6ade5c6 100644 --- a/openmeter-collector/builder-api/internal/httpapi/token_test.go +++ b/openmeter-collector/builder-api/internal/httpapi/token_test.go @@ -41,11 +41,6 @@ func (s stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionCo return &openmeter.SessionProvision{ Customer: &openmeter.Customer{}, CustomerKey: "pub-client:demo-user", - Balance: openmeter.TrialCreditBalance{ - HasAccess: true, - BalanceUsdMicros: "5000000", - LifetimeGrantedUsdMicros: "5000000", - }, }, nil } diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier.go b/openmeter-collector/builder-api/internal/oidcverify/verifier.go deleted file mode 100644 index 57604e1..0000000 --- a/openmeter-collector/builder-api/internal/oidcverify/verifier.go +++ /dev/null @@ -1,189 +0,0 @@ -package oidcverify - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" -) - -// Options configures claim extraction and scope requirements for incoming user JWTs. -type Options struct { - ClientClaim string - SubjectClaim string - RequiredScopes []string -} - -func (o Options) withDefaults() Options { - if strings.TrimSpace(o.ClientClaim) == "" { - o.ClientClaim = "azp" - } - if strings.TrimSpace(o.SubjectClaim) == "" { - o.SubjectClaim = "sub" - } - if len(o.RequiredScopes) == 0 { - o.RequiredScopes = []string{"sign:job"} - } - return o -} - -// VerifiedUser is an end-user identity extracted from an Auth0 access token. -type VerifiedUser struct { - ClientID string - ExternalUserID string -} - -// Verifier validates Auth0 end-user access tokens (device code, authorization code). -type Verifier struct { - issuer string - audience string - opts Options - cache *jwk.Cache - jwksURL string -} - -// New creates a JWKS-cached OIDC access-token verifier. -func New(ctx context.Context, issuer, audience string, opts Options) (*Verifier, error) { - issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") - audience = strings.TrimSpace(audience) - if issuer == "" { - return nil, fmt.Errorf("oidcverify: issuer is required") - } - if audience == "" { - return nil, fmt.Errorf("oidcverify: audience is required") - } - - jwksURL := issuer + "/.well-known/jwks.json" - cache := jwk.NewCache(ctx) - if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { - return nil, fmt.Errorf("oidcverify: register jwks: %w", err) - } - - return &Verifier{ - issuer: issuer, - audience: audience, - opts: opts.withDefaults(), - cache: cache, - jwksURL: jwksURL, - }, nil -} - -// NewWithJWKSURL creates a verifier that loads keys from an explicit JWKS URL (tests or OIDC_JWKS_URI overrides). -func NewWithJWKSURL(ctx context.Context, issuer, audience, jwksURL string, opts Options) (*Verifier, error) { - issuer = strings.TrimSuffix(strings.TrimSpace(issuer), "/") - audience = strings.TrimSpace(audience) - jwksURL = strings.TrimSpace(jwksURL) - if issuer == "" { - return nil, fmt.Errorf("oidcverify: issuer is required") - } - if audience == "" { - return nil, fmt.Errorf("oidcverify: audience is required") - } - if jwksURL == "" { - return nil, fmt.Errorf("oidcverify: jwksURL is required") - } - - cache := jwk.NewCache(ctx) - if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil { - return nil, fmt.Errorf("oidcverify: register jwks: %w", err) - } - - return &Verifier{ - issuer: issuer, - audience: audience, - opts: opts.withDefaults(), - cache: cache, - jwksURL: jwksURL, - }, nil -} - -// VerifyUserAccessToken validates a bearer JWT and returns tenant + end-user ids. -func (v *Verifier) VerifyUserAccessToken(ctx context.Context, token, expectedClientID string) (*VerifiedUser, error) { - token = strings.TrimSpace(token) - if token == "" { - return nil, fmt.Errorf("missing access token") - } - if strings.Count(token, ".") != 2 { - return nil, fmt.Errorf("not a JWT") - } - - keySet, err := v.cache.Get(ctx, v.jwksURL) - if err != nil { - return nil, fmt.Errorf("load jwks: %w", err) - } - - parsed, err := jwt.ParseString( - token, - jwt.WithKeySet(keySet), - jwt.WithAudience(v.audience), - ) - if err != nil { - return nil, fmt.Errorf("jwt verification failed: %w", err) - } - if normalizeIssuer(parsed.Issuer()) != normalizeIssuer(v.issuer) { - return nil, fmt.Errorf("jwt verification failed: iss not satisfied") - } - - for _, scope := range v.opts.RequiredScopes { - if err := requireScope(parsed, scope); err != nil { - return nil, err - } - } - - clientID := claimString(parsed, v.opts.ClientClaim) - if clientID == "" { - clientID = claimString(parsed, "azp") - } - if clientID == "" { - return nil, fmt.Errorf("token missing %s claim", v.opts.ClientClaim) - } - expectedClientID = strings.TrimSpace(expectedClientID) - if expectedClientID != "" && clientID != expectedClientID { - return nil, fmt.Errorf("token azp does not match clientId") - } - - externalUserID := claimString(parsed, v.opts.SubjectClaim) - if externalUserID == "" { - externalUserID = claimString(parsed, "external_user_id") - } - if externalUserID == "" { - externalUserID = parsed.Subject() - } - if externalUserID == "" { - return nil, fmt.Errorf("token missing %s claim", v.opts.SubjectClaim) - } - - return &VerifiedUser{ - ClientID: clientID, - ExternalUserID: externalUserID, - }, nil -} - -func requireScope(tok jwt.Token, required string) error { - scopeRaw, ok := tok.Get("scope") - if !ok { - return fmt.Errorf("missing required scope: %s", required) - } - granted := strings.Fields(fmt.Sprint(scopeRaw)) - for _, s := range granted { - if s == required { - return nil - } - } - return fmt.Errorf("missing required scope: %s", required) -} - -func claimString(tok jwt.Token, name string) string { - raw, ok := tok.Get(name) - if !ok || raw == nil { - return "" - } - return strings.TrimSpace(fmt.Sprint(raw)) -} - -func normalizeIssuer(issuer string) string { - return strings.TrimSuffix(strings.TrimSpace(issuer), "/") -} diff --git a/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go b/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go deleted file mode 100644 index 3d24777..0000000 --- a/openmeter-collector/builder-api/internal/oidcverify/verifier_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package oidcverify - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" -) - -func TestVerifyUserAccessToken(t *testing.T) { - t.Parallel() - - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - - publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) - if err != nil { - t.Fatal(err) - } - _ = publicJWK.Set(jwk.KeyIDKey, "test-key") - _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) - keySet := jwk.NewSet() - _ = keySet.AddKey(publicJWK) - - jwksBody, err := json.Marshal(keySet) - if err != nil { - t.Fatal(err) - } - - issuer := "https://idp.test" - audience := "livepeer-clearinghouse" - clientID := "pub-client" - subject := "google-oauth2|105691875604954324733" - - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) - - // Point issuer at test server path for JWKS only. - verifier := &Verifier{ - issuer: issuer, - audience: audience, - opts: Options{}.withDefaults(), - cache: jwk.NewCache(context.Background()), - jwksURL: jwksServer.URL, - } - if err := verifier.cache.Register(jwksServer.URL, jwk.WithMinRefreshInterval(time.Hour)); err != nil { - t.Fatal(err) - } - - token := buildTestToken(t, privateKey, issuer, audience, clientID, subject, "sign:job openid") - - user, err := verifier.VerifyUserAccessToken(context.Background(), token, clientID) - if err != nil { - t.Fatalf("verify: %v", err) - } - if user.ClientID != clientID { - t.Fatalf("clientID = %q", user.ClientID) - } - if user.ExternalUserID != subject { - t.Fatalf("externalUserID = %q", user.ExternalUserID) - } - - if _, err := verifier.VerifyUserAccessToken(context.Background(), token, "other-client"); err == nil { - t.Fatal("expected azp mismatch error") - } - - badScope := buildTestToken(t, privateKey, issuer, audience, clientID, subject, "openid") - if _, err := verifier.VerifyUserAccessToken(context.Background(), badScope, clientID); err == nil { - t.Fatal("expected missing scope error") - } -} - -func TestVerifyUserAccessTokenClaimFallbacks(t *testing.T) { - t.Parallel() - - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - - publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) - if err != nil { - t.Fatal(err) - } - _ = publicJWK.Set(jwk.KeyIDKey, "test-key") - _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) - keySet := jwk.NewSet() - _ = keySet.AddKey(publicJWK) - - jwksBody, err := json.Marshal(keySet) - if err != nil { - t.Fatal(err) - } - - issuer := "https://idp.test" - audience := "livepeer-clearinghouse" - clientID := "pub-client" - - jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - t.Cleanup(jwksServer.Close) - - verifier := &Verifier{ - issuer: issuer, - audience: audience, - opts: Options{ - ClientClaim: "app_client_id", - SubjectClaim: "external_user_id", - RequiredScopes: []string{"sign:job"}, - }.withDefaults(), - cache: jwk.NewCache(context.Background()), - jwksURL: jwksServer.URL, - } - if err := verifier.cache.Register(jwksServer.URL, jwk.WithMinRefreshInterval(time.Hour)); err != nil { - t.Fatal(err) - } - - token := buildTestTokenWithClaims( - t, - privateKey, - issuer, - audience, - map[string]any{ - "azp": clientID, - "external_user_id": "ext-user-1", - "scope": "sign:job", - }, - "ignored-sub", - ) - - user, err := verifier.VerifyUserAccessToken(context.Background(), token, clientID) - if err != nil { - t.Fatalf("verify: %v", err) - } - if user.ClientID != clientID { - t.Fatalf("clientID = %q, want azp fallback", user.ClientID) - } - if user.ExternalUserID != "ext-user-1" { - t.Fatalf("externalUserID = %q", user.ExternalUserID) - } -} - -func buildTestTokenWithClaims( - t *testing.T, - privateKey *rsa.PrivateKey, - issuer, audience string, - claims map[string]any, - subject string, -) string { - t.Helper() - builder := jwt.NewBuilder(). - Issuer(issuer + "/"). - Audience([]string{audience}). - Subject(subject). - IssuedAt(time.Now()). - Expiration(time.Now().Add(5 * time.Minute)) - for k, v := range claims { - builder = builder.Claim(k, v) - } - tok, err := builder.Build() - if err != nil { - t.Fatal(err) - } - headers := jws.NewHeaders() - _ = headers.Set(jws.KeyIDKey, "test-key") - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) - if err != nil { - t.Fatal(err) - } - return string(signed) -} - -func buildTestToken( - t *testing.T, - privateKey *rsa.PrivateKey, - issuer, audience, clientID, subject, scope string, -) string { - t.Helper() - tok, err := jwt.NewBuilder(). - Issuer(issuer + "/"). - Audience([]string{audience}). - Subject(subject). - Claim("azp", clientID). - Claim("scope", scope). - IssuedAt(time.Now()). - Expiration(time.Now().Add(5 * time.Minute)). - Build() - if err != nil { - t.Fatal(err) - } - headers := jws.NewHeaders() - _ = headers.Set(jws.KeyIDKey, "test-key") - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) - if err != nil { - t.Fatal(err) - } - return string(signed) -} diff --git a/openmeter-collector/builder-api/internal/openmeter/allowance.go b/openmeter-collector/builder-api/internal/openmeter/allowance.go deleted file mode 100644 index 0069985..0000000 --- a/openmeter-collector/builder-api/internal/openmeter/allowance.go +++ /dev/null @@ -1,191 +0,0 @@ -package openmeter - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" -) - -type entitlementValue struct { - HasAccess bool `json:"hasAccess"` - Balance float64 `json:"balance"` - Usage float64 `json:"usage"` - TotalAvailableGrantAmount float64 `json:"totalAvailableGrantAmount"` -} - -type createGrantRequest struct { - Amount int64 `json:"amount"` - Priority int `json:"priority"` - EffectiveAt time.Time `json:"effectiveAt"` - Expiration grantExpiration `json:"expiration"` -} - -type grantExpiration struct { - Duration string `json:"duration"` - Count int `json:"count"` -} - -// GetTrialCreditBalance reads the user-scoped trial allowance for a customer key. -func (c *Client) GetTrialCreditBalance(ctx context.Context, customerKey, featureKey string, defaultGrantMicros int64) (TrialCreditBalance, error) { - featureKey = strings.TrimSpace(featureKey) - if featureKey == "" { - featureKey = "network_spend" - } - - value, err := c.getEntitlementValue(ctx, customerKey, featureKey) - if err != nil { - return TrialCreditBalance{ - HasAccess: false, - BalanceUsdMicros: "0", - ConsumedUsdMicros: "0", - LifetimeGrantedUsdMicros: "0", - }, nil - } - - balance := int64(max(0, value.Balance)) - usage := int64(max(0, value.Usage)) - granted := int64(max(0, value.TotalAvailableGrantAmount)) - if granted == 0 { - granted = balance + usage - } - if granted == 0 && defaultGrantMicros > 0 { - granted = defaultGrantMicros - } - - return TrialCreditBalance{ - HasAccess: value.HasAccess && balance > 0, - BalanceUsdMicros: strconv.FormatInt(balance, 10), - ConsumedUsdMicros: strconv.FormatInt(usage, 10), - LifetimeGrantedUsdMicros: strconv.FormatInt(granted, 10), - }, nil -} - -// GetTrialCreditBalanceWithFallback returns entitlement balance when available, -// otherwise falls back to the configured starter grant for Konnect-style deployments. -func (c *Client) GetTrialCreditBalanceWithFallback(ctx context.Context, customerKey, featureKey string, defaultGrantMicros int64) (TrialCreditBalance, error) { - balance, err := c.GetTrialCreditBalance(ctx, customerKey, featureKey, defaultGrantMicros) - if err != nil { - return balance, err - } - if balance.HasAccess || defaultGrantMicros <= 0 { - return balance, nil - } - - if _, entErr := c.getEntitlementValue(ctx, customerKey, featureKey); entErr == nil { - return balance, nil - } - - defaultGrant := strconv.FormatInt(defaultGrantMicros, 10) - return TrialCreditBalance{ - HasAccess: true, - BalanceUsdMicros: defaultGrant, - ConsumedUsdMicros: "0", - LifetimeGrantedUsdMicros: defaultGrant, - }, nil -} - -// EnsureTrialGrant creates a one-year trial grant when the customer has no allowance yet. -func (c *Client) EnsureTrialGrant(ctx context.Context, customerKey, featureKey string, amountMicros int64) error { - if amountMicros <= 0 { - return nil - } - featureKey = strings.TrimSpace(featureKey) - if featureKey == "" { - featureKey = "network_spend" - } - - balance, err := c.GetTrialCreditBalance(ctx, customerKey, featureKey, amountMicros) - if err != nil { - return err - } - if balance.HasAccess { - return nil - } - - payload := createGrantRequest{ - Amount: amountMicros, - Priority: 1, - EffectiveAt: time.Now().UTC(), - Expiration: grantExpiration{Duration: "YEAR", Count: 1}, - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - - path := fmt.Sprintf("%s/customers/%s/entitlements/%s/grants", - c.baseURL, - urlPathEscape(customerKey), - urlPathEscape(featureKey), - ) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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 grant: %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 grant %d: %s", resp.StatusCode, string(respBody)) - } - return nil -} - -func (c *Client) getEntitlementValue(ctx context.Context, customerKey, featureKey string) (*entitlementValue, error) { - path := fmt.Sprintf("%s/customers/%s/entitlements/%s/value", - c.baseURL, - urlPathEscape(customerKey), - urlPathEscape(featureKey), - ) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, 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 value: %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 entitlement value %d: %s", resp.StatusCode, string(respBody)) - } - - var value entitlementValue - if err := json.Unmarshal(respBody, &value); err != nil { - return nil, err - } - return &value, nil -} - -func urlPathEscape(value string) string { - return strings.ReplaceAll(value, ":", "%3A") -} - -func max(a, b float64) float64 { - if a > b { - return a - } - return b -} diff --git a/openmeter-collector/builder-api/internal/openmeter/provision.go b/openmeter-collector/builder-api/internal/openmeter/provision.go index 6ee2e53..3facf89 100644 --- a/openmeter-collector/builder-api/internal/openmeter/provision.go +++ b/openmeter-collector/builder-api/internal/openmeter/provision.go @@ -2,7 +2,7 @@ package openmeter import "context" -// ProvisionSession upserts customer, default subscription, trial grant, and returns allowance. +// 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) @@ -15,19 +15,8 @@ func (c *Client) ProvisionSession(ctx context.Context, cfg ProvisionConfig, clie return nil, err } - if err := c.EnsureTrialGrant(ctx, customerKey, cfg.TrialFeatureKey, cfg.DefaultStarterIncludedMicros); err != nil { - // Konnect may not support explicit grants; continue with subscription-only allowance. - _ = err - } - - balance, err := c.GetTrialCreditBalanceWithFallback(ctx, customerKey, cfg.TrialFeatureKey, cfg.DefaultStarterIncludedMicros) - if err != nil { - return nil, err - } - return &SessionProvision{ Customer: customer, CustomerKey: customerKey, - Balance: balance, }, nil } diff --git a/openmeter-collector/builder-api/internal/openmeter/provision_test.go b/openmeter-collector/builder-api/internal/openmeter/provision_test.go index eff27fa..c05d0af 100644 --- a/openmeter-collector/builder-api/internal/openmeter/provision_test.go +++ b/openmeter-collector/builder-api/internal/openmeter/provision_test.go @@ -2,10 +2,8 @@ package openmeter_test import ( "context" - "encoding/json" "net/http" "net/http/httptest" - "strings" "testing" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" @@ -19,13 +17,12 @@ func TestCustomerKey(t *testing.T) { } } -func TestProvisionSessionCreatesCustomerSubscriptionAndGrant(t *testing.T) { +func TestProvisionSessionCreatesCustomerAndSubscription(t *testing.T) { t.Parallel() var ( - customerCreated bool + customerCreated bool subscriptionCreated bool - grantCreated bool ) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -39,27 +36,11 @@ func TestProvisionSessionCreatesCustomerSubscriptionAndGrant(t *testing.T) { _, _ = 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") - if subscriptionCreated { - _, _ = w.Write([]byte(`{"data":[{"id":"sub-1","customer_id":"cust-1","status":"active"}]}`)) - return - } _, _ = 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"}`)) - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/value"): - w.Header().Set("Content-Type", "application/json") - if grantCreated { - _, _ = w.Write([]byte(`{"hasAccess":true,"balance":5000000,"usage":0,"totalAvailableGrantAmount":5000000}`)) - return - } - w.WriteHeader(http.StatusNotFound) - return - case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/grants"): - grantCreated = true - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"id":"grant-1"}`)) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } @@ -68,9 +49,7 @@ func TestProvisionSessionCreatesCustomerSubscriptionAndGrant(t *testing.T) { client := openmeter.New(server.URL, "test-key") result, err := client.ProvisionSession(context.Background(), openmeter.ProvisionConfig{ - DefaultPlanKey: "clearinghouse_default_ppu", - TrialFeatureKey: "network_spend", - DefaultStarterIncludedMicros: 5_000_000, + DefaultPlanKey: "clearinghouse_default_ppu", }, "pub-client", "demo-user") if err != nil { t.Fatal(err) @@ -78,14 +57,8 @@ func TestProvisionSessionCreatesCustomerSubscriptionAndGrant(t *testing.T) { if result.CustomerKey != "pub-client:demo-user" { t.Fatalf("customer key = %q", result.CustomerKey) } - if !customerCreated || !subscriptionCreated || !grantCreated { - t.Fatalf("customer=%v subscription=%v grant=%v", customerCreated, subscriptionCreated, grantCreated) - } - if !result.Balance.HasAccess { - t.Fatalf("expected access, got %+v", result.Balance) - } - if result.Balance.BalanceUsdMicros != "5000000" { - t.Fatalf("balance = %q", result.Balance.BalanceUsdMicros) + if !customerCreated || !subscriptionCreated { + t.Fatalf("customer=%v subscription=%v", customerCreated, subscriptionCreated) } } @@ -100,9 +73,6 @@ func TestProvisionSessionReusesExistingCustomer(t *testing.T) { 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"}]}`)) - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/entitlements/network_spend/value"): - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"hasAccess":true,"balance":1000000,"usage":4000000,"totalAvailableGrantAmount":5000000}`)) default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } @@ -111,56 +81,12 @@ func TestProvisionSessionReusesExistingCustomer(t *testing.T) { client := openmeter.New(server.URL, "test-key") result, err := client.ProvisionSession(context.Background(), openmeter.ProvisionConfig{ - DefaultPlanKey: "clearinghouse_default_ppu", - TrialFeatureKey: "network_spend", - DefaultStarterIncludedMicros: 5_000_000, + DefaultPlanKey: "clearinghouse_default_ppu", }, "pub-client", "demo-user") if err != nil { t.Fatal(err) } - if result.Balance.BalanceUsdMicros != "1000000" { - t.Fatalf("balance = %q", result.Balance.BalanceUsdMicros) - } -} - -func TestGetTrialCreditBalanceFallsBackToConfiguredGrant(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - t.Cleanup(server.Close) - - client := openmeter.New(server.URL, "test-key") - balance, err := client.GetTrialCreditBalanceWithFallback(context.Background(), "pub-client:demo-user", "network_spend", 5_000_000) - if err != nil { - t.Fatal(err) - } - if !balance.HasAccess || balance.BalanceUsdMicros != "5000000" { - t.Fatalf("balance = %+v", balance) - } -} - -func TestGetTrialCreditBalanceExhausted(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "hasAccess": false, - "balance": 0, - "usage": 5_000_000, - "totalAvailableGrantAmount": 5_000_000, - }) - })) - t.Cleanup(server.Close) - - client := openmeter.New(server.URL, "test-key") - balance, err := client.GetTrialCreditBalance(context.Background(), "pub-client:demo-user", "network_spend", 5_000_000) - if err != nil { - t.Fatal(err) - } - if balance.HasAccess { - t.Fatalf("expected exhausted balance, got %+v", balance) + if result.Customer.ID != "cust-1" { + t.Fatalf("customer id = %q", result.Customer.ID) } } diff --git a/openmeter-collector/builder-api/internal/openmeter/types.go b/openmeter-collector/builder-api/internal/openmeter/types.go index 1f09d28..539fa34 100644 --- a/openmeter-collector/builder-api/internal/openmeter/types.go +++ b/openmeter-collector/builder-api/internal/openmeter/types.go @@ -1,23 +1,12 @@ package openmeter -// TrialCreditBalance is the user-scoped allowance snapshot for session minting. -type TrialCreditBalance struct { - HasAccess bool - BalanceUsdMicros string - ConsumedUsdMicros string - LifetimeGrantedUsdMicros string -} - // SessionProvision is the result of no-database OpenMeter provisioning for exchange. type SessionProvision struct { Customer *Customer CustomerKey string - Balance TrialCreditBalance } -// ProvisionConfig controls subscription and allowance provisioning. +// ProvisionConfig controls default-plan subscription provisioning. type ProvisionConfig struct { - DefaultPlanKey string - TrialFeatureKey string - DefaultStarterIncludedMicros int64 + DefaultPlanKey string } diff --git a/openmeter-collector/builder-api/internal/tokenexchange/errors.go b/openmeter-collector/builder-api/internal/tokenexchange/errors.go index 35b8965..00a8b62 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/errors.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/errors.go @@ -53,10 +53,6 @@ func serverError(description string) *Error { return newError("server_error", description, description, 500) } -func insufficientAllowance(description string) *Error { - return newError("insufficient_allowance", description, description, 402) -} - 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 index a860cbb..5a15a80 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler.go @@ -7,7 +7,6 @@ import ( 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/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" ) @@ -26,16 +25,14 @@ type Request struct { // Result is a signer-session token exchange response. type Result struct { - AccessToken string - TokenType string - ExpiresIn int - Scope string - BalanceUsdMicros string - LifetimeGrantedUsdMicros string - SignerURL string - DiscoveryURL string - IssuedTokenType string - CorrelationID string + AccessToken string + TokenType string + ExpiresIn int + Scope string + SignerURL string + DiscoveryURL string + IssuedTokenType string + CorrelationID string } // SignerMinter mints short-lived signer JWTs. @@ -43,15 +40,21 @@ type SignerMinter interface { MintSignerToken(ctx context.Context, publicClientID, externalUserID string) (*auth0mint.TokenResponse, error) } -// SessionProvisioner upserts OpenMeter customer, subscription, and allowance. +// 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 - oidc *oidcverify.Verifier + verifier UserTokenVerifier apiKeys *apikey.Store minter SignerMinter openmeter SessionProvisioner @@ -60,14 +63,14 @@ type Handler struct { // NewHandler constructs a token exchange handler. func NewHandler( cfg config.Config, - oidc *oidcverify.Verifier, + verifier UserTokenVerifier, apiKeys *apikey.Store, minter SignerMinter, om SessionProvisioner, ) *Handler { return &Handler{ cfg: cfg, - oidc: oidc, + verifier: verifier, apiKeys: apiKeys, minter: minter, openmeter: om, @@ -106,13 +109,9 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin return nil, err } - provision, err := h.openmeter.ProvisionSession(ctx, h.provisionConfig(), clientID, externalUserID) - if err != nil { + if _, err := h.openmeter.ProvisionSession(ctx, h.provisionConfig(), clientID, externalUserID); err != nil { return nil, wrapServerError(err) } - if !provision.Balance.HasAccess { - return nil, insufficientAllowance("trial credits exhausted") - } minted, err := h.minter.MintSignerToken(ctx, clientID, externalUserID) if err != nil { @@ -125,14 +124,12 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin } result := &Result{ - AccessToken: minted.AccessToken, - TokenType: "Bearer", - ExpiresIn: minted.ExpiresIn, - Scope: scope, - BalanceUsdMicros: provision.Balance.BalanceUsdMicros, - LifetimeGrantedUsdMicros: provision.Balance.LifetimeGrantedUsdMicros, - IssuedTokenType: IssuedAccessTokenType, - CorrelationID: correlationID, + AccessToken: minted.AccessToken, + TokenType: "Bearer", + ExpiresIn: minted.ExpiresIn, + Scope: scope, + IssuedTokenType: IssuedAccessTokenType, + CorrelationID: correlationID, } if h.cfg.SignerURL != "" { result.SignerURL = h.cfg.SignerURL @@ -145,9 +142,7 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin func (h *Handler) provisionConfig() openmeter.ProvisionConfig { return openmeter.ProvisionConfig{ - DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, - TrialFeatureKey: h.cfg.OpenMeterTrialFeatureKey, - DefaultStarterIncludedMicros: h.cfg.OpenMeterDefaultStarterIncludedUsdMicros, + DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, } } @@ -209,14 +204,14 @@ func (h *Handler) validateTarget(resource string, audiences []string) error { 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.oidc == nil { + if h.verifier == nil { return "", "", invalidGrant("subject_token is not a valid access token for this issuer") } - verified, verifyErr := h.oidc.VerifyUserAccessToken(ctx, subjectToken, publicClientID) + 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 verified.ClientID, verified.ExternalUserID, nil + return verifiedClientID, verifiedUserID, nil } if !strings.HasPrefix(subjectToken, h.cfg.APIKeyPrefix) { diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go index 39a6156..d25d4e5 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go @@ -2,22 +2,12 @@ package tokenexchange_test import ( "context" - "crypto/rand" - "crypto/rsa" - "encoding/json" - "net/http" - "net/http/httptest" + "errors" "testing" - "time" - "github.com/lestrrat-go/jwx/v2/jwa" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jws" - "github.com/lestrrat-go/jwx/v2/jwt" 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/oidcverify" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/openmeter" "github.com/livepeer/clearinghouse/openmeter-collector/builder-api/internal/tokenexchange" ) @@ -35,9 +25,8 @@ func (s stubMinter) MintSignerToken(_ context.Context, _, _ string) (*auth0mint. } type stubProvisioner struct { - provision *openmeter.SessionProvision - err error - calls int + err error + calls int } func (s *stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionConfig, string, string) (*openmeter.SessionProvision, error) { @@ -45,34 +34,42 @@ func (s *stubProvisioner) ProvisionSession(context.Context, openmeter.ProvisionC if s.err != nil { return nil, s.err } - if s.provision != nil { - return s.provision, nil - } return &openmeter.SessionProvision{ Customer: &openmeter.Customer{ID: "cust-1", Key: "pub-client:demo-user"}, CustomerKey: "pub-client:demo-user", - Balance: openmeter.TrialCreditBalance{ - HasAccess: true, - BalanceUsdMicros: "5000000", - ConsumedUsdMicros: "0", - LifetimeGrantedUsdMicros: "5000000", - }, }, nil } -func testHandler(t *testing.T, oidc *oidcverify.Verifier) *tokenexchange.Handler { +// 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() cfg := config.Config{ - Auth0Audience: "livepeer-clearinghouse", - SignerM2MClientID: "m2m-client", - SignerM2MSecret: "m2m-secret", - APIKeyPrefix: "sk_", - SignerURL: "http://localhost:8081", - DiscoveryURL: "http://localhost/discovery", + Auth0Audience: "livepeer-clearinghouse", + SignerM2MClientID: "m2m-client", + SignerM2MSecret: "m2m-secret", + APIKeyPrefix: "sk_", + SignerURL: "http://localhost:8081", + DiscoveryURL: "http://localhost/discovery", } return tokenexchange.NewHandler( cfg, - oidc, + verifier, &apikey.Store{ Prefix: "sk_", Demo: map[string]apikey.DemoEntry{ @@ -182,46 +179,6 @@ func TestExchangeAPIKeyHappyPathWithoutM2M(t *testing.T) { if result.AccessToken != "minted-jwt" { t.Fatalf("access_token = %q", result.AccessToken) } - if result.BalanceUsdMicros != "5000000" { - t.Fatalf("balance = %q", result.BalanceUsdMicros) - } -} - -func TestExchangeRejectsExhaustedAllowance(t *testing.T) { - t.Parallel() - 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"}}, - &stubProvisioner{provision: &openmeter.SessionProvision{ - CustomerKey: "pub-client:demo-user", - Balance: openmeter.TrialCreditBalance{ - HasAccess: false, - BalanceUsdMicros: "0", - ConsumedUsdMicros: "5000000", - }, - }}, - ) - _, 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) - } } func TestExchangeRejectsClientMismatch(t *testing.T) { @@ -305,28 +262,14 @@ func TestExchangeAPIKeyHappyPath(t *testing.T) { func TestExchangeJWTHappyPath(t *testing.T) { t.Parallel() - privateKey, jwksServer := testJWKS(t) - t.Cleanup(jwksServer.Close) - - verifier, err := oidcverify.NewWithJWKSURL( - context.Background(), - "https://idp.test", - "livepeer-clearinghouse", - jwksServer.URL, - oidcverify.Options{RequiredScopes: []string{"sign:job"}}, - ) - if err != nil { - t.Fatal(err) - } - - token := signJWT(t, privateKey, "pub-client", "user-1") + 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: token, + SubjectToken: "header.payload.signature", SubjectTokenType: tokenexchange.SubjectAccessTokenType, Resource: "livepeer-clearinghouse", }, "corr-2") @@ -338,50 +281,17 @@ func TestExchangeJWTHappyPath(t *testing.T) { } } -func testJWKS(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { - t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal(err) - } - publicJWK, err := jwk.FromRaw(&privateKey.PublicKey) - if err != nil { - t.Fatal(err) - } - _ = publicJWK.Set(jwk.KeyIDKey, "k") - _ = publicJWK.Set(jwk.AlgorithmKey, jwa.RS256) - keySet := jwk.NewSet() - _ = keySet.AddKey(publicJWK) - jwksBody, err := json.Marshal(keySet) - if err != nil { - t.Fatal(err) - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jwksBody) - })) - return privateKey, server -} - -func signJWT(t *testing.T, privateKey *rsa.PrivateKey, clientID, subject string) string { - t.Helper() - tok, err := jwt.NewBuilder(). - Issuer("https://idp.test/"). - Audience([]string{"livepeer-clearinghouse"}). - Subject(subject). - Claim("azp", clientID). - Claim("scope", "sign:job"). - IssuedAt(time.Now()). - Expiration(time.Now().Add(5 * time.Minute)). - Build() - if err != nil { - t.Fatal(err) - } - headers := jws.NewHeaders() - _ = headers.Set(jws.KeyIDKey, "k") - signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, privateKey, jws.WithProtectedHeaders(headers))) - if err != nil { - t.Fatal(err) +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) } - return string(signed) } 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 +} From becbc97fd4136df3abe7d4c5b858db96c5dc3808 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 1 Jul 2026 13:37:37 -0400 Subject: [PATCH 11/14] feat(openmeter-collector): enhance entrypoint script and docker-compose configuration - Added support for loading multiple environment files in the entrypoint script, improving flexibility for configuration management. - Updated docker-compose.yml to expose port 8095 and include a new environment file for the auth0-provisioner, facilitating better integration and service communication. - Implemented cleanup logic in the entrypoint script to manage child processes effectively, ensuring graceful shutdowns. --- docker-compose.yml | 3 ++ openmeter-collector/entrypoint.sh | 75 ++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5012e23..01d03eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,8 +72,11 @@ services: depends_on: kafka: condition: service_healthy + ports: + - "127.0.0.1:8095:8095" 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. diff --git a/openmeter-collector/entrypoint.sh b/openmeter-collector/entrypoint.sh index 3e36085..c1bf6f8 100644 --- a/openmeter-collector/entrypoint.sh +++ b/openmeter-collector/entrypoint.sh @@ -1,12 +1,27 @@ #!/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 @@ -14,6 +29,16 @@ if [ -z "${OPENMETER_URL:-}" ]; then fi 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" @@ -27,4 +52,40 @@ case "$base" in ;; esac -exec /usr/local/bin/benthos -c /config.yaml +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 + OPENMETER_URL="$openmeter_mgmt_url" /usr/local/bin/builder-api & + builder_pid=$! + else + echo "builder-api: skipped — AUTH0_MGMT_CLIENT_ID/SECRET not set (re-run auth0-provisioner/provision/bootstrap.sh)" >&2 + fi +fi + +# 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 From bcf5f5bb71ea488653131c02270da811eb0927a0 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 7 Jul 2026 12:37:10 -0400 Subject: [PATCH 12/14] fix(openmeter-collector): update subscription query parameter for customer ID - Modified the subscription listing function to use the correct bracketed filter format for the customer ID, ensuring accurate subscription retrieval from Konnect. - Added comments to clarify the necessity of this change for idempotency and correct filtering. --- .../builder-api/internal/openmeter/subscription.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openmeter-collector/builder-api/internal/openmeter/subscription.go b/openmeter-collector/builder-api/internal/openmeter/subscription.go index 7ddc90a..e839973 100644 --- a/openmeter-collector/builder-api/internal/openmeter/subscription.go +++ b/openmeter-collector/builder-api/internal/openmeter/subscription.go @@ -89,7 +89,9 @@ func (c *Client) listSubscriptions(ctx context.Context, customerID string) ([]su return nil, err } q := req.URL.Query() - q.Set("customer_id", customerID) + // 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) From c4a5434a4d3d14900bfbb3a913b30fd64cbd669c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 10 Jul 2026 21:50:01 -0400 Subject: [PATCH 13/14] feat(konnect-credentials): implement per-tenant Konnect organization binding and SPAT issuance - Introduced a new service for managing per-tenant Konnect organizations, allowing tenants to bind their organizations and issue Provisioner, Usage, and Ingest SPATs. - Added routes for binding organizations, issuing credentials, and managing catalog provisioning. - Implemented encryption for sensitive data at rest and ensured proper role assignments for system accounts. - Enhanced README documentation to provide clear instructions on usage and configuration for the new service. - Included tests to validate the functionality and isolation of tenant credentials. --- .env.example | 11 + README.md | 18 +- docker-compose.yml | 26 ++ identity-webhook/scripts/bootstrap-publish.sh | 24 + konnect-credentials/Dockerfile | 19 + konnect-credentials/README.md | 110 +++++ konnect-credentials/catalog.json | 83 ++++ konnect-credentials/catalog.mjs | 189 ++++++++ konnect-credentials/crypto.mjs | 56 +++ konnect-credentials/konnect-client.mjs | 179 ++++++++ konnect-credentials/package.json | 25 ++ konnect-credentials/provision.mjs | 137 ++++++ konnect-credentials/roles.mjs | 56 +++ konnect-credentials/routes.mjs | 409 ++++++++++++++++++ konnect-credentials/server.mjs | 116 +++++ konnect-credentials/store.mjs | 120 +++++ konnect-credentials/tests/isolation.test.mjs | 195 +++++++++ konnect-credentials/tests/roles.test.mjs | 35 ++ konnect-credentials/tests/store.test.mjs | 71 +++ openmeter-collector/collector.yaml | 50 ++- openmeter-collector/entrypoint.sh | 59 +-- 21 files changed, 1959 insertions(+), 29 deletions(-) create mode 100755 identity-webhook/scripts/bootstrap-publish.sh create mode 100644 konnect-credentials/Dockerfile create mode 100644 konnect-credentials/README.md create mode 100644 konnect-credentials/catalog.json create mode 100644 konnect-credentials/catalog.mjs create mode 100644 konnect-credentials/crypto.mjs create mode 100644 konnect-credentials/konnect-client.mjs create mode 100644 konnect-credentials/package.json create mode 100644 konnect-credentials/provision.mjs create mode 100644 konnect-credentials/roles.mjs create mode 100644 konnect-credentials/routes.mjs create mode 100644 konnect-credentials/server.mjs create mode 100644 konnect-credentials/store.mjs create mode 100644 konnect-credentials/tests/isolation.test.mjs create mode 100644 konnect-credentials/tests/roles.test.mjs create mode 100644 konnect-credentials/tests/store.test.mjs diff --git a/.env.example b/.env.example index ff7ed12..c3dd123 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,7 @@ SIGNER_REMOTE_DISCOVERY=0 # LIVE_AI_CAP_REPORT_INTERVAL= # --- openmeter-collector --- +# 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 @@ -85,3 +86,13 @@ OPENMETER_DEFAULT_PLAN_KEY=clearinghouse_default_ppu # 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) --- +# M2M secret for bind/credentials APIs and collector ingest lookup. +PLATFORM_API_SECRET=dev-platform-secret-change-me +# AES key material: 64-char hex, 32-byte base64, or passphrase (scrypt). +CREDENTIALS_ENCRYPTION_KEY=dev-credentials-encryption-change-me +# Compose sets KONNECT_CREDENTIALS_URL=http://konnect-credentials:8091 for the collector. +# 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/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/docker-compose.yml b/docker-compose.yml index 01d03eb..eb2e3e4 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: . @@ -74,9 +93,16 @@ services: condition: service_healthy ports: - "127.0.0.1:8095:8095" + konnect-credentials: + condition: service_healthy + 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/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/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..1f16cff --- /dev/null +++ b/konnect-credentials/README.md @@ -0,0 +1,110 @@ +# 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. + +## 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..ea8d798 --- /dev/null +++ b/konnect-credentials/routes.mjs @@ -0,0 +1,409 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createKonnectClient, ingestUrl, 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])); + } + } + + // 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 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..96e2125 --- /dev/null +++ b/konnect-credentials/tests/isolation.test.mjs @@ -0,0 +1,195 @@ +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); + }); + + 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/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 c1bf6f8..a862477 100644 --- a/openmeter-collector/entrypoint.sh +++ b/openmeter-collector/entrypoint.sh @@ -23,34 +23,39 @@ load_env "${ENV_LIVEPEER_FILE:-/service/.env.livepeer}" : "${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%/}" +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 + # 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 + # 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="" @@ -68,8 +73,12 @@ benthos_pid=$! if [ -x /usr/local/bin/builder-api ]; then if [ -n "${AUTH0_MGMT_CLIENT_ID:-}" ] && [ -n "${AUTH0_MGMT_CLIENT_SECRET:-}" ]; then - OPENMETER_URL="$openmeter_mgmt_url" /usr/local/bin/builder-api & - builder_pid=$! + if [ -n "$openmeter_mgmt_url" ]; then + OPENMETER_URL="$openmeter_mgmt_url" /usr/local/bin/builder-api & + builder_pid=$! + else + echo "builder-api: skipped — OPENMETER_URL unset (management API base required)" >&2 + fi else echo "builder-api: skipped — AUTH0_MGMT_CLIENT_ID/SECRET not set (re-run auth0-provisioner/provision/bootstrap.sh)" >&2 fi From 74b6180b34c143d63bc7e9953ba0e5501a12fbb3 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 10 Jul 2026 22:02:01 -0400 Subject: [PATCH 14/14] feat(openmeter-collector): enhance builder-api with OpenMeter session provisioning and trial management - Implemented a new session service in the builder-api to provision OpenMeter customers and subscriptions, including trial credits management. - Updated environment configuration to support trial feature keys and allowance enforcement. - Enhanced the token exchange process to check for trial credits before minting signer JWTs, returning appropriate error responses when allowances are exhausted. - Added new endpoints and updated existing ones to facilitate OpenMeter lookups and session provisioning. - Improved documentation to reflect changes in the provisioning logic and environment variable requirements. - Included comprehensive tests to validate the new session provisioning and trial grant functionalities. --- .env.example | 14 +- docker-compose.yml | 4 +- konnect-credentials/README.md | 10 + konnect-credentials/routes.mjs | 24 +- konnect-credentials/tests/isolation.test.mjs | 32 ++ openmeter-collector/builder-api/README.md | 56 ++- .../builder-api/cmd/builder-api/main.go | 12 +- .../builder-api/internal/config/config.go | 38 +- .../builder-api/internal/httpapi/server.go | 13 +- .../builder-api/internal/httpapi/token.go | 36 +- .../builder-api/internal/openmeter/access.go | 324 ++++++++++++++++++ .../internal/openmeter/access_test.go | 123 +++++++ .../internal/openmeter/resolver.go | 140 ++++++++ .../internal/openmeter/resolver_test.go | 55 +++ .../builder-api/internal/openmeter/session.go | 67 ++++ .../builder-api/internal/openmeter/types.go | 14 +- .../internal/tokenexchange/errors.go | 4 + .../internal/tokenexchange/handler.go | 47 ++- .../internal/tokenexchange/handler_test.go | 137 +++++++- 19 files changed, 1080 insertions(+), 70 deletions(-) create mode 100644 openmeter-collector/builder-api/internal/openmeter/access.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/access_test.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/resolver.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/resolver_test.go create mode 100644 openmeter-collector/builder-api/internal/openmeter/session.go diff --git a/.env.example b/.env.example index c3dd123..43cde0f 100644 --- a/.env.example +++ b/.env.example @@ -72,13 +72,17 @@ 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 @@ -87,12 +91,12 @@ OPENMETER_DEFAULT_PLAN_KEY=clearinghouse_default_ppu PRICE_ORACLE_URL=https://api.coinbase.com/v2/prices/ETH-USD/spot PRICE_ORACLE_REFRESH=5m -# --- konnect-credentials (+ collector multi-tenant ingest) --- -# M2M secret for bind/credentials APIs and collector ingest lookup. +# --- 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 (scrypt). +# 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 the collector. +# 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/docker-compose.yml b/docker-compose.yml index eb2e3e4..ee1d78c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,10 +91,10 @@ services: depends_on: kafka: condition: service_healthy - ports: - - "127.0.0.1:8095:8095" konnect-credentials: condition: service_healthy + ports: + - "127.0.0.1:8095:8095" environment: KONNECT_CREDENTIALS_URL: http://konnect-credentials:8091 volumes: diff --git a/konnect-credentials/README.md b/konnect-credentials/README.md index 1f16cff..662f63b 100644 --- a/konnect-credentials/README.md +++ b/konnect-credentials/README.md @@ -74,6 +74,16 @@ 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 | diff --git a/konnect-credentials/routes.mjs b/konnect-credentials/routes.mjs index ea8d798..c0bde3b 100644 --- a/konnect-credentials/routes.mjs +++ b/konnect-credentials/routes.mjs @@ -1,6 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createKonnectClient, ingestUrl, unwrapOne } from "./konnect-client.mjs"; +import { createKonnectClient, ingestUrl, openMeterBase, unwrapOne } from "./konnect-client.mjs"; import { bootstrapCatalog } from "./catalog.mjs"; import { provisionCredentials, rotateCredential } from "./provision.mjs"; @@ -39,6 +39,14 @@ export async function routeRequest(request, { } } + // 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$/); @@ -375,6 +383,20 @@ async function getIngest(store, clientId) { }, 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) { diff --git a/konnect-credentials/tests/isolation.test.mjs b/konnect-credentials/tests/isolation.test.mjs index 96e2125..e7882fc 100644 --- a/konnect-credentials/tests/isolation.test.mjs +++ b/konnect-credentials/tests/isolation.test.mjs @@ -129,6 +129,38 @@ describe("routes isolation and handoff", () => { 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 () => { diff --git a/openmeter-collector/builder-api/README.md b/openmeter-collector/builder-api/README.md index 5cad333..8b7e565 100644 --- a/openmeter-collector/builder-api/README.md +++ b/openmeter-collector/builder-api/README.md @@ -1,15 +1,32 @@ # Clearinghouse Builder API -Go HTTP service co-located in the `openmeter-collector` container. Provisions **Auth0 end-users**, **OpenMeter customers**, and mints **signer session JWTs** via Auth0. +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 + upsert OpenMeter customer | +| `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 @@ -136,6 +153,26 @@ curl -sS \ 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: @@ -145,6 +182,21 @@ Customers are upserted with: 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 diff --git a/openmeter-collector/builder-api/cmd/builder-api/main.go b/openmeter-collector/builder-api/cmd/builder-api/main.go index 4a4945a..c2065e9 100644 --- a/openmeter-collector/builder-api/cmd/builder-api/main.go +++ b/openmeter-collector/builder-api/cmd/builder-api/main.go @@ -35,7 +35,13 @@ func main() { } minter := auth0mint.New(cfg.Auth0Issuer, cfg.Auth0Audience, cfg.SignerM2MClientID, cfg.SignerM2MSecret) - omClient := openmeter.New(cfg.OpenMeterURL, cfg.OpenMeterAPIKey) + 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 @@ -55,9 +61,9 @@ func main() { Demo: demoKeys, Auth0: auth0Client, } - tokenHandler := tokenexchange.NewHandler(cfg, verifier, keyStore, minter, omClient) + tokenHandler := tokenexchange.NewHandler(cfg, verifier, keyStore, minter, session) - srv := httpapi.NewServer(cfg, auth0Client, minter, omClient, tokenHandler, openAPISpec) + srv := httpapi.NewServer(cfg, auth0Client, minter, session, tokenHandler, openAPISpec) server := &http.Server{ Addr: ":" + cfg.Port, Handler: srv.Handler(), diff --git a/openmeter-collector/builder-api/internal/config/config.go b/openmeter-collector/builder-api/internal/config/config.go index 063fc19..98b4b3f 100644 --- a/openmeter-collector/builder-api/internal/config/config.go +++ b/openmeter-collector/builder-api/internal/config/config.go @@ -21,6 +21,11 @@ type Config struct { OpenMeterURL string OpenMeterAPIKey string OpenMeterDefaultPlanKey string + KonnectCredentialsURL string + PlatformAPISecret string + OpenMeterTrialFeatureKey string + OpenMeterTrialGrantUSDMicros int64 + OpenMeterEnforceAllowance bool SignerURL string DiscoveryURL string APIKeyPrefix string @@ -46,6 +51,10 @@ func Load() (Config, error) { 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", @@ -57,6 +66,14 @@ func Load() (Config, error) { 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, "/") + "/" } @@ -78,8 +95,11 @@ func Load() (Config, error) { if cfg.SignerM2MSecret == "" { missing = append(missing, "AUTH0_SIGNER_M2M_CLIENT_SECRET") } - if cfg.OpenMeterAPIKey == "" { - missing = append(missing, "OPENMETER_API_KEY") + 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, ", ")) @@ -108,3 +128,17 @@ func firstEnv(keys ...string) string { 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/server.go b/openmeter-collector/builder-api/internal/httpapi/server.go index 9257ce5..f252bd0 100644 --- a/openmeter-collector/builder-api/internal/httpapi/server.go +++ b/openmeter-collector/builder-api/internal/httpapi/server.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "encoding/json" "io" "net/http" @@ -18,13 +19,17 @@ type Server struct { cfg config.Config auth0 *auth0mgmt.Client minter *auth0mint.Minter - openmeter *openmeter.Client + 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 *openmeter.Client, tokenExchange *tokenexchange.Handler, openAPISpec []byte) *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, @@ -125,7 +130,9 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { } if _, err := s.openmeter.ProvisionSession(ctx, openmeter.ProvisionConfig{ - DefaultPlanKey: s.cfg.OpenMeterDefaultPlanKey, + 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 diff --git a/openmeter-collector/builder-api/internal/httpapi/token.go b/openmeter-collector/builder-api/internal/httpapi/token.go index 7d2b146..0e9dea3 100644 --- a/openmeter-collector/builder-api/internal/httpapi/token.go +++ b/openmeter-collector/builder-api/internal/httpapi/token.go @@ -11,14 +11,16 @@ import ( ) 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"` + 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) { @@ -78,13 +80,15 @@ func (s *Server) handleOIDCToken(w http.ResponseWriter, r *http.Request) { } 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, + 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/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/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/types.go b/openmeter-collector/builder-api/internal/openmeter/types.go index 539fa34..4fedcbc 100644 --- a/openmeter-collector/builder-api/internal/openmeter/types.go +++ b/openmeter-collector/builder-api/internal/openmeter/types.go @@ -2,11 +2,17 @@ package openmeter // SessionProvision is the result of no-database OpenMeter provisioning for exchange. type SessionProvision struct { - Customer *Customer - CustomerKey string + Customer *Customer + CustomerKey string + HasAccess bool + BalanceUSDMicros int64 + BalanceSource string } -// ProvisionConfig controls default-plan subscription provisioning. +// ProvisionConfig controls default-plan subscription and trial grant provisioning. type ProvisionConfig struct { - DefaultPlanKey string + DefaultPlanKey string + TrialFeatureKey string + TrialGrantUSDMicros int64 + EnforceAllowance bool } diff --git a/openmeter-collector/builder-api/internal/tokenexchange/errors.go b/openmeter-collector/builder-api/internal/tokenexchange/errors.go index 00a8b62..514a477 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/errors.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/errors.go @@ -49,6 +49,10 @@ 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) } diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler.go b/openmeter-collector/builder-api/internal/tokenexchange/handler.go index 5a15a80..b76b853 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler.go @@ -25,14 +25,16 @@ type Request struct { // 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 + 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. @@ -109,9 +111,13 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin return nil, err } - if _, err := h.openmeter.ProvisionSession(ctx, h.provisionConfig(), clientID, externalUserID); err != nil { + 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 { @@ -124,12 +130,18 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin } result := &Result{ - AccessToken: minted.AccessToken, - TokenType: "Bearer", - ExpiresIn: minted.ExpiresIn, - Scope: scope, - IssuedTokenType: IssuedAccessTokenType, - CorrelationID: correlationID, + 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 @@ -142,7 +154,10 @@ func (h *Handler) Exchange(ctx context.Context, req Request, correlationID strin func (h *Handler) provisionConfig() openmeter.ProvisionConfig { return openmeter.ProvisionConfig{ - DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, + DefaultPlanKey: h.cfg.OpenMeterDefaultPlanKey, + TrialFeatureKey: h.cfg.OpenMeterTrialFeatureKey, + TrialGrantUSDMicros: h.cfg.OpenMeterTrialGrantUSDMicros, + EnforceAllowance: h.cfg.OpenMeterEnforceAllowance, } } diff --git a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go index d25d4e5..6c7adca 100644 --- a/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go +++ b/openmeter-collector/builder-api/internal/tokenexchange/handler_test.go @@ -24,22 +24,35 @@ func (s stubMinter) MintSignerToken(_ context.Context, _, _ string) (*auth0mint. return s.response, nil } -type stubProvisioner struct { - err error - calls int -} - 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", + 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 @@ -59,19 +72,27 @@ func (s stubVerifier) VerifyUserAccessToken(_ context.Context, _, expectedClient func testHandler(t *testing.T, verifier tokenexchange.UserTokenVerifier) *tokenexchange.Handler { t.Helper() - cfg := config.Config{ - Auth0Audience: "livepeer-clearinghouse", - SignerM2MClientID: "m2m-client", - SignerM2MSecret: "m2m-secret", - APIKeyPrefix: "sk_", - SignerURL: "http://localhost:8081", - DiscoveryURL: "http://localhost/discovery", + 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: "sk_", + Prefix: cfg.APIKeyPrefix, Demo: map[string]apikey.DemoEntry{ "sk_demo": {ClientID: "pub-client", UserID: "demo-user"}, }, @@ -82,7 +103,7 @@ func testHandler(t *testing.T, verifier tokenexchange.UserTokenVerifier) *tokene ExpiresIn: 300, Scope: "sign:job", }}, - &stubProvisioner{}, + provisioner, ) } @@ -258,6 +279,90 @@ func TestExchangeAPIKeyHappyPath(t *testing.T) { 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) {