Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(apple): Add apple user migrations script #15406

Merged
merged 1 commit into from
Jun 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions packages/fxa-auth-server/scripts/apple-transfer-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

// This script is used to import users from Apple into FxA. It consumes a CSV
// containing the Apple `transfer_sub` id and exchanges it for their profile information.
// It then creates/updates the user and links the Apple account to the FxA account.
// Example input file: /tests/fixtures/users-apple.csv
//
// Usage: node scripts/apple-transfer-users.js -i <input file> -o <output file>

const {ApplePocketFxAMigration} = require('./transfer-users/apple');

const program = require('commander');

const log = require('../lib/log')({});
const config = require('../config').config.getProperties();
const Token = require('../lib/tokens')(log, config);
const AuthDB = require('../lib/db')(config, log, Token);

program
.option('-d, --delimiter [delimiter]', 'Delimiter for input file', ',')
.option('-o, --output <filename>', 'Output filename to save results to', 'output.csv')
.option(
'-i, --input <filename>',
'Input filename from which to read input if not specified on the command line',
)
.parse(process.argv);

if (!program.input) {
console.error('input file must be specified');
process.exit(1);
}

async function main() {
const migration = new ApplePocketFxAMigration(program.input, config, AuthDB, program.output, program.delimiter);
await migration.load();
await migration.transferUsers();
await migration.close();
}

main();

277 changes: 277 additions & 0 deletions packages/fxa-auth-server/scripts/transfer-users/apple.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use strict';

// This contains classes and functions used to import users from Apple into FxA.
const fs = require('fs');
const path = require('path');
const program = require('commander');

const axios = require('axios');
const random = require('../../lib/crypto/random');
const uuid = require('uuid');

const GRANT_TYPE = 'client_credentials';
const SCOPE = 'user.migration';
const USER_MIGRATION_ENDPOINT = 'https://appleid.apple.com/auth/usermigrationinfo';

const APPLE_PROVIDER = 2;

