Skip to content

Commit

Permalink
Merge pull request #26 from rossta/vapid
Browse files Browse the repository at this point in the history
Implement VAPID authorization
  • Loading branch information
zaru authored Oct 14, 2016
2 parents 18fff9b + de87c4c commit 15b68f5
Show file tree
Hide file tree
Showing 11 changed files with 648 additions and 110 deletions.
202 changes: 193 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Build Status](https://travis-ci.org/zaru/webpush.svg?branch=master)](https://travis-ci.org/zaru/webpush)
[![Gem Version](https://badge.fury.io/rb/webpush.svg)](https://badge.fury.io/rb/webpush)

This Gem will send the Web Push API. It supports the encryption necessary to payload.
This gem makes it possible to send push messages to web browsers from Ruby backends using the [Web Push Protocol](https://tools.ietf.org/html/draft-ietf-webpush-protocol-10). It supports [Message Encryption for Web Push](https://tools.ietf.org/html/draft-ietf-webpush-encryption) to send messages securely from server to user agent.

Payload is supported by Chrome50+, Firefox48+.

Expand All @@ -26,7 +26,188 @@ Or install it yourself as:

## Usage

### using the payload
Sending a web push message to a visitor of your website requires a number of steps:

1. Your server has (optionally) generated (one-time) a set of [Voluntary Application server Identification (VAPID)](https://tools.ietf.org/html/draft-ietf-webpush-vapid-01) keys.
2. To send messages through Chrome, you have registered your site through the [Google Developer Console](https://console.developers.google.com/) and have obtained a GCM sender id. For using Google's deprecated GCM protocol instead of VAPID, a separate GCM API key from your app settings would also be necessary.
3. A 'manifest.json' file, linked from your user's page, identifies your app settings, including the GCM sender ID.
5. Also in the user's web browser, a `serviceWorker` is installed and activated and its `pushManager` property is subscribed to push events with your VAPID public key, with creates a `subscription` JSON object on the client side.
6. Your server uses the `webpush` gem to send a notification with the `subscription` obtained from the client and an optional payload (the message).
7. Your service worker is set up to receive `'push'` events. To trigger a desktop notification, the user has accepted the prompt to receive notifications from your site.

### Generating VAPID keys

Use `webpush` to generate a VAPID key that has both a `public_key` and `private_key` attribute to be saved on the server side.

```ruby
# One-time, on the server
vapid_key = Webpush.generate_key

# Save these in your application server settings
vapid_key.public_key
vapid_key.private_key
```

### Declaring manifest.json

For Chrome web push, add the GCM sender id to a `manifest.json` file served at the scope of your app (or above), like at the root.

```javascript
{
"name": "My Website",
"gcm_sender_id": "1006629465533"
}
```

And link to it somewhere in the `<head>` tag:

```html
<!-- index.html -->
<link rel="manifest" href="/manifest.json" />
```

### Installing a service worker

Your application javascript must register a service worker script at an appropriate scope (we're sticking with the root).

```javascript
// application.js
// Register the serviceWorker script at /serviceworker.js from your server if supported
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/serviceworker.js')
.then(function(reg) {
console.log('Service worker change, registered the service worker');
});
}
// Otherwise, no push notifications :(
else {
console.error('Service worker is not supported in this browser');
}
```

### Subscribing to push notifications

The VAPID public key you generated earlier is made available to the client as a `UInt8Array`. To do this, one way would be to expose the urlsafe-decoded bytes from Ruby to JavaScript when rendering the HTML template. (Global variables used here for simplicity).

```javascript
window.vapidPublicKey = new Uint8Array(<%= Base64.urlsafe_decode64(ENV['VAPID_PUBLIC_KEY']).bytes %>);
```

Your application javascript would then use the `navigator.serviceWorker.pushManager` to subscribe to push notifications, passing the VAPID public key to the subscription settings.

```javascript
// application.js
// When serviceWorker is supported, installed, and activated,
// subscribe the pushManager property with the vapidPublicKey
navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: window.vapidPublicKey
});
});
```

Note: If you will not be sending VAPID details, then there is no need generate VAPID
keys, and the `applicationServerKey` parameter may be omitted from the
`pushManager.subscribe` call.

### Triggering a web push notification

Hook into an client-side or backend event in your app to deliver a push message. The server must be made aware of the `subscription`. In the example below, we send the JSON generated subscription object to our backend at the "/push" endpoint with a message.

```javascript
// application.js
// Send the subscription and message from the client for the backend
// to set up a push notification
$(".webpush-button").on("click", (e) => {
navigator.serviceWorker.ready
.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.getSubscription()
.then((subscription) => {
$.post("/push", { subscription: subscription.toJSON(), message: "You clicked a button!" });
});
});
```
Imagine a Ruby app endpoint that responds to the request by triggering notification through the `webpush` gem.
```ruby
# app.rb
# Use the webpush gem API to deliver a push notiifcation merging
# the message, subscription values, and vapid options
post "/push" do
Webpush.payload_send(
message: params[:message]
endpoint: params[:subscription][:endpoint],
p256dh: params[:subscription][:keys][:p256dh],
auth: params[:subscription][:keys][:p256dh],
vapid: {
subject: "mailto:[email protected]",
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
}
)
end
```
Note: the VAPID options should be omitted if the client-side subscription was
generated without the `applicationServerKey` parameter described earlier.
### Receiving the push event
Your `/serviceworker.js` script can respond to `'push'` events. One action it can take is to trigger desktop notifications by calling `showNotification` on the `registration` property.
```javascript
// serviceworker.js
// The serviceworker context can respond to 'push' events and trigger
// notifications on the registration property
self.addEventListener("push", (event) => {
let title = (event.data && event.data.text()) || "Yay a message";
let body = "We have received a push message";
let tag = "push-simple-demo-notification-tag";
let icon = '/assets/my-logo-120x120.png';

event.waitUntil(
self.registration.showNotification(title, { body, icon, tag })
)
});
```
Before the notifications can be displayed, the user must grant permission for [notifications](https://developer.mozilla.org/en-US/docs/Web/API/notification) in a browser prompt, using something like the example below.
```javascript
// application.js

// Let's check if the browser supports notifications
if (!("Notification" in window)) {
console.error("This browser does not support desktop notification");
}

// Let's check whether notification permissions have already been granted
else if (Notification.permission === "granted") {
console.log("Permission to receive notifications has been granted");
}

// Otherwise, we need to ask the user for permission
else if (Notification.permission !== 'denied') {
Notification.requestPermission(function (permission) {
// If the user accepts, let's create a notification
if (permission === "granted") {
console.log("Permission to receive notifications has been granted");
}
});
}
```
If everything worked, you should see a desktop notification triggered via web
push. Yay!
Note: if you're using Rails, check out [serviceworker-rails](https://github.com/rossta/serviceworker-rails), a gem that makes it easier to host serviceworker scripts and manifest.json files at canonical endpoints (i.e., non-digested URLs) while taking advantage of the asset pipeline.
## API
### With a payload
```ruby
message = {
Expand All @@ -36,22 +217,25 @@ message = {
}

Webpush.payload_send(
endpoint: "https://android.googleapis.com/gcm/send/eah7hak....",
endpoint: "https://fcm.googleapis.com/gcm/send/eah7hak....",
message: JSON.generate(message),
p256dh: "BO/aG9nYXNkZmFkc2ZmZHNmYWRzZmFl...",
auth: "aW1hcmthcmFpa3V6ZQ==",
ttl: 600, #optional, ttl in seconds, defaults to 2419200 (4 weeks)
api_key: "[GoogleDeveloper APIKEY]" # optional, not used in Firefox.
ttl: 600 #optional, ttl in seconds, defaults to 2419200 (4 weeks),
vapid: {
subject: "mailto:[email protected]",
public_key: ENV['VAPID_PUBLIC_KEY'],
private_key: ENV['VAPID_PRIVATE_KEY']
}
)
```
### not use the payload
### Without a payload
```ruby
Webpush.payload_send(
endpoint: "https://android.googleapis.com/gcm/send/eah7hak....",
ttl: 600, #optional, ttl in seconds, defaults to 2419200 (4 weeks)
api_key: "[GoogleDeveloper APIKEY]" # optional, not used in Firefox.
endpoint: "https://fcm.googleapis.com/gcm/send/eah7hak....",
ttl: 600 #optional, ttl in seconds, defaults to 2419200 (4 weeks)
)
```
Expand Down
42 changes: 24 additions & 18 deletions lib/webpush.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,12 @@
require 'json'

require 'webpush/version'
require 'webpush/errors'
require 'webpush/vapid_key'
require 'webpush/encryption'
require 'webpush/request'

module Webpush

# It is temporary URL until supported by the GCM server.
GCM_URL = 'https://android.googleapis.com/gcm/send'
TEMP_GCM_URL = 'https://gcm-http.googleapis.com/gcm'

class << self
# Deliver the payload to the required endpoint given by the JavaScript
# PushSubscription. Including an optional message requires p256dh and
Expand All @@ -23,23 +20,32 @@ class << self
# @param message [String] the optional payload
# @param p256dh [String] the user's public ECDH key given by the PushSubscription
# @param auth [String] the user's private ECDH key given by the PushSubscription
# @param vapid [Hash<Symbol,String>] options for VAPID
# @option vapid [String] :subject contact URI for the app server as a "mailto:" or an "https:"
# @option vapid [String] :public_key the VAPID public key
# @option vapid [String] :private_key the VAPID private key
# @param options [Hash<Symbol,String>] additional options for the notification
# @option options [String] :api_key required for Google, omit for Firefox
# @option options [#to_s] :ttl Time-to-live in seconds
def payload_send(endpoint:, message: "", p256dh: "", auth: "", **options)
endpoint = endpoint.gsub(GCM_URL, TEMP_GCM_URL)

payload = build_payload(message, p256dh, auth)

Webpush::Request.new(endpoint, options.merge(payload: payload)).perform
def payload_send(message: "", endpoint:, p256dh: "", auth: "", vapid: {}, **options)
subscription = {
endpoint: endpoint,
keys: {
p256dh: p256dh,
auth: auth
}
}
Webpush::Request.new(
message: message,
subscription: subscription,
vapid: vapid,
**options
).perform
end

private

def build_payload(message, p256dh, auth)
return {} if message.nil? || message.empty?

Webpush::Encryption.encrypt(message, p256dh, auth)
# public_key: vapid_key.public_key.to_bn.to_s(2)
# private_key: vapid_key.private_key.to_s(2)
def generate_key
VapidKey.new
end
end
end
1 change: 1 addition & 0 deletions lib/webpush/encryption.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def encrypt(message, p256dh, auth)
ciphertext: ciphertext,
salt: salt,
server_public_key_bn: convert16bit(server_public_key_bn),
server_public_key: server_public_key_bn.to_s(2),
shared_secret: shared_secret
}
end
Expand Down
9 changes: 9 additions & 0 deletions lib/webpush/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Webpush
class Error < RuntimeError; end

class ConfigurationError < Error; end

class ResponseError < Error; end

class InvalidSubscription < ResponseError; end
end
Loading

0 comments on commit 15b68f5

Please sign in to comment.