Skip to content

Commit

Permalink
Add App Check token to FirebaseServerApp (#8651)
Browse files Browse the repository at this point in the history
 FirebaseServerApp now accepts an optional App Check token at initialization. The product SDKs will look for this token, and if it's present, the SDKs will use this value in lieu of calling getToken on App Check.

This change affects the following SDKs: Auth, Cloud Functions, Data Connect, Firestore, Realtime Database, Vertex AI
  • Loading branch information
DellaBitta authored Jan 21, 2025
1 parent 3aefcc3 commit 97d48c7
Show file tree
Hide file tree
Showing 21 changed files with 262 additions and 52 deletions.
15 changes: 15 additions & 0 deletions .changeset/kind-pets-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@firebase/app': minor
'firebase': minor
'@firebase/data-connect': patch
'@firebase/firestore': patch
'@firebase/functions': patch
'@firebase/database': patch
'@firebase/vertexai': patch
'@firebase/storage': patch
'@firebase/auth': patch
---

`FirebaseServerApp` can now be initalized with an App Check token instead of invoking the App Check
`getToken` method. This should unblock the use of App Check enforced products in SSR environments
where the App Check SDK cannot be initialized.
3 changes: 2 additions & 1 deletion common/api-review/app.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface FirebaseServerApp extends FirebaseApp {

// @public
export interface FirebaseServerAppSettings extends Omit<FirebaseAppSettings, 'name'> {
appCheckToken?: string;
authIdToken?: string;
releaseOnDeref?: object;
}
Expand Down Expand Up @@ -115,7 +116,7 @@ export function initializeServerApp(options: FirebaseOptions | FirebaseApp, conf
export function _isFirebaseApp(obj: FirebaseApp | FirebaseOptions): obj is FirebaseApp;

// @internal (undocumented)
export function _isFirebaseServerApp(obj: FirebaseApp | FirebaseServerApp): obj is FirebaseServerApp;
export function _isFirebaseServerApp(obj: FirebaseApp | FirebaseServerApp | null | undefined): obj is FirebaseServerApp;

// @public
export function onLog(logCallback: LogCallback | null, options?: LogOptions): void;
Expand Down
19 changes: 17 additions & 2 deletions docs-devsite/app.firebaseserverappsettings.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,31 @@ export interface FirebaseServerAppSettings extends Omit<FirebaseAppSettings, 'na
| 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 <code>getAuth</code> with a <code>FirebaseServerApp</code> configured with a validated <code>authIdToken</code> causes an automatic attempt to sign in the user that the <code>authIdToken</code> 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 initialization.<!-- -->If a user is successfully signed in, then the Auth instance's <code>onAuthStateChanged</code> callback is invoked with the <code>User</code> object as per standard Auth flows. However, <code>User</code> objects created via an <code>authIdToken</code> do not have a refresh token. Attempted <code>refreshToken</code> operations fail. |
| [appCheckToken](./app.firebaseserverappsettings.md#firebaseserverappsettingsappchecktoken) | string | An optional App Check token. If provided, the Firebase SDKs that use App Check will utilize this App Check token in place of requiring an instance of App Check to be initialized.<!-- -->If the token fails local verification due to expiration or parsing errors, then a console error is logged at the time of initialization of the <code>FirebaseServerApp</code> instance. |
| [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 <code>getAuth</code> with a <code>FirebaseServerApp</code> configured with a validated <code>authIdToken</code> causes an automatic attempt to sign in the user that the <code>authIdToken</code> represents. The token needs to have been recently minted for this operation to succeed.<!-- -->If the token fails local verification due to expiration or parsing errors, then a console error is logged at the time of initialization of the <code>FirebaseServerApp</code> instance.<!-- -->If the Auth service has failed to validate the token when the Auth SDK is initialized, then an warning is logged to the console and the Auth SDK will not sign in a user on initialization.<!-- -->If a user is successfully signed in, then the Auth instance's <code>onAuthStateChanged</code> callback is invoked with the <code>User</code> object as per standard Auth flows. However, <code>User</code> objects created via an <code>authIdToken</code> do not have a refresh token. Attempted <code>refreshToken</code> operations fail. |
| [releaseOnDeref](./app.firebaseserverappsettings.md#firebaseserverappsettingsreleaseonderef) | object | An optional object. If provided, the Firebase SDK uses a <code>FinalizationRegistry</code> object to monitor the garbage collection status of the provided object. The Firebase SDK releases its reference on the <code>FirebaseServerApp</code> instance when the provided <code>releaseOnDeref</code> 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 <code>FirebaseServerApp</code> 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 <code>FirebaseServerApp</code> instance by invoking <code>deleteApp</code>.<!-- -->If the application provides an object in this parameter, but the application is executed in a JavaScript engine that predates the support of <code>FinalizationRegistry</code> (introduced in node v14.6.0, for instance), then an error is thrown at <code>FirebaseServerApp</code> initialization. |
## FirebaseServerAppSettings.appCheckToken
An optional App Check token. If provided, the Firebase SDKs that use App Check will utilize this App Check token in place of requiring an instance of App Check to be initialized.
If the token fails local verification due to expiration or parsing errors, then a console error is logged at the time of initialization of the `FirebaseServerApp` instance.
<b>Signature:</b>
```typescript
appCheckToken?: string;
```
## 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 initialization.
If the token fails local verification due to expiration or parsing errors, then a console error is logged at the time of initialization of the `FirebaseServerApp` instance.
If the Auth service has failed to validate the token when the Auth SDK is initialized, then an warning is logged to the console and the Auth SDK will not sign in a user on initialization.
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.
Expand Down
59 changes: 59 additions & 0 deletions packages/app/src/firebaseServerApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ import '../test/setup';
import { ComponentContainer } from '@firebase/component';
import { FirebaseServerAppImpl } from './firebaseServerApp';
import { FirebaseServerAppSettings } from './public-types';
import { base64Encode } from '@firebase/util';

const BASE64_DUMMY = base64Encode('dummystrings'); // encodes to ZHVtbXlzdHJpbmdz

// Creates a three part dummy token with an expiration claim in the second part. The expration
// time is based on the date offset provided.
function createServerAppTokenWithOffset(daysOffset: number): string {
const timeInSeconds = Math.trunc(
new Date().setDate(new Date().getDate() + daysOffset) / 1000
);
const secondPart = JSON.stringify({ exp: timeInSeconds });
const token =
BASE64_DUMMY + '.' + base64Encode(secondPart) + '.' + BASE64_DUMMY;
return token;
}

describe('FirebaseServerApp', () => {
it('has various accessors', () => {
Expand Down Expand Up @@ -155,4 +170,48 @@ describe('FirebaseServerApp', () => {

expect(JSON.stringify(app)).to.eql(undefined);
});

it('accepts a valid authIdToken expiration', () => {
const options = { apiKey: 'APIKEY' };
const authIdToken = createServerAppTokenWithOffset(/*daysOffset=*/ 1);
const serverAppSettings: FirebaseServerAppSettings = {
automaticDataCollectionEnabled: false,
releaseOnDeref: options,
authIdToken
};
let encounteredError = false;
try {
new FirebaseServerAppImpl(
options,
serverAppSettings,
'testName',
new ComponentContainer('test')
);
} catch (e) {
encounteredError = true;
}
expect(encounteredError).to.be.false;
});

it('accepts a valid appCheckToken expiration', () => {
const options = { apiKey: 'APIKEY' };
const appCheckToken = createServerAppTokenWithOffset(/*daysOffset=*/ 1);
const serverAppSettings: FirebaseServerAppSettings = {
automaticDataCollectionEnabled: false,
releaseOnDeref: options,
appCheckToken
};
let encounteredError = false;
try {
new FirebaseServerAppImpl(
options,
serverAppSettings,
'testName',
new ComponentContainer('test')
);
} catch (e) {
encounteredError = true;
}
expect(encounteredError).to.be.false;
});
});
39 changes: 39 additions & 0 deletions packages/app/src/firebaseServerApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,35 @@ import { ComponentContainer } from '@firebase/component';
import { FirebaseAppImpl } from './firebaseApp';
import { ERROR_FACTORY, AppError } from './errors';
import { name as packageName, version } from '../package.json';
import { base64Decode } from '@firebase/util';

// Parse the token and check to see if the `exp` claim is in the future.
// Reports an error to the console if the token or claim could not be parsed, or if `exp` is in
// the past.
function validateTokenTTL(base64Token: string, tokenName: string): void {
const secondPart = base64Decode(base64Token.split('.')[1]);
if (secondPart === null) {
console.error(
`FirebaseServerApp ${tokenName} is invalid: second part could not be parsed.`
);
return;
}
const expClaim = JSON.parse(secondPart).exp;
if (expClaim === undefined) {
console.error(
`FirebaseServerApp ${tokenName} is invalid: expiration claim could not be parsed`
);
return;
}
const exp = JSON.parse(secondPart).exp * 1000;
const now = new Date().getTime();
const diff = exp - now;
if (diff <= 0) {
console.error(
`FirebaseServerApp ${tokenName} is invalid: the token has expired.`
);
}
}

export class FirebaseServerAppImpl
extends FirebaseAppImpl
Expand Down Expand Up @@ -67,6 +96,16 @@ export class FirebaseServerAppImpl
...serverConfig
};

// Ensure that the current time is within the `authIdtoken` window of validity.
if (this._serverConfig.authIdToken) {
validateTokenTTL(this._serverConfig.authIdToken, 'authIdToken');
}

// Ensure that the current time is within the `appCheckToken` window of validity.
if (this._serverConfig.appCheckToken) {
validateTokenTTL(this._serverConfig.appCheckToken, 'appCheckToken');
}

this._finalizationRegistry = null;
if (typeof FinalizationRegistry !== 'undefined') {
this._finalizationRegistry = new FinalizationRegistry(() => {
Expand Down
27 changes: 25 additions & 2 deletions packages/app/src/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { expect } from 'chai';
import { stub } from 'sinon';
import '../test/setup';
import { createTestComponent, TestService } from '../test/util';
import { initializeApp, getApps, deleteApp } from './api';
import { initializeApp, initializeServerApp, getApps, deleteApp } from './api';
import { FirebaseAppImpl } from './firebaseApp';
import {
_addComponent,
Expand All @@ -28,9 +28,11 @@ import {
_components,
_clearComponents,
_getProvider,
_removeServiceInstance
_removeServiceInstance,
_isFirebaseServerApp
} from './internal';
import { logger } from './logger';
import { isBrowser } from '@firebase/util';

declare module '@firebase/component' {
interface NameServiceMapping {
Expand Down Expand Up @@ -161,4 +163,25 @@ describe('Internal API tests', () => {
expect(instance1).to.not.equal(instance2);
});
});

describe('_isFirebaseServerApp', () => {
it('detects a valid FirebaseServerApp', () => {
if (!isBrowser()) {
// FirebaseServerApp isn't supported for execution in browser environments.
const app = initializeServerApp({}, {});
expect(_isFirebaseServerApp(app)).to.be.true;
}
});
it('a standard FirebaseApp returns false', () => {
const app = initializeApp({});
expect(_isFirebaseServerApp(app)).to.be.false;
});
it('a null object returns false', () => {
expect(_isFirebaseServerApp(null)).to.be.false;
});
it('undefined returns false', () => {
let app: undefined;
expect(_isFirebaseServerApp(app)).to.be.false;
});
});
});
5 changes: 4 additions & 1 deletion packages/app/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,11 @@ export function _isFirebaseApp(
* @internal
*/
export function _isFirebaseServerApp(
obj: FirebaseApp | FirebaseServerApp
obj: FirebaseApp | FirebaseServerApp | null | undefined
): obj is FirebaseServerApp {
if (obj === null || obj === undefined) {
return false;
}
return (obj as FirebaseServerApp).settings !== undefined;
}

Expand Down
17 changes: 14 additions & 3 deletions packages/app/src/public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,11 @@ export interface FirebaseServerAppSettings
* 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 initialization.
* If the token fails local verification due to expiration or parsing errors, then a console error
* is logged at the time of initialization of the `FirebaseServerApp` instance.
*
* If the Auth service has failed to validate the token when the Auth SDK is initialized, then an
* warning is logged to the console and the Auth SDK will not sign in a user on initialization.
*
* 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
Expand All @@ -196,6 +198,15 @@ export interface FirebaseServerAppSettings
*/
authIdToken?: string;

/**
* An optional App Check token. If provided, the Firebase SDKs that use App Check will utilize
* this App Check token in place of requiring an instance of App Check to be initialized.
*
* If the token fails local verification due to expiration or parsing errors, then a console error
* is logged at the time of initialization of the `FirebaseServerApp` instance.
*/
appCheckToken?: string;

/**
* An optional object. If provided, the Firebase SDK uses a `FinalizationRegistry`
* object to monitor the garbage collection status of the provided object. The
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/core/auth/auth_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,9 @@ export class AuthImpl implements AuthInternal, _FirebaseService {
}

async _getAppCheckToken(): Promise<string | undefined> {
if (_isFirebaseServerApp(this.app) && this.app.settings.appCheckToken) {
return this.app.settings.appCheckToken;
}
const appCheckTokenResult = await this.appCheckServiceProvider
.getImmediate({ optional: true })
?.getToken();
Expand Down
31 changes: 0 additions & 31 deletions packages/auth/test/integration/flows/firebaseserverapp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,37 +166,6 @@ describe('Integration test: Auth FirebaseServerApp tests', () => {
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 credentials user', async () => {
if (isBrowser()) {
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/data-connect/src/api/DataConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export class DataConnect {
}
if (this._appCheckProvider) {
this._appCheckTokenProvider = new AppCheckTokenProvider(
this.app.name,
this.app,
this._appCheckProvider
);
}
Expand Down
17 changes: 13 additions & 4 deletions packages/data-connect/src/core/AppCheckTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* limitations under the License.
*/

import { FirebaseApp, _isFirebaseServerApp } from '@firebase/app';
import {
AppCheckInternalComponentName,
AppCheckTokenListener,
Expand All @@ -29,10 +30,14 @@ import { Provider } from '@firebase/component';
*/
export class AppCheckTokenProvider {
private appCheck?: FirebaseAppCheckInternal;
private serverAppAppCheckToken?: string;
constructor(
private appName_: string,
app: FirebaseApp,
private appCheckProvider?: Provider<AppCheckInternalComponentName>
) {
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
this.appCheck = appCheckProvider?.getImmediate({ optional: true });
if (!this.appCheck) {
void appCheckProvider
Expand All @@ -42,7 +47,11 @@ export class AppCheckTokenProvider {
}
}

getToken(forceRefresh?: boolean): Promise<AppCheckTokenResult> {
getToken(): Promise<AppCheckTokenResult> {
if (this.serverAppAppCheckToken) {
return Promise.resolve({ token: this.serverAppAppCheckToken });
}

if (!this.appCheck) {
return new Promise<AppCheckTokenResult>((resolve, reject) => {
// Support delayed initialization of FirebaseAppCheck. This allows our
Expand All @@ -51,14 +60,14 @@ export class AppCheckTokenProvider {
// becomes available before the timoeout below expires.
setTimeout(() => {
if (this.appCheck) {
this.getToken(forceRefresh).then(resolve, reject);
this.getToken().then(resolve, reject);
} else {
resolve(null);
}
}, 0);
});
}
return this.appCheck.getToken(forceRefresh);
return this.appCheck.getToken();
}

addTokenChangeListener(listener: AppCheckTokenListener): void {
Expand Down
Loading

0 comments on commit 97d48c7

Please sign in to comment.