Skip to content

Commit

Permalink
Merge pull request #293 from outdoorsy/sign-in-with-apple
Browse files Browse the repository at this point in the history
Sign In with Apple
  • Loading branch information
bentranter authored Oct 22, 2019
2 parents 42e707e + 24153e7 commit a472872
Show file tree
Hide file tree
Showing 9 changed files with 463 additions and 3 deletions.
4 changes: 1 addition & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ language: go
sudo: false

go:
- 1.7
- 1.8
- 1.9
- "1.9"
- "1.10"
- "1.11"
- "1.12"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ $ go get github.com/markbates/goth
## Supported Providers

* Amazon
* Apple
* Auth0
* Azure AD
* Battle.net
Expand Down
3 changes: 3 additions & 0 deletions examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"github.com/markbates/goth/providers/apple"
"html/template"
"net/http"
"os"
Expand Down Expand Up @@ -124,6 +125,7 @@ func main() {
nextcloud.NewCustomisedDNS(os.Getenv("NEXTCLOUD_KEY"), os.Getenv("NEXTCLOUD_SECRET"), "http://localhost:3000/auth/nextcloud/callback", os.Getenv("NEXTCLOUD_URL")),
gitea.New(os.Getenv("GITEA_KEY"), os.Getenv("GITEA_SECRET"), "http://localhost:3000/auth/gitea/callback"),
shopify.New(os.Getenv("SHOPIFY_KEY"), os.Getenv("SHOPIFY_SECRET"), "http://localhost:3000/auth/shopify/callback", shopify.ScopeReadCustomers, shopify.ScopeReadOrders),
apple.New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "http://localhost:3000/auth/apple/callback", nil, apple.ScopeName, apple.ScopeEmail),
)

// OpenID Connect is based on OpenID Connect Auto Discovery URL (https://openid.net/specs/openid-connect-discovery-1_0-17.html)
Expand Down Expand Up @@ -184,6 +186,7 @@ func main() {
m["naver"] = "Naver"
m["yandex"] = "Yandex"
m["nextcloud"] = "NextCloud"
m["apple"] = "Apple"

var keys []string
for k := range m {
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ module github.com/markbates/goth
require (
cloud.google.com/go v0.30.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gorilla/mux v1.6.2
github.com/gorilla/pat v0.0.0-20180118222023-199c85a7f6d1
github.com/gorilla/sessions v1.1.1
github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da
github.com/lestrrat-go/jwx v0.9.0
github.com/markbates/going v1.0.0
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c
github.com/pkg/errors v0.8.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.2.2
golang.org/x/oauth2 v0.0.0-20180620175406-ef147856a6dd
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ cloud.google.com/go v0.30.0 h1:xKvyLgk56d0nksWq49J0UyGEeUIicTl4+UBiX1NPX9g=
cloud.google.com/go v0.30.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
Expand All @@ -16,10 +18,16 @@ github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0Pr
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da h1:FjHUJJ7oBW4G/9j1KzlHaXL09LyMVM9rupS39lncbXk=
github.com/jarcoal/httpmock v0.0.0-20180424175123-9c70cfe4a1da/go.mod h1:ks+b9deReOc7jgqp+e7LuFiCBH6Rm5hL32cLcEAArb4=
github.com/lestrrat-go/jwx v0.9.0 h1:Fnd0EWzTm0kFrBPzE/PEPp9nzllES5buMkksPMjEKpM=
github.com/lestrrat-go/jwx v0.9.0/go.mod h1:iEoxlYfZjvoGpuWwxUz+eR5e6KTJGsaRcy/YNA/UnBk=
github.com/lestrrat/jwx v0.9.0 h1:sxyUKCQ0KpX4+GPvSu9lAS0tIwpg7F/O8p/HqyZL4ns=
github.com/lestrrat/jwx v0.9.0/go.mod h1:Ogdl8bCZz7p5/jj4RY2LQTceY/c+AoTIk9gJY+KP4H0=
github.com/markbates/going v1.0.0 h1:DQw0ZP7NbNlFGcKbcE/IVSOAFzScxRtLpd0rLMzLhq0=
github.com/markbates/going v1.0.0/go.mod h1:I6mnB4BPnEeqo85ynXIx1ZFLLbtiLHNXVgWeFO9OGOA=
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c h1:3wkDRdxK92dF+c1ke2dtj7ZzemFWBHB9plnJOtlwdFA=
github.com/mrjones/oauth v0.0.0-20180629183705-f4e24b6d100c/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
Expand Down
175 changes: 175 additions & 0 deletions providers/apple/apple.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Package `apple` implements the OAuth2 protocol for authenticating users through Apple.
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
package apple

import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/dgrijalva/jwt-go"
"github.com/markbates/goth"
"golang.org/x/oauth2"
)

const (
authEndpoint = "https://appleid.apple.com/auth/authorize"
tokenEndpoint = "https://appleid.apple.com/auth/token"

ScopeEmail = "email"
ScopeName = "name"

AppleAudOrIss = "https://appleid.apple.com"
)

type Provider struct {
providerName string
clientId string
secret string
redirectURL string
config *oauth2.Config
httpClient *http.Client
formPostResponseMode bool
timeNowFn func() time.Time
}

func New(clientId, secret, redirectURL string, httpClient *http.Client, scopes ...string) *Provider {
p := &Provider{
clientId: clientId,
secret: secret,
redirectURL: redirectURL,
providerName: "apple",
}
p.configure(scopes)
p.httpClient = httpClient
return p
}

func (p Provider) Name() string {
return p.providerName
}

func (p *Provider) SetName(name string) {
p.providerName = name
}

func (p Provider) ClientId() string {
return p.clientId
}

type SecretParams struct {
pkcs8PrivateKey, teamId, keyId, clientId string
iat, exp int
}

func MakeSecret(sp SecretParams) (*string, error) {
block, rest := pem.Decode([]byte(strings.TrimSpace(sp.pkcs8PrivateKey)))
if block == nil || len(rest) > 0 {
return nil, errors.New("invalid private key")
}
pk, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"iss": sp.teamId,
"iat": sp.iat,
"exp": sp.exp,
"aud": AppleAudOrIss,
"sub": sp.clientId,
})
token.Header["kid"] = sp.keyId
ss, err := token.SignedString(pk)
return &ss, err
}

