Files
buzz/docs/enterprise-identity-adapter.md
Will Pfleger 1e67395fd1 docs(identity): tighten enterprise adapter contract (#8069)
Tightens `docs/enterprise-identity-adapter.md` to define the enterprise
adapter contract implemented by kgoose in
[cash-server#132770](https://github.com/squareup/cash-server/pull/132770)
and the Buzz Desktop client in
[buzz-app#562](https://github.com/block/buzz-app/pull/562).

- Requires canonical `wss://host[:port]` relay URLs with a lowercase
scheme, the lowercase ASCII (A-label) host form with no trailing dot,
the default port omitted, and no trailing slash, path, query, or
fragment. Adapters MUST configure relays in canonical form, compare
`relay_url` against that exact form, and reject unknown relays with 403
`authorization_denied`.
- Requires NIP-98 proof freshness of at most 60 seconds old and at most
5 seconds in the future, with the proof bound to the endpoint, method,
and exact request body.
- Rejects content-encoded request bodies with 400 `invalid_request`.
- Caps assertions at `exp - iat <= 300` seconds and the adapter session
expiry, requires `typ` `nip-fi+jwt`, and validates the echoed
`nostr_pubkey`.
- Defines refusal and retry handling: Clients MUST retry 429 and 503
with bounded backoff whatever the `error` code. Clients MUST treat any
other status, a 400/401/403/413 response with a contract-undefined code,
or a rejected `200` response including one it cannot parse, as a
refusal: keep the session, show it as refused, and not retry
automatically. Network failures retry with bounded backoff.

This tightens the enterprise adapter contract restored in
[#8064](https://github.com/block/buzz/pull/8064).

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-10-05 15:39:13 -04:00

246 lines
11 KiB
Markdown

# 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:
```text
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:
```text
{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.
```http
POST /v1/login/exchange
Content-Type: application/json
{
"code": "single-use-code-from-callback",
"handoff_secret": "base64url-random-secret"
}
```
Success response:
```json
{
"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.
```http
GET /v1/session
Authorization: Bearer {session_token}
```
Success response uses the same shape as the exchange response except
`session_token` is omitted:
```json
{
"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.
```http
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.
Clients MUST send it in the canonical form `wss://host[:port]`, with the
scheme in lowercase, the host as its lowercase ASCII (A-label) form with no
trailing dot, the default port omitted, and no trailing slash, path, query, or
fragment. Adapters MUST configure relays in this canonical form and MUST
compare `relay_url` against it exactly.
An unknown relay MUST be rejected with 403 `authorization_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 no more than 60
seconds old and MUST NOT be more than 5 seconds in the future.
- The request body is limited to 4096 bytes and MUST NOT be content-encoded. A
content-encoded body, such as a gzipped body, MUST be rejected with 400
`invalid_request`. 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`):
```json
{
"assertion": "<compact-JWS-assertion>",
"nostr_pubkey": "<64-char lowercase hex public key>",
"expires_at": 1790000300
}
```
`expires_at` is the assertion `exp` as Unix seconds. Adapters MUST issue
assertions with `exp - iat <= 300` seconds and MUST NOT set `exp` later than the
adapter session's expiry. Clients MAY refuse an assertion with more than 300
seconds remaining when it arrives. 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](nips/NIP-FI.md): a dedicated assertion's protected `typ` MUST be exactly
`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. Clients MUST reject a response whose `nostr_pubkey`
differs from the key sent in the request.
A client that rejects a `200` response, including one it cannot parse, MUST
treat it as a refusal: keep the session, show it as refused, and not retry
automatically.
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 |
Clients MUST retry 429 and 503 with bounded backoff whatever the `error` code.
Clients MUST treat any other status, or a 400/401/403/413 with a code this
contract does not define, as a refusal: keep the session, show it as refused,
and not retry automatically.
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](nips/NIP-FI.md):
```http
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.