iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: force re-auth when required scopes change

TestFlight users whose OAuth tokens were minted before a scope was
added to the client scope list don't have the new permissions, so
`getActorFavorites` (and any other newer-scoped endpoint) returns
403 and the UI silently lands in a broken state.

Capture the `scope` field from the OAuth token response and persist
it to Keychain alongside the access token, then at launch compare
the stored grant against a new `AuthManager.requiredScopes` constant
(extracted from the inline array in `login()`). If anything's
missing, log out and set `reauthReason` so `LoginView` can render
an explanatory banner above the handle input.

A versioned UserDefaults key (`scopeMigrationDone_v1`) guarantees
the migration runs at most once per install — even if a re-login
somehow returns another insufficient grant, we don't loop; the
user just lands on the regular error state instead. Bumping the
suffix is a one-line change next time we add a scope.

Also promotes `self.favoriteGalleries` in a logger autoclosure
inside `ProfileDetailViewModel.loadFavorites` to appease the
implicit-self warning.

authored by

Hima Aramona and committed by
Chad Miller
e3e6ba61 afeeed2a

+94 -15
+61 -15
Grain/API/AuthManager.swift
··· 15 15 var userHandle: String? 16 16 var userAvatar: String? 17 17 var avatarImage: UIImage? 18 + /// Set when a launch-time scope migration forced the user to sign out. 19 + /// LoginView reads this to display an explanation above the sign-in form. 20 + var reauthReason: String? 18 21 19 22 private(set) var dpop: DPoP? 20 23 private var codeVerifier: String? ··· 29 32 nonisolated static let clientID = "grain-native://app" 30 33 nonisolated static let redirectURI = "grain://oauth/callback" 31 34 35 + /// OAuth scopes the app currently requests at sign-in. Update here, then 36 + /// bump a `scopeMigration*` flag below if you want existing installs to 37 + /// be forced through a fresh sign-in to pick the new scope up. 38 + nonisolated static let requiredScopes: [String] = [ 39 + "atproto", 40 + "blob:image/*", 41 + "repo:social.grain.gallery", 42 + "repo:social.grain.gallery.item", 43 + "repo:social.grain.photo", 44 + "repo:social.grain.photo.exif", 45 + "repo:social.grain.actor.profile", 46 + "repo:social.grain.graph.follow", 47 + "repo:social.grain.graph.block", 48 + "repo:social.grain.favorite", 49 + "repo:social.grain.comment", 50 + "repo:social.grain.story", 51 + "repo:app.bsky.feed.post?action=create", 52 + ] 53 + 54 + /// Version-tagged UserDefaults key marking that a one-shot scope 55 + /// migration has already run for this install. Prevents re-auth loops 56 + /// when a re-login still yields a token without the newly added scopes. 57 + /// To force another migration (e.g. after adding a scope), bump the 58 + /// suffix: `scopeMigrationDone_v2`, `_v3`, etc. 59 + private static let scopeMigrationKey = "scopeMigrationDone_v1" 60 + 32 61 init() { 33 62 let spid = authSignposter.makeSignpostID() 34 63 let state = authSignposter.beginInterval("SessionRestore", id: spid) ··· 50 79 dpop = try? DPoP.loadOrCreate() 51 80 authSignposter.endInterval("DPoPLoad", dpopState) 52 81 logger.debug("[DPoPLoad] end") 82 + 83 + runScopeMigrationIfNeeded() 53 84 } else { 54 85 authSignposter.emitEvent("KeychainRead", id: spid, "authenticated=false") 55 86 logger.debug("[KeychainRead] authenticated=false") ··· 58 89 logger.debug("[SessionRestore] end") 59 90 } 60 91 92 + /// One-shot check at launch: if the currently-stored token predates the 93 + /// scope-persistence code (or is missing any required scope) and we 94 + /// haven't already run this migration, log the user out so they re-auth 95 + /// with a fresh grant. The UserDefaults flag guarantees this fires at 96 + /// most once per install per version — even if the re-login somehow 97 + /// still returns an insufficient grant, we don't loop. 98 + private func runScopeMigrationIfNeeded() { 99 + guard !UserDefaults.standard.bool(forKey: Self.scopeMigrationKey) else { return } 100 + 101 + let stored = TokenStorage.grantedScope.map { Set($0.split(separator: " ").map(String.init)) } ?? [] 102 + let missing = Self.requiredScopes.filter { !stored.contains($0) } 103 + guard !missing.isEmpty else { 104 + // Nothing to do — stored token already covers every required scope. 105 + UserDefaults.standard.set(true, forKey: Self.scopeMigrationKey) 106 + return 107 + } 108 + 109 + logger.info("[ScopeMigration] forcing re-auth; missing=\(missing.joined(separator: ","), privacy: .public)") 110 + UserDefaults.standard.set(true, forKey: Self.scopeMigrationKey) 111 + logout() 112 + reauthReason = "Grain has been updated. Please sign in again to enable new features." 113 + } 114 + 61 115 /// Start the OAuth login flow. Set `createAccount` to show the sign-up page. 62 116 func login(handle: String = "", createAccount: Bool = false) async throws { 63 117 let dpop = try DPoP.loadOrCreate() ··· 78 132 "response_type": "code", 79 133 "code_challenge": challenge, 80 134 "code_challenge_method": "S256", 81 - "scope": [ 82 - "atproto", 83 - "blob:image/*", 84 - "repo:social.grain.gallery", 85 - "repo:social.grain.gallery.item", 86 - "repo:social.grain.photo", 87 - "repo:social.grain.photo.exif", 88 - "repo:social.grain.actor.profile", 89 - "repo:social.grain.graph.follow", 90 - "repo:social.grain.graph.block", 91 - "repo:social.grain.favorite", 92 - "repo:social.grain.comment", 93 - "repo:social.grain.story", 94 - "repo:app.bsky.feed.post?action=create", 95 - ].joined(separator: " "), 135 + "scope": Self.requiredScopes.joined(separator: " "), 96 136 ] 97 137 if createAccount { 98 138 parBody["prompt"] = "create" ··· 315 355 TokenStorage.userDID = response.sub 316 356 TokenStorage.userHandle = response.handle 317 357 TokenStorage.tokenExpiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn)) 358 + if let scope = response.scope { 359 + TokenStorage.grantedScope = scope 360 + } 318 361 319 362 // Guard each @Observable assignment: the macro's setter always fires the 320 363 // observation registrar even when the value is unchanged, so token refreshes ··· 322 365 if !isAuthenticated { isAuthenticated = true } 323 366 if userDID != response.sub { userDID = response.sub } 324 367 if userHandle != response.handle { userHandle = response.handle } 368 + if reauthReason != nil { reauthReason = nil } 325 369 } 326 370 327 371 func fetchAvatarIfNeeded() async { ··· 395 439 let refreshToken: String? 396 440 let sub: String 397 441 let handle: String? 442 + let scope: String? 398 443 399 444 enum CodingKeys: String, CodingKey { 400 445 case accessToken = "access_token" ··· 403 448 case refreshToken = "refresh_token" 404 449 case sub 405 450 case handle 451 + case scope 406 452 } 407 453 } 408 454
+12
Grain/API/TokenStorage.swift
··· 56 56 } 57 57 } 58 58 59 + /// Space-separated OAuth scope string from the token response. Used at 60 + /// launch to detect tokens minted before newer scopes were added so the 61 + /// app can force a fresh sign-in. `nil` for sessions created before this 62 + /// field was introduced — treat as a full scope mismatch. 63 + static var grantedScope: String? { 64 + get { try? keychain.get("token_scope") } 65 + set { 66 + if let newValue { try? keychain.set(newValue, key: "token_scope") } else { try? keychain.remove("token_scope") } 67 + } 68 + } 69 + 59 70 static func clear() { 60 71 accessToken = nil 61 72 refreshToken = nil ··· 63 74 userHandle = nil 64 75 userAvatar = nil 65 76 tokenExpiresAt = nil 77 + grantedScope = nil 66 78 } 67 79 }
+21
Grain/Views/LoginView.swift
··· 48 48 .foregroundStyle(.white) 49 49 .padding(.bottom, suggestions.isEmpty ? 60 : 20) 50 50 51 + if let reason = auth.reauthReason, suggestions.isEmpty { 52 + HStack(alignment: .top, spacing: 10) { 53 + Image(systemName: "exclamationmark.circle.fill") 54 + .font(.body.weight(.medium)) 55 + .foregroundStyle(.white) 56 + Text(reason) 57 + .font(.subheadline) 58 + .foregroundStyle(.white) 59 + .multilineTextAlignment(.leading) 60 + } 61 + .padding(.horizontal, 16) 62 + .padding(.vertical, 12) 63 + .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12)) 64 + .overlay( 65 + RoundedRectangle(cornerRadius: 12) 66 + .stroke(.white.opacity(0.25), lineWidth: 1) 67 + ) 68 + .padding(.horizontal, 20) 69 + .padding(.bottom, 20) 70 + } 71 + 51 72 if suggestions.isEmpty { 52 73 // Heading 53 74 Text("Log in with your internet handle")