Skip to content

Commit

Permalink
added apple oauth2 integration
Browse files Browse the repository at this point in the history
  • Loading branch information
ganigeorgiev committed Mar 1, 2023
1 parent 41f01ba commit f5e5fae
Show file tree
Hide file tree
Showing 68 changed files with 1,011 additions and 234 deletions.
15 changes: 12 additions & 3 deletions apis/record_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
urlOpts := []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
}

if name == auth.NameApple {
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms#3332113
urlOpts = append(urlOpts, oauth2.SetAuthURLParam("response_mode", "query"))
}

result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
Expand All @@ -137,9 +147,8 @@ func (api *recordAuthApi) authMethods(c echo.Context) error {
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
urlOpts...,
) + "&redirect_uri=", // empty redirect_uri so that users can append their redirect url
})
}

Expand Down
28 changes: 27 additions & 1 deletion apis/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func bindSettingsApi(app core.App, rg *echo.Group) {
subGroup.PATCH("", api.set)
subGroup.POST("/test/s3", api.testS3)
subGroup.POST("/test/email", api.testEmail)
subGroup.POST("/apple/generate-client-secret", api.generateAppleClientSecret)
}

type settingsApi struct {
Expand Down Expand Up @@ -121,8 +122,8 @@ func (api *settingsApi) testEmail(c echo.Context) error {

// send
if err := form.Submit(); err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
// form error
return NewBadRequestError("Failed to send the test email.", fErr)
}

Expand All @@ -132,3 +133,28 @@ func (api *settingsApi) testEmail(c echo.Context) error {

return c.NoContent(http.StatusNoContent)
}

func (api *settingsApi) generateAppleClientSecret(c echo.Context) error {
form := forms.NewAppleClientSecretCreate(api.app)

// load request
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}

// generate
secret, err := form.Submit()
if err != nil {
// form error
if fErr, ok := err.(validation.Errors); ok {
return NewBadRequestError("Invalid client secret data.", fErr)
}

// secret generation error
return NewBadRequestError("Failed to generate client secret. Raw error: \n"+err.Error(), nil)
}

return c.JSON(http.StatusOK, map[string]any{
"secret": secret,
})
}
3 changes: 3 additions & 0 deletions apis/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func TestSettingsList(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
},
Expand Down Expand Up @@ -139,6 +140,7 @@ func TestSettingsSet(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"acme_test"`,
Expand Down Expand Up @@ -202,6 +204,7 @@ func TestSettingsSet(t *testing.T) {
`"oidcAuth":{`,
`"oidc2Auth":{`,
`"oidc3Auth":{`,
`"appleAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
Expand Down
3 changes: 1 addition & 2 deletions core/events.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package core

import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
Expand All @@ -11,8 +12,6 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"

"github.com/labstack/echo/v5"
)

type BaseCollectionEvent struct {
Expand Down
112 changes: 112 additions & 0 deletions forms/apple_client_secret_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package forms

import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"regexp"
"strings"
"time"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/core"
)

var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)

// AppleClientSecretCreate is a [models.Admin] upsert (create/update) form.
//
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
type AppleClientSecretCreate struct {
app core.App

// ClientId is the identifier of your app (aka. Service ID).
ClientId string `form:"clientId" json:"clientId"`

// TeamId is a 10-character string associated with your developer account
// (usually could be found next to your name in the Apple Developer site).
TeamId string `form:"teamId" json:"teamId"`

// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
// private key associated with your developer account.
KeyId string `form:"keyId" json:"keyId"`

// PrivateKey is the private key associated to your app.
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
PrivateKey string `form:"privateKey" json:"privateKey"`

// Duration specifies how long the generated JWT token should be considered valid
// The specified value must be in seconds and max 15777000 (~6months).
Duration int `form:"duration" json:"duration"`
}

// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
// config created from the provided [core.App] instances.
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
form := &AppleClientSecretCreate{
app: app,
}

return form
}

// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AppleClientSecretCreate) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.ClientId, validation.Required),
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
)
}

// Submit validates the form and returns a new Apple Client Secret JWT.
func (form *AppleClientSecretCreate) Submit() (string, error) {
if err := form.Validate(); err != nil {
return "", err
}

signKey, err := parsePKCS8PrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
if err != nil {
return "", err
}

now := time.Now()

claims := &jwt.StandardClaims{
Audience: "https://appleid.apple.com",
Subject: form.ClientId,
Issuer: form.TeamId,
IssuedAt: now.Unix(),
ExpiresAt: now.Add(time.Duration(form.Duration) * time.Second).Unix(),
}

token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = form.KeyId

return token.SignedString(signKey)
}

// parsePKCS8PrivateKeyFromPEM parses PEM encoded Elliptic Curve Private Key Structure.
//
// https://github.com/dgrijalva/jwt-go/issues/179
func parsePKCS8PrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(key)
if block == nil {
return nil, jwt.ErrKeyMustBePEMEncoded
}

parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}

pkey, ok := parsedKey.(*ecdsa.PrivateKey)
if !ok {
return nil, jwt.ErrNotECPrivateKey
}

return pkey, nil
}
117 changes: 117 additions & 0 deletions forms/apple_client_secret_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package forms_test

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"testing"

"github.com/golang-jwt/jwt/v4"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tests"
)

func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}

encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}

privatePem := pem.EncodeToMemory(
&pem.Block{
Type: "PRIVATE KEY",
Bytes: encodedKey,
},
)

scenarios := []struct {
name string
formData map[string]any
expectError bool
}{
{
"empty data",
map[string]any{},
true,
},
{
"invalid data",
map[string]any{
"clientId": "",
"teamId": "123456789",
"keyId": "123456789",
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
"duration": -1,
},
true,
},
{
"valid data",
map[string]any{
"clientId": "123",
"teamId": "1234567890",
"keyId": "1234567891",
"privateKey": string(privatePem),
"duration": 1,
},
false,
},
}

for _, s := range scenarios {
form := forms.NewAppleClientSecretCreate(app)

rawData, marshalErr := json.Marshal(s.formData)
if marshalErr != nil {
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
continue
}

// load data
loadErr := json.Unmarshal(rawData, form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}

secret, err := form.Submit()

hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}

if hasErr {
continue
}

if secret == "" {
t.Errorf("[%s] Expected non-empty secret", s.name)
}

claims := jwt.MapClaims{}
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
if err != nil {
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
}

if alg := token.Header["alg"]; alg != "ES256" {
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
}

if kid := token.Header["kid"]; kid != form.KeyId {
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
}
}
}
7 changes: 7 additions & 0 deletions forms/record_oauth2_login.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package forms

import (
"context"
"errors"
"fmt"
"time"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
Expand Down Expand Up @@ -127,6 +129,11 @@ func (form *RecordOAuth2Login) Submit(
return nil, nil, err
}

ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*time.Second))
defer cancel()

provider.SetContext(ctx)

// load provider configuration
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
if err := providerConfig.SetupProvider(provider); err != nil {
Expand Down
Loading

0 comments on commit f5e5fae

Please sign in to comment.