From 2dd4cad593c939f902f8dc81fea36f6ea72e2a97 Mon Sep 17 00:00:00 2001 From: MathBunny Date: Mon, 6 Jul 2020 10:55:47 -0400 Subject: [PATCH 1/6] Move internally used types to dedicated classes --- src/auth/auth-api-request.ts | 16 +- src/auth/auth-config-internal.ts | 646 +++++++++++++++++++++ src/auth/auth-config.ts | 625 +------------------- src/auth/auth.ts | 14 +- src/auth/credential-internal.ts | 283 +++++++++ src/auth/credential.ts | 264 +-------- src/auth/identifier-internal.ts | 38 ++ src/auth/tenant.ts | 2 +- src/auth/token-generator-internal.ts | 343 +++++++++++ src/auth/token-generator.ts | 307 +--------- src/auth/token-verifier-internal.ts | 321 ++++++++++ src/auth/token-verifier.ts | 307 +--------- src/auth/user-import-builder-internal.ts | 511 ++++++++++++++++ src/auth/user-import-builder.ts | 497 +--------------- src/firebase-namespace.ts | 2 +- src/utils/index.ts | 2 +- test/resources/mocks.ts | 3 +- test/unit/auth/auth-api-request.spec.ts | 8 +- test/unit/auth/auth-config.spec.ts | 12 +- test/unit/auth/auth.spec.ts | 9 +- test/unit/auth/credential.spec.ts | 7 +- test/unit/auth/tenant.spec.ts | 2 +- test/unit/auth/token-generator.spec.ts | 5 +- test/unit/auth/token-verifier.spec.ts | 6 +- test/unit/auth/user-import-builder.spec.ts | 5 +- test/unit/firebase-app.spec.ts | 3 +- test/unit/firebase.spec.ts | 3 +- test/unit/utils/index.spec.ts | 2 +- 28 files changed, 2217 insertions(+), 2026 deletions(-) create mode 100644 src/auth/auth-config-internal.ts create mode 100644 src/auth/credential-internal.ts create mode 100644 src/auth/identifier-internal.ts create mode 100644 src/auth/token-generator-internal.ts create mode 100644 src/auth/token-verifier-internal.ts create mode 100644 src/auth/user-import-builder-internal.ts diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index b573ebf181..b87a00af2a 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -19,10 +19,11 @@ import * as validator from '../utils/validator'; import {deepCopy, deepExtend} from '../utils/deep-copy'; import { - UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, - isProviderIdentifier, UidIdentifier, EmailIdentifier, PhoneIdentifier, + UserIdentifier, UidIdentifier, EmailIdentifier, PhoneIdentifier, ProviderIdentifier, } from './identifier'; +import { isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, + isProviderIdentifier} from './identifier-internal'; import {FirebaseApp} from '../firebase-app'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { @@ -30,16 +31,19 @@ import { } from '../utils/api-request'; import {CreateRequest, UpdateRequest} from './user-record'; import { - UserImportBuilder, UserImportOptions, UserImportRecord, - UserImportResult, AuthFactorInfo, convertMultiFactorInfoToServerFormat, + UserImportOptions, UserImportRecord, UserImportResult } from './user-import-builder'; +import {UserImportBuilder, AuthFactorInfo, convertMultiFactorInfoToServerFormat} from './user-import-builder-internal'; import * as utils from '../utils/index'; import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder'; import { - SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, - OIDCConfigServerRequest, SAMLConfigServerRequest, AuthProviderConfig, + AuthProviderConfig, OIDCUpdateAuthProviderRequest, SAMLUpdateAuthProviderRequest, } from './auth-config'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, + OIDCConfigServerRequest, SAMLConfigServerRequest +} from './auth-config-internal'; import {Tenant, TenantOptions, TenantServerResponse} from './tenant'; diff --git a/src/auth/auth-config-internal.ts b/src/auth/auth-config-internal.ts new file mode 100644 index 0000000000..f6e7acf9c0 --- /dev/null +++ b/src/auth/auth-config-internal.ts @@ -0,0 +1,646 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from '../utils/validator'; +import {deepCopy} from '../utils/deep-copy'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import {SAMLAuthProviderConfig, SAMLAuthProviderRequest, OIDCAuthProviderConfig, OIDCUpdateAuthProviderRequest} from './auth-config'; + +/** The server side SAML configuration request interface. */ +export interface SAMLConfigServerRequest { + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; + [key: string]: any; +} + +/** The server side SAML configuration response interface. */ +export interface SAMLConfigServerResponse { + // Used when getting config. + // projects/${projectId}/inboundSamlConfigs/${providerId} + name?: string; + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; +} + +/** The server side OIDC configuration request interface. */ +export interface OIDCConfigServerRequest { + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; + [key: string]: any; +} + +/** The server side OIDC configuration response interface. */ +export interface OIDCConfigServerResponse { + // Used when getting config. + // projects/${projectId}/oauthIdpConfigs/${providerId} + name?: string; + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; +} + +/** The email provider configuration interface. */ +export interface EmailSignInProviderConfig { + enabled?: boolean; + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + + +/** The email provider configuration interface. */ +export interface EmailSignInProviderConfig { + enabled?: boolean; + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + +/** The server side email configuration request interface. */ +export interface EmailSignInConfigServerRequest { + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; +} + + +/** + * Defines the email sign-in config class used to convert client side EmailSignInConfig + * to a format that is understood by the Auth server. + */ +export class EmailSignInConfig implements EmailSignInProviderConfig { + public readonly enabled?: boolean; + public readonly passwordRequired?: boolean; + + /** + * Static method to convert a client side request to a EmailSignInConfigServerRequest. + * Throws an error if validation fails. + * + * @param {any} options The options object to convert to a server request. + * @return {EmailSignInConfigServerRequest} The resulting server request. + */ + public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { + const request: EmailSignInConfigServerRequest = {}; + EmailSignInConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'enabled')) { + request.allowPasswordSignup = options.enabled; + } + if (Object.prototype.hasOwnProperty.call(options, 'passwordRequired')) { + request.enableEmailLinkSignin = !options.passwordRequired; + } + return request; + } + + /** + * Validates the EmailSignInConfig options object. Throws an error on failure. + * + * @param {any} options The options object to validate. + */ + private static validate(options: EmailSignInProviderConfig): void { + // TODO: Validate the request. + const validKeys = { + enabled: true, + passwordRequired: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid EmailSignInConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.enabled" must be a boolean.', + ); + } + if (typeof options.passwordRequired !== 'undefined' && + !validator.isBoolean(options.passwordRequired)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.passwordRequired" must be a boolean.', + ); + } + } + + /** + * The EmailSignInConfig constructor. + * + * @param {any} response The server side response used to initialize the + * EmailSignInConfig object. + * @constructor + */ + constructor(response: {[key: string]: any}) { + if (typeof response.allowPasswordSignup === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); + } + this.enabled = response.allowPasswordSignup; + this.passwordRequired = !response.enableEmailLinkSignin; + } + + /** @return {object} The plain object representation of the email sign-in config. */ + public toJSON(): object { + return { + enabled: this.enabled, + passwordRequired: this.passwordRequired, + }; + } +} + + +/** + * Defines the SAMLConfig class used to convert a client side configuration to its + * server side representation. + */ +export class SAMLConfig implements SAMLAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly idpEntityId: string; + public readonly ssoURL: string; + public readonly x509Certificates: string[]; + public readonly rpEntityId: string; + public readonly callbackURL?: string; + public readonly enableRequestSigning?: boolean; + + /** + * Converts a client side request to a SAMLConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a SAMLConfig request, + * returns null. + * + * @param {SAMLAuthProviderRequest} options The options object to convert to a server request. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + * @return {?SAMLConfigServerRequest} The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: SAMLAuthProviderRequest, + ignoreMissingFields = false): SAMLConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: SAMLConfigServerRequest = {}; + // Validate options. + SAMLConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + // IdP config. + if (options.idpEntityId || options.ssoURL || options.x509Certificates) { + request.idpConfig = { + idpEntityId: options.idpEntityId, + ssoUrl: options.ssoURL, + signRequest: options.enableRequestSigning, + idpCertificates: typeof options.x509Certificates === 'undefined' ? undefined : [], + }; + if (options.x509Certificates) { + for (const cert of (options.x509Certificates || [])) { + request.idpConfig!.idpCertificates!.push({x509Certificate: cert}); + } + } + } + // RP config. + if (options.callbackURL || options.rpEntityId) { + request.spConfig = { + spEntityId: options.rpEntityId, + callbackUri: options.callbackURL, + }; + } + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name. + * @return {?string} The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/inboundSamlConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/inboundSamlConfigs\/(saml\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param {any} providerId The provider ID to check. + * @return {boolean} Whether the provider ID corresponds to a SAML provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0; + } + + /** + * Validates the SAMLConfig options object. Throws an error on failure. + * + * @param {SAMLAuthProviderRequest} options The options object to validate. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + */ + public static validate(options: SAMLAuthProviderRequest, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + idpEntityId: true, + ssoURL: true, + x509Certificates: true, + rpEntityId: true, + callbackURL: true, + enableRequestSigning: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SAML config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('saml.') !== 0) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + } else if (!ignoreMissingFields) { + // providerId is required and not provided correctly. + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && + !validator.isNonEmptyString(options.idpEntityId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && + !validator.isURL(options.ssoURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && + !validator.isNonEmptyString(options.rpEntityId)) { + throw new FirebaseAuthError( + !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && + !validator.isURL(options.callbackURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && + !validator.isArray(options.x509Certificates)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + (options.x509Certificates || []).forEach((cert: string) => { + if (!validator.isNonEmptyString(cert)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + }); + if (typeof options.enableRequestSigning !== 'undefined' && + !validator.isBoolean(options.enableRequestSigning)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.displayName" must be a valid string.', + ); + } + } + + /** + * The SAMLConfig constructor. + * + * @param {any} response The server side response used to initialize the SAMLConfig object. + * @constructor + */ + constructor(response: SAMLConfigServerResponse) { + if (!response || + !response.idpConfig || + !response.idpConfig.idpEntityId || + !response.idpConfig.ssoUrl || + !response.spConfig || + !response.spConfig.spEntityId || + !response.name || + !(validator.isString(response.name) && + SAMLConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + + const providerId = SAMLConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + // RP config. + this.rpEntityId = response.spConfig.spEntityId; + this.callbackURL = response.spConfig.callbackUri; + // IdP config. + this.idpEntityId = response.idpConfig.idpEntityId; + this.ssoURL = response.idpConfig.ssoUrl; + this.enableRequestSigning = !!response.idpConfig.signRequest; + const x509Certificates: string[] = []; + for (const cert of (response.idpConfig.idpCertificates || [])) { + if (cert.x509Certificate) { + x509Certificates.push(cert.x509Certificate); + } + } + this.x509Certificates = x509Certificates; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + } + + /** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */ + public toJSON(): SAMLAuthProviderConfig { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + idpEntityId: this.idpEntityId, + ssoURL: this.ssoURL, + x509Certificates: deepCopy(this.x509Certificates), + rpEntityId: this.rpEntityId, + callbackURL: this.callbackURL, + enableRequestSigning: this.enableRequestSigning, + }; + } +} + +/** The generic request interface for updating/creating an OIDC Auth provider. */ +export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest { + providerId?: string; +} + +/** + * Defines the OIDCConfig class used to convert a client side configuration to its + * server side representation. + */ +export class OIDCConfig implements OIDCAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly issuer: string; + public readonly clientId: string; + + /** + * Converts a client side request to a OIDCConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a OIDCConfig request, + * returns null. + * + * @param {OIDCAuthProviderRequest} options The options object to convert to a server request. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + * @return {?OIDCConfigServerRequest} The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: OIDCAuthProviderRequest, + ignoreMissingFields = false): OIDCConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: OIDCConfigServerRequest = {}; + // Validate options. + OIDCConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + request.issuer = options.issuer; + request.clientId = options.clientId; + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name + * @return {?string} The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/oauthIdpConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/oauthIdpConfigs\/(oidc\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param {any} providerId The provider ID to check. + * @return {boolean} Whether the provider ID corresponds to an OIDC provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0; + } + + /** + * Validates the OIDCConfig options object. Throws an error on failure. + * + * @param {OIDCAuthProviderRequest} options The options object to validate. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + */ + public static validate(options: OIDCAuthProviderRequest, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + clientId: true, + issuer: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid OIDC config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('oidc.') !== 0) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + } else if (!ignoreMissingFields) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && + !validator.isNonEmptyString(options.clientId)) { + throw new FirebaseAuthError( + !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, + '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && + !validator.isURL(options.issuer)) { + throw new FirebaseAuthError( + !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.displayName" must be a valid string.', + ); + } + } + + /** + * The OIDCConfig constructor. + * + * @param {any} response The server side response used to initialize the OIDCConfig object. + * @constructor + */ + constructor(response: OIDCConfigServerResponse) { + if (!response || + !response.issuer || + !response.clientId || + !response.name || + !(validator.isString(response.name) && + OIDCConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); + } + + const providerId = OIDCConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + this.clientId = response.clientId; + this.issuer = response.issuer; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + } + + /** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */ + public toJSON(): OIDCAuthProviderConfig { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + issuer: this.issuer, + clientId: this.clientId, + }; + } +} diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 95527eb1a7..559bf64bf4 100755 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import * as validator from '../utils/validator'; -import {deepCopy} from '../utils/deep-copy'; -import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; - /** The filter interface used for listing provider configurations. */ export interface AuthProviderConfigFilter { @@ -49,66 +45,6 @@ export interface SAMLAuthProviderConfig extends AuthProviderConfig { enableRequestSigning?: boolean; } -/** The server side SAML configuration request interface. */ -export interface SAMLConfigServerRequest { - idpConfig?: { - idpEntityId?: string; - ssoUrl?: string; - idpCertificates?: Array<{ - x509Certificate: string; - }>; - signRequest?: boolean; - }; - spConfig?: { - spEntityId?: string; - callbackUri?: string; - }; - displayName?: string; - enabled?: boolean; - [key: string]: any; -} - -/** The server side SAML configuration response interface. */ -export interface SAMLConfigServerResponse { - // Used when getting config. - // projects/${projectId}/inboundSamlConfigs/${providerId} - name?: string; - idpConfig?: { - idpEntityId?: string; - ssoUrl?: string; - idpCertificates?: Array<{ - x509Certificate: string; - }>; - signRequest?: boolean; - }; - spConfig?: { - spEntityId?: string; - callbackUri?: string; - }; - displayName?: string; - enabled?: boolean; -} - -/** The server side OIDC configuration request interface. */ -export interface OIDCConfigServerRequest { - clientId?: string; - issuer?: string; - displayName?: string; - enabled?: boolean; - [key: string]: any; -} - -/** The server side OIDC configuration response interface. */ -export interface OIDCConfigServerResponse { - // Used when getting config. - // projects/${projectId}/oauthIdpConfigs/${providerId} - name?: string; - clientId?: string; - issuer?: string; - displayName?: string; - enabled?: boolean; -} - /** The public API response interface for listing provider configs. */ export interface ListProviderConfigResults { providerConfigs: AuthProviderConfig[]; @@ -140,564 +76,5 @@ export interface OIDCUpdateAuthProviderRequest { displayName?: string; } -/** The generic request interface for updating/creating an OIDC Auth provider. */ -export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest { - providerId?: string; -} - /** The public API request interface for updating a generic Auth provider. */ -export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; - -/** The email provider configuration interface. */ -export interface EmailSignInProviderConfig { - enabled?: boolean; - passwordRequired?: boolean; // In the backend API, default is true if not provided -} - -/** The server side email configuration request interface. */ -export interface EmailSignInConfigServerRequest { - allowPasswordSignup?: boolean; - enableEmailLinkSignin?: boolean; -} - - -/** - * Defines the email sign-in config class used to convert client side EmailSignInConfig - * to a format that is understood by the Auth server. - */ -export class EmailSignInConfig implements EmailSignInProviderConfig { - public readonly enabled?: boolean; - public readonly passwordRequired?: boolean; - - /** - * Static method to convert a client side request to a EmailSignInConfigServerRequest. - * Throws an error if validation fails. - * - * @param {any} options The options object to convert to a server request. - * @return {EmailSignInConfigServerRequest} The resulting server request. - */ - public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { - const request: EmailSignInConfigServerRequest = {}; - EmailSignInConfig.validate(options); - if (Object.prototype.hasOwnProperty.call(options, 'enabled')) { - request.allowPasswordSignup = options.enabled; - } - if (Object.prototype.hasOwnProperty.call(options, 'passwordRequired')) { - request.enableEmailLinkSignin = !options.passwordRequired; - } - return request; - } - - /** - * Validates the EmailSignInConfig options object. Throws an error on failure. - * - * @param {any} options The options object to validate. - */ - private static validate(options: EmailSignInProviderConfig): void { - // TODO: Validate the request. - const validKeys = { - enabled: true, - passwordRequired: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig" must be a non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${key}" is not a valid EmailSignInConfig parameter.`, - ); - } - } - // Validate content. - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig.enabled" must be a boolean.', - ); - } - if (typeof options.passwordRequired !== 'undefined' && - !validator.isBoolean(options.passwordRequired)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig.passwordRequired" must be a boolean.', - ); - } - } - - /** - * The EmailSignInConfig constructor. - * - * @param {any} response The server side response used to initialize the - * EmailSignInConfig object. - * @constructor - */ - constructor(response: {[key: string]: any}) { - if (typeof response.allowPasswordSignup === 'undefined') { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); - } - this.enabled = response.allowPasswordSignup; - this.passwordRequired = !response.enableEmailLinkSignin; - } - - /** @return {object} The plain object representation of the email sign-in config. */ - public toJSON(): object { - return { - enabled: this.enabled, - passwordRequired: this.passwordRequired, - }; - } -} - - -/** - * Defines the SAMLConfig class used to convert a client side configuration to its - * server side representation. - */ -export class SAMLConfig implements SAMLAuthProviderConfig { - public readonly enabled: boolean; - public readonly displayName?: string; - public readonly providerId: string; - public readonly idpEntityId: string; - public readonly ssoURL: string; - public readonly x509Certificates: string[]; - public readonly rpEntityId: string; - public readonly callbackURL?: string; - public readonly enableRequestSigning?: boolean; - - /** - * Converts a client side request to a SAMLConfigServerRequest which is the format - * accepted by the backend server. - * Throws an error if validation fails. If the request is not a SAMLConfig request, - * returns null. - * - * @param {SAMLAuthProviderRequest} options The options object to convert to a server request. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - * @return {?SAMLConfigServerRequest} The resulting server request or null if not valid. - */ - public static buildServerRequest( - options: SAMLAuthProviderRequest, - ignoreMissingFields = false): SAMLConfigServerRequest | null { - const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); - if (!makeRequest) { - return null; - } - const request: SAMLConfigServerRequest = {}; - // Validate options. - SAMLConfig.validate(options, ignoreMissingFields); - request.enabled = options.enabled; - request.displayName = options.displayName; - // IdP config. - if (options.idpEntityId || options.ssoURL || options.x509Certificates) { - request.idpConfig = { - idpEntityId: options.idpEntityId, - ssoUrl: options.ssoURL, - signRequest: options.enableRequestSigning, - idpCertificates: typeof options.x509Certificates === 'undefined' ? undefined : [], - }; - if (options.x509Certificates) { - for (const cert of (options.x509Certificates || [])) { - request.idpConfig!.idpCertificates!.push({x509Certificate: cert}); - } - } - } - // RP config. - if (options.callbackURL || options.rpEntityId) { - request.spConfig = { - spEntityId: options.rpEntityId, - callbackUri: options.callbackURL, - }; - } - return request; - } - - /** - * Returns the provider ID corresponding to the resource name if available. - * - * @param {string} resourceName The server side resource name. - * @return {?string} The provider ID corresponding to the resource, null otherwise. - */ - public static getProviderIdFromResourceName(resourceName: string): string | null { - // name is of form projects/project1/inboundSamlConfigs/providerId1 - const matchProviderRes = resourceName.match(/\/inboundSamlConfigs\/(saml\..*)$/); - if (!matchProviderRes || matchProviderRes.length < 2) { - return null; - } - return matchProviderRes[1]; - } - - /** - * @param {any} providerId The provider ID to check. - * @return {boolean} Whether the provider ID corresponds to a SAML provider. - */ - public static isProviderId(providerId: any): providerId is string { - return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0; - } - - /** - * Validates the SAMLConfig options object. Throws an error on failure. - * - * @param {SAMLAuthProviderRequest} options The options object to validate. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - */ - public static validate(options: SAMLAuthProviderRequest, ignoreMissingFields = false): void { - const validKeys = { - enabled: true, - displayName: true, - providerId: true, - idpEntityId: true, - ssoURL: true, - x509Certificates: true, - rpEntityId: true, - callbackURL: true, - enableRequestSigning: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig" must be a valid non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - `"${key}" is not a valid SAML config parameter.`, - ); - } - } - // Required fields. - if (validator.isNonEmptyString(options.providerId)) { - if (options.providerId.indexOf('saml.') !== 0) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PROVIDER_ID, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - } else if (!ignoreMissingFields) { - // providerId is required and not provided correctly. - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && - !validator.isNonEmptyString(options.idpEntityId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && - !validator.isURL(options.ssoURL)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', - ); - } - if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && - !validator.isNonEmptyString(options.rpEntityId)) { - throw new FirebaseAuthError( - !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && - !validator.isURL(options.callbackURL)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', - ); - } - if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && - !validator.isArray(options.x509Certificates)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - (options.x509Certificates || []).forEach((cert: string) => { - if (!validator.isNonEmptyString(cert)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - }); - if (typeof options.enableRequestSigning !== 'undefined' && - !validator.isBoolean(options.enableRequestSigning)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', - ); - } - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.enabled" must be a boolean.', - ); - } - if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.displayName" must be a valid string.', - ); - } - } - - /** - * The SAMLConfig constructor. - * - * @param {any} response The server side response used to initialize the SAMLConfig object. - * @constructor - */ - constructor(response: SAMLConfigServerResponse) { - if (!response || - !response.idpConfig || - !response.idpConfig.idpEntityId || - !response.idpConfig.ssoUrl || - !response.spConfig || - !response.spConfig.spEntityId || - !response.name || - !(validator.isString(response.name) && - SAMLConfig.getProviderIdFromResourceName(response.name))) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - - const providerId = SAMLConfig.getProviderIdFromResourceName(response.name); - if (!providerId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - this.providerId = providerId; - - // RP config. - this.rpEntityId = response.spConfig.spEntityId; - this.callbackURL = response.spConfig.callbackUri; - // IdP config. - this.idpEntityId = response.idpConfig.idpEntityId; - this.ssoURL = response.idpConfig.ssoUrl; - this.enableRequestSigning = !!response.idpConfig.signRequest; - const x509Certificates: string[] = []; - for (const cert of (response.idpConfig.idpCertificates || [])) { - if (cert.x509Certificate) { - x509Certificates.push(cert.x509Certificate); - } - } - this.x509Certificates = x509Certificates; - // When enabled is undefined, it takes its default value of false. - this.enabled = !!response.enabled; - this.displayName = response.displayName; - } - - /** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */ - public toJSON(): SAMLAuthProviderConfig { - return { - enabled: this.enabled, - displayName: this.displayName, - providerId: this.providerId, - idpEntityId: this.idpEntityId, - ssoURL: this.ssoURL, - x509Certificates: deepCopy(this.x509Certificates), - rpEntityId: this.rpEntityId, - callbackURL: this.callbackURL, - enableRequestSigning: this.enableRequestSigning, - }; - } -} - -/** - * Defines the OIDCConfig class used to convert a client side configuration to its - * server side representation. - */ -export class OIDCConfig implements OIDCAuthProviderConfig { - public readonly enabled: boolean; - public readonly displayName?: string; - public readonly providerId: string; - public readonly issuer: string; - public readonly clientId: string; - - /** - * Converts a client side request to a OIDCConfigServerRequest which is the format - * accepted by the backend server. - * Throws an error if validation fails. If the request is not a OIDCConfig request, - * returns null. - * - * @param {OIDCAuthProviderRequest} options The options object to convert to a server request. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - * @return {?OIDCConfigServerRequest} The resulting server request or null if not valid. - */ - public static buildServerRequest( - options: OIDCAuthProviderRequest, - ignoreMissingFields = false): OIDCConfigServerRequest | null { - const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); - if (!makeRequest) { - return null; - } - const request: OIDCConfigServerRequest = {}; - // Validate options. - OIDCConfig.validate(options, ignoreMissingFields); - request.enabled = options.enabled; - request.displayName = options.displayName; - request.issuer = options.issuer; - request.clientId = options.clientId; - return request; - } - - /** - * Returns the provider ID corresponding to the resource name if available. - * - * @param {string} resourceName The server side resource name - * @return {?string} The provider ID corresponding to the resource, null otherwise. - */ - public static getProviderIdFromResourceName(resourceName: string): string | null { - // name is of form projects/project1/oauthIdpConfigs/providerId1 - const matchProviderRes = resourceName.match(/\/oauthIdpConfigs\/(oidc\..*)$/); - if (!matchProviderRes || matchProviderRes.length < 2) { - return null; - } - return matchProviderRes[1]; - } - - /** - * @param {any} providerId The provider ID to check. - * @return {boolean} Whether the provider ID corresponds to an OIDC provider. - */ - public static isProviderId(providerId: any): providerId is string { - return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0; - } - - /** - * Validates the OIDCConfig options object. Throws an error on failure. - * - * @param {OIDCAuthProviderRequest} options The options object to validate. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - */ - public static validate(options: OIDCAuthProviderRequest, ignoreMissingFields = false): void { - const validKeys = { - enabled: true, - displayName: true, - providerId: true, - clientId: true, - issuer: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig" must be a valid non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - `"${key}" is not a valid OIDC config parameter.`, - ); - } - } - // Required fields. - if (validator.isNonEmptyString(options.providerId)) { - if (options.providerId.indexOf('oidc.') !== 0) { - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', - ); - } - } else if (!ignoreMissingFields) { - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', - ); - } - if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && - !validator.isNonEmptyString(options.clientId)) { - throw new FirebaseAuthError( - !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, - '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && - !validator.isURL(options.issuer)) { - throw new FirebaseAuthError( - !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', - ); - } - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.enabled" must be a boolean.', - ); - } - if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.displayName" must be a valid string.', - ); - } - } - - /** - * The OIDCConfig constructor. - * - * @param {any} response The server side response used to initialize the OIDCConfig object. - * @constructor - */ - constructor(response: OIDCConfigServerResponse) { - if (!response || - !response.issuer || - !response.clientId || - !response.name || - !(validator.isString(response.name) && - OIDCConfig.getProviderIdFromResourceName(response.name))) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); - } - - const providerId = OIDCConfig.getProviderIdFromResourceName(response.name); - if (!providerId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - this.providerId = providerId; - - this.clientId = response.clientId; - this.issuer = response.issuer; - // When enabled is undefined, it takes its default value of false. - this.enabled = !!response.enabled; - this.displayName = response.displayName; - } - - /** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */ - public toJSON(): OIDCAuthProviderConfig { - return { - enabled: this.enabled, - displayName: this.displayName, - providerId: this.providerId, - issuer: this.issuer, - clientId: this.clientId, - }; - } -} +export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; \ No newline at end of file diff --git a/src/auth/auth.ts b/src/auth/auth.ts index df52c17754..a2c5a0d62c 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -15,13 +15,14 @@ */ import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; +import { UserIdentifier } from './identifier'; import { - UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, -} from './identifier'; + isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, +} from './identifier-internal'; import {FirebaseApp} from '../firebase-app'; import * as admin from '../'; // import {FirebaseNamespace} from '../firebase-namespace'; -import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator'; +import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator-internal'; import { AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, } from './auth-api-request'; @@ -33,12 +34,15 @@ import { import * as utils from '../utils/index'; import * as validator from '../utils/validator'; -import { FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; +import { createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; +import { FirebaseTokenVerifier } from './token-verifier-internal'; import {ActionCodeSettings} from './action-code-settings-builder'; import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, - SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, +} from './auth-config-internal'; import {TenantManager} from './tenant-manager'; const Auth_: {[name: string]: Auth} = {}; diff --git a/src/auth/credential-internal.ts b/src/auth/credential-internal.ts new file mode 100644 index 0000000000..d4a7c3fd4a --- /dev/null +++ b/src/auth/credential-internal.ts @@ -0,0 +1,283 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs = require('fs'); +import {Credential, GoogleOAuthAccessToken} from './credential'; +import {Agent} from 'http'; +import {HttpClient, HttpRequestConfig, HttpError, HttpResponse} from '../utils/api-request'; +import {AppErrorCodes, FirebaseAppError} from '../utils/error'; +import * as util from '../utils/validator'; + +const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; +const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; +const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; + +// NOTE: the Google Metadata Service uses HTTP over a vlan +const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; +const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; +const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; + +const ONE_HOUR_IN_SECONDS = 60 * 60; +const JWT_ALGORITHM = 'RS256'; + + +/** + * Implementation of Credential that uses a service account. + */ +export class ServiceAccountCredential implements Credential { + public readonly projectId: string; + public readonly privateKey: string; + public readonly clientEmail: string; + + private readonly httpClient: HttpClient; + + /** + * Creates a new ServiceAccountCredential from the given parameters. + * + * @param serviceAccountPathOrObject Service account json object or path to a service account json file. + * @param httpAgent Optional http.Agent to use when calling the remote token server. + * @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the + * environment, as opposed to being explicitly specified by the developer. + * + * @constructor + */ + constructor( + serviceAccountPathOrObject: string | object, + private readonly httpAgent?: Agent, + readonly implicit: boolean = false) { + + const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ? + ServiceAccount.fromPath(serviceAccountPathOrObject) + : new ServiceAccount(serviceAccountPathOrObject); + this.projectId = serviceAccount.projectId; + this.privateKey = serviceAccount.privateKey; + this.clientEmail = serviceAccount.clientEmail; + this.httpClient = new HttpClient(); + } + + public getAccessToken(): Promise { + const token = this.createAuthJwt_(); + const postData = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3A' + + 'grant-type%3Ajwt-bearer&assertion=' + token; + const request: HttpRequestConfig = { + method: 'POST', + url: `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: postData, + httpAgent: this.httpAgent, + }; + return requestAccessToken(this.httpClient, request); + } + + private createAuthJwt_(): string { + const claims = { + scope: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/firebase.database', + 'https://www.googleapis.com/auth/firebase.messaging', + 'https://www.googleapis.com/auth/identitytoolkit', + 'https://www.googleapis.com/auth/userinfo.email', + ].join(' '), + }; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const jwt = require('jsonwebtoken'); + // This method is actually synchronous so we can capture and return the buffer. + return jwt.sign(claims, this.privateKey, { + audience: GOOGLE_TOKEN_AUDIENCE, + expiresIn: ONE_HOUR_IN_SECONDS, + issuer: this.clientEmail, + algorithm: JWT_ALGORITHM, + }); + } +} + + + +/** + * Implementation of Credential that gets access tokens from the metadata service available + * in the Google Cloud Platform. This authenticates the process as the default service account + * of an App Engine instance or Google Compute Engine machine. + */ +export class ComputeEngineCredential implements Credential { + + private readonly httpClient = new HttpClient(); + private readonly httpAgent?: Agent; + private projectId?: string; + + constructor(httpAgent?: Agent) { + this.httpAgent = httpAgent; + } + + public getAccessToken(): Promise { + const request = this.buildRequest(GOOGLE_METADATA_SERVICE_TOKEN_PATH); + return requestAccessToken(this.httpClient, request); + } + + public getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); + } + + const request = this.buildRequest(GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH); + return this.httpClient.send(request) + .then((resp) => { + this.projectId = resp.text!; + return this.projectId; + }) + .catch((err) => { + const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + `Failed to determine project ID: ${detail}`); + }); + } + + private buildRequest(urlPath: string): HttpRequestConfig { + return { + method: 'GET', + url: `http://${GOOGLE_METADATA_SERVICE_HOST}${urlPath}`, + headers: { + 'Metadata-Flavor': 'Google', + }, + httpAgent: this.httpAgent, + }; + } +} + + +/** + * A struct containing the properties necessary to use service account JSON credentials. + */ +class ServiceAccount { + + public readonly projectId: string; + public readonly privateKey: string; + public readonly clientEmail: string; + + public static fromPath(filePath: string): ServiceAccount { + try { + return new ServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch (error) { + // Throw a nicely formed error message if the file contents cannot be parsed + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Failed to parse service account json file: ' + error, + ); + } + } + + constructor(json: object) { + if (!util.isNonNullObject(json)) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Service account must be an object.', + ); + } + + copyAttr(this, json, 'projectId', 'project_id'); + copyAttr(this, json, 'privateKey', 'private_key'); + copyAttr(this, json, 'clientEmail', 'client_email'); + + let errorMessage; + if (!util.isNonEmptyString(this.projectId)) { + errorMessage = 'Service account object must contain a string "project_id" property.'; + } else if (!util.isNonEmptyString(this.privateKey)) { + errorMessage = 'Service account object must contain a string "private_key" property.'; + } else if (!util.isNonEmptyString(this.clientEmail)) { + errorMessage = 'Service account object must contain a string "client_email" property.'; + } + + if (typeof errorMessage !== 'undefined') { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const forge = require('node-forge'); + try { + forge.pki.privateKeyFromPem(this.privateKey); + } catch (error) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Failed to parse private key: ' + error); + } + } +} + +/** + * Obtain a new OAuth2 token by making a remote service call. + */ +export function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Promise { + return client.send(request).then((resp) => { + const json = resp.data; + if (!json.access_token || !json.expires_in) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + `Unexpected response while fetching access token: ${ JSON.stringify(json) }`, + ); + } + return json; + }).catch((err) => { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err)); + }); +} + +/** + * Constructs a human-readable error message from the given Error. + */ +function getErrorMessage(err: Error): string { + const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + return `Error fetching access token: ${detail}`; +} + + +/** + * Extracts details from the given HTTP error response, and returns a human-readable description. If + * the response is JSON-formatted, looks up the error and error_description fields sent by the + * Google Auth servers. Otherwise returns the entire response payload as the error detail. + */ +function getDetailFromResponse(response: HttpResponse): string { + if (response.isJson() && response.data.error) { + const json = response.data; + let detail = json.error; + if (json.error_description) { + detail += ' (' + json.error_description + ')'; + } + return detail; + } + return response.text || 'Missing error payload'; +} + + +/** + * Copies the specified property from one object to another. + * + * If no property exists by the given "key", looks for a property identified by "alt", and copies it instead. + * This can be used to implement behaviors such as "copy property myKey or my_key". + * + * @param to Target object to copy the property into. + * @param from Source object to copy the property from. + * @param key Name of the property to copy. + * @param alt Alternative name of the property to copy. + */ +export function copyAttr(to: {[key: string]: any}, from: {[key: string]: any}, key: string, alt: string): void { + const tmp = from[key] || from[alt]; + if (typeof tmp !== 'undefined') { + to[key] = tmp; + } +} \ No newline at end of file diff --git a/src/auth/credential.ts b/src/auth/credential.ts index 43de4d3ed3..f3c60de618 100644 --- a/src/auth/credential.ts +++ b/src/auth/credential.ts @@ -1,5 +1,5 @@ /*! - * Copyright 2017 Google Inc. + * Copyright 2020 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,24 +15,16 @@ */ // Use untyped import syntax for Node built-ins -import fs = require('fs'); import os = require('os'); +import fs = require('fs'); import path = require('path'); import {AppErrorCodes, FirebaseAppError} from '../utils/error'; -import {HttpClient, HttpRequestConfig, HttpError, HttpResponse} from '../utils/api-request'; +import {HttpClient, HttpRequestConfig} from '../utils/api-request'; +import {ServiceAccountCredential, ComputeEngineCredential, requestAccessToken, copyAttr} from './credential-internal'; import {Agent} from 'http'; import * as util from '../utils/validator'; -const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; -const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; -const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; - -// NOTE: the Google Metadata Service uses HTTP over a vlan -const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; -const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; -const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; - const configDir = (() => { // Windows has a dedicated low-rights location for apps at ~/Application Data const sys = os.platform(); @@ -50,9 +42,6 @@ const GCLOUD_CREDENTIAL_PATH = configDir && path.resolve(configDir, GCLOUD_CREDE const REFRESH_TOKEN_HOST = 'www.googleapis.com'; const REFRESH_TOKEN_PATH = '/oauth2/v4/token'; -const ONE_HOUR_IN_SECONDS = 60 * 60; -const JWT_ALGORITHM = 'RS256'; - /** * Interface for Google OAuth 2.0 access tokens. */ @@ -70,189 +59,6 @@ export interface Credential { getAccessToken(): Promise; } -/** - * Implementation of Credential that uses a service account. - */ -export class ServiceAccountCredential implements Credential { - - public readonly projectId: string; - public readonly privateKey: string; - public readonly clientEmail: string; - - private readonly httpClient: HttpClient; - - /** - * Creates a new ServiceAccountCredential from the given parameters. - * - * @param serviceAccountPathOrObject Service account json object or path to a service account json file. - * @param httpAgent Optional http.Agent to use when calling the remote token server. - * @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the - * environment, as opposed to being explicitly specified by the developer. - * - * @constructor - */ - constructor( - serviceAccountPathOrObject: string | object, - private readonly httpAgent?: Agent, - readonly implicit: boolean = false) { - - const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ? - ServiceAccount.fromPath(serviceAccountPathOrObject) - : new ServiceAccount(serviceAccountPathOrObject); - this.projectId = serviceAccount.projectId; - this.privateKey = serviceAccount.privateKey; - this.clientEmail = serviceAccount.clientEmail; - this.httpClient = new HttpClient(); - } - - public getAccessToken(): Promise { - const token = this.createAuthJwt_(); - const postData = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3A' + - 'grant-type%3Ajwt-bearer&assertion=' + token; - const request: HttpRequestConfig = { - method: 'POST', - url: `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - data: postData, - httpAgent: this.httpAgent, - }; - return requestAccessToken(this.httpClient, request); - } - - private createAuthJwt_(): string { - const claims = { - scope: [ - 'https://www.googleapis.com/auth/cloud-platform', - 'https://www.googleapis.com/auth/firebase.database', - 'https://www.googleapis.com/auth/firebase.messaging', - 'https://www.googleapis.com/auth/identitytoolkit', - 'https://www.googleapis.com/auth/userinfo.email', - ].join(' '), - }; - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const jwt = require('jsonwebtoken'); - // This method is actually synchronous so we can capture and return the buffer. - return jwt.sign(claims, this.privateKey, { - audience: GOOGLE_TOKEN_AUDIENCE, - expiresIn: ONE_HOUR_IN_SECONDS, - issuer: this.clientEmail, - algorithm: JWT_ALGORITHM, - }); - } -} - -/** - * A struct containing the properties necessary to use service account JSON credentials. - */ -class ServiceAccount { - - public readonly projectId: string; - public readonly privateKey: string; - public readonly clientEmail: string; - - public static fromPath(filePath: string): ServiceAccount { - try { - return new ServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8'))); - } catch (error) { - // Throw a nicely formed error message if the file contents cannot be parsed - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Failed to parse service account json file: ' + error, - ); - } - } - - constructor(json: object) { - if (!util.isNonNullObject(json)) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Service account must be an object.', - ); - } - - copyAttr(this, json, 'projectId', 'project_id'); - copyAttr(this, json, 'privateKey', 'private_key'); - copyAttr(this, json, 'clientEmail', 'client_email'); - - let errorMessage; - if (!util.isNonEmptyString(this.projectId)) { - errorMessage = 'Service account object must contain a string "project_id" property.'; - } else if (!util.isNonEmptyString(this.privateKey)) { - errorMessage = 'Service account object must contain a string "private_key" property.'; - } else if (!util.isNonEmptyString(this.clientEmail)) { - errorMessage = 'Service account object must contain a string "client_email" property.'; - } - - if (typeof errorMessage !== 'undefined') { - throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); - } - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const forge = require('node-forge'); - try { - forge.pki.privateKeyFromPem(this.privateKey); - } catch (error) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Failed to parse private key: ' + error); - } - } -} - -/** - * Implementation of Credential that gets access tokens from the metadata service available - * in the Google Cloud Platform. This authenticates the process as the default service account - * of an App Engine instance or Google Compute Engine machine. - */ -export class ComputeEngineCredential implements Credential { - - private readonly httpClient = new HttpClient(); - private readonly httpAgent?: Agent; - private projectId?: string; - - constructor(httpAgent?: Agent) { - this.httpAgent = httpAgent; - } - - public getAccessToken(): Promise { - const request = this.buildRequest(GOOGLE_METADATA_SERVICE_TOKEN_PATH); - return requestAccessToken(this.httpClient, request); - } - - public getProjectId(): Promise { - if (this.projectId) { - return Promise.resolve(this.projectId); - } - - const request = this.buildRequest(GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH); - return this.httpClient.send(request) - .then((resp) => { - this.projectId = resp.text!; - return this.projectId; - }) - .catch((err) => { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - `Failed to determine project ID: ${detail}`); - }); - } - - private buildRequest(urlPath: string): HttpRequestConfig { - return { - method: 'GET', - url: `http://${GOOGLE_METADATA_SERVICE_HOST}${urlPath}`, - headers: { - 'Metadata-Flavor': 'Google', - }, - httpAgent: this.httpAgent, - }; - } -} - /** * Implementation of Credential that gets access tokens from refresh tokens. */ @@ -377,66 +183,6 @@ export function isApplicationDefault(credential?: Credential): boolean { (credential instanceof RefreshTokenCredential && credential.implicit); } -/** - * Copies the specified property from one object to another. - * - * If no property exists by the given "key", looks for a property identified by "alt", and copies it instead. - * This can be used to implement behaviors such as "copy property myKey or my_key". - * - * @param to Target object to copy the property into. - * @param from Source object to copy the property from. - * @param key Name of the property to copy. - * @param alt Alternative name of the property to copy. - */ -function copyAttr(to: {[key: string]: any}, from: {[key: string]: any}, key: string, alt: string): void { - const tmp = from[key] || from[alt]; - if (typeof tmp !== 'undefined') { - to[key] = tmp; - } -} - -/** - * Obtain a new OAuth2 token by making a remote service call. - */ -function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Promise { - return client.send(request).then((resp) => { - const json = resp.data; - if (!json.access_token || !json.expires_in) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - `Unexpected response while fetching access token: ${ JSON.stringify(json) }`, - ); - } - return json; - }).catch((err) => { - throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err)); - }); -} - -/** - * Constructs a human-readable error message from the given Error. - */ -function getErrorMessage(err: Error): string { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; - return `Error fetching access token: ${detail}`; -} - -/** - * Extracts details from the given HTTP error response, and returns a human-readable description. If - * the response is JSON-formatted, looks up the error and error_description fields sent by the - * Google Auth servers. Otherwise returns the entire response payload as the error detail. - */ -function getDetailFromResponse(response: HttpResponse): string { - if (response.isJson() && response.data.error) { - const json = response.data; - let detail = json.error; - if (json.error_description) { - detail += ' (' + json.error_description + ')'; - } - return detail; - } - return response.text || 'Missing error payload'; -} function credentialFromFile(filePath: string, httpAgent?: Agent): Credential { const credentialsFile = readCredentialFile(filePath); @@ -484,4 +230,4 @@ function readCredentialFile(filePath: string, ignoreMissing?: boolean): {[key: s 'Failed to parse contents of the credentials file as an object: ' + error, ); } -} +} \ No newline at end of file diff --git a/src/auth/identifier-internal.ts b/src/auth/identifier-internal.ts new file mode 100644 index 0000000000..8859305dde --- /dev/null +++ b/src/auth/identifier-internal.ts @@ -0,0 +1,38 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {UidIdentifier, UserIdentifier, EmailIdentifier, PhoneIdentifier, ProviderIdentifier} from './identifier'; + +/* User defined type guards. See + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards + */ + +export function isUidIdentifier(id: UserIdentifier): id is UidIdentifier { + return (id as UidIdentifier).uid !== undefined; +} + +export function isEmailIdentifier(id: UserIdentifier): id is EmailIdentifier { + return (id as EmailIdentifier).email !== undefined; +} + +export function isPhoneIdentifier(id: UserIdentifier): id is PhoneIdentifier { + return (id as PhoneIdentifier).phoneNumber !== undefined; +} + +export function isProviderIdentifier(id: ProviderIdentifier): id is ProviderIdentifier { + const pid = id as ProviderIdentifier; + return pid.providerId !== undefined && pid.providerUid !== undefined; +} \ No newline at end of file diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 0b2ed1dca9..4462c3cd0f 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -18,7 +18,7 @@ import * as validator from '../utils/validator'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, -} from './auth-config'; +} from './auth-config-internal'; /** The TenantOptions interface used for create/read/update tenant operations. */ export interface TenantOptions { diff --git a/src/auth/token-generator-internal.ts b/src/auth/token-generator-internal.ts new file mode 100644 index 0000000000..180b2216b4 --- /dev/null +++ b/src/auth/token-generator-internal.ts @@ -0,0 +1,343 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import { FirebaseApp } from '../firebase-app'; +import {ServiceAccountCredential} from './credential-internal'; +import {AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; +import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request'; + +import * as validator from '../utils/validator'; +import { toWebSafeBase64 } from '../utils'; +import { BLACKLISTED_CLAIMS } from './token-generator'; + + +const ALGORITHM_RS256 = 'RS256'; +const ONE_HOUR_IN_SECONDS = 60 * 60; + +/** + * CryptoSigner interface represents an object that can be used to sign JWTs. + */ +export interface CryptoSigner { + /** + * Cryptographically signs a buffer of data. + * + * @param {Buffer} buffer The data to be signed. + * @return {Promise} A promise that resolves with the raw bytes of a signature. + */ + sign(buffer: Buffer): Promise; + + /** + * Returns the ID of the service account used to sign tokens. + * + * @return {Promise} A promise that resolves with a service account ID. + */ + getAccountId(): Promise; +} + + +/** + * A CryptoSigner implementation that uses an explicitly specified service account private key to + * sign data. Performs all operations locally, and does not make any RPC calls. + */ +export class ServiceAccountSigner implements CryptoSigner { + + /** + * Creates a new CryptoSigner instance from the given service account credential. + * + * @param {ServiceAccountCredential} credential A service account credential. + */ + constructor(private readonly credential: ServiceAccountCredential) { + if (!credential) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', + ); + } + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires + const sign = crypto.createSign('RSA-SHA256'); + sign.update(buffer); + return Promise.resolve(sign.sign(this.credential.privateKey)); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + return Promise.resolve(this.credential.clientEmail); + } +} + +/** + * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without + * a service account ID, attempts to discover a service account ID by consulting the local Metadata + * service. This will succeed in managed environments like Google Cloud Functions and App Engine. + * + * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob + * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata + */ +export class IAMSigner implements CryptoSigner { + private readonly httpClient: AuthorizedHttpClient; + private serviceAccountId?: string; + + constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { + if (!httpClient) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', + ); + } + if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', + ); + } + this.httpClient = httpClient; + this.serviceAccountId = serviceAccountId; + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + return this.getAccountId().then((serviceAccount) => { + const request: HttpRequestConfig = { + method: 'POST', + url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, + data: {bytesToSign: buffer.toString('base64')}, + }; + return this.httpClient.send(request); + }).then((response: any) => { + // Response from IAM is base64 encoded. Decode it into a buffer and return. + return Buffer.from(response.data.signature, 'base64'); + }).catch((err) => { + if (err instanceof HttpError) { + const error = err.response.data; + if (validator.isNonNullObject(error) && error.error) { + const errorCode = error.error.status; + const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + + 'for more details on how to use and troubleshoot this feature.'; + const errorMsg = `${error.error.message}; ${description}`; + + throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error); + } + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Error returned from server: ' + error + '. Additionally, an ' + + 'internal error occurred while attempting to extract the ' + + 'errorcode from the error.', + ); + } + throw err; + }); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + if (validator.isNonEmptyString(this.serviceAccountId)) { + return Promise.resolve(this.serviceAccountId); + } + const request: HttpRequestConfig = { + method: 'GET', + url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', + headers: { + 'Metadata-Flavor': 'Google', + }, + }; + const client = new HttpClient(); + return client.send(request).then((response) => { + if (!response.text) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'HTTP Response missing payload', + ); + } + this.serviceAccountId = response.text; + return response.text; + }).catch((err) => { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + `Failed to determine service account. Make sure to initialize ` + + `the SDK with a service account credential. Alternatively specify a service ` + + `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, + ); + }); + } +} + +/** + * Create a new CryptoSigner instance for the given app. If the app has been initialized with a service + * account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner. + * + * @param {FirebaseApp} app A FirebaseApp instance. + * @return {CryptoSigner} A CryptoSigner instance. + */ +export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { + const credential = app.options.credential; + if (credential instanceof ServiceAccountCredential) { + return new ServiceAccountSigner(credential); + } + + return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); +} + +/** + * Class for generating different types of Firebase Auth tokens (JWTs). + */ +export class FirebaseTokenGenerator { + + private readonly signer: CryptoSigner; + + /** + * @param tenantId The tenant ID to use for the generated Firebase Auth + * Custom token. If absent, then no tenant ID claim will be set in the + * resulting JWT. + */ + constructor(signer: CryptoSigner, public readonly tenantId?: string) { + if (!validator.isNonNullObject(signer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', + ); + } + if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '`tenantId` argument must be a non-empty string.'); + } + this.signer = signer; + } + + /** + * Creates a new Firebase Auth Custom token. + * + * @param uid The user ID to use for the generated Firebase Auth Custom token. + * @param developerClaims Optional developer claims to include in the generated Firebase + * Auth Custom token. + * @return A Promise fulfilled with a Firebase Auth Custom token signed with a + * service account key and containing the provided payload. + */ + public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise { + let errorMessage: string | undefined; + if (!validator.isNonEmptyString(uid)) { + errorMessage = '`uid` argument must be a non-empty string uid.'; + } else if (uid.length > 128) { + errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; + } else if (!this.isDeveloperClaimsValid_(developerClaims)) { + errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; + } + + if (errorMessage) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } + + const claims: {[key: string]: any} = {}; + if (typeof developerClaims !== 'undefined') { + for (const key in developerClaims) { + /* istanbul ignore else */ + if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { + if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Developer claim "${key}" is reserved and cannot be specified.`, + ); + } + claims[key] = developerClaims[key]; + } + } + } + return this.signer.getAccountId().then((account) => { + const header: JWTHeader = { + alg: ALGORITHM_RS256, + typ: 'JWT', + }; + const iat = Math.floor(Date.now() / 1000); + const body: JWTBody = { + aud: FIREBASE_AUDIENCE, + iat, + exp: iat + ONE_HOUR_IN_SECONDS, + iss: account, + sub: account, + uid, + }; + if (this.tenantId) { + // eslint-disable-next-line @typescript-eslint/camelcase + body.tenant_id = this.tenantId; + } + if (Object.keys(claims).length > 0) { + body.claims = claims; + } + const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; + const signPromise = this.signer.sign(Buffer.from(token)); + return Promise.all([token, signPromise]); + }).then(([token, signature]) => { + return `${token}.${this.encodeSegment(signature)}`; + }); + } + + private encodeSegment(segment: object | Buffer): string { + const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment)); + return toWebSafeBase64(buffer).replace(/=+$/, ''); + } + + /** + * Returns whether or not the provided developer claims are valid. + * + * @param {object} [developerClaims] Optional developer claims to validate. + * @return {boolean} True if the provided claims are valid; otherwise, false. + */ + private isDeveloperClaimsValid_(developerClaims?: object): boolean { + if (typeof developerClaims === 'undefined') { + return true; + } + return validator.isNonNullObject(developerClaims); + } +} + + +/** + * Represents the header of a JWT. + */ +interface JWTHeader { + alg: string; + typ: string; +} + +/** + * Represents the body of a JWT. + */ +interface JWTBody { + claims?: object; + uid: string; + aud: string; + iat: number; + exp: number; + iss: string; + sub: string; + tenant_id?: string; +} + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; \ No newline at end of file diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 96188af944..362cf3aeba 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -14,27 +14,12 @@ * limitations under the License. */ -import { FirebaseApp } from '../firebase-app'; -import {ServiceAccountCredential} from './credential'; -import {AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; -import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request'; - -import * as validator from '../utils/validator'; -import { toWebSafeBase64 } from '../utils'; - - -const ALGORITHM_RS256 = 'RS256'; -const ONE_HOUR_IN_SECONDS = 60 * 60; - // List of blacklisted claims which cannot be provided when creating a custom token export const BLACKLISTED_CLAIMS = [ 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat', 'iss', 'jti', 'nbf', 'nonce', ]; -// Audience to use for Firebase Auth Custom tokens -const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; - /** * CryptoSigner interface represents an object that can be used to sign JWTs. */ @@ -53,294 +38,4 @@ export interface CryptoSigner { * @return {Promise} A promise that resolves with a service account ID. */ getAccountId(): Promise; -} - -/** - * Represents the header of a JWT. - */ -interface JWTHeader { - alg: string; - typ: string; -} - -/** - * Represents the body of a JWT. - */ -interface JWTBody { - claims?: object; - uid: string; - aud: string; - iat: number; - exp: number; - iss: string; - sub: string; - tenant_id?: string; -} - -/** - * A CryptoSigner implementation that uses an explicitly specified service account private key to - * sign data. Performs all operations locally, and does not make any RPC calls. - */ -export class ServiceAccountSigner implements CryptoSigner { - - /** - * Creates a new CryptoSigner instance from the given service account credential. - * - * @param {ServiceAccountCredential} credential A service account credential. - */ - constructor(private readonly credential: ServiceAccountCredential) { - if (!credential) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', - ); - } - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires - const sign = crypto.createSign('RSA-SHA256'); - sign.update(buffer); - return Promise.resolve(sign.sign(this.credential.privateKey)); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - return Promise.resolve(this.credential.clientEmail); - } -} - -/** - * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without - * a service account ID, attempts to discover a service account ID by consulting the local Metadata - * service. This will succeed in managed environments like Google Cloud Functions and App Engine. - * - * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob - * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata - */ -export class IAMSigner implements CryptoSigner { - private readonly httpClient: AuthorizedHttpClient; - private serviceAccountId?: string; - - constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { - if (!httpClient) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', - ); - } - if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', - ); - } - this.httpClient = httpClient; - this.serviceAccountId = serviceAccountId; - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - return this.getAccountId().then((serviceAccount) => { - const request: HttpRequestConfig = { - method: 'POST', - url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, - data: {bytesToSign: buffer.toString('base64')}, - }; - return this.httpClient.send(request); - }).then((response: any) => { - // Response from IAM is base64 encoded. Decode it into a buffer and return. - return Buffer.from(response.data.signature, 'base64'); - }).catch((err) => { - if (err instanceof HttpError) { - const error = err.response.data; - if (validator.isNonNullObject(error) && error.error) { - const errorCode = error.error.status; - const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + - 'for more details on how to use and troubleshoot this feature.'; - const errorMsg = `${error.error.message}; ${description}`; - - throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error); - } - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Error returned from server: ' + error + '. Additionally, an ' + - 'internal error occurred while attempting to extract the ' + - 'errorcode from the error.', - ); - } - throw err; - }); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - if (validator.isNonEmptyString(this.serviceAccountId)) { - return Promise.resolve(this.serviceAccountId); - } - const request: HttpRequestConfig = { - method: 'GET', - url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', - headers: { - 'Metadata-Flavor': 'Google', - }, - }; - const client = new HttpClient(); - return client.send(request).then((response) => { - if (!response.text) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'HTTP Response missing payload', - ); - } - this.serviceAccountId = response.text; - return response.text; - }).catch((err) => { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - `Failed to determine service account. Make sure to initialize ` + - `the SDK with a service account credential. Alternatively specify a service ` + - `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, - ); - }); - } -} - -/** - * Create a new CryptoSigner instance for the given app. If the app has been initialized with a service - * account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner. - * - * @param {FirebaseApp} app A FirebaseApp instance. - * @return {CryptoSigner} A CryptoSigner instance. - */ -export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { - const credential = app.options.credential; - if (credential instanceof ServiceAccountCredential) { - return new ServiceAccountSigner(credential); - } - - return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); -} - -/** - * Class for generating different types of Firebase Auth tokens (JWTs). - */ -export class FirebaseTokenGenerator { - - private readonly signer: CryptoSigner; - - /** - * @param tenantId The tenant ID to use for the generated Firebase Auth - * Custom token. If absent, then no tenant ID claim will be set in the - * resulting JWT. - */ - constructor(signer: CryptoSigner, public readonly tenantId?: string) { - if (!validator.isNonNullObject(signer)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', - ); - } - if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '`tenantId` argument must be a non-empty string.'); - } - this.signer = signer; - } - - /** - * Creates a new Firebase Auth Custom token. - * - * @param uid The user ID to use for the generated Firebase Auth Custom token. - * @param developerClaims Optional developer claims to include in the generated Firebase - * Auth Custom token. - * @return A Promise fulfilled with a Firebase Auth Custom token signed with a - * service account key and containing the provided payload. - */ - public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise { - let errorMessage: string | undefined; - if (!validator.isNonEmptyString(uid)) { - errorMessage = '`uid` argument must be a non-empty string uid.'; - } else if (uid.length > 128) { - errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; - } else if (!this.isDeveloperClaimsValid_(developerClaims)) { - errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; - } - - if (errorMessage) { - throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); - } - - const claims: {[key: string]: any} = {}; - if (typeof developerClaims !== 'undefined') { - for (const key in developerClaims) { - /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { - if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `Developer claim "${key}" is reserved and cannot be specified.`, - ); - } - claims[key] = developerClaims[key]; - } - } - } - return this.signer.getAccountId().then((account) => { - const header: JWTHeader = { - alg: ALGORITHM_RS256, - typ: 'JWT', - }; - const iat = Math.floor(Date.now() / 1000); - const body: JWTBody = { - aud: FIREBASE_AUDIENCE, - iat, - exp: iat + ONE_HOUR_IN_SECONDS, - iss: account, - sub: account, - uid, - }; - if (this.tenantId) { - // eslint-disable-next-line @typescript-eslint/camelcase - body.tenant_id = this.tenantId; - } - if (Object.keys(claims).length > 0) { - body.claims = claims; - } - const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; - const signPromise = this.signer.sign(Buffer.from(token)); - return Promise.all([token, signPromise]); - }).then(([token, signature]) => { - return `${token}.${this.encodeSegment(signature)}`; - }); - } - - private encodeSegment(segment: object | Buffer): string { - const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment)); - return toWebSafeBase64(buffer).replace(/=+$/, ''); - } - - /** - * Returns whether or not the provided developer claims are valid. - * - * @param {object} [developerClaims] Optional developer claims to validate. - * @return {boolean} True if the provided claims are valid; otherwise, false. - */ - private isDeveloperClaimsValid_(developerClaims?: object): boolean { - if (typeof developerClaims === 'undefined') { - return true; - } - return validator.isNonNullObject(developerClaims); - } -} - +} \ No newline at end of file diff --git a/src/auth/token-verifier-internal.ts b/src/auth/token-verifier-internal.ts new file mode 100644 index 0000000000..91465f42ba --- /dev/null +++ b/src/auth/token-verifier-internal.ts @@ -0,0 +1,321 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '../firebase-app'; +import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { DecodedIdToken } from './auth'; +import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import * as jwt from 'jsonwebtoken'; + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; + +/** User facing token information related to the Firebase ID token. */ +export const ID_TOKEN_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + verifyApiName: 'verifyIdToken()', + jwtName: 'Firebase ID token', + shortName: 'ID token', + expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, +}; + + +/** User facing token information related to the Firebase session cookie. */ +export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', + verifyApiName: 'verifySessionCookie()', + jwtName: 'Firebase session cookie', + shortName: 'session cookie', + expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, +}; + +/** Interface that defines token related user facing information. */ +export interface FirebaseTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** JWT Expiration error code. */ + expiredErrorCode: ErrorInfo; +} + +/** + * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. + */ +export class FirebaseTokenVerifier { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt: number; + private readonly shortNameArticle: string; + + constructor(private clientCertUrl: string, private algorithm: string, + private issuer: string, private tokenInfo: FirebaseTokenInfo, + private readonly app: FirebaseApp) { + if (!validator.isURL(clientCertUrl)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided public client certificate URL is an invalid URL.`, + ); + } else if (!validator.isNonEmptyString(algorithm)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT algorithm is an empty string.`, + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT issuer is an invalid URL.`, + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT information is not an object or null.`, + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT verification documentation URL is invalid.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT verify API name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public full name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public short name must be a non-empty string.`, + ); + } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT expiration error code must be a non-null ErrorInfo object.`, + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + // For backward compatibility, the project ID is validated in the verification call. + } + /** + * Verifies the format and signature of a Firebase Auth JWT token. + * + * @param {string} jwtToken The Firebase Auth JWT token to verify. + * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID + * token. + */ + public verifyJWT(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + return util.findProjectId(this.app) + .then((projectId) => { + return this.verifyJWTWithProjectId(jwtToken, projectId); + }); + } + + private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + `Must initialize app with a cert credential or set your Firebase project ID as the ` + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, + ); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + const header = fullDecodedToken && fullDecodedToken.header; + const payload = fullDecodedToken && fullDecodedToken.payload; + + const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + + `Firebase project as the service account used to authenticate this SDK.`; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string | undefined; + if (!fullDecodedToken) { + errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + + `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + } else if (typeof header.kid === 'undefined') { + const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); + const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); + + if (isCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a custom token.`; + } else if (isLegacyCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a legacy custom token.`; + } else { + errorMessage = 'Firebase ID token has no "kid" claim.'; + } + + errorMessage += verifyJwtTokenDocsMessage; + } else if (header.alg !== this.algorithm) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + `" but got ` + + `"` + header.alg + `".` + verifyJwtTokenDocsMessage; + } else if (payload.aud !== projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + projectId + `" but got "` + payload.aud + `".` + projectIdMatchMessage + + verifyJwtTokenDocsMessage; + } else if (payload.iss !== this.issuer + projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + + `"${this.issuer}"` + projectId + `" but got "` + + payload.iss + `".` + projectIdMatchMessage + verifyJwtTokenDocsMessage; + } else if (typeof payload.sub !== 'string') { + errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub.length > 128) { + errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + + verifyJwtTokenDocsMessage; + } + if (errorMessage) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + + return this.fetchPublicKeys().then((publicKeys) => { + if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` + + `Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` + + `client app and try again.`, + ), + ); + } else { + return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); + } + }); + } + + /** + * Verifies the JWT signature using the provided public key. + * @param {string} jwtToken The JWT token to verify. + * @param {string} publicKey The public key certificate. + * @return {Promise} A promise that resolves with the decoded JWT claims on successful + * verification. + */ + private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + return new Promise((resolve, reject) => { + jwt.verify(jwtToken, publicKey, { + algorithms: [this.algorithm], + }, (error: jwt.VerifyErrors, decodedToken: string | object) => { + if (error) { + if (error.name === 'TokenExpiredError') { + const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + + ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); + } else if (error.name === 'JsonWebTokenError') { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); + } else { + // TODO(rsgowman): I think the typing on jwt.verify is wrong. It claims that this can be either a string or an + // object, but the code always seems to call it as an object. Investigate and upstream typing changes if this + // is actually correct. + if (typeof decodedToken === 'string') { + return reject(new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + "Unexpected decodedToken. Expected an object but got a string: '" + decodedToken + "'", + )); + } else { + const decodedIdToken = (decodedToken as DecodedIdToken); + decodedIdToken.uid = decodedIdToken.sub; + resolve(decodedIdToken); + } + } + }); + }); + } + + /** + * Fetches the public keys for the Google certs. + * + * @return {Promise} A promise fulfilled with public keys for the Google certs. + */ + private fetchPublicKeys(): Promise<{[key: string]: string}> { + const publicKeysExist = (typeof this.publicKeys !== 'undefined'); + const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); + const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); + if (publicKeysExist && publicKeysStillValid) { + return Promise.resolve(this.publicKeys); + } + + const client = new HttpClient(); + const request: HttpRequestConfig = { + method: 'GET', + url: this.clientCertUrl, + httpAgent: this.app.options.httpAgent, + }; + return client.send(request).then((resp) => { + if (!resp.isJson() || resp.data.error) { + // Treat all non-json messages and messages with an 'error' field as + // error responses. + throw new HttpError(resp); + } + if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { + const cacheControlHeader: string = resp.headers['cache-control']; + const parts = cacheControlHeader.split(','); + parts.forEach((part) => { + const subParts = part.trim().split('='); + if (subParts[0] === 'max-age') { + const maxAge: number = +subParts[1]; + this.publicKeysExpireAt = Date.now() + (maxAge * 1000); + } + }); + } + this.publicKeys = resp.data; + return resp.data; + }).catch((err) => { + if (err instanceof HttpError) { + let errorMessage = 'Error fetching public keys for Google certs: '; + const resp = err.response; + if (resp.isJson() && resp.data.error) { + errorMessage += `${resp.data.error}`; + if (resp.data.error_description) { + errorMessage += ' (' + resp.data.error_description + ')'; + } + } else { + errorMessage += `${resp.text}`; + } + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage); + } + throw err; + }); + } +} \ No newline at end of file diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index cc0c4f4c02..ca18c3c57f 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -14,17 +14,10 @@ * limitations under the License. */ -import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; - -import * as util from '../utils/index'; -import * as validator from '../utils/validator'; -import * as jwt from 'jsonwebtoken'; -import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; -import { DecodedIdToken } from './auth'; +import { FirebaseTokenVerifier } from './token-verifier-internal'; import { FirebaseApp } from '../firebase-app'; +import { ID_TOKEN_INFO, SESSION_COOKIE_INFO } from './token-verifier-internal'; -// Audience to use for Firebase Auth Custom tokens -const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; export const ALGORITHM_RS256 = 'RS256'; @@ -35,302 +28,6 @@ const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/secur // URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon. const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; -/** User facing token information related to the Firebase ID token. */ -export const ID_TOKEN_INFO: FirebaseTokenInfo = { - url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', - verifyApiName: 'verifyIdToken()', - jwtName: 'Firebase ID token', - shortName: 'ID token', - expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, -}; - -/** User facing token information related to the Firebase session cookie. */ -export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { - url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', - verifyApiName: 'verifySessionCookie()', - jwtName: 'Firebase session cookie', - shortName: 'session cookie', - expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, -}; - -/** Interface that defines token related user facing information. */ -export interface FirebaseTokenInfo { - /** Documentation URL. */ - url: string; - /** verify API name. */ - verifyApiName: string; - /** The JWT full name. */ - jwtName: string; - /** The JWT short name. */ - shortName: string; - /** JWT Expiration error code. */ - expiredErrorCode: ErrorInfo; -} - -/** - * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. - */ -export class FirebaseTokenVerifier { - private publicKeys: {[key: string]: string}; - private publicKeysExpireAt: number; - private readonly shortNameArticle: string; - - constructor(private clientCertUrl: string, private algorithm: string, - private issuer: string, private tokenInfo: FirebaseTokenInfo, - private readonly app: FirebaseApp) { - if (!validator.isURL(clientCertUrl)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided public client certificate URL is an invalid URL.`, - ); - } else if (!validator.isNonEmptyString(algorithm)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT algorithm is an empty string.`, - ); - } else if (!validator.isURL(issuer)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT issuer is an invalid URL.`, - ); - } else if (!validator.isNonNullObject(tokenInfo)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT information is not an object or null.`, - ); - } else if (!validator.isURL(tokenInfo.url)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT verification documentation URL is invalid.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT verify API name must be a non-empty string.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT public full name must be a non-empty string.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT public short name must be a non-empty string.`, - ); - } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT expiration error code must be a non-null ErrorInfo object.`, - ); - } - this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; - - // For backward compatibility, the project ID is validated in the verification call. - } - - /** - * Verifies the format and signature of a Firebase Auth JWT token. - * - * @param {string} jwtToken The Firebase Auth JWT token to verify. - * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID - * token. - */ - public verifyJWT(jwtToken: string): Promise { - if (!validator.isString(jwtToken)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, - ); - } - - return util.findProjectId(this.app) - .then((projectId) => { - return this.verifyJWTWithProjectId(jwtToken, projectId); - }); - } - - private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise { - if (!validator.isNonEmptyString(projectId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - `Must initialize app with a cert credential or set your Firebase project ID as the ` + - `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, - ); - } - - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); - - const header = fullDecodedToken && fullDecodedToken.header; - const payload = fullDecodedToken && fullDecodedToken.payload; - - const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + - `Firebase project as the service account used to authenticate this SDK.`; - const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + - `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - - let errorMessage: string | undefined; - if (!fullDecodedToken) { - errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + - `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; - } else if (typeof header.kid === 'undefined') { - const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); - const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); - - if (isCustomToken) { - errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + - `${this.tokenInfo.shortName}, but was given a custom token.`; - } else if (isLegacyCustomToken) { - errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + - `${this.tokenInfo.shortName}, but was given a legacy custom token.`; - } else { - errorMessage = 'Firebase ID token has no "kid" claim.'; - } - - errorMessage += verifyJwtTokenDocsMessage; - } else if (header.alg !== this.algorithm) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + `" but got ` + - `"` + header.alg + `".` + verifyJwtTokenDocsMessage; - } else if (payload.aud !== projectId) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + - projectId + `" but got "` + payload.aud + `".` + projectIdMatchMessage + - verifyJwtTokenDocsMessage; - } else if (payload.iss !== this.issuer + projectId) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + - `"${this.issuer}"` + projectId + `" but got "` + - payload.iss + `".` + projectIdMatchMessage + verifyJwtTokenDocsMessage; - } else if (typeof payload.sub !== 'string') { - errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; - } else if (payload.sub === '') { - errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; - } else if (payload.sub.length > 128) { - errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + - verifyJwtTokenDocsMessage; - } - if (errorMessage) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - - return this.fetchPublicKeys().then((publicKeys) => { - if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` + - `Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` + - `client app and try again.`, - ), - ); - } else { - return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); - } - - }); - } - - /** - * Verifies the JWT signature using the provided public key. - * @param {string} jwtToken The JWT token to verify. - * @param {string} publicKey The public key certificate. - * @return {Promise} A promise that resolves with the decoded JWT claims on successful - * verification. - */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { - const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + - `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - return new Promise((resolve, reject) => { - jwt.verify(jwtToken, publicKey, { - algorithms: [this.algorithm], - }, (error: jwt.VerifyErrors, decodedToken: string | object) => { - if (error) { - if (error.name === 'TokenExpiredError') { - const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + - ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + - verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); - } else if (error.name === 'JsonWebTokenError') { - const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); - } else { - // TODO(rsgowman): I think the typing on jwt.verify is wrong. It claims that this can be either a string or an - // object, but the code always seems to call it as an object. Investigate and upstream typing changes if this - // is actually correct. - if (typeof decodedToken === 'string') { - return reject(new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - "Unexpected decodedToken. Expected an object but got a string: '" + decodedToken + "'", - )); - } else { - const decodedIdToken = (decodedToken as DecodedIdToken); - decodedIdToken.uid = decodedIdToken.sub; - resolve(decodedIdToken); - } - } - }); - }); - } - - /** - * Fetches the public keys for the Google certs. - * - * @return {Promise} A promise fulfilled with public keys for the Google certs. - */ - private fetchPublicKeys(): Promise<{[key: string]: string}> { - const publicKeysExist = (typeof this.publicKeys !== 'undefined'); - const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); - const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); - if (publicKeysExist && publicKeysStillValid) { - return Promise.resolve(this.publicKeys); - } - - const client = new HttpClient(); - const request: HttpRequestConfig = { - method: 'GET', - url: this.clientCertUrl, - httpAgent: this.app.options.httpAgent, - }; - return client.send(request).then((resp) => { - if (!resp.isJson() || resp.data.error) { - // Treat all non-json messages and messages with an 'error' field as - // error responses. - throw new HttpError(resp); - } - if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { - const cacheControlHeader: string = resp.headers['cache-control']; - const parts = cacheControlHeader.split(','); - parts.forEach((part) => { - const subParts = part.trim().split('='); - if (subParts[0] === 'max-age') { - const maxAge: number = +subParts[1]; - this.publicKeysExpireAt = Date.now() + (maxAge * 1000); - } - }); - } - this.publicKeys = resp.data; - return resp.data; - }).catch((err) => { - if (err instanceof HttpError) { - let errorMessage = 'Error fetching public keys for Google certs: '; - const resp = err.response; - if (resp.isJson() && resp.data.error) { - errorMessage += `${resp.data.error}`; - if (resp.data.error_description) { - errorMessage += ' (' + resp.data.error_description + ')'; - } - } else { - errorMessage += `${resp.text}`; - } - throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage); - } - throw err; - }); - } -} - /** * Creates a new FirebaseTokenVerifier to verify Firebase ID tokens. * diff --git a/src/auth/user-import-builder-internal.ts b/src/auth/user-import-builder-internal.ts new file mode 100644 index 0000000000..58298e1432 --- /dev/null +++ b/src/auth/user-import-builder-internal.ts @@ -0,0 +1,511 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {deepCopy, deepExtend} from '../utils/deep-copy'; +import * as utils from '../utils'; +import * as validator from '../utils/validator'; +import {AuthClientErrorCode, FirebaseAuthError, FirebaseArrayIndexError} from '../utils/error'; +import {UserImportRecord, UserImportOptions, ValidatorFunction, + UploadAccountRequest, UserImportResult, SecondFactor} from './user-import-builder'; + +/** Interface representing an Auth second factor in Auth server format. */ +export interface AuthFactorInfo { + // Not required for signupNewUser endpoint. + mfaEnrollmentId?: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +/** UploadAccount endpoint request hash options. */ +export interface UploadAccountOptions { + hashAlgorithm?: string; + signerKey?: string; + rounds?: number; + memoryCost?: number; + saltSeparator?: string; + cpuMemCost?: number; + parallelization?: number; + blockSize?: number; + dkLen?: number; +} + +/** + * Converts a client format second factor object to server format. + * @param multiFactorInfo The client format second factor. + * @return The corresponding AuthFactorInfo server request format. + */ +export function convertMultiFactorInfoToServerFormat(multiFactorInfo: SecondFactor): AuthFactorInfo { + let enrolledAt; + if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { + if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { + // Convert from UTC date string (client side format) to ISO date string (server side format). + enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + + `UTC date string.`); + } + } + // Currently only phone second factors are supported. + if (multiFactorInfo.factorId === 'phone') { + // If any required field is missing or invalid, validation will still fail later. + const authFactorInfo: AuthFactorInfo = { + mfaEnrollmentId: multiFactorInfo.uid, + displayName: multiFactorInfo.displayName, + // Required for all phone second factors. + phoneInfo: multiFactorInfo.phoneNumber, + enrolledAt, + }; + for (const objKey in authFactorInfo) { + if (typeof authFactorInfo[objKey] === 'undefined') { + delete authFactorInfo[objKey]; + } + } + return authFactorInfo; + } else { + // Unsupported second factor. + throw new FirebaseAuthError( + AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); + } +} + + +/** + * Class that provides a helper for building/validating uploadAccount requests and + * UserImportResult responses. + */ +export class UserImportBuilder { + private requiresHashOptions: boolean; + private validatedUsers: UploadAccountUser[]; + private validatedOptions: UploadAccountOptions; + private indexMap: {[key: number]: number}; + private userImportResultErrors: FirebaseArrayIndexError[]; + + /** + * @param {UserImportRecord[]} users The list of user records to import. + * @param {UserImportOptions=} options The import options which includes hashing + * algorithm details. + * @param {ValidatorFunction=} userRequestValidator The user request validator function. + * @constructor + */ + constructor( + users: UserImportRecord[], + options?: UserImportOptions, + userRequestValidator?: ValidatorFunction) { + this.requiresHashOptions = false; + this.validatedUsers = []; + this.userImportResultErrors = []; + this.indexMap = {}; + + this.validatedUsers = this.populateUsers(users, userRequestValidator); + this.validatedOptions = this.populateOptions(options, this.requiresHashOptions); + } + + /** + * Returns the corresponding constructed uploadAccount request. + * @return {UploadAccountRequest} The constructed uploadAccount request. + */ + public buildRequest(): UploadAccountRequest { + const users = this.validatedUsers.map((user) => { + return deepCopy(user); + }); + return deepExtend({users}, deepCopy(this.validatedOptions)) as UploadAccountRequest; + } + + /** + * Populates the UserImportResult using the client side detected errors and the server + * side returned errors. + * @return {UserImportResult} The user import result based on the returned failed + * uploadAccount response. + */ + public buildResponse( + failedUploads: Array<{index: number; message: string}>): UserImportResult { + // Initialize user import result. + const importResult: UserImportResult = { + successCount: this.validatedUsers.length, + failureCount: this.userImportResultErrors.length, + errors: deepCopy(this.userImportResultErrors), + }; + importResult.failureCount += failedUploads.length; + importResult.successCount -= failedUploads.length; + failedUploads.forEach((failedUpload) => { + importResult.errors.push({ + // Map backend request index to original developer provided array index. + index: this.indexMap[failedUpload.index], + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_USER_IMPORT, + failedUpload.message, + ), + }); + }); + // Sort errors by index. + importResult.errors.sort((a, b) => { + return a.index - b.index; + }); + // Return sorted result. + return importResult; + } + + /** + * Validates and returns the hashing options of the uploadAccount request. + * Throws an error whenever an invalid or missing options is detected. + * @param {UserImportOptions} options The UserImportOptions. + * @param {boolean} requiresHashOptions Whether to require hash options. + * @return {UploadAccountOptions} The populated UploadAccount options. + */ + private populateOptions( + options: UserImportOptions | undefined, requiresHashOptions: boolean): UploadAccountOptions { + let populatedOptions: UploadAccountOptions; + if (!requiresHashOptions) { + return {}; + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"UserImportOptions" are required when importing users with passwords.', + ); + } + if (!validator.isNonNullObject(options.hash)) { + throw new FirebaseAuthError( + AuthClientErrorCode.MISSING_HASH_ALGORITHM, + `"hash.algorithm" is missing from the provided "UserImportOptions".`, + ); + } + if (typeof options.hash.algorithm === 'undefined' || + !validator.isNonEmptyString(options.hash.algorithm)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `"hash.algorithm" must be a string matching the list of supported algorithms.`, + ); + } + + let rounds: number; + switch (options.hash.algorithm) { + case 'HMAC_SHA512': + case 'HMAC_SHA256': + case 'HMAC_SHA1': + case 'HMAC_MD5': + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + `A non-empty "hash.key" byte buffer must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + }; + break; + + case 'MD5': + case 'SHA1': + case 'SHA256': + case 'SHA512': { + // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] + rounds = getNumberField(options.hash, 'rounds'); + const minRounds = options.hash.algorithm === 'MD5' ? 0 : 1; + if (isNaN(rounds) || rounds < minRounds || rounds > 8192) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between ${minRounds} and 8192 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + } + case 'PBKDF_SHA1': + case 'PBKDF2_SHA256': + rounds = getNumberField(options.hash, 'rounds'); + if (isNaN(rounds) || rounds < 0 || rounds > 120000) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between 0 and 120000 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + + case 'SCRYPT': { + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + `A "hash.key" byte buffer must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + rounds = getNumberField(options.hash, 'rounds'); + if (isNaN(rounds) || rounds <= 0 || rounds > 8) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between 1 and 8 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const memoryCost = getNumberField(options.hash, 'memoryCost'); + if (isNaN(memoryCost) || memoryCost <= 0 || memoryCost > 14) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + `A valid "hash.memoryCost" number between 1 and 14 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + if (typeof options.hash.saltSeparator !== 'undefined' && + !validator.isBuffer(options.hash.saltSeparator)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_SALT_SEPARATOR, + `"hash.saltSeparator" must be a byte buffer.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + rounds, + memoryCost, + saltSeparator: utils.toWebSafeBase64(options.hash.saltSeparator || Buffer.from('')), + }; + break; + } + case 'BCRYPT': + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + }; + break; + + case 'STANDARD_SCRYPT': { + const cpuMemCost = getNumberField(options.hash, 'memoryCost'); + if (isNaN(cpuMemCost)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + `A valid "hash.memoryCost" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const parallelization = getNumberField(options.hash, 'parallelization'); + if (isNaN(parallelization)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_PARALLELIZATION, + `A valid "hash.parallelization" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const blockSize = getNumberField(options.hash, 'blockSize'); + if (isNaN(blockSize)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_BLOCK_SIZE, + `A valid "hash.blockSize" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const dkLen = getNumberField(options.hash, 'derivedKeyLength'); + if (isNaN(dkLen)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_DERIVED_KEY_LENGTH, + `A valid "hash.derivedKeyLength" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + cpuMemCost, + parallelization, + blockSize, + dkLen, + }; + break; + } + default: + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `Unsupported hash algorithm provider "${options.hash.algorithm}".`, + ); + } + return populatedOptions; + } + + /** + * Validates and returns the users list of the uploadAccount request. + * Whenever a user with an error is detected, the error is cached and will later be + * merged into the user import result. This allows the processing of valid users without + * failing early on the first error detected. + * @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser + * objects. + * @param {ValidatorFunction=} userValidator The user validator function. + * @return {UploadAccountUser[]} The populated uploadAccount users. + */ + private populateUsers( + users: UserImportRecord[], userValidator?: ValidatorFunction): UploadAccountUser[] { + const populatedUsers: UploadAccountUser[] = []; + users.forEach((user, index) => { + try { + const result = populateUploadAccountUser(user, userValidator); + if (typeof result.passwordHash !== 'undefined') { + this.requiresHashOptions = true; + } + // Only users that pass client screening will be passed to backend for processing. + populatedUsers.push(result); + // Map user's index (the one to be sent to backend) to original developer provided array. + this.indexMap[populatedUsers.length - 1] = index; + } catch (error) { + // Save the client side error with respect to the developer provided array. + this.userImportResultErrors.push({ + index, + error, + }); + } + }); + return populatedUsers; + } +} + + +/** UploadAccount endpoint request user interface. */ +export interface UploadAccountUser { + localId: string; + email?: string; + emailVerified?: boolean; + displayName?: string; + disabled?: boolean; + photoUrl?: string; + phoneNumber?: string; + providerUserInfo?: Array<{ + rawId: string; + providerId: string; + email?: string; + displayName?: string; + photoUrl?: string; + }>; + mfaInfo?: AuthFactorInfo[]; + passwordHash?: string; + salt?: string; + lastLoginAt?: number; + createdAt?: number; + customAttributes?: string; + tenantId?: string; +} + +/** + * @param {any} obj The object to check for number field within. + * @param {string} key The entry key. + * @return {number} The corresponding number if available. Otherwise, NaN. + */ +function getNumberField(obj: any, key: string): number { + if (typeof obj[key] !== 'undefined' && obj[key] !== null) { + return parseInt(obj[key].toString(), 10); + } + return NaN; +} + + +/** + * Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid + * fields are provided. + * @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. + * @param {ValidatorFunction=} userValidator The user validator function. + * @return {UploadAccountUser} The corresponding UploadAccountUser to return. + */ +function populateUploadAccountUser( + user: UserImportRecord, userValidator?: ValidatorFunction): UploadAccountUser { + const result: UploadAccountUser = { + localId: user.uid, + email: user.email, + emailVerified: user.emailVerified, + displayName: user.displayName, + disabled: user.disabled, + photoUrl: user.photoURL, + phoneNumber: user.phoneNumber, + providerUserInfo: [], + mfaInfo: [], + tenantId: user.tenantId, + customAttributes: user.customClaims && JSON.stringify(user.customClaims), + }; + if (typeof user.passwordHash !== 'undefined') { + if (!validator.isBuffer(user.passwordHash)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PASSWORD_HASH, + ); + } + result.passwordHash = utils.toWebSafeBase64(user.passwordHash); + } + if (typeof user.passwordSalt !== 'undefined') { + if (!validator.isBuffer(user.passwordSalt)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PASSWORD_SALT, + ); + } + result.salt = utils.toWebSafeBase64(user.passwordSalt); + } + if (validator.isNonNullObject(user.metadata)) { + if (validator.isNonEmptyString(user.metadata.creationTime)) { + result.createdAt = new Date(user.metadata.creationTime).getTime(); + } + if (validator.isNonEmptyString(user.metadata.lastSignInTime)) { + result.lastLoginAt = new Date(user.metadata.lastSignInTime).getTime(); + } + } + if (validator.isArray(user.providerData)) { + user.providerData.forEach((providerData) => { + result.providerUserInfo!.push({ + providerId: providerData.providerId, + rawId: providerData.uid, + email: providerData.email, + displayName: providerData.displayName, + photoUrl: providerData.photoURL, + }); + }); + } + + // Convert user.multiFactor.enrolledFactors to server format. + if (validator.isNonNullObject(user.multiFactor) && + validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { + user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + result.mfaInfo!.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } + + // Remove blank fields. + let key: keyof UploadAccountUser; + for (key in result) { + if (typeof result[key] === 'undefined') { + delete result[key]; + } + } + if (result.providerUserInfo!.length === 0) { + delete result.providerUserInfo; + } + if (result.mfaInfo!.length === 0) { + delete result.mfaInfo; + } + // Validate the constructured user individual request. This will throw if an error + // is detected. + if (typeof userValidator === 'function') { + userValidator(result); + } + return result; +} + diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts index 89e1802ec3..9268f2aa65 100755 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import {deepCopy, deepExtend} from '../utils/deep-copy'; -import * as utils from '../utils'; -import * as validator from '../utils/validator'; -import {AuthClientErrorCode, FirebaseAuthError, FirebaseArrayIndexError} from '../utils/error'; +import {FirebaseArrayIndexError} from '../utils/error'; +import {UploadAccountUser, UploadAccountOptions} from './user-import-builder-internal'; /** Firebase Auth supported hashing algorithms for import operations. */ export type HashAlgorithmType = 'SCRYPT' | 'STANDARD_SCRYPT' | 'HMAC_SHA512' | @@ -81,56 +79,6 @@ export interface UserImportRecord { tenantId?: string; } -/** Interface representing an Auth second factor in Auth server format. */ -export interface AuthFactorInfo { - // Not required for signupNewUser endpoint. - mfaEnrollmentId?: string; - displayName?: string; - phoneInfo?: string; - enrolledAt?: string; - [key: string]: any; -} - - -/** UploadAccount endpoint request user interface. */ -interface UploadAccountUser { - localId: string; - email?: string; - emailVerified?: boolean; - displayName?: string; - disabled?: boolean; - photoUrl?: string; - phoneNumber?: string; - providerUserInfo?: Array<{ - rawId: string; - providerId: string; - email?: string; - displayName?: string; - photoUrl?: string; - }>; - mfaInfo?: AuthFactorInfo[]; - passwordHash?: string; - salt?: string; - lastLoginAt?: number; - createdAt?: number; - customAttributes?: string; - tenantId?: string; -} - - -/** UploadAccount endpoint request hash options. */ -export interface UploadAccountOptions { - hashAlgorithm?: string; - signerKey?: string; - rounds?: number; - memoryCost?: number; - saltSeparator?: string; - cpuMemCost?: number; - parallelization?: number; - blockSize?: number; - dkLen?: number; -} - /** UploadAccount endpoint complete request interface. */ export interface UploadAccountRequest extends UploadAccountOptions { @@ -148,444 +96,3 @@ export interface UserImportResult { /** Callback function to validate an UploadAccountUser object. */ export type ValidatorFunction = (data: UploadAccountUser) => void; - - -/** - * Converts a client format second factor object to server format. - * @param multiFactorInfo The client format second factor. - * @return The corresponding AuthFactorInfo server request format. - */ -export function convertMultiFactorInfoToServerFormat(multiFactorInfo: SecondFactor): AuthFactorInfo { - let enrolledAt; - if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { - if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { - // Convert from UTC date string (client side format) to ISO date string (server side format). - enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); - } else { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ENROLLMENT_TIME, - `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + - `UTC date string.`); - } - } - // Currently only phone second factors are supported. - if (multiFactorInfo.factorId === 'phone') { - // If any required field is missing or invalid, validation will still fail later. - const authFactorInfo: AuthFactorInfo = { - mfaEnrollmentId: multiFactorInfo.uid, - displayName: multiFactorInfo.displayName, - // Required for all phone second factors. - phoneInfo: multiFactorInfo.phoneNumber, - enrolledAt, - }; - for (const objKey in authFactorInfo) { - if (typeof authFactorInfo[objKey] === 'undefined') { - delete authFactorInfo[objKey]; - } - } - return authFactorInfo; - } else { - // Unsupported second factor. - throw new FirebaseAuthError( - AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, - `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); - } -} - - -/** - * @param {any} obj The object to check for number field within. - * @param {string} key The entry key. - * @return {number} The corresponding number if available. Otherwise, NaN. - */ -function getNumberField(obj: any, key: string): number { - if (typeof obj[key] !== 'undefined' && obj[key] !== null) { - return parseInt(obj[key].toString(), 10); - } - return NaN; -} - - -/** - * Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid - * fields are provided. - * @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. - * @param {ValidatorFunction=} userValidator The user validator function. - * @return {UploadAccountUser} The corresponding UploadAccountUser to return. - */ -function populateUploadAccountUser( - user: UserImportRecord, userValidator?: ValidatorFunction): UploadAccountUser { - const result: UploadAccountUser = { - localId: user.uid, - email: user.email, - emailVerified: user.emailVerified, - displayName: user.displayName, - disabled: user.disabled, - photoUrl: user.photoURL, - phoneNumber: user.phoneNumber, - providerUserInfo: [], - mfaInfo: [], - tenantId: user.tenantId, - customAttributes: user.customClaims && JSON.stringify(user.customClaims), - }; - if (typeof user.passwordHash !== 'undefined') { - if (!validator.isBuffer(user.passwordHash)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PASSWORD_HASH, - ); - } - result.passwordHash = utils.toWebSafeBase64(user.passwordHash); - } - if (typeof user.passwordSalt !== 'undefined') { - if (!validator.isBuffer(user.passwordSalt)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PASSWORD_SALT, - ); - } - result.salt = utils.toWebSafeBase64(user.passwordSalt); - } - if (validator.isNonNullObject(user.metadata)) { - if (validator.isNonEmptyString(user.metadata.creationTime)) { - result.createdAt = new Date(user.metadata.creationTime).getTime(); - } - if (validator.isNonEmptyString(user.metadata.lastSignInTime)) { - result.lastLoginAt = new Date(user.metadata.lastSignInTime).getTime(); - } - } - if (validator.isArray(user.providerData)) { - user.providerData.forEach((providerData) => { - result.providerUserInfo!.push({ - providerId: providerData.providerId, - rawId: providerData.uid, - email: providerData.email, - displayName: providerData.displayName, - photoUrl: providerData.photoURL, - }); - }); - } - - // Convert user.multiFactor.enrolledFactors to server format. - if (validator.isNonNullObject(user.multiFactor) && - validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { - user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { - result.mfaInfo!.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); - }); - } - - // Remove blank fields. - let key: keyof UploadAccountUser; - for (key in result) { - if (typeof result[key] === 'undefined') { - delete result[key]; - } - } - if (result.providerUserInfo!.length === 0) { - delete result.providerUserInfo; - } - if (result.mfaInfo!.length === 0) { - delete result.mfaInfo; - } - // Validate the constructured user individual request. This will throw if an error - // is detected. - if (typeof userValidator === 'function') { - userValidator(result); - } - return result; -} - - -/** - * Class that provides a helper for building/validating uploadAccount requests and - * UserImportResult responses. - */ -export class UserImportBuilder { - private requiresHashOptions: boolean; - private validatedUsers: UploadAccountUser[]; - private validatedOptions: UploadAccountOptions; - private indexMap: {[key: number]: number}; - private userImportResultErrors: FirebaseArrayIndexError[]; - - /** - * @param {UserImportRecord[]} users The list of user records to import. - * @param {UserImportOptions=} options The import options which includes hashing - * algorithm details. - * @param {ValidatorFunction=} userRequestValidator The user request validator function. - * @constructor - */ - constructor( - users: UserImportRecord[], - options?: UserImportOptions, - userRequestValidator?: ValidatorFunction) { - this.requiresHashOptions = false; - this.validatedUsers = []; - this.userImportResultErrors = []; - this.indexMap = {}; - - this.validatedUsers = this.populateUsers(users, userRequestValidator); - this.validatedOptions = this.populateOptions(options, this.requiresHashOptions); - } - - /** - * Returns the corresponding constructed uploadAccount request. - * @return {UploadAccountRequest} The constructed uploadAccount request. - */ - public buildRequest(): UploadAccountRequest { - const users = this.validatedUsers.map((user) => { - return deepCopy(user); - }); - return deepExtend({users}, deepCopy(this.validatedOptions)) as UploadAccountRequest; - } - - /** - * Populates the UserImportResult using the client side detected errors and the server - * side returned errors. - * @return {UserImportResult} The user import result based on the returned failed - * uploadAccount response. - */ - public buildResponse( - failedUploads: Array<{index: number; message: string}>): UserImportResult { - // Initialize user import result. - const importResult: UserImportResult = { - successCount: this.validatedUsers.length, - failureCount: this.userImportResultErrors.length, - errors: deepCopy(this.userImportResultErrors), - }; - importResult.failureCount += failedUploads.length; - importResult.successCount -= failedUploads.length; - failedUploads.forEach((failedUpload) => { - importResult.errors.push({ - // Map backend request index to original developer provided array index. - index: this.indexMap[failedUpload.index], - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_USER_IMPORT, - failedUpload.message, - ), - }); - }); - // Sort errors by index. - importResult.errors.sort((a, b) => { - return a.index - b.index; - }); - // Return sorted result. - return importResult; - } - - /** - * Validates and returns the hashing options of the uploadAccount request. - * Throws an error whenever an invalid or missing options is detected. - * @param {UserImportOptions} options The UserImportOptions. - * @param {boolean} requiresHashOptions Whether to require hash options. - * @return {UploadAccountOptions} The populated UploadAccount options. - */ - private populateOptions( - options: UserImportOptions | undefined, requiresHashOptions: boolean): UploadAccountOptions { - let populatedOptions: UploadAccountOptions; - if (!requiresHashOptions) { - return {}; - } - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"UserImportOptions" are required when importing users with passwords.', - ); - } - if (!validator.isNonNullObject(options.hash)) { - throw new FirebaseAuthError( - AuthClientErrorCode.MISSING_HASH_ALGORITHM, - `"hash.algorithm" is missing from the provided "UserImportOptions".`, - ); - } - if (typeof options.hash.algorithm === 'undefined' || - !validator.isNonEmptyString(options.hash.algorithm)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ALGORITHM, - `"hash.algorithm" must be a string matching the list of supported algorithms.`, - ); - } - - let rounds: number; - switch (options.hash.algorithm) { - case 'HMAC_SHA512': - case 'HMAC_SHA256': - case 'HMAC_SHA1': - case 'HMAC_MD5': - if (!validator.isBuffer(options.hash.key)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_KEY, - `A non-empty "hash.key" byte buffer must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - signerKey: utils.toWebSafeBase64(options.hash.key), - }; - break; - - case 'MD5': - case 'SHA1': - case 'SHA256': - case 'SHA512': { - // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] - rounds = getNumberField(options.hash, 'rounds'); - const minRounds = options.hash.algorithm === 'MD5' ? 0 : 1; - if (isNaN(rounds) || rounds < minRounds || rounds > 8192) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between ${minRounds} and 8192 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - rounds, - }; - break; - } - case 'PBKDF_SHA1': - case 'PBKDF2_SHA256': - rounds = getNumberField(options.hash, 'rounds'); - if (isNaN(rounds) || rounds < 0 || rounds > 120000) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between 0 and 120000 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - rounds, - }; - break; - - case 'SCRYPT': { - if (!validator.isBuffer(options.hash.key)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_KEY, - `A "hash.key" byte buffer must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - rounds = getNumberField(options.hash, 'rounds'); - if (isNaN(rounds) || rounds <= 0 || rounds > 8) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between 1 and 8 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const memoryCost = getNumberField(options.hash, 'memoryCost'); - if (isNaN(memoryCost) || memoryCost <= 0 || memoryCost > 14) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_MEMORY_COST, - `A valid "hash.memoryCost" number between 1 and 14 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - if (typeof options.hash.saltSeparator !== 'undefined' && - !validator.isBuffer(options.hash.saltSeparator)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_SALT_SEPARATOR, - `"hash.saltSeparator" must be a byte buffer.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - signerKey: utils.toWebSafeBase64(options.hash.key), - rounds, - memoryCost, - saltSeparator: utils.toWebSafeBase64(options.hash.saltSeparator || Buffer.from('')), - }; - break; - } - case 'BCRYPT': - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - }; - break; - - case 'STANDARD_SCRYPT': { - const cpuMemCost = getNumberField(options.hash, 'memoryCost'); - if (isNaN(cpuMemCost)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_MEMORY_COST, - `A valid "hash.memoryCost" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const parallelization = getNumberField(options.hash, 'parallelization'); - if (isNaN(parallelization)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_PARALLELIZATION, - `A valid "hash.parallelization" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const blockSize = getNumberField(options.hash, 'blockSize'); - if (isNaN(blockSize)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_BLOCK_SIZE, - `A valid "hash.blockSize" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const dkLen = getNumberField(options.hash, 'derivedKeyLength'); - if (isNaN(dkLen)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_DERIVED_KEY_LENGTH, - `A valid "hash.derivedKeyLength" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - cpuMemCost, - parallelization, - blockSize, - dkLen, - }; - break; - } - default: - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ALGORITHM, - `Unsupported hash algorithm provider "${options.hash.algorithm}".`, - ); - } - return populatedOptions; - } - - /** - * Validates and returns the users list of the uploadAccount request. - * Whenever a user with an error is detected, the error is cached and will later be - * merged into the user import result. This allows the processing of valid users without - * failing early on the first error detected. - * @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser - * objects. - * @param {ValidatorFunction=} userValidator The user validator function. - * @return {UploadAccountUser[]} The populated uploadAccount users. - */ - private populateUsers( - users: UserImportRecord[], userValidator?: ValidatorFunction): UploadAccountUser[] { - const populatedUsers: UploadAccountUser[] = []; - users.forEach((user, index) => { - try { - const result = populateUploadAccountUser(user, userValidator); - if (typeof result.passwordHash !== 'undefined') { - this.requiresHashOptions = true; - } - // Only users that pass client screening will be passed to backend for processing. - populatedUsers.push(result); - // Map user's index (the one to be sent to backend) to original developer provided array. - this.indexMap[populatedUsers.length - 1] = index; - } catch (error) { - // Save the client side error with respect to the developer provided array. - this.userImportResultErrors.push({ - index, - error, - }); - } - }); - return populatedUsers; - } -} diff --git a/src/firebase-namespace.ts b/src/firebase-namespace.ts index 557be2a51b..77a205e446 100644 --- a/src/firebase-namespace.ts +++ b/src/firebase-namespace.ts @@ -23,9 +23,9 @@ import {FirebaseServiceFactory} from './firebase-service'; // FirebaseServiceInt import { Credential, RefreshTokenCredential, - ServiceAccountCredential, getApplicationDefault, } from './auth/credential'; +import {ServiceAccountCredential} from './auth/credential-internal'; import * as validator from './utils/validator'; diff --git a/src/utils/index.ts b/src/utils/index.ts index f6e2f41232..d7f5309f0e 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,7 +15,7 @@ */ import {FirebaseApp, FirebaseAppOptions} from '../firebase-app'; -import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential'; +import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential-internal'; import * as validator from './validator'; diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index 7c73e8fe54..72c8edcb7a 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -27,7 +27,8 @@ import * as jwt from 'jsonwebtoken'; import {FirebaseNamespace} from '../../src/firebase-namespace'; import {FirebaseServiceInterface} from '../../src/firebase-service'; import {FirebaseApp, FirebaseAppOptions} from '../../src/firebase-app'; -import {Credential, GoogleOAuthAccessToken, ServiceAccountCredential} from '../../src/auth/credential'; +import {Credential, GoogleOAuthAccessToken} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; const ALGORITHM = 'RS256'; const ONE_HOUR_IN_SECONDS = 60 * 60; diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 15f90407d7..3c8ba0c653 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -36,13 +36,17 @@ import { RESERVED_CLAIMS, FIREBASE_AUTH_UPLOAD_ACCOUNT, FIREBASE_AUTH_CREATE_SESSION_COOKIE, EMAIL_ACTION_REQUEST_TYPES, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, } from '../../../src/auth/auth-api-request'; -import {UserImportBuilder, UserImportRecord} from '../../../src/auth/user-import-builder'; +import {UserImportRecord} from '../../../src/auth/user-import-builder'; +import {UserImportBuilder} from '../../../src/auth/user-import-builder-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {ActionCodeSettingsBuilder} from '../../../src/auth/action-code-settings-builder'; import { OIDCAuthProviderConfig, SAMLAuthProviderConfig, OIDCUpdateAuthProviderRequest, - SAMLUpdateAuthProviderRequest, SAMLConfigServerResponse, + SAMLUpdateAuthProviderRequest, } from '../../../src/auth/auth-config'; +import { + SAMLConfigServerResponse, +} from '../../../src/auth/auth-config-internal'; import {UserIdentifier} from '../../../src/auth/identifier'; import {TenantOptions} from '../../../src/auth/tenant'; import { UpdateRequest, UpdateMultiFactorInfoRequest } from '../../../src/auth/user-record'; diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index d5777d787d..f291a28e6f 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -20,14 +20,18 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; + import { - OIDCConfig, SAMLConfig, SAMLConfigServerRequest, - SAMLConfigServerResponse, OIDCConfigServerRequest, - OIDCConfigServerResponse, SAMLUpdateAuthProviderRequest, + SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, OIDCAuthProviderConfig, - EmailSignInConfig, } from '../../../src/auth/auth-config'; +import { + OIDCConfig, SAMLConfig, SAMLConfigServerRequest, + SAMLConfigServerResponse, OIDCConfigServerRequest, + OIDCConfigServerResponse, EmailSignInConfig, +} from '../../../src/auth/auth-config-internal'; + chai.should(); chai.use(sinonChai); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index b869580c1f..608d97a094 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -35,14 +35,15 @@ import { import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import * as validator from '../../../src/utils/validator'; -import { FirebaseTokenVerifier } from '../../../src/auth/token-verifier'; +import { FirebaseTokenVerifier } from '../../../src/auth/token-verifier-internal'; +import {AuthProviderConfigFilter} from '../../../src/auth/auth-config'; import { - AuthProviderConfigFilter, OIDCConfig, SAMLConfig, + OIDCConfig, SAMLConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, -} from '../../../src/auth/auth-config'; +} from '../../../src/auth/auth-config-internal'; import {deepCopy} from '../../../src/utils/deep-copy'; import { TenantManager } from '../../../src/auth/tenant-manager'; -import { ServiceAccountCredential } from '../../../src/auth/credential'; +import { ServiceAccountCredential } from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; chai.should(); diff --git a/test/unit/auth/credential.spec.ts b/test/unit/auth/credential.spec.ts index 881dcb7525..de328ad07a 100644 --- a/test/unit/auth/credential.spec.ts +++ b/test/unit/auth/credential.spec.ts @@ -31,9 +31,12 @@ import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; import { - GoogleOAuthAccessToken, RefreshTokenCredential, ServiceAccountCredential, - ComputeEngineCredential, getApplicationDefault, isApplicationDefault, Credential, + GoogleOAuthAccessToken, RefreshTokenCredential, + getApplicationDefault, isApplicationDefault, Credential, } from '../../../src/auth/credential'; +import { + ServiceAccountCredential, ComputeEngineCredential +} from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; import {Agent} from 'https'; import { FirebaseAppError } from '../../../src/utils/error'; diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 1fb1e24dfa..fa31ef21c4 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,7 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config'; +import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config-internal'; import { Tenant, TenantOptions, TenantServerResponse, } from '../../../src/auth/tenant'; diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index 3a6dbe4df4..6d722a91e3 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -25,10 +25,11 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { - BLACKLISTED_CLAIMS, FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner, + BLACKLISTED_CLAIMS, } from '../../../src/auth/token-generator'; +import {FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner} from '../../../src/auth/token-generator-internal'; -import { ServiceAccountCredential } from '../../../src/auth/credential'; +import { ServiceAccountCredential } from '../../../src/auth/credential-internal'; import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request'; import { FirebaseApp } from '../../../src/firebase-app'; import * as utils from '../utils'; diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index bf95f96380..cbd436ed6a 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -28,10 +28,10 @@ import * as chaiAsPromised from 'chai-as-promised'; import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); import * as mocks from '../../resources/mocks'; -import {FirebaseTokenGenerator, ServiceAccountSigner} from '../../../src/auth/token-generator'; -import * as verifier from '../../../src/auth/token-verifier'; +import {FirebaseTokenGenerator, ServiceAccountSigner} from '../../../src/auth/token-generator-internal'; +import * as verifier from '../../../src/auth/token-verifier-internal'; -import {ServiceAccountCredential} from '../../../src/auth/credential'; +import {ServiceAccountCredential} from '../../../src/auth/credential-internal'; import { AuthClientErrorCode } from '../../../src/utils/error'; import { FirebaseApp } from '../../../src/firebase-app'; diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts index b94e22f1f4..16ee9a1b21 100755 --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.spec.ts @@ -20,9 +20,12 @@ import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; import { - UserImportBuilder, ValidatorFunction, UserImportResult, UserImportRecord, + ValidatorFunction, UserImportResult, UserImportRecord, UploadAccountRequest, } from '../../../src/auth/user-import-builder'; +import { + UserImportBuilder +} from '../../../src/auth/user-import-builder-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {toWebSafeBase64} from '../../../src/utils'; diff --git a/test/unit/firebase-app.spec.ts b/test/unit/firebase-app.spec.ts index f89970bb0c..d4c6551c06 100644 --- a/test/unit/firebase-app.spec.ts +++ b/test/unit/firebase-app.spec.ts @@ -25,7 +25,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as utils from './utils'; import * as mocks from '../resources/mocks'; -import {GoogleOAuthAccessToken, ServiceAccountCredential} from '../../src/auth/credential'; +import {GoogleOAuthAccessToken} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; // import {FirebaseServiceInterface} from '../../src/firebase-service'; import {FirebaseApp, FirebaseAccessToken} from '../../src/firebase-app'; import {FirebaseNamespace, FirebaseNamespaceInternals, FIREBASE_CONFIG_VAR} from '../../src/firebase-namespace'; diff --git a/test/unit/firebase.spec.ts b/test/unit/firebase.spec.ts index 0819bbb704..00f9a524cb 100644 --- a/test/unit/firebase.spec.ts +++ b/test/unit/firebase.spec.ts @@ -27,7 +27,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../resources/mocks'; import * as firebaseAdmin from '../../src/index'; -import {RefreshTokenCredential, ServiceAccountCredential, isApplicationDefault} from '../../src/auth/credential'; +import {RefreshTokenCredential, isApplicationDefault} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; chai.should(); chai.use(chaiAsPromised); diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 2065e2fb49..9631072b01 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -25,7 +25,7 @@ import { } from '../../../src/utils/index'; import {isNonEmptyString} from '../../../src/utils/validator'; import {FirebaseApp, FirebaseAppOptions} from '../../../src/firebase-app'; -import { ComputeEngineCredential } from '../../../src/auth/credential'; +import { ComputeEngineCredential } from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; import * as utils from '../utils'; import { FirebaseAppError } from '../../../src/utils/error'; From c6918b647a887fc07401e1c9a2f468ec3d4fff8a Mon Sep 17 00:00:00 2001 From: MathBunny Date: Mon, 6 Jul 2020 11:17:34 -0400 Subject: [PATCH 2/6] Move internally used types to dedicated classes --- src/auth/auth-api-request.ts | 16 +- src/auth/auth-config-internal.ts | 646 +++++++++++++++++++++ src/auth/auth-config.ts | 625 +------------------- src/auth/auth.ts | 14 +- src/auth/credential-internal.ts | 283 +++++++++ src/auth/credential.ts | 264 +-------- src/auth/identifier-internal.ts | 38 ++ src/auth/tenant.ts | 2 +- src/auth/token-generator-internal.ts | 343 +++++++++++ src/auth/token-generator.ts | 307 +--------- src/auth/token-verifier-internal.ts | 321 ++++++++++ src/auth/token-verifier.ts | 307 +--------- src/auth/user-import-builder-internal.ts | 511 ++++++++++++++++ src/auth/user-import-builder.ts | 497 +--------------- src/firebase-namespace.ts | 2 +- src/utils/index.ts | 2 +- test/resources/mocks.ts | 3 +- test/unit/auth/auth-api-request.spec.ts | 8 +- test/unit/auth/auth-config.spec.ts | 12 +- test/unit/auth/auth.spec.ts | 9 +- test/unit/auth/credential.spec.ts | 7 +- test/unit/auth/tenant.spec.ts | 2 +- test/unit/auth/token-generator.spec.ts | 5 +- test/unit/auth/token-verifier.spec.ts | 6 +- test/unit/auth/user-import-builder.spec.ts | 5 +- test/unit/firebase-app.spec.ts | 3 +- test/unit/firebase.spec.ts | 3 +- test/unit/utils/index.spec.ts | 2 +- 28 files changed, 2217 insertions(+), 2026 deletions(-) create mode 100644 src/auth/auth-config-internal.ts create mode 100644 src/auth/credential-internal.ts create mode 100644 src/auth/identifier-internal.ts create mode 100644 src/auth/token-generator-internal.ts create mode 100644 src/auth/token-verifier-internal.ts create mode 100644 src/auth/user-import-builder-internal.ts diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index b573ebf181..b87a00af2a 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -19,10 +19,11 @@ import * as validator from '../utils/validator'; import {deepCopy, deepExtend} from '../utils/deep-copy'; import { - UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, - isProviderIdentifier, UidIdentifier, EmailIdentifier, PhoneIdentifier, + UserIdentifier, UidIdentifier, EmailIdentifier, PhoneIdentifier, ProviderIdentifier, } from './identifier'; +import { isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, + isProviderIdentifier} from './identifier-internal'; import {FirebaseApp} from '../firebase-app'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { @@ -30,16 +31,19 @@ import { } from '../utils/api-request'; import {CreateRequest, UpdateRequest} from './user-record'; import { - UserImportBuilder, UserImportOptions, UserImportRecord, - UserImportResult, AuthFactorInfo, convertMultiFactorInfoToServerFormat, + UserImportOptions, UserImportRecord, UserImportResult } from './user-import-builder'; +import {UserImportBuilder, AuthFactorInfo, convertMultiFactorInfoToServerFormat} from './user-import-builder-internal'; import * as utils from '../utils/index'; import {ActionCodeSettings, ActionCodeSettingsBuilder} from './action-code-settings-builder'; import { - SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, - OIDCConfigServerRequest, SAMLConfigServerRequest, AuthProviderConfig, + AuthProviderConfig, OIDCUpdateAuthProviderRequest, SAMLUpdateAuthProviderRequest, } from './auth-config'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, + OIDCConfigServerRequest, SAMLConfigServerRequest +} from './auth-config-internal'; import {Tenant, TenantOptions, TenantServerResponse} from './tenant'; diff --git a/src/auth/auth-config-internal.ts b/src/auth/auth-config-internal.ts new file mode 100644 index 0000000000..f6e7acf9c0 --- /dev/null +++ b/src/auth/auth-config-internal.ts @@ -0,0 +1,646 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from '../utils/validator'; +import {deepCopy} from '../utils/deep-copy'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import {SAMLAuthProviderConfig, SAMLAuthProviderRequest, OIDCAuthProviderConfig, OIDCUpdateAuthProviderRequest} from './auth-config'; + +/** The server side SAML configuration request interface. */ +export interface SAMLConfigServerRequest { + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; + [key: string]: any; +} + +/** The server side SAML configuration response interface. */ +export interface SAMLConfigServerResponse { + // Used when getting config. + // projects/${projectId}/inboundSamlConfigs/${providerId} + name?: string; + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; +} + +/** The server side OIDC configuration request interface. */ +export interface OIDCConfigServerRequest { + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; + [key: string]: any; +} + +/** The server side OIDC configuration response interface. */ +export interface OIDCConfigServerResponse { + // Used when getting config. + // projects/${projectId}/oauthIdpConfigs/${providerId} + name?: string; + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; +} + +/** The email provider configuration interface. */ +export interface EmailSignInProviderConfig { + enabled?: boolean; + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + + +/** The email provider configuration interface. */ +export interface EmailSignInProviderConfig { + enabled?: boolean; + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + +/** The server side email configuration request interface. */ +export interface EmailSignInConfigServerRequest { + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; +} + + +/** + * Defines the email sign-in config class used to convert client side EmailSignInConfig + * to a format that is understood by the Auth server. + */ +export class EmailSignInConfig implements EmailSignInProviderConfig { + public readonly enabled?: boolean; + public readonly passwordRequired?: boolean; + + /** + * Static method to convert a client side request to a EmailSignInConfigServerRequest. + * Throws an error if validation fails. + * + * @param {any} options The options object to convert to a server request. + * @return {EmailSignInConfigServerRequest} The resulting server request. + */ + public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { + const request: EmailSignInConfigServerRequest = {}; + EmailSignInConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'enabled')) { + request.allowPasswordSignup = options.enabled; + } + if (Object.prototype.hasOwnProperty.call(options, 'passwordRequired')) { + request.enableEmailLinkSignin = !options.passwordRequired; + } + return request; + } + + /** + * Validates the EmailSignInConfig options object. Throws an error on failure. + * + * @param {any} options The options object to validate. + */ + private static validate(options: EmailSignInProviderConfig): void { + // TODO: Validate the request. + const validKeys = { + enabled: true, + passwordRequired: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid EmailSignInConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.enabled" must be a boolean.', + ); + } + if (typeof options.passwordRequired !== 'undefined' && + !validator.isBoolean(options.passwordRequired)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.passwordRequired" must be a boolean.', + ); + } + } + + /** + * The EmailSignInConfig constructor. + * + * @param {any} response The server side response used to initialize the + * EmailSignInConfig object. + * @constructor + */ + constructor(response: {[key: string]: any}) { + if (typeof response.allowPasswordSignup === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); + } + this.enabled = response.allowPasswordSignup; + this.passwordRequired = !response.enableEmailLinkSignin; + } + + /** @return {object} The plain object representation of the email sign-in config. */ + public toJSON(): object { + return { + enabled: this.enabled, + passwordRequired: this.passwordRequired, + }; + } +} + + +/** + * Defines the SAMLConfig class used to convert a client side configuration to its + * server side representation. + */ +export class SAMLConfig implements SAMLAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly idpEntityId: string; + public readonly ssoURL: string; + public readonly x509Certificates: string[]; + public readonly rpEntityId: string; + public readonly callbackURL?: string; + public readonly enableRequestSigning?: boolean; + + /** + * Converts a client side request to a SAMLConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a SAMLConfig request, + * returns null. + * + * @param {SAMLAuthProviderRequest} options The options object to convert to a server request. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + * @return {?SAMLConfigServerRequest} The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: SAMLAuthProviderRequest, + ignoreMissingFields = false): SAMLConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: SAMLConfigServerRequest = {}; + // Validate options. + SAMLConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + // IdP config. + if (options.idpEntityId || options.ssoURL || options.x509Certificates) { + request.idpConfig = { + idpEntityId: options.idpEntityId, + ssoUrl: options.ssoURL, + signRequest: options.enableRequestSigning, + idpCertificates: typeof options.x509Certificates === 'undefined' ? undefined : [], + }; + if (options.x509Certificates) { + for (const cert of (options.x509Certificates || [])) { + request.idpConfig!.idpCertificates!.push({x509Certificate: cert}); + } + } + } + // RP config. + if (options.callbackURL || options.rpEntityId) { + request.spConfig = { + spEntityId: options.rpEntityId, + callbackUri: options.callbackURL, + }; + } + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name. + * @return {?string} The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/inboundSamlConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/inboundSamlConfigs\/(saml\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param {any} providerId The provider ID to check. + * @return {boolean} Whether the provider ID corresponds to a SAML provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0; + } + + /** + * Validates the SAMLConfig options object. Throws an error on failure. + * + * @param {SAMLAuthProviderRequest} options The options object to validate. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + */ + public static validate(options: SAMLAuthProviderRequest, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + idpEntityId: true, + ssoURL: true, + x509Certificates: true, + rpEntityId: true, + callbackURL: true, + enableRequestSigning: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SAML config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('saml.') !== 0) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + } else if (!ignoreMissingFields) { + // providerId is required and not provided correctly. + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && + !validator.isNonEmptyString(options.idpEntityId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && + !validator.isURL(options.ssoURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && + !validator.isNonEmptyString(options.rpEntityId)) { + throw new FirebaseAuthError( + !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && + !validator.isURL(options.callbackURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && + !validator.isArray(options.x509Certificates)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + (options.x509Certificates || []).forEach((cert: string) => { + if (!validator.isNonEmptyString(cert)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + }); + if (typeof options.enableRequestSigning !== 'undefined' && + !validator.isBoolean(options.enableRequestSigning)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.displayName" must be a valid string.', + ); + } + } + + /** + * The SAMLConfig constructor. + * + * @param {any} response The server side response used to initialize the SAMLConfig object. + * @constructor + */ + constructor(response: SAMLConfigServerResponse) { + if (!response || + !response.idpConfig || + !response.idpConfig.idpEntityId || + !response.idpConfig.ssoUrl || + !response.spConfig || + !response.spConfig.spEntityId || + !response.name || + !(validator.isString(response.name) && + SAMLConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + + const providerId = SAMLConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + // RP config. + this.rpEntityId = response.spConfig.spEntityId; + this.callbackURL = response.spConfig.callbackUri; + // IdP config. + this.idpEntityId = response.idpConfig.idpEntityId; + this.ssoURL = response.idpConfig.ssoUrl; + this.enableRequestSigning = !!response.idpConfig.signRequest; + const x509Certificates: string[] = []; + for (const cert of (response.idpConfig.idpCertificates || [])) { + if (cert.x509Certificate) { + x509Certificates.push(cert.x509Certificate); + } + } + this.x509Certificates = x509Certificates; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + } + + /** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */ + public toJSON(): SAMLAuthProviderConfig { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + idpEntityId: this.idpEntityId, + ssoURL: this.ssoURL, + x509Certificates: deepCopy(this.x509Certificates), + rpEntityId: this.rpEntityId, + callbackURL: this.callbackURL, + enableRequestSigning: this.enableRequestSigning, + }; + } +} + +/** The generic request interface for updating/creating an OIDC Auth provider. */ +export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest { + providerId?: string; +} + +/** + * Defines the OIDCConfig class used to convert a client side configuration to its + * server side representation. + */ +export class OIDCConfig implements OIDCAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly issuer: string; + public readonly clientId: string; + + /** + * Converts a client side request to a OIDCConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a OIDCConfig request, + * returns null. + * + * @param {OIDCAuthProviderRequest} options The options object to convert to a server request. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + * @return {?OIDCConfigServerRequest} The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: OIDCAuthProviderRequest, + ignoreMissingFields = false): OIDCConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: OIDCConfigServerRequest = {}; + // Validate options. + OIDCConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + request.issuer = options.issuer; + request.clientId = options.clientId; + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name + * @return {?string} The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/oauthIdpConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/oauthIdpConfigs\/(oidc\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param {any} providerId The provider ID to check. + * @return {boolean} Whether the provider ID corresponds to an OIDC provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0; + } + + /** + * Validates the OIDCConfig options object. Throws an error on failure. + * + * @param {OIDCAuthProviderRequest} options The options object to validate. + * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. + */ + public static validate(options: OIDCAuthProviderRequest, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + clientId: true, + issuer: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid OIDC config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('oidc.') !== 0) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + } else if (!ignoreMissingFields) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && + !validator.isNonEmptyString(options.clientId)) { + throw new FirebaseAuthError( + !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, + '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && + !validator.isURL(options.issuer)) { + throw new FirebaseAuthError( + !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.displayName" must be a valid string.', + ); + } + } + + /** + * The OIDCConfig constructor. + * + * @param {any} response The server side response used to initialize the OIDCConfig object. + * @constructor + */ + constructor(response: OIDCConfigServerResponse) { + if (!response || + !response.issuer || + !response.clientId || + !response.name || + !(validator.isString(response.name) && + OIDCConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); + } + + const providerId = OIDCConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + this.clientId = response.clientId; + this.issuer = response.issuer; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + } + + /** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */ + public toJSON(): OIDCAuthProviderConfig { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + issuer: this.issuer, + clientId: this.clientId, + }; + } +} diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 95527eb1a7..559bf64bf4 100755 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import * as validator from '../utils/validator'; -import {deepCopy} from '../utils/deep-copy'; -import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; - /** The filter interface used for listing provider configurations. */ export interface AuthProviderConfigFilter { @@ -49,66 +45,6 @@ export interface SAMLAuthProviderConfig extends AuthProviderConfig { enableRequestSigning?: boolean; } -/** The server side SAML configuration request interface. */ -export interface SAMLConfigServerRequest { - idpConfig?: { - idpEntityId?: string; - ssoUrl?: string; - idpCertificates?: Array<{ - x509Certificate: string; - }>; - signRequest?: boolean; - }; - spConfig?: { - spEntityId?: string; - callbackUri?: string; - }; - displayName?: string; - enabled?: boolean; - [key: string]: any; -} - -/** The server side SAML configuration response interface. */ -export interface SAMLConfigServerResponse { - // Used when getting config. - // projects/${projectId}/inboundSamlConfigs/${providerId} - name?: string; - idpConfig?: { - idpEntityId?: string; - ssoUrl?: string; - idpCertificates?: Array<{ - x509Certificate: string; - }>; - signRequest?: boolean; - }; - spConfig?: { - spEntityId?: string; - callbackUri?: string; - }; - displayName?: string; - enabled?: boolean; -} - -/** The server side OIDC configuration request interface. */ -export interface OIDCConfigServerRequest { - clientId?: string; - issuer?: string; - displayName?: string; - enabled?: boolean; - [key: string]: any; -} - -/** The server side OIDC configuration response interface. */ -export interface OIDCConfigServerResponse { - // Used when getting config. - // projects/${projectId}/oauthIdpConfigs/${providerId} - name?: string; - clientId?: string; - issuer?: string; - displayName?: string; - enabled?: boolean; -} - /** The public API response interface for listing provider configs. */ export interface ListProviderConfigResults { providerConfigs: AuthProviderConfig[]; @@ -140,564 +76,5 @@ export interface OIDCUpdateAuthProviderRequest { displayName?: string; } -/** The generic request interface for updating/creating an OIDC Auth provider. */ -export interface OIDCAuthProviderRequest extends OIDCUpdateAuthProviderRequest { - providerId?: string; -} - /** The public API request interface for updating a generic Auth provider. */ -export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; - -/** The email provider configuration interface. */ -export interface EmailSignInProviderConfig { - enabled?: boolean; - passwordRequired?: boolean; // In the backend API, default is true if not provided -} - -/** The server side email configuration request interface. */ -export interface EmailSignInConfigServerRequest { - allowPasswordSignup?: boolean; - enableEmailLinkSignin?: boolean; -} - - -/** - * Defines the email sign-in config class used to convert client side EmailSignInConfig - * to a format that is understood by the Auth server. - */ -export class EmailSignInConfig implements EmailSignInProviderConfig { - public readonly enabled?: boolean; - public readonly passwordRequired?: boolean; - - /** - * Static method to convert a client side request to a EmailSignInConfigServerRequest. - * Throws an error if validation fails. - * - * @param {any} options The options object to convert to a server request. - * @return {EmailSignInConfigServerRequest} The resulting server request. - */ - public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { - const request: EmailSignInConfigServerRequest = {}; - EmailSignInConfig.validate(options); - if (Object.prototype.hasOwnProperty.call(options, 'enabled')) { - request.allowPasswordSignup = options.enabled; - } - if (Object.prototype.hasOwnProperty.call(options, 'passwordRequired')) { - request.enableEmailLinkSignin = !options.passwordRequired; - } - return request; - } - - /** - * Validates the EmailSignInConfig options object. Throws an error on failure. - * - * @param {any} options The options object to validate. - */ - private static validate(options: EmailSignInProviderConfig): void { - // TODO: Validate the request. - const validKeys = { - enabled: true, - passwordRequired: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig" must be a non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${key}" is not a valid EmailSignInConfig parameter.`, - ); - } - } - // Validate content. - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig.enabled" must be a boolean.', - ); - } - if (typeof options.passwordRequired !== 'undefined' && - !validator.isBoolean(options.passwordRequired)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"EmailSignInConfig.passwordRequired" must be a boolean.', - ); - } - } - - /** - * The EmailSignInConfig constructor. - * - * @param {any} response The server side response used to initialize the - * EmailSignInConfig object. - * @constructor - */ - constructor(response: {[key: string]: any}) { - if (typeof response.allowPasswordSignup === 'undefined') { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); - } - this.enabled = response.allowPasswordSignup; - this.passwordRequired = !response.enableEmailLinkSignin; - } - - /** @return {object} The plain object representation of the email sign-in config. */ - public toJSON(): object { - return { - enabled: this.enabled, - passwordRequired: this.passwordRequired, - }; - } -} - - -/** - * Defines the SAMLConfig class used to convert a client side configuration to its - * server side representation. - */ -export class SAMLConfig implements SAMLAuthProviderConfig { - public readonly enabled: boolean; - public readonly displayName?: string; - public readonly providerId: string; - public readonly idpEntityId: string; - public readonly ssoURL: string; - public readonly x509Certificates: string[]; - public readonly rpEntityId: string; - public readonly callbackURL?: string; - public readonly enableRequestSigning?: boolean; - - /** - * Converts a client side request to a SAMLConfigServerRequest which is the format - * accepted by the backend server. - * Throws an error if validation fails. If the request is not a SAMLConfig request, - * returns null. - * - * @param {SAMLAuthProviderRequest} options The options object to convert to a server request. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - * @return {?SAMLConfigServerRequest} The resulting server request or null if not valid. - */ - public static buildServerRequest( - options: SAMLAuthProviderRequest, - ignoreMissingFields = false): SAMLConfigServerRequest | null { - const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); - if (!makeRequest) { - return null; - } - const request: SAMLConfigServerRequest = {}; - // Validate options. - SAMLConfig.validate(options, ignoreMissingFields); - request.enabled = options.enabled; - request.displayName = options.displayName; - // IdP config. - if (options.idpEntityId || options.ssoURL || options.x509Certificates) { - request.idpConfig = { - idpEntityId: options.idpEntityId, - ssoUrl: options.ssoURL, - signRequest: options.enableRequestSigning, - idpCertificates: typeof options.x509Certificates === 'undefined' ? undefined : [], - }; - if (options.x509Certificates) { - for (const cert of (options.x509Certificates || [])) { - request.idpConfig!.idpCertificates!.push({x509Certificate: cert}); - } - } - } - // RP config. - if (options.callbackURL || options.rpEntityId) { - request.spConfig = { - spEntityId: options.rpEntityId, - callbackUri: options.callbackURL, - }; - } - return request; - } - - /** - * Returns the provider ID corresponding to the resource name if available. - * - * @param {string} resourceName The server side resource name. - * @return {?string} The provider ID corresponding to the resource, null otherwise. - */ - public static getProviderIdFromResourceName(resourceName: string): string | null { - // name is of form projects/project1/inboundSamlConfigs/providerId1 - const matchProviderRes = resourceName.match(/\/inboundSamlConfigs\/(saml\..*)$/); - if (!matchProviderRes || matchProviderRes.length < 2) { - return null; - } - return matchProviderRes[1]; - } - - /** - * @param {any} providerId The provider ID to check. - * @return {boolean} Whether the provider ID corresponds to a SAML provider. - */ - public static isProviderId(providerId: any): providerId is string { - return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0; - } - - /** - * Validates the SAMLConfig options object. Throws an error on failure. - * - * @param {SAMLAuthProviderRequest} options The options object to validate. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - */ - public static validate(options: SAMLAuthProviderRequest, ignoreMissingFields = false): void { - const validKeys = { - enabled: true, - displayName: true, - providerId: true, - idpEntityId: true, - ssoURL: true, - x509Certificates: true, - rpEntityId: true, - callbackURL: true, - enableRequestSigning: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig" must be a valid non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - `"${key}" is not a valid SAML config parameter.`, - ); - } - } - // Required fields. - if (validator.isNonEmptyString(options.providerId)) { - if (options.providerId.indexOf('saml.') !== 0) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PROVIDER_ID, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - } else if (!ignoreMissingFields) { - // providerId is required and not provided correctly. - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', - ); - } - if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && - !validator.isNonEmptyString(options.idpEntityId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && - !validator.isURL(options.ssoURL)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', - ); - } - if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && - !validator.isNonEmptyString(options.rpEntityId)) { - throw new FirebaseAuthError( - !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && - !validator.isURL(options.callbackURL)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', - ); - } - if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && - !validator.isArray(options.x509Certificates)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - (options.x509Certificates || []).forEach((cert: string) => { - if (!validator.isNonEmptyString(cert)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', - ); - } - }); - if (typeof options.enableRequestSigning !== 'undefined' && - !validator.isBoolean(options.enableRequestSigning)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', - ); - } - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.enabled" must be a boolean.', - ); - } - if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"SAMLAuthProviderConfig.displayName" must be a valid string.', - ); - } - } - - /** - * The SAMLConfig constructor. - * - * @param {any} response The server side response used to initialize the SAMLConfig object. - * @constructor - */ - constructor(response: SAMLConfigServerResponse) { - if (!response || - !response.idpConfig || - !response.idpConfig.idpEntityId || - !response.idpConfig.ssoUrl || - !response.spConfig || - !response.spConfig.spEntityId || - !response.name || - !(validator.isString(response.name) && - SAMLConfig.getProviderIdFromResourceName(response.name))) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - - const providerId = SAMLConfig.getProviderIdFromResourceName(response.name); - if (!providerId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - this.providerId = providerId; - - // RP config. - this.rpEntityId = response.spConfig.spEntityId; - this.callbackURL = response.spConfig.callbackUri; - // IdP config. - this.idpEntityId = response.idpConfig.idpEntityId; - this.ssoURL = response.idpConfig.ssoUrl; - this.enableRequestSigning = !!response.idpConfig.signRequest; - const x509Certificates: string[] = []; - for (const cert of (response.idpConfig.idpCertificates || [])) { - if (cert.x509Certificate) { - x509Certificates.push(cert.x509Certificate); - } - } - this.x509Certificates = x509Certificates; - // When enabled is undefined, it takes its default value of false. - this.enabled = !!response.enabled; - this.displayName = response.displayName; - } - - /** @return {SAMLAuthProviderConfig} The plain object representation of the SAMLConfig. */ - public toJSON(): SAMLAuthProviderConfig { - return { - enabled: this.enabled, - displayName: this.displayName, - providerId: this.providerId, - idpEntityId: this.idpEntityId, - ssoURL: this.ssoURL, - x509Certificates: deepCopy(this.x509Certificates), - rpEntityId: this.rpEntityId, - callbackURL: this.callbackURL, - enableRequestSigning: this.enableRequestSigning, - }; - } -} - -/** - * Defines the OIDCConfig class used to convert a client side configuration to its - * server side representation. - */ -export class OIDCConfig implements OIDCAuthProviderConfig { - public readonly enabled: boolean; - public readonly displayName?: string; - public readonly providerId: string; - public readonly issuer: string; - public readonly clientId: string; - - /** - * Converts a client side request to a OIDCConfigServerRequest which is the format - * accepted by the backend server. - * Throws an error if validation fails. If the request is not a OIDCConfig request, - * returns null. - * - * @param {OIDCAuthProviderRequest} options The options object to convert to a server request. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - * @return {?OIDCConfigServerRequest} The resulting server request or null if not valid. - */ - public static buildServerRequest( - options: OIDCAuthProviderRequest, - ignoreMissingFields = false): OIDCConfigServerRequest | null { - const makeRequest = validator.isNonNullObject(options) && - (options.providerId || ignoreMissingFields); - if (!makeRequest) { - return null; - } - const request: OIDCConfigServerRequest = {}; - // Validate options. - OIDCConfig.validate(options, ignoreMissingFields); - request.enabled = options.enabled; - request.displayName = options.displayName; - request.issuer = options.issuer; - request.clientId = options.clientId; - return request; - } - - /** - * Returns the provider ID corresponding to the resource name if available. - * - * @param {string} resourceName The server side resource name - * @return {?string} The provider ID corresponding to the resource, null otherwise. - */ - public static getProviderIdFromResourceName(resourceName: string): string | null { - // name is of form projects/project1/oauthIdpConfigs/providerId1 - const matchProviderRes = resourceName.match(/\/oauthIdpConfigs\/(oidc\..*)$/); - if (!matchProviderRes || matchProviderRes.length < 2) { - return null; - } - return matchProviderRes[1]; - } - - /** - * @param {any} providerId The provider ID to check. - * @return {boolean} Whether the provider ID corresponds to an OIDC provider. - */ - public static isProviderId(providerId: any): providerId is string { - return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0; - } - - /** - * Validates the OIDCConfig options object. Throws an error on failure. - * - * @param {OIDCAuthProviderRequest} options The options object to validate. - * @param {boolean=} ignoreMissingFields Whether to ignore missing fields. - */ - public static validate(options: OIDCAuthProviderRequest, ignoreMissingFields = false): void { - const validKeys = { - enabled: true, - displayName: true, - providerId: true, - clientId: true, - issuer: true, - }; - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig" must be a valid non-null object.', - ); - } - // Check for unsupported top level attributes. - for (const key in options) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - `"${key}" is not a valid OIDC config parameter.`, - ); - } - } - // Required fields. - if (validator.isNonEmptyString(options.providerId)) { - if (options.providerId.indexOf('oidc.') !== 0) { - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', - ); - } - } else if (!ignoreMissingFields) { - throw new FirebaseAuthError( - !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, - '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', - ); - } - if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && - !validator.isNonEmptyString(options.clientId)) { - throw new FirebaseAuthError( - !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, - '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', - ); - } - if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && - !validator.isURL(options.issuer)) { - throw new FirebaseAuthError( - !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', - ); - } - if (typeof options.enabled !== 'undefined' && - !validator.isBoolean(options.enabled)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.enabled" must be a boolean.', - ); - } - if (typeof options.displayName !== 'undefined' && - !validator.isString(options.displayName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - '"OIDCAuthProviderConfig.displayName" must be a valid string.', - ); - } - } - - /** - * The OIDCConfig constructor. - * - * @param {any} response The server side response used to initialize the OIDCConfig object. - * @constructor - */ - constructor(response: OIDCConfigServerResponse) { - if (!response || - !response.issuer || - !response.clientId || - !response.name || - !(validator.isString(response.name) && - OIDCConfig.getProviderIdFromResourceName(response.name))) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); - } - - const providerId = OIDCConfig.getProviderIdFromResourceName(response.name); - if (!providerId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); - } - this.providerId = providerId; - - this.clientId = response.clientId; - this.issuer = response.issuer; - // When enabled is undefined, it takes its default value of false. - this.enabled = !!response.enabled; - this.displayName = response.displayName; - } - - /** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */ - public toJSON(): OIDCAuthProviderConfig { - return { - enabled: this.enabled, - displayName: this.displayName, - providerId: this.providerId, - issuer: this.issuer, - clientId: this.clientId, - }; - } -} +export type UpdateAuthProviderRequest = SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; \ No newline at end of file diff --git a/src/auth/auth.ts b/src/auth/auth.ts index df52c17754..a2c5a0d62c 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -15,13 +15,14 @@ */ import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; +import { UserIdentifier } from './identifier'; import { - UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, -} from './identifier'; + isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, +} from './identifier-internal'; import {FirebaseApp} from '../firebase-app'; import * as admin from '../'; // import {FirebaseNamespace} from '../firebase-namespace'; -import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator'; +import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator-internal'; import { AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, } from './auth-api-request'; @@ -33,12 +34,15 @@ import { import * as utils from '../utils/index'; import * as validator from '../utils/validator'; -import { FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; +import { createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; +import { FirebaseTokenVerifier } from './token-verifier-internal'; import {ActionCodeSettings} from './action-code-settings-builder'; import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, - SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, } from './auth-config'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, +} from './auth-config-internal'; import {TenantManager} from './tenant-manager'; const Auth_: {[name: string]: Auth} = {}; diff --git a/src/auth/credential-internal.ts b/src/auth/credential-internal.ts new file mode 100644 index 0000000000..d4a7c3fd4a --- /dev/null +++ b/src/auth/credential-internal.ts @@ -0,0 +1,283 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs = require('fs'); +import {Credential, GoogleOAuthAccessToken} from './credential'; +import {Agent} from 'http'; +import {HttpClient, HttpRequestConfig, HttpError, HttpResponse} from '../utils/api-request'; +import {AppErrorCodes, FirebaseAppError} from '../utils/error'; +import * as util from '../utils/validator'; + +const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; +const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; +const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; + +// NOTE: the Google Metadata Service uses HTTP over a vlan +const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; +const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; +const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; + +const ONE_HOUR_IN_SECONDS = 60 * 60; +const JWT_ALGORITHM = 'RS256'; + + +/** + * Implementation of Credential that uses a service account. + */ +export class ServiceAccountCredential implements Credential { + public readonly projectId: string; + public readonly privateKey: string; + public readonly clientEmail: string; + + private readonly httpClient: HttpClient; + + /** + * Creates a new ServiceAccountCredential from the given parameters. + * + * @param serviceAccountPathOrObject Service account json object or path to a service account json file. + * @param httpAgent Optional http.Agent to use when calling the remote token server. + * @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the + * environment, as opposed to being explicitly specified by the developer. + * + * @constructor + */ + constructor( + serviceAccountPathOrObject: string | object, + private readonly httpAgent?: Agent, + readonly implicit: boolean = false) { + + const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ? + ServiceAccount.fromPath(serviceAccountPathOrObject) + : new ServiceAccount(serviceAccountPathOrObject); + this.projectId = serviceAccount.projectId; + this.privateKey = serviceAccount.privateKey; + this.clientEmail = serviceAccount.clientEmail; + this.httpClient = new HttpClient(); + } + + public getAccessToken(): Promise { + const token = this.createAuthJwt_(); + const postData = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3A' + + 'grant-type%3Ajwt-bearer&assertion=' + token; + const request: HttpRequestConfig = { + method: 'POST', + url: `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: postData, + httpAgent: this.httpAgent, + }; + return requestAccessToken(this.httpClient, request); + } + + private createAuthJwt_(): string { + const claims = { + scope: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/firebase.database', + 'https://www.googleapis.com/auth/firebase.messaging', + 'https://www.googleapis.com/auth/identitytoolkit', + 'https://www.googleapis.com/auth/userinfo.email', + ].join(' '), + }; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const jwt = require('jsonwebtoken'); + // This method is actually synchronous so we can capture and return the buffer. + return jwt.sign(claims, this.privateKey, { + audience: GOOGLE_TOKEN_AUDIENCE, + expiresIn: ONE_HOUR_IN_SECONDS, + issuer: this.clientEmail, + algorithm: JWT_ALGORITHM, + }); + } +} + + + +/** + * Implementation of Credential that gets access tokens from the metadata service available + * in the Google Cloud Platform. This authenticates the process as the default service account + * of an App Engine instance or Google Compute Engine machine. + */ +export class ComputeEngineCredential implements Credential { + + private readonly httpClient = new HttpClient(); + private readonly httpAgent?: Agent; + private projectId?: string; + + constructor(httpAgent?: Agent) { + this.httpAgent = httpAgent; + } + + public getAccessToken(): Promise { + const request = this.buildRequest(GOOGLE_METADATA_SERVICE_TOKEN_PATH); + return requestAccessToken(this.httpClient, request); + } + + public getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); + } + + const request = this.buildRequest(GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH); + return this.httpClient.send(request) + .then((resp) => { + this.projectId = resp.text!; + return this.projectId; + }) + .catch((err) => { + const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + `Failed to determine project ID: ${detail}`); + }); + } + + private buildRequest(urlPath: string): HttpRequestConfig { + return { + method: 'GET', + url: `http://${GOOGLE_METADATA_SERVICE_HOST}${urlPath}`, + headers: { + 'Metadata-Flavor': 'Google', + }, + httpAgent: this.httpAgent, + }; + } +} + + +/** + * A struct containing the properties necessary to use service account JSON credentials. + */ +class ServiceAccount { + + public readonly projectId: string; + public readonly privateKey: string; + public readonly clientEmail: string; + + public static fromPath(filePath: string): ServiceAccount { + try { + return new ServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch (error) { + // Throw a nicely formed error message if the file contents cannot be parsed + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Failed to parse service account json file: ' + error, + ); + } + } + + constructor(json: object) { + if (!util.isNonNullObject(json)) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Service account must be an object.', + ); + } + + copyAttr(this, json, 'projectId', 'project_id'); + copyAttr(this, json, 'privateKey', 'private_key'); + copyAttr(this, json, 'clientEmail', 'client_email'); + + let errorMessage; + if (!util.isNonEmptyString(this.projectId)) { + errorMessage = 'Service account object must contain a string "project_id" property.'; + } else if (!util.isNonEmptyString(this.privateKey)) { + errorMessage = 'Service account object must contain a string "private_key" property.'; + } else if (!util.isNonEmptyString(this.clientEmail)) { + errorMessage = 'Service account object must contain a string "client_email" property.'; + } + + if (typeof errorMessage !== 'undefined') { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const forge = require('node-forge'); + try { + forge.pki.privateKeyFromPem(this.privateKey); + } catch (error) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Failed to parse private key: ' + error); + } + } +} + +/** + * Obtain a new OAuth2 token by making a remote service call. + */ +export function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Promise { + return client.send(request).then((resp) => { + const json = resp.data; + if (!json.access_token || !json.expires_in) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + `Unexpected response while fetching access token: ${ JSON.stringify(json) }`, + ); + } + return json; + }).catch((err) => { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err)); + }); +} + +/** + * Constructs a human-readable error message from the given Error. + */ +function getErrorMessage(err: Error): string { + const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; + return `Error fetching access token: ${detail}`; +} + + +/** + * Extracts details from the given HTTP error response, and returns a human-readable description. If + * the response is JSON-formatted, looks up the error and error_description fields sent by the + * Google Auth servers. Otherwise returns the entire response payload as the error detail. + */ +function getDetailFromResponse(response: HttpResponse): string { + if (response.isJson() && response.data.error) { + const json = response.data; + let detail = json.error; + if (json.error_description) { + detail += ' (' + json.error_description + ')'; + } + return detail; + } + return response.text || 'Missing error payload'; +} + + +/** + * Copies the specified property from one object to another. + * + * If no property exists by the given "key", looks for a property identified by "alt", and copies it instead. + * This can be used to implement behaviors such as "copy property myKey or my_key". + * + * @param to Target object to copy the property into. + * @param from Source object to copy the property from. + * @param key Name of the property to copy. + * @param alt Alternative name of the property to copy. + */ +export function copyAttr(to: {[key: string]: any}, from: {[key: string]: any}, key: string, alt: string): void { + const tmp = from[key] || from[alt]; + if (typeof tmp !== 'undefined') { + to[key] = tmp; + } +} \ No newline at end of file diff --git a/src/auth/credential.ts b/src/auth/credential.ts index 43de4d3ed3..f3c60de618 100644 --- a/src/auth/credential.ts +++ b/src/auth/credential.ts @@ -1,5 +1,5 @@ /*! - * Copyright 2017 Google Inc. + * Copyright 2020 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,24 +15,16 @@ */ // Use untyped import syntax for Node built-ins -import fs = require('fs'); import os = require('os'); +import fs = require('fs'); import path = require('path'); import {AppErrorCodes, FirebaseAppError} from '../utils/error'; -import {HttpClient, HttpRequestConfig, HttpError, HttpResponse} from '../utils/api-request'; +import {HttpClient, HttpRequestConfig} from '../utils/api-request'; +import {ServiceAccountCredential, ComputeEngineCredential, requestAccessToken, copyAttr} from './credential-internal'; import {Agent} from 'http'; import * as util from '../utils/validator'; -const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token'; -const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com'; -const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token'; - -// NOTE: the Google Metadata Service uses HTTP over a vlan -const GOOGLE_METADATA_SERVICE_HOST = 'metadata.google.internal'; -const GOOGLE_METADATA_SERVICE_TOKEN_PATH = '/computeMetadata/v1/instance/service-accounts/default/token'; -const GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH = '/computeMetadata/v1/project/project-id'; - const configDir = (() => { // Windows has a dedicated low-rights location for apps at ~/Application Data const sys = os.platform(); @@ -50,9 +42,6 @@ const GCLOUD_CREDENTIAL_PATH = configDir && path.resolve(configDir, GCLOUD_CREDE const REFRESH_TOKEN_HOST = 'www.googleapis.com'; const REFRESH_TOKEN_PATH = '/oauth2/v4/token'; -const ONE_HOUR_IN_SECONDS = 60 * 60; -const JWT_ALGORITHM = 'RS256'; - /** * Interface for Google OAuth 2.0 access tokens. */ @@ -70,189 +59,6 @@ export interface Credential { getAccessToken(): Promise; } -/** - * Implementation of Credential that uses a service account. - */ -export class ServiceAccountCredential implements Credential { - - public readonly projectId: string; - public readonly privateKey: string; - public readonly clientEmail: string; - - private readonly httpClient: HttpClient; - - /** - * Creates a new ServiceAccountCredential from the given parameters. - * - * @param serviceAccountPathOrObject Service account json object or path to a service account json file. - * @param httpAgent Optional http.Agent to use when calling the remote token server. - * @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the - * environment, as opposed to being explicitly specified by the developer. - * - * @constructor - */ - constructor( - serviceAccountPathOrObject: string | object, - private readonly httpAgent?: Agent, - readonly implicit: boolean = false) { - - const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ? - ServiceAccount.fromPath(serviceAccountPathOrObject) - : new ServiceAccount(serviceAccountPathOrObject); - this.projectId = serviceAccount.projectId; - this.privateKey = serviceAccount.privateKey; - this.clientEmail = serviceAccount.clientEmail; - this.httpClient = new HttpClient(); - } - - public getAccessToken(): Promise { - const token = this.createAuthJwt_(); - const postData = 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3A' + - 'grant-type%3Ajwt-bearer&assertion=' + token; - const request: HttpRequestConfig = { - method: 'POST', - url: `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - data: postData, - httpAgent: this.httpAgent, - }; - return requestAccessToken(this.httpClient, request); - } - - private createAuthJwt_(): string { - const claims = { - scope: [ - 'https://www.googleapis.com/auth/cloud-platform', - 'https://www.googleapis.com/auth/firebase.database', - 'https://www.googleapis.com/auth/firebase.messaging', - 'https://www.googleapis.com/auth/identitytoolkit', - 'https://www.googleapis.com/auth/userinfo.email', - ].join(' '), - }; - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const jwt = require('jsonwebtoken'); - // This method is actually synchronous so we can capture and return the buffer. - return jwt.sign(claims, this.privateKey, { - audience: GOOGLE_TOKEN_AUDIENCE, - expiresIn: ONE_HOUR_IN_SECONDS, - issuer: this.clientEmail, - algorithm: JWT_ALGORITHM, - }); - } -} - -/** - * A struct containing the properties necessary to use service account JSON credentials. - */ -class ServiceAccount { - - public readonly projectId: string; - public readonly privateKey: string; - public readonly clientEmail: string; - - public static fromPath(filePath: string): ServiceAccount { - try { - return new ServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8'))); - } catch (error) { - // Throw a nicely formed error message if the file contents cannot be parsed - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Failed to parse service account json file: ' + error, - ); - } - } - - constructor(json: object) { - if (!util.isNonNullObject(json)) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Service account must be an object.', - ); - } - - copyAttr(this, json, 'projectId', 'project_id'); - copyAttr(this, json, 'privateKey', 'private_key'); - copyAttr(this, json, 'clientEmail', 'client_email'); - - let errorMessage; - if (!util.isNonEmptyString(this.projectId)) { - errorMessage = 'Service account object must contain a string "project_id" property.'; - } else if (!util.isNonEmptyString(this.privateKey)) { - errorMessage = 'Service account object must contain a string "private_key" property.'; - } else if (!util.isNonEmptyString(this.clientEmail)) { - errorMessage = 'Service account object must contain a string "client_email" property.'; - } - - if (typeof errorMessage !== 'undefined') { - throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); - } - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const forge = require('node-forge'); - try { - forge.pki.privateKeyFromPem(this.privateKey); - } catch (error) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - 'Failed to parse private key: ' + error); - } - } -} - -/** - * Implementation of Credential that gets access tokens from the metadata service available - * in the Google Cloud Platform. This authenticates the process as the default service account - * of an App Engine instance or Google Compute Engine machine. - */ -export class ComputeEngineCredential implements Credential { - - private readonly httpClient = new HttpClient(); - private readonly httpAgent?: Agent; - private projectId?: string; - - constructor(httpAgent?: Agent) { - this.httpAgent = httpAgent; - } - - public getAccessToken(): Promise { - const request = this.buildRequest(GOOGLE_METADATA_SERVICE_TOKEN_PATH); - return requestAccessToken(this.httpClient, request); - } - - public getProjectId(): Promise { - if (this.projectId) { - return Promise.resolve(this.projectId); - } - - const request = this.buildRequest(GOOGLE_METADATA_SERVICE_PROJECT_ID_PATH); - return this.httpClient.send(request) - .then((resp) => { - this.projectId = resp.text!; - return this.projectId; - }) - .catch((err) => { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - `Failed to determine project ID: ${detail}`); - }); - } - - private buildRequest(urlPath: string): HttpRequestConfig { - return { - method: 'GET', - url: `http://${GOOGLE_METADATA_SERVICE_HOST}${urlPath}`, - headers: { - 'Metadata-Flavor': 'Google', - }, - httpAgent: this.httpAgent, - }; - } -} - /** * Implementation of Credential that gets access tokens from refresh tokens. */ @@ -377,66 +183,6 @@ export function isApplicationDefault(credential?: Credential): boolean { (credential instanceof RefreshTokenCredential && credential.implicit); } -/** - * Copies the specified property from one object to another. - * - * If no property exists by the given "key", looks for a property identified by "alt", and copies it instead. - * This can be used to implement behaviors such as "copy property myKey or my_key". - * - * @param to Target object to copy the property into. - * @param from Source object to copy the property from. - * @param key Name of the property to copy. - * @param alt Alternative name of the property to copy. - */ -function copyAttr(to: {[key: string]: any}, from: {[key: string]: any}, key: string, alt: string): void { - const tmp = from[key] || from[alt]; - if (typeof tmp !== 'undefined') { - to[key] = tmp; - } -} - -/** - * Obtain a new OAuth2 token by making a remote service call. - */ -function requestAccessToken(client: HttpClient, request: HttpRequestConfig): Promise { - return client.send(request).then((resp) => { - const json = resp.data; - if (!json.access_token || !json.expires_in) { - throw new FirebaseAppError( - AppErrorCodes.INVALID_CREDENTIAL, - `Unexpected response while fetching access token: ${ JSON.stringify(json) }`, - ); - } - return json; - }).catch((err) => { - throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, getErrorMessage(err)); - }); -} - -/** - * Constructs a human-readable error message from the given Error. - */ -function getErrorMessage(err: Error): string { - const detail: string = (err instanceof HttpError) ? getDetailFromResponse(err.response) : err.message; - return `Error fetching access token: ${detail}`; -} - -/** - * Extracts details from the given HTTP error response, and returns a human-readable description. If - * the response is JSON-formatted, looks up the error and error_description fields sent by the - * Google Auth servers. Otherwise returns the entire response payload as the error detail. - */ -function getDetailFromResponse(response: HttpResponse): string { - if (response.isJson() && response.data.error) { - const json = response.data; - let detail = json.error; - if (json.error_description) { - detail += ' (' + json.error_description + ')'; - } - return detail; - } - return response.text || 'Missing error payload'; -} function credentialFromFile(filePath: string, httpAgent?: Agent): Credential { const credentialsFile = readCredentialFile(filePath); @@ -484,4 +230,4 @@ function readCredentialFile(filePath: string, ignoreMissing?: boolean): {[key: s 'Failed to parse contents of the credentials file as an object: ' + error, ); } -} +} \ No newline at end of file diff --git a/src/auth/identifier-internal.ts b/src/auth/identifier-internal.ts new file mode 100644 index 0000000000..8859305dde --- /dev/null +++ b/src/auth/identifier-internal.ts @@ -0,0 +1,38 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {UidIdentifier, UserIdentifier, EmailIdentifier, PhoneIdentifier, ProviderIdentifier} from './identifier'; + +/* User defined type guards. See + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards + */ + +export function isUidIdentifier(id: UserIdentifier): id is UidIdentifier { + return (id as UidIdentifier).uid !== undefined; +} + +export function isEmailIdentifier(id: UserIdentifier): id is EmailIdentifier { + return (id as EmailIdentifier).email !== undefined; +} + +export function isPhoneIdentifier(id: UserIdentifier): id is PhoneIdentifier { + return (id as PhoneIdentifier).phoneNumber !== undefined; +} + +export function isProviderIdentifier(id: ProviderIdentifier): id is ProviderIdentifier { + const pid = id as ProviderIdentifier; + return pid.providerId !== undefined && pid.providerUid !== undefined; +} \ No newline at end of file diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 0b2ed1dca9..4462c3cd0f 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -18,7 +18,7 @@ import * as validator from '../utils/validator'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, -} from './auth-config'; +} from './auth-config-internal'; /** The TenantOptions interface used for create/read/update tenant operations. */ export interface TenantOptions { diff --git a/src/auth/token-generator-internal.ts b/src/auth/token-generator-internal.ts new file mode 100644 index 0000000000..180b2216b4 --- /dev/null +++ b/src/auth/token-generator-internal.ts @@ -0,0 +1,343 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import { FirebaseApp } from '../firebase-app'; +import {ServiceAccountCredential} from './credential-internal'; +import {AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; +import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request'; + +import * as validator from '../utils/validator'; +import { toWebSafeBase64 } from '../utils'; +import { BLACKLISTED_CLAIMS } from './token-generator'; + + +const ALGORITHM_RS256 = 'RS256'; +const ONE_HOUR_IN_SECONDS = 60 * 60; + +/** + * CryptoSigner interface represents an object that can be used to sign JWTs. + */ +export interface CryptoSigner { + /** + * Cryptographically signs a buffer of data. + * + * @param {Buffer} buffer The data to be signed. + * @return {Promise} A promise that resolves with the raw bytes of a signature. + */ + sign(buffer: Buffer): Promise; + + /** + * Returns the ID of the service account used to sign tokens. + * + * @return {Promise} A promise that resolves with a service account ID. + */ + getAccountId(): Promise; +} + + +/** + * A CryptoSigner implementation that uses an explicitly specified service account private key to + * sign data. Performs all operations locally, and does not make any RPC calls. + */ +export class ServiceAccountSigner implements CryptoSigner { + + /** + * Creates a new CryptoSigner instance from the given service account credential. + * + * @param {ServiceAccountCredential} credential A service account credential. + */ + constructor(private readonly credential: ServiceAccountCredential) { + if (!credential) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', + ); + } + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires + const sign = crypto.createSign('RSA-SHA256'); + sign.update(buffer); + return Promise.resolve(sign.sign(this.credential.privateKey)); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + return Promise.resolve(this.credential.clientEmail); + } +} + +/** + * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without + * a service account ID, attempts to discover a service account ID by consulting the local Metadata + * service. This will succeed in managed environments like Google Cloud Functions and App Engine. + * + * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob + * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata + */ +export class IAMSigner implements CryptoSigner { + private readonly httpClient: AuthorizedHttpClient; + private serviceAccountId?: string; + + constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { + if (!httpClient) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', + ); + } + if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', + ); + } + this.httpClient = httpClient; + this.serviceAccountId = serviceAccountId; + } + + /** + * @inheritDoc + */ + public sign(buffer: Buffer): Promise { + return this.getAccountId().then((serviceAccount) => { + const request: HttpRequestConfig = { + method: 'POST', + url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, + data: {bytesToSign: buffer.toString('base64')}, + }; + return this.httpClient.send(request); + }).then((response: any) => { + // Response from IAM is base64 encoded. Decode it into a buffer and return. + return Buffer.from(response.data.signature, 'base64'); + }).catch((err) => { + if (err instanceof HttpError) { + const error = err.response.data; + if (validator.isNonNullObject(error) && error.error) { + const errorCode = error.error.status; + const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + + 'for more details on how to use and troubleshoot this feature.'; + const errorMsg = `${error.error.message}; ${description}`; + + throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error); + } + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Error returned from server: ' + error + '. Additionally, an ' + + 'internal error occurred while attempting to extract the ' + + 'errorcode from the error.', + ); + } + throw err; + }); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + if (validator.isNonEmptyString(this.serviceAccountId)) { + return Promise.resolve(this.serviceAccountId); + } + const request: HttpRequestConfig = { + method: 'GET', + url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', + headers: { + 'Metadata-Flavor': 'Google', + }, + }; + const client = new HttpClient(); + return client.send(request).then((response) => { + if (!response.text) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'HTTP Response missing payload', + ); + } + this.serviceAccountId = response.text; + return response.text; + }).catch((err) => { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + `Failed to determine service account. Make sure to initialize ` + + `the SDK with a service account credential. Alternatively specify a service ` + + `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, + ); + }); + } +} + +/** + * Create a new CryptoSigner instance for the given app. If the app has been initialized with a service + * account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner. + * + * @param {FirebaseApp} app A FirebaseApp instance. + * @return {CryptoSigner} A CryptoSigner instance. + */ +export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { + const credential = app.options.credential; + if (credential instanceof ServiceAccountCredential) { + return new ServiceAccountSigner(credential); + } + + return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); +} + +/** + * Class for generating different types of Firebase Auth tokens (JWTs). + */ +export class FirebaseTokenGenerator { + + private readonly signer: CryptoSigner; + + /** + * @param tenantId The tenant ID to use for the generated Firebase Auth + * Custom token. If absent, then no tenant ID claim will be set in the + * resulting JWT. + */ + constructor(signer: CryptoSigner, public readonly tenantId?: string) { + if (!validator.isNonNullObject(signer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', + ); + } + if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '`tenantId` argument must be a non-empty string.'); + } + this.signer = signer; + } + + /** + * Creates a new Firebase Auth Custom token. + * + * @param uid The user ID to use for the generated Firebase Auth Custom token. + * @param developerClaims Optional developer claims to include in the generated Firebase + * Auth Custom token. + * @return A Promise fulfilled with a Firebase Auth Custom token signed with a + * service account key and containing the provided payload. + */ + public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise { + let errorMessage: string | undefined; + if (!validator.isNonEmptyString(uid)) { + errorMessage = '`uid` argument must be a non-empty string uid.'; + } else if (uid.length > 128) { + errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; + } else if (!this.isDeveloperClaimsValid_(developerClaims)) { + errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; + } + + if (errorMessage) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } + + const claims: {[key: string]: any} = {}; + if (typeof developerClaims !== 'undefined') { + for (const key in developerClaims) { + /* istanbul ignore else */ + if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { + if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `Developer claim "${key}" is reserved and cannot be specified.`, + ); + } + claims[key] = developerClaims[key]; + } + } + } + return this.signer.getAccountId().then((account) => { + const header: JWTHeader = { + alg: ALGORITHM_RS256, + typ: 'JWT', + }; + const iat = Math.floor(Date.now() / 1000); + const body: JWTBody = { + aud: FIREBASE_AUDIENCE, + iat, + exp: iat + ONE_HOUR_IN_SECONDS, + iss: account, + sub: account, + uid, + }; + if (this.tenantId) { + // eslint-disable-next-line @typescript-eslint/camelcase + body.tenant_id = this.tenantId; + } + if (Object.keys(claims).length > 0) { + body.claims = claims; + } + const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; + const signPromise = this.signer.sign(Buffer.from(token)); + return Promise.all([token, signPromise]); + }).then(([token, signature]) => { + return `${token}.${this.encodeSegment(signature)}`; + }); + } + + private encodeSegment(segment: object | Buffer): string { + const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment)); + return toWebSafeBase64(buffer).replace(/=+$/, ''); + } + + /** + * Returns whether or not the provided developer claims are valid. + * + * @param {object} [developerClaims] Optional developer claims to validate. + * @return {boolean} True if the provided claims are valid; otherwise, false. + */ + private isDeveloperClaimsValid_(developerClaims?: object): boolean { + if (typeof developerClaims === 'undefined') { + return true; + } + return validator.isNonNullObject(developerClaims); + } +} + + +/** + * Represents the header of a JWT. + */ +interface JWTHeader { + alg: string; + typ: string; +} + +/** + * Represents the body of a JWT. + */ +interface JWTBody { + claims?: object; + uid: string; + aud: string; + iat: number; + exp: number; + iss: string; + sub: string; + tenant_id?: string; +} + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; \ No newline at end of file diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 96188af944..362cf3aeba 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -14,27 +14,12 @@ * limitations under the License. */ -import { FirebaseApp } from '../firebase-app'; -import {ServiceAccountCredential} from './credential'; -import {AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; -import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '../utils/api-request'; - -import * as validator from '../utils/validator'; -import { toWebSafeBase64 } from '../utils'; - - -const ALGORITHM_RS256 = 'RS256'; -const ONE_HOUR_IN_SECONDS = 60 * 60; - // List of blacklisted claims which cannot be provided when creating a custom token export const BLACKLISTED_CLAIMS = [ 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat', 'iss', 'jti', 'nbf', 'nonce', ]; -// Audience to use for Firebase Auth Custom tokens -const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; - /** * CryptoSigner interface represents an object that can be used to sign JWTs. */ @@ -53,294 +38,4 @@ export interface CryptoSigner { * @return {Promise} A promise that resolves with a service account ID. */ getAccountId(): Promise; -} - -/** - * Represents the header of a JWT. - */ -interface JWTHeader { - alg: string; - typ: string; -} - -/** - * Represents the body of a JWT. - */ -interface JWTBody { - claims?: object; - uid: string; - aud: string; - iat: number; - exp: number; - iss: string; - sub: string; - tenant_id?: string; -} - -/** - * A CryptoSigner implementation that uses an explicitly specified service account private key to - * sign data. Performs all operations locally, and does not make any RPC calls. - */ -export class ServiceAccountSigner implements CryptoSigner { - - /** - * Creates a new CryptoSigner instance from the given service account credential. - * - * @param {ServiceAccountCredential} credential A service account credential. - */ - constructor(private readonly credential: ServiceAccountCredential) { - if (!credential) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'INTERNAL ASSERT: Must provide a service account credential to initialize ServiceAccountSigner.', - ); - } - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - const crypto = require('crypto'); // eslint-disable-line @typescript-eslint/no-var-requires - const sign = crypto.createSign('RSA-SHA256'); - sign.update(buffer); - return Promise.resolve(sign.sign(this.credential.privateKey)); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - return Promise.resolve(this.credential.clientEmail); - } -} - -/** - * A CryptoSigner implementation that uses the remote IAM service to sign data. If initialized without - * a service account ID, attempts to discover a service account ID by consulting the local Metadata - * service. This will succeed in managed environments like Google Cloud Functions and App Engine. - * - * @see https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob - * @see https://cloud.google.com/compute/docs/storing-retrieving-metadata - */ -export class IAMSigner implements CryptoSigner { - private readonly httpClient: AuthorizedHttpClient; - private serviceAccountId?: string; - - constructor(httpClient: AuthorizedHttpClient, serviceAccountId?: string) { - if (!httpClient) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Must provide a HTTP client to initialize IAMSigner.', - ); - } - if (typeof serviceAccountId !== 'undefined' && !validator.isNonEmptyString(serviceAccountId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'INTERNAL ASSERT: Service account ID must be undefined or a non-empty string.', - ); - } - this.httpClient = httpClient; - this.serviceAccountId = serviceAccountId; - } - - /** - * @inheritDoc - */ - public sign(buffer: Buffer): Promise { - return this.getAccountId().then((serviceAccount) => { - const request: HttpRequestConfig = { - method: 'POST', - url: `https://iam.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:signBlob`, - data: {bytesToSign: buffer.toString('base64')}, - }; - return this.httpClient.send(request); - }).then((response: any) => { - // Response from IAM is base64 encoded. Decode it into a buffer and return. - return Buffer.from(response.data.signature, 'base64'); - }).catch((err) => { - if (err instanceof HttpError) { - const error = err.response.data; - if (validator.isNonNullObject(error) && error.error) { - const errorCode = error.error.status; - const description = 'Please refer to https://firebase.google.com/docs/auth/admin/create-custom-tokens ' + - 'for more details on how to use and troubleshoot this feature.'; - const errorMsg = `${error.error.message}; ${description}`; - - throw FirebaseAuthError.fromServerError(errorCode, errorMsg, error); - } - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Error returned from server: ' + error + '. Additionally, an ' + - 'internal error occurred while attempting to extract the ' + - 'errorcode from the error.', - ); - } - throw err; - }); - } - - /** - * @inheritDoc - */ - public getAccountId(): Promise { - if (validator.isNonEmptyString(this.serviceAccountId)) { - return Promise.resolve(this.serviceAccountId); - } - const request: HttpRequestConfig = { - method: 'GET', - url: 'http://metadata/computeMetadata/v1/instance/service-accounts/default/email', - headers: { - 'Metadata-Flavor': 'Google', - }, - }; - const client = new HttpClient(); - return client.send(request).then((response) => { - if (!response.text) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'HTTP Response missing payload', - ); - } - this.serviceAccountId = response.text; - return response.text; - }).catch((err) => { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - `Failed to determine service account. Make sure to initialize ` + - `the SDK with a service account credential. Alternatively specify a service ` + - `account with iam.serviceAccounts.signBlob permission. Original error: ${err}`, - ); - }); - } -} - -/** - * Create a new CryptoSigner instance for the given app. If the app has been initialized with a service - * account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner. - * - * @param {FirebaseApp} app A FirebaseApp instance. - * @return {CryptoSigner} A CryptoSigner instance. - */ -export function cryptoSignerFromApp(app: FirebaseApp): CryptoSigner { - const credential = app.options.credential; - if (credential instanceof ServiceAccountCredential) { - return new ServiceAccountSigner(credential); - } - - return new IAMSigner(new AuthorizedHttpClient(app), app.options.serviceAccountId); -} - -/** - * Class for generating different types of Firebase Auth tokens (JWTs). - */ -export class FirebaseTokenGenerator { - - private readonly signer: CryptoSigner; - - /** - * @param tenantId The tenant ID to use for the generated Firebase Auth - * Custom token. If absent, then no tenant ID claim will be set in the - * resulting JWT. - */ - constructor(signer: CryptoSigner, public readonly tenantId?: string) { - if (!validator.isNonNullObject(signer)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', - ); - } - if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '`tenantId` argument must be a non-empty string.'); - } - this.signer = signer; - } - - /** - * Creates a new Firebase Auth Custom token. - * - * @param uid The user ID to use for the generated Firebase Auth Custom token. - * @param developerClaims Optional developer claims to include in the generated Firebase - * Auth Custom token. - * @return A Promise fulfilled with a Firebase Auth Custom token signed with a - * service account key and containing the provided payload. - */ - public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise { - let errorMessage: string | undefined; - if (!validator.isNonEmptyString(uid)) { - errorMessage = '`uid` argument must be a non-empty string uid.'; - } else if (uid.length > 128) { - errorMessage = '`uid` argument must a uid with less than or equal to 128 characters.'; - } else if (!this.isDeveloperClaimsValid_(developerClaims)) { - errorMessage = '`developerClaims` argument must be a valid, non-null object containing the developer claims.'; - } - - if (errorMessage) { - throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); - } - - const claims: {[key: string]: any} = {}; - if (typeof developerClaims !== 'undefined') { - for (const key in developerClaims) { - /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(developerClaims, key)) { - if (BLACKLISTED_CLAIMS.indexOf(key) !== -1) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `Developer claim "${key}" is reserved and cannot be specified.`, - ); - } - claims[key] = developerClaims[key]; - } - } - } - return this.signer.getAccountId().then((account) => { - const header: JWTHeader = { - alg: ALGORITHM_RS256, - typ: 'JWT', - }; - const iat = Math.floor(Date.now() / 1000); - const body: JWTBody = { - aud: FIREBASE_AUDIENCE, - iat, - exp: iat + ONE_HOUR_IN_SECONDS, - iss: account, - sub: account, - uid, - }; - if (this.tenantId) { - // eslint-disable-next-line @typescript-eslint/camelcase - body.tenant_id = this.tenantId; - } - if (Object.keys(claims).length > 0) { - body.claims = claims; - } - const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; - const signPromise = this.signer.sign(Buffer.from(token)); - return Promise.all([token, signPromise]); - }).then(([token, signature]) => { - return `${token}.${this.encodeSegment(signature)}`; - }); - } - - private encodeSegment(segment: object | Buffer): string { - const buffer: Buffer = (segment instanceof Buffer) ? segment : Buffer.from(JSON.stringify(segment)); - return toWebSafeBase64(buffer).replace(/=+$/, ''); - } - - /** - * Returns whether or not the provided developer claims are valid. - * - * @param {object} [developerClaims] Optional developer claims to validate. - * @return {boolean} True if the provided claims are valid; otherwise, false. - */ - private isDeveloperClaimsValid_(developerClaims?: object): boolean { - if (typeof developerClaims === 'undefined') { - return true; - } - return validator.isNonNullObject(developerClaims); - } -} - +} \ No newline at end of file diff --git a/src/auth/token-verifier-internal.ts b/src/auth/token-verifier-internal.ts new file mode 100644 index 0000000000..91465f42ba --- /dev/null +++ b/src/auth/token-verifier-internal.ts @@ -0,0 +1,321 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '../firebase-app'; +import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { DecodedIdToken } from './auth'; +import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import * as jwt from 'jsonwebtoken'; + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; + +/** User facing token information related to the Firebase ID token. */ +export const ID_TOKEN_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + verifyApiName: 'verifyIdToken()', + jwtName: 'Firebase ID token', + shortName: 'ID token', + expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, +}; + + +/** User facing token information related to the Firebase session cookie. */ +export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', + verifyApiName: 'verifySessionCookie()', + jwtName: 'Firebase session cookie', + shortName: 'session cookie', + expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, +}; + +/** Interface that defines token related user facing information. */ +export interface FirebaseTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** JWT Expiration error code. */ + expiredErrorCode: ErrorInfo; +} + +/** + * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. + */ +export class FirebaseTokenVerifier { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt: number; + private readonly shortNameArticle: string; + + constructor(private clientCertUrl: string, private algorithm: string, + private issuer: string, private tokenInfo: FirebaseTokenInfo, + private readonly app: FirebaseApp) { + if (!validator.isURL(clientCertUrl)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided public client certificate URL is an invalid URL.`, + ); + } else if (!validator.isNonEmptyString(algorithm)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT algorithm is an empty string.`, + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT issuer is an invalid URL.`, + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT information is not an object or null.`, + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT verification documentation URL is invalid.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT verify API name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public full name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public short name must be a non-empty string.`, + ); + } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT expiration error code must be a non-null ErrorInfo object.`, + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + // For backward compatibility, the project ID is validated in the verification call. + } + /** + * Verifies the format and signature of a Firebase Auth JWT token. + * + * @param {string} jwtToken The Firebase Auth JWT token to verify. + * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID + * token. + */ + public verifyJWT(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + return util.findProjectId(this.app) + .then((projectId) => { + return this.verifyJWTWithProjectId(jwtToken, projectId); + }); + } + + private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + `Must initialize app with a cert credential or set your Firebase project ID as the ` + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, + ); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + const header = fullDecodedToken && fullDecodedToken.header; + const payload = fullDecodedToken && fullDecodedToken.payload; + + const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + + `Firebase project as the service account used to authenticate this SDK.`; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string | undefined; + if (!fullDecodedToken) { + errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + + `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + } else if (typeof header.kid === 'undefined') { + const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); + const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); + + if (isCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a custom token.`; + } else if (isLegacyCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a legacy custom token.`; + } else { + errorMessage = 'Firebase ID token has no "kid" claim.'; + } + + errorMessage += verifyJwtTokenDocsMessage; + } else if (header.alg !== this.algorithm) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + `" but got ` + + `"` + header.alg + `".` + verifyJwtTokenDocsMessage; + } else if (payload.aud !== projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + projectId + `" but got "` + payload.aud + `".` + projectIdMatchMessage + + verifyJwtTokenDocsMessage; + } else if (payload.iss !== this.issuer + projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + + `"${this.issuer}"` + projectId + `" but got "` + + payload.iss + `".` + projectIdMatchMessage + verifyJwtTokenDocsMessage; + } else if (typeof payload.sub !== 'string') { + errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub.length > 128) { + errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + + verifyJwtTokenDocsMessage; + } + if (errorMessage) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + + return this.fetchPublicKeys().then((publicKeys) => { + if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` + + `Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` + + `client app and try again.`, + ), + ); + } else { + return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); + } + }); + } + + /** + * Verifies the JWT signature using the provided public key. + * @param {string} jwtToken The JWT token to verify. + * @param {string} publicKey The public key certificate. + * @return {Promise} A promise that resolves with the decoded JWT claims on successful + * verification. + */ + private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + return new Promise((resolve, reject) => { + jwt.verify(jwtToken, publicKey, { + algorithms: [this.algorithm], + }, (error: jwt.VerifyErrors, decodedToken: string | object) => { + if (error) { + if (error.name === 'TokenExpiredError') { + const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + + ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); + } else if (error.name === 'JsonWebTokenError') { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); + } else { + // TODO(rsgowman): I think the typing on jwt.verify is wrong. It claims that this can be either a string or an + // object, but the code always seems to call it as an object. Investigate and upstream typing changes if this + // is actually correct. + if (typeof decodedToken === 'string') { + return reject(new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + "Unexpected decodedToken. Expected an object but got a string: '" + decodedToken + "'", + )); + } else { + const decodedIdToken = (decodedToken as DecodedIdToken); + decodedIdToken.uid = decodedIdToken.sub; + resolve(decodedIdToken); + } + } + }); + }); + } + + /** + * Fetches the public keys for the Google certs. + * + * @return {Promise} A promise fulfilled with public keys for the Google certs. + */ + private fetchPublicKeys(): Promise<{[key: string]: string}> { + const publicKeysExist = (typeof this.publicKeys !== 'undefined'); + const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); + const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); + if (publicKeysExist && publicKeysStillValid) { + return Promise.resolve(this.publicKeys); + } + + const client = new HttpClient(); + const request: HttpRequestConfig = { + method: 'GET', + url: this.clientCertUrl, + httpAgent: this.app.options.httpAgent, + }; + return client.send(request).then((resp) => { + if (!resp.isJson() || resp.data.error) { + // Treat all non-json messages and messages with an 'error' field as + // error responses. + throw new HttpError(resp); + } + if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { + const cacheControlHeader: string = resp.headers['cache-control']; + const parts = cacheControlHeader.split(','); + parts.forEach((part) => { + const subParts = part.trim().split('='); + if (subParts[0] === 'max-age') { + const maxAge: number = +subParts[1]; + this.publicKeysExpireAt = Date.now() + (maxAge * 1000); + } + }); + } + this.publicKeys = resp.data; + return resp.data; + }).catch((err) => { + if (err instanceof HttpError) { + let errorMessage = 'Error fetching public keys for Google certs: '; + const resp = err.response; + if (resp.isJson() && resp.data.error) { + errorMessage += `${resp.data.error}`; + if (resp.data.error_description) { + errorMessage += ' (' + resp.data.error_description + ')'; + } + } else { + errorMessage += `${resp.text}`; + } + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage); + } + throw err; + }); + } +} \ No newline at end of file diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index cc0c4f4c02..ca18c3c57f 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -14,17 +14,10 @@ * limitations under the License. */ -import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; - -import * as util from '../utils/index'; -import * as validator from '../utils/validator'; -import * as jwt from 'jsonwebtoken'; -import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; -import { DecodedIdToken } from './auth'; +import { FirebaseTokenVerifier } from './token-verifier-internal'; import { FirebaseApp } from '../firebase-app'; +import { ID_TOKEN_INFO, SESSION_COOKIE_INFO } from './token-verifier-internal'; -// Audience to use for Firebase Auth Custom tokens -const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; export const ALGORITHM_RS256 = 'RS256'; @@ -35,302 +28,6 @@ const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/secur // URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon. const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; -/** User facing token information related to the Firebase ID token. */ -export const ID_TOKEN_INFO: FirebaseTokenInfo = { - url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', - verifyApiName: 'verifyIdToken()', - jwtName: 'Firebase ID token', - shortName: 'ID token', - expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, -}; - -/** User facing token information related to the Firebase session cookie. */ -export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { - url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', - verifyApiName: 'verifySessionCookie()', - jwtName: 'Firebase session cookie', - shortName: 'session cookie', - expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, -}; - -/** Interface that defines token related user facing information. */ -export interface FirebaseTokenInfo { - /** Documentation URL. */ - url: string; - /** verify API name. */ - verifyApiName: string; - /** The JWT full name. */ - jwtName: string; - /** The JWT short name. */ - shortName: string; - /** JWT Expiration error code. */ - expiredErrorCode: ErrorInfo; -} - -/** - * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. - */ -export class FirebaseTokenVerifier { - private publicKeys: {[key: string]: string}; - private publicKeysExpireAt: number; - private readonly shortNameArticle: string; - - constructor(private clientCertUrl: string, private algorithm: string, - private issuer: string, private tokenInfo: FirebaseTokenInfo, - private readonly app: FirebaseApp) { - if (!validator.isURL(clientCertUrl)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided public client certificate URL is an invalid URL.`, - ); - } else if (!validator.isNonEmptyString(algorithm)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT algorithm is an empty string.`, - ); - } else if (!validator.isURL(issuer)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT issuer is an invalid URL.`, - ); - } else if (!validator.isNonNullObject(tokenInfo)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT information is not an object or null.`, - ); - } else if (!validator.isURL(tokenInfo.url)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The provided JWT verification documentation URL is invalid.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT verify API name must be a non-empty string.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT public full name must be a non-empty string.`, - ); - } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT public short name must be a non-empty string.`, - ); - } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `The JWT expiration error code must be a non-null ErrorInfo object.`, - ); - } - this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; - - // For backward compatibility, the project ID is validated in the verification call. - } - - /** - * Verifies the format and signature of a Firebase Auth JWT token. - * - * @param {string} jwtToken The Firebase Auth JWT token to verify. - * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID - * token. - */ - public verifyJWT(jwtToken: string): Promise { - if (!validator.isString(jwtToken)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, - ); - } - - return util.findProjectId(this.app) - .then((projectId) => { - return this.verifyJWTWithProjectId(jwtToken, projectId); - }); - } - - private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise { - if (!validator.isNonEmptyString(projectId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - `Must initialize app with a cert credential or set your Firebase project ID as the ` + - `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, - ); - } - - const fullDecodedToken: any = jwt.decode(jwtToken, { - complete: true, - }); - - const header = fullDecodedToken && fullDecodedToken.header; - const payload = fullDecodedToken && fullDecodedToken.payload; - - const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + - `Firebase project as the service account used to authenticate this SDK.`; - const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + - `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - - let errorMessage: string | undefined; - if (!fullDecodedToken) { - errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + - `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; - } else if (typeof header.kid === 'undefined') { - const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); - const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); - - if (isCustomToken) { - errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + - `${this.tokenInfo.shortName}, but was given a custom token.`; - } else if (isLegacyCustomToken) { - errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + - `${this.tokenInfo.shortName}, but was given a legacy custom token.`; - } else { - errorMessage = 'Firebase ID token has no "kid" claim.'; - } - - errorMessage += verifyJwtTokenDocsMessage; - } else if (header.alg !== this.algorithm) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + `" but got ` + - `"` + header.alg + `".` + verifyJwtTokenDocsMessage; - } else if (payload.aud !== projectId) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + - projectId + `" but got "` + payload.aud + `".` + projectIdMatchMessage + - verifyJwtTokenDocsMessage; - } else if (payload.iss !== this.issuer + projectId) { - errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + - `"${this.issuer}"` + projectId + `" but got "` + - payload.iss + `".` + projectIdMatchMessage + verifyJwtTokenDocsMessage; - } else if (typeof payload.sub !== 'string') { - errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; - } else if (payload.sub === '') { - errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; - } else if (payload.sub.length > 128) { - errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + - verifyJwtTokenDocsMessage; - } - if (errorMessage) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - - return this.fetchPublicKeys().then((publicKeys) => { - if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` + - `Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` + - `client app and try again.`, - ), - ); - } else { - return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); - } - - }); - } - - /** - * Verifies the JWT signature using the provided public key. - * @param {string} jwtToken The JWT token to verify. - * @param {string} publicKey The public key certificate. - * @return {Promise} A promise that resolves with the decoded JWT claims on successful - * verification. - */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { - const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + - `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; - return new Promise((resolve, reject) => { - jwt.verify(jwtToken, publicKey, { - algorithms: [this.algorithm], - }, (error: jwt.VerifyErrors, decodedToken: string | object) => { - if (error) { - if (error.name === 'TokenExpiredError') { - const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + - ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + - verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); - } else if (error.name === 'JsonWebTokenError') { - const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); - } else { - // TODO(rsgowman): I think the typing on jwt.verify is wrong. It claims that this can be either a string or an - // object, but the code always seems to call it as an object. Investigate and upstream typing changes if this - // is actually correct. - if (typeof decodedToken === 'string') { - return reject(new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - "Unexpected decodedToken. Expected an object but got a string: '" + decodedToken + "'", - )); - } else { - const decodedIdToken = (decodedToken as DecodedIdToken); - decodedIdToken.uid = decodedIdToken.sub; - resolve(decodedIdToken); - } - } - }); - }); - } - - /** - * Fetches the public keys for the Google certs. - * - * @return {Promise} A promise fulfilled with public keys for the Google certs. - */ - private fetchPublicKeys(): Promise<{[key: string]: string}> { - const publicKeysExist = (typeof this.publicKeys !== 'undefined'); - const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); - const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); - if (publicKeysExist && publicKeysStillValid) { - return Promise.resolve(this.publicKeys); - } - - const client = new HttpClient(); - const request: HttpRequestConfig = { - method: 'GET', - url: this.clientCertUrl, - httpAgent: this.app.options.httpAgent, - }; - return client.send(request).then((resp) => { - if (!resp.isJson() || resp.data.error) { - // Treat all non-json messages and messages with an 'error' field as - // error responses. - throw new HttpError(resp); - } - if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { - const cacheControlHeader: string = resp.headers['cache-control']; - const parts = cacheControlHeader.split(','); - parts.forEach((part) => { - const subParts = part.trim().split('='); - if (subParts[0] === 'max-age') { - const maxAge: number = +subParts[1]; - this.publicKeysExpireAt = Date.now() + (maxAge * 1000); - } - }); - } - this.publicKeys = resp.data; - return resp.data; - }).catch((err) => { - if (err instanceof HttpError) { - let errorMessage = 'Error fetching public keys for Google certs: '; - const resp = err.response; - if (resp.isJson() && resp.data.error) { - errorMessage += `${resp.data.error}`; - if (resp.data.error_description) { - errorMessage += ' (' + resp.data.error_description + ')'; - } - } else { - errorMessage += `${resp.text}`; - } - throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage); - } - throw err; - }); - } -} - /** * Creates a new FirebaseTokenVerifier to verify Firebase ID tokens. * diff --git a/src/auth/user-import-builder-internal.ts b/src/auth/user-import-builder-internal.ts new file mode 100644 index 0000000000..58298e1432 --- /dev/null +++ b/src/auth/user-import-builder-internal.ts @@ -0,0 +1,511 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {deepCopy, deepExtend} from '../utils/deep-copy'; +import * as utils from '../utils'; +import * as validator from '../utils/validator'; +import {AuthClientErrorCode, FirebaseAuthError, FirebaseArrayIndexError} from '../utils/error'; +import {UserImportRecord, UserImportOptions, ValidatorFunction, + UploadAccountRequest, UserImportResult, SecondFactor} from './user-import-builder'; + +/** Interface representing an Auth second factor in Auth server format. */ +export interface AuthFactorInfo { + // Not required for signupNewUser endpoint. + mfaEnrollmentId?: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +/** UploadAccount endpoint request hash options. */ +export interface UploadAccountOptions { + hashAlgorithm?: string; + signerKey?: string; + rounds?: number; + memoryCost?: number; + saltSeparator?: string; + cpuMemCost?: number; + parallelization?: number; + blockSize?: number; + dkLen?: number; +} + +/** + * Converts a client format second factor object to server format. + * @param multiFactorInfo The client format second factor. + * @return The corresponding AuthFactorInfo server request format. + */ +export function convertMultiFactorInfoToServerFormat(multiFactorInfo: SecondFactor): AuthFactorInfo { + let enrolledAt; + if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { + if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { + // Convert from UTC date string (client side format) to ISO date string (server side format). + enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + + `UTC date string.`); + } + } + // Currently only phone second factors are supported. + if (multiFactorInfo.factorId === 'phone') { + // If any required field is missing or invalid, validation will still fail later. + const authFactorInfo: AuthFactorInfo = { + mfaEnrollmentId: multiFactorInfo.uid, + displayName: multiFactorInfo.displayName, + // Required for all phone second factors. + phoneInfo: multiFactorInfo.phoneNumber, + enrolledAt, + }; + for (const objKey in authFactorInfo) { + if (typeof authFactorInfo[objKey] === 'undefined') { + delete authFactorInfo[objKey]; + } + } + return authFactorInfo; + } else { + // Unsupported second factor. + throw new FirebaseAuthError( + AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); + } +} + + +/** + * Class that provides a helper for building/validating uploadAccount requests and + * UserImportResult responses. + */ +export class UserImportBuilder { + private requiresHashOptions: boolean; + private validatedUsers: UploadAccountUser[]; + private validatedOptions: UploadAccountOptions; + private indexMap: {[key: number]: number}; + private userImportResultErrors: FirebaseArrayIndexError[]; + + /** + * @param {UserImportRecord[]} users The list of user records to import. + * @param {UserImportOptions=} options The import options which includes hashing + * algorithm details. + * @param {ValidatorFunction=} userRequestValidator The user request validator function. + * @constructor + */ + constructor( + users: UserImportRecord[], + options?: UserImportOptions, + userRequestValidator?: ValidatorFunction) { + this.requiresHashOptions = false; + this.validatedUsers = []; + this.userImportResultErrors = []; + this.indexMap = {}; + + this.validatedUsers = this.populateUsers(users, userRequestValidator); + this.validatedOptions = this.populateOptions(options, this.requiresHashOptions); + } + + /** + * Returns the corresponding constructed uploadAccount request. + * @return {UploadAccountRequest} The constructed uploadAccount request. + */ + public buildRequest(): UploadAccountRequest { + const users = this.validatedUsers.map((user) => { + return deepCopy(user); + }); + return deepExtend({users}, deepCopy(this.validatedOptions)) as UploadAccountRequest; + } + + /** + * Populates the UserImportResult using the client side detected errors and the server + * side returned errors. + * @return {UserImportResult} The user import result based on the returned failed + * uploadAccount response. + */ + public buildResponse( + failedUploads: Array<{index: number; message: string}>): UserImportResult { + // Initialize user import result. + const importResult: UserImportResult = { + successCount: this.validatedUsers.length, + failureCount: this.userImportResultErrors.length, + errors: deepCopy(this.userImportResultErrors), + }; + importResult.failureCount += failedUploads.length; + importResult.successCount -= failedUploads.length; + failedUploads.forEach((failedUpload) => { + importResult.errors.push({ + // Map backend request index to original developer provided array index. + index: this.indexMap[failedUpload.index], + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_USER_IMPORT, + failedUpload.message, + ), + }); + }); + // Sort errors by index. + importResult.errors.sort((a, b) => { + return a.index - b.index; + }); + // Return sorted result. + return importResult; + } + + /** + * Validates and returns the hashing options of the uploadAccount request. + * Throws an error whenever an invalid or missing options is detected. + * @param {UserImportOptions} options The UserImportOptions. + * @param {boolean} requiresHashOptions Whether to require hash options. + * @return {UploadAccountOptions} The populated UploadAccount options. + */ + private populateOptions( + options: UserImportOptions | undefined, requiresHashOptions: boolean): UploadAccountOptions { + let populatedOptions: UploadAccountOptions; + if (!requiresHashOptions) { + return {}; + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"UserImportOptions" are required when importing users with passwords.', + ); + } + if (!validator.isNonNullObject(options.hash)) { + throw new FirebaseAuthError( + AuthClientErrorCode.MISSING_HASH_ALGORITHM, + `"hash.algorithm" is missing from the provided "UserImportOptions".`, + ); + } + if (typeof options.hash.algorithm === 'undefined' || + !validator.isNonEmptyString(options.hash.algorithm)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `"hash.algorithm" must be a string matching the list of supported algorithms.`, + ); + } + + let rounds: number; + switch (options.hash.algorithm) { + case 'HMAC_SHA512': + case 'HMAC_SHA256': + case 'HMAC_SHA1': + case 'HMAC_MD5': + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + `A non-empty "hash.key" byte buffer must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + }; + break; + + case 'MD5': + case 'SHA1': + case 'SHA256': + case 'SHA512': { + // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] + rounds = getNumberField(options.hash, 'rounds'); + const minRounds = options.hash.algorithm === 'MD5' ? 0 : 1; + if (isNaN(rounds) || rounds < minRounds || rounds > 8192) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between ${minRounds} and 8192 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + } + case 'PBKDF_SHA1': + case 'PBKDF2_SHA256': + rounds = getNumberField(options.hash, 'rounds'); + if (isNaN(rounds) || rounds < 0 || rounds > 120000) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between 0 and 120000 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + + case 'SCRYPT': { + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + `A "hash.key" byte buffer must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + rounds = getNumberField(options.hash, 'rounds'); + if (isNaN(rounds) || rounds <= 0 || rounds > 8) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between 1 and 8 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const memoryCost = getNumberField(options.hash, 'memoryCost'); + if (isNaN(memoryCost) || memoryCost <= 0 || memoryCost > 14) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + `A valid "hash.memoryCost" number between 1 and 14 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + if (typeof options.hash.saltSeparator !== 'undefined' && + !validator.isBuffer(options.hash.saltSeparator)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_SALT_SEPARATOR, + `"hash.saltSeparator" must be a byte buffer.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + rounds, + memoryCost, + saltSeparator: utils.toWebSafeBase64(options.hash.saltSeparator || Buffer.from('')), + }; + break; + } + case 'BCRYPT': + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + }; + break; + + case 'STANDARD_SCRYPT': { + const cpuMemCost = getNumberField(options.hash, 'memoryCost'); + if (isNaN(cpuMemCost)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + `A valid "hash.memoryCost" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const parallelization = getNumberField(options.hash, 'parallelization'); + if (isNaN(parallelization)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_PARALLELIZATION, + `A valid "hash.parallelization" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const blockSize = getNumberField(options.hash, 'blockSize'); + if (isNaN(blockSize)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_BLOCK_SIZE, + `A valid "hash.blockSize" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + const dkLen = getNumberField(options.hash, 'derivedKeyLength'); + if (isNaN(dkLen)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_DERIVED_KEY_LENGTH, + `A valid "hash.derivedKeyLength" number must be provided for ` + + `hash algorithm ${options.hash.algorithm}.`, + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + cpuMemCost, + parallelization, + blockSize, + dkLen, + }; + break; + } + default: + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `Unsupported hash algorithm provider "${options.hash.algorithm}".`, + ); + } + return populatedOptions; + } + + /** + * Validates and returns the users list of the uploadAccount request. + * Whenever a user with an error is detected, the error is cached and will later be + * merged into the user import result. This allows the processing of valid users without + * failing early on the first error detected. + * @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser + * objects. + * @param {ValidatorFunction=} userValidator The user validator function. + * @return {UploadAccountUser[]} The populated uploadAccount users. + */ + private populateUsers( + users: UserImportRecord[], userValidator?: ValidatorFunction): UploadAccountUser[] { + const populatedUsers: UploadAccountUser[] = []; + users.forEach((user, index) => { + try { + const result = populateUploadAccountUser(user, userValidator); + if (typeof result.passwordHash !== 'undefined') { + this.requiresHashOptions = true; + } + // Only users that pass client screening will be passed to backend for processing. + populatedUsers.push(result); + // Map user's index (the one to be sent to backend) to original developer provided array. + this.indexMap[populatedUsers.length - 1] = index; + } catch (error) { + // Save the client side error with respect to the developer provided array. + this.userImportResultErrors.push({ + index, + error, + }); + } + }); + return populatedUsers; + } +} + + +/** UploadAccount endpoint request user interface. */ +export interface UploadAccountUser { + localId: string; + email?: string; + emailVerified?: boolean; + displayName?: string; + disabled?: boolean; + photoUrl?: string; + phoneNumber?: string; + providerUserInfo?: Array<{ + rawId: string; + providerId: string; + email?: string; + displayName?: string; + photoUrl?: string; + }>; + mfaInfo?: AuthFactorInfo[]; + passwordHash?: string; + salt?: string; + lastLoginAt?: number; + createdAt?: number; + customAttributes?: string; + tenantId?: string; +} + +/** + * @param {any} obj The object to check for number field within. + * @param {string} key The entry key. + * @return {number} The corresponding number if available. Otherwise, NaN. + */ +function getNumberField(obj: any, key: string): number { + if (typeof obj[key] !== 'undefined' && obj[key] !== null) { + return parseInt(obj[key].toString(), 10); + } + return NaN; +} + + +/** + * Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid + * fields are provided. + * @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. + * @param {ValidatorFunction=} userValidator The user validator function. + * @return {UploadAccountUser} The corresponding UploadAccountUser to return. + */ +function populateUploadAccountUser( + user: UserImportRecord, userValidator?: ValidatorFunction): UploadAccountUser { + const result: UploadAccountUser = { + localId: user.uid, + email: user.email, + emailVerified: user.emailVerified, + displayName: user.displayName, + disabled: user.disabled, + photoUrl: user.photoURL, + phoneNumber: user.phoneNumber, + providerUserInfo: [], + mfaInfo: [], + tenantId: user.tenantId, + customAttributes: user.customClaims && JSON.stringify(user.customClaims), + }; + if (typeof user.passwordHash !== 'undefined') { + if (!validator.isBuffer(user.passwordHash)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PASSWORD_HASH, + ); + } + result.passwordHash = utils.toWebSafeBase64(user.passwordHash); + } + if (typeof user.passwordSalt !== 'undefined') { + if (!validator.isBuffer(user.passwordSalt)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PASSWORD_SALT, + ); + } + result.salt = utils.toWebSafeBase64(user.passwordSalt); + } + if (validator.isNonNullObject(user.metadata)) { + if (validator.isNonEmptyString(user.metadata.creationTime)) { + result.createdAt = new Date(user.metadata.creationTime).getTime(); + } + if (validator.isNonEmptyString(user.metadata.lastSignInTime)) { + result.lastLoginAt = new Date(user.metadata.lastSignInTime).getTime(); + } + } + if (validator.isArray(user.providerData)) { + user.providerData.forEach((providerData) => { + result.providerUserInfo!.push({ + providerId: providerData.providerId, + rawId: providerData.uid, + email: providerData.email, + displayName: providerData.displayName, + photoUrl: providerData.photoURL, + }); + }); + } + + // Convert user.multiFactor.enrolledFactors to server format. + if (validator.isNonNullObject(user.multiFactor) && + validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { + user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + result.mfaInfo!.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } + + // Remove blank fields. + let key: keyof UploadAccountUser; + for (key in result) { + if (typeof result[key] === 'undefined') { + delete result[key]; + } + } + if (result.providerUserInfo!.length === 0) { + delete result.providerUserInfo; + } + if (result.mfaInfo!.length === 0) { + delete result.mfaInfo; + } + // Validate the constructured user individual request. This will throw if an error + // is detected. + if (typeof userValidator === 'function') { + userValidator(result); + } + return result; +} + diff --git a/src/auth/user-import-builder.ts b/src/auth/user-import-builder.ts index 89e1802ec3..9268f2aa65 100755 --- a/src/auth/user-import-builder.ts +++ b/src/auth/user-import-builder.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import {deepCopy, deepExtend} from '../utils/deep-copy'; -import * as utils from '../utils'; -import * as validator from '../utils/validator'; -import {AuthClientErrorCode, FirebaseAuthError, FirebaseArrayIndexError} from '../utils/error'; +import {FirebaseArrayIndexError} from '../utils/error'; +import {UploadAccountUser, UploadAccountOptions} from './user-import-builder-internal'; /** Firebase Auth supported hashing algorithms for import operations. */ export type HashAlgorithmType = 'SCRYPT' | 'STANDARD_SCRYPT' | 'HMAC_SHA512' | @@ -81,56 +79,6 @@ export interface UserImportRecord { tenantId?: string; } -/** Interface representing an Auth second factor in Auth server format. */ -export interface AuthFactorInfo { - // Not required for signupNewUser endpoint. - mfaEnrollmentId?: string; - displayName?: string; - phoneInfo?: string; - enrolledAt?: string; - [key: string]: any; -} - - -/** UploadAccount endpoint request user interface. */ -interface UploadAccountUser { - localId: string; - email?: string; - emailVerified?: boolean; - displayName?: string; - disabled?: boolean; - photoUrl?: string; - phoneNumber?: string; - providerUserInfo?: Array<{ - rawId: string; - providerId: string; - email?: string; - displayName?: string; - photoUrl?: string; - }>; - mfaInfo?: AuthFactorInfo[]; - passwordHash?: string; - salt?: string; - lastLoginAt?: number; - createdAt?: number; - customAttributes?: string; - tenantId?: string; -} - - -/** UploadAccount endpoint request hash options. */ -export interface UploadAccountOptions { - hashAlgorithm?: string; - signerKey?: string; - rounds?: number; - memoryCost?: number; - saltSeparator?: string; - cpuMemCost?: number; - parallelization?: number; - blockSize?: number; - dkLen?: number; -} - /** UploadAccount endpoint complete request interface. */ export interface UploadAccountRequest extends UploadAccountOptions { @@ -148,444 +96,3 @@ export interface UserImportResult { /** Callback function to validate an UploadAccountUser object. */ export type ValidatorFunction = (data: UploadAccountUser) => void; - - -/** - * Converts a client format second factor object to server format. - * @param multiFactorInfo The client format second factor. - * @return The corresponding AuthFactorInfo server request format. - */ -export function convertMultiFactorInfoToServerFormat(multiFactorInfo: SecondFactor): AuthFactorInfo { - let enrolledAt; - if (typeof multiFactorInfo.enrollmentTime !== 'undefined') { - if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { - // Convert from UTC date string (client side format) to ISO date string (server side format). - enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); - } else { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ENROLLMENT_TIME, - `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + - `UTC date string.`); - } - } - // Currently only phone second factors are supported. - if (multiFactorInfo.factorId === 'phone') { - // If any required field is missing or invalid, validation will still fail later. - const authFactorInfo: AuthFactorInfo = { - mfaEnrollmentId: multiFactorInfo.uid, - displayName: multiFactorInfo.displayName, - // Required for all phone second factors. - phoneInfo: multiFactorInfo.phoneNumber, - enrolledAt, - }; - for (const objKey in authFactorInfo) { - if (typeof authFactorInfo[objKey] === 'undefined') { - delete authFactorInfo[objKey]; - } - } - return authFactorInfo; - } else { - // Unsupported second factor. - throw new FirebaseAuthError( - AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, - `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.`); - } -} - - -/** - * @param {any} obj The object to check for number field within. - * @param {string} key The entry key. - * @return {number} The corresponding number if available. Otherwise, NaN. - */ -function getNumberField(obj: any, key: string): number { - if (typeof obj[key] !== 'undefined' && obj[key] !== null) { - return parseInt(obj[key].toString(), 10); - } - return NaN; -} - - -/** - * Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid - * fields are provided. - * @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. - * @param {ValidatorFunction=} userValidator The user validator function. - * @return {UploadAccountUser} The corresponding UploadAccountUser to return. - */ -function populateUploadAccountUser( - user: UserImportRecord, userValidator?: ValidatorFunction): UploadAccountUser { - const result: UploadAccountUser = { - localId: user.uid, - email: user.email, - emailVerified: user.emailVerified, - displayName: user.displayName, - disabled: user.disabled, - photoUrl: user.photoURL, - phoneNumber: user.phoneNumber, - providerUserInfo: [], - mfaInfo: [], - tenantId: user.tenantId, - customAttributes: user.customClaims && JSON.stringify(user.customClaims), - }; - if (typeof user.passwordHash !== 'undefined') { - if (!validator.isBuffer(user.passwordHash)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PASSWORD_HASH, - ); - } - result.passwordHash = utils.toWebSafeBase64(user.passwordHash); - } - if (typeof user.passwordSalt !== 'undefined') { - if (!validator.isBuffer(user.passwordSalt)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_PASSWORD_SALT, - ); - } - result.salt = utils.toWebSafeBase64(user.passwordSalt); - } - if (validator.isNonNullObject(user.metadata)) { - if (validator.isNonEmptyString(user.metadata.creationTime)) { - result.createdAt = new Date(user.metadata.creationTime).getTime(); - } - if (validator.isNonEmptyString(user.metadata.lastSignInTime)) { - result.lastLoginAt = new Date(user.metadata.lastSignInTime).getTime(); - } - } - if (validator.isArray(user.providerData)) { - user.providerData.forEach((providerData) => { - result.providerUserInfo!.push({ - providerId: providerData.providerId, - rawId: providerData.uid, - email: providerData.email, - displayName: providerData.displayName, - photoUrl: providerData.photoURL, - }); - }); - } - - // Convert user.multiFactor.enrolledFactors to server format. - if (validator.isNonNullObject(user.multiFactor) && - validator.isNonEmptyArray(user.multiFactor.enrolledFactors)) { - user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { - result.mfaInfo!.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); - }); - } - - // Remove blank fields. - let key: keyof UploadAccountUser; - for (key in result) { - if (typeof result[key] === 'undefined') { - delete result[key]; - } - } - if (result.providerUserInfo!.length === 0) { - delete result.providerUserInfo; - } - if (result.mfaInfo!.length === 0) { - delete result.mfaInfo; - } - // Validate the constructured user individual request. This will throw if an error - // is detected. - if (typeof userValidator === 'function') { - userValidator(result); - } - return result; -} - - -/** - * Class that provides a helper for building/validating uploadAccount requests and - * UserImportResult responses. - */ -export class UserImportBuilder { - private requiresHashOptions: boolean; - private validatedUsers: UploadAccountUser[]; - private validatedOptions: UploadAccountOptions; - private indexMap: {[key: number]: number}; - private userImportResultErrors: FirebaseArrayIndexError[]; - - /** - * @param {UserImportRecord[]} users The list of user records to import. - * @param {UserImportOptions=} options The import options which includes hashing - * algorithm details. - * @param {ValidatorFunction=} userRequestValidator The user request validator function. - * @constructor - */ - constructor( - users: UserImportRecord[], - options?: UserImportOptions, - userRequestValidator?: ValidatorFunction) { - this.requiresHashOptions = false; - this.validatedUsers = []; - this.userImportResultErrors = []; - this.indexMap = {}; - - this.validatedUsers = this.populateUsers(users, userRequestValidator); - this.validatedOptions = this.populateOptions(options, this.requiresHashOptions); - } - - /** - * Returns the corresponding constructed uploadAccount request. - * @return {UploadAccountRequest} The constructed uploadAccount request. - */ - public buildRequest(): UploadAccountRequest { - const users = this.validatedUsers.map((user) => { - return deepCopy(user); - }); - return deepExtend({users}, deepCopy(this.validatedOptions)) as UploadAccountRequest; - } - - /** - * Populates the UserImportResult using the client side detected errors and the server - * side returned errors. - * @return {UserImportResult} The user import result based on the returned failed - * uploadAccount response. - */ - public buildResponse( - failedUploads: Array<{index: number; message: string}>): UserImportResult { - // Initialize user import result. - const importResult: UserImportResult = { - successCount: this.validatedUsers.length, - failureCount: this.userImportResultErrors.length, - errors: deepCopy(this.userImportResultErrors), - }; - importResult.failureCount += failedUploads.length; - importResult.successCount -= failedUploads.length; - failedUploads.forEach((failedUpload) => { - importResult.errors.push({ - // Map backend request index to original developer provided array index. - index: this.indexMap[failedUpload.index], - error: new FirebaseAuthError( - AuthClientErrorCode.INVALID_USER_IMPORT, - failedUpload.message, - ), - }); - }); - // Sort errors by index. - importResult.errors.sort((a, b) => { - return a.index - b.index; - }); - // Return sorted result. - return importResult; - } - - /** - * Validates and returns the hashing options of the uploadAccount request. - * Throws an error whenever an invalid or missing options is detected. - * @param {UserImportOptions} options The UserImportOptions. - * @param {boolean} requiresHashOptions Whether to require hash options. - * @return {UploadAccountOptions} The populated UploadAccount options. - */ - private populateOptions( - options: UserImportOptions | undefined, requiresHashOptions: boolean): UploadAccountOptions { - let populatedOptions: UploadAccountOptions; - if (!requiresHashOptions) { - return {}; - } - if (!validator.isNonNullObject(options)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - '"UserImportOptions" are required when importing users with passwords.', - ); - } - if (!validator.isNonNullObject(options.hash)) { - throw new FirebaseAuthError( - AuthClientErrorCode.MISSING_HASH_ALGORITHM, - `"hash.algorithm" is missing from the provided "UserImportOptions".`, - ); - } - if (typeof options.hash.algorithm === 'undefined' || - !validator.isNonEmptyString(options.hash.algorithm)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ALGORITHM, - `"hash.algorithm" must be a string matching the list of supported algorithms.`, - ); - } - - let rounds: number; - switch (options.hash.algorithm) { - case 'HMAC_SHA512': - case 'HMAC_SHA256': - case 'HMAC_SHA1': - case 'HMAC_MD5': - if (!validator.isBuffer(options.hash.key)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_KEY, - `A non-empty "hash.key" byte buffer must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - signerKey: utils.toWebSafeBase64(options.hash.key), - }; - break; - - case 'MD5': - case 'SHA1': - case 'SHA256': - case 'SHA512': { - // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] - rounds = getNumberField(options.hash, 'rounds'); - const minRounds = options.hash.algorithm === 'MD5' ? 0 : 1; - if (isNaN(rounds) || rounds < minRounds || rounds > 8192) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between ${minRounds} and 8192 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - rounds, - }; - break; - } - case 'PBKDF_SHA1': - case 'PBKDF2_SHA256': - rounds = getNumberField(options.hash, 'rounds'); - if (isNaN(rounds) || rounds < 0 || rounds > 120000) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between 0 and 120000 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - rounds, - }; - break; - - case 'SCRYPT': { - if (!validator.isBuffer(options.hash.key)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_KEY, - `A "hash.key" byte buffer must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - rounds = getNumberField(options.hash, 'rounds'); - if (isNaN(rounds) || rounds <= 0 || rounds > 8) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ROUNDS, - `A valid "hash.rounds" number between 1 and 8 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const memoryCost = getNumberField(options.hash, 'memoryCost'); - if (isNaN(memoryCost) || memoryCost <= 0 || memoryCost > 14) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_MEMORY_COST, - `A valid "hash.memoryCost" number between 1 and 14 must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - if (typeof options.hash.saltSeparator !== 'undefined' && - !validator.isBuffer(options.hash.saltSeparator)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_SALT_SEPARATOR, - `"hash.saltSeparator" must be a byte buffer.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - signerKey: utils.toWebSafeBase64(options.hash.key), - rounds, - memoryCost, - saltSeparator: utils.toWebSafeBase64(options.hash.saltSeparator || Buffer.from('')), - }; - break; - } - case 'BCRYPT': - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - }; - break; - - case 'STANDARD_SCRYPT': { - const cpuMemCost = getNumberField(options.hash, 'memoryCost'); - if (isNaN(cpuMemCost)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_MEMORY_COST, - `A valid "hash.memoryCost" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const parallelization = getNumberField(options.hash, 'parallelization'); - if (isNaN(parallelization)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_PARALLELIZATION, - `A valid "hash.parallelization" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const blockSize = getNumberField(options.hash, 'blockSize'); - if (isNaN(blockSize)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_BLOCK_SIZE, - `A valid "hash.blockSize" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - const dkLen = getNumberField(options.hash, 'derivedKeyLength'); - if (isNaN(dkLen)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_DERIVED_KEY_LENGTH, - `A valid "hash.derivedKeyLength" number must be provided for ` + - `hash algorithm ${options.hash.algorithm}.`, - ); - } - populatedOptions = { - hashAlgorithm: options.hash.algorithm, - cpuMemCost, - parallelization, - blockSize, - dkLen, - }; - break; - } - default: - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_HASH_ALGORITHM, - `Unsupported hash algorithm provider "${options.hash.algorithm}".`, - ); - } - return populatedOptions; - } - - /** - * Validates and returns the users list of the uploadAccount request. - * Whenever a user with an error is detected, the error is cached and will later be - * merged into the user import result. This allows the processing of valid users without - * failing early on the first error detected. - * @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser - * objects. - * @param {ValidatorFunction=} userValidator The user validator function. - * @return {UploadAccountUser[]} The populated uploadAccount users. - */ - private populateUsers( - users: UserImportRecord[], userValidator?: ValidatorFunction): UploadAccountUser[] { - const populatedUsers: UploadAccountUser[] = []; - users.forEach((user, index) => { - try { - const result = populateUploadAccountUser(user, userValidator); - if (typeof result.passwordHash !== 'undefined') { - this.requiresHashOptions = true; - } - // Only users that pass client screening will be passed to backend for processing. - populatedUsers.push(result); - // Map user's index (the one to be sent to backend) to original developer provided array. - this.indexMap[populatedUsers.length - 1] = index; - } catch (error) { - // Save the client side error with respect to the developer provided array. - this.userImportResultErrors.push({ - index, - error, - }); - } - }); - return populatedUsers; - } -} diff --git a/src/firebase-namespace.ts b/src/firebase-namespace.ts index 557be2a51b..77a205e446 100644 --- a/src/firebase-namespace.ts +++ b/src/firebase-namespace.ts @@ -23,9 +23,9 @@ import {FirebaseServiceFactory} from './firebase-service'; // FirebaseServiceInt import { Credential, RefreshTokenCredential, - ServiceAccountCredential, getApplicationDefault, } from './auth/credential'; +import {ServiceAccountCredential} from './auth/credential-internal'; import * as validator from './utils/validator'; diff --git a/src/utils/index.ts b/src/utils/index.ts index f6e2f41232..d7f5309f0e 100755 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,7 +15,7 @@ */ import {FirebaseApp, FirebaseAppOptions} from '../firebase-app'; -import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential'; +import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential-internal'; import * as validator from './validator'; diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index 7c73e8fe54..72c8edcb7a 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -27,7 +27,8 @@ import * as jwt from 'jsonwebtoken'; import {FirebaseNamespace} from '../../src/firebase-namespace'; import {FirebaseServiceInterface} from '../../src/firebase-service'; import {FirebaseApp, FirebaseAppOptions} from '../../src/firebase-app'; -import {Credential, GoogleOAuthAccessToken, ServiceAccountCredential} from '../../src/auth/credential'; +import {Credential, GoogleOAuthAccessToken} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; const ALGORITHM = 'RS256'; const ONE_HOUR_IN_SECONDS = 60 * 60; diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index 15f90407d7..3c8ba0c653 100755 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -36,13 +36,17 @@ import { RESERVED_CLAIMS, FIREBASE_AUTH_UPLOAD_ACCOUNT, FIREBASE_AUTH_CREATE_SESSION_COOKIE, EMAIL_ACTION_REQUEST_TYPES, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, } from '../../../src/auth/auth-api-request'; -import {UserImportBuilder, UserImportRecord} from '../../../src/auth/user-import-builder'; +import {UserImportRecord} from '../../../src/auth/user-import-builder'; +import {UserImportBuilder} from '../../../src/auth/user-import-builder-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {ActionCodeSettingsBuilder} from '../../../src/auth/action-code-settings-builder'; import { OIDCAuthProviderConfig, SAMLAuthProviderConfig, OIDCUpdateAuthProviderRequest, - SAMLUpdateAuthProviderRequest, SAMLConfigServerResponse, + SAMLUpdateAuthProviderRequest, } from '../../../src/auth/auth-config'; +import { + SAMLConfigServerResponse, +} from '../../../src/auth/auth-config-internal'; import {UserIdentifier} from '../../../src/auth/identifier'; import {TenantOptions} from '../../../src/auth/tenant'; import { UpdateRequest, UpdateMultiFactorInfoRequest } from '../../../src/auth/user-record'; diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index d5777d787d..f291a28e6f 100755 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -20,14 +20,18 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; + import { - OIDCConfig, SAMLConfig, SAMLConfigServerRequest, - SAMLConfigServerResponse, OIDCConfigServerRequest, - OIDCConfigServerResponse, SAMLUpdateAuthProviderRequest, + SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, SAMLAuthProviderConfig, OIDCAuthProviderConfig, - EmailSignInConfig, } from '../../../src/auth/auth-config'; +import { + OIDCConfig, SAMLConfig, SAMLConfigServerRequest, + SAMLConfigServerResponse, OIDCConfigServerRequest, + OIDCConfigServerResponse, EmailSignInConfig, +} from '../../../src/auth/auth-config-internal'; + chai.should(); chai.use(sinonChai); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index b869580c1f..608d97a094 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -35,14 +35,15 @@ import { import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import * as validator from '../../../src/utils/validator'; -import { FirebaseTokenVerifier } from '../../../src/auth/token-verifier'; +import { FirebaseTokenVerifier } from '../../../src/auth/token-verifier-internal'; +import {AuthProviderConfigFilter} from '../../../src/auth/auth-config'; import { - AuthProviderConfigFilter, OIDCConfig, SAMLConfig, + OIDCConfig, SAMLConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, -} from '../../../src/auth/auth-config'; +} from '../../../src/auth/auth-config-internal'; import {deepCopy} from '../../../src/utils/deep-copy'; import { TenantManager } from '../../../src/auth/tenant-manager'; -import { ServiceAccountCredential } from '../../../src/auth/credential'; +import { ServiceAccountCredential } from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; chai.should(); diff --git a/test/unit/auth/credential.spec.ts b/test/unit/auth/credential.spec.ts index 881dcb7525..de328ad07a 100644 --- a/test/unit/auth/credential.spec.ts +++ b/test/unit/auth/credential.spec.ts @@ -31,9 +31,12 @@ import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; import { - GoogleOAuthAccessToken, RefreshTokenCredential, ServiceAccountCredential, - ComputeEngineCredential, getApplicationDefault, isApplicationDefault, Credential, + GoogleOAuthAccessToken, RefreshTokenCredential, + getApplicationDefault, isApplicationDefault, Credential, } from '../../../src/auth/credential'; +import { + ServiceAccountCredential, ComputeEngineCredential +} from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; import {Agent} from 'https'; import { FirebaseAppError } from '../../../src/utils/error'; diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 1fb1e24dfa..fa31ef21c4 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,7 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; -import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config'; +import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config-internal'; import { Tenant, TenantOptions, TenantServerResponse, } from '../../../src/auth/tenant'; diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index 3a6dbe4df4..6d722a91e3 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -25,10 +25,11 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { - BLACKLISTED_CLAIMS, FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner, + BLACKLISTED_CLAIMS, } from '../../../src/auth/token-generator'; +import {FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner} from '../../../src/auth/token-generator-internal'; -import { ServiceAccountCredential } from '../../../src/auth/credential'; +import { ServiceAccountCredential } from '../../../src/auth/credential-internal'; import { AuthorizedHttpClient, HttpClient } from '../../../src/utils/api-request'; import { FirebaseApp } from '../../../src/firebase-app'; import * as utils from '../utils'; diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index bf95f96380..cbd436ed6a 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -28,10 +28,10 @@ import * as chaiAsPromised from 'chai-as-promised'; import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); import * as mocks from '../../resources/mocks'; -import {FirebaseTokenGenerator, ServiceAccountSigner} from '../../../src/auth/token-generator'; -import * as verifier from '../../../src/auth/token-verifier'; +import {FirebaseTokenGenerator, ServiceAccountSigner} from '../../../src/auth/token-generator-internal'; +import * as verifier from '../../../src/auth/token-verifier-internal'; -import {ServiceAccountCredential} from '../../../src/auth/credential'; +import {ServiceAccountCredential} from '../../../src/auth/credential-internal'; import { AuthClientErrorCode } from '../../../src/utils/error'; import { FirebaseApp } from '../../../src/firebase-app'; diff --git a/test/unit/auth/user-import-builder.spec.ts b/test/unit/auth/user-import-builder.spec.ts index b94e22f1f4..16ee9a1b21 100755 --- a/test/unit/auth/user-import-builder.spec.ts +++ b/test/unit/auth/user-import-builder.spec.ts @@ -20,9 +20,12 @@ import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; import { - UserImportBuilder, ValidatorFunction, UserImportResult, UserImportRecord, + ValidatorFunction, UserImportResult, UserImportRecord, UploadAccountRequest, } from '../../../src/auth/user-import-builder'; +import { + UserImportBuilder +} from '../../../src/auth/user-import-builder-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; import {toWebSafeBase64} from '../../../src/utils'; diff --git a/test/unit/firebase-app.spec.ts b/test/unit/firebase-app.spec.ts index f89970bb0c..d4c6551c06 100644 --- a/test/unit/firebase-app.spec.ts +++ b/test/unit/firebase-app.spec.ts @@ -25,7 +25,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as utils from './utils'; import * as mocks from '../resources/mocks'; -import {GoogleOAuthAccessToken, ServiceAccountCredential} from '../../src/auth/credential'; +import {GoogleOAuthAccessToken} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; // import {FirebaseServiceInterface} from '../../src/firebase-service'; import {FirebaseApp, FirebaseAccessToken} from '../../src/firebase-app'; import {FirebaseNamespace, FirebaseNamespaceInternals, FIREBASE_CONFIG_VAR} from '../../src/firebase-namespace'; diff --git a/test/unit/firebase.spec.ts b/test/unit/firebase.spec.ts index 0819bbb704..00f9a524cb 100644 --- a/test/unit/firebase.spec.ts +++ b/test/unit/firebase.spec.ts @@ -27,7 +27,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../resources/mocks'; import * as firebaseAdmin from '../../src/index'; -import {RefreshTokenCredential, ServiceAccountCredential, isApplicationDefault} from '../../src/auth/credential'; +import {RefreshTokenCredential, isApplicationDefault} from '../../src/auth/credential'; +import {ServiceAccountCredential} from '../../src/auth/credential-internal'; chai.should(); chai.use(chaiAsPromised); diff --git a/test/unit/utils/index.spec.ts b/test/unit/utils/index.spec.ts index 2065e2fb49..9631072b01 100755 --- a/test/unit/utils/index.spec.ts +++ b/test/unit/utils/index.spec.ts @@ -25,7 +25,7 @@ import { } from '../../../src/utils/index'; import {isNonEmptyString} from '../../../src/utils/validator'; import {FirebaseApp, FirebaseAppOptions} from '../../../src/firebase-app'; -import { ComputeEngineCredential } from '../../../src/auth/credential'; +import { ComputeEngineCredential } from '../../../src/auth/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; import * as utils from '../utils'; import { FirebaseAppError } from '../../../src/utils/error'; From 96fc7763c2d7ff151c325a8347a57615e149de23 Mon Sep 17 00:00:00 2001 From: MathBunny Date: Mon, 6 Jul 2020 15:29:29 -0400 Subject: [PATCH 3/6] Add progress --- src/auth/user-record-internal.ts | 288 +++++++++++++++++++++++++++++ src/auth/user-record.ts | 223 +++------------------- test/unit/auth/user-record.spec.ts | 13 +- 3 files changed, 317 insertions(+), 207 deletions(-) create mode 100644 src/auth/user-record-internal.ts diff --git a/src/auth/user-record-internal.ts b/src/auth/user-record-internal.ts new file mode 100644 index 0000000000..944eaf66e1 --- /dev/null +++ b/src/auth/user-record-internal.ts @@ -0,0 +1,288 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {MultiFactorInfo, PhoneMultiFactorInfo} from './user-record'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import {isNonNullObject} from '../utils/validator'; +import * as utils from '../utils'; + +export interface MultiFactorInfoResponse { + mfaEnrollmentId: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +export interface ProviderUserInfoResponse { + rawId: string; + displayName?: string; + email?: string; + photoUrl?: string; + phoneNumber?: string; + providerId: string; + federatedId?: string; +} + +export interface GetAccountInfoUserResponse { + localId: string; + email?: string; + emailVerified?: boolean; + phoneNumber?: string; + displayName?: string; + photoUrl?: string; + disabled?: boolean; + passwordHash?: string; + salt?: string; + customAttributes?: string; + validSince?: string; + tenantId?: string; + providerUserInfo?: ProviderUserInfoResponse[]; + mfaInfo?: MultiFactorInfoResponse[]; + createdAt?: string; + lastLoginAt?: string; + [key: string]: any; +} + +/** Enums for multi-factor identifiers. */ +export enum MultiFactorId { + Phone = 'phone', +} + + +/** Class representing multi-factor related properties of a user. */ +export class MultiFactor { + public readonly enrolledFactors: ReadonlyArray; + + /** + * Initializes the MultiFactor object using the server side or JWT format response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: GetAccountInfoUserResponse) { + const parsedEnrolledFactors: MultiFactorInfo[] = []; + if (!isNonNullObject(response)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor response'); + } else if (response.mfaInfo) { + response.mfaInfo.forEach((factorResponse) => { + const multiFactorInfo = initMultiFactorInfo(factorResponse); + if (multiFactorInfo) { + parsedEnrolledFactors.push(multiFactorInfo); + } + }); + } + // Make enrolled factors immutable. + utils.addReadonlyGetter( + this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors)); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()), + }; + } +} + +/** + * Initializes the MultiFactorInfo associated subclass using the server side. + * If no MultiFactorInfo is associated with the response, null is returned. + * + * @param response The server side response. + * @constructor + */ +export function initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null { + let multiFactorInfo: MultiFactorInfo | null = null; + // Only PhoneMultiFactorInfo currently available. + try { + multiFactorInfo = new PhoneMultiFactorInfo(response); + } catch (e) { + // Ignore error. + } + return multiFactorInfo; +} + +/** + * Abstract class representing a multi-factor info interface. + */ +export abstract class MultiFactorInfo { + public readonly uid: string; + public readonly displayName: string | null; + public readonly factorId: MultiFactorId; + public readonly enrollmentTime: string; + + /** + * Initializes the MultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: MultiFactorInfoResponse) { + this.initFromServerResponse(response); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + uid: this.uid, + displayName: this.displayName, + factorId: this.factorId, + enrollmentTime: this.enrollmentTime, + }; + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected abstract getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null; + + /** + * Initializes the MultiFactorInfo object using the provided server response. + * + * @param response The server side response. + */ + private initFromServerResponse(response: MultiFactorInfoResponse): void { + const factorId = response && this.getFactorId(response); + if (!factorId || !response || !response.mfaEnrollmentId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + } + utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); + utils.addReadonlyGetter(this, 'factorId', factorId); + utils.addReadonlyGetter(this, 'displayName', response.displayName || null); + // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. + // For example, "2017-01-15T01:30:15.01Z". + // This can be parsed directly via Date constructor. + // This can be computed using Data.prototype.toISOString. + if (response.enrolledAt) { + utils.addReadonlyGetter( + this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); + } else { + utils.addReadonlyGetter(this, 'enrollmentTime', null); + } + } +} + +/** + * Abstract class representing a multi-factor info interface. + */ +export abstract class MultiFactorInfoImpl implements MultiFactorInfo { + public readonly uid: string; + public readonly displayName: string | null; + public readonly factorId: MultiFactorId; + public readonly enrollmentTime: string; + + /** + * Initializes the MultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: MultiFactorInfoResponse) { + this.initFromServerResponse(response); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return { + uid: this.uid, + displayName: this.displayName, + factorId: this.factorId, + enrollmentTime: this.enrollmentTime, + }; + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected abstract getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null; + + /** + * Initializes the MultiFactorInfo object using the provided server response. + * + * @param response The server side response. + */ + private initFromServerResponse(response: MultiFactorInfoResponse): void { + const factorId = response && this.getFactorId(response); + if (!factorId || !response || !response.mfaEnrollmentId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor info response'); + } + utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); + utils.addReadonlyGetter(this, 'factorId', factorId); + utils.addReadonlyGetter(this, 'displayName', response.displayName || null); + // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. + // For example, "2017-01-15T01:30:15.01Z". + // This can be parsed directly via Date constructor. + // This can be computed using Data.prototype.toISOString. + if (response.enrolledAt) { + utils.addReadonlyGetter( + this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); + } else { + utils.addReadonlyGetter(this, 'enrollmentTime', null); + } + } +} + + +/** Class representing a phone MultiFactorInfo object. */ +export class PhoneMultiFactorInfoImpl extends MultiFactorInfoImpl { + public readonly phoneNumber: string; + + /** + * Initializes the PhoneMultiFactorInfo object using the server side response. + * + * @param response The server side response. + * @constructor + */ + constructor(response: MultiFactorInfoResponse) { + super(response); + utils.addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); + } + + /** @return The plain object representation. */ + public toJSON(): any { + return Object.assign( + super.toJSON(), + { + phoneNumber: this.phoneNumber, + }); + } + + /** + * Returns the factor ID based on the response provided. + * + * @param response The server side response. + * @return The multi-factor ID associated with the provided response. If the response is + * not associated with any known multi-factor ID, null is returned. + */ + protected getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null { + return (response && response.phoneInfo) ? MultiFactorId.Phone : null; + } +} \ No newline at end of file diff --git a/src/auth/user-record.ts b/src/auth/user-record.ts index 7d0ebef81b..da6542f202 100644 --- a/src/auth/user-record.ts +++ b/src/auth/user-record.ts @@ -15,8 +15,9 @@ */ import {deepCopy} from '../utils/deep-copy'; -import {isNonNullObject} from '../utils/validator'; import * as utils from '../utils'; +import {MultiFactorInfoResponse, MultiFactorId, GetAccountInfoUserResponse, + MultiFactor, ProviderUserInfoResponse} from './user-record-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; /** @@ -100,206 +101,6 @@ export interface CreateRequest extends UpdateRequest { }; } -export interface MultiFactorInfoResponse { - mfaEnrollmentId: string; - displayName?: string; - phoneInfo?: string; - enrolledAt?: string; - [key: string]: any; -} - -export interface ProviderUserInfoResponse { - rawId: string; - displayName?: string; - email?: string; - photoUrl?: string; - phoneNumber?: string; - providerId: string; - federatedId?: string; -} - -export interface GetAccountInfoUserResponse { - localId: string; - email?: string; - emailVerified?: boolean; - phoneNumber?: string; - displayName?: string; - photoUrl?: string; - disabled?: boolean; - passwordHash?: string; - salt?: string; - customAttributes?: string; - validSince?: string; - tenantId?: string; - providerUserInfo?: ProviderUserInfoResponse[]; - mfaInfo?: MultiFactorInfoResponse[]; - createdAt?: string; - lastLoginAt?: string; - [key: string]: any; -} - -/** Enums for multi-factor identifiers. */ -export enum MultiFactorId { - Phone = 'phone', -} - -/** - * Abstract class representing a multi-factor info interface. - */ -export abstract class MultiFactorInfo { - public readonly uid: string; - public readonly displayName: string | null; - public readonly factorId: MultiFactorId; - public readonly enrollmentTime: string; - - /** - * Initializes the MultiFactorInfo associated subclass using the server side. - * If no MultiFactorInfo is associated with the response, null is returned. - * - * @param response The server side response. - * @constructor - */ - public static initMultiFactorInfo(response: MultiFactorInfoResponse): MultiFactorInfo | null { - let multiFactorInfo: MultiFactorInfo | null = null; - // Only PhoneMultiFactorInfo currently available. - try { - multiFactorInfo = new PhoneMultiFactorInfo(response); - } catch (e) { - // Ignore error. - } - return multiFactorInfo; - } - - /** - * Initializes the MultiFactorInfo object using the server side response. - * - * @param response The server side response. - * @constructor - */ - constructor(response: MultiFactorInfoResponse) { - this.initFromServerResponse(response); - } - - /** @return The plain object representation. */ - public toJSON(): any { - return { - uid: this.uid, - displayName: this.displayName, - factorId: this.factorId, - enrollmentTime: this.enrollmentTime, - }; - } - - /** - * Returns the factor ID based on the response provided. - * - * @param response The server side response. - * @return The multi-factor ID associated with the provided response. If the response is - * not associated with any known multi-factor ID, null is returned. - */ - protected abstract getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null; - - /** - * Initializes the MultiFactorInfo object using the provided server response. - * - * @param response The server side response. - */ - private initFromServerResponse(response: MultiFactorInfoResponse): void { - const factorId = response && this.getFactorId(response); - if (!factorId || !response || !response.mfaEnrollmentId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid multi-factor info response'); - } - utils.addReadonlyGetter(this, 'uid', response.mfaEnrollmentId); - utils.addReadonlyGetter(this, 'factorId', factorId); - utils.addReadonlyGetter(this, 'displayName', response.displayName || null); - // Encoded using [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. - // For example, "2017-01-15T01:30:15.01Z". - // This can be parsed directly via Date constructor. - // This can be computed using Data.prototype.toISOString. - if (response.enrolledAt) { - utils.addReadonlyGetter( - this, 'enrollmentTime', new Date(response.enrolledAt).toUTCString()); - } else { - utils.addReadonlyGetter(this, 'enrollmentTime', null); - } - } -} - -/** Class representing a phone MultiFactorInfo object. */ -export class PhoneMultiFactorInfo extends MultiFactorInfo { - public readonly phoneNumber: string; - - /** - * Initializes the PhoneMultiFactorInfo object using the server side response. - * - * @param response The server side response. - * @constructor - */ - constructor(response: MultiFactorInfoResponse) { - super(response); - utils.addReadonlyGetter(this, 'phoneNumber', response.phoneInfo); - } - - /** @return The plain object representation. */ - public toJSON(): any { - return Object.assign( - super.toJSON(), - { - phoneNumber: this.phoneNumber, - }); - } - - /** - * Returns the factor ID based on the response provided. - * - * @param response The server side response. - * @return The multi-factor ID associated with the provided response. If the response is - * not associated with any known multi-factor ID, null is returned. - */ - protected getFactorId(response: MultiFactorInfoResponse): MultiFactorId | null { - return (response && response.phoneInfo) ? MultiFactorId.Phone : null; - } -} - -/** Class representing multi-factor related properties of a user. */ -export class MultiFactor { - public readonly enrolledFactors: ReadonlyArray; - - /** - * Initializes the MultiFactor object using the server side or JWT format response. - * - * @param response The server side response. - * @constructor - */ - constructor(response: GetAccountInfoUserResponse) { - const parsedEnrolledFactors: MultiFactorInfo[] = []; - if (!isNonNullObject(response)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid multi-factor response'); - } else if (response.mfaInfo) { - response.mfaInfo.forEach((factorResponse) => { - const multiFactorInfo = MultiFactorInfo.initMultiFactorInfo(factorResponse); - if (multiFactorInfo) { - parsedEnrolledFactors.push(multiFactorInfo); - } - }); - } - // Make enrolled factors immutable. - utils.addReadonlyGetter( - this, 'enrolledFactors', Object.freeze(parsedEnrolledFactors)); - } - - /** @return The plain object representation. */ - public toJSON(): any { - return { - enrolledFactors: this.enrolledFactors.map((info) => info.toJSON()), - }; - } -} - /** * User metadata class that provides metadata information like user account creation * and last sign in time. @@ -489,3 +290,23 @@ export class UserRecord { return json; } } + + +/** + * Abstract class representing a multi-factor info interface. + */ +export interface MultiFactorInfo { + readonly uid: string; + readonly displayName: string | null; + readonly factorId: MultiFactorId; + readonly enrollmentTime: string; + + /** @return The plain object representation. */ + toJSON(): any; +} + + +/** Class representing a phone MultiFactorInfo object. */ +export interface PhoneMultiFactorInfo extends MultiFactorInfo { + readonly phoneNumber: string; +} \ No newline at end of file diff --git a/test/unit/auth/user-record.spec.ts b/test/unit/auth/user-record.spec.ts index af1ae9773b..c17d89549a 100644 --- a/test/unit/auth/user-record.spec.ts +++ b/test/unit/auth/user-record.spec.ts @@ -20,9 +20,10 @@ import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; import { - UserInfo, UserMetadata, UserRecord, GetAccountInfoUserResponse, ProviderUserInfoResponse, - MultiFactor, PhoneMultiFactorInfo, MultiFactorInfo, MultiFactorInfoResponse, + UserInfo, UserMetadata, UserRecord, PhoneMultiFactorInfo, } from '../../../src/auth/user-record'; +import {GetAccountInfoUserResponse, ProviderUserInfoResponse, + MultiFactor, MultiFactorInfoResponse, initMultiFactorInfo} from '../../../src/auth/user-record-internal'; chai.should(); @@ -246,8 +247,8 @@ describe('PhoneMultiFactorInfo', () => { enrolledAt: now.toISOString(), phoneInfo: '+16505551234', }; - const phoneMultiFactorInfo = new PhoneMultiFactorInfo(serverResponse); - const phoneMultiFactorInfoMissingFields = new PhoneMultiFactorInfo({ + const phoneMultiFactorInfo = new PhoneMultiFactorInfoImpl(serverResponse); + const phoneMultiFactorInfoMissingFields = new PhoneMultiFactorInfoImpl({ mfaEnrollmentId: serverResponse.mfaEnrollmentId, phoneInfo: serverResponse.phoneInfo, }); @@ -385,11 +386,11 @@ describe('MultiFactorInfo', () => { describe('initMultiFactorInfo', () => { it('should return expected PhoneMultiFactorInfo', () => { - expect(MultiFactorInfo.initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo); + expect(initMultiFactorInfo(serverResponse)).to.deep.equal(phoneMultiFactorInfo); }); it('should return null for invalid MultiFactorInfo', () => { - expect(MultiFactorInfo.initMultiFactorInfo(undefined as any)).to.be.null; + expect(initMultiFactorInfo(undefined as any)).to.be.null; }); }); }); From 5606241baa01e0e292bc90a78121b7823fcfc21d Mon Sep 17 00:00:00 2001 From: MathBunny Date: Thu, 9 Jul 2020 10:02:48 -0400 Subject: [PATCH 4/6] ServiceAccountCredential is internal --- test/unit/firebase.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/firebase.spec.ts b/test/unit/firebase.spec.ts index 4e22b71f60..db6e4afa57 100644 --- a/test/unit/firebase.spec.ts +++ b/test/unit/firebase.spec.ts @@ -27,7 +27,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../resources/mocks'; import { initializeApp, apps, credential, cert, app } from '../../lib/index'; -import { RefreshTokenCredential, ServiceAccountCredential, isApplicationDefault } from '../../lib/auth/credential'; +import { RefreshTokenCredential, isApplicationDefault } from '../../lib/auth/credential'; +import { ServiceAccountCredential } from '../../lib/auth/credential-internal'; chai.should(); chai.use(chaiAsPromised); From 22ee0731ea330c266dee1a4fa2734068c438f6ae Mon Sep 17 00:00:00 2001 From: MathBunny Date: Thu, 9 Jul 2020 15:06:12 -0400 Subject: [PATCH 5/6] Add Auth internal --- src/auth/auth-api-request.ts | 11 +- src/auth/auth-internal.ts | 817 ++++++++++++++++++++++++++ src/auth/auth.ts | 606 +++---------------- src/auth/tenant-internal.ts | 171 ++++++ src/auth/tenant-manager.ts | 12 +- src/auth/tenant.ts | 123 +--- test/unit/auth/auth.spec.ts | 41 +- test/unit/auth/tenant-manager.spec.ts | 15 +- test/unit/auth/tenant.spec.ts | 43 +- 9 files changed, 1128 insertions(+), 711 deletions(-) create mode 100644 src/auth/auth-internal.ts create mode 100644 src/auth/tenant-internal.ts diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index b87a00af2a..551d596dbf 100755 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -44,7 +44,8 @@ import { SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, OIDCConfigServerRequest, SAMLConfigServerRequest } from './auth-config-internal'; -import {Tenant, TenantOptions, TenantServerResponse} from './tenant'; +import {TenantOptions, TenantServerResponse} from './tenant'; +import {TenantImpl} from './tenant-internal'; /** Firebase Auth request header. */ @@ -1810,7 +1811,7 @@ const UPDATE_TENANT = new ApiSettings('/tenants/{tenantId}?updateMask={updateMas .setResponseValidator((response: any) => { // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name) || - !Tenant.getTenantIdFromResourceName(response.name)) { + !TenantImpl.getTenantIdFromResourceName(response.name)) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to update tenant', @@ -1845,7 +1846,7 @@ const CREATE_TENANT = new ApiSettings('/tenants', 'POST') .setResponseValidator((response: any) => { // Response should always contain at least the tenant name. if (!validator.isNonEmptyString(response.name) || - !Tenant.getTenantIdFromResourceName(response.name)) { + !TenantImpl.getTenantIdFromResourceName(response.name)) { throw new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'INTERNAL ASSERT FAILED: Unable to create new tenant', @@ -1963,7 +1964,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { public createTenant(tenantOptions: TenantOptions): Promise { try { // Construct backend request. - const request = Tenant.buildServerRequest(tenantOptions, true); + const request = TenantImpl.buildServerRequest(tenantOptions, true); return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, CREATE_TENANT, request) .then((response: any) => { return response as TenantServerResponse; @@ -1986,7 +1987,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler { } try { // Construct backend request. - const request = Tenant.buildServerRequest(tenantOptions, false); + const request = TenantImpl.buildServerRequest(tenantOptions, false); const updateMask = utils.generateUpdateMask(request); return this.invokeRequestHandler(this.tenantMgmtResourceBuilder, UPDATE_TENANT, request, {tenantId, updateMask: updateMask.join(',')}) diff --git a/src/auth/auth-internal.ts b/src/auth/auth-internal.ts new file mode 100644 index 0000000000..2a3f0c5e9c --- /dev/null +++ b/src/auth/auth-internal.ts @@ -0,0 +1,817 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TenantAwareAuth, GetUsersResult, ListUsersResult, DeleteUsersResult, DecodedIdToken, SessionCookieOptions } from './auth'; + +import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; +import { UserIdentifier } from './identifier'; +import { + isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, +} from './identifier-internal'; +import {FirebaseApp} from '../firebase-app'; +// import {FirebaseNamespace} from '../firebase-namespace'; +import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator-internal'; +import { + AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, +} from './auth-api-request'; +import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; +import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; +import { + UserImportOptions, UserImportRecord, UserImportResult, +} from './user-import-builder'; + +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; +import { createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; +import { FirebaseTokenVerifier } from './token-verifier-internal'; +import {ActionCodeSettings} from './action-code-settings-builder'; +import { + AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, +} from './auth-config'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, +} from './auth-config-internal'; +import {TenantManager} from './tenant-manager'; + + +/** + * Base Auth class. Mainly used for user management APIs. + */ +export class BaseAuthImpl { + + protected readonly tokenGenerator: FirebaseTokenGenerator; + protected readonly idTokenVerifier: FirebaseTokenVerifier; + protected readonly sessionCookieVerifier: FirebaseTokenVerifier; + + /** + * The BaseAuth class constructor. + * + * @param app The FirebaseApp to associate with this Auth instance. + * @param authRequestHandler The RPC request handler for this instance. + * @param tokenGenerator Optional token generator. If not specified, a + * (non-tenant-aware) instance will be created. Use this paramter to + * specify a tenant-aware tokenGenerator. + * @constructor + */ + constructor(app: FirebaseApp, protected readonly authRequestHandler: T, tokenGenerator?: FirebaseTokenGenerator) { + if (tokenGenerator) { + this.tokenGenerator = tokenGenerator; + } else { + const cryptoSigner = cryptoSignerFromApp(app); + this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); + } + + this.sessionCookieVerifier = createSessionCookieVerifier(app); + this.idTokenVerifier = createIdTokenVerifier(app); + } + + /** + * Creates a new custom token that can be sent back to a client to use with + * signInWithCustomToken(). + * + * @param {string} uid The uid to use as the JWT subject. + * @param {object=} developerClaims Optional additional claims to include in the JWT payload. + * + * @return {Promise} A JWT for the provided payload. + */ + public createCustomToken(uid: string, developerClaims?: object): Promise { + return this.tokenGenerator.createCustomToken(uid, developerClaims); + } + + /** + * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the ID token was revoked. If the corresponding + * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified + * the check is not applied. + * + * @param {string} idToken The JWT to verify. + * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifyIdToken(idToken: string, checkRevoked = false): Promise { + return this.idTokenVerifier.verifyJWT(idToken) + .then((decodedIdToken: DecodedIdToken) => { + // Whether to check if the token was revoked. + if (!checkRevoked) { + return decodedIdToken; + } + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.ID_TOKEN_REVOKED); + }); + } + + /** + * Looks up the user identified by the provided user id and returns a promise that is + * fulfilled with a user record for the given user if that user is found. + * + * @param {string} uid The uid of the user to look up. + * @return {Promise} A promise that resolves with the corresponding user record. + */ + public getUser(uid: string): Promise { + return this.authRequestHandler.getAccountInfoByUid(uid) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Looks up the user identified by the provided email and returns a promise that is + * fulfilled with a user record for the given user if that user is found. + * + * @param {string} email The email of the user to look up. + * @return {Promise} A promise that resolves with the corresponding user record. + */ + public getUserByEmail(email: string): Promise { + return this.authRequestHandler.getAccountInfoByEmail(email) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Looks up the user identified by the provided phone number and returns a promise that is + * fulfilled with a user record for the given user if that user is found. + * + * @param {string} phoneNumber The phone number of the user to look up. + * @return {Promise} A promise that resolves with the corresponding user record. + */ + public getUserByPhoneNumber(phoneNumber: string): Promise { + return this.authRequestHandler.getAccountInfoByPhoneNumber(phoneNumber) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + * There are no ordering guarantees; in particular, the nth entry in the result list is not + * guaranteed to correspond to the nth entry in the input parameters list. + * + * Only a maximum of 100 identifiers may be supplied. If more than 100 identifiers are supplied, + * this method will immediately throw a FirebaseAuthError. + * + * @param identifiers The identifiers used to indicate which user records should be returned. Must + * have <= 100 entries. + * @return {Promise} A promise that resolves to the corresponding user records. + * @throws FirebaseAuthError If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + */ + public getUsers(identifiers: UserIdentifier[]): Promise { + if (!validator.isArray(identifiers)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, '`identifiers` parameter must be an array'); + } + return this.authRequestHandler + .getAccountInfoByIdentifiers(identifiers) + .then((response: any) => { + /** + * Checks if the specified identifier is within the list of + * UserRecords. + */ + const isUserFound = ((id: UserIdentifier, userRecords: UserRecord[]): boolean => { + return !!userRecords.find((userRecord) => { + if (isUidIdentifier(id)) { + return id.uid === userRecord.uid; + } else if (isEmailIdentifier(id)) { + return id.email === userRecord.email; + } else if (isPhoneIdentifier(id)) { + return id.phoneNumber === userRecord.phoneNumber; + } else if (isProviderIdentifier(id)) { + const matchingUserInfo = userRecord.providerData.find((userInfo) => { + return id.providerId === userInfo.providerId; + }); + return !!matchingUserInfo && id.providerUid === matchingUserInfo.uid; + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unhandled identifier type'); + } + }); + }); + + const users = response.users ? response.users.map((user: any) => new UserRecord(user)) : []; + const notFound = identifiers.filter((id) => !isUserFound(id, users)); + + return { users, notFound }; + }); + } + + /** + * 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. + * + * @param {number=} maxResults The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param {string=} pageToken The next page token. If not specified, returns users starting + * without any offset. + * @return {Promise<{users: UserRecord[], pageToken?: string}>} A promise that resolves with + * the current batch of downloaded users and the next page token. For the last page, an + * empty list of users and no page token are returned. + */ + public listUsers(maxResults?: number, pageToken?: string): Promise { + return this.authRequestHandler.downloadAccount(maxResults, pageToken) + .then((response: any) => { + // List of users to return. + const users: UserRecord[] = []; + // Convert each user response to a UserRecord. + response.users.forEach((userResponse: any) => { + users.push(new UserRecord(userResponse)); + }); + // Return list of user records and the next page token if available. + const result = { + users, + pageToken: response.nextPageToken, + }; + // Delete result.pageToken if undefined. + if (typeof result.pageToken === 'undefined') { + delete result.pageToken; + } + return result; + }); + } + + /** + * Creates a new user with the properties provided. + * + * @param {CreateRequest} properties The properties to set on the new user record to be created. + * @return {Promise} A promise that resolves with the newly created user record. + */ + public createUser(properties: CreateRequest): Promise { + return this.authRequestHandler.createNewAccount(properties) + .then((uid) => { + // Return the corresponding user record. + return this.getUser(uid); + }) + .catch((error) => { + if (error.code === 'auth/user-not-found') { + // Something must have happened after creating the user and then retrieving it. + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the user record provided.'); + } + throw error; + }); + } + + /** + * Deletes the user identified by the provided user id and returns a promise that is + * fulfilled when the user is found and successfully deleted. + * + * @param {string} uid The uid of the user to delete. + * @return {Promise} A promise that resolves when the user is successfully deleted. + */ + public deleteUser(uid: string): Promise { + return this.authRequestHandler.deleteAccount(uid) + .then(() => { + // Return nothing on success. + }); + } + + public deleteUsers(uids: string[]): Promise { + if (!validator.isArray(uids)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, '`uids` parameter must be an array'); + } + return this.authRequestHandler.deleteAccounts(uids, /*force=*/true) + .then((batchDeleteAccountsResponse) => { + const result: DeleteUsersResult = { + failureCount: 0, + successCount: uids.length, + errors: [], + }; + + if (!validator.isNonEmptyArray(batchDeleteAccountsResponse.errors)) { + return result; + } + + result.failureCount = batchDeleteAccountsResponse.errors.length; + result.successCount = uids.length - batchDeleteAccountsResponse.errors.length; + result.errors = batchDeleteAccountsResponse.errors.map((batchDeleteErrorInfo) => { + if (batchDeleteErrorInfo.index === undefined) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Corrupt BatchDeleteAccountsResponse detected'); + } + + const errMsgToError = (msg?: string): FirebaseAuthError => { + // We unconditionally set force=true, so the 'NOT_DISABLED' error + // should not be possible. + const code = msg && msg.startsWith('NOT_DISABLED') ? + AuthClientErrorCode.USER_NOT_DISABLED : AuthClientErrorCode.INTERNAL_ERROR; + return new FirebaseAuthError(code, batchDeleteErrorInfo.message); + }; + + return { + index: batchDeleteErrorInfo.index, + error: errMsgToError(batchDeleteErrorInfo.message), + }; + }); + + return result; + }); + } + + /** + * Updates an existing user with the properties provided. + * + * @param {string} uid The uid identifier of the user to update. + * @param {UpdateRequest} properties The properties to update on the existing user. + * @return {Promise} A promise that resolves with the modified user record. + */ + public updateUser(uid: string, properties: UpdateRequest): Promise { + return this.authRequestHandler.updateExistingAccount(uid, properties) + .then((existingUid) => { + // Return the corresponding user record. + return this.getUser(existingUid); + }); + } + + /** + * Sets additional developer claims on an existing user identified by the provided UID. + * + * @param {string} uid The user to edit. + * @param {object} customUserClaims The developer claims to set. + * @return {Promise} A promise that resolves when the operation completes + * successfully. + */ + public setCustomUserClaims(uid: string, customUserClaims: object | null): Promise { // TODO ... | null + return this.authRequestHandler.setCustomUserClaims(uid, customUserClaims) + .then(() => { + // Return nothing on success. + }); + } + + /** + * Revokes all refresh tokens for the specified user identified by the provided UID. + * In addition to revoking all refresh tokens for a user, all ID tokens issued before + * revocation will also be revoked on the Auth backend. Any request with an ID token + * generated before revocation will be rejected with a token expired error. + * + * @param {string} uid The user whose tokens are to be revoked. + * @return {Promise} A promise that resolves when the operation completes + * successfully. + */ + public revokeRefreshTokens(uid: string): Promise { + return this.authRequestHandler.revokeRefreshTokens(uid) + .then(() => { + // Return nothing on success. + }); + } + + /** + * Imports the list of users provided to Firebase Auth. This is useful when + * migrating from an external authentication system without having to use the Firebase CLI SDK. + * At most, 1000 users are allowed to be imported one at a time. + * When importing a list of password users, UserImportOptions are required to be specified. + * + * @param {UserImportRecord[]} users The list of user records to import to Firebase Auth. + * @param {UserImportOptions=} options The user import options, required when the users provided + * include password credentials. + * @return {Promise} A promise that resolves when the operation completes + * with the result of the import. This includes the number of successful imports, the number + * of failed uploads and their corresponding errors. + */ + public importUsers( + users: UserImportRecord[], options?: UserImportOptions): Promise { + return this.authRequestHandler.uploadAccount(users, options); + } + + /** + * Creates a new Firebase session cookie with the specified options that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param {string} idToken The Firebase ID token to exchange for a session cookie. + * @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes + * custom session duration. + * + * @return {Promise} A promise that resolves on success with the created session cookie. + */ + public createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { + // Return rejected promise if expiresIn is not available. + if (!validator.isNonNullObject(sessionCookieOptions) || + !validator.isNumber(sessionCookieOptions.expiresIn)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); + } + return this.authRequestHandler.createSessionCookie( + idToken, sessionCookieOptions.expiresIn); + } + + /** + * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the session cookie was revoked. If the corresponding + * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not + * specified the check is not performed. + * + * @param {string} sessionCookie The session cookie to verify. + * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifySessionCookie( + sessionCookie: string, checkRevoked = false): Promise { + return this.sessionCookieVerifier.verifyJWT(sessionCookie) + .then((decodedIdToken: DecodedIdToken) => { + // Whether to check if the token was revoked. + if (!checkRevoked) { + return decodedIdToken; + } + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.SESSION_COOKIE_REVOKED); + }); + } + + /** + * Generates the out of band email action link for password reset flows for the + * email specified using the action code settings provided. + * Returns a promise that resolves with the generated link. + * + * @param {string} email The email of the user whose password is to be reset. + * @param {ActionCodeSettings=} actionCodeSettings The optional action code setings which defines whether + * the link is to be handled by a mobile app and the additional state information to be passed in the + * deep link, etc. + * @return {Promise} A promise that resolves with the password reset link. + */ + public generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('PASSWORD_RESET', email, actionCodeSettings); + } + + /** + * Generates the out of band email action link for email verification flows for the + * email specified using the action code settings provided. + * Returns a promise that resolves with the generated link. + * + * @param {string} email The email of the user to be verified. + * @param {ActionCodeSettings=} actionCodeSettings The optional action code setings which defines whether + * the link is to be handled by a mobile app and the additional state information to be passed in the + * deep link, etc. + * @return {Promise} A promise that resolves with the email verification link. + */ + public generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings); + } + + /** + * Generates the out of band email action link for email link sign-in flows for the + * email specified using the action code settings provided. + * Returns a promise that resolves with the generated link. + * + * @param {string} email The email of the user signing in. + * @param {ActionCodeSettings} actionCodeSettings The required action code setings which defines whether + * the link is to be handled by a mobile app and the additional state information to be passed in the + * deep link, etc. + * @return {Promise} A promise that resolves with the email sign-in link. + */ + public generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('EMAIL_SIGNIN', email, actionCodeSettings); + } + + /** + * Returns the list of existing provider configuation matching the filter provided. + * At most, 100 provider configs are allowed to be imported at a time. + * + * @param {AuthProviderConfigFilter} options The provider config filter to apply. + * @return {Promise} A promise that resolves with the list of provider configs + * meeting the filter requirements. + */ + public listProviderConfigs(options: AuthProviderConfigFilter): Promise { + const processResponse = (response: any, providerConfigs: AuthProviderConfig[]): ListProviderConfigResults => { + // Return list of provider configuration and the next page token if available. + const result: ListProviderConfigResults = { + providerConfigs, + }; + // Delete result.pageToken if undefined. + if (Object.prototype.hasOwnProperty.call(response, 'nextPageToken')) { + result.pageToken = response.nextPageToken; + } + return result; + }; + if (options && options.type === 'oidc') { + return this.authRequestHandler.listOAuthIdpConfigs(options.maxResults, options.pageToken) + .then((response: any) => { + // List of provider configurations to return. + const providerConfigs: OIDCConfig[] = []; + // Convert each provider config response to a OIDCConfig. + response.oauthIdpConfigs.forEach((configResponse: any) => { + providerConfigs.push(new OIDCConfig(configResponse)); + }); + // Return list of provider configuration and the next page token if available. + return processResponse(response, providerConfigs); + }); + } else if (options && options.type === 'saml') { + return this.authRequestHandler.listInboundSamlConfigs(options.maxResults, options.pageToken) + .then((response: any) => { + // List of provider configurations to return. + const providerConfigs: SAMLConfig[] = []; + // Convert each provider config response to a SAMLConfig. + response.inboundSamlConfigs.forEach((configResponse: any) => { + providerConfigs.push(new SAMLConfig(configResponse)); + }); + // Return list of provider configuration and the next page token if available. + return processResponse(response, providerConfigs); + }); + } + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"AuthProviderConfigFilter.type" must be either "saml' or "oidc"`)); + } + + /** + * Looks up an Auth provider configuration by ID. + * Returns a promise that resolves with the provider configuration corresponding to the provider ID specified. + * + * @param {string} providerId The provider ID corresponding to the provider config to return. + * @return {Promise} + */ + public getProviderConfig(providerId: string): Promise { + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.getOAuthIdpConfig(providerId) + .then((response: OIDCConfigServerResponse) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.getInboundSamlConfig(providerId) + .then((response: SAMLConfigServerResponse) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Deletes the provider configuration corresponding to the provider ID passed. + * + * @param {string} providerId The provider ID corresponding to the provider config to delete. + * @return {Promise} A promise that resolves on completion. + */ + public deleteProviderConfig(providerId: string): Promise { + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.deleteOAuthIdpConfig(providerId); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.deleteInboundSamlConfig(providerId); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Returns a promise that resolves with the updated AuthProviderConfig when the provider configuration corresponding + * to the provider ID specified is updated with the specified configuration. + * + * @param {string} providerId The provider ID corresponding to the provider config to update. + * @param {UpdateAuthProviderRequest} updatedConfig The updated configuration. + * @return {Promise} A promise that resolves with the updated provider configuration. + */ + public updateProviderConfig( + providerId: string, updatedConfig: UpdateAuthProviderRequest): Promise { + if (!validator.isNonNullObject(updatedConfig)) { + return Promise.reject(new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'Request is missing "UpdateAuthProviderRequest" configuration.', + )); + } + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.updateOAuthIdpConfig(providerId, updatedConfig) + .then((response) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.updateInboundSamlConfig(providerId, updatedConfig) + .then((response) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Returns a promise that resolves with the newly created AuthProviderConfig when the new provider configuration is + * created. + * @param {AuthProviderConfig} config The provider configuration to create. + * @return {Promise} A promise that resolves with the created provider configuration. + */ + public createProviderConfig(config: AuthProviderConfig): Promise { + if (!validator.isNonNullObject(config)) { + return Promise.reject(new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'Request is missing "AuthProviderConfig" configuration.', + )); + } + if (OIDCConfig.isProviderId(config.providerId)) { + return this.authRequestHandler.createOAuthIdpConfig(config) + .then((response) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(config.providerId)) { + return this.authRequestHandler.createInboundSamlConfig(config) + .then((response) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Verifies the decoded Firebase issued JWT is not revoked. Returns a promise that resolves + * with the decoded claims on success. Rejects the promise with revocation error if revoked. + * + * @param {DecodedIdToken} decodedIdToken The JWT's decoded claims. + * @param {ErrorInfo} revocationErrorInfo The revocation error info to throw on revocation + * detection. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + private verifyDecodedJWTNotRevoked( + decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { + // Get tokens valid after time for the corresponding user. + return this.getUser(decodedIdToken.sub) + .then((user: UserRecord) => { + // If no tokens valid after time available, token is not revoked. + if (user.tokensValidAfterTime) { + // Get the ID token authentication time and convert to milliseconds UTC. + const authTimeUtc = decodedIdToken.auth_time * 1000; + // Get user tokens valid after time in milliseconds UTC. + const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); + // Check if authentication time is older than valid since time. + if (authTimeUtc < validSinceUtc) { + throw new FirebaseAuthError(revocationErrorInfo); + } + } + // All checks above passed. Return the decoded token. + return decodedIdToken; + }); + } +} + + +/** + * The tenant aware Auth class. + */ +export class TenantAwareAuthImpl extends BaseAuthImpl implements TenantAwareAuth { + public readonly tenantId: string; + + /** + * The TenantAwareAuth class constructor. + * + * @param {object} app The app that created this tenant. + * @param tenantId The corresponding tenant ID. + * @constructor + */ + constructor(app: FirebaseApp, tenantId: string) { + const cryptoSigner = cryptoSignerFromApp(app); + const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId); + super(app, new TenantAwareAuthRequestHandler(app, tenantId), tokenGenerator); + utils.addReadonlyGetter(this, 'tenantId', tenantId); + } + + /** + * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the ID token was revoked. If the corresponding + * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified + * the check is not applied. + * + * @param {string} idToken The JWT to verify. + * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifyIdToken(idToken: string, checkRevoked = false): Promise { + return super.verifyIdToken(idToken, checkRevoked) + .then((decodedClaims) => { + // Validate tenant ID. + if (decodedClaims.firebase.tenant !== this.tenantId) { + throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + } + return decodedClaims; + }); + } + + /** + * Creates a new Firebase session cookie with the specified options that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param {string} idToken The Firebase ID token to exchange for a session cookie. + * @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes + * custom session duration. + * + * @return {Promise} A promise that resolves on success with the created session cookie. + */ + public createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { + // Validate arguments before processing. + if (!validator.isNonEmptyString(idToken)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN)); + } + if (!validator.isNonNullObject(sessionCookieOptions) || + !validator.isNumber(sessionCookieOptions.expiresIn)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); + } + // This will verify the ID token and then match the tenant ID before creating the session cookie. + return this.verifyIdToken(idToken) + .then(() => { + return super.createSessionCookie(idToken, sessionCookieOptions); + }); + } + + /** + * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the session cookie was revoked. If the corresponding + * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not + * specified the check is not performed. + * + * @param {string} sessionCookie The session cookie to verify. + * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifySessionCookie( + sessionCookie: string, checkRevoked = false): Promise { + return super.verifySessionCookie(sessionCookie, checkRevoked) + .then((decodedClaims) => { + if (decodedClaims.firebase.tenant !== this.tenantId) { + throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); + } + return decodedClaims; + }); + } +} + + +/** + * Internals of an Auth instance. + */ +class AuthInternals implements FirebaseServiceInternalsInterface { + /** + * Deletes the service and its associated resources. + * + * @return {Promise<()>} An empty Promise that will be fulfilled when the service is deleted. + */ + public delete(): Promise { + // There are no resources to clean up + return Promise.resolve(undefined); + } +} + + +/** + * Auth service bound to the provided app. + * An Auth instance can have multiple tenants. + */ +export class AuthImpl extends BaseAuthImpl implements FirebaseServiceInterface { + + public INTERNAL: AuthInternals = new AuthInternals(); + private readonly tenantManager_: TenantManager; + private readonly app_: FirebaseApp; + + /** + * @param {object} app The app for this Auth service. + * @constructor + */ + constructor(app: FirebaseApp) { + super(app, new AuthRequestHandler(app)); + this.app_ = app; + this.tenantManager_ = new TenantManager(app); + } + + /** + * Returns the app associated with this Auth instance. + * + * @return {FirebaseApp} The app associated with this Auth instance. + */ + get app(): FirebaseApp { + return this.app_; + } + + /** @return The current Auth instance's tenant manager. */ + public tenantManager(): TenantManager { + return this.tenantManager_; + } +} + + diff --git a/src/auth/auth.ts b/src/auth/auth.ts index a2c5a0d62c..778268720a 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -17,51 +17,27 @@ import {UserRecord, CreateRequest, UpdateRequest} from './user-record'; import { UserIdentifier } from './identifier'; import { - isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, -} from './identifier-internal'; + AuthImpl, +} from './auth-internal'; import {FirebaseApp} from '../firebase-app'; import * as admin from '../'; -// import {FirebaseNamespace} from '../firebase-namespace'; -import {FirebaseTokenGenerator, cryptoSignerFromApp} from './token-generator-internal'; import { AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler, } from './auth-api-request'; -import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo, FirebaseArrayIndexError} from '../utils/error'; -import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; +import {FirebaseArrayIndexError} from '../utils/error'; +import {FirebaseServiceInterface} from '../firebase-service'; import { UserImportOptions, UserImportRecord, UserImportResult, } from './user-import-builder'; -import * as utils from '../utils/index'; -import * as validator from '../utils/validator'; -import { createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; -import { FirebaseTokenVerifier } from './token-verifier-internal'; import {ActionCodeSettings} from './action-code-settings-builder'; import { AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest, } from './auth-config'; -import { - SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, -} from './auth-config-internal'; import {TenantManager} from './tenant-manager'; const Auth_: {[name: string]: Auth} = {}; -/** - * Internals of an Auth instance. - */ -class AuthInternals implements FirebaseServiceInternalsInterface { - /** - * Deletes the service and its associated resources. - * - * @return {Promise<()>} An empty Promise that will be fulfilled when the service is deleted. - */ - public delete(): Promise { - // There are no resources to clean up - return Promise.resolve(undefined); - } -} - /** Represents the result of the {@link admin.auth.getUsers()} API. */ export interface GetUsersResult { @@ -123,37 +99,17 @@ export interface SessionCookieOptions { expiresIn: number; } +/** + * The tenant aware Auth class. + */ +export interface TenantAwareAuth extends BaseAuth { + readonly tenantId: string; +} /** * Base Auth class. Mainly used for user management APIs. */ -export class BaseAuth { - - protected readonly tokenGenerator: FirebaseTokenGenerator; - protected readonly idTokenVerifier: FirebaseTokenVerifier; - protected readonly sessionCookieVerifier: FirebaseTokenVerifier; - - /** - * The BaseAuth class constructor. - * - * @param app The FirebaseApp to associate with this Auth instance. - * @param authRequestHandler The RPC request handler for this instance. - * @param tokenGenerator Optional token generator. If not specified, a - * (non-tenant-aware) instance will be created. Use this paramter to - * specify a tenant-aware tokenGenerator. - * @constructor - */ - constructor(app: FirebaseApp, protected readonly authRequestHandler: T, tokenGenerator?: FirebaseTokenGenerator) { - if (tokenGenerator) { - this.tokenGenerator = tokenGenerator; - } else { - const cryptoSigner = cryptoSignerFromApp(app); - this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); - } - - this.sessionCookieVerifier = createSessionCookieVerifier(app); - this.idTokenVerifier = createIdTokenVerifier(app); - } +export interface BaseAuth { /** * Creates a new custom token that can be sent back to a client to use with @@ -164,9 +120,7 @@ export class BaseAuth { * * @return {Promise} A JWT for the provided payload. */ - public createCustomToken(uid: string, developerClaims?: object): Promise { - return this.tokenGenerator.createCustomToken(uid, developerClaims); - } + createCustomToken(uid: string, developerClaims?: object): Promise; /** * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects @@ -180,18 +134,21 @@ export class BaseAuth { * @return {Promise} A Promise that will be fulfilled after a successful * verification. */ - public verifyIdToken(idToken: string, checkRevoked = false): Promise { - return this.idTokenVerifier.verifyJWT(idToken) - .then((decodedIdToken: DecodedIdToken) => { - // Whether to check if the token was revoked. - if (!checkRevoked) { - return decodedIdToken; - } - return this.verifyDecodedJWTNotRevoked( - decodedIdToken, - AuthClientErrorCode.ID_TOKEN_REVOKED); - }); - } + verifyIdToken(idToken: string, checkRevoked: boolean): Promise; + + /** + * // TODO: DEFAULT IMPORT (documentation??????) + * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the ID token was revoked. If the corresponding + * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified + * the check is not applied. + * + * @param {string} idToken The JWT to verify. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + verifyIdToken(idToken: string): Promise; /** * Looks up the user identified by the provided user id and returns a promise that is @@ -200,13 +157,7 @@ export class BaseAuth { * @param {string} uid The uid of the user to look up. * @return {Promise} A promise that resolves with the corresponding user record. */ - public getUser(uid: string): Promise { - return this.authRequestHandler.getAccountInfoByUid(uid) - .then((response: any) => { - // Returns the user record populated with server response. - return new UserRecord(response.users[0]); - }); - } + getUser(uid: string): Promise; /** * Looks up the user identified by the provided email and returns a promise that is @@ -215,13 +166,7 @@ export class BaseAuth { * @param {string} email The email of the user to look up. * @return {Promise} A promise that resolves with the corresponding user record. */ - public getUserByEmail(email: string): Promise { - return this.authRequestHandler.getAccountInfoByEmail(email) - .then((response: any) => { - // Returns the user record populated with server response. - return new UserRecord(response.users[0]); - }); - } + getUserByEmail(email: string): Promise; /** * Looks up the user identified by the provided phone number and returns a promise that is @@ -230,13 +175,7 @@ export class BaseAuth { * @param {string} phoneNumber The phone number of the user to look up. * @return {Promise} A promise that resolves with the corresponding user record. */ - public getUserByPhoneNumber(phoneNumber: string): Promise { - return this.authRequestHandler.getAccountInfoByPhoneNumber(phoneNumber) - .then((response: any) => { - // Returns the user record populated with server response. - return new UserRecord(response.users[0]); - }); - } + getUserByPhoneNumber(phoneNumber: string): Promise; /** * Gets the user data corresponding to the specified identifiers. @@ -253,45 +192,7 @@ export class BaseAuth { * @throws FirebaseAuthError If any of the identifiers are invalid or if more than 100 * identifiers are specified. */ - public getUsers(identifiers: UserIdentifier[]): Promise { - if (!validator.isArray(identifiers)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, '`identifiers` parameter must be an array'); - } - return this.authRequestHandler - .getAccountInfoByIdentifiers(identifiers) - .then((response: any) => { - /** - * Checks if the specified identifier is within the list of - * UserRecords. - */ - const isUserFound = ((id: UserIdentifier, userRecords: UserRecord[]): boolean => { - return !!userRecords.find((userRecord) => { - if (isUidIdentifier(id)) { - return id.uid === userRecord.uid; - } else if (isEmailIdentifier(id)) { - return id.email === userRecord.email; - } else if (isPhoneIdentifier(id)) { - return id.phoneNumber === userRecord.phoneNumber; - } else if (isProviderIdentifier(id)) { - const matchingUserInfo = userRecord.providerData.find((userInfo) => { - return id.providerId === userInfo.providerId; - }); - return !!matchingUserInfo && id.providerUid === matchingUserInfo.uid; - } else { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unhandled identifier type'); - } - }); - }); - - const users = response.users ? response.users.map((user: any) => new UserRecord(user)) : []; - const notFound = identifiers.filter((id) => !isUserFound(id, users)); - - return { users, notFound }; - }); - } + getUsers(identifiers: UserIdentifier[]): Promise; /** * Exports a batch of user accounts. Batch size is determined by the maxResults argument. @@ -305,27 +206,7 @@ export class BaseAuth { * the current batch of downloaded users and the next page token. For the last page, an * empty list of users and no page token are returned. */ - public listUsers(maxResults?: number, pageToken?: string): Promise { - return this.authRequestHandler.downloadAccount(maxResults, pageToken) - .then((response: any) => { - // List of users to return. - const users: UserRecord[] = []; - // Convert each user response to a UserRecord. - response.users.forEach((userResponse: any) => { - users.push(new UserRecord(userResponse)); - }); - // Return list of user records and the next page token if available. - const result = { - users, - pageToken: response.nextPageToken, - }; - // Delete result.pageToken if undefined. - if (typeof result.pageToken === 'undefined') { - delete result.pageToken; - } - return result; - }); - } + listUsers(maxResults?: number, pageToken?: string): Promise; /** * Creates a new user with the properties provided. @@ -333,22 +214,7 @@ export class BaseAuth { * @param {CreateRequest} properties The properties to set on the new user record to be created. * @return {Promise} A promise that resolves with the newly created user record. */ - public createUser(properties: CreateRequest): Promise { - return this.authRequestHandler.createNewAccount(properties) - .then((uid) => { - // Return the corresponding user record. - return this.getUser(uid); - }) - .catch((error) => { - if (error.code === 'auth/user-not-found') { - // Something must have happened after creating the user and then retrieving it. - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Unable to create the user record provided.'); - } - throw error; - }); - } + createUser(properties: CreateRequest): Promise; /** * Deletes the user identified by the provided user id and returns a promise that is @@ -357,57 +223,9 @@ export class BaseAuth { * @param {string} uid The uid of the user to delete. * @return {Promise} A promise that resolves when the user is successfully deleted. */ - public deleteUser(uid: string): Promise { - return this.authRequestHandler.deleteAccount(uid) - .then(() => { - // Return nothing on success. - }); - } - - public deleteUsers(uids: string[]): Promise { - if (!validator.isArray(uids)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, '`uids` parameter must be an array'); - } - return this.authRequestHandler.deleteAccounts(uids, /*force=*/true) - .then((batchDeleteAccountsResponse) => { - const result: DeleteUsersResult = { - failureCount: 0, - successCount: uids.length, - errors: [], - }; - - if (!validator.isNonEmptyArray(batchDeleteAccountsResponse.errors)) { - return result; - } - - result.failureCount = batchDeleteAccountsResponse.errors.length; - result.successCount = uids.length - batchDeleteAccountsResponse.errors.length; - result.errors = batchDeleteAccountsResponse.errors.map((batchDeleteErrorInfo) => { - if (batchDeleteErrorInfo.index === undefined) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'Corrupt BatchDeleteAccountsResponse detected'); - } - - const errMsgToError = (msg?: string): FirebaseAuthError => { - // We unconditionally set force=true, so the 'NOT_DISABLED' error - // should not be possible. - const code = msg && msg.startsWith('NOT_DISABLED') ? - AuthClientErrorCode.USER_NOT_DISABLED : AuthClientErrorCode.INTERNAL_ERROR; - return new FirebaseAuthError(code, batchDeleteErrorInfo.message); - }; - - return { - index: batchDeleteErrorInfo.index, - error: errMsgToError(batchDeleteErrorInfo.message), - }; - }); - - return result; - }); - } + deleteUser(uid: string): Promise; + deleteUsers(uids: string[]): Promise; /** * Updates an existing user with the properties provided. * @@ -415,13 +233,7 @@ export class BaseAuth { * @param {UpdateRequest} properties The properties to update on the existing user. * @return {Promise} A promise that resolves with the modified user record. */ - public updateUser(uid: string, properties: UpdateRequest): Promise { - return this.authRequestHandler.updateExistingAccount(uid, properties) - .then((existingUid) => { - // Return the corresponding user record. - return this.getUser(existingUid); - }); - } + updateUser(uid: string, properties: UpdateRequest): Promise; /** * Sets additional developer claims on an existing user identified by the provided UID. @@ -431,12 +243,7 @@ export class BaseAuth { * @return {Promise} A promise that resolves when the operation completes * successfully. */ - public setCustomUserClaims(uid: string, customUserClaims: object|null): Promise { // TODO ... | null - return this.authRequestHandler.setCustomUserClaims(uid, customUserClaims) - .then(() => { - // Return nothing on success. - }); - } + setCustomUserClaims(uid: string, customUserClaims: object|null): Promise; /** * Revokes all refresh tokens for the specified user identified by the provided UID. @@ -448,12 +255,7 @@ export class BaseAuth { * @return {Promise} A promise that resolves when the operation completes * successfully. */ - public revokeRefreshTokens(uid: string): Promise { - return this.authRequestHandler.revokeRefreshTokens(uid) - .then(() => { - // Return nothing on success. - }); - } + revokeRefreshTokens(uid: string): Promise; /** * Imports the list of users provided to Firebase Auth. This is useful when @@ -468,10 +270,8 @@ export class BaseAuth { * with the result of the import. This includes the number of successful imports, the number * of failed uploads and their corresponding errors. */ - public importUsers( - users: UserImportRecord[], options?: UserImportOptions): Promise { - return this.authRequestHandler.uploadAccount(users, options); - } + importUsers( + users: UserImportRecord[], options?: UserImportOptions): Promise; /** * Creates a new Firebase session cookie with the specified options that can be used for @@ -484,16 +284,8 @@ export class BaseAuth { * * @return {Promise} A promise that resolves on success with the created session cookie. */ - public createSessionCookie( - idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { - // Return rejected promise if expiresIn is not available. - if (!validator.isNonNullObject(sessionCookieOptions) || - !validator.isNumber(sessionCookieOptions.expiresIn)) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); - } - return this.authRequestHandler.createSessionCookie( - idToken, sessionCookieOptions.expiresIn); - } + createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise; /** * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects @@ -507,19 +299,23 @@ export class BaseAuth { * @return {Promise} A Promise that will be fulfilled after a successful * verification. */ - public verifySessionCookie( - sessionCookie: string, checkRevoked = false): Promise { - return this.sessionCookieVerifier.verifyJWT(sessionCookie) - .then((decodedIdToken: DecodedIdToken) => { - // Whether to check if the token was revoked. - if (!checkRevoked) { - return decodedIdToken; - } - return this.verifyDecodedJWTNotRevoked( - decodedIdToken, - AuthClientErrorCode.SESSION_COOKIE_REVOKED); - }); - } + verifySessionCookie( + sessionCookie: string, checkRevoked: boolean): Promise; + + /** + * TODO: CHECK DEFAULT IMPORT!!! + * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the session cookie was revoked. If the corresponding + * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not + * specified the check is not performed. + * + * @param {string} sessionCookie The session cookie to verify. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + verifySessionCookie( + sessionCookie: string): Promise; /** * Generates the out of band email action link for password reset flows for the @@ -532,9 +328,7 @@ export class BaseAuth { * deep link, etc. * @return {Promise} A promise that resolves with the password reset link. */ - public generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { - return this.authRequestHandler.getEmailActionLink('PASSWORD_RESET', email, actionCodeSettings); - } + generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise; /** * Generates the out of band email action link for email verification flows for the @@ -547,9 +341,7 @@ export class BaseAuth { * deep link, etc. * @return {Promise} A promise that resolves with the email verification link. */ - public generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { - return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings); - } + generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise; /** * Generates the out of band email action link for email link sign-in flows for the @@ -562,9 +354,7 @@ export class BaseAuth { * deep link, etc. * @return {Promise} A promise that resolves with the email sign-in link. */ - public generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise { - return this.authRequestHandler.getEmailActionLink('EMAIL_SIGNIN', email, actionCodeSettings); - } + generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise; /** * Returns the list of existing provider configuation matching the filter provided. @@ -574,48 +364,7 @@ export class BaseAuth { * @return {Promise} A promise that resolves with the list of provider configs * meeting the filter requirements. */ - public listProviderConfigs(options: AuthProviderConfigFilter): Promise { - const processResponse = (response: any, providerConfigs: AuthProviderConfig[]): ListProviderConfigResults => { - // Return list of provider configuration and the next page token if available. - const result: ListProviderConfigResults = { - providerConfigs, - }; - // Delete result.pageToken if undefined. - if (Object.prototype.hasOwnProperty.call(response, 'nextPageToken')) { - result.pageToken = response.nextPageToken; - } - return result; - }; - if (options && options.type === 'oidc') { - return this.authRequestHandler.listOAuthIdpConfigs(options.maxResults, options.pageToken) - .then((response: any) => { - // List of provider configurations to return. - const providerConfigs: OIDCConfig[] = []; - // Convert each provider config response to a OIDCConfig. - response.oauthIdpConfigs.forEach((configResponse: any) => { - providerConfigs.push(new OIDCConfig(configResponse)); - }); - // Return list of provider configuration and the next page token if available. - return processResponse(response, providerConfigs); - }); - } else if (options && options.type === 'saml') { - return this.authRequestHandler.listInboundSamlConfigs(options.maxResults, options.pageToken) - .then((response: any) => { - // List of provider configurations to return. - const providerConfigs: SAMLConfig[] = []; - // Convert each provider config response to a SAMLConfig. - response.inboundSamlConfigs.forEach((configResponse: any) => { - providerConfigs.push(new SAMLConfig(configResponse)); - }); - // Return list of provider configuration and the next page token if available. - return processResponse(response, providerConfigs); - }); - } - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"AuthProviderConfigFilter.type" must be either "saml' or "oidc"`)); - } + listProviderConfigs(options: AuthProviderConfigFilter): Promise; /** * Looks up an Auth provider configuration by ID. @@ -624,20 +373,7 @@ export class BaseAuth { * @param {string} providerId The provider ID corresponding to the provider config to return. * @return {Promise} */ - public getProviderConfig(providerId: string): Promise { - if (OIDCConfig.isProviderId(providerId)) { - return this.authRequestHandler.getOAuthIdpConfig(providerId) - .then((response: OIDCConfigServerResponse) => { - return new OIDCConfig(response); - }); - } else if (SAMLConfig.isProviderId(providerId)) { - return this.authRequestHandler.getInboundSamlConfig(providerId) - .then((response: SAMLConfigServerResponse) => { - return new SAMLConfig(response); - }); - } - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); - } + getProviderConfig(providerId: string): Promise; /** * Deletes the provider configuration corresponding to the provider ID passed. @@ -645,14 +381,7 @@ export class BaseAuth { * @param {string} providerId The provider ID corresponding to the provider config to delete. * @return {Promise} A promise that resolves on completion. */ - public deleteProviderConfig(providerId: string): Promise { - if (OIDCConfig.isProviderId(providerId)) { - return this.authRequestHandler.deleteOAuthIdpConfig(providerId); - } else if (SAMLConfig.isProviderId(providerId)) { - return this.authRequestHandler.deleteInboundSamlConfig(providerId); - } - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); - } + deleteProviderConfig(providerId: string): Promise; /** * Returns a promise that resolves with the updated AuthProviderConfig when the provider configuration corresponding @@ -662,27 +391,8 @@ export class BaseAuth { * @param {UpdateAuthProviderRequest} updatedConfig The updated configuration. * @return {Promise} A promise that resolves with the updated provider configuration. */ - public updateProviderConfig( - providerId: string, updatedConfig: UpdateAuthProviderRequest): Promise { - if (!validator.isNonNullObject(updatedConfig)) { - return Promise.reject(new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - 'Request is missing "UpdateAuthProviderRequest" configuration.', - )); - } - if (OIDCConfig.isProviderId(providerId)) { - return this.authRequestHandler.updateOAuthIdpConfig(providerId, updatedConfig) - .then((response) => { - return new OIDCConfig(response); - }); - } else if (SAMLConfig.isProviderId(providerId)) { - return this.authRequestHandler.updateInboundSamlConfig(providerId, updatedConfig) - .then((response) => { - return new SAMLConfig(response); - }); - } - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); - } + updateProviderConfig( + providerId: string, updatedConfig: UpdateAuthProviderRequest): Promise; /** * Returns a promise that resolves with the newly created AuthProviderConfig when the new provider configuration is @@ -690,189 +400,17 @@ export class BaseAuth { * @param {AuthProviderConfig} config The provider configuration to create. * @return {Promise} A promise that resolves with the created provider configuration. */ - public createProviderConfig(config: AuthProviderConfig): Promise { - if (!validator.isNonNullObject(config)) { - return Promise.reject(new FirebaseAuthError( - AuthClientErrorCode.INVALID_CONFIG, - 'Request is missing "AuthProviderConfig" configuration.', - )); - } - if (OIDCConfig.isProviderId(config.providerId)) { - return this.authRequestHandler.createOAuthIdpConfig(config) - .then((response) => { - return new OIDCConfig(response); - }); - } else if (SAMLConfig.isProviderId(config.providerId)) { - return this.authRequestHandler.createInboundSamlConfig(config) - .then((response) => { - return new SAMLConfig(response); - }); - } - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); - } - - /** - * Verifies the decoded Firebase issued JWT is not revoked. Returns a promise that resolves - * with the decoded claims on success. Rejects the promise with revocation error if revoked. - * - * @param {DecodedIdToken} decodedIdToken The JWT's decoded claims. - * @param {ErrorInfo} revocationErrorInfo The revocation error info to throw on revocation - * detection. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. - */ - private verifyDecodedJWTNotRevoked( - decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { - // Get tokens valid after time for the corresponding user. - return this.getUser(decodedIdToken.sub) - .then((user: UserRecord) => { - // If no tokens valid after time available, token is not revoked. - if (user.tokensValidAfterTime) { - // Get the ID token authentication time and convert to milliseconds UTC. - const authTimeUtc = decodedIdToken.auth_time * 1000; - // Get user tokens valid after time in milliseconds UTC. - const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); - // Check if authentication time is older than valid since time. - if (authTimeUtc < validSinceUtc) { - throw new FirebaseAuthError(revocationErrorInfo); - } - } - // All checks above passed. Return the decoded token. - return decodedIdToken; - }); - } -} - - -/** - * The tenant aware Auth class. - */ -export class TenantAwareAuth extends BaseAuth { - public readonly tenantId: string; - - /** - * The TenantAwareAuth class constructor. - * - * @param {object} app The app that created this tenant. - * @param tenantId The corresponding tenant ID. - * @constructor - */ - constructor(app: FirebaseApp, tenantId: string) { - const cryptoSigner = cryptoSignerFromApp(app); - const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId); - super(app, new TenantAwareAuthRequestHandler(app, tenantId), tokenGenerator); - utils.addReadonlyGetter(this, 'tenantId', tenantId); - } - - /** - * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects - * the promise if the token could not be verified. If checkRevoked is set to true, - * verifies if the session corresponding to the ID token was revoked. If the corresponding - * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified - * the check is not applied. - * - * @param {string} idToken The JWT to verify. - * @param {boolean=} checkRevoked Whether to check if the ID token is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. - */ - public verifyIdToken(idToken: string, checkRevoked = false): Promise { - return super.verifyIdToken(idToken, checkRevoked) - .then((decodedClaims) => { - // Validate tenant ID. - if (decodedClaims.firebase.tenant !== this.tenantId) { - throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); - } - return decodedClaims; - }); - } - - /** - * Creates a new Firebase session cookie with the specified options that can be used for - * session management (set as a server side session cookie with custom cookie policy). - * The session cookie JWT will have the same payload claims as the provided ID token. - * - * @param {string} idToken The Firebase ID token to exchange for a session cookie. - * @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes - * custom session duration. - * - * @return {Promise} A promise that resolves on success with the created session cookie. - */ - public createSessionCookie( - idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { - // Validate arguments before processing. - if (!validator.isNonEmptyString(idToken)) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN)); - } - if (!validator.isNonNullObject(sessionCookieOptions) || - !validator.isNumber(sessionCookieOptions.expiresIn)) { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); - } - // This will verify the ID token and then match the tenant ID before creating the session cookie. - return this.verifyIdToken(idToken) - .then(() => { - return super.createSessionCookie(idToken, sessionCookieOptions); - }); - } - - /** - * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects - * the promise if the token could not be verified. If checkRevoked is set to true, - * verifies if the session corresponding to the session cookie was revoked. If the corresponding - * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not - * specified the check is not performed. - * - * @param {string} sessionCookie The session cookie to verify. - * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. - */ - public verifySessionCookie( - sessionCookie: string, checkRevoked = false): Promise { - return super.verifySessionCookie(sessionCookie, checkRevoked) - .then((decodedClaims) => { - if (decodedClaims.firebase.tenant !== this.tenantId) { - throw new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); - } - return decodedClaims; - }); - } + createProviderConfig(config: AuthProviderConfig): Promise; } - /** * Auth service bound to the provided app. * An Auth instance can have multiple tenants. */ -export class Auth extends BaseAuth implements FirebaseServiceInterface { - - public INTERNAL: AuthInternals = new AuthInternals(); - private readonly tenantManager_: TenantManager; - private readonly app_: FirebaseApp; - - /** - * @param {object} app The app for this Auth service. - * @constructor - */ - constructor(app: FirebaseApp) { - super(app, new AuthRequestHandler(app)); - this.app_ = app; - this.tenantManager_ = new TenantManager(app); - } - - /** - * Returns the app associated with this Auth instance. - * - * @return {FirebaseApp} The app associated with this Auth instance. - */ - get app(): FirebaseApp { - return this.app_; - } - +export interface Auth extends BaseAuth, FirebaseServiceInterface { + app: FirebaseApp; /** @return The current Auth instance's tenant manager. */ - public tenantManager(): TenantManager { - return this.tenantManager_; - } + tenantManager(): TenantManager; } export function auth(app?: FirebaseApp): Auth { @@ -880,7 +418,7 @@ export function auth(app?: FirebaseApp): Auth { app = admin.app(); } if (!(app.name in Auth_)) { - Auth_[app.name] = new Auth(app); + Auth_[app.name] = new AuthImpl(app); } return Auth_[app.name]; } diff --git a/src/auth/tenant-internal.ts b/src/auth/tenant-internal.ts new file mode 100644 index 0000000000..b93d2fc3b2 --- /dev/null +++ b/src/auth/tenant-internal.ts @@ -0,0 +1,171 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import * as validator from '../utils/validator'; +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import { + EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, +} from './auth-config-internal'; +import {Tenant} from './tenant'; + +/** The TenantOptions interface used for create/read/update tenant operations. */ +export interface TenantOptions { + displayName?: string; + emailSignInConfig?: EmailSignInProviderConfig; +} + +/** The corresponding server side representation of a TenantOptions object. */ +export interface TenantOptionsServerRequest extends EmailSignInConfigServerRequest { + displayName?: string; +} + +/** The tenant server response interface. */ +export interface TenantServerResponse { + name: string; + displayName?: string; + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; +} + +/** The interface representing the listTenant API response. */ +export interface ListTenantsResult { + tenants: Tenant[]; + pageToken?: string; +} + + +/** + * Tenant class that defines a Firebase Auth tenant. + */ +export class TenantImpl implements Tenant { + public readonly tenantId: string; + public readonly displayName?: string; + public readonly emailSignInConfig?: EmailSignInConfig; + + /** + * Builds the corresponding server request for a TenantOptions object. + * + * @param {TenantOptions} tenantOptions The properties to convert to a server request. + * @param {boolean} createRequest Whether this is a create request. + * @return {object} The equivalent server request. + */ + public static buildServerRequest( + tenantOptions: TenantOptions, createRequest: boolean): TenantOptionsServerRequest { + TenantImpl.validate(tenantOptions, createRequest); + let request: TenantOptionsServerRequest = {}; + if (typeof tenantOptions.emailSignInConfig !== 'undefined') { + request = EmailSignInConfig.buildServerRequest(tenantOptions.emailSignInConfig); + } + if (typeof tenantOptions.displayName !== 'undefined') { + request.displayName = tenantOptions.displayName; + } + return request; + } + + /** + * Returns the tenant ID corresponding to the resource name if available. + * + * @param {string} resourceName The server side resource name + * @return {?string} The tenant ID corresponding to the resource, null otherwise. + */ + public static getTenantIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/tenants/tenant1 + const matchTenantRes = resourceName.match(/\/tenants\/(.*)$/); + if (!matchTenantRes || matchTenantRes.length < 2) { + return null; + } + return matchTenantRes[1]; + } + + /** + * Validates a tenant options object. Throws an error on failure. + * + * @param {any} request The tenant options object to validate. + * @param {boolean} createRequest Whether this is a create request. + */ + private static validate(request: any, createRequest: boolean): void { + const validKeys = { + displayName: true, + emailSignInConfig: true, + }; + const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; + if (!validator.isNonNullObject(request)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}" must be a valid non-null object.`, + ); + } + // Check for unsupported top level attributes. + for (const key in request) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid ${label} parameter.`, + ); + } + } + // Validate displayName type if provided. + if (typeof request.displayName !== 'undefined' && + !validator.isNonEmptyString(request.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${label}.displayName" must be a valid non-empty string.`, + ); + } + // Validate emailSignInConfig type if provided. + if (typeof request.emailSignInConfig !== 'undefined') { + // This will throw an error if invalid. + EmailSignInConfig.buildServerRequest(request.emailSignInConfig); + } + } + + /** + * The Tenant object constructor. + * + * @param {any} response The server side response used to initialize the Tenant object. + * @constructor + */ + constructor(response: any) { + const tenantId = TenantImpl.getTenantIdFromResourceName(response.name); + if (!tenantId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid tenant response', + ); + } + this.tenantId = tenantId; + this.displayName = response.displayName; + try { + this.emailSignInConfig = new EmailSignInConfig(response); + } catch (e) { + // If allowPasswordSignup is undefined, it is disabled by default. + this.emailSignInConfig = new EmailSignInConfig({ + allowPasswordSignup: false, + }); + } + } + + /** @return {object} The plain object representation of the tenant. */ + public toJSON(): object { + return { + tenantId: this.tenantId, + displayName: this.displayName, + emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), + }; + } +} + diff --git a/src/auth/tenant-manager.ts b/src/auth/tenant-manager.ts index 73950a76bd..c3307a7114 100644 --- a/src/auth/tenant-manager.ts +++ b/src/auth/tenant-manager.ts @@ -17,9 +17,11 @@ import {AuthRequestHandler} from './auth-api-request'; import {FirebaseApp} from '../firebase-app'; import {TenantAwareAuth} from './auth'; +import {TenantAwareAuthImpl} from './auth-internal'; import { Tenant, TenantServerResponse, ListTenantsResult, TenantOptions, } from './tenant'; +import {TenantImpl} from './tenant-internal'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import * as validator from '../utils/validator'; @@ -54,7 +56,7 @@ export class TenantManager { throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); } if (typeof this.tenantsMap[tenantId] === 'undefined') { - this.tenantsMap[tenantId] = new TenantAwareAuth(this.app, tenantId); + this.tenantsMap[tenantId] = new TenantAwareAuthImpl(this.app, tenantId); } return this.tenantsMap[tenantId]; } @@ -69,7 +71,7 @@ export class TenantManager { public getTenant(tenantId: string): Promise { return this.authRequestHandler.getTenant(tenantId) .then((response: TenantServerResponse) => { - return new Tenant(response); + return new TenantImpl(response); }); } @@ -94,7 +96,7 @@ export class TenantManager { const tenants: Tenant[] = []; // Convert each user response to a Tenant. response.tenants.forEach((tenantResponse: TenantServerResponse) => { - tenants.push(new Tenant(tenantResponse)); + tenants.push(new TenantImpl(tenantResponse)); }); // Return list of tenants and the next page token if available. const result = { @@ -129,7 +131,7 @@ export class TenantManager { public createTenant(tenantOptions: TenantOptions): Promise { return this.authRequestHandler.createTenant(tenantOptions) .then((response: TenantServerResponse) => { - return new Tenant(response); + return new TenantImpl(response); }); } @@ -143,7 +145,7 @@ export class TenantManager { public updateTenant(tenantId: string, tenantOptions: TenantOptions): Promise { return this.authRequestHandler.updateTenant(tenantId, tenantOptions) .then((response: TenantServerResponse) => { - return new Tenant(response); + return new TenantImpl(response); }); } } diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 4462c3cd0f..8dc58a3d45 100755 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -14,10 +14,8 @@ * limitations under the License. */ -import * as validator from '../utils/validator'; -import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import { - EmailSignInConfig, EmailSignInConfigServerRequest, EmailSignInProviderConfig, + EmailSignInConfigServerRequest, EmailSignInProviderConfig, EmailSignInConfig } from './auth-config-internal'; /** The TenantOptions interface used for create/read/update tenant operations. */ @@ -49,121 +47,12 @@ export interface ListTenantsResult { /** * Tenant class that defines a Firebase Auth tenant. */ -export class Tenant { - public readonly tenantId: string; - public readonly displayName?: string; - public readonly emailSignInConfig?: EmailSignInConfig; +export interface Tenant { + readonly tenantId: string; + readonly displayName?: string; + readonly emailSignInConfig?: EmailSignInConfig; - /** - * Builds the corresponding server request for a TenantOptions object. - * - * @param {TenantOptions} tenantOptions The properties to convert to a server request. - * @param {boolean} createRequest Whether this is a create request. - * @return {object} The equivalent server request. - */ - public static buildServerRequest( - tenantOptions: TenantOptions, createRequest: boolean): TenantOptionsServerRequest { - Tenant.validate(tenantOptions, createRequest); - let request: TenantOptionsServerRequest = {}; - if (typeof tenantOptions.emailSignInConfig !== 'undefined') { - request = EmailSignInConfig.buildServerRequest(tenantOptions.emailSignInConfig); - } - if (typeof tenantOptions.displayName !== 'undefined') { - request.displayName = tenantOptions.displayName; - } - return request; - } - - /** - * Returns the tenant ID corresponding to the resource name if available. - * - * @param {string} resourceName The server side resource name - * @return {?string} The tenant ID corresponding to the resource, null otherwise. - */ - public static getTenantIdFromResourceName(resourceName: string): string | null { - // name is of form projects/project1/tenants/tenant1 - const matchTenantRes = resourceName.match(/\/tenants\/(.*)$/); - if (!matchTenantRes || matchTenantRes.length < 2) { - return null; - } - return matchTenantRes[1]; - } - - /** - * Validates a tenant options object. Throws an error on failure. - * - * @param {any} request The tenant options object to validate. - * @param {boolean} createRequest Whether this is a create request. - */ - private static validate(request: any, createRequest: boolean): void { - const validKeys = { - displayName: true, - emailSignInConfig: true, - }; - const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; - if (!validator.isNonNullObject(request)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${label}" must be a valid non-null object.`, - ); - } - // Check for unsupported top level attributes. - for (const key in request) { - if (!(key in validKeys)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${key}" is not a valid ${label} parameter.`, - ); - } - } - // Validate displayName type if provided. - if (typeof request.displayName !== 'undefined' && - !validator.isNonEmptyString(request.displayName)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - `"${label}.displayName" must be a valid non-empty string.`, - ); - } - // Validate emailSignInConfig type if provided. - if (typeof request.emailSignInConfig !== 'undefined') { - // This will throw an error if invalid. - EmailSignInConfig.buildServerRequest(request.emailSignInConfig); - } - } - - /** - * The Tenant object constructor. - * - * @param {any} response The server side response used to initialize the Tenant object. - * @constructor - */ - constructor(response: any) { - const tenantId = Tenant.getTenantIdFromResourceName(response.name); - if (!tenantId) { - throw new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - 'INTERNAL ASSERT FAILED: Invalid tenant response', - ); - } - this.tenantId = tenantId; - this.displayName = response.displayName; - try { - this.emailSignInConfig = new EmailSignInConfig(response); - } catch (e) { - // If allowPasswordSignup is undefined, it is disabled by default. - this.emailSignInConfig = new EmailSignInConfig({ - allowPasswordSignup: false, - }); - } - } /** @return {object} The plain object representation of the tenant. */ - public toJSON(): object { - return { - tenantId: this.tenantId, - displayName: this.displayName, - emailSignInConfig: this.emailSignInConfig && this.emailSignInConfig.toJSON(), - }; - } + toJSON(): object; } - diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 608d97a094..a68013035f 100755 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -26,8 +26,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as utils from '../utils'; import * as mocks from '../../resources/mocks'; -import {Auth, TenantAwareAuth, BaseAuth, DecodedIdToken} from '../../../src/auth/auth'; -import {UserRecord, UpdateRequest} from '../../../src/auth/user-record'; +import {Auth, BaseAuth, DecodedIdToken, UserRecord, UpdateRequest} from '../../../src/auth'; +import {AuthImpl, TenantAwareAuthImpl} from '../../../src/auth/auth-internal'; import {FirebaseApp} from '../../../src/firebase-app'; import { AuthRequestHandler, TenantAwareAuthRequestHandler, AbstractAuthRequestHandler, @@ -52,7 +52,6 @@ chai.use(chaiAsPromised); const expect = chai.expect; - interface AuthTest { name: string; supportsTenantManagement: boolean; @@ -61,14 +60,12 @@ interface AuthTest { init(app: FirebaseApp): BaseAuth; } - interface EmailActionTest { api: string; requestType: string; requiresSettings: boolean; } - /** * @param {string=} tenantId The optional tenant Id. * @return {object} A sample valid server response as returned from getAccountInfo @@ -239,20 +236,20 @@ const TENANT_ID = 'tenantId'; const AUTH_CONFIGS: AuthTest[] = [ { name: 'Auth', - Auth, + Auth: AuthImpl, supportsTenantManagement: true, RequestHandler: AuthRequestHandler, init: (app: FirebaseApp) => { - return new Auth(app); + return new AuthImpl(app); }, }, { name: 'TenantAwareAuth', - Auth: TenantAwareAuth, + Auth: TenantAwareAuthImpl, supportsTenantManagement: false, RequestHandler: TenantAwareAuthRequestHandler, init: (app: FirebaseApp) => { - return new TenantAwareAuth(app, TENANT_ID); + return new TenantAwareAuthImpl(app, TENANT_ID); }, }, ]; @@ -287,14 +284,14 @@ AUTH_CONFIGS.forEach((testConfig) => { return mockApp.delete(); }); - if (testConfig.Auth === Auth) { + if (testConfig.Auth === AuthImpl) { // Run tests for Auth. describe('Constructor', () => { const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; invalidApps.forEach((invalidApp) => { it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { expect(() => { - const authAny: any = Auth; + const authAny: any = AuthImpl; return new authAny(invalidApp); }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); }); @@ -302,13 +299,13 @@ AUTH_CONFIGS.forEach((testConfig) => { it('should throw given no app', () => { expect(() => { - const authAny: any = Auth; + const authAny: any = AuthImpl; return new authAny(); }).to.throw('First argument passed to admin.auth() must be a valid Firebase app instance.'); }); it('should reject given no project ID', () => { - const authWithoutProjectId = new Auth(mocks.mockCredentialApp()); + const authWithoutProjectId = new AuthImpl(mocks.mockCredentialApp()); authWithoutProjectId.getUser('uid') .should.eventually.be.rejectedWith( 'Failed to determine project ID for Auth. Initialize the SDK with service ' @@ -318,7 +315,7 @@ AUTH_CONFIGS.forEach((testConfig) => { it('should not throw given a valid app', () => { expect(() => { - return new Auth(mockApp); + return new AuthImpl(mockApp); }).not.to.throw(); }); }); @@ -332,7 +329,7 @@ AUTH_CONFIGS.forEach((testConfig) => { it('is read-only', () => { expect(() => { (auth as any).app = mockApp; - }).to.throw('Cannot set property app of # which has only a getter'); + }).to.throw('Cannot set property app of # which has only a getter'); }); }); @@ -358,7 +355,7 @@ AUTH_CONFIGS.forEach((testConfig) => { expect(decodedToken).to.have.property('header').that.has.property('typ', 'JWT'); }); - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { it('should contain tenant_id', async () => { const token = await auth.createCustomToken('uid1'); expect(jwt.decode(token)).to.have.property('tenant_id', TENANT_ID); @@ -624,7 +621,7 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { it('should be rejected with ID token missing tenant ID', () => { const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); // Restore verifyIdToken stub. @@ -868,7 +865,7 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { it('should be rejected with session cookie missing tenant ID', () => { const expectedError = new FirebaseAuthError(AuthClientErrorCode.MISMATCHING_TENANT_ID); // Restore verifyIdToken stub. @@ -2039,7 +2036,7 @@ AUTH_CONFIGS.forEach((testConfig) => { }).to.throw(expectedOptionsError); }); - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { it('should throw and fail quickly when users provided have mismatching tenant IDs', () => { const usersCopy = deepCopy(users); // Simulate one user with mismatching tenant ID. @@ -2182,7 +2179,7 @@ AUTH_CONFIGS.forEach((testConfig) => { expect(createSessionCookieStub) .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); // TenantAwareAuth should verify the ID token first. - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { expect(verifyIdTokenStub) .to.have.been.calledOnce.and.calledWith(idToken); } else { @@ -2214,7 +2211,7 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - if (testConfig.Auth === TenantAwareAuth) { + if (testConfig.Auth === TenantAwareAuthImpl) { it('should be rejected when ID token provided is invalid', () => { // Simulate auth.verifyIdToken() fails when called. const verifyIdTokenStub = sinon.stub(testConfig.Auth.prototype, 'verifyIdToken') @@ -3182,7 +3179,7 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); - if (testConfig.Auth === Auth) { + if (testConfig.Auth === AuthImpl) { describe('INTERNAL.delete()', () => { it('should delete Auth instance', () => { (auth as Auth).INTERNAL.delete().should.eventually.be.fulfilled; diff --git a/test/unit/auth/tenant-manager.spec.ts b/test/unit/auth/tenant-manager.spec.ts index 82f89599d9..9ce1289218 100644 --- a/test/unit/auth/tenant-manager.spec.ts +++ b/test/unit/auth/tenant-manager.spec.ts @@ -25,7 +25,8 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import {FirebaseApp} from '../../../src/firebase-app'; import {AuthRequestHandler} from '../../../src/auth/auth-api-request'; -import {Tenant, TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; +import {TenantOptions, TenantServerResponse, ListTenantsResult} from '../../../src/auth/tenant'; +import {TenantImpl} from '../../../src/auth/tenant-internal'; import {TenantManager} from '../../../src/auth/tenant-manager'; import {AuthClientErrorCode, FirebaseAuthError} from '../../../src/utils/error'; @@ -82,7 +83,7 @@ describe('TenantManager', () => { it('should return a TenantAwareAuth with read-only tenant ID', () => { expect(() => { (tenantManager.authForTenant(TENANT_ID) as any).tenantId = 'OTHER-TENANT-ID'; - }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); + }).to.throw('Cannot assign to read only property \'tenantId\' of object \'#\''); }); it('should cache the returned TenantAwareAuth', () => { @@ -98,7 +99,7 @@ describe('TenantManager', () => { describe('getTenant()', () => { const tenantId = 'tenant-id'; - const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedTenant = new TenantImpl(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError(AuthClientErrorCode.TENANT_NOT_FOUND); // Stubs used to simulate underlying API calls. let stubs: sinon.SinonStub[] = []; @@ -182,8 +183,8 @@ describe('TenantManager', () => { }; const expectedResult: ListTenantsResult = { tenants: [ - new Tenant({name: 'projects/project-id/tenants/tenant-id1'}), - new Tenant({name: 'projects/project-id/tenants/tenant-id2'}), + new TenantImpl({name: 'projects/project-id/tenants/tenant-id1'}), + new TenantImpl({name: 'projects/project-id/tenants/tenant-id2'}), ], pageToken: 'NEXT_PAGE_TOKEN', }; @@ -384,7 +385,7 @@ describe('TenantManager', () => { passwordRequired: true, }, }; - const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedTenant = new TenantImpl(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'Unable to create the tenant provided.'); @@ -476,7 +477,7 @@ describe('TenantManager', () => { passwordRequired: true, }, }; - const expectedTenant = new Tenant(GET_TENANT_RESPONSE); + const expectedTenant = new TenantImpl(GET_TENANT_RESPONSE); const expectedError = new FirebaseAuthError( AuthClientErrorCode.INTERNAL_ERROR, 'Unable to update the tenant provided.'); diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index fa31ef21c4..cbf0e0ba1d 100755 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -22,8 +22,9 @@ import * as chaiAsPromised from 'chai-as-promised'; import {deepCopy} from '../../../src/utils/deep-copy'; import {EmailSignInConfig, EmailSignInProviderConfig} from '../../../src/auth/auth-config-internal'; import { - Tenant, TenantOptions, TenantServerResponse, + TenantOptions, TenantServerResponse, } from '../../../src/auth/tenant'; +import {TenantImpl} from '../../../src/auth/tenant-internal'; chai.should(); @@ -56,14 +57,14 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); const tenantOptionsServerRequest = deepCopy(serverRequest); delete tenantOptionsServerRequest.name; - expect(Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + expect(TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest)) .to.deep.equal(tenantOptionsServerRequest); }); it('should throw on invalid EmailSignInConfig object', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); tenantOptionsClientRequest.emailSignInConfig = null as unknown as EmailSignInProviderConfig; - expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest)) + expect(() => TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest)) .to.throw('"EmailSignInConfig" must be a non-null object.'); }); @@ -71,14 +72,14 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.emailSignInConfig.enabled = 'invalid'; expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw('"EmailSignInConfig.enabled" must be a boolean.'); }); it('should not throw on valid client request object', () => { const tenantOptionsClientRequest = deepCopy(clientRequest); expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).not.to.throw; }); @@ -86,7 +87,7 @@ describe('Tenant', () => { nonObjects.forEach((request) => { it('should throw on invalid UpdateTenantRequest:' + JSON.stringify(request), () => { expect(() => { - Tenant.buildServerRequest(request as any, !createRequest); + TenantImpl.buildServerRequest(request as any, !createRequest); }).to.throw('"UpdateTenantRequest" must be a valid non-null object.'); }); }); @@ -95,7 +96,7 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.unsupported = 'value'; expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw(`"unsupported" is not a valid UpdateTenantRequest parameter.`); }); @@ -105,7 +106,7 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.displayName = displayName; expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).to.throw('"UpdateTenantRequest.displayName" must be a valid non-empty string.'); }); }); @@ -117,7 +118,7 @@ describe('Tenant', () => { const tenantOptionsServerRequest: TenantServerResponse = deepCopy(serverRequest); delete tenantOptionsServerRequest.name; - expect(Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + expect(TenantImpl.buildServerRequest(tenantOptionsClientRequest, createRequest)) .to.deep.equal(tenantOptionsServerRequest); }); @@ -125,7 +126,7 @@ describe('Tenant', () => { const tenantOptionsClientRequest: TenantOptions = deepCopy(clientRequest); tenantOptionsClientRequest.emailSignInConfig = null as unknown as EmailSignInProviderConfig; - expect(() => Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest)) + expect(() => TenantImpl.buildServerRequest(tenantOptionsClientRequest, createRequest)) .to.throw('"EmailSignInConfig" must be a non-null object.'); }); @@ -133,7 +134,7 @@ describe('Tenant', () => { nonObjects.forEach((request) => { it('should throw on invalid CreateTenantRequest:' + JSON.stringify(request), () => { expect(() => { - Tenant.buildServerRequest(request as any, createRequest); + TenantImpl.buildServerRequest(request as any, createRequest); }).to.throw('"CreateTenantRequest" must be a valid non-null object.'); }); }); @@ -142,7 +143,7 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.unsupported = 'value'; expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, createRequest); }).to.throw(`"unsupported" is not a valid CreateTenantRequest parameter.`); }); @@ -152,7 +153,7 @@ describe('Tenant', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.displayName = displayName; expect(() => { - Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + TenantImpl.buildServerRequest(tenantOptionsClientRequest, createRequest); }).to.throw('"CreateTenantRequest.displayName" must be a valid non-empty string.'); }); }); @@ -161,25 +162,25 @@ describe('Tenant', () => { describe('getTenantIdFromResourceName()', () => { it('should return the expected tenant ID from resource name', () => { - expect(Tenant.getTenantIdFromResourceName('projects/project1/tenants/TENANT-ID')) + expect(TenantImpl.getTenantIdFromResourceName('projects/project1/tenants/TENANT-ID')) .to.equal('TENANT-ID'); }); it('should return the expected tenant ID from resource name whose project ID contains "tenants" substring', () => { - expect(Tenant.getTenantIdFromResourceName('projects/projecttenants/tenants/TENANT-ID')) + expect(TenantImpl.getTenantIdFromResourceName('projects/projecttenants/tenants/TENANT-ID')) .to.equal('TENANT-ID'); }); it('should return null when no tenant ID is found', () => { - expect(Tenant.getTenantIdFromResourceName('projects/project1')).to.be.null; + expect(TenantImpl.getTenantIdFromResourceName('projects/project1')).to.be.null; }); }); describe('constructor', () => { const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); - const tenant = new Tenant(serverRequestCopy); + const tenant = new TenantImpl(serverRequestCopy); it('should not throw on valid initialization', () => { - expect(() => new Tenant(serverRequest)).not.to.throw(); + expect(() => new TenantImpl(serverRequest)).not.to.throw(); }); it('should set readonly property tenantId', () => { @@ -202,7 +203,7 @@ describe('Tenant', () => { const invalidOptions = deepCopy(serverRequest); // Use resource name that does not include a tenant ID. invalidOptions.name = 'projects/project1'; - expect(() => new Tenant(invalidOptions)) + expect(() => new TenantImpl(invalidOptions)) .to.throw('INTERNAL ASSERT FAILED: Invalid tenant response'); }); @@ -212,7 +213,7 @@ describe('Tenant', () => { displayName: 'TENANT-DISPLAY-NAME', }; expect(() => { - const tenantWithoutAllowPasswordSignup = new Tenant(serverResponse); + const tenantWithoutAllowPasswordSignup = new TenantImpl(serverResponse); expect(tenantWithoutAllowPasswordSignup.displayName).to.equal(serverResponse.displayName); expect(tenantWithoutAllowPasswordSignup.tenantId).to.equal('TENANT-ID'); @@ -226,7 +227,7 @@ describe('Tenant', () => { describe('toJSON()', () => { const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); it('should return the expected object representation of a tenant', () => { - expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ + expect(new TenantImpl(serverRequestCopy).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', displayName: 'TENANT-DISPLAY-NAME', emailSignInConfig: { From a15a0be63de8768985eaba9068eee2c2db1d7806 Mon Sep 17 00:00:00 2001 From: MathBunny Date: Thu, 9 Jul 2020 15:44:04 -0400 Subject: [PATCH 6/6] Remove overload --- src/auth/auth.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 778268720a..45d4e0ad93 100755 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -134,21 +134,7 @@ export interface BaseAuth { * @return {Promise} A Promise that will be fulfilled after a successful * verification. */ - verifyIdToken(idToken: string, checkRevoked: boolean): Promise; - - /** - * // TODO: DEFAULT IMPORT (documentation??????) - * Verifies a JWT auth token. Returns a Promise with the tokens claims. Rejects - * the promise if the token could not be verified. If checkRevoked is set to true, - * verifies if the session corresponding to the ID token was revoked. If the corresponding - * user's session was invalidated, an auth/id-token-revoked error is thrown. If not specified - * the check is not applied. - * - * @param {string} idToken The JWT to verify. - * @return {Promise} A Promise that will be fulfilled after a successful - * verification. - */ - verifyIdToken(idToken: string): Promise; + verifyIdToken(idToken: string, checkRevoked?: boolean): Promise; /** * Looks up the user identified by the provided user id and returns a promise that is