feat(gateway): auto Hydra registration + OAuth token acquisition (Phase 1b)#473
feat(gateway): auto Hydra registration + OAuth token acquisition (Phase 1b)#473raahulrahl merged 4 commits intomainfrom
Conversation
Phase 1b Commit 1. Adds the building block that lets the gateway
self-register with Hydra on boot, removing the manual
"hydra create oauth2-client" step from the operator's enable path.
New file: gateway/src/bindu/identity/hydra-admin.ts
* ensureHydraClient({adminUrl, did, publicKeyBase58, clientSecret,
scope, ...}) — idempotent. GETs /admin/clients/{did} first; if
200 the client already exists and the function is a no-op. If
404, POSTs /admin/clients with the full payload. Any other
status (5xx, auth failure) surfaces the Hydra response body so
operators can diagnose.
* deriveClientSecret(seed) — derives the OAuth client_secret
from the gateway's Ed25519 seed using sha256(seed ‖ scoped-tag)
→ base64url. Avoids needing any persistent state beyond the
seed env var. Anyone with the seed can compute the secret, but
anyone with the seed can already impersonate the gateway by
signing as it, so the derivation adds no new trust boundary.
* buildClientPayload — matches bindu/auth/hydra/registration.py's
shape byte-for-byte so the same Hydra can host both gateways
and agents without schema drift: client_id=DID,
client_name=human-readable, grant_types=["client_credentials"]
by default, token_endpoint_auth_method="client_secret_post",
and metadata carrying public_key, key_type, verification_method,
hybrid_auth=true, registered_at timestamp.
Tests: gateway/tests/bindu/hydra-admin.test.ts — 9 cases
* deriveClientSecret: deterministic for same seed ; different
seeds produce different secrets ; output is base64url (no
+/= padding).
* ensureHydraClient: GET 200 → no POST ; GET 404 → POST with
expected payload shape (all fields asserted including metadata
contents) ; GET 5xx surfaces body ; POST 4xx after 404 GET
surfaces body ; grantTypes override works ; trailing slash on
adminUrl is tolerated (no double-slash in constructed URL).
All 86 gateway tests pass (77 previous + 9 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1b Commit 2. Adds the token acquisition / caching module the
gateway uses to get a Hydra-issued OAuth bearer without an
operator-supplied env var. Pair with hydra-admin (Commit 1) to
fully automate the gateway's side of the DID auth handshake.
New file: gateway/src/bindu/identity/hydra-token.ts
* createTokenProvider({tokenUrl, clientId, clientSecret, scope,
refreshThresholdSec, ...}) — returns a TokenProvider with a
single method getToken(): Promise<string>.
* Cache + proactive refresh: tokens are cached in-memory with an
absolute expiry timestamp. getToken() returns the cached token
until it has less than refreshThresholdSec (default 30s) of
runway, then fetches a fresh one.
* Concurrent-caller coalescing: N parallel getToken() calls
during a refresh share a single in-flight promise. Stops a
plan's tool-call fan-out from each triggering its own Hydra
hit — matters for latency, rate limits, and clean telemetry.
* Hydra contract: POSTs form-encoded
grant_type=client_credentials + client_id + client_secret +
scope to the token endpoint (per RFC 6749 §4.4). Parses the
{access_token, expires_in} response. Non-2xx surfaces the body
so operators can diagnose 401s (invalid_client) vs 5xx
(Hydra down) vs 429 (rate limit).
* In-memory only by design: disk/Redis persistence would add
failure modes without improving the common path — we fetch at
cold-start anyway because ensureHydraClient runs right before.
Tests: gateway/tests/bindu/hydra-token.test.ts — 7 cases
* First call fetches; 2nd and 3rd calls return cached string
without re-fetching.
* Form body shape sanity-check: grant_type, client_id,
client_secret, scope are all form-encoded correctly.
* Proactive refresh: controlled clock advances past the
threshold, next getToken() fetches fresh. Asserts the exact
fetch count.
* Coalescing: three concurrent getToken() calls before the fetch
resolves share a single in-flight promise, all resolve to the
same token, fetchMock called once.
* Hydra 4xx surfaces status + body.
* Malformed response (missing access_token or expires_in) is
rejected with a clear error — no silent cache of garbage.
* Recovery: a failed fetch doesn't leave the provider stuck;
the next call retries.
All 93 gateway tests pass (86 previous + 7 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1b Commit 3. Completes the auto-path plumbing so a peer
configured with bare { type: "did_signed" } (no tokenEnvVar) pulls
its OAuth bearer from the gateway's auto-acquired token provider
instead of requiring an operator-supplied env var per peer.
Auth resolver (gateway/src/bindu/auth/resolver.ts):
* PeerAuth.did_signed.tokenEnvVar is now optional. Three resolution
modes, tried in order:
1. tokenEnvVar set on the peer → read token from that env var
(unchanged — the federated-deploy escape hatch)
2. Gateway has a TokenProvider configured → getToken()
(the common single-Hydra auto path from Phase 1b)
3. Neither → throw a clear error naming both options
* buildAuthHeaders gains an optional fourth tokenProvider
parameter. Identity is still required (the signer needs it);
the token source is now flexible.
RPC / client plumbing:
* gateway/src/bindu/client/fetch.ts — RpcInput grows a
tokenProvider field; rpc() threads it into buildAuthHeaders.
* gateway/src/bindu/client/poll.ts — SendAndPollInput + baseRpc
grow the field.
* gateway/src/bindu/client/index.ts — makeLayer(identity?,
tokenProvider?) takes both; runCall and runCancel accept both
and pass them through.
Existing test coverage:
* The previous did_signed test (tokenEnvVar set) still passes
unchanged.
* The schema test updated: `{ type: "did_signed" }` alone is now
valid (it used to require tokenEnvVar); `tokenEnvVar: 42` is
still rejected on type grounds.
New tests (3 cases in auth-resolver.test.ts):
* Fallback: did_signed without tokenEnvVar + a provider → pulls
token from provider, produces correct signed headers, provider
called exactly once.
* Precedence: when both tokenEnvVar and provider are set, the
peer-scoped env var wins (explicit-over-implicit; matches the
federated-deploy story where each peer has its own Hydra).
* Failure: neither tokenEnvVar nor provider → clear error
pointing at both BINDU_GATEWAY_HYDRA_TOKEN_URL (the auto path)
and the peer-scoped escape hatch.
All 96 gateway tests pass (93 previous + 3 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1b Commit 4 (final). Wires ensureHydraClient and
createTokenProvider into main() so operators get end-to-end auto:
set the identity + Hydra URLs, start the gateway, everything else
is handled.
gateway/src/index.ts
* buildAppLayer now takes an optional TokenProvider alongside
identity, passes both into BinduClient.makeLayer.
* setupHydraIntegration(identity) is the new boot-time async
hook. Reads three env vars:
BINDU_GATEWAY_HYDRA_ADMIN_URL (e.g. http://hydra:4445)
BINDU_GATEWAY_HYDRA_TOKEN_URL (e.g. http://hydra:4444/oauth2/token)
BINDU_GATEWAY_HYDRA_SCOPE (space-separated; default:
"openid offline agent:read agent:write")
Both URLs unset → returns undefined (manual/federated path still
works, just no auto token). One URL set without the other →
throws with a clear "Partial Hydra config" error. Both set →
calls ensureHydraClient (idempotent) then returns a
TokenProvider.
* Registration blocks boot completion — we want to fail fast if
the admin URL is unreachable rather than discover the problem
on the first peer call. The log line ordering:
DID identity loaded: ...
public key (base58): ...
registering with Hydra at ...
Hydra registration confirmed for ...
* Deterministic client_secret: re-derived from the seed at boot
(not plumbed through from loadLocalIdentity, which doesn't
expose raw bytes by design). Same derivation as Commit 1
documented in hydra-admin.ts.
Tests: gateway/tests/index-identity.test.ts — 3 new cases
* No Hydra env vars → returns undefined (manual/federated mode).
* Admin URL set without token URL → throws with "Partial Hydra
config" (pointing at both vars).
* Token URL set without admin URL → same error (symmetric).
The happy path (both set, registration succeeds, TokenProvider
returned) is covered end-to-end by the component tests in Commits
1 and 2 — ensureHydraClient's idempotent GET-then-POST and
createTokenProvider's cache + concurrency guarantees.
Docs:
* gateway/.env.example — three new Hydra vars documented with
the same "set both or neither" fail-fast rule.
* gateway/README.md — rewrote the "DID signing for downstream
peers" section with an Auto/Manual split. Shows both setup
flows side by side, explains peer-config precedence
(peer-scoped tokenEnvVar wins over gateway provider), lists
every failure mode with the exact error text.
All 99 gateway tests pass.
Phase 1b end-to-end: set four env vars (DID_SEED, AUTHOR, NAME,
HYDRA_ADMIN_URL, HYDRA_TOKEN_URL), restart, peers configured with
bare { type: "did_signed" } just work. No manual Hydra commands,
no per-peer env vars, no disk state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThis PR extends the Bindu gateway with Hydra OAuth2 integration, enabling automatic DID-signed peer authentication without per-peer token configuration. It introduces Hydra client registration at boot time, gateway-level token provisioning with caching and refresh, and optional token provider threading through the RPC client layer. Documentation, comprehensive tests, and updated configuration examples accompany the feature. Changes
Sequence Diagram(s)sequenceDiagram
participant Gateway as Gateway Boot
participant HAdmin as Hydra Admin
participant HTok as Hydra Token
participant Peer as Peer (did_signed)
rect rgba(100, 200, 150, 0.5)
note over Gateway,HTok: Boot-time Hydra Setup
Gateway->>HAdmin: GET /admin/clients/{gatewayDID}
alt Client exists (200)
HAdmin-->>Gateway: {clientId, clientSecret}
else Client not found (404)
Gateway->>HAdmin: POST /admin/clients with registration payload
HAdmin-->>Gateway: {clientId, clientSecret}
end
Gateway->>HTok: POST /token with client_credentials grant
HTok-->>Gateway: {access_token, expires_in}
Gateway->>Gateway: Cache token with refresh threshold
end
rect rgba(150, 150, 200, 0.5)
note over Gateway,Peer: Runtime RPC Call with Auto Token
Peer->>Gateway: RPC call (did_signed peer, no tokenEnvVar)
Gateway->>Gateway: Check cached token lifetime
alt Token needs refresh
Gateway->>HTok: POST /token with client_credentials grant
HTok-->>Gateway: {access_token, expires_in}
Gateway->>Gateway: Update cached token
end
Gateway->>Gateway: Sign request with DID private key
Gateway->>Gateway: Add Authorization: Bearer {access_token}
Gateway->>Peer: Signed RPC request with bearer token
Peer-->>Gateway: Response
end
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes the manual-Hydra-registration step from Phase 1a. Set five env vars, start the gateway, everything else is automatic.
What this adds
gateway/src/bindu/identity/hydra-admin.ts/admin/clients. client_secret derived from seed (no disk state).gateway/src/bindu/identity/hydra-token.tsdid_signedfallbackgateway/src/bindu/auth/resolver.tstokenEnvVarnow optional — omitted → falls back to gateway's token providergateway/src/index.tssetupHydraIntegration(identity)on boot: reads env, registers, creates provider.env.example,README.mdEnd-to-end operator flow (auto mode)
Peer config now becomes:
{ "url": "http://agent:3773", "auth": { "type": "did_signed" } }No manual
hydra create oauth2-client, no per-peer tokens stashed in env vars, no file on disk beyond the seed that was already required.Four commits, reviewable in order
cd7fab7— Hydra admin client. 334 lines.ensureHydraClientidempotent registration +deriveClientSecretderivation-from-seed. 9 tests covering GET 200 (no POST), GET 404 → POST (correct payload shape), GET 5xx / POST 4xx surfacing body, grant types override, trailing slash tolerance.43a5a07— OAuth token provider. 310 lines.createTokenProviderwith in-memory cache + proactive refresh + concurrent-caller coalescing. 7 tests covering cache hit, form body shape, controlled-clock refresh, stampede coalescing, Hydra 4xx surfacing, malformed response rejection, post-error recovery.e95939f—did_signedfallback. 161 lines.tokenEnvVaroptional; resolution order = peer env var > gateway provider > error. 3 new tests covering provider fallback, peer-scoped precedence, no-option error.40b4953— Bootstrap + docs. 241 lines.setupHydraIntegration(identity)in main(), partial-Hydra-config fail-fast,.env.example+ README rewrite with Auto/Manual split.Failure mode table (also in README)
BINDU_GATEWAY_DID_SEED must decode to exactly 32 bytesPartial DID identity config — set all three or nonePartial Hydra config — set both or neitherHydra admin GET /admin/clients/... returned 503: ...did_signedpeer, no identitydid_signed peer requires a gateway LocalIdentitydid_signedpeer, no token sourceFail-fast-at-boot where possible; clear-error-at-call-time where config isn't testable until a peer is actually called.
Test summary
99 gateway tests pass (77 baseline from Phase 1a + 22 new across four new files/sections).
Deferred to later phases
docs/GATEWAY_DID_SETUP.md/.well-known/did.jsonendpoint on the gateway (A2A-native DID resolution — peers could skip Hydra introspection in favor of resolving the gateway's DID directly). Not needed for MVP since Hydra metadata carries the public key.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests