Skip to content

Commit 79a7bcc

Browse files
authored
Merge pull request #332 from appwrite/feat-dart-sign-in-with-apple
feat: add dart function to handle Sign in with Apple
2 parents 8d891bb + af01112 commit 79a7bcc

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

dart/sign_in_with_apple/.gitignore

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# See https://www.dartlang.org/guides/libraries/private-files
2+
3+
# Files and directories created by pub
4+
.dart_tool/
5+
.packages
6+
build/
7+
# If you're building an application, you may want to check-in your pubspec.lock
8+
pubspec.lock
9+
10+
# Directory created by dartdoc
11+
# If you don't generate documentation locally you can remove this line.
12+
doc/api/
13+
14+
# dotenv environment variables file
15+
.env*
16+
17+
# Avoid committing generated Javascript files:
18+
*.dart.js
19+
*.info.json # Produced by the --dump-info flag.
20+
*.js # When generated by dart2js. Don't specify *.js if your
21+
# project includes source files written in JavaScript.
22+
*.js_
23+
*.js.deps
24+
*.js.map
25+
26+
.flutter-plugins
27+
.flutter-plugins-dependencies
28+
29+
# Directory used by Appwrite CLI for local development
30+
.appwrite

dart/sign_in_with_apple/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# sign-in-with-apple
2+
3+
This function:
4+
5+
1. Exchanges an authorization code with Apple to obtain the user's id token.
6+
1. If a user with matching id or email doesn't exist, a new user will be created.
7+
1. The user's email will be verified if is hasn't been already.
8+
1. A token will be returned allowing the user to exchange the token for a session via `account.createSession()`.
9+
10+
> 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.
11+
12+
## 🧰 Usage
13+
14+
### POST /
15+
16+
**Headers**
17+
18+
The Content-Type header must be set to `application/json` so that the request body can be properly parsed as JSON.
19+
20+
* `Content-Type`: `application/json`
21+
22+
**Request**
23+
24+
This function accepts:
25+
26+
* `code` (required) - authorization code from the Sign in with Apple credential
27+
* `firstName` - given name from the Sign in with Apple credential
28+
* `lastName` - family name from the Sign in with Apple credential
29+
30+
Sample request body:
31+
32+
```json
33+
{
34+
"code": "c361a519253b3486ea3c7ecd4e9b6903f.0.suut.3LCHm9ytku1B2v4r5IayPQ",
35+
"firstName": "Walter",
36+
"lastName": "O'Brien",
37+
}
38+
```
39+
40+
**Response**
41+
42+
This function returns:
43+
44+
* `secret` - `secret` to be passed to `account.createSession()` to create a session
45+
* `userId` - `userId` to be passed to `account.createSession()` to create a session
46+
* `expire` - ISO formatted timestamp for when the secret expires
47+
48+
Sample `200` Response:
49+
50+
```json
51+
{
52+
"secret": "0cbdd4fd7638e0f3f55871adf2256f8f42f6faa01c9300e482c9a585b76611343dee8562ce4421b1cf9e9de6f8341fb2286499cb7992d02accd2dc699211008c",
53+
"userId": "90a5450f396c242637c39b4c39e07af4",
54+
"expire": "2025-07-15T00:10:21.345+00:00",
55+
}
56+
```
57+
58+
## ⚙️ Configuration
59+
60+
| Setting | Value |
61+
| ----------------- | --------------- |
62+
| Runtime | Dart (3.5 ) |
63+
| Entrypoint | `lib/main.dart` |
64+
| Build Commands | `dart pub get` |
65+
| Permissions | `any` |
66+
| Timeout (Seconds) | 15 |
67+
| Scopes | `users.read`, `users.write` |
68+
69+
## 🔒 Environment Variables
70+
71+
The following environment variables are required:
72+
73+
* `BUNDLE_ID` - the bundle Id of the app that generated the authorization code
74+
* `TEAM_ID` - Apple Developer team Id
75+
* `KEY_ID` - Id of the key from the Apple Developer portal
76+
* `KEY_CONTENTS_ENCODED` - base64 encoded p8 certificate
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include: package:lints/recommended.yaml
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:io';
4+
import 'package:crypto/crypto.dart';
5+
import 'package:dart_appwrite/dart_appwrite.dart';
6+
import 'package:dart_appwrite/models.dart';
7+
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
8+
import 'package:http/http.dart' as http;
9+
10+
Future<dynamic> main(final context) async {
11+
final requiredEnvVars = [
12+
'BUNDLE_ID',
13+
'TEAM_ID',
14+
'KEY_ID',
15+
'KEY_CONTENTS_ENCODED'
16+
];
17+
for (var varName in requiredEnvVars) {
18+
if (Platform.environment[varName]?.isEmpty ?? true) {
19+
throw Exception('Environment variable $varName must be set.');
20+
}
21+
}
22+
23+
final bundleId = Platform.environment['BUNDLE_ID']!;
24+
final teamId = Platform.environment['TEAM_ID']!;
25+
final keyId = Platform.environment['KEY_ID']!;
26+
final keyContentsEncoded = Platform.environment['KEY_CONTENTS_ENCODED']!;
27+
final keyContents = utf8.decode(base64Decode(keyContentsEncoded));
28+
29+
final key = ECPrivateKey(keyContents);
30+
31+
final reqBody = context.req.bodyJson as Map<String, dynamic>;
32+
final code = reqBody['code'] ?? '';
33+
final firstName = reqBody['firstName'] ?? '';
34+
final lastName = reqBody['lastName'] ?? '';
35+
36+
// Validate input
37+
if (code.isEmpty) {
38+
throw Exception('Code must be provided in the request body.');
39+
}
40+
41+
// Create a JWT client secret
42+
final header = {'alg': 'ES256', 'kid': keyId};
43+
final jwt = JWT(
44+
{},
45+
header: header,
46+
subject: bundleId,
47+
issuer: teamId,
48+
audience: Audience.one('https://appleid.apple.com'),
49+
);
50+
final clientSecret = jwt.sign(
51+
key,
52+
algorithm: JWTAlgorithm.ES256,
53+
expiresIn: Duration(minutes: 5),
54+
);
55+
56+
final authTokenRequestBody = {
57+
'grant_type': 'authorization_code',
58+
'code': code,
59+
'client_id': bundleId,
60+
'client_secret': clientSecret,
61+
};
62+
63+
final authTokenResponse = await http.post(
64+
Uri.parse('https://appleid.apple.com/auth/token'),
65+
headers: {
66+
'Content-Type': 'application/x-www-form-urlencoded',
67+
},
68+
body: authTokenRequestBody,
69+
);
70+
71+
if (authTokenResponse.statusCode != 200) {
72+
throw Exception(
73+
'Failed to exchange code for token: ${authTokenResponse.body}');
74+
}
75+
76+
final body = json.decode(authTokenResponse.body);
77+
78+
// Use access token to fetch any additional information if needed
79+
// final accessToken = body['access_token'] ?? '';
80+
81+
// Store refresh token if you want to refresh the access token later
82+
// final refreshToken = body['refresh_token'] ?? '';
83+
84+
final idToken = JWT.decode(body['id_token']);
85+
final sub = idToken.payload['sub'] ?? '';
86+
if (sub.isEmpty) {
87+
throw Exception('ID Token does not contain a valid subject (sub) claim.');
88+
}
89+
// Hash the sub because it is too long and has characters that are not allowed in Appwrite user IDs
90+
final userId = md5.convert(utf8.encode(sub)).toString();
91+
final email = idToken.payload['email'] ?? '';
92+
final userName = '$firstName $lastName'.trim();
93+
94+
// You can use the Appwrite SDK to interact with other services
95+
// For this example, we're using the Users service
96+
final client = Client()
97+
.setEndpoint(Platform.environment['APPWRITE_FUNCTION_API_ENDPOINT']!)
98+
.setProject(Platform.environment['APPWRITE_FUNCTION_PROJECT_ID']!)
99+
.setKey(context.req.headers['x-appwrite-key'] ?? '');
100+
final users = Users(client);
101+
102+
// Find user by ID
103+
User? user;
104+
try {
105+
user = await users.get(userId: userId);
106+
} on AppwriteException catch (e) {
107+
if (e.type != 'user_not_found') {
108+
rethrow;
109+
}
110+
}
111+
112+
// Find user by email
113+
final userList = await users.list(queries: [Query.equal('email', email)]);
114+
if (userList.users.isNotEmpty) {
115+
user = userList.users.first;
116+
}
117+
118+
// If user does not exist, create a new user
119+
user ??= await users.create(
120+
userId: ID.custom(userId),
121+
email: email,
122+
name: userName.isEmpty ? null : userName,
123+
);
124+
125+
// Mark the user as verified if not already verified
126+
if (!user.emailVerification) {
127+
users.updateEmailVerification(
128+
userId: userId,
129+
emailVerification: true,
130+
);
131+
}
132+
133+
// Create token
134+
final token = await users.createToken(
135+
userId: user.$id,
136+
expire: 60,
137+
length: 128,
138+
);
139+
140+
return context.res.json({
141+
'secret': token.secret,
142+
'userId': user.$id,
143+
'expire': token.expire,
144+
});
145+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: sign_in_with_apple
2+
version: 1.0.0
3+
4+
environment:
5+
sdk: ^2.17.0
6+
7+
dependencies:
8+
dart_appwrite: ^16.0.0
9+
dart_jsonwebtoken: ^3.2.0
10+
http: ^1.4.0
11+
crypto: ^3.0.6
12+
13+
dev_dependencies:
14+
lints: ^2.0.0

0 commit comments

Comments
 (0)