Skip to content

Allow createCustomToken() to work with tenant-aware auth #708

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
Dec 16, 2019

Conversation

rsgowman
Copy link
Member

No description provided.

@rsgowman rsgowman self-assigned this Nov 28, 2019
@rsgowman rsgowman force-pushed the rsgowman/tenant_customtoken branch from 5e6cb16 to 8b02f7a Compare November 28, 2019 16:30
return this.createCustomTokenInternal(uid, tenantId, developerClaims);
}

private createCustomTokenInternal(
Copy link
Member Author

Choose a reason for hiding this comment

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

Alternatively to having two separate createCustomToken*() methods, we could have one with an options object, i.e.:

interface CreateCustomTokenOptions {
  tenantId?: string;
  developerClaims?: {[key: string]: any};
}
public createCustomToken(uid: string, options?: CreateCustomTokenOptions) { ... }

@@ -293,22 +294,22 @@ describe('FirebaseTokenGenerator', () => {
it('should throw given no uid', () => {
expect(() => {
(tokenGenerator as any).createCustomToken();
}).to.throw('First argument to createCustomToken() must be a non-empty string uid');
}).to.throw(FirebaseAuthError).with.property('code', 'auth/argument-error');
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've switched many of the error check tests from 'verify the error message' to 'verify the error code'. Personally, I prefer just verifying the code, as the error message itself often changes which makes the tests a little fragile. But this is hardly a universal opinion. I'm happy to add the error message back in if you'd prefer.

(Obviously, these changes in particular were caused by me tweaking the error message slightly and having a bunch of tests fail as a result.)

@@ -466,4 +467,215 @@ describe('FirebaseTokenGenerator', () => {
});
});
});

describe('createCustomTokenWithTenantId()', () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

These tests largely duplicate the createCustomToken() tests above, but validate that the path with tenantIds also works. Roughly half of these tests are pretty low (though not quite no) value and I'd be happy to remove them. i.e. the tests that validate the uid handling is correct, even when a tennantid is provided, e.g. 'should throw given an empty string uid'.

Opinions welcome.

Copy link
Contributor

Choose a reason for hiding this comment

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

You can follow a similar approach to other tests for common API used in a tenant and non-tenant context where shared code is used. This avoids the duplication and makes it easier to make changes in the future.

Copy link
Member Author

Choose a reason for hiding this comment

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

You mean like this?

AUTH_CONFIGS.forEach((testConfig) => {

The problem here is that:
a) We have a TokenGenerator (rather than an Auth object) and the TokenGenerator is not tenant aware.
b) The API for creating a custom token differs based on whether you want a tenantid included or not.

This problem is the more general problem of testing method overloads; I'm not aware of a great solution. Here's the obvious:
a) Just duplicate the tests. (i.e. as currently implemented)
b) Mock the implementation method and just verify that createCustomToken() pipes it's arguments through successfully. Possibly more pragmatic, but requires you to peek at the implementation (and to assume it won't change.)

In our case, we could also:
c) Make TokenGenerator tenant aware. Then we could use the same approach as for Auth instances. It seems a little odd... but I think it's workable with some refactoring around how the Auth instance creates its TokenGenerator.
d) Iterate over a list of functions, like this (which is pretty ugly):

const createTokenFns = [
  (uid: string, tenantId: string, developerClaims?: {[key: string]: any}): Promise<string> => {
    return tokenGenerator.createCustomToken(string, developerClaims);
  }
  (uid: string, tenantId: string, developerClaims?: {[key: string]: any}): Promise<string> => {
    return tokenGenerator.createCustomTokenWithTenantId(string, tenantId, developerClaims);
  }];

createTokensFns.forEach((fn) => {
  describe("create custom token, possibly tenant aware", () => {
    it('should work', () => {
      expect(fn('uid', 'tenantid_that_might_be_ignored')).should.eventually.be.fulfilled;
    });
  });
});

I don't like (d); I mildly don't like (b). (c) doesn't seem so bad though. wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

TokenGenerator is created per Auth and TenantAwareAuth instance. The tenant ID in that case does not change after creation (could be set in the constructor). TokenGenerator has only one public method createCustomToken that mints tokens that are tenant aware. Every call of that should also set the tenant ID in the minted JWT. So maybe (c) is a good choice. But I am not sure how much work is involved in this. Do you want to explore and make an assessment?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not too much at all. Done.

Copy link
Contributor

@bojeil-google bojeil-google 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 putting this together. Took a quick look. Will take a more thorough look tomorrow.

