From 40a77a226c7eeab9967c9040fdd81b7efe324236 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 18 Aug 2026 13:17:53 +0200 Subject: [PATCH] feat: allow anonymous sessions for async status updates The biggest usecase would be e.g. chunked uploading, we should be able to early close the connection (prevent e.g. WAF issues) and then still give the UI asynchronous status updates. Also needed for other long running processes where the request itself might get terminated due to timeouts (network issues, WAF, etc). Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Ferdinand Thiessen --- DEVELOPING.md | 101 ++++++++++- appinfo/routes.php | 5 + lib/AnonymousSession.php | 48 +++++ lib/AnonymousSessionManager.php | 145 +++++++++++++++ lib/AppInfo/Application.php | 4 + lib/Capabilities.php | 42 +++-- lib/Controller/AnonymousAuthController.php | 47 +++++ lib/IAnonymousSessionManager.php | 73 ++++++++ lib/Migration/Install.php | 29 ++- src/connection.rs | 6 +- src/event.rs | 64 ++++++- src/lib.rs | 21 ++- src/user.rs | 38 +++- tests/integration.rs | 94 ++++++++++ tests/lib/AnonymousSessionManagerTest.php | 196 +++++++++++++++++++++ 15 files changed, 881 insertions(+), 32 deletions(-) create mode 100644 lib/AnonymousSession.php create mode 100644 lib/AnonymousSessionManager.php create mode 100644 lib/Controller/AnonymousAuthController.php create mode 100644 lib/IAnonymousSessionManager.php create mode 100644 tests/lib/AnonymousSessionManagerTest.php diff --git a/DEVELOPING.md b/DEVELOPING.md index 58fa540..6f9d1a5 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -108,7 +108,7 @@ You can send custom events from a nextcloud app using the methods provided by `O ```php // in a real app, you'll want to setup DI to get an instance of `IQueue` -$queue = \OC::$server->get(OCA\NotifyPush\IQueue::class); +$queue = \OCP\Server::get(OCA\NotifyPush\Queue\IQueue::class); $queue->push('notify_custom', [ 'user' => "uid", 'message' => "my_message_type", @@ -127,6 +127,105 @@ listen('my_message_type', (message_type, optional_body) => { }) ``` +## Anonymous sessions + +Sometimes you want to push messages to a client that doesn't have a user session, such as a visitor of a public share +or a device that is still going through some kind of pairing flow. + +For these cases your app can create an "anonymous session". Creating a session gives you two values: + +- an **id**, which your app keeps server side and uses to address the session +- a **token**, which you hand to the client and which the client uses to connect to the push server + +Anonymous sessions live in a separate namespace from users, an anonymous session can never receive the messages of a +user (or of another session) and only receives the messages your app explicitly sends to it. In particular it does +*not* receive any of the built in `notify_file`, `notify_activity` or `notify_notification` messages. + +### Creating a session + +```php +// in a real app, you'll want to setup DI to get an instance of `IAnonymousSessionManager` +$sessionManager = \OCP\Server::get(OCA\NotifyPush\IAnonymousSessionManager::class); + +$session = $sessionManager->createSession('myapp'); + +$session->getId(); // "myapp:xIhK1i..." keep this, you need it to send messages +$session->getToken(); // "eyJpZCI6..." hand this to the client +$session->getExpiration(); // unix timestamp after which the client can no longer connect +``` + +Sessions are not stored server side, the id and expiration date are encoded in the token itself and signed with an +instance wide key. This means the client can keep reconnecting with the same token until it expires, but also that an +individual session can not be revoked before it expires. Because of that the lifetime is kept short: the `ttl` (second +argument of `createSession`) is one hour by default and can not be more than 24 hours. + +If a client needs to keep listening for longer than that, issue it a new token for the same session instead of +creating a new session, so the id your app stored stays valid: + +```php +$session = $sessionManager->renewToken($sessionId); +``` + +The previous token is not invalidated by this, it keeps working until it expires on its own. + +### Sending messages to a session + +```php +$sessionManager->send($sessionId, 'my_message_type', ['foo' => 'bar']); +``` + +Which is delivered to the client exactly like a custom event for a user, as `'my_message_type {"foo":"bar"}'`. + +Messages sent while no client is connected with the session are dropped, there is no buffering. + +Session ids are prefixed with the id of the app that created them to keep apps from accidentally addressing each +others sessions. Note that this is a convention and not a security boundary, every app on the server can push to the +message queue directly and therefore to any session. Don't send data over a session of another app, and don't rely on +other apps not being able to send to yours. + +### Connecting from the client + +The client only needs the token, everything else is discovered from the capabilities. Note that the +`ocs/v2.php/cloud/capabilities` request can be made without authentication. + +- Get the `websocket` and `anon_pre_auth` endpoints from the ocs capabilities request +- `POST` the token to the `anon_pre_auth` endpoint as `token`, a short lived pre-authentication token is returned +- Open the websocket +- Send an empty string as username over the websocket +- Send the pre-authentication token as password +- On disconnect, request a new pre-authentication token and connect again + +```javascript +async function connect(nextcloud_url, session_token) { + let capabilities = await fetch(`${nextcloud_url}/ocs/v2.php/cloud/capabilities`, { + headers: {'Accept': 'application/json', 'OCS-APIREQUEST': 'true'}, + }) + .then(response => response.json()) + .then(json => json.ocs.data.capabilities.notify_push); + + let body = new FormData(); + body.set('token', session_token); + let response = await fetch(capabilities.endpoints.anon_pre_auth, {method: 'POST', body}); + if (!response.ok) { + throw new Error('session token is no longer valid'); + } + let pre_auth_token = await response.text(); + + let ws = new WebSocket(capabilities.endpoints.websocket); + ws.onopen = () => { + ws.send(""); + ws.send(pre_auth_token); + }; + ws.onmessage = (msg) => { + console.log(msg.data); + }; + return ws; +} +``` + +The pre-authentication token is single use and only valid for 15 seconds, so it has to be requested again for every +(re)connect. The session token itself stays valid until it expires. + ## Building The server binary is built using rust and cargo, and requires a minimum of rust `1.94`. diff --git a/appinfo/routes.php b/appinfo/routes.php index 0b79359..e05c091 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -32,5 +32,10 @@ 'name' => 'Auth#getUid', 'url' => '/uid', ], + [ + 'name' => 'AnonymousAuth#preAuth', + 'url' => '/anon_pre_auth', + 'verb' => 'POST', + ], ], ]; diff --git a/lib/AnonymousSession.php b/lib/AnonymousSession.php new file mode 100644 index 0000000..871b484 --- /dev/null +++ b/lib/AnonymousSession.php @@ -0,0 +1,48 @@ +id; + } + + /** + * The token the client needs to connect to the push server, hand this to the client. + * + * The token is a secret, anyone holding it can receive the messages sent to this session. + */ + public function getToken(): string { + return $this->token; + } + + /** + * Unix timestamp after which the token stops working and the client can no longer (re)connect + */ + public function getExpiration(): int { + return $this->expiration; + } +} diff --git a/lib/AnonymousSessionManager.php b/lib/AnonymousSessionManager.php new file mode 100644 index 0000000..f277eed --- /dev/null +++ b/lib/AnonymousSessionManager.php @@ -0,0 +1,145 @@ +random->generate(self::SESSION_ID_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + + return $this->renewToken($id, $ttl); + } + + #[\Override] + public function renewToken(string $sessionId, int $ttl = self::DEFAULT_TTL): AnonymousSession { + if (preg_match(self::SESSION_ID_PATTERN, $sessionId) !== 1) { + throw new \InvalidArgumentException('Invalid session id: ' . $sessionId); + } + if ($ttl <= 0 || $ttl > self::MAX_TTL) { + throw new \InvalidArgumentException('Session ttl needs to be between 1 and ' . self::MAX_TTL . ' seconds'); + } + + $expiration = $this->timeFactory->getTime() + $ttl; + + return new AnonymousSession($sessionId, $this->createToken($sessionId, $expiration), $expiration); + } + + #[\Override] + public function send(string $sessionId, string $message, mixed $body = null): void { + $this->queue->push('notify_custom', [ + 'session' => $sessionId, + 'message' => $message, + 'body' => $body, + ]); + } + + /** + * Get the id of the session a token belongs to, or null if the token is invalid or expired. + */ + public function validateToken(string $token): ?string { + $parts = explode('.', $token); + if (count($parts) !== 2) { + return null; + } + [$payload, $signature] = $parts; + + if (!hash_equals($this->sign($payload), $signature)) { + return null; + } + + $decoded = json_decode($this->decode($payload), true); + if (!is_array($decoded) || !isset($decoded['id'], $decoded['exp']) + || !is_string($decoded['id']) || !is_int($decoded['exp'])) { + return null; + } + if ($decoded['exp'] <= $this->timeFactory->getTime()) { + return null; + } + + return $decoded['id']; + } + + /** + * Announce a session to the push server and get a short lived token the client can use + * to authenticate a websocket connection. + */ + public function preAuthenticate(string $sessionId): string { + $token = $this->random->generate(self::PRE_AUTH_TOKEN_LENGTH); + + $this->queue->push('notify_pre_auth', [ + 'session' => $sessionId, + 'token' => $token, + ]); + + return $token; + } + + private function createToken(string $id, int $expiration): string { + $payload = $this->encode(json_encode([ + 'id' => $id, + 'exp' => $expiration, + ], JSON_THROW_ON_ERROR)); + + return $payload . '.' . $this->sign($payload); + } + + private function sign(string $payload): string { + return $this->encode(hash_hmac('sha256', $payload, $this->getSigningKey(), true)); + } + + private function getSigningKey(): string { + $key = $this->appConfig->getValueString(Application::APP_ID, self::SIGNING_KEY_CONFIG_KEY); + if ($key === '') { + $key = $this->random->generate(self::SIGNING_KEY_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + $this->appConfig->setValueString(Application::APP_ID, self::SIGNING_KEY_CONFIG_KEY, $key, sensitive: true); + } + return $key; + } + + private function encode(string $data): string { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function decode(string $data): string { + return (string)base64_decode(strtr($data, '-_', '+/'), true); + } +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a389f68..ad8a22c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,8 +8,10 @@ namespace OCA\NotifyPush\AppInfo; +use OCA\NotifyPush\AnonymousSessionManager; use OCA\NotifyPush\Capabilities; use OCA\NotifyPush\CSPListener; +use OCA\NotifyPush\IAnonymousSessionManager; use OCA\NotifyPush\Listener; use OCA\NotifyPush\Queue\IQueue; use OCA\NotifyPush\Queue\NullQueue; @@ -40,6 +42,8 @@ public function __construct() { public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); + $context->registerServiceAlias(IAnonymousSessionManager::class, AnonymousSessionManager::class); + $context->registerService(IQueue::class, function (ContainerInterface $c) { /** @var PushRedisFactory $factory */ $factory = $c->get(PushRedisFactory::class); diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 66c62fb..352ddd4 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,37 +8,51 @@ namespace OCA\NotifyPush; -use OCP\Capabilities\ICapability; +use OCP\Capabilities\IPublicCapability; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\IUserSession; -class Capabilities implements ICapability { +class Capabilities implements IPublicCapability { private $config; private $urlGenerator; + private $userSession; - public function __construct(IConfig $config, IURLGenerator $urlGenerator) { + public function __construct(IConfig $config, IURLGenerator $urlGenerator, IUserSession $userSession) { $this->config = $config; $this->urlGenerator = $urlGenerator; + $this->userSession = $userSession; } public function getCapabilities() { $baseEndpoint = $this->config->getAppValue('notify_push', 'base_endpoint'); + if (!$baseEndpoint) { + return []; + } + $wsEndpoint = str_replace('https://', 'wss://', $baseEndpoint); $wsEndpoint = str_replace('http://', 'ws://', $wsEndpoint) . '/ws'; - if ($baseEndpoint) { - return [ - 'notify_push' => [ - 'type' => ['files', 'activities', 'notifications'], - 'endpoints' => [ - 'websocket' => $wsEndpoint, - 'pre_auth' => $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('notify_push.Auth.preAuth')) - ], + // endpoints that are usable without a user session + $capabilities = [ + 'notify_push' => [ + 'endpoints' => [ + 'websocket' => $wsEndpoint, + 'anon_pre_auth' => $this->url('notify_push.AnonymousAuth.preAuth'), ], - ]; - } else { - return []; + ], + ]; + + if ($this->userSession->isLoggedIn()) { + $capabilities['notify_push']['type'] = ['files', 'activities', 'notifications']; + $capabilities['notify_push']['endpoints']['pre_auth'] = $this->url('notify_push.Auth.preAuth'); } + + return $capabilities; + } + + private function url(string $route): string { + return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($route)); } } diff --git a/lib/Controller/AnonymousAuthController.php b/lib/Controller/AnonymousAuthController.php new file mode 100644 index 0000000..44f874b --- /dev/null +++ b/lib/Controller/AnonymousAuthController.php @@ -0,0 +1,47 @@ +sessionManager->validateToken($token); + if ($sessionId === null) { + return new DataDisplayResponse('Invalid or expired session token', Http::STATUS_UNAUTHORIZED); + } + + return new DataDisplayResponse($this->sessionManager->preAuthenticate($sessionId)); + } +} diff --git a/lib/IAnonymousSessionManager.php b/lib/IAnonymousSessionManager.php new file mode 100644 index 0000000..bf81373 --- /dev/null +++ b/lib/IAnonymousSessionManager.php @@ -0,0 +1,73 @@ +binaryFinder = $setupWizard; + $this->appConfig = $appConfig; + $this->random = $random; } public function getName() { @@ -29,5 +37,24 @@ public function getName() { public function run(IOutput $output) { $path = $this->binaryFinder->getBinaryPath(); @chmod($path, 0755); + + $this->setupAnonymousSessionKey(); + } + + /** + * Generate the key used to sign anonymous session tokens ahead of time, so concurrent + * requests can't race each other generating it. + */ + private function setupAnonymousSessionKey(): void { + if ($this->appConfig->getValueString(Application::APP_ID, AnonymousSessionManager::SIGNING_KEY_CONFIG_KEY) !== '') { + return; + } + + $this->appConfig->setValueString( + Application::APP_ID, + AnonymousSessionManager::SIGNING_KEY_CONFIG_KEY, + $this->random->generate(AnonymousSessionManager::SIGNING_KEY_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC), + sensitive: true, + ); } } diff --git a/src/connection.rs b/src/connection.rs index 044d09a..fdb927e 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -25,6 +25,8 @@ use warp::filters::ws::{Message, WebSocket}; const USER_CONNECTION_LIMIT: usize = 64; const PING_INTERVAL: Duration = Duration::from_secs(30); +/// How long a pre-auth token can be used to authenticate a socket after it has been requested +pub const PRE_AUTH_VALIDITY: Duration = Duration::from_secs(15); #[derive(Default)] pub struct ActiveConnections(DashMap, PassthruHasher>); @@ -271,8 +273,8 @@ async fn socket_auth( .map_err(|_| AuthenticationError::InvalidMessage)? .trim(); - // cleanup all pre_auth tokens older than 15s - let cutoff = Instant::now() - Duration::from_secs(15); + // cleanup all expired pre_auth tokens + let cutoff = Instant::now() - PRE_AUTH_VALIDITY; app.pre_auth.retain(|_, (time, _)| *time > cutoff); if let Some((_, (_, user))) = app.pre_auth.remove(password) { diff --git a/src/event.rs b/src/event.rs index 2f2199f..6b73250 100644 --- a/src/event.rs +++ b/src/event.rs @@ -11,9 +11,39 @@ use redis::Msg; use serde::Deserialize; use serde_json::Value; use std::convert::TryFrom; +use std::fmt; use thiserror::Error; use tokio_stream::{Stream, StreamExt}; +/// The recipient of a message, either a logged in user or an anonymous session +/// +/// Serialized as a single field, either `{"user": "uid"}` or `{"session": "session id"}` +#[derive(Debug, Deserialize)] +pub enum Target { + #[serde(rename = "user")] + User(String), + #[serde(rename = "session")] + AnonymousSession(String), +} + +impl Target { + pub fn id(&self) -> UserId { + match self { + Target::User(user) => UserId::new(user), + Target::AnonymousSession(session) => UserId::anonymous(session), + } + } +} + +impl fmt::Display for Target { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Target::User(user) => write!(f, "user {user}"), + Target::AnonymousSession(session) => write!(f, "anonymous session {session}"), + } + } +} + #[derive(Debug, Deserialize)] pub struct StorageUpdate { pub storage: u32, @@ -44,7 +74,8 @@ pub struct Notification { #[derive(Debug, Deserialize)] pub struct PreAuth { - pub user: UserId, + #[serde(flatten)] + pub target: Target, pub token: String, } @@ -63,10 +94,11 @@ pub enum Query { #[derive(Debug, Deserialize)] pub struct Custom { - pub user: UserId, + #[serde(flatten)] + pub target: Target, pub message: String, #[serde(default)] - pub body: Box, // use `Box` to reduce size of `Event` enum from 72 to 48 bytes + pub body: Box, // use `Box` to keep the size of the `Event` enum down } #[derive(Debug, Deserialize, Display)] @@ -89,9 +121,9 @@ pub enum Event { Activity(Activity), #[display("notification notification for user {0.user}")] Notification(Notification), - #[display("pre_auth user {0.user}")] + #[display("pre_auth {0.target}")] PreAuth(PreAuth), - #[display("custom notification {0.message} for user {0.user}")] + #[display("custom notification {0.message} for {0.target}")] Custom(Custom), #[display("config update")] Config(Config), @@ -152,6 +184,28 @@ impl TryFrom for Event { } } +#[test] +fn test_decode_custom_target() { + let user: Custom = serde_json::from_str(r#"{"user":"foo","message":"msg"}"#).unwrap(); + assert_eq!(user.target.id(), UserId::new("foo")); + + let session: Custom = + serde_json::from_str(r#"{"session":"myapp:foo","message":"msg","body":null}"#).unwrap(); + assert_eq!(session.target.id(), UserId::anonymous("myapp:foo")); + assert_eq!(*session.body, Value::Null); + + let with_body: Custom = + serde_json::from_str(r#"{"session":"myapp:foo","message":"msg","body":{"a":1}}"#).unwrap(); + assert_eq!(*with_body.body, serde_json::json!({"a": 1})); + + let pre_auth: PreAuth = + serde_json::from_str(r#"{"session":"myapp:foo","token":"tok"}"#).unwrap(); + assert_eq!(pre_auth.target.id(), UserId::anonymous("myapp:foo")); + assert_eq!(pre_auth.token, "tok"); + + assert!(serde_json::from_str::(r#"{"message":"msg"}"#).is_err()); +} + pub async fn subscribe( client: &Redis, ) -> Result<( diff --git a/src/lib.rs b/src/lib.rs index ac95698..df46149 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ use crate::config::{Bind, Config, TlsConfig}; -use crate::connection::{handle_user_socket, ActiveConnections, ConnectionOptions}; +use crate::connection::{ + handle_user_socket, ActiveConnections, ConnectionOptions, PRE_AUTH_VALIDITY, +}; pub use crate::error::Error; use crate::error::{SelfTestError, SocketError}; use crate::event::{ @@ -53,6 +55,9 @@ pub mod user; pub type Result = std::result::Result; +/// Number of outstanding pre-auth tokens after which expired tokens get cleaned up +const PRE_AUTH_CLEANUP_THRESHOLD: usize = 1024; + pub struct App { connections: ActiveConnections, nc_client: nc::Client, @@ -191,16 +196,22 @@ impl App { self.connections .send_to_user(&user, PushMessage::Notification); } - Event::PreAuth(PreAuth { user, token }) => { - self.pre_auth.insert(token, (Instant::now(), user)); + Event::PreAuth(PreAuth { target, token }) => { + // tokens are normally cleaned up when a socket authenticates, requested tokens + // that are never used would linger, so clean up once the map starts growing + if self.pre_auth.len() > PRE_AUTH_CLEANUP_THRESHOLD { + let cutoff = Instant::now() - PRE_AUTH_VALIDITY; + self.pre_auth.retain(|_, (time, _)| *time > cutoff); + } + self.pre_auth.insert(token, (Instant::now(), target.id())); } Event::Custom(Custom { - user, + target, message, body, }) => { self.connections - .send_to_user(&user, PushMessage::Custom(message, body)); + .send_to_user(&target.id(), PushMessage::Custom(message, body)); } Event::Config(event::Config::LogSpec(spec)) => { match self.log_handle.lock().await.parse_and_push_temp_spec(&spec) { diff --git a/src/user.rs b/src/user.rs index eaa683b..d0b5d40 100644 --- a/src/user.rs +++ b/src/user.rs @@ -21,6 +21,17 @@ static USER_NAMES: Lazy> = Lazy::new(DashMa // Use the same hash state for generating user hash for every instance static RANDOM_STATE: OnceBox = OnceBox::new(); +/// The kind of identity a connection is registered under +/// +/// The kind is hashed together with the id itself, ensuring that an anonymous session +/// can never end up with the same identity as a user, no matter what characters +/// the user backend allows in user ids. +#[derive(Clone, Copy, Eq, PartialEq)] +enum IdKind { + User = 0, + AnonymousSession = 1, +} + #[derive(Clone, Eq, PartialEq, Hash)] pub struct UserId { hash: u64, @@ -28,15 +39,26 @@ pub struct UserId { impl UserId { pub fn new(user_id: &str) -> Self { + UserId::with_kind(IdKind::User, user_id) + } + + /// Identity of an anonymous session, lives in a separate namespace from user ids + pub fn anonymous(session_id: &str) -> Self { + UserId::with_kind(IdKind::AnonymousSession, session_id) + } + + fn with_kind(kind: IdKind, id: &str) -> Self { let state = RANDOM_STATE.get_or_init(|| Box::new(RandomState::new())); let mut hash = state.build_hasher(); - hash.write(user_id.as_bytes()); + hash.write_u8(kind as u8); + hash.write(id.as_bytes()); let hash = hash.finish(); if log::max_level() >= LevelFilter::Info { - USER_NAMES - .entry(hash) - .or_insert_with(|| user_id.to_string()); + USER_NAMES.entry(hash).or_insert_with(|| match kind { + IdKind::User => id.to_string(), + IdKind::AnonymousSession => format!("anonymous session {id}"), + }); } UserId { hash } @@ -130,3 +152,11 @@ impl fmt::Debug for UserId { } } } + +#[test] +fn test_anonymous_session_doesnt_collide_with_user() { + assert_ne!(UserId::new("admin"), UserId::anonymous("admin")); + assert_eq!(UserId::new("admin"), UserId::new("admin")); + assert_eq!(UserId::anonymous("admin"), UserId::anonymous("admin")); + assert_ne!(UserId::anonymous("foo"), UserId::anonymous("bar")); +} diff --git a/tests/integration.rs b/tests/integration.rs index bfd580a..6ec5a48 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -471,6 +471,100 @@ async fn test_pre_auth() { assert_next_message(&mut client, "notify_activity").await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_anonymous_session() { + let services = Services::new().await; + + let server_handle = services.spawn_server().await; + + sleep(Duration::from_millis(500)).await; + + let mut redis = services.redis_client().await; + redis + .publish::<_, _, ()>( + "notify_pre_auth", + r#"{"session":"myapp:session1", "token": "token"}"#, + ) + .await + .unwrap(); + + sleep(Duration::from_millis(100)).await; + + let mut client = server_handle.connect_auth("", "token").await; + + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"myapp:session1", "message":"my_custom_message", "body": {"foo": "bar"}}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut client, r#"my_custom_message {"foo":"bar"}"#).await; + + // messages for a different session don't get delivered + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"myapp:session2", "message":"my_custom_message"}"#, + ) + .await + .unwrap(); + + assert_no_message(&mut client).await; +} + +/// An anonymous session must never share the identity of a user with the same id +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_anonymous_session_isolated_from_user() { + let services = Services::new().await; + services.add_user("foo", "bar"); + + let server_handle = services.spawn_server().await; + + sleep(Duration::from_millis(500)).await; + + let mut redis = services.redis_client().await; + redis + .publish::<_, _, ()>("notify_pre_auth", r#"{"session":"foo", "token": "token"}"#) + .await + .unwrap(); + + sleep(Duration::from_millis(100)).await; + + let mut anonymous_client = server_handle.connect_auth("", "token").await; + let mut user_client = server_handle.connect_auth("foo", "bar").await; + + // events for the user don't leak into the session with the same id + redis + .publish::<_, _, ()>("notify_activity", r#"{"user":"foo"}"#) + .await + .unwrap(); + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"user":"foo", "message":"for_the_user"}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut user_client, "notify_activity").await; + assert_next_message(&mut user_client, "for_the_user").await; + assert_no_message(&mut anonymous_client).await; + + // and the other way around + redis + .publish::<_, _, ()>( + "notify_custom", + r#"{"session":"foo", "message":"for_the_session"}"#, + ) + .await + .unwrap(); + + assert_next_message(&mut anonymous_client, "for_the_session").await; + assert_no_message(&mut user_client).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_notify_notification() { let services = Services::new().await; diff --git a/tests/lib/AnonymousSessionManagerTest.php b/tests/lib/AnonymousSessionManagerTest.php new file mode 100644 index 0000000..ed0494c --- /dev/null +++ b/tests/lib/AnonymousSessionManagerTest.php @@ -0,0 +1,196 @@ +createMock(IQueue::class); + $queue->method('push')->willReturnCallback(function ($channel, $event) { + $this->events[$channel][] = $event; + }); + + $random = $this->createMock(ISecureRandom::class); + $random->method('generate')->willReturnCallback(function (int $length) { + $this->randomCounter++; + return str_pad('r' . $this->randomCounter, $length, 'x'); + }); + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback(fn () => $this->signingKey); + $appConfig->method('setValueString')->willReturnCallback(function ($app, $key, $value) { + $this->signingKey = $value; + return true; + }); + + $timeFactory = $this->createMock(ITimeFactory::class); + $timeFactory->method('getTime')->willReturnCallback(fn () => $this->time); + + return new AnonymousSessionManager($queue, $random, $appConfig, $timeFactory); + } + + public function testCreateAndValidate(): void { + $manager = $this->getManager(); + + $session = $manager->createSession('myapp'); + + $this->assertStringStartsWith('myapp:', $session->getId()); + $this->assertEquals($this->time + IAnonymousSessionManager::DEFAULT_TTL, $session->getExpiration()); + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + } + + public function testSessionIdsAreUnique(): void { + $manager = $this->getManager(); + + $this->assertNotEquals( + $manager->createSession('myapp')->getId(), + $manager->createSession('myapp')->getId(), + ); + } + + public function testRenewToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 50; + $renewed = $manager->renewToken($session->getId(), 100); + + $this->assertEquals($session->getId(), $renewed->getId()); + $this->assertNotEquals($session->getToken(), $renewed->getToken()); + $this->assertEquals($this->time + 100, $renewed->getExpiration()); + + // the old token keeps working until it expires on its own + $this->time += 51; + $this->assertNull($manager->validateToken($session->getToken())); + $this->assertEquals($session->getId(), $manager->validateToken($renewed->getToken())); + } + + public function testRenewInvalidSessionId(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->renewToken('not a session id'); + } + + public function testTokenStaysValidForReconnects(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 50; + + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + $this->assertEquals($session->getId(), $manager->validateToken($session->getToken())); + } + + public function testExpiredToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + $this->time += 101; + + $this->assertNull($manager->validateToken($session->getToken())); + } + + /** + * @return array + */ + public static function invalidTokenProvider(): array { + return [ + 'empty' => [''], + 'no signature' => ['eyJpZCI6ICJteWFwcDpmb28iLCAiZXhwIjogOTk5OTk5OTk5OX0'], + 'too many parts' => ['a.b.c'], + 'garbage payload' => ['!!!.!!!'], + ]; + } + + /** + * @dataProvider invalidTokenProvider + */ + public function testInvalidToken(string $token): void { + $this->assertNull($this->getManager()->validateToken($token)); + } + + public function testTamperedToken(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp', 100); + + [$payload, $signature] = explode('.', $session->getToken()); + $forgedPayload = rtrim(strtr(base64_encode(json_encode([ + 'id' => 'otherapp:stolen', + 'exp' => $this->time + 100, + ])), '+/', '-_'), '='); + + $this->assertNull($manager->validateToken($forgedPayload . '.' . $signature)); + $this->assertNull($manager->validateToken($payload . '.' . strrev($signature))); + } + + public function testTokenFromDifferentInstance(): void { + $manager = $this->getManager(); + $session = $manager->createSession('myapp'); + + // simulate a different instance, and therefore a different signing key + $this->signingKey = ''; + $otherManager = $this->getManager(); + + $this->assertNull($otherManager->validateToken($session->getToken())); + } + + public function testInvalidAppId(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->createSession('my app'); + } + + public function testInvalidTtl(): void { + $this->expectException(\InvalidArgumentException::class); + $this->getManager()->createSession('myapp', IAnonymousSessionManager::MAX_TTL + 1); + } + + public function testSend(): void { + $manager = $this->getManager(); + + $manager->send('myapp:session1', 'my_message', ['foo' => 'bar']); + $manager->send('myapp:session1', 'my_message'); + + $this->assertEquals([ + [ + 'session' => 'myapp:session1', + 'message' => 'my_message', + 'body' => ['foo' => 'bar'], + ], + [ + 'session' => 'myapp:session1', + 'message' => 'my_message', + 'body' => null, + ], + ], $this->events['notify_custom']); + } + + public function testPreAuthenticate(): void { + $manager = $this->getManager(); + + $token = $manager->preAuthenticate('myapp:session1'); + + $this->assertEquals([ + [ + 'session' => 'myapp:session1', + 'token' => $token, + ], + ], $this->events['notify_pre_auth']); + } +}