Implementing secure web push notifications at Huddle
Huddle is a secure collaboration platform that allows people to work in workspaces, where they can produce, upload, and comment on content as well as create and assign tasks and file requests, and request approvals. We send out email notifications when something of interest to the user happens in Huddle. For example, if someone @mentions you in a comment or invites you to a new workspace, you’ll get an email. However, emails can sometimes get lost or filtered away, and not everyone has their email client open all the time.
Web push notifications
Thanks to new web APIs, web applications can now send push notifications to users, even if the web app isn’t open in any tab. In fact, Microsoft Edge shows notifications natively on the desktop even if the browser is completely closed. Google Chrome for Android shows a native notification on the device when a web push notification is triggered. Push notifications are possible thanks to two core web technologies:
This article doesn’t dive into how to write a push server or use the Notification API — plenty of blogs and tutorials cover this in detail. Here are some guides I found useful:
Instead, this article focuses on sending and handling notifications on the web as securely as possible, and our experience getting there at Huddle.
Note: Code snippets in this article are for illustration purposes only and are not the final product of the project.
Web push notifications… an innovation time experiment at Huddle 💡
Huddle gives developers two days every two weeks to work on their own projects that produce value to Huddle as a business. I started to look into how we can use service workers in the Huddle web app to improve the user experience. After some investigation and a lot of discussion with the team, I found that the most feasible usage of service workers that would produce a good user experience improvement was to implement push notifications.
The idea was simple: every time something happened in Huddle that the user might care about, we’d send a push notification to the user’s browser. This means that the project was composed of two different tasks: 1) figuring out when something interesting happened and 2) notifiying the user via web push when something happens.
The hard work was already done…
Event-driven architecture
Huddle’s software stack follows an Event Driven Architecture. That is, when a notable thing happens inside or outside a Huddle sub-system (or Business Capability), an Event is raised which disseminates immediately to all interested automated parties. The interested parties evaluate the event, and optionally take action. The event-driven action may include the invocation of a service, the triggering of a business process, and/or further information publication/syndication. By its nature, an event driven architecture is extremely loosely coupled, and highly distributed. Therefore adding new consumers to react to existing events is an almost trivial operation on a message queue. In Huddle’s case, a RabbitMQ ® consumer.
Notifications are just events
Notifications are a concept within Huddle, whereby a Huddle User receives an in-app message or email regarding an event they care about. Therefore, web push notifications have a clear integration point — to subscribe to receive Events on the NotificationCreatedEvent
RabbitMQ topic.
So the first task was to use a node.js Rabbit MQ library to listen for Notification events. I decided amqp.node for this as it was mentioned in the RabbitMQ tutorial. In fact, the code looks very similar to the official publish/subscribe tutorial, so if you’re interested in the details of RabbitMQ with Node.js, do have a read. I re-wrote the subscribe in ES6 and wrapped it up so I can call any callback from another module:
Now, I can listen to notification events from elsewhere in my node.js app, without worrying about the underlying RabbitMQ implementation. An example of this usage can be seen below:
const connectAndListen = require('./connectandlisten');
connectAndListen(notificationData => {
console.log('Received notification!', notificationData);
});
The notificationData
we get back in the callback simply has the user id and the notification id. We can do a Huddle API call to get back the details of the notification, given a notification ID.
Great! So now, the next step is all about subscribing users and using connectAndListen
to send them web push notifications.
Securely subscribing users
Huddle uses OAuth to make authenticated API calls on behalf of the user. The easiest way to get this experiment live was to create a new client that would perform API requests even if the user is logged out from the web app. We don’t want to share the web app’s auth tokens because this would cause conflicts when refreshing the tokens.
There are 5 main entities in the subscribe flow:
- The user — a human being who wants to subscribe to notifications.
- The web app — a JS single page application who’s authenticated using a OAuth client.
- The browser — Not just any browser. One that supports service workers.
- The webpush server — a node.js application that will send out push notifications.
- Huddle — accessible via authenticated API calls
The flow is split into two main phases: first getting the push subscription from the browser, then securely sending it to the webpush server.
1. Getting the push subscription
This bit is not much different from what you’d find in other tutorials, so I won’t mention a lot of details here. If you’re not familiar with web push notifications, I encourage you to have a look at one of the guides mentioned at the start of this article.
We first ask the browser for notification permissions, then register the service worker, then use the pushManager to get a push subscription.
What we get back is a PushSubscription
object. This is what we need to send to the webpush server.
2. Sending the subscription to the webpush server securely
We now need to send the PushSubscription
to the webpush server. To do this, we need to serialise it and use an HTTP POST request to the webpush server, but we also need to send the webpush server information about the user. Given a user id and a PushSubscription
, the web push server can store this information so that when a notification happens, it can look up the PushSubscription
for that user id and send a web push notification.
But we need to be secure about how we send the user id — if we just sent it alone, this would allow anyone to get access to that user’s notification. That’s why we also send the user’s auth token. We can check that the user is who they are claiming to be by doing a Huddle user API call with the token the user is providing. If the Huddle API call returns successfully, we confirm that the user id is valid.
Authenticating the user against our own webpush client
We now need to authenticate the user for the new webpush oauth client. This is because we want to be able to do API calls on the user’s behalf. An alternative is to re-use the same auth token that the user sent, but this has problems. First, there’s a chance the token might have expired on the web app client, so we’d need to refresh it on the webpush client. But when we do, it invalidates it on the web app client again — and thus a vicious cycle of auth token refreshes would kick off. Second, a different client would allow us to do API calls even if the user is logged out of the web app. This is important for us because when the user closes their browser window, they would get logged out of the web app — and therefore they would not get web push notifications.
To authenticate the user for the webpush client, we go through the normal OAuth2 flow. I’ll skip the details, but in the end, we store the new auth token and refresh token against the user so that any time we need to do an API call we can get the auth token.
Storing the push subscription for the user id
Once we confirm the user is who they claim to be and authenticate them against our own webpush client, we can store the PushSubscription
against the user id. I decided to use a Redis database for this as we had existing infrastructure at Huddle that used Redis. Redis also supports self-destructing storage keys, which I’ll explain how they came in handy later in this article.
The overall subscription flow can be seen in the diagram below:
Securely sending the push notifications
We can use the connectAndListen
function which we discussed earlier to listen for Notification events and use the Node.js web-push
library to send out the push notifications. Given the notificationData
, we can get the user id to whom the notification relates using notificationData.recipientId
. Using this user id, we can check our database to see if that user is subscribed to web push notifications. If they are, we can use the Huddle notification API (using the auth token we have stored against the user) to get the actual notification data.
The code below sums this up:
We use createApi
to give us a helper API library that handles authentication and token refresh. And we use createUserStore
to give us a storage helper library that sets and retrieves data about the user from Redis. Their implementations are simple wrappers that aren’t very useful to share.
At the end of the function, we call notifySubscription(subscription, formattedItem)
for each subscription
that is associated with the user. This function is responsible for sending the message securely to the browser.
A simple implementation of notifySubscription
would simple use theweb-push
library to send a the formattedItem
directly:
This would work well for most cases. Notification message data is encrypted browser-to-server, so even if the browser vendor (Google/Mozilla/Microsoft) get access to that data as they are a man in the middle, they won’t be able to read it.
But at Huddle we wanted to make sure it’s even more secure. Instead of sending the message directly, we generate and send a unique guid. Here’s what that looks like:
Where storeNotificationAndGenerateNewId
would use Redis and a secure guid library like uuid to store the data against that guid.
When the service worker receives it, it will call a Huddle API call to webpush to retrieve the data:
From this complicated hop, we achieve a few good characteristics:
- We never store the data on any other server — even if the data is encrypted!
- We are able to audit when the service worker wakes up to get the data.
- We are able to easily enforce a time limit (using a TTL on the storage item in Redis) in which the service worker can retrieve the data.
- We can ensure that the data is only ever retrieved once — this prevents sending notifications multiple times to lots of different browsers if the user subscribed on different devices. Whichever service worker wakes up first, will get the data first!
- We can iterate and improve on the rules and mechanics above because it is powered by a guid
Here’s the full picture for notification flow:
The result
Our experiment is going to be rolled out to Huddlers (internally) only first, then we’ll use the feedback to learn how to surface these notifications better and test it out with real users.
Thanks to Ian Pender for contributing the “Event driven architecture” section.
Special thank you to Ian Pender and Liam Westley for their architectural guidance and helping keep this project moving.