atlassian-connect-auth
Version:
Helper for handling webhooks from Atlassian products
229 lines (185 loc) • 8.78 kB
Markdown
# atlassian-connect-auth
[](https://snyk.io/test/github/DanielHreben/atlassian-connect-auth?targetFile=package.json)
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:
- [Jira Cloud: Understanding JWT for Connect apps](https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/)
- [Confluence Cloud: Understanding JWT for Connect apps](https://developer.atlassian.com/cloud/confluence/understanding-jwt/)
- [Bitbucket App: Understanding JWT for apps](https://developer.atlassian.com/cloud/bitbucket/understanding-jwt-for-apps/)
## Usage
```typescript
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 in`storedEntity` as follows:
```javascript
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:
```javascript
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:
```typescript
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:
```javascript
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.