export class AppleUser {
constructor(email, transferSub, uid, alternateEmails, db, writeStream, config) {
this.email = email;
this.transferSub = transferSub;
this.uid = uid;
this.alternateEmails = alternateEmails || [];
this.db = db;
this.writeStream = writeStream;
this.config = config;
}

// Exchanges the Apple `transfer_sub` for the user's profile information and
// moves the user to the new team.
// Ref: https://developer.apple.com/documentation/sign_in_with_apple/bringing_new_apps_and_users_into_your_team#3559300
async exchangeIdentifiers(accessToken) {
try {
const options = {
transfer_sub: this.transferSub,
client_id: this.config.appleAuthConfig.clientId,
client_secret: this.config.appleAuthConfig.clientSecret,
};
const res = await axios.post(USER_MIGRATION_ENDPOINT,
new URLSearchParams(options).toString(),
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

// Apple user info here contains `sub` (Apple unique id), `email` and `is_private_email`
this.appleUserInfo = res.data;
return res.data;
} catch (err) {
this.setFailure(err);
if (err.response && err.response.status === 429) {
console.error(`Rate limit exceeded, try again later: ${this.transferSub}`);
} else {
console.error(`Something went wrong with transfer: ${this.transferSub} ${err}`);
}
}
}

setSuccess(accountRecord) {
this.success = true;
this.accountRecord = accountRecord;
}

setFailure(err) {
this.success = false;
this.err = err;
}

async createLinkedAccount(accountRecord, sub) {
// If the user already has a linked account, delete it and create a new one.
await this.db.deleteLinkedAccount(accountRecord.uid, APPLE_PROVIDER);
await this.db.createLinkedAccount(accountRecord.uid, sub, APPLE_PROVIDER);
}

async createUpdateFxAUser() {
const sub = this.appleUserInfo.sub; // The recipient team-scoped identifier for the user.
const appleEmail = this.appleUserInfo.email; // The private email address specific to the recipient team.

// TODO, maybe we should mark this failure
// const isPrivateEmail = this.appleUserInfo.is_private_email; // Boolean if email is private
// if (isPrivateEmail) {
// this.setFailure({ message: 'Apple email is private' });
// }

// 1. Check if user exists in FxA via the uid value from Pocket. We should expect
// the uid to be valid, but if it isn't error out.
try {
if (this.uid) {
const accountRecord = await this.db.account(this.uid);
await this.createLinkedAccount(accountRecord, sub);
this.setSuccess(accountRecord);
return;
}
} catch (err) {
// We shouldn't expect Pocket to send a uid that doesn't exist in FxA, but
// if they do, error out.
const msg = `Uid not found: ${this.uid}`;
console.error(msg);
this.setFailure(err);
return;
}

// 2. Check all emails to see if there exists a match in FxA, link Apple account
// to the FxA account.
let accountRecord;
// FxA tries to find an email match in the following order:
// 1. Primary email from Pocket
// 2. Apple email from `transfer_sub`
// 3. Alternate emails from Pocket
this.alternateEmails.unshift(appleEmail);
this.alternateEmails.unshift(this.email);
if (this.alternateEmails) {
for (const email of this.alternateEmails) {
try {
accountRecord = await this.db.accountRecord(email);
break;
} catch (err) {
// Account not found try next email
}
}
}
// There was a match! Link the Apple account to the FxA account.
if (accountRecord) {
await this.createLinkedAccount(accountRecord, sub);
this.setSuccess(accountRecord);
return;
}

// 3. No matches mean this is a completely new FxA user, create the user and
// link the Apple account to the FxA account.
try {
const emailCode = await random.hex(16);
const authSalt = await random.hex(32);
const [kA, wrapWrapKb] = await random.hex(32, 32);
accountRecord = await this.db.createAccount({
uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'),
createdAt: Date.now(),
email: appleEmail,
emailCode,
emailVerified: true,
kA,
wrapWrapKb,
authSalt,
verifierVersion: this.config.verifierVersion,
verifyHash: Buffer.alloc(32).toString('hex'),
verifierSetAt: 0, // No password set
});
await this.createLinkedAccount(accountRecord, sub);
this.setSuccess(accountRecord);
} catch (err) {
this.setFailure(err);
}
}

async transferUser(accessToken) {
await this.exchangeIdentifiers(accessToken);
await this.createUpdateFxAUser(this.appleUserInfo);
this.saveResult();
console.log(`Transfer complete: ${this.transferSub} ${this.success}`);
}

saveResult() {
const appleEmail = this.appleUserInfo.email;
const fxaEmail = this.accountRecord.email;
const uid = this.accountRecord.uid; // Newly created uid
const transferSub = this.transferSub;
const success = this.success;
const err = (this.err && this.err.message) || '';
const line = `${transferSub},${uid},${fxaEmail},${appleEmail},${success},${err}`;
this.writeStream.write(line + '\n');
}
}

export class ApplePocketFxAMigration {
constructor(filename, config, db, outputFilename, delimiter) {
this.users = [];
this.db = db;
this.filename = filename;
this.config = config;
this.delimiter = delimiter;

this.writeStream = fs.createWriteStream(outputFilename);
this.writeStream.on('finish', () => {
console.log(`Results saved successfully ${outputFilename}`);
});
this.writeStream.on('error', (err) => {
console.error(`There was an error writing the file: ${err}`);
});
}

parseCSV() {
try {
const input = fs
.readFileSync(path.resolve(this.filename))
.toString('utf8');

if (!input.length) {
return [];
}

// Parse the input file CSV style
return input.split(/\n/).map((s, index) => {
// First index is the row headers
if (index === 0) return;

const delimiter = program.delimiter || ',';
const tokens = s.split(delimiter);
const transferSub = tokens[0];
const uid = tokens[1];
const email = tokens[2];
let alternateEmails = [];

if (tokens[3]) {
// Splits on `:` since they are not allowed in emails
alternateEmails = tokens[3].replaceAll('"', '').split(':');
}
return new AppleUser(email, transferSub, uid, alternateEmails, this.db, this.config);
}).filter((user) => user);
} catch (err) {
console.error('No such file or directory');
process.exit(1);
}
}

writeStreamFileHeader() {
this.writeStream.write('transferSub,uid,fxaEmail,appleEmail,success,err\n');
}

writeStreamClose(){
this.writeStream.end();
}

async transferUsers() {
this.writeStreamFileHeader();

const accessToken = await this.generateAccessToken();
for (const user of this.users) {
await user.transferUser(accessToken);
}

this.writeStreamClose();
}

async load() {
this.db = await this.db.connect(this.config);
this.users = this.parseCSV();
console.info(
'%s accounts loaded from %s',
this.users.length,
this.filename,
);
}

async close() {
await this.db.close();
}

async generateAccessToken() {
const tokenOptions = {
grant_type: GRANT_TYPE,
scope: SCOPE,
client_id: this.config.appleAuthConfig.clientId,
client_secret: this.config.appleAuthConfig.clientSecret,
};
const tokenRes = await axios.post(this.config.appleAuthConfig.tokenEndpoint,
new URLSearchParams(tokenOptions).toString(),
);
console.log('Obtained access token');
return tokenRes.data['access_token'];
}
}
Loading