/**
* Creates a new Firebase Auth Custom token.
*
* @param {string} uid The user ID to use for the generated Firebase Auth Custom token.
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is the current pattern but we should move away from re-stating the types, eg. string in the javadocs.
This is according to the internal typescript readability guideline.

Copy link
Member Author

Choose a reason for hiding this comment

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

Very happy to do this. (Done.) Obviously, I've only done this for the methods I've touched in this PR (createCustomToken*).

});
expect(() => {
mockCredentialAuth.createCustomToken(mocks.uid, mocks.developerClaims);
}).not.to.throw;
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems to be a mismatch with the test description (should throw if...)

Copy link
Member Author

Choose a reason for hiding this comment

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

Huh; that's odd. Seems to go back to #285 which is literally called 'Support for creating custom tokens without service account credentials'. So the test was adjusted as part of that, but the test description was not. I've fixed it.

Copy link
Member Author

Choose a reason for hiding this comment

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

But wait! That's wrong too, since this test can't call "the IAM service in the cloud when the service account credentials are not provided." So instead of throwing immediately, it now returns a failed future. Fixed (for real this time) by ensuring it does fail, but not immediately. (Might be worth checking with @hiranya911 to see if this captures the original intent.)

@@ -466,4 +467,215 @@ describe('FirebaseTokenGenerator', () => {
});
});
});

describe('createCustomTokenWithTenantId()', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

You can follow a similar approach to other tests for common API used in a tenant and non-tenant context where shared code is used. This avoids the duplication and makes it easier to make changes in the future.

Copy link
Contributor

@bojeil-google bojeil-google left a comment

Choose a reason for hiding this comment

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

Latest changes look good. Just some minor comments.

constructor(app: FirebaseApp, protected readonly authRequestHandler: T) {
const cryptoSigner = cryptoSignerFromApp(app);
this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner);
constructor(app: FirebaseApp, protected readonly authRequestHandler: T, tokenGenerator?: FirebaseTokenGenerator) {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should update the javadocs with the right number of parameters

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. (Also removed {types})

if (!validator.isNonNullObject(signer)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.',
);
}
if (tenantId !== undefined && !validator.isNonEmptyString(tenantId)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: typeof tenantId !== 'undefined'

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.

if (tenantId !== undefined && !validator.isNonEmptyString(tenantId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'`tenantId` argument must be a non-empty string uid.');
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe remove uid mention as it may confuse the developer since we use it to refer to the user unique identifier?
tenantId argument must be a non-empty string.

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. (I had copied an error message from somewhere else and adjusted it, but didn't remove the uid part.)

expect(() => {
tokenGenerator.createCustomToken(uid);
}).not.to.throw();
const tokenGeneratorConfigs = [{
Copy link
Contributor

Choose a reason for hiding this comment

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

You can also declare an interface for this.

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 don't think that's necessary; the type checker will still enforce everything via it's type deduction. (And I verified that, because I initially got it wrong.) If you want it anyways for doc purposes, or any other reason, I'm happy to add it in though.


it('createCustomToken() mints a JWT that can be used to sign in tenant users', async () => {
try {
firebase.auth!().tenantId = createdTenantId;
Copy link
Contributor

Choose a reason for hiding this comment

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

Style guide does not seem to recommend non-nullability assertions without a reason.
https://g3doc.corp.google.com/javascript/typescript/g3doc/styleguide.md?cl=head#type-and-non-nullability-assertions

Copy link
Member Author

Choose a reason for hiding this comment

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

It allows it when the reason is "obvious". So I'd argue:
a) It's "obvious" that this is present because we've imported '@firebase/auth'.
b) It's overly verbose to comment each case (or to expect(firebase.auth).to.be.ok) each time.)
c) Worst case, something goes wrong and the test fails. Which is exactly what we'd want (albeit, the cause might not be as obvious as ideal.)

A possible compromise: It's reasonably obvious that this is the client auth instance, but not perfectly obvious. We could make a helper, and that helper could do the expect dance since it wouldn't clutter the tests. The helper would make it more obvious that this is the client instance (rather than admin.auth), so it'd serve that purpose too. (Done.)

@@ -91,6 +91,10 @@ function randomOidcProviderId(): string {
return 'oidc.' + generateRandomString(10, false).toLowerCase();
}

function client_auth(): FirebaseAuth {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use camel case clientAuth for function names here and elsewhere.

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.

@rsgowman rsgowman merged commit 99d3214 into master Dec 16, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants