Skip to content

Commit 1f5d11c

Browse files
authored
Merge pull request #102 from supabase/feature/apple-oauth
Feature: Add Apple Provider 🍎
2 parents f72308f + f8e1c4b commit 1f5d11c

9 files changed

Lines changed: 247 additions & 14 deletions

File tree

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ The default group to assign all new users to.
168168

169169
### External Authentication Providers
170170

171-
We support `azure`, `bitbucket`, `github`, `gitlab`, `facebook`, `twitter`, and `google` for external authentication.
171+
We support `azure`, `bitbucket`, `github`, `gitlab`, `facebook`, `twitter`, `apple` and `google` for external authentication.
172172
Use the names as the keys underneath `external` to configure each separately.
173173

174174
```properties
@@ -199,6 +199,38 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values.
199199

200200
The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` only. Defaults to `https://gitlab.com`.
201201

202+
#### Apple OAuth
203+
204+
To try out external authentication with Apple locally, you will need to do the following:
205+
1. Remap localhost to \<my_custom_dns \> in your `/etc/hosts` config.
206+
2. Configure gotrue to serve HTTPS traffic over localhost by replacing `ListenAndServe` in [api.go](api/api.go) with:
207+
```
208+
func (a *API) ListenAndServe(hostAndPort string) {
209+
log := logrus.WithField("component", "api")
210+
path, err := os.Getwd()
211+
if err != nil {
212+
log.Println(err)
213+
}
214+
server := &http.Server{
215+
Addr: hostAndPort,
216+
Handler: a.handler,
217+
}
218+
done := make(chan struct{})
219+
defer close(done)
220+
go func() {
221+
waitForTermination(log, done)
222+
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
223+
defer cancel()
224+
server.Shutdown(ctx)
225+
}()
226+
if err := server.ListenAndServeTLS("PATH_TO_CRT_FILE", "PATH_TO_KEY_FILE"); err != http.ErrServerClosed {
227+
log.WithError(err).Fatal("http server listen failed")
228+
}
229+
}
230+
```
231+
3. Generate the crt and key file. See [here](https://www.freecodecamp.org/news/how-to-get-https-working-on-your-local-development-environment-in-5-minutes-7af615770eec/) for more information.
232+
4. Generate the `GOTRUE_EXTERNAL_APPLE_SECRET` by following this [post](https://medium.com/identity-beyond-borders/how-to-configure-sign-in-with-apple-77c61e336003)!
233+
202234
### E-Mail
203235

204236
Sending email is not required, but highly recommended for password recovery.

api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
9999
r.Use(api.loadInstanceConfig)
100100
}
101101
r.Get("/", api.ExternalProviderCallback)
102+
r.Post("/", api.ExternalProviderCallback)
102103
})
103104

