Framework-agnostic OAuth integration for AT Protocol (Bluesky) applications.
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