Skip to content

Commit 8507d93

Browse files
Defines TenantAwareAuth (#551)
* Defines TenantAwareAuth and its user management APIs, email action link APIs, OIDC/SAML provider config mgmt APIs. * Throws error when tenantId is provided in createUser and updateUser Auth API requests. Adds detailed tenant mismatch error for uploadAccount on tenant Id mismatch for TenantAwareAuth. * Addresses comments. * Added missing mapping to client error of MISSING_DISPLAY_NAME error.
1 parent 5c7d44d commit 8507d93

File tree

5 files changed

+2759
-2271
lines changed

5 files changed

+2759
-2271
lines changed

src/auth/auth-api-request.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean =
238238
phoneNumber: true,
239239
customAttributes: true,
240240
validSince: true,
241-
tenantId: true,
241+
// Pass tenantId only for uploadAccount requests.
242+
tenantId: uploadAccountRequest,
242243
passwordHash: uploadAccountRequest,
243244
salt: uploadAccountRequest,
244245
createdAt: uploadAccountRequest,
@@ -474,6 +475,12 @@ export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('/accounts:update'
474475
AuthClientErrorCode.INTERNAL_ERROR,
475476
'INTERNAL ASSERT FAILED: Server request is missing user identifier');
476477
}
478+
// Throw error when tenantId is passed in POST body.
479+
if (typeof request.tenantId !== 'undefined') {
480+
throw new FirebaseAuthError(
481+
AuthClientErrorCode.INVALID_ARGUMENT,
482+
'"tenantId" is an invalid "UpdateRequest" property.');
483+
}
477484
validateCreateEditRequest(request);
478485
})
479486
// Set response validator.
@@ -505,6 +512,12 @@ export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('/accounts', 'POST
505512
`"validSince" cannot be set when creating a new user.`,
506513
);
507514
}
515+
// Throw error when tenantId is passed in POST body.
516+
if (typeof request.tenantId !== 'undefined') {
517+
throw new FirebaseAuthError(
518+
AuthClientErrorCode.INVALID_ARGUMENT,
519+
'"tenantId" is an invalid "CreateRequest" property.');
520+
}
508521
validateCreateEditRequest(request);
509522
})
510523
// Set response validator.
@@ -917,15 +930,6 @@ export abstract class AbstractAuthRequestHandler {
917930
);
918931
}
919932

920-
if (properties.hasOwnProperty('tenantId')) {
921-
return Promise.reject(
922-
new FirebaseAuthError(
923-
AuthClientErrorCode.INVALID_ARGUMENT,
924-
'Tenant ID cannot be modified on an existing user.',
925-
),
926-
);
927-
}
928-
929933
// Build the setAccountInfo request.
930934
const request: any = deepCopy(properties);
931935
request.localId = uid;
@@ -1470,4 +1474,34 @@ export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler {
14701474
protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder {
14711475
return new TenantAwareAuthResourceUrlBuilder(this.projectId, 'v2beta1', this.tenantId);
14721476
}
1477+
1478+
/**
1479+
* Imports the list of users provided to Firebase Auth. This is useful when
1480+
* migrating from an external authentication system without having to use the Firebase CLI SDK.
1481+
* At most, 1000 users are allowed to be imported one at a time.
1482+
* When importing a list of password users, UserImportOptions are required to be specified.
1483+
*
1484+
* Overrides the superclass methods by adding an additional check to match tenant IDs of
1485+
* imported user records if present.
1486+
*
1487+
* @param {UserImportRecord[]} users The list of user records to import to Firebase Auth.
1488+
* @param {UserImportOptions=} options The user import options, required when the users provided
1489+
* include password credentials.
1490+
* @return {Promise<UserImportResult>} A promise that resolves when the operation completes
1491+
* with the result of the import. This includes the number of successful imports, the number
1492+
* of failed uploads and their corresponding errors.
1493+
*/
1494+
public uploadAccount(
1495+
users: UserImportRecord[], options?: UserImportOptions): Promise<UserImportResult> {
1496+
// Add additional check to match tenant ID of imported user records.
1497+
users.forEach((user: UserImportRecord, index: number) => {
1498+
if (validator.isNonEmptyString(user.tenantId) &&
1499+
user.tenantId !== this.tenantId) {
1500+
throw new FirebaseAuthError(
1501+
AuthClientErrorCode.MISMATCHING_TENANT_ID,
1502+
`UserRecord of index "${index}" has mismatching tenant ID "${user.tenantId}"`);
1503+
}
1504+
});
1505+
return super.uploadAccount(users, options);
1506+
}
14731507
}

