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:

  • ServiceServices/Auth/AuthManager.swift: the single source of truth for the session, talking directly to the Supabase client.
  • View modelViews/SignIns/SignInViewModel.swift: form validation and a thin async bridge to AuthManager.
  • ViewsViews/SignIns/: SignInView (the entry screen, in SignIn.swift), SignInOptions (email + Magic Link), RegistrationView (new accounts), and AppleButton (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.

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 refreshes currentSession. LandingView calls this .onAppear so 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, clears currentSession, 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
}

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:

  1. sendMagicLink (or registerNewUserWithEmail) tells Supabase to email a link pointing at shipthatapp://login-callback.
  2. The user taps it; iOS matches the scheme registered under CFBundleURLTypes in Info.plist and launches the app.
  3. .onOpenURL fires, handleIncomingURL recognises the host, and getSessionFromUrl(url:) exchanges the URL for a session.
  4. currentSession updates, the success toast shows, and LandingView swaps 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 AuthManager mirroring signInWithApple — call client.auth.signInWithIdToken (or client.auth.signInWithOAuth) and set currentSession from the returned session.user.
  • Tune password rules. Edit isFormValid in SignInViewModel — it's the single gate every email path runs through.
  • Rebrand the screens. SignInView, SignInOptions, and RegistrationView are plain SwiftUI; swap the "Logo" asset, copy, and accent colour. The view model and AuthManager stay untouched.
  • Store more profile data. AppleButton already captures name/email on first sign-in via SettingsManager; extend updateUserProfile to persist additional fields.
Previous
Splash Screen