Skip to content

Commit 20e4e7d

Browse files
authored
[server] Set Key ID on JWT tokens, use it for verification - WEB-100 (#17227)
* retest * retest * fix * fix * Fix * also clear jwt cookie * align max age with config * [installer] Add key id for each auth keypair * retest * [server] Attach and verify JWT Key ID * Fix * fix var name * Fix comment
1 parent bce4700 commit 20e4e7d

File tree

2 files changed

+46
-32
lines changed

2 files changed

+46
-32
lines changed

components/server/src/auth/jwt.spec.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ class TestAuthJWT {
2626
hostUrl: new GitpodHostUrl("https://mp-server-d7650ec945.preview.gitpod-dev.com"),
2727
auth: {
2828
pki: {
29-
signing: toKeyPair(this.signingKeyPair),
30-
validating: [toKeyPair(this.validatingKeyPair1), toKeyPair(this.validatingKeyPair2)],
29+
signing: toKeyPair("0001", this.signingKeyPair),
30+
validating: [toKeyPair("0002", this.validatingKeyPair1), toKeyPair("0003", this.validatingKeyPair2)],
3131
},
3232
},
3333
} as Config;
@@ -44,6 +44,7 @@ class TestAuthJWT {
4444

4545
const subject = "user-id";
4646
const encoded = await sut.sign(subject, {});
47+
console.log("encoded", encoded);
4748

4849
const decoded = await verify(encoded, this.config.auth.pki.signing.publicKey, {
4950
algorithms: ["RS512"],
@@ -70,11 +71,13 @@ class TestAuthJWT {
7071
async test_verify_validates_older_keys() {
7172
const sut = this.container.get<AuthJWT>(AuthJWT);
7273

74+
const keypair = this.config.auth.pki.validating[1];
7375
const subject = "user-id";
74-
const encoded = await sign({}, this.config.auth.pki.validating[1].privateKey, {
76+
const encoded = await sign({}, keypair.privateKey, {
7577
algorithm: "RS512",
7678
expiresIn: "1d",
7779
issuer: this.config.hostUrl.toStringWoRootSlash(),
80+
keyid: keypair.id,
7881
subject,
7982
});
8083

@@ -86,11 +89,16 @@ class TestAuthJWT {
8689
}
8790
}
8891

89-
function toKeyPair(kp: crypto.KeyPairKeyObjectResult): {
92+
function toKeyPair(
93+
id: string,
94+
kp: crypto.KeyPairKeyObjectResult,
95+
): {
96+
id: string;
9097
privateKey: string;
9198
publicKey: string;
9299
} {
93100
return {
101+
id,
94102
privateKey: kp.privateKey
95103
.export({
96104
type: "pkcs1",

components/server/src/auth/jwt.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import * as jsonwebtoken from "jsonwebtoken";
88
import { Config } from "../config";
99
import { inject, injectable } from "inversify";
10-
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1110

1211
const algorithm: jsonwebtoken.Algorithm = "RS512";
1312

@@ -21,43 +20,38 @@ export class AuthJWT {
2120
expiresIn,
2221
issuer: this.config.hostUrl.toStringWoRootSlash(),
2322
subject,
23+
keyid: this.config.auth.pki.signing.id,
2424
};
2525

2626
return sign(payload, this.config.auth.pki.signing.privateKey, opts);
2727
}
2828

2929
async verify(encoded: string): Promise<jsonwebtoken.JwtPayload> {
30-
// When we verify an encoded token, we verify it using all available public keys
31-
// That is, we check the following:
32-
// * The current signing public key
33-
// * All other validating public keys, in order
34-
//
35-
// We do this to allow for key-rotation. But tokens already issued would fail to validate
36-
// if the signing key was changed. To accept older sessions, which are still valid
37-
// we need to check for older keys also.
38-
const validatingPublicKeys = this.config.auth.pki.validating.map((keypair) => keypair.publicKey);
39-
const publicKeys = [
40-
this.config.auth.pki.signing.publicKey, // signing key is checked first
41-
...validatingPublicKeys,
42-
];
30+
const keypairs = [this.config.auth.pki.signing, ...this.config.auth.pki.validating];
31+
const publicKeysByID = keypairs.reduce<{ [id: string]: string }>((byID, keypair) => {
32+
byID[keypair.id] = keypair.publicKey;
33+
return byID;
34+
}, {});
4335

44-
let lastErr;
45-
for (let publicKey of publicKeys) {
46-
try {
47-
const decoded = await verify(encoded, publicKey, {
48-
algorithms: [algorithm],
49-
});
50-
return decoded;
51-
} catch (err) {
52-
log.debug(`Failed to verify JWT token using public key.`, err);
53-
lastErr = err;
54-
}
36+
// When we verify an encoded token, we first read the Key ID from the token header (without verifying the signature)
37+
// and then lookup the appropriate key from out collection of available keys.
38+
const decodedWithoutVerification = decodeWithoutVerification(encoded);
39+
const keyID = decodedWithoutVerification.header.kid;
40+
41+
if (!keyID) {
42+
throw new Error("JWT token does not contain kid (key id) property.");
5543
}
5644

57-
log.error(`Failed to verify JWT using any available public key.`, lastErr, {
58-
publicKeyCount: publicKeys.length,
45+
if (!publicKeysByID[keyID]) {
46+
throw new Error("JWT was signed with an unknown key id");
47+
}
48+
49+
// Given we know which keyid this token was presumably signed with, let's verify the token using the public key.
50+
const verified = await verify(encoded, publicKeysByID[keyID], {
51+
algorithms: [algorithm],
5952
});
60-
throw lastErr;
53+
54+
return verified;
6155
}
6256
}
6357

@@ -90,3 +84,15 @@ export async function verify(
9084
});
9185
});
9286
}
87+
88+
export function decodeWithoutVerification(encoded: string, options?: jsonwebtoken.DecodeOptions): jsonwebtoken.Jwt {
89+
const decoded = jsonwebtoken.decode(encoded, { ...options, complete: true });
90+
if (!decoded) {
91+
throw new Error("Failed to decode JWT");
92+
}
93+
if (typeof decoded === "string") {
94+
throw new Error("Decoded JWT was a string, JSON payload required.");
95+
}
96+
97+
return decoded;
98+
}

0 commit comments

Comments
 (0)