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 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
1 change: 1 addition & 0 deletions 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
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
159 changes: 159 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,148 @@ 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', () => {
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((e as Error).toString()).to.contain(
'Unexpected end of JSON input'
);
}
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((e as Error).toString()).to.contain(
'Unexpected end of JSON input'
);
}
expect(encounteredError).to.be.true;
});
});
37 changes: 37 additions & 0 deletions packages/app/src/firebaseServerApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ 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 now = new Date(new Date().getDate() - 1).now()
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 +94,16 @@ export class FirebaseServerAppImpl
...serverConfig
};

// Validate the authIdtoken validation window.
if (this._serverConfig.authIdToken) {
validateTokenTTL(this._serverConfig.authIdToken, 'authIdToken');
}

// Validate the appCheckToken validation window.
if (this._serverConfig.appCheckToken) {
validateTokenTTL(this._serverConfig.appCheckToken, 'appCheckToken');
}

this._finalizationRegistry = null;
if (typeof FinalizationRegistry !== 'undefined') {
this._finalizationRegistry = new FinalizationRegistry(() => {
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
* 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
12 changes: 5 additions & 7 deletions packages/data-connect/src/api/DataConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export class DataConnect {
private _transportOptions?: TransportOptions;
private _authTokenProvider?: AuthTokenProvider;
_isUsingGeneratedSdk: boolean = false;
private _appCheckTokenProvider?: AppCheckTokenProvider;
private _appCheckTokenProvider: AppCheckTokenProvider;
// @internal
constructor(
public readonly app: FirebaseApp,
Expand Down Expand Up @@ -149,12 +149,10 @@ export class DataConnect {
this._authProvider
);
}
if (this._appCheckProvider) {
this._appCheckTokenProvider = new AppCheckTokenProvider(
this.app.name,
this._appCheckProvider
);
}
this._appCheckTokenProvider = new AppCheckTokenProvider(
this.app,
this._appCheckProvider
);

this._initialized = true;
this._transport = new this._transportClass(
Expand Down
11 changes: 10 additions & 1 deletion 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,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This value was never used.

app: FirebaseApp,
private appCheckProvider?: Provider<AppCheckInternalComponentName>
) {
if (_isFirebaseServerApp(app) && app.settings.appCheckToken) {
this.serverAppAppCheckToken = app.settings.appCheckToken;
}
Copy link
Contributor Author

@DellaBitta DellaBitta Dec 16, 2024

Choose a reason for hiding this comment

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

During the initialization of the Data Connect-specific AppCheckTokenProvider, check if the provided app is a FirebaseServerApp that contains an App Check token. If it does, store the token locally so getToken can return it (below).

this.appCheck = appCheckProvider?.getImmediate({ optional: true });
if (!this.appCheck) {
void appCheckProvider
Expand All @@ -43,6 +48,10 @@ export class AppCheckTokenProvider {
}

getToken(forceRefresh?: boolean): 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 Down
2 changes: 1 addition & 1 deletion packages/database/src/api/Database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export function repoManagerDatabaseFromApp(
repoInfo,
app,
authTokenProvider,
new AppCheckTokenProvider(app.name, appCheckProvider)
new AppCheckTokenProvider(app, appCheckProvider)
);
return new Database(repo, app);
}
Expand Down
Loading
Loading