func (p Provider) Secret() string {
return p.secret
}

func (p Provider) RedirectURL() string {
return p.redirectURL
}

func (p Provider) BeginAuth(state string) (goth.Session, error) {
opts := make([]oauth2.AuthCodeOption, 0, 1)
if p.formPostResponseMode {
opts = append(opts, oauth2.SetAuthURLParam("response_mode", "form_post"))
}
return &Session{
AuthURL: p.config.AuthCodeURL(state, opts...),
}, nil
}

func (Provider) UnmarshalSession(data string) (goth.Session, error) {
s := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
return s, err
}

// Apple doesn't seem to provide a user profile endpoint like all the other providers do.
// Therefore this will return a User with the unique identifier obtained through authorization
// as the only identifying attribute.
// A full name and email can be obtained from the form post response
// to the redirect page following authentication, if the name are email scopes are requested.
func (p Provider) FetchUser(session goth.Session) (goth.User, error) {
s := session.(*Session)
if s.AccessToken == "" {
return goth.User{}, fmt.Errorf("no access token obtained for session with provider %s", p.Name())
}
return goth.User{
Provider: p.Name(),
UserID: s.ID.Sub,
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
ExpiresAt: s.ExpiresAt,
}, nil
}

// Debug is a no-op for the apple package.
func (Provider) Debug(bool) {}

func (p Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.httpClient)
}

func (p Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
token := &oauth2.Token{RefreshToken: refreshToken}
ts := p.config.TokenSource(goth.ContextForClient(p.Client()), token)
newToken, err := ts.Token()
if err != nil {
return nil, err
}
return newToken, err
}

func (Provider) RefreshTokenAvailable() bool {
return true
}

func (p *Provider) configure(scopes []string) {
c := &oauth2.Config{
ClientID: p.clientId,
ClientSecret: p.secret,
RedirectURL: p.redirectURL,
Endpoint: oauth2.Endpoint{
AuthURL: authEndpoint,
TokenURL: tokenEndpoint,
},
Scopes: make([]string, 0, len(scopes)),
}

for _, scope := range scopes {
c.Scopes = append(c.Scopes, scope)
if scope == "name" || scope == "email" {
p.formPostResponseMode = true
}
}

p.config = c
}
101 changes: 101 additions & 0 deletions providers/apple/apple_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package apple

import (
"net/http"
"net/url"
"os"
"testing"

"github.com/markbates/goth"
"github.com/stretchr/testify/assert"
)

func Test_New(t *testing.T) {
t.Parallel()
a := assert.New(t)
p := provider()

a.Equal(p.ClientId(), os.Getenv("APPLE_KEY"))
a.Equal(p.Secret(), os.Getenv("APPLE_SECRET"))
a.Equal(p.RedirectURL(), "/foo")
}

func Test_Implements_Provider(t *testing.T) {
t.Parallel()
a := assert.New(t)
a.Implements((*goth.Provider)(nil), provider())
}

func Test_BeginAuth(t *testing.T) {
t.Parallel()
a := assert.New(t)
p := provider()
session, err := p.BeginAuth("test_state")
s := session.(*Session)
a.NoError(err)
a.Contains(s.AuthURL, "appleid.apple.com/auth/authorize")
}

func Test_SessionFromJSON(t *testing.T) {
t.Parallel()
a := assert.New(t)

p := provider()
session, err := p.UnmarshalSession(`{"AuthURL":"https://appleid.apple.com/auth/authorize","AccessToken":"1234567890"}`)
a.NoError(err)

s := session.(*Session)
a.Equal(s.AuthURL, "https://appleid.apple.com/auth/authorize")
a.Equal(s.AccessToken, "1234567890")
}

func provider() *Provider {
return New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "/foo", nil)
}

func TestMakeSecret(t *testing.T) {
a := assert.New(t)

iat := 1570636633
ss, err := MakeSecret(SecretParams{
pkcs8PrivateKey: `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPALVklHT2n9FNxeP
c1+TCP+Ep7YOU7T9KB5MTVpjL1ShRANCAATXAbDMQ/URATKRoSIFMkwetLH/M2S4
nNFzkp23qt9IJDivieB/BBJct1UvhoICg5eZDhSR+x7UH3Uhog8qgoIC
-----END PRIVATE KEY-----`, // example
teamId: "TK...",
keyId: "<keyId>",
clientId: "<clientId>",
iat: iat,
exp: iat + 15777000,
})
a.NoError(err)
a.NotZero(ss)
//fmt.Printf("signed secret: %s", *ss)
}

func TestAuthorize(t *testing.T) {
ss := "" // a value from MakeSecret
if ss == "" {
t.Skip()
}

a := assert.New(t)

client := http.DefaultClient
p := New(
"<clientId>",
ss,
"https://example-app.com/redirect",
client,
"name", "email")
session, _ := p.BeginAuth("test_state")

_, err := session.Authorize(p, url.Values{
"code": []string{"<authorization code from successful authentication>"},
})
if err != nil {
errStr := err.Error()
a.Fail(errStr)
}
}
Loading

0 comments on commit a472872

Please sign in to comment.