Skip to content

Commit

Permalink
feat(golinks): simplify auth logic using bult-in bearerToken middleware
Browse files Browse the repository at this point in the history
Also in this commit we added debug API classes.

Signed-off-by: Andrei Jiroh Halili <[email protected]>
  • Loading branch information
ajhalili2006 committed Jul 27, 2024
1 parent 437d779 commit 874e2ec
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 86 deletions.
61 changes: 61 additions & 0 deletions apps/golinks-v2/src/api/debug.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
73 changes: 38 additions & 35 deletions apps/golinks-v2/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
golinkNotFound,
tags,
userApiKey,
wikilinkNotAvailable,
} from "lib/constants";
import { DiscordInviteLinkCreate, DiscordInviteLinkList } from "api/discord";
import { adminApiKeyAuth, slackAppInstaller } from "lib/auth";
Expand All @@ -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 }>();
Expand All @@ -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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}`);
Expand Down
77 changes: 45 additions & 32 deletions apps/golinks-v2/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
}
}
52 changes: 33 additions & 19 deletions apps/golinks-v2/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -31,7 +32,7 @@ export const contact = {
email: "[email protected]",
};

export function getWorkersDashboardUrl(env: EnvBindings<Env>["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 {
Expand Down Expand Up @@ -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.`

0 comments on commit 874e2ec

Please sign in to comment.