diff --git a/node-typescript/sign_in_with_apple/.gitignore b/node-typescript/sign_in_with_apple/.gitignore new file mode 100644 index 00000000..f236e5ce --- /dev/null +++ b/node-typescript/sign_in_with_apple/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.env +.DS_Store \ No newline at end of file diff --git a/node-typescript/sign_in_with_apple/.prettierrc.json b/node-typescript/sign_in_with_apple/.prettierrc.json new file mode 100644 index 00000000..6bdf86a1 --- /dev/null +++ b/node-typescript/sign_in_with_apple/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2 +} diff --git a/node-typescript/sign_in_with_apple/README.md b/node-typescript/sign_in_with_apple/README.md new file mode 100644 index 00000000..45f775b1 --- /dev/null +++ b/node-typescript/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 | Node.js (20.0) | +| Entrypoint | `dist/main.js` | +| Build Commands | `npm install && npm run build` | +| 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/node-typescript/sign_in_with_apple/package-lock.json b/node-typescript/sign_in_with_apple/package-lock.json new file mode 100644 index 00000000..7a6c2fc5 --- /dev/null +++ b/node-typescript/sign_in_with_apple/package-lock.json @@ -0,0 +1,249 @@ +{ + "name": "sign-in-with-apple", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sign-in-with-apple", + "version": "1.0.0", + "dependencies": { + "jsonwebtoken": "^9.0.2", + "node-appwrite": "^14.1.0", + "typescript": "^5.4.5", + "undici": "^6.18.1" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.12.12", + "prettier": "^3.2.5" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.10.tgz", + "integrity": "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-appwrite": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz", + "integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==", + "license": "BSD-3-Clause", + "dependencies": { + "node-fetch-native-with-agent": "1.7.2" + } + }, + "node_modules/node-fetch-native-with-agent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", + "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/node-typescript/sign_in_with_apple/package.json b/node-typescript/sign_in_with_apple/package.json new file mode 100644 index 00000000..6195a0db --- /dev/null +++ b/node-typescript/sign_in_with_apple/package.json @@ -0,0 +1,28 @@ +{ + "name": "sign-in-with-apple", + "version": "1.0.0", + "description": "Sign in with Apple for Appwrite functions", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "format": "prettier --write ." + }, + "keywords": [ + "appwrite", + "apple", + "sign-in", + "authentication" + ], + "dependencies": { + "jsonwebtoken": "^9.0.2", + "node-appwrite": "^14.1.0", + "typescript": "^5.4.5", + "undici": "^6.18.1" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.12.12", + "prettier": "^3.2.5" + } +} diff --git a/node-typescript/sign_in_with_apple/src/main.ts b/node-typescript/sign_in_with_apple/src/main.ts new file mode 100644 index 00000000..2a1b133d --- /dev/null +++ b/node-typescript/sign_in_with_apple/src/main.ts @@ -0,0 +1,192 @@ +import { Client, Users, Query, ID } from 'node-appwrite'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import { request } from 'undici'; + +interface RequestBody { + code: string; + firstName?: string; + lastName?: string; +} + +interface AppleTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + id_token: string; +} + +interface AppleIdTokenPayload { + sub: string; + email?: string; + email_verified?: boolean; + aud: string; + iss: string; + iat: number; + exp: number; +} + +// This Appwrite function will be executed every time your function is triggered +export default async ({ req, res, log, error }: any) => { + try { + // Validate required environment variables + const requiredEnvVars = [ + 'BUNDLE_ID', + 'TEAM_ID', + 'KEY_ID', + 'KEY_CONTENTS_ENCODED', + ]; + for (const varName of requiredEnvVars) { + if (!process.env[varName]) { + throw new Error(`Environment variable ${varName} must be set.`); + } + } + + const bundleId = process.env.BUNDLE_ID!; + const teamId = process.env.TEAM_ID!; + const keyId = process.env.KEY_ID!; + const keyContentsEncoded = process.env.KEY_CONTENTS_ENCODED!; + + // Decode the base64 encoded private key + const keyContents = Buffer.from(keyContentsEncoded, 'base64').toString( + 'utf8' + ); + + // Parse request body + const reqBody: RequestBody = + typeof req.body === 'string' ? JSON.parse(req.body) : req.body; + const { code, firstName = '', lastName = '' } = reqBody; + + // Validate input + if (!code) { + throw new Error('Code must be provided in the request body.'); + } + + // Create JWT client secret for Apple + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: teamId, + iat: now, + exp: now + 300, // 5 minutes + aud: 'https://appleid.apple.com', + sub: bundleId, + }; + + const header = { + alg: 'ES256', + kid: keyId, + }; + + const clientSecret = jwt.sign(payload, keyContents, { + algorithm: 'ES256', + header: header, + }); + + // Exchange authorization code for tokens + const authTokenRequestBody = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: bundleId, + client_secret: clientSecret, + }); + + const authTokenResponse = await request( + 'https://appleid.apple.com/auth/token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: authTokenRequestBody.toString(), + } + ); + + if (authTokenResponse.statusCode !== 200) { + const responseBody = await authTokenResponse.body.text(); + throw new Error(`Failed to exchange code for token: ${responseBody}`); + } + + const tokenData = + (await authTokenResponse.body.json()) as AppleTokenResponse; + + // Decode the ID token to get user information + const idTokenDecoded = jwt.decode( + tokenData.id_token + ) as AppleIdTokenPayload; + + if (!idTokenDecoded || !idTokenDecoded.sub) { + throw new Error('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 + const userId = crypto + .createHash('md5') + .update(idTokenDecoded.sub) + .digest('hex'); + const email = idTokenDecoded.email || ''; + const userName = `${firstName} ${lastName}`.trim(); + + // Initialize Appwrite client + const client = new Client() + .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT ?? '') + .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID ?? '') + .setKey(req.headers['x-appwrite-key'] ?? ''); + + const users = new Users(client); + + // Find user by ID + let user = null; + try { + user = await users.get(userId); + } catch (err: any) { + if (err.type !== 'user_not_found') { + throw err; + } + } + + // Find user by email if not found by ID + if (!user && email) { + try { + const userList = await users.list([Query.equal('email', email)]); + if (userList.users.length > 0) { + user = userList.users[0]; + } + } catch (err: any) { + log('Error searching for user by email:', err.message); + } + } + + // If user does not exist, create a new user + if (!user) { + user = await users.create( + ID.custom(userId), + email, + undefined, // phone + undefined, // password + userName || undefined // name + ); + } + + // Mark the user as verified if not already verified + if (!user.emailVerification && email) { + try { + await users.updateEmailVerification(userId, true); + } catch (err: any) { + log('Error updating email verification:', err.message); + } + } + + // Create token + const token = await users.createToken(user.$id, 60, 128); + + return res.json({ + secret: token.secret, + userId: user.$id, + expire: token.expire, + }); + } catch (err: any) { + error('Error in sign-in-with-apple function:', err.message); + return res.json({ error: err.message }, 400); + } +}; diff --git a/node-typescript/sign_in_with_apple/tsconfig.json b/node-typescript/sign_in_with_apple/tsconfig.json new file mode 100644 index 00000000..bb6a18c8 --- /dev/null +++ b/node-typescript/sign_in_with_apple/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}