import(/* webpackChunkName: "entry" */'@/components/
const HomePage = () => import(/* webpackChunkName: "entry" */'@/components/static/home');
const AppPage = () => import(/* webpackChunkName: "static" */'@/components/static/app');
+const AppleRedirectPage = () => import(/* webpackChunkName: "static" */'@/components/static/appleRedirect');
const ClearBrowserDataPage = () => import(/* webpackChunkName: "static" */'@/components/static/clearBrowserData');
const CommunityGuidelinesPage = () => import(/* webpackChunkName: "static" */'@/components/static/communityGuidelines');
const ContactPage = () => import(/* webpackChunkName: "static" */'@/components/static/contact');
@@ -272,6 +273,9 @@ const router = new VueRouter({
{
name: 'app', path: 'app', component: AppPage, meta: { requiresLogin: false },
},
+ {
+ name: 'appleRedirect', path: 'apple-redirect', component: AppleRedirectPage, meta: { requiresLogin: false },
+ },
{
name: 'clearBrowserData', path: 'clear-browser-data', component: ClearBrowserDataPage, meta: { requiresLogin: false },
},
diff --git a/website/client/src/store/actions/auth.js b/website/client/src/store/actions/auth.js
index 8d80da0ecd..2d5a352316 100644
--- a/website/client/src/store/actions/auth.js
+++ b/website/client/src/store/actions/auth.js
@@ -82,6 +82,27 @@ export async function socialAuth (store, params) {
localStorage.setItem(LOCALSTORAGE_AUTH_KEY, userLocalData);
}
+export async function appleAuth (store, params) {
+ const url = '/api/v4/user/auth/apple';
+ const result = await axios.get(url, {
+ params: {
+ code: params.code,
+ name: params.name,
+ },
+ });
+
+ const user = result.data.data;
+
+ const userLocalData = JSON.stringify({
+ auth: {
+ apiId: user.id,
+ apiToken: user.apiToken,
+ },
+ });
+
+ localStorage.setItem(LOCALSTORAGE_AUTH_KEY, userLocalData);
+}
+
export function logout (store, options = {}) {
localStorage.clear();
const query = options.redirectToLogin === true ? '?redirectToLogin=true' : '';
diff --git a/website/client/vue.config.js b/website/client/vue.config.js
index 10260e1f9e..cfa5132202 100644
--- a/website/client/vue.config.js
+++ b/website/client/vue.config.js
@@ -25,6 +25,7 @@ const envVars = [
'STRIPE_PUB_KEY',
'FACEBOOK_KEY',
'GOOGLE_CLIENT_ID',
+ 'APPLE_AUTH_CLIENT_ID',
'AMPLITUDE_KEY',
'LOGGLY_CLIENT_TOKEN',
// TODO necessary? if yes how not to mess up with vue cli? 'NODE_ENV'
diff --git a/website/common/script/constants.js b/website/common/script/constants.js
index 03c3d1e4f4..9353385cb7 100644
--- a/website/common/script/constants.js
+++ b/website/common/script/constants.js
@@ -20,6 +20,7 @@ export const CHAT_FLAG_FROM_SHADOW_MUTE = 10;
export const SUPPORTED_SOCIAL_NETWORKS = [
{ key: 'facebook', name: 'Facebook' },
{ key: 'google', name: 'Google' },
+ { key: 'apple', name: 'Apple' },
];
export const GUILDS_PER_PAGE = 30; // number of guilds to return per page when using pagination
@@ -27,6 +28,7 @@ export const GUILDS_PER_PAGE = 30; // number of guilds to return per page when u
export const PARTY_LIMIT_MEMBERS = 30;
export const MINIMUM_PASSWORD_LENGTH = 8;
+
export const TRANSFORMATION_DEBUFFS_LIST = {
snowball: 'salt',
spookySparkles: 'opaquePotion',
diff --git a/website/server/controllers/api-v3/auth.js b/website/server/controllers/api-v3/auth.js
index 9d1befafb8..3cafd1a1c0 100644
--- a/website/server/controllers/api-v3/auth.js
+++ b/website/server/controllers/api-v3/auth.js
@@ -146,6 +146,41 @@ api.loginSocial = {
},
};
+// Called by apple for web authentication.
+api.redirectApple = {
+ method: 'POST',
+ middlewares: [authWithHeaders({
+ optional: true,
+ })],
+ url: '/user/auth/apple',
+ async handler (req, res) {
+ if (req.body.id_token) {
+ req.body.network = 'apple';
+ return loginSocial(req, res);
+ }
+ let url = `/static/apple-redirect?code=${req.body.code}`;
+ if (req.body.user) {
+ const { name } = JSON.parse(req.body.user);
+ url += `&name=${name.firstName} ${name.lastName}`;
+ }
+ return res.redirect(303, url);
+ },
+};
+
+// Called as a callback by Apple. Internal route
+// Can be passed `code` and `name` as query parameters
+api.loginApple = {
+ method: 'GET',
+ middlewares: [authWithHeaders({
+ optional: true,
+ })],
+ url: '/user/auth/apple',
+ async handler (req, res) {
+ req.body.network = 'apple';
+ return loginSocial(req, res);
+ },
+};
+
/**
* @api {put} /api/v3/user/auth/update-username Update username
* @apiDescription Update and verify the user's username
diff --git a/website/server/libs/auth/apple.js b/website/server/libs/auth/apple.js
new file mode 100644
index 0000000000..ed34d3506f
--- /dev/null
+++ b/website/server/libs/auth/apple.js
@@ -0,0 +1,52 @@
+import AppleAuth from 'apple-auth';
+import nconf from 'nconf';
+import jwt from 'jsonwebtoken';
+import jwksClient from 'jwks-rsa';
+import util from 'util';
+
+const APPLE_PRIVATE_KEY = nconf.get('APPLE_AUTH_PRIVATE_KEY');
+const APPLE_AUTH_CLIENT_ID = nconf.get('APPLE_AUTH_CLIENT_ID');
+const APPLE_TEAM_ID = nconf.get('APPLE_TEAM_ID');
+const APPLE_AUTH_KEY_ID = nconf.get('APPLE_AUTH_KEY_ID');
+const BASE_URL = nconf.get('BASE_URL');
+
+const appleAuth = new AppleAuth(JSON.stringify({
+ client_id: APPLE_AUTH_CLIENT_ID, // eslint-disable-line camelcase
+ team_id: APPLE_TEAM_ID, // eslint-disable-line camelcase
+ key_id: APPLE_AUTH_KEY_ID, // eslint-disable-line camelcase
+ redirect_uri: `${BASE_URL}/api/v4/user/auth/apple`, // eslint-disable-line camelcase
+ scope: 'name email',
+}), APPLE_PRIVATE_KEY, 'text');
+
+const APPLE_PUBLIC_KEYS_URL = 'https://appleid.apple.com/auth/keys';
+
+const appleJwksClient = jwksClient({
+ jwksUri: APPLE_PUBLIC_KEYS_URL,
+});
+
+const getAppleSigningKey = util.promisify(appleJwksClient.getSigningKey);
+
+export async function appleProfile (req) {
+ const code = req.body.code ? req.body.code : req.query.code;
+ const passedToken = req.body.id_token ? req.body.id_token : req.query.id_token;
+ let idToken;
+
+ if (code) {
+ const response = await appleAuth.accessToken(code);
+ idToken = response.id_token;
+ } else if (passedToken) {
+ idToken = passedToken;
+ }
+
+ const decodedToken = jwt.decode(idToken, { complete: true });
+ const signingKey = await getAppleSigningKey(decodedToken.header.kid);
+ const applePublicKey = signingKey.getPublicKey();
+
+ const verifiedPayload = await jwt.verify(idToken, applePublicKey, { algorithms: 'RS256' });
+
+ return {
+ id: verifiedPayload.sub,
+ emails: [{ value: verifiedPayload.email }],
+ name: verifiedPayload.name || req.body.name || req.query.name,
+ };
+}
diff --git a/website/server/libs/auth/social.js b/website/server/libs/auth/social.js
index a56adb64d7..02e4d64faf 100644
--- a/website/server/libs/auth/social.js
+++ b/website/server/libs/auth/social.js
@@ -6,6 +6,7 @@ import {
generateUsername,
loginRes,
} from './utils';
+import { appleProfile } from './apple';
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../email';
@@ -24,14 +25,21 @@ function _passportProfile (network, accessToken) {
export async function loginSocial (req, res) { // eslint-disable-line import/prefer-default-export
const existingUser = res.locals.user;
- const accessToken = req.body.authResponse.access_token;
const { network } = req.body;
const isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS
.find(supportedNetwork => supportedNetwork.key === network);
if (!isSupportedNetwork) throw new BadRequest(res.t('unsupportedNetwork'));
- const profile = await _passportProfile(network, accessToken);
+ let profile = {};
+ if (network === 'apple') {
+ profile = await appleProfile(req);
+ } else {
+ const accessToken = req.body.authResponse.access_token;
+ profile = await _passportProfile(network, accessToken);
+ }
+
+ if (!profile.id) throw new BadRequest(res.t('invalidData'));
let user = await User.findOne({
[`auth.${network}.id`]: profile.id,
@@ -80,7 +88,7 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
user.newUser = true;
}
- loginRes(user, req, res);
+ const response = loginRes(user, req, res);
// Clean previous email preferences
if (
@@ -114,5 +122,5 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
});
}
- return null;
+ return response;
}
diff --git a/website/server/libs/auth/utils.js b/website/server/libs/auth/utils.js
index 99c9083a3f..292f8f5226 100644
--- a/website/server/libs/auth/utils.js
+++ b/website/server/libs/auth/utils.js
@@ -1,5 +1,6 @@
import nconf from 'nconf';
import shortid from 'short-uuid';
+import url from 'url';
import { NotAuthorized } from '../errors';
@@ -18,6 +19,11 @@ export function loginRes (user, req, res) {
{ communityManagerEmail: COMMUNITY_MANAGER_EMAIL, userId: user._id },
));
}
+ const urlPath = url.parse(req.url).pathname;
+ if (req.headers['x-client'] === 'habitica-android' && urlPath.includes('apple')) {
+ // This is a workaround for android not being able to handle sign in with apple better.
+ return res.redirect(`/?id=${user._id}&key=${user.apiToken}&newUser=${user.newUser || false}`);
+ }
const responseData = {
id: user._id,
@@ -25,6 +31,5 @@ export function loginRes (user, req, res) {
newUser: user.newUser || false,
username: user.auth.local.username,
};
-
return res.respond(200, responseData);
}
diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js
index 2fac9e33b0..4db85dcc6b 100644
--- a/website/server/models/user/schema.js
+++ b/website/server/models/user/schema.js
@@ -31,6 +31,7 @@ export default new Schema({
$type: Schema.Types.Mixed,
default: () => ({}),
},
+ apple: { $type: Schema.Types.Mixed, default: () => ({}) },
local: {
email: {
$type: String,