-
Notifications
You must be signed in to change notification settings - Fork 15
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
🐛 Fix: Pre-emptively trigger login after google token expired #212
base: main
Are you sure you want to change the base?
Changes from all commits
ad5c40b
bcd99b2
5c77f5e
3563c4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,11 +20,14 @@ import { | |
findCompassUserBy, | ||
updateGoogleRefreshToken, | ||
} from "@backend/user/queries/user.queries"; | ||
import GoogleAuthService from "@backend/auth/services/google.auth.service"; | ||
import GoogleAuthService, { | ||
getGAuthClientForUser, | ||
} from "@backend/auth/services/google.auth.service"; | ||
import userService from "@backend/user/services/user.service"; | ||
import compassAuthService from "@backend/auth/services/compass.auth.service"; | ||
import syncService from "@backend/sync/services/sync.service"; | ||
import { isInvalidGoogleToken } from "@backend/common/services/gcal/gcal.utils"; | ||
import { BaseError } from "@core/errors/errors.base"; | ||
|
||
import { initGoogleClient } from "../services/auth.utils"; | ||
|
||
|
@@ -63,6 +66,33 @@ class AuthController { | |
res.promise({ userId }); | ||
}; | ||
|
||
verifyGToken = async (req: SessionRequest, res: Res_Promise) => { | ||
try { | ||
const userId = req.session?.getUserId(); | ||
|
||
if (!userId) { | ||
res.promise({ valid: false, error: "No session found" }); | ||
return; | ||
} | ||
|
||
const gAuthClient = await getGAuthClientForUser({ _id: userId }); | ||
|
||
// Upon receiving an access token, we know the session is valid | ||
const accessToken = await gAuthClient.getAccessToken(); | ||
|
||
res.promise({ valid: true }); | ||
} catch (error) { | ||
if (error instanceof BaseError && error.result === "No access token") { | ||
res.promise({ valid: false, error: "Invalid Google Token" }); | ||
} else { | ||
res.promise({ | ||
valid: null, // We don't know if the session is valid or not, so we return null | ||
error, | ||
}); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
}; | ||
|
||
loginOrSignup = async (req: SReqBody<{ code: string }>, res: Res_Promise) => { | ||
try { | ||
const { code } = req.body; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,17 +3,56 @@ import { OAuth2Client, TokenPayload } from "google-auth-library"; | |
import { Logger } from "@core/logger/winston.logger"; | ||
import { Status } from "@core/errors/status.codes"; | ||
import { BaseError } from "@core/errors/errors.base"; | ||
import { gCalendar } from "@core/types/gcal"; | ||
import { UserInfo_Google } from "@core/types/auth.types"; | ||
import { ENV } from "@backend/common/constants/env.constants"; | ||
import { findCompassUserBy } from "@backend/user/queries/user.queries"; | ||
import { UserError } from "@backend/common/constants/error.constants"; | ||
import { error } from "@backend/common/errors/handlers/error.handler"; | ||
import { Schema_User } from "@core/types/user.types"; | ||
import { WithId } from "mongodb"; | ||
|
||
import compassAuthService from "./compass.auth.service"; | ||
|
||
const logger = Logger("app:google.auth.service"); | ||
|
||
export const getGcalClient = async (userId: string) => { | ||
export const getGAuthClientForUser = async ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted as its own function for reusability. |
||
user: WithId<Schema_User> | { _id: string } | ||
) => { | ||
const gAuthClient = new GoogleAuthService(); | ||
|
||
let gRefreshToken: string | undefined; | ||
|
||
if ("google" in user && user.google) { | ||
gRefreshToken = user.google.gRefreshToken; | ||
} | ||
|
||
if (!gRefreshToken) { | ||
const userId = "_id" in user ? (user._id as string) : undefined; | ||
|
||
if (!userId) { | ||
logger.error(`Expected to either get a user or a userId.`); | ||
throw error(UserError.InvalidValue, "User not found"); | ||
} | ||
|
||
const _user = await findCompassUserBy("_id", userId); | ||
|
||
if (!_user) { | ||
logger.error(`Couldn't find user with this id: ${userId}`); | ||
throw error(UserError.UserNotFound, "User not found"); | ||
} | ||
|
||
gRefreshToken = _user.google.gRefreshToken; | ||
} | ||
|
||
gAuthClient.oauthClient.setCredentials({ | ||
refresh_token: gRefreshToken, | ||
}); | ||
|
||
return gAuthClient; | ||
}; | ||
|
||
export const getGcalClient = async (userId: string): Promise<gCalendar> => { | ||
const user = await findCompassUserBy("_id", userId); | ||
if (!user) { | ||
logger.error(`Couldn't find user with this id: ${userId}`); | ||
|
@@ -24,11 +63,7 @@ export const getGcalClient = async (userId: string) => { | |
); | ||
} | ||
|
||
const gAuthClient = new GoogleAuthService(); | ||
|
||
gAuthClient.oauthClient.setCredentials({ | ||
refresh_token: user.google.gRefreshToken, | ||
}); | ||
const gAuthClient = await getGAuthClientForUser(user); | ||
|
||
const calendar = google.calendar({ | ||
version: "v3", | ||
|
@@ -49,7 +84,7 @@ class GoogleAuthService { | |
); | ||
} | ||
|
||
getGcalClient() { | ||
getGcalClient(): gCalendar { | ||
const gcal = google.calendar({ | ||
version: "v3", | ||
auth: this.oauthClient, | ||
|
@@ -82,6 +117,21 @@ class GoogleAuthService { | |
const payload = ticket.getPayload() as TokenPayload; | ||
return payload; | ||
} | ||
|
||
async getAccessToken() { | ||
const { token } = await this.oauthClient.getAccessToken(); | ||
|
||
if (!token) { | ||
throw new BaseError( | ||
"No access token", | ||
"oauth client is missing access token. Probably needs a new refresh token to obtain a new access token", | ||
Status.BAD_REQUEST, | ||
false | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than throwing a new Then throw it using the
The Try not to include too many implementation details in the result string, like "Probably needs a new refresh token to obtain a new access token." We don't want to send unnecessary info to the client. Instead, you can include that info in a debug log. This'll make it easier to test, typecheck, and prevent bugs |
||
|
||
return token; | ||
} | ||
} | ||
|
||
export default GoogleAuthService; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,21 +3,32 @@ import Session from "supertokens-auth-react/recipe/session"; | |
import { useNavigate } from "react-router-dom"; | ||
import { ROOT_ROUTES } from "@web/common/constants/routes"; | ||
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; | ||
import { validateGoogleAccessToken } from "@web/auth/gauth.util"; | ||
|
||
export const ProtectedRoute = ({ children }: { children: ReactNode }) => { | ||
const navigate = useNavigate(); | ||
|
||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); | ||
|
||
useLayoutEffect(() => { | ||
async function fetchSession() { | ||
const isAuthenticated = await Session.doesSessionExist(); | ||
async function ensureAuthentication() { | ||
const isSessionValid = await Session.doesSessionExist(); | ||
const isGAccessTokenValid = await validateGoogleAccessToken(); | ||
|
||
const isAuthenticated = isSessionValid && isGAccessTokenValid; | ||
setIsAuthenticated(isAuthenticated); | ||
if (!isAuthenticated) { | ||
navigate(ROOT_ROUTES.LOGIN); | ||
const dueToGAuth = !!isSessionValid && !isGAccessTokenValid; | ||
|
||
if (dueToGAuth) { | ||
navigate(`${ROOT_ROUTES.LOGIN}?reason=gauth-session-expired`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create a constant for the reason instead of just using the string here. This'll make it easier to test, access across other files, and rename it when things change |
||
} else { | ||
navigate(ROOT_ROUTES.LOGIN); | ||
} | ||
} | ||
} | ||
|
||
void fetchSession(); | ||
void ensureAuthentication(); | ||
}, [navigate]); | ||
|
||
if (isAuthenticated === null) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { ENV_WEB } from "@web/common/constants/env.constants"; | ||
|
||
export const validateGoogleAccessToken = async () => { | ||
try { | ||
const res = await fetch(`${ENV_WEB.API_BASEURL}/auth/google`, { | ||
method: "GET", | ||
credentials: "include", | ||
}); | ||
|
||
if (!res.ok) return false; | ||
|
||
const body = (await res.json()) as { valid: boolean }; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use the shared response type when casting here |
||
|
||
return !!body.valid; | ||
} catch (error) { | ||
console.error(error); | ||
return false; | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,16 @@ | ||
import { v4 as uuidv4 } from "uuid"; | ||
import React, { useEffect, useRef, useState } from "react"; | ||
import { Navigate } from "react-router-dom"; | ||
import { Navigate, useSearchParams } from "react-router-dom"; | ||
import GoogleButton from "react-google-button"; | ||
import Session from "supertokens-auth-react/recipe/session"; | ||
import Session, { signOut } from "supertokens-auth-react/recipe/session"; | ||
import { validateGoogleAccessToken } from "@web/auth/gauth.util"; | ||
import { useGoogleLogin } from "@react-oauth/google"; | ||
import { AlignItems, FlexDirections } from "@web/components/Flex/styled"; | ||
import { AuthApi } from "@web/common/apis/auth.api"; | ||
import { ROOT_ROUTES } from "@web/common/constants/routes"; | ||
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader"; | ||
import { toast } from "react-toastify"; | ||
import { SyncApi } from "@web/common/apis/sync.api"; | ||
|
||
import { | ||
SignInButtonWrapper, | ||
|
@@ -20,7 +23,18 @@ import { | |
StyledLoginContainer, | ||
} from "./styled"; | ||
|
||
const clearSession = async () => { | ||
try { | ||
await SyncApi.stopWatches(); | ||
await signOut(); | ||
} catch (error) { | ||
console.error("Failed to clear session", error); | ||
} | ||
}; | ||
tyler-dane marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
export const LoginView = () => { | ||
const [searchParams] = useSearchParams(); | ||
|
||
const [isAuthenticated, setIsAuthenticated] = useState(false); | ||
const [isAuthenticating, setIsAuthenticating] = useState(false); | ||
|
||
|
@@ -29,14 +43,23 @@ export const LoginView = () => { | |
useEffect(() => { | ||
const checkSession = async () => { | ||
const isAlreadyAuthed = await Session.doesSessionExist(); | ||
setIsAuthenticated(isAlreadyAuthed); | ||
const isGAuthSessionValid = await validateGoogleAccessToken(); | ||
setIsAuthenticated(isAlreadyAuthed && isGAuthSessionValid); | ||
}; | ||
|
||
checkSession().catch((e) => { | ||
alert(e); | ||
console.log(e); | ||
}); | ||
}, []); | ||
const reason = searchParams.get("reason"); | ||
|
||
if (reason === "gauth-session-expired") { | ||
toast.error("Google session expired, please login again"); | ||
clearSession(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} else { | ||
checkSession().catch((e) => { | ||
alert(e); | ||
console.log(e); | ||
clearSession(); | ||
}); | ||
} | ||
}, [searchParams]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we not need to await |
||
|
||
const SCOPES_REQUIRED = [ | ||
"email", | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would've liked to handle this error more consistently with how other google related errors are handled but I couldn't establish a pattern in functions like
isInvalidGoogleToken
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why couldn't that pattern be established?