-
Notifications
You must be signed in to change notification settings - Fork 928
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
Changes from 23 commits
98faa1e
75dcfdf
4f7aa73
1b203e3
a83bf46
575fb0c
25d4347
8a06b92
e3d2e78
8c9493a
a8aef0a
209e209
65a9bf3
b9f5379
0397763
c01acf5
22c9cdd
851323f
17e3dde
0d409f8
d8754a4
12fa104
bfd9211
a6a5cd7
b2bf7ca
faea732
f3ec559
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
||
|
@@ -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`, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
} | ||
|
@@ -36,7 +58,7 @@ interface TokensWithTTL extends Tokens { | |
} | ||
|
||
interface UserCredentials { | ||
user: string | { [key: string]: unknown }; | ||
user: string | User; | ||
tokens: TokensWithExpiration; | ||
scopes: string[]; | ||
} | ||
|
@@ -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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comes from |
||
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[] { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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... There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
@@ -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, | ||
}; | ||
|
@@ -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!, | ||
}; | ||
|
@@ -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"); | ||
|
@@ -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( | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Why is this account.tokens if it only has one token? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trying to keep terminology similar where I can. The |
||
updateAccount(account); | ||
} | ||
|
||
return lastAccessToken!; | ||
} catch (err) { | ||
if (err?.context?.body?.error === "invalid_scope") { | ||
|
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?