Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
6.8 KiB
Rust
201 lines
6.8 KiB
Rust
//! HMAC-signed realtime subscriber tokens (v1.1.6, design notes §5).
|
|
//!
|
|
//! A token is a compact, URL-safe, two-part string:
|
|
//!
|
|
//! ```text
|
|
//! <base64url(payload)>.<base64url(signature)>
|
|
//! ```
|
|
//!
|
|
//! where `payload` is the JSON [`TokenClaims`] and `signature` is
|
|
//! `HMAC-SHA256(app_signing_key, base64url(payload))`. Tokens are minted
|
|
//! by scripts via `pubsub::subscriber_token` (the minter lives in
|
|
//! manager-core's `PubsubServiceImpl`) and verified by the SSE subscribe
|
|
//! path (the verifier lives in manager-core's `RealtimeAuthority` impl).
|
|
//! Both sides depend on this module so the byte-for-byte contract has a
|
|
//! single home.
|
|
//!
|
|
//! There is no per-token revocation in v1.1.6 by design: HMAC bearers
|
|
//! can't be individually revoked. Rotating an app's signing key
|
|
//! invalidates every token for that app wholesale; short TTLs are the
|
|
//! safety mechanism.
|
|
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine as _;
|
|
use hmac::{Hmac, Mac};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::Sha256;
|
|
use thiserror::Error;
|
|
|
|
use crate::AppId;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
/// The signed payload. `exp` / `iat` are Unix seconds.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TokenClaims {
|
|
pub app_id: AppId,
|
|
pub topics: Vec<String>,
|
|
pub exp: i64,
|
|
pub iat: i64,
|
|
}
|
|
|
|
impl TokenClaims {
|
|
/// Does this token grant access to `topic`?
|
|
#[must_use]
|
|
pub fn allows_topic(&self, topic: &str) -> bool {
|
|
self.topics.iter().any(|t| t == topic)
|
|
}
|
|
|
|
/// Is the token expired relative to `now_unix` (Unix seconds)?
|
|
#[must_use]
|
|
pub fn is_expired(&self, now_unix: i64) -> bool {
|
|
now_unix >= self.exp
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum TokenError {
|
|
#[error("token is malformed")]
|
|
Malformed,
|
|
#[error("token signature is invalid")]
|
|
BadSignature,
|
|
#[error("token has expired")]
|
|
Expired,
|
|
}
|
|
|
|
/// Sign `claims` with `key`, producing the `payload.signature` string.
|
|
#[must_use]
|
|
pub fn sign(key: &[u8], claims: &TokenClaims) -> String {
|
|
// `serde_json` on a fixed-field struct never fails to serialize.
|
|
let payload_json = serde_json::to_vec(claims).expect("TokenClaims serialize");
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json);
|
|
let sig = mac_sign(key, payload_b64.as_bytes());
|
|
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
|
|
format!("{payload_b64}.{sig_b64}")
|
|
}
|
|
|
|
/// Verify `token` against `key` and check expiry against `now_unix`
|
|
/// (Unix seconds). Returns the decoded [`TokenClaims`] on success.
|
|
///
|
|
/// Topic-scope checking (is the requested topic in the token's list?)
|
|
/// is the caller's responsibility via [`TokenClaims::allows_topic`] —
|
|
/// this function proves authenticity + liveness only.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`TokenError::Malformed`] if the shape / base64 / JSON is wrong,
|
|
/// [`TokenError::BadSignature`] if the HMAC doesn't match, or
|
|
/// [`TokenError::Expired`] if `now_unix >= exp`.
|
|
pub fn verify(key: &[u8], token: &str, now_unix: i64) -> Result<TokenClaims, TokenError> {
|
|
let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Malformed)?;
|
|
|
|
let provided_sig = URL_SAFE_NO_PAD
|
|
.decode(sig_b64)
|
|
.map_err(|_| TokenError::Malformed)?;
|
|
|
|
// Constant-time verify of the MAC over the exact payload bytes.
|
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
|
mac.update(payload_b64.as_bytes());
|
|
mac.verify_slice(&provided_sig)
|
|
.map_err(|_| TokenError::BadSignature)?;
|
|
|
|
// Signature good → decode the claims and check expiry.
|
|
let payload_json = URL_SAFE_NO_PAD
|
|
.decode(payload_b64)
|
|
.map_err(|_| TokenError::Malformed)?;
|
|
let claims: TokenClaims =
|
|
serde_json::from_slice(&payload_json).map_err(|_| TokenError::Malformed)?;
|
|
|
|
if claims.is_expired(now_unix) {
|
|
return Err(TokenError::Expired);
|
|
}
|
|
Ok(claims)
|
|
}
|
|
|
|
fn mac_sign(key: &[u8], data: &[u8]) -> Vec<u8> {
|
|
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
|
mac.update(data);
|
|
mac.finalize().into_bytes().to_vec()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn claims(app: AppId, topics: &[&str], exp: i64) -> TokenClaims {
|
|
TokenClaims {
|
|
app_id: app,
|
|
topics: topics.iter().map(|s| (*s).to_string()).collect(),
|
|
iat: 1000,
|
|
exp,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_verifies() {
|
|
let key = b"super-secret-key-bytes-0123456789";
|
|
let app = AppId::new();
|
|
let c = claims(app, &["chat.room.1", "user.notify"], 5000);
|
|
let token = sign(key, &c);
|
|
let got = verify(key, &token, 2000).expect("valid token verifies");
|
|
assert_eq!(got, c);
|
|
assert!(got.allows_topic("chat.room.1"));
|
|
assert!(!got.allows_topic("chat.room.2"));
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_payload_fails() {
|
|
let key = b"super-secret-key-bytes-0123456789";
|
|
let app = AppId::new();
|
|
let token = sign(key, &claims(app, &["t"], 5000));
|
|
// Flip a character in the payload half.
|
|
let (payload, sig) = token.split_once('.').unwrap();
|
|
let mut bytes = payload.as_bytes().to_vec();
|
|
bytes[0] ^= 0x01;
|
|
let tampered = format!("{}.{sig}", String::from_utf8_lossy(&bytes));
|
|
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_signature_fails() {
|
|
let key = b"super-secret-key-bytes-0123456789";
|
|
let app = AppId::new();
|
|
let token = sign(key, &claims(app, &["t"], 5000));
|
|
let (payload, _sig) = token.split_once('.').unwrap();
|
|
// A valid-base64 but wrong signature.
|
|
let bogus = URL_SAFE_NO_PAD.encode([0u8; 32]);
|
|
let tampered = format!("{payload}.{bogus}");
|
|
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
|
|
}
|
|
|
|
#[test]
|
|
fn different_key_fails() {
|
|
let app = AppId::new();
|
|
let token = sign(
|
|
b"key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
&claims(app, &["t"], 5000),
|
|
);
|
|
assert_eq!(
|
|
verify(b"key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &token, 2000),
|
|
Err(TokenError::BadSignature)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn expired_token_fails_at_expiry_check() {
|
|
let key = b"super-secret-key-bytes-0123456789";
|
|
let app = AppId::new();
|
|
let token = sign(key, &claims(app, &["t"], 5000));
|
|
// now == exp → expired (>= boundary).
|
|
assert_eq!(verify(key, &token, 5000), Err(TokenError::Expired));
|
|
assert_eq!(verify(key, &token, 9999), Err(TokenError::Expired));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_token_fails() {
|
|
let key = b"super-secret-key-bytes-0123456789";
|
|
assert_eq!(verify(key, "no-dot-here", 0), Err(TokenError::Malformed));
|
|
assert_eq!(verify(key, "a.b.c", 0), Err(TokenError::Malformed));
|
|
}
|
|
}
|