Flutter中基于授权码模式的OAuth2客户端对接Intuit QuickBooks Online的技术咨询
Hey there! Let’s walk through your QuickBooks OAuth2 implementation in Flutter Web, clear up your questions, and fix up your code to align with the Authorization Code Grant Type properly.
First, let’s correct a key misunderstanding in your current approach: the Authorization Code Grant Type doesn’t return an access token directly in the URL fragment (that’s the Implicit Flow, which is less secure). The correct flow for your use case is:
- User triggers the auth flow, opening a QuickBooks login popup with
response_type=code - After user authorization, QuickBooks redirects to your configured
redirect_uriwith a temporarycodeparameter - Your app uses this code (plus your client ID/secret) to call Intuit’s token endpoint and exchange it for a valid
access_tokenandrefresh_token - Use the access token to call QuickBooks API, and use the refresh token to get a new access token when the old one expires
2.1 Token & Authorization Code Storage
- Authorization Code: Don’t store this! It’s a one-time use token that expires quickly. You should exchange it for access/refresh tokens immediately after receiving it.
- Access Token & Refresh Token: Yes, these should be stored, but with security in mind:
- Use Firestore, but lock it down with Firebase Security Rules so each user can only access their own token document.
- The refresh token is long-lived, so avoid exposing it to the frontend. It’s safer to let a backend (like Cloud Functions) hold and manage the refresh token, while the frontend only uses the short-lived access token.
2.2 Role of Cloud Functions
Cloud Functions are critical here for two big reasons:
- Security: You can’t expose your
client_secretin frontend Flutter code (it would be visible to anyone inspecting your app’s source). Cloud Functions run server-side, so you can safely store the client secret there and handle the token exchange. - Token Management: Use Cloud Functions to handle refresh token logic (when the access token expires, call Intuit’s endpoint to get a new one) and manage Firestore token storage. This keeps your frontend code clean and secure.
- Optional Firestore Proxy: While frontend can write directly to Firestore, using Cloud Functions adds an extra layer of validation to ensure only authorized users can store/retrieve tokens.
2.3 User Sessions & State Management
- User Sessions: Pair your QuickBooks auth with Firebase Auth (even if you use email/password login) to create a stable user identity. You can link QuickBooks tokens to a Firebase Auth UID, making it easy to associate tokens with specific users.
- State Management: Use a package like Riverpod, Provider, or Bloc to manage the access token state across your app. This lets you easily access the token from any screen, trigger refresh logic when it expires, and update the UI when the token state changes.
Let’s revise your code to follow the correct Authorization Code Flow and address security gaps.
3.1 Redirect Page (static.html)
First, create a simple HTML page at your redirect_uri to capture the authorization code and send it back to your Flutter app:
<!-- static.html --> <!DOCTYPE html> <html> <head> <title>QuickBooks Auth Redirect</title> </head> <body> <script> const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const state = urlParams.get('state'); // Send code/state to parent Flutter window window.opener.postMessage({ type: 'quickbooks_auth', code: code, state: state }, window.opener.origin); window.close(); </script> </body> </html>
3.2 Updated Flutter Code
Modify your _MyHomePageState to listen for the authorization code and call a Cloud Function to exchange it for tokens:
class _MyHomePageState extends State<MyHomePage> { late html.WindowBase _popupWin; String? _accessToken; final String _expectedState = "security_token=138r5719ru3e1&url=https://qb-payment-app.web.app/"; // Match your auth state @override void initState() { super.initState(); // Listen for auth code from static.html html.window.onMessage.listen((event) { if (event.data['type'] == 'quickbooks_auth') { final receivedCode = event.data['code']; final receivedState = event.data['state']; // Validate state to prevent CSRF attacks if (receivedState == _expectedState) { _exchangeCodeForToken(receivedCode); } else { print("Invalid state detected - potential CSRF risk!"); } _popupWin.close(); } }); // Open auth popup on app load WidgetsBinding.instance!.addPostFrameCallback((_) { final currentUri = Uri.base; final redirectUri = Uri( host: currentUri.host, scheme: currentUri.scheme, port: currentUri.port, path: '/static.html', ).toString(); final authUrl = Uri.parse('https://appcenter.intuit.com/connect/oauth2') .replace(queryParameters: { 'client_id': clientId, 'response_type': 'code', 'scope': 'com.intuit.quickbooks.accounting', 'redirect_uri': redirectUri, 'state': _expectedState, }).toString(); _popupWin = html.window.open( authUrl, "Intuit QuickBooks Online Auth", "width=800, height=900, scrollbars=yes" ); }); } // Call Cloud Function to exchange code for tokens Future<void> _exchangeCodeForToken(String code) async { try { // Replace with your Cloud Function URL final response = await http.post( Uri.parse('https://us-central1-your-project.cloudfunctions.net/exchangeQuickBooksCode'), body: jsonEncode({ 'code': code, 'redirect_uri': 'https://qb-payment-app.web.app/static.html' }), headers: {'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { final tokenData = jsonDecode(response.body); final accessToken = tokenData['access_token']; final refreshToken = tokenData['refresh_token']; // Store tokens in Firestore (linked to Firebase Auth user) await FirebaseFirestore.instance .collection('user_tokens') .doc(FirebaseAuth.instance.currentUser!.uid) .set({ 'access_token': accessToken, 'refresh_token': refreshToken, 'expires_at': DateTime.now().add(Duration(seconds: tokenData['expires_in'])), }); setState(() { _accessToken = accessToken; }); print("Successfully authenticated with QuickBooks!"); } else { print("Token exchange failed: ${response.body}"); } } catch (e) { print("Error during token exchange: $e"); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('QuickBooks Auth Demo')), body: Center( child: _accessToken == null ? const CircularProgressIndicator() : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Authenticated Successfully!'), const SizedBox(height: 16), Text('Access Token: ${_accessToken!.substring(0, 20)}...'), ], ), ), ); } }
3.3 Cloud Function for Token Exchange
Create a Node.js Cloud Function to handle the secure token exchange (this keeps your client secret hidden):
const functions = require("firebase-functions"); const axios = require("axios"); exports.exchangeQuickBooksCode = functions.https.onRequest(async (req, res) => { const { code, redirect_uri } = req.body; const clientId = "YOUR_QUICKBOOKS_CLIENT_ID"; const clientSecret = "YOUR_QUICKBOOKS_CLIENT_SECRET"; try { const tokenResponse = await axios.post( "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", new URLSearchParams({ grant_type: "authorization_code", code: code, redirect_uri: redirect_uri, }), { auth: { username: clientId, password: clientSecret }, headers: { "Content-Type": "application/x-www-form-urlencoded" }, } ); res.status(200).send(tokenResponse.data); } catch (error) { functions.logger.error("Token exchange error:", error); res.status(error.response.status).send(error.response.data); } });
- Firebase Security Rules: Lock down your Firestore
user_tokenscollection to only allow access by the owner:rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /user_tokens/{userId} { allow read, write: if request.auth != null && request.auth.uid == userId; } } } - Token Refresh Logic: Add another Cloud Function to refresh access tokens when they expire, using the stored refresh token.
- Error Handling: Add user-friendly error messages for cases like failed auth, popup closure without authorization, or token expiration.
- Firebase Auth Integration: Require users to log into Firebase Auth first, so you have a stable UID to link QuickBooks tokens to.
内容的提问来源于stack exchange,提问作者Stephen Stilwell

