diff --git a/apps/golinks-v2/migrations/0007_add-datetime-on-gh-oauth-challenges.sql b/apps/golinks-v2/migrations/0007_add-datetime-on-gh-oauth-challenges.sql new file mode 100644 index 0000000..f9a6529 --- /dev/null +++ b/apps/golinks-v2/migrations/0007_add-datetime-on-gh-oauth-challenges.sql @@ -0,0 +1,19 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_GitHubOAuthChallenge" ( + "id" TEXT NOT NULL PRIMARY KEY, + "challenge" TEXT NOT NULL, + "code" TEXT, + "metadata" TEXT NOT NULL, + "username" TEXT, + "userId" TEXT, + "created_on" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_on" DATETIME NOT NULL, + "expires_on" DATETIME NOT NULL +); +INSERT INTO "new_GitHubOAuthChallenge" ("challenge", "code", "id", "metadata", "userId", "username") SELECT "challenge", "code", "id", "metadata", "userId", "username" FROM "GitHubOAuthChallenge"; +DROP TABLE "GitHubOAuthChallenge"; +ALTER TABLE "new_GitHubOAuthChallenge" RENAME TO "GitHubOAuthChallenge"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/apps/golinks-v2/prisma/schema.prisma b/apps/golinks-v2/prisma/schema.prisma index 21c31c6..d12f675 100644 --- a/apps/golinks-v2/prisma/schema.prisma +++ b/apps/golinks-v2/prisma/schema.prisma @@ -60,6 +60,9 @@ model GitHubOAuthChallenge { metadata String username String? userId String? + created_on DateTime @default(now()) + updated_on DateTime @updatedAt() + expires_on DateTime } model ApiToken { diff --git a/apps/golinks-v2/src/api/github.ts b/apps/golinks-v2/src/api/github.ts index a74af50..9490fc6 100644 --- a/apps/golinks-v2/src/api/github.ts +++ b/apps/golinks-v2/src/api/github.ts @@ -1,6 +1,7 @@ import { PrismaD1 } from "@prisma/adapter-d1"; import { PrismaClient } from "@prisma/client"; import { Context } from "hono"; +import { githubOAuthExchange } from "lib/auth"; import { generateSlug } from "lib/utils"; export async function githubAuth(context: Context) { @@ -40,8 +41,7 @@ export async function githubOAuthCallback(context: Context) { const appId = context.env.GITHUB_OAUTH_ID; const appSecret = context.env.GITHUB_OAUTH_SECRET; const redirect_uri = `${context.env.BASE_URL}/auth/github/callback`; - const adapter = new PrismaD1(context.env.golinks); - const prisma = new PrismaClient({ adapter }); + const { state, code, error, error_description, error_uri } = context.req.query(); let payload = { code, @@ -51,9 +51,6 @@ export async function githubOAuthCallback(context: Context) { state, grant_type: "authorization_code", }; - let formBody = Object.entries(payload) - .map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)) - .join("&"); if (error) { const errorMsg = `OAuth flow was aborted with the following information: error: ${error} @@ -64,13 +61,7 @@ Please try again authenicating. If you still having issues, please file a ticket https://go.andreijiroh.xyz/feedback/oauth-bug with the details above`; return context.newResponse(errorMsg, 400); } - const authToken = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - body: formBody, - headers: { - Accept: "application/json", - }, - }); + const authToken = await githubOAuthExchange(payload); const { access_token } = await authToken.json(); const stateJson = JSON.parse(state); @@ -78,12 +69,7 @@ https://go.andreijiroh.xyz/feedback/oauth-bug with the details above`; return context.newResponse("OAuth flow was aborted because the auth code is invalid.", 400); } const ticket = generateSlug(12); - fetch("https://api.github.com/user", { - headers: { - Authorization: `bearer ${access_token}`, - }, - }).then((result) => { - const { username, id } = result.json(); + then((result) => { const dbResult = prisma.gitHubOAuthChallenge.update({ where: { challenge: stateJson.challenge, diff --git a/apps/golinks-v2/src/api/slack.ts b/apps/golinks-v2/src/api/slack.ts index d95f369..57054b2 100644 --- a/apps/golinks-v2/src/api/slack.ts +++ b/apps/golinks-v2/src/api/slack.ts @@ -5,6 +5,7 @@ import { Buffer } from "node:buffer"; import { generateSlug } from "lib/utils"; import { PrismaD1 } from "@prisma/adapter-d1"; import { Prisma, PrismaClient } from "@prisma/client"; +import { addBotToken, generateChallenge, lookupBotToken, slackOAuthExchange, updateBotToken } from "lib/auth"; type SlackSlashCommand = { token?: string; @@ -21,8 +22,8 @@ type SlackSlashCommand = { trigger_id: string; }; -function helpMessage(context: Context, params: SlackSlashCommand) { - const challenge = `challenge_${generateSlug(24)}`; +async function helpMessage(context: Context, params: SlackSlashCommand) { + const challenge = await generateChallenge(context.env.golinks); const githubAuthUrl = `${context.env.BASE_URL}/auth/github?client_id=slack&slack_team=${params.team_id}&slack_id=${params.user_id}&state=${challenge}`; const templateJson = { blocks: [ @@ -89,8 +90,6 @@ function helpMessage(context: Context, params: SlackSlashCommand) { text: "Open API docs", emoji: true, }, - value: "api-docs", - action_id: "golinks-bot-help", url: `${context.env.BASE_URL}/api/docs`, }, }, @@ -108,6 +107,59 @@ function helpMessage(context: Context, params: SlackSlashCommand) { return context.json(templateJson); } +function authChallengePrompt(context: Context, params: SlackSlashCommand) { + const newchallenge = `challenge_${generateSlug(24)}`; + const githubAuthUrl = `${context.env.BASE_URL}/auth/github?client_id=slack&slack_team=${params.team_id}&slack_id=${params.user_id}&state=${challenge}`; + const templateCallback = { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "You should be redirected to the GitHub OAuth flow soon. Once you copied the code, press the button below and paste the code in the popout.", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Submit authenication ticket", + emoji: true, + }, + style: "primary", + value: "ghAuthTicket_tbd", + action_id: "github-auth-challenge", + }, + ], + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Having issues? Try authenicating again by using the button below or get a new challenge with `/go login` command.", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Retry OAuth flow", + emoji: true, + }, + url: "https://todo", + }, + ], + }, + ], + }; +} + /** * Handle requests for OAuth-based app installation * @param context @@ -121,8 +173,6 @@ export async function slackOAuth(context: Context) { return context.redirect(`https://slack.com/oauth/v2/authorize?client_id=${appId}&scope=${scopes}&redirect_uri=${callback}`); } export async function slackOAuthCallback(context: Context) { - const adapter = new PrismaD1(context.env.golinks); - const prisma = new PrismaClient({ adapter }); const callback = `${context.env.BASE_URL}/auth/slack/callback`; const params = context.req.query(); @@ -134,17 +184,8 @@ export async function slackOAuthCallback(context: Context) { grant_type: "authorization_code", }; - let formBody = Object.entries(payload) - .map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)) - .join("&"); - try { - const api = await fetch("https://slack.com/api/oauth.v2.access", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: formBody, - }); - + const api = await slackOAuthExchange(payload); const result = await api.json(); console.log(`[slack-oauth] result: ${JSON.stringify(result)} (${api.status})`); @@ -152,43 +193,26 @@ export async function slackOAuthCallback(context: Context) { return context.json({ ok: false, error: result.error }, api.status); } - const team = await prisma.slackBotToken.findFirst({ - where: { - teamId: result.is_enterprise_install == true ? result.enterprise.id : result.team.id, - enterprise_install: result.is_enterprise_install, - }, - }); + const team = lookupBotToken( + context.env.golinks, + result.is_enterprise_install == true ? result.enterprise.id : result.team.id, + result.is_enterprise_install, + ); if (!team) { - prisma.slackBotToken - .create({ - data: { - teamId: result.is_enterprise_install == true ? result.enterprise.id : result.team.id, - enterprise_install: result.is_enterprise_install, - token: result.token, - }, - }) - .then((result) => { - console.log(`[prisma-client] ${result}`); - return context.json({ ok: true, result: "Successfully installed, run `/go help` in a channel or DM." }); - }); + const deploy = await addBotToken( + context.env.golinks, + result.is_enterprise_install == true ? result.enterprise.id : result.team.id, + result.is_enterprise_install, + ); } else { - prisma.slackBotToken - .update({ - where: { - teamId: result.is_enterprise_install == true ? result.enterprise.id : result.team.id, - enterprise_install: result.is_enterprise_install, - }, - data: { - token: result.token, - enterprise_install: result.is_enterprise_install, - }, - }) - .then((result) => { - console.log(`[prisma-client] ${result}`); - return context.json({ ok: true, result: "Successfully installed, run `/go help` in a channel or DM." }); - }); + const deploy = await updateBotToken( + context.env.golinks, + result.is_enterprise_install == true ? result.enterprise.id : result.team.id, + result.is_enterprise_install, + ); } + return context.newResponse(`Installation success! Try it now with "/go help" command.`); } catch (err) { console.error(err); return context.json({ ok: false, error: "Something gone wrong, but we're looking onto it" }, 500); @@ -239,7 +263,7 @@ export async function handleSlackCommand(context: Context) { if (command === "go") { if (data.text == "" || data.text == "help") { - return helpMessage(context, data); + return await helpMessage(context, data); } } else if (command == "ping") { const end = Date.now() - start; @@ -249,6 +273,8 @@ export async function handleSlackCommand(context: Context) { return context.newResponse("Unsupported command"); } +export async function handleSlackInteractivity(context: Context) {} + /** * Validate request timestamps to ensure that we don't received forged requests * within 3-5 minutes diff --git a/apps/golinks-v2/src/lib/auth.ts b/apps/golinks-v2/src/lib/auth.ts index 1788147..4e009b6 100644 --- a/apps/golinks-v2/src/lib/auth.ts +++ b/apps/golinks-v2/src/lib/auth.ts @@ -1,4 +1,9 @@ +import { PrismaD1 } from "@prisma/adapter-d1"; +import { PrismaClient } from "@prisma/client"; import { Context, Next } from "hono"; +import { EnvBindings, Env } from "types"; +import { generateSlug } from "./utils"; +import { error } from "console"; export async function adminApiKeyAuth(c: Context, next: Next) { if (c.req.method == "GET" || c.req.method == "HEAD") { @@ -24,3 +29,111 @@ export async function adminApiKeyAuth(c: Context, next: Next) { } return await next(); } + +export async function slackOAuthExchange(payload: object) { + let formBody = Object.entries(payload) + .map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)) + .join("&"); + const api = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formBody, + }); + return api; +} + +export async function lookupBotToken(db: EnvBindings["golinks"], teamId: string, is_enterprise_install?: boolean) { + const adapter = new PrismaD1(db); + const prisma = new PrismaClient({ adapter }); + try { + const team = await prisma.slackBotToken.findFirst({ + where: { + teamId, + enterprise_install: is_enterprise_install, + }, + }); + return team; + } catch (error) { + return Promise.reject(Error(`${error}`)); + } +} + +export async function addBotToken(db: EnvBindings["golinks"], teamId: string, token: string, enterprise_install?: boolean) { + const adapter = new PrismaD1(db); + const prisma = new PrismaClient({ adapter }); + try { + const result = await prisma.slackBotToken.create({ + data: { + teamId, + enterprise_install, + token, + }, + }); + return result; + } catch (error) { + return Promise.reject(Error(`${error}`)); + } +} + +export async function updateBotToken(db: EnvBindings["golinks"], teamId: string, token: string, enterprise_install?: boolean) { + const adapter = new PrismaD1(db); + const prisma = new PrismaClient({ adapter }); + try { + const result = await prisma.slackBotToken.update({ + where: { + teamId, + }, + data: { + teamId, + enterprise_install, + token, + }, + }); + return result; + } catch (error) { + return Promise.reject(Error(`${error}`)); + } +} + +export async function githubOAuthExchange(payload: object) { + let formBody = Object.entries(payload) + .map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)) + .join("&"); + return await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + body: formBody, + headers: { + Accept: "application/json", + }, + }); +} + +export async function getUserInfoAndGenerateTicket(token: string, context: Context) { + try { + const profile = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${token}`, + }, + }); + const { username, id, email } = profile.json(); + } catch (error) { + return Promise.reject(Error(error)); + } +} + +export async function generateChallenge(db: EnvBindings["golinks"], metadata) { + const adapter = new PrismaD1(db); + const prisma = new PrismaClient({ adapter }); + const newchallenge = generateSlug(24); + try { + const result = await prisma.gitHubOAuthChallenge.create({ + data: { + challenge: newchallenge, + metadata, + }, + }); + } catch (error) { + console.error(error); + return Promise.reject(Error(error)); + } +} diff --git a/apps/golinks-v2/src/lib/utils.ts b/apps/golinks-v2/src/lib/utils.ts index 4c03939..7a6bf75 100644 --- a/apps/golinks-v2/src/lib/utils.ts +++ b/apps/golinks-v2/src/lib/utils.ts @@ -20,12 +20,12 @@ import data from "../data/old-links.json"; export async function handleOldUrls(c: Context, next: Next) { const force_redirect = c.req.query("force_redirect"); if (data[c.req.path] && !force_redirect) { - console.log(`[old-link-redirects] data for ${c.req.path}: ${JSON.stringify(data[c.req.path])}`); + console.log(`[old-link-redirects] data for ${c.req.path}: ${JSON.stringify(data[c.req.path])}`); return c.redirect( `/landing/deprecated?golink=${c.req.path.replace(/^\/|\/$/g, "")}&reason=${encodeURIComponent(data[c.req.path].reason)}`, ); } else if (data[c.req.path] && force_redirect) { - console.log(`[old-link-redirects] data for ${c.req.path}: ${JSON.stringify(data[c.req.path])}`); + console.log(`[old-link-redirects] data for ${c.req.path}: ${JSON.stringify(data[c.req.path])}`); return c.redirect(data[c.req.path].url); } return await next();