From b71df3938ed694f92456f200b38c511d38e406e2 Mon Sep 17 00:00:00 2001 From: Kunal Date: Thu, 20 Aug 2026 17:58:56 +0530 Subject: [PATCH 1/2] fix: quickstart hand-rolls the actor assertion the SDK now builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 4 signs the RFC 7523 assertion with `pyjwt.encode` and exchanges it with `issue_token_exchange`. Both have had SDK helpers since highflame-sdk#32: `build_actor_assertion()` builds the assertion, and `delegate_to()` builds it and performs the exchange in one call, resolving `aud` from the client's own issuer. Keeping the manual cells rather than replacing them — this is ZeroID's own repo and the mechanics are the point of that section. Adds the one-call equivalent after them, so a reader knows they do not have to hand-roll either in their own code. Verified the helper is a drop-in for what the notebook writes by hand: alg build_actor_assertion=ES256 manual=ES256 iss == sub both the agent's WIMSE URI aud issuer (string vs the manual list; server accepts both) ttl 120s default, configurable manual 300s extra adds a `jti` nonce the manual version omits The claim set is exacting — `iss` must be the WIMSE URI exactly, `aud` must be the issuer — and the server reports any mistake as an undifferentiated `invalid_grant`, which is what makes hand-rolling it expensive. Left alone deliberately: `localhost:8899` and the separate `ZeroIDClient` are correct here. This notebook is a walkthrough of a locally-run ZeroID in the service's own repo, not an SDK quickstart, so pointing it at SaaS or the unified client would be wrong. Refs highflame-sdk#124. Co-Authored-By: Claude Opus 5 (1M context) --- examples/zeroid_quickstart.ipynb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/zeroid_quickstart.ipynb b/examples/zeroid_quickstart.ipynb index 8247e64c..f15ebb68 100644 --- a/examples/zeroid_quickstart.ipynb +++ b/examples/zeroid_quickstart.ipynb @@ -610,6 +610,20 @@ "outputs": [], "source": "delegated_response = client.tokens.issue_token_exchange(\n subject_token=orchestrator_token, # orchestrator's active token\n actor_token=actor_assertion, # tool agent's self-signed assertion\n scope=\"data:read\", # requested scope (must be subset of both)\n)\n\ndelegated_token = delegated_response.access_token\n\nprint(f\"Delegated token issued:\")\nprint(f\" Token Type: {delegated_response.token_type}\")\nprint(f\" Scope: {delegated_response.scope}\")\nprint(f\" Expires In: {delegated_response.expires_in}s\")\nprint(f\" Token: {delegated_token[:60]}...\")" }, + { + "cell_type": "markdown", + "id": "61cca6a6", + "metadata": {}, + "source": "### The same thing in one call\n\nThe two cells above are what delegation *is* — a self-signed assertion, then an\nRFC 8693 exchange — and they are written out here because this is ZeroID's own\nrepo and the mechanics are the point.\n\nIn your own code you do not need to hand-roll either. `build_actor_assertion()`\nbuilds the assertion, and `delegate_to()` builds it *and* performs the exchange.\n\nThe claim set is exacting — `iss` must be the WIMSE URI exactly, `aud` must be\nthe ZeroID issuer — and the server reports any mistake as an undifferentiated\n`invalid_grant`, so the helpers are worth using." + }, + { + "cell_type": "code", + "id": "de37091f", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": "from highflame.zeroid import build_actor_assertion\n\n# Equivalent to the pyjwt.encode cell above: ES256, iss == sub == the WIMSE\n# URI, aud == the issuer. It also adds a `jti` nonce, which the manual version\n# above omits.\nassertion = build_actor_assertion(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n audience=issuer_url,\n)\n\n# Or skip both steps. delegate_to() signs the assertion and exchanges it,\n# resolving `aud` from the client's own issuer.\none_call = client.tokens.delegate_to(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n scope=\"data:read\",\n subject_token=orchestrator_token,\n)\n\nprint(f\"assertion built: {assertion[:40]}...\")\nprint(f\"delegated in one call, scope: {one_call.scope}\")" + }, { "cell_type": "markdown", "id": "cell-s4-claims-md", From 131b0230703d5fed0e9010f16e17a1211545cd6a Mon Sep 17 00:00:00 2001 From: Kunal Date: Thu, 20 Aug 2026 18:25:29 +0530 Subject: [PATCH 2/2] fix: two cells in the quickstart cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by executing the notebook against the quickstart container, which became possible once highflame-sdk gained a configurable admin path prefix (highflame-sdk#145). `tokens.revoke(delegated_token)` in section 5 raises: RFC 7009 authenticates the *client*, so revocation needs the OAuth client credentials — which section 3 already has in scope as `oauth_client.client_id` and `client_secret`. Without them ZeroID answers `invalid_client`. `delegate_to` in the cell added earlier on this branch needs an explicit `audience`. It normally reads the issuer off the client's own token, and this notebook's client is unauthenticated; `issuer_url` is already resolved by the discovery cell. All 60 cells now run clean against `highflame-zeroid:quickstart`. Co-Authored-By: Claude Opus 5 (1M context) --- examples/zeroid_quickstart.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/zeroid_quickstart.ipynb b/examples/zeroid_quickstart.ipynb index f15ebb68..37a9d396 100644 --- a/examples/zeroid_quickstart.ipynb +++ b/examples/zeroid_quickstart.ipynb @@ -622,7 +622,7 @@ "metadata": {}, "execution_count": null, "outputs": [], - "source": "from highflame.zeroid import build_actor_assertion\n\n# Equivalent to the pyjwt.encode cell above: ES256, iss == sub == the WIMSE\n# URI, aud == the issuer. It also adds a `jti` nonce, which the manual version\n# above omits.\nassertion = build_actor_assertion(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n audience=issuer_url,\n)\n\n# Or skip both steps. delegate_to() signs the assertion and exchanges it,\n# resolving `aud` from the client's own issuer.\none_call = client.tokens.delegate_to(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n scope=\"data:read\",\n subject_token=orchestrator_token,\n)\n\nprint(f\"assertion built: {assertion[:40]}...\")\nprint(f\"delegated in one call, scope: {one_call.scope}\")" + "source": "from highflame.zeroid import build_actor_assertion\n\n# Equivalent to the pyjwt.encode cell above: ES256, iss == sub == the WIMSE\n# URI, aud == the issuer. It also adds a `jti` nonce, which the manual version\n# above omits.\nassertion = build_actor_assertion(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n audience=issuer_url,\n)\n\n# Or skip both steps. delegate_to() signs the assertion and exchanges it,\n# resolving `aud` from the client's own issuer.\none_call = client.tokens.delegate_to(\n wimse_uri=tool_agent.wimse_uri,\n private_key_pem=tool_agent_private_key,\n scope=\"data:read\",\n subject_token=orchestrator_token,\n # This client is unauthenticated, so delegate_to cannot read the\n # issuer off its own token. Pass it — it is the `aud` the assertion\n # needs, and the discovery cell above already resolved it.\n audience=issuer_url,\n)\n\nprint(f\"assertion built: {assertion[:40]}...\")\nprint(f\"delegated in one call, scope: {one_call.scope}\")" }, { "cell_type": "markdown", @@ -715,7 +715,7 @@ "metadata": {}, "outputs": [], "source": [ - "client.tokens.revoke(delegated_token)\n", + "# RFC 7009 authenticates the *client*, not the token, so revocation needs\n# the OAuth client credentials from section 3. Without them ZeroID answers\n# invalid_client. To revoke an agent without an OAuth client, use\n# agents.deactivate(), which collapses every token delegated from it.\nclient.tokens.revoke(\n delegated_token,\n client_id=oauth_client.client_id,\n client_secret=client_secret,\n)\n", "print(\"Token revocation request sent (RFC 7009 — always returns 200).\")" ] },