Skip to content

Commit b67ec5e

Browse files
authored
Add linkWithCredential(), linkWithPhoneNumber(), unlink() (#3213)
* Add unlink(), linkWithCredential(), linkWithPhoneNumber() * Formatting * Formatting * PR feedback * Formatting * Add export to index.ts * Formatting
1 parent bbdde9b commit b67ec5e

File tree

14 files changed

+608
-53
lines changed

14 files changed

+608
-53
lines changed

packages-exp/auth-exp/src/core/credentials/phone.test.ts

+42
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,48 @@ describe('core/credentials/phone', () => {
7777
});
7878
});
7979

80+
context('#_linkToIdToken', () => {
81+
const response: IdTokenResponse = {
82+
idToken: '',
83+
refreshToken: '',
84+
kind: '',
85+
expiresIn: '10',
86+
localId: 'uid'
87+
};
88+
89+
it('calls the endpoint with session and code', async () => {
90+
const cred = new PhoneAuthCredential({
91+
verificationId: 'session-info',
92+
verificationCode: 'code'
93+
});
94+
95+
const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response);
96+
97+
expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response);
98+
expect(route.calls[0].request).to.eql({
99+
sessionInfo: 'session-info',
100+
code: 'code',
101+
idToken: 'id-token'
102+
});
103+
});
104+
105+
it('calls the endpoint with proof and number', async () => {
106+
const cred = new PhoneAuthCredential({
107+
temporaryProof: 'temp-proof',
108+
phoneNumber: 'number'
109+
});
110+
111+
const route = mockEndpoint(Endpoint.SIGN_IN_WITH_PHONE_NUMBER, response);
112+
113+
expect(await cred._linkToIdToken(auth, 'id-token')).to.eql(response);
114+
expect(route.calls[0].request).to.eql({
115+
temporaryProof: 'temp-proof',
116+
phoneNumber: 'number',
117+
idToken: 'id-token'
118+
});
119+
});
120+
});
121+
80122
context('#toJSON', () => {
81123
it('fills out the object with everything that is set', () => {
82124
const cred = new PhoneAuthCredential({

packages-exp/auth-exp/src/core/credentials/phone.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as externs from '@firebase/auth-types-exp';
1919

2020
import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa';
2121
import {
22+
linkWithPhoneNumber,
2223
signInWithPhoneNumber,
2324
SignInWithPhoneNumberRequest
2425
} from '../../api/authentication/sms';
@@ -46,9 +47,10 @@ export class PhoneAuthCredential
4647
}
4748

4849
_linkToIdToken(auth: Auth, idToken: string): Promise<IdTokenResponse> {
49-
void auth;
50-
void idToken;
51-
return debugFail('not implemented');
50+
return linkWithPhoneNumber(auth, {
51+
idToken,
52+
...this.makeVerificationRequest()
53+
});
5254
}
5355

5456
_matchIdTokenWithUid(auth: Auth, uid: string): Promise<IdTokenResponse> {

packages-exp/auth-exp/src/core/strategies/credential.test.ts

+107-18
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,26 @@ import {
2323
ProviderId,
2424
SignInMethod
2525
} from '@firebase/auth-types-exp';
26+
import { FirebaseError } from '@firebase/util';
2627

2728
import { mockEndpoint } from '../../../test/api/helper';
28-
import { testAuth } from '../../../test/mock_auth';
29+
import { testAuth, testUser } from '../../../test/mock_auth';
2930
import { MockAuthCredential } from '../../../test/mock_auth_credential';
3031
import * as mockFetch from '../../../test/mock_fetch';
3132
import { Endpoint } from '../../api';
3233
import { APIUserInfo } from '../../api/account_management/account';
3334
import { Auth } from '../../model/auth';
3435
import { IdTokenResponse } from '../../model/id_token';
35-
import { signInWithCredential } from './credential';
36+
import { User } from '../../model/user';
37+
import {
38+
_assertLinkedStatus,
39+
linkWithCredential,
40+
signInWithCredential
41+
} from './credential';
3642

3743
use(chaiAsPromised);
3844

39-
describe('core/strategies/signInWithCredential', () => {
45+
describe('core/strategies/credential', () => {
4046
const serverUser: APIUserInfo = {
4147
localId: 'local-id',
4248
displayName: 'display-name',
@@ -63,32 +69,115 @@ describe('core/strategies/signInWithCredential', () => {
6369
);
6470

6571
let auth: Auth;
72+
let getAccountInfoEndpoint: mockFetch.Route;
73+
let user: User;
6674

6775
beforeEach(async () => {
6876
auth = await testAuth();
6977
mockFetch.setUp();
7078
authCredential._setIdTokenResponse(idTokenResponse);
71-
mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
79+
getAccountInfoEndpoint = mockEndpoint(Endpoint.GET_ACCOUNT_INFO, {
7280
users: [serverUser]
7381
});
82+
83+
user = testUser(auth, 'uid', undefined, true);
7484
});
85+
7586
afterEach(mockFetch.tearDown);
7687

77-
it('should return a valid user credential', async () => {
78-
const { credential, user, operationType } = await signInWithCredential(
79-
auth,
80-
authCredential
81-
);
82-
expect(credential!.providerId).to.eq(ProviderId.FIREBASE);
83-
expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_LINK);
84-
expect(user.uid).to.eq('local-id');
85-
expect(user.tenantId).to.eq('tenant-id');
86-
expect(user.displayName).to.eq('display-name');
87-
expect(operationType).to.eq(OperationType.SIGN_IN);
88+
describe('signInWithCredential', () => {
89+
it('should return a valid user credential', async () => {
90+
const { credential, user, operationType } = await signInWithCredential(
91+
auth,
92+
authCredential
93+
);
94+
expect(credential!.providerId).to.eq(ProviderId.FIREBASE);
95+
expect(credential!.signInMethod).to.eq(SignInMethod.EMAIL_LINK);
96+
expect(user.uid).to.eq('local-id');
97+
expect(user.tenantId).to.eq('tenant-id');
98+
expect(user.displayName).to.eq('display-name');
99+
expect(operationType).to.eq(OperationType.SIGN_IN);
100+
});
101+
102+
it('should update the current user', async () => {
103+
const { user } = await signInWithCredential(auth, authCredential);
104+
expect(auth.currentUser).to.eq(user);
105+
});
106+
});
107+
108+
describe('linkWithCredential', () => {
109+
it('should throw an error if the provider is already linked', async () => {
110+
getAccountInfoEndpoint.response = {
111+
users: [
112+
{
113+
...serverUser,
114+
providerUserInfo: [{ providerId: ProviderId.FIREBASE }]
115+
}
116+
]
117+
};
118+
119+
await expect(linkWithCredential(user, authCredential)).to.be.rejectedWith(
120+
FirebaseError,
121+
'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).'
122+
);
123+
});
124+
125+
it('should return a valid user credential', async () => {
126+
const {
127+
credential,
128+
user: newUser,
129+
operationType
130+
} = await linkWithCredential(user, authCredential);
131+
expect(operationType).to.eq(OperationType.LINK);
132+
expect(newUser).to.eq(user);
133+
expect(credential).to.be.null;
134+
});
88135
});
89136

90-
it('should update the current user', async () => {
91-
const { user } = await signInWithCredential(auth, authCredential);
92-
expect(auth.currentUser).to.eq(user);
137+
describe('_assertLinkedStatus', () => {
138+
it('should error with already linked if expectation is true', async () => {
139+
getAccountInfoEndpoint.response = {
140+
users: [
141+
{
142+
...serverUser,
143+
providerUserInfo: [{ providerId: ProviderId.GOOGLE }]
144+
}
145+
]
146+
};
147+
148+
await expect(
149+
_assertLinkedStatus(false, user, ProviderId.GOOGLE)
150+
).to.be.rejectedWith(
151+
FirebaseError,
152+
'Firebase: User can only be linked to one identity for the given provider. (auth/provider-already-linked).'
153+
);
154+
});
155+
156+
it('should not error if provider is not linked', async () => {
157+
await expect(_assertLinkedStatus(false, user, ProviderId.GOOGLE)).not.to
158+
.be.rejected;
159+
});
160+
161+
it('should error if provider is not linked but it was expected to be', async () => {
162+
await expect(
163+
_assertLinkedStatus(true, user, ProviderId.GOOGLE)
164+
).to.be.rejectedWith(
165+
FirebaseError,
166+
'Firebase: User was not linked to an account with the given provider. (auth/no-such-provider).'
167+
);
168+
});
169+
170+
it('should not error if provider is linked and that is expected', async () => {
171+
getAccountInfoEndpoint.response = {
172+
users: [
173+
{
174+
...serverUser,
175+
providerUserInfo: [{ providerId: ProviderId.GOOGLE }]
176+
}
177+
]
178+
};
179+
await expect(_assertLinkedStatus(true, user, ProviderId.GOOGLE)).not.to.be
180+
.rejected;
181+
});
93182
});
94183
});

packages-exp/auth-exp/src/core/strategies/credential.ts

+57-1
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@
1616
*/
1717

1818
import * as externs from '@firebase/auth-types-exp';
19-
2019
import { OperationType, UserCredential } from '@firebase/auth-types-exp';
20+
21+
import { PhoneOrOauthTokenResponse } from '../../api/authentication/mfa';
22+
import { SignInWithPhoneNumberResponse } from '../../api/authentication/sms';
2123
import { Auth } from '../../model/auth';
24+
import { User } from '../../model/user';
2225
import { AuthCredential } from '../credentials';
26+
import { PhoneAuthCredential } from '../credentials/phone';
27+
import { AuthErrorCode } from '../errors';
28+
import { _reloadWithoutSaving } from '../user/reload';
2329
import { UserCredentialImpl } from '../user/user_credential_impl';
30+
import { assert } from '../util/assert';
31+
import { providerDataAsNames } from '../util/providers';
2432

2533
export async function signInWithCredential(
2634
authExtern: externs.Auth,
@@ -39,3 +47,51 @@ export async function signInWithCredential(
3947
await auth.updateCurrentUser(userCredential.user);
4048
return userCredential;
4149
}
50+
51+
export async function linkWithCredential(
52+
userExtern: externs.User,
53+
credentialExtern: externs.AuthCredential
54+
): Promise<UserCredential> {
55+
const user = userExtern as User;
56+
const credential = credentialExtern as AuthCredential;
57+
await _assertLinkedStatus(false, user, credential.providerId);
58+
59+
const response = await credential._linkToIdToken(
60+
user.auth,
61+
await user.getIdToken()
62+
);
63+
64+
const newCred = _authCredentialFromTokenResponse(response);
65+
await user._updateTokensIfNecessary(response, /* reload */ true);
66+
return new UserCredentialImpl(user, newCred, OperationType.LINK);
67+
}
68+
69+
export function _authCredentialFromTokenResponse(
70+
response: PhoneOrOauthTokenResponse
71+
): AuthCredential | null {
72+
const {
73+
temporaryProof,
74+
phoneNumber
75+
} = response as SignInWithPhoneNumberResponse;
76+
if (temporaryProof && phoneNumber) {
77+
return new PhoneAuthCredential({ temporaryProof, phoneNumber });
78+
}
79+
80+
// TODO: Handle Oauth cases
81+
return null;
82+
}
83+
84+
export async function _assertLinkedStatus(
85+
expected: boolean,
86+
user: User,
87+
provider: externs.ProviderId
88+
): Promise<void> {
89+
await _reloadWithoutSaving(user);
90+
const providerIds = providerDataAsNames(user.providerData);
91+
92+
const code =
93+
expected === false
94+
? AuthErrorCode.PROVIDER_ALREADY_LINKED
95+
: AuthErrorCode.NO_SUCH_PROVIDER;
96+
assert(providerIds.has(provider) === expected, user.auth.name, code);
97+
}

0 commit comments

Comments
 (0)