Features
Authentication Flow
Auth is the first wall every app hits, and rolling it yourself eats days: session persistence, deep-link callbacks, Apple's nonce dance, App Store account-deletion rules. ShipThatApp ships all of it wired up. One @Observable singleton — AuthManager — backs email/password, passwordless Magic Link, and Sign in with Apple on top of Supabase, and the whole flow is reactive: the moment currentSession changes, the UI swaps between the signed-out and signed-in experience.
Secrets stay out of the binary
Your Supabase URL and anon key are never hardcoded. AuthManager reads them from Info.plist, which is populated from a gitignored Config.xcconfig (SUPABASE_URL / SUPABASE_KEY). Nothing sensitive lands in source control. See Configurations for the full key list.
Architecture at a Glance
Authentication lives in three layers:
- Service —
Services/Auth/AuthManager.swift: the single source of truth for the session, talking directly to the Supabase client. - View model —
Views/SignIns/SignInViewModel.swift: form validation and a thin async bridge toAuthManager. - Views —
Views/SignIns/:SignInView(the entry screen, inSignIn.swift),SignInOptions(email + Magic Link),RegistrationView(new accounts), andAppleButton(Sign in with Apple).
Navigation is driven by LandingView + AppRouter. LandingView watches authManager.currentSession: nil shows the sign-in stack, non-nil shows the authenticated tab bar — no manual screen pushing required.
AuthManager
AuthManager is a @MainActor @Observable final class exposed as AuthManager.shared. It owns two observable properties the UI reacts to:
var currentSession: AppUser? // nil when signed out
var errorMessage: Error? // last auth error, surfaced inline
The Supabase client is built lazily from Info.plist — fail-fast if the keys are missing, so a misconfigured build never silently runs without a backend:
let client: SupabaseClient = {
guard let supabaseURLString = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String,
let supabaseURL = URL(string: supabaseURLString),
let supabaseKey = Bundle.main.infoDictionary?["SUPABASE_KEY"] as? String else {
fatalError("Supabase configuration not found in Info.plist")
}
return SupabaseClient(supabaseURL: supabaseURL, supabaseKey: supabaseKey)
}()
AppUser is the app's lightweight session model — it deliberately carries only what the UI needs:
struct AppUser {
let uid: String
let email: String?
}
The Three Sign-In Methods
Email & password
@discardableResult
func signInWithEmail(email: String, password: String) async throws -> AppUser
On success it sets currentSession and presents a confirmation Toast. On failure it logs the error, surfaces it through errorMessage and a toast, resets currentSession to nil, and rethrows — so callers can react to a failed sign-in instead of receiving a sentinel user. New accounts go through registerNewUserWithEmail(email:password:), which signs the user up (with the shipthatapp://login-callback redirect) and establishes the session in one call.
Passwordless Magic Link
func sendMagicLink(email: String) async throws
This calls Supabase's signInWithOTP with the app's custom redirect and confirms with a "Magic Link sent" toast. The user taps the link in their email, iOS routes the callback URL back into the app, and the session is finalised in getSessionFromUrl(url:) (see Magic Link Deep-Link Flow).
Sign in with Apple (with replay protection)
@discardableResult
func signInWithApple(idToken: String, nonce: String) async throws -> AppUser
The signature takes a nonce alongside the identity token — this is the part most boilerplates get wrong. AppleButton generates a random nonce, sends its SHA-256 hash to Apple, and passes the raw nonce to Supabase, which matches it against the hash embedded in the returned token. That binds the token to this exact request and blocks replay attacks:
SignInWithAppleButton { request in
let nonce = CryptoUtils.shared.randomNonceString()
currentNonce = nonce
request.requestedScopes = [.email, .fullName]
request.nonce = CryptoUtils.shared.sha256(nonce)
} onCompletion: { result in
// ... extract credential + idToken, then:
try await authManager.signInWithApple(idToken: idToken, nonce: nonce)
}
Apple only returns the user's name and email on the first authorization, so AppleButton immediately persists them via SettingsManager.updateUserProfile(...) before they're lost.
Session Lifecycle
AuthManager keeps currentSession honest across launches and auth events:
getCurrentSession() async throws -> AppUser— pulls the live Supabase session and refreshescurrentSession.LandingViewcalls this.onAppearso a returning user lands signed-in.updateCurrentSession() async— non-throwing wrapper that refreshes the session and toasts on failure; use it where you can't propagate an error.getSessionFromUrl(url:) async throws -> AppUser— completes a Magic Link sign-in from the callback URL.signOut() async throws— invalidates the Supabase session, clearscurrentSession, and toasts.
Because currentSession is observable, you never push or pop the login screen by hand. LandingView reacts to it:
if authManager.currentSession != nil {
authenticatedView // tab bar
} else {
unauthenticatedView // SignInView
}
Magic Link Deep-Link Flow
The Magic Link and email-registration redirects use the custom URL scheme shipthatapp://login-callback. LandingView listens for it and hands the callback to AuthManager:
.onOpenURL { incomingURL in
handleIncomingURL(incomingURL)
}
private func handleIncomingURL(_ url: URL) {
if url.scheme == "shipthatapp" && url.host == "login-callback" {
Task {
do {
_ = try await authManager.getSessionFromUrl(url: url)
} catch {
Logger.viewCycle.error("Error setting session: \(error)")
}
}
return
}
router.navigate(to: url) // non-auth deep links fall through to AppRouter
}
End to end:
sendMagicLink(orregisterNewUserWithEmail) tells Supabase to email a link pointing atshipthatapp://login-callback.- The user taps it; iOS matches the scheme registered under
CFBundleURLTypesinInfo.plistand launches the app. .onOpenURLfires,handleIncomingURLrecognises the host, andgetSessionFromUrl(url:)exchanges the URL for a session.currentSessionupdates, the success toast shows, andLandingViewswaps to the authenticated view.
Set your own scheme
The scheme shipthatapp appears in two places that must match: the redirectTo URLs in AuthManager and the CFBundleURLTypes entry in Info.plist. Also add the same redirect to your Supabase project's URL Configuration allow-list, or the link won't return to the app.
SignInViewModel: Validation Before the Network
SignInViewModel keeps cheap client-side checks out of AuthManager. It validates before any request is sent and publishes a human-readable errorText the views render inline:
func isFormValid(email: String, password: String) -> Bool {
errorText = nil
guard email.isValidEmail() else {
errorText = "Provide correct email address"
return false
}
guard password.count >= 7 else {
errorText = "Provide password that is at least 7 characters long"
return false
}
return true
}
signInWithEmail, registerNewUserWithEmail, and sendMagicLink all run validation first, then delegate to AuthManager.shared. email.isValidEmail() comes from the String extension in Utils/Extensions/.
Account Deletion (App Store 5.1.1(v))
Any app that lets users create an account must let them delete it in-app — a common rejection trap. ShipThatApp handles it correctly. Deleting an auth user requires Supabase's service-role key, which must never ship in a client, so deleteAccount() invokes a Supabase Edge Function that authenticates the caller by their JWT and deletes them with admin rights:
func deleteAccount() async throws {
try await client.functions.invoke(functionName: "delete-account")
try? await client.auth.signOut()
SettingsManager.shared.clearUserProfile()
KeychainSwift().delete(Config.Keychain.tokenKey)
currentSession = nil
// ... success toast
}
On success it signs out, wipes the cached profile, and clears the Keychain token. The matching delete-account function lives in supabase/functions/ in the backend repo.
How to Use It
Call AuthManager.shared from a view model or read it from the environment — LandingView injects it. A custom sign-in button needs nothing more than:
Button("Sign in") {
Task {
try? await AuthManager.shared.signInWithEmail(
email: email,
password: password
)
}
}
Once the call succeeds, currentSession flips and the app navigates itself. To gate a screen on auth, branch on authManager.currentSession exactly like LandingView does.
Customize / Extend It
- Add an OAuth provider (Google, GitHub…). Enable it in the Supabase dashboard, then add a method to
AuthManagermirroringsignInWithApple— callclient.auth.signInWithIdToken(orclient.auth.signInWithOAuth) and setcurrentSessionfrom the returnedsession.user. - Tune password rules. Edit
isFormValidinSignInViewModel— it's the single gate every email path runs through. - Rebrand the screens.
SignInView,SignInOptions, andRegistrationVieware plain SwiftUI; swap the"Logo"asset, copy, and accent colour. The view model andAuthManagerstay untouched. - Store more profile data.
AppleButtonalready captures name/email on first sign-in viaSettingsManager; extendupdateUserProfileto persist additional fields.
Related
- Configurations — where
SUPABASE_URL,SUPABASE_KEY, and the URL scheme live. - AuthManager — the manager-level reference.
- SignInViewModel — form validation details.
- In-App Purchases — gate Pro features behind both auth and entitlements. </content> </invoke>