English | 日本語
A modular authentication package that wires Firebase / Apple / Google / REST concretions into a vendor-agnostic core — one target at a time.
Authentication is split into three responsibilities, each defined as a vendor-agnostic protocol. Concrete implementations live in separate targets and are composed at the app's composition root.
| Responsibility | Protocol (core) | Concrete (separate target) |
|---|---|---|
| Credential acquisition | CredentialProvider |
AppleCredentialProvider / GoogleCredentialProvider |
| Session exchange | Authenticator |
FirebaseAuthenticator |
| Post-auth action (idempotent) | PostAuthenticationAction |
APIUserProvisioning (REST) |
AuthenticationStore (@Observable, @MainActor) wires these together and exposes a state property that views observe.
| Target | Role | External dependencies |
|---|---|---|
Authentication |
Core abstractions (protocols, value types, AuthenticationStore) |
None |
AuthenticationUI |
SwiftUI views + Environment DI | SwiftUI (system) |
AuthenticationApple |
Apple credential acquisition | AuthenticationServices / CryptoKit (system) |
AuthenticationGoogle |
Google credential acquisition | GoogleSignIn |
AuthenticationFirebase |
Firebase session exchange and configuration | FirebaseAuth |
AuthenticationAPI |
REST post-authentication action | swift-api-client |
The core (Authentication) and UI (AuthenticationUI) targets have zero vendor SDK dependencies.
Views can depend on only these two, keeping SwiftUI Previews free of Firebase / GoogleSignIn.
Concrete implementations (Firebase, etc.) are imported only at the composition root.
import SwiftUI
import Authentication
import AuthenticationUI
import AuthenticationFirebase
import AuthenticationApple
import AuthenticationGoogle
import AuthenticationAPI
import APIClient
@main
struct MyApp: App {
@State private var store: AuthenticationStore
init() {
FirebaseConfigurator.configure() // Production. Use .configure(environment: .emulator()) for local dev.
let apiClient = APIClientImpl(
baseURL: URL(string: "https://api.example.com")!,
authTokenProvider: APITokenProviderAdapter(FirebaseTokenProvider())
)
_store = State(initialValue: AuthenticationStore(
authenticator: FirebaseAuthenticator(
accountDeletion: APIAccountDeletion(apiClient: apiClient, path: "/v1/me")
),
postAuthentication: APIUserProvisioning(apiClient: apiClient),
credentialProviders: [
AppleCredentialProvider(),
GoogleCredentialProvider(clientID: FirebaseConfigurator.googleClientID ?? "")
]
))
}
var body: some Scene {
WindowGroup {
AuthenticatedRootView(
loading: { ProgressView() },
unauthenticated: {
VStack(spacing: 16) {
GoogleSignInButton()
AppleSignInButton()
}
.padding(.horizontal, 32)
},
error: { error in Text(error.localizedDescription) },
authenticated: { user in MainView(userID: user.id) }
)
.authenticationStore(store)
}
}
}import SwiftUI
import Authentication
import AuthenticationUI
struct MainView: View {
@Environment(\.authenticationStore) private var store
let userID: String
var body: some View {
VStack {
Text("Welcome, \(userID)")
Button("Sign Out") { Task { try? await store?.signOut() } }
}
}
}
#Preview {
// No Firebase needed — uses a stub.
MainView(userID: "preview")
.authenticationStore(.previewUnauthenticated)
}AuthenticationState transitions:
.checking— verifying stored session on launch.unauthenticated— no active session.authenticatedPendingProvisioning— session exchanged, post-auth action pending.authenticated(AuthUser)— fully authenticated.error(any Error)— an error occurred
Only .authenticated means the account is ready: while provisioning is pending the session exists but the app should still show a loading state.
APIUserProvisioning calls POST <path> (default /auth/initialize) once the session exists, with the Firebase ID token attached as Authorization: Bearer. The store calls it once per authentication session, but retries and reinstalls mean the endpoint has to be idempotent on the server too. Omit postAuthentication when there is nothing to provision.
signIn replaces the session, so an anonymous user who signs in with Apple gets a new user id and leaves the anonymous account's data behind. link attaches the credential to the account in hand and keeps the id:
do {
try await store.link(using: .apple)
} catch AuthError.credentialAlreadyInUse(let collision) {
// The person already has an account with this provider (typically from another device).
// Switching to it changes the user id; ask before doing so.
try await store.signIn(resolving: collision)
}Never exchange the credential that collided a second time: the nonce inside an Apple identity token is spent by the link, and Firebase answers "Duplicate credential received". signIn(resolving:) uses the replacement credential Firebase returned with the error and, when there is none, runs the provider's sheet again for a fresh one. Code that talks to Firebase directly can convert this package's credentials with FirebaseCredentialMapper.makeCredential(from:) rather than keeping its own copy.
Deleting the Firebase account from the device is refused when the last sign-in is too old (requiresRecentLogin), and when the app first deletes its server data that refusal arrives after the data is gone. Give FirebaseAuthenticator an AccountDeletion — APIAccountDeletion sends DELETE <path> through swift-api-client — and deleteAccount() calls the server, then clears the local session only once the server succeeded. Without one, the account is deleted from the device as before. Either way, deleteAccount() now ends the local session itself.
AuthTokenProviding.token(forceRefresh:) fetches a new token even when the cached one has not expired — what to do after a 401. A transport that takes a token closure gets one from tokenSource:
let token: @Sendable (_ forceRefresh: Bool) async throws -> String = FirebaseTokenProvider().tokenSourceApp Review guideline 5.1.1(v) requires revoking the user's Apple tokens on account deletion. The credential from AppleCredentialProvider carries Apple's authorizationCode; it is single use and expires in about five minutes, so authorize again right before deleting, revoke, then delete:
let fresh = try await AppleCredentialProvider().acquireCredential()
if let code = fresh.authorizationCode {
try await firebaseAuthenticator.revokeAppleToken(authorizationCode: code)
}
try await firebaseAuthenticator.deleteAccount()AuthProviderID is open-ended. Implement CredentialProvider / Authenticator / PostAuthenticationAction to plug in any backend — direct Firestore access, custom OIDC, anything else.
API reference for every module: no-problem-dev.github.io/swift-authentication.
dependencies: [
.package(url: "https://github.com/no-problem-dev/swift-authentication.git", from: "10.0.0")
]Add only the products your target needs:
- Screen modules →
AuthenticationUI(+Authentication) - Composition root (App target) →
AuthenticationFirebase/AuthenticationApple/AuthenticationGoogle/AuthenticationAPI
| Swift | Platforms |
|---|---|
| 6.2 | iOS 17+ · macOS 14+ |
MIT License. See LICENSE for details.