From a63c60e88be120b9fb143ccef8df63becd157387 Mon Sep 17 00:00:00 2001 From: ahmtydn Date: Tue, 11 Nov 2025 18:31:36 +0300 Subject: [PATCH 01/11] feat: add Google Sign-In function with token verification and user management --- dart/sign_in_with_google/.gitignore | 3 + dart/sign_in_with_google/README.md | 114 +++++++++++++++++ .../sign_in_with_google/analysis_options.yaml | 1 + dart/sign_in_with_google/lib/main.dart | 120 ++++++++++++++++++ dart/sign_in_with_google/pubspec.yaml | 12 ++ 5 files changed, 250 insertions(+) create mode 100644 dart/sign_in_with_google/.gitignore create mode 100644 dart/sign_in_with_google/README.md create mode 100644 dart/sign_in_with_google/analysis_options.yaml create mode 100644 dart/sign_in_with_google/lib/main.dart create mode 100644 dart/sign_in_with_google/pubspec.yaml diff --git a/dart/sign_in_with_google/.gitignore b/dart/sign_in_with_google/.gitignore new file mode 100644 index 00000000..49ce72d7 --- /dev/null +++ b/dart/sign_in_with_google/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +.packages +pubspec.lock diff --git a/dart/sign_in_with_google/README.md b/dart/sign_in_with_google/README.md new file mode 100644 index 00000000..4edae363 --- /dev/null +++ b/dart/sign_in_with_google/README.md @@ -0,0 +1,114 @@ +````markdown +# sign-in-with-google + +This function: + +1. Verifies a Google ID token obtained from the client application. +1. If a user with matching id or email doesn't exist, a new user will be created. +1. The user's email will be verified if Google has verified it and it hasn't been already in Appwrite. +1. A token will be returned allowing the user to exchange the token for a session via `account.createSession()`. + +> Note: This function verifies the Google ID token using Google's tokeninfo endpoint as described in the [official documentation](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). + +## 🧰 Usage + +### POST / + +**Headers** + +The Content-Type header must be set to `application/json` so that the request body can be properly parsed as JSON. + +* `Content-Type`: `application/json` + +**Request** + +This function accepts: + +* `idToken` (required) - Google ID token obtained from the client-side Google Sign-In flow + +Sample request body: + +```json +{ + "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlMzA3..." +} +``` + +**Response** + +This function returns: + +* `secret` - `secret` to be passed to `account.createSession()` to create a session +* `userId` - `userId` to be passed to `account.createSession()` to create a session +* `expire` - ISO formatted timestamp for when the secret expires + +Sample `200` Response: + +```json +{ + "secret": "0cbdd4fd7638e0f3f55871adf2256f8f42f6faa01c9300e482c9a585b76611343dee8562ce4421b1cf9e9de6f8341fb2286499cb7992d02accd2dc699211008c", + "userId": "112345678901234567890", + "expire": "2025-07-15T00:10:21.345+00:00" +} +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | --------------- | +| Runtime | Dart (3.5 ) | +| Entrypoint | `lib/main.dart` | +| Build Commands | `dart pub get` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | +| Scopes | `users.read`, `users.write` | + +## 🔒 Environment Variables + +The following environment variables are required: + +* `GOOGLE_CLIENT_ID` - Your Google OAuth 2.0 Client ID from the Google Cloud Console + +## 📱 Client-Side Integration + +To obtain the Google ID token from your client application, use the [google_sign_in](https://pub.dev/packages/google_sign_in) package: + +```dart +import 'package:google_sign_in/google_sign_in.dart'; + +final GoogleSignIn _googleSignIn = GoogleSignIn( + clientId: 'YOUR_CLIENT_ID.apps.googleusercontent.com', +); + +Future signInWithGoogle() async { + try { + final GoogleSignInAccount? account = await _googleSignIn.signIn(); + if (account == null) return; + + final GoogleSignInAuthentication auth = await account.authentication; + final String? idToken = auth.idToken; + + if (idToken != null) { + // Send the idToken to your Appwrite function + final response = await http.post( + Uri.parse('YOUR_FUNCTION_URL'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'idToken': idToken}), + ); + // Handle the response + } + } catch (error) { + print('Error signing in with Google: $error'); + } +} +``` + +## 🔐 Security Notes + +* The Google ID token is verified using Google's official tokeninfo endpoint +* The token's audience (client ID) is verified to match your application +* The token's issuer is verified to be Google +* The token's expiration time is checked +* Email verification status from Google is honored in Appwrite + +```` \ No newline at end of file diff --git a/dart/sign_in_with_google/analysis_options.yaml b/dart/sign_in_with_google/analysis_options.yaml new file mode 100644 index 00000000..572dd239 --- /dev/null +++ b/dart/sign_in_with_google/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart new file mode 100644 index 00000000..21e07b5f --- /dev/null +++ b/dart/sign_in_with_google/lib/main.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dart_appwrite/dart_appwrite.dart'; +import 'package:dart_appwrite/models.dart'; +import 'package:http/http.dart' as http; + +Future main(final context) async { + final requiredEnvVars = ['GOOGLE_CLIENT_ID']; + for (var varName in requiredEnvVars) { + if (Platform.environment[varName]?.isEmpty ?? true) { + throw Exception('Environment variable $varName must be set.'); + } + } + + final googleClientId = Platform.environment['GOOGLE_CLIENT_ID']!; + + final reqBody = context.req.bodyJson as Map; + final idToken = reqBody['idToken'] ?? ''; + + // Validate input + if (idToken.isEmpty) { + throw Exception('idToken must be provided in the request body.'); + } + + // Verify the Google ID token + final tokenInfoResponse = await http.get( + Uri.parse('https://oauth2.googleapis.com/tokeninfo?id_token=$idToken'), + ); + + if (tokenInfoResponse.statusCode != 200) { + throw Exception( + 'Failed to verify Google ID token: ${tokenInfoResponse.body}', + ); + } + + final tokenInfo = json.decode(tokenInfoResponse.body); + + // Verify the token's audience matches our client ID + final aud = tokenInfo['aud'] ?? ''; + if (aud != googleClientId) { + throw Exception('ID Token audience does not match the expected client ID.'); + } + + // Verify the token is issued by Google + final iss = tokenInfo['iss'] ?? ''; + if (iss != 'https://accounts.google.com' && iss != 'accounts.google.com') { + throw Exception('ID Token is not issued by Google.'); + } + + // Verify the token has not expired + final exp = int.tryParse(tokenInfo['exp']?.toString() ?? '0') ?? 0; + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (exp <= currentTime) { + throw Exception('ID Token has expired.'); + } + + // Extract user information + final userId = tokenInfo['sub'] ?? ''; + if (userId.isEmpty) { + throw Exception('ID Token does not contain a valid subject (sub) claim.'); + } + + final email = tokenInfo['email'] ?? ''; + final emailVerified = tokenInfo['email_verified'] == 'true'; + final name = tokenInfo['name'] ?? ''; + final picture = tokenInfo['picture'] ?? ''; + + // You can use the Appwrite SDK to interact with other services + // For this example, we're using the Users service + final client = Client() + .setEndpoint(Platform.environment['APPWRITE_FUNCTION_API_ENDPOINT']!) + .setProject(Platform.environment['APPWRITE_FUNCTION_PROJECT_ID']!) + .setKey(context.req.headers['x-appwrite-key'] ?? ''); + final users = Users(client); + + // Find user by ID + User? user; + try { + user = await users.get(userId: userId); + } on AppwriteException catch (e) { + if (e.type != 'user_not_found') { + rethrow; + } + } + + // Find user by email + if (user == null && email.isNotEmpty) { + final userList = await users.list(queries: [Query.equal('email', email)]); + if (userList.users.isNotEmpty) { + user = userList.users.first; + } + } + + // If user does not exist, create a new user + user ??= await users.create( + userId: ID.custom(userId), + email: email.isEmpty ? null : email, + name: name.isEmpty ? null : name, + ); + + // Mark the user as verified if the email is verified by Google and not already verified + if (emailVerified && !user.emailVerification) { + users.updateEmailVerification(userId: userId, emailVerification: true); + } + + // Create token + final token = await users.createToken( + userId: user.$id, + expire: 60, + length: 128, + ); + + return context.res.json({ + 'secret': token.secret, + 'userId': user.$id, + 'expire': token.expire, + }); +} diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml new file mode 100644 index 00000000..f5b40d6b --- /dev/null +++ b/dart/sign_in_with_google/pubspec.yaml @@ -0,0 +1,12 @@ +name: sign_in_with_google +version: 1.0.0 + +environment: + sdk: ^2.17.0 + +dependencies: + dart_appwrite: ^16.0.0 + http: ^1.4.0 + +dev_dependencies: + lints: ^2.0.0 From 34e6ff1e51741c7c277fa979f569b416fabadf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:04:35 +0300 Subject: [PATCH 02/11] fix: update dependencies and ensure email verification is awaited --- dart/sign_in_with_google/lib/main.dart | 6 ++++-- dart/sign_in_with_google/pubspec.yaml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index 21e07b5f..fa8d223e 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -65,7 +65,6 @@ Future main(final context) async { final email = tokenInfo['email'] ?? ''; final emailVerified = tokenInfo['email_verified'] == 'true'; final name = tokenInfo['name'] ?? ''; - final picture = tokenInfo['picture'] ?? ''; // You can use the Appwrite SDK to interact with other services // For this example, we're using the Users service @@ -102,7 +101,10 @@ Future main(final context) async { // Mark the user as verified if the email is verified by Google and not already verified if (emailVerified && !user.emailVerification) { - users.updateEmailVerification(userId: userId, emailVerification: true); + await users.updateEmailVerification( + userId: userId, + emailVerification: true, + ); } // Create token diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml index f5b40d6b..daa5fe70 100644 --- a/dart/sign_in_with_google/pubspec.yaml +++ b/dart/sign_in_with_google/pubspec.yaml @@ -5,8 +5,8 @@ environment: sdk: ^2.17.0 dependencies: - dart_appwrite: ^16.0.0 - http: ^1.4.0 + dart_appwrite: ^19.3.0 + http: ^1.5.0 dev_dependencies: lints: ^2.0.0 From bf3d3ed4cf461be0f7fcda49d2605bc1649eb0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:07:05 +0300 Subject: [PATCH 03/11] fix: correct markdown formatting in README and ensure email verification note is clear --- dart/sign_in_with_google/README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dart/sign_in_with_google/README.md b/dart/sign_in_with_google/README.md index 4edae363..7d5a5fd3 100644 --- a/dart/sign_in_with_google/README.md +++ b/dart/sign_in_with_google/README.md @@ -1,4 +1,3 @@ -````markdown # sign-in-with-google This function: @@ -109,6 +108,4 @@ Future signInWithGoogle() async { * The token's audience (client ID) is verified to match your application * The token's issuer is verified to be Google * The token's expiration time is checked -* Email verification status from Google is honored in Appwrite - -```` \ No newline at end of file +* Email verification status from Google is honored in Appwrite \ No newline at end of file From 4127d80dcd8541efae09816e947a54ba227c0f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:30:17 +0300 Subject: [PATCH 04/11] feat: enhance Google ID token verification process and update dependencies --- dart/sign_in_with_google/README.md | 9 ++-- dart/sign_in_with_google/lib/main.dart | 69 +++++++++++++++++++------- dart/sign_in_with_google/pubspec.yaml | 5 +- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/dart/sign_in_with_google/README.md b/dart/sign_in_with_google/README.md index 7d5a5fd3..ebc99f95 100644 --- a/dart/sign_in_with_google/README.md +++ b/dart/sign_in_with_google/README.md @@ -7,7 +7,7 @@ This function: 1. The user's email will be verified if Google has verified it and it hasn't been already in Appwrite. 1. A token will be returned allowing the user to exchange the token for a session via `account.createSession()`. -> Note: This function verifies the Google ID token using Google's tokeninfo endpoint as described in the [official documentation](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). +> Note: This function verifies the Google ID token by validating its cryptographic signature using Google's public keys (JWK format), as recommended in the [official documentation](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). The function also validates the token's audience, issuer, and expiry claims. ## 🧰 Usage @@ -104,8 +104,9 @@ Future signInWithGoogle() async { ## 🔐 Security Notes -* The Google ID token is verified using Google's official tokeninfo endpoint +* The Google ID token's cryptographic signature is verified using Google's public keys (RSA) +* The correct public key is selected using the `kid` (key ID) from the JWT header * The token's audience (client ID) is verified to match your application -* The token's issuer is verified to be Google -* The token's expiration time is checked +* The token's issuer is verified to be Google (`accounts.google.com`) +* The token's expiration time is checked to ensure it hasn't expired * Email verification status from Google is honored in Appwrite \ No newline at end of file diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index fa8d223e..97303ec6 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:dart_appwrite/dart_appwrite.dart'; import 'package:dart_appwrite/models.dart'; import 'package:http/http.dart' as http; +import 'package:jose/jose.dart'; Future main(final context) async { final requiredEnvVars = ['GOOGLE_CLIENT_ID']; @@ -24,47 +25,81 @@ Future main(final context) async { throw Exception('idToken must be provided in the request body.'); } - // Verify the Google ID token - final tokenInfoResponse = await http.get( - Uri.parse('https://oauth2.googleapis.com/tokeninfo?id_token=$idToken'), + // Fetch Google's public keys for JWT verification + final certsResponse = await http.get( + Uri.parse('https://www.googleapis.com/oauth2/v3/certs'), ); - if (tokenInfoResponse.statusCode != 200) { + if (certsResponse.statusCode != 200) { throw Exception( - 'Failed to verify Google ID token: ${tokenInfoResponse.body}', - ); + 'Failed to fetch Google public keys: ${certsResponse.body}'); + } + + final jwks = JsonWebKeySet.fromJson(json.decode(certsResponse.body)); + + // Parse the JWT to get the header + final jwtParts = idToken.split('.'); + if (jwtParts.length != 3) { + throw Exception('Invalid JWT format.'); + } + + // Decode header to get the key ID (kid) + final headerJson = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(jwtParts[0]))), + ); + final keyId = headerJson['kid'] as String?; + if (keyId == null) { + throw Exception('JWT header does not contain a key ID (kid).'); + } + + // Find the matching key by kid (key ID) + final key = jwks.keys.firstWhere( + (k) => k.keyId == keyId, + orElse: () => throw Exception('No matching key found for kid: $keyId'), + ); + + // Create a key store and verify the signature + final keyStore = JsonWebKeyStore()..addKey(key); + + JsonWebToken jwt; + try { + // Verify and decode the JWT signature + jwt = await JsonWebToken.decodeAndVerify(idToken, keyStore); + } catch (e) { + throw Exception('Failed to verify JWT signature: $e'); } - final tokenInfo = json.decode(tokenInfoResponse.body); + // Extract claims from the verified token + final claims = jwt.claims; // Verify the token's audience matches our client ID - final aud = tokenInfo['aud'] ?? ''; - if (aud != googleClientId) { + final audiences = claims.audience ?? []; + if (!audiences.contains(googleClientId)) { throw Exception('ID Token audience does not match the expected client ID.'); } // Verify the token is issued by Google - final iss = tokenInfo['iss'] ?? ''; + final iss = claims.issuer ?? ''; if (iss != 'https://accounts.google.com' && iss != 'accounts.google.com') { throw Exception('ID Token is not issued by Google.'); } // Verify the token has not expired - final exp = int.tryParse(tokenInfo['exp']?.toString() ?? '0') ?? 0; - final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; - if (exp <= currentTime) { + final exp = claims.expiry; + if (exp == null || exp.isBefore(DateTime.now())) { throw Exception('ID Token has expired.'); } // Extract user information - final userId = tokenInfo['sub'] ?? ''; + final userId = claims.subject ?? ''; if (userId.isEmpty) { throw Exception('ID Token does not contain a valid subject (sub) claim.'); } - final email = tokenInfo['email'] ?? ''; - final emailVerified = tokenInfo['email_verified'] == 'true'; - final name = tokenInfo['name'] ?? ''; + final claimsJson = claims.toJson(); + final email = claimsJson['email']?.toString() ?? ''; + final emailVerified = claimsJson['email_verified'] == true; + final name = claimsJson['name']?.toString() ?? ''; // You can use the Appwrite SDK to interact with other services // For this example, we're using the Users service diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml index daa5fe70..3ebcc569 100644 --- a/dart/sign_in_with_google/pubspec.yaml +++ b/dart/sign_in_with_google/pubspec.yaml @@ -5,8 +5,9 @@ environment: sdk: ^2.17.0 dependencies: - dart_appwrite: ^19.3.0 - http: ^1.5.0 + dart_appwrite: ^19.4.0 + http: ^1.6.0 + jose: ^0.3.5 dev_dependencies: lints: ^2.0.0 From cdfdae025f83a61bb32990626f3451900f390adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:32:12 +0300 Subject: [PATCH 05/11] Update dart/sign_in_with_google/README.md Co-authored-by: Steven Nguyen <1477010+stnguyen90@users.noreply.github.com> --- dart/sign_in_with_google/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/sign_in_with_google/README.md b/dart/sign_in_with_google/README.md index ebc99f95..b24ae467 100644 --- a/dart/sign_in_with_google/README.md +++ b/dart/sign_in_with_google/README.md @@ -55,7 +55,7 @@ Sample `200` Response: | Setting | Value | | ----------------- | --------------- | -| Runtime | Dart (3.5 ) | +| Runtime | Dart (3.5) | | Entrypoint | `lib/main.dart` | | Build Commands | `dart pub get` | | Permissions | `any` | From b172b1107ce93ac597fd0a0b28228ac8ef074a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:45:12 +0300 Subject: [PATCH 06/11] feat: implement timeout handling for fetching Google public keys and improve error logging --- dart/sign_in_with_google/lib/main.dart | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index 97303ec6..5abfea1e 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -26,9 +26,28 @@ Future main(final context) async { } // Fetch Google's public keys for JWT verification - final certsResponse = await http.get( - Uri.parse('https://www.googleapis.com/oauth2/v3/certs'), - ); + http.Response certsResponse; + try { + certsResponse = await http + .get( + Uri.parse('https://www.googleapis.com/oauth2/v3/certs'), + ) + .timeout( + const Duration(seconds: 5), + onTimeout: () { + throw TimeoutException( + 'Request to fetch Google public keys timed out after 5 seconds', + ); + }, + ); + } on TimeoutException catch (e) { + context.log('Timeout fetching Google public keys: $e'); + throw Exception( + 'Failed to fetch Google public keys: Request timed out. Please try again.'); + } catch (e) { + context.log('Error fetching Google public keys: $e'); + throw Exception('Failed to fetch Google public keys: $e'); + } if (certsResponse.statusCode != 200) { throw Exception( @@ -137,7 +156,7 @@ Future main(final context) async { // Mark the user as verified if the email is verified by Google and not already verified if (emailVerified && !user.emailVerification) { await users.updateEmailVerification( - userId: userId, + userId: user.$id, emailVerification: true, ); } From 86272237b95af0a57f1ab93bb8385c6a24263ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:45:54 +0300 Subject: [PATCH 07/11] feat: update Dart SDK version requirement to >=3.5.0 <4.0.0 --- dart/sign_in_with_google/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml index 3ebcc569..8c6f6752 100644 --- a/dart/sign_in_with_google/pubspec.yaml +++ b/dart/sign_in_with_google/pubspec.yaml @@ -2,7 +2,7 @@ name: sign_in_with_google version: 1.0.0 environment: - sdk: ^2.17.0 + sdk: '>=3.5.0 <4.0.0' dependencies: dart_appwrite: ^19.4.0 From d2657c39b25289a0036c434ae4bb31603f1a8731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 02:54:29 +0300 Subject: [PATCH 08/11] feat: add additional required environment variables for Appwrite integration Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- dart/sign_in_with_google/lib/main.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index 5abfea1e..6307fbbf 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -8,7 +8,11 @@ import 'package:http/http.dart' as http; import 'package:jose/jose.dart'; Future main(final context) async { - final requiredEnvVars = ['GOOGLE_CLIENT_ID']; + final requiredEnvVars = [ + 'GOOGLE_CLIENT_ID', + 'APPWRITE_FUNCTION_API_ENDPOINT', + 'APPWRITE_FUNCTION_PROJECT_ID', + ]; for (var varName in requiredEnvVars) { if (Platform.environment[varName]?.isEmpty ?? true) { throw Exception('Environment variable $varName must be set.'); From 683223f0a948909a7c79dc559038fa90b1367284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 15 Nov 2025 03:21:28 +0300 Subject: [PATCH 09/11] feat: enhance ID token issuer verification with detailed error message --- dart/sign_in_with_google/lib/main.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index 6307fbbf..eb211b99 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -102,9 +102,10 @@ Future main(final context) async { } // Verify the token is issued by Google - final iss = claims.issuer ?? ''; - if (iss != 'https://accounts.google.com' && iss != 'accounts.google.com') { - throw Exception('ID Token is not issued by Google.'); + final iss = (claims.issuer?.toString() ?? '').trim(); + final validIssuers = ['https://accounts.google.com', 'accounts.google.com']; + if (!validIssuers.contains(iss)) { + throw Exception('ID Token is not issued by Google. Issuer: $iss'); } // Verify the token has not expired From ff31c0f2db5eab0400616a1be52c9d7530dbbe7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:22:06 +0300 Subject: [PATCH 10/11] feat: refactor Google OAuth implementation and update dependencies --- dart/sign_in_with_google/lib/main.dart | 530 +++++++++++++++++++------ dart/sign_in_with_google/pubspec.yaml | 2 +- 2 files changed, 402 insertions(+), 130 deletions(-) diff --git a/dart/sign_in_with_google/lib/main.dart b/dart/sign_in_with_google/lib/main.dart index eb211b99..3bb18b3e 100644 --- a/dart/sign_in_with_google/lib/main.dart +++ b/dart/sign_in_with_google/lib/main.dart @@ -4,178 +4,450 @@ import 'dart:io'; import 'package:dart_appwrite/dart_appwrite.dart'; import 'package:dart_appwrite/models.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:http/http.dart' as http; -import 'package:jose/jose.dart'; -Future main(final context) async { - final requiredEnvVars = [ - 'GOOGLE_CLIENT_ID', - 'APPWRITE_FUNCTION_API_ENDPOINT', - 'APPWRITE_FUNCTION_PROJECT_ID', +class GoogleOAuthConfig { + static const googleCertsUrl = 'https://www.googleapis.com/oauth2/v1/certs'; + static const validIssuers = [ + 'https://accounts.google.com', + 'accounts.google.com' ]; - for (var varName in requiredEnvVars) { - if (Platform.environment[varName]?.isEmpty ?? true) { - throw Exception('Environment variable $varName must be set.'); - } + static const certCacheDuration = Duration(hours: 1); + static const httpTimeout = Duration(seconds: 5); + static const tokenExpiryMinutes = 60; + static const tokenLength = 128; +} + +class GoogleCertificateCache { + Map? _certificates; + DateTime? _expiryTime; + + bool get needsRefresh => + _expiryTime == null || DateTime.now().isAfter(_expiryTime!); + + Map? get value => needsRefresh ? null : _certificates; + + void store(Map certificates, Duration cacheDuration) { + _certificates = certificates; + _expiryTime = DateTime.now().add(cacheDuration); } - final googleClientId = Platform.environment['GOOGLE_CLIENT_ID']!; + void invalidate() { + _certificates = null; + _expiryTime = null; + } +} - final reqBody = context.req.bodyJson as Map; - final idToken = reqBody['idToken'] ?? ''; +class GoogleOAuthException implements Exception { + final String message; + final String? details; + final int statusCode; - // Validate input - if (idToken.isEmpty) { - throw Exception('idToken must be provided in the request body.'); + GoogleOAuthException(this.message, {this.details, this.statusCode = 400}); + + @override + String toString() => details != null ? '$message: $details' : message; +} + +class GoogleTokenValidator { + final String clientId; + final GoogleCertificateCache _cache; + + GoogleTokenValidator(this.clientId, this._cache); + Duration _parseCacheControl(String? cacheControl) { + if (cacheControl == null || cacheControl.isEmpty) { + return GoogleOAuthConfig.certCacheDuration; + } + + // "public, max-age=24784, must-revalidate, no-transform" + final directives = cacheControl.split(',').map((d) => d.trim()); + + for (final directive in directives) { + if (directive.startsWith('max-age=')) { + try { + final seconds = int.parse(directive.substring(8)); + // minimum 1 hour, maximum 7 days + final boundedSeconds = seconds.clamp(3600, 604800); + return Duration(seconds: boundedSeconds); + } catch (e) { + break; + } + } + } + + return GoogleOAuthConfig.certCacheDuration; } - // Fetch Google's public keys for JWT verification - http.Response certsResponse; - try { - certsResponse = await http - .get( - Uri.parse('https://www.googleapis.com/oauth2/v3/certs'), - ) - .timeout( - const Duration(seconds: 5), - onTimeout: () { - throw TimeoutException( - 'Request to fetch Google public keys timed out after 5 seconds', + Future _fetchCertificates() async { + try { + final response = await http + .get(Uri.parse(GoogleOAuthConfig.googleCertsUrl)) + .timeout(GoogleOAuthConfig.httpTimeout); + + if (response.statusCode != 200) { + throw GoogleOAuthException( + 'Failed to fetch Google certificates', + details: 'HTTP ${response.statusCode}', + statusCode: 502, ); - }, - ); - } on TimeoutException catch (e) { - context.log('Timeout fetching Google public keys: $e'); - throw Exception( - 'Failed to fetch Google public keys: Request timed out. Please try again.'); - } catch (e) { - context.log('Error fetching Google public keys: $e'); - throw Exception('Failed to fetch Google public keys: $e'); + } + + final certificates = Map.from(json.decode(response.body)); + final cacheDuration = + _parseCacheControl(response.headers['cache-control']); + + return CertificateResponse( + certificates: certificates, + cacheDuration: cacheDuration, + ); + } on TimeoutException { + throw GoogleOAuthException( + 'Certificate fetch timeout', + details: 'Google servers did not respond in time', + statusCode: 504, + ); + } on SocketException catch (e) { + throw GoogleOAuthException( + 'Network error fetching certificates', + details: e.message, + statusCode: 503, + ); + } catch (e) { + throw GoogleOAuthException( + 'Unexpected error fetching certificates', + details: e.toString(), + statusCode: 500, + ); + } } - if (certsResponse.statusCode != 200) { - throw Exception( - 'Failed to fetch Google public keys: ${certsResponse.body}'); + Future> _getCertificates(context) async { + final cached = _cache.value; + if (cached != null) { + context.log('Using cached Google certificates'); + return cached; + } + + context.log('Fetching fresh Google certificates'); + final response = await _fetchCertificates(); + + _cache.store(response.certificates, response.cacheDuration); + context.log( + 'Certificates cached for ${response.cacheDuration.inSeconds} seconds (from Cache-Control header)'); + + return response.certificates; } - final jwks = JsonWebKeySet.fromJson(json.decode(certsResponse.body)); + JwtComponents _parseJwtStructure(String token) { + final parts = token.split('.'); + if (parts.length != 3) { + throw GoogleOAuthException('Malformed JWT token'); + } + + try { + final headerJson = json.decode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[0]))), + ); + final kid = headerJson['kid'] as String?; - // Parse the JWT to get the header - final jwtParts = idToken.split('.'); - if (jwtParts.length != 3) { - throw Exception('Invalid JWT format.'); + if (kid == null || kid.isEmpty) { + throw GoogleOAuthException('JWT missing key ID (kid)'); + } + + return JwtComponents(parts, kid); + } catch (e) { + throw GoogleOAuthException('Failed to parse JWT header', + details: e.toString()); + } } - // Decode header to get the key ID (kid) - final headerJson = json.decode( - utf8.decode(base64Url.decode(base64Url.normalize(jwtParts[0]))), - ); - final keyId = headerJson['kid'] as String?; - if (keyId == null) { - throw Exception('JWT header does not contain a key ID (kid).'); + JWT _verifySignature(String token, String pemCertificate) { + try { + return JWT.verify(token, RSAPublicKey.cert(pemCertificate)); + } on JWTExpiredException { + throw GoogleOAuthException('Token has expired'); + } on JWTException catch (e) { + throw GoogleOAuthException('Invalid token signature', details: e.message); + } catch (e) { + throw GoogleOAuthException('Token verification failed', + details: e.toString()); + } } - // Find the matching key by kid (key ID) - final key = jwks.keys.firstWhere( - (k) => k.keyId == keyId, - orElse: () => throw Exception('No matching key found for kid: $keyId'), - ); + GoogleUserInfo _validateClaims(Map payload) { + final aud = payload['aud']?.toString() ?? ''; + if (aud != clientId) { + throw GoogleOAuthException('Token audience mismatch'); + } - // Create a key store and verify the signature - final keyStore = JsonWebKeyStore()..addKey(key); + final iss = (payload['iss']?.toString() ?? '').trim(); + if (!GoogleOAuthConfig.validIssuers.contains(iss)) { + throw GoogleOAuthException('Invalid token issuer', details: iss); + } - JsonWebToken jwt; - try { - // Verify and decode the JWT signature - jwt = await JsonWebToken.decodeAndVerify(idToken, keyStore); - } catch (e) { - throw Exception('Failed to verify JWT signature: $e'); + final exp = payload['exp'] as int?; + if (exp == null) { + throw GoogleOAuthException('Token missing expiration claim'); + } + + final expiryDate = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + if (expiryDate.isBefore(DateTime.now())) { + throw GoogleOAuthException('Token has expired'); + } + + final userId = payload['sub']?.toString() ?? ''; + if (userId.isEmpty) { + throw GoogleOAuthException('Token missing user ID (sub)'); + } + + return GoogleUserInfo( + userId: userId, + email: payload['email']?.toString() ?? '', + emailVerified: payload['email_verified'] == true, + name: payload['name']?.toString() ?? '', + ); } - // Extract claims from the verified token - final claims = jwt.claims; + Future validate(String idToken, context) async { + final components = _parseJwtStructure(idToken); + final certificates = await _getCertificates(context); + + final pemCert = certificates[components.keyId]; + if (pemCert == null) { + _cache.invalidate(); + throw GoogleOAuthException( + 'Certificate not found', + details: 'kid: ${components.keyId}', + ); + } + + final jwt = _verifySignature(idToken, pemCert); + final payload = jwt.payload as Map; + + return _validateClaims(payload); + } +} + +class AppwriteUserManager { + final Users _users; + + AppwriteUserManager(this._users); - // Verify the token's audience matches our client ID - final audiences = claims.audience ?? []; - if (!audiences.contains(googleClientId)) { - throw Exception('ID Token audience does not match the expected client ID.'); + Future findUser(String userId, String email) async { + try { + return await _users.get(userId: userId); + } on AppwriteException catch (e) { + if (e.type != 'user_not_found') { + rethrow; + } + } + if (email.isNotEmpty) { + try { + final userList = await _users.list( + queries: [Query.equal('email', email)], + ); + return userList.users.isNotEmpty ? userList.users.first : null; + } on AppwriteException catch (_) { + return null; + } + } + + return null; } - // Verify the token is issued by Google - final iss = (claims.issuer?.toString() ?? '').trim(); - final validIssuers = ['https://accounts.google.com', 'accounts.google.com']; - if (!validIssuers.contains(iss)) { - throw Exception('ID Token is not issued by Google. Issuer: $iss'); + Future createUser(GoogleUserInfo userInfo) async { + return await _users.create( + userId: ID.custom(userInfo.userId), + email: userInfo.email.isEmpty ? null : userInfo.email, + name: userInfo.name.isEmpty ? null : userInfo.name, + ); } - // Verify the token has not expired - final exp = claims.expiry; - if (exp == null || exp.isBefore(DateTime.now())) { - throw Exception('ID Token has expired.'); + Future ensureEmailVerified(User user, bool shouldBeVerified) async { + if (shouldBeVerified && !user.emailVerification) { + await _users.updateEmailVerification( + userId: user.$id, + emailVerification: true, + ); + } } - // Extract user information - final userId = claims.subject ?? ''; - if (userId.isEmpty) { - throw Exception('ID Token does not contain a valid subject (sub) claim.'); + Future generateToken(String userId) async { + return await _users.createToken( + userId: userId, + expire: GoogleOAuthConfig.tokenExpiryMinutes, + length: GoogleOAuthConfig.tokenLength, + ); } +} - final claimsJson = claims.toJson(); - final email = claimsJson['email']?.toString() ?? ''; - final emailVerified = claimsJson['email_verified'] == true; - final name = claimsJson['name']?.toString() ?? ''; +class CertificateResponse { + final Map certificates; + final Duration cacheDuration; - // You can use the Appwrite SDK to interact with other services - // For this example, we're using the Users service - final client = Client() - .setEndpoint(Platform.environment['APPWRITE_FUNCTION_API_ENDPOINT']!) - .setProject(Platform.environment['APPWRITE_FUNCTION_PROJECT_ID']!) - .setKey(context.req.headers['x-appwrite-key'] ?? ''); - final users = Users(client); + CertificateResponse({ + required this.certificates, + required this.cacheDuration, + }); +} - // Find user by ID - User? user; - try { - user = await users.get(userId: userId); - } on AppwriteException catch (e) { - if (e.type != 'user_not_found') { - rethrow; - } - } +class JwtComponents { + final List parts; + final String keyId; + + JwtComponents(this.parts, this.keyId); +} + +class GoogleUserInfo { + final String userId; + final String email; + final bool emailVerified; + final String name; + + GoogleUserInfo({ + required this.userId, + required this.email, + required this.emailVerified, + required this.name, + }); +} + +class AuthResponse { + final String secret; + final String userId; + final String expire; + + AuthResponse({ + required this.secret, + required this.userId, + required this.expire, + }); + + Map toJson() => { + 'secret': secret, + 'userId': userId, + 'expire': expire, + }; +} + +class EnvironmentConfig { + final String googleClientId; + final String apiEndpoint; + final String projectId; + + EnvironmentConfig._({ + required this.googleClientId, + required this.apiEndpoint, + required this.projectId, + }); + + static EnvironmentConfig load() { + final requiredVars = { + 'GOOGLE_CLIENT_ID': 'Google OAuth Client ID', + 'APPWRITE_FUNCTION_API_ENDPOINT': 'Appwrite API endpoint', + 'APPWRITE_FUNCTION_PROJECT_ID': 'Appwrite Project ID', + }; - // Find user by email - if (user == null && email.isNotEmpty) { - final userList = await users.list(queries: [Query.equal('email', email)]); - if (userList.users.isNotEmpty) { - user = userList.users.first; + for (final entry in requiredVars.entries) { + final value = Platform.environment[entry.key]; + if (value == null || value.isEmpty) { + throw GoogleOAuthException( + 'Missing required configuration', + details: '${entry.value} (${entry.key}) must be set', + statusCode: 500, + ); + } } + + return EnvironmentConfig._( + googleClientId: Platform.environment['GOOGLE_CLIENT_ID']!, + apiEndpoint: Platform.environment['APPWRITE_FUNCTION_API_ENDPOINT']!, + projectId: Platform.environment['APPWRITE_FUNCTION_PROJECT_ID']!, + ); } +} + +class GoogleOAuthHandler { + final GoogleTokenValidator _validator; + final AppwriteUserManager _userManager; - // If user does not exist, create a new user - user ??= await users.create( - userId: ID.custom(userId), - email: email.isEmpty ? null : email, - name: name.isEmpty ? null : name, + GoogleOAuthHandler( + this._validator, + this._userManager, ); - // Mark the user as verified if the email is verified by Google and not already verified - if (emailVerified && !user.emailVerification) { - await users.updateEmailVerification( + Future authenticate(String idToken, context) async { + context.log('Starting Google OAuth authentication'); + + final userInfo = await _validator.validate(idToken, context); + context.log('Token validated for user: ${userInfo.userId}'); + + var user = await _userManager.findUser(userInfo.userId, userInfo.email); + + if (user == null) { + context.log('Creating new user: ${userInfo.userId}'); + user = await _userManager.createUser(userInfo); + } else { + context.log('Found existing user: ${user.$id}'); + } + await _userManager.ensureEmailVerified(user, userInfo.emailVerified); + final token = await _userManager.generateToken(user.$id); + context.log('Generated token for user: ${user.$id}'); + + return AuthResponse( + secret: token.secret, userId: user.$id, - emailVerification: true, + expire: token.expire, ); } +} - // Create token - final token = await users.createToken( - userId: user.$id, - expire: 60, - length: 128, - ); +final _certificateCache = GoogleCertificateCache(); - return context.res.json({ - 'secret': token.secret, - 'userId': user.$id, - 'expire': token.expire, - }); +Future main(final context) async { + try { + final config = EnvironmentConfig.load(); + final reqBody = context.req.bodyJson as Map?; + if (reqBody == null) { + throw GoogleOAuthException('Request body is required'); + } + + final idToken = reqBody['idToken']?.toString() ?? ''; + if (idToken.isEmpty) { + throw GoogleOAuthException('idToken is required in request body'); + } + final client = Client() + .setEndpoint(config.apiEndpoint) + .setProject(config.projectId) + .setKey(context.req.headers['x-appwrite-key'] ?? ''); + final validator = + GoogleTokenValidator(config.googleClientId, _certificateCache); + final userManager = AppwriteUserManager(Users(client)); + final handler = GoogleOAuthHandler(validator, userManager); + final response = await handler.authenticate(idToken, context); + + return context.res.json(response.toJson()); + } on GoogleOAuthException catch (e) { + context.error('OAuth error: $e'); + return context.res.json( + {'error': e.message, 'details': e.details}, + statusCode: e.statusCode, + ); + } on AppwriteException catch (e) { + context.error('Appwrite error: ${e.message}'); + return context.res.json( + {'error': 'Authentication service error', 'details': e.message}, + statusCode: e.code ?? 500, + ); + } catch (e, stackTrace) { + context.error('Unexpected error: $e\n$stackTrace'); + return context.res.json( + { + 'error': 'Internal server error', + 'details': 'An unexpected error occurred' + }, + statusCode: 500, + ); + } } diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml index 8c6f6752..81a6eb79 100644 --- a/dart/sign_in_with_google/pubspec.yaml +++ b/dart/sign_in_with_google/pubspec.yaml @@ -7,7 +7,7 @@ environment: dependencies: dart_appwrite: ^19.4.0 http: ^1.6.0 - jose: ^0.3.5 + dart_jsonwebtoken: ^2.17.0 dev_dependencies: lints: ^2.0.0 From ab22688d10b8dd417dd2d12940fcebf2f00dc34c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20Ayd=C4=B1n?= <84200491+ahmtydn@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:23:03 +0300 Subject: [PATCH 11/11] feat: update dart_jsonwebtoken dependency to version 3.3.1 --- dart/sign_in_with_google/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/sign_in_with_google/pubspec.yaml b/dart/sign_in_with_google/pubspec.yaml index 81a6eb79..a145889d 100644 --- a/dart/sign_in_with_google/pubspec.yaml +++ b/dart/sign_in_with_google/pubspec.yaml @@ -7,7 +7,7 @@ environment: dependencies: dart_appwrite: ^19.4.0 http: ^1.6.0 - dart_jsonwebtoken: ^2.17.0 + dart_jsonwebtoken: ^3.3.1 dev_dependencies: lints: ^2.0.0