Authentication
Email/password, OTP verification, social sign-in, and session management.
Authentication is Integrated end to end on top of supabase-swift's Auth
module. It covers email/password, email OTP, password reset, email/password
changes, session management, account deletion, and native Apple + Google sign-in.
Session management
SessionController is the app's source of truth for "who is signed in". It's a
@MainActor @Observable object that subscribes to the auth state stream and
republishes the current user:
AuthRepository.sessionStreamwraps supabase-swift'sauthStateChangesas anAsyncStream<Session?>.SessionControllerconsumes it, settingcurrentUserand flippingisLoadingtofalseafter the first event.- The
Authgate inRootViewreadscurrentUserto decide between the auth stack and the main shell.
The session lives in the Keychain, not UserDefaults. supabase-swift's
default KeychainLocalStorage owns token persistence. Never mirror auth tokens
into AppPreferences / UserDefaults — the Keychain copy is authoritative and
survives reinstalls per the system's Keychain policy.
Email & password flows
Every flow is a method on AuthRepositoryProtocol, returning AppResult<Void>
(see Data Layer). The underlying SupabaseAuthService
maps each to a supabase-swift auth call:
| Flow | supabase-swift call | Notes |
|---|---|---|
| Sign in | auth.signIn(email:password:) | — |
| Sign up | auth.signUp(...) + data: ["name": ...] | Name is stored in user metadata |
| Verify sign-up | auth.verifyOTP(type: .signup) | 6-digit email OTP |
| Resend code | auth.resend(type: .signup) | — |
| Forgot password | auth.resetPasswordForEmail(...) | Sends a recovery code |
| Complete reset | verifyOTP(type: .recovery) → update(password:) | Two-step |
| Change password | auth.update(user: .init(password:)) | While signed in |
| Change email | auth.update(user: .init(email:)) | Triggers confirm OTP |
| Confirm email change | verifyOTP(type: .emailChange) | — |
| Sign out | auth.signOut() | This device |
| Sign out others | auth.signOut(scope: .others) | Keeps current device |
There's also a passwordless path — sendLoginOtp / verifyLoginOtp
(signInWithOTP(shouldCreateUser: false) → verifyOTP(type: .magiclink)).
Retry policy
The repository codifies retry per method — this is a deliberate correctness choice, not a blanket wrapper:
- Idempotent calls retry up to 2× (300ms apart):
resendEmailOtp,resetPassword,changeEmail,signOutOtherSessions,deleteAccount. - Single-use / credential submissions never retry:
signIn,signUp, everyverify*Otp,completePasswordReset,changePassword, and the social flows. Retrying would burn a single-use OTP or trip captcha / rate-limit thresholds.
Input validation
Features/Auth/Validation/AuthValidation.swift holds pure-Swift, dependency-free
validators (email, password, otp, required) returning a typed
AuthValidationError. Rules: valid email regex; password ≥ 8 chars with at least
one letter and one number; OTP exactly 6 digits. Being pure functions, they're
unit-tested without a network or a view.
Social sign-in
The template README's "Out of scope" line is stale — both Apple and Google
sign-in are fully wired in Features/Auth/Social/. Verified against code.
Both providers obtain a native ID token and hand it to Supabase via
auth.signInWithIdToken(...) — no web redirect.
Apple
AppleSignInButton drives ASAuthorizationController directly (so it can render
as an icon-only tile), generating a random nonce per request and sending its
SHA-256 hash to Apple while forwarding the raw nonce to Supabase
(OpenIDConnectCredentials(provider: .apple, idToken:, nonce:)). On first
sign-in the user's name is captured into user metadata. No xcconfig key is
needed — add the Sign in with Apple capability in Xcode and enable the Apple
provider in Supabase. Apple is rendered first (leading) to satisfy App Store
review's prominence rule.
GoogleSignInButton uses the GoogleSignIn SDK
(GIDSignIn.signIn(withPresenting:)) to get a Google ID token, then calls
OpenIDConnectCredentials(provider: .google, idToken:). It needs
GOOGLE_IOS_CLIENT_ID — without it the tile renders dimmed and inert so the
UI still composes during local development.
Setup (from the template's docs/google-sign-in-configuration.md):
Create two Google Cloud OAuth clients
A Web client (used by Supabase) and an iOS client whose Bundle ID matches
com.soarstarter.SoarStarterSwift. Add
https://<ref>.supabase.co/auth/v1/callback to the Web client's authorized
redirect URIs.
Configure the Supabase Google provider
Under Authentication → Providers → Google: set Client IDs to a
comma-separated <web-client-id>,<ios-client-id> (Web first, iOS included so the
token audience is accepted), the Web client secret, and turn Skip nonce
checks ON (the native flow passes no nonce).
Set the keys in Secrets.xcconfig
GOOGLE_IOS_CLIENT_ID and GOOGLE_REVERSED_CLIENT_ID (same ID, segments
reversed). The project injects them into Info.plist as GIDClientID and a
CFBundleURLTypes URL scheme for the OAuth callback. Rebuild after changing.
Common Google failures (audience mismatch, nonce error, no return to app) are in Troubleshooting.
Delete account
Me → Account & Security → Delete account calls the delete-account Edge
Function with the user's JWT — clients can't delete their own auth.users row,
only the service role can. The function, in order:
- Identifies the caller from the JWT (POST only, no body).
- Deletes the user's
todos,device_tokens, andprofilesrows (missing tables are skipped;subscriptions/paymentscascade fromauth.users). - Removes the user's files under
sample-uploads/<user-id>/. - Calls
auth.admin.deleteUser, revoking every session on every device.
Rows and files are deleted before the auth user so a mid-flight failure leaves the account intact and the call retryable. Apple requires in-app account deletion for App Store approval — see App Store Release.
