diff --git a/docs/06-concepts/11-authentication/04-providers/06-github/01-setup.md b/docs/06-concepts/11-authentication/04-providers/06-github/01-setup.md new file mode 100644 index 00000000..5e3643f5 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/06-github/01-setup.md @@ -0,0 +1,272 @@ +# Setup + +To set up **Sign in with GitHub**, you must create OAuth2 credentials on [GitHub](https://github.com/settings/apps) and configure your Serverpod application accordingly. + +:::caution +You need to install the auth module before you continue, see [Setup](../../setup). +::: + +## Choosing Your GitHub App Type + +GitHub offers two ways to obtain OAuth2 credentials: + +- **GitHub Apps**: more suitable when building integrations or bots that belong to an organization or repository, operate with their own identity, continue functioning regardless of which users come and go, and only access the repositories and permissions explicitly granted. They provide fine‑grained control, short‑lived tokens, and are the modern, secure choice for most automation and service scenarios. +- **OAuth Apps**: preferred when the primary need is authenticating users with "Sign in with GitHub" or performing actions strictly as the currently logged‑in user under broad OAuth scopes. Similar to other OAuth providers like Google or Apple, they allow access to a user’s GitHub resources within the scopes granted, but lack the flexibility and security of GitHub Apps. + +:::tip +[GitHub Apps](https://github.com/settings/apps) are the preferred choice for most scenarios — especially mobile and modern integrations. +See the official comparison here: [Differences between GitHub Apps and OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps). +::: + +## Create your credentials + +1. Go to [GitHub Developer Settings](https://github.com/settings/apps). +2. Click **New GitHub App** (recommended) or **New OAuth App**. + + ![GitHub App Setup](/img/authentication/providers/github/1-register-app.png) + +3. Fill in the required fields: + - **App name** + - **Homepage URL** + - **Callback URL(s)** (use your app's redirect URI, e.g., `myapp://auth` for mobile) + - **Permissions** (at minimum: account permission = profile; add others as needed) + - **Webhook URL** (disable if not required; serves to receive events like commits, pull requests, and repo changes) + + ![GitHub App Setup](/img/authentication/providers/github/2-add-permission.png) + + ![GitHub App Setup](/img/authentication/providers/github/3-add-permission.png) + +:::tip +Webhooks let your GitHub App automatically receive notifications about repository activity. +If your app doesn’t need to react to events (like commits or pull requests), it’s best to disable the webhook URL to reduce unnecessary traffic and complexity. +::: + +4. Click **Create GitHub App** (for GitHub Apps) or **Register application** (for OAuth Apps). This will save your app and generate the **Client ID**. + +5. After the app is created, click **Generate a new client secret** to obtain the **Client Secret**. Copy both the **Client ID** and **Client Secret** for later use. + + ![GitHub App Setup](/img/authentication/providers/github/4-get-credentials.png) + +## Server-side Configuration + +### Store the Credentials + +This can be done by adding your credentials to the `githubClientId` and `githubClientSecret` keys in the `config/passwords.yaml` file, or by setting them as the values of the `SERVERPOD_PASSWORD_githubClientId` and `SERVERPOD_PASSWORD_githubClientSecret` environment variables. + +```yaml +development: + githubClientId: 'YOUR_GITHUB_CLIENT_ID' + githubClientSecret: 'YOUR_GITHUB_CLIENT_SECRET' +``` + +:::warning +Keep your Client Secret confidential. Never commit this value to version control. Store it securely using environment variables or secret management. +::: + +### Configure the GitHub Identity Provider + +In your main `server.dart` file, configure the GitHub identity provider: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_idp_server/core.dart'; +import 'package:serverpod_auth_idp_server/providers/github.dart'; + +void run(List args) async { + final pod = Serverpod( + args, + Protocol(), + Endpoints(), + ); + + pod.initializeAuthServices( + tokenManagerBuilders: [ + JwtConfigFromPasswords(), + ], + identityProviderBuilders: [ + GitHubIdpConfig( + oauthCredentials: GitHubOAuthCredentials.fromJson({ + 'clientId': pod.getPassword('githubClientId')!, + 'clientSecret': pod.getPassword('githubClientSecret')!, + }), + ), + ], + ); + + await pod.start(); +} +``` + +:::tip +You can use `GitHubIdpConfigFromPasswords()` to automatically load credentials from `config/passwords.yaml` or the `SERVERPOD_PASSWORD_githubClientId` and `SERVERPOD_PASSWORD_githubClientSecret` environment variables: + +```dart +identityProviderBuilders: [ + GitHubIdpConfigFromPasswords(), +], +``` + +::: + +### Expose the Endpoint + +Create an endpoint that extends `GitHubIdpBaseEndpoint` to expose the GitHub authentication API: + +```dart +import 'package:serverpod_auth_idp_server/providers/github.dart'; + +class GitHubIdpEndpoint extends GitHubIdpBaseEndpoint {} +``` + +### Generate and Migrate + +Finally, run `serverpod generate` to generate the client code and create a migration to initialize the database for the provider. More detailed instructions can be found in the general [identity providers setup section](../../setup#identity-providers-configuration). + +### Basic configuration options + +- `clientId`: Required. The Client ID of your GitHub App or OAuth App. +- `clientSecret`: Required. The Client Secret generated for your GitHub App or OAuth App. + +For more details on configuration options, see the [configuration section](./configuration). + +## Client-side configuration + +Add the `serverpod_auth_idp_flutter` package to your Flutter app. The GitHub provider uses [`flutter_web_auth_2`](https://pub.dev/packages/flutter_web_auth_2) to handle the OAuth2 flow, so any documentation there should also apply to this 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](https://pub.dev/packages/flutter_web_auth_2) documentation. + +### Android + +In order to capture the callback url, the following activity needs to be added to your `AndroidManifest.xml`. Be sure to replace `YOUR_CALLBACK_URL_SCHEME_HERE` with your actual callback url scheme registered in your GitHub app. + +```xml + + + + + + + + + + + + + + +``` + +### Web + +On the web, you need a specific endpoint to capture the OAuth2 callback. To set this up, create an HTML file (e.g., `auth.html`) inside your project's `./web` folder and add the following content: + +```html + +Authentication complete +

