Skip to content

Commit

Permalink
use identity token
Browse files Browse the repository at this point in the history
  • Loading branch information
lucjross committed Oct 16, 2019
1 parent 6036867 commit abbabd9
Show file tree
Hide file tree
Showing 7 changed files with 213 additions and 20 deletions.
2 changes: 1 addition & 1 deletion examples/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +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.New(os.Getenv("APPLE_KEY"), os.Getenv("APPLE_SECRET"), "https://example-app.com/redirect", 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
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
89 changes: 74 additions & 15 deletions providers/apple/apple.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
package apple

import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/dgrijalva/jwt-go"
"net/http"
"strings"
"time"

"github.com/markbates/goth"
"golang.org/x/oauth2"
Expand All @@ -15,15 +20,22 @@ import (
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
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 {
Expand All @@ -33,7 +45,7 @@ func New(clientId, secret, redirectURL string, httpClient *http.Client, scopes .
redirectURL: redirectURL,
providerName: "apple",
}
p.config = newConfig(p, scopes)
p.configure(scopes)
p.httpClient = httpClient
return p
}
Expand All @@ -50,6 +62,32 @@ 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
}
Expand All @@ -59,8 +97,12 @@ func (p Provider) RedirectURL() string {
}

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),
AuthURL: p.config.AuthCodeURL(state, opts...),
}, nil
}

Expand All @@ -70,8 +112,22 @@ func (Provider) UnmarshalSession(data string) (goth.Session, error) {
return s, err
}

func (Provider) FetchUser(goth.Session) (goth.User, error) {
return goth.User{}, errors.New("not implemented")
// Apple doesn't seem to provide a user profile endpoint like all the other providers do.
// Therefore this will return a User with only the the unique identifier obtained through authorization.
// A full name and email can be obtained from the form post response
// to the redirect page following authentication.
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.
Expand All @@ -95,11 +151,11 @@ func (Provider) RefreshTokenAvailable() bool {
return true
}

func newConfig(provider *Provider, scopes []string) *oauth2.Config {
func (p *Provider) configure(scopes []string) {
c := &oauth2.Config{
ClientID: provider.clientId,
ClientSecret: provider.secret,
RedirectURL: provider.redirectURL,
ClientID: p.clientId,
ClientSecret: p.secret,
RedirectURL: p.redirectURL,
Endpoint: oauth2.Endpoint{
AuthURL: authEndpoint,
TokenURL: tokenEndpoint,
Expand All @@ -109,7 +165,10 @@ func newConfig(provider *Provider, scopes []string) *oauth2.Config {

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

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

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

Expand Down Expand Up @@ -50,3 +52,50 @@ func Test_SessionFromJSON(t *testing.T) {
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)
}
}
80 changes: 77 additions & 3 deletions providers/apple/session.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
package apple

import (
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"golang.org/x/oauth2"
"fmt"
"time"

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

const (
idTokenVerificationKeyEndpoint = "https://appleid.apple.com/auth/keys"
)

type Session struct{
type ID struct {
Sub string `json:"sub"`
}

type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
ID
}

func (s Session) GetAuthURL() (string, error) {
Expand All @@ -28,9 +43,20 @@ func (s Session) Marshal() string {
return string(b)
}

type IDTokenClaims struct {
jwt.StandardClaims
AccessTokenHash string `json:"at_hash"`
AuthTime int `json:"auth_time"`
}

func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
p := provider.(*Provider)
token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"))
opts := []oauth2.AuthCodeOption{
// Apple requires client id & secret as headers
oauth2.SetAuthURLParam("client_id", p.clientId),
oauth2.SetAuthURLParam("client_secret", p.secret),
}
token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"), opts...)
if err != nil {
return "", err
}
Expand All @@ -42,6 +68,54 @@ func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string,
s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry

if idToken := token.Extra("id_token"); idToken != nil {
idToken, err := jwt.ParseWithClaims(idToken.(string), &IDTokenClaims{}, func(t *jwt.Token) (interface{}, error) {
claims := t.Claims.(*IDTokenClaims)
vErr := new(jwt.ValidationError)
if !claims.VerifyAudience(p.clientId, true) {
vErr.Inner = fmt.Errorf("audience is incorrect")
vErr.Errors |= jwt.ValidationErrorAudience
}
if !claims.VerifyIssuer(AppleAudOrIss, true) {
vErr.Inner = fmt.Errorf("issuer is incorrect")
vErr.Errors |= jwt.ValidationErrorIssuer
}
if vErr.Errors > 0 {
return nil, vErr
}

// per OpenID Connect Core 1.0 §3.2.2.9, Access Token Validation
hash := sha256.Sum256([]byte(s.AccessToken))
halfHash := hash[0:(len(hash) / 2)]
encodedHalfHash := base64.RawURLEncoding.EncodeToString(halfHash)
if encodedHalfHash != claims.AccessTokenHash {
vErr.Inner = fmt.Errorf(`identity token invalid`)
vErr.Errors |= jwt.ValidationErrorClaimsInvalid
return nil, vErr
}

// get the public key for verifying the identity token signature
// todo: respect Cache-Control header and retrieve this less frequently
set, err := jwk.FetchHTTP(idTokenVerificationKeyEndpoint, jwk.WithHTTPClient(p.httpClient))
if err != nil {
return nil, err
}
pubKeyIface, _ := set.Keys[0].Materialize()
pubKey, ok := pubKeyIface.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf(`expected RSA public key from %s`, idTokenVerificationKeyEndpoint)
}
return pubKey, nil
})
if err != nil {
return "", err
}
s.ID = ID{
Sub: idToken.Claims.(*IDTokenClaims).Subject,
}
}

return token.AccessToken, err
}

Expand Down
2 changes: 1 addition & 1 deletion providers/apple/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func Test_ToJSON(t *testing.T) {
s := &Session{}

data := s.Marshal()
a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`)
a.Equal(data, `{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z","sub":""}`)
}

func Test_String(t *testing.T) {
Expand Down

0 comments on commit abbabd9

Please sign in to comment.