Skip to content

Commit 387a09f

Browse files
✨ Feat: Preemptively trigger login after google token expired (#212)
* ✨ Feat(Backend): `/api/auth/session/gauth/verify` route Implement a new api route to validate a user's google oauth session * 🐛 Fix(Web): Handle google oauth session validation * refactor(backend,web): make uri to /auth/google also renames controller method from verifyGAuthSession to verifyGToken to more accurately represent what's happening. Compass doesn't maintain a persistent session with Google like it does with it's user's session (via Supertokens). Instead, it just passes the access token in requests * refactor(web): convert GoogleOAuthSession to a function making it a class doesn't add much value, as we're not taking advantage of any class benefits (inheritance, class methods, encapsulation) * chore(web): Rename `valid`, always set value to boolean - Rename `valid` to `isValid` to make it more consistent with codebase - Always set value to either `true` or `false` for consistency * chore(web): Add return type to `getGAuthClientForUser` * chore(backend): Handle potential google API errors * chore(web): change `valid` to `isValid` Followup to 86beb9f * chore: remove unnecessary typing in google.auth.service This same type is returned as part of type inference. Explicitly setting the function return type is acceptable is it is somehow different than the inferred type. In this case, the extra code doesn't provide any value and just requires more maintenance. * chore: remove unnecessary google error handling * chore(web): create a constant for gauth session failure reason This'll make it easier to test, access across other files, and rename it when things change * chore(web): extract response into dedicated type for reusability keeping it inside `gauth.util.ts` until I figure out where to put it * chore(web): Use dedicated constant instead of magic string * chore(web): refactor session effect to async await purpose of using await statements is to avoid creating traps for future devs when working on this part of the code. * chore(backend): Implement dedicated error object for google auth session handling * enhancment(web): rewrite google session expired message Users don't care what a session is or that it's the Google connection that's requiring us to make them reauthenticate * chore: convert error toast warning * chore(backend): remove unused accessToken variable * chore: update result arg in NoGAuthAccessToken error This makes it more clear what happened as a result of the error * refactor: use AuthApi (axios) and use response type for google access token endpoint * feat(web): extract auth checking from ProtectedRoute into separate hook and * chore(web): simplify error log in UserContext * refactor(web): remove auth checking from Login * chore(web): remove search param logic from Login * fix(web): don't blindly trigger signout after 401 response 401 is now a valid response to the /api/auth/google endpoint, which is used to validate a google token * fix(web): explicitly check for false in useAuthCheck this is needed to distinguish between boolean values (when we know something happened) and the default value of null (when nothing happened). Without this, an infinite loop of routing and auth checks resulted * fix(web): redirect after clicking login button (no auth) if user already authenticated This is a compromise between UX and functionality that lets us provide smooth routing while also ensuring that the user doesn't re-authenticated unnecessarily. It does so by checking if the user is already authenticated as a side effect during the Login page load. If so, when the user clicks the Login button they will be redirected. If not, the user will go through the regular login flow and be redirected afterwards * chore(web): remove console.log in auth.api --------- Co-authored-by: Tyler Dane <tyler@switchback.tech>
1 parent 34cacd5 commit 387a09f

File tree

12 files changed

+255
-84
lines changed

12 files changed

+255
-84
lines changed

packages/backend/src/auth/auth.routes.config.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,35 @@ import { CommonRoutesConfig } from "@backend/common/common.routes.config";
55
import authController from "./controllers/auth.controller";
66
import authMiddleware from "./middleware/auth.middleware";
77

8+
/**
9+
* Routes with the verifyIsDev middleware are
10+
* only available when running the app in dev,
11+
* as they are not called by production code.
12+
*/
813
export class AuthRoutes extends CommonRoutesConfig {
914
constructor(app: express.Application) {
1015
super(app, "AuthRoutes");
1116
}
1217

1318
configureRoutes(): express.Application {
1419
/**
15-
* Convenience routes for debugging (eg via Postman)
16-
*
17-
* Production code shouldn't call these
18-
* directly, which is why they're limited to devs only
20+
* Checks whether user's google access token is still valid
1921
*/
22+
this.app.route(`/api/auth/google`).get([
23+
verifySession(),
24+
//@ts-expect-error res.promise is not returning response types correctly
25+
authController.verifyGToken,
26+
]);
27+
2028
this.app
2129
.route(`/api/auth/session`)
2230
.all(authMiddleware.verifyIsDev)
23-
//@ts-ignore
31+
//@ts-expect-error res.promise is not returning response types correctly
32+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
2433
.post(authController.createSession)
2534
.get([
2635
verifySession(),
27-
//@ts-ignore
36+
//@ts-expect-error res.promise is not returning response types correctly
2837
authController.getUserIdFromSession,
2938
]);
3039

@@ -38,7 +47,7 @@ export class AuthRoutes extends CommonRoutesConfig {
3847
*/
3948
this.app.route(`/api/oauth/google`).post([
4049
authMiddleware.verifyGoogleOauthCode,
41-
//@ts-ignore
50+
//@ts-expect-error res.promise is not returning response types correctly
4251
authController.loginOrSignup,
4352
]);
4453

packages/backend/src/auth/controllers/auth.controller.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import Session from "supertokens-node/recipe/session";
88
import { Logger } from "@core/logger/winston.logger";
99
import { gCalendar } from "@core/types/gcal";
1010
import { Schema_User } from "@core/types/user.types";
11-
import { Result_Auth_Compass, UserInfo_Compass } from "@core/types/auth.types";
11+
import {
12+
Result_Auth_Compass,
13+
Result_VerifyGToken,
14+
UserInfo_Compass,
15+
} from "@core/types/auth.types";
1216
import {
1317
ReqBody,
1418
Res_Promise,
@@ -20,11 +24,14 @@ import {
2024
findCompassUserBy,
2125
updateGoogleRefreshToken,
2226
} from "@backend/user/queries/user.queries";
23-
import GoogleAuthService from "@backend/auth/services/google.auth.service";
27+
import GoogleAuthService, {
28+
getGAuthClientForUser,
29+
} from "@backend/auth/services/google.auth.service";
2430
import userService from "@backend/user/services/user.service";
2531
import compassAuthService from "@backend/auth/services/compass.auth.service";
2632
import syncService from "@backend/sync/services/sync.service";
2733
import { isInvalidGoogleToken } from "@backend/common/services/gcal/gcal.utils";
34+
import { BaseError } from "@core/errors/errors.base";
2835

2936
import { initGoogleClient } from "../services/auth.utils";
3037

@@ -63,6 +70,31 @@ class AuthController {
6370
res.promise({ userId });
6471
};
6572

73+
verifyGToken = async (req: SessionRequest, res: Res_Promise) => {
74+
try {
75+
const userId = req.session?.getUserId();
76+
77+
if (!userId) {
78+
res.promise({ isValid: false, error: "No session found" });
79+
return;
80+
}
81+
82+
const gAuthClient = await getGAuthClientForUser({ _id: userId });
83+
84+
// Upon receiving an access token, we know the session is valid
85+
await gAuthClient.getAccessToken();
86+
87+
const result: Result_VerifyGToken = { isValid: true };
88+
res.promise(result);
89+
} catch (error) {
90+
const result: Result_VerifyGToken = {
91+
isValid: false,
92+
error: error as Error | BaseError,
93+
};
94+
res.promise(result);
95+
}
96+
};
97+
6698
loginOrSignup = async (req: SReqBody<{ code: string }>, res: Res_Promise) => {
6799
try {
68100
const { code } = req.body;

packages/backend/src/auth/services/google.auth.service.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,59 @@ import { OAuth2Client, TokenPayload } from "google-auth-library";
33
import { Logger } from "@core/logger/winston.logger";
44
import { Status } from "@core/errors/status.codes";
55
import { BaseError } from "@core/errors/errors.base";
6+
import { gCalendar } from "@core/types/gcal";
67
import { UserInfo_Google } from "@core/types/auth.types";
78
import { ENV } from "@backend/common/constants/env.constants";
89
import { findCompassUserBy } from "@backend/user/queries/user.queries";
9-
import { UserError } from "@backend/common/constants/error.constants";
10+
import {
11+
AuthError,
12+
UserError,
13+
} from "@backend/common/constants/error.constants";
1014
import { error } from "@backend/common/errors/handlers/error.handler";
15+
import { Schema_User } from "@core/types/user.types";
16+
import { WithId } from "mongodb";
1117

1218
import compassAuthService from "./compass.auth.service";
1319

1420
const logger = Logger("app:google.auth.service");
1521

16-
export const getGcalClient = async (userId: string) => {
22+
export const getGAuthClientForUser = async (
23+
user: WithId<Schema_User> | { _id: string }
24+
) => {
25+
const gAuthClient = new GoogleAuthService();
26+
27+
let gRefreshToken: string | undefined;
28+
29+
if ("google" in user && user.google) {
30+
gRefreshToken = user.google.gRefreshToken;
31+
}
32+
33+
if (!gRefreshToken) {
34+
const userId = "_id" in user ? (user._id as string) : undefined;
35+
36+
if (!userId) {
37+
logger.error(`Expected to either get a user or a userId.`);
38+
throw error(UserError.InvalidValue, "User not found");
39+
}
40+
41+
const _user = await findCompassUserBy("_id", userId);
42+
43+
if (!_user) {
44+
logger.error(`Couldn't find user with this id: ${userId}`);
45+
throw error(UserError.UserNotFound, "User not found");
46+
}
47+
48+
gRefreshToken = _user.google.gRefreshToken;
49+
}
50+
51+
gAuthClient.oauthClient.setCredentials({
52+
refresh_token: gRefreshToken,
53+
});
54+
55+
return gAuthClient;
56+
};
57+
58+
export const getGcalClient = async (userId: string): Promise<gCalendar> => {
1759
const user = await findCompassUserBy("_id", userId);
1860
if (!user) {
1961
logger.error(`Couldn't find user with this id: ${userId}`);
@@ -24,11 +66,7 @@ export const getGcalClient = async (userId: string) => {
2466
);
2567
}
2668

27-
const gAuthClient = new GoogleAuthService();
28-
29-
gAuthClient.oauthClient.setCredentials({
30-
refresh_token: user.google.gRefreshToken,
31-
});
69+
const gAuthClient = await getGAuthClientForUser(user);
3270

3371
const calendar = google.calendar({
3472
version: "v3",
@@ -49,7 +87,7 @@ class GoogleAuthService {
4987
);
5088
}
5189

52-
getGcalClient() {
90+
getGcalClient(): gCalendar {
5391
const gcal = google.calendar({
5492
version: "v3",
5593
auth: this.oauthClient,
@@ -82,6 +120,19 @@ class GoogleAuthService {
82120
const payload = ticket.getPayload() as TokenPayload;
83121
return payload;
84122
}
123+
124+
async getAccessToken() {
125+
const { token } = await this.oauthClient.getAccessToken();
126+
127+
if (!token) {
128+
throw error(
129+
AuthError.NoGAuthAccessToken,
130+
"Google auth access token not returned"
131+
);
132+
}
133+
134+
return token;
135+
}
85136
}
86137

87138
export default GoogleAuthService;

packages/backend/src/common/constants/error.constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const AuthError = {
2121
status: Status.INTERNAL_SERVER,
2222
isOperational: false,
2323
},
24+
NoGAuthAccessToken: {
25+
description: "No gauth access token",
26+
status: Status.BAD_REQUEST,
27+
isOperational: true,
28+
},
2429
};
2530

2631
export const DbError = {

packages/core/src/types/auth.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ export interface Result_Auth_Compass {
66
error?: BaseError;
77
}
88

9+
export interface Result_VerifyGToken {
10+
isValid: boolean;
11+
error?: BaseError | Error;
12+
}
13+
914
export interface User_Google {
1015
id: string;
1116
email: string;
Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,39 @@
1-
import React, { ReactNode, useLayoutEffect, useState } from "react";
2-
import Session from "supertokens-auth-react/recipe/session";
1+
import React, { ReactNode, useEffect } from "react";
32
import { useNavigate } from "react-router-dom";
43
import { ROOT_ROUTES } from "@web/common/constants/routes";
54
import { AbsoluteOverflowLoader } from "@web/components/AbsoluteOverflowLoader";
5+
import { AUTH_FAILURE_REASONS } from "@web/common/constants/auth.constants";
6+
7+
import { useAuthCheck } from "./useAuthCheck";
68

79
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
810
const navigate = useNavigate();
9-
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
1011

11-
useLayoutEffect(() => {
12-
async function fetchSession() {
13-
const isAuthenticated = await Session.doesSessionExist();
14-
setIsAuthenticated(isAuthenticated);
15-
if (!isAuthenticated) {
16-
navigate(ROOT_ROUTES.LOGIN);
12+
const { isAuthenticated, isCheckingAuth, isGoogleTokenActive } =
13+
useAuthCheck();
14+
15+
useEffect(() => {
16+
const handleAuthCheck = () => {
17+
if (isAuthenticated === false) {
18+
if (isGoogleTokenActive === false) {
19+
navigate(
20+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.GAUTH_SESSION_EXPIRED}`
21+
);
22+
} else {
23+
navigate(
24+
`${ROOT_ROUTES.LOGIN}?reason=${AUTH_FAILURE_REASONS.USER_SESSION_EXPIRED}`
25+
);
26+
}
1727
}
18-
}
28+
};
1929

20-
void fetchSession();
21-
}, [navigate]);
30+
void handleAuthCheck();
31+
}, [isAuthenticated, isGoogleTokenActive, navigate]);
2232

23-
if (isAuthenticated === null) {
24-
return <AbsoluteOverflowLoader />;
25-
} else {
26-
return <>{children}</>;
27-
}
33+
return (
34+
<>
35+
{isCheckingAuth && <AbsoluteOverflowLoader />}
36+
{children}
37+
</>
38+
);
2839
};

packages/web/src/auth/UserContext.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
2323
const uid = await getUserId();
2424
setUserId(uid);
2525
} catch (e) {
26-
console.log("Failed to get user because:");
27-
console.log(e);
26+
console.log("Failed to get user because:", e);
2827
} finally {
2928
setIsLoadingUser(false);
3029
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useState, useLayoutEffect } from "react";
2+
import Session from "supertokens-auth-react/recipe/session";
3+
import { AuthApi } from "@web/common/apis/auth.api";
4+
5+
export const useAuthCheck = () => {
6+
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
7+
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
8+
const [isSessionActive, setIsSessionActive] = useState<boolean | null>(null);
9+
const [isGoogleTokenActive, setIsGoogleTokenActive] = useState<
10+
boolean | null
11+
>(null);
12+
13+
useLayoutEffect(() => {
14+
const checkAuth = async () => {
15+
try {
16+
setIsCheckingAuth(true);
17+
const _isSessionActive = await Session.doesSessionExist();
18+
setIsSessionActive(_isSessionActive);
19+
20+
const _isGoogleTokenActive = await AuthApi.validateGoogleAccessToken();
21+
setIsGoogleTokenActive(_isGoogleTokenActive);
22+
23+
setIsAuthenticated(isSessionActive && isGoogleTokenActive);
24+
} catch (error) {
25+
console.error("Error checking authentication:", error);
26+
} finally {
27+
setIsCheckingAuth(false);
28+
}
29+
};
30+
31+
void checkAuth();
32+
}, [isGoogleTokenActive, isSessionActive]);
33+
34+
return {
35+
isAuthenticated,
36+
isCheckingAuth,
37+
isGoogleTokenActive,
38+
isSessionActive,
39+
};
40+
};
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { Result_Auth_Compass } from "@core/types/auth.types";
1+
import {
2+
Result_Auth_Compass,
3+
Result_VerifyGToken,
4+
} from "@core/types/auth.types";
25

36
import { CompassApi } from "./compass.api";
47

@@ -7,6 +10,19 @@ const AuthApi = {
710
const response = await CompassApi.post(`/oauth/google`, { code });
811
return response.data as Result_Auth_Compass;
912
},
13+
async validateGoogleAccessToken() {
14+
try {
15+
const res = await CompassApi.get(`/auth/google`);
16+
17+
if (res.status !== 200) return false;
18+
19+
const body = res.data as Result_VerifyGToken;
20+
return !!body.isValid;
21+
} catch (error) {
22+
console.error(error);
23+
return false;
24+
}
25+
},
1026
};
1127

1228
export { AuthApi };

packages/web/src/common/apis/compass.api.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,7 @@ CompassApi.interceptors.response.use(
3737
return Promise.resolve();
3838
}
3939

40-
if (
41-
status === Status.GONE ||
42-
status === Status.NOT_FOUND ||
43-
status === Status.UNAUTHORIZED
44-
) {
40+
if (status === Status.GONE || status === Status.NOT_FOUND) {
4541
await _signOut(status);
4642
} else {
4743
console.log(error);

0 commit comments

Comments
 (0)