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

support token based provider for APNs #138

Merged
merged 9 commits into from
Jan 29, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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
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 | | |
cubicdaiya marked this conversation as resolved.
Show resolved Hide resolved
| 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()
cubicdaiya marked this conversation as resolved.
Show resolved Hide resolved
}
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