Skip to content

Commit b941a6e

Browse files
authored
Prototype of login via prototyper in Studio (#8938)
* Prototype of login via prototyper in Studio * Actually record it * Fleshing this out with consent and extra query param * format * pr fixes
1 parent 1fadb1e commit b941a6e

File tree

2 files changed

+79
-12
lines changed

2 files changed

+79
-12
lines changed

src/auth.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,13 @@ function urlsafeBase64(base64string: string) {
425425
return base64string.replace(/\+/g, "-").replace(/=+$/, "").replace(/\//g, "_");
426426
}
427427

428-
async function loginRemotely(): Promise<UserCredentials> {
428+
interface PrototyperRes {
429+
uri: string;
430+
sessionId: string;
431+
authorize: (authorizationCode: string) => Promise<UserCredentials>;
432+
}
433+
434+
export async function loginPrototyper(): Promise<PrototyperRes> {
429435
const authProxyClient = new apiv2.Client({
430436
urlPrefix: authProxyOrigin(),
431437
auth: false,
@@ -442,6 +448,55 @@ async function loginRemotely(): Promise<UserCredentials> {
442448
})
443449
).body?.token;
444450

451+
const loginUrl = `${authProxyOrigin()}/login?code_challenge=${codeChallenge}&session=${sessionId}&attest=${attestToken}&studio_prototyper=true}`;
452+
return {
453+
uri: loginUrl,
454+
sessionId: sessionId.substring(0, 5).toUpperCase(),
455+
authorize: async (code: string) => {
456+
const tokens = await getTokensFromAuthorizationCode(
457+
code,
458+
`${authProxyOrigin()}/complete`,
459+
codeVerifier,
460+
);
461+
462+
const creds = {
463+
user: jwt.decode(tokens.id_token!, { json: true }) as any as User,
464+
tokens: tokens,
465+
scopes: SCOPES,
466+
};
467+
recordCredentials(creds);
468+
return creds;
469+
},
470+
};
471+
}
472+
473+
// recordCredentials saves credentials to configstore to be used in future command runs.
474+
export function recordCredentials(creds: UserCredentials) {
475+
configstore.set("user", creds.user);
476+
configstore.set("tokens", creds.tokens);
477+
// store login scopes in case mandatory scopes grow over time
478+
configstore.set("loginScopes", creds.scopes);
479+
// remove old session token, if it exists
480+
configstore.delete("session");
481+
}
482+
483+
async function loginRemotely(): Promise<UserCredentials> {
484+
const authProxyClient = new apiv2.Client({
485+
urlPrefix: authProxyOrigin(),
486+
auth: false,
487+
});
488+
489+
const sessionId = uuidv4();
490+
const codeVerifier = randomBytes(32).toString("hex");
491+
// urlsafe base64 is required for code_challenge in OAuth PKCE
492+
const codeChallenge = urlsafeBase64(createHash("sha256").update(codeVerifier).digest("base64"));
493+
494+
const attestToken = (
495+
await authProxyClient.post<{ session_id: string }, { token: string }>("/attest", {
496+
session_id: sessionId,
497+
})
498+
).body.token;
499+
445500
const loginUrl = `${authProxyOrigin()}/login?code_challenge=${codeChallenge}&session=${sessionId}&attest=${attestToken}`;
446501

447502
logger.info();

src/commands/login.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,22 @@ import * as auth from "../auth";
1111
import { isCloudEnvironment } from "../utils";
1212
import { User, Tokens } from "../types/auth";
1313

14+
import { Options } from "../options";
15+
16+
export interface LoginOptions extends Options {
17+
prototyperLogin?: boolean;
18+
consent?: {
19+
metrics?: boolean;
20+
gemini?: boolean;
21+
};
22+
}
23+
1424
export const command = new Command("login")
1525
.description("log the CLI into Firebase")
1626
.option("--no-localhost", "login from a device without an accessible localhost")
1727
.option("--reauth", "force reauthentication even if already logged in")
18-
.action(async (options: any) => {
19-
if (options.nonInteractive) {
28+
.action(async (options: LoginOptions) => {
29+
if (options.nonInteractive && !options.prototyperLogin) {
2030
throw new FirebaseError(
2131
"Cannot run login in non-interactive mode. See " +
2232
clc.bold("login:ci") +
@@ -33,7 +43,10 @@ export const command = new Command("login")
3343
return user;
3444
}
3545

36-
if (!options.reauth) {
46+
if (options.consent) {
47+
options.consent?.metrics ?? configstore.set("usage", options.consent.metrics);
48+
options.consent?.gemini ?? configstore.set("gemini", options.consent.gemini);
49+
} else if (!options.reauth && !options.prototyperLogin) {
3750
utils.logBullet(
3851
"The Firebase CLI’s MCP server feature can optionally make use of Gemini in Firebase. " +
3952
"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")
5871
}
5972
}
6073

74+
// Special escape hatch for logging in when using firebase-tools as a module.
75+
if (options.prototyperLogin) {
76+
return auth.loginPrototyper();
77+
}
78+
6179
// Default to using the authorization code flow when the end
6280
// user is within a cloud-based environment, and therefore,
6381
// the authorization callback couldn't redirect to localhost.
64-
const useLocalhost = isCloudEnvironment() ? false : options.localhost;
65-
82+
const useLocalhost = !isCloudEnvironment() && !!options.localhost;
6683
const result = await auth.loginGoogle(useLocalhost, user?.email);
67-
configstore.set("user", result.user);
68-
configstore.set("tokens", result.tokens);
69-
// store login scopes in case mandatory scopes grow over time
70-
configstore.set("loginScopes", result.scopes);
71-
// remove old session token, if it exists
72-
configstore.delete("session");
84+
auth.recordCredentials(result);
7385

7486
logger.info();
7587
if (typeof result.user !== "string") {

0 commit comments

Comments
 (0)