Skip to content
57 changes: 56 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@
return getGlobalDefaultAccount();
}

const activeAccounts = configstore.get("activeAccounts") || {};

Check warning on line 74 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
const email: string | undefined = activeAccounts[projectDir];

Check warning on line 75 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access [projectDir] on an `any` value

Check warning on line 75 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

if (!email) {
return getGlobalDefaultAccount();
Expand All @@ -86,7 +86,7 @@
* Get all authenticated accounts _besides_ the default account.
*/
export function getAdditionalAccounts(): Account[] {
return configstore.get("additionalAccounts") || [];

Check warning on line 89 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

/**
Expand All @@ -108,7 +108,7 @@
/**
* Throw an error if the provided email is not a signed-in user.
*/
export function assertAccount(email: string, options?: { mcp?: boolean }) {

Check warning on line 111 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const allAccounts = getAllAccounts();
const accountExists = allAccounts.some((a) => a.user.email === email);
if (!accountExists) {
Expand All @@ -128,20 +128,20 @@
* @param options options object.
* @param account account to make active.
*/
export function setActiveAccount(options: any, account: Account) {

Check warning on line 131 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 131 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
if (account.tokens.refresh_token) {
setRefreshToken(account.tokens.refresh_token);
}

options.user = account.user;

Check warning on line 136 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .user on an `any` value
options.tokens = account.tokens;

Check warning on line 137 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .tokens on an `any` value
}

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

Check warning on line 144 in src/auth.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
apiv2.setRefreshToken(token);
}

Expand Down Expand Up @@ -425,7 +425,13 @@
return base64string.replace(/\+/g, "-").replace(/=+$/, "").replace(/\//g, "_");
}

async function loginRemotely(): Promise<UserCredentials> {
interface PrototyperRes {
uri: string;
sessionId: string;
authorize: (authorizationCode: string) => Promise<UserCredentials>;
}

export async function loginPrototyper(): Promise<PrototyperRes> {
const authProxyClient = new apiv2.Client({
urlPrefix: authProxyOrigin(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems like we call this multiple times, is this something we can just cache once and re-use?

auth: false,
Expand All @@ -442,6 +448,55 @@
})
).body?.token;

const loginUrl = `${authProxyOrigin()}/login?code_challenge=${codeChallenge}&session=${sessionId}&attest=${attestToken}&studio_prototyper=true}`;
return {
uri: loginUrl,
sessionId: sessionId.substring(0, 5).toUpperCase(),
authorize: async (code: string) => {
const tokens = await getTokensFromAuthorizationCode(
code,
`${authProxyOrigin()}/complete`,
codeVerifier,
);

const creds = {
user: jwt.decode(tokens.id_token!, { json: true }) as any as User,
tokens: tokens,
scopes: SCOPES,
};
recordCredentials(creds);
return creds;
},
};
}

// recordCredentials saves credentials to configstore to be used in future command runs.
export function recordCredentials(creds: UserCredentials) {
configstore.set("user", creds.user);
configstore.set("tokens", creds.tokens);
// store login scopes in case mandatory scopes grow over time
configstore.set("loginScopes", creds.scopes);
// remove old session token, if it exists
configstore.delete("session");
}

async function loginRemotely(): Promise<UserCredentials> {
const authProxyClient = new apiv2.Client({
urlPrefix: authProxyOrigin(),
auth: false,
});

const sessionId = uuidv4();
const codeVerifier = randomBytes(32).toString("hex");
// urlsafe base64 is required for code_challenge in OAuth PKCE
const codeChallenge = urlsafeBase64(createHash("sha256").update(codeVerifier).digest("base64"));

const attestToken = (
await authProxyClient.post<{ session_id: string }, { token: string }>("/attest", {
session_id: sessionId,
})
).body.token;

const loginUrl = `${authProxyOrigin()}/login?code_challenge=${codeChallenge}&session=${sessionId}&attest=${attestToken}`;

logger.info();
Expand Down
34 changes: 23 additions & 11 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,22 @@ import * as auth from "../auth";
import { isCloudEnvironment } from "../utils";
import { User, Tokens } from "../types/auth";

import { Options } from "../options";

export interface LoginOptions extends Options {
prototyperLogin?: boolean;
consent?: {
metrics?: boolean;
gemini?: boolean;
};
}

export const command = new Command("login")
.description("log the CLI into Firebase")
.option("--no-localhost", "login from a device without an accessible localhost")
.option("--reauth", "force reauthentication even if already logged in")
.action(async (options: any) => {
if (options.nonInteractive) {
.action(async (options: LoginOptions) => {
if (options.nonInteractive && !options.prototyperLogin) {
throw new FirebaseError(
"Cannot run login in non-interactive mode. See " +
clc.bold("login:ci") +
Expand All @@ -33,7 +43,10 @@ export const command = new Command("login")
return user;
}

if (!options.reauth) {
if (options.consent) {
options.consent?.metrics ?? configstore.set("usage", options.consent.metrics);
options.consent?.gemini ?? configstore.set("gemini", options.consent.gemini);
} else if (!options.reauth && !options.prototyperLogin) {
utils.logBullet(
"The Firebase CLI’s MCP server feature can optionally make use of Gemini in Firebase. " +
"Learn more about Gemini in Firebase and how it uses your data: https://firebase.google.com/docs/gemini-in-firebase#how-gemini-in-firebase-uses-your-data",
Expand All @@ -58,18 +71,17 @@ export const command = new Command("login")
}
}

// Special escape hatch for logging in when using firebase-tools as a module.
if (options.prototyperLogin) {
return auth.loginPrototyper();
}

// Default to using the authorization code flow when the end
// user is within a cloud-based environment, and therefore,
// the authorization callback couldn't redirect to localhost.
const useLocalhost = isCloudEnvironment() ? false : options.localhost;

const useLocalhost = !isCloudEnvironment() && !!options.localhost;
const result = await auth.loginGoogle(useLocalhost, user?.email);
configstore.set("user", result.user);
configstore.set("tokens", result.tokens);
// store login scopes in case mandatory scopes grow over time
configstore.set("loginScopes", result.scopes);
// remove old session token, if it exists
configstore.delete("session");
auth.recordCredentials(result);

logger.info();
if (typeof result.user !== "string") {
Expand Down
Loading