├── .husky └── pre-commit ├── .gitignore ├── .npmignore ├── .prettierrc ├── lib ├── index.js ├── state │ ├── null.js │ └── session.js ├── errors │ ├── tokenerror.js │ ├── internaloautherror.js │ └── authorizationerror.js └── strategy.js ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── test ├── package.test.js ├── errors │ ├── internaloautherror.test.js │ ├── tokenerror.test.js │ └── authorizationerror.test.js └── apple.test.js ├── LICENSE ├── CHANGELOG.md ├── package.json ├── examples └── server.js └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples/ 2 | test/ 3 | .travis.yml 4 | .prettierrc 5 | .eslintrc 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const Strategy = require('./strategy'); 2 | 3 | exports = module.exports = Strategy; 4 | 5 | exports.Strategy = Strategy; 6 | -------------------------------------------------------------------------------- /lib/state/null.js: -------------------------------------------------------------------------------- 1 | class NullStore { 2 | store(req, cb) { 3 | cb(); 4 | } 5 | 6 | verify(req, providedState, cb) { 7 | cb(null, true); 8 | } 9 | } 10 | 11 | module.exports = NullStore; 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "rules": { 8 | }, 9 | "parserOptions": { 10 | "ecmaVersion": 2017 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | labels: 9 | - dependencies 10 | versioning-strategy: increase 11 | commit-message: 12 | prefix: chore 13 | include: scope 14 | allow: 15 | - dependency-type: "production" 16 | -------------------------------------------------------------------------------- /test/package.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | const { expect } = require('chai'); 4 | 5 | var strategy = require('..'); 6 | 7 | describe('passport-apple', function () { 8 | it('should export Strategy constructor as module', function () { 9 | expect(strategy).to.be.a('function'); 10 | expect(strategy).to.equal(strategy.Strategy); 11 | }); 12 | 13 | it('should export Strategy constructor', function () { 14 | expect(strategy.Strategy).to.be.a('function'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.md' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node: [ '16', '18', '20'] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Setup node ${{ matrix.node }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node }} 23 | - run: npm install 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /lib/errors/tokenerror.js: -------------------------------------------------------------------------------- 1 | class TokenError extends Error { 2 | /** 3 | * @param {string} [message] 4 | * @param {string} [code] 5 | * @param {string} [uri] 6 | * @param {number} [status] 7 | */ 8 | constructor(message, code, uri, status) { 9 | super(message); 10 | Error.captureStackTrace(this, this.constructor); 11 | this.name = 'TokenError'; 12 | this.message = message; 13 | this.code = code || 'invalid_request'; 14 | this.uri = uri; 15 | this.status = status || 500; 16 | } 17 | } 18 | 19 | module.exports = TokenError; 20 | -------------------------------------------------------------------------------- /lib/errors/internaloautherror.js: -------------------------------------------------------------------------------- 1 | class InternalOAuthError extends Error { 2 | /** 3 | * @param {string} [message] 4 | * @param {object|Error} [err] 5 | */ 6 | constructor(message, err) { 7 | super(message); 8 | Error.captureStackTrace(this, this.constructor); 9 | this.name = 'InternalOAuthError'; 10 | this.message = message; 11 | this.oauthError = err; 12 | } 13 | 14 | /** 15 | * @returns {string} 16 | */ 17 | toString() { 18 | let m = this.name; 19 | if (this.message) m += ': ' + this.message; 20 | 21 | if (this.oauthError) { 22 | if (this.oauthError instanceof Error) { 23 | m = this.oauthError.toString(); 24 | } else if (this.oauthError.statusCode && this.oauthError.data) { 25 | m += ' (status: ' + this.oauthError.statusCode + ' data: ' + this.oauthError.data + ')'; 26 | } 27 | } 28 | return m; 29 | } 30 | } 31 | 32 | module.exports = InternalOAuthError; 33 | -------------------------------------------------------------------------------- /lib/errors/authorizationerror.js: -------------------------------------------------------------------------------- 1 | class AuthorizationError extends Error { 2 | /** 3 | * @param {string} [message] 4 | * @param {string} [code] 5 | * @param {string} [uri] 6 | * @param {number} [status] 7 | */ 8 | constructor(message, code, uri, status) { 9 | if (!status) { 10 | switch (code) { 11 | case 'access_denied': 12 | status = 403; 13 | break; 14 | case 'server_error': 15 | status = 502; 16 | break; 17 | case 'temporarily_unavailable': 18 | status = 503; 19 | break; 20 | } 21 | } 22 | 23 | super(message); 24 | Error.captureStackTrace(this, this.constructor); 25 | this.name = 'AuthorizationError'; 26 | this.message = message; 27 | this.code = code || 'server_error'; 28 | this.uri = uri; 29 | this.status = status || 500; 30 | } 31 | } 32 | 33 | module.exports = AuthorizationError; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nico Kaiser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.0.0 (2023-07-20) 2 | 3 | - BREAKING CHANGE: drop support for Node < 16 4 | 5 | ## 2.0.0 (2022-12-29) 6 | 7 | - BREAKING CHANGE: drop support for Node < 12 8 | 9 | ## 1.0.0 (2021-10-12) 10 | 11 | - BREAKING CHANGE: update dependencies, drop support for Node < 10 12 | - build: update husky config 13 | - build: have GitHub CI run with Node 16, not 10 14 | - deps: update dependencies 15 | - Upgrade to GitHub-native Dependabot 16 | - test: replace Travis with GitHub Actions 17 | - docs: mention usage of passReqToCallback (fixes #19) 18 | - docs: remove korean README, was outdated 19 | - nonce verification 20 | - re-use client secret as long as it is valid, instead of re-signing each time 21 | - verify the JWT signature using JWKS; set provider to 'apple' 22 | - Update to Prettier 2 23 | - Add korean README file 24 | 25 | ## 0.2.1 (2019-08-22) 26 | 27 | - docs: Mention expess.urlencoded in README.md 28 | - Use express.urlencoded instead of body-parser 29 | - Add body-parser to the example to actually make it return anything 30 | 31 | ## 0.2.0 (2019-08-19) 32 | 33 | - return emailVerified as boolean, use array struct for scope (thanks @hansemannn) 34 | - Add tests, docs and an example 35 | - Fix jsdoc 36 | 37 | ## 0.1.0 (2019-08-14) 38 | 39 | - Initial release 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nicokaiser/passport-apple", 3 | "version": "3.3.0", 4 | "description": "Sign in with Apple strategy for Passport", 5 | "keywords": [ 6 | "passport", 7 | "auth", 8 | "authn", 9 | "authentication", 10 | "authz", 11 | "authorization", 12 | "apple", 13 | "appleid" 14 | ], 15 | "files": [ 16 | "./lib/**/*" 17 | ], 18 | "main": "./lib", 19 | "scripts": { 20 | "lint": "eslint \"**/*.js\"", 21 | "test": "mocha --reporter spec test/*.test.js test/**/*.test.js && eslint \"**/*.js\"" 22 | }, 23 | "pre-commit": "test", 24 | "author": { 25 | "name": "Nico Kaiser", 26 | "email": "nico@kaiser.me", 27 | "url": "https://kaiser.me/" 28 | }, 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/nicokaiser/passport-apple.git" 33 | }, 34 | "dependencies": { 35 | "jsonwebtoken": "^9.0.2", 36 | "jwks-rsa": "^3.2.0", 37 | "oauth": "^0.10.2", 38 | "passport-strategy": "^1.0.0", 39 | "uid2": "1.0.0" 40 | }, 41 | "devDependencies": { 42 | "chai": "^4.3.7", 43 | "chai-passport-strategy": "^3.0.0", 44 | "eslint": "^8.57.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-prettier": "^5.1.3", 47 | "husky": "^9.0.11", 48 | "lint-staged": "^15.2.2", 49 | "mocha": "^10.3.0", 50 | "prettier": "^3.2.5" 51 | }, 52 | "lint-staged": { 53 | "*.js": "eslint" 54 | }, 55 | "engines": { 56 | "node": ">=16" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/errors/internaloautherror.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | const { expect } = require('chai'); 4 | 5 | const InternalOAuthError = require('../../lib/errors/internaloautherror'); 6 | 7 | describe('InternalOAuthError', function () { 8 | describe('constructed without a message', function () { 9 | var err = new InternalOAuthError(); 10 | 11 | it('should format correctly', function () { 12 | expect(err.toString()).to.equal('InternalOAuthError'); 13 | }); 14 | }); 15 | 16 | describe('constructed with a message', function () { 17 | var err = new InternalOAuthError('oops'); 18 | 19 | it('should format correctly', function () { 20 | expect(err.toString()).to.equal('InternalOAuthError: oops'); 21 | }); 22 | }); 23 | 24 | describe('constructed with a message and error', function () { 25 | var err = new InternalOAuthError('oops', new Error('something is wrong')); 26 | 27 | it('should format correctly', function () { 28 | expect(err.toString()).to.equal('Error: something is wrong'); 29 | }); 30 | }); 31 | 32 | describe('constructed with a message and object with status code and data', function () { 33 | var err = new InternalOAuthError('oops', { statusCode: 401, data: 'invalid OAuth credentials' }); 34 | 35 | it('should format correctly', function () { 36 | expect(err.toString()).to.equal('InternalOAuthError: oops (status: 401 data: invalid OAuth credentials)'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/state/session.js: -------------------------------------------------------------------------------- 1 | const uid = require('uid2'); 2 | 3 | class SessionStore { 4 | /** 5 | * @param {object} options 6 | * @param {string} options.key 7 | */ 8 | constructor(options) { 9 | if (!options.key) throw new TypeError('Session-based state store requires a session key'); 10 | this._key = options.key; 11 | } 12 | 13 | /** 14 | * @param {object} req 15 | * @param {function} callback 16 | */ 17 | store(req, callback) { 18 | if (!req.session) 19 | return callback( 20 | new Error( 21 | 'Apple authentication requires session support when using state. Did you forget to use express-session middleware?' 22 | ) 23 | ); 24 | 25 | const key = this._key; 26 | const state = uid(24); 27 | if (!req.session[key]) req.session[key] = {}; 28 | req.session[key].state = state; 29 | callback(null, state); 30 | } 31 | 32 | /** 33 | * @param {object} req 34 | * @param {string} providedState 35 | * @param {function} callback 36 | */ 37 | verify(req, providedState, callback) { 38 | if (!req.session) 39 | return callback( 40 | new Error( 41 | 'Apple authentication requires session support when using state. Did you forget to use express-session middleware?' 42 | ) 43 | ); 44 | 45 | const key = this._key; 46 | if (!req.session[key]) 47 | return callback(null, false, { message: 'Unable to verify authorization request state.' }); 48 | 49 | const state = req.session[key].state; 50 | if (!state) return callback(null, false, { message: 'Unable to verify authorization request state.' }); 51 | 52 | delete req.session[key].state; 53 | if (Object.keys(req.session[key]).length === 0) delete req.session[key]; 54 | 55 | if (state !== providedState) return callback(null, false, { message: 'Invalid authorization request state.' }); 56 | 57 | return callback(null, true); 58 | } 59 | } 60 | 61 | module.exports = SessionStore; 62 | -------------------------------------------------------------------------------- /examples/server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const express = require('express'); 5 | const session = require('express-session'); 6 | const passport = require('passport'); 7 | const errorHandler = require('errorhandler'); 8 | const AppleStrategy = require('@nicokaiser/passport-apple').Strategy; 9 | 10 | passport.serializeUser((user, callback) => callback(null, user)); 11 | 12 | passport.deserializeUser((user, callback) => callback(null, user)); 13 | 14 | passport.use( 15 | 'apple', 16 | new AppleStrategy( 17 | { 18 | clientID: 'org.example.service', 19 | teamID: '1234567890', 20 | keyID: '1234567890', 21 | key: fs.readFileSync(path.join(__dirname, 'AuthKey_1234567890.p8')), 22 | callbackURL: '/callback', 23 | scope: ['name', 'email'] 24 | }, 25 | (accessToken, refreshToken, profile, done) => { 26 | const { 27 | id, 28 | name: { firstName, lastName }, 29 | email 30 | } = profile; 31 | 32 | // Create or update the local user here. 33 | // Note: name and email are only submitted on the first login! 34 | 35 | done(null, { 36 | id, 37 | email, 38 | name: { firstName, lastName } 39 | }); 40 | } 41 | ) 42 | ); 43 | 44 | const app = express(); 45 | 46 | app.set('port', process.env.PORT || 3000); 47 | app.use( 48 | session({ 49 | resave: false, 50 | saveUninitialized: false, 51 | secret: 'keyboard cat' 52 | }) 53 | ); 54 | app.use(passport.initialize()); 55 | app.use(passport.session()); 56 | 57 | app.get('/', (req, res) => { 58 | res.send('Sign in with Apple'); 59 | }); 60 | 61 | app.get('/auth/apple', passport.authenticate('apple')); 62 | app.post( 63 | '/auth/apple/callback', 64 | express.urlencoded({ extended: false }), 65 | passport.authenticate('apple'), 66 | (req, res) => { 67 | res.json(req.user); 68 | } 69 | ); 70 | 71 | app.use(errorHandler()); 72 | 73 | app.listen(app.get('port')); 74 | -------------------------------------------------------------------------------- /test/errors/tokenerror.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | const { expect } = require('chai'); 4 | 5 | const TokenError = require('../../lib/errors/tokenerror'); 6 | 7 | describe('TokenError', function () { 8 | describe('constructed without a message', function () { 9 | var err = new TokenError(); 10 | 11 | it('should have default properties', function () { 12 | expect(err.message).to.be.undefined; 13 | expect(err.code).to.equal('invalid_request'); 14 | expect(err.uri).to.be.undefined; 15 | expect(err.status).to.equal(500); 16 | }); 17 | 18 | it('should format correctly', function () { 19 | //expect(err.toString()).to.equal('AuthorizationError'); 20 | expect(err.toString().indexOf('TokenError')).to.equal(0); 21 | }); 22 | }); 23 | 24 | describe('constructed with a message', function () { 25 | var err = new TokenError('Mismatched return URI'); 26 | 27 | it('should have default properties', function () { 28 | expect(err.message).to.equal('Mismatched return URI'); 29 | expect(err.code).to.equal('invalid_request'); 30 | expect(err.uri).to.be.undefined; 31 | expect(err.status).to.equal(500); 32 | }); 33 | 34 | it('should format correctly', function () { 35 | expect(err.toString()).to.equal('TokenError: Mismatched return URI'); 36 | }); 37 | }); 38 | 39 | describe('constructed with a message, code, uri and status', function () { 40 | var err = new TokenError( 41 | 'Unsupported grant type: foo', 42 | 'unsupported_grant_type', 43 | 'http://www.example.com/oauth/help', 44 | 501 45 | ); 46 | 47 | it('should have default properties', function () { 48 | expect(err.message).to.equal('Unsupported grant type: foo'); 49 | expect(err.code).to.equal('unsupported_grant_type'); 50 | expect(err.uri).to.equal('http://www.example.com/oauth/help'); 51 | expect(err.status).to.equal(501); 52 | }); 53 | 54 | it('should format correctly', function () { 55 | expect(err.toString()).to.equal('TokenError: Unsupported grant type: foo'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/errors/authorizationerror.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | const { expect } = require('chai'); 4 | 5 | const AuthorizationError = require('../../lib/errors/authorizationerror'); 6 | 7 | describe('AuthorizationError', function () { 8 | describe('constructed without a message', function () { 9 | const err = new AuthorizationError(); 10 | 11 | it('should have default properties', function () { 12 | expect(err.message).to.be.undefined; 13 | expect(err.code).to.equal('server_error'); 14 | expect(err.uri).to.be.undefined; 15 | expect(err.status).to.equal(500); 16 | }); 17 | 18 | it('should format correctly', function () { 19 | //expect(err.toString()).to.equal('AuthorizationError'); 20 | expect(err.toString().indexOf('AuthorizationError')).to.equal(0); 21 | }); 22 | }); 23 | 24 | describe('constructed with a message', function () { 25 | const err = new AuthorizationError('Invalid return URI'); 26 | 27 | it('should have default properties', function () { 28 | expect(err.message).to.equal('Invalid return URI'); 29 | expect(err.code).to.equal('server_error'); 30 | expect(err.uri).to.be.undefined; 31 | expect(err.status).to.equal(500); 32 | }); 33 | 34 | it('should format correctly', function () { 35 | expect(err.toString()).to.equal('AuthorizationError: Invalid return URI'); 36 | }); 37 | }); 38 | 39 | describe('constructed with a message and access_denied code', function () { 40 | const err = new AuthorizationError('Access denied', 'access_denied'); 41 | 42 | it('should have default properties', function () { 43 | expect(err.message).to.equal('Access denied'); 44 | expect(err.code).to.equal('access_denied'); 45 | expect(err.uri).to.be.undefined; 46 | expect(err.status).to.equal(403); 47 | }); 48 | }); 49 | 50 | describe('constructed with a message and server_error code', function () { 51 | const err = new AuthorizationError('Server error', 'server_error'); 52 | 53 | it('should have default properties', function () { 54 | expect(err.message).to.equal('Server error'); 55 | expect(err.code).to.equal('server_error'); 56 | expect(err.uri).to.be.undefined; 57 | expect(err.status).to.equal(502); 58 | }); 59 | }); 60 | 61 | describe('constructed with a message and temporarily_unavailable code', function () { 62 | const err = new AuthorizationError('Temporarily unavailable', 'temporarily_unavailable'); 63 | 64 | it('should have default properties', function () { 65 | expect(err.message).to.equal('Temporarily unavailable'); 66 | expect(err.code).to.equal('temporarily_unavailable'); 67 | expect(err.uri).to.be.undefined; 68 | expect(err.status).to.equal(503); 69 | }); 70 | }); 71 | 72 | describe('constructed with a message, code, uri and status', function () { 73 | const err = new AuthorizationError( 74 | 'Unsupported response type: foo', 75 | 'unsupported_response_type', 76 | 'http://www.example.com/oauth/help', 77 | 501 78 | ); 79 | 80 | it('should have default properties', function () { 81 | expect(err.message).to.equal('Unsupported response type: foo'); 82 | expect(err.code).to.equal('unsupported_response_type'); 83 | expect(err.uri).to.equal('http://www.example.com/oauth/help'); 84 | expect(err.status).to.equal(501); 85 | }); 86 | 87 | it('should format correctly', function () { 88 | expect(err.toString()).to.equal('AuthorizationError: Unsupported response type: foo'); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sign in with Apple strategy for Passport 2 | 3 | [![ci](https://github.com/nicokaiser/passport-apple/actions/workflows/ci.yml/badge.svg)](https://github.com/nicokaiser/passport-apple/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/%40nicokaiser%2Fpassport-apple.svg?style=flat)](https://www.npmjs.com/package/@nicokaiser/passport-apple) 5 | 6 | [Passport](http://www.passportjs.org/) strategy for authenticating with [Sign in with Apple](https://developer.apple.com/sign-in-with-apple/). 7 | 8 | ## Install 9 | 10 | $ npm install @nicokaiser/passport-apple 11 | 12 | ## Usage 13 | 14 | ### Create a Service 15 | 16 | Before using this module, you must register a service with Apple. You need an Apple Developer Account for this. 17 | 18 | - Register a new **App ID**, e.g. `com.example.test`, and enable the "Sign in with Apple" capability. 19 | - Register a new **Services ID**, e.g. `com.example.account`. This is the `clientID` for the module configuration. Configure "Sign in with Apple" for this service and set the **Return URLs**. 20 | - You might need to verify the ownership of the Domain by following the instructions. 21 | - Register a new **Key**, enable "Sign in with Apple" for this key and download it. Its ID is the `keyID`. 22 | 23 | ### Configure Strategy 24 | 25 | The Sign in with Apple authentication strategy authenticates users using an Apple ID and OAuth 2.0 tokens. The strategy options are supplied in the step above. The strategy also requires a `verify` callback, which receives an access token and profile, and calls `cb` providing a user. 26 | 27 | ```js 28 | passport.use(new AppleStrategy({ 29 | clientID: 'com.example.account', // Services ID 30 | teamID: '1234567890', // Team ID of your Apple Developer Account 31 | keyID: 'ABCDEFGHIJ', // Key ID, received from https://developer.apple.com/account/resources/authkeys/list 32 | key: fs.readFileSync(path.join('path', 'to', 'AuthKey_XYZ1234567.p8')), // Private key, downloaded from https://developer.apple.com/account/resources/authkeys/list 33 | scope: ['name', 'email'], 34 | callbackURL: 'https://example.com/auth/apple/callback' 35 | }, 36 | (accessToken, refreshToken, profile, cb) => { 37 | User.findOrCreate({ exampleId: profile.id }, (err, user) => { 38 | return cb(err, user); 39 | }); 40 | } 41 | )); 42 | ``` 43 | 44 | If `passReqToCallback` is set to `true`, `req` will be passed as the first argument to the verify callback: 45 | 46 | ```js 47 | passport.use(new AppleStrategy({ 48 | clientID: 'com.example.account', // Services ID 49 | ... 50 | passReqToCallback: true 51 | }, 52 | (req, accessToken, refreshToken, profile, cb) => { 53 | ... 54 | } 55 | )); 56 | ``` 57 | 58 | ### Authenticate Requests 59 | 60 | Use `passport.authenticate()`, specifying the `'apple'` strategy, to authenticate requests. The authorization code is passed via the `code` POST parameter, so your endpoint callback needs to support HTTPS POST and provide the `req.body` property. 61 | 62 | For example, as route middleware in an [Express](http://expressjs.com/) application, using `express.urlencoded` to provide `req.body`: 63 | 64 | ```js 65 | app.get('/auth/apple', 66 | passport.authenticate('apple')); 67 | 68 | app.post('/auth/apple/callback', 69 | express.urlencoded(), 70 | passport.authenticate('apple', { failureRedirect: '/login' }), 71 | (req, res) => { 72 | // Successful authentication, redirect home. 73 | res.redirect('/'); 74 | }); 75 | ``` 76 | 77 | You can find a complete example at [examples/server.js](examples/server.js). 78 | 79 | 80 | ### Nonce Verification 81 | To supply and verify a nonce to prevent a login session from being replayed, use the verifyNonce option when creating the strategy: 82 | ``` 83 | const generatedNonces = new NodeCache(); 84 | 85 | passport.use(new AppleStrategy({ 86 | ... 87 | verifyNonce: function(req, nonce, callback){ 88 | if(generatedNonces.take(nonce)){ 89 | callback(null, true); 90 | }else{ 91 | callback(new Error('invalid nonce'), false); 92 | } 93 | }, 94 | }, 95 | ... 96 | ); 97 | ``` 98 | And supply a nonce value in the options to each authenticate call: 99 | ``` 100 | app.post('/auth/apple/callback', 101 | express.urlencoded(), 102 | function(req, res, next){ 103 | const nonce = crypto.randomBytes(16).toString('hex'); 104 | generatedNonces.set(nonce, 1); 105 | passport.authenticate('apple', { failureRedirect: '/login', nonce: nonce })(req, res, next); 106 | }, 107 | (req, res) => { 108 | // Successful authentication, redirect home. 109 | res.redirect('/'); 110 | }); 111 | ``` 112 | 113 | For multi-server applications the nonces must be shared between all servers, for example by storing them in a shared cache or database. 114 | 115 | 116 | ## FAQ 117 | 118 | #### Which fields are provided in the user profile? 119 | 120 | Apple currently returns a User ID that is tied to you Team ID. That means, the same Apple ID will result in the same User ID returned for authentication requests done with your Team ID. Other Teams will get a different ID for this User. 121 | 122 | Also, if the User wants to, their name and email address is returned: 123 | 124 | ```js 125 | { id, name: { firstName, lastName }, email, emailVerified, isPrivateEmail } = profile; 126 | ``` 127 | 128 | *Note that on subsequent logins, only `id` is being returned, the other properties are only returned on the first login* 129 | 130 | #### Why not just use [passport-oauth2](https://github.com/jaredhanson/passport-oauth2/)? 131 | 132 | The login flow for Sign in with Apple is similar to OAuth 2 and OpenID Connect, but there are quite some differences. The OpenID Foundation published a document about this: [How Sign In with Apple differs from OpenID Connect](https://bitbucket.org/openid/connect/src/default/How-Sign-in-with-Apple-differs-from-OpenID-Connect.md). 133 | 134 | Namely, instead of a static `client_secret`, a JWT is used, however in a non-standard way. Also, user data is submitted alongside the authentication code via HTTP POST (and only if the "form_post" response mode is used!). 135 | 136 | Apple is still working on the interfaces, as Sign in with Apple is still in beta, so it may be OIDC compliant at some point in the future. 137 | 138 | #### How does this module differ from [passport-apple](https://github.com/ananay/passport-apple/)? 139 | 140 | [passport-apple](https://github.com/ananay/passport-apple/) uses passport-oauth2 and replaces its client secret methods. This works, however it does not support retrieving user data (like name and email). In order to properly support this, you would need to basically re-write a slimmed down version of passport-oauth2, which basically is what this module provides. 141 | 142 | ## License 143 | 144 | Licensed under [MIT](./LICENSE). 145 | -------------------------------------------------------------------------------- /test/apple.test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, xdescribe */ 2 | 3 | const chai = require('chai'); 4 | const chaiPassport = require('chai-passport-strategy'); 5 | const OAuth2 = require('oauth').OAuth2; 6 | const jwt = require('jsonwebtoken'); 7 | 8 | const AppleStrategy = require('../lib/strategy'); 9 | 10 | chai.use(chaiPassport); 11 | const expect = chai.expect; 12 | 13 | describe('AppleStrategy', () => { 14 | describe('constructed', () => { 15 | describe('with normal options', () => { 16 | const strategy = new AppleStrategy( 17 | { 18 | clientID: 'CLIENT_ID', 19 | teamID: 'TEAM_ID', 20 | keyID: 'KEY_ID', 21 | key: 'KEY' 22 | }, 23 | () => {} 24 | ); 25 | 26 | it('should be named apple', () => { 27 | expect(strategy.name).to.equal('apple'); 28 | }); 29 | }); 30 | 31 | describe('without a verify callback', function () { 32 | it('should throw', function () { 33 | expect(() => { 34 | new AppleStrategy({ 35 | clientID: 'CLIENT_ID', 36 | teamID: 'TEAM_ID', 37 | keyID: 'KEY_ID', 38 | key: 'KEY' 39 | }); 40 | }).to.throw(TypeError, 'AppleStrategy requires a verify callback'); 41 | }); 42 | }); 43 | 44 | describe('without a clientID option', function () { 45 | it('should throw', function () { 46 | expect(() => { 47 | new AppleStrategy( 48 | { 49 | teamID: 'TEAM_ID', 50 | keyID: 'KEY_ID', 51 | key: 'KEY' 52 | }, 53 | () => {} 54 | ); 55 | }).to.throw(TypeError, 'AppleStrategy requires a clientID option'); 56 | }); 57 | }); 58 | 59 | describe('without a teamID option', function () { 60 | it('should throw', function () { 61 | expect(() => { 62 | new AppleStrategy( 63 | { 64 | clientID: 'CLIENT_ID', 65 | keyID: 'KEY_ID', 66 | key: 'KEY' 67 | }, 68 | () => {} 69 | ); 70 | }).to.throw(TypeError, 'AppleStrategy requires a teamID option'); 71 | }); 72 | }); 73 | 74 | describe('without a keyID option', function () { 75 | it('should throw', function () { 76 | expect(() => { 77 | new AppleStrategy( 78 | { 79 | clientID: 'CLIENT_ID', 80 | teamID: 'TEAM_ID', 81 | key: 'KEY' 82 | }, 83 | () => {} 84 | ); 85 | }).to.throw(TypeError, 'AppleStrategy requires a keyID option'); 86 | }); 87 | }); 88 | 89 | describe('without a key option', function () { 90 | it('should throw', function () { 91 | expect(() => { 92 | new AppleStrategy( 93 | { 94 | clientID: 'CLIENT_ID', 95 | teamID: 'TEAM_ID', 96 | keyID: 'KEY_ID' 97 | }, 98 | () => {} 99 | ); 100 | }).to.throw(TypeError, 'AppleStrategy requires a key option'); 101 | }); 102 | }); 103 | }); 104 | 105 | describe('authorization request with display parameter', function () { 106 | const strategy = new AppleStrategy( 107 | { 108 | clientID: 'CLIENT_ID', 109 | teamID: 'TEAM_ID', 110 | keyID: 'KEY_ID', 111 | key: 'KEY' 112 | }, 113 | () => {} 114 | ); 115 | 116 | let url; 117 | 118 | before(function (done) { 119 | chai.passport 120 | .use(strategy) 121 | .redirect(function (u) { 122 | url = u; 123 | done(); 124 | }) 125 | .request(() => {}) 126 | .authenticate(); 127 | }); 128 | 129 | it('should be redirected', function () { 130 | expect(url).to.equal( 131 | 'https://appleid.apple.com/auth/authorize?client_id=CLIENT_ID&response_type=code&response_mode=form_post' 132 | ); 133 | }); 134 | }); 135 | 136 | describe('failure caused by user denying request', function () { 137 | const strategy = new AppleStrategy( 138 | { 139 | clientID: 'CLIENT_ID', 140 | teamID: 'TEAM_ID', 141 | keyID: 'KEY_ID', 142 | key: 'KEY' 143 | }, 144 | () => {} 145 | ); 146 | 147 | let info; 148 | 149 | before(function (done) { 150 | chai.passport 151 | .use(strategy) 152 | .fail((i) => { 153 | info = i; 154 | done(); 155 | }) 156 | .request(function (req) { 157 | req.body = {}; 158 | req.body.error = 'user_cancelled_authorize'; 159 | }) 160 | .authenticate(); 161 | }); 162 | 163 | it('should fail with info', function () { 164 | expect(info).to.not.be.undefined; 165 | expect(info.message).to.equal('User cancelled authorize'); 166 | }); 167 | }); 168 | 169 | describe('authorization response with user data', () => { 170 | const strategy = new AppleStrategy( 171 | { 172 | clientID: 'CLIENT_ID', 173 | teamID: 'TEAM_ID', 174 | keyID: 'KEY_ID', 175 | key: 'KEY' 176 | }, 177 | (accessToken, refreshToken, profile, done) => done(null, profile) 178 | ); 179 | 180 | // TODO: Replace jwt.verify with our own function here to be able to create and verfiy JWTs 181 | 182 | strategy._getOAuth2Client = () => { 183 | const oauth2 = new OAuth2(); 184 | oauth2.getOAuthAccessToken = (code, options, callback) => { 185 | if (code === 'SplxlOBeZQQYbYS6WxSbIA+ALT1' && options.grant_type === 'authorization_code') { 186 | return callback(null, 'AT', 'RT', { 187 | id_token: jwt.sign( 188 | { 189 | email: 'user@example.com' 190 | }, 191 | 'secret', 192 | { 193 | audience: 'CLIENT_ID', 194 | issuer: 'https://appleid.apple.com', 195 | subject: 'SUBJECT', 196 | expiresIn: 3600 197 | } 198 | ) 199 | }); 200 | } 201 | return callback({ 202 | statusCode: 400, 203 | data: '{"error":"invalid_grant"}' 204 | }); 205 | }; 206 | return oauth2; 207 | }; 208 | 209 | xdescribe('with req.body as object', () => { 210 | let user; 211 | 212 | before(function (done) { 213 | chai.passport 214 | .use(strategy) 215 | .success((u) => { 216 | user = u; 217 | done(); 218 | }) 219 | .request(function (req) { 220 | req.body = {}; 221 | req.body.user = { 222 | name: { firstName: 'John', lastName: 'Appleseed' } 223 | }; 224 | req.body.code = 'SplxlOBeZQQYbYS6WxSbIA+ALT1'; 225 | }) 226 | .authenticate(); 227 | }); 228 | 229 | it('should retrieve the user', function () { 230 | expect(user.id).to.equal('SUBJECT'); 231 | expect(user.email).to.equal('user@example.com'); 232 | expect(user.name.firstName).to.equal('John'); 233 | expect(user.name.lastName).to.equal('Appleseed'); 234 | }); 235 | }); 236 | 237 | xdescribe('with req.body as string', () => { 238 | let user; 239 | 240 | before(function (done) { 241 | chai.passport 242 | .use(strategy) 243 | .success((u) => { 244 | user = u; 245 | done(); 246 | }) 247 | .request(function (req) { 248 | req.body = {}; 249 | req.body.user = JSON.stringify({ 250 | name: { firstName: 'John', lastName: 'Appleseed' } 251 | }); 252 | req.body.code = 'SplxlOBeZQQYbYS6WxSbIA+ALT1'; 253 | }) 254 | .authenticate(); 255 | }); 256 | 257 | it('should retrieve the user', function () { 258 | expect(user.id).to.equal('SUBJECT'); 259 | expect(user.email).to.equal('user@example.com'); 260 | expect(user.name.firstName).to.equal('John'); 261 | expect(user.name.lastName).to.equal('Appleseed'); 262 | }); 263 | }); 264 | }); 265 | 266 | describe('error caused by invalid code sent to token endpoint', function () { 267 | const strategy = new AppleStrategy( 268 | { 269 | clientID: 'CLIENT_ID', 270 | teamID: 'TEAM_ID', 271 | keyID: 'KEY_ID', 272 | key: 'KEY' 273 | }, 274 | () => {} 275 | ); 276 | 277 | strategy._getOAuth2Client = () => { 278 | const oauth2 = new OAuth2(); 279 | oauth2.getOAuthAccessToken = (code, options, callback) => { 280 | return callback({ 281 | statusCode: 400, 282 | data: '{"error":"invalid_grant"}' 283 | }); 284 | }; 285 | return oauth2; 286 | }; 287 | 288 | let err; 289 | 290 | before(function (done) { 291 | chai.passport 292 | .use(strategy) 293 | .error(function (e) { 294 | err = e; 295 | done(); 296 | }) 297 | .request(function (req) { 298 | req.body = {}; 299 | req.body.code = 'SplxlOBeZQQYbYS6WxSbIA+ALT1'; 300 | }) 301 | .authenticate(); 302 | }); 303 | 304 | it('should error', function () { 305 | expect(err.constructor.name).to.equal('TokenError'); 306 | expect(err.code).to.equal('invalid_grant'); 307 | }); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /lib/strategy.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | const querystring = require('querystring'); 3 | 4 | const passport = require('passport-strategy'); 5 | const OAuth2 = require('oauth').OAuth2; 6 | const jwt = require('jsonwebtoken'); 7 | const jwksClient = require('jwks-rsa'); 8 | 9 | const NullStateStore = require('./state/null'); 10 | const SessionStateStore = require('./state/session'); 11 | const AuthorizationError = require('./errors/authorizationerror'); 12 | const TokenError = require('./errors/tokenerror'); 13 | const InternalOAuthError = require('./errors/internaloautherror'); 14 | 15 | const jwks_client = jwksClient({ 16 | strictSsl: true, 17 | rateLimit: true, 18 | cache: true, 19 | cacheMaxEntries: 100, 20 | cacheMaxAge: 1000 * 60 * 60 * 24, 21 | jwksUri: 'https://appleid.apple.com/auth/keys' 22 | }); 23 | 24 | const getAppleJWKSKey = (header, callback) => { 25 | jwks_client 26 | .getSigningKey(header.kid) 27 | .then((key) => { 28 | callback(null, key && (key.publicKey || key.rsaPublicKey)); 29 | }) 30 | .catch((err) => callback(err)); 31 | }; 32 | 33 | // the client secret for a given key is a signed JWT which is allowed to live 34 | // for a relatively long time, so cache and re-use these to avoid unnecessary 35 | // signature operations: 36 | const clientSecretCache = new Map(); 37 | 38 | class AppleStrategy extends passport.Strategy { 39 | /** 40 | * @param {object} options 41 | * @param {string} options.clientID 42 | * @param {string} options.teamID 43 | * @param {string} options.keyID 44 | * @param {string} options.key 45 | * @param {string} [options.authorizationURL=https://appleid.apple.com/auth/authorize] 46 | * @param {string} [options.tokenURL=https://appleid.apple.com/auth/token] 47 | * @param {Array} [options.scope] 48 | * @param {string} [options.sessionKey] 49 | * @param {boolean} [options.state] 50 | * @param {boolean} [options.passReqToCallback=false] 51 | * @param {string} [options.callbackURL] 52 | * @param {function} verify 53 | */ 54 | constructor(options = {}, verify) { 55 | if (!verify) throw new TypeError('AppleStrategy requires a verify callback'); 56 | if (!options.clientID) throw new TypeError('AppleStrategy requires a clientID option'); 57 | if (!options.teamID) throw new TypeError('AppleStrategy requires a teamID option'); 58 | if (!options.keyID) throw new TypeError('AppleStrategy requires a keyID option'); 59 | if (!options.key) throw new TypeError('AppleStrategy requires a key option'); 60 | 61 | super(); 62 | this.name = 'apple'; 63 | this._verify = verify; 64 | 65 | this._clientID = options.clientID; 66 | this._teamID = options.teamID; 67 | this._keyID = options.keyID; 68 | this._key = options.key; 69 | this._authorizationURL = options.authorizationURL || 'https://appleid.apple.com/auth/authorize'; 70 | this._tokenURL = options.tokenURL || 'https://appleid.apple.com/auth/token'; 71 | this._callbackURL = options.callbackURL; 72 | this._scope = options.scope; 73 | this._sessionKey = options.sessionKey || 'apple:' + url.parse(this._authorizationURL).hostname; 74 | this._clientSecretExpiry = options.clientSecretExpiry || '5 minutes'; 75 | this._verifyNonce = options.verifyNonce; 76 | 77 | if (options.state) { 78 | this._stateStore = new SessionStateStore({ key: this._sessionKey }); 79 | } else { 80 | this._stateStore = new NullStateStore(); 81 | } 82 | 83 | this._passReqToCallback = options.passReqToCallback; 84 | } 85 | 86 | verifyNonce(req, nonce_supported, nonce, callback) { 87 | if (this._verifyNonce && nonce_supported) { 88 | return this._verifyNonce(req, nonce, callback); 89 | } else { 90 | return callback(null, true); 91 | } 92 | } 93 | 94 | /** 95 | * @param {http.IncomingMessage} req 96 | * @param {object} [options] 97 | * @param {string} [options.callbackURL] 98 | * @param {Array} [options.scope] 99 | * @param {string} [options.state] 100 | */ 101 | authenticate(req, options = {}) { 102 | if (req.body && req.body.error) { 103 | if (req.body.error === 'user_cancelled_authorize') { 104 | return this.fail({ message: 'User cancelled authorize' }); 105 | } else { 106 | return this.error(new AuthorizationError(req.body.error, req.body.error)); 107 | } 108 | } 109 | 110 | let callbackURL = options.callbackURL || this._callbackURL; 111 | 112 | if (req.body && req.body.code) { 113 | const state = req.body.state; 114 | try { 115 | this._stateStore.verify(req, state, (err, ok, state) => { 116 | if (err) return this.error(err); 117 | if (!ok) return this.fail(state, 403); 118 | 119 | const code = req.body.code; 120 | 121 | const params = { grant_type: 'authorization_code' }; 122 | if (callbackURL) params.redirect_uri = callbackURL; 123 | 124 | const oauth2 = this._getOAuth2Client(); 125 | 126 | oauth2.getOAuthAccessToken(code, params, (err, accessToken, refreshToken, params) => { 127 | if (err) return this.error(this._createOAuthError('Failed to obtain access token', err)); 128 | 129 | const idToken = params['id_token']; 130 | if (!idToken) return this.error(new Error('ID Token not present in token response')); 131 | 132 | const verifyOpts = { 133 | audience: this._clientID, 134 | issuer: 'https://appleid.apple.com', 135 | algorithms: ['RS256'] 136 | }; 137 | jwt.verify(idToken, getAppleJWKSKey, verifyOpts, (err, jwtClaims) => { 138 | if (err) { 139 | return this.error(err); 140 | } 141 | 142 | this.verifyNonce(req, jwtClaims.nonce_supported, jwtClaims.nonce, (err, ok) => { 143 | if (err) return this.error(err); 144 | if (!ok) return this.fail({ message: 'invalid nonce' }); 145 | 146 | const profile = { id: jwtClaims.sub, provider: 'apple' }; 147 | 148 | if (jwtClaims.email) { 149 | profile.email = jwtClaims.email; 150 | } 151 | 152 | if (jwtClaims.email_verified) { 153 | profile.emailVerified = 154 | jwtClaims.email_verified === 'true' || jwtClaims.email_verified === true; 155 | } 156 | 157 | if (jwtClaims.is_private_email) { 158 | profile.isPrivateEmail = 159 | jwtClaims.is_private_email === 'true' || jwtClaims.is_private_email === true; 160 | } 161 | 162 | if (req.body.user) { 163 | if (typeof req.body.user === 'object' && req.body.user.name) { 164 | profile.name = req.body.user.name; 165 | } else { 166 | try { 167 | const user = JSON.parse(req.body.user); 168 | if (user && user.name) profile.name = user.name; 169 | } catch (ex) { 170 | return this.error(ex); 171 | } 172 | } 173 | } 174 | 175 | const verified = (err, user, info) => { 176 | if (err) return this.error(err); 177 | if (!user) return this.fail(info); 178 | 179 | info = info || {}; 180 | if (state) info.state = state; 181 | this.success(user, info); 182 | }; 183 | 184 | try { 185 | if (this._passReqToCallback) { 186 | this._verify(req, accessToken, refreshToken, profile, verified); 187 | } else { 188 | this._verify(accessToken, refreshToken, profile, verified); 189 | } 190 | } catch (ex) { 191 | return this.error(ex); 192 | } 193 | }); 194 | }); 195 | }); 196 | }); 197 | } catch (ex) { 198 | return this.error(ex); 199 | } 200 | } else { 201 | const params = { 202 | client_id: this._clientID, 203 | response_type: 'code', 204 | response_mode: 'form_post' 205 | }; 206 | if (callbackURL) params.redirect_uri = callbackURL; 207 | let scope = options.scope || this._scope; 208 | if (scope) { 209 | params.scope = scope.join(' '); 210 | } 211 | 212 | if (options.nonce) { 213 | params.nonce = options.nonce; 214 | } 215 | 216 | const state = options.state; 217 | if (state) { 218 | params.state = state; 219 | this.redirect(this._authorizationURL + '?' + querystring.stringify(params)); 220 | } else { 221 | this._stateStore.store(req, (err, state) => { 222 | if (err) return this.error(err); 223 | 224 | if (state) params.state = state; 225 | this.redirect(this._authorizationURL + '?' + querystring.stringify(params)); 226 | }); 227 | } 228 | } 229 | } 230 | 231 | /** 232 | * @param {string} body 233 | * @returns {Error} 234 | */ 235 | parseErrorResponse(body) { 236 | const json = JSON.parse(body); 237 | if (json.error) { 238 | return new TokenError(json.error_description || json.error, json.error, json.error_uri); 239 | } 240 | return null; 241 | } 242 | 243 | /** 244 | * @returns {jwt|string} signed jwt client secret 245 | */ 246 | _getClientSecret() { 247 | // if our current secret has expired (with a few seconds grace), or 248 | // hasn't been generated yet, regenerate it: 249 | const existing = clientSecretCache.get(this._keyID); 250 | if (!existing || jwt.decode(existing).exp < Date.now() / 1000 + 5) { 251 | clientSecretCache.set( 252 | this._keyID, 253 | jwt.sign({}, this._key, { 254 | algorithm: 'ES256', 255 | keyid: this._keyID, 256 | expiresIn: this._clientSecretExpiry, 257 | issuer: this._teamID, 258 | audience: 'https://appleid.apple.com', 259 | subject: this._clientID 260 | }) 261 | ); 262 | } 263 | return clientSecretCache.get(this._keyID); 264 | } 265 | 266 | /** 267 | * @returns {oauth2.OAuth2} 268 | */ 269 | _getOAuth2Client() { 270 | return new OAuth2(this._clientID, this._getClientSecret(), '', this._authorizationURL, this._tokenURL); 271 | } 272 | 273 | /** 274 | * @param {string} message 275 | * @param {object|Error} err 276 | * @returns {Error} 277 | */ 278 | _createOAuthError(message, err) { 279 | let e; 280 | if (err.statusCode && err.data) { 281 | try { 282 | e = this.parseErrorResponse(err.data); 283 | } catch (_) { 284 | // ignore 285 | } 286 | } 287 | if (!e) e = new InternalOAuthError(message, err); 288 | return e; 289 | } 290 | } 291 | 292 | module.exports = AppleStrategy; 293 | --------------------------------------------------------------------------------