Skip to main content

The core concept

The login page decides the OAuth flow based on user agent. When your login page loads, check if it’s running in Despia:
const userAgent = navigator.userAgent.toLowerCase();
const isDespia = userAgent.includes('despia');

if (isDespia) {
  // Use Despia native flow
  // redirect_uri: https://yourapp.com/native-callback
} else {
  // Use standard web flow  
  // redirect_uri: https://yourapp.com/auth
}
Why this matters: The OAuth browser session (ASWebAuthenticationSession/Chrome Custom Tabs) has the browser’s user agent, not Despia’s. So you can’t check user agent in the callback page - you must decide the flow on the login page.

Two complete flows

Web flow (standard OAuth)

When: userAgent doesn’t include ‘despia’ Flow:
  1. Login page → redirects to OAuth provider
  2. OAuth provider → redirects back to /auth
  3. /auth page → sets session, navigates to /dashboard
Code - Login page:
// On login button click
async function handleLogin() {
  const userAgent = navigator.userAgent.toLowerCase();
  const isDespia = userAgent.includes('despia');
  
  if (!isDespia) {
    // Web flow: redirect directly
    window.location.href = 'https://provider.com/oauth/authorize?' +
      'client_id=xxx&' +
      'redirect_uri=' + encodeURIComponent('https://yourapp.com/auth') + '&' +
      'response_type=code';
  }
}
Code - /auth page:
// Parse tokens from URL
const code = new URLSearchParams(window.location.search).get('code');

if (code) {
  // Exchange for tokens
  const response = await fetch('/api/token', {
    method: 'POST',
    body: JSON.stringify({ code })
  });
  
  const { access_token } = await response.json();
  
  // Store session
  localStorage.setItem('access_token', access_token);
  
  // Navigate to app
  window.location.href = '/dashboard';
}

Despia native flow

When: userAgent includes ‘despia’ Flow:
  1. Login page → calls despia('oauth://...') to open native browser
  2. OAuth provider → redirects to /native-callback (in native browser)
  3. /native-callback → extracts tokens, redirects to deeplink with oauth/ prefix
  4. Native app → intercepts deeplink, closes browser, navigates to /auth
  5. /auth page → receives tokens from URL, sets session
Code - Login page:
import despia from 'despia-native';

async function handleLogin() {
  const userAgent = navigator.userAgent.toLowerCase();
  const isDespia = userAgent.includes('despia');
  
  if (isDespia) {
    // Despia flow: open in native browser
    const oauthUrl = 'https://provider.com/oauth/authorize?' +
      'client_id=xxx&' +
      'redirect_uri=' + encodeURIComponent('https://yourapp.com/native-callback') + '&' +
      'response_type=code';
    
    // Opens ASWebAuthenticationSession (iOS) or Chrome Custom Tabs (Android)
    despia(`oauth://?url=${encodeURIComponent(oauthUrl)}`);
  }
}
Code - /native-callback page:
// This page runs inside the native browser session
// Extract tokens and close the browser

const code = new URLSearchParams(window.location.search).get('code');

if (code) {
  // Exchange for tokens
  const response = await fetch('/api/token', {
    method: 'POST',
    body: JSON.stringify({ code })
  });
  
  const { access_token, refresh_token } = await response.json();
  
  // Redirect to deeplink to CLOSE the browser
  // The oauth/ prefix tells Despia to close the browser session
  window.location.href = 
    `yourappdeeplink://oauth/auth?` +
    `access_token=${encodeURIComponent(access_token)}&` +
    `refresh_token=${encodeURIComponent(refresh_token)}`;
}
Code - /auth page (receives deeplink):
// Parse tokens from URL (deeplink redirects here)
const searchParams = new URLSearchParams(window.location.search);
const access_token = searchParams.get('access_token');
const refresh_token = searchParams.get('refresh_token');

if (access_token) {
  // Store session
  localStorage.setItem('access_token', decodeURIComponent(access_token));
  if (refresh_token) {
    localStorage.setItem('refresh_token', decodeURIComponent(refresh_token));
  }
  
  // Navigate to app
  window.location.href = '/dashboard';
}

Key differences

