Skip to content

Commit

Permalink
Merge branch 'main' into oauth-unlink-social-accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
diegosperes authored Apr 3, 2024
2 parents ab77e1c + e6c0a6e commit fc71db4
Show file tree
Hide file tree
Showing 26 changed files with 1,267 additions and 59 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## HEAD

## 1.19.0

### Added

* Sign in with Apple oauth support

## 1.18.2

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ include .env
ORG := keratin
PROJECT := authn-server
NAME := $(ORG)/$(PROJECT)
VERSION := 1.18.2
VERSION := 1.19.0
MAIN := main.go

BIN := $(shell pwd)/bin
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
}
39 changes: 39 additions & 0 deletions lib/oauth/apple/claims.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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'")
}

return c.Claims.Validate(jwt.Expected{
Issuer: BaseURL,
Audience: jwt.Audience{clientID},
})
}
Loading

0 comments on commit fc71db4

Please sign in to comment.