Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
- Added support for enabling Firebase Authentication providers via `firebase deploy`. You can configure providers in `firebase.json` like so:

```json
{
"auth": {
"providers": {
"anonymous": true,
"emailPassword": true,
"googleSignIn": {
"oAuthBrandDisplayName": "My App",
"supportEmail": "support@myapp.com"
}
}
}
}
```

- Fixes an issue where the `--only` flag was not always respected for `firebase mcp`
39 changes: 39 additions & 0 deletions schema/firebase-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@
],
"type": "string"
},
"AuthConfig": {
"additionalProperties": false,
"properties": {
"providers": {
"additionalProperties": false,
"properties": {
"anonymous": {
"type": "boolean"
},
"emailPassword": {
"type": "boolean"
},
"googleSignIn": {
"additionalProperties": false,
"properties": {
"authorizedRedirectUris": {
"items": {
"type": "string"
},
"type": "array"
},
"oAuthBrandDisplayName": {
"type": "string"
},
"supportEmail": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
}
},
"type": "object"
},
"DataConnectSingle": {
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -1159,6 +1195,9 @@
}
]
},
"auth": {
"$ref": "#/definitions/AuthConfig"
},
"database": {
"anyOf": [
{
Expand Down
3 changes: 3 additions & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"extensions",
"dataconnect",
"apphosting",
"auth",
];
export const TARGET_PERMISSIONS: Record<(typeof VALID_DEPLOY_TARGETS)[number], string[]> = {
database: ["firebasedatabase.instances.update"],
Expand Down Expand Up @@ -73,6 +74,8 @@
"firebasedataconnect.schemas.list",
"firebasedataconnect.schemas.update",
],
apphosting: [],
auth: ["firebase.projects.update", "firebaseauth.configs.update"],
};

export const command = new Command("deploy")
Expand Down Expand Up @@ -109,7 +112,7 @@
})
.before((options: Options) => {
if (options.filteredTargets.includes("functions")) {
return checkServiceAccountIam(options.project!);

Check warning on line 115 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}
})
.before(async (options: Options) => {
Expand Down Expand Up @@ -145,10 +148,10 @@
}
logBullet("No Hosting site detected.");
const siteId = await pickHostingSiteName("", options);
await createSite(options.project!, siteId);

Check warning on line 151 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}
})
.before(checkValidTargetFilters)
.action((options) => {
return deploy(options.filteredTargets, options);

Check warning on line 156 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`

Check warning on line 156 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .filteredTargets on an `any` value

Check warning on line 156 in src/commands/deploy.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `("database" | "storage" | "firestore" | "functions" | "hosting" | "remoteconfig" | "extensions" | "dataconnect" | "apphosting" | "auth")[]`
});
5 changes: 5 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@
checked: false,
hidden: true,
},
{
value: "auth",
name: "Authentication: Set up Firebase Authentication",
checked: false,
},
];

if (isEnabled("fdcwebhooks")) {
Expand Down Expand Up @@ -207,7 +212,7 @@

const setup: Setup = {
config: config.src,
rcfile: config.readProjectFile(".firebaserc", {

Check warning on line 215 in src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
json: true,
fallback: {},
}),
Expand Down Expand Up @@ -283,7 +288,7 @@
}
}

export async function postInitSaves(setup: Setup, config: Config): Promise<void> {

Check warning on line 291 in src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
logger.info();
config.writeProjectFile("firebase.json", setup.config);
config.writeProjectFile(".firebaserc", setup.rcfile);
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@
"remoteconfig",
"dataconnect",
"apphosting",
"auth",
];

public options: any;

Check warning on line 38 in src/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
public projectDir: string;
public data: any = {};

Check warning on line 40 in src/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
public defaults: any = {};

Check warning on line 41 in src/config.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
public notes: any = {};

private _src: any;
Expand Down
107 changes: 107 additions & 0 deletions src/deploy/auth/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as deploy from "./deploy";
import * as provision from "../../management/provisioning/provision";
import { Options } from "../../options";
import * as apps from "../../management/apps";
import { ProviderMode } from "../../management/provisioning/types";

describe("deploy/auth", () => {
let sandbox: sinon.SinonSandbox;
let provisionStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
provisionStub = sandbox.stub(provision, "provisionFirebaseApp").resolves();
});

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

it("should skip if no auth config", async () => {
const options = { config: { src: {} }, project: "test-project" } as unknown as Options;
const context = { auth: { appId: "1:12345:web:abcdef" } };

await deploy.deploy(context, options);

expect(provisionStub).to.not.be.called;
});

it("should skip if no appId in context", async () => {
const options = {
config: {
src: {
auth: {
providers: { anonymous: true },
},
},
},
project: "test-project",
} as unknown as Options;
const context = { auth: {} };

await deploy.deploy(context, options);

expect(provisionStub).to.not.be.called;
});

it("should provision auth providers", async () => {
const options = {
config: {
src: {
auth: {
providers: {
anonymous: true,
emailPassword: true,
googleSignIn: {
oAuthBrandDisplayName: "Brand",
supportEmail: "support@example.com",
authorizedRedirectUris: ["https://example.com"],
},
},
},
},
},
project: "test-project",
} as unknown as Options;
const context = { auth: { appId: "1:12345:web:abcdef" } };

await deploy.deploy(context, options);

expect(provisionStub).to.be.calledOnce;
const args = provisionStub.firstCall.args[0];
expect(args.project).to.deep.equal({
parent: { type: "existing_project", projectId: "test-project" },
});
expect(args.app).to.deep.equal({ platform: apps.AppPlatform.WEB, appId: "1:12345:web:abcdef" });

const input = args.features?.firebaseAuthInput;
expect(input?.anonymousAuthProviderMode).to.equal(ProviderMode.PROVIDER_ENABLED);
expect(input?.emailAuthProviderMode).to.equal(ProviderMode.PROVIDER_ENABLED);
expect(input?.googleSigninProviderMode).to.equal(ProviderMode.PROVIDER_ENABLED);
expect(input?.googleSigninProviderConfig).to.deep.equal({
publicDisplayName: "Brand",
customerSupportEmail: "support@example.com",
oauthRedirectUris: ["https://example.com"],
});
});

it("should skip if no providers enabled", async () => {
const options = {
config: {
src: {
auth: {
providers: {},
},
},
},
project: "test-project",
} as unknown as Options;
const context = { auth: { appId: "1:12345:web:abcdef" } };

await deploy.deploy(context, options);

expect(provisionStub).to.not.be.called;
});
});
74 changes: 74 additions & 0 deletions src/deploy/auth/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Options } from "../../options";
import { needProjectId } from "../../projectUtils";
import { AuthConfig } from "../../firebaseConfig";
import { provisionFirebaseApp } from "../../management/provisioning/provision";
import { AppPlatform } from "../../management/apps";
import { FirebaseAuthInput, ProviderMode } from "../../management/provisioning/types";
import { logger } from "../../logger";
import { logSuccess } from "../../utils";

export async function deploy(context: any, options: Options): Promise<void> {
const projectId = needProjectId(options);
const config = options.config.src.auth as AuthConfig | undefined;

if (!config) {
return;
}

const appId = context.auth?.appId;
if (!appId) {
return;
}

const authInput: FirebaseAuthInput = {};
const providers = config.providers;
const logMsg: string[] = [];

if (providers) {
if (providers.anonymous === true) {
logMsg.push("anonymous");
authInput.anonymousAuthProviderMode = ProviderMode.PROVIDER_ENABLED;
}

if (providers.emailPassword === true) {
logMsg.push("email/password");
authInput.emailAuthProviderMode = ProviderMode.PROVIDER_ENABLED;
}

if (providers.googleSignIn) {
logMsg.push("Google sign-in");
authInput.googleSigninProviderMode = ProviderMode.PROVIDER_ENABLED;
authInput.googleSigninProviderConfig = {
publicDisplayName: providers.googleSignIn.oAuthBrandDisplayName,
customerSupportEmail: providers.googleSignIn.supportEmail,
oauthRedirectUris: providers.googleSignIn.authorizedRedirectUris,
};
}
}

// If no auth changes, skip
if (Object.keys(authInput).length === 0) {
logger.debug("[auth] No auth providers configured to enable.");
return;
}

logger.info(`Enabling auth providers: ${logMsg.join(", ")}...`);

await provisionFirebaseApp({
project: {
parent: {
type: "existing_project",
projectId: projectId,
},
},
app: {
platform: AppPlatform.WEB,
appId: appId,
},
features: {
firebaseAuthInput: authInput,
},
});

logSuccess(`Auth providers enabled: ${logMsg.join(", ")}`);
}
3 changes: 3 additions & 0 deletions src/deploy/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { prepare } from "./prepare";
export { deploy } from "./deploy";
export { release } from "./release";
89 changes: 89 additions & 0 deletions src/deploy/auth/prepare.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect } from "chai";
import * as sinon from "sinon";
import * as prepare from "./prepare";
import * as apps from "../../management/apps";
import { Options } from "../../options";

describe("deploy/auth/prepare", () => {
let sandbox: sinon.SinonSandbox;
let listAppsStub: sinon.SinonStub;
let createAppStub: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.createSandbox();
listAppsStub = sandbox.stub(apps, "listFirebaseApps").resolves([]);
createAppStub = sandbox.stub(apps, "createWebApp").resolves({
appId: "1:12345:web:created",
displayName: "Default Web App",
platform: apps.AppPlatform.WEB,
projectId: "test-project",
name: "projects/test-project/webApps/1:12345:web:created",
});
});

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

it("should skip if no auth config", async () => {
const options = { config: { src: {} }, project: "test-project" } as unknown as Options;
const context = {};

await prepare.prepare(context, options);

expect(listAppsStub).to.not.be.called;
});

it("should use existing Default Web App if found", async () => {
listAppsStub.resolves([
{
appId: "1:12345:web:found",
displayName: "Default Web App",
platform: apps.AppPlatform.WEB,
},
]);

const options = {
config: { src: { auth: { providers: {} } } },
project: "test-project",
} as unknown as Options;
const context: any = {};

await prepare.prepare(context, options);

expect(createAppStub).to.not.be.called;
expect(context.auth.appId).to.equal("1:12345:web:found");
});

it("should use first web app if Default Web App not found", async () => {
listAppsStub.resolves([
{ appId: "1:12345:web:other", displayName: "Other App", platform: apps.AppPlatform.WEB },
]);

const options = {
config: { src: { auth: { providers: {} } } },
project: "test-project",
} as unknown as Options;
const context: any = {};

await prepare.prepare(context, options);

expect(createAppStub).to.not.be.called;
expect(context.auth.appId).to.equal("1:12345:web:other");
});

it("should create Default Web App if no web apps exist", async () => {
listAppsStub.resolves([]);

const options = {
config: { src: { auth: { providers: {} } } },
project: "test-project",
} as unknown as Options;
const context: any = {};

await prepare.prepare(context, options);

expect(createAppStub).to.be.calledWith("test-project", { displayName: "Default Web App" });
expect(context.auth.appId).to.equal("1:12345:web:created");
});
});
Loading
Loading