diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js
index 387e98799b4..876a345671a 100644
--- a/packages/auth/demo/src/index.js
+++ b/packages/auth/demo/src/index.js
@@ -49,8 +49,8 @@ import {
signInWithCredential,
signInWithCustomToken,
signInWithEmailAndPassword,
+ signInWithEmailLink,
TotpMultiFactorGenerator,
- TotpSecret,
unlink,
updateEmail,
updatePassword,
@@ -995,6 +995,7 @@ function getActionCodeSettings() {
const installApp = $('input[name=install-app]:checked').val() === 'Yes';
const handleCodeInApp =
$('input[name=handle-in-app]:checked').val() === 'Yes';
+ const hostingLinkDomain = $('#hostingLinkDomain').val();
if (url || apn || ibi) {
actionCodeSettings['url'] = url;
if (apn) {
@@ -1010,6 +1011,9 @@ function getActionCodeSettings() {
};
}
actionCodeSettings['handleCodeInApp'] = handleCodeInApp;
+ if (hostingLinkDomain) {
+ actionCodeSettings['linkDomain'] = hostingLinkDomain;
+ }
}
return actionCodeSettings;
}
@@ -1020,6 +1024,7 @@ function onActionCodeSettingsReset() {
$('#apn').val('');
$('#amv').val('');
$('#ibi').val('');
+ $('#hostingLinkDomain').val('');
}
/**
diff --git a/packages/auth/karma.conf.js b/packages/auth/karma.conf.js
index 161f4819d32..749ad0672c4 100644
--- a/packages/auth/karma.conf.js
+++ b/packages/auth/karma.conf.js
@@ -41,7 +41,8 @@ function getTestFiles(argv) {
return [
'test/integration/flows/totp.test.ts',
'test/integration/flows/password_policy.test.ts',
- 'test/integration/flows/recaptcha_enterprise.test.ts'
+ 'test/integration/flows/recaptcha_enterprise.test.ts',
+ 'test/integration/flows/hosting_link.test.ts'
];
}
return argv.local
diff --git a/packages/auth/src/api/authentication/email_and_password.ts b/packages/auth/src/api/authentication/email_and_password.ts
index 2f9664f72db..9012b95d213 100644
--- a/packages/auth/src/api/authentication/email_and_password.ts
+++ b/packages/auth/src/api/authentication/email_and_password.ts
@@ -70,6 +70,7 @@ export interface GetOobCodeRequest {
dynamicLinkDomain?: string;
tenantId?: string;
targetProjectid?: string;
+ linkDomain?: string;
}
export interface VerifyEmailRequest extends GetOobCodeRequest {
diff --git a/packages/auth/src/api/errors.ts b/packages/auth/src/api/errors.ts
index d8f73f72821..0e59e3babbd 100644
--- a/packages/auth/src/api/errors.ts
+++ b/packages/auth/src/api/errors.ts
@@ -100,7 +100,8 @@ export const enum ServerError {
MISSING_RECAPTCHA_VERSION = 'MISSING_RECAPTCHA_VERSION',
INVALID_RECAPTCHA_VERSION = 'INVALID_RECAPTCHA_VERSION',
INVALID_REQ_TYPE = 'INVALID_REQ_TYPE',
- PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'PASSWORD_DOES_NOT_MEET_REQUIREMENTS'
+ PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'PASSWORD_DOES_NOT_MEET_REQUIREMENTS',
+ INVALID_HOSTING_LINK_DOMAIN = 'INVALID_HOSTING_LINK_DOMAIN'
}
/**
diff --git a/packages/auth/src/core/errors.ts b/packages/auth/src/core/errors.ts
index 9494658b9f0..0e7fb53059c 100644
--- a/packages/auth/src/core/errors.ts
+++ b/packages/auth/src/core/errors.ts
@@ -134,7 +134,8 @@ export const enum AuthErrorCode {
INVALID_RECAPTCHA_VERSION = 'invalid-recaptcha-version',
INVALID_REQ_TYPE = 'invalid-req-type',
UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION = 'unsupported-password-policy-schema-version',
- PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'password-does-not-meet-requirements'
+ PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'password-does-not-meet-requirements',
+ INVALID_HOSTING_LINK_DOMAIN = 'invalid-hosting-link-domain'
}
function _debugErrorMap(): ErrorMap
{
@@ -387,7 +388,10 @@ function _debugErrorMap(): ErrorMap {
[AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION]:
'The password policy received from the backend uses a schema version that is not supported by this version of the Firebase SDK.',
[AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS]:
- 'The password does not meet the requirements.'
+ 'The password does not meet the requirements.',
+ [AuthErrorCode.INVALID_HOSTING_LINK_DOMAIN]:
+ 'The provided Hosting link domain is not configured in Firebase Hosting or is not owned by ' +
+ 'the current project. This cannot be a default Hosting domain (`web.app` or `firebaseapp.com`).'
};
}
@@ -598,5 +602,6 @@ export const AUTH_ERROR_CODES_MAP_DO_NOT_USE_INTERNALLY = {
MISSING_CLIENT_TYPE: 'auth/missing-client-type',
MISSING_RECAPTCHA_VERSION: 'auth/missing-recaptcha-version',
INVALID_RECAPTCHA_VERSION: 'auth/invalid-recaptcha-version',
- INVALID_REQ_TYPE: 'auth/invalid-req-type'
+ INVALID_REQ_TYPE: 'auth/invalid-req-type',
+ INVALID_HOSTING_LINK_DOMAIN: 'auth/invalid-hosting-link-domain'
} as const;
diff --git a/packages/auth/src/core/strategies/action_code_settings.test.ts b/packages/auth/src/core/strategies/action_code_settings.test.ts
index 36784151156..09bc672ee06 100644
--- a/packages/auth/src/core/strategies/action_code_settings.test.ts
+++ b/packages/auth/src/core/strategies/action_code_settings.test.ts
@@ -26,6 +26,10 @@ describe('core/strategies/action_code_settings', () => {
let auth: TestAuth;
const request: GetOobCodeRequest = {};
+ const TEST_BUNDLE_ID = 'my-bundle';
+ const TEST_FDL_DOMAIN = 'fdl-domain';
+ const TEST_URL = 'my-url';
+
beforeEach(async () => {
auth = await testAuth();
});
@@ -35,10 +39,10 @@ describe('core/strategies/action_code_settings', () => {
_setActionCodeSettingsOnRequest(auth, request, {
handleCodeInApp: true,
iOS: {
- bundleId: 'my-bundle'
+ bundleId: TEST_BUNDLE_ID
},
url: '',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: TEST_FDL_DOMAIN
})
).to.throw(FirebaseError, '(auth/invalid-continue-uri)');
});
@@ -48,9 +52,9 @@ describe('core/strategies/action_code_settings', () => {
_setActionCodeSettingsOnRequest(auth, request, {
handleCodeInApp: true,
iOS: {
- bundleId: 'my-´bundle'
+ bundleId: TEST_BUNDLE_ID
},
- url: 'my-url'
+ url: TEST_URL
})
).to.not.throw();
});
@@ -60,14 +64,27 @@ describe('core/strategies/action_code_settings', () => {
_setActionCodeSettingsOnRequest(auth, request, {
handleCodeInApp: true,
iOS: {
- bundleId: 'my-´bundle'
+ bundleId: TEST_BUNDLE_ID
},
- url: 'my-url',
+ url: TEST_URL,
dynamicLinkDomain: ''
})
).to.throw(FirebaseError, '(auth/invalid-dynamic-link-domain)');
});
+ it('should require a non empty Hosting link URL', () => {
+ expect(() =>
+ _setActionCodeSettingsOnRequest(auth, request, {
+ handleCodeInApp: true,
+ iOS: {
+ bundleId: TEST_BUNDLE_ID
+ },
+ url: TEST_URL,
+ linkDomain: ''
+ })
+ ).to.throw(FirebaseError, '(auth/invalid-hosting-link-domain)');
+ });
+
it('should require a non-empty bundle ID', () => {
expect(() =>
_setActionCodeSettingsOnRequest(auth, request, {
@@ -75,8 +92,8 @@ describe('core/strategies/action_code_settings', () => {
iOS: {
bundleId: ''
},
- url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ url: TEST_URL,
+ dynamicLinkDomain: TEST_FDL_DOMAIN
})
).to.throw(FirebaseError, '(auth/missing-ios-bundle-id)');
});
@@ -88,8 +105,8 @@ describe('core/strategies/action_code_settings', () => {
android: {
packageName: ''
},
- url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ url: TEST_URL,
+ dynamicLinkDomain: TEST_FDL_DOMAIN
})
).to.throw(FirebaseError, '(auth/missing-android-pkg-name)');
});
diff --git a/packages/auth/src/core/strategies/action_code_settings.ts b/packages/auth/src/core/strategies/action_code_settings.ts
index 4afa54f2ab3..e7e4c994838 100644
--- a/packages/auth/src/core/strategies/action_code_settings.ts
+++ b/packages/auth/src/core/strategies/action_code_settings.ts
@@ -37,9 +37,16 @@ export function _setActionCodeSettingsOnRequest(
auth,
AuthErrorCode.INVALID_DYNAMIC_LINK_DOMAIN
);
+ _assert(
+ typeof actionCodeSettings.linkDomain === 'undefined' ||
+ actionCodeSettings.linkDomain.length > 0,
+ auth,
+ AuthErrorCode.INVALID_HOSTING_LINK_DOMAIN
+ );
request.continueUrl = actionCodeSettings.url;
request.dynamicLinkDomain = actionCodeSettings.dynamicLinkDomain;
+ request.linkDomain = actionCodeSettings.linkDomain;
request.canHandleCodeInApp = actionCodeSettings.handleCodeInApp;
if (actionCodeSettings.iOS) {
diff --git a/packages/auth/src/core/strategies/email.test.ts b/packages/auth/src/core/strategies/email.test.ts
index 85493a89aef..b2edc26ebb2 100644
--- a/packages/auth/src/core/strategies/email.test.ts
+++ b/packages/auth/src/core/strategies/email.test.ts
@@ -162,7 +162,8 @@ describe('core/strategies/sendEmailVerification', () => {
bundleId: 'my-bundle'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
@@ -170,6 +171,7 @@ describe('core/strategies/sendEmailVerification', () => {
idToken,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
iOSBundleId: 'my-bundle'
});
@@ -190,13 +192,15 @@ describe('core/strategies/sendEmailVerification', () => {
packageName: 'my-package'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
requestType: ActionCodeOperation.VERIFY_EMAIL,
idToken,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
androidInstallApp: false,
androidMinimumVersionCode: 'my-version',
@@ -270,7 +274,8 @@ describe('core/strategies/verifyBeforeUpdateEmail', () => {
bundleId: 'my-bundle'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
@@ -279,6 +284,7 @@ describe('core/strategies/verifyBeforeUpdateEmail', () => {
newEmail,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
iOSBundleId: 'my-bundle'
});
@@ -299,7 +305,8 @@ describe('core/strategies/verifyBeforeUpdateEmail', () => {
packageName: 'my-package'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
requestType: ActionCodeOperation.VERIFY_AND_CHANGE_EMAIL,
@@ -307,6 +314,7 @@ describe('core/strategies/verifyBeforeUpdateEmail', () => {
newEmail,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
androidInstallApp: false,
androidMinimumVersionCode: 'my-version',
diff --git a/packages/auth/src/core/strategies/email_and_password.test.ts b/packages/auth/src/core/strategies/email_and_password.test.ts
index 047e86dc17f..e92f5abb32d 100644
--- a/packages/auth/src/core/strategies/email_and_password.test.ts
+++ b/packages/auth/src/core/strategies/email_and_password.test.ts
@@ -121,7 +121,8 @@ describe('core/strategies/sendPasswordResetEmail', () => {
bundleId: 'my-bundle'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
@@ -129,6 +130,7 @@ describe('core/strategies/sendPasswordResetEmail', () => {
email,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
iOSBundleId: 'my-bundle',
clientType: 'CLIENT_TYPE_WEB'
@@ -149,13 +151,15 @@ describe('core/strategies/sendPasswordResetEmail', () => {
packageName: 'my-package'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
requestType: ActionCodeOperation.PASSWORD_RESET,
email,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
androidInstallApp: false,
androidMinimumVersionCode: 'my-version',
diff --git a/packages/auth/src/core/strategies/email_link.test.ts b/packages/auth/src/core/strategies/email_link.test.ts
index 97f8cdc7c7a..c58089bf497 100644
--- a/packages/auth/src/core/strategies/email_link.test.ts
+++ b/packages/auth/src/core/strategies/email_link.test.ts
@@ -124,7 +124,8 @@ describe('core/strategies/sendSignInLinkToEmail', () => {
bundleId: 'my-bundle'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
@@ -132,6 +133,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => {
email,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
iOSBundleId: 'my-bundle',
clientType: 'CLIENT_TYPE_WEB'
@@ -152,13 +154,15 @@ describe('core/strategies/sendSignInLinkToEmail', () => {
packageName: 'my-package'
},
url: 'my-url',
- dynamicLinkDomain: 'fdl-domain'
+ dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain'
});
expect(mock.calls[0].request).to.eql({
requestType: ActionCodeOperation.EMAIL_SIGNIN,
email,
continueUrl: 'my-url',
dynamicLinkDomain: 'fdl-domain',
+ linkDomain: 'hosting-link-domain',
canHandleCodeInApp: true,
androidInstallApp: false,
androidMinimumVersionCode: 'my-version',
diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts
index 07e56a6b9aa..ac1face6b6a 100644
--- a/packages/auth/src/model/public_types.ts
+++ b/packages/auth/src/model/public_types.ts
@@ -462,12 +462,7 @@ export interface ActionCodeSettings {
* Sets the Android package name.
*
* @remarks
- * This will try to open the link in an android app if it is
- * installed. If `installApp` is passed, it specifies whether to install the Android app if the
- * device supports it and the app is not already installed. If this field is provided without
- * a `packageName`, an error is thrown explaining that the `packageName` must be provided in
- * conjunction with this field. If `minimumVersion` is specified, and an older version of the
- * app is installed, the user is taken to the Play Store to upgrade the app.
+ * This will try to open the link in an Android app if it is installed.
*/
android?: {
installApp?: boolean;
@@ -490,8 +485,6 @@ export interface ActionCodeSettings {
*
* @remarks
* This will try to open the link in an iOS app if it is installed.
- *
- * App installation is not supported for iOS.
*/
iOS?: {
bundleId: string;
@@ -504,7 +497,7 @@ export interface ActionCodeSettings {
* - When the link is handled in the web action widgets, this is the deep link in the
* `continueUrl` query parameter.
* - When the link is handled in the app directly, this is the `continueUrl` query parameter in
- * the deep link of the Dynamic Link.
+ * the deep link of the Dynamic Link or Hosting link.
*/
url: string;
/**
@@ -513,8 +506,20 @@ export interface ActionCodeSettings {
*
*
* @defaultValue The first domain is automatically selected.
+ *
+ * @deprecated Firebase Dynamic Links is deprecated and will be shut down as early as August
+ * 2025. Instead, use {@link ActionCodeSettings.linkDomain} to set a custom domain for mobile
+ * links. Learn more in the {@link https://firebase.google.com/support/dynamic-links-faq | Dynamic Links deprecation FAQ}.
*/
dynamicLinkDomain?: string;
+ /**
+ * The optional custom Firebase Hosting domain to use when the link is to be opened via
+ * a specified mobile app. The domain must be configured in Firebase Hosting and owned
+ * by the project. This cannot be a default Hosting domain (`web.app` or `firebaseapp.com`).
+ *
+ * @defaultValue The default Hosting domain will be used (for example, `example.firebaseapp.com`).
+ */
+ linkDomain?: string;
}
/**
diff --git a/packages/auth/test/integration/flows/hosting_link.test.ts b/packages/auth/test/integration/flows/hosting_link.test.ts
new file mode 100644
index 00000000000..7de8e29f534
--- /dev/null
+++ b/packages/auth/test/integration/flows/hosting_link.test.ts
@@ -0,0 +1,106 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ *
+ * 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.
+ */
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import {
+ ActionCodeSettings,
+ Auth,
+ sendSignInLinkToEmail
+} from '@firebase/auth';
+import { expect, use } from 'chai';
+import {
+ cleanUpTestInstance,
+ getTestInstance,
+ randomEmail
+} from '../../helpers/integration/helpers';
+import { getEmulatorUrl } from '../../helpers/integration/settings';
+import chaiAsPromised from 'chai-as-promised';
+import { FirebaseError } from '@firebase/util';
+
+use(chaiAsPromised);
+
+// Assumes mobileLinksConfig.domain is set as "HOSTING_DOMAIN" in the test GCP-project.
+describe('Integration test: hosting link validation', () => {
+ let auth: Auth;
+ let email: string;
+
+ const AUTHORIZED_CUSTOM_DOMAIN = 'localhost/action_code_return';
+ const ANDROID_PACKAGE_NAME = 'com.google.firebase.test.thin';
+ const BASE_SETTINGS: ActionCodeSettings = {
+ url: 'http://' + AUTHORIZED_CUSTOM_DOMAIN,
+ handleCodeInApp: true,
+ android: { packageName: ANDROID_PACKAGE_NAME }
+ };
+ const VALID_LINK_DOMAIN = 'jscore-sandbox.testdomaindonotuse.com';
+ const INVALID_LINK_DOMAIN = 'invalid.testdomaindonotuse.com';
+ const INVALID_LINK_DOMAIN_ERROR = 'auth/invalid-hosting-link-domain';
+ const TEST_TENANT_ID = 'passpol-tenant-d7hha';
+
+ beforeEach(function () {
+ auth = getTestInstance();
+ email = randomEmail();
+
+ if (getEmulatorUrl()) {
+ this.skip();
+ }
+ });
+
+ afterEach(async () => {
+ await cleanUpTestInstance(auth);
+ });
+
+ it('allows user to sign in with default firebase hosting link', async () => {
+ // Sends email link to user using default hosting link.
+ await sendSignInLinkToEmail(auth, email, BASE_SETTINGS);
+ });
+
+ it('allows user to sign in to a tenant with default firebase hosting link', async () => {
+ auth.tenantId = TEST_TENANT_ID;
+ // Sends email link to user using default hosting link.
+ await sendSignInLinkToEmail(auth, email, BASE_SETTINGS);
+ });
+
+ it('allows user to sign in with custom firebase hosting link', async () => {
+ // Sends email link to user using custom hosting link.
+ await sendSignInLinkToEmail(auth, email, {
+ ...BASE_SETTINGS,
+ linkDomain: VALID_LINK_DOMAIN
+ });
+ });
+
+ it('allows user to sign in to a tenant with custom firebase hosting link', async () => {
+ // Sends email link to user using custom hosting link.
+ auth.tenantId = TEST_TENANT_ID;
+ await sendSignInLinkToEmail(auth, email, {
+ ...BASE_SETTINGS,
+ linkDomain: VALID_LINK_DOMAIN
+ });
+ });
+
+ it('sign in with invalid firebase hosting link throws exception', async () => {
+ // Throws an exception while sening email link to user using invalid hosting link.
+ await expect(
+ sendSignInLinkToEmail(auth, email, {
+ ...BASE_SETTINGS,
+ linkDomain: INVALID_LINK_DOMAIN
+ })
+ ).to.be.rejectedWith(
+ FirebaseError,
+ new RegExp('.*' + INVALID_LINK_DOMAIN_ERROR + '.*')
+ );
+ });
+});