diff --git a/src/auth.ts b/src/auth.ts index 8bdbe6b2d8b..f04c6425d22 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -425,6 +425,67 @@ function urlsafeBase64(base64string: string) { return base64string.replace(/\+/g, "-").replace(/=+$/, "").replace(/\//g, "_"); } +interface PrototyperRes { + uri: string; + sessionId: string; + authorize: (authorizationCode: string) => Promise; +} + +export async function loginPrototyper(): Promise { + 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}&studio_prototyper=true}`; + return { + uri: loginUrl, + sessionId: sessionId.substring(0, 5).toUpperCase(), + authorize: async (code: string) => { + try { + 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; + } catch (e) { + throw new FirebaseError( + "Unable to authenticate using the provided code. Please try again.", + ); + } + }, + }; +} + +// 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 { const authProxyClient = new apiv2.Client({ urlPrefix: authProxyOrigin(), diff --git a/src/commands/login.ts b/src/commands/login.ts index 9c4dbc768de..707bde39b08 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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") + @@ -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", @@ -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 await 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() ? false : !!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") {