Skip to content

Commit

Permalink
feat: add googleID/googleIDToken handling
Browse files Browse the repository at this point in the history
  • Loading branch information
trevor-anderson committed Mar 24, 2024
1 parent e46c681 commit 0f8df48
Show file tree
Hide file tree
Showing 15 changed files with 305 additions and 148 deletions.
3 changes: 2 additions & 1 deletion src/middleware/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export * from "./findUserByEmail.js";
export * from "./generateAuthToken.js";
export * from "./getUserFromAuthHeaderToken.js";
export * from "./parseGoogleIDToken.js";
export * from "./queryUserItems.js";
export * from "./registerNewUser.js";
export * from "./shouldUserLoginExist.js";
export * from "./validateGqlReqContext.js";
export * from "./validatePassword.js";
export * from "./validateLogin.js";
55 changes: 55 additions & 0 deletions src/middleware/auth/parseGoogleIDToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { parseGoogleOAuth2IDToken } from "@/lib/googleOAuth2Client";
import { mwAsyncCatchWrapper } from "@/middleware/helpers.js";
import type { RestApiRequestBodyByPath } from "@/types/open-api.js";

/**
* This middleware parses and validates a `googleIDToken` if provided. If valid,
* it is decoded to obtain the fields listed below. These fields are then used to
* create login args, which are added to `res.locals.googleIDTokenFields` and
* `req.body` to be read by downstream auth middleware.
*
* **Fields obtained from the `googleIDToken`:**
*
* - `googleID`
* - `email`
* - `givenName`
* - `familyName`
* - `picture` (profile photo URL)
*
* > **The structure of Google JWT ID tokens is available here:**
* > https://developers.google.com/identity/gsi/web/reference/js-reference#credential
*
* @see https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
*/
export const parseGoogleIDToken = mwAsyncCatchWrapper<
/**
* Since this mw creates user login args from the supplied Google ID token and adds
* them to the `req.body` object, an intersection is used for the type param here to
* tell downstream mw that `req.body` will include fields they require. Without this
* intersection, TS complains about the perceived `req.body` type mismatch.
*/
RestApiRequestBodyByPath["/auth/google-token"] &
RestApiRequestBodyByPath["/auth/login"] &
RestApiRequestBodyByPath["/auth/register"]
>(async (req, res, next) => {
// Since this mw is used on routes where auth via GoogleIDToken is optional, check if provided.
if (!req.body.googleIDToken) return next();

// Parse the google ID token:
const { _isValid, email, googleID, profile } = await parseGoogleOAuth2IDToken(
req.body.googleIDToken
);

// Add the fields to res.locals.googleIDTokenFields:
res.locals.googleIDTokenFields = {
_isValid,
email,
googleID,
profile,
};

// If not already present, add email to req.body for use by downstream mw (used by findUserByEmail)
if (!req.body.email) req.body.email = email;

next();
});
6 changes: 5 additions & 1 deletion src/middleware/auth/registerNewUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ export const registerNewUser = mwAsyncCatchWrapper<
handle,
email,
phone = null,
expoPushToken, // Only mobile-app logins will have this
password, // Only local logins will have this
},
} = req;

// For Google OAuth logins, get fields from the relevant res.locals object:
const { googleID, profile: profileParams } = res.locals.googleIDTokenFields ?? {};

// Set the authenticatedUser res.locals field used by `generateAuthToken`
res.locals.authenticatedUser = await User.createOne({
handle,
Expand All @@ -27,7 +32,6 @@ export const registerNewUser = mwAsyncCatchWrapper<
...(profileParams && { profile: Profile.fromParams(profileParams) }),
password,
googleID,
googleAccessToken,
});

