diff --git a/package.json b/package.json index f6307a3..30949ce 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "fastify-zod": "^1.4.0", "lru-cache": "^11.0.2", "mongoose": "^8.9.0", + "qs": "^6.14.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9360dcb..b66290b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: mongoose: specifier: ^8.9.0 version: 8.9.0 + qs: + specifier: ^6.14.0 + version: 6.14.0 zod: specifier: ^3.24.1 version: 3.24.1 @@ -628,6 +631,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.3: + resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==} + engines: {node: '>= 0.4'} + camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} @@ -927,6 +934,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obliterator@2.0.4: resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==} @@ -984,6 +995,10 @@ packages: resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} engines: {node: '>=6.0.0'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1033,6 +1048,22 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + sift@17.1.3: resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} @@ -2225,6 +2256,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.3: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.2.7 + camel-case@4.1.2: dependencies: pascal-case: 3.1.2 @@ -2567,6 +2603,8 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + object-inspect@1.13.4: {} + obliterator@2.0.4: {} on-exit-leak-free@2.1.2: {} @@ -2633,6 +2671,10 @@ snapshots: pvutils@1.1.3: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + quick-format-unescaped@4.0.4: {} real-require@0.2.0: {} @@ -2667,6 +2709,34 @@ snapshots: setprototypeof@1.2.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + sift@17.1.3: {} simple-oauth2@5.1.0: diff --git a/src/mailProxy/mailProxy.route.ts b/src/mailProxy/mailProxy.route.ts new file mode 100644 index 0000000..f2c7446 --- /dev/null +++ b/src/mailProxy/mailProxy.route.ts @@ -0,0 +1,39 @@ +import { FastifyInstance } from "fastify"; +import { $mail, ProxyRequest } from "./mailProxy.schema"; +import { getOutlookTokens } from "./mailProxy.service"; +import axios from "axios"; + +export async function mailProxyRoutes(fastify: FastifyInstance) { + fastify.post( + "/", + { + schema: { + body: $mail("proxyRequest"), + }, + config: { requiredClaims: ["mail:all"] }, + preHandler: [fastify.authorize], + }, + async (req, res) => { + const input = req.body as ProxyRequest; + + try { + const tokens = await getOutlookTokens(input.email); + if (!tokens) return res.code(404).send({ error: "resource not found" }); + + const result = await axios({ + url: input.url, + method: input.method, + headers: { + Authorization: "Bearer " + tokens.access_token, + }, + data: input.body, + validateStatus: () => true, + }); + + return res.code(result.status).send(result.data); + } catch (err) { + return err; + } + } + ); +} diff --git a/src/mailProxy/mailProxy.schema.ts b/src/mailProxy/mailProxy.schema.ts new file mode 100644 index 0000000..9da76d2 --- /dev/null +++ b/src/mailProxy/mailProxy.schema.ts @@ -0,0 +1,34 @@ +import { buildJsonSchemas } from "fastify-zod"; +import mongoose from "mongoose"; +import { z } from "zod"; + +export const mailModel = mongoose.model( + "oauth", + new mongoose.Schema({ + email: { + type: String, + unique: true, + required: true, + }, + access_token: String, + expiry: Date, + refresh_token: String, + }), + "oauth" +); + +const proxyRequest = z.object({ + email: z.string().email(), + url: z.string(), + method: z.enum(["GET", "POST", "PATCH", "DELETE"]), + body: z.any(), +}); + +export type ProxyRequest = z.infer; + +export const { schemas: mailSchemas, $ref: $mail } = buildJsonSchemas( + { + proxyRequest, + }, + { $id: "mail" } +); diff --git a/src/mailProxy/mailProxy.service.ts b/src/mailProxy/mailProxy.service.ts new file mode 100644 index 0000000..14acad4 --- /dev/null +++ b/src/mailProxy/mailProxy.service.ts @@ -0,0 +1,48 @@ +import qs from "qs"; +import axios from "axios"; +import { mailModel } from "./mailProxy.schema"; + +export async function getOutlookTokens(email: string) { + let tokens = await mailModel.findOne({ email: email }); + + if (!tokens) { + return null; + } else { + const date = new Date(); + const expiry = new Date(tokens.expiry); + + if (expiry > date) { + return tokens.access_token; + } else { + try { + let res = await axios({ + url: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + data: qs.stringify({ + client_id: process.env.MAIL_CLIENT_ID, + scope: process.env.MAIL_SCOPE, + refresh_token: tokens.refresh_token, + redirect_uri: process.env.MAIL_REDIRECT_URI, + grant_type: "refresh_token", + client_secret: process.env.MAIL_CLIENT_SECRET, + }), + }); + + let expiresAt = new Date(Date.now() + res.data.expires_in * 1000); + + await mailModel.findByIdAndUpdate(tokens._id, { + expiry: expiresAt, + refresh_token: res.data.refresh_token, + access_token: res.data.access_token, + }); + + return res.data.access_token; + } catch (err) { + throw new Error("error fetching tokens"); + } + } + } +} diff --git a/src/server.ts b/src/server.ts index fa9d1c5..2f665ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { notificationSchemas } from "./notification/notification.schema"; import { noteSchemas } from "./note/note.schema"; import { webAuthnRoutes } from "./webauthn/webauthn.route"; import { configSchemas } from "./config/config.schema"; +import { mailSchemas } from "./mailProxy/mailProxy.schema"; const app = fastify({ logger: true, trustProxy: true }); @@ -49,6 +50,7 @@ for (const schema of [ ...notificationSchemas, ...noteSchemas, ...configSchemas, + ...mailSchemas, ]) { app.addSchema(schema); } diff --git a/src/utils/claims.ts b/src/utils/claims.ts index 9227bef..96faf9c 100644 --- a/src/utils/claims.ts +++ b/src/utils/claims.ts @@ -23,4 +23,5 @@ export type Claim = | "notification:read" | "notification:write" | "config:read" - | "config:write"; + | "config:write" + | "mail:all"; diff --git a/src/utils/roles.ts b/src/utils/roles.ts index 34867fb..c73b94e 100644 --- a/src/utils/roles.ts +++ b/src/utils/roles.ts @@ -28,6 +28,7 @@ export const rules: Record< "notification:write", "config:read", "config:write", + "mail:all", ], hiddenFields: { orgs: ["__v"], diff --git a/src/webauthn/webauthn.route.ts b/src/webauthn/webauthn.route.ts index f9a6c3b..ed70933 100644 --- a/src/webauthn/webauthn.route.ts +++ b/src/webauthn/webauthn.route.ts @@ -154,7 +154,7 @@ export async function webAuthnRoutes(fastify: FastifyInstance) { { schema: { body: { - type: "string", + type: "object", properties: { email: { type: "string" }, },