Skip to content

Authentication Emulator Supports MFA for Simple Read/Write User Operations (Fixes #3170) #3173

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

Merged
merged 14 commits into from
Mar 18, 2021

Conversation

wokkaflokka
Copy link
Contributor

@wokkaflokka wokkaflokka commented Feb 24, 2021

Description

This PR proposes an initial resolution for #3170 by implementing rudimentary support for "mutli-factor" information during admin SDK operations (e.g., admin.auth().createUser(...)).

Note, deletes, client operations, and advanced scenarios are intentionally excluded from this change at this time. It seems like there is large potential scope of support for MFA in general, and possibly some changes going on under the hood, and I wanted to keep this as focused as possible to start.

Scenarios Tested

This change is focused on supporting read/write operations from the admin SDK while using the Authentication Emulator. It has been tested using the test case provided on #3170 as well as a modified variant of the test case from #3170, provided here.

import firebaseAdmin from 'firebase-admin';

let app: firebaseAdmin.app.App;

beforeAll(() => {
    app = firebaseAdmin.initializeApp({
        projectId: 'run-me-in-an-emulator',
    });
});

afterAll(async () => {
    await app.delete();
});

test('Firebase Authentication Emulator Returns MultiFactorSettings Data', async () => {
    expect(!!process.env.FIREBASE_AUTH_EMULATOR_HOST).toEqual(true);

    const user = await app.auth().createUser({
        email: '[email protected]',
        emailVerified: true,
        password: undefined,
        multiFactor: {
            enrolledFactors: [
                {
                    phoneNumber: '+12813308004',
                    factorId: 'phone',
                    displayName: 'Mobile',
                } as firebaseAdmin.auth.CreatePhoneMultiFactorInfoRequest,
            ],
        },
    });

    expect(user.multiFactor).toEqual({
        enrolledFactors: [{
            phoneNumber: '+12813308004',
            factorId: 'phone',
            displayName: 'Mobile',
            enrollmentTime: null,
            uid: expect.stringMatching(/^.+$/),
        }],
    });
});

Sample Commands

To run the test case above, configure a minimal typescript project with the implied dependencies and execute something like:

firebase emulators:exec --debug 'npm run test firebaseRepro.test.ts'

Fixes #3170.

@google-cla google-cla bot added the cla: yes Manual indication that this has passed CLA. label Feb 24, 2021
@samtstern
Copy link
Contributor

samtstern commented Feb 24, 2021

@wokkaflokka thanks for the super clear issue and the PR! Assigning to @yuchenshi who does most of the Auth emulator things around here.

(also 👋 we met back in the Firestore alpha days)

Copy link
Member

@yuchenshi yuchenshi left a comment

Choose a reason for hiding this comment

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

Thanks for the PR. Unfortunately, this creates a confusing situation where a user with mfaInfo can still sign in without MFA. I understand that you won't need to full implementation for you use case, but would you mind making it so that users with mfaInfo cannot sign in at all with a NotImplementedError?

@yuchenshi yuchenshi assigned wokkaflokka and unassigned yuchenshi Mar 5, 2021
@wokkaflokka
Copy link
Contributor Author

@yuchenshi thanks for the review. I'll get to work on those changes.

@wokkaflokka wokkaflokka requested a review from yuchenshi March 15, 2021 17:08
@wokkaflokka
Copy link
Contributor Author

wokkaflokka commented Mar 15, 2021

@yuchenshi appreciate all of your help to date. I've completed a second pass, addressing your initial review comments and extending support to the update case. My personal schedule should be less chaotic for the foreseeable future, so I should also be able to respond to any and all feedback on a quicker cycle at this time.

Summary of Changes

  1. Throw NotImplementedError for all "sign in" methods whenever a user with mfaInfo attempts to sign-in (9e6da88).
  2. Additional test-cases added around validation and formatting for "sign-up" related MFA flows (a41f116).
  3. Validation brought into operations.ts for MFA-related operations (a41f116).
  4. Extended MFA support to update flows and added additional test cases for the update case (a41f116).

Copy link
Member

@yuchenshi yuchenshi left a comment

Choose a reason for hiding this comment

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

This is looking great and I've left a few comments inline. The big blockers are the uniqueness checks and consistency for the maps in state.

And as of this PR, mfaInfo will be lost when you export and re-import users. The scope of the PR is already quite big and I think we should leave a TODO and address it in a follow-up PR.

@wokkaflokka
Copy link
Contributor Author

wokkaflokka commented Mar 15, 2021

@yuchenshi once again, thanks for the thorough review. I'll have another pass this evening or tomorrow morning addressing the points you raise. To summarize what I gleaned from review/what I intend to change in my follow-up:

  1. Fix the uniqueness behavior to require uniqueness per-user rather than across all users (match the behavior of the actual SDK).
  2. General updates to validation / internal procedures (as directed by comments).
  3. Improvements to style and implementation of some test code (as directed by comments).
  4. Add a TODO for mfa behavior related import/export flows. I may file a github issue for the behavior, as well.

wokkaflokka and others added 3 commits March 17, 2021 14:17
2. match SDK behavior with respect to MFA ID uniqueness constraints
3. stylistic updates to test code
4. add TODOs regarding import/export MFA support
@wokkaflokka
Copy link
Contributor Author

wokkaflokka commented Mar 17, 2021

@yuchenshi -- well, overpromised and underdelivered, but I've completed another pass of this PR. Changes are in two commits. I believe all existing comments should be addressed; the changes ended up being larger than expected, but I think it was a net positive. One of the benefits of this change is that I think we achieved a simplified state management for MFA data.

In the first commit, I implemented the points 1-4 I mentioned I would previously fix.

In a final testing pass, I spent a little bit of time testing against my real project to see how create/update work when duplicate enrollment ID's and duplicate phone numbers are specified. Based on the behaviors I observed, I implemented some changes in a second commit. These behaviors match the SDK for the following:

  1. If duplicate phone numbers are specified on create, only a single factor is created per duplicated phone number.
  2. If duplicate phone numbers are specified on update, only a single factor is created per duplicated phone number.
  3. MFA Enrollment ID is completely ignored on create (previously implemented).
  4. On update, a duplicate MFA Enrollment ID is (a) ignored if it is provided on a duplicated phone number (ignored because the duplicated phone number is ignored), and (b) raises an error if two different phone numbers specify the same MFA enrollment ID.

@wokkaflokka wokkaflokka requested a review from yuchenshi March 17, 2021 22:47
Copy link
Member

@yuchenshi yuchenshi left a comment

Choose a reason for hiding this comment

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

I think we're pretty close here. The only real blocker is using some constants for phone numbers instead of real phone numbers.

const secondMfaFactor = {
displayName: "Second MFA Factor",
phoneInfo: TEST_PHONE_NUMBER,
phoneInfo: "+12813308004",
Copy link
Member

Choose a reason for hiding this comment

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

I appreciate the reference, but let's not put Mike's number in the source code. If you need a different phone number, please create a constant like TEST_PHONE_NUMBER_2 and stick to the fictional blocks (e.g. the next number after TEST_PHONE_NUMBER). I think we'd need three or four of these.

function getMfaEnrollmentsFromRequest(
state: ProjectState,
request: MfaEnrollments,
getEnrollmentId: (enrollment: MfaEnrollment, enrollmentIds: Set<string>) => string
Copy link
Member

Choose a reason for hiding this comment

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

nit: I'd actually suggest just options: {generateEnrollmentIds: boolean} instead. Less code, less abstractions and more readable.

const savedMfaInfo = savedUserInfo.mfaInfo![0];
expect(savedMfaInfo?.mfaEnrollmentId).to.be.a("string").and.not.empty;
savedMfaInfo.displayName = "New Display Name";
savedMfaInfo.phoneInfo = "+15555550101";
Copy link
Member

Choose a reason for hiding this comment

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

Similarly this could use a constant like TEST_PHONE_NUMBER_2

const user = {
email: "[email protected]",
password: "notasecret",
mfaInfo: [mfaInfo, mfaInfo, mfaInfo],
mfaInfo: [TEST_MFA_INFO, { ...TEST_MFA_INFO, phoneInfo: "+12813308004" }],
Copy link
Member

Choose a reason for hiding this comment

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

Same


const newMfaInfo = {
displayName: "New New",
phoneInfo: "+12813308004",
phoneInfo: "+12813308005",
Copy link
Member

Choose a reason for hiding this comment

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

use a test phone number please

const user = {
email: "[email protected]",
password: "notasecret",
mfaInfo: [mfaInfo, mfaInfo, mfaInfo],
mfaInfo: [TEST_MFA_INFO, { ...TEST_MFA_INFO, phoneInfo: "+12813308004" }],
Copy link
Member

Choose a reason for hiding this comment

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

I wish we had more test phone numbers Back Then.

expect(info.mfaInfo).to.have.length(2);
for (const savedMfaInfo of info.mfaInfo!) {
if (savedMfaInfo.phoneInfo !== TEST_MFA_INFO.phoneInfo) {
expect(savedMfaInfo.phoneInfo).to.eq("+12813308004");
Copy link
Member

Choose a reason for hiding this comment

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

If I were you I'd probably do a search and replace

TEST_MFA_INFO,
TEST_MFA_INFO,
TEST_MFA_INFO,
{ ...TEST_MFA_INFO, phoneInfo: "+12813308004" },
Copy link
Member

Choose a reason for hiding this comment

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

25,000 calls per day

expect(bobInfo.mfaInfo).to.have.length(2);
for (const savedMfaInfo of bobInfo.mfaInfo!) {
if (savedMfaInfo.phoneInfo !== TEST_MFA_INFO.phoneInfo) {
expect(savedMfaInfo.phoneInfo).to.eq("+12813308004");
Copy link
Member

Choose a reason for hiding this comment

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

Are we stress testing the phone carrier?

phoneInfo: TEST_PHONE_NUMBER,
mfaEnrollmentId: randomId(28),
it("should ignore if multi factor enrollment ID is specified on create", async () => {
const mfaEnrollmentId1 = randomId(28);
Copy link
Member

Choose a reason for hiding this comment

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

Let's hard code some IDs like thisShouldBeIgnored1 -- more readable, and the test won't randomly fail if we had really really bad (good?) luck with the RNG.

@wokkaflokka
Copy link
Contributor Author

@yuchenshi all very reasonable. Can't blame ya!

  1. Test numbers moved to constants and changed.
  2. Replace function parameter with options (great recommendation).
  3. Remove explicit test dependency on RNG (another great rec).

Thanks very much for all your time and help so far. Much appreciated.

@yuchenshi
Copy link
Member

@wokkaflokka One last thing -- I meant that we should use "thisShouldBeIgnored1 for MFA IDs that are ignored (during sign up). For the other MFA enrollment IDs, let's use some other name such as enrollmentId1 etc. Once that is done and the branch is up to date, I'll do the changelogs and merge.

@wokkaflokka
Copy link
Contributor Author

@yuchenshi roger that, pushed another one.

@yuchenshi
Copy link
Member

@wokkaflokka Merged! Thanks a ton for the PR and I've filed #3221 to track the remaining work.

@samtstern
Copy link
Contributor

@wokkaflokka thank you so much for this contribution and thanks @yuchenshi for the super thorough reviews!

@wokkaflokka wokkaflokka deleted the issue.3170 branch October 27, 2021 20:14
devpeerapong pushed a commit to devpeerapong/firebase-tools that referenced this pull request Dec 14, 2021
…tions (Fixes firebase#3170) (firebase#3173)

* Authentication Emulator Supports MFA Info for Simple Read/Write User Operations (firebase#3170)

* throw NotImplementedError if an MFA user attempts to login using the Authentication Emulator

* bring validation into operations layer and extend MFA related support to the `updateUser` path + add test cases for validation of `signUp` MFA flows

* 1. simplify state handling for MFA
2. match SDK behavior with respect to MFA ID uniqueness constraints
3. stylistic updates to test code
4. add TODOs regarding import/export MFA support

* match the SDK behavior for duplicated phone numbers and duplicated MFA Enrollment IDs on create and update

* change import for brevity

* update constants and IDs used in tests + simplify ID generation

* update variables in test

* Update CHANGELOG.md

Co-authored-by: Yuchen Shi <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cla: yes Manual indication that this has passed CLA.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Authentication Emulator returns undefined Multi-Factor Info for Users
3 participants