src/auth/auth.ts

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import {UserRecord, CreateRequest, UpdateRequest} from './user-record';
1818
import {FirebaseApp} from '../firebase-app';
1919
import {FirebaseTokenGenerator, CryptoSigner, cryptoSignerFromApp} from './token-generator';
20-
import {AuthRequestHandler} from './auth-api-request';
20+
import {
21+
AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler,
22+
} from './auth-api-request';
2123
import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error';
2224
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
2325
import {
@@ -32,6 +34,7 @@ import {
3234
AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest,
3335
SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse,
3436
} from './auth-config';
37+
import {deepCopy, deepExtend} from '../utils/deep-copy';
3538

3639

3740
/**
@@ -72,6 +75,7 @@ export interface DecodedIdToken {
7275
iat: number;
7376
iss: string;
7477
sub: string;
78+
tenant?: string;
7579
[key: string]: any;
7680
}
7781

@@ -85,7 +89,7 @@ export interface SessionCookieOptions {
8589
/**
8690
* Base Auth class. Mainly used for user management APIs.
8791
*/
88-
class BaseAuth {
92+
export class BaseAuth {
8993
protected readonly tokenGenerator: FirebaseTokenGenerator;
9094
protected readonly idTokenVerifier: FirebaseTokenVerifier;
9195
protected readonly sessionCookieVerifier: FirebaseTokenVerifier;
@@ -94,14 +98,14 @@ class BaseAuth {
9498
* The BaseAuth class constructor.
9599
*
96100
* @param {string} projectId The corresponding project ID.
97-
* @param {FirebaseAuthRequestHandler} authRequestHandler The RPC request handler
101+
* @param {AbstractAuthRequestHandler} authRequestHandler The RPC request handler
98102
* for this instance.
99103
* @param {CryptoSigner} cryptoSigner The instance crypto signer used for custom token
100104
* minting.
101105
* @constructor
102106
*/
103107
constructor(protected readonly projectId: string,
104-
protected readonly authRequestHandler: AuthRequestHandler,
108+
protected readonly authRequestHandler: AbstractAuthRequestHandler,
105109
cryptoSigner: CryptoSigner) {
106110
this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner);
107111
this.sessionCookieVerifier = createSessionCookieVerifier(projectId);
@@ -599,11 +603,126 @@ class BaseAuth {
599603
}
600604

601605

606+
/**
607+
* The tenant aware Auth class.
608+
*/
609+
export class TenantAwareAuth extends BaseAuth {
610+
public readonly tenantId: string;
611+
612+
/**
613+
* The TenantAwareAuth class constructor.
614+
*
615+
* @param {object} app The app that created this tenant.
616+
* @param tenantId The corresponding tenant ID.
617+
* @constructor
618+
*/
619+
constructor(private readonly app: FirebaseApp, tenantId: string) {
620+
super(
621+
utils.getProjectId(app),
622+
new TenantAwareAuthRequestHandler(app, tenantId),
623+
cryptoSignerFromApp(app));
624+
utils.addReadonlyGetter(this, 'tenantId', tenantId);
625+
}
626+
627+
/**
628+
* Creates a new custom token that can be sent back to a client to use with
629+
* signInWithCustomToken().
630+
*
631+
* @param {string} uid The uid to use as the JWT subject.
632+
* @param {object=} developerClaims Optional additional claims to include in the JWT payload.
633+
*
634+
* @return {Promise<string>} A JWT for the provided payload.
635+
*/
636+
public createCustomToken(uid: string, developerClaims?: object): Promise<string> {
637+
// This is not yet supported by the Auth server. It is also not yet determined how this will be
638+
// supported.
639+
return Promise.reject(
640+
new FirebaseAuthError(AuthClientErrorCode.UNSUPPORTED_TENANT_OPERATION));
641+
}
642+
643+
/**
644+
* Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects
645+
* the promise if the token could not be verified. If checkRevoked is set to true,
646+
* verifies if the session corresponding to the ID token was revoked. If the corresponding
647+
* user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified
648+
* the check is not applied.
649+
*
650+
* @param {string} idToken The JWT to verify.
651+
* @param {boolean=} checkRevoked Whether to check if the ID token is revoked.
652+
* @return {Promise<DecodedIdToken>} A Promise that will be fulfilled after a successful
653+
* verification.
654+
*/
655+
public verifyIdToken(idToken: string, checkRevoked: boolean = false): Promise<DecodedIdToken> {
656+
return super.verifyIdToken(idToken, checkRevoked)
657+
.then((decodedClaims) => {
658+
// Validate tenant ID.
659+
if (decodedClaims.firebase.tenant !== this.tenantId) {
660+
throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID);
661+
}
662+
return decodedClaims;
663+
});
664+
}
665+
666+
/**
667+
* Creates a new Firebase session cookie with the specified options that can be used for
668+
* session management (set as a server side session cookie with custom cookie policy).
669+
* The session cookie JWT will have the same payload claims as the provided ID token.
670+
*
671+
* @param {string} idToken The Firebase ID token to exchange for a session cookie.
672+
* @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes
673+
* custom session duration.
674+
*
675+
* @return {Promise<string>} A promise that resolves on success with the created session cookie.
676+
*/
677+
public createSessionCookie(
678+
idToken: string, sessionCookieOptions: SessionCookieOptions): Promise<string> {
679+
// Validate arguments before processing.
680+
if (!validator.isNonEmptyString(idToken)) {
681+
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN));
682+
}
683+
if (!validator.isNonNullObject(sessionCookieOptions) ||
684+
!validator.isNumber(sessionCookieOptions.expiresIn)) {
685+
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION));
686+
}
687+
// This will verify the ID token and then match the tenant ID before creating the session cookie.
688+
return this.verifyIdToken(idToken)
689+
.then((decodedIdTokenClaims) => {
690+
return super.createSessionCookie(idToken, sessionCookieOptions);
691+
});
692+
}
693+
694+
/**
695+
* Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects
696+
* the promise if the token could not be verified. If checkRevoked is set to true,
697+
* verifies if the session corresponding to the session cookie was revoked. If the corresponding
698+
* user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not
699+
* specified the check is not performed.
700+
*
701+
* @param {string} sessionCookie The session cookie to verify.
702+
* @param {boolean=} checkRevoked Whether to check if the session cookie is revoked.
703+
* @return {Promise<DecodedIdToken>} A Promise that will be fulfilled after a successful
704+
* verification.
705+
*/
706+
public verifySessionCookie(
707+
sessionCookie: string, checkRevoked: boolean = false): Promise<DecodedIdToken> {
708+
return super.verifySessionCookie(sessionCookie, checkRevoked)
709+
.then((decodedClaims) => {
710+
if (decodedClaims.firebase.tenant !== this.tenantId) {
711+
throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID);
712+
}
713+
return decodedClaims;
714+
});
715+
}
716+
}
717+
718+
602719
/**
603720
* Auth service bound to the provided app.
721+
* An Auth instance can have multiple tenants.
604722
*/
605723
export class Auth extends BaseAuth implements FirebaseServiceInterface {
606724
public INTERNAL: AuthInternals = new AuthInternals();
725+
private readonly tenantsMap: {[key: string]: TenantAwareAuth};
607726
private readonly app_: FirebaseApp;
608727

609728
/**
@@ -632,6 +751,7 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface {
632751
new AuthRequestHandler(app),
633752
cryptoSignerFromApp(app));
634753
this.app_ = app;
754+
this.tenantsMap = {};
635755
}
636756

637757
/**
@@ -642,4 +762,20 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface {
642762
get app(): FirebaseApp {
643763
return this.app_;
644764
}
765+
766+
/**
767+
* Returns a TenantAwareAuth instance for the corresponding tenant ID.
768+
*
769+
* @param {string} tenantId The tenant ID whose TenantAwareAuth is to be returned.
770+
* @return {TenantAwareAuth} The corresponding TenantAwareAuth instance.
771+
*/
772+
public forTenant(tenantId: string): TenantAwareAuth {
773+
if (!validator.isNonEmptyString(tenantId)) {
774+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID);
775+
}
776+
if (typeof this.tenantsMap[tenantId] === 'undefined') {
777+
this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId);
778+
}
779+
return this.tenantsMap[tenantId];
780+
}
645781
}

src/utils/error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,10 @@ export class AuthClientErrorCode {
532532
code: 'missing-continue-uri',
533533
message: 'A valid continue URL must be provided in the request.',
534534
};
535+
public static MISSING_DISPLAY_NAME = {
536+
code: 'missing-display-name',
537+
message: 'The resource being created or edited is missing a valid display name.',
538+
};
535539
public static MISSING_IOS_BUNDLE_ID = {
536540
code: 'missing-ios-bundle-id',
537541
message: 'The request is missing an iOS Bundle ID.',
@@ -607,6 +611,10 @@ export class AuthClientErrorCode {
607611
message: 'The domain of the continue URL is not whitelisted. Whitelist the domain in the ' +
608612
'Firebase console.',
609613
};
614+
public static UNSUPPORTED_TENANT_OPERATION = {
615+
code: 'unsupported-tenant-operation',
616+
message: 'This operation is not supported in a multi-tenant context.',
617+
};
610618
public static USER_NOT_FOUND = {
611619
code: 'user-not-found',
612620
message: 'There is no user record corresponding to the provided identifier.',
@@ -827,6 +835,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
827835
TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED',
828836
// Continue URL provided in ActionCodeSettings has a domain that is not whitelisted.
829837
UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN',
838+
// Operation is not supported in a multi-tenant context.
839+
UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION',
830840
// User on which action is to be performed is not found.
831841
USER_NOT_FOUND: 'USER_NOT_FOUND',
832842
// Password provided is too weak.

0 commit comments

Comments
 (0)