> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Social Login

> Implement social login with the Prelude mobile SDKs.

This guide covers how to implement social login (OAuth) in your mobile application. Make sure you have [configured a social login provider](/session/documentation/integration-guide/social-login) on your backend before proceeding.

Pick your platform once: tabs across this section stay in sync.

## OAuth login flow

On the web the flow is three separate steps — redirect, callback, and finalize. On mobile the SDK runs all three for you in a single call:

1. **Present** — `loginWithOAuth` opens the provider's login page in a system web session (`ASWebAuthenticationSession` on iOS, a Chrome Custom Tab on Android)
2. **Callback** — the provider redirects back to your app's custom URL scheme with a `challenge_token`, which the SDK captures
3. **Finalize** — the SDK exchanges the `challenge_token` for a session and persists the tokens in the platform's secure store

The call returns a `FinalizeOAuthLoginResult`: either the user is **logged in**, or an **OTP step is required**. The second case only happens when the provider has [`verify_email`](/session/documentation/integration-guide/social-login#verify-email-via-otp) enabled and the IdP returns an email it has not verified — see [Verify email via OTP](#verify-email-via-otp) below.

<Accordion title="How is the OAuth flow secured?" icon="shield">
  The entire flow is protected by [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key for Code Exchange). The SDK generates a unique `code_verifier` and `code_challenge` pair for each login attempt, and the `challenge_token` can only be finalized once. This protects against:

  * **Authorization code interception** — a stolen `challenge_token` is useless without the `code_verifier`, which never leaves the device
  * **Replay attacks** — the `challenge_token` is invalidated after a single use
  * **Cross-site request forgery (CSRF)** — the server validates that the finalize request matches the original authorization request

  Every attempt carries its own verifier, so concurrent logins never share state. Once finalized, the session's tokens are bound to the device with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449).
</Accordion>

## Platform setup

The provider redirects back to your app through a **custom URL scheme** (e.g. `myapp://oauth-callback`) — not an `https` URL. Passing an `http`/`https` redirect throws an invalid-configuration error before any network call. The redirect URI must also be allowlisted in your provider's configuration.

<Tabs>
  <Tab title="iOS">
    No additional setup is required. The system web session captures the redirect scheme directly, so you don't need to register it in `Info.plist`.

    Social login lives in the `PreludeAuthSocial` product — an opt-in module so apps that skip social pull no extra code. Add it alongside `PreludeAuth` in `Package.swift`:

    ```swift theme={null}
    .product(name: "PreludeAuthSocial", package: "apple-auth-sdk"),
    ```
  </Tab>

  <Tab title="Android">
    Social login opens the provider page in a Chrome Custom Tab. Add `androidx.browser` to your module's `build.gradle.kts` (the SDK references it `compileOnly`, so apps that skip social pull no extra dependency):

    ```kotlin theme={null}
    dependencies {
        implementation("androidx.browser:browser:1.8.0")
    }
    ```

    Then declare the redirect target in `AndroidManifest.xml`, with the `data` scheme matching your `redirectUri`. The SDK supplies the activity's behavioral attributes; your app only contributes the intent-filter:

    ```xml theme={null}
    <activity
        android:name="so.prelude.android.auth.social.OAuthRedirectActivity"
        android:exported="true"
        android:launchMode="singleTask"
        android:theme="@android:style/Theme.Translucent.NoTitleBar">
        <intent-filter>
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.DEFAULT" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="myapp" />
        </intent-filter>
    </activity>
    ```
  </Tab>

  <Tab title="Flutter">
    * **iOS** — no extra configuration; the system web session captures the redirect scheme directly.
    * **Android** — social login opens a Chrome Custom Tab. Add `androidx.browser` to `android/app/build.gradle.kts` and declare the redirect target in `android/app/src/main/AndroidManifest.xml`, with the `data` scheme matching your `redirectUri`:

      ```kotlin theme={null}
      dependencies {
          implementation("androidx.browser:browser:1.8.0")
      }
      ```

      ```xml theme={null}
      <activity
          android:name="so.prelude.android.auth.social.OAuthRedirectActivity"
          android:exported="true"
          android:launchMode="singleTask"
          android:theme="@android:style/Theme.Translucent.NoTitleBar">
          <intent-filter>
              <action android:name="android.intent.action.VIEW" />
              <category android:name="android.intent.category.DEFAULT" />
              <category android:name="android.intent.category.BROWSABLE" />
              <data android:scheme="myapp" />
          </intent-filter>
      </activity>
      ```
  </Tab>

  <Tab title="React Native">
    Register the redirect URI's scheme with the OS so the callback returns to your app:

    * **iOS** — add the scheme to `CFBundleURLTypes` in `Info.plist` (or `ios.infoPlist` in `app.json`).
    * **Android** — depend on `androidx.browser` and declare the redirect activity in `AndroidManifest.xml`, with the `data` scheme matching your `redirectUri`:

      ```gradle theme={null}
      implementation("androidx.browser:browser:1.8.0")
      ```

      ```xml theme={null}
      <activity
          android:name="so.prelude.android.auth.social.OAuthRedirectActivity"
          android:exported="true"
          android:launchMode="singleTask"
          android:configChanges="keyboardHidden|orientation|screenSize"
          android:theme="@android:style/Theme.Translucent.NoTitleBar">
        <intent-filter>
          <action android:name="android.intent.action.VIEW" />
          <category android:name="android.intent.category.DEFAULT" />
          <category android:name="android.intent.category.BROWSABLE" />
          <data android:scheme="myapp" />
        </intent-filter>
      </activity>
      ```
  </Tab>
</Tabs>

## Sign in with a provider

Call `loginWithOAuth` with a provider and your redirect URI. The SDK presents the provider's page, handles the callback, and finalizes the session. Supported providers are `google`, `apple`, `microsoft`, `github`, `okta`, and `facebook`.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    import PreludeAuthSocial

    let result = try await client.loginWithOAuth(
        OAuthLoginOptions(
            provider: .google,
            redirectURI: URL(string: "myapp://oauth-callback")!
        )
    )
    ```

    Set `prefersEphemeralSession: true` to start every login from a clean session with no shared browser cookies.
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    import so.prelude.android.auth.*
    import so.prelude.android.auth.social.*

    val result = client.loginWithOAuth(
        context = context,
        options = OAuthLoginOptions(
            provider = OAuthProvider.GOOGLE,
            redirectUri = "myapp://oauth-callback",
        ),
    )
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

    final result = await client.loginWithOAuth(
      OAuthLoginOptions(
        provider: OAuthProvider.google,
        redirectUri: 'myapp://oauth-callback',
      ),
    );
    ```

    Set `prefersEphemeralSession: true` (iOS only) to start every login from a clean session with no shared browser cookies.
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    const result = await client.loginWithOAuth({
      provider: "google",
      redirectUri: "myapp://oauth-callback",
    });
    ```

    Set `prefersEphemeralSession: true` (iOS only) to start every login from a clean session with no shared browser cookies.
  </Tab>
</Tabs>

## Handle the result

On success the SDK persists the access and refresh tokens in the platform's secure store and returns the outcome. Handle the two cases: the user is logged in, or an email OTP step is required. A dismissed page surfaces as a cancellation — usually swallowed rather than shown as an error.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    import PreludeAuth
    import PreludeAuthSocial

    do {
        let result = try await client.loginWithOAuth(
            OAuthLoginOptions(
                provider: .google,
                redirectURI: URL(string: "myapp://oauth-callback")!
            )
        )
        switch result {
        case .loggedIn(let user):
            // User is now authenticated.
        case .otpRequired(let challenge, let email):
            // Provider email unverified — a code was sent to `email`.
            // Route to your OTP screen; see "Verify email via OTP".
        }
    } catch PreludeAuthError.cancelled {
        // User dismissed the page — not an error.
    } catch PreludeAuthError.conflict {
        // Another social login is already in progress.
    } catch PreludeAuthError.invalidConfiguration {
        // redirectURI is not a custom scheme.
    } catch PreludeAuthError.expiredChallengeToken {
        // Login expired — restart the flow.
    } catch PreludeAuthError.rateLimited {
        // Too many attempts.
    }
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    import so.prelude.android.auth.*
    import so.prelude.android.auth.social.*

    try {
        val result = client.loginWithOAuth(
            context = context,
            options = OAuthLoginOptions(
                provider = OAuthProvider.GOOGLE,
                redirectUri = "myapp://oauth-callback",
            ),
        )
        when (result) {
            is FinalizeOAuthLoginResult.LoggedIn -> {
                // result.user is now authenticated.
            }
            is FinalizeOAuthLoginResult.OtpRequired -> {
                // Provider email unverified — a code was sent to result.email.
                // Route to your OTP screen; see "Verify email via OTP".
            }
        }
    } catch (e: PreludeAuthError.Cancelled) {
        // User dismissed the page — not an error.
    } catch (e: PreludeAuthError.Conflict) {
        // Another social login is already in progress.
    } catch (e: PreludeAuthError.InvalidConfiguration) {
        // redirectUri is not a custom scheme.
    } catch (e: PreludeAuthError.RateLimited) {
        // Too many attempts.
    }
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

    try {
      final result = await client.loginWithOAuth(
        OAuthLoginOptions(
          provider: OAuthProvider.google,
          redirectUri: 'myapp://oauth-callback',
        ),
      );
      switch (result) {
        case OAuthLoggedIn(:final user):
          // User is now authenticated.
        case OAuthOtpRequired(:final challenge, :final email):
          // Provider email unverified — a code was sent to `email`.
          // Route to your OTP screen; see "Verify email via OTP".
      }
    } on CancelledException {
      // User dismissed the page — not an error.
    } on ConflictException {
      // Another social login is already in progress.
    } on InvalidConfigurationException {
      // redirectUri is not a custom scheme.
    } on RateLimitedException {
      // Too many attempts.
    }
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    import {
      CancelledError,
      ConflictError,
      InvalidConfigurationError,
      RateLimitedError,
    } from "@prelude.so/react-native-auth-sdk";

    try {
      const result = await client.loginWithOAuth({
        provider: "google",
        redirectUri: "myapp://oauth-callback",
      });
      if (result.kind === "loggedIn") {
        // result.user is now authenticated.
      } else if (result.kind === "otpRequired") {
        // Provider email unverified — a code was sent to result.email.
        // Route to your OTP screen; see "Verify email via OTP".
      }
    } catch (e) {
      if (e instanceof CancelledError) {
        // User dismissed the page — not an error.
      } else if (e instanceof ConflictError) {
        // Another social login is already in progress.
      } else if (e instanceof InvalidConfigurationError) {
        // redirectUri is not a custom scheme.
      } else if (e instanceof RateLimitedError) {
        // Too many attempts.
      } else {
        throw e;
      }
    }
    ```
  </Tab>
</Tabs>

## Verify email via OTP

When the provider config has [`verify_email`](/session/documentation/integration-guide/social-login#verify-email-via-otp) enabled and the IdP returns an unverified email, `loginWithOAuth` does not finalize the login. Instead it returns an OTP-required result carrying a resumable challenge and the `email` the code was sent to.

The code has already been sent. Show your OTP screen, then pass the code the user enters — along with the challenge from the result — to `checkOAuthEmailOTP`. On success it returns the authenticated user. A wrong code leaves the challenge valid so the user can retry.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    // From the .otpRequired case above:
    do {
        let user = try await client.checkOAuthEmailOTP(
            codeFromUser,
            resuming: challenge
        )
        // User is now authenticated.
    } catch PreludeAuthError.invalidOTPCode {
        // Wrong code — the challenge is still valid, let the user retry.
    }
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    // From the OtpRequired branch above:
    try {
        val user = client.checkOAuthEmailOTP(
            code = codeFromUser,
            resuming = challenge,
        )
        // User is now authenticated.
    } catch (e: PreludeAuthError.InvalidOTPCode) {
        // Wrong code — the challenge is still valid, let the user retry.
    }
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    // From the OAuthOtpRequired case above:
    try {
      final user = await client.checkOAuthEmailOTP(challenge, codeFromUser);
      // User is now authenticated.
    } on InvalidOTPCodeException {
      // Wrong code — the challenge is still valid, let the user retry.
    }
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    import { InvalidOTPCodeError } from "@prelude.so/react-native-auth-sdk";

    // From the otpRequired result above:
    try {
      const user = await client.checkOAuthEmailOTP(codeFromUser, challenge);
      // User is now authenticated.
    } catch (e) {
      if (e instanceof InvalidOTPCodeError) {
        // Wrong code — the challenge is still valid, let the user retry.
      } else {
        throw e;
      }
    }
    ```
  </Tab>
</Tabs>

## Present your own web session

`loginWithOAuth` presents the provider page for you. If you'd rather present it yourself — a custom web view or your own browser session — use the lower-level pair instead. `initiateOAuthLogin` returns the provider's authorization URL; present it, capture the `challenge_token` delivered to your redirect URI, then redeem it with `finalizeOAuthLogin`, which returns the same result type as `loginWithOAuth`.

<Tabs>
  <Tab title="iOS">
    ```swift theme={null}
    import PreludeAuth

    let context = try await client.initiateOAuthLogin(
        InitiateOAuthLoginOptions(
            provider: .google,
            redirectURI: "myapp://oauth-callback"
        )
    )
    // Present context.authorizationURL, capture the challenge_token, then:
    let result = try await client.finalizeOAuthLogin(context, challengeToken: token)
    ```
  </Tab>

  <Tab title="Android">
    ```kotlin theme={null}
    import so.prelude.android.auth.*

    val context = client.initiateOAuthLogin(
        InitiateOAuthLoginOptions(
            provider = OAuthProvider.GOOGLE,
            redirectUri = "myapp://oauth-callback",
        ),
    )
    // Present context.authorizationUrl, capture the challenge_token, then:
    val result = client.finalizeOAuthLogin(context, token)
    ```
  </Tab>

  <Tab title="Flutter">
    ```dart theme={null}
    final Uri authUrl = await client.initiateOAuthLogin(
      InitiateOAuthLoginOptions(
        provider: OAuthProvider.google,
        redirectUri: 'myapp://oauth-callback',
      ),
    );
    // Present authUrl, capture the challenge_token, then:
    final result = await client.finalizeOAuthLogin(token);
    ```
  </Tab>

  <Tab title="React Native">
    ```ts theme={null}
    const url = await client.initiateOAuthLogin({
      provider: "google",
      redirectUri: "myapp://oauth-callback",
    });
    // Present url, capture the challenge_token, then:
    const result = await client.finalizeOAuthLogin(token);
    ```
  </Tab>
</Tabs>

<Accordion title="Try it" icon="flask">
  Each example presents provider buttons, completes the login, and drops into an OTP screen when the provider's email needs verifying. Use the redirect scheme you configured in [Platform setup](#platform-setup).

  <Tabs>
    <Tab title="iOS">
      Add the `PreludeAuthSocial` product, then replace `ContentView.swift` with:

      ```swift ContentView.swift theme={null}
      import SwiftUI
      import PreludeAuth
      import PreludeAuthSocial

      private let appID = "YOUR_APP_ID"
      private let redirectURI = URL(string: "myapp://oauth-callback")!

      enum Step { case login, otp, done }

      @MainActor
      final class OAuthModel: ObservableObject {
          let client = try! PreludeAuthClient(
              endpoint: .custom("https://\(appID).session.prelude.dev")
          )

          @Published var step: Step = .login
          @Published var user: PreludeUser?
          @Published var error: String?

          private var challenge: OAuthEmailChallenge?

          func signIn(_ provider: OAuthProvider) async {
              error = nil
              do {
                  let result = try await client.loginWithOAuth(
                      OAuthLoginOptions(provider: provider, redirectURI: redirectURI)
                  )
                  switch result {
                  case .loggedIn(let user):
                      self.user = user
                      step = .done
                  case .otpRequired(let challenge, _):
                      self.challenge = challenge
                      step = .otp
                  }
              } catch PreludeAuthError.cancelled {
                  // Dismissed — leave state untouched.
              } catch {
                  self.error = "Something went wrong. Please try again."
              }
          }

          func verify(_ code: String) async {
              guard let challenge else { return }
              error = nil
              do {
                  user = try await client.checkOAuthEmailOTP(code, resuming: challenge)
                  step = .done
              } catch PreludeAuthError.invalidOTPCode {
                  error = "Wrong code. Please try again."
              } catch {
                  self.error = "Something went wrong. Please try again."
              }
          }
      }

      struct ContentView: View {
          @StateObject private var model = OAuthModel()
          @State private var code = ""

          private let providers: [OAuthProvider] = [.google, .apple, .microsoft, .github, .okta, .facebook]

          var body: some View {
              VStack(spacing: 12) {
                  switch model.step {
                  case .login:
                      ForEach(providers, id: \.self) { provider in
                          Button("Continue with \(provider.rawValue.capitalized)") {
                              Task { await model.signIn(provider) }
                          }
                          .buttonStyle(.borderedProminent)
                      }
                  case .otp:
                      TextField("Enter code", text: $code)
                          .keyboardType(.numberPad)
                          .textFieldStyle(.roundedBorder)
                      Button("Verify Code") {
                          Task { await model.verify(code) }
                      }
                      .buttonStyle(.borderedProminent)
                  case .done:
                      Text("Logged in").font(.headline)
                      Text(model.user?.profile.userID ?? "—").font(.caption.monospaced())
                  }
                  if let error = model.error {
                      Text(error).foregroundStyle(.red).font(.caption)
                  }
              }
              .padding()
          }
      }
      ```
    </Tab>

    <Tab title="Android">
      Add `androidx.browser` and declare `OAuthRedirectActivity` (see [Platform setup](#platform-setup)), then replace `MainActivity.kt` with:

      ```kotlin MainActivity.kt theme={null}
      package com.example.sessiontest

      import android.content.Context
      import android.os.Bundle
      import androidx.activity.ComponentActivity
      import androidx.activity.compose.setContent
      import androidx.compose.foundation.layout.*
      import androidx.compose.material3.*
      import androidx.compose.runtime.*
      import androidx.compose.ui.Modifier
      import androidx.compose.ui.platform.LocalContext
      import androidx.compose.ui.unit.dp
      import androidx.lifecycle.ViewModel
      import androidx.lifecycle.viewModelScope
      import androidx.lifecycle.viewmodel.compose.viewModel
      import kotlinx.coroutines.launch
      import so.prelude.android.auth.*
      import so.prelude.android.auth.social.*
      import java.net.URL

      private const val APP_ID = "YOUR_APP_ID"
      private const val REDIRECT_URI = "myapp://oauth-callback"

      enum class Step { Login, Otp, Done }

      class OAuthViewModel(ctx: Context) : ViewModel() {
          val client = PreludeAuthClient(
              context = ctx,
              baseUrl = URL("https://$APP_ID.session.prelude.dev"),
          )

          var step by mutableStateOf(Step.Login); private set
          var user by mutableStateOf<PreludeUser?>(null); private set
          var error by mutableStateOf<String?>(null); private set

          private var challenge: OAuthEmailChallenge? = null

          fun signIn(context: Context, provider: OAuthProvider) {
              error = null
              viewModelScope.launch {
                  try {
                      val result = client.loginWithOAuth(
                          context = context,
                          options = OAuthLoginOptions(provider = provider, redirectUri = REDIRECT_URI),
                      )
                      when (result) {
                          is FinalizeOAuthLoginResult.LoggedIn -> {
                              user = result.user
                              step = Step.Done
                          }
                          is FinalizeOAuthLoginResult.OtpRequired -> {
                              challenge = result.challenge
                              step = Step.Otp
                          }
                      }
                  } catch (e: PreludeAuthError.Cancelled) {
                      // Dismissed — leave state untouched.
                  } catch (e: PreludeAuthError) {
                      error = "Something went wrong. Please try again."
                  }
              }
          }

          fun verify(code: String) {
              val challenge = challenge ?: return
              error = null
              viewModelScope.launch {
                  try {
                      user = client.checkOAuthEmailOTP(code = code, resuming = challenge)
                      step = Step.Done
                  } catch (e: PreludeAuthError.InvalidOTPCode) {
                      error = "Wrong code. Please try again."
                  } catch (e: PreludeAuthError) {
                      error = "Something went wrong. Please try again."
                  }
              }
          }
      }

      class MainActivity : ComponentActivity() {
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContent {
                  MaterialTheme {
                      Surface {
                          val vm: OAuthViewModel = viewModel(factory = viewModelFactory(applicationContext))
                          val context = LocalContext.current
                          var code by remember { mutableStateOf("") }
                          val providers = listOf(
                              OAuthProvider.GOOGLE, OAuthProvider.APPLE, OAuthProvider.MICROSOFT,
                              OAuthProvider.GITHUB, OAuthProvider.OKTA, OAuthProvider.FACEBOOK,
                          )

                          Column(Modifier.fillMaxSize().padding(24.dp)) {
                              when (vm.step) {
                                  Step.Login -> providers.forEach { provider ->
                                      Button(
                                          onClick = { vm.signIn(context, provider) },
                                          modifier = Modifier.fillMaxWidth(),
                                      ) { Text("Continue with ${provider.name.lowercase().replaceFirstChar { it.uppercase() }}") }
                                      Spacer(Modifier.height(8.dp))
                                  }
                                  Step.Otp -> {
                                      OutlinedTextField(
                                          value = code, onValueChange = { code = it },
                                          label = { Text("Enter code") },
                                          modifier = Modifier.fillMaxWidth(),
                                      )
                                      Spacer(Modifier.height(12.dp))
                                      Button(onClick = { vm.verify(code) }) { Text("Verify Code") }
                                  }
                                  Step.Done -> {
                                      Text("Logged in", style = MaterialTheme.typography.headlineSmall)
                                      Text(vm.user?.profile?.userId ?: "—")
                                  }
                              }
                              vm.error?.let {
                                  Spacer(Modifier.height(12.dp))
                                  Text(it, color = MaterialTheme.colorScheme.error)
                              }
                          }
                      }
                  }
              }
          }
      }
      ```

      (`viewModelFactory` is the small helper defined in [Introduction](/session/documentation/frontend-sdks/mobile/introduction#helpers).)
    </Tab>

    <Tab title="Flutter">
      Add `androidx.browser` and declare `OAuthRedirectActivity` on Android (see [Platform setup](#platform-setup)), then replace `lib/main.dart` with:

      ```dart lib/main.dart theme={null}
      import 'package:flutter/material.dart';
      import 'package:prelude_flutter_auth_sdk/prelude_flutter_auth_sdk.dart';

      const appID = 'YOUR_APP_ID';
      const redirectUri = 'myapp://oauth-callback';

      void main() => runApp(const MyApp());

      enum _Step { login, otp, done }

      class MyApp extends StatefulWidget {
        const MyApp({super.key});
        @override
        State<MyApp> createState() => _MyAppState();
      }

      class _MyAppState extends State<MyApp> {
        late final client = PreludeAuthClient(
          endpoint: Endpoint.custom('https://$appID.session.prelude.dev'),
        );

        final _code = TextEditingController();
        _Step _step = _Step.login;
        PreludeUser? _user;
        OAuthEmailChallenge? _challenge;
        String? _error;

        static const _providers = OAuthProvider.values;

        Future<void> _signIn(OAuthProvider provider) async {
          setState(() => _error = null);
          try {
            final result = await client.loginWithOAuth(
              OAuthLoginOptions(provider: provider, redirectUri: redirectUri),
            );
            switch (result) {
              case OAuthLoggedIn(:final user):
                setState(() {
                  _user = user;
                  _step = _Step.done;
                });
              case OAuthOtpRequired(:final challenge):
                setState(() {
                  _challenge = challenge;
                  _step = _Step.otp;
                });
            }
          } on CancelledException {
            // Dismissed — leave state untouched.
          } on PreludeAuthException {
            setState(() => _error = 'Something went wrong. Please try again.');
          }
        }

        Future<void> _verify() async {
          final challenge = _challenge;
          if (challenge == null) return;
          setState(() => _error = null);
          try {
            final user = await client.checkOAuthEmailOTP(challenge, _code.text);
            setState(() {
              _user = user;
              _step = _Step.done;
            });
          } on InvalidOTPCodeException {
            setState(() => _error = 'Wrong code. Please try again.');
          } on PreludeAuthException {
            setState(() => _error = 'Something went wrong. Please try again.');
          }
        }

        @override
        void dispose() {
          _code.dispose();
          client.dispose();
          super.dispose();
        }

        @override
        Widget build(BuildContext context) {
          Widget body;
          switch (_step) {
            case _Step.login:
              body = Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  for (final provider in _providers) ...[
                    FilledButton(
                      onPressed: () => _signIn(provider),
                      child: Text('Continue with ${provider.name[0].toUpperCase()}${provider.name.substring(1)}'),
                    ),
                    const SizedBox(height: 8),
                  ],
                ],
              );
            case _Step.otp:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                TextField(
                  controller: _code,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(labelText: 'Enter code'),
                ),
                const SizedBox(height: 12),
                FilledButton(onPressed: _verify, child: const Text('Verify Code')),
              ]);
            case _Step.done:
              body = Column(mainAxisSize: MainAxisSize.min, children: [
                const Text('Logged in', style: TextStyle(fontSize: 20)),
                const SizedBox(height: 8),
                Text(_user?.profile.userID ?? '—',
                    style: const TextStyle(fontFamily: 'monospace')),
              ]);
          }

          return MaterialApp(
            home: Scaffold(
              body: Padding(
                padding: const EdgeInsets.all(24),
                child: Center(
                  child: Column(mainAxisSize: MainAxisSize.min, children: [
                    body,
                    if (_error != null) ...[
                      const SizedBox(height: 12),
                      Text(_error!, style: const TextStyle(color: Colors.red)),
                    ],
                  ]),
                ),
              ),
            ),
          );
        }
      }
      ```
    </Tab>

    <Tab title="React Native">
      Register the redirect scheme (see [Platform setup](#platform-setup)), then replace `app/index.tsx` with:

      ```tsx app/index.tsx theme={null}
      import {
        CancelledError,
        Endpoint,
        InvalidOTPCodeError,
        OAuthEmailChallenge,
        OAuthProvider,
        PreludeAuthClient,
        PreludeAuthError,
        PreludeUser,
      } from "@prelude.so/react-native-auth-sdk";
      import { useEffect, useRef, useState } from "react";
      import { Button, Text, TextInput, View } from "react-native";

      const APP_ID = "YOUR_APP_ID";
      const REDIRECT_URI = "myapp://oauth-callback";

      type Step = "login" | "otp" | "done";
      const providers: OAuthProvider[] = ["google", "apple", "microsoft", "github", "okta", "facebook"];

      export default function Home() {
        const clientRef = useRef<PreludeAuthClient | null>(null);
        const challengeRef = useRef<OAuthEmailChallenge | null>(null);
        const [step, setStep] = useState<Step>("login");
        const [code, setCode] = useState("");
        const [user, setUser] = useState<PreludeUser | null>(null);
        const [error, setError] = useState<string | null>(null);

        useEffect(() => {
          const c = new PreludeAuthClient({
            endpoint: Endpoint.custom(`https://${APP_ID}.session.prelude.dev`),
          });
          clientRef.current = c;
          return () => { c.dispose().catch(() => {}); };
        }, []);

        async function signIn(provider: OAuthProvider) {
          setError(null);
          try {
            const result = await clientRef.current!.loginWithOAuth({
              provider,
              redirectUri: REDIRECT_URI,
            });
            if (result.kind === "loggedIn") {
              setUser(result.user);
              setStep("done");
            } else {
              challengeRef.current = result.challenge;
              setStep("otp");
            }
          } catch (e) {
            if (e instanceof CancelledError) return; // Dismissed.
            if (e instanceof PreludeAuthError) setError("Something went wrong. Please try again.");
            else throw e;
          }
        }

        async function verify() {
          if (!challengeRef.current) return;
          setError(null);
          try {
            const u = await clientRef.current!.checkOAuthEmailOTP(code, challengeRef.current);
            setUser(u);
            setStep("done");
          } catch (e) {
            if (e instanceof InvalidOTPCodeError) setError("Wrong code. Please try again.");
            else if (e instanceof PreludeAuthError) setError("Something went wrong. Please try again.");
            else throw e;
          }
        }

        return (
          <View style={{ padding: 24, gap: 8 }}>
            {step === "login" && providers.map((provider) => (
              <Button
                key={provider}
                title={`Continue with ${provider[0].toUpperCase()}${provider.slice(1)}`}
                onPress={() => signIn(provider)}
              />
            ))}
            {step === "otp" && (
              <>
                <TextInput
                  placeholder="Enter code"
                  value={code}
                  onChangeText={setCode}
                  keyboardType="number-pad"
                  style={{ borderWidth: 1, padding: 8 }}
                />
                <Button title="Verify Code" onPress={verify} />
              </>
            )}
            {step === "done" && user && (
              <>
                <Text style={{ fontWeight: "600" }}>Logged in</Text>
                <Text>{user.profile.userID ?? "—"}</Text>
              </>
            )}
            {error && <Text style={{ color: "red" }}>{error}</Text>}
          </View>
        );
      }
      ```
    </Tab>
  </Tabs>
</Accordion>
