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

Add "whoami" command #572

Merged
merged 4 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/pink-lemons-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"replayio": patch
---

Add "whoami" command to print information about the current user and API key
3 changes: 2 additions & 1 deletion packages/replayio/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { initLogger } from "@replay-cli/shared/logger";
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import { setUserAgent } from "@replay-cli/shared/userAgent";
import { name, version } from "../package.json";
Expand All @@ -14,7 +15,7 @@ import "./commands/remove";
import "./commands/update";
import "./commands/upload";
import "./commands/upload-source-maps";
import { initLogger } from "@replay-cli/shared/logger";
import "./commands/whoami";

initLogger(name, version);

Expand Down
6 changes: 3 additions & 3 deletions packages/replayio/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import { registerCommand } from "../utils/commander/registerCommand";
import { checkAuthentication } from "../utils/initialization/checkAuthentication";
import { promptForAuthentication } from "../utils/initialization/promptForAuthentication";

registerCommand("login").description("Log into your Replay account (or register)").action(login);

async function login() {
const authenticated = await checkAuthentication();
if (authenticated) {
const { accessToken } = await getAccessToken();
if (accessToken) {
console.log("You are already signed in!");
} else {
await promptForAuthentication();
Expand Down
10 changes: 5 additions & 5 deletions packages/replayio/src/commands/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ registerCommand("logout").description("Log out of your Replay account").action(l
async function logout() {
await logoutIfAuthenticated();

const token = await getAccessToken();
if (token) {
const name = process.env.REPLAY_API_KEY ? "REPLAY_API_KEY" : "RECORD_REPLAY_API_KEY";

const { accessToken, apiKeySource } = await getAccessToken();
if (accessToken && apiKeySource) {
console.log(
`You are now signed out but still authenticated via the ${highlight(name)} env variable`
`You have been signed out but you are still authenticated by the ${highlight(
apiKeySource
)} env variable`
);
} else {
console.log("You are now signed out");
Expand Down
3 changes: 2 additions & 1 deletion packages/replayio/src/commands/upload-source-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ async function uploadSourceMaps(
root?: string;
}
) {
const { accessToken } = await getAccessToken();
const uploadPromise = uploadSourceMapsExternal({
extensions,
filepaths: filePaths,
group,
ignore,
key: await getAccessToken(),
key: accessToken,
root,
server: replayApiServer,
});
Expand Down
55 changes: 55 additions & 0 deletions packages/replayio/src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
import { getAuthInfo } from "@replay-cli/shared/graphql/getAuthInfo";
import { exitProcess } from "@replay-cli/shared/process/exitProcess";
import { dim, emphasize, highlight, link } from "@replay-cli/shared/theme";
import { name as packageName } from "../../package.json";
import { registerCommand } from "../utils/commander/registerCommand";
import { fetchViewerFromGraphQL } from "../utils/graphql/fetchViewerFromGraphQL";

registerCommand("whoami", {
checkForNpmUpdate: false,
checkForRuntimeUpdate: false,
requireAuthentication: false,
})
.description("Display info about the current user")
.action(info);

const DOCS_URL = "https://docs.replay.io/reference/api-keys";

async function info() {
const { accessToken, apiKeySource } = await getAccessToken();
if (accessToken) {
const authInfo = await getAuthInfo(accessToken);

const { userEmail, userName, teamName } = await fetchViewerFromGraphQL(accessToken);

if (apiKeySource) {
console.log(`You are authenticated by API key ${dim(`(process.env.${apiKeySource})`)}`);
console.log("");
if (authInfo.type === "user") {
console.log(`This API key belongs to ${emphasize(userName)} (${userEmail})`);
console.log(`Recordings you upload are ${emphasize("private")} by default`);
} else {
console.log(`This API key belongs to the team named ${emphasize(teamName)}`);
console.log(`Recordings you upload are ${emphasize("shared")} with other team members`);
}
console.log("");
console.log(`Learn more about API keys at ${link(DOCS_URL)}`);
} else {
console.log(`You signed in as ${emphasize(userName)} (${userEmail})`);
console.log("");
console.log(`Recordings you upload are ${emphasize("private")} by default`);
console.log("");
console.log(`Learn about other ways to sign in at ${link(DOCS_URL)}`);
}
} else {
console.log("You are not authenticated");
console.log("");
console.log(`Sign in by running ${highlight(`${packageName} login`)}`);
console.log("");
console.log("You can also authenticate with an API key");
console.log(`Learn more at ${link(DOCS_URL)}`);
}

await exitProcess(0);
}
7 changes: 3 additions & 4 deletions packages/replayio/src/utils/browser/reportBrowserCrash.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
import { getReplayPath } from "@replay-cli/shared/getReplayPath";
import { logger } from "@replay-cli/shared/logger";
import { getUserAgent } from "@replay-cli/shared/userAgent";
import { readFile, writeFileSync } from "fs-extra";
import { File, FormData, fetch } from "undici";
import { replayApiServer } from "../../config";
import { getUserAgent } from "@replay-cli/shared/userAgent";
import { checkAuthentication } from "../../utils/initialization/checkAuthentication";
import { getCurrentRuntimeMetadata } from "../../utils/initialization/getCurrentRuntimeMetadata";
import { runtimeMetadata } from "../../utils/installation/config";
import { findMostRecentFile } from "../findMostRecentFile";
Expand All @@ -13,8 +13,7 @@ export async function reportBrowserCrash(stderr: string) {
const errorLogPath = getReplayPath("recorder-crash.log");
writeFileSync(errorLogPath, stderr, "utf8");

const accessToken = await checkAuthentication();

const { accessToken } = await getAccessToken();
if (!accessToken) {
return {
errorLogPath,
Expand Down
68 changes: 68 additions & 0 deletions packages/replayio/src/utils/graphql/fetchViewerFromGraphQL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { GraphQLError } from "@replay-cli/shared/graphql/GraphQLError";
import { queryGraphQL } from "@replay-cli/shared/graphql/queryGraphQL";
import { logger } from "@replay-cli/shared/logger";

export type AuthInfo = {
userEmail: string | undefined;
userName: string | undefined;
teamName: string | undefined;
};

export async function fetchViewerFromGraphQL(accessToken: string): Promise<AuthInfo> {
logger.debug("Fetching viewer info from GraphQL");

const { data, errors } = await queryGraphQL(
"ViewerInfo",
`
query ViewerInfo {
viewer {
email
user {
name
}
}
auth {
workspaces {
edges {
node {
name
}
}
}
}
}
`,
{},
accessToken
);

if (errors) {
throw new GraphQLError("Failed to fetch auth info", errors);
}

const response = data as {
viewer: {
email: string;
user: {
name: string;
} | null;
};
auth: {
workspaces: {
edges: {
node: {
name: string;
};
}[];
};
};
};

const { viewer, auth } = response;

return {
userEmail: viewer?.email,
userName: viewer?.user?.name,
teamName: auth?.workspaces?.edges?.[0]?.node?.name,
};
}

This file was deleted.

4 changes: 2 additions & 2 deletions packages/replayio/src/utils/initialization/initialize.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { raceWithTimeout } from "@replay-cli/shared/async/raceWithTimeout";
import { getAccessToken } from "@replay-cli/shared/authentication/getAccessToken";
import { initLaunchDarklyFromAccessToken } from "@replay-cli/shared/launch-darkly/initLaunchDarklyFromAccessToken";
import { initMixpanelForUserSession } from "@replay-cli/shared/mixpanel/initMixpanelForUserSession";
import { name as packageName, version as packageVersion } from "../../../package.json";
import { logPromise } from "../async/logPromise";
import { checkAuthentication } from "./checkAuthentication";
import { checkForNpmUpdate } from "./checkForNpmUpdate";
import { checkForRuntimeUpdate } from "./checkForRuntimeUpdate";
import { promptForAuthentication } from "./promptForAuthentication";
Expand All @@ -22,7 +22,7 @@ export async function initialize({
// These initialization steps can run in parallel to improve startup time
// None of them should log anything though; that would interfere with the initialization-in-progress message
const promises = Promise.all([
checkAuthentication(),
getAccessToken().then(({ accessToken }) => accessToken),
shouldCheckForRuntimeUpdate
? raceWithTimeout(checkForRuntimeUpdate(), 5_000)
: Promise.resolve(),
Expand Down
36 changes: 26 additions & 10 deletions packages/shared/src/authentication/getAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,58 @@ import { cachedAuthPath } from "./config";
import { refreshAccessTokenOrThrow } from "./refreshAccessTokenOrThrow";
import { CachedAuthDetails } from "./types";

export async function getAccessToken(): Promise<string | undefined> {
export type AccessTokenInfo = {
accessToken: string | undefined;
apiKeySource: "REPLAY_API_KEY" | "RECORD_REPLAY_API_KEY" | undefined;
};

const NO_ACCESS_TOKEN: AccessTokenInfo = {
accessToken: undefined,
apiKeySource: undefined,
};

export async function getAccessToken(): Promise<AccessTokenInfo> {
if (process.env.REPLAY_API_KEY) {
logger.debug("Using token from env (REPLAY_API_KEY)");
return process.env.REPLAY_API_KEY;
return {
accessToken: process.env.REPLAY_API_KEY,
apiKeySource: "REPLAY_API_KEY",
};
} else if (process.env.RECORD_REPLAY_API_KEY) {
logger.debug("Using token from env (RECORD_REPLAY_API_KEY)");
return process.env.RECORD_REPLAY_API_KEY;
return {
accessToken: process.env.RECORD_REPLAY_API_KEY,
apiKeySource: "RECORD_REPLAY_API_KEY",
};
}

let { accessToken, refreshToken } = readFromCache<CachedAuthDetails>(cachedAuthPath) ?? {};
if (typeof accessToken !== "string") {
logger.debug("Unexpected accessToken value", { accessToken });
return;
return NO_ACCESS_TOKEN;
}
if (typeof refreshToken !== "string") {
logger.debug("Unexpected refreshToken", { refreshToken });
return;
return NO_ACCESS_TOKEN;
}

const [_, encodedToken, __] = accessToken.split(".", 3);
if (typeof encodedToken !== "string") {
logger.debug("Token did not contain a valid payload", { accessToken: maskString(accessToken) });
return;
return NO_ACCESS_TOKEN;
}

let payload: any;
try {
payload = JSON.parse(Buffer.from(encodedToken, "base64").toString());
} catch (error) {
logger.debug("Failed to decode token", { accessToken: maskString(accessToken), error });
return;
return NO_ACCESS_TOKEN;
}

if (typeof payload !== "object") {
logger.debug("Token payload was not an object");
return;
return NO_ACCESS_TOKEN;
}

const expiration = (payload?.exp ?? 0) * 1000;
Expand All @@ -57,11 +73,11 @@ export async function getAccessToken(): Promise<string | undefined> {
} catch (error) {
writeToCache(cachedAuthPath, undefined);
updateCachedAuthInfo(accessToken, undefined);
return;
return NO_ACCESS_TOKEN;
}
} else {
logger.debug(`Access token valid until ${expirationDate.toLocaleDateString()}`);
}

return accessToken;
return { accessToken, apiKeySource: undefined };
}
2 changes: 1 addition & 1 deletion packages/shared/src/graphql/getAuthInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type Cached = {

export async function getAuthInfo(accessToken: string): Promise<AuthInfo> {
const cached = readFromCache<Cached>(cachePath) ?? {};
let authInfo = cached[accessToken];

let authInfo = cached[accessToken];
if (!authInfo) {
authInfo = await fetchAuthInfoFromGraphQL(accessToken);

Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/protocol/ProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export default class ProtocolClient {

private onSocketOpen = async () => {
try {
const accessToken = await getAccessToken();
const { accessToken } = await getAccessToken();
assert(accessToken, "No access token found");

await setAccessToken(this, { accessToken });
Expand Down