Setup
The Serverpod Auth module provides generic OAuth2 utilities that simplify implementing custom identity providers. These utilities handle the complex OAuth2 authorization code flow with PKCE (Proof Key for Code Exchange), allowing you to integrate any OAuth2-compliant provider without dealing with low-level protocol details.
The OAuth2 utility consists of client-side and server-side components that work together to securely authenticate users:
- Client-side (
OAuth2PkceUtil): Manages the authorization flow in your Flutter app, handling browser redirects and PKCE challenge generation. - Server-side (
OAuth2PkceUtil): Exchanges authorization codes for access tokens on your backend.
The GitHub IDP is built using these utilities, serving as a reference implementation for developers creating custom providers.
Understanding OAuth2 with PKCEβ
OAuth2 with PKCE is an authorization protocol that allows users to grant your application access to their data without sharing passwords. The PKCE extension adds an additional security layer, particularly important for mobile and public clients.
The OAuth2 Flowβ
Here's how the complete flow works:
- Generate Code Verifier: Client generates a random cryptographic string (code verifier).
- Generate Code Challenge: Client creates a SHA-256 hash of the verifier (code challenge).
- Authorization Request: Client redirects user to provider with the code challenge.
- User Authorizes: User logs in and grants permissions.
- Receive Code: Provider redirects back with an authorization code.
- Token Exchange: Client sends code + verifier to your backend.
- Backend Exchange: Backend exchanges code + verifier for access token.
- Access Protected Resources: Use access token to fetch user information.
PKCE ensures that even if an attacker intercepts the authorization code, they cannot exchange it for an access token without the original code verifier.
Server-Side Implementationβ
Configurationβ
Configurationβ
The OAuth2 utility requires a configuration object that defines how your server communicates with the OAuth2 provider's token endpoint. Create a server-side configuration for token exchange:
import 'package:serverpod_auth_idp_server/core.dart';
final config = OAuth2PkceServerConfig(
// Token endpoint URL for exchanging authorization codes
tokenEndpointUrl: Uri.https('oauth.provider.com', '/oauth/token'),
// OAuth client ID (must match client-side)
clientId: pod.getPassword('myProviderClientId')!,
// OAuth client secret (keep secure!)
clientSecret: pod.getPassword('myProviderClientSecret')!,
// Function to parse token response from provider
parseTokenResponse: (data) {
// Your parse logic here
},
// Optional: Where to send credentials (default: header)
credentialsLocation: OAuth2CredentialsLocation.header,
// Optional: Custom parameter names for credentials
clientIdKey: 'client_id',
clientSecretKey: 'client_secret',
// Optional: Custom headers for token requests
tokenRequestHeaders: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
// Optional: Additional parameters for token exchange
tokenRequestParams: {
'grant_type': 'authorization_code',
},
);
The credentialsLocation parameter controls how your client credentials are sent to the OAuth2 provider:
- Header mode (recommended): Credentials are placed in the
Authorizationheader using HTTP Basic authentication. This follows RFCβ―6749 and is generally more secure, since sensitive values don't appear in the request body or logs. - Body mode: Credentials are sent as form parameters in the request body.Use this only if your provider doesn't support header-based authentication.
Exchanging Tokensβ
Using the previously created config object, create the OAuth2PkceUtil on your endpoint to exchange the authorization code:
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
class MyProviderIdpEndpoint extends IdpBaseEndpoint {
final oauth2Util = OAuth2PkceUtil(config: config);
Future<AuthSuccess> authenticate(
Session session, {
required String code,
required String codeVerifier,
required String redirectUri,
}) async {
try {
// Exchange authorization code for token response
final tokenResponse = await oauth2Util.exchangeCodeForToken(
code: code,
codeVerifier: codeVerifier,
redirectUri: redirectUri,
);
// Fetch user information from provider using access token
final userInfo = await _fetchUserInfo(session, tokenResponse.accessToken);
// Authenticate (find existing account or create new user)
final account = await _authenticate(session, userInfo);
// Issue authentication token to client
return await _issueToken(
session,
authUserId: account.authUserId,
scopes: account.scopes,
);
} on OAuth2InvalidResponseException catch (e) {
session.log('Invalid token response: ${e.message}');
throw Exception('Authentication failed');
} on OAuth2MissingAccessTokenException catch (e) {
session.log('Missing access token: ${e.message}');
throw Exception('Authentication failed');
} on OAuth2NetworkErrorException catch (e) {
session.log('Network error: ${e.message}');
throw Exception('Network error during authentication');
}
}
Future<Map<String, dynamic>> _fetchUserInfo(
Session session,
String accessToken,
) async {
// Fetch user data from provider's API using the access token
}
Future<AccountResult> _authenticate(
Session session,
Map<String, dynamic> userInfo,
) async {
// Find existing provider account or create new user based on provider user info
// Returns provider account (e.g., GoogleAccount, GitHubAccount) with authUserId linked to AuthUser
}
Future<AuthSuccess> _issueToken(
Session session, {
required int authUserId,
required Set<Scope> scopes,
}) async {
// Issue Serverpod authentication token for the authenticated user
}
}
### Exception Handling
The server-side utility throws these exceptions:
| Exception | Description | Typical Cause |
| ----------- | ------------- | --------------- |
| `OAuth2InvalidResponseException` | Invalid response from provider | HTTP errors, malformed JSON |
| `OAuth2MissingAccessTokenException` | Access token not in response | Provider didn't return token |
| `OAuth2NetworkErrorException` | Network failure | Timeout, connection issues |
| `OAuth2UnknownException` | Unexpected error | Unknown problems |
## Client-Side Implementation
### Configuration
The client-side OAuth2 utility also requires a configuration object that defines your provider's OAuth2 endpoints and authorization parameters. Create a client-side configuration:
```dart
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
final config = OAuth2PkceProviderClientConfig(
// Your provider's authorization endpoint
authorizationEndpoint: Uri.https('oauth.provider.com', '/oauth/authorize'),
// OAuth client ID from your provider
clientId: 'your-client-id',
// Callback URI registered with your provider
redirectUri: 'myapp://auth-callback',
// URL scheme for the callback
callbackUrlScheme: 'myapp',
// Default permission scopes to request
defaultScopes: ['profile', 'email'],
// Additional query parameters for authorization request
additionalAuthParams: {
'response_mode': 'query',
},
// Separator for joining scopes (default: ' ')
scopeSeparator: ' ',
// Enable state parameter for CSRF protection (default: true)
enableState: true,
// Enable PKCE for OAuth2 flow (default: true)
enablePKCE: true,
);
Initiating Authorizationβ
Using the previously created config object, create an OAuth2PkceUtil instance to start the authorization flow:
final oauth2Util = OAuth2PkceUtil(config: config);
try {
final result = await oauth2Util.authorize(
// Optional: override default scopes
scopes: ['profile', 'email'],
);
// The authorization code to exchange for an access token
final code = result.code;
// The PKCE code verifier (required for token exchange)
final codeVerifier = result.codeVerifier;
// Send both to your backend
await client.myProviderIdp.authenticate(
code: code,
codeVerifier: codeVerifier,
redirectUri: config.redirectUri,
);
} on OAuth2PkceUserCancelledException catch (e) {
// User cancelled the authorization flow
print('User cancelled: ${e.message}');
} on OAuth2PkceStateMismatchException catch (e) {
// Possible CSRF attack detected
print('Security error: ${e.message}');
} on OAuth2PkceMissingAuthorizationCodeException catch (e) {
// No authorization code in callback
print('Authorization failed: ${e.message}');
} on OAuth2PkceProviderErrorException catch (e) {
// Provider returned an error
print('Provider error: ${e.message}');
} on OAuth2PkceUnknownException catch (e) {
// Unexpected error
print('Unknown error: ${e.message}');
}
Exception Handlingβ
The client-side utility throws specific exceptions to help you handle different error scenarios:
| Exception | Description | Typical Cause |
|---|---|---|
OAuth2PkceUserCancelledException | User cancelled authorization | User closed browser/denied access |
OAuth2PkceStateMismatchException | State validation failed | Possible CSRF attack or browser issue |
OAuth2PkceMissingAuthorizationCodeException | No authorization code received | Provider didn't return expected code |
OAuth2PkceProviderErrorException | Provider returned error response | Invalid credentials, rate limiting |
OAuth2PkceUnknownException | Unexpected error occurred | Network issues, unknown problems |
Platform-Specific Configurationβ
The OAuth2 utility uses the flutter_web_auth_2 package under the hood, which requires platform-specific setup.
iOS and macOSβ
There is no special configuration needed for iOS and MacOS for "normal" authentication flows. However, if you are using Universal Links on iOS, they require redirect URIs to use https. Follow the instructions in the flutter_web_auth_2 documentation.
Androidβ
Add the callback activity to your AndroidManifest.xml:
<manifest>
<application>
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Replace with your actual callback URL scheme -->
<data android:scheme="myapp" />
</intent-filter>
</activity>
</application>
</manifest>
Webβ
Create an HTML callback page in your ./web folder (e.g., auth.html):
<!DOCTYPE html>
<title>Authentication complete</title>
<p>Authentication is complete. If this does not happen automatically, please close the window.</p>
<script>
function postAuthenticationMessage() {
const message = {
'flutter-web-auth-2': window.location.href
};
if (window.opener) {
window.opener.postMessage(message, window.location.origin);
window.close();
} else if (window.parent && window.parent !== window) {
window.parent.postMessage(message, window.location.origin);
} else {
localStorage.setItem('flutter-web-auth-2', window.location.href);
window.close();
}
}
postAuthenticationMessage();
</script>
You only need a single callback file (e.g. auth.html) in your ./web folder.
This file is shared across all IDPs that use the OAuth2 utility, as long as your redirect URIs point to it.
Make sure your redirect URI points to the callback file, e.g. https://yourdomain.com/auth.html
Complete Example of a Custom Providerβ
For a full endβtoβend implementation of a custom OAuth2 provider β including server configuration, client setup and integration of all components β see the Complete Example page.
Best Practicesβ
Security Considerationsβ
- Always Use PKCE: Keep
enablePKCE: truein your client configuration. PKCE protects against authorization code interception attacks. - Validate State Parameter: Keep
enableState: trueto prevent CSRF attacks. The state parameter ensures the authorization response matches your request. - Secure Client Secret: Never expose your client secret in client-side code. Store it securely in
passwords.yamlor environment variables on the server. - Use HTTPS: Always use HTTPS URLs for production endpoints. Only use HTTP for local development.
- Validate Redirect URIs: Ensure redirect URIs in your code exactly match those registered with your OAuth provider.
Error Handlingβ
- Catch Specific Exceptions: Handle each exception type appropriately rather than using generic catch-all handlers.
- Log Securely: Log errors for debugging but never log sensitive data like tokens or secrets.
- User-Friendly Messages: Show clear, actionable error messages to users without exposing technical details.