Skip to content

Add App Check token to FirebaseServerApp #8651

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

Merged
merged 25 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 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
3352b7f
Changelist copy update.
DellaBitta Jan 17, 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
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
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
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