Skip to content

[server] Set JWT cookie on sign-in WEB-100 #17200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
109 changes: 109 additions & 0 deletions components/server/src/auth/jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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>(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>(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();
92 changes: 92 additions & 0 deletions components/server/src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<jsonwebtoken.JwtPayload> {
// 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<string> {
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<jsonwebtoken.JwtPayload> {
return new Promise((resolve, reject) => {
jsonwebtoken.verify(encoded, publicKey, opts, (err, decoded) => {
if (err || !decoded) {
return reject(err);
}
resolve(decoded);
});
});
}
26 changes: 25 additions & 1 deletion components/server/src/auth/login-completion-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use strict cookies, please.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches the current cookie setting which we issue for session cookie. (it actually also marks the cookie as "secure" which we don't do currently)

Do you know the exact implications of changing this to strict?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A strict cookie means, that if the browser (already received the cookie) is redirected to a 3rd party, and then back to Gitpod, the cookie will not be sent. This is a typical scenario for redirect-based flows, where the 3rd party knows some sort of "returnTo" URL to redirect back, e.g. after asking user for consent.

Hmm, now having that said, it sounds like we'd break Git Integrations with that. There are (at least) two solutions available:

  1. using a state param and a nonce cookie for redirect-based flows, similar to what's now done for OIDC SSO. There we no longer rely on a session.
  2. use a trick to relax the cookie for the time of a web-flow cycle: Make Gitpod cookie "stricter" #16406

That's getting to complicating here, I guess.

So, not raising the bar here seems ok, but keep in mind that we need to make it strict eventually to improve the security posture.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

secure: true,
});

reportJWTCookieIssued();
}

response.redirect(returnTo);
}

Expand Down
3 changes: 3 additions & 0 deletions components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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();
});
10 changes: 10 additions & 0 deletions components/server/src/prometheus-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions components/server/src/session-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
};
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down