diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 7962ca5baa..8713b1bda7 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -13,11 +13,13 @@ export interface ActionCodeSettings { installApp?: boolean; minimumVersion?: string; }; + // @deprecated dynamicLinkDomain?: string; handleCodeInApp?: boolean; iOS?: { bundleId: string; }; + linkDomain?: string; url: string; } @@ -221,6 +223,11 @@ export class AuthClientErrorCode { message: string; }; // (undocumented) + static INVALID_HOSTING_LINK_DOMAIN: { + code: string; + message: string; + }; + // (undocumented) static INVALID_ID_TOKEN: { code: string; message: string; @@ -757,6 +764,14 @@ export interface ListUsersResult { users: UserRecord[]; } +// @public +export interface MobileLinksConfig { + domain?: MobileLinksDomain; +} + +// @public +export type MobileLinksDomain = 'HOSTING_DOMAIN' | 'FIREBASE_DYNAMIC_LINK_DOMAIN'; + // @public export interface MultiFactorConfig { factorIds?: AuthFactorType[]; @@ -847,6 +862,7 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { // @public export class ProjectConfig { readonly emailPrivacyConfig?: EmailPrivacyConfig; + readonly mobileLinksConfig?: MobileLinksConfig; get multiFactorConfig(): MultiFactorConfig | undefined; readonly passwordPolicyConfig?: PasswordPolicyConfig; get recaptchaConfig(): RecaptchaConfig | undefined; @@ -996,6 +1012,7 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor // @public export interface UpdateProjectConfigRequest { emailPrivacyConfig?: EmailPrivacyConfig; + mobileLinksConfig?: MobileLinksConfig; multiFactorConfig?: MultiFactorConfig; passwordPolicyConfig?: PasswordPolicyConfig; recaptchaConfig?: RecaptchaConfig; diff --git a/src/auth/action-code-settings-builder.ts b/src/auth/action-code-settings-builder.ts index 4ebc4df19c..13dbcbc725 100644 --- a/src/auth/action-code-settings-builder.ts +++ b/src/auth/action-code-settings-builder.ts @@ -94,8 +94,19 @@ export interface ActionCodeSettings { * configured per project. This field provides the ability to explicitly choose * configured per project. This fields provides the ability explicitly choose * one. If none is provided, the oldest domain is used by default. + * @deprecated use `linkDomain` instead */ dynamicLinkDomain?: string; + + /** + * Defines the custom Firebase Hosting domain to use when the link is to be opened + * via a specified mobile app, + * This is a replacement of Firebase Dynamic Link. + * If none is provided, + * a default hosting domain will be used (for example, `example.firebaseapp.com`) + */ + + linkDomain?: string; } /** Defines the email action code server request. */ @@ -103,6 +114,7 @@ interface EmailActionCodeRequest { continueUrl?: string; canHandleCodeInApp?: boolean; dynamicLinkDomain?: string; + linkDomain?: string; androidPackageName?: string; androidMinimumVersion: string; androidInstallApp?: boolean; @@ -123,6 +135,7 @@ export class ActionCodeSettingsBuilder { private ibi?: string; private canHandleCodeInApp?: boolean; private dynamicLinkDomain?: string; + private linkDomain?: string; /** * ActionCodeSettingsBuilder constructor. @@ -166,6 +179,14 @@ export class ActionCodeSettingsBuilder { } this.dynamicLinkDomain = actionCodeSettings.dynamicLinkDomain; + if (typeof actionCodeSettings.linkDomain !== 'undefined' && + !validator.isNonEmptyString(actionCodeSettings.linkDomain)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HOSTING_LINK_DOMAIN, + ); + } + this.linkDomain = actionCodeSettings.linkDomain; + if (typeof actionCodeSettings.iOS !== 'undefined') { if (!validator.isNonNullObject(actionCodeSettings.iOS)) { throw new FirebaseAuthError( @@ -230,6 +251,7 @@ export class ActionCodeSettingsBuilder { continueUrl: this.continueUrl, canHandleCodeInApp: this.canHandleCodeInApp, dynamicLinkDomain: this.dynamicLinkDomain, + linkDomain: this.linkDomain, androidPackageName: this.apn, androidMinimumVersion: this.amv, androidInstallApp: this.installApp, diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 23895d60f3..d2d07e9536 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -2137,6 +2137,60 @@ export interface PasswordPolicyConfig { constraints?: CustomStrengthOptionsConfig; } +/** + * Configuration for settings related to univeral links (iOS) + * and app links (Android). + */ +export interface MobileLinksConfig { + /** + * Use Firebase Hosting or dynamic link domain as the out-of-band code domain. + */ + domain?: MobileLinksDomain; +} + +/** + * Open code in app domain to use for app links and universal links. + */ +export type MobileLinksDomain = 'HOSTING_DOMAIN' | 'FIREBASE_DYNAMIC_LINK_DOMAIN'; + +/** + * Defines the MobileLinksAuthConfig class used for validation. + * + * @internal + */ +export class MobileLinksAuthConfig { + public static validate(options: MobileLinksConfig): void { + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MobileLinksConfig" must be a non-null object.', + ); + } + + const validKeys = { + domain: true, + }; + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid "MobileLinksConfig" parameter.`, + ); + } + } + + if (typeof options.domain !== 'undefined' + && options.domain !== 'HOSTING_DOMAIN' + && options.domain !== 'FIREBASE_DYNAMIC_LINK_DOMAIN') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MobileLinksConfig.domain" must be either "HOSTING_DOMAIN" or "FIREBASE_DYNAMIC_LINK_DOMAIN".', + ); + } + } +} + /** * A password policy's enforcement state. */ diff --git a/src/auth/base-auth.ts b/src/auth/base-auth.ts index 6f77e088f8..56941056d9 100644 --- a/src/auth/base-auth.ts +++ b/src/auth/base-auth.ts @@ -749,7 +749,7 @@ export abstract class BaseAuth { * minimumVersion: '12' * }, * handleCodeInApp: true, - * dynamicLinkDomain: 'custom.page.link' + * linkDomain: 'project-id.firebaseapp.com' * }; * admin.auth() * .generatePasswordResetLink('user@example.com', actionCodeSettings) @@ -802,7 +802,7 @@ export abstract class BaseAuth { * minimumVersion: '12' * }, * handleCodeInApp: true, - * dynamicLinkDomain: 'custom.page.link' + * linkDomain: 'project-id.firebaseapp.com' * }; * admin.auth() * .generateEmailVerificationLink('user@example.com', actionCodeSettings) @@ -883,7 +883,7 @@ export abstract class BaseAuth { * minimumVersion: '12' * }, * handleCodeInApp: true, - * dynamicLinkDomain: 'custom.page.link' + * linkDomain: 'project-id.firebaseapp.com' * }; * admin.auth() * .generateEmailVerificationLink('user@example.com', actionCodeSettings) diff --git a/src/auth/index.ts b/src/auth/index.ts index 52538eb7d5..69cb95bf2a 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -104,6 +104,8 @@ export { PasswordPolicyEnforcementState, CustomStrengthOptionsConfig, EmailPrivacyConfig, + MobileLinksConfig, + MobileLinksDomain, } from './auth-config'; export { diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 4a40e0c012..259f6eebb2 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -29,6 +29,8 @@ import { PasswordPolicyConfig, EmailPrivacyConfig, EmailPrivacyAuthConfig, + MobileLinksConfig, + MobileLinksAuthConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -60,6 +62,11 @@ export interface UpdateProjectConfigRequest { * The email privacy configuration to update on the project */ emailPrivacyConfig?: EmailPrivacyConfig; + + /** + * The mobile links configuration for the project + */ + mobileLinksConfig?: MobileLinksConfig; } /** @@ -71,6 +78,7 @@ export interface ProjectConfigServerResponse { recaptchaConfig?: RecaptchaAuthServerConfig; passwordPolicyConfig?: PasswordPolicyAuthServerConfig; emailPrivacyConfig?: EmailPrivacyConfig; + mobileLinksConfig?: MobileLinksConfig; } /** @@ -82,6 +90,7 @@ export interface ProjectConfigClientRequest { recaptchaConfig?: RecaptchaAuthServerConfig; passwordPolicyConfig?: PasswordPolicyAuthServerConfig; emailPrivacyConfig?: EmailPrivacyConfig; + mobileLinksConfig?: MobileLinksConfig; } /** @@ -128,6 +137,11 @@ export class ProjectConfig { */ public readonly emailPrivacyConfig?: EmailPrivacyConfig; + /** + * The mobile links configuration for the project + */ + public readonly mobileLinksConfig?: MobileLinksConfig + /** * Validates a project config options object. Throws an error on failure. * @@ -146,6 +160,7 @@ export class ProjectConfig { recaptchaConfig: true, passwordPolicyConfig: true, emailPrivacyConfig: true, + mobileLinksConfig: true, } // Check for unsupported top level attributes. for (const key in request) { @@ -179,6 +194,11 @@ export class ProjectConfig { if (typeof request.emailPrivacyConfig !== 'undefined') { EmailPrivacyAuthConfig.validate(request.emailPrivacyConfig); } + + // Validate Mobile Links Config if provided. + if (typeof request.mobileLinksConfig !== 'undefined') { + MobileLinksAuthConfig.validate(request.mobileLinksConfig); + } } /** @@ -206,6 +226,9 @@ export class ProjectConfig { if (typeof configOptions.emailPrivacyConfig !== 'undefined') { request.emailPrivacyConfig = configOptions.emailPrivacyConfig; } + if (typeof configOptions.mobileLinksConfig !== 'undefined') { + request.mobileLinksConfig = configOptions.mobileLinksConfig; + } return request; } @@ -234,6 +257,9 @@ export class ProjectConfig { if (typeof response.emailPrivacyConfig !== 'undefined') { this.emailPrivacyConfig = response.emailPrivacyConfig; } + if (typeof response.mobileLinksConfig !== 'undefined') { + this.mobileLinksConfig = response.mobileLinksConfig; + } } /** * Returns a JSON-serializable representation of this object. @@ -248,6 +274,7 @@ export class ProjectConfig { recaptchaConfig: deepCopy(this.recaptchaConfig), passwordPolicyConfig: deepCopy(this.passwordPolicyConfig), emailPrivacyConfig: deepCopy(this.emailPrivacyConfig), + mobileLinksConfig: deepCopy(this.mobileLinksConfig), }; if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; @@ -264,6 +291,9 @@ export class ProjectConfig { if (typeof json.emailPrivacyConfig === 'undefined') { delete json.emailPrivacyConfig; } + if (typeof json.mobileLinksConfig === 'undefined') { + delete json.mobileLinksConfig; + } return json; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index ef56431a03..cf3736adcb 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -471,6 +471,11 @@ export class AuthClientErrorCode { message: 'The provided dynamic link domain is not configured or authorized ' + 'for the current project.', }; + public static INVALID_HOSTING_LINK_DOMAIN = { + code: 'invalid-hosting-link-domain', + message: 'The provided hosting link domain is not configured in Firebase ' + + 'Hosting or is not owned by the current project.', + }; public static INVALID_EMAIL_VERIFIED = { code: 'invalid-email-verified', message: 'The emailVerified field must be a boolean.', @@ -933,6 +938,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_CONTINUE_URI: 'INVALID_CONTINUE_URI', // Dynamic link domain in provided ActionCodeSettings is not authorized. INVALID_DYNAMIC_LINK_DOMAIN: 'INVALID_DYNAMIC_LINK_DOMAIN', + // Hosting link domain in provided ActionCodeSettings is not owned by the current project. + INVALID_HOSTING_LINK_DOMAIN: 'INVALID_HOSTING_LINK_DOMAIN', // uploadAccount provides an email that already exists. DUPLICATE_EMAIL: 'EMAIL_ALREADY_EXISTS', // uploadAccount provides a localId that already exists. diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 8a6374363d..560cd47005 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -32,7 +32,7 @@ import { AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, - PasswordPolicyConfig, SmsRegionConfig, RecaptchaConfig, + PasswordPolicyConfig, SmsRegionConfig, RecaptchaConfig, ActionCodeSettings, } from '../../lib/auth/index'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; @@ -75,10 +75,25 @@ const mockUserData = { photoURL: 'http://www.example.com/' + newUserUid + '/photo.png', disabled: false, }; -const actionCodeSettings = { +const actionCodeSettings : ActionCodeSettings = { url: 'http://localhost/?a=1&b=2#c=3', handleCodeInApp: false, }; +const actionCodeSettingsWithCustomDomain: ActionCodeSettings = { + url: 'http://localhost/?a=1&b=2#c=3', + handleCodeInApp: true, + linkDomain: 'kobayashimaru.testdomaindonotuse.com', + iOS: { + bundleId: 'testBundleId', + }, +} +const actionCodeSettingsForFdlLinks: ActionCodeSettings = { + url: 'http://localhost/?a=1&b=2#c=3', + handleCodeInApp: true, + iOS: { + bundleId: 'testBundleId', + }, +} let deleteQueue = Promise.resolve(); interface UserImportTest { @@ -1095,6 +1110,7 @@ describe('admin.auth', () => { const uid = generateRandomString(20).toLowerCase(); const email = uid + '@example.com'; const newEmail = uid + 'new@example.com'; + const newEmail2 = uid + 'new2@example.com'; const newPassword = 'newPassword'; const userData = { uid, @@ -1105,6 +1121,13 @@ describe('admin.auth', () => { // Create the test user before running this suite of tests. before(() => { + // Update project config to have HOSTING_DOMAIN as mobileLinksConfig after each test + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, + }; + getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest); return getAuth().createUser(userData); }); @@ -1199,6 +1222,162 @@ describe('admin.auth', () => { expect(result.user!.emailVerified).to.be.true; }); }); + + it('generateSignInWithEmailLink() with custom linkDomain should return error in case of invalid hosting domain', + function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + const actionCodeSettingsWithInvalidLinkDomain = deepCopy(actionCodeSettings); + actionCodeSettingsWithInvalidLinkDomain.linkDomain = 'invaliddomain.firebaseapp.com'; + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsWithInvalidLinkDomain) + .catch((error) => { + expect(error.code).to.equal('auth/invalid-hosting-link-domain'); + }); + }); + + it('generatePasswordResetLink() should return a password reset link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure old password set on created user. + return getAuth().updateUser(uid, { password: 'password' }) + .then(() => { + return getAuth().generatePasswordResetLink(newEmail, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCodeForInAppRequest(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().confirmPasswordReset(code, newPassword); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(newEmail, newPassword); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(newEmail); + // Password reset also verifies the user's email. + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateEmailVerificationLink() should return a verification link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure the user's email is unverified. + return getAuth().updateUser(uid, { password: 'password', emailVerified: false }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.false; + return getAuth().generateEmailVerificationLink(newEmail, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCodeForInAppRequest(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().applyActionCode(code); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(newEmail, userData.password); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(newEmail); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a sign-in link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsWithCustomDomain) + .then((link) => { + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettingsWithCustomDomain.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().signInWithEmailLink(email, link); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateVerifyAndChangeEmailLink() should return a verification link with custom domain', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + // Ensure the user's email is verified. + return getAuth().updateUser(uid, { password: 'password', emailVerified: true }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.true; + return getAuth().generateVerifyAndChangeEmailLink(newEmail, newEmail2, actionCodeSettingsWithCustomDomain); + }) + .then((link) => { + const code = getActionCodeForInAppRequest(link); + expect(getContinueUrlForInAppRequest(link)).equal(actionCodeSettings.url); + expect(getHostName(link)).equal(actionCodeSettingsWithCustomDomain.linkDomain); + return clientAuth().applyActionCode(code); + }) + .then(() => { + return clientAuth().signInWithEmailAndPassword(newEmail2, 'password'); + }) + .then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(newEmail2); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a FDL sign-in' + + 'link with mobileLinksConfig set to FIREBASE_DYNAMIC_LINK_DOMAIN', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + domain: 'FIREBASE_DYNAMIC_LINK_DOMAIN', + } + }; + return getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest) + .then((projectConfig) => { + expect(projectConfig?.mobileLinksConfig?.domain).equal('FIREBASE_DYNAMIC_LINK_DOMAIN'); + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsForFdlLinks); + }).then((link) => { + expectFDLLink(link); + return clientAuth().signInWithEmailLink(email, link); + }).then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); + + it('generateSignInWithEmailLink() should return a FDL sign-in link with empty mobileLinksConfig', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + + const updateMobileLinksRequest: UpdateProjectConfigRequest = { + mobileLinksConfig: { + } + }; + return getAuth().projectConfigManager().updateProjectConfig(updateMobileLinksRequest) + .then((projectConfig) => { + expect(projectConfig.mobileLinksConfig).is.empty; + return getAuth().generateSignInWithEmailLink(email, actionCodeSettingsForFdlLinks); + }).then((link) => { + expectFDLLink(link); + return clientAuth().signInWithEmailLink(email, link); + }).then((result) => { + expect(result.user).to.exist; + expect(result.user!.email).to.equal(email); + expect(result.user!.emailVerified).to.be.true; + }); + }); }); describe('Project config management operations', () => { @@ -1302,6 +1481,9 @@ describe('admin.auth', () => { recaptchaConfig: recaptchaStateAuditConfig, emailPrivacyConfig: { enableImprovedEmailPrivacy: true, + }, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', } }; const projectConfigOption2: UpdateProjectConfigRequest = { @@ -1323,6 +1505,9 @@ describe('admin.auth', () => { emailPrivacyConfig: { enableImprovedEmailPrivacy: true, }, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; const expectedRecaptchaOffConfig: any = { @@ -1350,6 +1535,9 @@ describe('admin.auth', () => { passwordPolicyConfig: passwordConfig, recaptchaConfig: expectedRecaptchaOffConfig, emailPrivacyConfig: {}, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; const expectedProjectConfigSmsEnabledTotpDisabled: any = { @@ -1358,6 +1546,9 @@ describe('admin.auth', () => { passwordPolicyConfig: passwordConfig, recaptchaConfig: expectedRecaptchaOffConfig, emailPrivacyConfig: {}, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN', + }, }; it('updateProjectConfig() should resolve with the updated project config', () => { @@ -3231,6 +3422,78 @@ function getContinueUrl(link: string): string { return continueUrl!; } +/** + * Returns the host name corresponding to the link. + * + * @param link The link to parse for hostname + * @returns Hostname in the link + */ +function getHostName(link: string): string { + const parsedUrl = new url.URL(link); + return parsedUrl.hostname; +} + +/** + * Returns continue URL for handling in app requests. + * URL will be of the form, http://abc/__/auth/link?link= + * Coninue URL will be part of action link url + * + * @param link The link to parse for continue url + * @returns Link's corresponding continueUrl + */ +function getContinueUrlForInAppRequest(link: string): string { + const actionUrl = extractLinkUrl(link); + const continueUrl = actionUrl.searchParams.get('continueUrl'); + expect(continueUrl).to.exist; + return continueUrl!; +} + +/** + * Returns the action code corresponding to the link for in app requests. + * URL will be of the form, http://abc/__/auth/link?link= + * oobCode will be part of action link url + * + * @param link The link to parse for the action code. + * @return The link's corresponding action code. + */ +function getActionCodeForInAppRequest(link: string): string { + const actionUrl = extractLinkUrl(link); + const oobCode = actionUrl.searchParams.get('oobCode'); + expect(oobCode).to.exist; + return oobCode!; +} + +/** + * Extract URL in link parameter from the full link + * URL will be of the form, http://abc/__/auth/link?link= + * + * @param link The link to parse for the param + * @returns URL inside link param + */ +function extractLinkUrl(link: string): url.URL { + // Extract action url from link param + const parsedUrl = new url.URL(link); + const linkParam = parsedUrl.searchParams.get('link') ?? ''; + expect(linkParam).is.not.empty; + + return new url.URL(linkParam); +} + +/** + * Verify if the generated link is generated by FDL + * We leverage the params created by FDL to test whether a given link is FDL + * + * @param link Link to check whether it is FDL + */ +function expectFDLLink(link: string): void { + const parsedUrl = new url.URL(link); + // For ios, FDL creates a fallback url with param ifl + // We leverage that to test whether a given link is FDL link + // Note: This param does not exist when the link is generated for HOSTING_DOMAIN + const iflParam = parsedUrl.searchParams.get('ifl'); + expect(iflParam).is.not.null; +} + /** * Returns the tenant ID corresponding to the link. * diff --git a/test/unit/auth/action-code-settings-builder.spec.ts b/test/unit/auth/action-code-settings-builder.spec.ts index ceadde3e9b..4608f711f1 100644 --- a/test/unit/auth/action-code-settings-builder.spec.ts +++ b/test/unit/auth/action-code-settings-builder.spec.ts @@ -28,6 +28,7 @@ chai.use(sinonChai); chai.use(chaiAsPromised); const expect = chai.expect; +const TEST_LINK_DOMAIN = 'project-id.firebaseapp.com'; describe('ActionCodeSettingsBuilder', () => { describe('constructor', () => { @@ -43,7 +44,10 @@ describe('ActionCodeSettingsBuilder', () => { installApp: true, minimumVersion: '6', }, + // not removing this test since we are going to accept both dynamicLinkDomain + // and linkDomain for the onboarding phase. dynamicLinkDomain: 'custom.page.link', + linkDomain: TEST_LINK_DOMAIN, })).not.to.throw; }); @@ -69,6 +73,7 @@ describe('ActionCodeSettingsBuilder', () => { minimumVersion: '6', }, dynamicLinkDomain: 'custom.page.link', + linkDomain: TEST_LINK_DOMAIN, } as any); }).to.throw(AuthClientErrorCode.MISSING_CONTINUE_URI.message); }); @@ -109,6 +114,20 @@ describe('ActionCodeSettingsBuilder', () => { }); }); + const invalidHostingDomains = [null, NaN, 0, 1, true, false, '', + [TEST_LINK_DOMAIN], [], {}, { a: 1 }, _.noop]; + invalidHostingDomains.forEach((domain) => { + it('should throw on invalid linkDomain:' + JSON.stringify(domain), () => { + expect(() => { + return new ActionCodeSettingsBuilder({ + url: 'https://www.example.com/path/file?a=1&b=2', + handleCodeInApp: true, + linkDomain: domain, + } as any); + }).to.throw(AuthClientErrorCode.INVALID_HOSTING_LINK_DOMAIN.message); + }); + }); + const invalidIOSSettings = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; invalidIOSSettings.forEach((settings) => { it('should throw on invalid iOS object:' + JSON.stringify(settings), () => { @@ -228,11 +247,13 @@ describe('ActionCodeSettingsBuilder', () => { minimumVersion: '6', }, dynamicLinkDomain: 'custom.page.link', + linkDomain: TEST_LINK_DOMAIN, }); const expectedRequest = { continueUrl: 'https://www.example.com/path/file?a=1&b=2', canHandleCodeInApp: true, dynamicLinkDomain: 'custom.page.link', + linkDomain: TEST_LINK_DOMAIN, androidPackageName: 'com.example.android', androidMinimumVersion: '6', androidInstallApp: true, diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index bd789421b8..9a6d624b88 100644 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -3081,6 +3081,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => { minimumVersion: '6', }, dynamicLinkDomain: 'custom.page.link', + linkDomain: 'project-id.firebaseapp.com', }; const expectedActionCodeSettingsRequest = new ActionCodeSettingsBuilder(actionCodeSettings).buildRequest(); const expectedLink = 'https://custom.page.link?link=' + diff --git a/test/unit/auth/auth-config.spec.ts b/test/unit/auth/auth-config.spec.ts index 8894bdc5ff..c5bdb7c445 100644 --- a/test/unit/auth/auth-config.spec.ts +++ b/test/unit/auth/auth-config.spec.ts @@ -28,6 +28,8 @@ import { MAXIMUM_TEST_PHONE_NUMBERS, PasswordPolicyAuthConfig, CustomStrengthOptionsConfig, + MobileLinksAuthConfig, + MobileLinksConfig, } from '../../../src/auth/auth-config'; import { SAMLUpdateAuthProviderRequest, OIDCUpdateAuthProviderRequest, @@ -1322,3 +1324,30 @@ describe('PasswordPolicyAuthConfig',() => { }); }); }); + +describe('MobileLinksAuthConfig',() => { + describe('validate',() => { + it('should throw an error on invalid MobileLinksConfig key',() => { + const config: any = { + link: 'HOSTING_DOMAIN' + }; + expect(() => + MobileLinksAuthConfig.validate(config) + ).to.throw('"link" is not a valid "MobileLinksConfig" parameter.'); + }); + + it('should throw an error on invalid MobileLinksDomain',() => { + const config: any = { + domain: 'WRONG_DOMAIN' + }; + expect(() => MobileLinksAuthConfig.validate(config)) + .to.throw('"MobileLinksConfig.domain" must be either "HOSTING_DOMAIN" or "FIREBASE_DYNAMIC_LINK_DOMAIN".'); + });// + it('should now throw an error on valid MobileLinksConfig',() => { + const config: MobileLinksConfig = { + domain: 'HOSTING_DOMAIN' + }; + expect(() => MobileLinksAuthConfig.validate(config)).not.to.throw(); + }); + }); +}); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 4924cb5b12..34d4c3d9a6 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -2887,6 +2887,7 @@ AUTH_CONFIGS.forEach((testConfig) => { minimumVersion: '6', }, dynamicLinkDomain: 'custom.page.link', + linkDomain: 'project-id.firebaseapp.com', }; const expectedLink = 'https://custom.page.link?link=' + encodeURIComponent('https://projectId.firebaseapp.com/__/auth/action?oobCode=CODE') + diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index f116647568..7857deb398 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -87,7 +87,10 @@ describe('ProjectConfig', () => { useAccountDefender: true, useSmsBotScore: true, useSmsTollFraudProtection: true, - } + }, + mobileLinksConfig: { + domain: 'FIREBASE_DYNAMIC_LINK_DOMAIN', + }, }; const updateProjectConfigRequest1: UpdateProjectConfigRequest = { @@ -111,6 +114,9 @@ describe('ProjectConfig', () => { emailPrivacyConfig: { enableImprovedEmailPrivacy: false, }, + mobileLinksConfig: { + domain: 'HOSTING_DOMAIN' + }, }; const updateProjectConfigRequest2: UpdateProjectConfigRequest = { @@ -550,6 +556,22 @@ describe('ProjectConfig', () => { }).to.throw('"EmailPrivacyConfig.enableImprovedEmailPrivacy" must be a valid boolean value.'); }); + it('should throw on invalid MobileLinksConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.mobileLinksConfig.invalidParameter = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid "MobileLinksConfig" parameter.'); + }); + + it('should throw on invalid domain attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + configOptionsClientRequest.mobileLinksConfig.domain = 'random domain'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"MobileLinksConfig.domain" must be either "HOSTING_DOMAIN" or "FIREBASE_DYNAMIC_LINK_DOMAIN".'); + }); + const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { it('should throw on invalid UpdateProjectConfigRequest:' + JSON.stringify(request), () => { @@ -647,6 +669,13 @@ describe('ProjectConfig', () => { }; expect(projectConfig.emailPrivacyConfig).to.deep.equal(expectedEmailPrivacyConfig); }); + + it('should set readonly property mobileLinksConfig', () => { + const expectedMobileLinksConfig = { + domain: 'FIREBASE_DYNAMIC_LINK_DOMAIN', + }; + expect(projectConfig.mobileLinksConfig).to.deep.equal(expectedMobileLinksConfig); + }); }); describe('toJSON()', () => { @@ -704,6 +733,7 @@ describe('ProjectConfig', () => { emailPrivacyConfig: { enableImprovedEmailPrivacy: true, }, + mobileLinksConfig: deepCopy(serverResponse.mobileLinksConfig), }); }); @@ -720,6 +750,7 @@ describe('ProjectConfig', () => { delete serverResponseOptionalCopy.recaptchaConfig?.useSmsTollFraudProtection delete serverResponseOptionalCopy.passwordPolicyConfig; delete serverResponseOptionalCopy.emailPrivacyConfig; + delete serverResponseOptionalCopy.mobileLinksConfig; expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ recaptchaConfig: { recaptchaKeys: deepCopy(serverResponse.recaptchaConfig?.recaptchaKeys),