/* Data from this endpoint is returned to the sending client, so there's
Expand Down
52 changes: 52 additions & 0 deletions src/middleware/auth/validateLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mwAsyncCatchWrapper } from "@/middleware/helpers.js";
import { AuthError, InternalServerError } from "@/utils/httpErrors.js";
import { passwordHasher } from "@/utils/passwordHasher.js";
import type { CombineUnionOfObjects } from "@/types/helpers.js";
import type { RestApiRequestBodyByPath } from "@/types/open-api.js";

/**
* This middleware validates User's Login objects.
*
* - If the User's login type is `"LOCAL"`, it compares the provided password
* against the passwordHash stored in the db.
*
* - If the User's login type is `"GOOGLE_OAUTH"`, it checks the value of
* `res.locals.googleIDTokenFields?._isValid`, which is set by the `parseGoogleIDToken`
* middleware.
*
* If it's invalid, an AuthError is thrown.
*/
export const validateLogin = mwAsyncCatchWrapper<
CombineUnionOfObjects<RestApiRequestBodyByPath["/auth/login"]>
>(async (req, res, next) => {
const userItem = res.locals?.user;

if (!userItem) return next(new AuthError("User not found"));

// LOCAL LOGIN — validate password
if (userItem.login.type === "LOCAL") {
// Ensure password was provided
if (!req.body?.password) return next(new AuthError("Password is required"));

const isValidPassword = await passwordHasher.validate(
req.body.password,
userItem.login.passwordHash
);

if (!isValidPassword) next(new AuthError("Invalid email or password"));

/* Note: res.locals.user does not have `subscription`/`stripeConnectAccount` fields.
For `generateAuthToken`, these fields are obtained from the `queryUserItems` mw. */
res.locals.authenticatedUser = userItem;

// GOOGLE_OAUTH LOGIN — check res.locals.googleIDTokenFields._isValid
} else if (userItem.login.type === "GOOGLE_OAUTH") {
// The parseGoogleIDToken mw provides this res.locals field, check `_isValid`. The
// field should always be true here, else the fn would throw, but it provides clarity.
if (res.locals.googleIDTokenFields?._isValid) res.locals.authenticatedUser = userItem;
} else {
next(new InternalServerError("Invalid login"));
}

next();
});
38 changes: 0 additions & 38 deletions src/middleware/auth/validatePassword.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/models/User/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,14 @@ class UserModel extends Model<typeof UserModel.schema, UserItem, UserItemCreatio
passwordHash: { type: "string" },
// GOOGLE_OAUTH login properties:
googleID: { type: "string" },
googleAccessToken: { type: "string" },
},
validate: (login: unknown) =>
isPlainObject(login) &&
hasKey(login, "type") &&
(login.type === "LOCAL"
? hasKey(login, "passwordHash")
: login.type === "GOOGLE_OAUTH"
? hasKey(login, "googleID") && hasKey(login, "googleAccessToken")
? hasKey(login, "googleID")
: false),
},
profile: {
Expand Down
33 changes: 16 additions & 17 deletions src/models/User/createOne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import { logger } from "@/utils/logger.js";
import type { UserItem, User } from "@/models/User/User.js";
import type { Simplify, SetOptional } from "type-fest";

/** `User.createOne()` method params. */
export type UserCreateOneParams = Simplify<
CreateLoginParams &
SetOptional<
Pick<UserItem, "handle" | "email" | "phone" | "expoPushToken" | "profile">,
"profile"
>
>;

/**
* `User.createOne` creates the following items:
* - `User` (created in the DB)
Expand All @@ -20,12 +29,11 @@ export const createOne = async function (
{
handle,
email,
phone,
expoPushToken, // Only mobile users will have this
profile, // Only Google logins will have this at reg-time
phone = null,
expoPushToken, // Only mobile-app users will have this
password, // Only local logins will have this
googleID, // Only Google logins will have this
googleAccessToken, // Only Google logins will have this
profile, // Only Google OAuth logins will have this at reg-time
googleID, // Only Google OAuth logins will have this
}: UserCreateOneParams
) {
let newUser: UserItem;
Expand All @@ -37,7 +45,7 @@ export const createOne = async function (
// Create Stripe Customer via Stripe API
const { id: stripeCustomerID } = await stripe.customers.create({
email,
phone,
...(phone && { phone }),
...(newUserProfile.displayName.length > 0 && { name: newUserProfile.displayName }),
});

Expand All @@ -54,7 +62,7 @@ export const createOne = async function (
...(expoPushToken && { expoPushToken }),
stripeCustomerID,
profile: { ...newUserProfile },
login: await UserLogin.createLogin({ password, googleID, googleAccessToken }),
login: await UserLogin.createLogin({ password, googleID }),
});

newUserID = newUser.id;
Expand All @@ -74,7 +82,7 @@ export const createOne = async function (
id: newUser.id,
handle: newUser.handle,
email: newUser.email,
phone: newUser.phone,
phone: newUser.phone ?? null,
profile: newUser.profile,
createdAt: newUser.createdAt,
updatedAt: newUser.updatedAt,
Expand Down Expand Up @@ -119,12 +127,3 @@ export const createOne = async function (
stripeConnectAccount: newUserStripeConnectAccount,
};
};

/** `User.createOne()` method params. */
export type UserCreateOneParams = Simplify<
CreateLoginParams &
SetOptional<
Pick<UserItem, "handle" | "email" | "phone" | "expoPushToken" | "profile">,
"profile"
>
>;
17 changes: 2 additions & 15 deletions src/models/UserLogin/UserLogin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ describe("UserLogin", () => {

test("returns a GOOGLE_OAUTH UserLogin when called with a Google ID and access token", async () => {
const googleID = "gid_123";
const googleAccessToken = "gat_123";
const result = await UserLogin.createLogin({ googleID, googleAccessToken });
const result = await UserLogin.createLogin({ googleID });
expect(result.type).toBe("GOOGLE_OAUTH");
expect(result.googleID).toBe(googleID);
expect(result.googleAccessToken).toBe(googleAccessToken);
});

test("throws an error when called without any params", async () => {
Expand All @@ -30,18 +28,7 @@ describe("UserLogin", () => {

test(`throws an error when called with an invalid "googleID" arg`, async () => {
const googleID = "bad";
const googleAccessToken = "gat_123";
await expect(UserLogin.createLogin({ googleID, googleAccessToken })).rejects.toThrow(
"Invalid Google ID"
);
});

test(`throws an error when called with an invalid "googleAccessToken" arg`, async () => {
const googleID = "gid_123";
const googleAccessToken = "bad";
await expect(UserLogin.createLogin({ googleID, googleAccessToken })).rejects.toThrow(
"Invalid Google access token"
);
await expect(UserLogin.createLogin({ googleID })).rejects.toThrow("Invalid Google ID");
});
});
});
Loading

0 comments on commit 0f8df48

Please sign in to comment.