From c51fb7839c23906478cbdfa5c16262a58fbef07c Mon Sep 17 00:00:00 2001 From: Pavel Solodilov Date: Thu, 13 Jan 2022 16:39:18 +0000 Subject: [PATCH] feat: support signing reqs with jwts in node\n\nBitbucket Connect apps can use JWTs for signing the requests they are sending\nto the Bitbucket API. This adds this capability for Node, but not for browsers. --- .bilirc.ts | 2 ++ package.json | 1 + src/plugins/auth/before-request.ts | 11 +++++++- src/plugins/auth/types.ts | 8 +++++- src/plugins/auth/validate-options.ts | 4 ++- src/plugins/authenticate/authenticate.ts | 14 +++++++++- src/plugins/authenticate/before-request.ts | 8 ++++++ src/plugins/authenticate/types.ts | 2 ++ src/utils/create-jwt.ts | 32 ++++++++++++++++++++++ 9 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/utils/create-jwt.ts diff --git a/.bilirc.ts b/.bilirc.ts index 822a1e3..8291608 100644 --- a/.bilirc.ts +++ b/.bilirc.ts @@ -17,6 +17,8 @@ const config: Config = { globals: { 'node-fetch': 'fetch', }, + // This is an old package that refuses to be packaged as umd. + externals: ['atlassian-jwt'], } const PLUGIN = process.env.PLUGIN diff --git a/package.json b/package.json index 8912ead..a793cfd 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "test": "jest" }, "dependencies": { + "atlassian-jwt": "^2.0.2", "before-after-hook": "^2.1.0", "deepmerge": "^4.2.2", "is-plain-object": "^3.0.0", diff --git a/src/plugins/auth/before-request.ts b/src/plugins/auth/before-request.ts index 271bed1..6f72777 100644 --- a/src/plugins/auth/before-request.ts +++ b/src/plugins/auth/before-request.ts @@ -1,4 +1,5 @@ import btoaLite from 'utils/btoa-lite' +import { createJwt } from 'utils/create-jwt' type AuthPluginState = import('./types').AuthPluginState type RequestOptions = import('./types').RequestOptions @@ -9,9 +10,17 @@ export function beforeRequest( ): void { if ('token' in state.auth) { requestOptions.headers.authorization = `Bearer ${state.auth.token}` - } else if (state.auth.username) { + } else if ('username' in state.auth) { const hash = btoaLite(`${state.auth.username}:${state.auth.password}`) requestOptions.headers.authorization = `Basic ${hash}` + } else if (state.auth.appClientKey) { + const jwtValue = createJwt( + requestOptions.method, + requestOptions.url, + state.auth + ) + + requestOptions.headers.authorization = `JWT ${jwtValue}` } } diff --git a/src/plugins/auth/types.ts b/src/plugins/auth/types.ts index 84fb613..d0196a1 100644 --- a/src/plugins/auth/types.ts +++ b/src/plugins/auth/types.ts @@ -11,7 +11,13 @@ export type AuthToken = { token: string } -export type AuthOptions = AuthBasic | AuthToken +export type AuthJwt = { + appKey: string + appClientKey: string + appSharedSecret: string +} + +export type AuthOptions = AuthBasic | AuthToken | AuthJwt export type AuthPluginState = { client: APIClient diff --git a/src/plugins/auth/validate-options.ts b/src/plugins/auth/validate-options.ts index 8daf5cd..cf4220f 100644 --- a/src/plugins/auth/validate-options.ts +++ b/src/plugins/auth/validate-options.ts @@ -3,7 +3,9 @@ type AuthOptions = import('./types').AuthOptions export function validateOptions(auth: AuthOptions): void { if ('token' in auth) return - if (auth.username && auth.password) return + if ('username' in auth && 'password' in auth) return + + if (auth.appKey && auth.appClientKey && auth.appSharedSecret) return throw new Error(`Invalid "auth" option: ${JSON.stringify(auth)}`) } diff --git a/src/plugins/authenticate/authenticate.ts b/src/plugins/authenticate/authenticate.ts index 715aeb9..0a80262 100644 --- a/src/plugins/authenticate/authenticate.ts +++ b/src/plugins/authenticate/authenticate.ts @@ -26,9 +26,21 @@ export function authenticate( } break + case 'jwt': + if ( + !options.appKey || + !options.appClientKey || + !options.appSharedSecret + ) { + throw new Error( + 'JWT authentication requires an appKey, appClientKey, and appSharedSecret to be set' + ) + } + break + default: throw new Error( - "Invalid authentication type, must be 'apppassword', 'basic' or 'token'" + "Invalid authentication type, must be 'apppassword', 'basic', 'token', or 'jwt'" ) } diff --git a/src/plugins/authenticate/before-request.ts b/src/plugins/authenticate/before-request.ts index fd4eeac..55a81f5 100644 --- a/src/plugins/authenticate/before-request.ts +++ b/src/plugins/authenticate/before-request.ts @@ -1,4 +1,5 @@ import btoaLite from 'utils/btoa-lite' +import { createJwt } from 'utils/create-jwt' type AuthenticatePluginState = import('./types').AuthenticatePluginState type RequestOptions = import('./types').RequestOptions @@ -21,5 +22,12 @@ export function beforeRequest( case 'token': requestOptions.headers.authorization = `Bearer ${state.auth.token}` break + case 'jwt': + requestOptions.headers.authorization = `JWT ${createJwt( + requestOptions.method, + requestOptions.url, + state.auth + )}` + break } } diff --git a/src/plugins/authenticate/types.ts b/src/plugins/authenticate/types.ts index 516bb5b..725b56d 100644 --- a/src/plugins/authenticate/types.ts +++ b/src/plugins/authenticate/types.ts @@ -1,5 +1,6 @@ type AuthBasic = import('../auth/types').AuthBasic type AuthToken = import('../auth/types').AuthToken +type AuthJwt = import('../auth/types').AuthJwt export type APIClient = import('../../client/types').APIClient export type Options = import('../../client/types').Options export type RequestOptions = import('../../endpoint/types').RequestOptions @@ -7,6 +8,7 @@ export type RequestOptions = import('../../endpoint/types').RequestOptions export type AuthenticateOptions = | (AuthBasic & { type: 'apppassword' | 'basic' }) | (AuthToken & { type: 'token' }) + | (AuthJwt & { type: 'jwt' }) export type AuthenticatePluginState = { client: APIClient diff --git a/src/utils/create-jwt.ts b/src/utils/create-jwt.ts new file mode 100644 index 0000000..c2be97a --- /dev/null +++ b/src/utils/create-jwt.ts @@ -0,0 +1,32 @@ +import { + fromMethodAndUrl, + createQueryStringHash, + encodeSymmetric, + SymmetricAlgorithm, +} from 'atlassian-jwt' + +export function createJwt( + method: string, + fullPath: string, + auth: { appKey: string; appClientKey: string; appSharedSecret: string } +): string { + const now = Math.floor(Date.now() / 1000) + const req = fromMethodAndUrl(method, fullPath) + + const tokenData = { + iss: auth.appKey, + sub: auth.appClientKey, + iat: now, + exp: now + 300, + qsh: createQueryStringHash(req), + } + + // Source: https://bitbucket.org/atlassian/atlassian-connect-express/src/master/lib/middleware/authentication.js + const token = encodeSymmetric( + tokenData, + auth.appSharedSecret, + SymmetricAlgorithm.HS256 + ) + + return token +}