Skip to content
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

feat(auth): Implement getUserByProviderId #769

Merged
merged 12 commits into from
Feb 8, 2021
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') {
hiranya911 marked this conversation as resolved.
Show resolved Hide resolved
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')
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also call this API with email and phone and see if we get the expected result?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think we've already got that on lines 210,217. Or were you referring to something else?

Copy link
Contributor

Choose a reason for hiding this comment

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

I was referring to testing invocations like getUserByProviderUid('email', emailAddr).

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, I understand now. Yeah; we should have those too. Done.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. LGTM 👍

.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