Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
at main 413 lines 12 kB view raw view rendered
1# Mobile Authentication Guide 2 3This guide covers implementing AT Protocol OAuth for native mobile applications 4(iOS, Android) using `@tijs/atproto-oauth`. 5 6## Overview 7 8This library implements the 9[Backend-for-Frontend (BFF) pattern](https://atproto.com/specs/oauth#confidential-client-backend-for-frontend) 10recommended by AT Protocol for mobile apps requiring long-lived sessions. Your 11server acts as the OAuth client, keeping tokens secure while the mobile app 12receives a session cookie. 13 14Mobile authentication uses a secure WebView flow: 15 161. App opens a secure browser (ASWebAuthenticationSession on iOS, Custom Tabs on 17 Android) 182. User enters their handle and completes OAuth in the browser 193. Your server completes the OAuth exchange 204. Server redirects to your app's URL scheme with session credentials 215. App extracts credentials and stores them securely 22 23This approach keeps OAuth tokens on your server while giving the mobile app a 24session token for authenticated requests. 25 26## Server Configuration 27 28### Enable Mobile Support 29 30Add `mobileScheme` to your OAuth configuration: 31 32```typescript 33const oauth = createATProtoOAuth({ 34 baseUrl: "https://myapp.example.com", 35 appName: "My App", 36 cookieSecret: Deno.env.get("COOKIE_SECRET")!, 37 storage: new SQLiteStorage(valTownAdapter(sqlite)), 38 sessionTtl: 60 * 60 * 24 * 14, 39 mobileScheme: "myapp://auth-callback", // Your app's URL scheme 40}); 41``` 42 43The `mobileScheme` is the URL your app registers to handle OAuth callbacks. 44 45### How It Works 46 47When `/login` receives `mobile=true`: 48 491. The OAuth flow proceeds normally 502. After successful authentication, instead of redirecting to a web page, the 51 callback redirects to your `mobileScheme` with: 52 - `session_token`: Sealed session token for cookie authentication 53 - `did`: User's DID 54 - `handle`: User's handle 55 56Example callback URL: 57 58``` 59myapp://auth-callback?session_token=Fe26.2**abc...&did=did:plc:xyz&handle=alice.bsky.social 60``` 61 62## iOS Implementation 63 64### Register URL Scheme 65 66In your `Info.plist` or Xcode project settings, register your URL scheme: 67 68```xml 69<key>CFBundleURLTypes</key> 70<array> 71 <dict> 72 <key>CFBundleURLSchemes</key> 73 <array> 74 <string>myapp</string> 75 </array> 76 <key>CFBundleURLName</key> 77 <string>com.example.myapp</string> 78 </dict> 79</array> 80``` 81 82### Start OAuth Flow 83 84Use `ASWebAuthenticationSession` for secure OAuth: 85 86```swift 87import AuthenticationServices 88 89class AuthManager: NSObject, ASWebAuthenticationPresentationContextProviding { 90 private var authSession: ASWebAuthenticationSession? 91 92 func startLogin(handle: String) { 93 // Build login URL with mobile=true 94 var components = URLComponents(string: "https://myapp.example.com/login")! 95 components.queryItems = [ 96 URLQueryItem(name: "handle", value: handle), 97 URLQueryItem(name: "mobile", value: "true") 98 ] 99 100 guard let url = components.url else { return } 101 102 // Create secure auth session 103 authSession = ASWebAuthenticationSession( 104 url: url, 105 callbackURLScheme: "myapp" 106 ) { [weak self] callbackURL, error in 107 self?.handleCallback(callbackURL: callbackURL, error: error) 108 } 109 110 authSession?.presentationContextProvider = self 111 authSession?.prefersEphemeralWebBrowserSession = false 112 authSession?.start() 113 } 114 115 func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 116 UIApplication.shared.connectedScenes 117 .compactMap { $0 as? UIWindowScene } 118 .flatMap { $0.windows } 119 .first { $0.isKeyWindow } ?? UIWindow() 120 } 121} 122``` 123 124### Handle Callback 125 126Extract credentials from the callback URL: 127 128```swift 129func handleCallback(callbackURL: URL?, error: Error?) { 130 if let error = error as? ASWebAuthenticationSessionError, 131 error.code == .canceledLogin { 132 // User cancelled - not an error 133 return 134 } 135 136 guard let url = callbackURL, 137 let components = URLComponents(url: url, resolvingAgainstBaseURL: false), 138 let queryItems = components.queryItems else { 139 // Handle error 140 return 141 } 142 143 // Extract credentials 144 guard let sessionToken = queryItems.first(where: { $0.name == "session_token" })?.value, 145 let did = queryItems.first(where: { $0.name == "did" })?.value, 146 let handle = queryItems.first(where: { $0.name == "handle" })?.value else { 147 // Handle missing parameters 148 return 149 } 150 151 // Store session token securely (Keychain recommended) 152 saveToKeychain(sessionToken: sessionToken, did: did, handle: handle) 153 154 // Set up cookie for API requests 155 setSessionCookie(sessionToken: sessionToken) 156} 157``` 158 159### Cookie-Based API Requests 160 161The session token is an Iron Session sealed cookie value. Set it as a cookie for 162API requests: 163 164```swift 165func setSessionCookie(sessionToken: String) { 166 let cookie = HTTPCookie(properties: [ 167 .name: "sid", 168 .value: sessionToken, 169 .domain: "myapp.example.com", 170 .path: "/", 171 .secure: true, 172 .expires: Date().addingTimeInterval(60 * 60 * 24 * 14) // 14 days 173 ])! 174 175 HTTPCookieStorage.shared.setCookie(cookie) 176} 177 178// API requests automatically include the cookie 179func fetchProfile() async throws -> Profile { 180 let url = URL(string: "https://myapp.example.com/api/profile")! 181 let (data, _) = try await URLSession.shared.data(from: url) 182 return try JSONDecoder().decode(Profile.self, from: data) 183} 184``` 185 186## Android Implementation 187 188### Register URL Scheme 189 190In your `AndroidManifest.xml`: 191 192```xml 193<activity android:name=".AuthCallbackActivity" 194 android:exported="true"> 195 <intent-filter> 196 <action android:name="android.intent.action.VIEW" /> 197 <category android:name="android.intent.category.DEFAULT" /> 198 <category android:name="android.intent.category.BROWSABLE" /> 199 <data android:scheme="myapp" 200 android:host="auth-callback" /> 201 </intent-filter> 202</activity> 203``` 204 205### Start OAuth Flow 206 207Use Custom Tabs for secure OAuth: 208 209```kotlin 210import androidx.browser.customtabs.CustomTabsIntent 211 212fun startLogin(handle: String) { 213 val url = Uri.parse("https://myapp.example.com/login") 214 .buildUpon() 215 .appendQueryParameter("handle", handle) 216 .appendQueryParameter("mobile", "true") 217 .build() 218 219 val customTabsIntent = CustomTabsIntent.Builder().build() 220 customTabsIntent.launchUrl(context, url) 221} 222``` 223 224### Handle Callback 225 226In your callback activity: 227 228```kotlin 229class AuthCallbackActivity : AppCompatActivity() { 230 override fun onCreate(savedInstanceState: Bundle?) { 231 super.onCreate(savedInstanceState) 232 233 intent.data?.let { uri -> 234 val sessionToken = uri.getQueryParameter("session_token") 235 val did = uri.getQueryParameter("did") 236 val handle = uri.getQueryParameter("handle") 237 238 if (sessionToken != null && did != null && handle != null) { 239 // Store securely (EncryptedSharedPreferences recommended) 240 saveCredentials(sessionToken, did, handle) 241 242 // Set up cookie for API requests 243 setSessionCookie(sessionToken) 244 } 245 } 246 247 // Return to main app 248 finish() 249 } 250} 251``` 252 253### Cookie-Based API Requests 254 255```kotlin 256fun setSessionCookie(sessionToken: String) { 257 val cookieManager = CookieManager.getInstance() 258 val cookie = "sid=$sessionToken; Path=/; Secure; HttpOnly" 259 cookieManager.setCookie("https://myapp.example.com", cookie) 260} 261``` 262 263## Session Validation 264 265After setting the cookie, validate the session by calling your session endpoint: 266 267```swift 268// iOS 269func validateSession() async throws -> Bool { 270 let url = URL(string: "https://myapp.example.com/api/auth/session")! 271 let (data, response) = try await URLSession.shared.data(from: url) 272 273 guard let httpResponse = response as? HTTPURLResponse, 274 httpResponse.statusCode == 200 else { 275 return false 276 } 277 278 let session = try JSONDecoder().decode(SessionResponse.self, from: data) 279 return session.authenticated 280} 281``` 282 283## Session Restoration 284 285On app launch, restore the session from secure storage: 286 287```swift 288func restoreSession() { 289 guard let sessionToken = loadFromKeychain() else { 290 // No stored session 291 return 292 } 293 294 // Recreate cookie 295 setSessionCookie(sessionToken: sessionToken) 296 297 // Validate session is still valid 298 Task { 299 let isValid = try await validateSession() 300 if !isValid { 301 // Session expired, clear and prompt login 302 clearCredentials() 303 } 304 } 305} 306``` 307 308## Security Considerations 309 3101. **Secure Storage**: Store credentials in iOS Keychain or Android 311 EncryptedSharedPreferences. 312 3132. **URL Scheme**: Use a unique scheme unlikely to conflict with other apps. 314 Consider using a reverse-domain format. 315 3163. **Ephemeral Sessions**: Set `prefersEphemeralWebBrowserSession = false` on 317 iOS to allow SSO with existing Bluesky sessions. 318 3194. **Token Security**: The `session_token` is cryptographically sealed. It 320 cannot be tampered with or forged. 321 3225. **Server-Side Tokens**: OAuth access/refresh tokens stay on your server. The 323 mobile app only receives a session identifier. 324 325## Mobile Login Page 326 327For the best user experience, create a dedicated mobile login page: 328 329```typescript 330// /mobile-auth route 331app.get("/mobile-auth", (c) => { 332 return c.html(` 333 <!DOCTYPE html> 334 <html> 335 <head> 336 <meta name="viewport" content="width=device-width, initial-scale=1"> 337 <title>Sign in - My App</title> 338 </head> 339 <body> 340 <h1>Sign in to My App</h1> 341 <form action="/login" method="get"> 342 <input type="hidden" name="mobile" value="true"> 343 <input type="text" name="handle" placeholder="alice.bsky.social" required> 344 <button type="submit">Continue</button> 345 </form> 346 </body> 347 </html> 348 `); 349}); 350``` 351 352This provides a clean login experience within the secure WebView. 353 354## Troubleshooting 355 356### Callback Not Received 357 358- Verify URL scheme is registered correctly 359- Check that `mobileScheme` matches your registered scheme exactly 360- On iOS, ensure `callbackURLScheme` matches (without `://`) 361 362### Session Invalid After Callback 363 364- Verify the session token is being set as a cookie correctly 365- Check cookie domain matches your API domain 366- Ensure cookies are being sent with requests (`credentials: "include"`) 367 368### "Invalid state" Error 369 370- The OAuth state expired (default: 10 minutes) 371- User took too long to complete authorization 372- Start a new login flow 373 374## Resources 375 376### AT Protocol Documentation 377 378- [OAuth Specification](https://atproto.com/specs/oauth) - Full OAuth spec 379 including mobile client requirements 380- [OAuth Introduction](https://atproto.com/guides/oauth) - Overview of OAuth 381 patterns and app types 382- [BFF Pattern](https://atproto.com/specs/oauth#confidential-client-backend-for-frontend) - 383 Backend-for-Frontend architecture details 384 385### Example Implementations 386 387- [React Native OAuth Example](https://github.com/bluesky-social/cookbook/tree/main/react-native-oauth) - 388 Official Bluesky mobile example using `@atproto/oauth-client-expo` 389- [Go OAuth Web App](https://github.com/bluesky-social/cookbook/tree/main/go-oauth-web-app) - 390 BFF pattern implementation in Go 391- [Python OAuth Web App](https://github.com/bluesky-social/cookbook/tree/main/python-oauth-web-app) - 392 BFF pattern implementation in Python 393 394### iOS Swift Package 395 396- [ATProtoFoundation](https://github.com/tijs/ATProtoFoundation) - Swift package 397 providing the iOS client-side implementation for this library's BFF pattern, 398 including `IronSessionMobileOAuthCoordinator` for handling the OAuth flow and 399 `KeychainCredentialsStorage` for secure credential storage. 400 401### Alternative Approaches 402 403This library uses the BFF pattern where OAuth tokens stay on your server. If you 404prefer tokens on the device, consider: 405 406- [@atproto/oauth-client-expo](https://www.npmjs.com/package/@atproto/oauth-client-expo) - 407 Official Bluesky SDK for React Native (tokens on device) 408 409The BFF pattern is recommended when you need: 410 411- Long-lived sessions (up to 14 days for public clients) 412- Server-side API calls on behalf of users 413- Simplified mobile client code