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 +}