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:
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
Test HTTP server method, verify localhost redirect works
Test deep links, verify app reopens after auth
Test manual code entry on all platforms
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.