diff --git a/compose.ci.yaml b/compose.ci.yaml index f803c6b0..fd4250fb 100644 --- a/compose.ci.yaml +++ b/compose.ci.yaml @@ -18,6 +18,9 @@ services: DATABASE_HOST: db DATABASE_PORT: 5432 DATABASE_NAME: keijo_test + ATLASSIAN_CLIENT_ID: mock_id + ATLASSIAN_CLIENT_SECRET: mock_secret + SESSION_SECRET: mock_secret ports: - 4000:3001 diff --git a/compose.yaml b/compose.yaml index 4e4a2029..72124636 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,9 @@ services: service: server-base container_name: keijo-server environment: + CORS_ORIGIN: http://localhost:3000 + CALLBACK_URL: http://localhost:3001/jira/callback + CALLBACK_REDIRECT_URL: http://localhost:3000 DATABASE_NAME: keijo_dev ports: - 3001:3001 @@ -40,6 +43,8 @@ services: environment: NETVISOR_API_URL: http://nv-mock:4002 DATABASE_NAME: keijo_test + CORS_ORIGIN: http://localhost:4000 + ports: - 4001:3001 diff --git a/docs/configuration.md b/docs/configuration.md index 507c0066..7c5a6caa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,5 +31,14 @@ Key|Default|Description `DATABASE_HOST`||Postgres hostname. `DATABASE_PORT`||Postgres port. `DATABASE_SSL_MODE`|`false`|When set to `true`, TypeORM is configured to use SSL mode `no-verify` when connecting to Postgres. Otherwise, SSL is disabled. See [`node-postgres` docs](https://github.com/brianc/node-postgres/tree/master/packages/pg-connection-string#tcp-connections) for more. +`ATLASSIAN_CLIENT_ID`|| Client ID of your Atlassian OAuth 2.0 App. See [Jira OAuth2.0 apps](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/). +`ATLASSIAN_CLIENT_SECRET`|| Client Secret of your Atlassian OAuth 2.0 App. +`ATLASSIAN_AUTHORIZATION_URL`|https://auth.atlassian.com/authorize| Atlassian URL where user is directed to grant keijo access to use resources. +`ATLASSIAN_TOKEN_URL`|https://auth.atlassian.com/oauth/token| Atlassian URL where keijo exchanges authorization code for access and refresh tokens. +`CALLBACK_URL`|/jira/callback| Keijo URL where user is redirected from Jira after access is granted. +`CALLBACK_REDIRECT_URL`|/| Keijo URL where user is redirected from callback after callback is handled. +`SESSION_SECRET`|| Session secret. +`CORS_ORIGIN`|| Development Cors origin URL. +`TRUST_PROXY_IPS`|false| Trusted proxy ips to allow secure cookies to be sent over proxies. 1) See [Logging docs](./logging.md) for more information. diff --git a/docs/development-handbook.md b/docs/development-handbook.md index 941a4962..8dba70d3 100644 --- a/docs/development-handbook.md +++ b/docs/development-handbook.md @@ -121,3 +121,21 @@ Keijo is released as a single Docker image that contains the whole software. (Th 1. Make sure you are in `main` branch and the working tree is clean. 2. Run `npm version `. **Always follow the [semantic versioning guidelines](https://semver.org/).** This script uses `npm version` to apply the desired version bump, sync the sub-projects with it, and finally push the created tags to GitHub. 3. Allow the [CI/CD pipeline](https://github.com/funidata/keijo/actions) to finish. The new version is automatically published once the workflow completes. + +## Jira Integration + +Keijo integrates to Jira using OAuth2.0 to get, for example, issue related data. +The backend functions as a [token-mediating backend](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-token-mediating-backend), storing users Jira tokens in user sessions, and handing access token to the frontend to make +requests to [Jira Cloud REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v2). + +### Atlassian App + +An Atlassian OAuth2.0 app needs to be created to get necessary Atlassian credentials and values. +Atlassian app can be created in the [developer console](https://developer.atlassian.com/console/myapps/). +The app needs to be configured to have: + +- scope `read:jira-work` (permissions tab). +- callback URL e.g., `https:///jira/callback` or for local development e.g., `http://localhost:3001/jira/callback` (Authorization tab). + +Once these are added to the app, get the client ID and client sercret from app settings. +For more detailed steps and information see [Jira OAuth2.0 apps](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/). diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 5db3fbc2..702feecd 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; import { test as base } from "@playwright/test"; import { i18nFixture } from "./i18n-fixture"; -import dayjs, { Dayjs } from "dayjs"; +import dayjs from "dayjs"; import weekOfYear from "dayjs/plugin/weekOfYear"; import localizedFormat from "dayjs/plugin/localizedFormat"; import "dayjs/locale/en-gb"; @@ -11,14 +11,12 @@ import "dayjs/locale/fi"; */ export default defineConfig({ testDir: "./tests", - /* Run tests in files in parallel */ - fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: 2, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/e2e/tests/entry.mobile.spec.ts b/e2e/tests/entry.mobile.spec.ts index 005b6c61..474d36a0 100644 --- a/e2e/tests/entry.mobile.spec.ts +++ b/e2e/tests/entry.mobile.spec.ts @@ -114,20 +114,8 @@ test.describe("Entry defaults mobile", () => { }); test("Should use set default values", async ({ page, t }) => { - await page.goto(emptyWeekUrl + "/create"); - await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue(""); - await page.goto(emptyWeekUrl); - await page.getByRole("banner").getByLabel(t("controls.openMenu")).click(); - await page.getByRole("button", { name: t("controls.defaultsView") }).click(); - await page.getByRole("combobox", { name: t("entryDialog.product") }).fill(entries[0].product); - await page.getByRole("combobox", { name: t("entryDialog.activity") }).fill(entries[0].activity); - await page.goto(emptyWeekUrl + "/create"); - await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue( - entries[0].product, - ); - await expect(page.getByRole("combobox", { name: t("entryDialog.activity") })).toHaveValue( - entries[0].activity, - ); + await setDefaultValuesMobile(page, t, entries[0].product, entries[0].activity); + await setDefaultValuesMobile(page, t, productNames[1], activityNames[1]); }); }); @@ -140,3 +128,23 @@ const fillEntryFormMobile = async (page: Page, t: TFunction, entry: TestEntry) = await page.getByRole("gridcell", { name: entry.date.split(".")[0] }).click(); await page.getByRole("button", { name: "OK" }).click(); }; + +const setDefaultValuesMobile = async ( + page: Page, + t: TFunction, + product: string, + activity: string, +) => { + await page.goto(emptyWeekUrl); + await page.getByRole("banner").getByLabel(t("controls.openMenu")).click(); + await page.getByRole("button", { name: t("controls.defaultsView") }).click(); + await page.getByRole("combobox", { name: t("entryDialog.product") }).click(); + await page.getByRole("option", { name: product }).click(); + await page.getByRole("combobox", { name: t("entryDialog.activity") }).click(); + await page.getByRole("option", { name: activity }).click(); + await page.goto(emptyWeekUrl + "/create"); + await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue(product); + await expect(page.getByRole("combobox", { name: t("entryDialog.activity") })).toHaveValue( + activity, + ); +}; diff --git a/e2e/tests/entry.spec.ts b/e2e/tests/entry.spec.ts index 7ee36d77..a62ec612 100644 --- a/e2e/tests/entry.spec.ts +++ b/e2e/tests/entry.spec.ts @@ -115,25 +115,8 @@ test.describe("Entry defaults", () => { }); test("Should use set default values", async ({ page, t }) => { - await page.goto(emptyWeekUrl + "/create"); - await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue(""); - await page.goto(emptyWeekUrl); - - await page.getByLabel(t("controls.settingsMenu")).click(); - await page.getByRole("menuitem", { name: t("controls.defaultsView") }).click(); - - await page.getByRole("combobox", { name: t("entryDialog.product") }).click(); - await page.getByRole("option", { name: entries[0].product }).click(); - await page.getByRole("combobox", { name: t("entryDialog.activity") }).click(); - await page.getByRole("option", { name: entries[0].activity }).click(); - - await page.goto(emptyWeekUrl + "/create"); - await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue( - entries[0].product, - ); - await expect(page.getByRole("combobox", { name: t("entryDialog.activity") })).toHaveValue( - entries[0].activity, - ); + await setDefaultValues(page, t, entries[0].product, entries[0].activity); + await setDefaultValues(page, t, productNames[1], activityNames[1]); }); }); @@ -148,3 +131,20 @@ const fillEntryForm = async (page: Page, t: TFunction, entry: TestEntry) => { .first() .click(); }; + +const setDefaultValues = async (page: Page, t: TFunction, product: string, activity: string) => { + await page.goto(emptyWeekUrl); + await page.getByLabel(t("controls.settingsMenu")).click(); + await page.getByRole("menuitem", { name: t("controls.defaultsView") }).click(); + + await page.getByRole("combobox", { name: t("entryDialog.product") }).click(); + await page.getByRole("option", { name: product }).click(); + await page.getByRole("combobox", { name: t("entryDialog.activity") }).click(); + await page.getByRole("option", { name: activity }).click(); + + await page.goto(emptyWeekUrl + "/create"); + await expect(page.getByRole("combobox", { name: t("entryDialog.product") })).toHaveValue(product); + await expect(page.getByRole("combobox", { name: t("entryDialog.activity") })).toHaveValue( + activity, + ); +}; diff --git a/server/package-lock.json b/server/package-lock.json index 8a560466..41be0a45 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.0.8", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.0", "@nestjs/typeorm": "^10.0.2", @@ -21,10 +22,15 @@ "cache-manager": "^5.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "connect-pg-simple": "^9.0.1", + "cookie-parser": "^1.4.6", "dayjs": "^1.11.9", + "express-session": "^1.18.0", "fast-xml-parser": "^4.2.7", "graphql": "^16.8.0", "lodash": "^4.17.21", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0", "pg": "^8.12.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -38,10 +44,14 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/connect-pg-simple": "^7.0.3", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.2", "@types/lodash": "^4.14.197", "@types/node": "^20.3.1", + "@types/passport-oauth2": "^1.4.17", "@types/sha.js": "^2.4.1", "@types/supertest": "^2.0.12", "@types/uuid": "^9.0.2", @@ -2196,6 +2206,15 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.3.6", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.6.tgz", @@ -2589,6 +2608,26 @@ "@types/node": "*" } }, + "node_modules/@types/connect-pg-simple": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/connect-pg-simple/-/connect-pg-simple-7.0.3.tgz", + "integrity": "sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/express-session": "*", + "@types/pg": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -2643,6 +2682,15 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -2740,12 +2788,109 @@ "node": ">= 6" } }, + "node_modules/@types/oauth": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.5.tgz", + "integrity": "sha512-+oQ3C2Zx6ambINOcdIARF5Z3Tu3x//HipE889/fqo3sgpQZbe9c6ExdQFtN6qlhpR7p83lTZfPJt0tCAW29dog==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/passport": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", + "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-oauth2": { + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz", + "integrity": "sha512-ODiAHvso6JcWJ6ZkHHroVp05EHGhqQN533PtFNBkg8Fy5mERDqsr030AX81M0D69ZcaMvhF92SRckEk2B0HYYg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.11.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", + "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3541,6 +3686,14 @@ } ] }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -4119,6 +4272,17 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-pg-simple": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz", + "integrity": "sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==", + "dependencies": { + "pg": "^8.8.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -4157,6 +4321,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -4799,6 +4983,42 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6949,6 +7169,11 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6965,6 +7190,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6976,6 +7207,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7160,6 +7399,50 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7237,6 +7520,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", @@ -7282,6 +7570,15 @@ "node": ">=4.0.0" } }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/pg-pool": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", @@ -7452,6 +7749,12 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7590,6 +7893,14 @@ } ] }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9086,6 +9397,22 @@ "node": ">=8" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/server/package.json b/server/package.json index dd7d1ccb..c94d7d1f 100644 --- a/server/package.json +++ b/server/package.json @@ -27,6 +27,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/graphql": "^12.0.8", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/serve-static": "^4.0.0", "@nestjs/typeorm": "^10.0.2", @@ -34,10 +35,15 @@ "cache-manager": "^5.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "connect-pg-simple": "^9.0.1", + "cookie-parser": "^1.4.6", "dayjs": "^1.11.9", + "express-session": "^1.18.0", "fast-xml-parser": "^4.2.7", "graphql": "^16.8.0", "lodash": "^4.17.21", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0", "pg": "^8.12.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -51,10 +57,14 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/connect-pg-simple": "^7.0.3", + "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.2", "@types/lodash": "^4.14.197", "@types/node": "^20.3.1", + "@types/passport-oauth2": "^1.4.17", "@types/sha.js": "^2.4.1", "@types/supertest": "^2.0.12", "@types/uuid": "^9.0.2", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index ec5e3a52..650e3050 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -12,6 +12,7 @@ import { LoggerModule } from "./logger/logger.module"; import { NetvisorModule } from "./netvisor/netvisor.module"; import { SessionModule } from "./session/session.module"; import { UserSettingsModule } from "./user-settings/user-settings.module"; +import { JiraModule } from "./jira/jira.module"; @Module({ imports: [ @@ -27,6 +28,7 @@ import { UserSettingsModule } from "./user-settings/user-settings.module"; SessionModule, DatabaseModule, UserSettingsModule, + JiraModule, ], providers: [...appGuards], }) diff --git a/server/src/config/config.schema.ts b/server/src/config/config.schema.ts index 82d964d2..7af6795e 100644 --- a/server/src/config/config.schema.ts +++ b/server/src/config/config.schema.ts @@ -27,6 +27,20 @@ export const configSchema = object({ port: number(), ssl: boolean(), }), + jira: object({ + tokenUrl: string(), + clientId: string(), + clientSecret: string(), + authorizationUrl: string(), + callbackUrl: string(), + scopes: string(), + callbackRedirectUrl: string(), + }), + session: object({ + name: string(), + secret: string(), + }), + trustProxyIps: string().or(boolean()), }); export type Config = zod.infer; diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 539b10e4..0e584197 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -19,6 +19,14 @@ const { DATABASE_HOST, DATABASE_PORT, DATABASE_SSL_MODE, + ATLASSIAN_TOKEN_URL, + ATLASSIAN_CLIENT_ID, + ATLASSIAN_CLIENT_SECRET, + ATLASSIAN_AUTHORIZATION_URL, + CALLBACK_URL, + CALLBACK_REDIRECT_URL, + SESSION_SECRET, + TRUST_PROXY_IPS, } = process.env; const config = { @@ -49,6 +57,20 @@ const config = { port: Number(DATABASE_PORT), ssl: DATABASE_SSL_MODE === "true", }, + jira: { + tokenUrl: ATLASSIAN_TOKEN_URL || "https://auth.atlassian.com/oauth/token", + clientId: ATLASSIAN_CLIENT_ID, + clientSecret: ATLASSIAN_CLIENT_SECRET, + authorizationUrl: ATLASSIAN_AUTHORIZATION_URL || "https://auth.atlassian.com/authorize", + callbackUrl: CALLBACK_URL || "/jira/callback", + callbackRedirectUrl: CALLBACK_REDIRECT_URL || "/", + scopes: "read:jira-work offline_access", + }, + session: { + secret: SESSION_SECRET, + name: "sessionId", + }, + trustProxyIps: TRUST_PROXY_IPS || false, }; export default config; diff --git a/server/src/jira/jira.controller.ts b/server/src/jira/jira.controller.ts new file mode 100644 index 00000000..6bc6defc --- /dev/null +++ b/server/src/jira/jira.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Res, UseGuards } from "@nestjs/common"; +import { Response } from "express"; +import { BypassHeadersGuard } from "../decorators/bypass-headers-guard.decorator"; +import { JiraAuthGuard } from "./jira.guard"; +import { JiraService } from "./jira.service"; +import { SessionTokenGuard } from "./token.guard"; +import { ConfigService } from "../config/config.service"; +import { JiraTokens } from "./jira.types"; +import { SessionUser, ReqUser } from "./user.decorator"; + +@Controller("jira") +export class JiraController { + constructor( + private jiraService: JiraService, + private configService: ConfigService, + ) {} + + @BypassHeadersGuard() + @UseGuards(JiraAuthGuard) + @Get() + jiraAuthRedirect() {} + + @BypassHeadersGuard() + @UseGuards(JiraAuthGuard) + @Get("callback") + async handleRedirect(@Res() response: Response, @ReqUser() jiraTokens: JiraTokens) { + this.jiraService.setJiraSessionTokens(jiraTokens); + response.redirect(301, this.configService.config.jira.callbackRedirectUrl); + } + + @BypassHeadersGuard() + @UseGuards(SessionTokenGuard) + @Get("refresh") + async refreshTokens(@SessionUser() jiraTokens: JiraTokens) { + const freshTokens = await this.jiraService.getFreshTokens(jiraTokens.refreshToken); + this.jiraService.setJiraSessionTokens(freshTokens); + return { access_token: freshTokens.accessToken }; + } + + @BypassHeadersGuard() + @UseGuards(SessionTokenGuard) + @Get("access-token") + async getAccessToken(@SessionUser() jiraTokens: JiraTokens) { + return { access_token: jiraTokens.accessToken }; + } +} diff --git a/server/src/jira/jira.guard.ts b/server/src/jira/jira.guard.ts new file mode 100644 index 00000000..66833df2 --- /dev/null +++ b/server/src/jira/jira.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; + +@Injectable() +export class JiraAuthGuard extends AuthGuard("jira") {} diff --git a/server/src/jira/jira.module.ts b/server/src/jira/jira.module.ts new file mode 100644 index 00000000..dde0bf94 --- /dev/null +++ b/server/src/jira/jira.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { JiraController } from "./jira.controller"; +import { JiraStrategy } from "./jira.strategy"; +import { AxiosModule } from "../axios/axios.module"; +import { JiraService } from "./jira.service"; +import { PassportModule } from "@nestjs/passport"; + +@Module({ + imports: [AxiosModule, PassportModule.register({ session: true })], + controllers: [JiraController], + providers: [JiraStrategy, JiraService], +}) +export class JiraModule {} diff --git a/server/src/jira/jira.service.ts b/server/src/jira/jira.service.ts new file mode 100644 index 00000000..2a4633ab --- /dev/null +++ b/server/src/jira/jira.service.ts @@ -0,0 +1,36 @@ +import { Inject, Injectable, Scope } from "@nestjs/common"; +import { AxiosService } from "../axios/axios.service"; +import { ConfigService } from "../config/config.service"; +import { JiraTokens } from "./jira.types"; +import { REQUEST } from "@nestjs/core"; +import { Request } from "express"; + +@Injectable({ scope: Scope.REQUEST }) +export class JiraService { + constructor( + private axiosService: AxiosService, + private configService: ConfigService, + @Inject(REQUEST) private request: Request, + ) {} + + async getFreshTokens(refreshToken: string): Promise { + const { clientId, clientSecret, tokenUrl } = this.configService.config.jira; + const res = await this.axiosService.post<{ refresh_token: string; access_token: string }>( + tokenUrl, + { + grant_type: "refresh_token", + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + }, + { + headers: { "Content-Type": "application/json" }, + }, + ); + return { refreshToken: res.data.refresh_token, accessToken: res.data.access_token }; + } + + setJiraSessionTokens(jiraTokens: JiraTokens) { + this.request.session.user = jiraTokens; + } +} diff --git a/server/src/jira/jira.strategy.ts b/server/src/jira/jira.strategy.ts new file mode 100644 index 00000000..4c8cd30c --- /dev/null +++ b/server/src/jira/jira.strategy.ts @@ -0,0 +1,33 @@ +import { Strategy, StrategyOptions } from "passport-oauth2"; +import { PassportStrategy } from "@nestjs/passport"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "../config/config.service"; +import { JiraTokens } from "./jira.types"; + +@Injectable() +export class JiraStrategy extends PassportStrategy(Strategy, "jira") { + constructor(configService: ConfigService) { + const { authorizationUrl, callbackUrl, clientId, clientSecret, tokenUrl, scopes } = + configService.config.jira; + super({ + authorizationURL: authorizationUrl, + callbackURL: callbackUrl, + clientID: clientId, + clientSecret: clientSecret, + tokenURL: tokenUrl, + state: true, + scope: scopes, + }); + } + + authorizationParams() { + return { + audience: "api.atlassian.com", + prompt: "consent", + }; + } + + async validate(accessToken: string, refreshToken: string): Promise { + return { accessToken, refreshToken }; + } +} diff --git a/server/src/jira/jira.types.ts b/server/src/jira/jira.types.ts new file mode 100644 index 00000000..95adb8e6 --- /dev/null +++ b/server/src/jira/jira.types.ts @@ -0,0 +1,11 @@ +export type JiraTokens = { refreshToken: string; accessToken: string }; +export interface JiraUser { + user?: JiraTokens; +} + +declare module "express-session" { + interface SessionData extends JiraUser {} +} +declare module "express" { + export interface Request extends JiraUser {} +} diff --git a/server/src/jira/session-setup.ts b/server/src/jira/session-setup.ts new file mode 100644 index 00000000..a7c2a884 --- /dev/null +++ b/server/src/jira/session-setup.ts @@ -0,0 +1,31 @@ +import session from "express-session"; +import pgSession from "connect-pg-simple"; +import pg from "pg"; +import { ConfigService } from "../config/config.service"; + +export function createSession(configService: ConfigService) { + const { username, password, host, port, name } = configService.config.database; + const pgPool = new pg.Pool({ + database: name, + user: username, + password, + host, + port, + }); + + return session({ + store: new (pgSession(session))({ + pool: pgPool, + createTableIfMissing: configService.config.inDev, + }), + name: configService.config.session.name, + secret: configService.config.session.secret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: !configService.config.inDev, + maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days + }, + }); +} diff --git a/server/src/jira/token.guard.ts b/server/src/jira/token.guard.ts new file mode 100644 index 00000000..90c8c3ec --- /dev/null +++ b/server/src/jira/token.guard.ts @@ -0,0 +1,16 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Request } from "express"; + +@Injectable() +export class SessionTokenGuard implements CanActivate { + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const session = request.session; + + if (session.user?.refreshToken && session.user.accessToken) { + return true; + } + + return false; + } +} diff --git a/server/src/jira/user.decorator.ts b/server/src/jira/user.decorator.ts new file mode 100644 index 00000000..da20c9ad --- /dev/null +++ b/server/src/jira/user.decorator.ts @@ -0,0 +1,14 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { Request } from "express"; +import { SessionData } from "express-session"; + +export const SessionUser = createParamDecorator((_, ctx: ExecutionContext) => { + const request: Request = ctx.switchToHttp().getRequest(); + const session: SessionData = request.session; + return session.user; +}); + +export const ReqUser = createParamDecorator((_, ctx: ExecutionContext) => { + const request: Request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/server/src/main.ts b/server/src/main.ts index ad98f8d2..4d61674c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,17 +1,34 @@ import { ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; +import { NestExpressApplication } from "@nestjs/platform-express"; import { useContainer } from "class-validator"; import { AppModule } from "./app.module"; import { ConfigService } from "./config/config.service"; import { AppLogger } from "./logger/app-logger"; +import cookieParser from "cookie-parser"; +import { createSession } from "./jira/session-setup"; -const options = process.env.NODE_ENV === "development" ? { cors: { origin: "*" } } : {}; +const options = + process.env.NODE_ENV === "development" + ? { + cors: { + origin: process.env.CORS_ORIGIN, + credentials: true, + }, + } + : {}; (async () => { - const app = await NestFactory.create(AppModule, { bufferLogs: true, ...options }); + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + ...options, + }); const configService = app.get(ConfigService); app.useLogger(new AppLogger(configService)); useContainer(app.select(AppModule), { fallbackOnErrors: true }); app.useGlobalPipes(new ValidationPipe()); + app.set("trust proxy", configService.config.trustProxyIps); + app.use(createSession(configService)); + app.use(cookieParser()); await app.listen(configService.config.port); })(); diff --git a/server/test/utils/mock-config.service.ts b/server/test/utils/mock-config.service.ts index e3a7c2e3..c7f0e82e 100644 --- a/server/test/utils/mock-config.service.ts +++ b/server/test/utils/mock-config.service.ts @@ -28,6 +28,20 @@ export const defaultMockConfig: Config = { port: 0, ssl: false, }, + jira: { + tokenUrl: "test", + clientId: "test", + clientSecret: "test", + authorizationUrl: "test", + callbackUrl: "test", + callbackRedirectUrl: "test", + scopes: "test", + }, + session: { + name: "test", + secret: "test", + }, + trustProxyIps: false, }; const mockConfigProvider = (override: Partial = {}) => ({