Authentication is complete. If this does not happen automatically, please close the window.

+ +``` + +:::note +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. +::: + +## Present the authentication UI + +### Initializing the `GitHubSignInService` + +To use the GitHubSignInService, you need to initialize it in your main function. The initialization is done from the `initializeGitHubSignIn()` extension method on the `FlutterAuthSessionManager`. + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; +import 'package:serverpod_flutter/serverpod_flutter.dart'; +import 'package:your_client/your_client.dart'; + +late Client client; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Create the Serverpod client + client = Client('http://localhost:8080/') + ..connectivityMonitor = FlutterConnectivityMonitor() + ..authSessionManager = FlutterAuthSessionManager(); + + // Initialize Serverpod auth + await client.auth.initialize(); + + // Initialize GitHub Sign-In + // Note: For Web, ensure the redirectUri matches your auth.html location. + await client.auth.initializeGitHubSignIn( + clientId: 'YOUR_GITHUB_CLIENT_ID', + redirectUri: Uri.parse('https://example.com/auth.html'), + ); + + runApp(const MyApp()); +} +``` + +:::info +**Important**: Ensure the redirect URIs used in your code are also explicitly listed in your **GitHub App Dashboard** under "Callback URLs". For Android, you must also register this scheme in your `AndroidManifest.xml`. +::: + +### Using GitHubSignInWidget + +If you have configured the `GitHubSignInWidget` as described in the [setup section](#present-the-authentication-ui), the GitHub identity provider will be automatically detected and displayed in the sign-in widget. + +You can also use the `GitHubSignInWidget` to include the GitHub authentication flow in your own custom UI. + +```dart +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +GitHubSignInWidget( + client: client, + onAuthenticated: () { + // Do something when the user is authenticated. + // + // NOTE: You should not navigate to the home screen here, otherwise + // the user will have to sign in again every time they open the app. + }, + onError: (error) { + // Handle errors + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $error')), + ); + }, +) +``` + +The widget automatically handles: + +- GitHub Sign-In flow for iOS, macOS, Android, and Web. +- Token management. +- Underlying GitHub Sign-In package error handling. + +For details on how to customize the GitHub Sign-In UI in your Flutter app, see the [customizing the UI section](./customizing-the-ui). diff --git a/docs/06-concepts/11-authentication/04-providers/06-github/02-configuration.md b/docs/06-concepts/11-authentication/04-providers/06-github/02-configuration.md new file mode 100644 index 00000000..dd1a6946 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/06-github/02-configuration.md @@ -0,0 +1,125 @@ +# Configuration + +This page covers configuration options for the GitHub identity provider beyond the basic setup. + +## Configuration options + +Below is a non-exhaustive list of some of the most common configuration options. For more details on all options, check the `GitHubIdpConfig` in-code documentation. + +### Loading GitHub Credentials + +You can load GitHub OAuth credentials in several ways: + +**From JSON map (recommended for production):** + +```dart +final githubIdpConfig = GitHubIdpConfig( + oauthCredentials: GitHubOAuthCredentials.fromJson({ + 'clientId': pod.getPassword('githubClientId')!, + 'clientSecret': pod.getPassword('githubClientSecret')!, + }), +); +``` + +**From JSON file:** + +```dart +import 'dart:io'; + +final githubIdpConfig = GitHubIdpConfig( + oauthCredentials: GitHubOAuthCredentials.fromJsonFile( + File('config/github_oauth_credentials.json'), + ), +); +``` + +### Custom Account Validation + +You can customize the validation for GitHub account details before allowing sign-in. By default, the validation checks that the received account details contain a non-empty userIdentifier. + +```dart +final githubIdpConfig = GitHubIdpConfig( + // Optional: Custom validation for GitHub account details + githubAccountDetailsValidation: (GitHubAccountDetails accountDetails) { + // Throw an exception if account doesn't meet custom requirements + if (accountDetails.userIdentifier.isEmpty) { + throw GitHubUserInfoMissingDataException(); + } + }, +); +``` + +:::note +GitHub users can keep their email private, so email may be null even for valid accounts. To avoid blocking real users with private profiles from signing in, adjust your validation function with care. +::: + +### GitHubAccountDetails + +The `githubAccountDetailsValidation` callback receives a `GitHubAccountDetails` record with the following properties: + +| Property | Type | Description | +| ---------- | ------ | ------------- | +| `userIdentifier` | `String` | The GitHub user's unique identifier (UID) | +| `email` | `String?` | The user's email address (may be null if private) | +| `name` | `String?` | The user's display name from GitHub | +| `image` | `Uri?` | URL to the user's profile image | + +Example of accessing these properties: + +```dart +githubAccountDetailsValidation: (accountDetails) { + print('GitHub UID: ${accountDetails.userIdentifier}'); + print('Email: ${accountDetails.email}'); + print('Display name: ${accountDetails.name}'); + print('Profile image: ${accountDetails.image}'); + + // Custom validation logic + if (accountDetails.email == null) { + throw GitHubUserInfoMissingDataException(); + } +}, +``` + +:::info +The properties available depend on user privacy settings and granted permissions. +::: + +### Configuring Client IDs on the App + +#### Passing Client IDs in Code + +You can pass the `clientId` and `redirectUri` directly during initialization the GitHub Sign-In service: + +```dart +await client.auth.initializeGitHubSignIn( + clientId: 'YOUR_GITHUB_CLIENT_ID', + redirectUri: 'test-app://github/auth', +); +``` + +This approach is useful when you need different client IDs per platform and want to manage them in your Dart code. + +#### Using Environment Variables + +Alternatively, you can pass client IDs during build time using the `--dart-define` option. The GitHub Sign-In provider supports the following environment variables: + +- `GITHUB_CLIENT_ID`: Your GitHub OAuth client ID. +- `GITHUB_REDIRECT_URI`: The callback URI. + +**Example usage:** + +```bash +flutter run -d \ + --dart-define="GITHUB_CLIENT_ID=your_id" \ + --dart-define="GITHUB_REDIRECT_URI=test-app://github/auth" +``` + +This approach is useful when you need to: + +- Manage separate client IDs for different platforms (Android, iOS, Web) in a centralized way +- Avoid committing client IDs to version control +- Configure different credentials for different build environments (development, staging, production) + +:::tip +You can also set these environment variables in your IDE's run configuration or CI/CD pipeline to avoid passing them manually each time. +::: diff --git a/docs/06-concepts/11-authentication/04-providers/06-github/03-customizing-the-ui.md b/docs/06-concepts/11-authentication/04-providers/06-github/03-customizing-the-ui.md new file mode 100644 index 00000000..beba0ab7 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/06-github/03-customizing-the-ui.md @@ -0,0 +1,110 @@ +# Customizing the UI + +When using the GitHub identity provider, you can customize the UI to your liking. You can use the `GitHubSignInWidget` to display the GitHub Sign-In flow in your own custom UI, or you can use the `GitHubAuthController` to build a completely custom authentication interface. + +:::info +The `SignInWidget` uses the `GitHubSignInWidget` internally to display the GitHub Sign-In flow. You can also supply a custom `GitHubSignInWidget` to the `SignInWidget` to override the default behavior. + +```dart +SignInWidget( + client: client, + githubSignInWidget: GitHubSignInWidget( + client: client, + // Customize the widget + style: GitHubButtonStyle.black, + ), +) +``` + +::: + +## Using the `GitHubSignInWidget` + +The `GitHubSignInWidget` handles the complete GitHub Sign-In flow for your Flutter app. + +You can customize the widget's appearance and behavior: + +```dart +GitHubSignInWidget( + client: client, + // Button customization + text: GitHubButtonText.continueWith, // or signIn, signUp + type: GitHubButtonType.standard, // or icon + style: GitHubButtonStyle.black, // or white + size: GitHubButtonSize.large, // or medium + shape: GitHubButtonShape.pill, // or rectangular, rounded + logoAlignment: GitHubButtonLogoAlignment.left, // or center + minimumWidth: 240, // or null for automatic width + + // Scopes to request from GitHub + // These are the default. + scopes: const ['user', 'user:email', 'read:user'], + + onAuthenticated: () { + // Do something when the user is authenticated. + // + // NOTE: You should not navigate to the home screen here, otherwise + // the user will have to sign in again every time they open the app. + }, + onError: (error) { + // Handle errors + }, +) +``` + +## Building a custom UI with the `GitHubAuthController` + +For more control over the UI, you can use the `GitHubAuthController` class, which provides all the authentication logic without any UI components. This allows you to build a completely custom authentication interface. + +```dart +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +final controller = GitHubAuthController( + client: client, + onAuthenticated: () { + // Do something when the user is authenticated. + // + // NOTE: You should not navigate to the home screen here, otherwise + // the user will have to sign in again every time they open the app. + }, + onError: (error) { + // Handle errors + }, + scopes: const ['user', 'user:email', 'read:user'], +); + +// Initiate sign-in +await controller.signIn(); +``` + +### GitHubAuthController State Management + +Your widget should render the appropriate UI based on the `state` property of the controller. You can also use the below state properties to build your UI: + +```dart +// Check current state +final state = controller.state; // GitHubAuthState enum + +// Check if loading +final isLoading = controller.isLoading; + +// Check if authenticated +final isAuthenticated = controller.isAuthenticated; + +// Get error message +final errorMessage = controller.errorMessage; + +// Listen to state changes +controller.addListener(() { + setState(() { + // Rebuild UI when controller state changes + }); +}); +``` + +#### GitHubAuthController States + +- `GitHubAuthState.idle` - Ready for user interaction. +- `GitHubAuthState.loading` - Processing a sign-in request. +- `GitHubAuthState.error` - An error occurred. +- `GitHubAuthState.authenticated` - Authentication was successful. diff --git a/docs/06-concepts/11-authentication/04-providers/06-github/_category_.json b/docs/06-concepts/11-authentication/04-providers/06-github/_category_.json new file mode 100644 index 00000000..012cfb13 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/06-github/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "GitHub", + "collapsed": true +} \ No newline at end of file diff --git a/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/01-oauth2-utility-basic.md b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/01-oauth2-utility-basic.md new file mode 100644 index 00000000..790b04df --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/01-oauth2-utility-basic.md @@ -0,0 +1,324 @@ +# OAuth2 Utility Basic + +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. + +:::info +The [GitHub provider](../github/setup) 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: + +1. **Generate Code Verifier**: Client generates a random cryptographic string (code verifier). +2. **Generate Code Challenge**: Client creates a SHA-256 hash of the verifier (code challenge). +3. **Authorization Request**: Client redirects user to provider with the code challenge. +4. **User Authorizes**: User logs in and grants permissions. +5. **Receive Code**: Provider redirects back with an authorization code. +6. **Token Exchange**: Client sends code + verifier to your backend. +7. **Backend Exchange**: Backend exchanges code + verifier for access token. +8. **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 + +Create a server-side configuration for token exchange: + +```dart +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 extract access token from provider response + parseAccessToken: (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', + }, +); +``` + +:::info +`credentialsLocation` controls how your client credentials are sent to the OAuth2 provider: + +- **Header mode (recommended):** Credentials are placed in the `Authorization` header 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 + +Use the `OAuth2PkceUtil` on your endpoint to exchange the authorization code: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_idp_server/core.dart'; + +class MyProviderIdpEndpoint extends Endpoint { + final oauth2Util = OAuth2PkceUtil(config: config); + + Future authenticate( + Session session, { + required String code, + required String codeVerifier, + required String redirectUri, + }) async { + try { + // Exchange authorization code for access token + final accessToken = await oauth2Util.exchangeCodeForToken( + code: code, + codeVerifier: codeVerifier, + redirectUri: redirectUri, + ); + + // Use access token to fetch user information + final userInfo = await _fetchUserInfo(accessToken); + + // Authenticate or create user in your system + return await _authenticateUser(session, userInfo); + } 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'); + } + } +} +``` + +### 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 + +Creating a client-side configuration that defines your provider's OAuth2 endpoints and parameters: + +```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 + +Use the `OAuth2PkceUtil` to start the authorization flow: + +```dart +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](https://pub.dev/packages/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](https://pub.dev/packages/flutter_web_auth_2) documentation. + +#### Android + +Add the callback activity to your `AndroidManifest.xml`: + +```xml + + + + + + + + + + + + + + + +``` + +#### Web + +Create an HTML callback page in your `./web` folder (e.g., `auth.html`): + +```html + +Authentication complete +

