forked from pocketbase/pocketbase
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
41f01ba
commit f5e5fae
Showing
68 changed files
with
1,011 additions
and
234 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.