← Back to Docs

Web OAuth Integration

Complete guide to implementing unified web-based OAuth authentication that works across all platforms.

Problem Statement

Different platforms require different OAuth redirect URIs:

  • Mobile (Android/iOS): Custom URI scheme: miyolist://auth
  • Desktop (Windows/Linux): Localhost: http://localhost:8080/auth
  • macOS: May have different requirements

⚠️ Problem:

AniList OAuth only allows one redirect URI per client. Managing multiple platform-specific flows is complex and error-prone.

Solution: Unified Web Gateway

Use a single web-based OAuth flow accessible from all platforms:

https://miyo.my/auth/callback
✓ Single URI
One redirect for all platforms
✓ Consistent UX
Same flow everywhere
✓ Easy Maintenance
Update once, works everywhere

Architecture

┌─────────────┐
│  Flutter    │
│     App     │
└──────┬──────┘
       │ 1. Open Browser
       │    /auth/login
       ↓
┌─────────────┐
│   Website   │
│ (Astro/SSR) │
└──────┬──────┘
       │ 2. Redirect to AniList
       ↓
┌─────────────┐
│   AniList   │
│    OAuth    │
└──────┬──────┘
       │ 3. User authorizes
       │    Redirect with code
       ↓
┌─────────────┐
│  /callback  │
│   page      │
└──────┬──────┘
       │ 4. Display code
       │    Auto-close timer
       ↓
┌─────────────┐
│  Flutter    │
│   Extracts  │
│    Code     │
└──────┬──────┘
       │ 5. Exchange for token
       ↓
┌─────────────┐
│   AniList   │
│     API     │
└─────────────┘

Web Implementation

1. Login Page (/auth/login)

<script>
  const clientId = 'YOUR_CLIENT_ID';
  const redirectUri = `${window.location.origin}/auth/callback`;
  const authUrl = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;
</script>

<a href={authUrl}>
  Continue with AniList
</a>

2. Callback Page (/auth/callback)

---
const code = Astro.url.searchParams.get('code');
const error = Astro.url.searchParams.get('error');
---

