# Mobile Authentication Guide This guide covers implementing AT Protocol OAuth for native mobile applications (iOS, Android) using `@tijs/atproto-oauth`. ## Overview This library implements the [Backend-for-Frontend (BFF) pattern](https://atproto.com/specs/oauth#confidential-client-backend-for-frontend) recommended by AT Protocol for mobile apps requiring long-lived sessions. Your server acts as the OAuth client, keeping tokens secure while the mobile app receives a session cookie. Mobile authentication uses a secure WebView flow: 1. App opens a secure browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android) 2. User enters their handle and completes OAuth in the browser 3. Your server completes the OAuth exchange 4. Server redirects to your app's URL scheme with session credentials 5. App extracts credentials and stores them securely This approach keeps OAuth tokens on your server while giving the mobile app a session token for authenticated requests. ## Server Configuration ### Enable Mobile Support Add `mobileScheme` to your OAuth configuration: ```typescript const oauth = createATProtoOAuth({ baseUrl: "https://myapp.example.com", appName: "My App", cookieSecret: Deno.env.get("COOKIE_SECRET")!, storage: new SQLiteStorage(valTownAdapter(sqlite)), sessionTtl: 60 * 60 * 24 * 14, mobileScheme: "myapp://auth-callback", // Your app's URL scheme }); ``` The `mobileScheme` is the URL your app registers to handle OAuth callbacks. ### How It Works When `/login` receives `mobile=true`: 1. The OAuth flow proceeds normally 2. After successful authentication, instead of redirecting to a web page, the callback redirects to your `mobileScheme` with: - `session_token`: Sealed session token for cookie authentication - `did`: User's DID - `handle`: User's handle Example callback URL: ``` myapp://auth-callback?session_token=Fe26.2**abc...&did=did:plc:xyz&handle=alice.bsky.social ``` ## iOS Implementation ### Register URL Scheme In your `Info.plist` or Xcode project settings, register your URL scheme: ```xml CFBundleURLTypes CFBundleURLSchemes myapp CFBundleURLName com.example.myapp ``` ### Start OAuth Flow Use `ASWebAuthenticationSession` for secure OAuth: ```swift import AuthenticationServices class AuthManager: NSObject, ASWebAuthenticationPresentationContextProviding { private var authSession: ASWebAuthenticationSession? func startLogin(handle: String) { // Build login URL with mobile=true var components = URLComponents(string: "https://myapp.example.com/login")! components.queryItems = [ URLQueryItem(name: "handle", value: handle), URLQueryItem(name: "mobile", value: "true") ] guard let url = components.url else { return } // Create secure auth session authSession = ASWebAuthenticationSession( url: url, callbackURLScheme: "myapp" ) { [weak self] callbackURL, error in self?.handleCallback(callbackURL: callbackURL, error: error) } authSession?.presentationContextProvider = self authSession?.prefersEphemeralWebBrowserSession = false authSession?.start() } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first { $0.isKeyWindow } ?? UIWindow() } } ``` ### Handle Callback Extract credentials from the callback URL: ```swift func handleCallback(callbackURL: URL?, error: Error?) { if let error = error as? ASWebAuthenticationSessionError, error.code == .canceledLogin { // User cancelled - not an error return } guard let url = callbackURL, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { // Handle error return } // Extract credentials guard let sessionToken = queryItems.first(where: { $0.name == "session_token" })?.value, let did = queryItems.first(where: { $0.name == "did" })?.value, let handle = queryItems.first(where: { $0.name == "handle" })?.value else { // Handle missing parameters return } // Store session token securely (Keychain recommended) saveToKeychain(sessionToken: sessionToken, did: did, handle: handle) // Set up cookie for API requests setSessionCookie(sessionToken: sessionToken) } ``` ### Cookie-Based API Requests The session token is an Iron Session sealed cookie value. Set it as a cookie for API requests: ```swift func setSessionCookie(sessionToken: String) { let cookie = HTTPCookie(properties: [ .name: "sid", .value: sessionToken, .domain: "myapp.example.com", .path: "/", .secure: true, .expires: Date().addingTimeInterval(60 * 60 * 24 * 14) // 14 days ])! HTTPCookieStorage.shared.setCookie(cookie) } // API requests automatically include the cookie func fetchProfile() async throws -> Profile { let url = URL(string: "https://myapp.example.com/api/profile")! let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(Profile.self, from: data) } ``` ## Android Implementation ### Register URL Scheme In your `AndroidManifest.xml`: ```xml ``` ### Start OAuth Flow Use Custom Tabs for secure OAuth: ```kotlin import androidx.browser.customtabs.CustomTabsIntent fun startLogin(handle: String) { val url = Uri.parse("https://myapp.example.com/login") .buildUpon() .appendQueryParameter("handle", handle) .appendQueryParameter("mobile", "true") .build() val customTabsIntent = CustomTabsIntent.Builder().build() customTabsIntent.launchUrl(context, url) } ``` ### Handle Callback In your callback activity: ```kotlin class AuthCallbackActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) intent.data?.let { uri -> val sessionToken = uri.getQueryParameter("session_token") val did = uri.getQueryParameter("did") val handle = uri.getQueryParameter("handle") if (sessionToken != null && did != null && handle != null) { // Store securely (EncryptedSharedPreferences recommended) saveCredentials(sessionToken, did, handle) // Set up cookie for API requests setSessionCookie(sessionToken) } } // Return to main app finish() } } ``` ### Cookie-Based API Requests ```kotlin fun setSessionCookie(sessionToken: String) { val cookieManager = CookieManager.getInstance() val cookie = "sid=$sessionToken; Path=/; Secure; HttpOnly" cookieManager.setCookie("https://myapp.example.com", cookie) } ``` ## Session Validation After setting the cookie, validate the session by calling your session endpoint: ```swift // iOS func validateSession() async throws -> Bool { let url = URL(string: "https://myapp.example.com/api/auth/session")! let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return false } let session = try JSONDecoder().decode(SessionResponse.self, from: data) return session.authenticated } ``` ## Session Restoration On app launch, restore the session from secure storage: ```swift func restoreSession() { guard let sessionToken = loadFromKeychain() else { // No stored session return } // Recreate cookie setSessionCookie(sessionToken: sessionToken) // Validate session is still valid Task { let isValid = try await validateSession() if !isValid { // Session expired, clear and prompt login clearCredentials() } } } ``` ## Security Considerations 1. **Secure Storage**: Store credentials in iOS Keychain or Android EncryptedSharedPreferences. 2. **URL Scheme**: Use a unique scheme unlikely to conflict with other apps. Consider using a reverse-domain format. 3. **Ephemeral Sessions**: Set `prefersEphemeralWebBrowserSession = false` on iOS to allow SSO with existing Bluesky sessions. 4. **Token Security**: The `session_token` is cryptographically sealed. It cannot be tampered with or forged. 5. **Server-Side Tokens**: OAuth access/refresh tokens stay on your server. The mobile app only receives a session identifier. ## Mobile Login Page For the best user experience, create a dedicated mobile login page: ```typescript // /mobile-auth route app.get("/mobile-auth", (c) => { return c.html(` Sign in - My App

