Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

oauth: add sign-in with apple support (fixes #110) #243

Merged
merged 6 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## HEAD

### Added

* Sign in with Apple oauth support

## 1.18.2

### Fixed
Expand Down
16 changes: 13 additions & 3 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ func NewApp(cfg *Config, logger logrus.FieldLogger) (*App, error) {
)
}

oauthProviders := initializeOauthProviders(cfg)
oauthProviders, err := initializeOAuthProviders(cfg)
if err != nil {
return nil, errors.Wrap(err, "initializeOAuthProviders")
}

return &App{
// Provide access to root DB - useful when extending AccountStore functionality
Expand All @@ -118,7 +121,7 @@ func NewApp(cfg *Config, logger logrus.FieldLogger) (*App, error) {
}, nil
}

func initializeOauthProviders(cfg *Config) map[string]oauth.Provider {
func initializeOAuthProviders(cfg *Config) (map[string]oauth.Provider, error) {
oauthProviders := make(map[string]oauth.Provider)
if cfg.GoogleOauthCredentials != nil {
oauthProviders["google"] = *oauth.NewGoogleProvider(cfg.GoogleOauthCredentials)
Expand All @@ -135,5 +138,12 @@ func initializeOauthProviders(cfg *Config) map[string]oauth.Provider {
if cfg.MicrosoftOauthCredentials != nil {
oauthProviders["microsoft"] = *oauth.NewMicrosoftProvider(cfg.MicrosoftOauthCredentials)
}
return oauthProviders
if cfg.AppleOAuthCredentials != nil {
appleProvider, err := oauth.NewAppleProvider(cfg.AppleOAuthCredentials)
if err != nil {
return nil, err
}
oauthProviders["apple"] = *appleProvider
}
return oauthProviders, nil
}
32 changes: 27 additions & 5 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ import (
"strings"
"time"

"github.com/keratin/authn-server/app/data/private"

// a .env file is extremely useful during development
_ "github.com/joho/godotenv/autoload"
"github.com/keratin/authn-server/app/data/private"
"github.com/keratin/authn-server/lib/oauth"
"github.com/keratin/authn-server/lib/route"
"github.com/keratin/authn-server/ops"
Expand Down Expand Up @@ -77,6 +76,7 @@ type Config struct {
FacebookOauthCredentials *oauth.Credentials
DiscordOauthCredentials *oauth.Credentials
MicrosoftOauthCredentials *oauth.Credentials
AppleOAuthCredentials *oauth.Credentials
RefreshTokenExplicitExpiry bool
}

Expand All @@ -86,7 +86,8 @@ func (c *Config) OAuthEnabled() bool {
c.GitHubOauthCredentials != nil ||
c.FacebookOauthCredentials != nil ||
c.DiscordOauthCredentials != nil ||
c.MicrosoftOauthCredentials != nil
c.MicrosoftOauthCredentials != nil ||
c.AppleOAuthCredentials != nil
}

// SameSiteComputed returns either the specified http.SameSite, or a computed one from OAuth config
Expand Down Expand Up @@ -638,8 +639,8 @@ var configurers = []configurer{
return nil
},

// Microsoft_OAUTH_CREDENTIALS is a credential pair in the format `id:secret`. When specified,
// AuthN will enable routes for Discord OAuth signin.
// MICROSOFT_OAUTH_CREDENTIALS is a credential pair in the format `id:secret`.
// When specified, AuthN will enable routes for Microsoft OAuth signin.
func(c *Config) error {
if val, ok := os.LookupEnv("MICROSOFT_OAUTH_CREDENTIALS"); ok {
credentials, err := oauth.NewCredentials(val)
Expand All @@ -651,6 +652,27 @@ var configurers = []configurer{
return nil
},

// APPLE_OAUTH_CREDENTIALS is a credential in the format `id:secret:additional`.
// Note that the secret is not the client secret, but a private key used to sign
// a JWT sent to apple as a client secret. It should be provided as a hex-encoded
// representation of a PEM block
// Additional should be provided as a colon-delimited series of {key}={value} pairs.
// Required additional data includes:
// - teamID
// - keyID
// - expirySeconds
// When specified, AuthN will enable routes for Apple OAuth signin.
func(c *Config) error {
if val, ok := os.LookupEnv("APPLE_OAUTH_CREDENTIALS"); ok {
credentials, err := oauth.NewCredentials(val)
if err == nil {
c.AppleOAuthCredentials = credentials
}
return err
}
return nil
},

// APP_SIGNING_KEY is a hex encoded key used to sign notifications sent to client app using sha256-HMAC
func(c *Config) error {
if val, ok := os.LookupEnv("APP_SIGNING_KEY"); ok {
Expand Down
65 changes: 40 additions & 25 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,53 +261,68 @@ or

* `https://www.example.com/authn/oauth/google/return`

### `APPLE_OAUTH_CREDENTIALS`

| | |
|-----------|------------------------------------------------------------------------|
| Required? | No |
| Value | AppID:SecretSigningKey:keyID={kid}:teamID={tid}:expirySeconds={expiry} |
| Default | nil |

Additional credentialing data can be passed to the apple provider as key-value pairs in the form key=value after the second colon. For example:

`APPLE_OAUTH_CREDENTIALS=appID:appSecret:key1=val1:key2=val2`

Note that the client secret for apple is **NOT** a static value as for other providers. The secret sent is a JWT constructed using the additional data keyID, teamID and expirySeconds included with credentials.
The configured client secret is a private key used to sign the JWT. This should be configured with a hex encoded representation of the full PEM block of a private key obtained at https://developer.apple.com.

### `FACEBOOK_OAUTH_CREDENTIALS`

| | |
| --------- | --- |
| Required? | No |
| Value | AppID:AppSecret |
| Default | nil |
| | |
|-----------|-----------------|
| Required? | No |
| Value | AppID:AppSecret |
| Default | nil |

Create a Facebook app at https://developers.facebook.com and enable the Facebook Login product. In the Quickstart, enter [AuthN's OAuth Return](api.md#oauth-return) as the Site URL. Then switch over to Settings and find the App ID and Secret. Join those together with a `:` and provide them to AuthN as a single variable.

### `GITHUB_OAUTH_CREDENTIALS`

| | |
| --------- | --- |
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |
| | |
|-----------|-----------------------|
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |

Sign up for GitHub OAuth 2.0 credentials with the instructions here: https://developer.github.com/apps/building-oauth-apps. Your client's ID and secret must be joined together with a `:` and provided to AuthN as a single variable.

### `GOOGLE_OAUTH_CREDENTIALS`

| | |
| --------- | --- |
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |
| | |
|-----------|-----------------------|
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |

Sign up for Google OAuth 2.0 credentials with the instructions here: https://developers.google.com/identity/protocols/OpenIDConnect. Your client's ID and secret must be joined together with a `:` and provided to AuthN as a single variable.

### `DISCORD_OAUTH_CREDENTIALS`

| | |
| --------- | --- |
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |
| | |
|-----------|-----------------------|
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |

Sign up for Discord OAuth 2.0 credentials with the instructions here: https://discordapp.com/developers/docs/topics/oauth2. Your client's ID and secret must be joined together with a `:` and provided to AuthN as a single variable.

### `MICROSOFT_OAUTH_CREDENTIALS`

| | |
| --------- | --- |
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |
| | |
|-----------|-----------------------|
| Required? | No |
| Value | ClientID:ClientSecret |
| Default | nil |

Sign up for Microsoft OAuth 2.0 credentials with the instructions here: https://docs.microsoft.com/fr-fr/graph/auth/. Your client's ID and secret must be joined together with a `:` and provided to AuthN as a single variable.

Expand Down
62 changes: 62 additions & 0 deletions lib/oauth/apple.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package oauth

import (
"encoding/hex"
"fmt"
"net/http"

"github.com/keratin/authn-server/lib/oauth/apple"
"golang.org/x/oauth2"
)

// NewAppleProvider returns a AuthN integration for sign-in with Apple OAuth
func NewAppleProvider(credentials *Credentials) (*Provider, error) {
config := &oauth2.Config{
ClientID: credentials.ID,
// ClientSecret for apple is built using apple.GenerateSecret
// this function is passed to the provider for use as an override
// and built fresh on each call to provider.Config(returnURL).
ClientSecret: "",
Scopes: []string{"email"},
Endpoint: apple.Endpoint(),
}

teamID, keyID, expiresIn, constructErr := apple.ExtractCredentialData(credentials.Additional)
if constructErr != nil {
return nil, fmt.Errorf("apple: failed to extract credentials: %w", constructErr)
}

keyBytes, err := hex.DecodeString(credentials.Secret)
if err != nil {
return nil, fmt.Errorf("apple: failed to decode key from client secret: %w", err)
}

signingKey, constructErr := apple.ParsePrivateKey(keyBytes, keyID)
if constructErr != nil {
return nil, fmt.Errorf("apple: failed to parse signing key: %w", constructErr)
}

appleTokenReader := apple.NewTokenReader(config.ClientID)

getAppleUserInfo := func(t *oauth2.Token) (*UserInfo, error) {
id, email, err := appleTokenReader.GetUserDetailsFromToken(t)

if err != nil {
return nil, err
}

return &UserInfo{
ID: id,
Email: email,
}, nil
}

return NewProvider(config, getAppleUserInfo,
WithSecretGenerator(func() (string, error) {
return apple.GenerateSecret(*signingKey, keyID, config.ClientID, teamID, expiresIn)
}),
// Apple requires form_post response mode if scopes are requested
WithAuthCodeOptions(oauth2.SetAuthURLParam("response_mode", "form_post")),
// So we need to handle returns via POST instead of GET
WithReturnMethod(http.MethodPost)), nil
}
40 changes: 40 additions & 0 deletions lib/oauth/apple/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package apple

import (
"fmt"

"github.com/go-jose/go-jose/v3/jwt"
)

type Claims struct {
Email string `json:"email"`
jwt.Claims
}

// Validate performs apple-specific id_token validation.
// `email` is the only additional claim we currently require.
// See https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773
// for more details.
func (c Claims) Validate(clientID string) error {
if clientID == "" {
return fmt.Errorf("cannot validate with empty clientID")
}

if c.Email == "" {
return fmt.Errorf("missing claim 'email'")
}

if c.Expiry == nil {
return fmt.Errorf("missing claim 'exp'")
}

if c.IssuedAt == nil {
return fmt.Errorf("missing claim 'iat'")
}

// is default 1m leeway OK here?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd think so. if there's some network delay when the user returns to authn, this leeway should handle it. if the token is more than 1m too old, then someone's clock is out of sync and they're going to have bigger problems.

return c.Claims.Validate(jwt.Expected{
Issuer: BaseURL,
Audience: jwt.Audience{clientID},
})
Comment on lines +35 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i checked to confirm that it will validate exp and iat against time.Now() by default. 👍

}
Loading
Loading