Skip to content

Commit

Permalink
Use only a single account in VSCode. Support IDX Auth (#7420)
Browse files Browse the repository at this point in the history
* Auth working in IDX?

* Remove concept of multiple accounts in vscode. Support IDX Auth

* add changelog. small cleanup

* Remove console.log

* remove comment

* revert project changes

* address comments

* add loading spinner

* remove spacer

* formatting

---------

Co-authored-by: Joe Hanley <joehanley@google.com>
  • Loading branch information
hlshen and joehan authored Jul 9, 2024
1 parent 85d27c5 commit 27c9d6e
Show file tree
Hide file tree
Showing 13 changed files with 172 additions and 304 deletions.
1 change: 1 addition & 0 deletions firebase-vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## NEXT

- IDX Auth is picked up by VSCode
- [Fixed] Data Connect emulator issues properly streamed on startup
- [Fixed] Data Connect schema reloads consistently

Expand Down
2 changes: 2 additions & 0 deletions firebase-vscode/common/messaging/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ export interface ExtensionToWebviewParamsMap {
// data connect specific
notifyDataConnectResults: DataConnectResults;
notifyDataConnectRequiredArgs: { args: string[] };

notifyIsLoadingUser: boolean;
}

export type MessageParamsMap =
Expand Down
149 changes: 45 additions & 104 deletions firebase-vscode/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import * as vscode from "vscode";
import { inspect } from "util";

import {
getAllAccounts,
getGlobalDefaultAccount,
Expand All @@ -10,10 +7,9 @@ import {
import { logoutAction } from "../../src/commands/logout";
import { listFirebaseProjects } from "../../src/management/projects";
import { requireAuth } from "../../src/requireAuth";
import { Account, User } from "../../src/types/auth";
import { Account, Tokens, User } from "../../src/types/auth";
import { Options } from "../../src/options";
import { currentOptions, getCommandOptions } from "./options";
import { ServiceAccount } from "../common/types";
import { EmulatorUiSelections } from "../common/messaging/types";
import { pluginLogger } from "./logger-wrapper";
import { setAccessToken } from "../../src/apiv2";
Expand All @@ -32,116 +28,75 @@ import * as commandUtils from "../../src/emulator/commandUtils";
import { currentUser } from "./core/user";
import { firstWhere } from "./utils/signal";
export { Emulators };
/**
* Try to get a service account by calling requireAuth() without
* providing any account info.
*/
async function getServiceAccount() {
let email = null;
try {
// Make sure no user/token is sent
// to requireAuth which would prevent autoAuth() from being reached.
// We do need to send isVSCE to prevent project selection popup
const optionsMinusUser = await getCommandOptions(undefined, {
...currentOptions.value,
});
delete optionsMinusUser.user;
delete optionsMinusUser.tokens;
delete optionsMinusUser.token;
email = (await requireAuth(optionsMinusUser)) || null;
} catch (e) {
let errorMessage = e.message;
if (e.original?.message) {
errorMessage += ` (original: ${e.original.message})`;
}
pluginLogger.debug(
`No service account found (this may be normal), ` +
`requireAuth error output: ${errorMessage}`,
);
return null;
}
if (process.env.WORKSPACE_SERVICE_ACCOUNT_EMAIL) {
// If Monospace, get service account email using env variable as
// the metadata server doesn't currently return the credentials
// for the workspace service account. Remove when Monospace is
// updated to return credentials through the metadata server.
pluginLogger.debug(
`Using WORKSPACE_SERVICE_ACCOUNT_EMAIL env ` +
`variable to get service account email: ` +
`${process.env.WORKSPACE_SERVICE_ACCOUNT_EMAIL}`,
);
return process.env.WORKSPACE_SERVICE_ACCOUNT_EMAIL;
}
pluginLogger.debug(
`Got service account email through credentials:` + ` ${email}`,
);
return email;
}

/**
* Wrap the CLI's requireAuth() which is normally run before every command
* requiring user to be logged in. The CLI automatically supplies it with
* account info if found in configstore so we need to fill that part in.
*
*/
async function requireAuthWrapper(showError: boolean = true): Promise<boolean> {
// Try to get global default from configstore. For some reason this is
export async function requireAuthWrapper(
showError: boolean = true,
): Promise<User | null> {
// Try to get global default from configstore
pluginLogger.debug("requireAuthWrapper");
let account = getGlobalDefaultAccount();
// often overwritten when restarting the extension.
const accounts = getAllAccounts();

// helper to determine if VSCode options has the same account as global default
function isUserMatching(account: Account, options: Options) {

if (!options.user || !options.tokens) {
return false;
}

const optionsUser = options.user as User;
const optionsTokens = options.tokens as Tokens;
return (
account &&
account.user.email === optionsUser.email &&
account.tokens.access_token === optionsTokens.access_token // TODO: check if this is necessary
);
}

// only add account options when vscode is missing account information
if (!isUserMatching(account, currentOptions.value)) {
currentOptions.value = { ...currentOptions.value, ...account };
}

if (!account) {
// If nothing in configstore top level, grab the first "additionalAccount"
const accounts = getAllAccounts();
for (const additionalAccount of accounts) {
if (additionalAccount.user.email === currentUser.value.email) {
account = additionalAccount;
setGlobalDefaultAccount(account);
}
}
}
const commandOptions = await getCommandOptions(undefined, {
...currentOptions.value,
});
// `requireAuth()` is not just a check, but will also register SERVICE
// ACCOUNT tokens in memory as a variable in apiv2.ts, which is needed
// for subsequent API calls. Warning: this variable takes precedence
// over Google login tokens and must be removed if a Google
// account is the current user.
try {
const serviceAccountEmail = await getServiceAccount();
// Priority 1: Service account exists and is the current selected user
if (
serviceAccountEmail &&
currentUser.value.email === serviceAccountEmail
) {
// requireAuth should have been run and apiv2 token should be stored
// already due to getServiceAccount() call above.
return true;
} else if (account) {
// Priority 2: Google login account exists and is the currently selected
// user
// Priority 3: Google login account exists and there is no selected user
// Clear service account access token from memory in apiv2.
setAccessToken();
await requireAuth({ ...commandOptions, ...account });
return true;
} else if (serviceAccountEmail) {
// Priority 4: There is a service account but it's not set as
// currentUser for some reason, but there also isn't an oauth account.
// requireAuth was already run as part of getServiceAccount() above
return true;
setAccessToken(); // clears the current access token
const userEmail = await requireAuth(currentOptions.value); // client email
if (userEmail) {
pluginLogger.debug("User found: ", userEmail);

// VSCode only has the concept of a single user
return getGlobalDefaultAccount().user;
}

pluginLogger.debug("No user found (this may be normal)");
return false;
return null;
} catch (e) {
if (showError) {
// Show error to user - show a popup and log it with log level "error".
pluginLogger.error(
`requireAuth error: ${e.original?.message || e.message}`,
);
vscode.window.showErrorMessage("Not logged in", {
modal: true,
detail: `Log in by clicking "Sign in with Google" in the sidebar.`,
});
} else {
// User shouldn't need to see this error - not actionable,
// but we should log it for debugging purposes.
Expand All @@ -150,23 +105,8 @@ async function requireAuthWrapper(showError: boolean = true): Promise<boolean> {
e.original?.message || e.message,
);
}
return false;
}
}

export async function getAccounts(): Promise<Array<Account | ServiceAccount>> {
// Get Firebase login accounts
const accounts: Array<Account | ServiceAccount> = getAllAccounts();
pluginLogger.debug(`Found ${accounts.length} non-service accounts.`);
// Get other accounts (assuming service account for now, could also be glogin)
const serviceAccountEmail = await getServiceAccount();
if (serviceAccountEmail) {
pluginLogger.debug(`Found service account: ${serviceAccountEmail}`);
accounts.push({
user: { email: serviceAccountEmail, type: "service_account" },
});
return null;
}
return accounts;
}

export async function logoutUser(email: string): Promise<void> {
Expand All @@ -183,8 +123,8 @@ export async function login() {
}

export async function listProjects() {
const loggedIn = await requireAuthWrapper(false);
if (!loggedIn) {
const loggedInUser = await requireAuthWrapper(false);
if (!loggedInUser) {
return [];
}
return listFirebaseProjects();
Expand All @@ -193,9 +133,10 @@ export async function listProjects() {
export async function emulatorsStart(
emulatorUiSelections: EmulatorUiSelections,
) {
const only = emulatorUiSelections.mode === "dataconnect"
? `${Emulators.DATACONNECT},${Emulators.AUTH}`
: "";
const only =
emulatorUiSelections.mode === "dataconnect"
? `${Emulators.DATACONNECT},${Emulators.AUTH}`
: "";
const commandOptions = await getCommandOptions(undefined, {
...(await firstWhere(
// TODO use firstWhereDefined once currentOptions are undefined if not initialized yet
Expand Down
9 changes: 8 additions & 1 deletion firebase-vscode/src/core/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { pluginLogger } from "../logger-wrapper";
import { globalSignal } from "../utils/globals";
import { firstWhereDefined } from "../utils/signal";
import { User } from "../types/auth";

/** Available projects */
export const projects = globalSignal<Record<string, FirebaseProjectMetadata[]>>(
{},
Expand All @@ -24,6 +23,7 @@ const userScopedProjects = computed<FirebaseProjectMetadata[] | undefined>(
},
);

// TODO(hlshen): clean up concept of currentProject and currentProjectId
/** Gets the currently selected project, fallback to first default project in RC file */
export const currentProject = computed<FirebaseProjectMetadata | undefined>(
() => {
Expand Down Expand Up @@ -90,6 +90,13 @@ export function registerProject(broker: ExtensionBrokerImpl): Disposable {
});
});

// TODO: Set projectId from IDX Auth
// const monospaceLoginSub = effect(() => {
// if (currentOptions.value.projectId) {
// currentProjectId.value = currentOptions.value.projectId;
// }
// })

const command = vscode.commands.registerCommand(
"firebase.selectProject",
async () => {
Expand Down
71 changes: 24 additions & 47 deletions firebase-vscode/src/core/user.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,62 @@
import { computed, effect } from "@preact/signals-react";
import { Signal, computed, effect } from "@preact/signals-react";
import { Disposable, TelemetryLogger } from "vscode";
import { ServiceAccountUser } from "../types";
import { User as AuthUser } from "../../../src/types/auth";
import { ExtensionBrokerImpl } from "../extension-broker";
import { getAccounts, login, logoutUser } from "../cli";
import { login, logoutUser, requireAuthWrapper } from "../cli";
import { globalSignal } from "../utils/globals";
import { DATA_CONNECT_EVENT_NAME } from "../analytics";

type User = ServiceAccountUser | AuthUser;

/** Available user accounts */
export const users = globalSignal<Record<string /** email */, User>>({});

/** Currently selected user email */
export const currentUserId = globalSignal("");

/** Gets the currently selected user, fallback to first available user */
export const currentUser = computed<User | undefined>(() => {
return users.value[currentUserId.value] ?? Object.values(users.value)[0];
});
/** Currently selected user */
export const currentUser = globalSignal<User|null>(null);
const isLoadingUser = new Signal<boolean>(false);

export const isServiceAccount = computed(() => {
return (currentUser.value as ServiceAccountUser)?.type === "service_account";
});

export async function checkLogin() {
const accounts = await getAccounts();
users.value = accounts.reduce(
(cumm, curr) => ({ ...cumm, [curr.user.email]: curr.user }),
{}
);
return await requireAuthWrapper();
}

export function registerUser(broker: ExtensionBrokerImpl, telemetryLogger: TelemetryLogger): Disposable {

const sub1 = effect(() => {
broker.send("notifyUsers", { users: Object.values(users.value) });
});

const sub2 = effect(() => {
const notifyUserChangedSub = effect(() => {
broker.send("notifyUserChanged", { user: currentUser.value });
});

const sub3 = broker.on("getInitialData", async () => {
checkLogin();
const getInitialDataSub = broker.on("getInitialData", async () => {
isLoadingUser.value = true;
currentUser.value = await checkLogin();
isLoadingUser.value = false;
});

const sub4 = broker.on("addUser", async () => {
const isLoadingSub = effect(() => {
broker.send("notifyIsLoadingUser", isLoadingUser.value);
})

const addUserSub = broker.on("addUser", async () => {
telemetryLogger.logUsage(DATA_CONNECT_EVENT_NAME.LOGIN);
const { user } = await login();
users.value = {
...users.value,
[user.email]: user,
};
currentUserId.value = user.email;
currentUser.value = user;
});

const sub5 = broker.on("requestChangeUser", ({ user }) => {
currentUserId.value = user.email;
});

const sub6 = broker.on("logout", async ({ email }) => {
const logoutSub = broker.on("logout", async ({ email }) => {
try {
await logoutUser(email);
const accounts = await getAccounts();
users.value = accounts.reduce(
(cumm, curr) => ({ ...cumm, [curr.user.email]: curr.user }),
{}
);
currentUserId.value = "";
currentUser.value = null;
} catch (e) {
// ignored
}
});

return Disposable.from(
{ dispose: sub1 },
{ dispose: sub2 },
{ dispose: sub3 },
{ dispose: sub4 },
{ dispose: sub5 },
{ dispose: sub6 }
{ dispose: notifyUserChangedSub },
{ dispose: getInitialDataSub },
{ dispose: addUserSub },
{ dispose: logoutSub },
{ dispose: isLoadingSub },
);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
specVersion: v1alpha
serviceId: us-east
connectorDirs:
- ./connectors/a
specVersion: "v1alpha"
serviceId: "us-east"
location: "europe-north1"
schema:
source: ./schema
source: "./schema"
datasource:
postgresql:
database: "my-database"
database: "emulator"
cloudSql:
instanceId: "dataconnect-test"
connectorDirs: ["./connectors"]
Loading

0 comments on commit 27c9d6e

Please sign in to comment.