104105
r.Route("/", func(r *router) {

api/external.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/netlify/gotrue/models"
1717
"github.com/netlify/gotrue/storage"
1818
"github.com/sirupsen/logrus"
19+
"golang.org/x/oauth2"
1920
)
2021

2122
type ExternalProviderClaims struct {
@@ -79,18 +80,29 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
7980
return internalServerError("Error creating state").WithInternalError(err)
8081
}
8182

82-
var url string
83-
if twitterProvider, ok := p.(*provider.TwitterProvider); ok {
84-
url = twitterProvider.AuthCodeURL(tokenString)
85-
err := gothic.StoreInSession(providerType, twitterProvider.Marshal(), r, w)
83+
var authURL string
84+
switch externalProvider := p.(type) {
85+
case *provider.TwitterProvider:
86+
authURL = externalProvider.AuthCodeURL(tokenString)
87+
err := gothic.StoreInSession(providerType, externalProvider.Marshal(), r, w)
8688
if err != nil {
8789
return internalServerError("Error storing request token in session").WithInternalError(err)
8890
}
89-
} else {
90-
url = p.AuthCodeURL(tokenString)
91+
case *provider.AppleProvider:
92+
opts := make([]oauth2.AuthCodeOption, 0, 1)
93+
opts = append(opts, oauth2.SetAuthURLParam("response_mode", "form_post"))
94+
authURL = externalProvider.Config.AuthCodeURL(tokenString, opts...)
95+
if authURL != "" {
96+
if u, err := url.Parse(authURL); err == nil {
97+
u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", "%20")
98+
authURL = u.String()
99+
}
100+
}
101+
default:
102+
authURL = p.AuthCodeURL(tokenString)
91103
}
92104

93-
http.Redirect(w, r, url, http.StatusFound)
105+
http.Redirect(w, r, authURL, http.StatusFound)
94106
return nil
95107
}
96108

@@ -326,6 +338,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
326338
name = strings.ToLower(name)
327339

328340
switch name {
341+
case "apple":
342+
return provider.NewAppleProvider(config.External.Apple)
329343
case "bitbucket":
330344
return provider.NewBitbucketProvider(config.External.Bitbucket)
331345
case "github":

api/external_oauth.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"context"
55
"net/http"
6+
"net/url"
67

78
"github.com/markbates/goth/gothic"
89
"github.com/mrjones/oauth"
@@ -18,7 +19,13 @@ type OAuthProviderData struct {
1819
// loadOAuthState parses the `state` query parameter as a JWS payload,
1920
// extracting the provider requested
2021
func (a *API) loadOAuthState(w http.ResponseWriter, r *http.Request) (context.Context, error) {
21-
state := r.URL.Query().Get("state")
22+
var state string
23+
if r.Method == http.MethodPost {
24+
state = r.FormValue("state")
25+
} else {
26+
state = r.URL.Query().Get("state")
27+
}
28+
2229
if state == "" {
2330
return nil, badRequestError("OAuth state parameter missing")
2431
}
@@ -36,7 +43,12 @@ func (a *API) loadOAuthState(w http.ResponseWriter, r *http.Request) (context.Co
3643
}
3744

3845
func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType string) (*OAuthProviderData, error) {
39-
rq := r.URL.Query()
46+
var rq url.Values
47+
if err := r.ParseForm(); r.Method == http.MethodPost && err == nil {
48+
rq = r.Form
49+
} else {
50+
rq = r.URL.Query()
51+
}
4052

4153
extError := rq.Get("error")
4254
if extError != "" {
@@ -69,6 +81,15 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s
6981
return nil, internalServerError("Error getting user email from external provider").WithInternalError(err)
7082
}
7183

84+
switch externalProvider := oAuthProvider.(type) {
85+
case *provider.AppleProvider:
86+
// apple only returns user info the first time
87+
oauthUser := rq.Get("user")
88+
if oauthUser != "" {
89+
userData.Metadata = externalProvider.ParseUser(oauthUser)
90+
}
91+
}
92+
7293
return &OAuthProviderData{
7394
userData: userData,
7495
token: token.AccessToken,
@@ -98,10 +119,9 @@ func (a *API) oAuth1Callback(ctx context.Context, r *http.Request, providerType
98119
return nil, internalServerError("Unable to retrieve access token").WithInternalError(err)
99120
}
100121
userData, err = twitterProvider.FetchUserData(ctx, accessToken)
101-
}
102-
103-
if err != nil {
104-
return nil, internalServerError("Error getting user email from external provider").WithInternalError(err)
122+
if err != nil {
123+
return nil, internalServerError("Error getting user email from external provider").WithInternalError(err)
124+
}
105125
}
106126

107127
return &OAuthProviderData{

api/provider/apple.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"crypto/rsa"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
"net/http"
11+
12+
"github.com/dgrijalva/jwt-go"
13+
"github.com/lestrrat-go/jwx/jwk"
14+
"github.com/netlify/gotrue/conf"
15+
"golang.org/x/oauth2"
16+
)
17+
18+
const (
19+
authEndpoint = "https://appleid.apple.com/auth/authorize"
20+
tokenEndpoint = "https://appleid.apple.com/auth/token"
21+
22+
ScopeEmail = "email"
23+
ScopeName = "name"
24+
25+
appleAudOrIss = "https://appleid.apple.com"
26+
idTokenVerificationKeyEndpoint = "https://appleid.apple.com/auth/keys"
27+
)
28+
29+
type AppleProvider struct {
30+
*oauth2.Config
31+
APIPath string
32+
httpClient *http.Client
33+
}
34+
35+
type appleName struct {
36+
FirstName string `json:"firstName"`
37+
LastName string `json:"lastName"`
38+
}
39+
40+
type appleUser struct {
41+
Name appleName `json:"name"`
42+
Email string `json:"email"`
43+
}
44+
45+
type idTokenClaims struct {
46+
jwt.StandardClaims
47+
AccessTokenHash string `json:"at_hash"`
48+
AuthTime int `json:"auth_time"`
49+
Email string `json:"email"`
50+
IsPrivateEmail bool `json:"is_private_email,string"`
51+
}
52+
53+
func NewAppleProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) {
54+
if err := ext.Validate(); err != nil {
55+
return nil, err
56+
}
57+
58+
return &AppleProvider{
59+
Config: &oauth2.Config{
60+
ClientID: ext.ClientID,
61+
ClientSecret: ext.Secret,
62+
Endpoint: oauth2.Endpoint{
63+
AuthURL: authEndpoint,
64+
TokenURL: tokenEndpoint,
65+
},
66+
Scopes: []string{
67+
ScopeEmail,
68+
ScopeName,
69+
},
70+
RedirectURL: ext.RedirectURI,
71+
},
72+
APIPath: "",
73+
}, nil
74+
}
75+
76+
func (p AppleProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
77+
opts := []oauth2.AuthCodeOption{
78+
oauth2.SetAuthURLParam("client_id", p.ClientID),
79+
oauth2.SetAuthURLParam("secret", p.ClientSecret),
80+
}
81+
return p.Exchange(oauth2.NoContext, code, opts...)
82+
}
83+
84+
func (p AppleProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
85+
var user *UserProvidedData
86+
if tok.AccessToken == "" {
87+
return &UserProvidedData{}, nil
88+
}
89+
if idToken := tok.Extra("id_token"); idToken != nil {
90+
idToken, err := jwt.ParseWithClaims(idToken.(string), &idTokenClaims{}, func(t *jwt.Token) (interface{}, error) {
91+
kid := t.Header["kid"].(string)
92+
claims := t.Claims.(*idTokenClaims)
93+
vErr := new(jwt.ValidationError)
94+
if !claims.VerifyAudience(p.ClientID, true) {
95+
vErr.Inner = fmt.Errorf("incorrect audience")
96+
vErr.Errors |= jwt.ValidationErrorAudience
97+
}
98+
if !claims.VerifyIssuer(appleAudOrIss, true) {
99+
vErr.Inner = fmt.Errorf("incorrect issuer")
100+
vErr.Errors |= jwt.ValidationErrorIssuer
101+
}
102+
if vErr.Errors > 0 {
103+
return nil, vErr
104+
}
105+
106+
// per OpenID Connect Core 1.0 §3.2.2.9, Access Token Validation
107+
hash := sha256.Sum256([]byte(tok.AccessToken))
108+
halfHash := hash[0:(len(hash) / 2)]
109+
encodedHalfHash := base64.RawURLEncoding.EncodeToString(halfHash)
110+
if encodedHalfHash != claims.AccessTokenHash {
111+
vErr.Inner = fmt.Errorf(`invalid identity token`)
112+
vErr.Errors |= jwt.ValidationErrorClaimsInvalid
113+
return nil, vErr
114+
}
115+
116+
// get the public key for verifying the identity token signature
117+
set, err := jwk.FetchHTTP(idTokenVerificationKeyEndpoint, jwk.WithHTTPClient(http.DefaultClient))
118+
if err != nil {
119+
return nil, err
120+
}
121+
selectedKey := set.Keys[0]
122+
for _, key := range set.Keys {
123+
if key.KeyID() == kid {
124+
selectedKey = key
125+
break
126+
}
127+
}
128+
pubKeyIface, _ := selectedKey.Materialize()
129+
pubKey, ok := pubKeyIface.(*rsa.PublicKey)
130+
if !ok {
131+
return nil, fmt.Errorf(`expected RSA public key from %s`, idTokenVerificationKeyEndpoint)
132+
}
133+
return pubKey, nil
134+
})
135+
if err != nil {
136+
return &UserProvidedData{}, err
137+
}
138+
user = &UserProvidedData{
139+
Emails: []Email{{
140+
Email: idToken.Claims.(*idTokenClaims).Email,
141+
Verified: true,
142+
Primary: true,
143+
}},
144+
}
145+
146+
}
147+
return user, nil
148+
}
149+
150+
func (p AppleProvider) ParseUser(data string) map[string]string {
151+
userData := &appleUser{}
152+
err := json.Unmarshal([]byte(data), userData)
153+
if err != nil {
154+
return nil
155+
}
156+
return map[string]string{
157+
"firstName": userData.Name.FirstName,
158+
"lastName": userData.Name.LastName,
159+
"email": userData.Email,
160+
}
161+
}

api/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import "net/http"
44

55
type ProviderSettings struct {
6+
Apple bool `json:"apple"`
67
Bitbucket bool `json:"bitbucket"`
78
GitHub bool `json:"github"`
89
GitLab bool `json:"gitlab"`
@@ -30,6 +31,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
3031

3132
return sendJSON(w, http.StatusOK, &Settings{
3233
ExternalProviders: ProviderSettings{
34+
Apple: config.External.Apple.Enabled,
3335
Bitbucket: config.External.Bitbucket.Enabled,
3436
GitHub: config.External.Github.Enabled,
3537
GitLab: config.External.Gitlab.Enabled,

conf/configuration.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type EmailContentConfiguration struct {
8080
}
8181

8282
type ProviderConfiguration struct {
83+
Apple OAuthProviderConfiguration `json:"apple"`
8384
Bitbucket OAuthProviderConfiguration `json:"bitbucket"`
8485
Github OAuthProviderConfiguration `json:"github"`
8586
Gitlab OAuthProviderConfiguration `json:"gitlab"`

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ require (
2424
github.com/jmoiron/sqlx v1.3.1 // indirect
2525
github.com/joho/godotenv v1.3.0
2626
github.com/kelseyhightower/envconfig v1.4.0
27+
github.com/lestrrat-go/jwx v0.9.0
2728
github.com/lib/pq v1.9.0 // indirect
2829
github.com/markbates/goth v1.67.1
2930
github.com/microcosm-cc/bluemonday v1.0.4 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
367367
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
368368
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
369369
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
370+
github.com/lestrrat-go/jwx v0.9.0 h1:Fnd0EWzTm0kFrBPzE/PEPp9nzllES5buMkksPMjEKpM=
370371
github.com/lestrrat-go/jwx v0.9.0/go.mod h1:iEoxlYfZjvoGpuWwxUz+eR5e6KTJGsaRcy/YNA/UnBk=
371372
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
372373
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=

0 commit comments

Comments
 (0)