/atlassian-connect-auth

Helper for handling webhooks from Atlassian products

Primary LanguageTypeScriptApache License 2.0Apache-2.0

atlassian-connect-auth

Known Vulnerabilities

This library implements authentication for installation requests, webhooks, and page loading from Atlassian products built with Connect.

For a deeper understanding of the concepts built into this library, please read through the Atlassian Connect documentation for the corresponding product:

Usage

import {
  AuthError,
  AuthErrorCode,
  CredentialsWithEntity,
  ExpressReqAuthDataProvider,
  InstallationType,
  verifyInstallation,
  verifyRequest,
} from 'atlassian-connect-auth'

// Consumers of this library have to provide a KeyProvider implementation that will fetch the public key from a CDN.
// Examples can be found under the `test` directory in this library.
import { GotKeyProvider } from './GotKeyProvider';

const baseUrl = 'https://your-app-base-url.com'
const asymmetricKeyProvider = new GotKeyProvider()

async function loadInstallationEntity(clientKey: string): Promise<CredentialsWithEntity<InstallationEntity>> {
  const storedEntity = await model.InstallationEntity.findOne({ where: { clientKey } })
  if (storedEntity) {
    return {
      sharedSecret: decrypt(storedEntity.encryptedSharedSecret),
      storedEntity,
    }
  }
}

const handleInstallation = async (req, res) => {
  try {
    const result = await verifyInstallation({
      baseUrl,
      asymmetricKeyProvider,
      authDataProvider: new ExpressReqAuthDataProvider(req),
      credentialsLoader: loadInstallationEntity,
    })

    const newInstallationEntity = req.body

    if (result.type === InstallationType.update) {
      const existingInstallationEntity = result.storedEntity
      await existingInstallationEntity.update(newInstallationEntity)
    } else {
      await model.InstallationEntity.create(newInstallationEntity)
    }

    res.sendStatus(201)
  } catch (error) {
    if (error instanceof AuthError) {
      console.warn(error)
      res.sendStatus(401)
    } else {
      console.error(error)
      res.sendStatus(500)
    }
  }
}

const handleAuth = async (req, res, next) => {
  try {
    const { connectJwt, storedEntity } = await verifyRequest({
      baseUrl,
      asymmetricKeyProvider,
      authDataProvider: new ExpressReqAuthDataProvider(req),
      credentialsLoader: loadInstallationEntity,
      queryStringHashType: 'context',
    })

    req.context = {
      accountId: connectJwt.context?.user?.accountId ?? connectJwt.sub,
      installationData: storedEntity
    }

    next()
  } catch (error) {
    if (error instanceof AuthError) {
      console.warn(error)
      res.sendStatus(401)
    } else {
      console.error(error)
      res.sendStatus(500)
    }
  }
}

const app = express()
  .post('/api/hooks/jira/installed', handleInstall)
  .post('/api/hooks/jira/uninstalled', handleAuth, handleUninstall)
  .post('/api/hooks/jira/project/created', handleAuth, handleProjectCreated)

Upgrading to 3.x with signed installs

Addon class was replaced with stateless function calls

Remove class instantiation and replace method calls with function calls as follows:

  • addon.auth()verifyRequest()
  • addon.install()verifyInstallation()

Also:

  • Move the baseUrl argument from the class instantiation to the function calls.
  • Remove the product argument altogether.

Callback changes

  • Replace loadCredentials with credentialsLoader.
    • The return value used to be any object with a required sharedSecret property.
    • Now you should return an object with a sharedSecret property and optionally your stored database value instoredEntity as follows:
        return {
          sharedSecret: '...',
          storedEntity: databaseInstallationData,
        }
  • Remove saveCredentials from the installation verification. Use the request body payload to persist the installation data. It's safe after verifying the installation request.
  • storedEntity will be returned by the verification function if a value is provided.
    • For installation updates (when the loader callback returns a stored entity), verifyInstallation() will return the loaded entity with an attribute also named storedEntity.
    • For new installations (when the loader callback does not return a stored entity), verifyInstallation() will not return the property storedEntity.

Query String Hash

Replace the argument skipQsh with queryStringHashType, which is an enum with the following values:

  • 'skip': skip QSH verification altogether. Use this in routes you had skipQsg: true.
  • 'computed': force verification using regular QSH algorithm.
  • 'context': force verification using static value context-qsh.
  • 'any': accepts both 'computed' and 'context'.

Note: Bitbucket Cloud does not currently support context-qsh as it does not have a JavaScript API that allows generating a context token.

Extracting the token from a request

Version 2.x took a request object as the first argument of the verifications functions. It expected an Express.js-like request object in order to extract the token from headers or query arguments.

Version 3.x decouples that from the web framework with the authDataProvider parameter.

  • Remove the first argument with the request object.
  • Provide an implementation of authDataProvider.
    • For Express.js, use provided ExpressReqAuthDataProvider. Example:
        verifyRequest({
          authDataProvider: new ExpressReqAuthDataProvider(req),
          ...
        })
    • Replace custom token extraction with customExtractToken with your implementation of AuthDataProvider.
      • Implement interface AuthDataProvider with your own token extraction.
      • Extend ExpressReqAuthDataProvider and add new ways of extracting the token from the req object. For instance:
        export class MyAuthDataProvider extends ExpressReqAuthDataProvider {
          extractConnectJwt(): string {
            // Custom query argument
            const jwt = this.req.query.customJwt as string
            if (jwt) {
              return jwt
            }
      
            // fallback to regular Connect token extraction
            return super.extractConnectJwt()
          }
        }

Signed installs and legacy verification

Add the authorizationMethod argument to the verification methods to define how you want installations to be verified.

  • sharedSecret: force legacy method that won't check new installations and will use the sharedSecret to verify installation updates and uninstallations.
  • publicKey: force new signed installs that use a public key to verify new installations, installation updates, and uninstallations.
  • any: accept both verification methods, meant to be used during the transition period. This is the default value.

Note: Bitbucket Cloud does not support signed installs as of 2021. You can still upgrade the library and keep it in compatibility mode (accepting legacy installs) as a preparation for a future upgrade.

Signed installations need to download a public key from the Atlassian Connect CDN. You need to provide an asymmetricKeyProvider to the verification functions.

  • Implement the KeyProvider interface with your HTTP client implementation.
  • The enum ConnectInstallKeysCdnUrl provides the base URLs for the Atlassian Connect CDN.
  • Look into ./test/keyProviderExamples for examples of implementations using Axios, Got, and Node Fetch.

Error codes

  • When checking error codes, replace string literals with values from the AuthErrorCode enum.
  • Code changes:
    • 'MISSED_TOKEN' is now AuthErrorCode.MISSING_JWT
    • 'MISSED_QSH' is now AuthErrorCode.MISSING_QSH

Upgrades in your app

  • Enabled signed installs in your app descriptor. For instance:
    apiMigrations: {
      gdpr: true,
      'context-qsh': true,
      'signed-install': true,
    },
  • Upgrade atlassian-jwt to 2.x, if you have a direct dependency.
    • This library depends on atlassian-jwt@2.x.
    • Replace encode() with encodeSymmetric() or encodeAsymmetric().
    • Replace decode() with decodeSymmetric() or decodeAsymmetric(). Passing the algorithm is required.