diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index f57dc89c56..88c835a47b 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -1146,6 +1146,106 @@ public function getFiles(): array 'destination' => 'internal/cmd/initpush_test.go', 'template' => 'cli/internal/cmd/initpush_test.go', ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/apns.go', + 'template' => 'cli/internal/apns/apns.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apns_test.go', + 'template' => 'cli/internal/apns/apns_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/apnstest/apnstest.go', + 'template' => 'cli/internal/apns/apnstest/apnstest.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter.go', + 'template' => 'cli/internal/apns/appwrite/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/adapter_test.go', + 'template' => 'cli/internal/apns/appwrite/adapter_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/auth.go', + 'template' => 'cli/internal/apns/appwrite/auth.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/jar.go', + 'template' => 'cli/internal/apns/appwrite/jar.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/appwrite/portal.go', + 'template' => 'cli/internal/apns/appwrite/portal.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp.go', + 'template' => 'cli/internal/apns/appwrite/srp.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/appwrite/srp_test.go', + 'template' => 'cli/internal/apns/appwrite/srp_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter.go', + 'template' => 'cli/internal/apns/expo/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/adapter_test.go', + 'template' => 'cli/internal/apns/expo/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/helper.js', + 'template' => 'cli/internal/apns/expo/helper/helper.js', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package.json', + 'template' => 'cli/internal/apns/expo/helper/package.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/helper/package-lock.json', + 'template' => 'cli/internal/apns/expo/helper/package-lock.json', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/expo/runtime.go', + 'template' => 'cli/internal/apns/expo/runtime.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/expo/runtime_test.go', + 'template' => 'cli/internal/apns/expo/runtime_test.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter.go', + 'template' => 'cli/internal/apns/fastlane/adapter.go', + ], + [ + 'scope' => 'default', + 'destination' => 'internal/apns/fastlane/adapter_test.go', + 'template' => 'cli/internal/apns/fastlane/adapter_test.go', + ], + [ + 'scope' => 'copy', + 'destination' => 'internal/apns/fastlane/helper/Fastfile', + 'template' => 'cli/internal/apns/fastlane/helper/Fastfile', + ], [ 'scope' => 'default', 'destination' => 'internal/cmd/pull.go', diff --git a/templates/cli/internal/apns/apns.go b/templates/cli/internal/apns/apns.go new file mode 100644 index 0000000000..34f6702a62 --- /dev/null +++ b/templates/cli/internal/apns/apns.go @@ -0,0 +1,196 @@ +// Package apns creates Apple Push Notification service (APNs) auth keys. +// +// Apple has no public API for creating APNs keys. The tools that automate it, +// fastlane's spaceship and @expo/apple-utils, sign in to the Apple Developer +// portal as a person would and call the portal's private API, and so does +// Appwrite's own implementation. Apple can change that API without notice, so +// each implementation is an Adapter, and which one runs is a setting rather +// than a release: when Apple breaks one, people switch to another. +// +// The package imports nothing from the CLI. Prompts and logging come in +// through the Request, so it can move to its own module and repository +// unchanged. +package apns + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +// Environment is the APNs environment a key works in. +type Environment string + +// The environments Apple offers when creating a key. +const ( + EnvironmentAll Environment = "all" + EnvironmentProduction Environment = "production" + EnvironmentSandbox Environment = "sandbox" +) + +// ParseEnvironment reads an environment name. Empty means all. +func ParseEnvironment(value string) (Environment, error) { + switch environment := Environment(strings.ToLower(strings.TrimSpace(value))); environment { + case "", EnvironmentAll: + return EnvironmentAll, nil + case EnvironmentProduction, EnvironmentSandbox: + return environment, nil + default: + return "", fmt.Errorf("unknown APNs environment %q: use production, sandbox or all", value) + } +} + +// Key is a newly created APNs key. Apple lets its contents be downloaded once. +type Key struct { + KeyID string + TeamID string + P8 string +} + +// ExistingKey is a key already on the team. +type ExistingKey struct { + ID string + Name string + CanRevoke bool +} + +// MaxKeysError means the team already has as many APNs keys as Apple allows. +// Keys lists the team's keys when the adapter could read them. +type MaxKeysError struct { + Keys []ExistingKey +} + +func (e *MaxKeysError) Error() string { + return "the Apple team already has the maximum number of APNs keys" +} + +var ( + // ErrUnavailable means the adapter cannot run here, for example because + // the runtime it needs is missing. Nothing was sent to Apple. + ErrUnavailable = errors.New("this APNs key setup is not available here") + // ErrInvalidCredentials means Apple refused the Apple ID or password. + ErrInvalidCredentials = errors.New("Apple did not accept the Apple ID and password") +) + +// Asker asks the person at the terminal. +type Asker interface { + // Ask reads one line. secret hides what is typed. + Ask(question string, secret bool) (string, error) + // Choose returns the index of the chosen option. + Choose(question string, options []string) (int, error) +} + +// Request describes the key to create. +type Request struct { + // TeamID is the Apple Developer team that owns the key. Empty lets the + // adapter use the account's only team, or ask which one. + TeamID string + Name string + Environment Environment + // AppleID and Password answer the sign-in questions when set. + AppleID string + Password string + // SessionDir is where an adapter may keep a signed-in session between + // runs, readable by the user only. + SessionDir string + // Reset signs in to Apple again instead of reusing a saved sign-in, and + // the new one replaces it. + Reset bool + Asker Asker + // Log prints a line for the person running the command. + Log func(format string, args ...any) +} + +// Adapter creates APNs keys one way. +type Adapter interface { + // Name is how people pick the adapter, for example "appwrite". + Name() string + // CreateKey signs in, creates the key and downloads it. It returns + // ErrUnavailable when it cannot run here, ErrInvalidCredentials when + // Apple refuses the sign-in, and a *MaxKeysError when the team has no + // free key slot. + CreateKey(ctx context.Context, request Request) (Key, error) +} + +var ( + keyIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) + teamIDPattern = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +// CreateKey checks the request, runs the adapter, and checks what it returned, +// so every adapter is held to the same contract. +func CreateKey(ctx context.Context, adapter Adapter, request Request) (Key, error) { + environment, err := ParseEnvironment(string(request.Environment)) + if err != nil { + return Key{}, err + } + request.Environment = environment + if strings.TrimSpace(request.Name) == "" { + return Key{}, errors.New("an APNs key needs a name") + } + if request.TeamID != "" && !teamIDPattern.MatchString(request.TeamID) { + return Key{}, fmt.Errorf("%q is not an Apple team ID: a team ID is 10 uppercase letters or digits", request.TeamID) + } + if request.Log == nil { + request.Log = func(string, ...any) {} + } + + key, err := adapter.CreateKey(ctx, request) + if err != nil { + return Key{}, err + } + switch { + case !keyIDPattern.MatchString(key.KeyID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid key ID %q", adapter.Name(), key.KeyID) + case !teamIDPattern.MatchString(key.TeamID): + return Key{}, fmt.Errorf("the %s APNs setup returned an invalid team ID %q", adapter.Name(), key.TeamID) + case request.TeamID != "" && key.TeamID != request.TeamID: + return Key{}, fmt.Errorf("the %s APNs setup created key %s on team %s, not %s", adapter.Name(), key.KeyID, key.TeamID, request.TeamID) + case !strings.Contains(key.P8, "PRIVATE KEY"): + return Key{}, fmt.Errorf("the %s APNs setup created key %s but returned no .p8 contents", adapter.Name(), key.KeyID) + } + + return key, nil +} + +// Registry holds the adapters a build offers, by name. +type Registry struct { + adapters map[string]Adapter +} + +// NewRegistry returns a registry holding adapters. +func NewRegistry(adapters ...Adapter) *Registry { + registry := &Registry{adapters: map[string]Adapter{}} + for _, adapter := range adapters { + registry.adapters[adapter.Name()] = adapter + } + + return registry +} + +// Names lists the registered adapters in order. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.adapters)) + for name := range r.adapters { + names = append(names, name) + } + sort.Strings(names) + + return names +} + +// Get returns the adapter called name. +func (r *Registry) Get(name string) (Adapter, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if adapter, ok := r.adapters[name]; ok { + return adapter, nil + } + if len(r.adapters) == 0 { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", name) + } + + return nil, fmt.Errorf("unknown APNs key setup %q: use one of %s", name, strings.Join(r.Names(), ", ")) +} diff --git a/templates/cli/internal/apns/apns_test.go b/templates/cli/internal/apns/apns_test.go new file mode 100644 index 0000000000..dc334abeb9 --- /dev/null +++ b/templates/cli/internal/apns/apns_test.go @@ -0,0 +1,115 @@ +package apns_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" +) + +func TestParseEnvironment(t *testing.T) { + for value, want := range map[string]apns.Environment{ + "": apns.EnvironmentAll, + "all": apns.EnvironmentAll, + "Production": apns.EnvironmentProduction, + " sandbox ": apns.EnvironmentSandbox, + } { + if got, err := apns.ParseEnvironment(value); err != nil || got != want { + t.Errorf("ParseEnvironment(%q) = %q, %v", value, got, err) + } + } + if _, err := apns.ParseEnvironment("staging"); err == nil { + t.Error("accepted staging") + } +} + +func TestCreateKeyPassesTheRequestThrough(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234", TeamID: "TEAM123456"} + key, err := apns.CreateKey(context.Background(), adapter, apns.Request{Name: "Appwrite Push", Environment: "Sandbox"}) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "TEAM123456" { + t.Errorf("key = %+v", key) + } + if len(adapter.Requests) != 1 || adapter.Requests[0].Environment != apns.EnvironmentSandbox || adapter.Requests[0].Log == nil { + t.Errorf("requests = %+v", adapter.Requests) + } +} + +func TestCreateKeyChecksTheRequest(t *testing.T) { + adapter := &apnstest.Adapter{KeyID: "NEWKEY1234"} + for _, request := range []apns.Request{ + {Name: "Appwrite Push", Environment: "staging"}, + {Name: " "}, + {Name: "Appwrite Push", TeamID: "team"}, + } { + if _, err := apns.CreateKey(context.Background(), adapter, request); err == nil { + t.Errorf("accepted %+v", request) + } + } + if len(adapter.Requests) != 0 { + t.Errorf("ran the adapter for a bad request: %+v", adapter.Requests) + } +} + +func TestCreateKeyHoldsAdaptersToTheContract(t *testing.T) { + for _, test := range []struct { + name string + adapter apns.Adapter + want string + }{ + {"key ID", &apnstest.Adapter{KeyID: "short", TeamID: "TEAM123456"}, `invalid key ID "short"`}, + {"team ID", &apnstest.Adapter{KeyID: "NEWKEY1234"}, `invalid team ID ""`}, + {"other team", &otherTeam{}, "on team OTHER12345, not TEAM123456"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := apns.CreateKey(context.Background(), test.adapter, apns.Request{Name: "Appwrite Push", TeamID: teamFor(test.name)}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Errorf("err = %v", err) + } + }) + } + + failing := &apnstest.Adapter{Err: apns.ErrInvalidCredentials} + if _, err := apns.CreateKey(context.Background(), failing, apns.Request{Name: "Appwrite Push"}); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("err = %v", err) + } +} + +func teamFor(test string) string { + if test == "other team" { + return "TEAM123456" + } + + return "" +} + +type otherTeam struct{} + +func (otherTeam) Name() string { return "other" } + +func (otherTeam) CreateKey(context.Context, apns.Request) (apns.Key, error) { + return apns.Key{KeyID: "NEWKEY1234", TeamID: "OTHER12345", P8: apnstest.P8()}, nil +} + +func TestRegistry(t *testing.T) { + empty := apns.NewRegistry() + if _, err := empty.Get("appwrite"); err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("empty registry err = %v", err) + } + + registry := apns.NewRegistry(&apnstest.Adapter{AdapterName: "expo"}, &apnstest.Adapter{AdapterName: "appwrite"}) + if names := strings.Join(registry.Names(), ","); names != "appwrite,expo" { + t.Errorf("names = %s", names) + } + if adapter, err := registry.Get(" Expo "); err != nil || adapter.Name() != "expo" { + t.Errorf("Get(expo) = %v, %v", adapter, err) + } + if _, err := registry.Get("fastlane"); err == nil || err.Error() != `unknown APNs key setup "fastlane": use one of appwrite, expo` { + t.Errorf("err = %v", err) + } +} diff --git a/templates/cli/internal/apns/apnstest/apnstest.go b/templates/cli/internal/apns/apnstest/apnstest.go new file mode 100644 index 0000000000..5f92d12ea9 --- /dev/null +++ b/templates/cli/internal/apns/apnstest/apnstest.go @@ -0,0 +1,62 @@ +// Package apnstest has a scripted APNs adapter for tests. +package apnstest + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter is a fake that records each request and returns a fresh P-256 key, +// or Err when set. +type Adapter struct { + AdapterName string + KeyID string + // TeamID is used when the request names no team, as when an account + // with one team signs in. + TeamID string + Err error + Requests []apns.Request +} + +// Name returns AdapterName, or "fake". +func (a *Adapter) Name() string { + if a.AdapterName != "" { + return a.AdapterName + } + + return "fake" +} + +// CreateKey records the request and returns a key or Err. +func (a *Adapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + a.Requests = append(a.Requests, request) + if a.Err != nil { + return apns.Key{}, a.Err + } + team := request.TeamID + if team == "" { + team = a.TeamID + } + + return apns.Key{KeyID: a.KeyID, TeamID: team, P8: P8()}, nil +} + +// P8 returns a new APNs-shaped private key in PEM form. +func P8() string { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + panic(err) + } + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + panic(err) + } + + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) +} diff --git a/templates/cli/internal/apns/appwrite/adapter.go b/templates/cli/internal/apns/appwrite/adapter.go new file mode 100644 index 0000000000..651f515fe1 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter.go @@ -0,0 +1,224 @@ +// Package appwrite is Appwrite's own APNs key setup, in Go with no runtime to +// install. +// +// It signs in the way the Apple Developer portal's web pages do, with the +// person's Apple ID, password and two-factor code, and calls the portal's +// private API. The sign-in follows spaceship (fastlane), which keeps up with +// Apple's changes in readable code, and the key calls follow +// @expo/apple-utils. +package appwrite + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// Adapter creates APNs keys with Appwrite's own sign-in. +type Adapter struct { + // For tests. Zero values mean Apple's servers and the real clock. + HTTP *http.Client + Hosts Hosts + Random io.Reader + Now func() time.Time +} + +// New returns the adapter with Apple's servers. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "appwrite" +} + +// CreateKey signs in, creates a team-scoped APNs key and downloads it. The +// signed-in session is kept in request.SessionDir, when set, so later runs +// skip two-factor. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + portal, err := a.connect(ctx, request) + if err != nil { + return apns.Key{}, err + } + defer portal.saveSession() + + teamID, err := portal.chooseTeam(ctx, request.TeamID) + if err != nil { + return apns.Key{}, err + } + existing, err := portal.keys(ctx, teamID) + if err != nil { + return apns.Key{}, err + } + keyID, err := portal.createKey(ctx, teamID, request.Name, request.Environment) + if errors.Is(err, errMaxKeys) { + return apns.Key{}, &apns.MaxKeysError{Keys: existing} + } + if err != nil { + return apns.Key{}, err + } + p8, err := portal.downloadKey(ctx, teamID, keyID) + if err != nil { + return apns.Key{}, fmt.Errorf("created key %s but could not download it, and Apple allows only one download. Revoke it in the Apple Developer portal: %w", keyID, err) + } + + return apns.Key{KeyID: keyID, TeamID: teamID, P8: p8}, nil +} + +func sessionPath(dir string) string { + if dir == "" { + return "" + } + + return filepath.Join(dir, "session.json") +} + +// saveSession keeps the cookies for the next run. Without a session folder +// the session lives only as long as this run. +func (p *portalClient) saveSession() { + if path := sessionPath(p.dir); path != "" { + if err := p.jar.save(path); err != nil { + p.log("Could not save the Apple sign-in, so the next run asks again: %v", err) + } + } +} + +// connect returns a signed-in portal client, reusing the saved session when +// it is still valid and belongs to the Apple ID being used. +func (a *Adapter) connect(ctx context.Context, request apns.Request) (*portalClient, error) { + if request.SessionDir != "" { + if err := os.MkdirAll(request.SessionDir, 0o700); err != nil { + return nil, err + } + } + httpClient := a.HTTP + if httpClient == nil { + httpClient = &http.Client{Timeout: time.Minute} + } + // With Reset nothing is loaded, not even the cookie that marks this + // machine as trusted, so Apple asks for a two-factor code again; the new + // session replaces the saved one. + jar := newSavedJar() + if path := sessionPath(request.SessionDir); path != "" && !request.Reset { + jar.load(path) + } + withJar := *httpClient + withJar.Jar = jar + logf := request.Log + if logf == nil { + logf = func(string, ...any) {} + } + portal := &portalClient{ + http: &withJar, jar: jar, hosts: a.Hosts, dir: request.SessionDir, asker: request.Asker, log: logf, + random: a.Random, now: a.Now, + } + if portal.hosts == (Hosts{}) { + portal.hosts = DefaultHosts + } + if portal.random == nil { + portal.random = rand.Reader + } + if portal.now == nil { + portal.now = time.Now + } + + accountName := strings.TrimSpace(request.AppleID) + if session, err := portal.session(ctx); err == nil && session != nil { + if accountName == "" || strings.EqualFold(session.User.EmailAddress, accountName) { + logf("Using the saved Apple sign-in for %s.", session.User.EmailAddress) + + return portal, nil + } + // Another Apple ID's session: none of it applies. + jar = newSavedJar() + withJar.Jar = jar + portal.jar = jar + } + // An expired session's cookies stay: among them is the one that marks + // this machine as trusted, which lets Apple skip two-factor on sign-in. + if err := portal.loadWidgetKey(ctx); err != nil { + return nil, err + } + + if accountName == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return nil, err + } + accountName = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+accountName, true) + if err != nil { + return nil, err + } + password = answer + } + + if err := portal.signIn(ctx, accountName, password); err != nil { + return nil, err + } + // Saved as soon as Apple accepts the sign-in, so a failure after this + // point does not cost another two-factor code. + portal.saveSession() + session, err := portal.session(ctx) + if err != nil { + return nil, fmt.Errorf("could not reach App Store Connect: %w", err) + } + if session == nil { + return nil, errors.New("Apple signed you in but App Store Connect refused the session. Sign in once at https://appstoreconnect.apple.com to accept any pending agreements, then try again") + } + + return portal, nil +} + +// chooseTeam checks teamID against the account's teams, or picks one. +func (p *portalClient) chooseTeam(ctx context.Context, teamID string) (string, error) { + teams, err := p.teams(ctx) + if errors.Is(err, errSessionExpired) { + return "", errors.New("the Apple session expired. Run the command again to sign in") + } + if err != nil { + return "", err + } + if len(teams) == 0 { + return "", errors.New("this Apple ID belongs to no Apple Developer team. APNs keys need a paid Apple Developer Program membership") + } + if teamID != "" { + ids := make([]string, len(teams)) + for index, team := range teams { + if team.ID == teamID { + return teamID, nil + } + ids[index] = team.ID + } + + return "", fmt.Errorf("this Apple ID is not on team %s. Its teams are %s", teamID, strings.Join(ids, ", ")) + } + if len(teams) == 1 { + p.log("Using Apple team %s (%s).", teams[0].Name, teams[0].ID) + + return teams[0].ID, nil + } + labels := make([]string, len(teams)) + for index, team := range teams { + labels[index] = fmt.Sprintf("%s (%s)", team.Name, team.ID) + } + chosen, err := p.asker.Choose("Which Apple Developer team should own the key?", labels) + if err != nil { + return "", err + } + + return teams[chosen].ID, nil +} diff --git a/templates/cli/internal/apns/appwrite/adapter_test.go b/templates/cli/internal/apns/appwrite/adapter_test.go new file mode 100644 index 0000000000..8128e8be58 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/adapter_test.go @@ -0,0 +1,695 @@ +package appwrite + +import ( + "bytes" + "context" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +const ( + testAppleID = "dev@example.com" + testPassword = "correct horse battery staple" + testCode = "123456" + testP8 = "-----BEGIN PRIVATE KEY-----\nMIGT\n-----END PRIVATE KEY-----" +) + +// fakeApple plays idmsa.apple.com, App Store Connect and the Developer +// portal: it runs the server side of SRP, so a wrong password or a wrong +// proof fails the way it would against Apple. +type fakeApple struct { + mu sync.Mutex + t *testing.T + requests []string + + widgetKeyGone bool + noTwoFactor bool + noTrustedDevices bool + olympusRefuses bool + // generation names the current signed-in cookie; bumping it expires + // every session handed out before. + generation int + textsSent int + // hashcashBits is the difficulty asked for; empty means 8. + hashcashBits string + teams []Team + keys []apns.ExistingKey + maxKeys bool + + salt []byte + b *big.Int + serverB []byte + clientA []byte + verifier *big.Int + created map[string]map[string]any +} + +func newFakeApple(t *testing.T) *fakeApple { + return &fakeApple{ + t: t, + teams: []Team{{ID: "ABCDE12345", Name: "Example Inc", Type: "Company/Organization"}}, + salt: []byte("0123456789abcdef"), + created: map[string]map[string]any{}, + } +} + +func (f *fakeApple) record(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) +} + +func (f *fakeApple) signedIn(r *http.Request) bool { + cookie, err := r.Cookie("myacinfo") + + return err == nil && cookie.Value == f.sessionCookie().Value +} + +func (f *fakeApple) sessionCookie() *http.Cookie { + return &http.Cookie{Name: "myacinfo", Value: fmt.Sprintf("signed-in-%d", f.generation), Path: "/"} +} + +// trusted reports whether the request carries the cookie 2sv/trust handed +// out, quoted the way Apple quotes it. +func (f *fakeApple) trusted(r *http.Request) bool { + cookie, err := r.Cookie("DES123") + + return err == nil && cookie.Value == "trusted==SRVT" && cookie.Quoted +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (f *fakeApple) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.record(r) + body, _ := io.ReadAll(r.Body) + var params map[string]any + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + form, _ := url.ParseQuery(string(body)) + params = map[string]any{} + for name := range form { + params[name] = form.Get(name) + } + } else { + _ = json.Unmarshal(body, ¶ms) + } + idmsa := r.Header.Get("X-Apple-Widget-Key") == "widget-key" + twoFactorSession := r.Header.Get("X-Apple-Id-Session-Id") == "session-1" && r.Header.Get("scnt") == "scnt-1" + + switch { + case r.Method == http.MethodHead && r.URL.Path == "/logout": + if len(r.Cookies()) > 0 { + f.t.Error("the widget key request sent cookies, which would sign the session out") + } + if !f.widgetKeyGone { + w.Header().Set("Location", "https://idmsa.apple.com/IDMSWebAuth/signout?widgetKey=widget-key&path=/") + } + w.WriteHeader(http.StatusFound) + + case r.URL.Path == "/olympus/v1/app/config": + w.WriteHeader(http.StatusNotFound) + + case r.URL.Path == "/appleauth/auth/signin/init" && idmsa: + f.clientA, _ = base64.StdEncoding.DecodeString(params["a"].(string)) + key, _ := passwordKey(testPassword, f.salt, 1000, "s2k") + x := new(big.Int).SetBytes(sha256Of(f.salt, sha256Of([]byte(":"), key))) + f.verifier = new(big.Int).Exp(srpG, x, srpN) + f.b = big.NewInt(987654321) + B := new(big.Int).Mul(hashPadded(srpN.Bytes(), srpG.Bytes()), f.verifier) + B.Add(B, new(big.Int).Exp(srpG, f.b, srpN)) + B.Mod(B, srpN) + f.serverB = B.Bytes() + writeJSON(w, http.StatusOK, map[string]any{ + "iteration": 1000, "salt": base64.StdEncoding.EncodeToString(f.salt), "protocol": "s2k", + "b": base64.StdEncoding.EncodeToString(f.serverB), "c": "challenge-c", + }) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth/signin": + bits := f.hashcashBits + if bits == "" { + bits = "8" + } + w.Header().Set("X-Apple-HC-Bits", bits) + w.Header().Set("X-Apple-HC-Challenge", "hc-challenge") + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/signin/complete" && idmsa: + token := r.Header.Get("X-Apple-HC") + if f.hashcashBits != "" && token != "" { + f.t.Errorf("answered a %s-bit hashcash: %q", f.hashcashBits, token) + } else if f.hashcashBits == "" && (!strings.Contains(token, ":hc-challenge::") || leadingZeroBits(sha1.Sum([]byte(token))) < 8) { + f.t.Errorf("hashcash = %q", token) + } + if params["c"] != "challenge-c" || params["accountName"] != testAppleID { + f.t.Errorf("complete params = %v", params) + } + // The server's side of SRP-6a: S = (A * v^u) ^ b mod N. + A := new(big.Int).SetBytes(f.clientA) + u := hashPadded(f.clientA, f.serverB) + S := new(big.Int).Exp(f.verifier, u, srpN) + S.Mul(S, A) + S.Exp(S, f.b, srpN) + K := sha256Of(S.Bytes()) + xor := new(big.Int).Xor(hashPadded(srpN.Bytes()), hashPadded(srpG.Bytes())) + expected := sha256Of(xor.Bytes(), sha256Of([]byte(testAppleID)), f.salt, f.clientA, f.serverB, K) + m1, _ := base64.StdEncoding.DecodeString(params["m1"].(string)) + if !bytes.Equal(m1, expected) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"serviceErrors": []map[string]string{{"code": "-20101", "message": "Your Apple ID or password was incorrect."}}}) + + return + } + if f.noTwoFactor || f.trusted(r) { + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + return + } + w.Header().Set("X-Apple-Id-Session-Id", "session-1") + w.Header().Set("scnt", "scnt-1") + writeJSON(w, http.StatusConflict, map[string]any{"authType": "hsa2"}) + + case r.Method == http.MethodGet && r.URL.Path == "/appleauth/auth" && twoFactorSession: + writeJSON(w, http.StatusOK, map[string]any{ + "trustedPhoneNumbers": []map[string]any{{"id": 1, "numberWithDialCode": "+1 (•••) •••-••12", "pushMode": "sms"}}, + "securityCode": map[string]any{"length": 6}, + "noTrustedDevices": f.noTrustedDevices, + }) + + case r.URL.Path == "/appleauth/auth/verify/trusteddevice/securitycode" && twoFactorSession: + code := params["securityCode"].(map[string]any)["code"] + if code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusNoContent) + + case r.Method == http.MethodPut && r.URL.Path == "/appleauth/auth/verify/phone" && twoFactorSession: + f.textsSent++ + writeJSON(w, http.StatusOK, map[string]any{}) + + case r.URL.Path == "/appleauth/auth/verify/phone/securitycode" && twoFactorSession: + phone, _ := params["phoneNumber"].(map[string]any) + code := params["securityCode"].(map[string]any)["code"] + if phone["id"] != float64(1) || params["mode"] != "sms" || code != testCode { + writeJSON(w, http.StatusBadRequest, map[string]any{"serviceErrors": []map[string]string{{"code": "-21669", "message": "Incorrect verification code."}}}) + + return + } + http.SetCookie(w, f.sessionCookie()) + w.WriteHeader(http.StatusOK) + + case r.URL.Path == "/appleauth/auth/2sv/trust" && twoFactorSession: + http.SetCookie(w, &http.Cookie{Name: "DES123", Value: "trusted==SRVT", Quoted: true, Path: "/"}) + w.WriteHeader(http.StatusNoContent) + + case r.URL.Path == "/olympus/v1/session": + if !f.signedIn(r) || f.olympusRefuses { + w.WriteHeader(http.StatusUnauthorized) + + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "user": map[string]any{"emailAddress": testAppleID}, "provider": map[string]any{"providerId": 1, "name": "Example Inc"}, + }) + + case strings.HasPrefix(r.URL.Path, "/services-account/QH65B2/"): + if !f.signedIn(r) { + w.WriteHeader(http.StatusUnauthorized) + + return + } + f.portal(w, r, strings.TrimPrefix(r.URL.Path, "/services-account/QH65B2/"), params) + + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } +} + +func (f *fakeApple) portal(w http.ResponseWriter, r *http.Request, path string, params map[string]any) { + switch path { + case "account/listTeams.action": + // The real endpoint refuses a JSON body with 415. + if r.Header.Get("Content-Type") != "" || r.ContentLength > 0 { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "teams": f.teams}) + case "account/auth/key/list": + // Like Apple, the key list takes a form and refuses JSON with 415. + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if params["teamId"] == nil || params["pageSize"] != "500" || params["sort"] != "name=asc" { + f.t.Errorf("list params = %v", params) + } + w.Header().Set("csrf", "csrf-token") + w.Header().Set("csrf_ts", "csrf-ts") + keys := []map[string]any{} + for _, key := range f.keys { + keys = append(keys, map[string]any{"keyId": key.ID, "keyName": key.Name, "canRevoke": key.CanRevoke}) + } + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": keys}) + case "account/auth/key/v2/create": + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusUnsupportedMediaType) + + return + } + if r.Header.Get("csrf") != "csrf-token" || r.Header.Get("csrf_ts") != "csrf-ts" { + f.t.Errorf("create sent csrf %q / %q", r.Header.Get("csrf"), r.Header.Get("csrf_ts")) + } + if f.maxKeys { + writeJSON(w, http.StatusOK, map[string]any{ + "resultCode": 9401, + "resultString": "You%20have%20already%20reached%20the%20maximum%20allowed%20number%20of%20team%20scoped%20Keys%20for%20this%20service%20in%20production%20and%20sandbox%20environment.", + }) + + return + } + f.created["NEWKEY1234"] = params + writeJSON(w, http.StatusOK, map[string]any{"resultCode": 0, "keys": []map[string]any{{"keyId": "NEWKEY1234", "keyName": params["name"]}}}) + case "account/auth/key/download": + if r.URL.Query().Get("keyId") != "NEWKEY1234" || r.URL.Query().Get("teamId") == "" { + f.t.Errorf("download query = %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(testP8)) + default: + f.t.Errorf("unexpected portal call %s", path) + w.WriteHeader(http.StatusNotFound) + } +} + +type scriptedAsker struct { + answers map[string][]string + choices map[string]int + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answers := range s.answers { + if strings.HasPrefix(question, prefix) && len(answers) > 0 { + s.answers[prefix] = answers[1:] + + return answers[0], nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(question string, options []string) (int, error) { + s.asked = append(s.asked, question) + if index, ok := s.choices[question]; ok { + return index, nil + } + + return 0, errors.New("no choice for " + question) +} + +// testClient runs the adapter against the fake with a fixed request, the way +// the CLI does. +type testClient struct { + adapter *Adapter + request apns.Request +} + +func (c testClient) CreateKey(ctx context.Context, teamID, name string, environment apns.Environment) (apns.Key, error) { + request := c.request + request.TeamID, request.Name, request.Environment = teamID, name, environment + + return c.adapter.CreateKey(ctx, request) +} + +// newTestClient points the adapter at fake. credentials holds the Apple ID +// and password the CLI would pass from APPWRITE_APPLE_ID and +// APPWRITE_APPLE_PASSWORD. +func newTestClient(t *testing.T, fake *fakeApple, asker *scriptedAsker, dir string, credentials map[string]string) (testClient, *[]string) { + server := httptest.NewServer(fake) + t.Cleanup(server.Close) + logged := &[]string{} + + return testClient{ + adapter: &Adapter{ + HTTP: server.Client(), + Hosts: Hosts{Idmsa: server.URL, AppStoreConnect: server.URL, Portal: server.URL}, + Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) }, + }, + request: apns.Request{ + AppleID: credentials["APPWRITE_APPLE_ID"], + Password: credentials["APPWRITE_APPLE_PASSWORD"], + SessionDir: dir, + Asker: asker, + Log: func(format string, args ...any) { + *logged = append(*logged, strings.TrimSpace(fmt.Sprintf(format, args...))) + }, + }, + }, logged +} + +func TestCreateKeySignsInWithTwoFactorAndReusesTheSession(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{ + "Apple ID": {testAppleID}, + "Password for": {testPassword}, + "Enter the 6-di": {"000000", testCode}, + }} + client, logged := newTestClient(t, fake, asker, dir, nil) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil { + t.Fatal(err) + } + if key.KeyID != "NEWKEY1234" || key.TeamID != "ABCDE12345" || key.P8 != testP8 { + t.Errorf("key = %+v", key) + } + created := fake.created["NEWKEY1234"] + configurations, _ := json.Marshal(created["serviceConfigurations"]) + requests, _ := json.Marshal(created["serviceConfigurationsRequests"]) + if created["teamId"] != "ABCDE12345" || created["name"] != "Appwrite Push" || created["scope"] != "team" || + string(configurations) != `{"U27F4V844T":[]}` || + string(requests) != `[{"environment":"all","identifiers":{},"isNew":true,"scope":"team","serviceId":"U27F4V844T"}]` { + t.Errorf("create params = %v", created) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for "+testAppleID+"|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)|Enter the 6-digit code shown on your Apple device (or type sms to get a text message)" { + t.Errorf("asked %v", asker.asked) + } + if !contains(*logged, "That code is not right. Try again.") || !contains(*logged, "Using Apple team Example Inc (ABCDE12345).") { + t.Errorf("logged %v", *logged) + } + info, err := os.Stat(filepath.Join(dir, "session.json")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("session file = %v, %v", info, err) + } + + // A second run reuses the trusted session: no questions, no sign-in. + again := &scriptedAsker{} + second, logged := newTestClient(t, fake, again, dir, nil) + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push 2", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + for _, request := range fake.requests { + if strings.Contains(request, "/appleauth/") { + t.Errorf("signed in again: %v", fake.requests) + + break + } + } + if !contains(*logged, "Using the saved Apple sign-in for "+testAppleID+".") { + t.Errorf("logged %v", *logged) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + asker := &scriptedAsker{} + client, logged := newTestClient(t, fake, asker, t.TempDir(), map[string]string{ + "APPWRITE_APPLE_ID": testAppleID, + "APPWRITE_APPLE_PASSWORD": testPassword, + }) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } + for _, line := range *logged { + if strings.Contains(line, testPassword) { + t.Errorf("logged the password: %v", *logged) + } + } +} + +func TestCreateKeyRejectsAWrongPassword(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + asker := &scriptedAsker{answers: map[string][]string{"Apple ID": {testAppleID}, "Password for": {"wrong"}}} + client, _ := newTestClient(t, fake, asker, dir, nil) + + if _, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll); !errors.Is(err, apns.ErrInvalidCredentials) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err == nil { + t.Error("saved a session after a failed sign-in") + } +} + +func TestCreateKeyReportsAFullTeam(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.maxKeys = true + fake.keys = []apns.ExistingKey{{ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 2 || maxKeys.Keys[0].ID != "OLDKEY1234" || maxKeys.Keys[1].Name != "Expo" { + t.Fatalf("err = %v", err) + } + if len(fake.created) != 0 { + t.Errorf("created %v", fake.created) + } +} + +func TestCreateKeyChoosesAmongTeams(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.teams = []Team{{ID: "AAAAA11111", Name: "Personal"}, {ID: "ABCDE12345", Name: "Example Inc"}} + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + asker := &scriptedAsker{choices: map[string]int{"Which Apple Developer team should own the key?": 1}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), env) + + key, err := client.CreateKey(context.Background(), "", "Appwrite Push", apns.EnvironmentAll) + if err != nil || key.TeamID != "ABCDE12345" { + t.Fatalf("key = %+v, %v", key, err) + } + + other, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + if _, err := other.CreateKey(context.Background(), "ZZZZZ99999", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "AAAAA11111, ABCDE12345") { + t.Errorf("err = %v", err) + } +} + +func TestWidgetKeyFallsBackToTheSavedCopy(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.widgetKeyGone = true + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "widget-key"), []byte("widget-key\n"), 0o600); err != nil { + t.Fatal(err) + } + client, _ := newTestClient(t, fake, &scriptedAsker{}, dir, map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + empty, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + if _, err := empty.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "sign-in key") { + t.Errorf("err = %v", err) + } +} + +func contains(lines []string, line string) bool { + for _, candidate := range lines { + if candidate == line { + return true + } + } + + return false +} + +func TestCreateKeyWithATextMessageCode(t *testing.T) { + fake := newFakeApple(t) + fake.noTrustedDevices = true + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-digit code sent to": {testCode}}} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Enter the 6-digit code sent to +1 (•••) •••-••12" { + t.Errorf("asked %v", asker.asked) + } + requests := strings.Join(fake.requests, ",") + // With no trusted devices and one phone, Apple has already texted the code. + if !strings.Contains(requests, "POST /appleauth/auth/verify/phone/securitycode") || strings.Contains(requests, "PUT /appleauth/auth/verify/phone,") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestTheSignInIsSavedAsSoonAsTheCodeIsAccepted(t *testing.T) { + fake := newFakeApple(t) + fake.olympusRefuses = true + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err == nil || + !strings.Contains(err.Error(), "refused the session") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "session.json")); err != nil { + t.Fatalf("the accepted sign-in was not saved: %v", err) + } + + fake.olympusRefuses = false + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } +} + +func TestAnExpiredSessionSignsInAgainWithoutACode(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + fake.generation++ + fake.requests = nil + again := &scriptedAsker{} + second, _ := newTestClient(t, fake, again, dir, env) + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if len(again.asked) != 0 { + t.Errorf("asked %v", again.asked) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || strings.Contains(requests, "/securitycode") { + t.Errorf("requests = %v", fake.requests) + } +} + +func TestCreateKeyForOneEnvironment(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), env) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentSandbox); err != nil { + t.Fatal(err) + } + requests := fake.created["NEWKEY1234"]["serviceConfigurationsRequests"].([]any) + if environment := requests[0].(map[string]any)["environment"]; environment != "sandbox" { + t.Errorf("environment = %v", environment) + } + +} + +func TestChoosingATextMessageIsNotACodeAttempt(t *testing.T) { + fake := newFakeApple(t) + asker := &scriptedAsker{answers: map[string][]string{ + "Enter the 6-digit code shown": {"sms"}, + "Enter the 6-digit code sent to": {"000000", "111111", testCode}, + }} + client, _ := newTestClient(t, fake, asker, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatalf("the third code was not accepted: %v", err) + } + if fake.textsSent != 1 || len(asker.asked) != 4 { + t.Errorf("texts = %d, asked %v", fake.textsSent, asker.asked) + } +} + +func TestAnImpracticalHashcashIsNotAnswered(t *testing.T) { + fake := newFakeApple(t) + fake.noTwoFactor = true + fake.hashcashBits = "40" + client, _ := newTestClient(t, fake, &scriptedAsker{}, t.TempDir(), map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword}) + + if _, err := client.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } +} + +func TestMaxKeysMessages(t *testing.T) { + for message, full := range map[string]bool{ + "You have already reached the maximum allowed number of Keys for this service.": true, + "You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.": true, + "An unexpected error occurred.": false, + } { + if got := maxKeysMessage.MatchString(message); got != full { + t.Errorf("maxKeysMessage(%q) = %v", message, got) + } + } +} + +func TestResetSignsInAgain(t *testing.T) { + fake := newFakeApple(t) + dir := t.TempDir() + env := map[string]string{"APPWRITE_APPLE_ID": testAppleID, "APPWRITE_APPLE_PASSWORD": testPassword} + first, _ := newTestClient(t, fake, &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}}, dir, env) + if _, err := first.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + + // The saved session and its trusted-machine cookie are ignored: Apple + // asks for the password check and a code again. + asker := &scriptedAsker{answers: map[string][]string{"Enter the 6-di": {testCode}}} + second, _ := newTestClient(t, fake, asker, dir, env) + second.request.Reset = true + fake.requests = nil + if _, err := second.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + requests := strings.Join(fake.requests, ",") + if !strings.Contains(requests, "/appleauth/auth/signin/complete") || !strings.Contains(requests, "/securitycode") || len(asker.asked) != 1 { + t.Errorf("asked %v, requests = %v", asker.asked, fake.requests) + } + + // The new session replaces the saved one: a normal run reuses it. + third, _ := newTestClient(t, fake, &scriptedAsker{}, dir, env) + fake.requests = nil + if _, err := third.CreateKey(context.Background(), "ABCDE12345", "Appwrite Push", apns.EnvironmentAll); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(fake.requests, ","), "/appleauth/") { + t.Errorf("signed in again after a reset: %v", fake.requests) + } +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/auth.go b/templates/cli/internal/apns/appwrite/auth.go new file mode 100644 index 0000000000..a912188c89 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/auth.go @@ -0,0 +1,507 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +// The sign-in follows spaceship (fastlane), which keeps up with Apple's +// changes in readable code: the widget key from the sign out redirect, SRP +// against idmsa.apple.com, hashcash, then two-factor and a trusted session. + +// Hosts are the Apple servers the client talks to. Tests point them at a fake. +type Hosts struct { + Idmsa string + AppStoreConnect string + Portal string +} + +// DefaultHosts are Apple's production servers. +var DefaultHosts = Hosts{ + Idmsa: "https://idmsa.apple.com", + AppStoreConnect: "https://appstoreconnect.apple.com", + Portal: "https://developer.apple.com", +} + +type portalClient struct { + http *http.Client + jar *savedJar + hosts Hosts + dir string + asker apns.Asker + log func(string, ...any) + random io.Reader + now func() time.Time + + widgetKey string + sessionID string + scnt string + csrf string + csrfTS string +} + +type olympusSession struct { + User struct { + EmailAddress string `json:"emailAddress"` + } `json:"user"` + Provider *struct { + ProviderID int64 `json:"providerId"` + Name string `json:"name"` + } `json:"provider"` +} + +type serviceErrors struct { + ServiceErrors []struct { + Code string `json:"code"` + Title string `json:"title"` + Message string `json:"message"` + } `json:"serviceErrors"` +} + +func (e serviceErrors) message() string { + for _, failure := range e.ServiceErrors { + if failure.Message != "" { + return failure.Message + } + if failure.Title != "" { + return failure.Title + } + } + + return "" +} + +func (e serviceErrors) has(code string) bool { + for _, failure := range e.ServiceErrors { + if failure.Code == code { + return true + } + } + + return false +} + +// request sends one request and reads the whole response. +func (p *portalClient) request(ctx context.Context, method, target string, body any, headers map[string]string) (*http.Response, []byte, error) { + var reader io.Reader + contentType := "" + switch value := body.(type) { + case nil: + case url.Values: + reader = strings.NewReader(value.Encode()) + contentType = "application/x-www-form-urlencoded" + default: + encoded, err := json.Marshal(body) + if err != nil { + return nil, nil, err + } + reader = bytes.NewReader(encoded) + contentType = "application/json" + } + request, err := http.NewRequestWithContext(ctx, method, target, reader) + if err != nil { + return nil, nil, err + } + request.Header.Set("User-Agent", "Appwrite CLI") + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := p.http.Do(request) + if err != nil { + return nil, nil, err + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + return nil, nil, err + } + + return response, payload, nil +} + +// idmsaHeaders are sent with every sign-in request. After Apple asks for +// two-factor, they also carry the session it handed out. +func (p *portalClient) idmsaHeaders() map[string]string { + headers := map[string]string{ + "Accept": "application/json, text/javascript", + "X-Requested-With": "XMLHttpRequest", + "X-Apple-Widget-Key": p.widgetKey, + } + if p.sessionID != "" { + headers["Accept"] = "application/json" + headers["X-Apple-Id-Session-Id"] = p.sessionID + headers["scnt"] = p.scnt + } + + return headers +} + +// session asks App Store Connect who is signed in. It returns nil when the +// cookies do not hold a usable session. +func (p *portalClient) session(ctx context.Context) (*olympusSession, error) { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.AppStoreConnect+"/olympus/v1/session", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, nil + } + var session olympusSession + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &session) != nil || session.Provider == nil { + return nil, nil + } + + return &session, nil +} + +// loadWidgetKey reads the key App Store Connect's web app signs in with from +// the sign out redirect, then from the copy saved by an earlier run, then from +// the endpoint Apple used to document (removed in September 2026). +func (p *portalClient) loadWidgetKey(ctx context.Context) error { + // Without a session folder there is nowhere to keep a copy. + cache := "" + if p.dir != "" { + cache = filepath.Join(p.dir, "widget-key") + } + if key := p.widgetKeyFromSignOut(ctx); key != "" { + p.widgetKey = key + p.cacheWidgetKey(cache) + + return nil + } + if cached, err := os.ReadFile(cache); cache != "" && err == nil && len(bytes.TrimSpace(cached)) > 0 { + p.widgetKey = string(bytes.TrimSpace(cached)) + + return nil + } + + response, payload, err := p.request(ctx, http.MethodGet, + p.hosts.AppStoreConnect+"/olympus/v1/app/config?hostname=itunesconnect.apple.com", nil, + map[string]string{"Accept": "application/json"}) + if err != nil { + return fmt.Errorf("could not reach App Store Connect: %w", err) + } + var config struct { + AuthServiceKey string `json:"authServiceKey"` + } + if response.StatusCode != http.StatusOK || json.Unmarshal(payload, &config) != nil || config.AuthServiceKey == "" { + return fmt.Errorf("could not read App Store Connect's sign-in key (HTTP %d)", response.StatusCode) + } + p.widgetKey = config.AuthServiceKey + p.cacheWidgetKey(cache) + + return nil +} + +func (p *portalClient) cacheWidgetKey(cache string) { + if cache != "" { + _ = os.WriteFile(cache, []byte(p.widgetKey), 0o600) + } +} + +// widgetKeyFromSignOut must neither send the session cookies nor follow the +// redirect: following it signs the session out. +func (p *portalClient) widgetKeyFromSignOut(ctx context.Context) string { + bare := &http.Client{ + Transport: p.http.Transport, + Timeout: p.http.Timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + request, err := http.NewRequestWithContext(ctx, http.MethodHead, p.hosts.AppStoreConnect+"/logout", nil) + if err != nil { + return "" + } + request.Header.Set("User-Agent", "Appwrite CLI") + response, err := bare.Do(request) + if err != nil { + return "" + } + response.Body.Close() + location, err := url.Parse(response.Header.Get("Location")) + if err != nil { + return "" + } + + return location.Query().Get("widgetKey") +} + +// signIn runs the SRP sign-in and, when Apple asks for it, two-factor. +func (p *portalClient) signIn(ctx context.Context, accountName, password string) error { + client, err := newSRPClient(p.random) + if err != nil { + return err + } + response, payload, err := p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/init", map[string]any{ + "a": base64.StdEncoding.EncodeToString(client.A), + "accountName": accountName, + "protocols": []string{"s2k", "s2k_fo"}, + }, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var challenge struct { + serviceErrors + Iteration int `json:"iteration"` + Salt string `json:"salt"` + Protocol string `json:"protocol"` + B string `json:"b"` + C string `json:"c"` + } + if err := json.Unmarshal(payload, &challenge); err != nil || response.StatusCode != http.StatusOK || challenge.B == "" { + if message := challenge.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } + salt, saltErr := base64.StdEncoding.DecodeString(challenge.Salt) + serverB, bErr := base64.StdEncoding.DecodeString(challenge.B) + if saltErr != nil || bErr != nil { + return errors.New("Apple sent an unreadable sign-in challenge") + } + key, err := passwordKey(password, salt, challenge.Iteration, challenge.Protocol) + if err != nil { + return err + } + m1, m2, err := client.proofs(accountName, key, salt, serverB) + if err != nil { + return err + } + + headers := p.idmsaHeaders() + token, err := p.hashcash(ctx) + if err != nil { + return err + } + if token != "" { + headers["X-Apple-HC"] = token + } + response, payload, err = p.request(ctx, http.MethodPost, p.hosts.Idmsa+"/appleauth/auth/signin/complete?isRememberMeEnabled=false", map[string]any{ + "accountName": accountName, + "c": challenge.C, + "m1": base64.StdEncoding.EncodeToString(m1), + "m2": base64.StdEncoding.EncodeToString(m2), + "rememberMe": false, + }, headers) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + + switch response.StatusCode { + case http.StatusOK: + return nil + case http.StatusConflict: + p.sessionID = response.Header.Get("X-Apple-Id-Session-Id") + p.scnt = response.Header.Get("scnt") + + return p.twoFactor(ctx) + case http.StatusUnauthorized, http.StatusForbidden: + return apns.ErrInvalidCredentials + case http.StatusPreconditionFailed: + return errors.New("Apple needs you to accept its terms or update your account first. Sign in at https://appleid.apple.com, then try again") + default: + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the sign-in: %s", message) + } + + return fmt.Errorf("Apple refused the sign-in (HTTP %d)", response.StatusCode) + } +} + +// hashcash answers the proof-of-work challenge Apple hands out on the sign-in +// page. Without one, Apple decides; spaceship carries on in that case too. +// maxHashcashBits bounds the work: Apple asks for about 11 bits, and 24 is +// some 16 million attempts, a few seconds. A harder challenge is not +// answered, as one Apple sends without a challenge is not. +const maxHashcashBits = 24 + +func (p *portalClient) hashcash(ctx context.Context) (string, error) { + response, _, err := p.request(ctx, http.MethodGet, + p.hosts.Idmsa+"/appleauth/auth/signin?widgetKey="+url.QueryEscape(p.widgetKey), nil, nil) + if err != nil { + return "", nil + } + bits, err := strconv.Atoi(response.Header.Get("X-Apple-HC-Bits")) + challenge := response.Header.Get("X-Apple-HC-Challenge") + if err != nil || challenge == "" || bits < 0 || bits > maxHashcashBits { + return "", nil + } + + return hashcash(ctx, bits, challenge, p.now()) +} + +type trustedPhone struct { + ID int64 `json:"id"` + NumberWithDialCode string `json:"numberWithDialCode"` + PushMode string `json:"pushMode"` +} + +const codeAttempts = 3 + +// twoFactor asks for the code Apple sent to the person's devices or phone, +// checks it, and marks the session trusted so later runs skip this step. +func (p *portalClient) twoFactor(ctx context.Context) error { + response, payload, err := p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth", nil, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + var options struct { + serviceErrors + TrustedDevices []any `json:"trustedDevices"` + TrustedPhoneNumbers []trustedPhone `json:"trustedPhoneNumbers"` + NoTrustedDevices bool `json:"noTrustedDevices"` + SecurityCode struct { + Length int `json:"length"` + } `json:"securityCode"` + } + if json.Unmarshal(payload, &options) != nil || response.StatusCode >= 300 { + return fmt.Errorf("Apple asked for two-factor authentication but did not say how (HTTP %d)", response.StatusCode) + } + if len(options.TrustedPhoneNumbers) == 0 { + if len(options.TrustedDevices) > 0 { + return errors.New("this Apple ID uses the older two-step verification. Turn on two-factor authentication at https://appleid.apple.com, then try again") + } + if message := options.message(); message != "" { + return fmt.Errorf("Apple could not send a verification code: %s", message) + } + + return errors.New("Apple asked for two-factor authentication but offered no way to receive a code") + } + length := options.SecurityCode.Length + if length == 0 { + length = 6 + } + + // A code was pushed to the trusted devices, unless there are none. With no + // devices and a single phone, Apple has already texted it. + var phone *trustedPhone + question := fmt.Sprintf("Enter the %d-digit code shown on your Apple device (or type sms to get a text message)", length) + if options.NoTrustedDevices { + if len(options.TrustedPhoneNumbers) == 1 { + phone = &options.TrustedPhoneNumbers[0] + } else if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + } + + // Only codes Apple rejects count as attempts; switching to a text message + // does not. + wrong := 0 + for { + code, err := p.asker.Ask(question, false) + if err != nil { + return err + } + code = strings.TrimSpace(code) + if phone == nil && strings.EqualFold(code, "sms") { + if phone, err = p.textCode(ctx, options.TrustedPhoneNumbers); err != nil { + return err + } + question = fmt.Sprintf("Enter the %d-digit code sent to %s", length, phone.NumberWithDialCode) + + continue + } + + route := "trusteddevice" + body := map[string]any{"securityCode": map[string]any{"code": code}} + if phone != nil { + route = "phone" + body["phoneNumber"] = map[string]any{"id": phone.ID} + body["mode"] = phoneMode(phone) + } + response, payload, err := p.request(ctx, http.MethodPost, + p.hosts.Idmsa+"/appleauth/auth/verify/"+route+"/securitycode", body, p.idmsaHeaders()) + if err != nil { + return fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode < 300 { + break + } + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if failure.has("-21669") || strings.Contains(strings.ToLower(failure.message()), "verification code") { + wrong++ + if wrong >= codeAttempts { + return errors.New("the verification code was wrong too many times") + } + p.log("That code is not right. Try again.") + + continue + } + if message := failure.message(); message != "" { + return fmt.Errorf("Apple refused the verification code: %s", message) + } + + return fmt.Errorf("Apple refused the verification code (HTTP %d)", response.StatusCode) + } + + // Trusting the session is what lets the next run skip two-factor. A + // failure here still leaves a working session, as spaceship notes. + _, _, _ = p.request(ctx, http.MethodGet, p.hosts.Idmsa+"/appleauth/auth/2sv/trust", nil, p.idmsaHeaders()) + + return nil +} + +func phoneMode(phone *trustedPhone) string { + if phone.PushMode != "" { + return phone.PushMode + } + + return "sms" +} + +// textCode asks which trusted phone should get the code and has Apple text it. +func (p *portalClient) textCode(ctx context.Context, phones []trustedPhone) (*trustedPhone, error) { + chosen := 0 + if len(phones) > 1 { + labels := make([]string, len(phones)) + for index, phone := range phones { + labels[index] = phone.NumberWithDialCode + } + var err error + if chosen, err = p.asker.Choose("Which phone number should get the code?", labels); err != nil { + return nil, err + } + } + phone := &phones[chosen] + response, payload, err := p.request(ctx, http.MethodPut, p.hosts.Idmsa+"/appleauth/auth/verify/phone", map[string]any{ + "phoneNumber": map[string]any{"id": phone.ID}, + "mode": phoneMode(phone), + }, p.idmsaHeaders()) + if err != nil { + return nil, fmt.Errorf("could not reach Apple: %w", err) + } + if response.StatusCode >= 300 { + var failure serviceErrors + _ = json.Unmarshal(payload, &failure) + if message := failure.message(); message != "" { + return nil, fmt.Errorf("Apple could not text the code: %s", message) + } + + return nil, fmt.Errorf("Apple could not text the code (HTTP %d)", response.StatusCode) + } + p.log("Apple sent a code to %s.", phone.NumberWithDialCode) + + return phone, nil +} diff --git a/templates/cli/internal/apns/appwrite/jar.go b/templates/cli/internal/apns/appwrite/jar.go new file mode 100644 index 0000000000..8c60dda5ac --- /dev/null +++ b/templates/cli/internal/apns/appwrite/jar.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "encoding/json" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "sync" + "time" +) + +// savedJar is a cookie jar that can be written to disk, so a signed-in and +// trusted Apple session is reused and the next run skips two-factor. +// net/http/cookiejar cannot list its cookies, so each cookie is also kept with +// the URL it came from and replayed into a fresh jar when loaded. +type savedJar struct { + mu sync.Mutex + inner *cookiejar.Jar + cookies map[string]savedCookie +} + +type savedCookie struct { + URL string `json:"url"` + Name string `json:"name"` + Value string `json:"value"` + Quoted bool `json:"quoted,omitempty"` + Domain string `json:"domain,omitempty"` + Path string `json:"path,omitempty"` + Expires time.Time `json:"expires,omitzero"` + Secure bool `json:"secure,omitempty"` + HTTPOnly bool `json:"httpOnly,omitempty"` +} + +func newSavedJar() *savedJar { + inner, _ := cookiejar.New(nil) + + return &savedJar{inner: inner, cookies: map[string]savedCookie{}} +} + +func (j *savedJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + j.inner.SetCookies(u, cookies) + origin := (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/"}).String() + for _, cookie := range cookies { + domain := cookie.Domain + if domain == "" { + domain = u.Hostname() + } + id := domain + "|" + cookie.Path + "|" + cookie.Name + expires := cookie.Expires + if cookie.MaxAge > 0 { + expires = time.Now().Add(time.Duration(cookie.MaxAge) * time.Second) + } + if cookie.MaxAge < 0 || (!expires.IsZero() && expires.Before(time.Now())) { + delete(j.cookies, id) + + continue + } + j.cookies[id] = savedCookie{ + URL: origin, Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: expires, + Secure: cookie.Secure, HTTPOnly: cookie.HttpOnly, + } + } +} + +func (j *savedJar) Cookies(u *url.URL) []*http.Cookie { + return j.inner.Cookies(u) +} + +// load replays the cookies saved at path. A missing or unreadable file leaves +// the jar empty: the caller then signs in again. +func (j *savedJar) load(path string) { + contents, err := os.ReadFile(path) + if err != nil { + return + } + var saved []savedCookie + if json.Unmarshal(contents, &saved) != nil { + return + } + for _, cookie := range saved { + origin, err := url.Parse(cookie.URL) + if err != nil || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { + continue + } + j.SetCookies(origin, []*http.Cookie{{ + Name: cookie.Name, Value: cookie.Value, Quoted: cookie.Quoted, + Domain: cookie.Domain, Path: cookie.Path, Expires: cookie.Expires, + Secure: cookie.Secure, HttpOnly: cookie.HTTPOnly, + }}) + } +} + +// save writes the cookies to path, readable by the user only. +func (j *savedJar) save(path string) error { + j.mu.Lock() + saved := make([]savedCookie, 0, len(j.cookies)) + for _, cookie := range j.cookies { + saved = append(saved, cookie) + } + j.mu.Unlock() + + contents, err := json.Marshal(saved) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + defer os.Remove(temporary.Name()) + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + + return err + } + if err := temporary.Close(); err != nil { + return err + } + + return os.Rename(temporary.Name(), path) +} diff --git a/templates/cli/internal/apns/appwrite/portal.go b/templates/cli/internal/apns/appwrite/portal.go new file mode 100644 index 0000000000..4a33de93e3 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/portal.go @@ -0,0 +1,253 @@ +package appwrite + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) +{% verbatim %} +// The Developer portal's private API, as the portal's own pages and +// @expo/apple-utils use it: cookie authenticated JSON under +// services-account/QH65B2, with the team in every request and CSRF tokens +// that a read hands out and a write sends back. + +const ( + apnsServiceID = "U27F4V844T" + keysPageSize = 500 +) + +// maxKeysMessage is Apple's answer when the team has no free key slot, worded +// "maximum allowed number of Keys" and, since 2026, "maximum allowed number +// of team scoped Keys for this service in production and sandbox +// environment". +var maxKeysMessage = regexp.MustCompile(`(?i)maximum allowed number of (?:[a-z ]+ )?keys`) + +var ( + errSessionExpired = errors.New("the Apple session expired") + errMaxKeys = errors.New("maximum keys") +) + +// Team is an Apple Developer team the account belongs to. +type Team struct { + ID string `json:"teamId"` + Name string `json:"name"` + Type string `json:"type"` +} + +// portalKey is a key as the portal lists it. +type portalKey struct { + ID string `json:"keyId"` + Name string `json:"keyName"` + CanRevoke bool `json:"canRevoke"` +} + +type portalResult struct { + ResultCode int `json:"resultCode"` + ResultString string `json:"resultString"` + UserString string `json:"userString"` +} + +func (r portalResult) message() string { + for _, value := range []string{r.UserString, r.ResultString} { + if decoded, err := url.PathUnescape(value); err == nil && decoded != "" { + return decoded + } + if value != "" { + return value + } + } + + return "" +} + +// bodyEncoding is how a POST to the portal sends its values. +type bodyEncoding int + +const ( + // formBody is what the portal expects, as apple-utils and spaceship send + // it: an endpoint answers a JSON body with 415. + formBody bodyEncoding = iota + // jsonBody is only for creating a key, which takes nested values. + jsonBody +) + +// portal calls one endpoint and returns the raw body after checking the +// result code. GET requests carry teamID in the query, others in the body. +func (p *portalClient) portal(ctx context.Context, method, path, teamID string, values map[string]any, encoding bodyEncoding) ([]byte, error) { + target := p.hosts.Portal + "/services-account/QH65B2/" + path + var body any + if method == http.MethodGet { + query := url.Values{} + if teamID != "" { + query.Set("teamId", teamID) + } + for name, value := range values { + query.Set(name, fmt.Sprint(value)) + } + target += "?" + query.Encode() + } else if encoding == jsonBody { + data := map[string]any{"teamId": teamID} + for name, value := range values { + data[name] = value + } + body = data + } else { + form := url.Values{} + if teamID != "" { + form.Set("teamId", teamID) + } + for name, value := range values { + form.Set(name, fmt.Sprint(value)) + } + // A call with nothing to send has no body at all. + if len(form) > 0 { + body = form + } + } + headers := map[string]string{"Accept": "application/json, text/plain, */*"} + if p.csrf != "" || p.csrfTS != "" { + headers["csrf"] = p.csrf + headers["csrf_ts"] = p.csrfTS + } + + response, payload, err := p.request(ctx, method, target, body, headers) + if err != nil { + return nil, fmt.Errorf("could not reach the Apple Developer portal: %w", err) + } + if token := response.Header.Get("csrf"); token != "" { + p.csrf, p.csrfTS = token, response.Header.Get("csrf_ts") + } + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return nil, errSessionExpired + } + if response.StatusCode >= 300 { + return nil, fmt.Errorf("the Apple Developer portal returned HTTP %d for %s", response.StatusCode, path) + } + + return payload, nil +} + +// result decodes a JSON portal response, failing on a non-zero result code. +func result(payload []byte, out any) error { + var status portalResult + if err := json.Unmarshal(payload, &status); err != nil { + return fmt.Errorf("the Apple Developer portal sent an unreadable response: %w", err) + } + if status.ResultCode != 0 { + message := status.message() + if strings.Contains(message, "session has expired") { + return errSessionExpired + } + if maxKeysMessage.MatchString(message) { + return errMaxKeys + } + if message == "" { + message = fmt.Sprintf("result code %d", status.ResultCode) + } + + return fmt.Errorf("the Apple Developer portal refused the request: %s", message) + } + + return json.Unmarshal(payload, out) +} + +func (p *portalClient) teams(ctx context.Context) ([]Team, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/listTeams.action", "", nil, formBody) + if err != nil { + return nil, err + } + var response struct { + Teams []Team `json:"teams"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + + return response.Teams, nil +} + +// keys lists the team's keys. It also hands out the CSRF tokens that creating +// a key needs, so it always runs first. +func (p *portalClient) keys(ctx context.Context, teamID string) ([]apns.ExistingKey, error) { + var all []apns.ExistingKey + for page := 1; ; page++ { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/list", teamID, map[string]any{ + "pageNumber": page, + "pageSize": keysPageSize, + "sort": "name=asc", + }, formBody) + if err != nil { + return nil, err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return nil, err + } + for _, key := range response.Keys { + all = append(all, apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke}) + } + if len(response.Keys) < keysPageSize { + return all, nil + } + } +} + +// createKey creates a team-scoped key with only APNs enabled, for the given +// environment, and returns its ID. +func (p *portalClient) createKey(ctx context.Context, teamID, name string, environment apns.Environment) (string, error) { + payload, err := p.portal(ctx, http.MethodPost, "account/auth/key/v2/create", teamID, map[string]any{ + "name": name, + "scope": "team", + "serviceConfigurations": map[string][]string{apnsServiceID: {}}, + "serviceConfigurationsRequests": []map[string]any{{ + "isNew": true, + "serviceId": apnsServiceID, + "identifiers": map[string]any{}, + "environment": string(environment), + "scope": "team", + }}, + }, jsonBody) + if err != nil { + return "", err + } + var response struct { + Keys []portalKey `json:"keys"` + } + if err := result(payload, &response); err != nil { + return "", err + } + if len(response.Keys) == 0 || response.Keys[0].ID == "" { + return "", errors.New("the Apple Developer portal created no key") + } + + return response.Keys[0].ID, nil +} + +// downloadKey returns the key's .p8 contents. Apple serves them once. +func (p *portalClient) downloadKey(ctx context.Context, teamID, keyID string) (string, error) { + payload, err := p.portal(ctx, http.MethodGet, "account/auth/key/download", teamID, map[string]any{"keyId": keyID}, formBody) + if err != nil { + return "", err + } + contents := string(payload) + if !strings.Contains(contents, "PRIVATE KEY") { + var ignored struct{} + if err := result(payload, &ignored); err != nil { + return "", err + } + + return "", errors.New("the Apple Developer portal did not send the key") + } + + return contents, nil +} +{% endverbatim %} \ No newline at end of file diff --git a/templates/cli/internal/apns/appwrite/srp.go b/templates/cli/internal/apns/appwrite/srp.go new file mode 100644 index 0000000000..91ab23d115 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp.go @@ -0,0 +1,184 @@ +package appwrite + +import ( + "context" + "crypto/pbkdf2" + "crypto/sha1" + "crypto/sha256" + "errors" + "fmt" + "io" + "math/big" + "strconv" + "strings" + "time" +) + +// Apple signs in with SRP-6a over the 2048-bit group of RFC 5054 and SHA-256, +// so the password itself never leaves the machine. This follows +// fastlane-sirp, the SRP client spaceship uses, byte for byte. Its quirks +// matter: x leaves the username out, and A, S and the server proof are +// hashed and sent without leading zero bytes, while k and u pad their inputs +// to the length of N. + +var ( + srpN = mustHex(strings.Join([]string{ + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050", + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50", + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8", + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B", + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748", + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6", + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6", + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73", + }, "")) + srpG = big.NewInt(2) + srpLength = len(srpN.Bytes()) +) + +func mustHex(value string) *big.Int { + number, ok := new(big.Int).SetString(value, 16) + if !ok { + panic("invalid hex: " + value) + } + + return number +} + +func sha256Of(parts ...[]byte) []byte { + hash := sha256.New() + for _, part := range parts { + hash.Write(part) + } + + return hash.Sum(nil) +} + +// padded returns value left-padded with zeros to the length of N. +func padded(value []byte) []byte { + if len(value) >= srpLength { + return value + } + out := make([]byte, srpLength) + copy(out[srpLength-len(value):], value) + + return out +} + +// hashPadded is fastlane-sirp's H(): the hash of the padded values, as a +// number reduced modulo N. +func hashPadded(values ...[]byte) *big.Int { + parts := make([][]byte, len(values)) + for index, value := range values { + parts[index] = padded(value) + } + sum := new(big.Int).SetBytes(sha256Of(parts...)) + + return sum.Mod(sum, srpN) +} + +// passwordKey derives the value Apple's SRP uses as the password: PBKDF2 over +// the SHA-256 of the password, hex encoded first for the older s2k_fo protocol. +func passwordKey(password string, salt []byte, iterations int, protocol string) ([]byte, error) { + digest := sha256Of([]byte(password)) + switch protocol { + case "s2k": + case "s2k_fo": + digest = []byte(fmt.Sprintf("%x", digest)) + default: + return nil, fmt.Errorf("unsupported Apple sign-in protocol %q", protocol) + } + + return pbkdf2.Key(sha256.New, string(digest), salt, iterations, 32) +} + +type srpClient struct { + a *big.Int + A []byte +} + +func newSRPClient(random io.Reader) (*srpClient, error) { + secret := make([]byte, 256) + if _, err := io.ReadFull(random, secret); err != nil { + return nil, err + } + + return newSRPClientWith(new(big.Int).SetBytes(secret)), nil +} + +func newSRPClientWith(a *big.Int) *srpClient { + return &srpClient{a: a, A: new(big.Int).Exp(srpG, a, srpN).Bytes()} +} + +// proofs computes the client proof M1 and the expected server proof M2 for +// the server's salt and B, given the derived password key. +func (c *srpClient) proofs(accountName string, key, salt, serverB []byte) ([]byte, []byte, error) { + B := new(big.Int).SetBytes(serverB) + if new(big.Int).Mod(B, srpN).Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + u := hashPadded(c.A, serverB) + if u.Sign() == 0 { + return nil, nil, errors.New("Apple sent an invalid sign-in challenge") + } + k := hashPadded(srpN.Bytes(), srpG.Bytes()) + x := new(big.Int).SetBytes(sha256Of(salt, sha256Of([]byte(":"), key))) + + // S = (B - k * g^x) ^ (a + u * x) mod N + base := new(big.Int).Exp(srpG, x, srpN) + base.Mul(base, k) + base.Sub(B, base) + base.Mod(base, srpN) + exponent := new(big.Int).Mul(u, x) + exponent.Add(exponent, c.a) + S := new(big.Int).Exp(base, exponent, srpN) + K := sha256Of(S.Bytes()) + + hashN := hashPadded(srpN.Bytes()) + hashG := hashPadded(srpG.Bytes()) + xor := new(big.Int).Xor(hashN, hashG) + M1 := sha256Of(xor.Bytes(), sha256Of([]byte(accountName)), salt, c.A, serverB, K) + M2 := new(big.Int).SetBytes(sha256Of(c.A, M1, K)).Bytes() + + return M1, M2, nil +} + +// hashcash answers Apple's proof-of-work challenge: the first counter whose +// "1:bits:date:challenge::counter" string has a SHA-1 starting with bits +// zero bits. The date is in UTC, as @expo/apple-utils sends it (spaceship +// uses local time, which only matches on a UTC machine). +// The search stops when ctx is done, checked every hashcashBatch attempts. +func hashcash(ctx context.Context, bits int, challenge string, now time.Time) (string, error) { + prefix := "1:" + strconv.Itoa(bits) + ":" + now.UTC().Format("20060102150405") + ":" + challenge + "::" + for counter := 0; ; counter++ { + if counter%hashcashBatch == 0 { + if err := ctx.Err(); err != nil { + return "", err + } + } + token := prefix + strconv.Itoa(counter) + if leadingZeroBits(sha1.Sum([]byte(token))) >= bits { + return token, nil + } + } +} + +const hashcashBatch = 1 << 16 + +func leadingZeroBits(sum [sha1.Size]byte) int { + count := 0 + for _, value := range sum { + if value == 0 { + count += 8 + + continue + } + for mask := byte(0x80); mask != 0 && value&mask == 0; mask >>= 1 { + count++ + } + + break + } + + return count +} diff --git a/templates/cli/internal/apns/appwrite/srp_test.go b/templates/cli/internal/apns/appwrite/srp_test.go new file mode 100644 index 0000000000..55f2fae534 --- /dev/null +++ b/templates/cli/internal/apns/appwrite/srp_test.go @@ -0,0 +1,129 @@ +package appwrite + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "errors" + "math/big" + "testing" + "time" +) + +// The expected values were computed with fastlane-sirp and spaceship's +// hashcash, the Ruby code spaceship signs in to Apple with, for fixed inputs. +var srpVectors = []struct { + protocol, password string + iterations int + a, clientA, salt, serverB, derived, m1, m2 string +}{ + { + protocol: "s2k", + password: "correct horse battery staple", + iterations: 1000, + a: "976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c976c0718d60c26f9f0c222b52a8e47282b714657aebb3e224000792ec3e9054c", + clientA: "lOFBKFUcFnIqiPacVzlN2auN4I/AMJTWac7+/T1qEp2nhqIlgqfiFMgS/lLgf2YHa/88G9823FwFSNcRw/h46ujunwE/pkwZKFscpaa68psSBryNQFK1HsWrHblsQdOicXq+dmD+p70d1tx6w935hE8Htq+3iFdyqvb/nrau/qdyKJ1ueEWpnJ71r35XmHdfVQ94/qAsOp753gLCRFNdTmvFONzkqS9PRzC3Ug9F0PKeMf1kQhurzoMZRo/HqS+Pz7bTdDW5efKMg4s6uvX7t+LffwUwxuoCP/NksHQyRznffQivjnh596NJ4XCrEpvu+tE4bOBjtlSYSYI45JQmrw==", + salt: "8XJskbjIRdqLv44iq8fuiQ==", + serverB: "bmqruv0a/EiZ7xJfeLeG1ilXyXzhUJNYtDmwpbgK152y+6RctWvwBHLLnTVQlfHYzKIgMrlChKSyCl6tXjOkKz73r03bRCUL675sZDN7K3pGEL2TvrU/qdb7XBgKUWI1ctW54xynuBy1YpSYoETwrrHirVB3LDKlBdp+J3T+MfuJeR270cGJ/nDKAXtfj3qwagSrM17wI193qe4jEevZD4mvlKztv29zQ6/T9q6vTopJ524kZTzcY00t0y9r7jVFzpRXXMfg5hCzrNdKkECjFpC6/UI/xDKmyl/Uux8yG5dA5Gcxcsb0GfT7g01i3sdd619gUlceFifCpvI56NIapQ==", + derived: "adb53b51adb7d706507d630b73e0d85ec46c34ffa95eabc6bfb6893092bfe538", + m1: "T02D0QeodD+9ldyfjZQUTynd976L9gg52UBkxEFXRjE=", + m2: "bkDJQIvXJOIj1/cMlHJfnab9VrMyBSzaVNJY4kdCik0=", + }, + { + protocol: "s2k_fo", + password: "p@ss wörd", + iterations: 1001, + a: "62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b62d6b738b14920953248eb84424d1a0a78c738c0a95e421e7f86ae320348068b", + clientA: "q3Y06Eo7PTQ8TvzRw5xZqRwCKcw0GTxg5+6b5a75N+Dy3rnNXSAM0/aPomhpAcHMz14FtSALPmpNXEYa3UxUUGWiLkp1RDLTzdJT2U/+X0lU6XBm5W0O8hyYe3/Na8JrQvNvsDfmahk4/w8ZKKDTSq1cbRiQHWV7wA/D5tl5jh/xPADahOe4jt3rU+COrHR3JcVDWknM0B4cL0HywA2J2/RrK/ushhR/E8Uu7wGvb7LbcFNhOmp4q6q9bLrm5zS6tbH6dXfD3V5W4bvgFOwhKKmS0B39v4sZZRSkHiTdLMAipBgShP5kPAi1S0RoRXFYD1sHI2v7m0mP5P+S45wI", + salt: "lneDgZnxLTb17pcdSfaKvA==", + serverB: "Ht8tUspsId2o6YnOxb/QKSLQ3vn0scNeYsV7ElRHRmXKj3b6neErfO7BCpQFvY59g1hvwwDT2fFa4Ah2nzLjRHG83W/vA/Jt0MltHSECjhV0ASfMHfMQVA6VlDLNAdAO8e4weK49v9j/AWobmdXQjBkSRTuTzMT4rKjXlmXdT54Mo3ryCPxhA2r4u+FYV4qn2vaW2GBKAj2ONMZQW0DWV90LhnnQZ6mib/mpUctOv+rTZ893ZbtytYByZtwnd91AV1QV55KNOmEqRrJEXlb3oqMVLR0FQyai2kZVH5yIT6Jri/wTfKKNkl1vIYsbM5ctc0k7UdADQYQ8070Bd1k0mQ==", + derived: "296173526d4ff2ba32b6027b25166623f12566dad89b707af650e146628e95b4", + m1: "Gfm875AAWz5Rh4ok/WHAzVuVqKPG6jwLnf/5OGyl+H4=", + m2: "XxfUcq4O5mBX/SC9s3QCv8nU0fuz5R47opb5rO9JDzE=", + }, +} + +func decode(t *testing.T, value string) []byte { + t.Helper() + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatal(err) + } + + return decoded +} + +func TestSRPMatchesFastlane(t *testing.T) { + for _, vector := range srpVectors { + t.Run(vector.protocol, func(t *testing.T) { + salt := decode(t, vector.salt) + key, err := passwordKey(vector.password, salt, vector.iterations, vector.protocol) + if err != nil || hex.EncodeToString(key) != vector.derived { + t.Fatalf("password key = %x, %v", key, err) + } + + a, _ := new(big.Int).SetString(vector.a, 16) + client := newSRPClientWith(a) + if !bytes.Equal(client.A, decode(t, vector.clientA)) { + t.Errorf("A = %s", base64.StdEncoding.EncodeToString(client.A)) + } + m1, m2, err := client.proofs("dev@example.com", key, salt, decode(t, vector.serverB)) + if err != nil { + t.Fatal(err) + } + if got := base64.StdEncoding.EncodeToString(m1); got != vector.m1 { + t.Errorf("m1 = %s, want %s", got, vector.m1) + } + if got := base64.StdEncoding.EncodeToString(m2); got != vector.m2 { + t.Errorf("m2 = %s, want %s", got, vector.m2) + } + }) + } +} + +func TestSRPRejectsAnInvalidChallenge(t *testing.T) { + client := newSRPClientWith(big.NewInt(12345)) + if _, _, err := client.proofs("dev@example.com", []byte("key"), []byte("salt"), srpN.Bytes()); err == nil { + t.Error("accepted B = N") + } + if _, err := passwordKey("password", []byte("salt"), 1, "s2k_unknown"); err == nil { + t.Error("accepted an unknown protocol") + } +} + +func TestHashcashStopsWhenCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if token, err := hashcash(ctx, 64, "challenge", time.Now()); !errors.Is(err, context.Canceled) || token != "" { + t.Errorf("hashcash = %q, %v", token, err) + } +} + +func TestHashcashUsesUTC(t *testing.T) { + utc := time.Date(2023, 2, 23, 17, 6, 0, 0, time.UTC) + india := utc.In(time.FixedZone("IST", 5*3600+1800)) + got, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", india) + want, _ := hashcash(context.Background(), 11, "4d74fb15eb23f465f1f6fcbf534e5877", utc) + if got != want { + t.Errorf("hashcash in IST = %s, in UTC = %s", got, want) + } +} + +func TestHashcashMatchesSpaceship(t *testing.T) { + for _, vector := range []struct { + bits int + challenge, date, token string + }{ + {11, "4d74fb15eb23f465f1f6fcbf534e5877", "20230223170600", "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373"}, + {10, "abc", "20261009120000", "1:10:20261009120000:abc::1266"}, + } { + date, err := time.Parse("20060102150405", vector.date) + if err != nil { + t.Fatal(err) + } + if got, err := hashcash(context.Background(), vector.bits, vector.challenge, date); err != nil || got != vector.token { + t.Errorf("hashcash(%d, %s) = %s, want %s", vector.bits, vector.challenge, got, vector.token) + } + } +} diff --git a/templates/cli/internal/apns/expo/adapter.go b/templates/cli/internal/apns/expo/adapter.go new file mode 100644 index 0000000000..7d0d3946de --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter.go @@ -0,0 +1,317 @@ +// Package expo is the APNs key setup built on @expo/apple-utils, the library +// eas-cli creates APNs keys with. +// +// It runs a small Node script embedded in the binary, with its package.json +// and a lockfile that pins @expo/apple-utils 2.2.1 (MIT) by hash. They are +// installed into the session folder on first use with npm ci +// --ignore-scripts. It runs on Node.js 18 or later, and downloads the Node.js +// LTS into the session folder when the machine has none it can use. +package expo + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/helper.js helper/package.json helper/package-lock.json +var helperFiles embed.FS + +const minimumNodeMajor = 18 + +// Adapter creates APNs keys with @expo/apple-utils. +type Adapter struct { + // Node and Npm are the commands to run. Empty means "node" and "npm" + // from PATH, or a downloaded Node.js when those are missing or too old. + Node string + Npm string + // For tests: where Node.js builds are downloaded from, the client, and + // the platform. Zero values mean nodejs.org and this machine. + NodeDist string + HTTP *http.Client + GOOS string + GOARCH string + // apple-utils prompts for anything the request does not answer, and for + // the two-factor code, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using node and npm from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "expo" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// CreateKey installs the helper if needed and runs it. apple-utils always +// creates keys for both APNs environments; for a single-environment request +// that key works too. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + log := request.Log + if log == nil { + log = func(string, ...any) {} + } + // For a reset the helper signs out of apple-utils before signing in. Its + // sign out asks for the Apple ID and forgets it, so the sign-in would ask + // again; asking here once and passing it to both avoids that. + if request.Reset && strings.TrimSpace(request.AppleID) == "" && request.Asker != nil { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + request.AppleID = strings.TrimSpace(answer) + } + dir := request.SessionDir + if dir == "" { + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + dir = scratch + } + tools, err := a.toolchain(ctx, dir, log) + if err != nil { + return apns.Key{}, err + } + if err := a.install(ctx, dir, tools); err != nil { + return apns.Key{}, err + } + if request.Environment != apns.EnvironmentAll { + log("The expo setup creates keys for both APNs environments; the key also works for %s.", request.Environment) + } + + scratch, err := os.MkdirTemp("", "appwrite-apns-expo-result-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + out := filepath.Join(scratch, "result.json") + + arguments := []string{filepath.Join(dir, "helper.js"), "--name", request.Name, "--out", out} + if request.TeamID != "" { + arguments = append(arguments, "--team-id", request.TeamID) + } + if request.Reset { + arguments = append(arguments, "--reset", "true") + } + command := exec.CommandContext(ctx, tools.node, arguments...) + command.Dir = dir + command.Stdin, command.Stdout, command.Stderr = a.terminal() + // apple-utils reads the Apple ID and password from these instead of + // asking, and stores the password in the macOS Keychain unless told not + // to. The CLI never keeps the password. + command.Env = append(tools.env(), "EXPO_NO_KEYCHAIN=1") + if request.AppleID != "" { + command.Env = append(command.Env, "EXPO_APPLE_ID="+request.AppleID) + } + if request.Password != "" { + command.Env = append(command.Env, "EXPO_APPLE_PASSWORD="+request.Password) + } + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper failed: %w", runErr) + } + + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the @expo/apple-utils helper returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + case "invalid-credentials": + return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) + default: + if decoded.Message == "" { + decoded.Message = decoded.Error + } + + return apns.Key{}, errors.New(decoded.Message) + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the @expo/apple-utils helper returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +func (a *Adapter) npm() string { + if a.Npm != "" { + return a.Npm + } + + return "npm" +} + +var nodeVersion = regexp.MustCompile(`^v?(\d+)\.`) + +func checkNode(ctx context.Context, node string) error { + output, err := exec.CommandContext(ctx, node, "--version").Output() + if err != nil { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later", apns.ErrUnavailable, minimumNodeMajor) + } + + return supportedNode(string(bytes.TrimSpace(output))) +} + +// toolchain is how node and npm are run. +type toolchain struct { + node string + npm []string + // bin is put first on PATH when node is a downloaded one. + bin string +} + +func (t toolchain) env() []string { + env := os.Environ() + if t.bin != "" { + env = append(env, "PATH="+t.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + return env +} + +// toolchain uses the configured node, then node and npm from PATH, then the +// Node.js downloaded into dir. A configured node is never replaced. +func (a *Adapter) toolchain(ctx context.Context, dir string, log func(string, ...any)) (toolchain, error) { + if a.Node != "" { + if err := checkNode(ctx, a.Node); err != nil { + return toolchain{}, err + } + + return toolchain{node: a.Node, npm: []string{a.npm()}}, nil + } + if checkNode(ctx, "node") == nil { + if _, err := exec.LookPath(a.npm()); err == nil { + return toolchain{node: "node", npm: []string{a.npm()}}, nil + } + } + + node, npm, err := a.downloadNode(ctx, filepath.Join(dir, "runtime"), log) + if err != nil { + return toolchain{}, fmt.Errorf("%w: the expo setup needs Node.js %d or later, and downloading it failed: %v", apns.ErrUnavailable, minimumNodeMajor, err) + } + if err := checkNode(ctx, node); err != nil { + return toolchain{}, err + } + + return toolchain{node: node, npm: []string{node, npm}, bin: filepath.Dir(node)}, nil +} + +func supportedNode(version string) error { + match := nodeVersion.FindStringSubmatch(version) + if match == nil { + return fmt.Errorf("%w: could not read the Node.js version %q", apns.ErrUnavailable, version) + } + if major, _ := strconv.Atoi(match[1]); major < minimumNodeMajor { + return fmt.Errorf("%w: the expo setup needs Node.js %d or later, found %s", apns.ErrUnavailable, minimumNodeMajor, version) + } + + return nil +} + +// install writes the embedded files into dir and runs npm ci when the +// lockfile changed or the dependencies are missing. The lockfile pins the +// exact package hashes, and --ignore-scripts keeps install scripts from +// running. +func (a *Adapter) install(ctx context.Context, dir string, tools toolchain) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + changed := false + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + contents, err := helperFiles.ReadFile("helper/" + name) + if err != nil { + return err + } + path := filepath.Join(dir, name) + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, contents) { + continue + } + if err := os.WriteFile(path, contents, 0o600); err != nil { + return err + } + changed = true + } + + if _, err := os.Stat(filepath.Join(dir, "node_modules", "@expo", "apple-utils", "package.json")); err == nil && !changed { + return nil + } + _, _, stderr := a.terminal() + arguments := append(append([]string{}, tools.npm[1:]...), "ci", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error") + command := exec.CommandContext(ctx, tools.npm[0], arguments...) + command.Dir = dir + command.Env = tools.env() + command.Stdout, command.Stderr = stderr, stderr + if err := command.Run(); err != nil { + // A failed install must not look complete on the next run. + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + + return fmt.Errorf("%w: npm ci failed in %s: %v", apns.ErrUnavailable, dir, err) + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/adapter_test.go b/templates/cli/internal/apns/expo/adapter_test.go new file mode 100644 index 0000000000..edf7ff9cbc --- /dev/null +++ b/templates/cli/internal/apns/expo/adapter_test.go @@ -0,0 +1,225 @@ +package expo + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedNode(t *testing.T) { + for version, supported := range map[string]bool{ + "v18.0.0": true, + "v22.11.0": true, + "v16.20.2": false, + "v8.9.0": false, + "garbage": false, + } { + err := supportedNode(version) + if (err == nil) != supported { + t.Errorf("supportedNode(%q) = %v", version, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedNode(%q) is not ErrUnavailable: %v", version, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || maxKeys.Keys[0].Name != "Old" { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeTools writes node and npm stand-ins that log their arguments. The node +// stand-in answers --version and writes result to the --out file. +func fakeTools(t *testing.T, version, result string) (string, string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "` + version + `"; exit 0; fi +echo "node $* EXPO_NO_KEYCHAIN=$EXPO_NO_KEYCHAIN EXPO_APPLE_ID=$EXPO_APPLE_ID" >> "` + log + `" +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '` + result + `' > "$2"; fi + shift +done +` + npm := `#!/bin/sh +echo "npm $*" >> "` + log + `" +mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json +` + for name, script := range map[string]string{"node": node, "npm": npm} { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + + return filepath.Join(bin, "node"), filepath.Join(bin, "npm"), log +} + +func TestCreateKeyInstallsOnceAndRunsTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentAll, + AppleID: "dev@example.com", + SessionDir: filepath.Join(t.TempDir(), "expo"), + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + } + + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 3 || !strings.HasPrefix(lines[0], "npm ci --ignore-scripts") { + t.Fatalf("calls:\n%s", calls) + } + for _, line := range lines[1:] { + if !strings.Contains(line, "helper.js --name Appwrite Push --out ") || + !strings.HasSuffix(line, "--team-id ABCDE12345 EXPO_NO_KEYCHAIN=1 EXPO_APPLE_ID=dev@example.com") { + t.Errorf("helper call = %s", line) + } + } + for _, name := range []string{"helper.js", "package.json", "package-lock.json"} { + if _, err := os.Stat(filepath.Join(request.SessionDir, name)); err != nil { + t.Errorf("%s was not installed: %v", name, err) + } + } +} + +// Without a team, the helper gets no --team-id and apple-utils picks the +// account's only team or asks, the flow the CLI uses when no team is known. +func TestCreateKeyWithoutATeamNotesTheEnvironment(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if len(logged) != 1 || !strings.Contains(logged[0], "both APNs environments") { + t.Errorf("logged %v", logged) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + helper := lines[len(lines)-1] + if !strings.Contains(helper, "helper.js --name Appwrite Push --out ") || strings.Contains(helper, "--team-id") { + t.Errorf("helper call = %s", helper) + } +} + +func TestCreateKeyNeedsARecentNode(t *testing.T) { + node, npm, log := fakeTools(t, "v16.20.2", `{}`) + adapter := &Adapter{Node: node, Npm: npm} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir()} + + if _, err := adapter.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran npm or the helper with an old Node.js") + } + + missing := &Adapter{Node: filepath.Join(t.TempDir(), "node")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing node err = %v", err) + } +} + +func TestCreateKeyPassesResetToTheHelper(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.Contains(helper, " --reset true ") { + t.Errorf("helper call = %s", helper) + } +} + +type oneAnswer struct { + answer string + asked []string +} + +func (o *oneAnswer) Ask(question string, secret bool) (string, error) { + o.asked = append(o.asked, question) + + return o.answer, nil +} + +func (o *oneAnswer) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +// A reset asks for the Apple ID once and hands it to apple-utils, whose sign +// out would otherwise ask, forget it, and leave the sign-in to ask again. +func TestResetAsksForTheAppleIDOnce(t *testing.T) { + node, npm, log := fakeTools(t, "v20.1.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Node: node, Npm: npm, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &oneAnswer{answer: "dev@example.com"} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), Reset: true, Asker: asker} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if helper := lines[len(lines)-1]; !strings.HasSuffix(helper, "EXPO_APPLE_ID=dev@example.com") || !strings.Contains(helper, " --reset true") { + t.Errorf("helper call = %s", helper) + } + + // With the Apple ID known, nothing is asked. + known := &oneAnswer{} + request.Asker, request.AppleID = known, "dev@example.com" + if _, err := adapter.CreateKey(context.Background(), request); err != nil || len(known.asked) != 0 { + t.Errorf("asked %v, %v", known.asked, err) + } +} diff --git a/templates/cli/internal/apns/expo/helper/helper.js b/templates/cli/internal/apns/expo/helper/helper.js new file mode 100644 index 0000000000..6b992760b2 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/helper.js @@ -0,0 +1,88 @@ +'use strict'; + +// Creates a team-scoped APNs key through the Apple Developer portal with +// @expo/apple-utils, the library eas-cli uses for the same job. +// +// apple-utils asks for the Apple ID, the password and the two-factor code +// itself, so the CLI runs this script with the terminal attached. Its logging +// goes to stdout, so the result is written to the file named by --out instead. + +const fs = require('fs'); +const { Auth, Keys, InvalidUserCredentialsError } = require('@expo/apple-utils'); + +function parseArguments(argv) { + const values = {}; + for (let index = 0; index < argv.length; index += 2) { + values[argv[index].replace(/^--/, '')] = argv[index + 1]; + } + if (!values.out || !values.name) { + throw new Error('usage: helper.js [--team-id ] [--reset true] --name --out '); + } + + return values; +} + +// Apple words a full team as "maximum allowed number of Keys" or, since 2026, +// "maximum allowed number of team scoped Keys ...", which apple-utils does not +// turn into MaxKeysCreatedError, so the message itself is checked too. +const maxKeysMessage = /maximum allowed number of (?:[a-z ]+ )?keys/i; + +function isMaxKeysError(error) { + return ( + error instanceof Keys.MaxKeysCreatedError || + maxKeysMessage.test(error?.rawDump?.resultString ?? '') || + maxKeysMessage.test(error?.message ?? '') + ); +} + +async function createKey({ 'team-id': teamId, name, reset }) { + // --reset deletes apple-utils' saved session for this Apple ID first, so the + // sign-in starts over, two-factor included. + if (reset === 'true') { + await Auth.logoutAsync({}); + } + // Without a team ID, apple-utils asks which team to use when there are several. + const authState = await Auth.loginAsync(teamId ? { teamId } : {}, { autoResolveProvider: true }); + const context = authState.context; + if (teamId && context.teamId !== teamId) { + throw new Error(`Signed in to Apple team ${context.teamId}, not ${teamId}`); + } + + let key; + try { + key = await Keys.createKeyAsync(context, { name, isApns: true }); + } catch (error) { + if (!isMaxKeysError(error)) { + throw error; + } + const keys = await Keys.getKeysAsync(context); + + return { + error: 'max-keys', + keys: keys.map(({ id, name, canRevoke }) => ({ id, name, canRevoke })), + }; + } + const p8 = await Keys.downloadKeyAsync(context, { id: key.id }); + + return { keyId: key.id, teamId: context.teamId, p8 }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + let result; + try { + result = await createKey(options); + } catch (error) { + const kind = error instanceof InvalidUserCredentialsError ? 'invalid-credentials' : 'failed'; + result = { error: kind, message: error?.message ?? String(error) }; + } + fs.writeFileSync(options.out, JSON.stringify(result), { mode: 0o600 }); + if (result.error) { + process.exitCode = 1; + } +} + +main().catch(error => { + console.error(error?.message ?? error); + process.exitCode = 1; +}); diff --git a/templates/cli/internal/apns/expo/helper/package-lock.json b/templates/cli/internal/apns/expo/helper/package-lock.json new file mode 100644 index 0000000000..cd61fd039f --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "appwrite-apple-helper", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appwrite-apple-helper", + "dependencies": { + "@expo/apple-utils": "2.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@expo/apple-utils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@expo/apple-utils/-/apple-utils-2.2.1.tgz", + "integrity": "sha512-Jr0yawuiJ83Lu76krKJYysXfvSC678YK8nIBHUoNjrOAQT392LttbfsoPA+8/zDBZOrfo+8eXgBq0a51b7wBVQ==", + "license": "MIT", + "bin": { + "apple-utils": "bin.js" + } + } + } +} diff --git a/templates/cli/internal/apns/expo/helper/package.json b/templates/cli/internal/apns/expo/helper/package.json new file mode 100644 index 0000000000..2e4c5e4598 --- /dev/null +++ b/templates/cli/internal/apns/expo/helper/package.json @@ -0,0 +1,11 @@ +{ + "name": "appwrite-apple-helper", + "private": true, + "description": "Creates an APNs key through the Apple Developer portal for appwrite init apns --create-key", + "engines": { + "node": ">=18" + }, + "dependencies": { + "@expo/apple-utils": "2.2.1" + } +} diff --git a/templates/cli/internal/apns/expo/runtime.go b/templates/cli/internal/apns/expo/runtime.go new file mode 100644 index 0000000000..fb7e357d4c --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime.go @@ -0,0 +1,278 @@ +package expo + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// When the machine has no Node.js the expo setup can use, it downloads this +// release, the Active LTS, into the session folder. Nothing is installed +// system-wide. Each build is checked against its SHA-256 from +// https://nodejs.org/dist/v24.21.0/SHASUMS256.txt before it is unpacked. +const nodeRelease = "v24.21.0" + +var nodeChecksums = map[string]string{ + "node-v24.21.0-darwin-arm64.tar.gz": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "node-v24.21.0-darwin-x64.tar.gz": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "node-v24.21.0-linux-arm64.tar.gz": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "node-v24.21.0-linux-x64.tar.gz": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "node-v24.21.0-win-arm64.zip": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921", + "node-v24.21.0-win-x64.zip": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541", +} + +const defaultNodeDist = "https://nodejs.org/dist" + +// nodeBuild names the official build for goos/goarch, without its extension, +// and the archive format it comes in. +func nodeBuild(goos, goarch string) (string, string, error) { + platform := map[string]string{"darwin": "darwin", "linux": "linux", "windows": "win"}[goos] + arch := map[string]string{"amd64": "x64", "arm64": "arm64"}[goarch] + if platform == "" || arch == "" { + return "", "", fmt.Errorf("no Node.js build to download for %s/%s", goos, goarch) + } + extension := ".tar.gz" + if goos == "windows" { + extension = ".zip" + } + + return "node-" + nodeRelease + "-" + platform + "-" + arch, extension, nil +} + +// nodePaths are the node binary and npm's entry script inside an unpacked +// build. npm is run through node, so its shell wrappers are not needed. +func nodePaths(root, goos string) (string, string) { + if goos == "windows" { + return filepath.Join(root, "node.exe"), filepath.Join(root, "node_modules", "npm", "bin", "npm-cli.js") + } + + return filepath.Join(root, "bin", "node"), filepath.Join(root, "lib", "node_modules", "npm", "bin", "npm-cli.js") +} + +// downloadNode makes sure the pinned Node.js is unpacked under dir and returns +// the node binary and npm's entry script. +func (a *Adapter) downloadNode(ctx context.Context, dir string, log func(string, ...any)) (string, string, error) { + goos, goarch := a.platform() + name, extension, err := nodeBuild(goos, goarch) + if err != nil { + return "", "", err + } + root := filepath.Join(dir, name) + node, npm := nodePaths(root, goos) + if _, err := os.Stat(npm); err == nil { + return node, npm, nil + } + archive := name + extension + checksum, ok := nodeChecksums[archive] + if !ok { + return "", "", fmt.Errorf("no checksum for %s", archive) + } + + dist := a.NodeDist + if dist == "" { + dist = defaultNodeDist + } + log("Node.js %d or later was not found. Downloading Node.js %s (about 50 MB, 200 MB unpacked) into %s ...", minimumNodeMajor, nodeRelease, dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", err + } + downloaded, err := a.fetch(ctx, dist+"/"+nodeRelease+"/"+archive, dir, checksum) + if err != nil { + return "", "", err + } + defer os.Remove(downloaded) + + // Unpack next to the final folder and move it into place, so a failed or + // interrupted unpack never looks like a usable runtime. + staging, err := os.MkdirTemp(dir, ".node-") + if err != nil { + return "", "", err + } + defer os.RemoveAll(staging) + if extension == ".zip" { + err = unzip(downloaded, staging) + } else { + err = untar(downloaded, staging) + } + if err != nil { + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + // Publishing is a rename, which fails when another run published first; + // that runtime is complete, so it is used rather than replaced. An + // existing runtime is never removed, as another run may be using it. + if err := os.Rename(filepath.Join(staging, name), root); err != nil { + if _, statErr := os.Stat(npm); statErr == nil { + return node, npm, nil + } + + return "", "", fmt.Errorf("could not unpack %s: %w", archive, err) + } + if _, err := os.Stat(npm); err != nil { + return "", "", fmt.Errorf("%s has no npm", archive) + } + + return node, npm, nil +} + +func (a *Adapter) platform() (string, string) { + if a.GOOS != "" { + return a.GOOS, a.GOARCH + } + + return runtime.GOOS, runtime.GOARCH +} + +// fetch downloads url into a file in dir and checks its SHA-256. +func (a *Adapter) fetch(ctx context.Context, url, dir, checksum string) (string, error) { + client := a.HTTP + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("could not download Node.js: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("could not download Node.js from %s (HTTP %d)", url, response.StatusCode) + } + + file, err := os.CreateTemp(dir, ".node-download-") + if err != nil { + return "", err + } + hash := sha256.New() + _, err = io.Copy(io.MultiWriter(file, hash), response.Body) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + os.Remove(file.Name()) + + return "", fmt.Errorf("could not download Node.js: %w", err) + } + if got := hex.EncodeToString(hash.Sum(nil)); got != checksum { + os.Remove(file.Name()) + + return "", fmt.Errorf("the Node.js download from %s does not match its checksum (got %s)", url, got) + } + + return file.Name(), nil +} + +// inside returns where name unpacks under root, refusing anything that would +// land outside it. +func inside(root, name string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(name)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe path %q in the archive", name) + } + + return filepath.Join(root, clean), nil +} + +func writeFile(path string, mode os.FileMode, contents io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&0o755|0o600) + if err != nil { + return err + } + _, err = io.Copy(file, contents) + if closeErr := file.Close(); err == nil { + err = closeErr + } + + return err +} + +// untar unpacks a .tar.gz into root. Symbolic links are skipped: they are +// only npm's and npx's shell entry points, and npm is run through node. +func untar(archive, root string) error { + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + compressed, err := gzip.NewReader(file) + if err != nil { + return err + } + reader := tar.NewReader(compressed) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + path, err := inside(root, header.Name) + if err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := writeFile(path, header.FileInfo().Mode(), reader); err != nil { + return err + } + } + } +} + +// unzip unpacks a .zip into root, with the same rules as untar. +func unzip(archive, root string) error { + reader, err := zip.OpenReader(archive) + if err != nil { + return err + } + defer reader.Close() + for _, entry := range reader.File { + path, err := inside(root, entry.Name) + if err != nil { + return err + } + if entry.FileInfo().IsDir() { + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + + continue + } + if !entry.FileInfo().Mode().IsRegular() { + continue + } + contents, err := entry.Open() + if err != nil { + return err + } + err = writeFile(path, entry.FileInfo().Mode(), contents) + contents.Close() + if err != nil { + return err + } + } + + return nil +} diff --git a/templates/cli/internal/apns/expo/runtime_test.go b/templates/cli/internal/apns/expo/runtime_test.go new file mode 100644 index 0000000000..5d7c2319e2 --- /dev/null +++ b/templates/cli/internal/apns/expo/runtime_test.go @@ -0,0 +1,237 @@ +package expo + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestNodeBuild(t *testing.T) { + for _, test := range []struct{ goos, goarch, name, extension string }{ + {"darwin", "arm64", "node-v24.21.0-darwin-arm64", ".tar.gz"}, + {"darwin", "amd64", "node-v24.21.0-darwin-x64", ".tar.gz"}, + {"linux", "amd64", "node-v24.21.0-linux-x64", ".tar.gz"}, + {"windows", "arm64", "node-v24.21.0-win-arm64", ".zip"}, + } { + name, extension, err := nodeBuild(test.goos, test.goarch) + if err != nil || name != test.name || extension != test.extension { + t.Errorf("nodeBuild(%s, %s) = %s, %s, %v", test.goos, test.goarch, name, extension, err) + } + if _, ok := nodeChecksums[name+extension]; !ok { + t.Errorf("no checksum for %s%s", name, extension) + } + } + if _, _, err := nodeBuild("plan9", "386"); err == nil { + t.Error("found a build for plan9/386") + } +} + +type entry struct { + name string + mode int64 + contents string + link string +} + +func tarball(t *testing.T, entries []entry) []byte { + t.Helper() + var buffer bytes.Buffer + compressed := gzip.NewWriter(&buffer) + writer := tar.NewWriter(compressed) + for _, item := range entries { + header := &tar.Header{Name: item.name, Mode: item.mode, Size: int64(len(item.contents)), Typeflag: tar.TypeReg} + if item.link != "" { + header = &tar.Header{Name: item.name, Mode: 0o777, Linkname: item.link, Typeflag: tar.TypeSymlink} + } + if err := writer.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(item.contents)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := compressed.Close(); err != nil { + t.Fatal(err) + } + + return buffer.Bytes() +} + +// fakeNodeBuild is a Node.js build whose node is a shell script: it answers +// --version, acts as npm when given npm-cli.js, and otherwise runs as the +// helper, writing a key to the --out file. +func fakeNodeBuild(t *testing.T) (string, []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + name, _, err := nodeBuild(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skip(err) + } + node := `#!/bin/sh +if [ "$1" = "--version" ]; then echo "v24.21.0"; exit 0; fi +case "$1" in + *npm-cli.js) mkdir -p node_modules/@expo/apple-utils && echo '{}' > node_modules/@expo/apple-utils/package.json; exit 0 ;; +esac +while [ $# -gt 0 ]; do + if [ "$1" = "--out" ]; then printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "$2"; fi + shift +done +` + + return name, tarball(t, []entry{ + {name: name + "/bin/node", mode: 0o755, contents: node}, + {name: name + "/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, contents: "// npm"}, + {name: name + "/bin/npm", link: "../lib/node_modules/npm/bin/npm-cli.js"}, + }) +} + +// serve answers the Node.js download, counting requests, and makes archive +// the expected build for this machine. +func serve(t *testing.T, name string, archive []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + sum := sha256.Sum256(archive) + previous := nodeChecksums[name+".tar.gz"] + nodeChecksums[name+".tar.gz"] = hex.EncodeToString(sum[:]) + t.Cleanup(func() { nodeChecksums[name+".tar.gz"] = previous }) + + requests := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path != "/"+nodeRelease+"/"+name+".tar.gz" { + w.WriteHeader(http.StatusNotFound) + + return + } + _, _ = w.Write(archive) + })) + t.Cleanup(server.Close) + + return server, requests +} + +// withoutNode leaves only mkdir on PATH, so neither node nor npm is found. +func withoutNode(t *testing.T) { + t.Helper() + bin := t.TempDir() + if err := os.Symlink("/bin/mkdir", filepath.Join(bin, "mkdir")); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) +} + +func TestCreateKeyDownloadsNodeWhenMissing(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, requests := serve(t, name, archive) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client(), Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + TeamID: "ABCDE12345", Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: t.TempDir(), + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + for range 2 { + key, err := adapter.CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + } + if requests.Load() != 1 { + t.Errorf("downloaded Node.js %d times", requests.Load()) + } + if len(logged) != 1 || !strings.Contains(logged[0], "Downloading Node.js %s") { + t.Errorf("logged %v", logged) + } + root := filepath.Join(request.SessionDir, "runtime", name) + if info, err := os.Stat(filepath.Join(root, "bin", "node")); err != nil || info.Mode().Perm()&0o100 == 0 { + t.Errorf("node = %v, %v", info, err) + } + if _, err := os.Lstat(filepath.Join(root, "bin", "npm")); err == nil { + t.Error("unpacked a symbolic link") + } +} + +func TestDownloadNodeRejectsABadChecksum(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + nodeChecksums[name+".tar.gz"] = strings.Repeat("0", 64) + withoutNode(t) + + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + dir := t.TempDir() + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, SessionDir: dir}) + if err == nil || !strings.Contains(err.Error(), "does not match its checksum") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "runtime", name)); err == nil { + t.Error("unpacked a download that failed its checksum") + } +} + +func TestUntarRefusesPathsOutsideTheFolder(t *testing.T) { + parent := t.TempDir() + root := filepath.Join(parent, "root") + archive := filepath.Join(parent, "evil.tar.gz") + if err := os.WriteFile(archive, tarball(t, []entry{ + {name: "../evil", mode: 0o644, contents: "x"}, + }), 0o600); err != nil { + t.Fatal(err) + } + + if err := untar(archive, root); err == nil || !strings.Contains(err.Error(), "unsafe path") { + t.Errorf("err = %v", err) + } + if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil { + t.Error("wrote outside the folder") + } +} + +func TestConcurrentFirstRunsShareOneRuntime(t *testing.T) { + name, archive := fakeNodeBuild(t) + server, _ := serve(t, name, archive) + dir := t.TempDir() + + const runs = 4 + errs := make(chan error, runs) + for range runs { + go func() { + adapter := &Adapter{NodeDist: server.URL, HTTP: server.Client()} + node, _, err := adapter.downloadNode(context.Background(), dir, func(string, ...any) {}) + if err == nil { + _, err = os.Stat(node) + } + errs <- err + }() + } + for range runs { + if err := <-errs; err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(dir, name, "lib", "node_modules", "npm", "bin", "npm-cli.js")); err != nil { + t.Errorf("runtime is incomplete after concurrent runs: %v", err) + } + leftovers, _ := filepath.Glob(filepath.Join(dir, ".node-*")) + if len(leftovers) != 0 { + t.Errorf("left staging files behind: %v", leftovers) + } +} diff --git a/templates/cli/internal/apns/fastlane/adapter.go b/templates/cli/internal/apns/fastlane/adapter.go new file mode 100644 index 0000000000..3e9e1f2b25 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter.go @@ -0,0 +1,382 @@ +// Package fastlane is the APNs key setup built on fastlane's spaceship, the +// open-source Apple Developer portal client. +// +// It runs a lane from a Fastfile embedded in the binary with the fastlane the +// person already has installed and brings no Ruby of its own: the project's +// Bundler fastlane when its Gemfile lists one, otherwise fastlane from PATH +// (a gem or Homebrew installation). It needs fastlane 2.225.0 or +// later, the first release whose spaceship signs in with SRP. +package fastlane + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +//go:embed helper/Fastfile +var fastfile []byte + +// fastlane 2.225.0 (October 2024) is the first that signs in with SRP, which +// Apple has required since then; earlier versions get "503 Service +// Temporarily Unavailable" when signing in. +var minimumVersion = [3]int{2, 225, 0} + +// Adapter creates APNs keys with an installed fastlane. +type Adapter struct { + // Fastlane is the command to run. Empty means the project's Bundler + // fastlane when there is one, otherwise "fastlane" from PATH. + Fastlane string + // Bundle is Bundler's command. Empty means "bundle" from PATH. + Bundle string + // Brew is Homebrew's command, offered to install a missing fastlane. + // Empty means "brew" from PATH. + Brew string + // WorkDir is the project folder whose Gemfile may list fastlane. Empty + // means the current directory. + WorkDir string + // spaceship prompts for the two-factor code, and for a team when the + // account has several, on the terminal. Nil means the process's own. + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns the adapter using fastlane from PATH and the terminal. +func New() *Adapter { + return &Adapter{} +} + +// Name is how people pick this setup. +func (a *Adapter) Name() string { + return "fastlane" +} + +type result struct { + KeyID string `json:"keyId"` + TeamID string `json:"teamId"` + P8 string `json:"p8"` + Error string `json:"error"` + Message string `json:"message"` + Keys []struct { + ID string `json:"id"` + Name string `json:"name"` + CanRevoke bool `json:"canRevoke"` + } `json:"keys"` +} + +// CreateKey runs the embedded lane. The Apple ID and password are asked here +// when the request has none, and handed to fastlane as FASTLANE_USER and +// FASTLANE_PASSWORD, so fastlane never offers to keep them in the Keychain. +func (a *Adapter) CreateKey(ctx context.Context, request apns.Request) (apns.Key, error) { + runner, err := a.runner() + if err != nil { + return apns.Key{}, err + } + err = runner.check(ctx) + // Installing is only offered for fastlane from PATH: a configured command + // is never replaced. + if errors.Is(err, errNotInstalled) && a.Fastlane == "" { + if err = a.offerInstall(ctx, request); err == nil { + err = runner.check(ctx) + } + } + if err != nil { + return apns.Key{}, err + } + if runner.gemfile != "" && request.Log != nil { + request.Log("Using fastlane from %s with bundle exec.", runner.gemfile) + } + + appleID := strings.TrimSpace(request.AppleID) + if appleID == "" { + answer, err := request.Asker.Ask("Apple ID (email)", false) + if err != nil { + return apns.Key{}, err + } + appleID = strings.TrimSpace(answer) + } + password := request.Password + if password == "" { + answer, err := request.Asker.Ask("Password for "+appleID, true) + if err != nil { + return apns.Key{}, err + } + password = answer + } + + // fastlane runs the Fastfile in ./fastlane of its working directory. + scratch, err := os.MkdirTemp("", "appwrite-apns-fastlane-") + if err != nil { + return apns.Key{}, err + } + defer os.RemoveAll(scratch) + if err := os.MkdirAll(filepath.Join(scratch, "fastlane"), 0o700); err != nil { + return apns.Key{}, err + } + if err := os.WriteFile(filepath.Join(scratch, "fastlane", "Fastfile"), fastfile, 0o600); err != nil { + return apns.Key{}, err + } + out := filepath.Join(scratch, "result.json") + + arguments := []string{"create_apns_key", "out:" + out, "name:" + request.Name, "environment:" + string(request.Environment)} + if request.TeamID != "" { + arguments = append(arguments, "team_id:"+request.TeamID) + } + if request.Reset { + arguments = append(arguments, "reset:true") + } + command := runner.command(ctx, arguments...) + command.Dir = scratch + command.Stdin, command.Stdout, command.Stderr = a.terminal() + command.Env = append(command.Env, + "FASTLANE_USER="+appleID, + "FASTLANE_PASSWORD="+password, + "FASTLANE_SKIP_UPDATE_CHECK=1", + "FASTLANE_OPT_OUT_USAGE=1", + "FASTLANE_HIDE_CHANGELOG=1", + ) + runErr := command.Run() + + contents, err := os.ReadFile(out) + if err != nil { + if runErr != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane failed: %w", runErr) + } + + return apns.Key{}, fmt.Errorf("the fastlane lane returned no result: %w", err) + } + + return decodeResult(contents) +} + +func decodeResult(contents []byte) (apns.Key, error) { + var decoded result + if err := json.Unmarshal(contents, &decoded); err != nil { + return apns.Key{}, fmt.Errorf("the fastlane lane returned an unreadable result: %w", err) + } + switch decoded.Error { + case "": + case "max-keys": + keys := make([]apns.ExistingKey, len(decoded.Keys)) + for index, key := range decoded.Keys { + keys[index] = apns.ExistingKey{ID: key.ID, Name: key.Name, CanRevoke: key.CanRevoke} + } + + return apns.Key{}, &apns.MaxKeysError{Keys: keys} + case "invalid-credentials": + return apns.Key{}, fmt.Errorf("%w: %s", apns.ErrInvalidCredentials, decoded.Message) + default: + if decoded.Message == "" { + decoded.Message = decoded.Error + } + + return apns.Key{}, errors.New(decoded.Message) + } + if decoded.KeyID == "" || decoded.P8 == "" { + return apns.Key{}, errors.New("the fastlane lane returned no key") + } + + return apns.Key{KeyID: decoded.KeyID, TeamID: decoded.TeamID, P8: decoded.P8}, nil +} + +func (a *Adapter) terminal() (io.Reader, io.Writer, io.Writer) { + stdin, stdout, stderr := a.Stdin, a.Stdout, a.Stderr + if stdin == nil { + stdin = os.Stdin + } + if stdout == nil { + stdout = os.Stdout + } + if stderr == nil { + stderr = os.Stderr + } + + return stdin, stdout, stderr +} + +// runner is how fastlane is started: a command, the arguments before the +// lane's, and the environment it needs. +type runner struct { + name string + prefix []string + env []string + gemfile string +} + +var gemfileFastlane = regexp.MustCompile(`(?m)^\s*gem\s+['"]fastlane['"]`) + +// runner picks the configured command, then the project's Bundler fastlane, +// then fastlane from PATH. A Bundler fastlane must run with bundle exec and +// the project's Gemfile, since the lane runs in a temporary folder. When the +// project pins fastlane but Bundler is missing, it stops rather than run a +// fastlane other than the pinned one. +func (a *Adapter) runner() (runner, error) { + base := append(os.Environ(), "FASTLANE_SKIP_UPDATE_CHECK=1", "FASTLANE_OPT_OUT_USAGE=1") + if a.Fastlane != "" { + return runner{name: a.Fastlane, env: base}, nil + } + bundle := a.Bundle + if bundle == "" { + bundle = "bundle" + } + if gemfile := projectGemfile(a.WorkDir); gemfile != "" { + if _, err := exec.LookPath(bundle); err != nil { + return runner{}, fmt.Errorf("%w: %s pins fastlane, but Bundler is not installed. Install it with gem install bundler, then run bundle install there", apns.ErrUnavailable, gemfile) + } + + return runner{name: bundle, prefix: []string{"exec", "fastlane"}, env: append(base, "BUNDLE_GEMFILE="+gemfile), gemfile: gemfile}, nil + } + + return runner{name: "fastlane", env: base}, nil +} + +// projectGemfile returns the Gemfile in dir, or in dir/ios as Flutter and +// React Native projects keep it, when it lists fastlane. +func projectGemfile(dir string) string { + if dir == "" { + dir, _ = os.Getwd() + } + for _, candidate := range []string{filepath.Join(dir, "Gemfile"), filepath.Join(dir, "ios", "Gemfile")} { + contents, err := os.ReadFile(candidate) + if err != nil || !gemfileFastlane.Match(contents) { + continue + } + if absolute, err := filepath.Abs(candidate); err == nil { + return absolute + } + } + + return "" +} + +func (r runner) command(ctx context.Context, arguments ...string) *exec.Cmd { + command := exec.CommandContext(ctx, r.name, append(append([]string{}, r.prefix...), arguments...)...) + command.Env = append([]string{}, r.env...) + + return command +} + +// errNotInstalled means there is no fastlane on PATH, which installing one +// can fix. +var errNotInstalled = errors.New("fastlane is not installed") + +func (r runner) check(ctx context.Context) error { + if r.gemfile == "" && r.name == "fastlane" { + if _, err := exec.LookPath(r.name); err != nil { + return fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + } + } + output, err := r.command(ctx, "--version").Output() + if err != nil { + if r.gemfile != "" { + return fmt.Errorf("%w: bundle exec fastlane failed with %s. Run bundle install there first", apns.ErrUnavailable, r.gemfile) + } + + return fmt.Errorf("%w: %s --version failed: %v", apns.ErrUnavailable, r.name, err) + } + upgrade := "brew upgrade fastlane, or gem update fastlane, depending on how it was installed" + if r.gemfile != "" { + upgrade = bundlerUpgrade(filepath.Dir(r.gemfile), runtime.GOOS) + } + + return supportedVersion(string(output), upgrade) +} + +// bundlerUpgrade says how to upgrade a project's Bundler fastlane. On Unix +// it is one command to paste, with the folder quoted for a POSIX shell. On +// Windows no quoting is safe in every shell (PowerShell expands $ inside +// double quotes, cmd expands %, and Windows PowerShell has no &&), so the +// folder is named rather than put in a command. +func bundlerUpgrade(folder, goos string) string { + if goos == "windows" { + return "bundle update fastlane, run from the folder " + folder + } + + return "cd " + shellQuote(folder) + " && bundle update fastlane" +} + +// shellQuote quotes path for a POSIX shell: in single quotes, with any single +// quote closed, escaped and reopened. A path of only safe characters is left +// as it is. +func shellQuote(path string) string { + if path != "" && strings.Trim(path, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-") == "" { + return path + } + + return "'" + strings.ReplaceAll(path, "'", `'\''`) + "'" +} + +const installHint = "Install it with brew install fastlane (Homebrew) or gem install fastlane, " + + "or add gem \"fastlane\" to your project's Gemfile and run bundle install. " + + "Or use --provider appwrite, which needs nothing installed" + +// offerInstall asks to install fastlane with Homebrew, when there is one, and +// runs it on the terminal. Without Homebrew, a terminal or a yes, it returns +// how to install fastlane instead. +func (a *Adapter) offerInstall(ctx context.Context, request apns.Request) error { + notInstalled := fmt.Errorf("%w: %w. %s", apns.ErrUnavailable, errNotInstalled, installHint) + brew := a.Brew + if brew == "" { + brew = "brew" + } + if _, err := exec.LookPath(brew); err != nil || request.Asker == nil { + return notInstalled + } + choice, err := request.Asker.Choose("fastlane is not installed. Install it with Homebrew now?", + []string{"Yes, run brew install fastlane", "No"}) + if err != nil || choice != 0 { + return notInstalled + } + + if request.Log != nil { + request.Log("Running brew install fastlane ...") + } + command := exec.CommandContext(ctx, brew, "install", "fastlane") + command.Stdin, command.Stdout, command.Stderr = a.terminal() + if err := command.Run(); err != nil { + return fmt.Errorf("%w: brew install fastlane failed: %v. %s", apns.ErrUnavailable, err, installHint) + } + + return nil +} + +var versionPattern = regexp.MustCompile(`fastlane (\d+)\.(\d+)\.(\d+)`) + +func supportedVersion(output, upgrade string) error { + match := versionPattern.FindStringSubmatch(output) + if match == nil { + return fmt.Errorf("%w: could not read the fastlane version", apns.ErrUnavailable) + } + var version [3]int + for index := range version { + version[index], _ = strconv.Atoi(match[index+1]) + } + for index := range version { + if version[index] != minimumVersion[index] { + if version[index] < minimumVersion[index] { + return fmt.Errorf("%w: the fastlane setup needs fastlane %s or later, found %s. Upgrade it with %s", apns.ErrUnavailable, versionString(minimumVersion), versionString(version), upgrade) + } + + break + } + } + + return nil +} + +func versionString(version [3]int) string { + return fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]) +} diff --git a/templates/cli/internal/apns/fastlane/adapter_test.go b/templates/cli/internal/apns/fastlane/adapter_test.go new file mode 100644 index 0000000000..d781813c66 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/adapter_test.go @@ -0,0 +1,444 @@ +package fastlane + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" +) + +func TestSupportedVersion(t *testing.T) { + for output, supported := range map[string]bool{ + "fastlane installation at path:\n/opt/homebrew/bin/fastlane\n-----------------------------\nfastlane 2.228.0\n": true, + "fastlane 2.225.0": true, + "fastlane 3.0.0": true, + "fastlane 2.224.9": false, + "fastlane 1.300.0": false, + "something else": false, + } { + err := supportedVersion(output, "brew upgrade fastlane") + if (err == nil) != supported { + t.Errorf("supportedVersion(%q) = %v", output, err) + } + if err != nil && !errors.Is(err, apns.ErrUnavailable) { + t.Errorf("supportedVersion(%q) is not ErrUnavailable: %v", output, err) + } + } +} + +func TestDecodeResult(t *testing.T) { + key, err := decodeResult([]byte(`{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"-----BEGIN PRIVATE KEY-----"}`)) + if err != nil || key.KeyID != "KEY1234567" || key.TeamID != "ABCDE12345" { + t.Errorf("key = %+v, %v", key, err) + } + + _, err = decodeResult([]byte(`{"error":"max-keys","keys":[{"id":"OLD1234567","name":"Old","canRevoke":true}]}`)) + var maxKeys *apns.MaxKeysError + if !errors.As(err, &maxKeys) || len(maxKeys.Keys) != 1 || maxKeys.Keys[0].ID != "OLD1234567" || !maxKeys.Keys[0].CanRevoke { + t.Errorf("max keys = %v", err) + } + + _, err = decodeResult([]byte(`{"error":"invalid-credentials","message":"Invalid username and password combination"}`)) + if !errors.Is(err, apns.ErrInvalidCredentials) { + t.Errorf("credentials = %v", err) + } + + if _, err := decodeResult([]byte(`{"error":"failed","message":"Apple is down"}`)); err == nil || err.Error() != "Apple is down" { + t.Errorf("failure = %v", err) + } + if _, err := decodeResult([]byte(`{}`)); err == nil { + t.Error("an empty result was accepted") + } +} + +// fakeFastlane writes a fastlane stand-in that answers --version with version +// and, for the lane, logs its arguments and environment, checks the Fastfile +// is in place and writes result to the out: file. +func fakeFastlane(t *testing.T, version, result string) (string, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + script := `#!/bin/sh +if [ "$1" = "--version" ]; then printf 'fastlane installation at path:\n/usr/local/bin/fastlane\nfastlane ` + version + `\n'; exit 0; fi +test -f fastlane/Fastfile || { echo "no Fastfile" >> "` + log + `"; exit 1; } +password=unset +[ -n "$FASTLANE_PASSWORD" ] && password=set +echo "fastlane $* FASTLANE_USER=$FASTLANE_USER password=$password" >> "` + log + `" +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '` + result + `' > "${argument#out:}" ;; + esac +done +` + path := filepath.Join(bin, "fastlane") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return path, log +} + +type scriptedAsker struct { + answers map[string]string + asked []string +} + +func (s *scriptedAsker) Ask(question string, secret bool) (string, error) { + s.asked = append(s.asked, question) + for prefix, answer := range s.answers { + if strings.HasPrefix(question, prefix) { + return answer, nil + } + } + + return "", errors.New("no answer for " + question) +} + +func (s *scriptedAsker) Choose(string, []string) (int, error) { + return 0, errors.New("not expected") +} + +func TestCreateKeyRunsTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{answers: map[string]string{"Apple ID": "dev@example.com", "Password for": "secret"}} + + key, err := adapter.CreateKey(context.Background(), apns.Request{ + TeamID: "ABCDE12345", + Name: "Appwrite Push", + Environment: apns.EnvironmentSandbox, + Asker: asker, + }) + if err != nil || key.KeyID != "KEY1234567" || key.P8 != "pem" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "Apple ID (email)|Password for dev@example.com" { + t.Errorf("asked %v", asker.asked) + } + calls, _ := os.ReadFile(log) + call := strings.TrimSpace(string(calls)) + if !strings.HasPrefix(call, "fastlane create_apns_key out:") || + !strings.HasSuffix(call, "name:Appwrite Push environment:sandbox team_id:ABCDE12345 FASTLANE_USER=dev@example.com password=set") { + t.Errorf("call = %s", call) + } +} + +func TestCreateKeyUsesTheCredentialsInTheRequest(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + asker := &scriptedAsker{} + + _, err := adapter.CreateKey(context.Background(), apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + }) + if err != nil { + t.Fatal(err) + } + if len(asker.asked) != 0 { + t.Errorf("asked %v", asker.asked) + } +} + +func TestCreateKeyNeedsARecentFastlane(t *testing.T) { + old, log := fakeFastlane(t, "2.220.0", `{}`) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &scriptedAsker{}} + + if _, err := (&Adapter{Fastlane: old}).CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) || + !strings.Contains(err.Error(), "found 2.220.0") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran the lane with an old fastlane") + } + + missing := &Adapter{Fastlane: filepath.Join(t.TempDir(), "fastlane")} + if _, err := missing.CreateKey(context.Background(), request); !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("missing fastlane err = %v", err) + } +} + +func TestProjectGemfile(t *testing.T) { + root := t.TempDir() + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("found %s in an empty folder", gemfile) + } + + if err := os.WriteFile(filepath.Join(root, "Gemfile"), []byte("source \"https://rubygems.org\"\ngem \"cocoapods\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != "" { + t.Errorf("used a Gemfile without fastlane: %s", gemfile) + } + + if err := os.MkdirAll(filepath.Join(root, "ios"), 0o700); err != nil { + t.Fatal(err) + } + ios := filepath.Join(root, "ios", "Gemfile") + if err := os.WriteFile(ios, []byte("source 'https://rubygems.org'\n\n gem 'fastlane', '~> 2.228'\n"), 0o600); err != nil { + t.Fatal(err) + } + if gemfile := projectGemfile(root); gemfile != ios { + t.Errorf("projectGemfile = %q, want %q", gemfile, ios) + } +} + +func TestCreateKeyRunsTheProjectsBundlerFastlane(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + log := filepath.Join(bin, "log") + bundle := filepath.Join(bin, "bundle") + script := `#!/bin/sh +echo "bundle $* BUNDLE_GEMFILE=$BUNDLE_GEMFILE" >> "` + log + `" +[ "$1 $2" = "exec fastlane" ] || exit 1 +if [ "$3" = "--version" ]; then echo "fastlane 2.228.0"; exit 0; fi +test -f fastlane/Fastfile || exit 1 +for argument in "$@"; do + case "$argument" in + out:*) printf '%s' '{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}' > "${argument#out:}" ;; + esac +done +` + if err := os.WriteFile(bundle, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: bundle, WorkDir: project, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", + Log: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }, + } + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + calls, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(calls)), "\n") + if len(lines) != 2 || lines[0] != "bundle exec fastlane --version BUNDLE_GEMFILE="+gemfile || + !strings.HasPrefix(lines[1], "bundle exec fastlane create_apns_key out:") || !strings.HasSuffix(lines[1], "BUNDLE_GEMFILE="+gemfile) { + t.Errorf("calls:\n%s", calls) + } + if len(logged) != 1 || logged[0] != "Using fastlane from "+gemfile+" with bundle exec." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyWillNotBypassAPinnedFastlane(t *testing.T) { + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + adapter := &Adapter{Bundle: filepath.Join(t.TempDir(), "bundle"), WorkDir: project} + + _, err := adapter.CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), gemfile+" pins fastlane, but Bundler is not installed") { + t.Fatalf("err = %v", err) + } +} + +// choosingAsker answers every choice with choice and records the questions. +type choosingAsker struct { + choice int + asked []string +} + +func (c *choosingAsker) Ask(question string, secret bool) (string, error) { + return "", errors.New("not expected: " + question) +} + +func (c *choosingAsker) Choose(question string, options []string) (int, error) { + c.asked = append(c.asked, question) + + return c.choice, nil +} + +// withoutFastlane leaves PATH with only bin, so fastlane is not found unless +// something puts it there. +func withoutFastlane(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell stand-ins") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + + return bin +} + +// fakeBrew writes a brew stand-in into bin that logs its arguments and, for +// install fastlane, copies fastlane into bin. +func fakeBrew(t *testing.T, bin, fastlane string) string { + t.Helper() + log := filepath.Join(t.TempDir(), "brew.log") + script := `#!/bin/sh +echo "brew $*" >> "` + log + `" +[ "$1 $2" = "install fastlane" ] && /bin/cp "` + fastlane + `" "` + bin + `/fastlane" +` + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + + return log +} + +func TestCreateKeyExplainsHowToInstallFastlane(t *testing.T) { + withoutFastlane(t) + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{}} + + _, err := (&Adapter{}).CreateKey(context.Background(), request) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + for _, part := range []string{"fastlane is not installed", "brew install fastlane", "gem install fastlane", "--provider appwrite"} { + if !strings.Contains(err.Error(), part) { + t.Errorf("message is missing %q: %v", part, err) + } + } +} + +func TestCreateKeyInstallsFastlaneWithHomebrew(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + var logged []string + request := apns.Request{ + Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Asker: asker, + Log: func(format string, args ...any) { logged = append(logged, format) }, + } + + key, err := (&Adapter{Stdout: &strings.Builder{}, Stderr: &strings.Builder{}}).CreateKey(context.Background(), request) + if err != nil || key.KeyID != "KEY1234567" { + t.Fatalf("key = %+v, %v", key, err) + } + if strings.Join(asker.asked, "|") != "fastlane is not installed. Install it with Homebrew now?" { + t.Errorf("asked %v", asker.asked) + } + if calls, _ := os.ReadFile(log); strings.TrimSpace(string(calls)) != "brew install fastlane" { + t.Errorf("brew calls = %q", calls) + } + if len(logged) != 1 || logged[0] != "Running brew install fastlane ..." { + t.Errorf("logged %v", logged) + } +} + +func TestCreateKeyDoesNotInstallFastlaneWhenDeclined(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + + _, err := (&Adapter{}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: &choosingAsker{choice: 1}}) + if !errors.Is(err, apns.ErrUnavailable) || !strings.Contains(err.Error(), "brew install fastlane") { + t.Fatalf("err = %v", err) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew after the offer was declined") + } +} + +func TestOldFastlaneSaysHowToUpgrade(t *testing.T) { + if err := supportedVersion("fastlane 2.220.0", "cd /app && bundle update fastlane"); err == nil || + !strings.Contains(err.Error(), "found 2.220.0. Upgrade it with cd /app && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestAConfiguredFastlaneIsNeverInstalled(t *testing.T) { + fastlane, _ := fakeFastlane(t, "2.228.0", `{}`) + bin := withoutFastlane(t) + log := fakeBrew(t, bin, fastlane) + asker := &choosingAsker{choice: 0} + + _, err := (&Adapter{Fastlane: "fastlane"}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, Asker: asker}) + if !errors.Is(err, apns.ErrUnavailable) { + t.Fatalf("err = %v", err) + } + if len(asker.asked) != 0 { + t.Errorf("offered to install: %v", asker.asked) + } + if _, err := os.Stat(log); err == nil { + t.Error("ran brew for a configured command") + } +} + +func TestTheBundlerUpgradeCommandRuns(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell stand-in") + } + project := t.TempDir() + gemfile := filepath.Join(project, "Gemfile") + if err := os.WriteFile(gemfile, []byte("gem \"fastlane\"\n"), 0o600); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(bundle, []byte("#!/bin/sh\necho \"fastlane 2.220.0\"\n"), 0o700); err != nil { + t.Fatal(err) + } + + _, err := (&Adapter{Bundle: bundle, WorkDir: project}).CreateKey(context.Background(), apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret"}) + if err == nil || !strings.Contains(err.Error(), "Upgrade it with cd "+project+" && bundle update fastlane") { + t.Errorf("err = %v", err) + } +} + +func TestBundlerUpgrade(t *testing.T) { + for _, test := range []struct{ folder, goos, want string }{ + {"/Users/dev/app", "darwin", "cd /Users/dev/app && bundle update fastlane"}, + {"/Users/dev/My App", "darwin", "cd '/Users/dev/My App' && bundle update fastlane"}, + {"/Users/dev/it's", "linux", `cd '/Users/dev/it'\''s' && bundle update fastlane`}, + {`/Users/dev/back\slash`, "linux", `cd '/Users/dev/back\slash' && bundle update fastlane`}, + {`/Users/dev/$(whoami)`, "linux", `cd '/Users/dev/$(whoami)' && bundle update fastlane`}, + {`C:\Users\dev\$(Get-Date) App`, "windows", `bundle update fastlane, run from the folder C:\Users\dev\$(Get-Date) App`}, + } { + if got := bundlerUpgrade(test.folder, test.goos); got != test.want { + t.Errorf("bundlerUpgrade(%q, %s) = %s, want %s", test.folder, test.goos, got, test.want) + } + } +} + +// The quoted command must work when pasted into a shell, for a folder whose +// name has spaces and a quote. +func TestTheQuotedCdRunsInAShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell") + } + folder := filepath.Join(t.TempDir(), "My Project's App") + if err := os.Mkdir(folder, 0o700); err != nil { + t.Fatal(err) + } + output, err := exec.Command("/bin/sh", "-c", "cd "+shellQuote(folder)+" && pwd -P").Output() + resolved, _ := filepath.EvalSymlinks(folder) + if err != nil || strings.TrimSpace(string(output)) != resolved { + t.Errorf("cd landed in %q, %v; want %q", output, err, resolved) + } +} + +func TestCreateKeyPassesResetToTheLane(t *testing.T) { + fastlane, log := fakeFastlane(t, "2.228.0", `{"keyId":"KEY1234567","teamId":"ABCDE12345","p8":"pem"}`) + adapter := &Adapter{Fastlane: fastlane, Stdout: &strings.Builder{}, Stderr: &strings.Builder{}} + request := apns.Request{Name: "Appwrite Push", Environment: apns.EnvironmentAll, AppleID: "dev@example.com", Password: "secret", Reset: true} + + if _, err := adapter.CreateKey(context.Background(), request); err != nil { + t.Fatal(err) + } + if calls, _ := os.ReadFile(log); !strings.Contains(string(calls), " reset:true ") { + t.Errorf("lane call = %s", calls) + } +} diff --git a/templates/cli/internal/apns/fastlane/helper/Fastfile b/templates/cli/internal/apns/fastlane/helper/Fastfile new file mode 100644 index 0000000000..c7e60b70c2 --- /dev/null +++ b/templates/cli/internal/apns/fastlane/helper/Fastfile @@ -0,0 +1,61 @@ +# Creates a team-scoped APNs key for `appwrite init apns --create-key +# --provider fastlane` with spaceship, fastlane's Apple Developer portal +# client, so any fastlane installation (gem, Homebrew or bundler) can run it. +# +# spaceship asks for the two-factor code, and for a team when the account has +# several, on the terminal. The Apple ID and password come from FASTLANE_USER +# and FASTLANE_PASSWORD. fastlane logs to stdout, so the result is written as +# JSON to the file named by out: instead. +require 'json' + +lane :create_apns_key do |options| + result = begin + create_apns_key_with_spaceship(options) + rescue StandardError => error + kind = error.kind_of?(Spaceship::InvalidUserCredentialsError) ? 'invalid-credentials' : 'failed' + { error: kind, message: error.message } + end + File.open(options[:out], 'w', 0o600) { |file| file.write(JSON.generate(result)) } +end + +def create_apns_key_with_spaceship(options) + require 'spaceship' + + # reset:true deletes spaceship's saved session for this Apple ID first, so + # the sign-in starts over, two-factor included. + if options[:reset].to_s == 'true' + cookie = Spaceship::PortalClient.new.tap { |client| client.user = ENV.fetch('FASTLANE_USER', nil) }.persistent_cookie_path + File.delete(cookie) if cookie && File.exist?(cookie) + end + Spaceship::Portal.login(ENV.fetch('FASTLANE_USER', nil), ENV.fetch('FASTLANE_PASSWORD', nil)) + team_id = options[:team_id].to_s + if team_id.empty? + Spaceship::Portal.select_team + else + Spaceship::Portal.select_team(team_id: team_id) + end + client = Spaceship::Portal.client + + # Key.create always asks for every environment, so the client is called + # directly with the one requested. + environment = options[:environment].to_s.empty? ? 'all' : options[:environment].to_s + begin + created = client.create_key!( + name: options[:name], + service_configs: { + Spaceship::Portal::Key::APNS_ID => { scope: 'team', environment: environment, identifiers: {} } + } + ) + rescue StandardError => error + # Worded with or without "team scoped" before "Keys". + raise unless error.message =~ /maximum allowed number of (?:[a-z ]+ )?keys/i + + keys = client.list_keys.map do |key| + { id: key['keyId'], name: key['keyName'], canRevoke: key['canRevoke'] == true } + end + return { error: 'max-keys', keys: keys } + end + + key_id = created['keyId'] + { keyId: key_id, teamId: client.team_id, p8: client.download_key(id: key_id) } +end diff --git a/templates/cli/internal/cmd/init.go b/templates/cli/internal/cmd/init.go index c36d053510..cec5d6c9e2 100644 --- a/templates/cli/internal/cmd/init.go +++ b/templates/cli/internal/cmd/init.go @@ -38,6 +38,8 @@ func newInitCommand() *cobra.Command { newInitFunctionCommand(), newInitSiteCommand(), newInitSkillCommand(), + newInitPushCommand(), + newInitApnsCommand(), newInitFcmCommand(), newInitBucketCommand(), newInitTeamCommand(), diff --git a/templates/cli/internal/cmd/initpush.go b/templates/cli/internal/cmd/initpush.go index 3affeeafc4..bd660acf50 100644 --- a/templates/cli/internal/cmd/initpush.go +++ b/templates/cli/internal/cmd/initpush.go @@ -4,6 +4,8 @@ package cmd import ( "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rsa" "crypto/x509" "encoding/json" @@ -15,17 +17,26 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "time" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/appwrite" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/expo" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/fastlane" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/app" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/config" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/output" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" "github.com/spf13/cobra" ) const ( + apnsKeyPage = "https://developer.apple.com/account/resources/authkeys/add" + apnsKeysListPage = "https://developer.apple.com/account/resources/authkeys/list" fcmConsoleHome = "https://console.firebase.google.com" fcmServiceAccounts = "https://console.firebase.google.com/project/%s/settings/serviceaccounts/adminsdk" fcmSetupGuide = "https://firebase.google.com/docs/android/setup#add-config-file" @@ -34,6 +45,22 @@ const ( firebaseListTimeout = 30 * time.Second ) +var ( + apnsKeyFileName = regexp.MustCompile(`^AuthKey_([A-Z0-9]{10})\.p8$`) + apnsKeyID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +type apnsOptions struct { + keyPath string + keyID string + teamID string + bundleID string + createKey bool + reset bool + environment string + setup string +} + type fcmOptions struct { keyPath string projectID string @@ -53,6 +80,12 @@ type pushSetup struct { googleHosts map[string]string googlePoll time.Duration googleTimeout time.Duration + ctx context.Context + getenv func(string) string + // apnsAdapters are the ways --create-key can create an APNs key, and + // apnsSessions is where they keep a signed-in session. + apnsAdapters *apns.Registry + apnsSessions string } func newPushSetup(command *cobra.Command) (*pushSetup, error) { @@ -73,6 +106,11 @@ func newPushSetup(command *cobra.Command) (*pushSetup, error) { } } + sessions := "" + if prefs, err := config.GlobalPath(app.ExecutableName); err == nil { + sessions = filepath.Join(filepath.Dir(prefs), "apns") + } + return &pushSetup{ api: context.api, prompter: context.prompter, @@ -85,9 +123,49 @@ func newPushSetup(command *cobra.Command) (*pushSetup, error) { console: newConsoleForPush(), googlePoll: 2 * time.Second, googleTimeout: googleSignInTimeout, + ctx: command.Context(), + getenv: os.Getenv, + apnsAdapters: apnsAdapters(), + apnsSessions: sessions, }, nil } +func newInitApnsCommand() *cobra.Command { + options := apnsOptions{} + command := &cobra.Command{ + Use: "apns", + Short: "Set up Apple Push Notification service for Appwrite Push", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + detected, err := detectApple(setup.root) + if err != nil { + return err + } + + return setup.apns(options, detected) + }, + } + + flags := command.Flags() + flags.StringVar(&options.keyPath, "key-path", "", "Path to an APNs auth key (.p8)") + flags.StringVar(&options.keyID, "key-id", "", "APNs key ID") + flags.StringVar(&options.teamID, "team-id", "", "Apple Developer team ID") + flags.StringVar(&options.bundleID, "bundle-id", "", "iOS app bundle ID") + flags.BoolVar(&options.createKey, "create-key", false, "Create the APNs key by signing in with your Apple ID (experimental)") + flags.StringVar(&options.environment, "environment", string(apns.EnvironmentAll), "APNs environment to set up: production, sandbox or all") + flags.StringVar(&options.setup, "provider", "", "How --create-key creates the key: appwrite, expo or fastlane. Defaults to $"+apnsSetupVariable+", then "+defaultApnsSetup) + flags.BoolVar(&options.reset, "reset", false, "Sign in to Apple again instead of reusing the saved sign-in, and create the key (implies --create-key)") + command.MarkFlagsMutuallyExclusive("create-key", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-path") + command.MarkFlagsMutuallyExclusive("reset", "key-id") + command.MarkFlagsMutuallyExclusive("create-key", "key-id") + + return command +} + func newInitFcmCommand() *cobra.Command { options := fcmOptions{} command := &cobra.Command{ @@ -115,6 +193,90 @@ func newInitFcmCommand() *cobra.Command { return command } +func newInitPushCommand() *cobra.Command { + var skipApns, skipFcm bool + command := &cobra.Command{ + Use: "push", + Short: "Set up background delivery for Appwrite Push on every platform in this project", + RunE: func(command *cobra.Command, args []string) error { + setup, err := newPushSetup(command) + if err != nil { + return err + } + + return setup.push(skipApns, skipFcm) + }, + } + + command.Flags().BoolVar(&skipApns, "skip-apns", false, "Do not set up APNs") + command.Flags().BoolVar(&skipFcm, "skip-fcm", false, "Do not set up FCM") + + return command +} + +func (s *pushSetup) push(skipApns, skipFcm bool) error { + apple, err := detectApple(s.root) + if err != nil { + return err + } + android, err := detectAndroid(s.root) + if err != nil { + return err + } + + runApns := apple.found() && !skipApns + runFcm := android.found() && !skipFcm + + fmt.Fprintln(s.out, output.Heading("Detected")) + fmt.Fprintf(s.out, " iOS %s\n", describeApple(apple, skipApns)) + fmt.Fprintf(s.out, " Android %s\n\n", describeAndroid(android, skipFcm)) + + if !runApns && !runFcm { + output.Log(s.out, "No iOS or Android app found here.") + output.Hint(s.out, "Run '%s init apns' or '%s init fcm' with --key-path to set up a platform directly.", + app.ExecutableName, app.ExecutableName) + + return nil + } + + var failures []string + if runApns { + if err := s.apns(apnsOptions{}, apple); err != nil { + s.reportStep("APNs", "apns", err) + failures = append(failures, "APNs") + } + } + if runFcm { + if runApns { + fmt.Fprintln(s.out) + } + if err := s.fcm(fcmOptions{}, android); err != nil { + s.reportStep("FCM", "fcm", err) + failures = append(failures, "FCM") + } + } + + if len(failures) > 0 { + return fmt.Errorf("%s setup did not finish", strings.Join(failures, " and ")) + } + + return nil +} + +func (s *pushSetup) reportStep(name, command string, err error) { + if errors.Is(err, prompt.ErrAborted) { + return + } + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) && unanswered.Flag != "" { + output.Failure(s.out, "%s: %q needs an answer but there is no interactive terminal", name, unanswered.Message) + output.Hint(s.out, "Run '%s init %s %s ' instead.", app.ExecutableName, command, unanswered.Flag) + + return + } + output.Failure(s.out, "%s: %s", name, err) +} + func describeFirebase(configs []firebaseConfig) string { parts := make([]string, 0, len(configs)) for _, config := range configs { @@ -128,6 +290,42 @@ func describeFirebase(configs []firebaseConfig) string { return strings.Join(parts, "; ") } +func describeApple(apple appleApp, skipped bool) string { + switch { + case !apple.found(): + return "not found" + case skipped: + return "skipped (--skip-apns)" + case len(apple.BundleIDs) == 0: + return "found, bundle ID unknown" + } + + return strings.Join(apple.BundleIDs, ", ") +} + +func describeAndroid(android androidApp, skipped bool) string { + switch { + case !android.found(): + return "not found" + case skipped: + return "skipped (--skip-fcm)" + } + + description := strings.Join(android.ApplicationIDs, ", ") + if description == "" { + description = "found, application ID unknown" + } + if projects := android.projectIDs(); len(projects) > 0 { + label := " (Firebase project " + if len(projects) > 1 { + label = " (Firebase projects " + } + description += label + strings.Join(projects, ", ") + ")" + } + + return description +} + func (s *pushSetup) pick(value string, detected []string, question, flag string, validate func(string) error) (string, error) { if value != "" { if validate != nil { @@ -166,6 +364,363 @@ func requiredValue(name string) func(string) error { } } +func matchesPattern(pattern *regexp.Regexp, description string) func(string) error { + return func(value string) error { + if !pattern.MatchString(value) { + return fmt.Errorf("%s", description) + } + + return nil + } +} + +func (s *pushSetup) apns(options apnsOptions, detected appleApp) error { + output.Log(s.out, "Setting up APNs ...") + // --reset is for signing in again, which only creating a key does. + if options.reset { + options.createKey = true + } + + environment, err := apnsEnvironment(options.environment) + if err != nil { + return err + } + wantProduction := environment != apns.EnvironmentSandbox + wantSandbox := environment != apns.EnvironmentProduction + + bundleID, err := s.pick(options.bundleID, detected.BundleIDs, + "Which iOS bundle ID should receive push notifications?", "--bundle-id", requiredValue("bundle ID")) + if err != nil { + return err + } + + existing, err := s.providers("apns") + if err != nil { + return err + } + production := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && !provider.sandbox() + }) + sandbox := findProvider(existing, func(provider messagingProvider) bool { + return provider.credential("bundleId") == bundleID && provider.sandbox() + }) + setUp := func(provider *messagingProvider, wanted bool) bool { + return !wanted || (provider != nil && provider.Enabled) + } + if setUp(production, wantProduction) && setUp(sandbox, wantSandbox) && options.keyPath == "" && !options.reset && !app.Flags().Force { + output.Success(s.out, "APNs%s is already set up for %s.", environmentLabel(environment), bundleID) + output.Hint(s.out, "Pass --force or --key-path to replace the key.") + s.configureXcode(detected, bundleID) + + return nil + } + + teams := detected.teams(bundleID) + for _, provider := range []*messagingProvider{production, sandbox} { + if provider != nil && len(teams) == 0 && appleTeamID.MatchString(provider.credential("teamId")) { + teams = []string{provider.credential("teamId")} + } + } + pickTeam := func() (string, error) { + return s.pick(options.teamID, teams, + "What is your Apple Developer team ID?", "--team-id", + matchesPattern(appleTeamID, "a team ID is 10 uppercase letters or digits")) + } + // Without a known team, signing in with the Apple ID chooses it, so the + // question waits until the key has come from somewhere else. + teamID := "" + if options.teamID != "" || len(teams) > 0 || options.keyPath != "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + keyPath := options.keyPath + createdKeyID := "" + if keyPath == "" { + var keyTeam string + keyPath, createdKeyID, keyTeam, err = s.apnsKey(teamID, options, environment) + if err != nil { + return err + } + if teamID == "" { + teamID = keyTeam + } + } + if teamID == "" { + if teamID, err = pickTeam(); err != nil { + return err + } + } + + key, err := readApnsKey(keyPath) + if err != nil { + return err + } + if options.keyPath != "" { + s.checkKeyLocation(expandHome(keyPath)) + } + + keyID := options.keyID + // A created key's ID comes from Apple; any other would not match the .p8. + if createdKeyID != "" { + if keyID != "" && keyID != createdKeyID { + output.Warn(s.out, "Using the created key's ID %s instead of --key-id %s.", createdKeyID, keyID) + } + keyID = createdKeyID + } + if keyID == "" { + if match := apnsKeyFileName.FindStringSubmatch(filepath.Base(keyPath)); match != nil { + keyID = match[1] + } + } + keyID, err = s.pick(keyID, nil, "What is the key ID of this APNs key?", "--key-id", + matchesPattern(apnsKeyID, "a key ID is 10 uppercase letters or digits")) + if err != nil { + return err + } + + for _, target := range []struct { + sandbox bool + wanted bool + name string + existing *messagingProvider + }{ + {false, wantProduction, "APNs (" + bundleID + ")", production}, + {true, wantSandbox, "APNs sandbox (" + bundleID + ")", sandbox}, + } { + if !target.wanted { + continue + } + body := map[string]any{ + "name": target.name, + "authKey": key, + "authKeyId": keyID, + "teamId": teamID, + "bundleId": bundleID, + "sandbox": target.sandbox, + "enabled": true, + } + if err := s.upsertProvider("apns", target.existing, body); err != nil { + return err + } + } + + s.configureXcode(detected, bundleID) + output.Success(s.out, "APNs%s is set up for %s.", environmentLabel(environment), bundleID) + + return nil +} + +// apnsKey finds or creates the APNs key and returns its path. With +// --create-key, signing in with the Apple ID is the only way, and a failure +// stops the command. Otherwise it is offered alongside the browser flow, and +// when it fails the other ways of providing the key remain. +func (s *pushSetup) apnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + create := options.createKey + explicit := create + automatic := "" + if s.apnsAdapters != nil && len(s.apnsAdapters.Names()) > 0 { + automatic = "Sign in with your Apple ID and create one (experimental)" + } + for { + if !create { + path, _, err := s.acquireKey("APNs auth key (.p8)", automatic, "Create one in the Apple Developer portal", "--key-path", apnsKeyPage, + "In the Apple Developer portal, create a key with Apple Push Notifications service (APNs) enabled and download it.", + func(path string) bool { + _, err := readApnsKey(path) + + return strings.HasSuffix(path, ".p8") && err == nil + }) + if !errors.Is(err, errAutomaticKey) { + return path, "", "", err + } + } + create = false + + path, keyID, keyTeam, err := s.createApnsKey(teamID, options, environment) + var maxKeys *apns.MaxKeysError + if errors.As(err, &maxKeys) { + output.Warn(s.out, "Apple allows two APNs keys per team, and this team has no free slot. Keys on the team:") + for _, key := range maxKeys.Keys { + output.Log(s.out, " %s (%s)", key.Name, key.ID) + } + output.Hint(s.out, "One key works for every app on the team. Reuse a .p8 you saved earlier, or revoke a key you no longer use at %s.", apnsKeysListPage) + + return "", "", "", errors.New("no APNs key can be created on this team. Pass --key-path with an existing .p8 instead") + } + if err == nil { + return path, keyID, keyTeam, nil + } + // --create-key asked for a new key: falling back to a .p8 already in + // the project folder would hide that none was created. + if explicit { + return "", "", "", fmt.Errorf("could not create the APNs key with your Apple ID: %w", err) + } + output.Warn(s.out, "Could not create the APNs key with your Apple ID: %s.", err) + output.Log(s.out, "Provide the key another way instead.") + automatic = "" + } +} + +const ( + // apnsSetupVariable picks the APNs key setup when --provider is not + // passed, so a broken one can be swapped without a release. + apnsSetupVariable = "APPWRITE_APNS_SETUP" + defaultApnsSetup = "appwrite" + // The Apple ID and password, when set, answer the sign-in questions. + appleIDVariable = "APPWRITE_APPLE_ID" + applePasswordVariable = "APPWRITE_APPLE_PASSWORD" +) + +// apnsAdapters lists the APNs key setups this build offers. Each lives in its +// own package under internal/apns and is added here. +func apnsAdapters() *apns.Registry { + return apns.NewRegistry(appwrite.New(), expo.New(), fastlane.New()) +} + +// apnsAdapter picks the setup: --provider, then APPWRITE_APNS_SETUP, then +// appwrite. +func (s *pushSetup) apnsAdapter(setup string) (apns.Adapter, error) { + if setup == "" { + setup = s.env(apnsSetupVariable) + } + if setup == "" { + setup = defaultApnsSetup + } + if s.apnsAdapters == nil { + return nil, fmt.Errorf("APNs key setup %q is not available: this build has none", setup) + } + + return s.apnsAdapters.Get(setup) +} + +func (s *pushSetup) env(name string) string { + if s.getenv == nil { + return "" + } + + return s.getenv(name) +} + +func (s *pushSetup) context() context.Context { + if s.ctx == nil { + return context.Background() + } + + return s.ctx +} + +// apnsEnvironment reads --environment. Empty means both, for init push. +func apnsEnvironment(value string) (apns.Environment, error) { + environment, err := apns.ParseEnvironment(value) + if err != nil { + return "", fmt.Errorf("--environment must be production, sandbox or all, not %q", value) + } + + return environment, nil +} + +// environmentLabel names a single environment in messages, and nothing for +// both. +func environmentLabel(environment apns.Environment) string { + if environment == apns.EnvironmentAll { + return "" + } + + return " (" + string(environment) + ")" +} + +// apnsAsker asks an APNs setup's questions through the CLI's prompts, so they +// look like every other question and fail with a clear message without a +// terminal. +type apnsAsker struct { + prompter prompt.Prompter +} + +func (a apnsAsker) Ask(question string, secret bool) (string, error) { + return a.prompter.Text(prompt.Text{Message: question, Secret: secret, Validate: requiredValue("an answer")}) +} + +func (a apnsAsker) Choose(question string, options []string) (int, error) { + choices := make([]prompt.Option, len(options)) + for index, label := range options { + choices[index] = prompt.Option{Label: label, Value: strconv.Itoa(index)} + } + value, err := a.prompter.Choice(prompt.Choice{Message: question, Options: choices}) + if err != nil { + return 0, err + } + + return strconv.Atoi(value) +} + +// createApnsKey creates a key through the Apple Developer portal and saves it +// into the project folder before anything else happens, because Apple lets a +// key be downloaded only once. +func (s *pushSetup) createApnsKey(teamID string, options apnsOptions, environment apns.Environment) (string, string, string, error) { + adapter, err := s.apnsAdapter(options.setup) + if err != nil { + return "", "", "", err + } + request := apns.Request{ + TeamID: teamID, + Name: "Appwrite Push " + time.Now().Format("20060102150405"), + Environment: environment, + AppleID: s.env(appleIDVariable), + Password: s.env(applePasswordVariable), + Reset: options.reset, + Asker: apnsAsker{s.prompter}, + Log: func(format string, args ...any) { + output.Log(s.out, format, args...) + }, + } + if s.apnsSessions != "" { + request.SessionDir = filepath.Join(s.apnsSessions, adapter.Name()) + } + output.Log(s.out, "Signing in to the Apple Developer portal (%s).", adapter.Name()) + if options.reset { + output.Log(s.out, "Signing in again: the saved Apple sign-in is not used (--reset).") + } + if request.AppleID != "" { + output.Log(s.out, "Using the Apple ID from %s.", appleIDVariable) + } + if request.Password != "" { + output.Log(s.out, "Using the Apple ID password from %s.", applePasswordVariable) + } + key, err := apns.CreateKey(s.context(), adapter, request) + var unanswered *prompt.NonInteractiveError + if errors.As(err, &unanswered) { + return "", "", "", fmt.Errorf("signing in to Apple asked %q, which needs an interactive terminal. %s and %s answer the Apple ID and password, but the two-factor code always needs one. Without a terminal, pass --key-path with an existing .p8 instead of --create-key", unanswered.Message, appleIDVariable, applePasswordVariable) + } + if err != nil { + return "", "", "", err + } + + directory := s.root + if len(s.keyDirs) > 0 { + directory = s.keyDirs[0] + } + path := filepath.Join(directory, "AuthKey_"+key.KeyID+".p8") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + _, err = file.WriteString(key.P8) + if closeErr := file.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", "", "", fmt.Errorf("could not save the new key %s: %w", key.KeyID, err) + } + + output.Success(s.out, "Created APNs key %s on team %s and saved it to %s.", key.KeyID, key.TeamID, s.display(path)) + output.Hint(s.out, "Apple lets you download this key only once. Keep the file somewhere safe and out of version control.") + s.checkKeyLocation(path) + + return path, key.KeyID, key.TeamID, nil +} + func (s *pushSetup) fcm(options fcmOptions, detected androidApp) error { output.Log(s.out, "Setting up FCM ...") @@ -655,6 +1210,27 @@ func expandHome(path string) string { return path } +func readApnsKey(path string) (string, error) { + contents, err := os.ReadFile(expandHome(path)) + if err != nil { + return "", err + } + block, _ := pem.Decode(contents) + if block == nil || block.Type != "PRIVATE KEY" { + return "", fmt.Errorf("%s is not an APNs auth key: expected a PEM 'PRIVATE KEY' block", path) + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("%s is not an APNs auth key: %w", path, err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok || ecKey.Curve != elliptic.P256() { + return "", fmt.Errorf("%s is not an APNs auth key: expected a P-256 elliptic curve key", path) + } + + return string(contents), nil +} + func readServiceAccount(path, projectID string) (map[string]any, error) { contents, err := os.ReadFile(expandHome(path)) if err != nil { @@ -710,6 +1286,18 @@ type messagingProvider struct { Options map[string]any `json:"options"` } +func (p messagingProvider) credential(name string) string { + value, _ := p.Credentials[name].(string) + + return value +} + +func (p messagingProvider) sandbox() bool { + value, _ := p.Options["sandbox"].(bool) + + return value +} + func (p messagingProvider) projectID() string { account, _ := p.Credentials["serviceAccountJSON"].(map[string]any) value, _ := account["project_id"].(string) @@ -762,6 +1350,51 @@ func (s *pushSetup) upsertProvider(kind string, existing *messagingProvider, bod return nil } +func (s *pushSetup) configureXcode(detected appleApp, bundleID string) { + targets := detected.targets(bundleID) + inXcode, entitlements, infoPlists := false, 0, 0 + for _, target := range targets { + if target.Project == "" { + continue + } + inXcode = true + for _, path := range target.Entitlements { + entitlements++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistString(contents, "aps-environment", "development") + }) + s.reportEdit(path, "aps-environment", changed, err) + } + for _, path := range target.InfoPlists { + infoPlists++ + changed, err := editPlist(path, func(contents string) string { + return ensurePlistArrayValue(contents, "UIBackgroundModes", "remote-notification") + }) + s.reportEdit(path, "the remote-notification background mode", changed, err) + } + } + + if inXcode && entitlements == 0 { + output.Hint(s.out, "In Xcode, open Signing & Capabilities and add Push Notifications, so the app gets an aps-environment entitlement.") + } + if inXcode && infoPlists == 0 { + output.Hint(s.out, "In Xcode, add Background Modes and tick Remote notifications.") + } + if detected.ExpoConfig != "" { + output.Hint(s.out, "Expo regenerates ios/ on prebuild. To keep these settings, add \"remote-notification\" to expo.ios.infoPlist.UIBackgroundModes in %s.", + filepath.Base(detected.ExpoConfig)) + } +} + +func (s *pushSetup) reportEdit(path, what string, changed bool, err error) { + switch { + case err != nil: + output.Warn(s.out, "Could not add %s to %s: %s", what, path, err) + case changed: + output.Success(s.out, "Added %s to %s", what, path) + } +} + func (s *pushSetup) gradleHints(detected androidApp) { if detected.ExpoConfig != "" { if !detected.ExpoGoogleServices { @@ -787,3 +1420,106 @@ func (s *pushSetup) gradleHints(detected androidApp) { detected.Modules[0]) } } + +func editPlist(path string, edit func(string) string) (bool, error) { + contents, err := os.ReadFile(path) + if err != nil { + return false, err + } + original := string(contents) + if !strings.Contains(original, ""); index >= 0 && !strings.Contains(contents, "") { + indent, _ := lineIndent(contents, index) + contents = contents[:index] + "\n" + indent + "" + contents[index+len(""):] + } + end := strings.LastIndex(contents, "") + if end < 0 { + return contents, -1, "", "" + } + closing, lineStart := lineIndent(contents, end) + + unit := "\t" + if key := plistKeyLine.FindStringSubmatch(contents); key != nil && strings.HasPrefix(key[1], closing) && len(key[1]) > len(closing) { + unit = key[1][len(closing):] + } + + return contents, lineStart, closing + unit, unit +} + +var plistKeyLine = regexp.MustCompile(`(?m)^([ \t]*)`) + +func ensurePlistString(contents, key, value string) string { + if strings.Contains(contents, ""+key+"") { + return contents + } + contents, at, indent, _ := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + "" + key + "\n" + indent + "" + value + "\n" + contents[at:] +} + +func ensurePlistArrayValue(contents, key, value string) string { + marker := "" + key + "" + entry := "" + value + "" + if index := strings.Index(contents, marker); index >= 0 { + after := index + len(marker) + rest := contents[after:] + if empty := strings.Index(rest, ""); empty >= 0 && strings.TrimSpace(rest[:empty]) == "" { + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + at := after + empty + + return contents[:at] + "\n" + keyIndent + unit + entry + "\n" + keyIndent + "" + contents[at+len(""):] + } + open := strings.Index(rest, "") + closing := strings.Index(rest, "") + if open < 0 || closing < open || strings.TrimSpace(rest[:open]) != "" { + return contents + } + if strings.Contains(rest[open:closing], entry) { + return contents + } + keyIndent, _ := lineIndent(contents, index) + _, _, _, unit := topLevelDict(contents) + _, lineStart := lineIndent(contents, after+closing) + if lineStart > 0 && contents[lineStart-1] != '\n' { + return contents + } + + return contents[:lineStart] + keyIndent + unit + entry + "\n" + contents[lineStart:] + } + + contents, at, indent, unit := topLevelDict(contents) + if at < 0 { + return contents + } + + return contents[:at] + indent + marker + "\n" + indent + "\n" + indent + unit + entry + "\n" + indent + "\n" + contents[at:] +} diff --git a/templates/cli/internal/cmd/initpush_test.go b/templates/cli/internal/cmd/initpush_test.go index 673db67b9d..50a050081a 100644 --- a/templates/cli/internal/cmd/initpush_test.go +++ b/templates/cli/internal/cmd/initpush_test.go @@ -2,6 +2,9 @@ package cmd import ( "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -20,10 +23,117 @@ import ( "testing" "time" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns" + "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/apns/apnstest" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/client" "github.com/{{ sdk.gitUserName }}/{{ sdk.gitRepoName | caseDash }}/internal/prompt" ) +const testPbxproj = `// !$*UTF8*$! +{ + objects = { + +/* Begin PBXNativeTarget section */ + A00000000000000000000001 /* MyApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */; + name = MyApp; + productType = "com.apple.product-type.application"; + }; + A00000000000000000000002 /* MyAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */; + name = MyAppTests; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A00000000000000000000003 /* Widget */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */; + name = Widget; + productType = "com.apple.product-type.app-extension"; + }; + A00000000000000000000004 /* Admin */ = { + isa = PBXNativeTarget; + buildConfigurationList = B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */; + name = Admin; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin XCBuildConfiguration section */ + C00000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = MyApp/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "My App"; + }; + name = Debug; + }; + C00000000000000000000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + DEVELOPMENT_TEAM = ABCDE12345; + PRODUCT_BUNDLE_IDENTIFIER = com.example.MyAppTests; + }; + name = Debug; + }; + C00000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Widget/Widget.entitlements; + DEVELOPMENT_TEAM = ABCDE12345; + INFOPLIST_FILE = Widget/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.widget; + }; + name = Debug; + }; + C00000000000000000000004 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Admin/Admin.entitlements; + DEVELOPMENT_TEAM = FGHIJ67890; + INFOPLIST_FILE = Admin/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.example.admin; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B00000000000000000000001 /* Build configuration list for PBXNativeTarget "MyApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000001 /* Debug */, + ); + }; + B00000000000000000000002 /* Build configuration list for PBXNativeTarget "MyAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000002 /* Debug */, + ); + }; + B00000000000000000000003 /* Build configuration list for PBXNativeTarget "Widget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000003 /* Debug */, + ); + }; + B00000000000000000000004 /* Build configuration list for PBXNativeTarget "Admin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00000000000000000000004 /* Debug */, + ); + }; +/* End XCConfigurationList section */ + }; +} +` + +const myAppBundle = "org.reactjs.native.example.My-App" + func writeFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -34,6 +144,35 @@ func writeFile(t *testing.T, path, contents string) { } } +func TestDetectAppleReadsTheAppTarget(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "node_modules", "dep", "Dep.xcodeproj", "project.pbxproj"), + strings.ReplaceAll(testPbxproj, "ABCDE12345", "ZZZZZ99999")) + + app, err := detectApple(root) + if err != nil { + t.Fatal(err) + } + + if strings.Join(app.BundleIDs, ",") != "com.example.admin,"+myAppBundle { + t.Errorf("bundle IDs = %v", app.BundleIDs) + } + if strings.Join(app.teams(myAppBundle), ",") != "ABCDE12345" || strings.Join(app.teams("com.example.admin"), ",") != "FGHIJ67890" { + t.Errorf("teams = %v / %v", app.teams(myAppBundle), app.teams("com.example.admin")) + } + targets := app.targets(myAppBundle) + if len(targets) != 1 { + t.Fatalf("targets = %+v", app.Targets) + } + if strings.Join(targets[0].Entitlements, ",") != filepath.Join(root, "ios", "MyApp", "MyApp.entitlements") { + t.Errorf("entitlements = %v", targets[0].Entitlements) + } + if strings.Join(targets[0].InfoPlists, ",") != filepath.Join(root, "ios", "MyApp", "Info.plist") { + t.Errorf("Info.plist = %v", targets[0].InfoPlists) + } +} + func TestDetectAndroidReadsGradleAndGoogleServices(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "android", "app", "build.gradle.kts"), `plugins { @@ -73,17 +212,86 @@ android { func TestDetectReadsExpoConfig(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "app.json"), `{"expo": { + "ios": {"bundleIdentifier": "com.example.expo", "appleTeamId": "ABCDE12345"}, "android": {"package": "com.example.expo", "googleServicesFile": "./google-services.json"} }}`) writeFile(t, filepath.Join(root, "google-services.json"), `{"project_info": {"project_id": "expo-project"}, "client": []}`) + apple, _ := detectApple(root) android, _ := detectAndroid(root) - if android.ExpoConfig == "" || android.ApplicationIDs[0] != "com.example.expo" || strings.Join(android.projectIDs(), ",") != "expo-project" { + if !apple.found() || apple.BundleIDs[0] != "com.example.expo" || apple.TeamIDs[0] != "ABCDE12345" { + t.Errorf("apple = %+v", apple) + } + if !android.found() || android.ApplicationIDs[0] != "com.example.expo" || strings.Join(android.projectIDs(), ",") != "expo-project" { t.Errorf("android = %+v", android) } } +func TestEnsurePlistEdits(t *testing.T) { + empty := "\n\n\n\n" + got := ensurePlistString(empty, "aps-environment", "development") + want := "\n\n\n\taps-environment\n\tdevelopment\n\n\n" + if got != want { + t.Errorf("empty dict:\n%s", got) + } + if ensurePlistString(got, "aps-environment", "production") != got { + t.Error("an existing aps-environment was changed") + } + + info := "\n\n\tCFBundleName\n\tApp\n\tUIBackgroundModes\n\t\n\t\tfetch\n\t\n\n\n" + got = ensurePlistArrayValue(info, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, "\t\n\t\tfetch\n\t\tremote-notification\n\t") { + t.Errorf("existing array:\n%s", got) + } + if ensurePlistArrayValue(got, "UIBackgroundModes", "remote-notification") != got { + t.Error("remote-notification was added twice") + } + + expo := "\n \n CFBundleName\n App\n \n\n" + got = ensurePlistArrayValue(expo, "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " App\n UIBackgroundModes\n \n remote-notification\n \n \n") { + t.Errorf("two-space plist:\n%s", got) + } + got = ensurePlistArrayValue(strings.Replace(got, "remote-notification", "fetch", 1), "UIBackgroundModes", "remote-notification") + if !strings.Contains(got, " fetch\n remote-notification\n ") { + t.Errorf("two-space array:\n%s", got) + } + + selfClosing := "\n\n\tUIBackgroundModes\n\t\n\n\n" + got = ensurePlistArrayValue(selfClosing, "UIBackgroundModes", "remote-notification") + if got != "\n\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n" { + t.Errorf("self-closing array:\n%s", got) + } + + bare := "\n\n\tNested\n\t\n\t\n\n\n" + got = ensurePlistArrayValue(bare, "UIBackgroundModes", "remote-notification") + if !strings.HasSuffix(got, "\t\n\tUIBackgroundModes\n\t\n\t\tremote-notification\n\t\n\n\n") { + t.Errorf("new array:\n%s", got) + } +} + +func writeKey(t *testing.T, path string, key any) { + t.Helper() + encoded, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))) +} + +func testApnsKey(t *testing.T, directory string) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(directory, "AuthKey_KEY1234567.p8") + writeKey(t, path, key) + + return path +} + func testServiceAccount(t *testing.T, directory, projectID string) string { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) @@ -104,6 +312,20 @@ func testServiceAccount(t *testing.T, directory, projectID string) string { return path } +func TestReadApnsKeyRejectsOtherKeys(t *testing.T) { + directory := t.TempDir() + if _, err := readApnsKey(testApnsKey(t, directory)); err != nil { + t.Errorf("P-256 key rejected: %s", err) + } + + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + rsaPath := filepath.Join(directory, "rsa.p8") + writeKey(t, rsaPath, rsaKey) + if _, err := readApnsKey(rsaPath); err == nil || !strings.Contains(err.Error(), "P-256") { + t.Errorf("RSA key accepted: %v", err) + } +} + func TestReadServiceAccountChecksTheProject(t *testing.T) { path := testServiceAccount(t, t.TempDir(), "demo-project") if _, err := readServiceAccount(path, "demo-project"); err != nil { @@ -116,14 +338,14 @@ func TestReadServiceAccountChecksTheProject(t *testing.T) { func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { directory := t.TempDir() - old := testServiceAccount(t, directory, "old-project") + old := testApnsKey(t, directory) past := time.Now().Add(-time.Hour) if err := os.Chtimes(old, past, past); err != nil { t.Fatal(err) } started := time.Now() - fresh := filepath.Join(directory, "new-project-firebase-adminsdk-abc.json") + fresh := filepath.Join(directory, "AuthKey_NEW1234567.p8") go func() { time.Sleep(50 * time.Millisecond) writeFile(t, filepath.Join(directory, "notes.txt"), "not a key") @@ -132,7 +354,7 @@ func TestWaitForKeyFileIgnoresEarlierFiles(t *testing.T) { }() path, err := waitForKeyFile([]string{t.TempDir(), directory}, started, func(path string) bool { - return strings.HasSuffix(path, ".json") + return apnsKeyFileName.MatchString(filepath.Base(path)) }, 5*time.Second, 10*time.Millisecond) if err != nil { t.Fatal(err) @@ -239,6 +461,93 @@ func newTestPushSetup(t *testing.T, server *httptest.Server, prompter prompt.Pro }, out } +func TestInitApnsCreatesBothEnvironmentsAndEditsXcode(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + writeFile(t, filepath.Join(root, "ios", "MyApp.xcodeproj", "project.pbxproj"), testPbxproj) + writeFile(t, filepath.Join(root, "ios", "MyApp", "MyApp.entitlements"), "\n\n\n") + writeFile(t, filepath.Join(root, "ios", "MyApp", "Info.plist"), "\n\n\n\n") + untouched := map[string]string{} + for _, path := range []string{"Admin/Admin.entitlements", "Admin/Info.plist", "Widget/Widget.entitlements", "Widget/Info.plist"} { + untouched[path] = "\n\n\n\n" + writeFile(t, filepath.Join(root, "ios", path), untouched[path]) + } + keyPath := testApnsKey(t, t.TempDir()) + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + detected, _ := detectApple(root) + if err := setup.apns(apnsOptions{keyPath: keyPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for index, sandbox := range []bool{false, true} { + provider := messaging.providers[index] + credentials := provider["credentials"].(map[string]any) + if credentials["bundleId"] != myAppBundle || credentials["teamId"] != "ABCDE12345" || + credentials["authKeyId"] != "KEY1234567" || provider["options"].(map[string]any)["sandbox"] != sandbox || + provider["enabled"] != true { + t.Errorf("provider %d = %v", index, provider) + } + } + + entitlements, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "MyApp.entitlements")) + if !strings.Contains(string(entitlements), "aps-environment\n\tdevelopment") { + t.Errorf("entitlements:\n%s", entitlements) + } + info, _ := os.ReadFile(filepath.Join(root, "ios", "MyApp", "Info.plist")) + if !strings.Contains(string(info), "remote-notification") { + t.Errorf("Info.plist:\n%s", info) + } + for path, original := range untouched { + if contents, _ := os.ReadFile(filepath.Join(root, "ios", path)); string(contents) != original { + t.Errorf("%s was edited:\n%s", path, contents) + } + } + + previous := map[string]any{} + for _, provider := range messaging.providers { + previous[provider["$id"].(string)] = provider["credentials"].(map[string]any)["authKey"] + } + rotated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rotatedPath := filepath.Join(t.TempDir(), "AuthKey_ROTATE1234.p8") + writeKey(t, rotatedPath, rotated) + if err := setup.apns(apnsOptions{keyPath: rotatedPath, bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers after rotation = %v", messaging.providers) + } + for _, provider := range messaging.providers { + id := provider["$id"].(string) + credentials := provider["credentials"].(map[string]any) + old, kept := previous[id] + if !kept || credentials["authKeyId"] != "ROTATE1234" || credentials["authKey"] == old { + t.Errorf("provider %s after rotation = %v", id, credentials) + } + } + + messaging.requests = nil + if err := setup.apns(apnsOptions{bundleID: myAppBundle}, detected); err != nil { + t.Fatal(err) + } + if strings.Join(messaging.requests, ",") != "GET /messaging/providers" { + t.Errorf("already set up requests = %v", messaging.requests) + } +} + func TestInitFcmRefusesAMismatchedApp(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) @@ -431,6 +740,32 @@ func (r recordingPrompter) Choice(question prompt.Choice) (string, error) { return r.Scripted.Choice(question) } +func TestInitApnsUsesAKeyInTheProjectFolder(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + testApnsKey(t, root) + writeFile(t, filepath.Join(root, "notes.p8"), "not a key") + + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if !strings.Contains(out.String(), "Using AuthKey_KEY1234567.p8 from the project folder.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + func TestInitFcmUsesAKeyInTheProjectFolderBeforeAskingForAProject(t *testing.T) { messaging := &fakeMessaging{} server := httptest.NewServer(messaging) @@ -476,13 +811,13 @@ func TestBrowserFlowFindsAKeySavedIntoTheProjectFolder(t *testing.T) { setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, root) go func() { time.Sleep(100 * time.Millisecond) - testServiceAccount(t, root, "saved-project") + testApnsKey(t, root) }() - if err := setup.fcm(fcmOptions{}, androidApp{}); err != nil { + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { t.Fatal(err) } - if len(messaging.providers) != 1 || messaging.providers[0]["name"] != "FCM (saved-project)" { + if len(messaging.providers) != 2 { t.Errorf("providers = %v", messaging.providers) } } @@ -495,16 +830,16 @@ func TestKeysInAGitRepositoryMustBeIgnored(t *testing.T) { if err := exec.Command("git", "-C", root, "init", "-q").Run(); err != nil { t.Skip("git init failed") } - key := testServiceAccount(t, root, "demo-project") + key := testApnsKey(t, root) out := &bytes.Buffer{} setup := &pushSetup{out: out, keyDirs: []string{root}} setup.checkKeyLocation(key) - if !strings.Contains(out.String(), "demo-project-firebase-adminsdk-abc.json is inside a git repository and not ignored") { + if !strings.Contains(out.String(), "AuthKey_KEY1234567.p8 is inside a git repository and not ignored") { t.Errorf("output:\n%s", out.String()) } - writeFile(t, filepath.Join(root, ".gitignore"), "*-firebase-adminsdk-*.json\n") + writeFile(t, filepath.Join(root, ".gitignore"), "*.p8\n") out.Reset() setup.checkKeyLocation(key) if out.Len() != 0 { @@ -880,3 +1215,507 @@ func TestInitFcmExplainsAServerWithoutTheFirebaseManagementAPI(t *testing.T) { t.Errorf("providers = %v", messaging.providers) } } + +// useAdapter makes adapter the only APNs key setup, under the default name, +// as an account with one team (APPLE12345) would see it. +func useAdapter(setup *pushSetup, adapter *apnstest.Adapter) *apnstest.Adapter { + adapter.AdapterName = defaultApnsSetup + if adapter.TeamID == "" { + adapter.TeamID = "APPLE12345" + } + setup.apnsAdapters = apns.NewRegistry(adapter) + + return adapter +} +func TestInitApnsCreatesTheKeyWithTheAppleID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + scripted := &prompt.Scripted{} + setup, out := newTestPushSetup(t, server, scripted, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "ABCDE12345" || !strings.HasPrefix(appleKey.Requests[0].Name, "Appwrite Push ") { + t.Errorf("helper calls = %v", appleKey.Requests) + } + saved := filepath.Join(root, "AuthKey_NEWKEY1234.p8") + info, err := os.Stat(saved) + if err != nil { + t.Fatalf("key was not saved: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("key mode = %v", info.Mode().Perm()) + } + if len(messaging.providers) != 2 { + t.Fatalf("providers = %v", messaging.providers) + } + for _, provider := range messaging.providers { + credentials := provider["credentials"].(map[string]any) + if credentials["authKeyId"] != "NEWKEY1234" || credentials["teamId"] != "ABCDE12345" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Created APNs key NEWKEY1234 on team ABCDE12345 and saved it to AuthKey_NEWKEY1234.p8.") { + t.Errorf("output:\n%s", out.String()) + } + + // A second run finds both providers and does not spend another key slot. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 { + t.Errorf("created another key on rerun: %v", appleKey.Requests) + } +} + +func TestInitApnsOffersTheAppleIDWhenNoKeyIsFound(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{ + "How would you like to provide the APNs auth key (.p8)?": "automatic", + }} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || len(messaging.providers) != 2 { + t.Errorf("calls = %v, providers = %v", appleKey.Requests, messaging.providers) + } +} + +func TestInitApnsStopsWhenCreateKeyFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + // A key already in the project folder must not stand in for the one + // --create-key was asked to create. + testApnsKey(t, root) + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, root) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || err.Error() != "could not create the APNs key with your Apple ID: could not reach Apple" { + t.Fatalf("err = %v", err) + } + if len(scripted.Asked) != 0 || len(messaging.providers) != 0 { + t.Errorf("asked %v, providers %v", scripted.Asked, messaging.providers) + } +} + +// choiceSequence answers the same choice differently each time it is asked. +type choiceSequence struct { + *prompt.Scripted + answers []string +} + +func (c *choiceSequence) Choice(question prompt.Choice) (string, error) { + c.Scripted.Asked = append(c.Scripted.Asked, question.Message) + answer := c.answers[0] + c.answers = c.answers[1:] + + return answer, nil +} + +func TestInitApnsFallsBackWhenTheChosenAppleSignInFails(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &choiceSequence{ + Scripted: &prompt.Scripted{Texts: map[string]string{"Path to the APNs auth key (.p8)": existing}}, + answers: []string{"automatic", "file"}, + } + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{Err: errors.New("could not reach Apple")}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Could not create the APNs key with your Apple ID: could not reach Apple.") { + t.Errorf("output:\n%s", out.String()) + } + if len(messaging.providers) != 2 || messaging.providers[0]["credentials"].(map[string]any)["authKeyId"] != "KEY1234567" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsStopsWhenTheTeamHasNoFreeKeySlot(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := &apnstest.Adapter{Err: &apns.MaxKeysError{Keys: []apns.ExistingKey{ + {ID: "OLDKEY1234", Name: "Firebase", CanRevoke: true}, + {ID: "OLDKEY5678", Name: "Expo", CanRevoke: true}, + }}} + useAdapter(setup, appleKey) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "--key-path") { + t.Fatalf("err = %v", err) + } + for _, line := range []string{"Firebase (OLDKEY1234)", "Expo (OLDKEY5678)", apnsKeysListPage} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } + if entries, _ := os.ReadDir(root); len(entries) != 0 { + t.Errorf("wrote %v", entries) + } +} + +func TestInitApnsTakesTheTeamFromTheAppleSignIn(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{} + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", createKey: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(scripted.Asked) != 0 { + t.Errorf("asked %v", scripted.Asked) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].TeamID != "" { + t.Errorf("helper calls = %v", appleKey.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["teamId"] != "APPLE12345" { + t.Errorf("provider = %v", provider) + } + } +} + +func TestInitApnsAsksForTheTeamAfterAKeyFile(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + existing := testApnsKey(t, t.TempDir()) + scripted := &prompt.Scripted{ + Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, + Texts: map[string]string{ + "Path to the APNs auth key (.p8)": existing, + "What is your Apple Developer team ID?": "ABCDE12345", + }, + } + setup, _ := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app"}, appleApp{}); err != nil { + t.Fatal(err) + } + want := []string{"How would you like to provide the APNs auth key (.p8)?", "Path to the APNs auth key (.p8)", "What is your Apple Developer team ID?"} + if strings.Join(scripted.Asked, "|") != strings.Join(want, "|") { + t.Errorf("asked %v", scripted.Asked) + } + if messaging.providers[0]["credentials"].(map[string]any)["teamId"] != "ABCDE12345" { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsSetsUpOneEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + root := t.TempDir() + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, root) + appleKey := useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, environment: "sandbox"} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || appleKey.Requests[0].Environment != apns.EnvironmentSandbox { + t.Errorf("helper calls = %v", appleKey.Requests) + } + if len(messaging.providers) != 1 || messaging.providers[0]["options"].(map[string]any)["sandbox"] != true { + t.Fatalf("providers = %v", messaging.providers) + } + if !strings.Contains(out.String(), "APNs (sandbox) is set up for com.example.app.") { + t.Errorf("output:\n%s", out.String()) + } + + // The sandbox provider is all a sandbox run needs. + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appleKey.Requests) != 1 || !strings.Contains(out.String(), "APNs (sandbox) is already set up for com.example.app.") { + t.Errorf("calls = %v, output:\n%s", appleKey.Requests, out.String()) + } + + // Production still needs its own provider; the key already in the folder + // serves it. + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", environment: "production"}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(messaging.providers) != 2 || messaging.providers[1]["options"].(map[string]any)["sandbox"] != false { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsRejectsAnUnknownEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + err := setup.apns(apnsOptions{bundleID: "com.example.app", environment: "staging"}, appleApp{}) + if err == nil || err.Error() != `--environment must be production, sandbox or all, not "staging"` { + t.Fatalf("err = %v", err) + } + if len(messaging.requests) != 0 { + t.Errorf("requests = %v", messaging.requests) + } +} + +func TestInitApnsPicksTheSetupFromTheFlagThenTheEnvironment(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + appwrite := &apnstest.Adapter{AdapterName: "appwrite", KeyID: "APPWRITE01", TeamID: "ABCDE12345"} + expo := &apnstest.Adapter{AdapterName: "expo", KeyID: "EXPOKEY001", TeamID: "ABCDE12345"} + setup.apnsAdapters = apns.NewRegistry(appwrite, expo) + env := map[string]string{ + "APPWRITE_APNS_SETUP": "expo", + "APPWRITE_APPLE_ID": "dev@example.com", + "APPWRITE_APPLE_PASSWORD": "secret", + } + setup.getenv = func(name string) string { return env[name] } + options := apnsOptions{bundleID: "com.example.app", createKey: true} + + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(expo.Requests) != 1 || len(appwrite.Requests) != 0 { + t.Fatalf("expo = %d, appwrite = %d requests", len(expo.Requests), len(appwrite.Requests)) + } + request := expo.Requests[0] + if request.AppleID != "dev@example.com" || request.Password != "secret" || request.Asker == nil { + t.Errorf("request = %+v", request) + } + for _, line := range []string{"Signing in to the Apple Developer portal (expo).", "Using the Apple ID from APPWRITE_APPLE_ID.", "Using the Apple ID password from APPWRITE_APPLE_PASSWORD."} { + if !strings.Contains(out.String(), line) { + t.Errorf("output is missing %q:\n%s", line, out.String()) + } + } + if strings.Contains(out.String(), "secret") { + t.Errorf("printed the password:\n%s", out.String()) + } + + // Each run starts from no providers and a fresh folder: a key file is + // never overwritten. + fresh := func() { + messaging.providers = nil + setup.keyDirs = []string{t.TempDir()} + } + fresh() + options.setup = "appwrite" + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 1 || len(expo.Requests) != 1 { + t.Errorf("--provider did not win over %s", apnsSetupVariable) + } + + delete(env, "APPWRITE_APNS_SETUP") + options.setup = "" + fresh() + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + if len(appwrite.Requests) != 2 { + t.Errorf("the default is not %s", defaultApnsSetup) + } +} + +func TestInitApnsReportsAnUnknownSetup(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(&apnstest.Adapter{AdapterName: "appwrite"}) + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true, setup: "fastlane"}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), `unknown APNs key setup "fastlane": use one of appwrite`) { + t.Fatalf("err = %v", err) + } + if len(messaging.providers) != 0 { + t.Errorf("providers = %v", messaging.providers) + } +} + +func TestInitApnsWithoutSetupsOnlyOffersTheOtherWays(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + var offered []prompt.Option + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "file"}, Texts: map[string]string{"Path to the APNs auth key (.p8)": testApnsKey(t, t.TempDir())}} + setup, _ := newTestPushSetup(t, server, optionsRecorder{scripted, &offered}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry() + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, option := range offered { + if option.Value == "automatic" { + t.Errorf("offered the Apple sign-in with no setup built in: %v", offered) + } + } + + messaging.providers = nil + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil || !strings.Contains(err.Error(), "this build has none") { + t.Errorf("err = %v", err) + } +} + +// optionsRecorder records the options of the key question. +type optionsRecorder struct { + *prompt.Scripted + options *[]prompt.Option +} + +func (r optionsRecorder) Choice(question prompt.Choice) (string, error) { + if question.Message == "How would you like to provide the APNs auth key (.p8)?" { + *r.options = question.Options + } + + return r.Scripted.Choice(question) +} + +func TestInitApnsUsesTheCreatedKeysID(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + scripted := &prompt.Scripted{Choices: map[string]string{"How would you like to provide the APNs auth key (.p8)?": "automatic"}} + setup, out := newTestPushSetup(t, server, scripted, t.TempDir()) + useAdapter(setup, &apnstest.Adapter{KeyID: "NEWKEY1234"}) + + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", keyID: "WRONGKEY12"}, appleApp{}); err != nil { + t.Fatal(err) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "NEWKEY1234" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Using the created key's ID NEWKEY1234 instead of --key-id WRONGKEY12.") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--create-key", "--key-id", "WRONGKEY12"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-id") { + t.Errorf("--create-key with --key-id: %v", err) + } +} + +// askingAdapter asks one question before creating a key, as a sign-in does. +type askingAdapter struct{} + +func (askingAdapter) Name() string { return defaultApnsSetup } + +func (askingAdapter) CreateKey(_ context.Context, request apns.Request) (apns.Key, error) { + if _, err := request.Asker.Ask("Enter the 6-digit code sent to +1 •••12", false); err != nil { + return apns.Key{}, err + } + + return apns.Key{KeyID: "NEWKEY1234", TeamID: request.TeamID, P8: apnstest.P8()}, nil +} + +func TestInitApnsExplainsASignInWithoutATerminal(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, _ := newTestPushSetup(t, server, prompt.NonInteractive{}, t.TempDir()) + setup.apnsAdapters = apns.NewRegistry(askingAdapter{}) + + err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true}, appleApp{}) + if err == nil { + t.Fatal("no error without a terminal") + } + message := err.Error() + for _, part := range []string{`asked "Enter the 6-digit code sent to +1 •••12"`, "APPWRITE_APPLE_ID", "the two-factor code always needs one", "instead of --create-key"} { + if !strings.Contains(message, part) { + t.Errorf("message is missing %q: %s", part, message) + } + } + if strings.Contains(message, "Pass --key-path instead") { + t.Errorf("suggests a flag --create-key rejects: %s", message) + } +} + +func TestInitApnsResetSignsInAgainAndCreatesAKey(t *testing.T) { + messaging := &fakeMessaging{} + server := httptest.NewServer(messaging) + defer server.Close() + + setup, out := newTestPushSetup(t, server, &prompt.Scripted{}, t.TempDir()) + adapter := useAdapter(setup, &apnstest.Adapter{KeyID: "FIRSTKEY12"}) + options := apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", createKey: true} + if err := setup.apns(options, appleApp{}); err != nil { + t.Fatal(err) + } + + // Already set up, yet --reset signs in again and creates a key, without + // --create-key or --force. + setup.keyDirs = []string{t.TempDir()} + adapter.KeyID = "SECONDKEY1" + if err := setup.apns(apnsOptions{bundleID: "com.example.app", teamID: "ABCDE12345", reset: true}, appleApp{}); err != nil { + t.Fatal(err) + } + if len(adapter.Requests) != 2 || adapter.Requests[0].Reset || !adapter.Requests[1].Reset { + t.Fatalf("requests = %+v", adapter.Requests) + } + for _, provider := range messaging.providers { + if provider["credentials"].(map[string]any)["authKeyId"] != "SECONDKEY1" { + t.Errorf("provider = %v", provider) + } + } + if !strings.Contains(out.String(), "Signing in again: the saved Apple sign-in is not used (--reset).") { + t.Errorf("output:\n%s", out.String()) + } + + command := newInitApnsCommand() + if err := command.ParseFlags([]string{"--reset", "--key-path", "AuthKey_KEY1234567.p8"}); err != nil { + t.Fatal(err) + } + if err := command.ValidateFlagGroups(); err == nil || !strings.Contains(err.Error(), "key-path") { + t.Errorf("--reset with --key-path: %v", err) + } +} diff --git a/templates/cli/internal/cmd/initpushdetect.go b/templates/cli/internal/cmd/initpushdetect.go index 6aad5ec23f..092aa9730e 100644 --- a/templates/cli/internal/cmd/initpushdetect.go +++ b/templates/cli/internal/cmd/initpushdetect.go @@ -12,6 +12,51 @@ import ( "strings" ) +type appleTarget struct { + Project string + BundleID string + TeamID string + Entitlements []string + InfoPlists []string +} + +type appleApp struct { + Projects []string + Targets []appleTarget + BundleIDs []string + TeamIDs []string + ExpoConfig string +} + +func (a appleApp) found() bool { + return len(a.Projects) > 0 || a.ExpoConfig != "" +} + +func (a appleApp) targets(bundleID string) []appleTarget { + var targets []appleTarget + for _, target := range a.Targets { + if target.BundleID == bundleID { + targets = append(targets, target) + } + } + + return targets +} + +func (a appleApp) teams(bundleID string) []string { + var teams []string + for _, target := range a.targets(bundleID) { + if target.TeamID != "" { + teams = appendUnique(teams, target.TeamID) + } + } + if len(teams) == 0 && len(a.TeamIDs) == 1 { + return a.TeamIDs + } + + return teams +} + type firebaseConfig struct { Path string ProjectID string @@ -30,6 +75,10 @@ type androidApp struct { HasMessaging bool } +func (a androidApp) found() bool { + return len(a.Modules) > 0 || a.ExpoConfig != "" +} + func (a androidApp) projectIDs() []string { var projects []string for _, config := range a.Firebase { @@ -81,8 +130,148 @@ func walkProject(root string, visit func(path string, entry fs.DirEntry) error) }) } +const applicationProductType = "com.apple.product-type.application" + +var ( + pbxObject = regexp.MustCompile(`(?ms)^\t\t([0-9A-Fa-f]{24})(?: /\*.*?\*/)? = \{\n(.*?)\n\t\t\};`) + pbxIsa = regexp.MustCompile(`(?m)^\t\t\tisa = (\w+);`) + pbxProductType = regexp.MustCompile(`(?m)^\t\t\tproductType = "?([^";]+)"?;`) + pbxConfigList = regexp.MustCompile(`(?m)^\t\t\tbuildConfigurationList = ([0-9A-Fa-f]{24})`) + pbxConfigs = regexp.MustCompile(`(?s)buildConfigurations = \((.*?)\);`) + pbxReference = regexp.MustCompile(`[0-9A-Fa-f]{24}`) + pbxBuildSettings = regexp.MustCompile(`(?s)buildSettings = \{(.*?)\n\t\t\t\};`) + pbxSetting = regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*) = (.*?);\s*$`) + appleTeamID = regexp.MustCompile(`^[A-Z0-9]{10}$`) +) + +func pbxValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + value = value[1 : len(value)-1] + } + + return value +} + +func rfc1034(value string) string { + var builder strings.Builder + for _, character := range value { + switch { + case character >= 'a' && character <= 'z', character >= 'A' && character <= 'Z', + character >= '0' && character <= '9', character == '-', character == '.': + builder.WriteRune(character) + default: + builder.WriteRune('-') + } + } + + return builder.String() +} + +func resolveBundleID(bundleID string, settings map[string]string) string { + name := settings["PRODUCT_NAME"] + if name != "" && !strings.Contains(name, "$(") { + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME:rfc1034identifier)", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME:rfc1034identifier}", rfc1034(name)) + bundleID = strings.ReplaceAll(bundleID, "$(PRODUCT_NAME)", name) + bundleID = strings.ReplaceAll(bundleID, "${PRODUCT_NAME}", name) + } + if strings.Contains(bundleID, "$") { + return "" + } + + return bundleID +} + +func projectPath(projectDir, value string) string { + value = strings.TrimPrefix(value, "$(SRCROOT)/") + value = strings.TrimPrefix(value, "${SRCROOT}/") + value = strings.TrimPrefix(value, "$(PROJECT_DIR)/") + if strings.Contains(value, "$") { + return "" + } + + return filepath.Join(projectDir, value) +} + +func pbxSettings(body string) map[string]string { + settings := map[string]string{} + block := pbxBuildSettings.FindStringSubmatch(body) + if block == nil { + return settings + } + for _, setting := range pbxSetting.FindAllStringSubmatch(block[1], -1) { + settings[setting[1]] = pbxValue(setting[2]) + } + + return settings +} + +func readXcodeProject(app *appleApp, xcodeproj string) { + contents, err := os.ReadFile(filepath.Join(xcodeproj, "project.pbxproj")) + if err != nil { + return + } + app.Projects = append(app.Projects, xcodeproj) + projectDir := filepath.Dir(xcodeproj) + + objects := map[string]string{} + var applications []string + for _, object := range pbxObject.FindAllStringSubmatch(string(contents), -1) { + objects[object[1]] = object[2] + isa := pbxIsa.FindStringSubmatch(object[2]) + productType := pbxProductType.FindStringSubmatch(object[2]) + if isa != nil && isa[1] == "PBXNativeTarget" && productType != nil && productType[1] == applicationProductType { + applications = append(applications, object[2]) + } + } + + for _, application := range applications { + list := pbxConfigList.FindStringSubmatch(application) + if list == nil { + continue + } + configs := pbxConfigs.FindStringSubmatch(objects[list[1]]) + if configs == nil { + continue + } + + byBundle := map[string]*appleTarget{} + var order []string + for _, reference := range pbxReference.FindAllString(configs[1], -1) { + settings := pbxSettings(objects[reference]) + bundleID := resolveBundleID(settings["PRODUCT_BUNDLE_IDENTIFIER"], settings) + if bundleID == "" { + continue + } + target, ok := byBundle[bundleID] + if !ok { + target = &appleTarget{Project: xcodeproj, BundleID: bundleID} + byBundle[bundleID] = target + order = append(order, bundleID) + } + if team := settings["DEVELOPMENT_TEAM"]; appleTeamID.MatchString(team) && target.TeamID == "" { + target.TeamID = team + } + if path := projectPath(projectDir, settings["CODE_SIGN_ENTITLEMENTS"]); settings["CODE_SIGN_ENTITLEMENTS"] != "" && path != "" { + target.Entitlements = appendUnique(target.Entitlements, path) + } + if path := projectPath(projectDir, settings["INFOPLIST_FILE"]); settings["INFOPLIST_FILE"] != "" && path != "" { + target.InfoPlists = appendUnique(target.InfoPlists, path) + } + } + for _, bundleID := range order { + app.Targets = append(app.Targets, *byBundle[bundleID]) + } + } +} + type expoConfig struct { Expo struct { + IOS struct { + BundleIdentifier string `json:"bundleIdentifier"` + AppleTeamID string `json:"appleTeamId"` + } `json:"ios"` Android struct { Package string `json:"package"` GoogleServicesFile string `json:"googleServicesFile"` @@ -106,6 +295,54 @@ func readExpoConfig(root string) (expoConfig, string) { return expoConfig{}, "" } +func detectApple(root string) (appleApp, error) { + app := appleApp{} + + err := walkProject(root, func(path string, entry fs.DirEntry) error { + if entry.IsDir() && strings.HasSuffix(entry.Name(), ".xcodeproj") { + readXcodeProject(&app, path) + + return filepath.SkipDir + } + + return nil + }) + if err != nil { + return app, err + } + + config, path := readExpoConfig(root) + if bundleID := config.Expo.IOS.BundleIdentifier; bundleID != "" { + app.ExpoConfig = path + team := "" + if appleTeamID.MatchString(config.Expo.IOS.AppleTeamID) { + team = config.Expo.IOS.AppleTeamID + } + matched := false + for index := range app.Targets { + if app.Targets[index].BundleID == bundleID { + matched = true + if app.Targets[index].TeamID == "" { + app.Targets[index].TeamID = team + } + } + } + if !matched { + app.Targets = append(app.Targets, appleTarget{BundleID: bundleID, TeamID: team}) + } + } + + for _, target := range app.Targets { + app.BundleIDs = appendUnique(app.BundleIDs, target.BundleID) + if target.TeamID != "" { + app.TeamIDs = appendUnique(app.TeamIDs, target.TeamID) + } + } + sort.Strings(app.BundleIDs) + + return app, nil +} + var ( gradleApplicationPlugin = regexp.MustCompile(`com\.android\.application`) gradleApplicationID = regexp.MustCompile(`\bapplicationId\s*(?:=\s*)?["']([^"']+)["']`)