From 96062bc77ee69290361d175db3a39125c84aa653 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 14:59:20 +0300 Subject: [PATCH 01/29] Add env vars for jira and session --- server/src/config/config.schema.ts | 13 +++++++++++++ server/src/config/config.ts | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/src/config/config.schema.ts b/server/src/config/config.schema.ts index 82d964d2..5403f739 100644 --- a/server/src/config/config.schema.ts +++ b/server/src/config/config.schema.ts @@ -27,6 +27,19 @@ 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(), + }), }); export type Config = zod.infer; diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 539b10e4..e1246da8 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -19,6 +19,15 @@ const { DATABASE_HOST, DATABASE_PORT, DATABASE_SSL_MODE, + JIRA_TOKEN_URL, + ATLASSIAN_CLIENT_ID, + ATLASSIAN_CLIENT_SECRET, + ATLASSIAN_AUTHORIZATION_URL, + CALLBACK_URL, + CALLBACK_REDIRECT_URL, + SCOPES, + SESSION_NAME, + SESSION_SECRET, } = process.env; const config = { @@ -49,6 +58,20 @@ const config = { port: Number(DATABASE_PORT), ssl: DATABASE_SSL_MODE === "true", }, + jira: { + tokenUrl: JIRA_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 || NODE_ENV === "development" ? "http://localhost:3000/" : "/", + scopes: SCOPES || "read:jira-work offline_access", + }, + session: { + name: SESSION_NAME || "sessionId", + secret: SESSION_SECRET, + }, }; export default config; From 2083d7f3f8ade0f99b40c615275786424c1e60b5 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:00:01 +0300 Subject: [PATCH 02/29] Add packages for oauth and sessions --- server/package-lock.json | 214 +++++++++++++++++++++++++++++++++++++++ server/package.json | 8 ++ 2 files changed, 222 insertions(+) diff --git a/server/package-lock.json b/server/package-lock.json index 8a560466..69b7a525 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,11 +22,15 @@ "cache-manager": "^5.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.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", "pg": "^8.12.0", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sha.js": "^2.4.11", @@ -38,10 +43,13 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@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 +2204,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 +2606,15 @@ "@types/node": "*" } }, + "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 +2669,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 +2775,41 @@ "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/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", @@ -3541,6 +3605,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", @@ -4157,6 +4229,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 +4891,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 +7077,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", @@ -6976,6 +7109,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 +7301,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", @@ -7318,6 +7503,11 @@ "split2": "^4.1.0" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7590,6 +7780,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 +9284,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..4aa39086 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,11 +35,15 @@ "cache-manager": "^5.2.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.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", "pg": "^8.12.0", + "passport": "^0.7.0", + "passport-oauth2": "^1.8.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sha.js": "^2.4.11", @@ -51,10 +56,13 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@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", From 51d27e54bfe06ac91c5836e9d5b328f0c8e02a14 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:01:57 +0300 Subject: [PATCH 03/29] Add types --- server/src/jira/jira.types.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 server/src/jira/jira.types.ts diff --git a/server/src/jira/jira.types.ts b/server/src/jira/jira.types.ts new file mode 100644 index 00000000..e3a8cfd9 --- /dev/null +++ b/server/src/jira/jira.types.ts @@ -0,0 +1,6 @@ +import { Session, SessionData } from "express-session"; + +export type Tokens = { user: { refreshToken: string; accessToken: string } }; +export type SessionPartialTokens = Session & Partial; +export type RequestWithTokens = Request & Tokens; +export type SessionWithTokens = Session & SessionData & Tokens; From 847f949a070c1f385cf3c0decd2388cb032cd9e3 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:02:47 +0300 Subject: [PATCH 04/29] Add session guard --- server/src/jira/token.guard.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 server/src/jira/token.guard.ts diff --git a/server/src/jira/token.guard.ts b/server/src/jira/token.guard.ts new file mode 100644 index 00000000..212d8dd3 --- /dev/null +++ b/server/src/jira/token.guard.ts @@ -0,0 +1,14 @@ +import { Injectable, ExecutionContext, CanActivate, UnauthorizedException } from "@nestjs/common"; +import { Request } from "express"; +import { SessionPartialTokens } from "./jira.types"; + +@Injectable() +export class tokenGuard implements CanActivate { + async canActivate(context: ExecutionContext) { + const req: Request = context.switchToHttp().getRequest(); + const session: SessionPartialTokens = req.session; + if (!session.user || !session.user.refreshToken || !session.user.accessToken) + throw new UnauthorizedException(); + return true; + } +} From c247e9fe7212cac77511d73b9950ae3ff201050f Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:04:04 +0300 Subject: [PATCH 05/29] Add jira service with refresh tokens --- server/src/jira/jira.service.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 server/src/jira/jira.service.ts diff --git a/server/src/jira/jira.service.ts b/server/src/jira/jira.service.ts new file mode 100644 index 00000000..832f56e6 --- /dev/null +++ b/server/src/jira/jira.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from "@nestjs/common"; +import { AxiosResponse } from "axios"; +import { AxiosService } from "src/axios/axios.service"; +import { ConfigService } from "src/config/config.service"; + +@Injectable() +export class JiraService { + constructor( + private axiosService: AxiosService, + private configService: ConfigService, + ) {} + + async getFreshTokens( + refreshToken: string, + ): Promise<{ refreshToken: string; accessToken: string }> { + const { clientId, clientSecret, tokenUrl } = this.configService.config.jira; + const res: AxiosResponse<{ refresh_token: string; access_token: string }> = + await this.axiosService.post( + 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 }; + } +} From ae7cac7df2cb781de54abebc98cceba2a1c2c8ce Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:05:27 +0300 Subject: [PATCH 06/29] Add strategy for jira oauth2 --- server/src/jira/jira.guard.ts | 5 +++++ server/src/jira/jira.strategy.ts | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 server/src/jira/jira.guard.ts create mode 100644 server/src/jira/jira.strategy.ts 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.strategy.ts b/server/src/jira/jira.strategy.ts new file mode 100644 index 00000000..1f303d78 --- /dev/null +++ b/server/src/jira/jira.strategy.ts @@ -0,0 +1,35 @@ +import { Strategy, StrategyOptions } from "passport-oauth2"; +import { PassportStrategy } from "@nestjs/passport"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "src/config/config.service"; + +@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<{ accessToken: string; refreshToken: string }> { + return { accessToken, refreshToken }; + } +} From cb90aa1f47a2985e5ecec72cf18a42eb761ff514 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:06:28 +0300 Subject: [PATCH 07/29] Add jira controller --- server/src/jira/jira.controller.ts | 55 ++++++++++++++++++++++++++++++ server/src/jira/jira.module.ts | 13 +++++++ 2 files changed, 68 insertions(+) create mode 100644 server/src/jira/jira.controller.ts create mode 100644 server/src/jira/jira.module.ts diff --git a/server/src/jira/jira.controller.ts b/server/src/jira/jira.controller.ts new file mode 100644 index 00000000..da66880c --- /dev/null +++ b/server/src/jira/jira.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Req, Res, Session, UseGuards } from "@nestjs/common"; +import { Response } from "express"; +import { BypassHeadersGuard } from "src/decorators/bypass-headers-guard.decorator"; +import { JiraAuthGuard } from "./jira.guard"; +import { JiraService } from "./jira.service"; +import { tokenGuard } from "./token.guard"; +import { ConfigService } from "src/config/config.service"; +import { RequestWithTokens, SessionWithTokens } from "./jira.types"; + +@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({ passthrough: true }) response: Response, + @Req() + request: RequestWithTokens, + @Session() session: SessionWithTokens, + ) { + session.user = { + accessToken: request.user.accessToken, + refreshToken: request.user.refreshToken, + }; + response.redirect(301, this.configService.config.jira.callbackRedirectUrl); + } + + @BypassHeadersGuard() + @UseGuards(tokenGuard) + @Get("refresh") + async refreshTokens(@Session() session: SessionWithTokens) { + const { accessToken, refreshToken } = await this.jiraService.getFreshTokens( + session.user.refreshToken, + ); + session.user = { accessToken: accessToken, refreshToken: refreshToken }; + return { access_token: accessToken }; + } + + @BypassHeadersGuard() + @UseGuards(tokenGuard) + @Get("access-token") + async getAccessToken(@Session() session: SessionWithTokens) { + return { access_token: session.user.accessToken }; + } +} diff --git a/server/src/jira/jira.module.ts b/server/src/jira/jira.module.ts new file mode 100644 index 00000000..d2c97a36 --- /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 "src/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 {} From ef4d2fcc7b35347e86f64713b18aa77bccf36c76 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:07:37 +0300 Subject: [PATCH 08/29] Use jira and session modules to app --- server/src/app.module.ts | 2 ++ server/src/main.ts | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) 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/main.ts b/server/src/main.ts index ad98f8d2..fa46da32 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -4,8 +4,14 @@ 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 session from "express-session"; +import passport from "passport"; -const options = process.env.NODE_ENV === "development" ? { cors: { origin: "*" } } : {}; +const options = + process.env.NODE_ENV === "development" + ? { cors: { origin: "http://localhost:3000", credentials: true } } + : {}; (async () => { const app = await NestFactory.create(AppModule, { bufferLogs: true, ...options }); @@ -13,5 +19,21 @@ const options = process.env.NODE_ENV === "development" ? { cors: { origin: "*" } app.useLogger(new AppLogger(configService)); useContainer(app.select(AppModule), { fallbackOnErrors: true }); app.useGlobalPipes(new ValidationPipe()); + app.use( + // TODO: change default store to pg store + session({ + name: configService.config.session.name, + secret: configService.config.session.secret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + }, + }), + ); + app.use(passport.initialize()); + app.use(passport.session()); + app.use(cookieParser()); await app.listen(configService.config.port); })(); From a6557467d62af0d95d1875c181fc75a75e1fd056 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:23:06 +0300 Subject: [PATCH 09/29] Remove unnecessary passport middlware --- server/src/main.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/src/main.ts b/server/src/main.ts index fa46da32..010bc702 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -6,7 +6,6 @@ import { ConfigService } from "./config/config.service"; import { AppLogger } from "./logger/app-logger"; import cookieParser from "cookie-parser"; import session from "express-session"; -import passport from "passport"; const options = process.env.NODE_ENV === "development" @@ -32,8 +31,6 @@ const options = }, }), ); - app.use(passport.initialize()); - app.use(passport.session()); app.use(cookieParser()); await app.listen(configService.config.port); })(); From ed0c605faf481a50acf6580ffb917fa085cafc07 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:53:52 +0300 Subject: [PATCH 10/29] Add new fields to test config --- server/test/utils/mock-config.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/test/utils/mock-config.service.ts b/server/test/utils/mock-config.service.ts index e3a7c2e3..f4b6d7f7 100644 --- a/server/test/utils/mock-config.service.ts +++ b/server/test/utils/mock-config.service.ts @@ -28,6 +28,19 @@ 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", + }, }; const mockConfigProvider = (override: Partial = {}) => ({ From 0f3d45406b60eafe2af14b861263cabb095e9415 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 5 Jun 2024 15:54:20 +0300 Subject: [PATCH 11/29] Change development env cors options --- server/src/main.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/main.ts b/server/src/main.ts index 010bc702..b5035e25 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -7,10 +7,7 @@ import { AppLogger } from "./logger/app-logger"; import cookieParser from "cookie-parser"; import session from "express-session"; -const options = - process.env.NODE_ENV === "development" - ? { cors: { origin: "http://localhost:3000", credentials: true } } - : {}; +const options = process.env.NODE_ENV === "development" ? { cors: { origin: "*" } } : {}; (async () => { const app = await NestFactory.create(AppModule, { bufferLogs: true, ...options }); From 109790d229420306c2d8640585dab5882fefae71 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 6 Jun 2024 16:33:19 +0300 Subject: [PATCH 12/29] Refactor jira session logic and types --- server/src/jira/jira.controller.ts | 37 +++++++++++------------------- server/src/jira/jira.service.ts | 12 ++++++++-- server/src/jira/jira.types.ts | 15 ++++++++---- server/src/jira/token.guard.ts | 6 ++--- server/src/jira/user.decorator.ts | 14 +++++++++++ 5 files changed, 51 insertions(+), 33 deletions(-) create mode 100644 server/src/jira/user.decorator.ts diff --git a/server/src/jira/jira.controller.ts b/server/src/jira/jira.controller.ts index da66880c..15fb1857 100644 --- a/server/src/jira/jira.controller.ts +++ b/server/src/jira/jira.controller.ts @@ -1,11 +1,12 @@ -import { Controller, Get, Req, Res, Session, UseGuards } from "@nestjs/common"; +import { Controller, Get, Res, UseGuards } from "@nestjs/common"; import { Response } from "express"; import { BypassHeadersGuard } from "src/decorators/bypass-headers-guard.decorator"; import { JiraAuthGuard } from "./jira.guard"; import { JiraService } from "./jira.service"; -import { tokenGuard } from "./token.guard"; +import { SessionTokenGuard } from "./token.guard"; import { ConfigService } from "src/config/config.service"; -import { RequestWithTokens, SessionWithTokens } from "./jira.types"; +import { JiraTokens } from "./jira.types"; +import { SessionUser, ReqUser } from "./user.decorator"; @Controller("jira") export class JiraController { @@ -22,34 +23,24 @@ export class JiraController { @BypassHeadersGuard() @UseGuards(JiraAuthGuard) @Get("callback") - async handleRedirect( - @Res({ passthrough: true }) response: Response, - @Req() - request: RequestWithTokens, - @Session() session: SessionWithTokens, - ) { - session.user = { - accessToken: request.user.accessToken, - refreshToken: request.user.refreshToken, - }; + async handleRedirect(@Res() response: Response, @ReqUser() jiraTokens: JiraTokens) { + this.jiraService.setJiraSessionTokens(jiraTokens); response.redirect(301, this.configService.config.jira.callbackRedirectUrl); } @BypassHeadersGuard() - @UseGuards(tokenGuard) + @UseGuards(SessionTokenGuard) @Get("refresh") - async refreshTokens(@Session() session: SessionWithTokens) { - const { accessToken, refreshToken } = await this.jiraService.getFreshTokens( - session.user.refreshToken, - ); - session.user = { accessToken: accessToken, refreshToken: refreshToken }; - return { access_token: accessToken }; + async refreshTokens(@SessionUser() jiraTokens: JiraTokens) { + const freshTokens = await this.jiraService.getFreshTokens(jiraTokens.refreshToken); + this.jiraService.setJiraSessionTokens(freshTokens); + return { access_token: freshTokens.accessToken }; } @BypassHeadersGuard() - @UseGuards(tokenGuard) + @UseGuards(SessionTokenGuard) @Get("access-token") - async getAccessToken(@Session() session: SessionWithTokens) { - return { access_token: session.user.accessToken }; + async getAccessToken(@SessionUser() jiraTokens: JiraTokens) { + return { access_token: jiraTokens.accessToken }; } } diff --git a/server/src/jira/jira.service.ts b/server/src/jira/jira.service.ts index 832f56e6..61255d91 100644 --- a/server/src/jira/jira.service.ts +++ b/server/src/jira/jira.service.ts @@ -1,13 +1,17 @@ -import { Injectable } from "@nestjs/common"; +import { Inject, Injectable, Scope } from "@nestjs/common"; import { AxiosResponse } from "axios"; import { AxiosService } from "src/axios/axios.service"; import { ConfigService } from "src/config/config.service"; +import { JiraTokens } from "./jira.types"; +import { REQUEST } from "@nestjs/core"; +import { Request } from "express"; -@Injectable() +@Injectable({ scope: Scope.REQUEST }) export class JiraService { constructor( private axiosService: AxiosService, private configService: ConfigService, + @Inject(REQUEST) private request: Request, ) {} async getFreshTokens( @@ -29,4 +33,8 @@ export class JiraService { ); 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.types.ts b/server/src/jira/jira.types.ts index e3a8cfd9..95adb8e6 100644 --- a/server/src/jira/jira.types.ts +++ b/server/src/jira/jira.types.ts @@ -1,6 +1,11 @@ -import { Session, SessionData } from "express-session"; +export type JiraTokens = { refreshToken: string; accessToken: string }; +export interface JiraUser { + user?: JiraTokens; +} -export type Tokens = { user: { refreshToken: string; accessToken: string } }; -export type SessionPartialTokens = Session & Partial; -export type RequestWithTokens = Request & Tokens; -export type SessionWithTokens = Session & SessionData & Tokens; +declare module "express-session" { + interface SessionData extends JiraUser {} +} +declare module "express" { + export interface Request extends JiraUser {} +} diff --git a/server/src/jira/token.guard.ts b/server/src/jira/token.guard.ts index 212d8dd3..967c2658 100644 --- a/server/src/jira/token.guard.ts +++ b/server/src/jira/token.guard.ts @@ -1,12 +1,12 @@ import { Injectable, ExecutionContext, CanActivate, UnauthorizedException } from "@nestjs/common"; import { Request } from "express"; -import { SessionPartialTokens } from "./jira.types"; +import { SessionData } from "express-session"; @Injectable() -export class tokenGuard implements CanActivate { +export class SessionTokenGuard implements CanActivate { async canActivate(context: ExecutionContext) { const req: Request = context.switchToHttp().getRequest(); - const session: SessionPartialTokens = req.session; + const session: SessionData = req.session; if (!session.user || !session.user.refreshToken || !session.user.accessToken) throw new UnauthorizedException(); return true; 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; +}); From a573ebbc146c31dc6ce5d1d7491ee87d167c3ee0 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 6 Jun 2024 16:34:14 +0300 Subject: [PATCH 13/29] Fix cors error for local dev and test --- compose.yaml | 2 ++ server/src/main.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index 4e4a2029..622305dd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,6 +40,8 @@ services: environment: NETVISOR_API_URL: http://nv-mock:4002 DATABASE_NAME: keijo_test + DEV_FRONTEND_PORT: 4000 + ports: - 4001:3001 diff --git a/server/src/main.ts b/server/src/main.ts index b5035e25..cadd9e4b 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -7,7 +7,15 @@ import { AppLogger } from "./logger/app-logger"; import cookieParser from "cookie-parser"; import session from "express-session"; -const options = process.env.NODE_ENV === "development" ? { cors: { origin: "*" } } : {}; +const options = + process.env.NODE_ENV === "development" + ? { + cors: { + origin: `http://localhost:${process.env.DEV_FRONTEND_PORT || 3000}`, + credentials: true, + }, + } + : {}; (async () => { const app = await NestFactory.create(AppModule, { bufferLogs: true, ...options }); From 3131b991b5b58f42871f9309ab77055a8258720c Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 11 Jun 2024 15:42:13 +0300 Subject: [PATCH 14/29] Add jira mock env values for ci --- compose.ci.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From 6b0b5868f259092fb5c43e3dcf15d59546898593 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 11 Jun 2024 15:52:44 +0300 Subject: [PATCH 15/29] Clean up jira token types --- server/src/jira/jira.service.ts | 4 +--- server/src/jira/jira.strategy.ts | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/server/src/jira/jira.service.ts b/server/src/jira/jira.service.ts index 61255d91..03e68c8d 100644 --- a/server/src/jira/jira.service.ts +++ b/server/src/jira/jira.service.ts @@ -14,9 +14,7 @@ export class JiraService { @Inject(REQUEST) private request: Request, ) {} - async getFreshTokens( - refreshToken: string, - ): Promise<{ refreshToken: string; accessToken: string }> { + async getFreshTokens(refreshToken: string): Promise { const { clientId, clientSecret, tokenUrl } = this.configService.config.jira; const res: AxiosResponse<{ refresh_token: string; access_token: string }> = await this.axiosService.post( diff --git a/server/src/jira/jira.strategy.ts b/server/src/jira/jira.strategy.ts index 1f303d78..1933f904 100644 --- a/server/src/jira/jira.strategy.ts +++ b/server/src/jira/jira.strategy.ts @@ -2,6 +2,7 @@ import { Strategy, StrategyOptions } from "passport-oauth2"; import { PassportStrategy } from "@nestjs/passport"; import { Injectable } from "@nestjs/common"; import { ConfigService } from "src/config/config.service"; +import { JiraTokens } from "./jira.types"; @Injectable() export class JiraStrategy extends PassportStrategy(Strategy, "jira") { @@ -26,10 +27,7 @@ export class JiraStrategy extends PassportStrategy(Strategy, "jira") { }; } - async validate( - accessToken: string, - refreshToken: string, - ): Promise<{ accessToken: string; refreshToken: string }> { + async validate(accessToken: string, refreshToken: string): Promise { return { accessToken, refreshToken }; } } From ee840ceb0d3b5be82e40f141e052e57662d44d74 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 12 Jun 2024 13:58:48 +0300 Subject: [PATCH 16/29] Use postgres to store sessions --- server/package-lock.json | 125 +++++++++++++++++++++++++++++++++++++-- server/package.json | 4 +- server/src/main.ts | 16 ++++- 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 69b7a525..41be0a45 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,15 +22,16 @@ "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", - "pg": "^8.12.0", "passport": "^0.7.0", "passport-oauth2": "^1.8.0", + "pg": "^8.12.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sha.js": "^2.4.11", @@ -43,6 +44,7 @@ "@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", @@ -2606,6 +2608,17 @@ "@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", @@ -2810,6 +2823,74 @@ "@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", @@ -4191,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", @@ -7098,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", @@ -7422,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", @@ -7467,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", @@ -7503,11 +7615,6 @@ "split2": "^4.1.0" } }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" - }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7642,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", diff --git a/server/package.json b/server/package.json index 4aa39086..c94d7d1f 100644 --- a/server/package.json +++ b/server/package.json @@ -35,15 +35,16 @@ "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", - "pg": "^8.12.0", "passport": "^0.7.0", "passport-oauth2": "^1.8.0", + "pg": "^8.12.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "sha.js": "^2.4.11", @@ -56,6 +57,7 @@ "@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", diff --git a/server/src/main.ts b/server/src/main.ts index cadd9e4b..4951bae5 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -6,6 +6,8 @@ import { ConfigService } from "./config/config.service"; import { AppLogger } from "./logger/app-logger"; import cookieParser from "cookie-parser"; import session from "express-session"; +import pgSession from "connect-pg-simple"; +import pg from "pg"; const options = process.env.NODE_ENV === "development" @@ -23,9 +25,20 @@ const options = app.useLogger(new AppLogger(configService)); useContainer(app.select(AppModule), { fallbackOnErrors: true }); app.useGlobalPipes(new ValidationPipe()); + const { username, password, host, port, name } = configService.config.database; + const pgPool = new pg.Pool({ + database: name, + user: username, + password, + host, + port, + }); app.use( - // TODO: change default store to pg store session({ + store: new (pgSession(session))({ + pool: pgPool, + createTableIfMissing: true, + }), name: configService.config.session.name, secret: configService.config.session.secret, resave: false, @@ -33,6 +46,7 @@ const options = cookie: { httpOnly: true, secure: process.env.NODE_ENV !== "development", + maxAge: 90 * 24 * 60 * 60 * 1000, }, }), ); From a1ed3dfee2629a565a2ecb0811f580b6664efcb6 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Mon, 1 Jul 2024 13:41:28 +0300 Subject: [PATCH 17/29] Refactor session creation --- server/src/jira/session-setup.ts | 31 +++++++++++++++++++++++++++++++ server/src/main.ts | 30 ++---------------------------- 2 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 server/src/jira/session-setup.ts diff --git a/server/src/jira/session-setup.ts b/server/src/jira/session-setup.ts new file mode 100644 index 00000000..93e6643c --- /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 "src/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: true, + }), + name: configService.config.session.name, + secret: configService.config.session.secret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV !== "development", + maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days + }, + }); +} diff --git a/server/src/main.ts b/server/src/main.ts index 4951bae5..e2d4b28a 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -5,9 +5,7 @@ import { AppModule } from "./app.module"; import { ConfigService } from "./config/config.service"; import { AppLogger } from "./logger/app-logger"; import cookieParser from "cookie-parser"; -import session from "express-session"; -import pgSession from "connect-pg-simple"; -import pg from "pg"; +import { createSession } from "./jira/session-setup"; const options = process.env.NODE_ENV === "development" @@ -25,31 +23,7 @@ const options = app.useLogger(new AppLogger(configService)); useContainer(app.select(AppModule), { fallbackOnErrors: true }); app.useGlobalPipes(new ValidationPipe()); - const { username, password, host, port, name } = configService.config.database; - const pgPool = new pg.Pool({ - database: name, - user: username, - password, - host, - port, - }); - app.use( - session({ - store: new (pgSession(session))({ - pool: pgPool, - createTableIfMissing: true, - }), - name: configService.config.session.name, - secret: configService.config.session.secret, - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - secure: process.env.NODE_ENV !== "development", - maxAge: 90 * 24 * 60 * 60 * 1000, - }, - }), - ); + app.use(createSession(configService)); app.use(cookieParser()); await app.listen(configService.config.port); })(); From 4c1a91d69d9ccfdb56617f206c012e487bec9c41 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 2 Jul 2024 13:38:45 +0300 Subject: [PATCH 18/29] Edit env variables --- compose.yaml | 2 +- server/src/config/config.ts | 4 ++-- server/src/main.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 622305dd..8e3a1540 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,7 +40,7 @@ services: environment: NETVISOR_API_URL: http://nv-mock:4002 DATABASE_NAME: keijo_test - DEV_FRONTEND_PORT: 4000 + CORS_ORIGIN: http://localhost:4000 ports: - 4001:3001 diff --git a/server/src/config/config.ts b/server/src/config/config.ts index e1246da8..48e9f4fe 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -19,7 +19,7 @@ const { DATABASE_HOST, DATABASE_PORT, DATABASE_SSL_MODE, - JIRA_TOKEN_URL, + ATLASSIAN_TOKEN_URL, ATLASSIAN_CLIENT_ID, ATLASSIAN_CLIENT_SECRET, ATLASSIAN_AUTHORIZATION_URL, @@ -59,7 +59,7 @@ const config = { ssl: DATABASE_SSL_MODE === "true", }, jira: { - tokenUrl: JIRA_TOKEN_URL || "https://auth.atlassian.com/oauth/token", + 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", diff --git a/server/src/main.ts b/server/src/main.ts index e2d4b28a..53731df7 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -11,7 +11,7 @@ const options = process.env.NODE_ENV === "development" ? { cors: { - origin: `http://localhost:${process.env.DEV_FRONTEND_PORT || 3000}`, + origin: process.env.CORS_ORIGIN, credentials: true, }, } From 5b901814f3953bc9c46522b8c003fe660757f156 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 2 Jul 2024 13:41:17 +0300 Subject: [PATCH 19/29] Add new env vars to docs --- docs/configuration.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 507c0066..2db60fdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,5 +31,15 @@ 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. +`SCOPES`|read:jira-work offline_access| Scopes for Jira data that keijo requests access for. See [Jira OAuth Scopes](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/). +`SESSION_NAME`|sessionId| Name for session that is created for user when authorizing Jira. +`SESSION_SECRET`|| Session secret. +`CORS_ORIGIN`|| Development Cors origin URL. 1) See [Logging docs](./logging.md) for more information. From 9b7519b365d81bf6fb0f91413f188328f08b97d4 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 2 Jul 2024 13:58:26 +0300 Subject: [PATCH 20/29] Small fixes --- server/src/config/config.ts | 3 +-- server/src/jira/jira.controller.ts | 4 ++-- server/src/jira/jira.module.ts | 2 +- server/src/jira/jira.service.ts | 30 ++++++++++++++---------------- server/src/jira/jira.strategy.ts | 2 +- server/src/jira/session-setup.ts | 6 +++--- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 48e9f4fe..bd837ed6 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -64,8 +64,7 @@ const config = { clientSecret: ATLASSIAN_CLIENT_SECRET, authorizationUrl: ATLASSIAN_AUTHORIZATION_URL || "https://auth.atlassian.com/authorize", callbackUrl: CALLBACK_URL || "/jira/callback", - callbackRedirectUrl: - CALLBACK_REDIRECT_URL || NODE_ENV === "development" ? "http://localhost:3000/" : "/", + callbackRedirectUrl: CALLBACK_REDIRECT_URL || "/", scopes: SCOPES || "read:jira-work offline_access", }, session: { diff --git a/server/src/jira/jira.controller.ts b/server/src/jira/jira.controller.ts index 15fb1857..6bc6defc 100644 --- a/server/src/jira/jira.controller.ts +++ b/server/src/jira/jira.controller.ts @@ -1,10 +1,10 @@ import { Controller, Get, Res, UseGuards } from "@nestjs/common"; import { Response } from "express"; -import { BypassHeadersGuard } from "src/decorators/bypass-headers-guard.decorator"; +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 "src/config/config.service"; +import { ConfigService } from "../config/config.service"; import { JiraTokens } from "./jira.types"; import { SessionUser, ReqUser } from "./user.decorator"; diff --git a/server/src/jira/jira.module.ts b/server/src/jira/jira.module.ts index d2c97a36..dde0bf94 100644 --- a/server/src/jira/jira.module.ts +++ b/server/src/jira/jira.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { JiraController } from "./jira.controller"; import { JiraStrategy } from "./jira.strategy"; -import { AxiosModule } from "src/axios/axios.module"; +import { AxiosModule } from "../axios/axios.module"; import { JiraService } from "./jira.service"; import { PassportModule } from "@nestjs/passport"; diff --git a/server/src/jira/jira.service.ts b/server/src/jira/jira.service.ts index 03e68c8d..2a4633ab 100644 --- a/server/src/jira/jira.service.ts +++ b/server/src/jira/jira.service.ts @@ -1,7 +1,6 @@ import { Inject, Injectable, Scope } from "@nestjs/common"; -import { AxiosResponse } from "axios"; -import { AxiosService } from "src/axios/axios.service"; -import { ConfigService } from "src/config/config.service"; +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"; @@ -16,19 +15,18 @@ export class JiraService { async getFreshTokens(refreshToken: string): Promise { const { clientId, clientSecret, tokenUrl } = this.configService.config.jira; - const res: AxiosResponse<{ refresh_token: string; access_token: string }> = - await this.axiosService.post( - tokenUrl, - { - grant_type: "refresh_token", - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - }, - { - headers: { "Content-Type": "application/json" }, - }, - ); + 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 }; } diff --git a/server/src/jira/jira.strategy.ts b/server/src/jira/jira.strategy.ts index 1933f904..4c8cd30c 100644 --- a/server/src/jira/jira.strategy.ts +++ b/server/src/jira/jira.strategy.ts @@ -1,7 +1,7 @@ import { Strategy, StrategyOptions } from "passport-oauth2"; import { PassportStrategy } from "@nestjs/passport"; import { Injectable } from "@nestjs/common"; -import { ConfigService } from "src/config/config.service"; +import { ConfigService } from "../config/config.service"; import { JiraTokens } from "./jira.types"; @Injectable() diff --git a/server/src/jira/session-setup.ts b/server/src/jira/session-setup.ts index 93e6643c..a7c2a884 100644 --- a/server/src/jira/session-setup.ts +++ b/server/src/jira/session-setup.ts @@ -1,7 +1,7 @@ import session from "express-session"; import pgSession from "connect-pg-simple"; import pg from "pg"; -import { ConfigService } from "src/config/config.service"; +import { ConfigService } from "../config/config.service"; export function createSession(configService: ConfigService) { const { username, password, host, port, name } = configService.config.database; @@ -16,7 +16,7 @@ export function createSession(configService: ConfigService) { return session({ store: new (pgSession(session))({ pool: pgPool, - createTableIfMissing: true, + createTableIfMissing: configService.config.inDev, }), name: configService.config.session.name, secret: configService.config.session.secret, @@ -24,7 +24,7 @@ export function createSession(configService: ConfigService) { saveUninitialized: false, cookie: { httpOnly: true, - secure: process.env.NODE_ENV !== "development", + secure: !configService.config.inDev, maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days }, }); From 46b9edc5d1e447489cbd19d52919388804fc8b8f Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 2 Jul 2024 15:43:39 +0300 Subject: [PATCH 21/29] Add docs about Jira integration --- docs/development-handbook.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/development-handbook.md b/docs/development-handbook.md index 941a4962..22bc0bfd 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:///callback/jira` (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/). From 683216bb11f8b5711b1977be2051e5e886ec90a3 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Tue, 2 Jul 2024 16:38:03 +0300 Subject: [PATCH 22/29] Add trust proxy env var --- docs/configuration.md | 1 + server/src/config/config.schema.ts | 1 + server/src/config/config.ts | 2 ++ server/src/main.ts | 7 ++++++- 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2db60fdd..e9af82a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,5 +41,6 @@ Key|Default|Description `SESSION_NAME`|sessionId| Name for session that is created for user when authorizing Jira. `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/server/src/config/config.schema.ts b/server/src/config/config.schema.ts index 5403f739..7af6795e 100644 --- a/server/src/config/config.schema.ts +++ b/server/src/config/config.schema.ts @@ -40,6 +40,7 @@ export const configSchema = 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 bd837ed6..646b42b0 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -28,6 +28,7 @@ const { SCOPES, SESSION_NAME, SESSION_SECRET, + TRUST_PROXY_IPS, } = process.env; const config = { @@ -71,6 +72,7 @@ const config = { name: SESSION_NAME || "sessionId", secret: SESSION_SECRET, }, + trustProxyIps: TRUST_PROXY_IPS || false, }; export default config; diff --git a/server/src/main.ts b/server/src/main.ts index 53731df7..4d61674c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,5 +1,6 @@ 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"; @@ -18,11 +19,15 @@ const options = : {}; (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); From fff8fc8fcbe04d0c5ec863bf3aa9a2774da76c87 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 3 Jul 2024 09:25:41 +0300 Subject: [PATCH 23/29] Add env to test config --- server/test/utils/mock-config.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/test/utils/mock-config.service.ts b/server/test/utils/mock-config.service.ts index f4b6d7f7..c7f0e82e 100644 --- a/server/test/utils/mock-config.service.ts +++ b/server/test/utils/mock-config.service.ts @@ -41,6 +41,7 @@ export const defaultMockConfig: Config = { name: "test", secret: "test", }, + trustProxyIps: false, }; const mockConfigProvider = (override: Partial = {}) => ({ From 31960ec6b1cf8ebc7cd50fbc67672c6b6b35a712 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 3 Jul 2024 15:40:22 +0300 Subject: [PATCH 24/29] Fix e2e tests --- e2e/playwright.config.ts | 6 +++--- e2e/tests/entry.mobile.spec.ts | 36 +++++++++++++++++++------------- e2e/tests/entry.spec.ts | 38 +++++++++++++++++----------------- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 5db3fbc2..174e9cda 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"; @@ -16,9 +16,9 @@ export default defineConfig({ /* 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, + ); +}; From 3b317dfc3f94f041bdd33a44757eb7477055c1c0 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Wed, 3 Jul 2024 15:49:08 +0300 Subject: [PATCH 25/29] Remove fully parallel option from e2e config --- e2e/playwright.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 174e9cda..702feecd 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -11,8 +11,6 @@ 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 */ From 273e4354b1d6844700657ebd2f74e537591928a9 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 4 Jul 2024 10:09:37 +0300 Subject: [PATCH 26/29] Small edit for jira docs --- docs/development-handbook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development-handbook.md b/docs/development-handbook.md index 22bc0bfd..8dba70d3 100644 --- a/docs/development-handbook.md +++ b/docs/development-handbook.md @@ -135,7 +135,7 @@ Atlassian app can be created in the [developer console](https://developer.atlass The app needs to be configured to have: - scope `read:jira-work` (permissions tab). -- callback URL e.g., `https:///callback/jira` (Authorization 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/). From 794181486f6da40fa6d281a2c7321628cf01bbec Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 4 Jul 2024 10:42:28 +0300 Subject: [PATCH 27/29] Refactor token guard as suggested --- server/src/jira/token.guard.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/server/src/jira/token.guard.ts b/server/src/jira/token.guard.ts index 967c2658..90c8c3ec 100644 --- a/server/src/jira/token.guard.ts +++ b/server/src/jira/token.guard.ts @@ -1,14 +1,16 @@ -import { Injectable, ExecutionContext, CanActivate, UnauthorizedException } from "@nestjs/common"; +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { Request } from "express"; -import { SessionData } from "express-session"; @Injectable() export class SessionTokenGuard implements CanActivate { async canActivate(context: ExecutionContext) { - const req: Request = context.switchToHttp().getRequest(); - const session: SessionData = req.session; - if (!session.user || !session.user.refreshToken || !session.user.accessToken) - throw new UnauthorizedException(); - return true; + const request = context.switchToHttp().getRequest(); + const session = request.session; + + if (session.user?.refreshToken && session.user.accessToken) { + return true; + } + + return false; } } From 61fe1c211ad79d1de520c2d7f0ee98ba60da1003 Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 4 Jul 2024 11:03:26 +0300 Subject: [PATCH 28/29] Remove unnecessary env vars --- docs/configuration.md | 2 -- server/src/config/config.ts | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e9af82a9..7c5a6caa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,8 +37,6 @@ Key|Default|Description `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. -`SCOPES`|read:jira-work offline_access| Scopes for Jira data that keijo requests access for. See [Jira OAuth Scopes](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/). -`SESSION_NAME`|sessionId| Name for session that is created for user when authorizing Jira. `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. diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 646b42b0..0e584197 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -25,8 +25,6 @@ const { ATLASSIAN_AUTHORIZATION_URL, CALLBACK_URL, CALLBACK_REDIRECT_URL, - SCOPES, - SESSION_NAME, SESSION_SECRET, TRUST_PROXY_IPS, } = process.env; @@ -66,11 +64,11 @@ const config = { authorizationUrl: ATLASSIAN_AUTHORIZATION_URL || "https://auth.atlassian.com/authorize", callbackUrl: CALLBACK_URL || "/jira/callback", callbackRedirectUrl: CALLBACK_REDIRECT_URL || "/", - scopes: SCOPES || "read:jira-work offline_access", + scopes: "read:jira-work offline_access", }, session: { - name: SESSION_NAME || "sessionId", secret: SESSION_SECRET, + name: "sessionId", }, trustProxyIps: TRUST_PROXY_IPS || false, }; From e53c1017ea863a06bc78cc88cf07401f5d23cf1d Mon Sep 17 00:00:00 2001 From: Kimi Kuru Date: Thu, 4 Jul 2024 11:03:59 +0300 Subject: [PATCH 29/29] Add dev default env vars --- compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose.yaml b/compose.yaml index 8e3a1540..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