-
Notifications
You must be signed in to change notification settings - Fork 605
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #293 from outdoorsy/sign-in-with-apple
Sign In with Apple
- Loading branch information
Showing
9 changed files
with
463 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,9 +3,7 @@ language: go | |
sudo: false | ||
|
||
go: | ||
- 1.7 | ||
- 1.8 | ||
- 1.9 | ||
- "1.9" | ||
- "1.10" | ||
- "1.11" | ||
- "1.12" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.