{error ? (
  <div>Error: {error}</div>
) : code ? (
  <div>
    <h2>Authorization Successful!</h2>
    <p>Closing window in <span id="countdown">5</span> seconds...</p>
    
    <!-- Hidden div for app to extract code -->
    <div id="auth-code" data-code={code} style="display: none;"></div>
    
    <details>
      <summary>Manual Copy (if window doesn't close)</summary>
      <code>{code}</code>
      <button onclick="copyCode()">Copy</button>
    </details>
  </div>
) : (
  <div>Loading...</div>
)}

<script>
  // Auto-close countdown
  let seconds = 5;
  const countdown = setInterval(() => {
    seconds--;
    document.getElementById('countdown').textContent = seconds;
    if (seconds <= 0) {
      clearInterval(countdown);
      window.close();
    }
  }, 1000);

  // Copy function
  function copyCode() {
    const code = document.getElementById('auth-code').dataset.code;
    navigator.clipboard.writeText(code);
    alert('Code copied to clipboard!');
  }
</script>

Flutter Implementation

💡 Three Methods: Desktop (HTTP Server), Mobile (Deep Links), Fallback (Manual Entry)

Method A: HTTP Server (Desktop)

Best for Windows, Linux, macOS desktop apps.

import 'dart:io';
import 'package:url_launcher/url_launcher.dart';

Future<String?> authenticateWithWeb() async {
  // 1. Start local server
  final server = await HttpServer.bind('localhost', 8080);
  
  // 2. Open web auth in browser
  final webAuthUrl = 'https://miyo.my/auth/login';
  if (await canLaunchUrl(Uri.parse(webAuthUrl))) {
    await launchUrl(
      Uri.parse(webAuthUrl),
      mode: LaunchMode.externalApplication,
    );
  }
  
  // 3. Wait for redirect
  await for (final request in server) {
    final code = request.uri.queryParameters['code'];
    
    if (code != null) {
      // Send success response
      request.response
        ..statusCode = 200
        ..write('Success! You can close this window.')
        ..close();
      
      // Cleanup
      await server.close();
      
      return code;
    }
  }
  
  return null;
}

Method B: Deep Links (Mobile)

Best for Android and iOS apps.

// AndroidManifest.xml
<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="miyolist" android:host="auth" />
</intent-filter>

// Dart code
import 'package:uni_links/uni_links.dart';

StreamSubscription? _linkSubscription;

void initDeepLinks() {
  _linkSubscription = uriLinkStream.listen((Uri? uri) {
    if (uri != null && uri.scheme == 'miyolist' && uri.host == 'auth') {
      final code = uri.queryParameters['code'];
      if (code != null) {
        handleAuthCode(code);
      }
    }
  });
}

Method C: Manual Entry (Fallback)

Universal fallback for all platforms.

Future<String?> authenticateManual() async {
  // 1. Open web auth
  final webAuthUrl = 'https://miyo.my/auth/login';
  await launchUrl(Uri.parse(webAuthUrl));
  
  // 2. Show dialog for manual code entry
  return await showDialog<String>(
    context: context,
    builder: (context) => AlertDialog(
      title: Text('Enter Authorization Code'),
      content: TextField(
        controller: _codeController,
        decoration: InputDecoration(
          hintText: 'Paste code from browser',
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text('Cancel'),
        ),
        TextButton(
          onPressed: () => Navigator.pop(context, _codeController.text),
          child: Text('Submit'),
        ),
      ],
    ),
  );
}

Token Exchange

After receiving the code, exchange it for an access token.

Future<String?> exchangeCodeForToken(String code) async {
  final response = await http.post(
    Uri.parse('https://anilist.co/api/v2/oauth/token'),
    headers: {'Content-Type': 'application/json'},
    body: json.encode({
      'grant_type': 'authorization_code',
      'client_id': 'YOUR_CLIENT_ID',
      'client_secret': 'YOUR_CLIENT_SECRET',
      'redirect_uri': 'https://miyo.my/auth/callback',
      'code': code,
    }),
  );
  
  if (response.statusCode == 200) {
    final data = json.decode(response.body);
    return data['access_token'];
  }
  
  return null;
}

Security Considerations

🔒 PKCE (Proof Key for Code Exchange)

Recommended for public clients (mobile/desktop apps). Prevents authorization code interception attacks.

// Generate code verifier
final codeVerifier = base64Url.encode(List.generate(32, (_) => Random.secure().nextInt(256)));

// Generate code challenge
final codeChallenge = base64Url.encode(sha256.convert(utf8.encode(codeVerifier)).bytes);

// Add to auth URL
final authUrl = 'https://anilist.co/api/v2/oauth/authorize?'
    'client_id=$clientId&'
    'redirect_uri=$redirectUri&'
    'response_type=code&'
    'code_challenge=$codeChallenge&'
    'code_challenge_method=S256';

🔑 Environment Variables

Never hardcode client secrets in your code. Use environment variables or secure storage.

// .env file
ANILIST_CLIENT_ID=your_client_id
ANILIST_CLIENT_SECRET=your_client_secret

// Flutter usage
import 'package:flutter_dotenv/flutter_dotenv.dart';

final clientId = dotenv.env['ANILIST_CLIENT_ID'];

⏱️ Token Expiration

AniList access tokens don't expire, but implement refresh logic for future-proofing. Store tokens securely using flutter_secure_storage.

Deployment

Vercel

vercel --prod

Automatic deployments, edge functions, zero config

Netlify

netlify deploy --prod

Easy setup, continuous deployment, form handling

Cloudflare

wrangler pages deploy

Global CDN, workers, excellent performance

Post-Deployment Checklist

  • ✓ Update AniList redirect URI: https://yourdomain.com/auth/callback
  • ✓ Update Flutter app constants with production URL
  • ✓ Test OAuth flow on all platforms
  • ✓ Enable HTTPS (should be automatic)
  • ✓ Configure custom domain (optional)

Testing

Test Checklist

🖥️
Desktop (Windows/Linux/macOS):

Test HTTP server method, verify localhost redirect works

📱
Mobile (Android/iOS):

Test deep links, verify app reopens after auth

✏️
Fallback:

Test manual code entry on all platforms

Error Handling:

Test access_denied, network errors, timeout scenarios

Troubleshooting

Redirect URI Mismatch Error

Make sure the redirect URI in your AniList app settings exactly matches the one in your code. Include https:// and trailing paths.

Window Doesn't Auto-Close

Some browsers block window.close(). Provide manual copy fallback as shown in the callback page example.

Deep Links Not Working

Verify AndroidManifest.xml and Info.plist configurations. Test with adb: adb shell am start -a android.intent.action.VIEW -d "miyolist://auth?code=test"

Token Exchange Fails

Check that client_secret is correct, code hasn't expired (use within 10 minutes), and redirect_uri matches exactly.