Skip to content

Commit

Permalink
Add support for authenticating the CLI and switching accounts (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
hbenl authored Apr 4, 2024
1 parent a59ed96 commit 789c55b
Show file tree
Hide file tree
Showing 11 changed files with 189 additions and 14 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.0.10",
"chalk": "^4",
"cookie": "^0.6.0",
"date-fns": "^3.3.1",
"graphql": "^16.8.1",
"lodash": "^4.17.21",
Expand All @@ -44,6 +45,7 @@
"@playwright/test": "^1.42.1",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^14.2.1",
"@types/cookie": "^0.6.0",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.14.202",
"@types/node": "^20",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const COOKIES = {
accessToken: "replay:dashboard:access-token",
browserAuth: "replay:browser-auth",
defaultPathname: "replay:dashboard:default-pathname",
mobileWarningDismissed: "replay:dashboard:mobile-warning-dismissed",
testRunsFilters: "replay:dashboard:test-runs-filters",
Expand All @@ -14,3 +15,8 @@ export const HEADERS = {
};

export const LOCAL_STORAGE = {};

export const URLS = {
api: "https://api.replay.io",
app: process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : process.env.APP_URL!,
};
3 changes: 2 additions & 1 deletion src/graphql/graphQLClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { URLS } from "@/constants";
import {
ApolloClient,
InMemoryCache,
Expand All @@ -23,7 +24,7 @@ export function getGraphQLClient(accessToken: string) {
"Content-Type": "application/json",
"Replay-Client-Id": "196a9e7b-dba5-46ee-8b81-fac66991f431",
},
uri: "https://api.replay.io/v1/graphql",
uri: `${URLS.api}/v1/graphql`,
});
const retryLink = new RetryLink({
attempts: {
Expand Down
37 changes: 37 additions & 0 deletions src/graphql/queries/fulfillAuthRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { URLS } from "@/constants";

export async function fulfillAuthRequest(id: string, token: string) {
const resp = await fetch(`${URLS.api}/v1/graphql`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation FulfillAuthRequest($secret: String!, $id: String!, $token: String!) {
fulfillAuthRequest(input: {secret: $secret, id: $id, token: $token}) {
success
source
}
}
`,
variables: {
secret: "omNN-4K*GiHhqUH8-7mUB6Ecz8ZPBtcqH68V",
id,
token,
},
}),
});

const json = await resp.json();

if (json.errors) {
throw new Error(json.errors[0].message);
}

if (!json.data.fulfillAuthRequest.success) {
throw new Error("Failed to fulfill authentication request");
}

return json.data.fulfillAuthRequest.source;
}
33 changes: 33 additions & 0 deletions src/graphql/queries/initAuthRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { URLS } from "@/constants";

export async function initAuthRequest(key: string, source: string) {
const resp = await fetch(`${URLS.api}/v1/graphql`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
mutation InitAutRequest($key: String!, $source: String = "browser") {
initAuthRequest(input: {key: $key, source: $source}) {
id
challenge
serverKey
}
}
`,
variables: {
key,
source,
},
}),
});

const json: any = await resp.json();

if (json.errors) {
throw new Error(json.errors[0].message);
}

return json.data.initAuthRequest;
}
16 changes: 11 additions & 5 deletions src/pages/api/auth/[auth0].ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { handleAuth, handleLogin } from "@auth0/nextjs-auth0";

const authorizationParams = {
audience: "https://api.replay.io",
code_challenge_method: "S256",
response_type: "code" as "code",
scope: "openid profile offline_access",
};

export default handleAuth({
login: handleLogin({
login: handleLogin({ authorizationParams }),
switchAccount: handleLogin({
authorizationParams: {
audience: "https://api.replay.io",
code_challenge_method: "S256",
response_type: "code",
scope: "openid profile offline_access",
...authorizationParams,
prompt: "login",
},
}),
});
56 changes: 56 additions & 0 deletions src/pages/api/browser/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { COOKIES, URLS } from "@/constants";
import cookie from "cookie";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "@auth0/nextjs-auth0";
import { initAuthRequest } from "@/graphql/queries/initAuthRequest";
import { fulfillAuthRequest } from "@/graphql/queries/fulfillAuthRequest";

const getQueryValue = (query: string | string[] | undefined) => (Array.isArray(query) ? query[0] : query);

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const key = getQueryValue(req.query.key);
const source = getQueryValue(req.query.source) || "browser";

try {
if (key) {
const { id } = await initAuthRequest(key, source);

res.setHeader(
"Set-Cookie",
cookie.serialize(COOKIES.browserAuth, id, {
secure: URLS.app.startsWith("https://"),
httpOnly: true,
path: "/",
maxAge: 5 * 60 * 1000,
})
);

res.redirect("/login?returnTo=/api/browser/auth");
} else {
const browserAuth = req.cookies[COOKIES.browserAuth];

if (!browserAuth) {
res.statusCode = 400;
res.statusMessage = "Missing cookie";
res.send("");

return;
}

const session = await getSession(req, res);
if (session?.refreshToken) {
const source = await fulfillAuthRequest(browserAuth, session.refreshToken);
res.redirect(`/browser/authenticated?source=${source}`);
} else {
res.statusCode = 400;
res.statusMessage = "Missing refresh token";
res.send("");
}
}
} catch (e: any) {
console.error(e);

res.statusCode = 500;
res.send("");
}
};
3 changes: 2 additions & 1 deletion src/pages/api/releases.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { URLS } from "@/constants";
import { NextApiRequest, NextApiResponse } from "next";

export type Release = {
Expand All @@ -19,7 +20,7 @@ export default async function handler(
}

export async function fetchReleases() {
const response = await fetch("https://api.replay.io/v1/releases");
const response = await fetch(`${URLS.api}/v1/releases`);
const releases = await response.json();
return releases as Release[];
}
20 changes: 20 additions & 0 deletions src/pages/browser/authenticated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { EmptyLayout } from "@/components/EmptyLayout";
import { Message } from "@/components/Message";
import { ReplayLogo } from "@/components/ReplayLogo";
import React from "react";

export default function Page() {
return (
<Message className="max-w-96 p-8 gap-8 text-center">
<ReplayLogo className="text-white min-w-20 min-h-20" />
<div className="font-bold text-xl">
Authentication Complete
</div>
<div>
You have successfully logged in. You may close this window.
</div>
</Message>
);
};

Page.Layout = EmptyLayout;
17 changes: 10 additions & 7 deletions src/pages/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { Message } from "@/components/Message";
import { ReplayLogo } from "@/components/ReplayLogo";
import { getSession } from "@auth0/nextjs-auth0";
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";

export default function Page({
user,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
const searchParams = useSearchParams();
const returnTo = searchParams?.get("returnTo") || "/";

if (user) {
return (
Expand All @@ -19,19 +21,20 @@ export default function Page({
<div>
You are already logged in as <strong>{user.name}</strong>.
</div>
<Button onClick={() => router.push("/")} size="large">
Continue to Library
<Button onClick={() => router.push(returnTo)} size="large">
{returnTo === "/" ? "Continue to Library" : "Continue with this account"}
</Button>
{/* TODO [FE-2379] Support account switcher
{
globalThis.__IS_RECORD_REPLAY_RUNTIME__ || (
<Button
onClick={() => router.push("/api/auth/login")}
onClick={() => router.push(`/api/auth/switchAccount?returnTo=${returnTo}`)}
size="large"
variant="outline"
>
Switch accounts
</Button>
)
*/}
}
</Message>
);
} else {
Expand All @@ -46,7 +49,7 @@ export default function Page({
Learn more
</ExternalLink>
</div>
<Button onClick={() => router.push("/api/auth/login")} size="large">
<Button onClick={() => router.push(`/api/auth/login?returnTo=${returnTo}`)} size="large">
Sign in with Google
</Button>
</Message>
Expand Down

0 comments on commit 789c55b

Please sign in to comment.