Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ OKTA_AUDIENCE=sprout-desktop
# OKTA_AUDIENCE=sprout-api
# OKTA_PUBKEY_CLAIM=nostr_pubkey

# ── Corporate identity mode ─────────────────────────────────────────────────
# Identity mode: "disabled" (default), "proxy", "hybrid".
# proxy — all connections must present a valid identity JWT (no fallback).
# hybrid — identity JWT preferred; connections without it fall through to
# standard auth (API tokens, Okta JWTs) for agents.
# SPROUT_IDENTITY_MODE=disabled
#
# JWT claim names for uid (identity binding key) and username (display only).
# SPROUT_IDENTITY_UID_CLAIM=uid
# SPROUT_IDENTITY_USER_CLAIM=user
#
# Identity provider JWKS configuration. When set, identity JWTs are validated
# against this IdP independently of the main Okta/Keycloak JWKS config above.
# If unset, falls back to OKTA_JWKS_URI / OKTA_ISSUER / OKTA_AUDIENCE.
# SPROUT_IDENTITY_JWKS_URI=http://localhost:9200/certs
# SPROUT_IDENTITY_ISSUER=my-identity-provider
# SPROUT_IDENTITY_AUDIENCE=my-audience
#
# HTTP header name for the proxy-injected identity JWT.
# Defaults to the value shown below. Override if your auth proxy uses a
# different header name.
# SPROUT_IDENTITY_JWT_HEADER=x-forwarded-identity-token

# -----------------------------------------------------------------------------
# Ephemeral Channels (TTL testing)
# -----------------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ simple and testable.
thread root events. Any code that inserts replies must update these counters —
check existing reply handlers for the pattern.

**Identity binding (proxy mode)**: In corporate deployments the relay sits
behind a trusted auth proxy that injects an identity JWT header
(configured via `SPROUT_IDENTITY_JWT_HEADER`).
`SPROUT_IDENTITY_MODE` controls behaviour:

- `disabled` (default) — standard Nostr key-based auth only.
- `proxy` — all connections must present a valid identity JWT; the relay binds
uid → pubkey in the `identity_bindings` table. NIP-42 is still required to
prove pubkey ownership. Keys are shared across devices via NIP-AB pairing.
- `hybrid` — identity JWT preferred for humans; connections without the header
fall through to standard auth (API tokens, Okta JWTs) for agents.

Identity bindings are **immutable** — once a uid is bound to a pubkey, a
different pubkey returns a mismatch error. Use `sprout-admin unbind-identity`
to clear a binding (e.g., key rotation, offboarding).

**Trusted-proxy security invariant**: The relay trusts proxy headers
unconditionally. It **must** be deployed behind the trusted reverse proxy —
direct access to the relay port would allow header injection.

---

