Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/webapp/app/components/UserProfilePhoto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function UserAvatar({
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
) : (
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ const EnvironmentSchema = z
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
Expand Down
110 changes: 109 additions & 1 deletion apps/webapp/app/models/user.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import type { GoogleProfile } from "remix-auth-google";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import {
Expand All @@ -20,7 +21,14 @@ type FindOrCreateGithub = {
authenticationExtraParams: Record<string, unknown>;
};

type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
authenticationProfile: GoogleProfile;
authenticationExtraParams: Record<string, unknown>;
};

type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;

type LoggedInUser = {
user: User;
Expand All @@ -35,6 +43,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
}
}

Expand Down Expand Up @@ -162,6 +173,103 @@ export async function findOrCreateGithubUser({
};
}

export async function findOrCreateGoogleUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGoogle): Promise<LoggedInUser> {
assertEmailAllowed(email);

const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;

const authIdentifier = `google:${authenticationProfile.id}`;

const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});

const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});

if (existingEmailUser && !existingUser) {
// Link existing email account to Google auth
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationMethod: "GOOGLE",
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});

return {
user,
isNewUser: false,
};
}

if (existingEmailUser && existingUser) {
// User already linked to Google, update profile info
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
});

return {
user,
isNewUser: false,
};
}

const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GOOGLE",
},
});

return {
user,
isNewUser: !existingUser,
};
}

export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
Expand Down
21 changes: 11 additions & 10 deletions apps/webapp/app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";
Expand Down Expand Up @@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo);

return redirect("/login/mfa", {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));

return redirect("/login/mfa", { headers });
}

// and store the user data
session.set(authenticator.sessionKey, auth);

return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));

return redirect(redirectTo, { headers });
};
61 changes: 61 additions & 0 deletions apps/webapp/app/routes/auth.google.callback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { prisma } from "~/db.server";
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.google";
import { sanitizeRedirectPath } from "~/utils";

export let loader: LoaderFunction = async ({ request }) => {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const redirectTo = sanitizeRedirectPath(redirectValue);

const auth = await authenticator.authenticate("google", request, {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
});

// manually get the session
const session = await getSession(request.headers.get("cookie"));

const userRecord = await prisma.user.findFirst({
where: {
id: auth.userId,
},
select: {
id: true,
mfaEnabledAt: true,
},
});

if (!userRecord) {
return redirectWithErrorMessage(
"/login",
request,
"Could not find your account. Please contact support."
);
}

if (userRecord.mfaEnabledAt) {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));

return redirect("/login/mfa", { headers });
}

// and store the user data
session.set(authenticator.sessionKey, auth);

const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));

return redirect(redirectTo, { headers });
};

33 changes: 33 additions & 0 deletions apps/webapp/app/routes/auth.google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";

export let loader: LoaderFunction = () => redirect("/login");

export let action: ActionFunction = async ({ request }) => {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo");

try {
// call authenticate as usual, in successRedirect use returnTo or a fallback
return await authenticator.authenticate("google", request, {
successRedirect: redirectTo ?? "/",
failureRedirect: "/login",
});
} catch (error) {
// here we catch anything authenticator.authenticate throw, this will
// include redirects
// if the error is a Response and is a redirect
if (error instanceof Response) {
// we need to append a Set-Cookie header with a cookie storing the
// returnTo value
error.headers.append("Set-Cookie", await redirectCookie.serialize(redirectTo));
}
throw error;
}
};

export const redirectCookie = createCookie("google-redirect-to", {
maxAge: 60 * 60, // 1 hour
httpOnly: true,
});

Loading