in packages/fxa-auth-server/lib/routes/linked-accounts.ts [206:511]
async loginOrCreateAccount(request: AuthRequest) {
const requestPayload = request.payload as any;
const provider = requestPayload.provider as Provider;
const providerId = PROVIDER[provider];
const service = requestPayload.service;
// Currently, FxA supports creating a linked account via the oauth authorization flow
// This flow returns an `id_token` which is used create/get FxA account.
let idToken: any;
const code = requestPayload.code;
const { deviceId, flowId, flowBeginTime } = await request.app
.metricsContext;
switch (provider) {
case 'google': {
if (!this.googleAuthClient) {
throw error.thirdPartyAccountError();
}
const { clientId, clientSecret, redirectUri } =
this.config.googleAuthConfig;
let rawIdToken;
if (code) {
const data = {
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
try {
const res = await axios.post(
this.config.googleAuthConfig.tokenEndpoint,
data
);
// We currently only use the `id_token` after completing the
// authorization code exchange. In the future we could store a
// refresh token to do other things like revoking sessions.
//
// See https://developers.google.com/identity/protocols/oauth2/openid-connect#exchangecode
rawIdToken = res.data['id_token'];
const verifiedToken = await this.googleAuthClient.verifyIdToken({
idToken: rawIdToken,
audience: clientId,
});
idToken = verifiedToken.getPayload();
} catch (err) {
this.log.error('linked_account.code_exchange_error', err);
throw error.thirdPartyAccountError();
}
}
break;
}
case 'apple': {
const { clientId, keyId, privateKey, teamId } =
this.config.appleAuthConfig;
if (!clientId || !keyId || !privateKey || !teamId) {
throw error.thirdPartyAccountError();
}
let rawIdToken;
const clientSecret = await this.generateAppleClientSecret(
clientId,
keyId,
privateKey,
teamId
);
const code = requestPayload.code;
if (code) {
const data = {
code,
client_id: clientId,
client_secret: clientSecret,
grant_type: 'authorization_code',
};
try {
const res = await axios.post(
this.config.appleAuthConfig.tokenEndpoint,
new URLSearchParams(data).toString()
);
rawIdToken = res.data['id_token'];
idToken = jose.decodeJwt(rawIdToken);
} catch (err) {
this.log.error('linked_account.code_exchange_error', err);
throw error.thirdPartyAccountError();
}
}
break;
}
}
if (!idToken) {
throw error.thirdPartyAccountError();
}
const userid = idToken.sub;
const email = idToken.email;
const name = idToken.name;
let accountRecord;
const linkedAccountRecord = await this.db.getLinkedAccount(
userid,
provider
);
if (!linkedAccountRecord) {
// Something has gone wrong! We shouldn't hit a case where we have an unlinked without
// an email set in the idToken. Failing hard and fast. Logging more info
if (!email) {
this.log.error('linked_account.no_email_in_id_token', {
provider,
userid,
name,
});
throw error.thirdPartyAccountError();
}
try {
// This is a new third party account linking an existing FxA account
accountRecord = await this.db.accountRecord(email);
await this.db.createLinkedAccount(accountRecord.uid, userid, provider);
if (name) {
await this.updateProfileDisplayName(accountRecord.uid, name);
}
const geoData = request.app.geo;
const ip = request.app.clientAddress;
const emailOptions = {
acceptLanguage: request.app.acceptLanguage,
deviceId,
flowId,
flowBeginTime,
ip,
location: geoData.location,
providerName: PROVIDER_NAME[provider],
timeZone: geoData.timeZone,
uaBrowser: request.app.ua.browser,
uaBrowserVersion: request.app.ua.browserVersion,
uaOS: request.app.ua.os,
uaOSVersion: request.app.ua.osVersion,
uaDeviceType: request.app.ua.deviceType,
uid: accountRecord.uid,
};
await this.mailer.sendPostAddLinkedAccountEmail(
accountRecord.emails,
accountRecord,
emailOptions
);
request.setMetricsFlowCompleteSignal('account.login', 'login');
switch (provider) {
case 'google':
await this.glean.thirdPartyAuth.googleLoginComplete(request, {
reason: 'linking',
});
break;
case 'apple':
await this.glean.thirdPartyAuth.appleLoginComplete(request, {
reason: 'linking',
});
break;
}
await request.emitMetricsEvent('account.login', {
uid: accountRecord.uid,
deviceId,
flowId,
flowBeginTime,
service,
});
} catch (err) {
this.log.trace(
'Account.login.sendPostAddLinkedAccountNotification.error',
{
error: err,
}
);
if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) {
throw err;
}
// This is a new user creating a new FxA account, we
// create the FxA account with random password and mark email
// verified
const emailCode = await random.hex(16);
const authSalt = await random.hex(32);
const [kA, wrapWrapKb, wrapWrapKbVersion2] = await random.hex(
32,
32,
32
);
accountRecord = await this.db.createAccount({
uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'),
createdAt: Date.now(),
email,
emailCode,
emailVerified: true,
kA,
wrapWrapKb,
wrapWrapKbVersion2,
authSalt,
// This will be set with a real value when the users sets an account password.
clientSalt: undefined,
verifierVersion: this.config.verifierVersion,
verifyHash: Buffer.alloc(32).toString('hex'),
verifyHashVersion2: Buffer.alloc(32).toString('hex'),
verifierSetAt: 0,
locale: request.app.acceptLanguage,
});
await this.db.createLinkedAccount(accountRecord.uid, userid, provider);
if (name) {
await this.updateProfileDisplayName(accountRecord.uid, name);
}
// Currently, we treat accounts created from a linked account as a new
// registration and emit the correspond event. Note that depending on
// where might not be a top of funnel for this completion event.
request.setMetricsFlowCompleteSignal(
'account.verified',
'registration'
);
switch (provider) {
case 'google':
await this.glean.thirdPartyAuth.googleRegComplete(request);
break;
case 'apple':
await this.glean.thirdPartyAuth.appleRegComplete(request);
break;
}
await request.emitMetricsEvent('account.verified', {
uid: accountRecord.uid,
deviceId,
flowId,
flowBeginTime,
service,
});
this.glean.registration.complete(request, { uid: accountRecord.uid });
}
} else {
// This is an existing user and existing FxA user
accountRecord = await this.db.account(linkedAccountRecord.uid);
if (service === 'sync') {
request.setMetricsFlowCompleteSignal('account.signed', 'login');
} else {
request.setMetricsFlowCompleteSignal('account.login', 'login');
}
await request.emitMetricsEvent('account.login', {
uid: accountRecord.uid,
deviceId,
flowId,
flowBeginTime,
service,
});
switch (provider) {
case 'google':
await this.glean.thirdPartyAuth.googleLoginComplete(request);
break;
case 'apple':
await this.glean.thirdPartyAuth.appleLoginComplete(request);
break;
}
}
let verificationMethod,
mustVerifySession = false,
tokenVerificationId = undefined;
const hasTotpToken = await this.otpUtils.hasTotpToken(accountRecord);
if (hasTotpToken) {
mustVerifySession = true;
tokenVerificationId = await random.hex(16);
verificationMethod = 'totp-2fa';
}
const sessionTokenOptions = {
uid: accountRecord.uid,
email: accountRecord.primaryEmail.email,
emailCode: accountRecord.primaryEmail.emailCode,
emailVerified: accountRecord.primaryEmail.isVerified,
verifierSetAt: accountRecord.verifierSetAt,
mustVerify: mustVerifySession,
tokenVerificationId,
uaBrowser: request.app.ua.browser,
uaBrowserVersion: request.app.ua.browserVersion,
uaOS: request.app.ua.os,
uaOSVersion: request.app.ua.osVersion,
uaDeviceType: request.app.ua.deviceType,
uaFormFactor: request.app.ua.formFactor,
providerId,
};
const sessionToken = await this.db.createSessionToken(sessionTokenOptions);
return {
uid: sessionToken.uid,
sessionToken: sessionToken.data,
providerUid: userid,
email,
...(verificationMethod ? { verificationMethod } : {}),
};
}