Skip to content

Commit

Permalink
feat: handle user scope updates
Browse files Browse the repository at this point in the history
  • Loading branch information
veryCrunchy committed Apr 27, 2024
1 parent 8056530 commit 3aa5b25
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 20 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"graphql-yoga": "^5.1.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mysql2": "^3.9.1",
"node-cache": "^5.1.2",
"oslo": "^1.1.1",
Expand All @@ -36,6 +37,7 @@
"devDependencies": {
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.0",
"@types/node": "^20.11.17",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

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

67 changes: 47 additions & 20 deletions src/routes/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import { createJWT } from "oslo/jwt";
import { createId } from "@paralleldrive/cuid2";
import { TimeSpan } from "oslo";
import type { Request, Response } from "express";

import { hasAllItems } from "~/util";
const router = express.Router();

async function handleAuthCallback(req: Request, res: Response) {
router.get("/login/callback", async (req: Request, res: Response) => {
const redirectPath = req.cookies.redirect_path ?? "/";
// const callbackURL =
// req.cookies.token_callback?.toString() ?? env.CALLBACK_URL;
Expand All @@ -32,7 +32,7 @@ async function handleAuthCallback(req: Request, res: Response) {
const state = req.query.state?.toString() ?? null;
const platform = req.query.platform?.toString() ?? null;
const storedState = req.cookies.oauth_state ?? null;
const storedScopes = req.cookies.oauth_scopes ?? null;
const storedScopes: string = req.cookies.oauth_scopes ?? null;
if (
!platform ||
!(platform === "twitch" || platform === "discord") ||
Expand All @@ -47,10 +47,11 @@ async function handleAuthCallback(req: Request, res: Response) {

const user = {
id: "",
email: "",
username: "",
displayName: "",
email: "",
avatar: "",
scopes: [""],
};
let tokens!: Tokens;
try {
Expand All @@ -65,13 +66,23 @@ async function handleAuthCallback(req: Request, res: Response) {
},
}
);
const twitchScopeResponse = await fetch(
"https://id.twitch.tv/oauth2/validate",
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Client-Id": env.TWITCH_CLIENT_ID,
},
}
);
const twitchUser: TwitchUserResponse = (await twitchUserResponse.json())
.data[0];
user.id = twitchUser.id;
user.email = twitchUser.email;
user.username = twitchUser.login;
user.displayName = twitchUser.display_name;
user.avatar = twitchUser.profile_image_url;
user.scopes = (await twitchScopeResponse.json()).scopes as string[];
}
if (platform === "discord") {
tokens = await discordAuth.validateAuthorizationCode(code);
Expand All @@ -98,15 +109,26 @@ async function handleAuthCallback(req: Request, res: Response) {
.selectDistinct()
.from(schema.accounts)
.where(eq(schema.accounts.id, user.id));

let userId!: string;
const [emailMatch] = await db
.select()
.from(schema.accounts)
.where(eq(schema.accounts.email, user.email))
.limit(1);

let userId: string = existingAccount?.userId ?? createId();
const existingScopesArray = existingAccount?.scope.split(" ");
//make sure user keeps old scopes in addition to new scopes
if (
existingAccount?.platform === "twitch" &&
!hasAllItems(user.scopes, existingScopesArray)
) {
return res.redirect(
301,
"/login/twitch?scopes=" +
user.scopes.concat(existingScopesArray).join("+")
);
}
if (!existingAccount) {
const [emailMatch] = await db
.select()
.from(schema.accounts)
.where(eq(schema.accounts.email, user.email))
.limit(1);

await db.transaction(async transaction => {
// Check if user exists by email, else create a new user
if (emailMatch && emailMatch.userId) {
Expand All @@ -129,7 +151,6 @@ async function handleAuthCallback(req: Request, res: Response) {

// Update the user with the primaryAccountId obtained from the inserted account
if (!userId) {
userId = createId();
await transaction.insert(schema.users).values({
id: userId,
primaryAccountId: user.id,
Expand All @@ -141,7 +162,17 @@ async function handleAuthCallback(req: Request, res: Response) {
}
});
// If account already exists we simply create a token for the user linked to that account
} else userId = existingAccount.userId!;
} else {
await db
.update(schema.accounts)
.set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
scope: user.scopes.join(" "),
})
.where(eq(schema.accounts.id, existingAccount.id));
}

// Check if there is an existing and valid token for the user
// This ensures we don't flood the database with tokens, and have 2 at max per user
Expand All @@ -166,8 +197,8 @@ async function handleAuthCallback(req: Request, res: Response) {
jwt = existingSession.token;
} else {
const payload = {
u: userId, // user (used for validation on api requests)
a: user.id, // account (currently not actually used)
u: userId,
a: user.id, // user account id (twitch / discord)
};
jwt = await createJWT("HS256", secret, payload, {
expiresIn: new TimeSpan(30, "d"),
Expand All @@ -192,10 +223,6 @@ async function handleAuthCallback(req: Request, res: Response) {
console.log(e);
return res.status(500).send("Internal Server Error");
}
}

router.get("/login/callback", async (req: Request, res: Response) => {
await handleAuthCallback(req, res);
});

export default router;
13 changes: 13 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1 +1,14 @@
import { intersection } from "lodash";

export const cleanFilePath = (path: string) => path.replace(/^\/|\/$/g, "");

/**
* Checks if first array contains all items in second array.
* @example
* hasAllItems(["a"], ["a", "b", "c"]) //false
* hasAllItems(["a", "b", "c"], ["a"]) //true
*/
export function hasAllItems<T>(a: Array<T>, b: Array<T>): boolean {
const is = intersection(a, b);
return is.length === b.length;
}

0 comments on commit 3aa5b25

Please sign in to comment.