Files
buzz/docs/enterprise-identity-adapter.md
Will Pfleger e982f70fba docs: restore enterprise identity adapter contract with relay assertion (#8064)
🤖 Restores `docs/enterprise-identity-adapter.md`, the vendor-neutral
HTTP contract between Buzz Desktop and an enterprise identity adapter,
and adds the request that turns an adapter session into a NIP-FI relay
assertion.

The login, code exchange, session check, transport, and privacy sections
are byte-for-byte the text from the head of #7715 (`4ef08e23`). Buzz
Desktop's enterprise login client
([buzz-app#525](https://github.com/block/buzz-app/pull/525)) already
calls those routes.

The new **Relay assertion** section defines `POST
/v1/identity/assertions`:

- **Request:** a JSON body with `relay_url` and `nostr_pubkey`. The
adapter session goes in `Authorization: Bearer`, and a NIP-98 proof
signed by that key, bound to the endpoint URL, method, and exact body
hash, goes in `Nostr-Authorization`. Clients and adapters must not log
either header's value.
- **Response:** `assertion`, `nostr_pubkey`, and `expires_at`. Under
this contract an assertion lives at most 5 minutes and never outlives
the adapter session; that cap is the adapter contract's rule, not a
relay constant. The assertion's `typ` and `aud` rules point to NIP-FI.
The assertion key must be the key that signs the NIP-42 relay login.
- **Denials:** a table of status, error code, meaning, and the client
action for each. A missing session is 401 `session_required`; missing or
repeated proof headers, a repeated `Authorization` header, multiple
session credential kinds, malformed bodies, and unknown fields are 400
`invalid_request`. 401s clear the session and return to login; 403
`authorization_denied` keeps the session and disconnects from that relay
without automatic retry; other 403s, 400, and 413 keep the session and
show the error without retry; 429, 503, and network failures retry with
bounded backoff. Adapters may return `session_required` for any invalid,
expired, or revoked credential, and clients handle both 401 codes
identically.

Agent keys are out of scope and need a separate design.

Related:
[cash-server#132756](https://github.com/squareup/cash-server/pull/132756)
implements this contract in kgoose.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-03 15:12:15 -04:00

9.8 KiB

Enterprise identity adapter contract

Buzz Desktop learns that a relay requires enterprise identity from the relay's NIP-11 federated_identity advertisement. Discovery deliberately contains no issuer URL, tenant ID, audience, or vendor-specific login details; those stay in operator-controlled Desktop build configuration.

When a trusted build connects to a trusted enterprise relay, Desktop speaks the provider-neutral HTTP contract below to the configured enterprise identity adapter. The adapter may use Auth0, Okta, SAML, LDAP, or another upstream identity system behind this boundary. Desktop does not implement or depend on that upstream protocol.

Build configuration

Enterprise builds set:

  • BUZZ_BUILD_ENTERPRISE_AUTH_RELAYS: comma-separated allowlist of relay URLs for which Desktop will honor NIP-FI enterprise login.
  • BUZZ_BUILD_ENTERPRISE_AUTH_ADAPTER_BASE_URL: base URL of the adapter implementing this document. Remote adapter URLs must use HTTPS. Plaintext HTTP is accepted only for loopback development hosts (localhost, 127.0.0.0/8, or ::1). Required when the relay allowlist is set.
  • BUZZ_BUILD_ENTERPRISE_PROFILE_PROJECTION: optional 1/true opt-in. When unset, adapter identity fields are private login state only and are never projected into a public Nostr profile.

Hosted-community Builderlab configuration is separate:

  • BUZZ_BUILD_BUILDERLAB_API_BASE_URL continues to configure only Builderlab hosted-community management. It is not used for enterprise login.

Browser login

Desktop starts a localhost callback and generates a high-entropy handoff secret. It opens:

GET {adapter_base}/v1/login/start?return_to={callback_url}&handoff_challenge={base64url(sha256(handoff_secret))}&handoff_challenge_method=S256

The adapter authenticates the user however the operator chooses, binds the completed browser login to handoff_challenge, and redirects to the exact return_to loopback callback. Desktop always supplies http://127.0.0.1:{ephemeral_port}/callback/{nonce}; adapters should accept loopback callbacks on any port as recommended by RFC 8252 §7.3 and reject non-loopback callback hosts. Success redirects to:

{callback_url}?code={single_use_code}

Failures may redirect to the same callback with error and optional error_description query parameters. Desktop surfaces error_description when present, otherwise error.

The code alone is not a credential. The adapter MUST accept it only when the exchange presents the matching handoff secret. Browser navigation may follow the adapter's identity-provider redirects; the non-browser adapter calls below must not depend on redirect handling.

Code exchange

Desktop exchanges the browser code with a no-redirect HTTP client. Any 3xx is treated as a failed exchange so {code, handoff_secret} is never replayed to a redirect target.

POST /v1/login/exchange
Content-Type: application/json

{
  "code": "single-use-code-from-callback",
  "handoff_secret": "base64url-random-secret"
}

Success response:

{
  "session_token": "opaque-adapter-session-token",
  "expires_at": "2026-09-23T21:00:00Z",
  "email": "employee@example.com",
  "profile_projection": {
    "username": "employee",
    "display_name": "Employee Name"
  }
}

email and profile_projection are optional. profile_projection is ignored by Desktop unless BUZZ_BUILD_ENTERPRISE_PROFILE_PROJECTION opts into publishing those fields as the user's public Buzz profile. The exchange expires_at and a later session-check expires_at must describe the same fixed adapter session; Desktop rejects mismatched values during login so a code exchange cannot commit a different session than the one verified by /v1/session.

Session check

Desktop checks or reuses an in-memory enterprise adapter session with the same no-redirect HTTP client. Any 3xx is treated as a failed session check so Bearer session credentials are never replayed to a redirect target.

GET /v1/session
Authorization: Bearer {session_token}

Success response uses the same shape as the exchange response except session_token is omitted:

{
  "expires_at": "2026-09-23T21:00:00Z",
  "email": "employee@example.com",
  "profile_projection": null
}

The adapter returns non-2xx when the token is invalid or expired. Desktop then clears only the enterprise adapter session. Builderlab hosted-community state is not affected.

Relay assertion

Before connecting to a NIP-FI-protected relay, the client asks the adapter for a short-lived NIP-FI assertion naming the Nostr key it will connect with. The request carries two credentials: the adapter session, which proves who the user is, and a NIP-98 proof, which proves the client controls the key.

POST /v1/identity/assertions
Authorization: Bearer {session_token}
Nostr-Authorization: Nostr {base64-NIP-98-event}
Content-Type: application/json

{
  "relay_url": "wss://community.example.com",
  "nostr_pubkey": "<64-char lowercase hex public key>"
}
  • relay_url selects an adapter-configured relay; the adapter never fetches it. An unknown relay is denied.
  • nostr_pubkey is the lowercase hex key the client will authenticate to the relay with. The NIP-98 event MUST be signed by this key, use kind 27235, carry exactly one u tag equal to the absolute URL of this endpoint, a method tag of POST, and a payload tag equal to the lowercase hex SHA-256 of the exact request body bytes. Its created_at MUST be current; adapters should allow no more than 60 seconds of age.
  • The request body is limited to 4096 bytes and MUST NOT be content-encoded. Unknown fields are rejected.
  • Clients and adapters MUST NOT log the values of the Authorization or Nostr-Authorization headers.

Success response (200, Cache-Control: no-store):

{
  "assertion": "<compact-JWS-assertion>",
  "nostr_pubkey": "<64-char lowercase hex public key>",
  "expires_at": 1790000300
}

expires_at is the assertion exp as Unix seconds. Under this adapter contract an assertion lives at most 5 minutes and never outlives the adapter session; clients request a fresh one before it expires. The 5-minute cap is this contract's rule, not a relay constant: the relay enforces the token exp and its own deployment-configured maximum_assertion_age. The assertion itself follows NIP-FI: a dedicated assertion's protected typ is nip-fi+jwt, and its aud MUST exactly match the canonical host URI of the community the relay resolves from the connection's Host. The assertion's nostr_pubkey MUST be the key that signs the NIP-42 relay login; the relay rejects any other key.

Denials return a JSON body {"error": "<code>"} with Cache-Control: no-store:

Status error Meaning Client action
400 invalid_request Malformed body, unknown fields, a missing or repeated Nostr-Authorization header, a repeated Authorization header, or more than one kind of session credential Keep the session, show the error, do not retry automatically
401 session_required No adapter session was presented, or the presented one is not usable Clear the adapter session and return to browser login
401 session_expired The adapter session ended Clear the adapter session and return to browser login
403 authorization_denied The user or relay is not authorized for assertions Keep the session, disconnect from that relay, show access denied, do not retry automatically; a manual retry or app restart asks again
403 invalid_proof The NIP-98 proof failed verification Keep the session, show the error, do not retry automatically
403 binding_mismatch The adapter has bound this user to a different Nostr key Keep the session, show the error, do not retry automatically
413 request_too_large The body exceeds 4096 bytes Keep the session, show the error, do not retry automatically
429 rate_limited Too many requests Keep the session, retry with bounded backoff
503 issuance_unavailable The adapter could not issue an assertion Keep the session, retry with bounded backoff

A missing session is always 401 session_required, never 400. Network failures are handled like 429 and 503: keep the session and retry with bounded backoff.

An invalid, expired or revoked credential may return session_required; adapters that distinguish ended sessions may return session_expired. Clients handle both identically.

Assertions for agent keys are not covered by this contract yet and need a separate design.

NIP-FI assertion transport

The adapter session header above is only for Desktop↔adapter account/session requests. It is not the relay's NIP-FI proof transport.

When Desktop (or another Buzz client) later accesses a NIP-FI-protected relay or HTTP route, the relay proof uses the NIP-FI headers defined by docs/nips/NIP-FI.md:

Authorization: Nostr <base64-NIP-98-event>
Nostr-Federated-Identity: Bearer <compact-JWS-assertion>

That separation is intentional: Authorization: Bearer <session_token> belongs to the enterprise identity adapter, while relay proof keeps the NIP-98 event in Authorization and carries the identity assertion in Nostr-Federated-Identity. The public contract MUST NOT use Authorization for both an adapter session and a NIP-FI assertion on the same request, and MUST NOT use Builderlab-specific X-BB-Session-Credential or Authorization: BBIdentity schemes.

Privacy

NIP-FI defines no public identity projection. Adapter responses are private login state by default. Operators that want managed public profiles must make that a build-time policy choice and provide explicitly publishable profile_projection fields; raw upstream claims, legal names, emails, issuer identifiers, subjects, and assertion contents must not be written to public Nostr events implicitly.