diff --git a/dart/sign_in_with_apple/.gitignore b/dart/sign_in_with_apple/.gitignore new file mode 100644 index 00000000..a8852f54 --- /dev/null +++ b/dart/sign_in_with_apple/.gitignore @@ -0,0 +1,30 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +# dotenv environment variables file +.env* + +# Avoid committing generated Javascript files: +*.dart.js +*.info.json # Produced by the --dump-info flag. +*.js # When generated by dart2js. Don't specify *.js if your + # project includes source files written in JavaScript. +*.js_ +*.js.deps +*.js.map + +.flutter-plugins +.flutter-plugins-dependencies + +# Directory used by Appwrite CLI for local development +.appwrite \ No newline at end of file diff --git a/dart/sign_in_with_apple/README.md b/dart/sign_in_with_apple/README.md new file mode 100644 index 00000000..81f8e96f --- /dev/null +++ b/dart/sign_in_with_apple/README.md @@ -0,0 +1,76 @@ +# sign-in-with-apple + +This function: + +1. Exchanges an authorization code with Apple to obtain the user's id token. +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 is hasn't been already. +1. A token will be returned allowing the user to exchange the token for a session via `account.createSession()`. + +> Note: this function uses an md5 hash of the `sub` as the user's id since the value from Apple is too long and has unsupported characters. + +## 🧰 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: + +* `code` (required) - authorization code from the Sign in with Apple credential +* `firstName` - given name from the Sign in with Apple credential +* `lastName` - family name from the Sign in with Apple credential + +Sample request body: + +```json +{ + "code": "c361a519253b3486ea3c7ecd4e9b6903f.0.suut.3LCHm9ytku1B2v4r5IayPQ", + "firstName": "Walter", + "lastName": "O'Brien", +} +``` + +**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": "90a5450f396c242637c39b4c39e07af4", + "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: + +* `BUNDLE_ID` - the bundle Id of the app that generated the authorization code +* `TEAM_ID` - Apple Developer team Id +* `KEY_ID` - Id of the key from the Apple Developer portal +* `KEY_CONTENTS_ENCODED` - base64 encoded p8 certificate diff --git a/dart/sign_in_with_apple/analysis_options.yaml b/dart/sign_in_with_apple/analysis_options.yaml new file mode 100644 index 00000000..572dd239 --- /dev/null +++ b/dart/sign_in_with_apple/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/dart/sign_in_with_apple/lib/main.dart b/dart/sign_in_with_apple/lib/main.dart new file mode 100644 index 00000000..d7cd005c --- /dev/null +++ b/dart/sign_in_with_apple/lib/main.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +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; + +Future main(final context) async { + final requiredEnvVars = [ + 'BUNDLE_ID', + 'TEAM_ID', + 'KEY_ID', + 'KEY_CONTENTS_ENCODED' + ]; + for (var varName in requiredEnvVars) { + if (Platform.environment[varName]?.isEmpty ?? true) { + throw Exception('Environment variable $varName must be set.'); + } + } + + final bundleId = Platform.environment['BUNDLE_ID']!; + final teamId = Platform.environment['TEAM_ID']!; + final keyId = Platform.environment['KEY_ID']!; + final keyContentsEncoded = Platform.environment['KEY_CONTENTS_ENCODED']!; + final keyContents = utf8.decode(base64Decode(keyContentsEncoded)); + + final key = ECPrivateKey(keyContents); + + final reqBody = context.req.bodyJson as Map; + final code = reqBody['code'] ?? ''; + final firstName = reqBody['firstName'] ?? ''; + final lastName = reqBody['lastName'] ?? ''; + + // Validate input + if (code.isEmpty) { + throw Exception('Code must be provided in the request body.'); + } + + // Create a JWT client secret + final header = {'alg': 'ES256', 'kid': keyId}; + final jwt = JWT( + {}, + header: header, + subject: bundleId, + issuer: teamId, + audience: Audience.one('https://appleid.apple.com'), + ); + final clientSecret = jwt.sign( + key, + algorithm: JWTAlgorithm.ES256, + expiresIn: Duration(minutes: 5), + ); + + final authTokenRequestBody = { + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': bundleId, + 'client_secret': clientSecret, + }; + + final authTokenResponse = await http.post( + Uri.parse('https://appleid.apple.com/auth/token'), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: authTokenRequestBody, + ); + + if (authTokenResponse.statusCode != 200) { + throw Exception( + 'Failed to exchange code for token: ${authTokenResponse.body}'); + } + + final body = json.decode(authTokenResponse.body); + + // Use access token to fetch any additional information if needed + // final accessToken = body['access_token'] ?? ''; + + // Store refresh token if you want to refresh the access token later + // final refreshToken = body['refresh_token'] ?? ''; + + final idToken = JWT.decode(body['id_token']); + final sub = idToken.payload['sub'] ?? ''; + if (sub.isEmpty) { + throw Exception('ID Token does not contain a valid subject (sub) claim.'); + } + // Hash the sub because it is too long and has characters that are not allowed in Appwrite user IDs + final userId = md5.convert(utf8.encode(sub)).toString(); + final email = idToken.payload['email'] ?? ''; + final userName = '$firstName $lastName'.trim(); + + // 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 + 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, + name: userName.isEmpty ? null : userName, + ); + + // Mark the user as verified if not already verified + if (!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_apple/pubspec.yaml b/dart/sign_in_with_apple/pubspec.yaml new file mode 100644 index 00000000..38a98de2 --- /dev/null +++ b/dart/sign_in_with_apple/pubspec.yaml @@ -0,0 +1,14 @@ +name: sign_in_with_apple +version: 1.0.0 + +environment: + sdk: ^2.17.0 + +dependencies: + dart_appwrite: ^16.0.0 + dart_jsonwebtoken: ^3.2.0 + http: ^1.4.0 + crypto: ^3.0.6 + +dev_dependencies: + lints: ^2.0.0