527 lines
17 KiB
Go
527 lines
17 KiB
Go
package provider
|
||
|
||
// Implementation sign in with Apple for allow users to sign in to web services using their Apple ID.
|
||
// For correct work this provider user must has Apple developer account and correct configure "sign in with Apple" at in
|
||
// See more: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api
|
||
// and https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms
|
||
|
||
import (
|
||
"context"
|
||
"crypto"
|
||
"crypto/ecdsa"
|
||
"crypto/sha1"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"fmt"
|
||
"io/ioutil"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"golang.org/x/oauth2"
|
||
|
||
"github.com/go-pkgz/rest"
|
||
"github.com/golang-jwt/jwt"
|
||
"github.com/pkg/errors"
|
||
|
||
"github.com/go-pkgz/auth/logger"
|
||
"github.com/go-pkgz/auth/token"
|
||
)
|
||
|
||
const (
|
||
// appleAuthURL is the base authentication URL for sign in with Apple ID and fetch request code for user validation request.
|
||
appleAuthURL = "https://appleid.apple.com/auth/authorize"
|
||
|
||
// appleTokenURL is the endpoint for verifying tokens and get user unique ID and E-mail
|
||
appleTokenURL = "https://appleid.apple.com/auth/token" // #nosec
|
||
|
||
// appleRequestContentType is the valid type which apple REST API accept only
|
||
appleRequestContentType = "application/x-www-form-urlencoded"
|
||
|
||
// UserAgent required to every request to Apple REST API
|
||
defaultUserAgent = "github.com/go-pkgz/auth"
|
||
|
||
// AcceptJSONHeader is the content to accept from response
|
||
AcceptJSONHeader = "application/json"
|
||
)
|
||
|
||
// appleVerificationResponse is based on https://developer.apple.com/documentation/signinwithapplerestapi/tokenresponse
|
||
type appleVerificationResponse struct {
|
||
// A token used to access allowed user data, but now not implemented public interface for it.
|
||
AccessToken string `json:"access_token"`
|
||
|
||
// Access token type, always equal the "bearer".
|
||
TokenType string `json:"token_type"`
|
||
|
||
// Access token expires time in seconds. Always equal 3600 seconds (1 hour)
|
||
ExpiresIn int `json:"expires_in"`
|
||
|
||
// The refresh token used to regenerate new access tokens.
|
||
RefreshToken string `json:"refresh_token"`
|
||
|
||
// Main JSON Web Token that contains the user’s identity information.
|
||
IDToken string `json:"id_token"`
|
||
|
||
// Used to capture any error returned in response. Always check error for empty
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
// AppleConfig is the main oauth2 required parameters for "Sign in with Apple"
|
||
type AppleConfig struct {
|
||
ClientID string // the identifier Services ID for your app created in Apple developer account.
|
||
TeamID string // developer Team ID (10 characters), required for create JWT. It available, after signed in at developer account, by link: https://developer.apple.com/account/#/membership
|
||
KeyID string // private key ID assigned to private key obtain in Apple developer account
|
||
|
||
scopes []string // for this package allow only username scope and UID in token claims. Apple service API provide only "email" and "name" scope values (https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope)
|
||
privateKey interface{} // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
|
||
publicKey crypto.PublicKey // need for validate sign of token
|
||
clientSecret string // is the JWT client secret will create after first call and then used until expired
|
||
jwkURL string // URL for fetch JWK Apple keys, need redefine for tests
|
||
}
|
||
|
||
// AppleHandler implements login via Apple ID
|
||
type AppleHandler struct {
|
||
Params
|
||
|
||
// all of these fields specific to particular oauth2 provider
|
||
name string
|
||
// infoURL string not implemented at Apple side
|
||
endpoint oauth2.Endpoint
|
||
|
||
mapUser func(jwt.MapClaims) token.User // map info from InfoURL to User
|
||
conf AppleConfig // main config for Apple auth provider
|
||
|
||
PrivateKeyLoader PrivateKeyLoaderInterface // custom function interface for load private key
|
||
|
||
}
|
||
|
||
// PrivateKeyLoaderInterface interface for implement custom loader for Apple private key from user source
|
||
type PrivateKeyLoaderInterface interface {
|
||
LoadPrivateKey() ([]byte, error)
|
||
}
|
||
|
||
// LoadFromFileFunc is the type for use pre-defined private key loader function
|
||
// Path field must be set with actual path to private key file
|
||
type LoadFromFileFunc struct {
|
||
Path string
|
||
}
|
||
|
||
// LoadApplePrivateKeyFromFile return instance for pre-defined loader function from local file
|
||
func LoadApplePrivateKeyFromFile(path string) LoadFromFileFunc {
|
||
return LoadFromFileFunc{
|
||
Path: path,
|
||
}
|
||
}
|
||
|
||
// LoadPrivateKey implement pre-defined (built-in) PrivateKeyLoaderInterface interface method for load private key from local file
|
||
func (lf LoadFromFileFunc) LoadPrivateKey() ([]byte, error) {
|
||
if lf.Path == "" {
|
||
return nil, errors.New("empty private key path not allowed")
|
||
}
|
||
|
||
keyFile, err := os.Open(lf.Path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
keyValue, err := ioutil.ReadAll(keyFile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
err = keyFile.Close()
|
||
return keyValue, err
|
||
}
|
||
|
||
// NewApple create new AppleProvider instance with a user parameters
|
||
// Private key must be set, when instance create call, for create `client_secret`
|
||
func NewApple(p Params, appleCfg AppleConfig, privateKeyLoader PrivateKeyLoaderInterface) (*AppleHandler, error) {
|
||
|
||
if p.L == nil {
|
||
p.L = logger.NoOp
|
||
}
|
||
var emptyParams []string
|
||
|
||
// check required parameters filled
|
||
if appleCfg.ClientID == "" {
|
||
emptyParams = append(emptyParams, "ClientID")
|
||
}
|
||
if appleCfg.TeamID == "" {
|
||
emptyParams = append(emptyParams, "TeamID")
|
||
}
|
||
if appleCfg.KeyID == "" {
|
||
emptyParams = append(emptyParams, "KeyID")
|
||
}
|
||
if len(emptyParams) > 0 {
|
||
return nil, fmt.Errorf("required params missed: %s", strings.Join(emptyParams, ", "))
|
||
}
|
||
|
||
ah := AppleHandler{
|
||
Params: p,
|
||
name: "apple", // static name for an Apple provider
|
||
|
||
conf: AppleConfig{
|
||
ClientID: appleCfg.ClientID,
|
||
TeamID: appleCfg.TeamID,
|
||
KeyID: appleCfg.KeyID,
|
||
scopes: []string{"name"},
|
||
jwkURL: appleKeysURL,
|
||
},
|
||
|
||
endpoint: oauth2.Endpoint{
|
||
AuthURL: appleAuthURL,
|
||
TokenURL: appleTokenURL,
|
||
},
|
||
|
||
mapUser: func(claims jwt.MapClaims) token.User {
|
||
var usr token.User
|
||
if uid, ok := claims["sub"]; ok {
|
||
usr.ID = "apple_" + token.HashID(sha1.New(), uid.(string))
|
||
}
|
||
return usr
|
||
},
|
||
}
|
||
|
||
if privateKeyLoader == nil {
|
||
return nil, errors.New("private key loader undefined")
|
||
}
|
||
|
||
ah.PrivateKeyLoader = privateKeyLoader
|
||
|
||
err := ah.initPrivateKey()
|
||
return &ah, err
|
||
}
|
||
|
||
// initPrivateKey parse Apple private key and assign to AppleHandler
|
||
func (ah *AppleHandler) initPrivateKey() error {
|
||
|
||
sKey, err := ah.PrivateKeyLoader.LoadPrivateKey()
|
||
if err != nil {
|
||
return errors.Wrap(err, "problem with private key loading")
|
||
}
|
||
|
||
block, _ := pem.Decode(sKey)
|
||
if block == nil {
|
||
return errors.New("empty block after decoding")
|
||
}
|
||
ah.conf.privateKey, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ah.conf.publicKey = ah.conf.privateKey.(*ecdsa.PrivateKey).Public()
|
||
ah.conf.clientSecret, err = ah.createClientSecret()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// tokenKeyFunc use for verify JWT sign, it receives the parsed token and should return the key for validating.
|
||
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (interface{}, error) {
|
||
if jwtToken == nil {
|
||
return nil, errors.New("failed to call token keyFunc, because token is nil")
|
||
}
|
||
return ah.conf.publicKey, nil // extract public key from private key
|
||
}
|
||
|
||
// Name of the provider
|
||
func (ah *AppleHandler) Name() string { return ah.name }
|
||
|
||
// LoginHandler - GET */{provider-name}/login
|
||
func (ah *AppleHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||
|
||
ah.Logf("[DEBUG] login with %s", ah.Name())
|
||
// make state (random) and store in session
|
||
state, err := randToken()
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make oauth2 state")
|
||
return
|
||
}
|
||
|
||
cid, err := randToken()
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make claim's id")
|
||
return
|
||
}
|
||
|
||
claims := token.Claims{
|
||
Handshake: &token.Handshake{
|
||
State: state,
|
||
From: r.URL.Query().Get("from"),
|
||
},
|
||
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
|
||
StandardClaims: jwt.StandardClaims{
|
||
Id: cid,
|
||
Audience: r.URL.Query().Get("site"),
|
||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||
},
|
||
}
|
||
|
||
if _, err = ah.JwtService.Set(w, claims); err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to set token")
|
||
return
|
||
}
|
||
|
||
// return login url
|
||
loginURL, err := ah.prepareLoginURL(state, r.URL.Path)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("prepare login url for [%s] provider failed", ah.name)
|
||
ah.Logf("[ERROR] %s", errMsg)
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, errMsg)
|
||
return
|
||
}
|
||
ah.Logf("[DEBUG] login url %s, claims=%+v", loginURL, claims)
|
||
|
||
http.Redirect(w, r, loginURL, http.StatusFound)
|
||
}
|
||
|
||
// AuthHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser
|
||
// GET /callback
|
||
func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||
|
||
// read response form data
|
||
if err := r.ParseForm(); err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "read callback response from data failed")
|
||
return
|
||
}
|
||
|
||
state := r.FormValue("state") // state value which sent with auth request
|
||
code := r.FormValue("code") // client code for validation
|
||
|
||
// response with user name filed return only one time at first login, next login field user doesn't exist
|
||
// until user delete sign with Apple ID in account profile (security section)
|
||
// example response: {"name":{"firstName":"Chan","lastName":"Lu"},"email":"user@email.com"}
|
||
jUser := r.FormValue("user") // json string with user name
|
||
|
||
oauthClaims, _, err := ah.JwtService.Get(r)
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to get token")
|
||
return
|
||
}
|
||
|
||
if oauthClaims.Handshake == nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "invalid handshake token")
|
||
return
|
||
}
|
||
|
||
retrievedState := oauthClaims.Handshake.State
|
||
if retrievedState == "" || retrievedState != state {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "unexpected state")
|
||
return
|
||
}
|
||
|
||
var resp appleVerificationResponse
|
||
err = ah.exchange(context.Background(), code, ah.makeRedirURL(r.URL.Path), &resp)
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "exchange failed")
|
||
return
|
||
}
|
||
ah.Logf("[DEBUG] response data %+v", resp)
|
||
if resp.Error != "" {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("fetch IDtoken response error: %s", resp.Error))
|
||
return
|
||
}
|
||
|
||
// trying to fetch Apple public key (JWK) for verify token signature, it need for verify IDToken received from Apple
|
||
keySet, err := fetchAppleJWK(r.Context(), ah.conf.jwkURL)
|
||
if err != nil {
|
||
ah.L.Logf("[ERROR] failed to fetch JWK from Apple key service: " + err.Error())
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to fetch JWK from Apple key service: %s", resp.Error))
|
||
return
|
||
}
|
||
|
||
// get token claims for extract uid (and email or name if they exist in scope)
|
||
tokenClaims := jwt.MapClaims{}
|
||
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc)
|
||
if err != nil {
|
||
ah.L.Logf("[ERROR] failed to get claims: " + err.Error())
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to token validation, key is invalid: %s", resp.Error))
|
||
return
|
||
}
|
||
|
||
u := ah.mapUser(tokenClaims)
|
||
|
||
u, err = setAvatar(ah.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second})
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
|
||
return
|
||
}
|
||
|
||
// try parse username if one exist at response or noname assign
|
||
ah.parseUserData(&u, jUser)
|
||
|
||
cid, err := randToken()
|
||
if err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to make claim's id")
|
||
return
|
||
}
|
||
|
||
claims := token.Claims{
|
||
User: &u,
|
||
StandardClaims: jwt.StandardClaims{
|
||
Issuer: ah.Issuer,
|
||
Id: cid,
|
||
Audience: oauthClaims.Audience,
|
||
},
|
||
SessionOnly: false,
|
||
}
|
||
|
||
if _, err = ah.JwtService.Set(w, claims); err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "failed to set token")
|
||
return
|
||
}
|
||
|
||
ah.Logf("[DEBUG] user info %+v", u)
|
||
|
||
// redirect to back url if presented in login query params
|
||
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
|
||
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
rest.RenderJSON(w, &u)
|
||
|
||
}
|
||
|
||
// LogoutHandler - GET /logout
|
||
func (ah AppleHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||
if _, _, err := ah.JwtService.Get(r); err != nil {
|
||
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, err, "logout not allowed")
|
||
return
|
||
}
|
||
ah.JwtService.Reset(w)
|
||
}
|
||
|
||
// exchange sends the validation token request and gets access token and user claims
|
||
// (e.g. https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens)
|
||
func (ah *AppleHandler) exchange(ctx context.Context, code, redirectURI string, result *appleVerificationResponse) error {
|
||
|
||
// check client_secret for valid and recreate new (client_secret JWT) if required
|
||
if tkn, err := jwt.Parse(ah.conf.clientSecret, ah.tokenKeyFunc); err != nil || tkn == nil {
|
||
ah.conf.clientSecret, err = ah.createClientSecret()
|
||
if err != nil {
|
||
return errors.Wrap(err, "client secret create failed")
|
||
}
|
||
}
|
||
|
||
data := url.Values{}
|
||
data.Set("client_id", ah.conf.ClientID)
|
||
data.Set("client_secret", ah.conf.clientSecret) // JWT signed with Apple private key
|
||
data.Set("code", code)
|
||
data.Set("redirect_uri", redirectURI) // redirect URL can't refer to localhost and must have trusted certificate and https protocol
|
||
data.Set("grant_type", "authorization_code")
|
||
|
||
client := http.Client{Timeout: time.Second * 5}
|
||
req, err := http.NewRequestWithContext(ctx, "POST", ah.endpoint.TokenURL, strings.NewReader(data.Encode()))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
req.Header.Add("content-type", appleRequestContentType)
|
||
req.Header.Add("accept", AcceptJSONHeader)
|
||
req.Header.Add("user-agent", defaultUserAgent) // apple requires a user agent
|
||
|
||
res, err := client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Trying to decode (unmarshal json) data of response
|
||
err = json.NewDecoder(res.Body).Decode(result)
|
||
if err != nil {
|
||
return errors.Wrap(err, "unmarshalling data from apple service response failed")
|
||
}
|
||
|
||
defer func() {
|
||
if err = res.Body.Close(); err != nil {
|
||
ah.L.Logf("[ERROR] close request body failed when get access token: %v", err)
|
||
}
|
||
}()
|
||
|
||
// If above operation done successfully checking a response code and error descriptions, if one exist.
|
||
// Apple service will response either 200 (OK) or 400 (any error).
|
||
if res.StatusCode != http.StatusOK || result.Error != "" {
|
||
return fmt.Errorf("apple token service error: %s", result.Error)
|
||
}
|
||
|
||
return err
|
||
}
|
||
|
||
// createClientSecret use for create the JWT client secret required to make requests to the Apple validation server.
|
||
// for more details go to link: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048
|
||
func (ah *AppleHandler) createClientSecret() (string, error) {
|
||
|
||
if ah.conf.privateKey == nil {
|
||
return "", errors.New("private key can't be empty")
|
||
}
|
||
// Create a claims
|
||
now := time.Now()
|
||
exp := now.Add(time.Minute * 30).Unix() // default value
|
||
|
||
claims := &jwt.StandardClaims{
|
||
Issuer: ah.conf.TeamID,
|
||
IssuedAt: now.Unix(),
|
||
ExpiresAt: exp,
|
||
Audience: "https://appleid.apple.com",
|
||
Subject: ah.conf.ClientID,
|
||
}
|
||
|
||
tkn := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||
tkn.Header["alg"] = "ES256"
|
||
tkn.Header["kid"] = ah.conf.KeyID
|
||
|
||
return tkn.SignedString(ah.conf.privateKey)
|
||
}
|
||
|
||
func (ah *AppleHandler) parseUserData(user *token.User, jUser string) {
|
||
|
||
type UserData struct {
|
||
Name struct {
|
||
FirstName string `json:"firstName"`
|
||
LastName string `json:"lastName"`
|
||
} `json:"name"`
|
||
Email string `json:"email"`
|
||
}
|
||
|
||
var userData UserData
|
||
|
||
// Catch error for log only. No need break flow if user name doesn't exist
|
||
if err := json.Unmarshal([]byte(jUser), &userData); err != nil {
|
||
ah.L.Logf("[DEBUG] failed to parse user data %s: %v", user, err)
|
||
user.Name = "noname_" + user.ID[6:12] // paste noname if user name failed to parse
|
||
return
|
||
}
|
||
|
||
user.Name = fmt.Sprintf("%s %s", userData.Name.FirstName, userData.Name.LastName)
|
||
}
|
||
|
||
func (ah *AppleHandler) prepareLoginURL(state, path string) (string, error) {
|
||
|
||
scopesList := strings.Join(ah.conf.scopes, " ")
|
||
|
||
authURL, err := url.Parse(ah.endpoint.AuthURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
query := authURL.Query()
|
||
query.Set("state", state)
|
||
query.Set("response_type", "code")
|
||
query.Set("response_mode", "form_post")
|
||
query.Set("client_id", ah.conf.ClientID)
|
||
query.Set("scope", scopesList)
|
||
query.Set("redirect_uri", ah.makeRedirURL(path))
|
||
authURL.RawQuery = query.Encode()
|
||
|
||
return authURL.String(), nil
|
||
|
||
}
|
||
|
||
func (ah AppleHandler) makeRedirURL(path string) string {
|
||
elems := strings.Split(path, "/")
|
||
newPath := strings.Join(elems[:len(elems)-1], "/")
|
||
|
||
return strings.TrimRight(ah.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
|
||
}
|