Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3478666
feat: Add App Check debug token management commands
andrewheard May 26, 2026
82b99ec
feat: Implement firebase appcheck:debug <appId> to generate local .fi…
AustinBenoit Jun 2, 2026
e4b0c85
feat: align appcheck:debugtoken CLI commands with REST backend specif…
AustinBenoit Jul 15, 2026
af4791c
test: add command unit tests for appcheck:debugtoken CLI scenario suite
AustinBenoit Jul 15, 2026
2923b69
fix: address code review comments for appcheck:debugtoken CLI and RES…
AustinBenoit Jul 15, 2026
16f5b66
fix: resolve lint errors and formatting in appcheck commands and spec
AustinBenoit Jul 15, 2026
32bf9cc
fix: stub requireAuth in spec and enable auth on App Check Client for…
AustinBenoit Jul 15, 2026
e703b3c
fix: bypass command pre-action hooks in spec for CI test runner
AustinBenoit Jul 16, 2026
687fa9d
Fix nock matching for Node 24 by using RegExp
AustinBenoit Jul 16, 2026
12e511a
Fix prettier formatting in tests
AustinBenoit Jul 16, 2026
c0f6c2c
fix(appcheck): stub client directly to bypass Node 24 nock fetch bug
AustinBenoit Jul 16, 2026
759221c
feat(appcheck): refactor cli command namespace and args
AustinBenoit Jul 17, 2026
12a1f85
feat(appcheck): require --force to overwrite existing token display n…
AustinBenoit Jul 17, 2026
c424e61
fix(lint): fix prettier formatting errors in appcheck-debugtokens-cre…
AustinBenoit Jul 17, 2026
018b192
chore: remove old appcheck-debug files properly
AustinBenoit Jul 20, 2026
06960f6
Refactor: extract App Check prompt utils and remove stale endpoints
AustinBenoit Jul 21, 2026
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Add `appcheck:debugtokens:create`, `appcheck:debugtokens:list`, and `appcheck:debugtokens:delete` CLI commands for managing App Check debug tokens (#10801).
- Add `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` HTTP headers to `OneMcpServer` requests per the MCP 0728 standard release candidate (https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ and https://modelcontextprotocol.io/seps/2243-http-standardization).
- Fixes Storage Emulator to support JSON uploads larger than 100KB without hanging or throwing 413 error (#8355)
- Add `extdeprecationwarnings` experiment to display phased deprecation notices and guidance across `ext:*` CLI commands.
Expand Down
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,35 @@

let commandScopes = new Set<string>();

export const authProxyOrigin = () =>

Check warning on line 8 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("FIREBASE_AUTHPROXY_URL", "https://auth.firebase.tools");
// "In this context, the client secret is obviously not treated as a secret"
// https://developers.google.com/identity/protocols/OAuth2InstalledApp
export const clientId = () =>

Check warning on line 12 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride(
"FIREBASE_CLIENT_ID",
"563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com",
);
export const clientSecret = () =>

Check warning on line 17 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("FIREBASE_CLIENT_SECRET", "j9iVZfS8kkCEFUPaAeJV0sAi");
export const cloudbillingOrigin = () =>

Check warning on line 19 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDBILLING_URL", "https://cloudbilling.googleapis.com");
export const cloudloggingOrigin = () =>

Check warning on line 21 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDLOGGING_URL", "https://logging.googleapis.com");
export const cloudMonitoringOrigin = () =>

Check warning on line 23 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com");
export const containerRegistryDomain = () =>

Check warning on line 25 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io");

export const developerConnectOrigin = () =>

Check warning on line 28 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com");
export const developerConnectP4SADomain = () =>

Check warning on line 30 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_P4SA_DOMAIN", "gcp-sa-devconnect.iam.gserviceaccount.com");

export const artifactRegistryDomain = () =>

Check warning on line 33 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function
utils.envOverride("ARTIFACT_REGISTRY_DOMAIN", "https://artifactregistry.googleapis.com");
export const appCheckOrigin = () =>
utils.envOverride("FIREBASE_APPCHECK_URL", "https://firebaseappcheck.googleapis.com");
export const appDistributionOrigin = () =>
utils.envOverride(
"FIREBASE_APP_DISTRIBUTION_URL",
Expand Down
56 changes: 56 additions & 0 deletions src/appcheck/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { appCheckOrigin } from "../api";
import { Client } from "../apiv2";
import { DebugToken, ListDebugTokensResponse } from "./types";

const API_VERSION = "v1";

export const client = new Client({
urlPrefix: appCheckOrigin(),
auth: true,
apiVersion: API_VERSION,
});

/**
* Creates a new DebugToken for the specified app.
*/
export async function createDebugToken(
projectNumber: string,
appId: string,
displayName: string,
token: string,
): Promise<DebugToken> {
const parent = `projects/${projectNumber}/apps/${appId}`;
const res = await client.post<Partial<DebugToken>, DebugToken>(`${parent}/debugTokens`, {
displayName,
token,
});
return res.body;
}

/**
* Lists all DebugTokens for the specified app.
*/
export async function listDebugTokens(projectNumber: string, appId: string): Promise<DebugToken[]> {
const parent = `projects/${projectNumber}/apps/${appId}`;
const debugTokens: DebugToken[] = [];
let pageToken = "";
do {
const queryParams: Record<string, string> = {};
if (pageToken) {
queryParams.pageToken = pageToken;
}
const res = await client.get<ListDebugTokensResponse>(`${parent}/debugTokens`, { queryParams });
if (res.body?.debugTokens) {
debugTokens.push(...res.body.debugTokens);
}
pageToken = res.body?.nextPageToken || "";
} while (pageToken);
return debugTokens;
}

/**
* Deletes the specified DebugToken.
*/
export async function deleteDebugToken(name: string): Promise<void> {
await client.delete<void>(name);
}
100 changes: 100 additions & 0 deletions src/appcheck/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { expect } from "chai";
import * as sinon from "sinon";

import { createDebugToken, listDebugTokens, deleteDebugToken, DebugToken, client } from "./index";
import { FirebaseError } from "../error";

describe("appcheck", () => {
const projectNumber = "123456789";
const appId = "1:123456789:web:abc123def456";
const parent = `projects/${projectNumber}/apps/${appId}`;
const debugTokenId = "debug-token-id-123";
const debugTokenName = `${parent}/debugTokens/${debugTokenId}`;
const dummyDebugToken: DebugToken = {
name: debugTokenName,
displayName: "My Debug Token",
token: "00000000-0000-0000-0000-000000000000",
updateTime: "2023-01-01T00:00:00Z",
};

let sandbox: sinon.SinonSandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
});

describe("createDebugToken", () => {
it("should resolve with created DebugToken on success", async () => {
const postStub = sandbox.stub(client, "post").resolves({ body: dummyDebugToken } as any);

const result = await createDebugToken(
projectNumber,
appId,
dummyDebugToken.displayName,
dummyDebugToken.token,
);

expect(result).to.deep.equal(dummyDebugToken);
expect(postStub.calledOnce).to.be.true;
expect(postStub.firstCall.args[0]).to.match(/.*debugTokens.*/);
expect(postStub.firstCall.args[1]).to.deep.equal({
displayName: dummyDebugToken.displayName,
token: dummyDebugToken.token,
});
});

it("should throw error on failure", async () => {
const postStub = sandbox.stub(client, "post").rejects(new FirebaseError("Invalid request"));

await expect(
createDebugToken(projectNumber, appId, dummyDebugToken.displayName, dummyDebugToken.token),
).to.be.rejectedWith(FirebaseError, "Invalid request");
expect(postStub.calledOnce).to.be.true;
});
});

describe("listDebugTokens", () => {
it("should resolve with list of DebugTokens on success", async () => {
const getStub = sandbox
.stub(client, "get")
.resolves({ body: { debugTokens: [dummyDebugToken] } } as any);

const result = await listDebugTokens(projectNumber, appId);
expect(result).to.deep.equal([dummyDebugToken]);
expect(getStub.calledOnce).to.be.true;
expect(getStub.firstCall.args[0]).to.match(/.*debugTokens.*/);
});

it("should handle pagination", async () => {
const secondToken: DebugToken = {
...dummyDebugToken,
name: `${parent}/debugTokens/token-2`,
};
const getStub = sandbox.stub(client, "get");
getStub
.onFirstCall()
.resolves({ body: { debugTokens: [dummyDebugToken], nextPageToken: "page-2" } } as any);
getStub.onSecondCall().resolves({ body: { debugTokens: [secondToken] } } as any);

const result = await listDebugTokens(projectNumber, appId);
expect(result).to.deep.equal([dummyDebugToken, secondToken]);
expect(getStub.calledTwice).to.be.true;
expect(getStub.secondCall.args[0]).to.match(/.*debugTokens.*/);
expect(getStub.secondCall.args[1]).to.deep.equal({ queryParams: { pageToken: "page-2" } });
});
});

describe("deleteDebugToken", () => {
it("should resolve on success", async () => {
const deleteStub = sandbox.stub(client, "delete").resolves({ body: {} } as any);

await expect(deleteDebugToken(debugTokenName)).to.be.eventually.fulfilled;
expect(deleteStub.calledOnce).to.be.true;
expect(deleteStub.firstCall.args[0]).to.match(/.*debugTokens.*/);
});
});
});
3 changes: 3 additions & 0 deletions src/appcheck/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * from "./prompts";
52 changes: 52 additions & 0 deletions src/appcheck/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AppPlatform, listFirebaseApps, selectAppInteractively } from "../management/apps";
import { needProjectId } from "../projectUtils";
import { detectApps } from "../appUtils";
import { FirebaseError } from "../error";
import { logger } from "../logger";
import * as clc from "colorette";
import { AppCheckDebugOptions } from "./types";

/**
* Gets the appId from options or prompts the user to select an app if multiple exist.
* Uses needProjectId(options) to retrieve the active or specified project ID.
*/
export async function getOrPromptAppId(
options: AppCheckDebugOptions,
): Promise<{ projectId: string; appId: string }> {
const projectId = needProjectId(options);

logger.info(`Active Project: ${clc.bold(projectId)}`);

if (options.app) {
return { projectId, appId: options.app };
}

const projectDir = options.cwd || process.cwd();
let apps = await listFirebaseApps(projectId, AppPlatform.ANY);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As I'm revieiwng this, I'm realizing that there is duplicated logic for selecting an app from the list of exisitng apps in 3 places in the CLI already 🤦 That's an oversight on my part, but we really ought to centralize this . I'm gonna send you a PR shortly to do so - please review and we can merge that before this one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if (!apps.length) {
throw new FirebaseError(`There are no apps associated with project ${projectId}.`);
}

const localApps = await detectApps(projectDir);
const localAppIds = localApps.map((a) => a.appId).filter(Boolean) as string[];
if (localAppIds.length > 0) {
const filteredApps = apps.filter((app) => localAppIds.includes(app.appId));
if (filteredApps.length > 0) {
apps = filteredApps;
}
}

if (apps.length === 1) {
return { projectId, appId: apps[0].appId };
} else if (options.nonInteractive) {
throw new FirebaseError(`Project ${projectId} has multiple apps, must specify an app id.`);
}

const selectedApp = await selectAppInteractively(apps, AppPlatform.ANY, {
message: "Select the app to register a debug token for:",
});

return { projectId, appId: selectedApp.appId };
}

export const getOrPromptProjectAndAppId = getOrPromptAppId;
18 changes: 18 additions & 0 deletions src/appcheck/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Options } from "../options";

export interface DebugToken {
name: string;
displayName: string;
token: string;
updateTime?: string;
}

export interface ListDebugTokensResponse {
debugTokens?: DebugToken[];
nextPageToken?: string;
}

export interface AppCheckDebugOptions extends Options {
app?: string;
displayName?: string;
}
Loading
Loading