Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add App Check token to FirebaseServerApp #8651

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
25f264f
Initial implementation across all SDKs.
DellaBitta Nov 22, 2024
b971b89
Exp validation at FiresbaseServerApp init.
DellaBitta Dec 4, 2024
7c8ec93
FiresbaseServerApp init tests
DellaBitta Dec 4, 2024
de89ecd
Firestore cache appCheckToken instead of full app.
DellaBitta Dec 16, 2024
e632eeb
again for LiteAppCheckTokenProvider
DellaBitta Dec 16, 2024
1e511b5
Changeset
DellaBitta Dec 16, 2024
ad17dab
Merge branch 'main' into ddb-fsa-appcheck
DellaBitta Dec 16, 2024
33e4889
Update app.firebaseserverappsettings.md
DellaBitta Dec 16, 2024
02708d3
Remove auth's invalid token test
DellaBitta Dec 16, 2024
c1a1322
Check encounteredError only
DellaBitta Dec 17, 2024
34372c4
Update firebaseServerApp.test.ts
DellaBitta Dec 17, 2024
a5075a2
Changeset rewording
DellaBitta Dec 17, 2024
a218674
revert unneeded data connect change.
DellaBitta Dec 17, 2024
e6b6625
Update comments
DellaBitta Dec 17, 2024
9da69bc
Fix error introduced in data connect revert
DellaBitta Dec 17, 2024
9a1299b
update to isFirebaseServerApp to take null | undef
DellaBitta Dec 19, 2024
b3a1c4f
Update API reports
DellaBitta Dec 19, 2024
037041f
Fixes or PR feedback.
DellaBitta Dec 19, 2024
d6e1917
Database throw error instead of reject promise
DellaBitta Dec 19, 2024
4fc151f
Fixes for typos & formatting in comments
DellaBitta Jan 14, 2025
302e1dc
docgen
DellaBitta Jan 14, 2025
61ec38d
Merge branch 'main' into ddb-fsa-appcheck
DellaBitta Jan 14, 2025
0526b87
Review fixes.
DellaBitta Jan 14, 2025
c444e66
Merge branch 'main' into ddb-fsa-appcheck
DellaBitta Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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` may now be initalized with an App Check token in leu 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
11 changes: 11 additions & 0 deletions docs-devsite/app.firebaseserverappsettings.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,20 @@ export interface FirebaseServerAppSettings extends Omit<FirebaseAppSettings, 'na

| Property | Type | Description |
| --- | --- | --- |
| [appCheckToken](./app.firebaseserverappsettings.md#firebaseserverappsettingsappchecktoken) | string | An optional App Check token. If provided, the Firebase SDKs that use App Check will utilizze this App Check token in lieu of requiring an instance of App Check to be initialized. |
| [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. |
| [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 utilizze this App Check token in lieu of requiring an instance of App Check to be initialized.

<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.
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export const enum AppError {
IDB_WRITE = 'idb-set',
IDB_DELETE = 'idb-delete',
FINALIZATION_REGISTRY_NOT_SUPPORTED = 'finalization-registry-not-supported',
INVALID_SERVER_APP_ENVIRONMENT = 'invalid-server-app-environment'
INVALID_SERVER_APP_ENVIRONMENT = 'invalid-server-app-environment',
INVALID_SERVER_APP_TOKEN_FORMAT = 'invalid-server-app-token-format',
SERVER_APP_TOKEN_EXPIRED = 'server-app-token-expired'
}

const ERRORS: ErrorMap<AppError> = {
Expand Down Expand Up @@ -61,7 +63,11 @@ const ERRORS: ErrorMap<AppError> = {
[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.'
'FirebaseServerApp is not for use in browser environments.',
[AppError.INVALID_SERVER_APP_TOKEN_FORMAT]:
'FirebaseServerApp {$tokenName} could not be parsed.',
[AppError.SERVER_APP_TOKEN_EXPIRED]:
'FirebaseServerApp {$tokenName} could not be parsed.'
};

interface ErrorParams {
Expand All @@ -75,6 +81,8 @@ interface ErrorParams {
[AppError.IDB_WRITE]: { originalErrorMessage?: string };
[AppError.IDB_DELETE]: { originalErrorMessage?: string };
[AppError.FINALIZATION_REGISTRY_NOT_SUPPORTED]: { appName?: string };
[AppError.INVALID_SERVER_APP_TOKEN_FORMAT]: { tokenName: string };
[AppError.SERVER_APP_TOKEN_EXPIRED]: { tokenName: string };
}

export const ERROR_FACTORY = new ErrorFactory<AppError, ErrorParams>(
Expand Down
153 changes: 153 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,142 @@ 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('throws when authIdToken has expired', () => {
jamesdaniels marked this conversation as resolved.
Show resolved Hide resolved
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((e as Error).toString()).to.contain(
'app/server-app-token-expired'
);
}
expect(encounteredError).to.be.true;
});

it('throws when authIdToken has too few parts', () => {
const options = { apiKey: 'APIKEY' };
const authIdToken = 'blah';
const serverAppSettings: FirebaseServerAppSettings = {
automaticDataCollectionEnabled: false,
releaseOnDeref: options,
authIdToken: base64Encode(authIdToken)
};
let encounteredError = false;
try {
new FirebaseServerAppImpl(
options,
serverAppSettings,
'testName',
new ComponentContainer('test')
);
} catch (e) {
encounteredError = true;
}
expect(encounteredError).to.be.true;
});

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;
});

it('throws when appCheckToken has expired', () => {
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((e as Error).toString()).to.contain(
'app/server-app-token-expired'
);
}
expect(encounteredError).to.be.true;
});

it('throws when appCheckToken has too few parts', () => {
const options = { apiKey: 'APIKEY' };
const appCheckToken = 'blah';
const serverAppSettings: FirebaseServerAppSettings = {
automaticDataCollectionEnabled: false,
releaseOnDeref: options,
appCheckToken: base64Encode(appCheckToken)
};
let encounteredError = false;
try {
new FirebaseServerAppImpl(
options,
serverAppSettings,
'testName',
new ComponentContainer('test')
);
} catch (e) {
encounteredError = true;
}
expect(encounteredError).to.be.true;
});
});
36 changes: 36 additions & 0 deletions packages/app/src/firebaseServerApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,32 @@ 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.
// Throws an error 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) {
throw ERROR_FACTORY.create(AppError.INVALID_SERVER_APP_TOKEN_FORMAT, {
tokenName
});
}
const expClaim = JSON.parse(secondPart).exp;
if (expClaim === undefined) {
throw ERROR_FACTORY.create(AppError.INVALID_SERVER_APP_TOKEN_FORMAT, {
tokenName
});
}
const exp = JSON.parse(secondPart).exp * 1000;
const now = new Date().getTime();
const diff = exp - now;
if (diff <= 0) {
throw ERROR_FACTORY.create(AppError.SERVER_APP_TOKEN_EXPIRED, {
tokenName
});
}
}

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

// Ensure that the current time is within the authIdtoken window of validity.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authIdtoken and appChecktoken look like literals that should be backticked.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

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
6 changes: 6 additions & 0 deletions packages/app/src/public-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ export interface FirebaseServerAppSettings
*/
authIdToken?: string;

/**
* An optional App Check token. If provided, the Firebase SDKs that use App Check will utilizze
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo "utilize."

Personally I like in lieu, but I'll bet good money our style guide advises something more like "instead of" or "in place of."

Copy link
Contributor Author

@DellaBitta DellaBitta Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! I went with "in place of".

* this App Check token in lieu of requiring an instance of App Check to be initialized.
*/
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
Loading
Loading