Sign in to My App

`); }); ``` This provides a clean login experience within the secure WebView. ## Troubleshooting ### Callback Not Received - Verify URL scheme is registered correctly - Check that `mobileScheme` matches your registered scheme exactly - On iOS, ensure `callbackURLScheme` matches (without `://`) ### Session Invalid After Callback - Verify the session token is being set as a cookie correctly - Check cookie domain matches your API domain - Ensure cookies are being sent with requests (`credentials: "include"`) ### "Invalid state" Error - The OAuth state expired (default: 10 minutes) - User took too long to complete authorization - Start a new login flow ## Resources ### AT Protocol Documentation - [OAuth Specification](https://atproto.com/specs/oauth) - Full OAuth spec including mobile client requirements - [OAuth Introduction](https://atproto.com/guides/oauth) - Overview of OAuth patterns and app types - [BFF Pattern](https://atproto.com/specs/oauth#confidential-client-backend-for-frontend) - Backend-for-Frontend architecture details ### Example Implementations - [React Native OAuth Example](https://github.com/bluesky-social/cookbook/tree/main/react-native-oauth) - Official Bluesky mobile example using `@atproto/oauth-client-expo` - [Go OAuth Web App](https://github.com/bluesky-social/cookbook/tree/main/go-oauth-web-app) - BFF pattern implementation in Go - [Python OAuth Web App](https://github.com/bluesky-social/cookbook/tree/main/python-oauth-web-app) - BFF pattern implementation in Python ### iOS Swift Package - [ATProtoFoundation](https://github.com/tijs/ATProtoFoundation) - Swift package providing the iOS client-side implementation for this library's BFF pattern, including `IronSessionMobileOAuthCoordinator` for handling the OAuth flow and `KeychainCredentialsStorage` for secure credential storage. ### Alternative Approaches This library uses the BFF pattern where OAuth tokens stay on your server. If you prefer tokens on the device, consider: - [@atproto/oauth-client-expo](https://www.npmjs.com/package/@atproto/oauth-client-expo) - Official Bluesky SDK for React Native (tokens on device) The BFF pattern is recommended when you need: - Long-lived sessions (up to 14 days for public clients) - Server-side API calls on behalf of users - Simplified mobile client code