## Testing
Expand Down
27 changes: 24 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ sprout-huddle (LiveKit audio/video integration — standalone, not wired i
sprout-sdk (typed Nostr event builders — used by sprout-mcp, sprout-acp, and sprout-cli)
sprout-media (Blossom/S3 media storage)
sprout-cli (agent-first CLI)
sprout-admin (operator CLI: mint/list API tokens)
sprout-admin (operator CLI: mint/list API tokens, unbind identities)
sprout-test-client (integration test harness + manual CLI)
```

Expand Down Expand Up @@ -179,9 +179,25 @@ The client must respond with `["AUTH", <signed-event>]` before submitting events
| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta |
| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts |
| HTTP Bearer JWT | `Authorization: Bearer <jwt>` header on REST endpoints | REST API clients |
| NIP-42 + proxy identity | Identity JWT at upgrade + NIP-42 challenge | Corporate SSO via auth proxy (proxy/hybrid mode) |

On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`.

#### Proxy Identity Mode (Corporate SSO)

When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted auth proxy that injects an identity JWT via the configured header (`SPROUT_IDENTITY_JWT_HEADER`). The flow adds a two-phase binding step:

1. **WS Upgrade** — The relay validates the identity JWT (signature + expiry via JWKS), extracts `uid` and `username` claims, and stashes them as `PendingProxyIdentity` on the connection. No pubkey is known yet.
2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`.
3. **REST API** — Proxy-authenticated REST requests validate the identity JWT, then look up the existing `uid → pubkey` binding from the `identity_bindings` table.
4. **Registration** — `POST /api/identity/register` allows initial binding of a pubkey to a corporate identity via NIP-98 proof of key ownership.

**Binding semantics:** Once a uid is bound to a pubkey, the binding is immutable. A different pubkey for the same uid returns a 409 Conflict. Admin can unbind via `sprout-admin unbind-identity`. Keys are shared across devices via NIP-AB pairing.

**Hybrid mode:** When the identity JWT header is absent, the connection falls through to standard NIP-42 auth (API tokens, Okta JWTs). This allows agents without corporate JWTs to authenticate alongside human users.

**Security invariant:** The relay trusts proxy headers unconditionally. It **must** be deployed behind the trusted reverse proxy — direct access would allow header injection.

### Step 4: Active Loops

Three concurrent tasks run for the lifetime of the connection:
Expand Down Expand Up @@ -764,15 +780,18 @@ Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Ag

### sprout-admin — Operator CLI

**213 LOC.** Two subcommands:
**~280 LOC.** Three subcommands:

| Subcommand | Purpose |
|------------|---------|
| `mint-token` | Generate API token, store SHA-256 hash in DB, display raw token once |
| `list-tokens` | List all active tokens (ID, name, scopes, created) |
| `unbind-identity` | Remove identity binding(s) for key rotation or offboarding |

`mint-token` options: `--name`, `--scopes` (comma-separated), optional `--pubkey`. If `--pubkey` omitted, generates a new keypair and displays `nsec` (bech32) and pubkey.

`unbind-identity` options: `--uid` (required), optional `--device-cn` (omit to remove all devices), `--clear-name` (also clears verified_name from user records). Cache propagation delay: up to 2 minutes.

Raw token is shown exactly once and never stored. Only the SHA-256 hash reaches the database.

---
Expand Down Expand Up @@ -818,6 +837,7 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic
| NIP-42 timestamp | ±60 second tolerance — prevents replay attacks |
| AUTH events | Never stored in Postgres, never logged in audit chain |
| Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default |
| Proxy identity | JWT validated via JWKS; headers trusted from auth proxy only; `require_auth_token` forced true |

### Input Validation

Expand Down Expand Up @@ -890,6 +910,7 @@ Docker Compose provides the full local development stack. All services include h
| `api_tokens` | API token records (hash only, never plaintext) |
| `audit_log` | Hash-chain audit entries |
| `delivery_log` | Delivery tracking (partitioned; Rust module pending) |
| `identity_bindings` | Proxy mode: uid → pubkey binding for corporate identity |

### Redis Key Patterns

Expand Down Expand Up @@ -939,7 +960,7 @@ These are verified gaps in the current implementation — not design aspirations
| sprout-sdk | 1,237 | Shared library |
| sprout-media | 977 | Media storage |
| sprout-cli | 2,919 | Tooling |
| sprout-admin | 213 | Tooling |
| sprout-admin | ~280 | Tooling |
| sprout-test-client | 9,319 | Tooling |
| **Total** | **~72,126** | |

Expand Down
32 changes: 32 additions & 0 deletions crates/sprout-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ enum Command {
},
/// List all active API tokens.
ListTokens,
/// Remove an identity binding (for key rotation or offboarding).
UnbindIdentity {
/// Corporate user identifier (UID from identity JWT).
#[arg(long)]
uid: String,

/// Also clear verified_name from the user record.
#[arg(long, default_value_t = false)]
clear_name: bool,
},
}

#[tokio::main]
Expand All @@ -61,6 +71,9 @@ async fn main() -> Result<()> {
owner_pubkey,
} => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?,
Command::ListTokens => list_tokens(&db).await?,
Command::UnbindIdentity { uid, clear_name } => {
unbind_identity(&db, &uid, clear_name).await?
}
}

Ok(())
Expand Down Expand Up @@ -183,6 +196,25 @@ async fn mint_token(
Ok(())
}

async fn unbind_identity(db: &Db, uid: &str, clear_name: bool) -> Result<()> {
let binding = db.get_identity_binding(uid).await?;
let deleted = db.delete_identity_binding(uid).await?;
if deleted {
println!("Removed identity binding for uid={uid}");
if clear_name {
if let Some(binding) = binding {
let cleared = db.clear_verified_name(&binding.pubkey).await?;
if cleared {
println!("Cleared verified_name for the bound pubkey");
}
}
}
} else {
println!("No binding found for uid={uid}");
}
Ok(())
}

async fn list_tokens(db: &Db) -> Result<()> {
let tokens = db.list_active_tokens().await?;

Expand Down
187 changes: 187 additions & 0 deletions crates/sprout-auth/src/identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//! Corporate identity mode for the Sprout relay.
//!
//! Supports proxy-based identity where an upstream auth proxy
//! injects identity JWTs. The relay extracts corporate identity claims and binds
//! the client's self-generated pubkey to them.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

/// How corporate identity is resolved for incoming connections.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum IdentityMode {
/// Identity mode is disabled — standard Nostr key-based authentication.
#[default]
Disabled,
/// An auth proxy injects identity JWTs into requests.
/// All connections **must** present a valid identity JWT — no fallback.
Proxy,
/// Transitional mode: proxy identity is preferred for human users, but
/// connections without an identity JWT fall through to standard auth
/// (API tokens, Okta JWTs, NIP-42). Use this while agents lack JWTs.
Hybrid,
}

impl IdentityMode {
/// Returns `true` if proxy identity JWT validation is active
/// (either strict `Proxy` or transitional `Hybrid` mode).
pub fn is_proxy(&self) -> bool {
matches!(self, Self::Proxy | Self::Hybrid)
}
}

impl fmt::Display for IdentityMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => write!(f, "disabled"),
Self::Proxy => write!(f, "proxy"),
Self::Hybrid => write!(f, "hybrid"),
}
}
}

impl FromStr for IdentityMode {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"disabled" => Ok(Self::Disabled),
"proxy" => Ok(Self::Proxy),
"hybrid" => Ok(Self::Hybrid),
other => Err(format!("unknown identity mode: {other}")),
}
}
}

/// Configuration for corporate identity resolution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityConfig {
/// The identity mode to use.
#[serde(default = "default_mode")]
pub mode: IdentityMode,
/// JWT claim name containing the corporate user ID.
#[serde(default = "default_uid_claim")]
pub uid_claim: String,
/// JWT claim name containing the human-readable username.
#[serde(default = "default_user_claim")]
pub user_claim: String,
/// JWKS endpoint URL for the identity provider (e.g. the auth proxy).
/// Falls back to the main Okta/JWKS URI if empty.
#[serde(default)]
pub jwks_uri: String,
/// Expected JWT issuer claim for identity JWTs.
/// Falls back to the main Okta issuer if empty.
#[serde(default)]
pub issuer: String,
/// Expected JWT audience claim for identity JWTs.
/// Falls back to the main Okta audience if empty.
#[serde(default)]
pub audience: String,
/// HTTP header containing the identity JWT injected by the auth proxy.
#[serde(default = "default_identity_jwt_header")]
pub identity_jwt_header: String,
}

impl Default for IdentityConfig {
fn default() -> Self {
Self {
mode: default_mode(),
uid_claim: default_uid_claim(),
user_claim: default_user_claim(),
jwks_uri: String::new(),
issuer: String::new(),
audience: String::new(),
identity_jwt_header: default_identity_jwt_header(),
}
}
}

fn default_mode() -> IdentityMode {
IdentityMode::Disabled
}

fn default_uid_claim() -> String {
"uid".to_string()
}

fn default_user_claim() -> String {
"user".to_string()
}

fn default_identity_jwt_header() -> String {
"x-forwarded-identity-token".to_string()
}

// Custom serde for IdentityMode as a lowercase string.
impl Serialize for IdentityMode {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}

impl<'de> Deserialize<'de> for IdentityMode {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(serde::de::Error::custom)
}
}

/// Claims extracted from a validated proxy identity JWT.
///
/// Used by the relay to identify the corporate user without deriving keys.
/// The relay binds the client's self-generated pubkey to these claims.
#[derive(Debug, Clone)]
pub struct ProxyIdentityClaims {
/// Corporate user identifier (stable, immutable).
pub uid: String,
/// Human-readable username for display purposes.
pub username: String,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn identity_mode_from_str() {
assert_eq!(
"disabled".parse::<IdentityMode>().unwrap(),
IdentityMode::Disabled
);
assert_eq!(
"proxy".parse::<IdentityMode>().unwrap(),
IdentityMode::Proxy
);
assert_eq!(
"Proxy".parse::<IdentityMode>().unwrap(),
IdentityMode::Proxy
);
assert_eq!(
"hybrid".parse::<IdentityMode>().unwrap(),
IdentityMode::Hybrid
);
assert_eq!(
"Hybrid".parse::<IdentityMode>().unwrap(),
IdentityMode::Hybrid
);
assert!("unknown".parse::<IdentityMode>().is_err());
}

#[test]
fn identity_mode_is_proxy() {
assert!(!IdentityMode::Disabled.is_proxy());
assert!(IdentityMode::Proxy.is_proxy());
assert!(IdentityMode::Hybrid.is_proxy());
}

#[test]
fn identity_config_defaults() {
let config = IdentityConfig::default();
assert_eq!(config.mode, IdentityMode::Disabled);
assert_eq!(config.uid_claim, "uid");
assert_eq!(config.user_claim, "user");
assert_eq!(config.identity_jwt_header, "x-forwarded-identity-token");
}
}
Loading
Loading