forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthService.swift
More file actions
2869 lines (2586 loc) · 109 KB
/
Copy pathAuthService.swift
File metadata and controls
2869 lines (2586 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import AppKit
import AuthenticationServices
import CryptoKit
@preconcurrency import FirebaseAuth
import FirebaseCore
import Foundation
import OmiSupport
import Sentry
extension Notification.Name {
/// Posted by AuthService.signOut() so views can reset @AppStorage-backed properties directly.
static let userDidSignOut = Notification.Name("com.omi.desktop.userDidSignOut")
/// Posted whenever the signed-in user's name becomes known (Apple first-auth
/// capture, or a later backend/Firebase fetch). `givenName`/`familyName` are
/// plain UserDefaults, so onboarding can't observe them directly — it listens
/// for this and re-reads `AuthService.shared.givenName`.
static let authNameDidUpdate = Notification.Name("com.omi.desktop.authNameDidUpdate")
}
@MainActor
class AuthService {
static let shared = AuthService()
// Use AuthState for UI updates - it's a pure Swift ObservableObject
// that doesn't reference Firebase types at the class level
private var authState: AuthState { AuthState.shared }
var isSignedIn: Bool {
get { authState.isSignedIn }
set { authState.transition(to: newValue ? .authenticated : .signedOut) }
}
var isLoading: Bool {
get { authState.isLoading }
set { authState.isLoading = newValue }
}
var error: String? {
get { authState.error }
set { authState.error = newValue }
}
private var authStateHandle: AuthStateDidChangeListenerHandle?
private var isConfigured: Bool = false
// OAuth state for CSRF protection
private var pendingOAuthState: String?
private var pendingOAuthFlow: OAuthFlowContext?
private var loopbackCallbackServer: OAuthLoopbackCallbackServer?
private var oauthContinuation: CheckedContinuation<(code: String, state: String), Error>?
private var oauthTimeoutTask: Task<Void, Never>?
// Native Apple Sign In
private var currentNonce: String?
private var appleSignInDelegate: AppleSignInDelegate?
// API Configuration
// Auth uses the production Python backend by default because web OAuth
// provider callbacks are host allowlisted. Override with OMI_AUTH_API_URL
// for local auth backend testing.
private var apiBaseURL: String {
DesktopBackendEnvironment.authBaseURL()
}
private var redirectURI: String {
return "\(urlScheme)://auth/callback"
}
private var currentBundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "unknown.bundle"
}
private var urlScheme: String {
if let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]],
let firstType = urlTypes.first,
let schemes = firstType["CFBundleURLSchemes"] as? [String],
let scheme = schemes.first
{
return scheme
}
return "omi-computer"
}
private struct OAuthFlowContext {
let id: String
let provider: String
let state: String
let startedAt: Date
let callbackTransport: String
}
// UserDefaults keys for auth persistence (dev builds with ad-hoc signing).
// Keys are defined once in `DefaultsKey` and read/written through the typed
// `UserDefaults` accessors so a typo is a compile error, not a silent nil.
//
// Secret-store service is team+bundle scoped so local Dev / named-bundle builds
// cannot poison each other or notarized Beta/Prod (keychain on shipped bundles,
// file store on developer bundles). See DesktopKeychainStore.scopedService.
private let authTokenKeychainAccount = "firebase-rest-tokens"
private var authTokenKeychainService: String {
DesktopKeychainStore.scopedService(DesktopKeychainStore.legacyAuthTokenService)
}
private struct StoredAuthTokens: Codable, Equatable {
let idToken: String
let refreshToken: String
let expiryTime: TimeInterval
let tokenUserId: String
}
private var cachedStoredTokens: StoredAuthTokens?
private var cachedStoredTokensLoaded = false
private func invalidateStoredTokensCache() {
cachedStoredTokens = nil
cachedStoredTokensLoaded = false
}
struct TokenStorageHooks {
var usesKeychainTokenStorage: () -> Bool
var allowsUserDefaultsFallback: () -> Bool
var readKeychainString: (_ service: String, _ account: String) -> String?
var writeKeychainString: (_ value: String, _ service: String, _ account: String) -> Bool
var deleteKeychainString: (_ service: String, _ account: String) -> Void
var recordsFallbackTelemetry: Bool
// Security invariant: new auth tokens live in DesktopKeychainStore on every
// build (login keychain on shipped bundles, file store otherwise). Plaintext
// UserDefaults fallback is disabled for new sign-ins. The read path remains
// only for transactional migration of older installs: keep that existing
// copy until secret-store read-back plus a forced refresh commit the new store.
nonisolated(unsafe) static let live = TokenStorageHooks(
usesKeychainTokenStorage: { true },
allowsUserDefaultsFallback: { false },
readKeychainString: { service, account in
// Only the team+bundle scoped service. Never query the unscoped
// legacy `com.omi.desktop.firebase-rest-session` item — a foreign
// ACL on that name is what triggers the login-keychain password
// dialog. Pre-scoping installs recover via UserDefaults migration.
DesktopKeychainStore.string(service: service, account: account)
},
writeKeychainString: { value, service, account in
DesktopKeychainStore.setString(value, service: service, account: account)
},
deleteKeychainString: { service, account in
// Only delete the scoped item. Touching the legacy unscoped name can
// itself prompt when the ACL belongs to another signing team.
DesktopKeychainStore.delete(service: service, account: account)
},
recordsFallbackTelemetry: true
)
}
var tokenStorageHooks = TokenStorageHooks.live
struct TokenRefreshHooks {
var dataForRequest: ((URLRequest) async throws -> (Data, URLResponse))?
nonisolated(unsafe) static let live = TokenRefreshHooks(dataForRequest: nil)
}
var tokenRefreshHooks = TokenRefreshHooks.live
// Firebase availability is injected for hermetic auth tests and resolves to
// nil when the default Firebase app was not configured at launch. Keeping
// this state separate from session ownership lets the REST-token flow remain
// authoritative while every direct SDK call stays safe.
// It deliberately does not alter AuthSessionAttempt fencing, token storage,
// or RuntimeOwnerIdentity; those remain the session owner's responsibility.
// The availability seam is intentionally narrow.
var firebaseAuthAvailability = FirebaseAuthAvailability.live
/// Returns the SDK only when its default Firebase app exists. Callers must
/// treat `nil` as an expected runtime mode, never as a reason to read the
/// Firebase SDK directly.
private func configuredFirebaseAuth() -> Auth? {
firebaseAuthAvailability.auth()
}
private var firebaseApiKey: String {
let environmentKey = getenv("FIREBASE_API_KEY").map { String(validatingCString: $0) } ?? nil
let key = AppBuild.firebaseAPIKey(
bundleIdentifier: AppBuild.bundleIdentifier,
environmentKey: environmentKey,
bundledKey: FirebaseApp.app()?.options.apiKey
)
if !key.isEmpty { return key }
log("AuthService: FIREBASE_API_KEY not set — auth operations will fail")
return ""
}
/// Resolve the Firebase Web API key or fail loudly (BL-019).
///
/// The key is provisioned asynchronously (APIKeyService fetches it from the
/// backend and `setenv`s it), so it can legitimately be absent right after a
/// cold launch — failing at launch would false-positive. Instead, fail at the
/// point of use: every identitytoolkit/securetoken request must resolve the key
/// through this helper so a missing/empty key surfaces as a clear, user-visible
/// `AuthError` instead of being interpolated into `?key=` and returning an
/// opaque HTTP 400 ("API key not valid") that looks like a generic auth failure.
private func requireFirebaseApiKey() throws -> String {
let key = firebaseApiKey
guard !key.isEmpty else {
log("AuthService: refusing to build an auth request without FIREBASE_API_KEY")
throw AuthError.missingFirebaseApiKey
}
return key
}
// MARK: - User Name Properties
/// Notify observers (onboarding) that the user's name is now known. Posted on
/// the main queue since it drives UI. Observers re-read `givenName` themselves.
func postNameDidUpdate() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .authNameDidUpdate, object: nil)
}
}
/// Get the user's given name (first name)
var givenName: String {
get { UserDefaults.standard.string(forKey: .authGivenName) ?? "" }
set { UserDefaults.standard.set(newValue, forKey: .authGivenName) }
}
/// Get the user's family name (last name)
var familyName: String {
get { UserDefaults.standard.string(forKey: .authFamilyName) ?? "" }
set { UserDefaults.standard.set(newValue, forKey: .authFamilyName) }
}
/// Get the user's full display name
var displayName: String {
let given = givenName
let family = familyName
if !given.isEmpty && !family.isEmpty {
return "\(given) \(family)"
} else if !given.isEmpty {
return given
} else if !family.isEmpty {
return family
}
return ""
}
private let sessionCoordinator = AuthSessionCoordinator.shared
let sessionAttemptFence = AuthSessionAttemptFence()
init() {
// Initialize without super
}
/// Start a new authoritative auth operation. Async completions from every
/// older restore/sign-in/refresh/invalidation/sign-out attempt become
/// incapable of mutating credentials or owner state immediately.
@discardableResult
func beginSessionAttempt() -> AuthSessionAttempt {
sessionAttemptFence.begin()
}
func currentSessionAttempt() -> AuthSessionAttempt {
sessionAttemptFence.current()
}
func isSessionAttemptCurrent(_ attempt: AuthSessionAttempt) -> Bool {
sessionAttemptFence.isCurrent(attempt)
}
private func discardStaleFirebaseUserIfNeeded(_ userID: String) {
guard let auth = configuredFirebaseAuth(),
auth.currentUser?.uid == userID
else {
return
}
try? auth.signOut()
}
// MARK: - Session invalidation (light — not nuclear signOut)
/// Clears tokens and signed-in UI state without onboarding wipe, capture stop,
/// or storage cache teardown. Use for expired/revoked credentials.
func invalidateSession(reason: AuthSessionCoordinator.InvalidateReason) async {
await sessionCoordinator.invalidateSession(reason: reason, auth: self)
}
/// Internal hook for `AuthSessionCoordinator` — not a user-facing sign-out.
func performLightSessionInvalidation() async -> Bool {
let attempt = beginSessionAttempt()
// Also clear the Firebase SDK session so that restoreAuthState() and the
// auth-state listener do not re-create the ghost session on the next
// launch. Unlike signOut(), this does NOT tear down storage caches or
// stop background services — it only clears the Firebase SDK user.
// The REST-backed session remains authoritative if the Firebase SDK was
// unavailable at launch. Only clear an SDK session when one exists.
do {
return try await commitSignedOutSession(
attempt: attempt,
phase: .needsReauth,
beforeClearingCredentials: { [self] in
if let auth = configuredFirebaseAuth() {
try auth.signOut()
}
})
} catch {
logError("AUTH: Session invalidation could not release owner-bound storage", error: error)
return false
}
}
// MARK: - Configuration (call after FirebaseApp.configure())
func configure() async {
guard !isConfigured else { return }
isConfigured = true
if UserDefaults.standard.string(forKey: .acceptedAccountDeletionOwnerId) != nil {
do {
try await signOut(acceptedAccountDeletion: true)
} catch {
logError("AUTH: Accepted account-deletion cleanup could not complete", error: error)
AuthState.shared.transition(to: .recoveryRequired)
return
}
}
let attempt = beginSessionAttempt()
// Arm the phase watchdog BEFORE the restore awaits anything: the restore's
// own awaits (owner transitions, the token refresh) must not be able to
// delay the restoring phase's bounded resolution by delaying the arming.
armRestoringPhaseWatchdog(attempt: attempt)
await restoreAuthState(attempt: attempt)
// The listener enriches a configured SDK session, but a REST-backed
// session can still restore and validate without it. Do not make listener
// setup a prerequisite for the auth state machine.
if configuredFirebaseAuth() != nil {
setupAuthStateListener()
} else {
log("AuthService: Firebase SDK unavailable; continuing with REST-backed auth")
}
}
/// The one guaranteed escape from the restoring phase.
///
/// Every fenced exit in the restore flow is silent (`validateRestoredSessionNow`,
/// `refreshIdToken`, and the `saveAuthState`/`commitRestoredSession` commits all
/// return without a transition when the attempt is no longer current), and any
/// newer session attempt — including the restore flow's own invalidation
/// branches — defuses an attempt-gated watchdog. Gating on the attempt therefore
/// made the watchdog defusable by exactly the interleaving it exists for: three
/// dev launches hung in `.restoring` for their whole session with the launch
/// attempt superseded and no further auth log after the listener's skip line.
/// The phase alone decides now: while the app still reports restoring, the
/// watchdog resolves it to the recoverable state (the same landing the old
/// watchdog produced whenever it was not defused). A user-driven sign-in that
/// is still running defers the resolution via `isLoading`, and a superseded
/// launch attempt is named in the log so the next occurrence names its race.
func armRestoringPhaseWatchdog(attempt: AuthSessionAttempt, timeout: TimeInterval = 5.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self else { return }
guard AuthState.shared.isRestoringAuth, !AuthState.shared.isLoading else { return }
let superseded = !self.isSessionAttemptCurrent(attempt)
log(
"AUTH_WATCHDOG: restoring phase timed out after \(String(format: "%.1f", timeout))s"
+ (superseded
? " — launch attempt superseded; a newer auth flow never resolved the phase"
: " — restore still in flight"))
AuthState.shared.transition(to: .recoveryRequired)
}
}
func bootstrapLocalHarnessAuthIfNeeded() async {
let attempt = beginSessionAttempt()
guard let email = DesktopLocalProfile.selectedEmail,
let password = DesktopLocalProfile.selectedPassword,
let selectedUser = DesktopLocalProfile.selectedUser
else {
log("OMI AUTH LOCAL: missing selected local auth user env; staying signed out")
return
}
if let savedEmail = UserDefaults.standard.string(forKey: .authUserEmail),
!savedEmail.isEmpty, savedEmail != email
{
guard await clearPersistedAuthState(attempt: attempt) else { return }
_ = sessionAttemptFence.commitIfCurrent(attempt) {
clearTokens()
}
log("OMI AUTH LOCAL: cleared stale persisted auth for email=\(savedEmail)")
}
do {
let tokens = try await signInWithPasswordViaAuthEmulator(email: email, password: password)
guard
try await commitSignedInSession(
tokens: tokens,
email: email,
attempt: attempt)
else {
return
}
if let display = DesktopLocalProfile.selectedDisplayName, !display.isEmpty {
let pieces = display.split(separator: " ", maxSplits: 1).map(String.init)
givenName = pieces.first ?? ""
familyName = pieces.count > 1 ? pieces[1] : ""
}
log("OMI AUTH LOCAL: signed in via emulator REST as \(email) uid=\(tokens.localId) user=\(selectedUser)")
} catch {
logError("OMI AUTH LOCAL: sign-in failed for \(email)", error: error)
self.error = "Local Auth emulator sign-in failed for \(email): \(error.localizedDescription)"
AuthState.shared.transition(to: .recoveryRequired)
}
}
private func signInWithPasswordViaAuthEmulator(email: String, password: String) async throws -> FirebaseTokenResult {
guard let hostPort = DesktopLocalProfile.authEmulatorHost else {
throw AuthError.invalidURL
}
let apiKey = try requireFirebaseApiKey()
guard
let url = URL(
string: "http://\(hostPort)/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=\(apiKey)"
)
else {
throw AuthError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10
request.httpBody = try JSONSerialization.data(withJSONObject: [
"email": email,
"password": password,
"returnSecureToken": true,
])
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
let errorBody = String(data: data, encoding: .utf8) ?? "unknown"
log("OMI AUTH LOCAL: emulator REST error \(httpResponse.statusCode): \(errorBody)")
throw AuthError.tokenExchangeFailed(httpResponse.statusCode)
}
return try Self.decodeFirebaseTokenResult(from: data, requireLocalId: true)
}
private func selectedLocalUserId(from idToken: String) -> String? {
Self.localUserId(fromIDToken: idToken)
}
/// Commit credentials and their owner as one generation-owned auth result.
/// Token persistence is synchronous and generation-locked; the awaited owner
/// transition re-checks the same attempt before changing defaults. A newer
/// operation therefore wins even if this task resumes later.
@discardableResult
func commitSignedInSession(
tokens: FirebaseTokenResult,
email: String?,
attempt: AuthSessionAttempt
) async throws -> Bool {
let attemptFence = sessionAttemptFence
// Credentials and their durable owner are one publication boundary.
// Writing B's token before revoking A left a window where an admitted A
// token read observed `tokenUserId == B` with `authUserId == A` and
// correctly treated the new credential as stale by deleting it. Reserve
// the owner fence first, quiesce A, then commit both stores synchronously
// on MainActor while the attempt-generation lock excludes superseding
// auth work. No AuthService token reader can interleave with a mixed
// A/B credential generation.
let committed = try await RuntimeOwnerIdentity.performEffectiveOwnerTransition(
plannedNextOwner: { _, previousOwner in
attemptFence.isCurrent(attempt) ? tokens.localId : previousOwner
},
{ _ in
try await MainActor.run {
try attemptFence.commitIfCurrent(attempt) {
try self.saveTokens(
idToken: tokens.idToken,
refreshToken: tokens.refreshToken,
expiresIn: tokens.expiresIn,
userId: tokens.localId)
let defaults = UserDefaults.standard
defaults.set(true, forKey: .authIsSignedIn)
defaults.set(email, forKey: .authUserEmail)
defaults.set(tokens.localId, forKey: .authUserId)
defaults.synchronize()
return true
} ?? false
}
})
guard committed else { return false }
guard sessionAttemptFence.isCurrent(attempt) else { return false }
NSLog("OMI AUTH: Atomically saved signed-in session for user %@", tokens.localId)
AuthState.shared.userEmail = email
sessionCoordinator.resetAfterSuccessfulSignIn()
return true
}
/// Publish a restored session only if the restore attempt remains current.
@discardableResult
func commitRestoredSession(
userId: String?,
email: String?,
attempt: AuthSessionAttempt
) async -> Bool {
guard
await saveAuthState(
isSignedIn: true,
email: email,
userId: userId,
attempt: attempt)
else {
return false
}
guard sessionAttemptFence.isCurrent(attempt) else { return false }
AuthState.shared.userEmail = email
sessionCoordinator.resetAfterSuccessfulSignIn()
return true
}
private func restoreAuthState(attempt: AuthSessionAttempt) async {
// Check if we have a saved auth state
let savedSignedIn = UserDefaults.standard.bool(forKey: .authIsSignedIn)
let savedEmail = UserDefaults.standard.string(forKey: .authUserEmail)
NSLog(
"OMI AUTH: Checking saved auth state - savedSignedIn: %@, savedEmail: %@",
savedSignedIn ? "true" : "false", savedEmail ?? "nil")
// Set auth state synchronously (we're already on main thread from configure()).
// Using DispatchQueue.main.async here would defer to the next run-loop tick,
// creating a race window where the Firebase auth state listener can fire first
// with user=nil and flip isSignedIn to false before we restore it.
if savedSignedIn {
AuthState.shared.transition(to: .restoring)
AuthState.shared.userEmail = configuredFirebaseAuth()?.currentUser?.email ?? savedEmail
// Migration: Fix empty userId by extracting from stored idToken.
let savedUserId = UserDefaults.standard.string(forKey: .authUserId) ?? ""
if savedUserId.isEmpty, let storedToken = storedIdToken {
if let payload = decodeJWT(storedToken),
let userId = payload["user_id"] as? String ?? payload["sub"] as? String
{
NSLog("OMI AUTH: Migrating empty userId - extracted from JWT: %@", userId)
guard await persistAuthenticatedOwner(userId, attempt: attempt) else { return }
}
}
// A persisted boolean and Firebase's cached currentUser are restore hints,
// never proof of a usable session. Keep every authenticated surface gated
// until a forced refresh succeeds.
validateRestoredSession(attempt: attempt)
} else {
NSLog("OMI AUTH: No saved auth state found")
guard
await saveAuthState(
isSignedIn: false,
email: nil,
userId: nil,
attempt: attempt)
else {
return
}
AuthState.shared.transition(to: .signedOut)
}
}
private func validateRestoredSession(attempt: AuthSessionAttempt) {
Task { [weak self] in
guard let self else { return }
await self.validateRestoredSessionNow(attempt: attempt)
}
}
func retryRestoredSession() async {
let attempt = beginSessionAttempt()
guard UserDefaults.standard.bool(forKey: .authIsSignedIn) else {
AuthState.shared.transition(to: .signedOut)
return
}
AuthState.shared.transition(to: .restoring)
await validateRestoredSessionNow(attempt: attempt)
}
private func validateRestoredSessionNow(attempt: AuthSessionAttempt) async {
guard sessionAttemptFence.isCurrent(attempt) else { return }
let hasFirebaseUser = !DesktopLocalProfile.isEnabled && configuredFirebaseAuth()?.currentUser != nil
guard storedRefreshToken != nil || storedIdToken != nil || hasFirebaseUser else {
NSLog("OMI AUTH: Restored session has no credentials — invalidating")
await invalidateSession(reason: .restoredSessionInvalid)
return
}
do {
_ = try await sessionCoordinator.refreshSingleFlight(auth: self)
guard sessionAttemptFence.isCurrent(attempt) else { return }
NSLog("OMI AUTH: Restored session validated via forced refresh")
let userId = storedTokenUserId ?? configuredFirebaseAuth()?.currentUser?.uid
guard
await commitRestoredSession(
userId: userId,
email: AuthState.shared.userEmail,
attempt: attempt)
else {
return
}
loadNameFromBackendIfNeeded()
APIKeyService.shared.startFetchingKeys()
Task { await APIKeyService.shared.reconcileBYOKActivation() }
Task { await FloatingBarUsageLimiter.shared.fetchPlan() }
} catch AuthError.notSignedIn {
guard sessionAttemptFence.isCurrent(attempt) else { return }
if sessionCoordinator.phase == .needsReauth
|| (storedIdToken == nil && storedRefreshToken == nil && !hasFirebaseUser)
{
NSLog("OMI AUTH: Restored session validation proved credentials absent")
await invalidateSession(reason: .restoredSessionInvalid)
} else {
NSLog("OMI AUTH: Restored session validation deferred - preserving credentials for retry")
AuthState.shared.transition(to: .recoveryRequired)
}
} catch {
guard sessionAttemptFence.isCurrent(attempt) else { return }
NSLog("OMI AUTH: Restored session validation deferred (transient): %@", error.localizedDescription)
AuthState.shared.transition(to: .recoveryRequired)
}
}
// MARK: - Auth State Listener
private func setupAuthStateListener() {
guard let auth = configuredFirebaseAuth() else { return }
authStateHandle = auth.addStateDidChangeListener { [weak self] _, user in
Task { @MainActor in
guard let self else { return }
if let user {
// Firebase currentUser is cached identity, not proof that the
// credential can refresh. Only enrich an already validated session;
// launch restoration remains owned by validateRestoredSessionNow().
log(
"AUTH_LISTENER: Firebase user present (uid=\(user.uid)), phase=\(String(describing: self.sessionCoordinator.phase))"
)
let ownerID = UserDefaults.standard.string(forKey: .authUserId)
let tokenOwnerID = self.storedTokenUserId
if self.sessionCoordinator.phase == .authenticated,
ownerID == user.uid,
tokenOwnerID == nil || tokenOwnerID == user.uid
{
let attempt = self.currentSessionAttempt()
guard
await self.saveAuthState(
isSignedIn: true,
email: user.email,
userId: user.uid,
attempt: attempt)
else {
return
}
AuthState.shared.userEmail = user.email
self.loadNameFromBackendIfNeeded()
Task { await SettingsSyncManager.shared.syncFromServer() }
}
} else {
// Firebase has no user - check if we have a saved session (for dev builds where Keychain doesn't persist)
let savedSignedIn = UserDefaults.standard.bool(forKey: .authIsSignedIn)
log("AUTH_LISTENER: Firebase user nil, savedSignedIn=\(savedSignedIn), currentIsSignedIn=\(self.isSignedIn)")
if !savedSignedIn {
// No saved session either - user is truly signed out
log("AUTH_LISTENER: No saved session - setting isSignedIn=false")
AuthState.shared.transition(to: .signedOut)
AuthState.shared.userEmail = nil
} else {
log("AUTH_LISTENER: Firebase user nil with saved session — validating REST tokens")
await self.validateSavedSessionAfterFirebaseNil()
}
}
}
}
}
/// When Firebase SDK has no user but UserDefaults says signed-in, probe REST tokens.
/// Invalidates only on definitive death; skips while launch restore is in flight.
private func validateSavedSessionAfterFirebaseNil() async {
let attempt = currentSessionAttempt()
let expectedOwnerID = UserDefaults.standard.string(forKey: .authUserId)
guard !AuthState.shared.isRestoringAuth else {
log("AUTH_LISTENER: skipping REST validation while launch restore is in flight")
return
}
guard storedRefreshToken != nil else {
await invalidateSession(reason: .restoredSessionInvalid)
return
}
do {
_ = try await sessionCoordinator.refreshSingleFlight(auth: self)
guard sessionAttemptFence.isCurrent(attempt),
UserDefaults.standard.string(forKey: .authUserId) == expectedOwnerID
else {
return
}
sessionCoordinator.resetAfterSuccessfulSignIn()
log("AUTH_LISTENER: saved REST session validated after Firebase nil")
} catch AuthError.notSignedIn where storedIdToken == nil || storedRefreshToken == nil {
guard sessionAttemptFence.isCurrent(attempt) else { return }
log("AUTH_LISTENER: saved REST session definitively dead — invalidating")
await invalidateSession(reason: .definitiveRefreshFailure)
} catch {
guard sessionAttemptFence.isCurrent(attempt) else { return }
log("AUTH_LISTENER: saved REST session validation deferred: \(error.localizedDescription)")
AuthState.shared.transition(to: .recoveryRequired)
}
}
// MARK: - Sign in with Apple (Native with Web OAuth Fallback)
@MainActor
func signInWithApple() async throws {
// Use web OAuth directly — native Apple Sign In requires entitlements that
// don't work reliably across dev/release builds. Web OAuth works everywhere.
try await signIn(provider: "apple")
}
// MARK: - Native Apple Sign In (requires com.apple.developer.applesignin entitlement)
@MainActor
private func signInWithAppleNative() async throws {
let sessionAttempt = beginSessionAttempt()
NSLog("OMI AUTH: Starting native Apple Sign In")
isLoading = true
error = nil
AnalyticsManager.shared.signInStarted(provider: "apple")
defer { isLoading = false }
// Step 1: Generate nonce for security
let nonce = generateNonce()
currentNonce = nonce
let hashedNonce = sha256(nonce)
// Step 2: Perform native Apple Sign In
let appleCredential = try await performAppleSignIn(hashedNonce: hashedNonce)
guard sessionAttemptFence.isCurrent(sessionAttempt) else {
throw AuthError.cancelled
}
appleSignInDelegate = nil // Clean up
// Step 3: Extract identity token
guard let identityTokenData = appleCredential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8)
else {
throw AuthError.missingToken
}
NSLog("OMI AUTH: Got Apple identity token")
// Save user name if provided (Apple only sends name on first sign-in)
var capturedFreshAppleName = false
if let fullName = appleCredential.fullName {
let given = fullName.givenName ?? ""
let family = fullName.familyName ?? ""
if !given.isEmpty {
givenName = given
familyName = family
capturedFreshAppleName = true
NSLog("OMI AUTH: Saved name from Apple: %@ %@", given, family)
postNameDidUpdate()
}
}
if let email = appleCredential.email {
AuthState.shared.userEmail = email
}
// Step 4: Sign in with Firebase using Apple credential
// Use Firebase SDK first (handles native bundle ID audience correctly),
// fall back to REST API (for web OAuth audience 'me.omi.web')
NSLog("OMI AUTH: Signing in with Firebase using Apple identity token...")
let nativeSignIn = try await FirebaseAuthAvailability.signInWithNativeApple(
auth: configuredFirebaseAuth(),
identityToken: identityToken,
nonce: nonce,
isSessionCurrent: { self.sessionAttemptFence.isCurrent(sessionAttempt) },
discardStaleFirebaseUser: { self.discardStaleFirebaseUserIfNeeded($0) },
restFallback: { try await self.signInWithAppleIdentityToken(identityToken: identityToken, nonce: nonce) }
)
// Extract email from identity token if not provided by Apple
if AuthState.shared.userEmail == nil {
if let payload = decodeJWT(identityToken),
let email = payload["email"] as? String
{
AuthState.shared.userEmail = email
}
}
guard
try await commitSignedInSession(
tokens: nativeSignIn.tokens,
email: AuthState.shared.userEmail,
attempt: sessionAttempt)
else {
throw AuthError.cancelled
}
if let fallbackReason = nativeSignIn.fallbackReason {
FirebaseAuthAvailability.recordNativeAppleFallback(reason: fallbackReason)
}
if givenName.isEmpty {
loadNameFromBackendIfNeeded()
} else if capturedFreshAppleName {
// Session is live now; persist the first-auth Apple name to Firebase +
// backend so it survives reinstalls (Apple won't resend it).
let capturedName = displayName
Task { [weak self] in await self?.updateGivenName(capturedName) }
}
AnalyticsManager.shared.identify()
AnalyticsManager.shared.signInCompleted(provider: "apple")
APIKeyService.shared.startFetchingKeys()
Task { await FloatingBarUsageLimiter.shared.fetchPlan() }
// Start trial polling for the newly signed-in user
if let state = AppState.current {
state.startTrialMetadataRefresh()
TrialBannerService.shared.start(appState: state)
}
// Refresh the chat usage limiter for the new account (PTT gate + floating
// bar read it); without this it stays nil/old until the next app launch.
Task { await FloatingBarUsageLimiter.shared.fetchPlan() }
if !AnalyticsManager.isDevBuild {
// Keep Sentry correlation opaque; PII remains in the application/backend,
// not crash and error reports.
SentrySDK.setUser(User(userId: nativeSignIn.tokens.localId))
}
NSLog("OMI AUTH: Apple Sign in complete!")
fetchConversations()
}
// MARK: - Sign in with Google (Web OAuth Flow)
@MainActor
func signInWithGoogle() async throws {
try await signIn(provider: "google")
}
// MARK: - Generic OAuth Sign In
@MainActor
private func signIn(provider: String) async throws {
// Guard against double sign-in (e.g., rapid button clicks before UI updates)
guard !isLoading else {
NSLog("OMI AUTH: Sign in already in progress, ignoring duplicate request")
return
}
let sessionAttempt = beginSessionAttempt()
NSLog("OMI AUTH: Starting Sign in with %@ (Web OAuth)", provider)
isLoading = true
error = nil
// Track sign-in started
AnalyticsManager.shared.signInStarted(provider: provider)
var activeFlowId: String?
var activeFlowStartedAt: Date?
var activeCallbackServer: OAuthLoopbackCallbackServer?
var activeCallbackTransport = "custom_scheme"
defer {
if activeFlowId == nil || pendingOAuthFlow == nil || pendingOAuthFlow?.id == activeFlowId {
isLoading = false
}
}
do {
// Step 1: Generate state for CSRF protection
let flowId = generateOAuthFlowID()
let state = generateState(flowId: flowId)
let codeVerifier = generateCodeVerifier()
let codeChallenge = makeCodeChallenge(for: codeVerifier)
let startedAt = Date()
activeFlowId = flowId
activeFlowStartedAt = startedAt
pendingOAuthState = state
let callbackServer: OAuthLoopbackCallbackServer?
do {
callbackServer = try OAuthLoopbackCallbackServer.start(
expectedState: state,
appOpenURL: "\(urlScheme)://open"
)
activeCallbackServer = callbackServer
loopbackCallbackServer = callbackServer
} catch {
callbackServer = nil
let nsError = error as NSError
trackAuthFlowEvent(
"Auth Callback Server Failed",
stage: "callback_server_started",
provider: provider,
authFlowId: flowId,
failureClass: "\(nsError.domain)_\(nsError.code)",
error: error.localizedDescription,
extraProperties: ["callback_transport": "custom_scheme_fallback"]
)
}
let selectedRedirectURI = callbackServer?.redirectURI ?? redirectURI
let selectedCallbackTransport = callbackServer == nil ? "custom_scheme_fallback" : "loopback"
activeCallbackTransport = selectedCallbackTransport
pendingOAuthFlow = OAuthFlowContext(
id: flowId,
provider: provider,
state: state,
startedAt: startedAt,
callbackTransport: selectedCallbackTransport
)
trackAuthFlowEvent(
"Auth Flow Started",
stage: "started",
provider: provider,
authFlowId: flowId,
extraProperties: ["callback_transport": selectedCallbackTransport]
)
if let callbackServer {
trackAuthFlowEvent(
"Auth Callback Server Started",
stage: "callback_server_started",
provider: provider,
authFlowId: flowId,
extraProperties: [
"callback_transport": selectedCallbackTransport,
"redirect_scheme": "http",
"loopback_port": callbackServer.port,
]
)
}
NSLog("OMI AUTH: Generated OAuth state")
// Step 2: Build authorization URL
let authURL = buildAuthorizationURL(
provider: provider,
state: state,
codeChallenge: codeChallenge,
redirectURI: selectedRedirectURI
)
NSLog("OMI AUTH: Opening browser for authentication")
// Step 3: Open browser for authentication
guard let url = URL(string: authURL) else {
trackAuthFlowEvent(
"Auth Callback Invalid", stage: "authorize_url", provider: provider, authFlowId: flowId,
failureClass: "invalid_url")
throw AuthError.invalidURL
}
NSWorkspace.shared.open(url)
trackAuthFlowEvent("Auth Browser Opened", stage: "browser_opened", provider: provider, authFlowId: flowId)
// Step 4: Wait for callback with authorization code
NSLog("OMI AUTH: Waiting for OAuth callback...")
let (code, returnedState) = try await waitForOAuthCallback(callbackServer: callbackServer)
clearLoopbackCallbackServerIfCurrent(callbackServer, flowId: flowId)
bringAppToFrontAfterAuthCallback()
if callbackServer != nil {
trackAuthFlowEvent(
"Auth Callback Received",
stage: "callback_received",
provider: provider,
authFlowId: flowId
)
}
// Step 5: Verify state matches
guard returnedState == state else {
NSLog("OMI AUTH: State mismatch - potential CSRF attack")
trackAuthFlowEvent(
"Auth Callback Invalid",
stage: "state_verified",
provider: provider,
authFlowId: flowId,
failureClass: "state_mismatch"
)
throw AuthError.stateMismatch
}
if callbackServer != nil {
trackAuthFlowEvent(
"Auth Callback Valid",
stage: "callback_validated",
provider: provider,
authFlowId: flowId
)
}
NSLog("OMI AUTH: Received valid authorization code")
// Step 6: Exchange code for custom token and user info
NSLog("OMI AUTH: Exchanging code for Firebase token...")
trackAuthFlowEvent("Auth Token Exchange Started", stage: "token_exchange", provider: provider, authFlowId: flowId)
let tokenResult: TokenExchangeResult
do {
tokenResult = try await exchangeCodeForToken(
code: code,
codeVerifier: codeVerifier,