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

Support multiple accounts #3192

Merged
merged 27 commits into from
Apr 6, 2021
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
98faa1e
Get basic commands in place
samtstern Mar 5, 2021
75dcfdf
Functions emulator args
samtstern Mar 8, 2021
4f7aa73
Logout
samtstern Mar 8, 2021
1b203e3
Fix compile
samtstern Mar 8, 2021
a83bf46
Handle token refreshing
samtstern Mar 8, 2021
575fb0c
Lint
samtstern Mar 8, 2021
25d4347
Fix tests
samtstern Mar 8, 2021
8a06b92
Fix inherited option bug
samtstern Mar 9, 2021
e3d2e78
Make firebase init work
samtstern Mar 9, 2021
8c9493a
Add some docstrings, clean up some global state
samtstern Mar 9, 2021
a8aef0a
Unit tests
samtstern Mar 9, 2021
209e209
Merge branch 'master' into ss-multi-auth
samtstern Mar 9, 2021
65a9bf3
Changelog
samtstern Mar 9, 2021
b9f5379
Fix unit tests
samtstern Mar 9, 2021
0397763
Fix inherited option issue with account
samtstern Mar 9, 2021
c01acf5
Review comments
samtstern Mar 16, 2021
22c9cdd
Merge remote-tracking branch 'github/master' into ss-multi-auth
samtstern Mar 16, 2021
851323f
Fix imports
samtstern Mar 16, 2021
17e3dde
Apply suggestions from code review
samtstern Mar 17, 2021
0d409f8
Merge branch 'master' into ss-multi-auth
samtstern Mar 17, 2021
d8754a4
Merge remote-tracking branch 'github/master' into ss-multi-auth
samtstern Mar 23, 2021
12fa104
Review comments
samtstern Mar 23, 2021
bfd9211
Merge branch 'ss-multi-auth' of github.com:firebase/firebase-tools in…
samtstern Mar 23, 2021
a6a5cd7
Prompt for new account if logging out of global default
samtstern Apr 6, 2021
b2bf7ca
Fix duplicate accounts
samtstern Apr 6, 2021
faea732
Fix requirePermissions issue
samtstern Apr 6, 2021
f3ec559
Merge branch 'master' into ss-multi-auth
samtstern Apr 6, 2021
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
Expand Up @@ -2,3 +2,4 @@
- Add support for reading/writing mfaInfo field in Auth Emulator (#3173).
- Work around CORS issues with jsdom in Auth Emulator and Emulator Hub (#3224).
- Fixes port conflict issues with `functions:shell` (#3210).
- Adds support for multiple accounts via new commands `login:use`, `login:add` and `login:list`.
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels like a feature that could do with (a) documentation in the README and (b) documentation in FireSite

Copy link
Contributor Author

Choose a reason for hiding this comment

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

cc @rachelsaunders I added a README section, can you take a look?

40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,18 @@ Below is a brief list of the available commands and their function:

### Configuration Commands

| Command | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **login** | Authenticate to your Firebase account. Requires access to a web browser. |
| **logout** | Sign out of the Firebase CLI. |
| **login:ci** | Generate an authentication token for use in non-interactive environments. |
| **use** | Set active Firebase project, manage project aliases. |
| **open** | Quickly open a browser to relevant project resources. |
| **init** | Setup a new Firebase project in the current directory. This command will create a `firebase.json` configuration file in your current directory. |
| **help** | Display help information about the CLI or specific commands. |
| Command | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **login** | Authenticate to your Firebase account. Requires access to a web browser. |
| **logout** | Sign out of the Firebase CLI. |
| **login:ci** | Generate an authentication token for use in non-interactive environments. |
| **login:add** | Authorize the CLI for an additional account. |
| **login:list** | List authorized CLI accounts. |
| **login:use** | Set the default account to use for this project |
| **use** | Set active Firebase project, manage project aliases. |
| **open** | Quickly open a browser to relevant project resources. |
| **init** | Setup a new Firebase project in the current directory. This command will create a `firebase.json` configuration file in your current directory. |
| **help** | Display help information about the CLI or specific commands. |

Append `--no-localhost` to login (i.e., `firebase login --no-localhost`) to copy and paste code instead of starting a local server for authentication. A use case might be if you SSH into an instance somewhere and you need to authenticate to Firebase on that machine.

Expand Down Expand Up @@ -172,6 +175,25 @@ The Firebase CLI can use one of four authentication methods listed in descending
- **Service Account** - set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to point to the path of a JSON service account key file.
- **Application Default Credentials** - if you use the `gcloud` CLI and log in with `gcloud auth application-default login`, the Firebase CLI will use them if none of the above credentials are present.

### Multiple Accounts

By default `firebase login` sets a single global account for use on all projects.
If you have multiple Google accounts which you use for Firebase projects you can
authorize multople accounts and use them on a per-project or per-command basis.

To authorize an additonal account for use with the CLI, run `firebase login:add`.
You can view the list of authorized accounts with `firebase login:list`.

To set the default account for a specific Firebase project directory, run
`firebase login:use` from within the directory and select the desired account.
To check the default account for a directory, run `firebase login:list` and the
default account for the current context will be listed first.

To set the account for a specific command invocation, use the `--account` flag
with any command. For example `firebase [email protected] deploy`. The
specified account must have already been added to the Firebase CLI using
`firebase login:add`.

### Cloud Functions Emulator

The Cloud Functions emulator is exposed through commands like `emulators:start`,
Expand Down
260 changes: 249 additions & 11 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,46 @@ import * as url from "url";
import * as util from "util";

import * as api from "./api";
import * as apiv2 from "./apiv2";
import { configstore } from "./configstore";
import { FirebaseError } from "./error";
import * as utils from "./utils";
import { logger } from "./logger";
import { prompt } from "./prompt";
import * as scopes from "./scopes";
import { clearCredentials } from "./defaultCredentials";

/* eslint-disable camelcase */
// The wire protocol for an access token returned by Google.
// When we actually refresh from the server we should always have
// these optional fields, but when a user passes --token we may
// only have access_token.
interface Tokens {
export interface Tokens {
id_token?: string;
access_token: string;
refresh_token?: string;
scopes?: string[];
}

export interface User {
email: string;

iss?: string;
azp?: string;
aud?: string;
sub?: number;
hd?: string;
email_verified?: boolean;
at_hash?: string;
iat?: number;
exp?: number;
}

export interface Account {
user: User;
tokens: Tokens;
}

interface TokensWithExpiration extends Tokens {
expires_at?: number;
}
Expand All @@ -36,7 +58,7 @@ interface TokensWithTTL extends Tokens {
}

interface UserCredentials {
user: string | { [key: string]: unknown };
user: string | User;
tokens: TokensWithExpiration;
scopes: string[];
}
Expand All @@ -54,6 +76,173 @@ interface GitHubAuthResponse {
// TODO fix after https://github.com/http-party/node-portfinder/pull/115
((portfinder as unknown) as { basePort: number }).basePort = 9005;

/**
* Get the global default account. Before multi-auth was implemented
* this was the only account.
*/
export function getGlobalDefaultAccount(): Account | undefined {
const user = configstore.get("user") as User | undefined;
const tokens = configstore.get("tokens") as Tokens | undefined;

// TODO: Is there ever a case where only User or Tokens is defined
// and we want to accept that?
if (!user || !tokens) {
return undefined;
}

return {
user,
tokens,
};
}

/**
* Get the default account associated with a project directory, or the global default.
* @param projectDir the Firebase project directory.
*/
export function getProjectDefaultAccount(projectDir?: string | null): Account | undefined {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can projectDir really be null? What does that mean rather than just an empty string?

Copy link
Contributor Author

@samtstern samtstern Mar 23, 2021

Choose a reason for hiding this comment

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

This comes from detectProjectRoot which sets options.projectRoot to string | null ... it's a nasty type but I want the code to be honest about what options.projectRoot (which is typed as any) can be and because detectProjectRoot is not always called both undefined and null are possible.

if (!projectDir) {
return getGlobalDefaultAccount();
}

const activeAccounts = configstore.get("activeAccounts") || {};
const email: string | undefined = activeAccounts[projectDir];

if (!email) {
return getGlobalDefaultAccount();
}

const allAccounts = getAllAccounts();
return allAccounts.find((a) => a.user.email === email);
}

/**
* Get all authenticated accounts _besides_ the default account.
*/
export function getAdditionalAccounts(): Account[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm noticing that all these new functions are exported - is that necessary? Some of these seem like helpers...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They're all used in at least one other file.

return configstore.get("additionalAccounts") || [];
}

/**
* Get all authenticated accounts.
*/
export function getAllAccounts(): Account[] {
const res: Account[] = [];

const defaultUser = getGlobalDefaultAccount();
if (defaultUser) {
res.push(defaultUser);
}

res.push(...getAdditionalAccounts());

return res;
}

/**
* Set the globally active account. Modifies the options object
* and sets global refresh token state.
* @param options options object.
* @param account account to make active.
*/
export function setActiveAccount(options: any, account: Account) {
if (account.tokens.refresh_token) {
setRefreshToken(account.tokens.refresh_token);
}

options.user = account.user;
options.tokens = account.tokens;
}

/**
* Set the global refresh token in both api and apiv2.
* @param token refresh token string
*/
export function setRefreshToken(token: string) {
api.setRefreshToken(token);
apiv2.setRefreshToken(token);
}

/**
* Select the right account to use based on the --account flag and the
* project defaults.
* @param account the --account flag, if passed.
* @param projectRoot the Firebase project root directory, if known.
*/
export function selectAccount(account?: string, projectRoot?: string): Account | undefined {
const defaultUser = getProjectDefaultAccount(projectRoot);

// Default to single-account behavior
if (!account) {
return defaultUser;
}

// Ensure that the user exists if specified
if (!defaultUser) {
throw new FirebaseError(`Account ${account} not found, have you run "firebase login"?`);
}

const matchingAccount = getAllAccounts().find((a) => a.user.email === account);
if (matchingAccount) {
return matchingAccount;
}

throw new FirebaseError(
`Account ${account} not found, run "firebase login:list" to see existing accounts or "firebase login:add" to add a new one`
);
}

/**
* Add an additional account to the login list.
* @param useLocalhost should the flow be interactive or code-based?
* @param email an optional hint to use for the google account picker
*/
export async function loginAdditionalAccount(useLocalhost: boolean, email?: string) {
// Log the user in using the passed email as a hint
const result = await loginGoogle(useLocalhost, email);

// The JWT library can technically return a string, even though it never should.
if (typeof result.user === "string") {
throw new FirebaseError("Failed to parse auth response, see debug log.");
}

if (email && result.user.email !== email) {
utils.logWarning(`Chosen account ${result.user.email} does not match account hint ${email}`);
}

const allAccounts = getAllAccounts();
const resultMatch = allAccounts.find((a) => a.user.email === email);
if (resultMatch) {
utils.logWarning(`Already logged in as ${email}, nothing to do`);
return;
}

const newAccount = {
user: result.user,
tokens: result.tokens,
};

const additionalAccounts = getAdditionalAccounts();
additionalAccounts.push(newAccount);

configstore.set("additionalAccounts", additionalAccounts);

return newAccount;
}

/**
* Set the default account to use with a Firebase project directory. Writes
* the setting to disk.
* @param projectDir the Firebase project directory.
* @param email email of the account.
*/
export function setProjectAccount(projectDir: string, email: string) {
logger.debug(`setProjectAccount(${projectDir}, ${email})`);
const activeAccounts: Record<string, string> = configstore.get("activeAccounts") || {};
activeAccounts[projectDir] = email;
configstore.set("activeAccounts", activeAccounts);
}

function open(url: string): void {
opn(url).catch((err) => {
logger.debug("Unable to open URL: " + err.stack);
Expand Down Expand Up @@ -223,7 +412,7 @@ async function loginWithoutLocalhost(userHint?: string): Promise<UserCredentials
// getTokensFromAuthorizationCode doesn't handle the --token case, so we know
// that we'll have a valid id_token.
return {
user: jwt.decode(tokens.id_token!)!,
user: jwt.decode(tokens.id_token!) as User,
tokens: tokens,
scopes: SCOPES,
};
Expand All @@ -243,7 +432,7 @@ async function loginWithLocalhostGoogle(port: number, userHint?: string): Promis
// getTokensFromAuthoirzationCode doesn't handle the --token case, so we know we'll
// always have an id_token.
return {
user: jwt.decode(tokens.id_token!)!,
user: jwt.decode(tokens.id_token!) as User,
tokens: tokens,
scopes: tokens.scopes!,
};
Expand Down Expand Up @@ -329,6 +518,10 @@ export async function loginGithub(): Promise<string> {
return loginWithLocalhostGitHub(port);
}

export function findAccountByEmail(email: string): Account | undefined {
return getAllAccounts().find((a) => a.user.email === email);
}

function haveValidTokens(refreshToken: string, authScopes: string[]) {
if (!lastAccessToken?.access_token) {
const tokens = configstore.get("tokens");
Expand All @@ -348,15 +541,58 @@ function haveValidTokens(refreshToken: string, authScopes: string[]) {
return hasTokens && hasSameScopes && !isExpired;
}

function logoutCurrentSession(refreshToken: string) {
const tokens = configstore.get("tokens");
const currentToken = tokens?.refresh_token;
if (refreshToken === currentToken) {
function deleteAccount(account: Account) {
// Check the global default user
const defaultAccount = getGlobalDefaultAccount();
if (account.user.email === defaultAccount?.user.email) {
configstore.delete("user");
configstore.delete("tokens");
configstore.delete("usage");
configstore.delete("analytics-uuid");
}

// Check all additional users
const additionalAccounts = getAdditionalAccounts();
const remainingAccounts = additionalAccounts.filter((a) => a.user.email !== account.user.email);
configstore.set("additionalAccounts", remainingAccounts);

// Clear any matching project defaults
const activeAccounts: Record<string, string> = configstore.get("activeAccounts") || {};
for (const [projectDir, projectAccount] of Object.entries(activeAccounts)) {
if (projectAccount === account.user.email) {
delete activeAccounts[projectDir];
}
}
configstore.set("activeAccounts", activeAccounts);
}

function updateAccount(account: Account) {
const defaultAccount = getGlobalDefaultAccount();
if (account.user.email === defaultAccount?.user.email) {
configstore.set("user", account.user);
configstore.set("tokens", account.tokens);
}

const additionalAccounts = getAdditionalAccounts();
const accountIndex = additionalAccounts.findIndex((a) => a.user.email === account.user.email);
if (accountIndex >= 0) {
additionalAccounts.splice(accountIndex, 1, account);
configstore.set("additionalAccounts", additionalAccounts);
}
}

function findAccountByRefreshToken(refreshToken: string): Account | undefined {
return getAllAccounts().find((a) => a.tokens.refresh_token === refreshToken);
}

function logoutCurrentSession(refreshToken: string) {
const account = findAccountByRefreshToken(refreshToken);
if (!account) {
return;
}

clearCredentials(account);
deleteAccount(account);
}

async function refreshTokens(
Expand Down Expand Up @@ -394,10 +630,12 @@ async function refreshTokens(
res.body
);

const currentRefreshToken = configstore.get("tokens")?.refresh_token;
if (refreshToken === currentRefreshToken) {
configstore.set("tokens", lastAccessToken);
const account = findAccountByRefreshToken(refreshToken);
if (account && lastAccessToken) {
account.tokens = lastAccessToken;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Why is this account.tokens if it only has one token?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Trying to keep terminology similar where I can. The "tokens" key has been used in configstore to refer to the object of type Tokens which contains access and refresh tokens. This is the same type.

updateAccount(account);
}

return lastAccessToken!;
} catch (err) {
if (err?.context?.body?.error === "invalid_scope") {
Expand Down
Loading