Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Apple JWT Token based authentication #43

Merged
merged 2 commits into from
Sep 26, 2017
Merged
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,8 @@ _testmain.go
*.exe
*.test
*.prof

/*.p12
/*.pem
/*.cer
/*.p8
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# APNS/2

NOTE: This is an experimental branch for the purpose of testing the new token based authentication

APNS/2 is a go package designed for simple, flexible and fast Apple Push Notifications on iOS, OSX and Safari using the new HTTP/2 Push provider API.

[![Build Status](https://travis-ci.org/sideshow/apns2.svg?branch=master)](https://travis-ci.org/sideshow/apns2) [![Coverage Status](https://coveralls.io/repos/sideshow/apns2/badge.svg?branch=master&service=github)](https://coveralls.io/github/sideshow/apns2?branch=master) [![GoDoc](https://godoc.org/github.com/sideshow/apns2?status.svg)](https://godoc.org/github.com/sideshow/apns2)
Expand All @@ -9,6 +11,7 @@ APNS/2 is a go package designed for simple, flexible and fast Apple Push Notific
- Uses new Apple APNs HTTP/2 connection
- Fast - See [notes on speed](https://github.com/sideshow/apns2/wiki/APNS-HTTP-2-Push-Speed)
- Works with go 1.6 and later
- Supports new Apple Token Based Authentication (JWT)
- Supports new iOS 10 features such as Collapse IDs, Subtitles and Mutable Notifications
- Supports persistent connections to APNs
- Supports VoIP/PushKit notifications (iOS 8 and later)
Expand Down Expand Up @@ -65,6 +68,34 @@ func main() {
}
```

## JWT Token Example

Instead of using a `.p12` or `.pem` certificate as above, you can optionally use
APNs JWT _Provider Authentication Tokens_. First you will need a signing key (`.p8` file), Key ID and Team ID [from Apple](http://help.apple.com/xcode/mac/current/#/dev54d690a66). Once you have these details, you can create a new client:

```go
authKey, err := token.AuthKeyFromFile("../AuthKey_XXX.p8")
if err != nil {
log.Fatal("token error:", err)
}

token := &token.Token{
AuthKey: authKey,
// KeyID from developer account (Certificates, Identifiers & Profiles -> Keys)
KeyID: "ABC123DEFG",
// TeamID from developer account (View Account -> Membership)
TeamID: "DEF123GHIJ",
}
...

client := apns2.NewTokenClient(token)
res, err := client.Push(notification)
```

- You can use one APNs signing key to authenticate tokens for multiple apps.
- A signing key works for both the development and production environments.
- A signing key doesn’t expire but can be revoked.

## Notification

At a minimum, a _Notification_ needs a _DeviceToken_, a _Topic_ and a _Payload_.
Expand Down
55 changes: 55 additions & 0 deletions _example/token/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"flag"
"fmt"
"log"
"os"

"github.com/sideshow/apns2"
"github.com/sideshow/apns2/token"
)

func main() {
authKeyPath := flag.String("authKey", "", "Path to .p8 APNSAuthKey file (Required)")
deviceToken := flag.String("token", "", "Push token (Required)")
topic := flag.String("topic", "", "Topic (Required)")
keyID := flag.String("keyID", "", "APNS KeyID (Required)")
teamID := flag.String("teamID", "", "APNS TeamID (Required)")
flag.Parse()

if *authKeyPath == "" || *deviceToken == "" || *topic == "" || *keyID == "" || *teamID == "" {
flag.PrintDefaults()
os.Exit(1)
}

authKey, err := token.AuthKeyFromFile(*authKeyPath)
if err != nil {
log.Fatal("token error:", err)
}

token := &token.Token{
AuthKey: authKey,
KeyID: *keyID,
TeamID: *teamID,
}

notification := &apns2.Notification{}
notification.DeviceToken = *deviceToken
notification.Topic = *topic
notification.Payload = []byte(`{
"aps" : {
"alert" : "Hello!"
}
}
`)

client := apns2.NewTokenClient(token).Production()
res, err := client.Push(notification)

if err != nil {
log.Fatal("Error:", err)
}

fmt.Printf("%v %v %v\n", res.StatusCode, res.ApnsID, res.Reason)
}
2 changes: 1 addition & 1 deletion certificate/certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ var (
// FromP12File loads a PKCS#12 certificate from a local file and returns a
// tls.Certificate.
//
// Use "" as the password argument if the pem certificate is not password
// Use "" as the password argument if the PKCS#12 certificate is not password
// protected.
func FromP12File(filename string, password string) (tls.Certificate, error) {
p12bytes, err := ioutil.ReadFile(filename)
Expand Down
56 changes: 47 additions & 9 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/http"
"time"

"github.com/sideshow/apns2/token"
"golang.org/x/net/http2"
)

Expand All @@ -38,11 +39,22 @@ var (
TCPKeepAlive = 60 * time.Second
)

// DialTLS is the default dial function for creating TLS connections for
// non-proxied HTTPS requests.
var DialTLS = func(network, addr string, cfg *tls.Config) (net.Conn, error) {
dialer := &net.Dialer{
Timeout: TLSDialTimeout,
KeepAlive: TCPKeepAlive,
}
return tls.DialWithDialer(dialer, network, addr, cfg)
}

// Client represents a connection with the APNs
type Client struct {
HTTPClient *http.Client
Certificate tls.Certificate
Host string
Certificate tls.Certificate
Token *token.Token
HTTPClient *http.Client
}

type connectionCloser interface {
Expand All @@ -69,13 +81,7 @@ func NewClient(certificate tls.Certificate) *Client {
}
transport := &http2.Transport{
TLSClientConfig: tlsConfig,
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
dialer := &net.Dialer{
Timeout: TLSDialTimeout,
KeepAlive: TCPKeepAlive,
}
return tls.DialWithDialer(dialer, network, addr, cfg)
},
DialTLS: DialTLS,
}
return &Client{
HTTPClient: &http.Client{
Expand All @@ -87,6 +93,28 @@ func NewClient(certificate tls.Certificate) *Client {
}
}

// NewTokenClient returns a new Client with an underlying http.Client configured
// with the correct APNs HTTP/2 transport settings. It does not connect to the APNs
// until the first Notification is sent via the Push method.
//
// As per the Apple APNs Provider API, you should keep a handle on this client
// so that you can keep your connections with APNs open across multiple
// notifications; don’t repeatedly open and close connections. APNs treats rapid
// connection and disconnection as a denial-of-service attack.
func NewTokenClient(token *token.Token) *Client {
transport := &http2.Transport{
DialTLS: DialTLS,
}
return &Client{
Token: token,
HTTPClient: &http.Client{
Transport: transport,
Timeout: HTTPClientTimeout,
},
Host: DefaultHost,
}
}

// Development sets the Client to use the APNs development push endpoint.
func (c *Client) Development() *Client {
c.Host = HostDevelopment
Expand Down Expand Up @@ -127,6 +155,11 @@ func (c *Client) PushWithContext(ctx Context, n *Notification) (*Response, error

url := fmt.Sprintf("%v/3/device/%v", c.Host, n.DeviceToken)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))

if c.Token != nil {
c.setTokenHeader(req)
}

setHeaders(req, n)

httpRes, err := c.requestWithContext(ctx, req)
Expand All @@ -153,6 +186,11 @@ func (c *Client) CloseIdleConnections() {
c.HTTPClient.Transport.(connectionCloser).CloseIdleConnections()
}

func (c *Client) setTokenHeader(r *http.Request) {
c.Token.GenerateIfExpired()
r.Header.Set("authorization", fmt.Sprintf("bearer %v", c.Token.Bearer))
}

func setHeaders(r *http.Request, n *Notification) {
r.Header.Set("Content-Type", "application/json; charset=utf-8")
if n.Topic != "" {
Expand Down
40 changes: 40 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package apns2_test

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"fmt"
"io/ioutil"
Expand All @@ -15,6 +18,7 @@ import (

apns "github.com/sideshow/apns2"
"github.com/sideshow/apns2/certificate"
"github.com/sideshow/apns2/token"
"github.com/stretchr/testify/assert"
)

Expand All @@ -27,6 +31,12 @@ func mockNotification() *apns.Notification {
return n
}

func mockToken() *token.Token {
pubkeyCurve := elliptic.P256()
authKey, _ := ecdsa.GenerateKey(pubkeyCurve, rand.Reader)
return &token.Token{AuthKey: authKey}
}

func mockCert() tls.Certificate {
return tls.Certificate{}
}
Expand All @@ -51,16 +61,31 @@ func TestClientDefaultHost(t *testing.T) {
assert.Equal(t, "https://api.development.push.apple.com", client.Host)
}

func TestTokenDefaultHost(t *testing.T) {
client := apns.NewTokenClient(mockToken()).Development()
assert.Equal(t, "https://api.development.push.apple.com", client.Host)
}

func TestClientDevelopmentHost(t *testing.T) {
client := apns.NewClient(mockCert()).Development()
assert.Equal(t, "https://api.development.push.apple.com", client.Host)
}

func TestTokenClientDevelopmentHost(t *testing.T) {
client := apns.NewTokenClient(mockToken()).Development()
assert.Equal(t, "https://api.development.push.apple.com", client.Host)
}

func TestClientProductionHost(t *testing.T) {
client := apns.NewClient(mockCert()).Production()
assert.Equal(t, "https://api.push.apple.com", client.Host)
}

func TestTokenClientProductionHost(t *testing.T) {
client := apns.NewTokenClient(mockToken()).Production()
assert.Equal(t, "https://api.push.apple.com", client.Host)
}

func TestClientBadUrlError(t *testing.T) {
n := mockNotification()
res, err := mockClient("badurl://badurl.com").Push(n)
Expand Down Expand Up @@ -157,6 +182,21 @@ func TestHeaders(t *testing.T) {
assert.NoError(t, err)
}

func TestAuthorizationHeader(t *testing.T) {
n := mockNotification()
token := mockToken()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "application/json; charset=utf-8", r.Header.Get("Content-Type"))
assert.Equal(t, fmt.Sprintf("bearer %v", token.Bearer), r.Header.Get("authorization"))
}))
defer server.Close()

client := mockClient(server.URL)
client.Token = token
_, err := client.Push(n)
assert.NoError(t, err)
}

func TestPayload(t *testing.T) {
n := mockNotification()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
28 changes: 28 additions & 0 deletions token/_fixtures/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 token/_fixtures/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 token/_fixtures/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 token/_fixtures/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-----
Loading