diff --git a/.changeset/afraid-fishes-repair.md b/.changeset/afraid-fishes-repair.md new file mode 100644 index 00000000000..0db8e8ffe22 --- /dev/null +++ b/.changeset/afraid-fishes-repair.md @@ -0,0 +1,8 @@ +--- +'@firebase/auth': minor +'@firebase/app': minor +'firebase': minor +--- + +Added the new `FirebaseServerApp` interface to bridge state +data between client and server runtime environments. This interface extends `FirebaseApp`. diff --git a/common/api-review/app.api.md b/common/api-review/app.api.md index 52f134dac16..e226940ef1b 100644 --- a/common/api-review/app.api.md +++ b/common/api-review/app.api.md @@ -71,6 +71,19 @@ export interface FirebaseOptions { storageBucket?: string; } +// @public +export interface FirebaseServerApp extends FirebaseApp { + name: string; + readonly settings: FirebaseServerAppSettings; +} + +// @public +export interface FirebaseServerAppSettings extends FirebaseAppSettings { + authIdToken?: string; + name?: undefined; + releaseOnDeref?: object; +} + // @internal (undocumented) export interface _FirebaseService { // (undocumented) @@ -96,6 +109,15 @@ export function initializeApp(options: FirebaseOptions, config?: FirebaseAppSett // @public export function initializeApp(): FirebaseApp; +// @public +export function initializeServerApp(options: FirebaseOptions | FirebaseApp, config: FirebaseServerAppSettings): FirebaseServerApp; + +// @internal (undocumented) +export function _isFirebaseApp(obj: FirebaseApp | FirebaseOptions): obj is FirebaseApp; + +// @internal (undocumented) +export function _isFirebaseServerApp(obj: FirebaseApp | FirebaseServerApp): obj is FirebaseServerApp; + // @public export function onLog(logCallback: LogCallback | null, options?: LogOptions): void; @@ -111,6 +133,9 @@ export function _removeServiceInstance(app: FirebaseApp, name: T // @public export const SDK_VERSION: string; +// @internal (undocumented) +export const _serverApps: Map; + // @public export function setLogLevel(logLevel: LogLevelString): void; diff --git a/config/tsconfig.base.json b/config/tsconfig.base.json index cf88a789aa2..0b585359a36 100644 --- a/config/tsconfig.base.json +++ b/config/tsconfig.base.json @@ -15,6 +15,7 @@ "es2015.core", "es2017.object", "es2017.string", + "ESNext.WeakRef", ], "module": "ES2015", "moduleResolution": "node", diff --git a/docs-devsite/app.firebaseserverapp.md b/docs-devsite/app.firebaseserverapp.md new file mode 100644 index 00000000000..66b51c45fb2 --- /dev/null +++ b/docs-devsite/app.firebaseserverapp.md @@ -0,0 +1,59 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# FirebaseServerApp interface +A [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) holds the initialization information for a collection of services running in server environments. + +Do not call this constructor directly. Instead, use [initializeServerApp()](./app.md#initializeserverapp_30ab697) to create an app. + +Signature: + +```typescript +export interface FirebaseServerApp extends FirebaseApp +``` +Extends: [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [name](./app.firebaseserverapp.md#firebaseserverappname) | string | There is no getApp() operation for FirebaseServerApp, so the name is not relevant for applications. However, it may be used internally, and is declared here so that FirebaseServerApp conforms to the FirebaseApp interface. | +| [settings](./app.firebaseserverapp.md#firebaseserverappsettings) | [FirebaseServerAppSettings](./app.firebaseserverappsettings.md#firebaseserverappsettings_interface) | The (read-only) configuration settings for this server app. These are the original parameters given in [initializeServerApp()](./app.md#initializeserverapp_30ab697). | + +## FirebaseServerApp.name + +There is no `getApp()` operation for `FirebaseServerApp`, so the name is not relevant for applications. However, it may be used internally, and is declared here so that `FirebaseServerApp` conforms to the `FirebaseApp` interface. + +Signature: + +```typescript +name: string; +``` + +## FirebaseServerApp.settings + +The (read-only) configuration settings for this server app. These are the original parameters given in [initializeServerApp()](./app.md#initializeserverapp_30ab697). + +Signature: + +```typescript +readonly settings: FirebaseServerAppSettings; +``` + +### Example + + +```javascript +const app = initializeServerApp(settings); +console.log(app.settings.authIdToken === options.authIdToken); // true + +``` + diff --git a/docs-devsite/app.firebaseserverappsettings.md b/docs-devsite/app.firebaseserverappsettings.md new file mode 100644 index 00000000000..20c335b04a6 --- /dev/null +++ b/docs-devsite/app.firebaseserverappsettings.md @@ -0,0 +1,70 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# FirebaseServerAppSettings interface +Configuration options given to [initializeServerApp()](./app.md#initializeserverapp_30ab697) + +Signature: + +```typescript +export interface FirebaseServerAppSettings extends FirebaseAppSettings +``` +Extends: [FirebaseAppSettings](./app.firebaseappsettings.md#firebaseappsettings_interface) + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [authIdToken](./app.firebaseserverappsettings.md#firebaseserverappsettingsauthidtoken) | string | An optional Auth ID token used to resume a signed in user session from a client runtime environment.Invoking getAuth with a FirebaseServerApp configured with a validated authIdToken causes an automatic attempt to sign in the user that the authIdToken represents. The token needs to have been recently minted for this operation to succeed.If the token fails local verification, or if the Auth service has failed to validate it when the Auth SDK is initialized, then a warning is logged to the console and the Auth SDK will not sign in a user on initalization.If a user is successfully signed in, then the Auth instance's onAuthStateChanged callback is invoked with the User object as per standard Auth flows. However, User objects created via an authIdToken do not have a refresh token. Attempted refreshToken operations fail. | +| [name](./app.firebaseserverappsettings.md#firebaseserverappsettingsname) | undefined | There is no getApp() operation for FirebaseServerApp, so the name is not relevant for applications. However, it may be used internally, and is declared here so that FirebaseServerApp conforms to the FirebaseApp interface. | +| [releaseOnDeref](./app.firebaseserverappsettings.md#firebaseserverappsettingsreleaseonderef) | object | An optional object. If provided, the Firebase SDK uses a FinalizationRegistry object to monitor the garbage collection status of the provided object. The Firebase SDK releases its reference on the FirebaseServerApp instance when the provided releaseOnDeref object is garbage collected.You can use this field to reduce memory management overhead for your application. If provided, an app running in a SSR pass does not need to perform FirebaseServerApp cleanup, so long as the reference object is deleted (by falling out of SSR scope, for instance.)If an object is not provided then the application must clean up the FirebaseServerApp instance by invoking deleteApp.If the application provides an object in this parameter, but the application is executed in a JavaScript engine that predates the support of FinalizationRegistry (introduced in node v14.6.0, for instance), then an error is thrown at FirebaseServerApp initialization. | + +## FirebaseServerAppSettings.authIdToken + +An optional Auth ID token used to resume a signed in user session from a client runtime environment. + +Invoking `getAuth` with a `FirebaseServerApp` configured with a validated `authIdToken` causes an automatic attempt to sign in the user that the `authIdToken` represents. The token needs to have been recently minted for this operation to succeed. + +If the token fails local verification, or if the Auth service has failed to validate it when the Auth SDK is initialized, then a warning is logged to the console and the Auth SDK will not sign in a user on initalization. + +If a user is successfully signed in, then the Auth instance's `onAuthStateChanged` callback is invoked with the `User` object as per standard Auth flows. However, `User` objects created via an `authIdToken` do not have a refresh token. Attempted `refreshToken` operations fail. + +Signature: + +```typescript +authIdToken?: string; +``` + +## FirebaseServerAppSettings.name + +There is no `getApp()` operation for `FirebaseServerApp`, so the name is not relevant for applications. However, it may be used internally, and is declared here so that `FirebaseServerApp` conforms to the `FirebaseApp` interface. + +Signature: + +```typescript +name?: undefined; +``` + +## FirebaseServerAppSettings.releaseOnDeref + +An optional object. If provided, the Firebase SDK uses a `FinalizationRegistry` object to monitor the garbage collection status of the provided object. The Firebase SDK releases its reference on the `FirebaseServerApp` instance when the provided `releaseOnDeref` object is garbage collected. + +You can use this field to reduce memory management overhead for your application. If provided, an app running in a SSR pass does not need to perform `FirebaseServerApp` cleanup, so long as the reference object is deleted (by falling out of SSR scope, for instance.) + +If an object is not provided then the application must clean up the `FirebaseServerApp` instance by invoking `deleteApp`. + +If the application provides an object in this parameter, but the application is executed in a JavaScript engine that predates the support of `FinalizationRegistry` (introduced in node v14.6.0, for instance), then an error is thrown at `FirebaseServerApp` initialization. + +Signature: + +```typescript +releaseOnDeref?: object; +``` diff --git a/docs-devsite/app.md b/docs-devsite/app.md index de0611cac1a..9c3b322aaaf 100644 --- a/docs-devsite/app.md +++ b/docs-devsite/app.md @@ -34,6 +34,7 @@ This package coordinates the communication between the different Firebase compon | function(options, ...) | | [initializeApp(options, name)](./app.md#initializeapp_cb2f5e1) | Creates and initializes a [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) instance.See [Add Firebase to your app](https://firebase.google.com/docs/web/setup#add_firebase_to_your_app) and [Initialize multiple projects](https://firebase.google.com/docs/web/setup#multiple-projects) for detailed documentation. | | [initializeApp(options, config)](./app.md#initializeapp_079e917) | Creates and initializes a FirebaseApp instance. | +| [initializeServerApp(options, config)](./app.md#initializeserverapp_30ab697) | Creates and initializes a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) instance.The FirebaseServerApp is similar to FirebaseApp, but is intended for execution in server side rendering environments only. Initialization will fail if invoked from a browser environment.See [Add Firebase to your app](https://firebase.google.com/docs/web/setup#add_firebase_to_your_app) and [Initialize multiple projects](https://firebase.google.com/docs/web/setup#multiple-projects) for detailed documentation. | ## Interfaces @@ -42,6 +43,8 @@ This package coordinates the communication between the different Firebase compon | [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) | A [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) holds the initialization information for a collection of services.Do not call this constructor directly. Instead, use [initializeApp()](./app.md#initializeapp_cb2f5e1) to create an app. | | [FirebaseAppSettings](./app.firebaseappsettings.md#firebaseappsettings_interface) | Configuration options given to [initializeApp()](./app.md#initializeapp_cb2f5e1) | | [FirebaseOptions](./app.firebaseoptions.md#firebaseoptions_interface) | Firebase configuration object. Contains a set of parameters required by services in order to successfully communicate with Firebase server APIs and to associate client data with your Firebase project and Firebase application. Typically this object is populated by the Firebase console at project setup. See also: [Learn about the Firebase config object](https://firebase.google.com/docs/web/setup#config-object). | +| [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) | A [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) holds the initialization information for a collection of services running in server environments.Do not call this constructor directly. Instead, use [initializeServerApp()](./app.md#initializeserverapp_30ab697) to create an app. | +| [FirebaseServerAppSettings](./app.firebaseserverappsettings.md#firebaseserverappsettings_interface) | Configuration options given to [initializeServerApp()](./app.md#initializeserverapp_30ab697) | ## Variables @@ -309,6 +312,54 @@ export declare function initializeApp(options: FirebaseOptions, config?: Firebas [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) +### initializeServerApp(options, config) {:#initializeserverapp_30ab697} + +Creates and initializes a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) instance. + +The `FirebaseServerApp` is similar to `FirebaseApp`, but is intended for execution in server side rendering environments only. Initialization will fail if invoked from a browser environment. + +See [Add Firebase to your app](https://firebase.google.com/docs/web/setup#add_firebase_to_your_app) and [Initialize multiple projects](https://firebase.google.com/docs/web/setup#multiple-projects) for detailed documentation. + +Signature: + +```typescript +export declare function initializeServerApp(options: FirebaseOptions | FirebaseApp, config: FirebaseServerAppSettings): FirebaseServerApp; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | [FirebaseOptions](./app.firebaseoptions.md#firebaseoptions_interface) \| [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) | Firebase.AppOptions to configure the app's services, or a a FirebaseApp instance which contains the AppOptions within. | +| config | [FirebaseServerAppSettings](./app.firebaseserverappsettings.md#firebaseserverappsettings_interface) | FirebaseServerApp configuration. | + +Returns: + +[FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface) + +The initialized `FirebaseServerApp`. + +### Example + + +```javascript + +// Initialize an instance of `FirebaseServerApp`. +// Retrieve your own options values by adding a web app on +// https://console.firebase.google.com +initializeServerApp({ + apiKey: "AIza....", // Auth / General Use + authDomain: "YOUR_APP.firebaseapp.com", // Auth with popup/redirect + databaseURL: "https://YOUR_APP.firebaseio.com", // Realtime Database + storageBucket: "YOUR_APP.appspot.com", // Storage + messagingSenderId: "123456789" // Cloud Messaging + }, + { + authIdToken: "Your Auth ID Token" + }); + +``` + ## SDK\_VERSION The current SDK version. diff --git a/docs-devsite/auth.auth.md b/docs-devsite/auth.auth.md index b089a9b09bd..cbbc7a9ceb0 100644 --- a/docs-devsite/auth.auth.md +++ b/docs-devsite/auth.auth.md @@ -265,6 +265,8 @@ auth.setPersistence(browserSessionPersistence); Signs out the current user. This does not automatically revoke the user's ID token. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 4449c8dc1a4..10f153f53fe 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -381,6 +381,8 @@ On successful creation of the user account, this user will also be signed in to User account creation can fail if the account already exists or the password is invalid. +This method is not supported on [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Note: The email address acts as a unique identifier for the user and enables an email-based password reset. This function will create a new user account and set the initial user password. Signature: @@ -451,7 +453,7 @@ Returns a [UserCredential](./auth.usercredential.md#usercredential_interface) fr If sign-in succeeded, returns the signed in user. If sign-in was unsuccessful, fails with an error. If no redirect operation was called, returns `null`. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -728,7 +730,7 @@ Changes the type of persistence on the [Auth](./auth.auth.md#auth_interface) ins This makes it easy for a user signing in to specify whether their session should be remembered or not. It also makes it easier to never persist the `Auth` state for applications that are shared by other users or have sensitive data. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -763,6 +765,8 @@ Asynchronously signs in as an anonymous user. If there is already an anonymous user signed in, that user will be returned; otherwise, a new anonymous user identity will be created and returned. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -785,6 +789,8 @@ Asynchronously signs in with the given credentials. An [AuthProvider](./auth.authprovider.md#authprovider_interface) can be used to generate the credential. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -810,6 +816,8 @@ Custom tokens are used to integrate Firebase Auth with existing auth systems, an Fails with an error if the token is invalid, expired, or not accepted by the Firebase Auth service. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -833,6 +841,8 @@ Asynchronously signs in using an email and password. Fails with an error if the email address and password do not match. When \[Email Enumeration Protection\](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled, this method fails with "auth/invalid-credential" in case of an invalid email/password. +This method is not supported on [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Note: The user's password is NOT the password used to access the user's email account. The email address serves as a unique identifier for the user, and the password is used to access the user's account in your Firebase project. See also: [createUserWithEmailAndPassword()](./auth.md#createuserwithemailandpassword_21ad33b). Signature: @@ -861,6 +871,8 @@ If no link is passed, the link is inferred from the current URL. Fails with an error if the email address is invalid or OTP in email link expires. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Note: Confirm the link is a sign-in email link before calling this method firebase.auth.Auth.isSignInWithEmailLink. Signature: @@ -913,7 +925,7 @@ This method sends a code via SMS to the given phone number, and returns a [Confi For abuse prevention, this method also requires a [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface). This SDK includes a reCAPTCHA-based implementation, [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class). This function can work on other platforms that do not support the [RecaptchaVerifier](./auth.recaptchaverifier.md#recaptchaverifier_class) (like React Native), but you need to use a third-party [ApplicationVerifier](./auth.applicationverifier.md#applicationverifier_interface) implementation. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -951,7 +963,7 @@ Authenticates a Firebase client using a popup-based OAuth authentication flow. If succeeds, returns the signed in user along with the provider's credential. If sign in was unsuccessful, returns an error object containing additional information about the error. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -993,7 +1005,7 @@ Authenticates a Firebase client using a full-page redirect flow. To handle the results and errors for this operation, refer to [getRedirectResult()](./auth.md#getredirectresult_c35dc1f). Follow the [best practices](https://firebase.google.com/docs/auth/web/redirect-best-practices) when using [signInWithRedirect()](./auth.md#signinwithredirect_770f816). -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -1045,6 +1057,8 @@ const operationType = result.operationType; Signs out the current user. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -1071,6 +1085,8 @@ This will trigger [onAuthStateChanged()](./auth.md#onauthstatechanged_b0d07ab) a The operation fails with an error if the user to be updated belongs to a different Firebase project. +This method is not supported by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -1347,7 +1363,7 @@ Links the [OAuthProvider](./auth.oauthprovider.md#oauthprovider_class) to the us To handle the results and errors for this operation, refer to [getRedirectResult()](./auth.md#getredirectresult_c35dc1f). Follow the [best practices](https://firebase.google.com/docs/auth/web/redirect-best-practices) when using [linkWithRedirect()](./auth.md#linkwithredirect_41c0b31). -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -1411,6 +1427,8 @@ Re-authenticates a user using a fresh credential. Use before operations such as [updatePassword()](./auth.md#updatepassword_6df673e) that require tokens from recent sign-in attempts. This method can be used to recover from a `CREDENTIAL_TOO_OLD_LOGIN_AGAIN` error or a `TOKEN_EXPIRED` error. +This method is not supported on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript @@ -1434,7 +1452,7 @@ Re-authenticates a user using a fresh phone credential. Use before operations such as [updatePassword()](./auth.md#updatepassword_6df673e) that require tokens from recent sign-in attempts. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -1460,7 +1478,7 @@ Reauthenticates the current user with the specified [OAuthProvider](./auth.oauth If the reauthentication is successful, the returned result will contain the user and the provider's credential. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -1498,7 +1516,7 @@ Reauthenticates the current user with the specified [OAuthProvider](./auth.oauth To handle the results and errors for this operation, refer to [getRedirectResult()](./auth.md#getredirectresult_c35dc1f). Follow the [best practices](https://firebase.google.com/docs/auth/web/redirect-best-practices) when using [reauthenticateWithRedirect()](./auth.md#reauthenticatewithredirect_41c0b31). -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or with [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: @@ -1630,6 +1648,8 @@ Updates the user's email address. An email will be sent to the original email address (if it was set) that allows to revoke the email address change, in order to protect them from account hijacking. +This method is not supported on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Important: this is a security sensitive operation that requires the user to have recently signed in. If this requirement isn't met, ask the user to authenticate again and then call [reauthenticateWithCredential()](./auth.md#reauthenticatewithcredential_60f8043). Signature: @@ -1676,7 +1696,7 @@ Promise<void> Updates the user's phone number. -This method does not work in a Node.js environment. +This method does not work in a Node.js environment or on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). Signature: diff --git a/docs-devsite/auth.user.md b/docs-devsite/auth.user.md index a774c914f34..f28c7eeb3bc 100644 --- a/docs-devsite/auth.user.md +++ b/docs-devsite/auth.user.md @@ -121,6 +121,8 @@ Deletes and signs out the user. Important: this is a security-sensitive operation that requires the user to have recently signed in. If this requirement isn't met, ask the user to authenticate again and then call one of the reauthentication methods like [reauthenticateWithCredential()](./auth.md#reauthenticatewithcredential_60f8043). +This method is not supported on any [User](./auth.user.md#user_interface) signed in by [Auth](./auth.auth.md#auth_interface) instances created with a [FirebaseServerApp](./app.firebaseserverapp.md#firebaseserverapp_interface). + Signature: ```typescript diff --git a/packages/app/src/api.test.ts b/packages/app/src/api.test.ts index ab361df6ff1..f5577d092e2 100644 --- a/packages/app/src/api.test.ts +++ b/packages/app/src/api.test.ts @@ -20,6 +20,7 @@ import { stub, spy } from 'sinon'; import '../test/setup'; import { initializeApp, + initializeServerApp, getApps, deleteApp, getApp, @@ -28,7 +29,7 @@ import { onLog } from './api'; import { DEFAULT_ENTRY_NAME } from './constants'; -import { _FirebaseService } from './public-types'; +import { FirebaseServerAppSettings, _FirebaseService } from './public-types'; import { _clearComponents, _components, @@ -39,6 +40,8 @@ import { createTestComponent } from '../test/util'; import { Component, ComponentType } from '@firebase/component'; import { Logger } from '@firebase/logger'; import { FirebaseAppImpl } from './firebaseApp'; +import { FirebaseServerAppImpl } from './firebaseServerApp'; +import { isBrowser } from '@firebase/util'; declare module '@firebase/component' { interface NameServiceMapping { @@ -54,7 +57,7 @@ describe('API tests', () => { }); describe('initializeApp', () => { - it('creats DEFAULT App', () => { + it('creates DEFAULT App', () => { const app = initializeApp({}); expect(app.name).to.equal(DEFAULT_ENTRY_NAME); }); @@ -91,7 +94,7 @@ describe('API tests', () => { ).to.equal(app); }); - it('throws when creating duplicate DEDAULT Apps with different options', () => { + it('throws when creating duplicate DEFAULT Apps with different options', () => { initializeApp({ apiKey: 'test1' }); @@ -120,7 +123,7 @@ describe('API tests', () => { ).throws(/'MyApp'.*exists/i); }); - it('throws when creating duplicate DEDAULT Apps with different config values', () => { + it('throws when creating duplicate DEFAULT Apps with different config values', () => { initializeApp( { apiKey: 'test1' @@ -161,12 +164,6 @@ describe('API tests', () => { expect(app.name).to.equal(appName); }); - it('takes an object as the second parameter to create named App', () => { - const appName = 'MyApp'; - const app = initializeApp({}, { name: appName }); - expect(app.name).to.equal(appName); - }); - it('sets automaticDataCollectionEnabled', () => { const app = initializeApp({}, { automaticDataCollectionEnabled: true }); expect(app.automaticDataCollectionEnabled).to.be.true; @@ -187,6 +184,213 @@ describe('API tests', () => { }); }); + describe('initializeServerApp', () => { + it('creates FirebaseServerApp fails in browsers.', () => { + if (isBrowser()) { + const options = { + apiKey: 'APIKEY' + }; + const serverAppSettings: FirebaseServerAppSettings = {}; + expect(() => initializeServerApp(options, serverAppSettings)).throws( + /FirebaseServerApp is not for use in browser environments./ + ); + } + }); + + it('creates FirebaseServerApp with options', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = {}; + + const app = initializeServerApp(options, serverAppSettings); + expect(app).to.not.equal(null); + expect(app.automaticDataCollectionEnabled).to.be.false; + await deleteApp(app); + expect((app as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + + it('creates FirebaseServerApp with automaticDataCollectionEnabled', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: true + }; + + const app = initializeServerApp(options, serverAppSettings); + expect(app).to.not.equal(null); + expect(app.automaticDataCollectionEnabled).to.be.true; + await deleteApp(app); + expect((app as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + + it('creates FirebaseServerApp with releaseOnDeref', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { apiKey: 'APIKEY' }; + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const app = initializeServerApp(options, serverAppSettings); + expect(app).to.not.equal(null); + expect(app.automaticDataCollectionEnabled).to.be.false; + await deleteApp(app); + expect((app as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + + it('creates FirebaseServerApp with FirebaseApp', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { + apiKey: 'test1' + }; + const standardApp = initializeApp(options); + expect(standardApp.name).to.equal(DEFAULT_ENTRY_NAME); + expect(standardApp.options.apiKey).to.equal('test1'); + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false + }; + + const app = initializeServerApp(standardApp, serverAppSettings); + expect(app).to.not.equal(null); + expect(app.options.apiKey).to.equal('test1'); + await deleteApp(app); + expect((app as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + }); + + it('create similar FirebaseServerApps does not return the same object', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { apiKey: 'APIKEY' }; + const serverAppSettingsOne: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: true + }; + + const serverAppSettingsTwo: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false + }; + + const appOne = initializeServerApp(options, serverAppSettingsOne); + expect(appOne).to.not.equal(null); + expect(appOne.automaticDataCollectionEnabled).to.be.true; + const appTwo = initializeServerApp(options, serverAppSettingsTwo); + expect(appTwo).to.not.equal(null); + expect(appTwo.automaticDataCollectionEnabled).to.be.false; + expect(appTwo).to.not.equal(appOne); + await deleteApp(appOne); + await deleteApp(appTwo); + expect((appOne as FirebaseServerAppImpl).isDeleted).to.be.true; + expect((appTwo as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + + it('create FirebaseServerApps with varying deleteOnDeref, and they still return same object ', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { apiKey: 'APIKEY' }; + const serverAppSettingsOne: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false + }; + + const serverAppSettingsTwo: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const appOne = initializeServerApp(options, serverAppSettingsOne); + expect(appOne).to.not.equal(null); + expect(appOne.automaticDataCollectionEnabled).to.be.false; + const appTwo = initializeServerApp(options, serverAppSettingsTwo); + expect(appTwo).to.not.equal(null); + expect(appTwo.automaticDataCollectionEnabled).to.be.false; + expect(appTwo).to.equal(appOne); + await deleteApp(appOne); + await deleteApp(appTwo); + }); + + it('create duplicate FirebaseServerApps returns the same object', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { apiKey: 'APIKEY' }; + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const appOne = initializeServerApp(options, serverAppSettings); + expect(appOne).to.not.equal(null); + expect(appOne.automaticDataCollectionEnabled).to.be.false; + const appTwo = initializeServerApp(options, serverAppSettings); + expect(appTwo).to.not.equal(null); + expect(appTwo).to.equal(appOne); + await deleteApp(appOne); + await deleteApp(appTwo); + }); + + it('deleting FirebaseServerApps is ref counted', async () => { + if (isBrowser()) { + // FirebaseServerApp isn't supported for execution in browser environments. + return; + } + + const options = { apiKey: 'APIKEY' }; + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const appOne = initializeServerApp(options, serverAppSettings); + expect((appOne as FirebaseServerAppImpl).refCount).to.equal(1); + + const appTwo = initializeServerApp(options, serverAppSettings); + expect(appTwo).to.equal(appOne); + expect((appOne as FirebaseServerAppImpl).refCount).to.equal(2); + expect((appTwo as FirebaseServerAppImpl).refCount).to.equal(2); + + await deleteApp(appOne); + expect((appOne as FirebaseServerAppImpl).refCount).to.equal(1); + expect((appTwo as FirebaseServerAppImpl).refCount).to.equal(1); + expect((appOne as FirebaseServerAppImpl).isDeleted).to.be.false; + expect((appTwo as FirebaseServerAppImpl).isDeleted).to.be.false; + + await deleteApp(appTwo); + expect((appOne as FirebaseServerAppImpl).refCount).to.equal(0); + expect((appTwo as FirebaseServerAppImpl).refCount).to.equal(0); + expect((appOne as FirebaseServerAppImpl).isDeleted).to.be.true; + expect((appTwo as FirebaseServerAppImpl).isDeleted).to.be.true; + }); + describe('getApp', () => { it('retrieves DEFAULT App', () => { const app = initializeApp({}); diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index d43c4ac65c2..5928fd737a8 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -17,8 +17,10 @@ import { FirebaseApp, + FirebaseServerApp, FirebaseOptions, - FirebaseAppSettings + FirebaseAppSettings, + FirebaseServerAppSettings } from './public-types'; import { DEFAULT_ENTRY_NAME, PLATFORM_LOG_STRING } from './constants'; import { ERROR_FACTORY, AppError } from './errors'; @@ -30,7 +32,14 @@ import { } from '@firebase/component'; import { version } from '../../firebase/package.json'; import { FirebaseAppImpl } from './firebaseApp'; -import { _apps, _components, _registerComponent } from './internal'; +import { FirebaseServerAppImpl } from './firebaseServerApp'; +import { + _apps, + _components, + _isFirebaseApp, + _registerComponent, + _serverApps +} from './internal'; import { logger } from './logger'; import { LogLevelString, @@ -39,7 +48,7 @@ import { LogOptions, setUserLogHandler } from '@firebase/logger'; -import { deepEqual, getDefaultAppConfig } from '@firebase/util'; +import { deepEqual, getDefaultAppConfig, isBrowser } from '@firebase/util'; export { FirebaseError } from '@firebase/util'; @@ -171,6 +180,126 @@ export function initializeApp( return newApp; } +/** + * Creates and initializes a {@link @firebase/app#FirebaseServerApp} instance. + * + * The `FirebaseServerApp` is similar to `FirebaseApp`, but is intended for execution in + * server side rendering environments only. Initialization will fail if invoked from a + * browser environment. + * + * See + * {@link + * https://firebase.google.com/docs/web/setup#add_firebase_to_your_app + * | Add Firebase to your app} and + * {@link + * https://firebase.google.com/docs/web/setup#multiple-projects + * | Initialize multiple projects} for detailed documentation. + * + * @example + * ```javascript + * + * // Initialize an instance of `FirebaseServerApp`. + * // Retrieve your own options values by adding a web app on + * // https://console.firebase.google.com + * initializeServerApp({ + * apiKey: "AIza....", // Auth / General Use + * authDomain: "YOUR_APP.firebaseapp.com", // Auth with popup/redirect + * databaseURL: "https://YOUR_APP.firebaseio.com", // Realtime Database + * storageBucket: "YOUR_APP.appspot.com", // Storage + * messagingSenderId: "123456789" // Cloud Messaging + * }, + * { + * authIdToken: "Your Auth ID Token" + * }); + * ``` + * + * @param options - `Firebase.AppOptions` to configure the app's services, or a + * a `FirebaseApp` instance which contains the `AppOptions` within. + * @param config - `FirebaseServerApp` configuration. + * + * @returns The initialized `FirebaseServerApp`. + * + * @public + */ +export function initializeServerApp( + options: FirebaseOptions | FirebaseApp, + config: FirebaseServerAppSettings +): FirebaseServerApp; + +export function initializeServerApp( + _options: FirebaseOptions | FirebaseApp, + _serverAppConfig: FirebaseServerAppSettings +): FirebaseServerApp { + if (isBrowser()) { + // FirebaseServerApp isn't designed to be run in browsers. + throw ERROR_FACTORY.create(AppError.INVALID_SERVER_APP_ENVIRONMENT); + } + + if (_serverAppConfig.automaticDataCollectionEnabled === undefined) { + _serverAppConfig.automaticDataCollectionEnabled = false; + } + + let appOptions: FirebaseOptions; + if (_isFirebaseApp(_options)) { + appOptions = _options.options; + } else { + appOptions = _options; + } + + // Build an app name based on a hash of the configuration options. + const nameObj = { + ..._serverAppConfig, + ...appOptions + }; + + // However, Do not mangle the name based on releaseOnDeref, since it will vary between the + // construction of FirebaseServerApp instances. For example, if the object is the request headers. + if (nameObj.releaseOnDeref !== undefined) { + delete nameObj.releaseOnDeref; + } + + const hashCode = (s: string): number => { + return [...s].reduce( + (hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, + 0 + ); + }; + + if (_serverAppConfig.releaseOnDeref !== undefined) { + if (typeof FinalizationRegistry === 'undefined') { + throw ERROR_FACTORY.create( + AppError.FINALIZATION_REGISTRY_NOT_SUPPORTED, + {} + ); + } + } + + const nameString = '' + hashCode(JSON.stringify(nameObj)); + const existingApp = _serverApps.get(nameString) as FirebaseServerApp; + if (existingApp) { + (existingApp as FirebaseServerAppImpl).incRefCount( + _serverAppConfig.releaseOnDeref + ); + return existingApp; + } + + const container = new ComponentContainer(nameString); + for (const component of _components.values()) { + container.addComponent(component); + } + + const newApp = new FirebaseServerAppImpl( + appOptions, + _serverAppConfig, + nameString, + container + ); + + _serverApps.set(nameString, newApp); + + return newApp; +} + /** * Retrieves a {@link @firebase/app#FirebaseApp} instance. * @@ -238,9 +367,20 @@ export function getApps(): FirebaseApp[] { * @public */ export async function deleteApp(app: FirebaseApp): Promise { + let cleanupProviders = false; const name = app.name; if (_apps.has(name)) { + cleanupProviders = true; _apps.delete(name); + } else if (_serverApps.has(name)) { + const firebaseServerApp = app as FirebaseServerAppImpl; + if (firebaseServerApp.decRefCount() <= 0) { + _serverApps.delete(name); + cleanupProviders = true; + } + } + + if (cleanupProviders) { await Promise.all( (app as FirebaseAppImpl).container .getProviders() diff --git a/packages/app/src/errors.ts b/packages/app/src/errors.ts index 25582398663..0149ef3dcb1 100644 --- a/packages/app/src/errors.ts +++ b/packages/app/src/errors.ts @@ -22,23 +22,27 @@ export const enum AppError { BAD_APP_NAME = 'bad-app-name', DUPLICATE_APP = 'duplicate-app', APP_DELETED = 'app-deleted', + SERVER_APP_DELETED = 'server-app-deleted', NO_OPTIONS = 'no-options', INVALID_APP_ARGUMENT = 'invalid-app-argument', INVALID_LOG_ARGUMENT = 'invalid-log-argument', IDB_OPEN = 'idb-open', IDB_GET = 'idb-get', IDB_WRITE = 'idb-set', - IDB_DELETE = 'idb-delete' + IDB_DELETE = 'idb-delete', + FINALIZATION_REGISTRY_NOT_SUPPORTED = 'finalization-registry-not-supported', + INVALID_SERVER_APP_ENVIRONMENT = 'invalid-server-app-environment' } const ERRORS: ErrorMap = { [AppError.NO_APP]: "No Firebase App '{$appName}' has been created - " + 'call initializeApp() first', - [AppError.BAD_APP_NAME]: "Illegal App name: '{$appName}", + [AppError.BAD_APP_NAME]: "Illegal App name: '{$appName}'", [AppError.DUPLICATE_APP]: "Firebase App named '{$appName}' already exists with different options or config", [AppError.APP_DELETED]: "Firebase App named '{$appName}' already deleted", + [AppError.SERVER_APP_DELETED]: 'Firebase Server App has been deleted', [AppError.NO_OPTIONS]: 'Need to provide options, when not being deployed to hosting via source.', [AppError.INVALID_APP_ARGUMENT]: @@ -53,7 +57,11 @@ const ERRORS: ErrorMap = { [AppError.IDB_WRITE]: 'Error thrown when writing to IndexedDB. Original error: {$originalErrorMessage}.', [AppError.IDB_DELETE]: - 'Error thrown when deleting from IndexedDB. Original error: {$originalErrorMessage}.' + 'Error thrown when deleting from IndexedDB. Original error: {$originalErrorMessage}.', + [AppError.FINALIZATION_REGISTRY_NOT_SUPPORTED]: + 'FirebaseServerApp deleteOnDeref field defined but the JS runtime does not support FinalizationRegistry.', + [AppError.INVALID_SERVER_APP_ENVIRONMENT]: + 'FirebaseServerApp is not for use in browser environments.' }; interface ErrorParams { @@ -66,6 +74,7 @@ interface ErrorParams { [AppError.IDB_GET]: { originalErrorMessage?: string }; [AppError.IDB_WRITE]: { originalErrorMessage?: string }; [AppError.IDB_DELETE]: { originalErrorMessage?: string }; + [AppError.FINALIZATION_REGISTRY_NOT_SUPPORTED]: { appName?: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/app/src/firebaseApp.ts b/packages/app/src/firebaseApp.ts index 60d3b29a3ea..4f36e818f96 100644 --- a/packages/app/src/firebaseApp.ts +++ b/packages/app/src/firebaseApp.ts @@ -28,8 +28,8 @@ import { import { ERROR_FACTORY, AppError } from './errors'; export class FirebaseAppImpl implements FirebaseApp { - private readonly _options: FirebaseOptions; - private readonly _name: string; + protected readonly _options: FirebaseOptions; + protected readonly _name: string; /** * Original config values passed in as a constructor parameter. * It is only used to compare with another config object to support idempotent initializeApp(). @@ -38,7 +38,7 @@ export class FirebaseAppImpl implements FirebaseApp { */ private readonly _config: Required; private _automaticDataCollectionEnabled: boolean; - private _isDeleted = false; + protected _isDeleted = false; private readonly _container: ComponentContainer; constructor( @@ -98,7 +98,7 @@ export class FirebaseAppImpl implements FirebaseApp { * This function will throw an Error if the App has already been deleted - * use before performing API actions on the App. */ - private checkDestroyed(): void { + protected checkDestroyed(): void { if (this.isDeleted) { throw ERROR_FACTORY.create(AppError.APP_DELETED, { appName: this._name }); } diff --git a/packages/app/src/firebaseServerApp.test.ts b/packages/app/src/firebaseServerApp.test.ts new file mode 100644 index 00000000000..408b5f09922 --- /dev/null +++ b/packages/app/src/firebaseServerApp.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2023 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. + */ + +import { expect } from 'chai'; +import '../test/setup'; +import { ComponentContainer } from '@firebase/component'; +import { FirebaseServerAppImpl } from './firebaseServerApp'; +import { FirebaseServerAppSettings } from './public-types'; + +describe('FirebaseServerApp', () => { + it('has various accessors', () => { + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const firebaseServerAppImpl = new FirebaseServerAppImpl( + options, + serverAppSettings, + 'testName', + new ComponentContainer('test') + ); + + expect(firebaseServerAppImpl.automaticDataCollectionEnabled).to.be.false; + expect(firebaseServerAppImpl.options).to.deep.equal(options); + }); + + it('deep-copies options', () => { + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const firebaseServerAppImpl = new FirebaseServerAppImpl( + options, + serverAppSettings, + 'testName', + new ComponentContainer('test') + ); + + expect(firebaseServerAppImpl.options).to.not.equal(options); + expect(firebaseServerAppImpl.options).to.deep.equal(options); + }); + + it('sets automaticDataCollectionEnabled', () => { + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const firebaseServerAppImpl = new FirebaseServerAppImpl( + options, + serverAppSettings, + 'testName', + new ComponentContainer('test') + ); + + expect(firebaseServerAppImpl.automaticDataCollectionEnabled).to.be.false; + firebaseServerAppImpl.automaticDataCollectionEnabled = true; + expect(firebaseServerAppImpl.automaticDataCollectionEnabled).to.be.true; + }); + + it('throws accessing any property after being deleted', () => { + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const app = new FirebaseServerAppImpl( + options, + serverAppSettings, + 'testName', + new ComponentContainer('test') + ); + + expect(() => app.options).to.not.throw(); + (app as unknown as FirebaseServerAppImpl).isDeleted = true; + + expect(() => app.options).throws('Firebase Server App has been deleted'); + + expect(() => app.automaticDataCollectionEnabled).throws( + 'Firebase Server App has been deleted' + ); + }); + + it('throws accessing any method after being deleted', () => { + const options = { + apiKey: 'APIKEY' + }; + + const serverAppSettings: FirebaseServerAppSettings = { + automaticDataCollectionEnabled: false, + releaseOnDeref: options + }; + + const app = new FirebaseServerAppImpl( + options, + serverAppSettings, + 'testName', + new ComponentContainer('test') + ); + + expect(() => app.settings).to.not.throw(); + (app as unknown as FirebaseServerAppImpl).isDeleted = true; + + expect(() => app.settings).throws('Firebase Server App has been deleted'); + }); +}); diff --git a/packages/app/src/firebaseServerApp.ts b/packages/app/src/firebaseServerApp.ts new file mode 100644 index 00000000000..b40471c903f --- /dev/null +++ b/packages/app/src/firebaseServerApp.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2023 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. + */ + +import { + FirebaseAppSettings, + FirebaseServerApp, + FirebaseServerAppSettings, + FirebaseOptions +} from './public-types'; +import { deleteApp, registerVersion } from './api'; +import { ComponentContainer } from '@firebase/component'; +import { FirebaseAppImpl } from './firebaseApp'; +import { ERROR_FACTORY, AppError } from './errors'; +import { name as packageName, version } from '../package.json'; + +export class FirebaseServerAppImpl + extends FirebaseAppImpl + implements FirebaseServerApp +{ + private readonly _serverConfig: FirebaseServerAppSettings; + private _finalizationRegistry: FinalizationRegistry; + private _refCount: number; + + constructor( + options: FirebaseOptions | FirebaseAppImpl, + serverConfig: FirebaseServerAppSettings, + name: string, + container: ComponentContainer + ) { + // Build configuration parameters for the FirebaseAppImpl base class. + const automaticDataCollectionEnabled = + serverConfig.automaticDataCollectionEnabled !== undefined + ? serverConfig.automaticDataCollectionEnabled + : false; + + // Create the FirebaseAppSettings object for the FirebaseAppImp constructor. + const config: Required = { + name, + automaticDataCollectionEnabled + }; + + if ((options as FirebaseOptions).apiKey !== undefined) { + // Construct the parent FirebaseAppImp object. + super(options as FirebaseOptions, config, container); + } else { + const appImpl: FirebaseAppImpl = options as FirebaseAppImpl; + super(appImpl.options, config, container); + } + + // Now construct the data for the FirebaseServerAppImpl. + this._serverConfig = { + automaticDataCollectionEnabled, + ...serverConfig + }; + + this._finalizationRegistry = new FinalizationRegistry(() => { + this.automaticCleanup(); + }); + + this._refCount = 0; + this.incRefCount(this._serverConfig.releaseOnDeref); + + // Do not retain a hard reference to the dref object, otherwise the FinalizationRegisry + // will never trigger. + this._serverConfig.releaseOnDeref = undefined; + serverConfig.releaseOnDeref = undefined; + + registerVersion(packageName, version, 'serverapp'); + } + + get refCount(): number { + return this._refCount; + } + + // Increment the reference count of this server app. If an object is provided, register it + // with the finalization registry. + incRefCount(obj: object | undefined): void { + if (this.isDeleted) { + return; + } + this._refCount++; + if (obj !== undefined) { + this._finalizationRegistry.register(obj, this); + } + } + + // Decrement the reference count. + decRefCount(): number { + if (this.isDeleted) { + return 0; + } + return --this._refCount; + } + + // Invoked by the FinalizationRegistry callback to note that this app should go through its + // reference counts and delete itself if no reference count remain. The coordinating logic that + // handles this is in deleteApp(...). + private automaticCleanup(): void { + void deleteApp(this); + } + + get settings(): FirebaseServerAppSettings { + this.checkDestroyed(); + return this._serverConfig; + } + + /** + * This function will throw an Error if the App has already been deleted - + * use before performing API actions on the App. + */ + protected checkDestroyed(): void { + if (this.isDeleted) { + throw ERROR_FACTORY.create(AppError.SERVER_APP_DELETED); + } + } +} diff --git a/packages/app/src/internal.ts b/packages/app/src/internal.ts index 9026a36b26a..7e0c1545962 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -15,17 +15,27 @@ * limitations under the License. */ -import { FirebaseApp } from './public-types'; +import { + FirebaseApp, + FirebaseOptions, + FirebaseServerApp +} from './public-types'; import { Component, Provider, Name } from '@firebase/component'; import { logger } from './logger'; import { DEFAULT_ENTRY_NAME } from './constants'; import { FirebaseAppImpl } from './firebaseApp'; +import { FirebaseServerAppImpl } from './firebaseServerApp'; /** * @internal */ export const _apps = new Map(); +/** + * @internal + */ +export const _serverApps = new Map(); + /** * Registered components. * @@ -90,6 +100,10 @@ export function _registerComponent( _addComponent(app as FirebaseAppImpl, component); } + for (const serverApp of _serverApps.values()) { + _addComponent(serverApp as FirebaseServerAppImpl, component); + } + return true; } @@ -131,6 +145,34 @@ export function _removeServiceInstance( _getProvider(app, name).clearInstance(instanceIdentifier); } +/** + * + * @param obj - an object of type FirebaseApp or FirebaseOptions. + * + * @returns true if the provide object is of type FirebaseApp. + * + * @internal + */ +export function _isFirebaseApp( + obj: FirebaseApp | FirebaseOptions +): obj is FirebaseApp { + return (obj as FirebaseApp).options !== undefined; +} + +/** + * + * @param obj - an object of type FirebaseApp. + * + * @returns true if the provided object is of type FirebaseServerAppImpl. + * + * @internal + */ +export function _isFirebaseServerApp( + obj: FirebaseApp | FirebaseServerApp +): obj is FirebaseServerApp { + return (obj as FirebaseServerApp).settings !== undefined; +} + /** * Test only * diff --git a/packages/app/src/public-types.ts b/packages/app/src/public-types.ts index 3ca76c47889..ad2b441933a 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -71,6 +71,37 @@ export interface FirebaseApp { automaticDataCollectionEnabled: boolean; } +/** + * A {@link @firebase/app#FirebaseServerApp} holds the initialization information + * for a collection of services running in server environments. + * + * Do not call this constructor directly. Instead, use + * {@link (initializeServerApp:1) | initializeServerApp()} to create + * an app. + * + * @public + */ +export interface FirebaseServerApp extends FirebaseApp { + /** + * There is no `getApp()` operation for `FirebaseServerApp`, so the name is not relevant for + * applications. However, it may be used internally, and is declared here so that + * `FirebaseServerApp` conforms to the `FirebaseApp` interface. + */ + name: string; + + /** + * The (read-only) configuration settings for this server app. These are the original + * parameters given in {@link (initializeServerApp:1) | initializeServerApp()}. + * + * @example + * ```javascript + * const app = initializeServerApp(settings); + * console.log(app.settings.authIdToken === options.authIdToken); // true + * ``` + */ + readonly settings: FirebaseServerAppSettings; +} + /** * @public * @@ -139,6 +170,60 @@ export interface FirebaseAppSettings { automaticDataCollectionEnabled?: boolean; } +/** + * @public + * + * Configuration options given to {@link (initializeServerApp:1) | initializeServerApp()} + */ +export interface FirebaseServerAppSettings extends FirebaseAppSettings { + /** + * An optional Auth ID token used to resume a signed in user session from a client + * runtime environment. + * + * Invoking `getAuth` with a `FirebaseServerApp` configured with a validated `authIdToken` + * causes an automatic attempt to sign in the user that the `authIdToken` represents. The token + * needs to have been recently minted for this operation to succeed. + * + * If the token fails local verification, or if the Auth service has failed to validate it when + * the Auth SDK is initialized, then a warning is logged to the console and the Auth SDK will not + * sign in a user on initalization. + * + * If a user is successfully signed in, then the Auth instance's `onAuthStateChanged` callback + * is invoked with the `User` object as per standard Auth flows. However, `User` objects + * created via an `authIdToken` do not have a refresh token. Attempted `refreshToken` + * operations fail. + */ + authIdToken?: string; + + /** + * An optional object. If provided, the Firebase SDK uses a `FinalizationRegistry` + * object to monitor the garbage collection status of the provided object. The + * Firebase SDK releases its reference on the `FirebaseServerApp` instance when the + * provided `releaseOnDeref` object is garbage collected. + * + * You can use this field to reduce memory management overhead for your application. + * If provided, an app running in a SSR pass does not need to perform + * `FirebaseServerApp` cleanup, so long as the reference object is deleted (by falling out of + * SSR scope, for instance.) + * + * If an object is not provided then the application must clean up the `FirebaseServerApp` + * instance by invoking `deleteApp`. + * + * If the application provides an object in this parameter, but the application is + * executed in a JavaScript engine that predates the support of `FinalizationRegistry` + * (introduced in node v14.6.0, for instance), then an error is thrown at `FirebaseServerApp` + * initialization. + */ + releaseOnDeref?: object; + + /** + * There is no `getApp()` operation for `FirebaseServerApp`, so the name is not relevant for + * applications. However, it may be used internally, and is declared here so that + * `FirebaseServerApp` conforms to the `FirebaseApp` interface. + */ + name?: undefined; +} + /** * @internal */ diff --git a/packages/auth/karma.conf.js b/packages/auth/karma.conf.js index 6845f0bd91d..198b079a15b 100644 --- a/packages/auth/karma.conf.js +++ b/packages/auth/karma.conf.js @@ -65,7 +65,8 @@ function getTestFiles(argv) { 'src/**/*.test.ts', 'test/helpers/**/*.test.ts', 'test/integration/flows/anonymous.test.ts', - 'test/integration/flows/email.test.ts' + 'test/integration/flows/email.test.ts', + 'test/integration/flows/firebaseserverapp.test.ts' ]; } } diff --git a/packages/auth/scripts/run_node_tests.ts b/packages/auth/scripts/run_node_tests.ts index 2bfc593d8fd..ce913612f64 100644 --- a/packages/auth/scripts/run_node_tests.ts +++ b/packages/auth/scripts/run_node_tests.ts @@ -48,7 +48,9 @@ let testConfig = [ ]; if (argv.integration) { - testConfig = ['test/integration/flows/{email,anonymous}.test.ts']; + testConfig = [ + 'test/integration/flows/{email,anonymous,firebaseserverapp}.test.ts' + ]; if (argv.local) { testConfig.push('test/integration/flows/*.local.test.ts'); } diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index cd75276e006..eb7e2a322c8 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -15,7 +15,11 @@ * limitations under the License. */ -import { _FirebaseService, FirebaseApp } from '@firebase/app'; +import { + _isFirebaseServerApp, + _FirebaseService, + FirebaseApp +} from '@firebase/app'; import { Provider } from '@firebase/component'; import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { @@ -58,7 +62,10 @@ import { PersistenceUserManager } from '../persistence/persistence_user_manager'; import { _reloadWithoutSaving } from '../user/reload'; -import { _assert } from '../util/assert'; +import { + _assert, + _serverAppCurrentUserOperationNotSupportedError +} from '../util/assert'; import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; @@ -74,6 +81,8 @@ import { _logWarn } from '../util/log'; import { _getPasswordPolicy } from '../../api/password_policy/get_password_policy'; import { PasswordPolicyInternal } from '../../model/password_policy'; import { PasswordPolicyImpl } from './password_policy_impl'; +import { getAccountInfo } from '../../api/account_management/account'; +import { UserImpl } from '../user/user_impl'; interface AsyncAction { (): Promise; @@ -168,6 +177,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } await this.initializeCurrentUser(popupRedirectResolver); + this.lastNotifiedUid = this.currentUser?.uid || null; if (this._deleted) { @@ -210,9 +220,47 @@ export class AuthImpl implements AuthInternal, _FirebaseService { await this._updateCurrentUser(user, /* skipBeforeStateCallbacks */ true); } + private async initializeCurrentUserFromIdToken( + idToken: string + ): Promise { + try { + const response = await getAccountInfo(this, { idToken }); + const user = await UserImpl._fromGetAccountInfoResponse( + this, + response, + idToken + ); + await this.directlySetCurrentUser(user); + } catch (err) { + console.warn( + 'FirebaseServerApp could not login user with provided authIdToken: ', + err + ); + await this.directlySetCurrentUser(null); + } + } + private async initializeCurrentUser( popupRedirectResolver?: PopupRedirectResolver ): Promise { + if (_isFirebaseServerApp(this.app)) { + const idToken = this.app.settings.authIdToken; + if (idToken) { + // Start the auth operation in the next tick to allow a moment for the customer's app to + // attach an emulator, if desired. + return new Promise(resolve => { + setTimeout(() => + this.initializeCurrentUserFromIdToken(idToken).then( + resolve, + resolve + ) + ); + }); + } else { + return this.directlySetCurrentUser(null); + } + } + // First check to see if we have a pending redirect event. const previouslyStoredUser = (await this.assertedPersistence.getCurrentUser()) as UserInternal | null; @@ -346,6 +394,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } async updateCurrentUser(userExtern: User | null): Promise { + if (_isFirebaseServerApp(this.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(this) + ); + } // The public updateCurrentUser method needs to make a copy of the user, // and also check that the project matches const user = userExtern @@ -387,6 +440,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } async signOut(): Promise { + if (_isFirebaseServerApp(this.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(this) + ); + } // Run first, to block _setRedirectUser() if any callbacks fail. await this.beforeStateQueue.runMiddleware(null); // Clear the redirect user when signOut is called @@ -400,6 +458,11 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } setPersistence(persistence: Persistence): Promise { + if (_isFirebaseServerApp(this.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(this) + ); + } return this.queue(async () => { await this.assertedPersistence.setPersistence(_getInstance(persistence)); }); diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index 506d7623f25..43b1adb4bb9 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -46,7 +46,8 @@ export { * remembered or not. It also makes it easier to never persist the `Auth` state for applications * that are shared by other users or have sensitive data. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -223,6 +224,9 @@ export function useDeviceLanguage(auth: Auth): void { * The operation fails with an error if the user to be updated belongs to a different Firebase * project. * + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * @param auth - The {@link Auth} instance. * @param user - The new {@link User}. * @@ -237,6 +241,10 @@ export function updateCurrentUser( /** * Signs out the current user. * + * @remarks + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * @param auth - The {@link Auth} instance. * * @public diff --git a/packages/auth/src/core/strategies/anonymous.ts b/packages/auth/src/core/strategies/anonymous.ts index cd2a40f8240..ee537fa8eb9 100644 --- a/packages/auth/src/core/strategies/anonymous.ts +++ b/packages/auth/src/core/strategies/anonymous.ts @@ -21,6 +21,8 @@ import { UserInternal } from '../../model/user'; import { UserCredentialImpl } from '../user/user_credential_impl'; import { _castAuth } from '../auth/auth_impl'; import { OperationType } from '../../model/enums'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; /** * Asynchronously signs in as an anonymous user. @@ -29,11 +31,19 @@ import { OperationType } from '../../model/enums'; * If there is already an anonymous user signed in, that user will be returned; otherwise, a * new anonymous user identity will be created and returned. * + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * @param auth - The {@link Auth} instance. * * @public */ export async function signInAnonymously(auth: Auth): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); await authInternal._initializationPromise; if (authInternal.currentUser?.isAnonymous) { diff --git a/packages/auth/src/core/strategies/credential.ts b/packages/auth/src/core/strategies/credential.ts index 00aa919f047..476cf888095 100644 --- a/packages/auth/src/core/strategies/credential.ts +++ b/packages/auth/src/core/strategies/credential.ts @@ -27,12 +27,19 @@ import { UserCredentialImpl } from '../user/user_credential_impl'; import { _castAuth } from '../auth/auth_impl'; import { getModularInstance } from '@firebase/util'; import { OperationType } from '../../model/enums'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; export async function _signInWithCredential( auth: AuthInternal, credential: AuthCredential, bypassAuthState = false ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const operationType = OperationType.SIGN_IN; const response = await _processCredentialSavingMfaContextIfNecessary( auth, @@ -57,6 +64,9 @@ export async function _signInWithCredential( * @remarks * An {@link AuthProvider} can be used to generate the credential. * + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * @param auth - The {@link Auth} instance. * @param credential - The auth credential. * @@ -99,6 +109,9 @@ export async function linkWithCredential( * attempts. This method can be used to recover from a `CREDENTIAL_TOO_OLD_LOGIN_AGAIN` error * or a `TOKEN_EXPIRED` error. * + * This method is not supported on any {@link User} signed in by {@link Auth} instances + * created with a {@link @firebase/app#FirebaseServerApp}. + * * @param user - The user. * @param credential - The auth credential. * diff --git a/packages/auth/src/core/strategies/custom_token.ts b/packages/auth/src/core/strategies/custom_token.ts index 6d1e1b36fe0..83c66d5edd6 100644 --- a/packages/auth/src/core/strategies/custom_token.ts +++ b/packages/auth/src/core/strategies/custom_token.ts @@ -22,7 +22,8 @@ import { IdTokenResponse } from '../../model/id_token'; import { UserCredentialImpl } from '../user/user_credential_impl'; import { _castAuth } from '../auth/auth_impl'; import { OperationType } from '../../model/enums'; - +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; /** * Asynchronously signs in using a custom token. * @@ -34,6 +35,9 @@ import { OperationType } from '../../model/enums'; * * Fails with an error if the token is invalid, expired, or not accepted by the Firebase Auth service. * + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * @param auth - The {@link Auth} instance. * @param customToken - The custom token to sign in with. * @@ -43,6 +47,11 @@ export async function signInWithCustomToken( auth: Auth, customToken: string ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); const response: IdTokenResponse = await getIdTokenResponse(authInternal, { token: customToken, diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index 86855dfa8b2..473b3800eac 100644 --- a/packages/auth/src/core/strategies/email_and_password.ts +++ b/packages/auth/src/core/strategies/email_and_password.ts @@ -29,7 +29,10 @@ import { signUp, SignUpRequest } from '../../api/authentication/sign_up'; import { MultiFactorInfoImpl } from '../../mfa/mfa_info'; import { EmailAuthProvider } from '../providers/email'; import { UserCredentialImpl } from '../user/user_credential_impl'; -import { _assert } from '../util/assert'; +import { + _assert, + _serverAppCurrentUserOperationNotSupportedError +} from '../util/assert'; import { _setActionCodeSettingsOnRequest } from './action_code_settings'; import { signInWithCredential } from './credential'; import { _castAuth } from '../auth/auth_impl'; @@ -39,6 +42,7 @@ import { OperationType } from '../../model/enums'; import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; import { IdTokenResponse } from '../../model/id_token'; import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +import { _isFirebaseServerApp } from '@firebase/app'; /** * Updates the password policy cached in the {@link Auth} instance if a policy is already @@ -253,6 +257,9 @@ export async function verifyPasswordResetCode( * * User account creation can fail if the account already exists or the password is invalid. * + * This method is not supported on {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * Note: The email address acts as a unique identifier for the user and enables an email-based * password reset. This function will create a new user account and set the initial user password. * @@ -267,6 +274,11 @@ export async function createUserWithEmailAndPassword( email: string, password: string ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); const request: SignUpRequest = { returnSecureToken: true, @@ -308,10 +320,14 @@ export async function createUserWithEmailAndPassword( * When [Email Enumeration Protection](https://cloud.google.com/identity-platform/docs/admin/email-enumeration-protection) is enabled, * this method fails with "auth/invalid-credential" in case of an invalid email/password. * + * This method is not supported on {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * Note: The user's password is NOT the password used to access the user's email account. The * email address serves as a unique identifier for the user, and the password is used to access * the user's account in your Firebase project. See also: {@link createUserWithEmailAndPassword}. * + * * @param auth - The {@link Auth} instance. * @param email - The users email address. * @param password - The users password. @@ -323,6 +339,11 @@ export function signInWithEmailAndPassword( email: string, password: string ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } return signInWithCredential( getModularInstance(auth), EmailAuthProvider.credential(email, password) diff --git a/packages/auth/src/core/strategies/email_link.ts b/packages/auth/src/core/strategies/email_link.ts index 55f95226656..351583a6bb5 100644 --- a/packages/auth/src/core/strategies/email_link.ts +++ b/packages/auth/src/core/strategies/email_link.ts @@ -34,6 +34,8 @@ import { getModularInstance } from '@firebase/util'; import { _castAuth } from '../auth/auth_impl'; import { handleRecaptchaFlow } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; /** * Sends a sign-in email link to the user with the specified email. @@ -131,6 +133,9 @@ export function isSignInWithEmailLink(auth: Auth, emailLink: string): boolean { * * Fails with an error if the email address is invalid or OTP in email link expires. * + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. + * * Note: Confirm the link is a sign-in email link before calling this method firebase.auth.Auth.isSignInWithEmailLink. * * @example @@ -154,6 +159,7 @@ export function isSignInWithEmailLink(auth: Auth, emailLink: string): boolean { * } * ``` * + * * @param auth - The {@link Auth} instance. * @param email - The user's email address. * @param emailLink - The link sent to the user's email address. @@ -165,6 +171,11 @@ export async function signInWithEmailLink( email: string, emailLink?: string ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authModular = getModularInstance(auth); const credential = EmailAuthProvider.credentialWithLink( email, diff --git a/packages/auth/src/core/user/account_info.ts b/packages/auth/src/core/user/account_info.ts index 3f061630435..72e2cbc7d2a 100644 --- a/packages/auth/src/core/user/account_info.ts +++ b/packages/auth/src/core/user/account_info.ts @@ -26,6 +26,8 @@ import { UserInternal } from '../../model/user'; import { _logoutIfInvalidated } from './invalidation'; import { getModularInstance } from '@firebase/util'; import { ProviderId } from '../../model/enums'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; /** * Updates a user's profile data. @@ -81,6 +83,9 @@ export async function updateProfile( * An email will be sent to the original email address (if it was set) that allows to revoke the * email address change, in order to protect them from account hijacking. * + * This method is not supported on any {@link User} signed in by {@link Auth} instances + * created with a {@link @firebase/app#FirebaseServerApp}. + * * Important: this is a security sensitive operation that requires the user to have recently signed * in. If this requirement isn't met, ask the user to authenticate again and then call * {@link reauthenticateWithCredential}. @@ -94,11 +99,13 @@ export async function updateProfile( * @public */ export function updateEmail(user: User, newEmail: string): Promise { - return updateEmailOrPassword( - getModularInstance(user) as UserInternal, - newEmail, - null - ); + const userInternal = getModularInstance(user) as UserInternal; + if (_isFirebaseServerApp(userInternal.auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(userInternal.auth) + ); + } + return updateEmailOrPassword(userInternal, newEmail, null); } /** diff --git a/packages/auth/src/core/user/reauthenticate.ts b/packages/auth/src/core/user/reauthenticate.ts index a88b9479b51..7e888c2c1d7 100644 --- a/packages/auth/src/core/user/reauthenticate.ts +++ b/packages/auth/src/core/user/reauthenticate.ts @@ -25,6 +25,8 @@ import { _assert, _fail } from '../util/assert'; import { _parseToken } from './id_token_result'; import { _logoutIfInvalidated } from './invalidation'; import { UserCredentialImpl } from './user_credential_impl'; +import { _isFirebaseServerApp } from '@firebase/app'; +import { _serverAppCurrentUserOperationNotSupportedError } from '../../core/util/assert'; export async function _reauthenticate( user: UserInternal, @@ -32,6 +34,11 @@ export async function _reauthenticate( bypassAuthState = false ): Promise { const { auth } = user; + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const operationType = OperationType.REAUTHENTICATE; try { diff --git a/packages/auth/src/core/user/reload.ts b/packages/auth/src/core/user/reload.ts index fc0a33b937a..ac9a1683e2d 100644 --- a/packages/auth/src/core/user/reload.ts +++ b/packages/auth/src/core/user/reload.ts @@ -102,7 +102,7 @@ function mergeProviderData( return [...deduped, ...newData]; } -function extractProviderData(providers: ProviderUserInfo[]): UserInfo[] { +export function extractProviderData(providers: ProviderUserInfo[]): UserInfo[] { return providers.map(({ providerId, ...provider }) => { return { providerId, diff --git a/packages/auth/src/core/user/token_manager.test.ts b/packages/auth/src/core/user/token_manager.test.ts index e1648d4eb32..b2e1609692f 100644 --- a/packages/auth/src/core/user/token_manager.test.ts +++ b/packages/auth/src/core/user/token_manager.test.ts @@ -144,8 +144,35 @@ describe('core/user/token_manager', () => { }); }); - it('returns null if the refresh token is missing', async () => { - expect(await stsTokenManager.getToken(auth)).to.be.null; + it('returns non-null if the refresh token is missing but token still valid', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + expirationTime: now + 100_000 + }); + const tokens = await stsTokenManager.getToken(auth, false); + expect(tokens).to.eql('token'); + }); + + it('throws an error if the refresh token is missing and force refresh is true', async () => { + Object.assign(stsTokenManager, { + accessToken: 'token', + expirationTime: now + 100_000 + }); + await expect(stsTokenManager.getToken(auth, true)).to.be.rejectedWith( + FirebaseError, + "Firebase: The user's credential is no longer valid. The user must sign in again. (auth/user-token-expired)" + ); + }); + + it('throws an error if the refresh token is missing and token is no longer valid', async () => { + Object.assign(stsTokenManager, { + accessToken: 'old-access-token', + expirationTime: now - 1 + }); + await expect(stsTokenManager.getToken(auth)).to.be.rejectedWith( + FirebaseError, + "Firebase: The user's credential is no longer valid. The user must sign in again. (auth/user-token-expired)" + ); }); it('throws an error if expired but refresh token is missing', async () => { diff --git a/packages/auth/src/core/user/token_manager.ts b/packages/auth/src/core/user/token_manager.ts index 5f56f88afb6..14969005d89 100644 --- a/packages/auth/src/core/user/token_manager.ts +++ b/packages/auth/src/core/user/token_manager.ts @@ -73,20 +73,22 @@ export class StsTokenManager { ); } + updateFromIdToken(idToken: string): void { + _assert(idToken.length !== 0, AuthErrorCode.INTERNAL_ERROR); + const expiresIn = _tokenExpiresIn(idToken); + this.updateTokensAndExpiration(idToken, null, expiresIn); + } + async getToken( auth: AuthInternal, forceRefresh = false ): Promise { - _assert( - !this.accessToken || this.refreshToken, - auth, - AuthErrorCode.TOKEN_EXPIRED - ); - if (!forceRefresh && this.accessToken && !this.isExpired) { return this.accessToken; } + _assert(this.refreshToken, auth, AuthErrorCode.TOKEN_EXPIRED); + if (this.refreshToken) { await this.refresh(auth, this.refreshToken!); return this.accessToken; @@ -113,7 +115,7 @@ export class StsTokenManager { private updateTokensAndExpiration( accessToken: string, - refreshToken: string, + refreshToken: string | null, expiresInSec: number ): void { this.refreshToken = refreshToken || null; diff --git a/packages/auth/src/core/user/user_impl.ts b/packages/auth/src/core/user/user_impl.ts index 44192cc4617..aeb031ab855 100644 --- a/packages/auth/src/core/user/user_impl.ts +++ b/packages/auth/src/core/user/user_impl.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import { IdTokenResult } from '../../model/public_types'; +import { IdTokenResult, UserInfo } from '../../model/public_types'; import { NextFn } from '@firebase/util'; - import { APIUserInfo, + GetAccountInfoResponse, deleteAccount } from '../../api/account_management/account'; import { FinalizeMfaResponse } from '../../api/authentication/mfa'; @@ -32,14 +32,18 @@ import { } from '../../model/user'; import { AuthErrorCode } from '../errors'; import { PersistedBlob } from '../persistence'; -import { _assert } from '../util/assert'; +import { + _assert, + _serverAppCurrentUserOperationNotSupportedError +} from '../util/assert'; import { getIdTokenResult } from './id_token_result'; import { _logoutIfInvalidated } from './invalidation'; import { ProactiveRefresh } from './proactive_refresh'; -import { _reloadWithoutSaving, reload } from './reload'; +import { extractProviderData, _reloadWithoutSaving, reload } from './reload'; import { StsTokenManager } from './token_manager'; import { UserMetadata } from './user_metadata'; import { ProviderId } from '../../model/enums'; +import { _isFirebaseServerApp } from '@firebase/app'; function assertStringOrUndefined( assertion: unknown, @@ -200,6 +204,11 @@ export class UserImpl implements UserInternal { } async delete(): Promise { + if (_isFirebaseServerApp(this.auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(this.auth) + ); + } const idToken = await this.getIdToken(); await _logoutIfInvalidated(this, deleteAccount(this.auth, { idToken })); this.stsTokenManager.clearRefreshToken(); @@ -333,4 +342,59 @@ export class UserImpl implements UserInternal { await _reloadWithoutSaving(user); return user; } + + /** + * Initialize a User from an idToken server response + * @param auth + * @param idTokenResponse + */ + static async _fromGetAccountInfoResponse( + auth: AuthInternal, + response: GetAccountInfoResponse, + idToken: string + ): Promise { + const coreAccount = response.users[0]; + _assert(coreAccount.localId !== undefined, AuthErrorCode.INTERNAL_ERROR); + + const providerData: UserInfo[] = + coreAccount.providerUserInfo !== undefined + ? extractProviderData(coreAccount.providerUserInfo) + : []; + + const isAnonymous = + !(coreAccount.email && coreAccount.passwordHash) && !providerData?.length; + + const stsTokenManager = new StsTokenManager(); + stsTokenManager.updateFromIdToken(idToken); + + // Initialize the Firebase Auth user. + const user = new UserImpl({ + uid: coreAccount.localId, + auth, + stsTokenManager, + isAnonymous + }); + + // update the user with data from the GetAccountInfo response. + const updates: Partial = { + uid: coreAccount.localId, + displayName: coreAccount.displayName || null, + photoURL: coreAccount.photoUrl || null, + email: coreAccount.email || null, + emailVerified: coreAccount.emailVerified || false, + phoneNumber: coreAccount.phoneNumber || null, + tenantId: coreAccount.tenantId || null, + providerData, + metadata: new UserMetadata( + coreAccount.createdAt, + coreAccount.lastLoginAt + ), + isAnonymous: + !(coreAccount.email && coreAccount.passwordHash) && + !providerData?.length + }; + + Object.assign(user, updates); + return user; + } } diff --git a/packages/auth/src/core/util/assert.ts b/packages/auth/src/core/util/assert.ts index adb391b033a..51dff0793e2 100644 --- a/packages/auth/src/core/util/assert.ts +++ b/packages/auth/src/core/util/assert.ts @@ -102,6 +102,16 @@ export function _errorWithCustomMessage( }); } +export function _serverAppCurrentUserOperationNotSupportedError( + auth: Auth +): FirebaseError { + return _errorWithCustomMessage( + auth, + AuthErrorCode.OPERATION_NOT_SUPPORTED, + 'Operations that alter the current user are not supported in conjunction with FirebaseServerApp' + ); +} + export function _assertInstanceOf( auth: Auth, object: object, diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index 0390ba5e30d..ea9b9e0c213 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -322,6 +322,10 @@ export interface Auth { useDeviceLanguage(): void; /** * Signs out the current user. This does not automatically revoke the user's ID token. + * + * @remarks + * This method is not supported by {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. */ signOut(): Promise; } @@ -1004,6 +1008,9 @@ export interface User extends UserInfo { * Important: this is a security-sensitive operation that requires the user to have recently * signed in. If this requirement isn't met, ask the user to authenticate again and then call * one of the reauthentication methods like {@link reauthenticateWithCredential}. + * + * This method is not supported on any {@link User} signed in by {@link Auth} instances + * created with a {@link @firebase/app#FirebaseServerApp}. */ delete(): Promise; /** diff --git a/packages/auth/src/platform_browser/strategies/phone.ts b/packages/auth/src/platform_browser/strategies/phone.ts index 745385c2db9..9e0c34d7058 100644 --- a/packages/auth/src/platform_browser/strategies/phone.ts +++ b/packages/auth/src/platform_browser/strategies/phone.ts @@ -31,7 +31,10 @@ import { ApplicationVerifierInternal } from '../../model/application_verifier'; import { PhoneAuthCredential } from '../../core/credentials/phone'; import { AuthErrorCode } from '../../core/errors'; import { _assertLinkedStatus, _link } from '../../core/user/link_unlink'; -import { _assert } from '../../core/util/assert'; +import { + _assert, + _serverAppCurrentUserOperationNotSupportedError +} from '../../core/util/assert'; import { AuthInternal } from '../../model/auth'; import { linkWithCredential, @@ -47,6 +50,7 @@ import { RECAPTCHA_VERIFIER_TYPE } from '../recaptcha/recaptcha_verifier'; import { _castAuth } from '../../core/auth/auth_impl'; import { getModularInstance } from '@firebase/util'; import { ProviderId } from '../../model/enums'; +import { _isFirebaseServerApp } from '@firebase/app'; interface OnConfirmationCallback { (credential: PhoneAuthCredential): Promise; @@ -82,7 +86,8 @@ class ConfirmationResultImpl implements ConfirmationResult { * {@link RecaptchaVerifier} (like React Native), but you need to use a * third-party {@link ApplicationVerifier} implementation. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -104,6 +109,11 @@ export async function signInWithPhoneNumber( phoneNumber: string, appVerifier: ApplicationVerifier ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); const verificationId = await _verifyPhoneNumber( authInternal, @@ -150,7 +160,8 @@ export async function linkWithPhoneNumber( * @remarks * Use before operations such as {@link updatePassword} that require tokens from recent sign-in attempts. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or on any {@link User} signed in by + * {@link Auth} instances created with a {@link @firebase/app#FirebaseServerApp}. * * @param user - The user. * @param phoneNumber - The user's phone number in E.164 format (e.g. +16505550101). @@ -164,6 +175,11 @@ export async function reauthenticateWithPhoneNumber( appVerifier: ApplicationVerifier ): Promise { const userInternal = getModularInstance(user) as UserInternal; + if (_isFirebaseServerApp(userInternal.auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(userInternal.auth) + ); + } const verificationId = await _verifyPhoneNumber( userInternal.auth, phoneNumber, @@ -259,7 +275,8 @@ export async function _verifyPhoneNumber( * Updates the user's phone number. * * @remarks - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or on any {@link User} signed in by + * {@link Auth} instances created with a {@link @firebase/app#FirebaseServerApp}. * * @example * ``` @@ -281,5 +298,11 @@ export async function updatePhoneNumber( user: User, credential: PhoneAuthCredential ): Promise { - await _link(getModularInstance(user) as UserInternal, credential); + const userInternal = getModularInstance(user) as UserInternal; + if (_isFirebaseServerApp(userInternal.auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(userInternal.auth) + ); + } + await _link(userInternal, credential); } diff --git a/packages/auth/src/platform_browser/strategies/popup.ts b/packages/auth/src/platform_browser/strategies/popup.ts index e47e03cd3f7..12ba128aca9 100644 --- a/packages/auth/src/platform_browser/strategies/popup.ts +++ b/packages/auth/src/platform_browser/strategies/popup.ts @@ -44,6 +44,7 @@ import { AuthPopup } from '../util/popup'; import { AbstractPopupRedirectOperation } from '../../core/strategies/abstract_popup_redirect_operation'; import { FederatedAuthProvider } from '../../core/providers/federated'; import { getModularInstance } from '@firebase/util'; +import { _isFirebaseServerApp } from '@firebase/app'; /* * The event timeout is the same on mobile and desktop, no need for Delay. Set this to 8s since @@ -63,7 +64,8 @@ export const _POLL_WINDOW_CLOSE_TIMEOUT = new Delay(2000, 10000); * If succeeds, returns the signed in user along with the provider's credential. If sign in was * unsuccessful, returns an error object containing additional information about the error. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -91,6 +93,11 @@ export async function signInWithPopup( provider: AuthProvider, resolver?: PopupRedirectResolver ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _createError(auth, AuthErrorCode.OPERATION_NOT_SUPPORTED) + ); + } const authInternal = _castAuth(auth); _assertInstanceOf(auth, provider, FederatedAuthProvider); const resolverInternal = _withDefaultResolver(authInternal, resolver); @@ -111,7 +118,8 @@ export async function signInWithPopup( * If the reauthentication is successful, the returned result will contain the user and the * provider's credential. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or on any {@link User} signed in by + * {@link Auth} instances created with a {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -136,6 +144,11 @@ export async function reauthenticateWithPopup( resolver?: PopupRedirectResolver ): Promise { const userInternal = getModularInstance(user) as UserInternal; + if (_isFirebaseServerApp(userInternal.auth.app)) { + return Promise.reject( + _createError(userInternal.auth, AuthErrorCode.OPERATION_NOT_SUPPORTED) + ); + } _assertInstanceOf(userInternal.auth, provider, FederatedAuthProvider); const resolverInternal = _withDefaultResolver(userInternal.auth, resolver); const action = new PopupOperation( diff --git a/packages/auth/src/platform_browser/strategies/redirect.ts b/packages/auth/src/platform_browser/strategies/redirect.ts index 8586ea589d2..1084fab1b6f 100644 --- a/packages/auth/src/platform_browser/strategies/redirect.ts +++ b/packages/auth/src/platform_browser/strategies/redirect.ts @@ -25,7 +25,10 @@ import { import { _castAuth } from '../../core/auth/auth_impl'; import { _assertLinkedStatus } from '../../core/user/link_unlink'; -import { _assertInstanceOf } from '../../core/util/assert'; +import { + _assertInstanceOf, + _serverAppCurrentUserOperationNotSupportedError +} from '../../core/util/assert'; import { _generateEventId } from '../../core/util/event_id'; import { AuthEventType } from '../../model/popup_redirect'; import { UserInternal } from '../../model/user'; @@ -36,6 +39,7 @@ import { } from '../../core/strategies/redirect'; import { FederatedAuthProvider } from '../../core/providers/federated'; import { getModularInstance } from '@firebase/util'; +import { _isFirebaseServerApp } from '@firebase/app'; /** * Authenticates a Firebase client using a full-page redirect flow. @@ -45,7 +49,8 @@ import { getModularInstance } from '@firebase/util'; * Follow the {@link https://firebase.google.com/docs/auth/web/redirect-best-practices * | best practices} when using {@link signInWithRedirect}. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -93,6 +98,11 @@ export async function _signInWithRedirect( provider: AuthProvider, resolver?: PopupRedirectResolver ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); _assertInstanceOf(auth, provider, FederatedAuthProvider); // Wait for auth initialization to complete, this will process pending redirects and clear the @@ -116,7 +126,8 @@ export async function _signInWithRedirect( * Follow the {@link https://firebase.google.com/docs/auth/web/redirect-best-practices * | best practices} when using {@link reauthenticateWithRedirect}. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances + * created with a {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -161,6 +172,11 @@ export async function _reauthenticateWithRedirect( ): Promise { const userInternal = getModularInstance(user) as UserInternal; _assertInstanceOf(userInternal.auth, provider, FederatedAuthProvider); + if (_isFirebaseServerApp(userInternal.auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(userInternal.auth) + ); + } // Wait for auth initialization to complete, this will process pending redirects and clear the // PENDING_REDIRECT_KEY in persistence. This should be completed before starting a new // redirect and creating a PENDING_REDIRECT_KEY entry. @@ -185,7 +201,8 @@ export async function _reauthenticateWithRedirect( * Follow the {@link https://firebase.google.com/docs/auth/web/redirect-best-practices * | best practices} when using {@link linkWithRedirect}. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances + * created with a {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -247,7 +264,8 @@ export async function _linkWithRedirect( * If sign-in succeeded, returns the signed in user. If sign-in was unsuccessful, fails with an * error. If no redirect operation was called, returns `null`. * - * This method does not work in a Node.js environment. + * This method does not work in a Node.js environment or with {@link Auth} instances created with a + * {@link @firebase/app#FirebaseServerApp}. * * @example * ```javascript @@ -293,6 +311,11 @@ export async function _getRedirectResult( resolverExtern?: PopupRedirectResolver, bypassAuthState = false ): Promise { + if (_isFirebaseServerApp(auth.app)) { + return Promise.reject( + _serverAppCurrentUserOperationNotSupportedError(auth) + ); + } const authInternal = _castAuth(auth); const resolver = _withDefaultResolver(authInternal, resolverExtern); const action = new RedirectAction(authInternal, resolver, bypassAuthState); diff --git a/packages/auth/test/helpers/integration/helpers.ts b/packages/auth/test/helpers/integration/helpers.ts index 9825a8f4ba0..540c926dd99 100644 --- a/packages/auth/test/helpers/integration/helpers.ts +++ b/packages/auth/test/helpers/integration/helpers.ts @@ -16,7 +16,7 @@ */ import * as sinon from 'sinon'; -import { deleteApp, initializeApp } from '@firebase/app'; +import { FirebaseServerApp, deleteApp, initializeApp } from '@firebase/app'; import { Auth, User } from '@firebase/auth'; import { getAuth, connectAuthEmulator } from '../../../'; // Use browser OR node dist entrypoint depending on test env. @@ -80,6 +80,29 @@ export function getTestInstance(requireEmulator = false): Auth { return auth; } +export function getTestInstanceForServerApp( + serverApp: FirebaseServerApp +): Auth { + const auth = getAuth(serverApp) as IntegrationTestAuth; + auth.settings.appVerificationDisabledForTesting = true; + const emulatorUrl = getEmulatorUrl(); + + if (emulatorUrl) { + connectAuthEmulator(auth, emulatorUrl, { disableWarnings: true }); + } + + // Don't track created users on the created Auth instance like we do for Auth objects created in + // getTestInstance(...) above. FirebaseServerApp testing re-uses users created by the Auth + // instances returned by getTestInstance, so those Auth cleanup routines will suffice. + auth.cleanUp = async () => { + // If we're in an emulated environment, the emulator will clean up for us. + //if (emulatorUrl) { + // await resetEmulator(); + //} + }; + return auth; +} + export async function cleanUpTestInstance(auth: Auth): Promise { await auth.signOut(); await (auth as IntegrationTestAuth).cleanUp(); diff --git a/packages/auth/test/integration/flows/firebaseserverapp.test.ts b/packages/auth/test/integration/flows/firebaseserverapp.test.ts new file mode 100644 index 00000000000..917430089c4 --- /dev/null +++ b/packages/auth/test/integration/flows/firebaseserverapp.test.ts @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2024 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. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { + Auth, + createUserWithEmailAndPassword, + EmailAuthProvider, + getAdditionalUserInfo, + getRedirectResult, + GoogleAuthProvider, + onAuthStateChanged, + OperationType, + reauthenticateWithCredential, + signInAnonymously, + signInWithCredential, + signInWithCustomToken, + signInWithEmailAndPassword, + signInWithEmailLink, + signInWithRedirect, + signOut, + updateCurrentUser, + updateEmail, + updateProfile +} from '@firebase/auth'; +import { isBrowser, FirebaseError } from '@firebase/util'; +import { initializeServerApp, deleteApp } from '@firebase/app'; + +import { + cleanUpTestInstance, + getTestInstance, + getTestInstanceForServerApp, + randomEmail +} from '../../helpers/integration/helpers'; + +import { getAppConfig } from '../../helpers/integration/settings'; + +use(chaiAsPromised); + +const signInWaitDuration = 200; + +describe('Integration test: Auth FirebaseServerApp tests', () => { + let auth: Auth; + + beforeEach(() => { + auth = getTestInstance(); + }); + + afterEach(async () => { + await cleanUpTestInstance(auth); + }); + + it('signs in with anonymous user', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.isAnonymous).to.be.true; + expect(user.uid).to.be.a('string'); + expect(user.emailVerified).to.be.false; + expect(user.providerData.length).to.equal(0); + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp(auth.app, firebaseServerAppSettings); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + + console.log('auth.emulatorConfig ', auth.emulatorConfig); + console.log('serverAuth.emulatorConfig ', serverAppAuth.emulatorConfig); + + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + + // Note, the serverAuthUser does not fully equal the standard Auth user + // since the serverAuthUser does not have a refresh token. + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(user.isAnonymous).to.be.equal(serverAuthUser.isAnonymous); + expect(user.emailVerified).to.be.equal(serverAuthUser.emailVerified); + expect(user.providerData.length).to.eq( + serverAuthUser.providerData.length + ); + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(numberServerLogins).to.equal(1); + + await deleteApp(serverApp); + }); + + it('getToken operations fullfilled or rejected', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.isAnonymous).to.be.true; + expect(user.uid).to.be.a('string'); + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(serverAppAuth).to.not.be.null; + expect(serverAuthUser.getIdToken); + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.equal(serverAuthUser); + } + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(numberServerLogins).to.equal(1); + expect(serverAppAuth.currentUser).to.not.be.null; + if (serverAppAuth.currentUser) { + const idToken = await serverAppAuth.currentUser.getIdToken( + /*forceRefresh=*/ false + ); + expect(idToken).to.not.be.null; + await expect(serverAppAuth.currentUser.getIdToken(/*forceRefresh=*/ true)) + .to.be.rejected; + } + + await deleteApp(serverApp); + }); + + it('invalid token does not sign in user', async () => { + if (isBrowser()) { + return; + } + const authIdToken = '{ invalid token }'; + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + expect(serverAppAuth.currentUser).to.be.null; + + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(numberServerLogins).to.equal(0); + expect(serverAppAuth.currentUser).to.be.null; + + await deleteApp(serverApp); + }); + + it('signs in with email crednetial user', async () => { + if (isBrowser()) { + return; + } + const email = randomEmail(); + const password = 'password'; + const userCred = await createUserWithEmailAndPassword( + auth, + email, + password + ); + const user = userCred.user; + expect(auth.currentUser).to.eq(userCred.user); + expect(userCred.operationType).to.eq(OperationType.SIGN_IN); + + const additionalUserInfo = getAdditionalUserInfo(userCred)!; + expect(additionalUserInfo.isNewUser).to.be.true; + expect(additionalUserInfo.providerId).to.eq('password'); + expect(user.isAnonymous).to.be.false; + expect(user.email).to.equal(email); + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + expect(serverAppAuth).to.not.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.equal(serverAuthUser); + } + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(serverAuthUser.refreshToken).to.be.empty; + expect(user.isAnonymous).to.be.equal(serverAuthUser.isAnonymous); + expect(user.emailVerified).to.be.equal(serverAuthUser.emailVerified); + expect(user.providerData.length).to.eq( + serverAuthUser.providerData.length + ); + expect(user.email).to.equal(serverAuthUser.email); + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(numberServerLogins).to.equal(1); + + await deleteApp(serverApp); + }); + + it('can reload user', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.uid).to.be.a('string'); + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(serverAppAuth).to.not.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.equal(serverAuthUser); + } + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(serverAppAuth.currentUser).to.not.be.null; + if (serverAppAuth.currentUser) { + await serverAppAuth.currentUser.reload(); + } + expect(numberServerLogins).to.equal(1); + + await deleteApp(serverApp); + }); + + it('can update server based user profile', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.uid).to.be.a('string'); + expect(user.displayName).to.be.null; + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + let numberServerLogins = 0; + const newDisplayName = 'newName'; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + expect(serverAppAuth).to.not.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.equal(serverAuthUser); + } + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(user.displayName).to.be.null; + void updateProfile(serverAuthUser, { + displayName: newDisplayName + }); + } + }); + + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(serverAppAuth.currentUser).to.not.be.null; + + if (serverAppAuth.currentUser) { + await serverAppAuth.currentUser.reload(); + } + + expect(numberServerLogins).to.equal(1); + expect(serverAppAuth).to.not.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.not.be.null; + expect(serverAppAuth.currentUser?.displayName).to.not.be.null; + expect(serverAppAuth.currentUser?.displayName).to.equal(newDisplayName); + } + + await deleteApp(serverApp); + }); + + it('can sign out of main auth and still use server auth', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.uid).to.be.a('string'); + expect(user.displayName).to.be.null; + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + const serverAppAuth = getTestInstanceForServerApp(serverApp); + let numberServerLogins = 0; + onAuthStateChanged(serverAppAuth, serverAuthUser => { + if (serverAuthUser) { + numberServerLogins++; + expect(serverAppAuth).to.not.be.null; + expect(user.uid).to.be.equal(serverAuthUser.uid); + expect(user.displayName).to.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.equal(serverAuthUser); + } + } + }); + + await signOut(auth); + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(serverAppAuth.currentUser).to.not.be.null; + + if (serverAppAuth.currentUser) { + await serverAppAuth.currentUser.reload(); + } + + expect(numberServerLogins).to.equal(1); + expect(serverAppAuth).to.not.be.null; + if (serverAppAuth) { + expect(serverAppAuth.currentUser).to.not.be.null; + } + + await deleteApp(serverApp); + }); + + it('auth operations fail correctly on FirebaseServerApp instances', async () => { + if (isBrowser()) { + return; + } + const userCred = await signInAnonymously(auth); + expect(auth.currentUser).to.eq(userCred.user); + + const user = userCred.user; + expect(user).to.equal(auth.currentUser); + expect(user.uid).to.be.a('string'); + + const authIdToken = await user.getIdToken(); + const firebaseServerAppSettings = { authIdToken }; + + const serverApp = initializeServerApp( + getAppConfig(), + firebaseServerAppSettings + ); + + const serverAppAuth = getTestInstanceForServerApp(serverApp); + await new Promise(resolve => { + setTimeout(resolve, signInWaitDuration); + }); + + expect(serverAppAuth.currentUser).to.not.be.null; + const email = randomEmail(); + const password = 'password'; + + // Auth tests: + await expect( + createUserWithEmailAndPassword(serverAppAuth, email, password) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect( + signInWithRedirect(serverAppAuth, new GoogleAuthProvider()) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect(getRedirectResult(serverAppAuth)).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect(signInAnonymously(serverAppAuth)).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + + const credential = EmailAuthProvider.credential(email, password); + await expect( + signInWithCredential(serverAppAuth, credential) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + + await expect( + signInWithCustomToken(serverAppAuth, 'custom token') + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect( + signInWithEmailAndPassword(serverAppAuth, email, password) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect( + signInWithEmailLink(serverAppAuth, email, 'email link') + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect( + updateCurrentUser(serverAppAuth, serverAppAuth.currentUser) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect( + updateCurrentUser(serverAppAuth, serverAppAuth.currentUser) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + await expect(signOut(serverAppAuth)).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + + if (serverAppAuth.currentUser !== null) { + await expect( + reauthenticateWithCredential(serverAppAuth.currentUser, credential) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + + await expect(serverAppAuth.currentUser.delete()).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + + await expect( + updateEmail(serverAppAuth.currentUser, email) + ).to.be.rejectedWith( + FirebaseError, + 'operation-not-supported-in-this-environment' + ); + } + + await deleteApp(serverApp); + }); +});