From 874e2ec3f42ea59dbd6d1b9e391f38fd59d109e5 Mon Sep 17 00:00:00 2001 From: Andrei Jiroh Halili Date: Sun, 28 Jul 2024 01:39:01 +0800 Subject: [PATCH] feat(golinks): simplify auth logic using bult-in bearerToken middleware Also in this commit we added debug API classes. Signed-off-by: Andrei Jiroh Halili --- apps/golinks-v2/src/api/debug.ts | 61 ++++++++++++++++++++++ apps/golinks-v2/src/index.tsx | 73 +++++++++++++------------- apps/golinks-v2/src/lib/auth.ts | 77 ++++++++++++++++------------ apps/golinks-v2/src/lib/constants.ts | 52 ++++++++++++------- 4 files changed, 177 insertions(+), 86 deletions(-) create mode 100644 apps/golinks-v2/src/api/debug.ts diff --git a/apps/golinks-v2/src/api/debug.ts b/apps/golinks-v2/src/api/debug.ts new file mode 100644 index 0000000..7b7db1e --- /dev/null +++ b/apps/golinks-v2/src/api/debug.ts @@ -0,0 +1,61 @@ +import { OpenAPIRoute, Str } from "chanfana"; +import { Context } from "hono"; +import { jwtVerify, SignJWT } from "jose"; +import { z } from "zod"; + +export class debugApiGenerateJwt extends OpenAPIRoute { + schema = { + tags: ["debug"], + summary: "Generate a example signed JWT or validate a JWT generated from this service.", + request: { + query: z.object({ + jwt: Str({ + description: "JWT to validate its signature against", + }), + }), + }, + security: [{ userApiKey: [] }], + }; + async handle(c) { + const { token } = c.req.query(); + const secret = new TextEncoder().encode(c.env.JWT_SIGNING_KEY); + const payload = { + slack: { + teamId: "T1234", + userId: "U1234", + enterpriseId: "E1234", + isEnterpriseInstall: true, + }, + example_jwt: true, + }; + + if (token == null) { + const exampleToken = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setAudience("challenge_1234abcd") + .setIssuer(c.env.BASE_URL) + .setIssuedAt() + .setExpirationTime("15 minutes") + .sign(secret); + return c.json({ ok: true, result: exampleToken }); + } + + const result = await jwtVerify(token, secret, { + issuer: c.env.BASE_URL, + clockTolerance: 30, + }); + return c.json({ ok: true, result }); + } +} + +export class debugApiGetBindings extends OpenAPIRoute { + schema = { + summary: "Show all Worker bindings associated with this instance, including secrets.", + security: [ + {userApiKey: []} + ] + } + async handle(c: Context) { + return c.json({ ok: true, result: c.env }); + } +} diff --git a/apps/golinks-v2/src/index.tsx b/apps/golinks-v2/src/index.tsx index 33c9ad4..1c88a89 100644 --- a/apps/golinks-v2/src/index.tsx +++ b/apps/golinks-v2/src/index.tsx @@ -14,6 +14,7 @@ import { golinkNotFound, tags, userApiKey, + wikilinkNotAvailable, } from "lib/constants"; import { DiscordInviteLinkCreate, DiscordInviteLinkList } from "api/discord"; import { adminApiKeyAuth, slackAppInstaller } from "lib/auth"; @@ -34,6 +35,8 @@ import * as jose from "jose"; import { IncomingMessage, ServerResponse } from "node:http"; import { InstallationQuery } from "@slack/oauth"; import { WikiLinkCreate } from "api/wikilinks"; +import { debugApiGetBindings } from "api/debug"; +import { bearerAuth } from 'hono/bearer-auth' // Start a Hono app const app = new Hono<{ Bindings: EnvBindings }>(); @@ -52,8 +55,31 @@ app.use( credentials: true, }), ); -app.use("/api/*", adminApiKeyAuth); -app.use("/debug", adminApiKeyAuth); +const privilegedMethods = ["POST", "PUT", "PATCH", "DELETE"] +const debugApiMethods = [ "GET", ...privilegedMethods ] +app.on(debugApiMethods, "/api/debug/*", async (c, next) => { + const bearer = bearerAuth({ + verifyToken(token, c) { + if (token == c.env.ADMIN_KEY) { + return true + } + } + }) + return bearer(c, next) +}) +app.on("POST", "/api/slack/*", async (c, next) => { + return await next() +}) +app.on(privilegedMethods, "/api/*", async (c, next) => { + const bearer = bearerAuth({ + verifyToken: async (token: string, context: Context) => { + if (token == context.env.ADMIN_KEY) { + return true + } + } + }) + return bearer(c, next) +}) app.use("/*", async (c, next) => await handleOldUrls(c, next)); // Setup OpenAPI registry @@ -97,6 +123,7 @@ openapi.get("/api/commit", CommitHash); // category: debug openapi.get("/api/debug/slack/bot-token", debugApiGetSlackBotToken); openapi.get("/api/debug/slack/auth-test", debugApiTestSlackBotToken); +openapi.get("/api/debug/bindings", debugApiGetBindings) // Undocumented API endpoints: Slack integration app.post("/api/slack/slash-commands/:command", async (c) => handleSlackCommand(c)); @@ -148,39 +175,6 @@ app.get("/feedback/:type", (c) => { return c.redirect(generateNewIssueUrl(type, "golinks", url)); }); -app.get("/api/debug/bindings", (context) => { - console.log(context.env); - return context.json(context.env); -}); -app.get("/api/debug/jwt", async (c) => { - const { token } = c.req.query(); - const secret = new TextEncoder().encode(c.env.JWT_SIGNING_KEY); - const payload = { - slack_team_id: "T1234", - slack_user_id: "U1234", - slack_enterprise_id: "E1234", - slack_enterprise_install: true, - example_jwt: true, - }; - - if (token == null) { - const exampleToken = await new jose.SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setAudience("challenge_1234abcd") - .setIssuer(c.env.BASE_URL) - .setIssuedAt() - .setExpirationTime("15 minutes") - .sign(secret); - return c.json({ ok: true, result: exampleToken }); - } - - const result = await jose.jwtVerify(token, secret, { - issuer: c.env.BASE_URL, - clockTolerance: 30, - }); - return c.json({ ok: true, result }); -}); - app.get("/:link", async (c) => { try { const { link } = c.req.param(); @@ -211,6 +205,15 @@ app.get("/discord/:inviteCode", async (c) => { } }); app.get("/go/:link", async (c) => { + const url = new URL(c.req.url) + const { hostname } = url + + if (c.env.DEPLOY_ENV != "production") { + if (!hostname.endsWith("andreijiroh.xyz")) { + return c.newResponse(wikilinkNotAvailable, 404) + } + } + try { const { link } = c.req.param(); console.log(`[redirector]: incoming request with path - ${link}`); diff --git a/apps/golinks-v2/src/lib/auth.ts b/apps/golinks-v2/src/lib/auth.ts index c33dd6c..759b04a 100644 --- a/apps/golinks-v2/src/lib/auth.ts +++ b/apps/golinks-v2/src/lib/auth.ts @@ -6,6 +6,7 @@ import { generateSlug } from "./utils"; import { add } from "date-fns"; import { error } from "console"; import { InstallProvider } from "@slack/oauth"; +import { SignJWT } from "jose"; export const slackAppInstaller = (env: EnvBindings) => new InstallProvider({ @@ -46,38 +47,6 @@ export const slackAppInstaller = (env: EnvBindings) => }, }); -export async function adminApiKeyAuth(c: Context, next: Next) { - const adminApiKey = c.env.ADMIN_KEY; - const apiKeyHeader = c.req.header("X-Golinks-Admin-Key"); - - if (c.req.path.startsWith("/api/slack")) { - return await next(); - } else if (c.req.path.startsWith("/debug") || c.req.path.startsWith("/api/debug")) { - if (c.env.DEPLOY_ENV == "development") { - return await next(); - } - } - - console.debug(`[auth] ${adminApiKey}:${apiKeyHeader}`); - - if (c.req.method == "GET" || c.req.method == "HEAD") { - if (!c.req.path.startsWith("/api/debug")) { - return await next(); - } - } - - if (!apiKeyHeader || apiKeyHeader !== adminApiKey) { - return c.json( - { - success: false, - error: "Unauthorized", - }, - 401, - ); - } - return await next(); -} - export async function slackOAuthExchange(payload: object) { let formBody = Object.entries(payload) .map(([key, value]) => encodeURIComponent(key) + "=" + encodeURIComponent(value)) @@ -189,3 +158,47 @@ export async function addNewChallenge(db: EnvBindings["golinks"], challenge, met return Promise.reject(Error(error)); } } + +/** + * + * @param env The `context.env` object from Hono + * @param aud User ID from the database + * @param clientSecret The `jwtKeypass_stuff` string as client secret from ApiToken Prisma model. + * @returns + */ +export async function generateJwt(env: EnvBindings, aud: string, clientSecret: string) { + const secret = new TextEncoder().encode(env.JWT_SIGNING_KEY); + const signature = new SignJWT() + .setIssuer(env.BASE_URL) + .setExpirationTime("90d") + .setSubject(clientSecret) + .setAudience(aud) + .sign(secret) + return signature +} + +export async function handleApiKeyGeneration(env: EnvBindings, username: string) { + const adapter = new PrismaD1(env.golinks); + const prisma = new PrismaClient({ adapter }); + const client_secret = `jwtKeypass_${generateSlug(64)}` + try { + const userData = await prisma.user.findFirst({ + where: { + username + } + }) + const jwt = await generateJwt(env, userData.id, client_secret) + const dbResult = await prisma.apiToken.create({ + data: { + token: client_secret, + userId: userData.id + } + }) + return { + jwt, dbResult + } + } catch (error) { + console.error(error) + return Promise.reject(new Error(error)) + } +} diff --git a/apps/golinks-v2/src/lib/constants.ts b/apps/golinks-v2/src/lib/constants.ts index 431856e..51f34b7 100644 --- a/apps/golinks-v2/src/lib/constants.ts +++ b/apps/golinks-v2/src/lib/constants.ts @@ -1,26 +1,27 @@ -import { EnvBindings, Env } from "types"; +import { EnvBindings } from "types"; export const adminApiKey = { type: "apiKey", name: "X-Golinks-Admin-Key", in: "header", - description: "Superadmin API key. This is temporary while we're working on support for managing API tokens in the database.", - externalDocs: { - description: "Learn more about admin access", - url: homepage - } + description: "This is being deprecated for the use of bearer token-based `userApiKey` instead", + externalDocs: { + description: "Learn more about admin access", + url: homepage, + }, }; export const userApiKey = { - type: "http", - scheme: "bearer", - format: "JWT", - description: "User bearer token in JWT format. The token will be checked server-side for expiration status and if it is revoked manually.", - externalDocs: { - description: "Request API access", - url: "https://go.andreijiroh.xyz/request-api-access" - } -} + type: "http", + scheme: "bearer", + format: "JWT", + description: + "User bearer token in JWT format. The token will be checked server-side for expiration status and if it is revoked manually.", + externalDocs: { + description: "Request API access", + url: "https://go.andreijiroh.xyz/request-api-access", + }, +}; export const homepage = "https://wiki.andreijiroh.xyz/golinks"; export const sources = "https://github.com/andreijiroh-dev/api-servers/tree/main/apps/golinks-v2"; @@ -31,7 +32,7 @@ export const contact = { email: "ajhalili2006@andreijiroh.xyz", }; -export function getWorkersDashboardUrl(env: EnvBindings["DEPLOY_ENV"]) { +export function getWorkersDashboardUrl(env: EnvBindings["DEPLOY_ENV"]) { if (env == "production") { return "https://dash.cloudflare.com/cf0bd808c6a294fd8c4d8f6d2cdeca05/workers/services/view/golinks-next/production"; } else { @@ -75,19 +76,32 @@ export const tags = [ url: "https://go.andreijiroh.xyz/feedback/add-discord-invite", }, }, + { + name: "meta", + description: "Utility API endpoints to check API availability and get the commit hash of latest deploy", + }, + { + name: "debug", + description: "Requires admin API key (aka the `ADMIN_KEY` secret) to access them.", + }, ]; -export const discordServerNotFound = (url?: string) => ` -Either that server is not on our records (perhaps the slug is just renamed) or +export const discordServerNotFound = (url?: string) => `\ +Either that server is not on our records (perhaps the slug is just renamed) or \ something went wrong on our side. Still seeing this? Submit a ticket in our issue tracker using the following URL: https://go.andreijiroh.xyz/feedback/broken-link${url !== undefined ? `?url=${url}` : ""}`; + export const golinkNotFound = (url?: string) => `\ -Either that golink is not on our records (perhaps the slug is just renamed) or something +Either that golink is not on our records (perhaps the slug is just renamed) or something \ went wrong on our side. Still seeing this? Submit a ticket in our issue tracker using the following URL: https://go.andreijiroh.xyz/feedback/broken-link${url !== undefined ? `?url=${url}` : ""}`; + +export const wikilinkNotAvailable = `\ +Golink-styled wikilinks are available in andreijiroh.xyz subdomains (and friends \ +at the moment, especially in the main website and digital garden.`