StepWeb FlowDespia Flow
Login pagewindow.location.href = oauthUrldespia('oauth://?url=...')
OAuth redirect/auth/native-callback
Callback actionSet session, navigateRedirect to deeplink
DeeplinkNoneyourappdeeplink://oauth/auth?tokens
Browser closesN/AWhen deeplink called
Final landing/auth (already there)/auth (via deeplink)

Apple Sign-In special handling

Apple Sign-In on iOS devices needs different handling:
async function handleAppleLogin() {
  const userAgent = navigator.userAgent.toLowerCase();
  const isIOSDespia = userAgent.includes('despia-iphone') || 
                       userAgent.includes('despia-ipad');
  const isAndroidDespia = userAgent.includes('despia-android');
  
  const appleOAuthUrl = 'https://appleid.apple.com/auth/authorize?...';
  
  if (isIOSDespia) {
    // iOS: Direct redirect (triggers native Apple dialog)
    window.location.href = appleOAuthUrl;
    
  } else if (isAndroidDespia) {
    // Android: Use oauth:// for Chrome Custom Tabs
    despia(`oauth://?url=${encodeURIComponent(appleOAuthUrl)}`);
    
  } else {
    // Web: Standard redirect
    window.location.href = appleOAuthUrl;
  }
}
Why: iOS has native Apple Sign-In built into WebKit. Direct redirect triggers the native dialog.

Troubleshooting

Browser session doesn’t open

Problem: User clicks login, nothing happens. Check:
console.log('User agent:', navigator.userAgent);
console.log('Is Despia:', navigator.userAgent.includes('despia'));
console.log('OAuth URL:', oauthUrl);
Common issues:
  • OAuth URL not properly encoded: Use encodeURIComponent()
  • Missing yourappdeeplink:// prefix: despia('oauth://?url=...') not just despia(url)
  • Testing in web browser instead of Despia app

Browser doesn’t close after login

Problem: User completes OAuth, stuck in browser. Cause: Missing oauth/ prefix in deeplink. Wrong:
// Browser won't close
window.location.href = `yourappdeeplink://auth?access_token=${token}`;
Right:
// Browser closes because of oauth/ prefix
window.location.href = `yourappdeeplink://oauth/auth?access_token=${token}`;
The oauth/ prefix in the deeplink is what tells Despia to close ASWebAuthenticationSession/Chrome Custom Tabs.

Tokens not reaching /auth page

