@@ -4,123 +4,150 @@ import { Request } from "atlassian-jwt/dist/lib/jwt";
4
4
import { envVars } from "../env" ;
5
5
6
6
/**
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.
10
30
*/
11
31
export const verifySymmetricJWTToken = async ( request : Request , token ?: string ) : Promise < JiraTenant > => {
12
32
// 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" ) ;
19
34
20
35
// 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." ) ;
31
41
}
32
42
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 ) ;
37
44
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 ) ) ;
40
54
} 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." ) ;
46
56
}
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 ;
47
64
} ;
48
65
49
66
50
67
/**
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.
54
77
*/
55
78
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." ) ;
62
88
}
63
89
90
+ validateIss ( unverifiedClaims . iss ) ;
91
+
64
92
const publicKey = await queryAtlassianConnectPublicKey ( getKeyId ( token ) ) ;
65
- const unverifiedClaims = decodeAsymmetric ( token , publicKey , getAlgorithm ( token ) , true ) ;
66
93
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." ) ;
72
100
}
73
101
102
+ // Validate the standard claims.
103
+ validateExp ( verifiedClaims . exp ) ;
104
+ validateQsh ( verifiedClaims . qsh , request ) ;
105
+
74
106
// 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" ) ;
80
109
}
110
+ } ;
81
111
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
+ }
91
114
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" ) ;
98
118
}
99
-
100
- await validateQsh ( verifiedClaims . qsh , request ) ;
101
119
} ;
102
120
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 => {
105
125
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" ) ;
110
127
}
111
128
} ;
112
129
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
+ } ;
113
141
114
142
/**
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
116
146
*/
117
147
const queryAtlassianConnectPublicKey = async ( keyId : string ) : Promise < string > => {
118
148
const response = await fetch ( `https://connect-install-keys.atlassian.com/${ keyId } ` ) ;
119
149
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 } ` ) ;
124
151
}
125
152
return response . text ( ) ;
126
153
} ;
0 commit comments