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
101 changes: 100 additions & 1 deletion DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@
'name' => 'Auth#getUid',
'url' => '/uid',
],
[
'name' => 'AnonymousAuth#preAuth',
'url' => '/anon_pre_auth',
'verb' => 'POST',
],
],
];
48 changes: 48 additions & 0 deletions lib/AnonymousSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\NotifyPush;

/**
* A push session for a client without a user session.
*
* @see IAnonymousSessionManager::createSession()
*/
class AnonymousSession {
public function __construct(
private readonly string $id,
private readonly string $token,
private readonly int $expiration,
) {
}

/**
* The id of the session, keep this server side to send messages to the session.
*
* @see IAnonymousSessionManager::send()
*/
public function getId(): string {
return $this->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;
}
}
145 changes: 145 additions & 0 deletions lib/AnonymousSessionManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\NotifyPush;

use OCA\NotifyPush\AppInfo\Application;
use OCA\NotifyPush\Queue\IQueue;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IAppConfig;
use OCP\Security\ISecureRandom;

/**
* Anonymous sessions are not stored server side, instead the session id is handed to the client
* as part of a token that is signed with an instance wide key.
*
* This keeps the session usable across reconnects without having to keep state for clients
* that might never connect in the first place.
*/
class AnonymousSessionManager implements IAnonymousSessionManager {
public const SIGNING_KEY_CONFIG_KEY = 'anonymous_session_key';
public const SIGNING_KEY_LENGTH = 64;

private const SESSION_ID_LENGTH = 32;
private const PRE_AUTH_TOKEN_LENGTH = 32;
private const APP_ID_PATTERN = '/^[a-z0-9_.-]+$/';
private const SESSION_ID_PATTERN = '/^[a-z0-9_.-]+:[a-zA-Z0-9]+$/';

public function __construct(
private readonly IQueue $queue,
private readonly ISecureRandom $random,
private readonly IAppConfig $appConfig,
private readonly ITimeFactory $timeFactory,
) {
}

#[\Override]
public function createSession(string $appId, int $ttl = self::DEFAULT_TTL): AnonymousSession {
if ($appId === '' || preg_match(self::APP_ID_PATTERN, $appId) !== 1) {
throw new \InvalidArgumentException('Invalid app id: ' . $appId);
}

$id = $appId . ':' . $this->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);
}
}
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading