Skip to content

Commit 123538d

Browse files
feat(auth): Multi-tenancy support for Google Cloud Identity Platform (#628)
Defines multi-tenancy APIs for Google Cloud Identity Platform.
1 parent 3310353 commit 123538d

19 files changed

+8244
-4689
lines changed

package-lock.json

Lines changed: 96 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/auth/auth-api-request.ts

Lines changed: 391 additions & 31 deletions
Large diffs are not rendered by default.

src/auth/auth-config.ts

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,115 @@ export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest {
149149
/** The public API request interface for updating a generic Auth provider. */
150150
export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest;
151151

152+
/** The email provider configuration interface. */
153+
export interface EmailSignInProviderConfig {
154+
enabled?: boolean;
155+
passwordRequired?: boolean; // In the backend API, default is true if not provided
156+
}
157+
158+
/** The server side email configuration request interface. */
159+
export interface EmailSignInConfigServerRequest {
160+
allowPasswordSignup?: boolean;
161+
enableEmailLinkSignin?: boolean;
162+
}
163+
164+
165+
/**
166+
* Defines the email sign-in config class used to convert client side EmailSignInConfig
167+
* to a format that is understood by the Auth server.
168+
*/
169+
export class EmailSignInConfig implements EmailSignInProviderConfig {
170+
public readonly enabled?: boolean;
171+
public readonly passwordRequired?: boolean;
172+
173+
/**
174+
* Static method to convert a client side request to a EmailSignInConfigServerRequest.
175+
* Throws an error if validation fails.
176+
*
177+
* @param {any} options The options object to convert to a server request.
178+
* @return {EmailSignInConfigServerRequest} The resulting server request.
179+
*/
180+
public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest {
181+
const request: EmailSignInConfigServerRequest = {};
182+
EmailSignInConfig.validate(options);
183+
if (options.hasOwnProperty('enabled')) {
184+
request.allowPasswordSignup = options.enabled;
185+
}
186+
if (options.hasOwnProperty('passwordRequired')) {
187+
request.enableEmailLinkSignin = !options.passwordRequired;
188+
}
189+
return request;
190+
}
191+
192+
/**
193+
* Validates the EmailSignInConfig options object. Throws an error on failure.
194+
*
195+
* @param {any} options The options object to validate.
196+
*/
197+
private static validate(options: {[key: string]: any}) {
198+
// TODO: Validate the request.
199+
const validKeys = {
200+
enabled: true,
201+
passwordRequired: true,
202+
};
203+
if (!validator.isNonNullObject(options)) {
204+
throw new FirebaseAuthError(
205+
AuthClientErrorCode.INVALID_ARGUMENT,
206+
'"EmailSignInConfig" must be a non-null object.',
207+
);
208+
}
209+
// Check for unsupported top level attributes.
210+
for (const key in options) {
211+
if (!(key in validKeys)) {
212+
throw new FirebaseAuthError(
213+
AuthClientErrorCode.INVALID_ARGUMENT,
214+
`"${key}" is not a valid EmailSignInConfig parameter.`,
215+
);
216+
}
217+
}
218+
// Validate content.
219+
if (typeof options.enabled !== 'undefined' &&
220+
!validator.isBoolean(options.enabled)) {
221+
throw new FirebaseAuthError(
222+
AuthClientErrorCode.INVALID_ARGUMENT,
223+
'"EmailSignInConfig.enabled" must be a boolean.',
224+
);
225+
}
226+
if (typeof options.passwordRequired !== 'undefined' &&
227+
!validator.isBoolean(options.passwordRequired)) {
228+
throw new FirebaseAuthError(
229+
AuthClientErrorCode.INVALID_ARGUMENT,
230+
'"EmailSignInConfig.passwordRequired" must be a boolean.',
231+
);
232+
}
233+
}
234+
235+
/**
236+
* The EmailSignInConfig constructor.
237+
*
238+
* @param {any} response The server side response used to initialize the
239+
* EmailSignInConfig object.
240+
* @constructor
241+
*/
242+
constructor(response: {[key: string]: any}) {
243+
if (typeof response.allowPasswordSignup === 'undefined') {
244+
throw new FirebaseAuthError(
245+
AuthClientErrorCode.INTERNAL_ERROR,
246+
'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response');
247+
}
248+
this.enabled = response.allowPasswordSignup;
249+
this.passwordRequired = !response.enableEmailLinkSignin;
250+
}
251+
252+
/** @return {object} The plain object representation of the email sign-in config. */
253+
public toJSON(): object {
254+
return {
255+
enabled: this.enabled,
256+
passwordRequired: this.passwordRequired,
257+
};
258+
}
259+
}
260+
152261

153262
/**
154263
* Defines the SAMLConfig class used to convert a client side configuration to its
@@ -367,24 +476,24 @@ export class SAMLConfig implements SAMLAuthProviderConfig {
367476
AuthClientErrorCode.INTERNAL_ERROR,
368477
'INTERNAL ASSERT FAILED: Invalid SAML configuration response');
369478
}
370-
utils.addReadonlyGetter(this, 'providerId', SAMLConfig.getProviderIdFromResourceName(response.name));
479+
this.providerId = SAMLConfig.getProviderIdFromResourceName(response.name);
371480
// RP config.
372-
utils.addReadonlyGetter(this, 'rpEntityId', response.spConfig.spEntityId);
373-
utils.addReadonlyGetter(this, 'callbackURL', response.spConfig.callbackUri);
481+
this.rpEntityId = response.spConfig.spEntityId;
482+
this.callbackURL = response.spConfig.callbackUri;
374483
// IdP config.
375-
utils.addReadonlyGetter(this, 'idpEntityId', response.idpConfig.idpEntityId);
376-
utils.addReadonlyGetter(this, 'ssoURL', response.idpConfig.ssoUrl);
377-
utils.addReadonlyGetter(this, 'enableRequestSigning', !!response.idpConfig.signRequest);
484+
this.idpEntityId = response.idpConfig.idpEntityId;
485+
this.ssoURL = response.idpConfig.ssoUrl;
486+
this.enableRequestSigning = !!response.idpConfig.signRequest;
378487
const x509Certificates: string[] = [];
379488
for (const cert of (response.idpConfig.idpCertificates || [])) {
380489
if (cert.x509Certificate) {
381490
x509Certificates.push(cert.x509Certificate);
382491
}
383492
}
384-
utils.addReadonlyGetter(this, 'x509Certificates', x509Certificates);
493+
this.x509Certificates = x509Certificates;
385494
// When enabled is undefined, it takes its default value of false.
386-
utils.addReadonlyGetter(this, 'enabled', !!response.enabled);
387-
utils.addReadonlyGetter(this, 'displayName', response.displayName);
495+
this.enabled = !!response.enabled;
496+
this.displayName = response.displayName;
388497
}
389498

390499
/** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */
@@ -555,12 +664,12 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
555664
AuthClientErrorCode.INTERNAL_ERROR,
556665
'INTERNAL ASSERT FAILED: Invalid OIDC configuration response');
557666
}
558-
utils.addReadonlyGetter(this, 'providerId', OIDCConfig.getProviderIdFromResourceName(response.name));
559-
utils.addReadonlyGetter(this, 'clientId', response.clientId);
560-
utils.addReadonlyGetter(this, 'issuer', response.issuer);
667+
this.providerId = OIDCConfig.getProviderIdFromResourceName(response.name);
668+
this.clientId = response.clientId;
669+
this.issuer = response.issuer;
561670
// When enabled is undefined, it takes its default value of false.
562-
utils.addReadonlyGetter(this, 'enabled', !!response.enabled);
563-
utils.addReadonlyGetter(this, 'displayName', response.displayName);
671+
this.enabled = !!response.enabled;
672+
this.displayName = response.displayName;
564673
}
565674

566675
/** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */

src/auth/auth.ts

Lines changed: 131 additions & 6 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 {FirebaseAuthRequestHandler} 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 {TenantManager} from './tenant-manager';
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<T extends AbstractAuthRequestHandler> {
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 {T} 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: FirebaseAuthRequestHandler,
108+
protected readonly authRequestHandler: T,
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<TenantAwareAuthRequestHandler> {
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
*/
605-
export class Auth extends BaseAuth implements FirebaseServiceInterface {
723+
export class Auth extends BaseAuth<AuthRequestHandler> implements FirebaseServiceInterface {
606724
public INTERNAL: AuthInternals = new AuthInternals();
725+
private readonly tenantManager_: TenantManager;
607726
private readonly app_: FirebaseApp;
608727

609728
/**
@@ -629,9 +748,10 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface {
629748
constructor(app: FirebaseApp) {
630749
super(
631750
Auth.getProjectId(app),
632-
new FirebaseAuthRequestHandler(app),
751+
new AuthRequestHandler(app),
633752
cryptoSignerFromApp(app));
634753
this.app_ = app;
754+
this.tenantManager_ = new TenantManager(app);
635755
}
636756

637757
/**
@@ -642,4 +762,9 @@ export class Auth extends BaseAuth implements FirebaseServiceInterface {
642762
get app(): FirebaseApp {
643763
return this.app_;
644764
}
765+
766+
/** @return The current Auth instance's tenant manager. */
767+
public tenantManager(): TenantManager {
768+
return this.tenantManager_;
769+
}
645770
}

0 commit comments

Comments
 (0)