Skip to content

Add linkWithCredential(), linkWithPhoneNumber(), unlink() #3213

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

Merged
merged 7 commits into from
Jun 15, 2020
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
42 changes: 42 additions & 0 deletions packages-exp/auth-exp/src/core/credentials/phone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,48 @@ describe('core/credentials/phone', () => {
});
});

context('#_linkToIdToken', () => {
const response: IdTokenResponse = {
idToken: '',
refreshToken: '',
kind: '',
expiresIn: '10',
localId: 'uid'
};

it('calls the endpoint with session and code', async () => {
const cred = new PhoneAuthCredential({
verificationId: 'session-info',
verificationCode: 'code'
});

const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response);

expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response);
expect(route.calls[0].request).to.eql({
sessionInfo: 'session-info',
code: 'code',
idToken: 'id-token'
});
});

it('calls the endpoint with proof and number', async () => {
const cred = new PhoneAuthCredential({
temporaryProof: 'temp-proof',
phoneNumber: 'number'
});

const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response);

expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response);
expect(route.calls[0].request).to.eql({
temporaryProof: 'temp-proof',
phoneNumber: 'number',
idToken: 'id-token'
});
});
});

context('#toJSON', () => {
it('fills out the object with everything that is set', () => {
const cred = new PhoneAuthCredential({
Expand Down
8 changes: 5 additions & 3 deletions packages-exp/auth-exp/src/core/credentials/phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as externs from '@firebase/auth-types-exp';

import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa';
import {
linkWithPhoneNumber,
signInWithPhoneNumber,
SignInWithPhoneNumberRequest
} from '../../api/authentication/sms';
Expand Down Expand Up @@ -46,9 +47,10 @@ export class PhoneAuthCredential
}

_linkToIdToken(auth: Auth, idToken: string): Promise<IdTokenResponse> {
void auth;
void idToken;
return debugFail('not implemented');
return linkWithPhoneNumber(auth, {
idToken,
...this.makeVerificationRequest()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

});
}

_matchIdTokenWithUid(auth: Auth, uid: string): Promise<IdTokenResponse> {
Expand Down
125 changes: 107 additions & 18 deletions packages-exp/auth-exp/src/core/strategies/credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,26 @@ import {
ProviderId,
SignInMethod
} from '@firebase/auth-types-exp';
import { FirebaseError } from '@firebase/util';

import { mockEndpoint } from '../../../test/api/helper';
import { testAuth } from '../../../test/mock_auth';
import { testAuth, testUser } from '../../../test/mock_auth';
import { MockAuthCredential } from '../../../test/mock_auth_credential';
import * as mockFetch from '../../../test/mock_fetch';
import { Endpoint } from '../../api';
import { APIUserInfo } from '../../api/account_management/account';
import { Auth } from '../../model/auth';
import { IdTokenResponse } from '../../model/id_token';
import { signInWithCredential } from './credential';
import { User } from '../../model/user';
import {
_assertLinkedStatus,
linkWithCredential,
signInWithCredential
} from './credential';

use(chaiAsPromised);

describe('core/strategies/signInWithCredential', () => {
describe('core/strategies/credential', () => {
const serverUser: APIUserInfo = {
localId: 'local-id',
displayName: 'display-name',
Expand All @@ -63,32 +69,115 @@ describe('core/strategies/signInWithCredential', () => {
);

let auth: Auth;
let getAccountInfoEndpoint: mockFetch.Route;
let user: User;

beforeEach(async () => {
auth = await testAuth();
mockFetch.setUp();
authCredential._setIdTokenResponse(idTokenResponse);
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
getAccountInfoEndpoint = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
users: [serverUser]
});

user = testUser(auth, 'uid', undefined, true);
});

afterEach(mockFetch.tearDown);

it('should return a valid user credential', async () => {
const { credential, user, operationType } = await signInWithCredential(
auth,
authCredential
);
expect(credential!.providerId).to.eq(ProviderId.FIREBASE);
expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_LINK);
expect(user.uid).to.eq('local-id');
expect(user.tenantId).to.eq('tenant-id');
expect(user.displayName).to.eq('display-name');
expect(operationType).to.eq(OperationType.SIGN_IN);
describe('signInWithCredential', () => {
it('should return a valid user credential', async () => {
const { credential, user, operationType } = await signInWithCredential(
auth,
authCredential
);
expect(credential!.providerId).to.eq(ProviderId.FIREBASE);
expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_LINK);
expect(user.uid).to.eq('local-id');
expect(user.tenantId).to.eq('tenant-id');
expect(user.displayName).to.eq('display-name');
expect(operationType).to.eq(OperationType.SIGN_IN);
});

it('should update the current user', async () => {
const { user } = await signInWithCredential(auth, authCredential);
expect(auth.currentUser).to.eq(user);
});
});

describe('linkWithCredential', () => {
it('should throw an error if the provider is already linked', async () => {
getAccountInfoEndpoint.response = {
users: [
{
...serverUser,
providerUserInfo: [{ providerId: ProviderId.FIREBASE }]
}
]
};

await expect(linkWithCredential(user, authCredential)).to.be.rejectedWith(
FirebaseError,
'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).'
);
});

it('should return a valid user credential', async () => {
const {
credential,
user: newUser,
operationType
} = await linkWithCredential(user, authCredential);
expect(operationType).to.eq(OperationType.LINK);
expect(newUser).to.eq(user);
expect(credential).to.be.null;
});
});

it('should update the current user', async () => {
const { user } = await signInWithCredential(auth, authCredential);
expect(auth.currentUser).to.eq(user);
describe('_assertLinkedStatus', () => {
it('should error with already linked if expectation is true', async () => {
getAccountInfoEndpoint.response = {
users: [
{
...serverUser,
providerUserInfo: [{ providerId: ProviderId.GOOGLE }]
}
]
};

await expect(
_assertLinkedStatus(false, user, ProviderId.GOOGLE)
).to.be.rejectedWith(
FirebaseError,
'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).'
);
});

it('should not error if provider is not linked', async () => {
await expect(_assertLinkedStatus(false, user, ProviderId.GOOGLE)).not.to
.be.rejected;
});

it('should error if provider is not linked but it was expected to be', async () => {
await expect(
_assertLinkedStatus(true, user, ProviderId.GOOGLE)
).to.be.rejectedWith(
FirebaseError,
'Firebase: User was not linked to an account with the given provider. (auth/no-such-provider).'
);
});

it('should not error if provider is linked and that is expected', async () => {
getAccountInfoEndpoint.response = {
users: [
{
...serverUser,
providerUserInfo: [{ providerId: ProviderId.GOOGLE }]
}
]
};
await expect(_assertLinkedStatus(true, user, ProviderId.GOOGLE)).not.to.be
.rejected;
});
});
});
58 changes: 57 additions & 1 deletion packages-exp/auth-exp/src/core/strategies/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@
*/

import * as externs from '@firebase/auth-types-exp';

import { OperationType, UserCredential } from '@firebase/auth-types-exp';

import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa';
import { SignInWithPhoneNumberResponse } from '../../api/authentication/sms';
import { Auth } from '../../model/auth';
import { User } from '../../model/user';
import { AuthCredential } from '../credentials';
import { PhoneAuthCredential } from '../credentials/phone';
import { AuthErrorCode } from '../errors';
import { _reloadWithoutSaving } from '../user/reload';
import { UserCredentialImpl } from '../user/user_credential_impl';
import { assert } from '../util/assert';
import { providerDataAsNames } from '../util/providers';

export async function signInWithCredential(
authExtern: externs.Auth,
Expand All @@ -39,3 +47,51 @@ export async function signInWithCredential(
await auth.updateCurrentUser(userCredential.user);
return userCredential;
}

export async function linkWithCredential(
userExtern: externs.User,
credentialExtern: externs.AuthCredential
): Promise<UserCredential> {
const user = userExtern as User;
const credential = credentialExtern as AuthCredential;
await _assertLinkedStatus(false, user, credential.providerId);

const response = await credential._linkToIdToken(
user.auth,
await user.getIdToken()
);

const newCred = _authCredentialFromTokenResponse(response);
await user._updateTokensIfNecessary(response, /* reload */ true);
return new UserCredentialImpl(user, newCred, OperationType.LINK);
}

export function _authCredentialFromTokenResponse(
response: PhoneOrOauthTokenResponse
): AuthCredential | null {
const {
temporaryProof,
phoneNumber
} = response as SignInWithPhoneNumberResponse;
if (temporaryProof && phoneNumber) {
return new PhoneAuthCredential({ temporaryProof, phoneNumber });
}

// TODO: Handle Oauth cases
return null;
}

export async function _assertLinkedStatus(
expected: boolean,
user: User,
provider: externs.ProviderId
): Promise<void> {
await _reloadWithoutSaving(user);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a potential race condition if this gets called twice, do we care?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, it should be fine

const providerIds = providerDataAsNames(user.providerData);

const code =
expected === false
? AuthErrorCode.PROVIDER_ALREADY_LINKED
: AuthErrorCode.NO_SUCH_PROVIDER;
assert(providerIds.has(provider) === expected, user.auth.name, code);
}
Loading