Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
support token based provider for APNs
Browse files Browse the repository at this point in the history
  • Loading branch information
catatsuy committed Jan 22, 2020
1 parent 92f8cb5 commit 970f405
Show file tree
Hide file tree
Showing 20 changed files with 465 additions and 41 deletions.
27 changes: 15 additions & 12 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,21 @@ The configuration for Gaurun has some sections. The example is [here](conf/gauru

## iOS Section

| name | type | description | default | note |
| ------------------ | ------ | ------------------------------------------------------ | ---------------- | ---- |
| enabled | bool | On/Off for push notication to APNs | true | |
| pem_cert_path | string | certification file path for APNs | | |
| pem_key_path | string | secret key file path for APNs | | |
| pem_key_passphrase | string | secret key file pass phrase for APNs | | |
| sandbox | bool | On/Off for sandbox environment | true | |
| retry_max | int | maximum retry count for push notication to APNs | 1 | |
| timeout | int | timeout for push notification to APNs | 5 | |
| keepalive_timeout | int | time for continuing keep-alive connection to APNs | 90 | |
| keepalive_conns | int | number of keep-alive connection to APNs | runtime.NumCPU() | |
| topic | string | the assigned value of `apns-topic` for Request headers | | |
| name | type | description | default | note |
| ------------------ | ------ | -------------------------------------------------------- | ---------------- | ---- |
| enabled | bool | On/Off for push notication to APNs | true | |
| pem_cert_path | string | certification file path for APNs | | |
| pem_key_path | string | secret key file path for APNs | | |
| pem_key_passphrase | string | secret key file pass phrase for APNs | | |
| auth_key_path | string | secret APNs auth key file (.p8) for token based provider | | |
| key_id | string | APNs key id for token based provider | | |
| team_id | string | APNs team id for token based provider | | |
| sandbox | bool | On/Off for sandbox environment | true | |
| retry_max | int | maximum retry count for push notication to APNs | 1 | |
| timeout | int | timeout for push notification to APNs | 5 | |
| keepalive_timeout | int | time for continuing keep-alive connection to APNs | 90 | |
| keepalive_conns | int | number of keep-alive connection to APNs | runtime.NumCPU() | |
| topic | string | the assigned value of `apns-topic` for Request headers | | |

`topic` is mandatory when the client is connected using the certificate that supports multiple topics.

Expand Down
22 changes: 22 additions & 0 deletions buford/push/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ var (
ErrUnregistered = errors.New("Unregistered")
ErrDeviceTokenNotForTopic = errors.New("DeviceTokenNotForTopic")

// Token authentication errors.
ErrMissingProviderToken = errors.New("MissingProviderToken")
ErrInvalidProviderToken = errors.New("InvalidProviderToken")
ErrExpiredProviderToken = errors.New("ExpiredProviderToken")
ErrTooManyProviderTokenUpdates = errors.New("TooManyProviderTokenUpdates")

// These errors should never happen when using Push.
ErrDuplicateHeaders = errors.New("DuplicateHeaders")
ErrBadPath = errors.New("BadPath")
Expand Down Expand Up @@ -105,6 +111,14 @@ func mapErrorReason(reason string) error {
e = ErrMissingTopic
case "InvalidPushType":
e = ErrInvalidPushType
case "MissingProviderToken":
e = ErrMissingProviderToken
case "InvalidProviderToken":
e = ErrInvalidProviderToken
case "ExpiredProviderToken":
e = ErrExpiredProviderToken
case "TooManyProviderTokenUpdates":
e = ErrTooManyProviderTokenUpdates
default:
e = errors.New(reason)
}
Expand Down Expand Up @@ -141,6 +155,14 @@ func (e *Error) Error() string {
return "the Topic header of the request was not specified and was required"
case ErrInvalidPushType:
return "the apns-push-type value is invalid"
case ErrMissingProviderToken:
return "no provider certificate was used to connect to APNs and Authorization header was missing or no provider token was specified"
case ErrInvalidProviderToken:
return "the provider token is not valid or the token signature could not be verified"
case ErrExpiredProviderToken:
return "the provider token is stale and a new token should be generated"
case ErrTooManyProviderTokenUpdates:
return "the provider token is being updated too often"
case ErrTopicDisallowed:
return "pushing to this topic is not allowed"
case ErrUnregistered:
Expand Down
9 changes: 9 additions & 0 deletions buford/push/header.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package push

import (
"fmt"
"net/http"
"strconv"
"time"

"github.com/mercari/gaurun/buford/token"
)

// Headers sent with a push to control the notification (optional)
Expand All @@ -26,6 +29,8 @@ type Headers struct {
// Topic for certificates with multiple topics.
Topic string

AuthToken *token.Token

PushType PushType
}

Expand Down Expand Up @@ -66,4 +71,8 @@ func (h *Headers) set(reqHeader http.Header) {
if h.PushType != "" {
reqHeader.Set("apns-push-type", string(h.PushType))
}

if h.AuthToken != nil {
reqHeader.Set("authorization", fmt.Sprintf("bearer %s", h.AuthToken.GenerateBearerIfExpired()))
}
}
42 changes: 42 additions & 0 deletions buford/push/header_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package push

import (
"net/http"
"strings"
"testing"
"time"

"github.com/mercari/gaurun/buford/token"
)

func TestHeaders(t *testing.T) {
Expand All @@ -25,6 +28,43 @@ func TestHeaders(t *testing.T) {
testHeader(t, reqHeader, "apns-priority", "5")
testHeader(t, reqHeader, "apns-topic", "bundle-id")
testHeader(t, reqHeader, "apns-push-type", "alert")
testHeader(t, reqHeader, "authorization", "")
}

func TestHeadersAuthToken(t *testing.T) {
ak, err := token.AuthKeyFromFile("testdata/authkey-valid.p8")
if err != nil {
t.Fatal(err)
}

headers := Headers{
ID: "uuid",
CollapseID: "game1.score.identifier",
Expiration: time.Unix(12622780800, 0),
LowPriority: true,
Topic: "bundle-id",
PushType: PushTypeAlert,
AuthToken: &token.Token{
AuthKey: ak,
KeyID: "key_id",
TeamID: "team_id",
},
}

reqHeader := http.Header{}
headers.set(reqHeader)

testHeader(t, reqHeader, "apns-id", "uuid")
testHeader(t, reqHeader, "apns-collapse-id", "game1.score.identifier")
testHeader(t, reqHeader, "apns-expiration", "12622780800")
testHeader(t, reqHeader, "apns-priority", "5")
testHeader(t, reqHeader, "apns-topic", "bundle-id")
testHeader(t, reqHeader, "apns-push-type", "alert")

actual := reqHeader.Get("authorization")
if !strings.HasPrefix(actual, "bearer ") {
t.Errorf("expected authorization header is the beginning of `beaer`, but got %s", actual)
}
}

func TestNilHeader(t *testing.T) {
Expand All @@ -38,6 +78,7 @@ func TestNilHeader(t *testing.T) {
testHeader(t, reqHeader, "apns-priority", "")
testHeader(t, reqHeader, "apns-topic", "")
testHeader(t, reqHeader, "apns-push-type", "")
testHeader(t, reqHeader, "authorization", "")
}

func TestEmptyHeaders(t *testing.T) {
Expand All @@ -51,6 +92,7 @@ func TestEmptyHeaders(t *testing.T) {
testHeader(t, reqHeader, "apns-priority", "")
testHeader(t, reqHeader, "apns-topic", "")
testHeader(t, reqHeader, "apns-push-type", "")
testHeader(t, reqHeader, "authorization", "")
}

func testHeader(t *testing.T, reqHeader http.Header, key, expected string) {
Expand Down
5 changes: 5 additions & 0 deletions buford/push/testdata/authkey-valid.p8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE
ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI
tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfeQl
-----END PRIVATE KEY-----
28 changes: 28 additions & 0 deletions buford/token/testdata/authkey-invalid-ecdsa.p8
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDfdOqotHd55SYO
0dLz2oXengw/tZ+q3ZmOPeVmMuOMIYO/Cv1wk2U0OK4pug4OBSJPhl09Zs6IwB8N
wPOU7EDTgMOcQUYB/6QNCI1J7Zm2oLtuchzz4pIb+o4ZAhVprLhRyvqi8OTKQ7kf
Gfs5Tuwmn1M/0fQkfzMxADpjOKNgf0uy6lN6utjdTrPKKFUQNdc6/Ty8EeTnQEwU
lsT2LAXCfEKxTn5RlRljDztS7Sfgs8VL0FPy1Qi8B+dFcgRYKFrcpsVaZ1lBmXKs
XDRu5QR/Rg3f9DRq4GR1sNH8RLY9uApMl2SNz+sR4zRPG85R/se5Q06Gu0BUQ3UP
m67ETVZLAgMBAAECggEADjU54mYvHpICXHjc5+JiFqiH8NkUgOG8LL4kwt3DeBp9
bP0+5hSJH8vmzwJkeGG9L79EWG4b/bfxgYdeNX7cFFagmWPRFrlxbd64VRYFawZH
RJt+2cbzMVI6DL8EK4bu5Ux5qTiV44Jw19hoD9nDzCTfPzSTSGrKD3iLPdnREYaI
GDVxcjBv3Tx6rrv3Z2lhHHKhEHb0RRjATcjAVKV9NZhMajJ4l9pqJ3A4IQrCBl95
ux6Xm1oXP0i6aR78cjchsCpcMXdP3WMsvHgTlsZT0RZLFHrvkiNHlPiil4G2/eHk
wvT//CrcbO6SmI/zCtMmypuHJqcr+Xb7GPJoa64WoQKBgQDwrfelf3Rdfo9kaK/b
rBmbu1++qWpYVPTedQy84DK2p3GE7YfKyI+fhbnw5ol3W1jjfvZCmK/p6eZR4jgy
J0KJ76z53T8HoDTF+FTkR55oM3TEM46XzI36RppWP1vgcNHdz3U4DAqkMlAh4lVm
3GiKPGX5JHHe7tWz/uZ55Kk58QKBgQDtrkqdSzWlOjvYD4mq4m8jPgS7v3hiHd+1
OT8S37zdoT8VVzo2T4SF+fBhI2lWYzpQp2sCjLmCwK9k/Gur55H2kTBTwzlQ6WSL
Te9Zj+eoMGklIirA+8YdQHXrO+CCw9BTJAF+c3c3xeUOLXafzyW29bASGfUtA7Ax
QAsR+Rr3+wKBgAwfZxrh6ZWP+17+WuVArOWIMZFj7SRX2yGdWa/lxwgmNPSSFkXj
hkBttujoY8IsSrTivzqpgCrTCjPTpir4iURzWw4W08bpjd7u3C/HX7Y16Uq8ohEJ
T5lslveDJ3iNljSK74eMK7kLg7fBM7YDogxccHJ1IHsvInp3e1pmZxOxAoGAO+bS
TUQ4N/UuQezgkF3TDrnBraO67leDGwRbfiE/U0ghQvqh5DA0QSPVzlWDZc9KUitv
j8vxsR9o1PW9GS0an17GJEYuetLnkShKK3NWOhBBX6d1yP9rVdH6JhgIJEy/g0Su
z7TAFiFc8i7JF8u4QJ05C8bZAMhOLotqftQeVOMCgYAid8aaRvaM2Q8a42Jn6ZTT
5ms6AvNr98sv0StnfmNQ+EYXN0bEk2huSW+w2hN34TYYBTjViQmHbhudwwu8lVjE
ccDmIXsUFbHVK+kTIpWGGchy5cYPs3k9s1nMR2av0Lojtw9WRY76xRXvN8W6R7Eh
wA2ax3+gEEYpGhjM/lO2Lg==
-----END PRIVATE KEY-----
27 changes: 27 additions & 0 deletions buford/token/testdata/authkey-invalid-pkcs8.p8
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEA33TqqLR3eeUmDtHS89qF3p4MP7Wfqt2Zjj3lZjLjjCGDvwr9
cJNlNDiuKboODgUiT4ZdPWbOiMAfDcDzlOxA04DDnEFGAf+kDQiNSe2ZtqC7bnIc
8+KSG/qOGQIVaay4Ucr6ovDkykO5Hxn7OU7sJp9TP9H0JH8zMQA6YzijYH9LsupT
errY3U6zyihVEDXXOv08vBHk50BMFJbE9iwFwnxCsU5+UZUZYw87Uu0n4LPFS9BT
8tUIvAfnRXIEWCha3KbFWmdZQZlyrFw0buUEf0YN3/Q0auBkdbDR/ES2PbgKTJdk
jc/rEeM0TxvOUf7HuUNOhrtAVEN1D5uuxE1WSwIDAQABAoIBAA41OeJmLx6SAlx4
3OfiYhaoh/DZFIDhvCy+JMLdw3gafWz9PuYUiR/L5s8CZHhhvS+/RFhuG/238YGH
XjV+3BRWoJlj0Ra5cW3euFUWBWsGR0SbftnG8zFSOgy/BCuG7uVMeak4leOCcNfY
aA/Zw8wk3z80k0hqyg94iz3Z0RGGiBg1cXIwb908eq6792dpYRxyoRB29EUYwE3I
wFSlfTWYTGoyeJfaaidwOCEKwgZfebsel5taFz9Iumke/HI3IbAqXDF3T91jLLx4
E5bGU9EWSxR675IjR5T4opeBtv3h5ML0//wq3GzukpiP8wrTJsqbhyanK/l2+xjy
aGuuFqECgYEA8K33pX90XX6PZGiv26wZm7tfvqlqWFT03nUMvOAytqdxhO2HysiP
n4W58OaJd1tY4372Qpiv6enmUeI4MidCie+s+d0/B6A0xfhU5EeeaDN0xDOOl8yN
+kaaVj9b4HDR3c91OAwKpDJQIeJVZtxoijxl+SRx3u7Vs/7meeSpOfECgYEA7a5K
nUs1pTo72A+JquJvIz4Eu794Yh3ftTk/Et+83aE/FVc6Nk+EhfnwYSNpVmM6UKdr
Aoy5gsCvZPxrq+eR9pEwU8M5UOlki03vWY/nqDBpJSIqwPvGHUB16zvggsPQUyQB
fnN3N8XlDi12n88ltvWwEhn1LQOwMUALEfka9/sCgYAMH2ca4emVj/te/lrlQKzl
iDGRY+0kV9shnVmv5ccIJjT0khZF44ZAbbbo6GPCLEq04r86qYAq0woz06Yq+IlE
c1sOFtPG6Y3e7twvx1+2NelKvKIRCU+ZbJb3gyd4jZY0iu+HjCu5C4O3wTO2A6IM
XHBydSB7LyJ6d3taZmcTsQKBgDvm0k1EODf1LkHs4JBd0w65wa2juu5XgxsEW34h
P1NIIUL6oeQwNEEj1c5Vg2XPSlIrb4/L8bEfaNT1vRktGp9exiRGLnrS55EoSitz
VjoQQV+ndcj/a1XR+iYYCCRMv4NErs+0wBYhXPIuyRfLuECdOQvG2QDITi6Lan7U
HlTjAoGAInfGmkb2jNkPGuNiZ+mU0+ZrOgLza/fLL9ErZ35jUPhGFzdGxJNobklv
sNoTd+E2GAU41YkJh24bncMLvJVYxHHA5iF7FBWx1SvpEyKVhhnIcuXGD7N5PbNZ
zEdmr9C6I7cPVkWO+sUV7zfFukexIcANmsd/oBBGKRoYzP5Tti4=
-----END RSA PRIVATE KEY-----
3 changes: 3 additions & 0 deletions buford/token/testdata/authkey-invalid.p8
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE
ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI
tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfZZZ
5 changes: 5 additions & 0 deletions buford/token/testdata/authkey-valid.p8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE
ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI
tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfeQl
-----END PRIVATE KEY-----
111 changes: 111 additions & 0 deletions buford/token/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Package token
// original: https://github.com/sideshow/apns2/blob/master/token/token.go
// Copyright (c) 2016 Adam Jones
package token

import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"io/ioutil"
"sync"
"time"

jwt "github.com/dgrijalva/jwt-go"
)

const (
// TokenTimeout is the period of time in seconds that a token is valid for.
// If the timestamp for token issue is not within the last hour, APNs
// rejects subsequent push messages. This is set to under an hour so that
// we generate a new token before the existing one expires.
TokenTimeout = 3000
)

// Possible errors when parsing a .p8 file.
var (
ErrAuthKeyNotPem = errors.New("token: AuthKey must be a valid .p8 PEM file")
ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
ErrAuthKeyNil = errors.New("token: AuthKey was nil")
)

// Token represents an Apple Provider Authentication Token (JSON Web Token).
type Token struct {
sync.Mutex
AuthKey *ecdsa.PrivateKey
KeyID string
TeamID string
IssuedAt int64
Bearer string
}

// AuthKeyFromFile loads a .p8 certificate from a local file and returns a
func AuthKeyFromFile(filename string) (*ecdsa.PrivateKey, error) {
bytes, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return AuthKeyFromBytes(bytes)
}

// AuthKeyFromBytes loads a .p8 certificate from an in memory byte array and
func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(bytes)
if block == nil {
return nil, ErrAuthKeyNotPem
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
switch pk := key.(type) {
case *ecdsa.PrivateKey:
return pk, nil
default:
return nil, ErrAuthKeyNotECDSA
}
}

// GenerateBearerIfExpired checks to see if the token is about to expire and
// generates a new token.
func (t *Token) GenerateBearerIfExpired() (bearer string) {
t.Lock()
defer t.Unlock()
if t.Expired() {
// TODO: error handling
t.Generate()
}
return t.Bearer
}

// Expired checks to see if the token has expired.
func (t *Token) Expired() bool {
return time.Now().Unix() >= (t.IssuedAt + TokenTimeout)
}

// Generate creates a new token.
func (t *Token) Generate() (bool, error) {
if t.AuthKey == nil {
return false, ErrAuthKeyNil
}
issuedAt := time.Now().Unix()
jwtToken := &jwt.Token{
Header: map[string]interface{}{
"alg": "ES256",
"kid": t.KeyID,
},
Claims: jwt.MapClaims{
"iss": t.TeamID,
"iat": issuedAt,
},
Method: jwt.SigningMethodES256,
}
bearer, err := jwtToken.SignedString(t.AuthKey)
if err != nil {
return false, err
}
t.IssuedAt = issuedAt
t.Bearer = bearer
return true, nil
}
Loading

0 comments on commit 970f405

Please sign in to comment.