diff --git a/components/server/package.json b/components/server/package.json index 001de1ea118a8b..6a96349738c292 100644 --- a/components/server/package.json +++ b/components/server/package.json @@ -70,6 +70,7 @@ "is-reachable": "^5.2.1", "js-yaml": "^3.10.0", "json-stream": "^1.0.0", + "jsonwebtoken": "^9.0.0", "lodash.debounce": "^4.0.8", "longjohn": "^0.2.12", "minio": "^7.0.12", diff --git a/components/server/src/auth/jwt.spec.ts b/components/server/src/auth/jwt.spec.ts new file mode 100644 index 00000000000000..3a3935bb6ee4c7 --- /dev/null +++ b/components/server/src/auth/jwt.spec.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { suite, test } from "mocha-typescript"; +import { AuthJWT, sign, verify } from "./jwt"; +import { Container } from "inversify"; +import { Config } from "../config"; +import * as crypto from "crypto"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import * as chai from "chai"; + +const expect = chai.expect; + +@suite() +class TestAuthJWT { + private container: Container; + + private signingKeyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + private validatingKeyPair1 = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + private validatingKeyPair2 = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + + private config: Config = { + hostUrl: new GitpodHostUrl("https://mp-server-d7650ec945.preview.gitpod-dev.com"), + auth: { + pki: { + signing: toKeyPair(this.signingKeyPair), + validating: [toKeyPair(this.validatingKeyPair1), toKeyPair(this.validatingKeyPair2)], + }, + }, + } as Config; + + async before() { + this.container = new Container(); + this.container.bind(Config).toConstantValue(this.config); + this.container.bind(AuthJWT).toSelf().inSingletonScope(); + } + + @test + async test_sign() { + const sut = this.container.get(AuthJWT); + + const subject = "user-id"; + const encoded = await sut.sign(subject, {}); + + const decoded = await verify(encoded, this.config.auth.pki.signing.publicKey, { + algorithms: ["RS512"], + }); + + expect(decoded["sub"]).to.equal(subject); + expect(decoded["iss"]).to.equal("https://mp-server-d7650ec945.preview.gitpod-dev.com"); + } + + @test + async test_verify_uses_primary_first() { + const sut = this.container.get(AuthJWT); + + const subject = "user-id"; + const encoded = await sut.sign(subject, {}); + + const decoded = await sut.verify(encoded); + + expect(decoded["sub"]).to.equal(subject); + expect(decoded["iss"]).to.equal("https://mp-server-d7650ec945.preview.gitpod-dev.com"); + } + + @test + async test_verify_validates_older_keys() { + const sut = this.container.get(AuthJWT); + + const subject = "user-id"; + const encoded = await sign({}, this.config.auth.pki.validating[1].privateKey, { + algorithm: "RS512", + expiresIn: "1d", + issuer: this.config.hostUrl.toStringWoRootSlash(), + subject, + }); + + // should use the second validating key and succesfully verify + const decoded = await sut.verify(encoded); + + expect(decoded["sub"]).to.equal(subject); + expect(decoded["iss"]).to.equal("https://mp-server-d7650ec945.preview.gitpod-dev.com"); + } +} + +function toKeyPair(kp: crypto.KeyPairKeyObjectResult): { + privateKey: string; + publicKey: string; +} { + return { + privateKey: kp.privateKey + .export({ + type: "pkcs1", + format: "pem", + }) + .toString(), + publicKey: kp.publicKey + .export({ + type: "pkcs1", + format: "pem", + }) + .toString(), + }; +} + +module.exports = new TestAuthJWT(); diff --git a/components/server/src/auth/jwt.ts b/components/server/src/auth/jwt.ts new file mode 100644 index 00000000000000..74f774951e6dd1 --- /dev/null +++ b/components/server/src/auth/jwt.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import * as jsonwebtoken from "jsonwebtoken"; +import { Config } from "../config"; +import { inject, injectable } from "inversify"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; + +const algorithm: jsonwebtoken.Algorithm = "RS512"; + +@injectable() +export class AuthJWT { + @inject(Config) protected config: Config; + + async sign(subject: string, payload: object | Buffer, expiresIn: string = `${24 * 7}h`): Promise { + const opts: jsonwebtoken.SignOptions = { + algorithm, + expiresIn, + issuer: this.config.hostUrl.toStringWoRootSlash(), + subject, + }; + + return sign(payload, this.config.auth.pki.signing.privateKey, opts); + } + + async verify(encoded: string): Promise { + // When we verify an encoded token, we verify it using all available public keys + // That is, we check the following: + // * The current signing public key + // * All other validating public keys, in order + // + // We do this to allow for key-rotation. But tokens already issued would fail to validate + // if the signing key was changed. To accept older sessions, which are still valid + // we need to check for older keys also. + const validatingPublicKeys = this.config.auth.pki.validating.map((keypair) => keypair.publicKey); + const publicKeys = [ + this.config.auth.pki.signing.publicKey, // signing key is checked first + ...validatingPublicKeys, + ]; + + let lastErr; + for (let publicKey of publicKeys) { + try { + const decoded = await verify(encoded, publicKey, { + algorithms: [algorithm], + }); + return decoded; + } catch (err) { + log.debug(`Failed to verify JWT token using public key.`, err); + lastErr = err; + } + } + + log.error(`Failed to verify JWT using any available public key.`, lastErr, { + publicKeyCount: publicKeys.length, + }); + throw lastErr; + } +} + +export async function sign( + payload: string | object | Buffer, + secret: jsonwebtoken.Secret, + opts: jsonwebtoken.SignOptions, +): Promise { + return new Promise((resolve, reject) => { + jsonwebtoken.sign(payload, secret, opts, (err, encoded) => { + if (err || !encoded) { + return reject(err); + } + return resolve(encoded); + }); + }); +} + +export async function verify( + encoded: string, + publicKey: string, + opts: jsonwebtoken.VerifyOptions, +): Promise { + return new Promise((resolve, reject) => { + jsonwebtoken.verify(encoded, publicKey, opts, (err, decoded) => { + if (err || !decoded) { + return reject(err); + } + resolve(decoded); + }); + }); +} diff --git a/components/server/src/auth/login-completion-handler.ts b/components/server/src/auth/login-completion-handler.ts index 14383a7a566e50..91aa9b0c0ef6ee 100644 --- a/components/server/src/auth/login-completion-handler.ts +++ b/components/server/src/auth/login-completion-handler.ts @@ -13,11 +13,14 @@ import { AuthFlow } from "./auth-provider"; import { HostContextProvider } from "./host-context-provider"; import { AuthProviderService } from "./auth-provider-service"; import { TosFlow } from "../terms/tos-flow"; -import { increaseLoginCounter } from "../prometheus-metrics"; +import { increaseLoginCounter, reportJWTCookieIssued } from "../prometheus-metrics"; import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics"; import { trackLogin } from "../analytics"; import { UserService } from "../user/user-service"; import { SubscriptionService } from "@gitpod/gitpod-payment-endpoint/lib/accounting"; +import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import { SessionHandlerProvider } from "../session-handler"; +import { AuthJWT } from "./jwt"; /** * The login completion handler pulls the strings between the OAuth2 flow, the ToS flow, and the session management. @@ -30,6 +33,7 @@ export class LoginCompletionHandler { @inject(AuthProviderService) protected readonly authProviderService: AuthProviderService; @inject(UserService) protected readonly userService: UserService; @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; + @inject(AuthJWT) protected readonly authJWT: AuthJWT; async complete( request: express.Request, @@ -93,6 +97,26 @@ export class LoginCompletionHandler { ); } + const isJWTCookieExperimentEnabled = await getExperimentsClientForBackend().getValueAsync( + "jwtSessionCookieEnabled", + false, + { + user: user, + }, + ); + if (isJWTCookieExperimentEnabled) { + const token = await this.authJWT.sign(user.id, {}); + + response.cookie(SessionHandlerProvider.getJWTCookieName(this.config.hostUrl), token, { + maxAge: this.config.session.maxAgeMs, + httpOnly: true, + sameSite: "lax", + secure: true, + }); + + reportJWTCookieIssued(); + } + response.redirect(returnTo); } diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 326047cc4b0bc1..3ff0d0c6865057 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -117,6 +117,7 @@ import { APIUserService } from "./api/user"; import { APITeamsService } from "./api/teams"; import { API } from "./api/server"; import { LinkedInService } from "./linkedin-service"; +import { AuthJWT } from "./auth/jwt"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -324,4 +325,6 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(APIUserService).toSelf().inSingletonScope(); bind(APITeamsService).toSelf().inSingletonScope(); bind(API).toSelf().inSingletonScope(); + + bind(AuthJWT).toSelf().inSingletonScope(); }); diff --git a/components/server/src/prometheus-metrics.ts b/components/server/src/prometheus-metrics.ts index 3a99758df09965..5ce054fe3dbcc8 100644 --- a/components/server/src/prometheus-metrics.ts +++ b/components/server/src/prometheus-metrics.ts @@ -25,6 +25,7 @@ export function registerServerMetrics(registry: prometheusClient.Registry) { registry.registerMetric(centralizedPermissionsValidationsTotal); registry.registerMetric(spicedbClientLatency); registry.registerMetric(dashboardErrorBoundary); + registry.registerMetric(jwtCookieIssued); } const loginCounter = new prometheusClient.Counter({ @@ -57,6 +58,15 @@ export function increaseApiConnectionCounter() { apiConnectionCounter.inc(); } +const jwtCookieIssued = new prometheusClient.Counter({ + name: "gitpod_server_jwt_cookie_issued_total", + help: "Total number of JWT cookies issued for login sessions", +}); + +export function reportJWTCookieIssued() { + jwtCookieIssued.inc(); +} + const apiConnectionClosedCounter = new prometheusClient.Counter({ name: "gitpod_server_api_connections_closed_total", help: "Total amount of closed API connections", diff --git a/components/server/src/session-handler.ts b/components/server/src/session-handler.ts index 30e6612720a63d..8b643eb6364481 100644 --- a/components/server/src/session-handler.ts +++ b/components/server/src/session-handler.ts @@ -15,6 +15,7 @@ const MySQLStore = mysqlstore(session); import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { Config as DBConfig } from "@gitpod/gitpod-db/lib/config"; import { Config } from "./config"; +import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; @injectable() export class SessionHandlerProvider { @@ -52,7 +53,7 @@ export class SessionHandlerProvider { path: "/", // default httpOnly: true, // default secure: false, // default, TODO SSL! Config proxy - maxAge: config.session.maxAgeMs, // configured in Helm chart, defaults to 3 days. + maxAge: config.session.maxAgeMs, sameSite: "lax", // default: true. "Lax" needed for OAuth. }; } @@ -65,11 +66,12 @@ export class SessionHandlerProvider { return `${derived}v2_`; } - static getOldCookieName(config: Config) { - return config.hostUrl + static getJWTCookieName(hostURL: GitpodHostUrl) { + const derived = hostURL .toString() .replace(/https?/, "") .replace(/[\W_]+/g, "_"); + return `${derived}jwt_`; } public clearSessionCookie(res: express.Response, config: Config): void { @@ -79,6 +81,8 @@ export class SessionHandlerProvider { delete options.expires; delete options.maxAge; res.clearCookie(name, options); + + res.clearCookie(SessionHandlerProvider.getJWTCookieName(this.config.hostUrl)); } protected createStore(): any | undefined {