feat: corporate identity binding — proxy/hybrid mode for enterprise deployments#293
Open
feat: corporate identity binding — proxy/hybrid mode for enterprise deployments#293
Conversation
Add proxy/hybrid identity mode where the relay sits behind a trusted reverse proxy (cf-doorman) that injects identity JWTs. The relay derives deterministic Nostr keypairs from the corporate UID claim via HMAC-SHA256. Backend: - sprout-auth: IdentityMode enum, IdentityConfig, derive_keypair_from_uid - sprout-relay: config env vars, POST /api/identity/bootstrap endpoint, WS + REST pre-auth from x-forwarded-identity-token header - sprout-db: ensure_user_with_verified_name for trusted display names - NIP-11: advertise identity_mode for desktop auto-discovery - Automatically force require_auth_token when identity mode is active Desktop: - IdentityGate component gates app on successful bootstrap - Tauri identity commands: bootstrap_identity, set_identity_from_secret_key - WARP connectivity error detection with user-friendly messaging Security: - Relay trusts x-forwarded-identity-token unconditionally (must be behind cf-doorman); startup warning documents the trusted-proxy assumption - Bootstrap response uses POST + Cache-Control: no-store to prevent intermediary caching of secret keys - Identity secret never leaves the relay; each user gets only their own key Amp-Thread-ID: https://ampcode.com/threads/T-019d7027-eacf-763c-8e72-7361f6f14bf4 Co-authored-by: Amp <amp@ampcode.com>
…rated pubkey binding Replace the relay-derived HMAC keypair approach with client-generated keys and server-side identity binding. The relay no longer knows private keys. Key changes: - New identity_bindings table: maps (uid, device_cn) to pubkey, enabling multi-device support under one corporate identity - POST /api/identity/register replaces /api/identity/bootstrap: client proves pubkey ownership via NIP-98, relay binds it to JWT identity - WS proxy mode now requires NIP-42 AUTH (no more pre-authentication): proxy identity claims are stashed at upgrade time, binding resolved when client sends AUTH event with its own key - REST proxy auth resolves pubkey from binding lookup instead of HMAC - Desktop generates/persists its own keypair locally and registers it with the relay on startup (no secret key returned from server) - Removed: HMAC key derivation, SPROUT_IDENTITY_SECRET/CONTEXT env vars, bootstrap endpoint, set_identity_from_secret_key Tauri command Security improvements: - Relay never sees or transmits private keys - No server-side secret whose compromise would rotate all identities - Per-device key isolation via (uid, device_cn) binding - Proof-of-possession required for both WS (NIP-42) and REST (NIP-98) Amp-Thread-ID: https://ampcode.com/threads/T-019d731d-8157-752e-adf2-387f5d48f3a5 Co-authored-by: Amp <amp@ampcode.com>
…guard - Fix JWT validation (was using validate_permissive) - Limit proxy scopes to non-admin - Add identity-bound pubkey guard: bound pubkeys must present JWT - Cache identity lookups via moka (2-min TTL), invalidate on bind - Extract shared AppState::is_identity_bound() helper - Add sprout-admin unbind-identity for key rotation/offboarding - Add identity-specific JWKS config (SPROUT_IDENTITY_JWKS_URI/ISSUER/AUDIENCE) - Use EXISTS instead of COUNT(*) for binding check - Remove existing_pubkey from mismatch logs (PII) - Add verified_name badge in desktop UI - Update ARCHITECTURE.md and AGENTS.md Amp-Thread-ID: https://ampcode.com/threads/T-019d77d2-5136-77ed-880b-928e13d5cb1c Co-authored-by: Amp <amp@ampcode.com>
…iedBadge - Make identity JWT and device CN header names configurable via SPROUT_IDENTITY_JWT_HEADER and SPROUT_IDENTITY_DEVICE_CN_HEADER - Replace all 'cf-doorman' references with generic 'auth proxy' - Remove hardcoded header names from error messages and docs - Fix NIP-11 identity_mode doc to include 'hybrid' - Add missing VerifiedBadge component (was untracked) - Fix cargo fmt in desktop/src-tauri identity command Amp-Thread-ID: https://ampcode.com/threads/T-019d791a-849a-746b-ba47-cb9b56ee196d Co-authored-by: Amp <amp@ampcode.com>
Collaborator
Follow-up Items (Subsequent PR)Tracked in #295. These are out of scope for this PR but should be addressed before or alongside the auth simplification work: Security Hardening
Concurrency
Client UX
Documentation
Cleanup
|
- Enforce pubkey uniqueness via UNIQUE index on identity_bindings.pubkey - Derive scopes from JWT claims instead of granting all_non_admin() - Fail closed on DB error in is_identity_bound() (deny, don't allow) - Register proxy-authenticated sessions in connection manager
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Corporate identity binding — proxy/hybrid mode for enterprise deployments
Problem
Enterprise deployments need to tie Nostr pubkeys to corporate identities (SSO) so the relay can verify who is behind each connection, support multi-device key management, and display verified usernames. Standard Nostr auth (NIP-42) only proves pubkey ownership — it says nothing about the human or service account behind the key.
Solution
Add a proxy identity mode where the relay sits behind a trusted auth proxy that injects identity JWTs. The relay binds each client's self-generated Nostr pubkey to a
(corporate_uid, device_cn)pair, creating an immutable identity binding. Three modes controlled bySPROUT_IDENTITY_MODE:disabled(default) — standard Nostr key-based auth only. No changes to existing behavior.proxy— all connections must present a valid identity JWT. The relay validates the JWT at connection time and binds the pubkey to the corporate identity during NIP-42 AUTH.hybrid— identity JWT preferred for human users; connections without the header fall through to standard auth (API tokens, Okta JWTs). Allows agents to authenticate alongside identity-bound humans.Architecture
Two-phase binding (WebSocket)
uidandusernameclaims, and stashes them asPendingProxyIdentityon the connection. The client's pubkey is not yet known.bind_or_validate_identity(uid, device_cn, pubkey)to create or validate the binding, then transitions toAuthenticated.REST API path
Proxy-authenticated REST requests validate the identity JWT, then look up the existing
(uid, device_cn) → pubkeybinding from theidentity_bindingstable. A registration endpoint (POST /api/identity/register) allows initial binding via NIP-98 proof of key ownership.Identity-bound guard
Once a pubkey is bound to a corporate identity, it cannot authenticate via standard auth (API tokens, NIP-42 pubkey-only) without presenting the identity JWT. This prevents key compromise bypass in hybrid mode — a stolen key can't be used to impersonate the corporate user from outside the proxy.
Schema
New
identity_bindingstable (declarative, inschema/schema.sql):Bindings are immutable — once a
(uid, device_cn)is bound to a pubkey, a different pubkey for the same slot returns a mismatch error. Usesprout-admin unbind-identityfor key rotation or offboarding.New
verified_namecolumn onusers— set from the identity JWT username claim, displayed with a verified badge in the desktop UI.Changes by crate
identitymodule:IdentityModeenum,IdentityConfig(mode, JWT/device-CN header names, claim names, JWKS config),ProxyIdentityClaims,validate_identity_jwt(),verify_nip42_event()(public for proxy path).Scope::all_non_admin()for identity-authenticated users.identity_bindingmodule:bind_or_validate_identity()(SELECT FOR UPDATE with 3s lock_timeout),get_identity_binding(),is_pubkey_identity_bound(),delete_identity_binding(),get_bindings_for_uid().ensure_user_with_verified_name().BindingResultenum (Created/Matched/Mismatch).ConnectionState: newPendingProxyIdentitystruct,AuthState::Pendingcarries optional proxy identity.handle_auth: proxy identity binding path, API token identity-bound guard, standard auth identity-bound guard.extract_auth_context: proxy/hybrid REST path with binding lookup.AppState::is_identity_bound()with moka cache (2-min TTL). Newapi/identity.rsregistration endpoint. NIP-11 advertisesidentity_mode. Configurable header names viaSPROUT_IDENTITY_JWT_HEADER/SPROUT_IDENTITY_DEVICE_CN_HEADER.unbind-identitycommand (by UID, optional device_cn, optional--clear-name).IdentityGatecomponent: callsinitializeIdentityat startup, configures relay client auth mode.MessageRow: verified badge for identity-bound users.identity.ts: identity initialization logic.Configuration
Security considerations
require_auth_tokenis forcedtruewhen identity mode is active.(uid, device_cn) → pubkeymapping cannot be changed without admin intervention (sprout-admin unbind-identity).Testing
IdentityMode,IdentityConfig,verify_nip42_event(public API).just ci).