Skip to content

Commit

Permalink
feat(auth): Implement getUserByProviderId (#769)
Browse files Browse the repository at this point in the history
RELEASE NOTE: Added a new getUserByProviderId() to lookup user accounts by their providers.
  • Loading branch information
rsgowman authored Feb 8, 2021
1 parent 01d8177 commit fc2f557
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 0 deletions.
1 change: 1 addition & 0 deletions etc/firebase-admin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export namespace auth {
getUser(uid: string): Promise<UserRecord>;
getUserByEmail(email: string): Promise<UserRecord>;
getUserByPhoneNumber(phoneNumber: string): Promise<UserRecord>;
getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord>;
getUsers(identifiers: UserIdentifier[]): Promise<GetUsersResult>;
importUsers(users: UserImportRecord[], options?: UserImportOptions): Promise<UserImportResult>;
listProviderConfigs(options: AuthProviderConfigFilter): Promise<ListProviderConfigResults>;
Expand Down
15 changes: 15 additions & 0 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,21 @@ export abstract class AbstractAuthRequestHandler {
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
}

public getAccountInfoByFederatedUid(providerId: string, rawId: string): Promise<object> {
if (!validator.isNonEmptyString(providerId) || !validator.isNonEmptyString(rawId)) {
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID);
}

const request = {
federatedUserId: [{
providerId,
rawId,
}],
};

return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
}

/**
* Looks up multiple users by their identifiers (uid, email, etc).
*
Expand Down
30 changes: 30 additions & 0 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,36 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> implements BaseAuthI
});
}

/**
* Gets the user data for the user corresponding to a given provider id.
*
* See [Retrieve user data](/docs/auth/admin/manage-users#retrieve_user_data)
* for code samples and detailed documentation.
*
* @param providerId The provider ID, for example, "google.com" for the
* Google provider.
* @param uid The user identifier for the given provider.
*
* @return A promise fulfilled with the user data corresponding to the
* given provider id.
*/
public getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord> {
// Although we don't really advertise it, we want to also handle
// non-federated idps with this call. So if we detect one of them, we'll
// reroute this request appropriately.
if (providerId === 'phone') {
return this.getUserByPhoneNumber(uid);
} else if (providerId === 'email') {
return this.getUserByEmail(uid);
}

return this.authRequestHandler.getAccountInfoByFederatedUid(providerId, uid)
.then((response: any) => {
// Returns the user record populated with server response.
return new UserRecord(response.users[0]);
});
}

/**
* Gets the user data corresponding to the specified identifiers.
*
Expand Down
15 changes: 15 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,21 @@ export namespace auth {
*/
getUserByPhoneNumber(phoneNumber: string): Promise<UserRecord>;

/**
* Gets the user data for the user corresponding to a given provider ID.
*
* See [Retrieve user data](/docs/auth/admin/manage-users#retrieve_user_data)
* for code samples and detailed documentation.
*
* @param providerId The provider ID, for example, "google.com" for the
* Google provider.
* @param uid The user identifier for the given provider.
*
* @return A promise fulfilled with the user data corresponding to the
* given provider id.
*/
getUserByProviderUid(providerId: string, uid: string): Promise<UserRecord>;

/**
* Gets the user data corresponding to the specified identifiers.
*
Expand Down
55 changes: 55 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,56 @@ describe('admin.auth', () => {
});
});

it('getUserByProviderUid() returns a user record with the matching provider id', async () => {
// TODO(rsgowman): Once we can link a provider id with a user, just do that
// here instead of creating a new user.
const randomUid = 'import_' + generateRandomString(20).toLowerCase();
const importUser: admin.auth.UserImportRecord = {
uid: randomUid,
email: '[email protected]',
phoneNumber: '+15555550000',
emailVerified: true,
disabled: false,
metadata: {
lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC',
creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC',
},
providerData: [{
displayName: 'User Name',
email: '[email protected]',
phoneNumber: '+15555550000',
photoURL: 'http://example.com/user',
providerId: 'google.com',
uid: 'google_uid',
}],
};

await admin.auth().importUsers([importUser]);

try {
await admin.auth().getUserByProviderUid('google.com', 'google_uid')
.then((userRecord) => {
expect(userRecord.uid).to.equal(importUser.uid);
});
} finally {
await safeDelete(importUser.uid);
}
});

it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => {
return admin.auth().getUserByProviderUid('email', mockUserData.email)
.then((userRecord) => {
expect(userRecord.uid).to.equal(newUserUid);
});
});

it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => {
return admin.auth().getUserByProviderUid('phone', mockUserData.phoneNumber)
.then((userRecord) => {
expect(userRecord.uid).to.equal(newUserUid);
});
});

describe('getUsers()', () => {
/**
* Filters a list of object to another list of objects that only contains
Expand Down Expand Up @@ -623,6 +673,11 @@ describe('admin.auth', () => {
.should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found');
});

it('getUserByProviderUid() fails when called with a non-existing provider id', () => {
return admin.auth().getUserByProviderUid('google.com', nonexistentUid)
.should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found');
});

it('updateUser() fails when called with a non-existing UID', () => {
return admin.auth().updateUser(nonexistentUid, {
emailVerified: true,
Expand Down
6 changes: 6 additions & 0 deletions test/unit/auth/auth-api-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,12 @@ describe('FIREBASE_AUTH_GET_ACCOUNT_INFO', () => {
return requestValidator(validRequest);
}).not.to.throw();
});
it('should succeed with federatedUserId passed', () => {
const validRequest = { federatedUserId: { providerId: 'google.com', rawId: 'google_uid_1234' } };
expect(() => {
return requestValidator(validRequest);
}).not.to.throw();
});
it('should fail when neither localId, email or phoneNumber are passed', () => {
const invalidRequest = { bla: ['1234'] };
expect(() => {
Expand Down
114 changes: 114 additions & 0 deletions test/unit/auth/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,120 @@ AUTH_CONFIGS.forEach((testConfig) => {
});
});

describe('getUserByProviderUid()', () => {
const providerId = 'google.com';
const providerUid = 'google_uid';
const tenantId = testConfig.supportsTenantManagement ? undefined : TENANT_ID;
const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(tenantId);
const expectedUserRecord = getValidUserRecord(expectedGetAccountInfoResult);
const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND);

// Stubs used to simulate underlying api calls.
let stubs: sinon.SinonStub[] = [];
beforeEach(() => sinon.spy(validator, 'isEmail'));
afterEach(() => {
(validator.isEmail as any).restore();
_.forEach(stubs, (stub) => stub.restore());
stubs = [];
});

it('should be rejected given no provider id', () => {
expect(() => (auth as any).getUserByProviderUid())
.to.throw(FirebaseAuthError)
.with.property('code', 'auth/invalid-provider-id');
});

it('should be rejected given an invalid provider id', () => {
expect(() => auth.getUserByProviderUid('', 'uid'))
.to.throw(FirebaseAuthError)
.with.property('code', 'auth/invalid-provider-id');
});

it('should be rejected given an invalid provider uid', () => {
expect(() => auth.getUserByProviderUid('id', ''))
.to.throw(FirebaseAuthError)
.with.property('code', 'auth/invalid-provider-id');
});

it('should be rejected given an app which returns null access tokens', () => {
return nullAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
});

it('should be rejected given an app which returns invalid access tokens', () => {
return malformedAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
});

it('should be rejected given an app which fails to generate access tokens', () => {
return rejectedPromiseAccessTokenAuth.getUserByProviderUid(providerId, providerUid)
.should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential');
});

it('should resolve with a UserRecord on success', () => {
// Stub getAccountInfoByEmail to return expected result.
const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByFederatedUid')
.resolves(expectedGetAccountInfoResult);
stubs.push(stub);
return auth.getUserByProviderUid(providerId, providerUid)
.then((userRecord) => {
// Confirm underlying API called with expected parameters.
expect(stub).to.have.been.calledOnce.and.calledWith(providerId, providerUid);
// Confirm expected user record response returned.
expect(userRecord).to.deep.equal(expectedUserRecord);
});
});

describe('non-federated providers', () => {
let invokeRequestHandlerStub: sinon.SinonStub;
beforeEach(() => {
invokeRequestHandlerStub = sinon.stub(testConfig.RequestHandler.prototype, 'invokeRequestHandler')
.resolves({
// nothing here is checked; we just need enough to not crash.
users: [{
localId: 1,
}],
});

});
afterEach(() => {
invokeRequestHandlerStub.restore();
});

it('phone lookups should use phoneNumber field', async () => {
await auth.getUserByProviderUid('phone', '+15555550001');
expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith(
sinon.match.any, sinon.match.any, {
phoneNumber: ['+15555550001'],
});
});

it('email lookups should use email field', async () => {
await auth.getUserByProviderUid('email', '[email protected]');
expect(invokeRequestHandlerStub).to.have.been.calledOnce.and.calledWith(
sinon.match.any, sinon.match.any, {
email: ['[email protected]'],
});
});
});

it('should throw an error when the backend returns an error', () => {
// Stub getAccountInfoByFederatedUid to throw a backend error.
const stub = sinon.stub(testConfig.RequestHandler.prototype, 'getAccountInfoByFederatedUid')
.rejects(expectedError);
stubs.push(stub);
return auth.getUserByProviderUid(providerId, providerUid)
.then(() => {
throw new Error('Unexpected success');
}, (error) => {
// Confirm underlying API called with expected parameters.
expect(stub).to.have.been.calledOnce.and.calledWith(providerId, providerUid);
// Confirm expected error returned.
expect(error).to.equal(expectedError);
});
});
});

describe('getUsers()', () => {
let stubs: sinon.SinonStub[] = [];

Expand Down

0 comments on commit fc2f557

Please sign in to comment.