Repository navigation
feat(cli): init apns and init push, with one-click APNs key creation - #1963
Closed
ArnabChatterjee20k wants to merge 36 commits into
Closed
ArnabChatterjee20k wants to merge 36 commits into
ArnabChatterjee20k wants to merge 36 commits into
Conversation
🟢 Tier S · Ready to merge
This PR adds Latest changes: The latest commits add
📂 Walkthrough · 22
Reviewed the commits since |
Apple has no public API for creating APNs keys, and the tools that do it (spaceship, @expo/apple-utils, Appwrite's own) all drive the Developer portal's private one, which Apple can change at any time. Each becomes an Adapter behind one contract: CreateKey checks the request, runs the adapter and checks the key it returns (key ID, team, .p8 contents), so every setup behaves the same to the caller. A Registry finds them by name. The package imports nothing from the CLI: prompts, logging, credentials and the session folder come in through the Request, so it can move to its own module unchanged. apnstest has a scripted adapter for tests.
--create-key creates the key with the setup picked by --provider, then APPWRITE_APNS_SETUP, then appwrite, so a setup Apple breaks can be swapped without a release. APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the sign-in questions, and the CLI names the variable it used. --environment (production, sandbox or all) picks the providers to set up and the environment of a created key. Without a known team, the sign-in chooses it. The new .p8 is saved before the providers are written, since Apple allows one download, and a failed --create-key stops rather than falling back to a key already in the folder. No setup is registered yet: each comes in its own change, and until then the sign-in option is not offered.
A key created by the Apple sign-in has an ID Apple chose. --key-id could override it, and the providers then got an ID that does not match the .p8, so every push would fail to authenticate. --create-key now rejects --key-id, and a key created from the prompt always uses Apple's ID, with a warning when --key-id said otherwise. Sign-in questions asked without a terminal also no longer suggest --key-path, which --create-key rejects. The error names the question, says APPWRITE_APPLE_ID and APPWRITE_APPLE_PASSWORD answer the Apple ID and password, that the two-factor code always needs a terminal, and to use --key-path instead of --create-key otherwise.
The appwrite adapter signs in to Apple and calls the Developer portal in Go, with nothing to install. The sign-in follows spaceship: the widget key from the App Store Connect sign out redirect, SRP-6a against idmsa.apple.com (PBKDF2 over the password, s2k and s2k_fo), hashcash, two-factor by device code or text message, and a trusted session kept in the request's session folder so later runs skip two-factor. The key calls follow @expo/apple-utils: list the team's keys, which hands out the CSRF tokens, create a team-scoped key with APNs for the requested environment, and download the .p8. The SRP and hashcash code is checked against values computed by fastlane-sirp and spaceship, and the whole flow against a fake Apple that runs the server side of SRP. The Go bodies of portal.go and adapter_test.go are Twig verbatim blocks, as their nested composite literals read as Twig tags.
Registers the appwrite adapter, the default for --create-key, so the Apple sign-in is offered and --provider appwrite (or APPWRITE_APNS_SETUP=appwrite) selects it.
The expo adapter creates APNs keys with @expo/apple-utils 2.2.1 (MIT), the library eas-cli uses. A small Node script, its package.json and a lockfile pinning the package hash are embedded with go:embed and installed into the session folder with npm ci --ignore-scripts when they change. It needs Node.js 18 or later and reports apns.ErrUnavailable otherwise, before contacting Apple. apple-utils prompts on the terminal for the two-factor code and for anything the request leaves out; the request's Apple ID and password are passed as EXPO_APPLE_ID and EXPO_APPLE_PASSWORD, and EXPO_NO_KEYCHAIN=1 keeps the password out of the macOS Keychain. Its InvalidUserCredentialsError maps to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError. apple-utils always creates keys for both environments, which also serve a single-environment request.
Registers the expo adapter, picked with --provider expo or APPWRITE_APNS_SETUP=expo.
The fastlane adapter creates APNs keys with spaceship, fastlane's open-source Apple Developer portal client, through the fastlane the person already has installed: a lane in an embedded Fastfile runs in a private temp folder, so a gem, Homebrew or bundler installation all work and no Ruby ships with the CLI. It needs fastlane 2.226.0 or later, the first to sign in with SRP, and reports apns.ErrUnavailable otherwise. The adapter asks for the Apple ID and password when the request has none and passes them as FASTLANE_USER and FASTLANE_PASSWORD, so fastlane never offers to store them in the Keychain; spaceship asks for the two-factor code on the terminal. The lane calls create_key! directly so the requested environment is honoured (Key.create always asks for all), maps InvalidUserCredentialsError to apns.ErrInvalidCredentials and a full team to apns.MaxKeysError with the team's keys.
Registers the fastlane adapter, picked with --provider fastlane or APPWRITE_APNS_SETUP=fastlane.
2.225.0 (October 2024) is the release that added SRP sign-in, per its notes: earlier versions get 503 from Apple. The minimum was set one release too high.
Typing sms to get the code by text message used one of the three attempts, leaving two for actual codes. Only codes Apple rejects count now.
The hashcash date was the machine's local time. @expo/apple-utils sends UTC (spaceship's local time only matches on a UTC machine), so a non-UTC machine produced a date hours away from the one Apple expects.
The fastlane setup ran fastlane from PATH in a temporary folder, so a fastlane installed only through a project's Gemfile was reported as unavailable. When the Gemfile in the folder the CLI runs in, or in its ios folder as Flutter and React Native projects keep it, lists fastlane and bundle is installed, the lane now runs with bundle exec fastlane and BUNDLE_GEMFILE pointing at that Gemfile. Otherwise fastlane from PATH is used as before. A Bundler fastlane that does not start asks for bundle install.
The CLI leaves the team out when none is known and lets the sign-in pick it. The test now checks that the helper then gets no --team-id.
When the project's Gemfile lists fastlane but Bundler is missing, the fastlane setup fell back to fastlane from PATH, which can be a different version from the pinned one. It now reports apns.ErrUnavailable naming the Gemfile and how to install Bundler.
…g-provider-cli-apns-adapters # Conflicts: # src/SDK/Language/CLI.php # templates/cli/internal/cmd/initpush.go
…aging-provider-cli-apns-adapters # Conflicts: # src/SDK/Language/CLI.php # templates/cli/internal/cmd/initpush.go
Any difficulty up to 64 bits was answered, and the search ignored cancellation, so an abnormal challenge could keep init apns spinning indefinitely. Challenges above 24 bits (Apple asks for about 11; 24 is some 16 million attempts) are now left unanswered, as one sent without a challenge is, and the search checks the context every 65536 attempts so Ctrl-C stops it.
The expo setup stopped when node or npm was missing, or node was older than 18. It now downloads the Node.js LTS (v24.21.0) for this OS and CPU from nodejs.org into the setup's session folder, checks the archive against the SHA-256 pinned for each official build, unpacks it beside its final folder and moves it into place, and runs npm through node. Nothing is installed system-wide, and later runs reuse it. Entries that would unpack outside the folder are refused and symbolic links (npm's shell wrappers) are skipped. A node set on the adapter is checked but never replaced.
The fastlane APNs key setup stopped with a bare 'needs fastlane' when none was installed. When Homebrew is available it now asks to run brew install fastlane on the terminal and carries on. Without Homebrew, a terminal or a yes, it says how to install fastlane (Homebrew, gem or the project's Gemfile) or to use --provider appwrite. An installed fastlane older than 2.225.0 now gets the matching upgrade command: bundle update fastlane for a project's Bundler fastlane, otherwise brew upgrade or gem update. A project's pinned fastlane and an explicit command are never replaced by an install.
Two expo setups starting together in the same session folder could both download Node.js, and the later one removed the runtime the earlier one had just published, maybe while it was running npm or the helper. Publishing is now only a rename: an existing runtime is never removed, and when another run published first, its complete runtime is used.
…er hint A fastlane command set on the adapter was offered a Homebrew install when it was missing, which could replace a deliberate choice; the offer is now only for fastlane from PATH. The too-old message for a project's Bundler fastlane said 'bundle update fastlane in <folder>', which Bundler reads as gem names; it now says 'cd <folder> && bundle update fastlane'.
cd <folder> && bundle update fastlane broke for folders with spaces: the shell split the path. The folder is now quoted for the shell people paste into, single quotes on Unix (with a single quote in the name escaped) and double quotes on Windows, and left as is when it has only safe characters.
Double quotes are not safe on Windows: PowerShell expands $name and $() inside them, so a folder name could change the pasted command or run an expression, cmd expands %, and Windows PowerShell has no &&. No single quoting works in every Windows shell, so the Bundler upgrade hint there names the folder to run bundle update fastlane from. Unix keeps the single-quoted cd command.
…aging-provider-cli-apns
The appwrite APNs setup signed in, then failed with HTTP 415 from account/listTeams.action: every portal POST carried a JSON body, and that endpoint, like the portal's other .action endpoints, refuses one. apple-utils and spaceship send it with no body. A portal call with nothing to send now has no body and no Content-Type. The fake portal in the tests answers a body there with 415, as Apple does.
After the team list, the appwrite APNs setup failed with HTTP 415 from account/auth/key/list: portal POSTs carried JSON. apple-utils and spaceship send them form encoded (application/x-www-form-urlencoded) and use JSON only to create a key. Portal POSTs are now forms, key creation stays JSON, and a call with nothing to send has no body. The fake portal in the tests accepts only those encodings, answering others with 415 as Apple does.
Apple now answers a team with no free APNs key slot with 'You have already reached the maximum allowed number of team scoped Keys for this service in production and sandbox environment.' All three setups looked for 'maximum allowed number of Keys', so the error came through raw instead of listing the team's keys with the --key-path advice. They now match the message with or without the words before 'Keys', and the expo helper also checks the error's own message, since apple-utils does not turn the new wording into MaxKeysCreatedError either.
A saved Apple sign-in was reused until Apple expired it, and the only way to sign in again, or as another Apple ID, was to delete a file by hand. --reset signs in from scratch, two-factor included, and the new session replaces the saved one. It implies --create-key, as signing in is only for creating a key, so it is not stopped by APNs being set up already, and it cannot be combined with --key-path or --key-id. apns.Request gains Reset. The appwrite setup loads none of the saved cookies, not even the one that marks the machine as trusted. The expo helper runs apple-utils' Auth.logoutAsync, which deletes its saved session for the Apple ID, and the fastlane lane deletes spaceship's saved session for the Apple ID before signing in.
With --reset and no APPWRITE_APPLE_ID, the expo setup asked for the Apple ID twice: apple-utils' Auth.logoutAsync asks for it to find the session and forgets it, and the sign-in that follows asked again. The adapter now asks once and passes it as EXPO_APPLE_ID, which both read (resolveCredentialsAsync takes the given username, then EXPO_APPLE_ID, and prompts only without either).
feat(cli): init apns --reset signs in to Apple again
This was referenced Oct 9, 2026
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1956 (
init fcm). Merge that first.Sets up Appwrite Push for iOS from the CLI: the APNs providers, the Xcode project, and, if wanted, the APNs key itself, created by signing in with an Apple ID.
init pushsets up iOS and Android together.What's in this PR
appwrite init apnsaps-environmententitlement and theremote-notificationbackground mode to its Xcode target.appwrite init pushinit apnsand/orinit fcm(#1956) for each.--create-key@expo/apple-utils) and fastlane (spaceship). Picked with--providerorAPPWRITE_APNS_SETUP, so if Apple's private API breaks one, users switch instead of waiting for a release.Merged into this branch
internal/apnsadapter contract and registry.--create-key,--provider/APPWRITE_APNS_SETUP,--environment,APPWRITE_APPLE_ID/APPWRITE_APPLE_PASSWORD. Saves the new.p8before the providers are written. Stops when--create-keyfails.@expo/apple-utils2.2.1 through an embedded Node helper, pinned by lockfile hash.brew install fastlanewhen fastlane is missing, and otherwise prints install or upgrade commands.Fixes from testing against a real Apple account and from review, committed directly here:
Open on top of this PR
init apns --resetsigns in to Apple again instead of reusing the saved sign-in.Flags
appwrite init apns--bundle-id <id>PRODUCT_BUNDLE_IDENTIFIERof the app target, or Expoios.bundleIdentifier. With several apps, a picker. With none, asked.--team-id <id>DEVELOPMENT_TEAMof the target, Expoios.appleTeamId, or the team on an existing provider. With--create-key, the Apple sign-in picks it (asks when the account has several). Otherwise asked.--key-path <file>.p8).p8in the project folder. Otherwise asked: sign in with the Apple ID and create one, create it in the browser, or use a file.--key-id <id>AuthKey_<KEYID>.p8file name, otherwise asked. Ignored for a created key, which uses Apple's ID.--create-key--provider <setup>appwrite,expoorfastlane$APPWRITE_APNS_SETUP, thenappwrite--environment <env>production,sandboxorallall: both providers, and a key for both environments--force(global)--create-keycan't be combined with--key-pathor--key-id.appwrite init push--skip-apns--skip-fcmEnvironment variables
APPWRITE_APNS_SETUP--create-key(appwrite,expo,fastlane)APPWRITE_APPLE_IDAPPWRITE_APPLE_PASSWORDUsage
Set up push for the whole project
iOS only, with a key already in the project folder
The bundle ID and team come from the Xcode project or
app.json. The key ID comes from theAuthKey_<KEYID>.p8file name, so nothing is asked.Create the key with the Apple ID (one click)
~/.appwrite/apns/<setup>/(0600), so later runs skip the password and two-factor until Apple expires it, about 30 days.--create-key, the same option is offered in the "How would you like to provide the APNs auth key?" prompt when no key is found.Skip the Apple ID and password prompts
Only the two-factor code is asked; a saved, trusted session skips that too.
Pick a setup
appwrite(default)expo@expo/apple-utils(npm ci --ignore-scripts, lockfile pinned). Node.js LTS v24.21.0 from nodejs.org whennodeis missing or older than 18 (about 50 MB, checksum verified, in~/.appwrite/apns/expo/runtime/).fastlanebrew install fastlane(asks first) when missing and Homebrew exists. Otherwise prints how to install it, or to use--provider appwrite. Usesbundle exec fastlanewhen the project's Gemfile (orios/Gemfile) pins fastlane.One environment only
Only that provider is set up, and a created key is limited to that environment. The expo setup always creates keys for both environments and says so.
CI or scripts (no terminal)
Pass --team-id instead.--create-keyneeds a terminal for the two-factor code. Without one, it explains that and points to--key-path.Rotate the key
When the team has no free key slot
Apple allows two team-scoped APNs keys per team.
Rerun
How it works
Detection:
PBXNativeTargets of product typeapplicationin.xcodeproj(tests, widgets and extensions are skipped), and Expoapp.json.$(PRODUCT_NAME:rfc1034identifier)), the team, the entitlements file andInfo.plist.Key sources, in order:
--key-path.p8in the project folder~/Downloads), or a fileA key that isn't git-ignored gets a warning.
Created keys:
.p8is saved asAuthKey_<ID>.p8(0600, never overwriting) before anything is sent to Appwrite, since Apple allows one download.--create-keystops instead of falling back to a key file.Setups (
internal/apns):apns.Adapter.apns.CreateKeychecks the request, then checks what the setup returns: a valid key ID, the requested team, and.p8contents.idmsa.apple.com(checked byte for byte against fastlane-sirp), hashcash, two-factor (device code or SMS), and2sv/trust.services-account/QH65B2/: team list, key list (which also hands out the CSRF tokens),v2/create(team scope, APNs for the chosen environment), download.helper.js, run with the terminal attached.EXPO_NO_KEYCHAIN=1keeps the password out of the Keychain.Fastfilelane, run in a temporary folder. The credentials go inFASTLANE_USER/FASTLANE_PASSWORD, so fastlane never offers to store them.Providers:
authKey,authKeyId,teamIdandbundleIdare written through the Appwrite API, likeserviceAccountJSONfor FCM.Xcode edits: only the chosen bundle's entitlements and
Info.plistare changed, keeping their indentation. For Expo, theapp.jsonsettings are printed as hints.Security
The flow was reviewed for credential exposure, file permissions, path traversal, command injection, the download and supply chain, cryptographic randomness, and cookie scope. No issues at high confidence.
--ignore-scriptsagainst a hashed lockfile.crypto/rand.Testing
.p8validation, the key folder lookup, and providers created, updated and "already set up".node,npm,fastlane,bundleandbrew. Node download with checksum and traversal checks, and Bundler detection.Q4GAU66T2N, saved it and set up both providers.-race, gofmt, and generation that is identical across runs.GOOS=windows go vet.apnstests under Go 1.26.5 (the CLI's minimum).Server dependency
Updating an APNs provider saved the bundle ID under
credentials.bundleinstead ofbundleId, so a changed bundle ID was ignored, and a provider created without one could never be enabled. Rotating keys or rerunninginit apnsneeds that server fix; creating providers works without it.Not in this PR