added apple oauth2 integration
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user