Authentication is complete. If this does not happen automatically, please close the window.

+ +``` + +:::note +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](./complete-example) page. + +## Best Practices + +### Security Considerations + +1. **Always Use PKCE**: Keep `enablePKCE: true` in your client configuration. PKCE protects against authorization code interception attacks. +2. **Validate State Parameter**: Keep `enableState: true` to prevent CSRF attacks. The state parameter ensures the authorization response matches your request. +3. **Secure Client Secret**: Never expose your client secret in client-side code. Store it securely in `passwords.yaml` or environment variables on the server. +4. **Use HTTPS**: Always use HTTPS URLs for production endpoints. Only use HTTP for local development. +5. **Validate Redirect URIs**: Ensure redirect URIs in your code exactly match those registered with your OAuth provider. + +### Error Handling + +1. **Catch Specific Exceptions**: Handle each exception type appropriately rather than using generic catch-all handlers. +2. **Log Securely**: Log errors for debugging but never log sensitive data like tokens or secrets. +3. **User-Friendly Messages**: Show clear, actionable error messages to users without exposing technical details. diff --git a/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/02-complete-example.md b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/02-complete-example.md new file mode 100644 index 00000000..00cd7575 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/02-complete-example.md @@ -0,0 +1,704 @@ +# Complete Example + +This page provides a complete, working implementation of a custom OAuth2 provider. Use this as a reference when building your own integration. + +## Overview + +This example implements authentication with a fictional OAuth2 provider called "MyProvider". The implementation includes: + +- Server-side token exchange and user management +- Client-side authorization flow +- Flutter UI integration +- Error handling + +## Server-Side Implementation + +### 1. Data Model + +First, create a data model to store provider accounts: + +```yaml +class: MyProviderAccount +serverOnly: true +table: my_provider_account +fields: + id: UuidValue?, defaultPersist=random_v7 + + # The AuthUser this account belongs to + authUser: module:serverpod_auth_core:AuthUser?, relation(onDelete=Cascade) + + # Provider's user identifier + providerId: String + + # User's email from provider (optional) + email: String? + + # Creation timestamp + created: DateTime, defaultModel=now + +indexes: + my_provider_account_provider_id: + fields: providerId + unique: true +``` + +### 2. Configuration + +Create the server configuration: + +```dart +import 'package:serverpod_auth_idp_server/core.dart'; +import 'my_provider_idp.dart'; + +class MyProviderIdpConfig extends IdentityProviderBuilder { + final String clientId; + final String clientSecret; + late final OAuth2PkceServerConfig oauth2Config; + + MyProviderIdpConfig({ + required this.clientId, + required this.clientSecret, + }) : oauth2Config = OAuth2PkceServerConfig( + tokenEndpointUrl: Uri.https('oauth.myprovider.com', '/oauth/token'), + clientId: clientId, + clientSecret: clientSecret, + credentialsLocation: OAuth2CredentialsLocation.header, + parseAccessToken: parseAccessToken, + ); + + static String parseAccessToken(Map response) { + final error = response['error'] as String?; + if (error != null) { + final description = response['error_description'] as String?; + throw OAuth2InvalidResponseException( + 'Provider error: $error${description != null ? ' - $description' : ''}', + ); + } + final token = response['access_token'] as String?; + if (token == null) { + throw const OAuth2MissingAccessTokenException('No access token in response'); + } + return token; + } + + @override + MyProviderIdp build({ + required TokenManager tokenManager, + required AuthUsers authUsers, + required UserProfiles userProfiles, + }) { + return MyProviderIdp( + config: this, + tokenIssuer: tokenManager, + authUsers: authUsers, + userProfiles: userProfiles, + ); + } +} +``` + +### 3. Provider Class + +Create the main identity provider class: + +```dart +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_idp_server/core.dart'; + +import '../generated/protocol.dart'; +import 'my_provider_idp_config.dart'; + +class MyProviderIdp { + static const String method = 'myprovider'; + + final MyProviderIdpConfig config; + final TokenIssuer _tokenIssuer; + final AuthUsers _authUsers; + final UserProfiles _userProfiles; + + late final OAuth2PkceUtil _oauth2Util; + + MyProviderIdp({ + required this.config, + required TokenIssuer tokenIssuer, + required AuthUsers authUsers, + required UserProfiles userProfiles, + }) : _tokenIssuer = tokenIssuer, + _authUsers = authUsers, + _userProfiles = userProfiles { + _oauth2Util = OAuth2PkceUtil(config: config.oauth2Config); + } + + Future login( + Session session, { + required String code, + required String codeVerifier, + required String redirectUri, + }) async { + return await DatabaseUtil.runInTransactionOrSavepoint( + session.db, + null, + (transaction) async { + // 1. Exchange authorization code for access token + final accessToken = await _oauth2Util.exchangeCodeForToken( + code: code, + codeVerifier: codeVerifier, + redirectUri: redirectUri, + ); + + // 2. Fetch user information + final userInfo = await _fetchUserInfo(session, accessToken); + + // 3. Authenticate (find or create user) + final account = await _authenticate(session, userInfo, transaction); + + // 4. Create user profile if new user + if (account.newAccount) { + await _createUserProfile( + session, + account.authUserId, + userInfo, + transaction, + ); + } + + // 5. Issue authentication token + return await _tokenIssuer.issueToken( + session, + authUserId: account.authUserId, + transaction: transaction, + method: method, + scopes: account.scopes, + ); + }, + ); + } + + Future> _fetchUserInfo( + Session session, + String accessToken, + ) async { + final response = await http.get( + Uri.https('api.myprovider.com', '/v1/user'), + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ); + + if (response.statusCode != 200) { + session.log( + 'Failed to fetch user info: ${response.statusCode}', + level: LogLevel.error, + ); + throw MyProviderAuthException('Failed to fetch user information'); + } + + try { + return jsonDecode(response.body) as Map; + } catch (e) { + session.log( + 'Failed to parse user info: $e', + level: LogLevel.error, + ); + throw MyProviderAuthException('Invalid user information format'); + } + } + + Future<_AccountResult> _authenticate( + Session session, + Map userInfo, + Transaction transaction, + ) async { + final providerId = userInfo['id']?.toString(); + if (providerId == null || providerId.isEmpty) { + throw MyProviderAuthException('Missing user ID from provider'); + } + + // Check if account exists + var account = await MyProviderAccount.db.findFirstRow( + session, + where: (t) => t.providerId.equals(providerId), + transaction: transaction, + ); + + final isNewAccount = account == null; + + if (isNewAccount) { + // Create new auth user + final authUser = await _authUsers.create( + session, + transaction: transaction, + ); + + // Create provider account + account = await MyProviderAccount.db.insertRow( + session, + MyProviderAccount( + providerId: providerId, + email: userInfo['email'] as String?, + authUserId: authUser.id, + ), + transaction: transaction, + ); + + return ( + authUserId: authUser.id, + newAccount: true, + scopes: authUser.scopes, + ); + } else { + // Get existing user + final authUser = await _authUsers.get( + session, + authUserId: account.authUserId, + transaction: transaction, + ); + + return ( + authUserId: authUser.id, + newAccount: false, + scopes: authUser.scopes, + ); + } + } + + Future _createUserProfile( + Session session, + UuidValue authUserId, + Map userInfo, + Transaction transaction, + ) async { + try { + await _userProfiles.createUserProfile( + session, + authUserId, + UserProfileData( + fullName: userInfo['name'] as String?, + email: userInfo['email'] as String?, + ), + transaction: transaction, + ); + } catch (e, stackTrace) { + session.log( + 'Failed to create user profile', + level: LogLevel.error, + exception: e, + stackTrace: stackTrace, + ); + // Don't fail the authentication if profile creation fails + } + } +} + +typedef _AccountResult = ({ + UuidValue authUserId, + bool newAccount, + Set scopes, +}); + +class MyProviderAuthException implements Exception { + final String message; + const MyProviderAuthException(this.message); + + @override + String toString() => 'MyProviderAuthException: $message'; +} +``` + +### 4. Endpoint + +Create the endpoint: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_idp_server/core.dart'; + +import 'my_provider_idp.dart'; + +class MyProviderIdpEndpoint extends Endpoint { + MyProviderIdp get myProviderIdp => + AuthServices.getIdentityProvider(); + + Future login( + Session session, { + required String code, + required String codeVerifier, + required String redirectUri, + }) async { + try { + return await myProviderIdp.login( + session, + code: code, + codeVerifier: codeVerifier, + redirectUri: redirectUri, + ); + } on OAuth2Exception catch (e) { + session.log( + 'OAuth2 error during authentication: ${e.message}', + level: LogLevel.error, + ); + throw Exception('Authentication failed'); + } on MyProviderAuthException catch (e) { + session.log( + 'MyProvider error: ${e.message}', + level: LogLevel.error, + ); + throw Exception('Authentication failed'); + } + } +} +``` + +### 5. Server Registration + +Register the provider in `server.dart`: + +```dart +import 'package:serverpod/serverpod.dart'; +import 'package:serverpod_auth_idp_server/core.dart'; + +import 'my_provider_idp_config.dart'; + +void run(List args) async { + final pod = Serverpod( + args, + Protocol(), + Endpoints(), + ); + + final myProviderConfig = MyProviderIdpConfig( + clientId: pod.getPassword('myProviderClientId')!, + clientSecret: pod.getPassword('myProviderClientSecret')!, + ); + + pod.initializeAuthServices( + tokenManagerBuilders: [ + JwtConfigFromPasswords(), + ], + identityProviderBuilders: [ + myProviderConfig, + ], + ); + + await pod.start(); +} +``` + +## Client-Side Implementation + +### 1. Configuration + +Create the client configuration: + +```dart +import 'package:flutter/foundation.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +class MyProviderConfig { + static const _clientIdEnvKey = 'MY_PROVIDER_CLIENT_ID'; + static const _redirectUriEnvKey = 'MY_PROVIDER_REDIRECT_URI'; + + static OAuth2PkceProviderClientConfig get clientConfig { + // Get credentials from environment or use defaults + final clientId = _getClientId(); + final redirectUri = _getRedirectUri(); + + return OAuth2PkceProviderClientConfig( + authorizationEndpoint: Uri.https('oauth.myprovider.com', '/oauth/authorize'), + clientId: clientId, + redirectUri: redirectUri, + callbackUrlScheme: Uri.parse(redirectUri).scheme, + defaultScopes: ['profile', 'email'], + ); + } + + static String _getClientId() { + const clientId = String.fromEnvironment(_clientIdEnvKey); + if (clientId.isNotEmpty) return clientId; + + // Development fallback + if (kDebugMode) { + return 'dev-client-id'; + } + + throw Exception('$_clientIdEnvKey not configured'); + } + + static String _getRedirectUri() { + const redirectUri = String.fromEnvironment(_redirectUriEnvKey); + if (redirectUri.isNotEmpty) return redirectUri; + + // Platform-specific defaults for development + if (kDebugMode) { + if (kIsWeb) { + return 'http://localhost:3000/auth.html'; + } else { + return 'myapp://auth-callback'; + } + } + + throw Exception('$_redirectUriEnvKey not configured'); + } +} +``` + +### 2. Service + +Create the sign-in service: + +```dart +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; + +import 'my_provider_config.dart'; + +class MyProviderService { + static final instance = MyProviderService._(); + MyProviderService._(); + + OAuth2PkceUtil? _oauth2Util; + + void initialize() { + if (_oauth2Util != null) return; + + _oauth2Util = OAuth2PkceUtil( + config: MyProviderConfig.clientConfig, + ); + } + + Future signIn({List? scopes}) async { + if (_oauth2Util == null) { + throw StateError( + 'MyProviderService not initialized. Call initialize() first.', + ); + } + + return await _oauth2Util!.authorize(scopes: scopes); + } +} +``` + +### 3. Controller + +Create an authentication controller: + +```dart +import 'package:flutter/foundation.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; +import 'package:your_client/your_client.dart'; + +import 'my_provider_config.dart'; +import 'my_provider_service.dart'; + +enum MyProviderAuthState { + idle, + loading, + authenticated, + error, +} + +class MyProviderAuthController extends ChangeNotifier { + final Client client; + final VoidCallback? onAuthenticated; + final Function(Object error)? onError; + + MyProviderAuthController({ + required this.client, + this.onAuthenticated, + this.onError, + }); + + MyProviderAuthState _state = MyProviderAuthState.idle; + Object? _error; + + MyProviderAuthState get state => _state; + bool get isLoading => _state == MyProviderAuthState.loading; + bool get isAuthenticated => client.auth.isAuthenticated; + String? get errorMessage => _error?.toString(); + + Future signIn() async { + if (_state == MyProviderAuthState.loading) return; + + _setState(MyProviderAuthState.loading); + + try { + // Get authorization code from provider + final result = await MyProviderService.instance.signIn(); + + // Exchange for tokens on backend + final endpoint = client.getEndpointOfType(); + await endpoint.login( + code: result.code, + codeVerifier: result.codeVerifier!, + redirectUri: MyProviderConfig.clientConfig.redirectUri, + ); + + _setState(MyProviderAuthState.authenticated); + onAuthenticated?.call(); + } on OAuth2PkceUserCancelledException { + // User cancelled - just reset to idle + _setState(MyProviderAuthState.idle); + } catch (error) { + _error = error; + _setState(MyProviderAuthState.error); + onError?.call(error); + } + } + + void _setState(MyProviderAuthState newState) { + if (newState != MyProviderAuthState.error) { + _error = null; + } + _state = newState; + notifyListeners(); + } +} +``` + +### 4. UI Widget + +Create the sign-in button: + +```dart +import 'package:flutter/material.dart'; +import 'package:your_client/your_client.dart'; + +import 'my_provider_auth_controller.dart'; + +class MyProviderSignInWidget extends StatefulWidget { + final Client client; + final VoidCallback? onAuthenticated; + final Function(Object error)? onError; + + const MyProviderSignInWidget({ + required this.client, + this.onAuthenticated, + this.onError, + super.key, + }); + + @override + State createState() => _MyProviderSignInWidgetState(); +} + +class _MyProviderSignInWidgetState extends State { + late final MyProviderAuthController _controller; + + @override + void initState() { + super.initState(); + _controller = MyProviderAuthController( + client: widget.client, + onAuthenticated: widget.onAuthenticated, + onError: widget.onError, + ); + _controller.addListener(_onControllerStateChanged); + } + + @override + void dispose() { + _controller.removeListener(_onControllerStateChanged); + _controller.dispose(); + super.dispose(); + } + + void _onControllerStateChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: _controller.isLoading ? null : _controller.signIn, + style: ElevatedButton.styleFrom( + minimumSize: const Size(240, 48), + ), + child: _controller.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in with MyProvider'), + ); + } +} +``` + +### 5. Initialization + +Initialize in your app: + +```dart +import 'package:flutter/material.dart'; +import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart'; +import 'package:serverpod_flutter/serverpod_flutter.dart'; +import 'package:your_client/your_client.dart'; + +import 'my_provider_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize Serverpod client + final client = Client('http://localhost:8080/') + ..connectivityMonitor = FlutterConnectivityMonitor() + ..authSessionManager = FlutterAuthSessionManager(); + + await client.auth.initialize(); + + // Initialize MyProvider service + MyProviderService.instance.initialize(); + + runApp(MyApp(client: client)); +} +``` + +### 6. Usage in UI + +Use the widget in your sign-in page: + +```dart +import 'package:flutter/material.dart'; +import 'package:your_client/your_client.dart'; + +import 'my_provider_sign_in_widget.dart'; + +class SignInPage extends StatelessWidget { + final Client client; + + const SignInPage({required this.client, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sign In')), + body: Center( + child: MyProviderSignInWidget( + client: client, + onAuthenticated: () { + // Navigate to home page after authentication + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => HomePage(client: client), + ), + ); + }, + onError: (error) { + // Show error message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sign in failed: $error'), + backgroundColor: Colors.red, + ), + ); + }, + ), + ), + ); + } +} +``` + +This compact example provides a clear template you can adapt to integrate any OAuth2 provider into your Serverpod project. Use this as a template for implementing your own OAuth2 provider integration. diff --git a/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/_category_.json b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/_category_.json new file mode 100644 index 00000000..b0e72845 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-oauth2-utility/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "OAuth2 Utility", + "collapsed": true +} \ No newline at end of file diff --git a/docs/06-concepts/11-authentication/04-providers/10-custom-providers.md b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-overview.md similarity index 72% rename from docs/06-concepts/11-authentication/04-providers/10-custom-providers.md rename to docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-overview.md index ab6c841a..4a8d31cd 100644 --- a/docs/06-concepts/11-authentication/04-providers/10-custom-providers.md +++ b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/01-overview.md @@ -4,4 +4,6 @@ Serverpod's authentication module makes it easy to implement custom authenticati :::note This section is under development and will be updated soon. + +The package also provides general-purpose utilities to facilitate building IDPs. See [OAuth2 Utility](./oauth2-utility). ::: diff --git a/docs/06-concepts/11-authentication/04-providers/10-custom-providers/_category_.json b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/_category_.json new file mode 100644 index 00000000..bc6ab338 --- /dev/null +++ b/docs/06-concepts/11-authentication/04-providers/10-custom-providers/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Custom Providers", + "collapsed": true +} \ No newline at end of file diff --git a/static/img/authentication/providers/github/1-register-app.png b/static/img/authentication/providers/github/1-register-app.png new file mode 100644 index 00000000..14a1b84a Binary files /dev/null and b/static/img/authentication/providers/github/1-register-app.png differ diff --git a/static/img/authentication/providers/github/2-add-permission.png b/static/img/authentication/providers/github/2-add-permission.png new file mode 100644 index 00000000..633d616e Binary files /dev/null and b/static/img/authentication/providers/github/2-add-permission.png differ diff --git a/static/img/authentication/providers/github/3-add-permission.png b/static/img/authentication/providers/github/3-add-permission.png new file mode 100644 index 00000000..3cb6bd90 Binary files /dev/null and b/static/img/authentication/providers/github/3-add-permission.png differ diff --git a/static/img/authentication/providers/github/4-get-credentials.png b/static/img/authentication/providers/github/4-get-credentials.png new file mode 100644 index 00000000..b98a38d7 Binary files /dev/null and b/static/img/authentication/providers/github/4-get-credentials.png differ