Skip to content

Commit fc2f557

Browse files
authored
feat(auth): Implement getUserByProviderId (#769)
RELEASE NOTE: Added a new getUserByProviderId() to lookup user accounts by their providers.
1 parent 01d8177 commit fc2f557

File tree

7 files changed

+236
-0
lines changed

7 files changed

+236
-0
lines changed

etc/firebase-admin.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export namespace auth {
106106
getUser(uid: string): Promise<UserRecord>;
107107
getUserByEmail(email: string): Promise<UserRecord>;
108108
getUserByPhoneNumber(phoneNumber: string): Promise<UserRecord>;
109+
getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord>;
109110
getUsers(identifiers: UserIdentifier[]): Promise<GetUsersResult>;
110111
importUsers(users: UserImportRecord[], options?: UserImportOptions): Promise<UserImportResult>;
111112
listProviderConfigs(options: AuthProviderConfigFilter): Promise<ListProviderConfigResults>;

src/auth/auth-api-request.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,21 @@ export abstract class AbstractAuthRequestHandler {
10791079
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
10801080
}
10811081

1082+
public getAccountInfoByFederatedUid(providerId: string, rawId: string): Promise<object> {
1083+
if (!validator.isNonEmptyString(providerId) || !validator.isNonEmptyString(rawId)) {
1084+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID);
1085+
}
1086+
1087+
const request = {
1088+
federatedUserId: [{
1089+
providerId,
1090+
rawId,
1091+
}],
1092+
};
1093+
1094+
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
1095+
}
1096+
10821097
/**
10831098
* Looks up multiple users by their identifiers (uid, email, etc).
10841099
*

src/auth/auth.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,36 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
173173
});
174174
}
175175

176+
/**
177+
* Gets the user data for the user corresponding to a given provider id.
178+
*
179+
* See [Retrieve user data](/docs/auth/admin/manage-users#retrieve_user_data)
180+
* for code samples and detailed documentation.
181+
*
182+
* @param providerId The provider ID, for example, "google.com" for the
183+
* Google provider.
184+
* @param uid The user identifier for the given provider.
185+
*
186+
* @return A promise fulfilled with the user data corresponding to the
187+
* given provider id.
188+
*/
189+
public getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord> {
190+
// Although we don't really advertise it, we want to also handle
191+
// non-federated idps with this call. So if we detect one of them, we'll
192+
// reroute this request appropriately.
193+
if (providerId === 'phone') {
194+
return this.getUserByPhoneNumber(uid);
195+
} else if (providerId === 'email') {
196+
return this.getUserByEmail(uid);
197+
}
198+
199+
return this.authRequestHandler.getAccountInfoByFederatedUid(providerId, uid)
200+
.then((response: any) => {
201+
// Returns the user record populated with server response.
202+
return new UserRecord(response.users[0]);
203+
});
204+
}
205+
176206
/**
177207
* Gets the user data corresponding to the specified identifiers.
178208
*

src/auth/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,6 +1516,21 @@ export namespace auth {
15161516
*/
15171517
getUserByPhoneNumber(phoneNumber: string): Promise<UserRecord>;
15181518

1519+
/**
1520+
* Gets the user data for the user corresponding to a given provider ID.
1521+
*
1522+
* See [Retrieve user data](/docs/auth/admin/manage-users#retrieve_user_data)
1523+
* for code samples and detailed documentation.
1524+
*
1525+
* @param providerId The provider ID, for example, "google.com" for the
1526+
* Google provider.
1527+
* @param uid The user identifier for the given provider.
1528+
*
1529+
* @return A promise fulfilled with the user data corresponding to the
1530+
* given provider id.
1531+
*/
1532+
getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord>;
1533+
15191534
/**
15201535
* Gets the user data corresponding to the specified identifiers.
15211536
*

test/integration/auth.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,56 @@ describe('admin.auth', () => {
221221
});
222222
});
223223

224+
it('getUserByProviderUid() returns a user record with the matching provider id', async () => {
225+
// TODO(rsgowman): Once we can link a provider id with a user, just do that
226+
// here instead of creating a new user.
227+
const randomUid = 'import_' + generateRandomString(20).toLowerCase();
228+
const importUser: admin.auth.UserImportRecord = {
229+
uid: randomUid,
230+
231+
phoneNumber: '+15555550000',
232+
emailVerified: true,
233+
disabled: false,
234+
metadata: {
235+
lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC',
236+
creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC',
237+
},
238+
providerData: [{
239+
displayName: 'User Name',
240+
241+
phoneNumber: '+15555550000',
242+
photoURL: 'http://example.com/user',
243+
providerId: 'google.com',
244+
uid: 'google_uid',
245+
}],
246+
};
247+
248+
await admin.auth().importUsers([importUser]);
249+
250+
try {
251+
await admin.auth().getUserByProviderUid('google.com', 'google_uid')
252+
.then((userRecord) => {
253+
expect(userRecord.uid).to.equal(importUser.uid);
254+
});
255+
} finally {
256+
await safeDelete(importUser.uid);
257+
}
258+
});
259+
260+
it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => {
261+
return admin.auth().getUserByProviderUid('email', mockUserData.email)
262+
.then((userRecord) => {
263+
expect(userRecord.uid).to.equal(newUserUid);
264+
});
265+
});
266+
267+
it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => {
268+
return admin.auth().getUserByProviderUid('phone', mockUserData.phoneNumber)
269+
.then((userRecord) => {
270+
expect(userRecord.uid).to.equal(newUserUid);
271+
});
272+
});
273+
224274
describe('getUsers()', () => {
225275
/**
226276
* Filters a list of object to another list of objects that only contains
@@ -623,6 +673,11 @@ describe('admin.auth', () => {
623673
.should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found');
624674
});
625675

676+
it('getUserByProviderUid() fails when called with a non-existing provider id', () => {
677+
return admin.auth().getUserByProviderUid('google.com', nonexistentUid)
678+
.should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found');
679+
});
680+
626681
it('updateUser() fails when called with a non-existing UID', () => {
627682
return admin.auth().updateUser(nonexistentUid, {
628683
emailVerified: true,

test/unit/auth/auth-api-request.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@ describe('FIREBASE_AUTH_GET_ACCOUNT_INFO', () => {
360360
return requestValidator(validRequest);
361361
}).not.to.throw();
362362
});
363+
it('should succeed with federatedUserId passed', () => {
364+
const validRequest = { federatedUserId: { providerId: 'google.com', rawId: 'google_uid_1234' } };
365+
expect(() => {
366+
return requestValidator(validRequest);
367+
}).not.to.throw();
368+
});
363369
it('should fail when neither localId, email or phoneNumber are passed', () => {
364370
const invalidRequest = { bla: ['1234'] };
365371
expect(() => {

test/unit/auth/auth.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,120 @@ AUTH_CONFIGS.forEach((testConfig) => {
11511151
});
11521152
});
11531153

1154+
describe('getUserByProviderUid()', () => {
1155+
const providerId = 'google.com';
1156+
const providerUid = 'google_uid';
1157+
const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID;
1158+
const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId);
1159+
const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult);
1160+
const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND);
1161+
1162+
// Stubs used to simulate underlying api calls.
1163+
let stubs: sinon.SinonStub[] = [];
1164+
beforeEach(() => sinon.spy(validator, 'isEmail'));
1165+
afterEach(() => {
1166+
(validator.isEmail as any).restore();
1167+
_.forEach(stubs, (stub) => stub.restore());
1168+
stubs = [];
1169+
});
1170+
1171+
it('should be rejected given no provider id', () => {
1172+
expect(() => (auth as any).getUserByProviderUid())
1173+
.to.throw(FirebaseAuthError)
1174+
.with.property('code', 'auth/invalid-provider-id');
1175+
});
1176+
1177+
it('should be rejected given an invalid provider id', () => {
1178+
expect(() => auth.getUserByProviderUid('', 'uid'))
1179+
.to.throw(FirebaseAuthError)
1180+
.with.property('code', 'auth/invalid-provider-id');
1181+
});
1182+
1183+
it('should be rejected given an invalid provider uid', () => {
1184+
expect(() => auth.getUserByProviderUid('id', ''))
1185+
.to.throw(FirebaseAuthError)
1186+
.with.property('code', 'auth/invalid-provider-id');
1187+
});
1188+
1189+
it('should be rejected given an app which returns null access tokens', () => {
1190+
return nullAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
1191+
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
1192+
});
1193+
1194+
it('should be rejected given an app which returns invalid access tokens', () => {
1195+
return malformedAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
1196+
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
1197+
});
1198+
1199+
it('should be rejected given an app which fails to generate access tokens', () => {
1200+
return rejectedPromiseAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
1201+
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
1202+
});
1203+
1204+
it('should resolve with a UserRecord on success', () => {
1205+
// Stub getAccountInfoByEmail to return expected result.
1206+
const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByFederatedUid')
1207+
.resolves(expectedGetAccountInfoResult);
1208+
stubs.push(stub);
1209+
return auth.getUserByProviderUid(providerId, providerUid)
1210+
.then((userRecord) => {
1211+
// Confirm underlying API called with expected parameters.
1212+
expect(stub).to.have.been.calledOnce.and.calledWith(providerId, providerUid);
1213+
// Confirm expected user record response returned.
1214+
expect(userRecord).to.deep.equal(expectedUserRecord);
1215+
});
1216+
});
1217+
1218+
describe('non-federated providers', () => {
1219+
let invokeRequestHandlerStub: sinon.SinonStub;
1220+
beforeEach(() => {
1221+
invokeRequestHandlerStub = sinon.stub(testConfig.RequestHandler.prototype, 'invokeRequestHandler')
1222+
.resolves({
1223+
// nothing here is checked; we just need enough to not crash.
1224+
users: [{
1225+
localId: 1,
1226+
}],
1227+
});
1228+
1229+
});
1230+
afterEach(() => {
1231+
invokeRequestHandlerStub.restore();
1232+
});
1233+
1234+
it('phone lookups should use phoneNumber field', async () => {
1235+
await auth.getUserByProviderUid('phone', '+15555550001');
1236+
expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith(
1237+
sinon.match.any, sinon.match.any, {
1238+
phoneNumber: ['+15555550001'],
1239+
});
1240+
});
1241+
1242+
it('email lookups should use email field', async () => {
1243+
await auth.getUserByProviderUid('email', '[email protected]');
1244+
expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith(
1245+
sinon.match.any, sinon.match.any, {
1246+
email: ['[email protected]'],
1247+
});
1248+
});
1249+
});
1250+
1251+
it('should throw an error when the backend returns an error', () => {
1252+
// Stub getAccountInfoByFederatedUid to throw a backend error.
1253+
const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByFederatedUid')
1254+
.rejects(expectedError);
1255+
stubs.push(stub);
1256+
return auth.getUserByProviderUid(providerId, providerUid)
1257+
.then(() => {
1258+
throw new Error('Unexpected success');
1259+
}, (error) => {
1260+
// Confirm underlying API called with expected parameters.
1261+
expect(stub).to.have.been.calledOnce.and.calledWith(providerId, providerUid);
1262+
// Confirm expected error returned.
1263+
expect(error).to.equal(expectedError);
1264+
});
1265+
});
1266+
});
1267+
11541268
describe('getUsers()', () => {
11551269
let stubs: sinon.SinonStub[] = [];
11561270

0 commit comments

Comments
 (0)