Problem: Browser closes but user not logged in. Check /auth page:
console.log('Full URL:', window.location.href);
console.log('Access token:', new URLSearchParams(window.location.search).get('access_token'));
Common issues:
  • Tokens not encoded in callback: Use encodeURIComponent(token) when building deeplink
  • Reading from wrong place: Tokens are in query params (?), not hash (#)
  • Not decoding: Use decodeURIComponent() when parsing

Redirect URI mismatch

Problem: OAuth provider shows “redirect_uri mismatch” error. Cause: Callback URL not registered with OAuth provider. Fix:
  1. For Despia flow: Register https://yourapp.com/native-callback
  2. For web flow: Register https://yourapp.com/auth
  3. URLs must match exactly (no trailing slash differences)

Hash tokens lost in SPA routing

Problem: Tokens disappear from URL hash. Solution: Create static HTML callback page. Create /native-callback.html:
<!DOCTYPE html>
<html>
<head>
  <title>Completing sign in...</title>
</head>
<body>
  <div style="padding: 2rem; text-align: center;">
    Completing sign in...
  </div>
  
  <script>
    (function() {
      // Parse hash tokens
      const hash = window.location.hash.substring(1);
      const params = new URLSearchParams(hash);
      const access_token = params.get('access_token');
      const refresh_token = params.get('refresh_token');
      
      if (access_token) {
        // Redirect to deeplink to close browser
        window.location.href = 
          `myapp://oauth/auth?` +
          `access_token=${encodeURIComponent(access_token)}&` +
          `refresh_token=${encodeURIComponent(refresh_token || '')}`;
      }
    })();
  </script>
</body>
</html>
Update OAuth redirect:
redirect_uri: https://yourapp.com/native-callback.html
Why this works: Static HTML loads directly, no router involved, hash preserved.

Complete implementation

1. Login page (handles both flows):
import despia from 'despia-native';

async function handleLogin() {
  const userAgent = navigator.userAgent.toLowerCase();
  const isDespia = userAgent.includes('despia');
  
  // Get OAuth URL from your backend
  const response = await fetch('/api/oauth/start', {
    method: 'POST',
    body: JSON.stringify({
      // Different redirect URIs for different flows
      redirect_uri: isDespia 
        ? 'https://yourapp.com/native-callback'
        : 'https://yourapp.com/auth',
      is_native: isDespia
    }),
    headers: { 'Content-Type': 'application/json' }
  });
  
  const { oauth_url } = await response.json();
  
  if (isDespia) {
    // Despia: Open native browser session
    despia(`oauth://?url=${encodeURIComponent(oauth_url)}`);
  } else {
    // Web: Standard redirect
    window.location.href = oauth_url;
  }
}
2. /native-callback page (Despia only):
// Runs in native browser session
(function() {
  // Get authorization code
  const code = new URLSearchParams(window.location.search).get('code');
  
  if (code) {
    // Exchange for tokens
    fetch('/api/oauth/exchange', {
      method: 'POST',
      body: JSON.stringify({ code }),
      headers: { 'Content-Type': 'application/json' }
    })
      .then(r => r.json())
      .then(({ access_token, refresh_token }) => {
        // Close browser with deeplink
        window.location.href = 
          `yourappdeeplink://oauth/auth?` +
          `access_token=${encodeURIComponent(access_token)}&` +
          `refresh_token=${encodeURIComponent(refresh_token)}`;
      });
  }
})();
3. /auth page (both flows):
// Runs on page load
(function() {
  // Check for code (web flow)
  const code = new URLSearchParams(window.location.search).get('code');
  
  if (code) {
    // Web flow: Exchange code for tokens
    fetch('/api/oauth/exchange', {
      method: 'POST',
      body: JSON.stringify({ code }),
      headers: { 'Content-Type': 'application/json' }
    })
      .then(r => r.json())
      .then(({ access_token, refresh_token }) => {
        localStorage.setItem('access_token', access_token);
        localStorage.setItem('refresh_token', refresh_token);
        window.location.href = '/dashboard';
      });
    return;
  }
  
  // Check for tokens (Despia flow via deeplink)
  const access_token = new URLSearchParams(window.location.search).get('access_token');
  
  if (access_token) {
    // Despia flow: Tokens already in URL
    const refresh_token = new URLSearchParams(window.location.search).get('refresh_token');
    
    localStorage.setItem('access_token', decodeURIComponent(access_token));
    if (refresh_token) {
      localStorage.setItem('refresh_token', decodeURIComponent(refresh_token));
    }
    
    window.location.href = '/dashboard';
  }
})();

Debug checklist

When OAuth isn’t working: On login page:
  • Check user agent: console.log(navigator.userAgent)
  • Verify correct flow selected
  • Verify OAuth URL is valid
  • Verify OAuth URL is encoded
In /native-callback (Despia only):
  • Check page loads: console.log('Callback loaded')
  • Check tokens received: console.log('Token:', !!access_token)
  • Check deeplink format: myapp://oauth/auth?...
  • Verify oauth/ prefix present
In /auth page:
  • Check URL: console.log(window.location.href)
  • Check for code (web): console.log('Code:', code)
  • Check for tokens (Despia): console.log('Token:', access_token)
  • Verify tokens stored in localStorage
  • Verify navigation to /dashboard happens

Remember

The login page determines the flow.
  • Check navigator.userAgent.includes('despia')
  • If true → Despia flow with despia('oauth://...') and /native-callback
  • If false → Web flow with standard redirect and /auth
**The **oauth/prefix is critical.
  • Deeplink format: yourappdeeplink://oauth/auth?tokens
  • Without oauth/ → browser won’t close
  • With oauth/ → browser closes and app receives tokens
Apple Sign-In on iOS is special.
  • Check for despia-iphone or despia-ipad
  • Use direct redirect (no oauth:// prefix)
  • Native Apple dialog opens automatically

For support or questions, contact: [email protected]