Skip to content

Commit 239cd70

Browse files
NONE: Refactor authentication mechanism (#50)
1 parent 30e77a5 commit 239cd70

File tree

3 files changed

+111
-82
lines changed

3 files changed

+111
-82
lines changed

src/middlewares/auth-header-jwt-middleware.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextFunction, Request, Response } from "express";
2-
import { verifyAsymmetricJWTToken, verifySymmetricJWTToken } from "../utils/jwt";
2+
import { JwtVerificationError, verifyAsymmetricJWTToken, verifySymmetricJWTToken } from "../utils/jwt";
33
import { fromExpressRequest } from "atlassian-jwt";
44

55

@@ -17,7 +17,8 @@ const validateAuthToken = (type: "symmetric" | "asymmetric") => async (req: Requ
1717
}
1818
next();
1919
} catch (e) {
20-
res.status(e.status).send(e.message);
20+
const message = e instanceof JwtVerificationError ? e.message : "Unauthorized";
21+
res.status(401).send(message);
2122
}
2223
};
2324
/**

src/middlewares/querystring-jwt-middleware.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextFunction, Request, Response } from "express";
2-
import { verifySymmetricJWTToken } from "../utils/jwt";
2+
import { JwtVerificationError, verifySymmetricJWTToken } from "../utils/jwt";
33
import { fromExpressRequest } from "atlassian-jwt";
44

55
/**
@@ -11,6 +11,7 @@ export const querystringJwtMiddleware = async (req: Request, res: Response, next
1111
res.locals.jiraTenant = await verifySymmetricJWTToken(fromExpressRequest(req), req.query.jwt as string);
1212
next();
1313
} catch (e) {
14-
res.status(e.status).send(e.message);
14+
const message = e instanceof JwtVerificationError ? e.message : "Unauthorized";
15+
res.status(401).send(message);
1516
}
1617
};

src/utils/jwt.ts

Lines changed: 105 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,123 +4,150 @@ import { Request } from "atlassian-jwt/dist/lib/jwt";
44
import { envVars } from "../env";
55

66
/**
7-
* This decodes the JWT token from Jira, verifies it against the jira tenant's shared secret
8-
* And returns the verified Jira tenant if it passes
9-
* https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#decoding-and-verifying-a-jwt-token
7+
* A Jira JWT token claims.
8+
*
9+
* @ses https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#manually-creating-a-jwt
10+
*/
11+
export type JiraJwtClaims = {
12+
readonly iss: string;
13+
readonly iat: number;
14+
readonly exp: number;
15+
readonly qsh: string;
16+
readonly sub?: string;
17+
readonly aud?: string[];
18+
}
19+
20+
/**
21+
* Verifies a Jira symmetric JWT token.
22+
*
23+
* This decodes the JWT token, verifies it against the jira tenant's shared secret
24+
* and returns the verified Jira tenant if it passes.
25+
*
26+
* @see https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#types-of-jwt-token
27+
* @see https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#decoding-and-verifying-a-jwt-token
28+
*
29+
* @throws {JwtVerificationError} The given token is invalid or cannot be verified.
1030
*/
1131
export const verifySymmetricJWTToken = async (request: Request, token?: string): Promise<JiraTenant> => {
1232
// if JWT is missing, return a 401
13-
if (!token) {
14-
return Promise.reject({
15-
status: 401,
16-
message: "Missing JWT token"
17-
});
18-
}
33+
if (!token) throw new JwtVerificationError("Missing JWT token");
1934

2035
// Decode jwt token without verification
21-
let data = decodeSymmetric(token, "", getAlgorithm(token), true);
22-
// Get the jira tenant associated with this url
23-
const jiraTenant = await database.findJiraTenant({ clientKey: data.iss });
24-
25-
// If tenant doesn't exist anymore, return a 404
26-
if (!jiraTenant) {
27-
return Promise.reject({
28-
status: 404,
29-
message: "Jira Tenant doesn't exist"
30-
});
36+
let unverifiedClaims: Record<string, unknown>;
37+
try {
38+
unverifiedClaims = decodeSymmetric(token, "", getAlgorithm(token), true) as Record<string, unknown>;
39+
} catch (e) {
40+
throw new JwtVerificationError("The JWT token is invalid.");
3141
}
3242

33-
try {
34-
// Try to verify the jwt token
35-
data = decodeSymmetric(token, jiraTenant.sharedSecret, getAlgorithm(token));
36-
await validateQsh(data.qsh, request);
43+
validateIss(unverifiedClaims.iss);
3744

38-
// If all verifications pass, save the jiraTenant to local to be used later
39-
return jiraTenant;
45+
// Get the jira tenant associated with this url
46+
const jiraTenant = await database.findJiraTenant({ clientKey: unverifiedClaims.iss as string });
47+
48+
if (!jiraTenant) throw new JwtVerificationError("The JWT token is invalid.");
49+
50+
// Decode a JWT token with verification.
51+
let verifiedClaims: JiraJwtClaims;
52+
try {
53+
verifiedClaims = decodeSymmetric(token, jiraTenant.sharedSecret, getAlgorithm(token));
4054
} catch (e) {
41-
// If verification doesn't work, show a 401 error
42-
return Promise.reject({
43-
status: 401,
44-
message: `JWT verification failed: ${e}`
45-
});
55+
throw new JwtVerificationError("The JWT token is not authentic.");
4656
}
57+
58+
// Validate the standard claims.
59+
validateQsh(verifiedClaims.qsh, request);
60+
validateExp(verifiedClaims.exp);
61+
62+
// If all verifications pass, save the jiraTenant to local to be used later
63+
return jiraTenant;
4764
};
4865

4966

5067
/**
51-
* This decodes the JWT token from Jira, verifies it based on the connect public key
52-
* This is used for installed and uninstalled lifecycle events
53-
* https://developer.atlassian.com/cloud/jira/platform/security-for-connect-apps/#validating-installation-lifecycle-requests
68+
* Verifies a Jira asymmetric JWT token, used for lifecycle event requests.
69+
*
70+
* This decodes the JWT token, verifies it based on the connect public key.
71+
*
72+
* @see https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#types-of-jwt-token
73+
* @see https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#decoding-and-verifying-a-jwt-token
74+
* @see https://developer.atlassian.com/cloud/jira/platform/security-for-connect-apps/#validating-installation-lifecycle-requests
75+
*
76+
* @throws {JwtVerificationError} The given token is invalid or cannot be verified.
5477
*/
5578
export const verifyAsymmetricJWTToken = async (request: Request, token?: string): Promise<void> => {
56-
// if JWT is missing, return a 401
57-
if (!token) {
58-
return Promise.reject({
59-
status: 401,
60-
message: "Missing JWT token"
61-
});
79+
if (!token) throw new JwtVerificationError("Missing JWT token");
80+
81+
let unverifiedClaims: Record<string, unknown>;
82+
83+
// Decode a JWT token without verification.
84+
try {
85+
unverifiedClaims = decodeAsymmetric(token, "", getAlgorithm(token), true) as Record<string, unknown>;
86+
} catch (e) {
87+
throw new JwtVerificationError("The JWT token is invalid.");
6288
}
6389

90+
validateIss(unverifiedClaims.iss);
91+
6492
const publicKey = await queryAtlassianConnectPublicKey(getKeyId(token));
65-
const unverifiedClaims = decodeAsymmetric(token, publicKey, getAlgorithm(token), true);
6693

67-
if (!unverifiedClaims.iss) {
68-
return Promise.reject({
69-
status: 401,
70-
message: "JWT claim did not contain the issuer (iss) claim"
71-
});
94+
// Decode a JWT token with verification.
95+
let verifiedClaims: JiraJwtClaims;
96+
try {
97+
verifiedClaims = decodeAsymmetric(token, publicKey, getAlgorithm(token));
98+
} catch (e) {
99+
throw new JwtVerificationError("The JWT token is not authentic.");
72100
}
73101

102+
// Validate the standard claims.
103+
validateExp(verifiedClaims.exp);
104+
validateQsh(verifiedClaims.qsh, request);
105+
74106
// Make sure the AUD claim has the correct URL
75-
if (!unverifiedClaims?.aud?.[0]?.includes(envVars.APP_URL)) {
76-
return Promise.reject({
77-
status: 401,
78-
message: "JWT claim did not contain the correct audience (aud) claim"
79-
});
107+
if (!verifiedClaims?.aud?.[0]?.includes(envVars.APP_URL)) {
108+
throw new JwtVerificationError("The JWT token does not contain the correct audience (aud) claim");
80109
}
110+
};
81111

82-
const verifiedClaims = decodeAsymmetric(token, publicKey, getAlgorithm(token));
83-
84-
// If claim doesn't have QSH, reject
85-
if (!verifiedClaims.qsh) {
86-
return Promise.reject({
87-
status: 401,
88-
message: "JWT validation Failed, no qsh"
89-
});
90-
}
112+
export class JwtVerificationError extends Error {
113+
}
91114

92-
// Check that claim is still within expiration, give 3 second leeway in case of time drift
93-
if (verifiedClaims.exp && (Date.now() / 1000 - 3) >= verifiedClaims.exp) {
94-
return Promise.reject({
95-
status: 401,
96-
message: "JWT validation failed, token is expired"
97-
});
115+
const validateIss = (iss: unknown): void => {
116+
if (typeof iss !== "string" || !iss) {
117+
throw new JwtVerificationError("The JWT token does not contain or contains the unexpected issuer (iss) claim");
98118
}
99-
100-
await validateQsh(verifiedClaims.qsh, request);
101119
};
102120

103-
// Check to see if QSH from token is the same as the request
104-
const validateQsh = async (qsh: string, request: Request): Promise<void> => {
121+
/**
122+
* Validates whether the fixed or URL-bound `qsh` claim.
123+
*/
124+
const validateQsh = (qsh: string, request: Request): void => {
105125
if (qsh !== "context-qsh" && qsh !== createQueryStringHash(request, false)) {
106-
return Promise.reject({
107-
status: 401,
108-
message: "JWT Verification Failed, wrong qsh"
109-
});
126+
throw new JwtVerificationError("JWT Verification Failed, wrong qsh");
110127
}
111128
};
112129

130+
/**
131+
* Validates the `exp` claim. Gives a 3-second leeway in case of time drift.
132+
*/
133+
const validateExp = (exp: number): void => {
134+
const leewayInSeconds = 3;
135+
const nowInSeconds = Date.now() / 1000 - 3;
136+
137+
if (nowInSeconds >= exp + leewayInSeconds) {
138+
throw new JwtVerificationError("The JWT validation failed, token is expired");
139+
}
140+
};
113141

114142
/**
115-
* Queries the public key for the specified keyId
143+
* Queries the public key for the specified keyId.
144+
*
145+
* @see https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#verifying-a-asymmetric-jwt-token-for-install-callbacks
116146
*/
117147
const queryAtlassianConnectPublicKey = async (keyId: string): Promise<string> => {
118148
const response = await fetch(`https://connect-install-keys.atlassian.com/${keyId}`);
119149
if (response.status !== 200) {
120-
return Promise.reject({
121-
status: 401,
122-
message: `Unable to get public key for keyId ${keyId}`
123-
});
150+
throw new JwtVerificationError(`Unable to get public key for keyId ${keyId}`);
124151
}
125152
return response.text();
126153
};

0 commit comments

Comments
 (0)