Skip to content
Next Next commit
Implement getUserByFederatedId()
  • Loading branch information
rsgowman committed Jan 20, 2020
commit fa9934b62420fb4ef581c5d0bf2b6f2dd3827e9f
17 changes: 16 additions & 1 deletion src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ export const FIREBASE_AUTH_DOWNLOAD_ACCOUNT = new ApiSettings('/accounts:batchGe
export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('/accounts:lookup', 'POST')
// Set request validator.
.setRequestValidator((request: any) => {
if (!request.localId && !request.email && !request.phoneNumber) {
if (!request.localId && !request.email && !request.phoneNumber && !request.federatedUserId) {
throw new FirebaseAuthError(
AuthClientErrorCode.INTERNAL_ERROR,
'INTERNAL ASSERT FAILED: Server request is missing user identifier');
Expand Down Expand Up @@ -811,6 +811,21 @@ export abstract class AbstractAuthRequestHandler {
return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request);
}

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

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

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

/**
* Exports the users (single batch only) with a size of maxResults and starting from
* the offset as specified by pageToken.
Expand Down
21 changes: 21 additions & 0 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,27 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
});
}

/**
* Gets the user data for the user corresponding to a given federated id.
*
* See [Retrieve user data](/docs/auth/admin/manage-users#retrieve_user_data)
* for code samples and detailed documentation.
*
* @param federatedId The provider ID, for example, "google.com" for the
* Google provider.
* @param federatedUid The user identifier for the given provider.
*
* @return A promise fulfilled with the user data corresponding to the
* provided federated id.
*/
public getUserByFederatedId(federatedId: string, federatedUid: string): Promise<UserRecord> {
return this.authRequestHandler.getAccountInfoByFederatedId(federatedId, federatedUid)
.then((response: any) => {
// Returns the user record populated with server response.
return new UserRecord(response.users[0]);
});
}

/**
* Exports a batch of user accounts. Batch size is determined by the maxResults argument.
* Starting point of the batch is determined by the pageToken argument.
Expand Down
15 changes: 15 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,21 @@ declare namespace admin.auth {
*/
getUserByPhoneNumber(phoneNumber: string): Promise<admin.auth.UserRecord>;

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

/**
* Retrieves a list of users (single batch only) with a size of `maxResults`
* starting from the offset as specified by `pageToken`. This is used to
Expand Down
44 changes: 44 additions & 0 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const mockUserData = {
displayName: 'Random User ' + newUserUid,
photoURL: 'http://www.example.com/' + newUserUid + '/photo.png',
disabled: false,

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: remove the blank line

Copy link
Member Author

Choose a reason for hiding this comment

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

Done.

};
const actionCodeSettings = {
url: 'http://localhost/?a=1&b=2#c=3',
Expand Down Expand Up @@ -168,6 +169,44 @@ describe('admin.auth', () => {
});
});

it('getUserByFederatedId() returns a user record with the matching federated 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 importUser: admin.auth.UserImportRecord = {
uid: 'uid',
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',
toJSON: () => { throw new Error('Unimplemented'); },
},
providerData: [{
displayName: 'User Name',
email: '[email protected]',
phoneNumber: '+15555550000',
photoURL: 'http://example.com/user',
toJSON: () => { throw new Error('Unimplemented'); },
providerId: 'google.com',
uid: 'google_uid',
}],
};

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

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

it('listUsers() returns up to the specified number of users', () => {
const promises: Array<Promise<admin.auth.UserRecord>> = [];
uids.forEach((uid) => {
Expand Down Expand Up @@ -356,6 +395,11 @@ describe('admin.auth', () => {
.should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found');
});

it('getUserByFederatedId() fails when called with a non-existing federated id', () => {
return admin.auth().getUserByFederatedId('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
81 changes: 81 additions & 0 deletions test/unit/auth/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,87 @@ AUTH_CONFIGS.forEach((testConfig) => {
});
});

describe('getUserByFederatedId()', () => {
const federatedId = 'google.com';
const federatedUid = '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 federated id', () => {
expect(() => (auth as any).getUserByFederatedId())
.to.throw(FirebaseAuthError)
.with.property('code', 'auth/invalid-provider-id');
});

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

it('should be rejected given an invalid federated uid', () => {
expect(() => auth.getUserByFederatedId('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.getUserByFederatedId(federatedId, federatedUid)
.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.getUserByFederatedId(federatedId, federatedUid)
.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.getUserByFederatedId(federatedId, federatedUid)
.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, 'getAccountInfoByFederatedId')
.resolves(expectedGetAccountInfoResult);
stubs.push(stub);
return auth.getUserByFederatedId(federatedId, federatedUid)
.then((userRecord) => {
// Confirm underlying API called with expected parameters.
expect(stub).to.have.been.calledOnce.and.calledWith(federatedId, federatedUid);
// Confirm expected user record response returned.
expect(userRecord).to.deep.equal(expectedUserRecord);
});
});

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

describe('deleteUser()', () => {
const uid = 'abcdefghijklmnopqrstuvwxyz';
const expectedDeleteAccountResult = {kind: 'identitytoolkit#DeleteAccountResponse'};
Expand Down