packages feed

atlassian-connect-core 0.7.2.0 → 0.8.0.0

raw patch · 4 files changed

+240/−41 lines, 4 filesdep ~atlassian-connect-descriptorPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: atlassian-connect-descriptor

API changes (from Hackage documentation)

- Snap.AtlassianConnect.HostRequest: instance GHC.Generics.Generic Snap.AtlassianConnect.HostRequest.ProductErrorResponse
- Snap.AtlassianConnect.HostRequest: instance GHC.Show.Show Snap.AtlassianConnect.HostRequest.ProductErrorResponse
+ Snap.AtlassianConnect.OAuth: AccessTokenResponse :: AccessToken -> Second -> AccessTokenType -> AccessTokenResponse
+ Snap.AtlassianConnect.OAuth: Bearer :: AccessTokenType
+ Snap.AtlassianConnect.OAuth: UnknownAccessTokenType :: AccessTokenType
+ Snap.AtlassianConnect.OAuth: [atrAccessToken] :: AccessTokenResponse -> AccessToken
+ Snap.AtlassianConnect.OAuth: [atrExpiresIn] :: AccessTokenResponse -> Second
+ Snap.AtlassianConnect.OAuth: [atrTokenType] :: AccessTokenResponse -> AccessTokenType
+ Snap.AtlassianConnect.OAuth: data AccessToken
+ Snap.AtlassianConnect.OAuth: data AccessTokenResponse
+ Snap.AtlassianConnect.OAuth: data AccessTokenType
+ Snap.AtlassianConnect.OAuth: requestAccessToken :: Tenant -> UserKey -> Maybe [ProductScope] -> IO (Either ProductErrorResponse (Maybe AccessTokenResponse))
- Snap.AtlassianConnect.HostRequest: hostGetRequest :: FromJSON a => Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
+ Snap.AtlassianConnect.HostRequest: hostGetRequest :: FromJSON a => Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
- Snap.AtlassianConnect.HostRequest: hostPostRequest :: FromJSON a => Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
+ Snap.AtlassianConnect.HostRequest: hostPostRequest :: FromJSON a => Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
- Snap.AtlassianConnect.HostRequest: hostPostRequestExtended :: FromJSON a => Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))
+ Snap.AtlassianConnect.HostRequest: hostPostRequestExtended :: FromJSON a => Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))
- Snap.AtlassianConnect.HostRequest: hostPutRequest :: FromJSON a => Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
+ Snap.AtlassianConnect.HostRequest: hostPutRequest :: FromJSON a => Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse a)
- Snap.AtlassianConnect.HostRequest: hostPutRequestExtended :: FromJSON a => Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))
+ Snap.AtlassianConnect.HostRequest: hostPutRequestExtended :: FromJSON a => Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))
- Snap.AtlassianConnect.HostRequest: hostRequest :: FromJSON a => StdMethod -> Tenant -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))
+ Snap.AtlassianConnect.HostRequest: hostRequest :: FromJSON a => StdMethod -> Tenant -> Maybe AccessToken -> ByteString -> [(ByteString, Maybe ByteString)] -> Endo Request -> Handler b Connect (Either ProductErrorResponse (Maybe a))

Files

atlassian-connect-core.cabal view
@@ -10,7 +10,7 @@ -- PVP summary:      +-+------- breaking API changes --                   | | +----- non-breaking API additions --                   | | | +--- code changes with no API change-version:             0.7.2.0+version:             0.8.0.0  -- A short (one-line) description of the package. synopsis:            Atlassian Connect snaplet for the Snap Framework and helper code.@@ -65,6 +65,7 @@    -- TODO do page tokens even belong here? Should they even be a separate library per service?   exposed-modules:    Snap.AtlassianConnect                     , Snap.AtlassianConnect.HostRequest+                    , Snap.AtlassianConnect.OAuth    -- Modules included in this library but not exported.   other-modules:      Data.AesonHelpers@@ -80,6 +81,7 @@                     , Snap.AtlassianConnect.Data                     , Snap.AtlassianConnect.Instances                     , Snap.AtlassianConnect.LifecycleResponse+                    , Snap.AtlassianConnect.NetworkCommon                     , Snap.AtlassianConnect.PageToken                     , Snap.AtlassianConnect.QueryStringHash                     , Snap.AtlassianConnect.Routes@@ -92,7 +94,7 @@    -- Other library packages from which modules are imported.   build-depends:       base >=4.7 && < 5-                       , atlassian-connect-descriptor >= 0.2+                       , atlassian-connect-descriptor >= 0.4.5                        , jwt                >= 0.6                        , aeson              >=  0.7.0.3                        , bytestring         >= 0.9
src/Snap/AtlassianConnect/HostRequest.hs view
@@ -4,7 +4,7 @@ {-| Module      : Snap.AtlassianConnect.HostRequest Description : Allows you to easily make requests from your Atlassian Connect addon to the Host Application (like Jira or Confluence).-Copyright   : (c) Robert Massioli, 2014+Copyright   : (c) Robert Massioli, 2014-2017 License     : APACHE-2 Maintainer  : rmassaioli@atlassian.com Stability   : experimental@@ -33,7 +33,7 @@     , hostPutRequest     , hostPutRequestExtended     , StdMethod(..)-    , ProductErrorResponse(..)+    , AC.ProductErrorResponse(..)     -- * Request Modifiers     , addHeader     , setPostParams@@ -69,82 +69,87 @@ import qualified Snap.AtlassianConnect.Data            as AC import qualified Snap.AtlassianConnect.Instances       as AC import qualified Snap.AtlassianConnect.QueryStringHash as QSH+import qualified Snap.AtlassianConnect.NetworkCommon   as AC import qualified Snap.AtlassianConnect.Tenant          as AC import qualified Snap.Snaplet                          as SS import qualified Web.JWT                               as JWT -type HttpResponseCode = Int---- | If you fail to properly communicate with the Host application then you will get a product error response back.-data ProductErrorResponse = ProductErrorResponse-   { perCode    :: HttpResponseCode -- ^ The HTTP error code.-   , perMessage :: T.Text           -- ^ The error message.-   } deriving (Show, Generic)- -- | This is a convinience method that calls 'hostRequest' as a GET method. -- -- Here is an example of it being used to make a call for user details: ----- > jiraUserDetailsResponse <- hostGetRequest myConnectTenant "/rest/api/2/user" [("username", Just "admin")] mempty+-- > jiraUserDetailsResponse <- hostGetRequest myConnectTenant Nothing "/rest/api/2/user" [("username", Just "admin")] mempty -- -- This example assumes that you are using the OverloadedStrings LANGUAGE pragma and that you don't need to modify the request. -- You might want to might want to modify the request for multiple reasons. Too add proxy details for example.-hostGetRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostGetRequest :: FromJSON a => AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse a) hostGetRequest = hostRequestWithContent GET  -- | This is a convinience method that calls 'hostRequest' as a POST method and requires that the -- resource returns content.-hostPostRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostPostRequest :: FromJSON a => AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse a) hostPostRequest = hostRequestWithContent POST  -- | This is the same method as hostPostRequest except that if HTTP 204 No Content is returned then -- you will get nothing instead of an error.-hostPostRequestExtended :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse (Maybe a))+hostPostRequestExtended :: FromJSON a => AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse (Maybe a)) hostPostRequestExtended = hostRequest POST  -- | This is a convinience method that calls 'hostRequest' as a PUT method.-hostPutRequest :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)+hostPutRequest :: FromJSON a => AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse a) hostPutRequest = hostRequestWithContent PUT  -- | This is the same method as hostPutRequest except that if HTTP 204 No Content is returned then -- you will get nothing instead of an error.-hostPutRequestExtended :: FromJSON a => AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse (Maybe a))+hostPutRequestExtended :: FromJSON a => AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse (Maybe a)) hostPutRequestExtended = hostRequest PUT -hostRequestWithContent :: FromJSON a => StdMethod -> AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse a)-hostRequestWithContent httpMethod tenant productRelativeUrl queryParams modifications = errorNoContent <$> hostRequest httpMethod tenant productRelativeUrl queryParams modifications+hostRequestWithContent :: FromJSON a => StdMethod -> AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse a)+hostRequestWithContent httpMethod tenant accessToken productRelativeUrl queryParams modifications = errorNoContent <$> hostRequest httpMethod tenant accessToken productRelativeUrl queryParams modifications -errorNoContent :: Either ProductErrorResponse (Maybe a) -> Either ProductErrorResponse a+errorNoContent :: Either AC.ProductErrorResponse (Maybe a) -> Either AC.ProductErrorResponse a errorNoContent (Left x) = Left x-errorNoContent (Right Nothing) = Left (ProductErrorResponse 204 "We expected to get content back from this resource but instead we recieved no content.")+errorNoContent (Right Nothing) = Left (AC.ProductErrorResponse 204 "We expected to get content back from this resource but instead we recieved no content.") errorNoContent (Right (Just x)) = Right x  -- | As an Atlassian Connect application you will want to make requests of the Host Applicaiton (JIRA / Confluence) -- so that you can get important information. This function lets you do so by doing most of the heavy lifting of -- having to create a JWT token and a Query String Hash. It also asserts that you intended to get a JSON response. -- You should use this method or the helper methods whenever you want to make requests of the host application.-hostRequest :: FromJSON a => StdMethod -> AC.Tenant -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either ProductErrorResponse (Maybe a))-hostRequest standardHttpMethod tenant productRelativeUrl queryParams requestModifications = do+hostRequest :: FromJSON a => StdMethod -> AC.Tenant -> Maybe AC.AccessToken -> B.ByteString -> [(B.ByteString, Maybe B.ByteString)] -> Endo Request -> SS.Handler b AC.Connect (Either AC.ProductErrorResponse (Maybe a))+hostRequest standardHttpMethod tenant Nothing productRelativeUrl queryParams requestModifications = do     currentTime <- MI.liftIO P.getPOSIXTime     pluginKey' <- fmap (CD.pluginKey . AC.connectPlugin) get     case generateJWTToken pluginKey' currentTime (AC.sharedSecret tenant) standardHttpMethod productBaseUrl url of-        Nothing -> return . Left $ ProductErrorResponse 500 "Failed to generate a JWT token to make the request. The request was never made: server error."-        (Just signature) -> MI.liftIO $ runRequest connectionManagerSettings standardHttpMethod url-            (  addHeader ("Accept","application/json")+        Nothing -> return . Left $ AC.ProductErrorResponse 500 "Failed to generate a JWT token to make the request. The request was never made: server error."+        (Just signature) -> MI.liftIO $ runRequest tlsManagerSettings standardHttpMethod url+            (  addHeader ("Accept", "application/json")             <> addHeader ("Authorization", jwtPrefix `B.append` T.encodeUtf8 signature)             <> requestModifications             )-            (basicResponder responder)+            (basicResponder AC.responder)     where         jwtPrefix :: B.ByteString         jwtPrefix = BC.pack "JWT " -        connectionManagerSettings = if "https://" `isPrefixOf` productBaseUrlString then tlsManagerSettings else defaultManagerSettings-         url = T.decodeUtf8 $ BC.pack productBaseUrlString `B.append` productRelativeUrl `B.append` renderQuery True queryParams         productBaseUrlString = show productBaseUrl         productBaseUrl = AC.getURI . AC.baseUrl $ tenant+hostRequest standardHttpMethod tenant (Just (AC.AccessToken accessToken)) productRelativeUrl queryParams requestModifications = do+    MI.liftIO $ putStrLn $ "Using bearer token: " ++ show accessToken+    MI.liftIO $ runRequest tlsManagerSettings standardHttpMethod url+        (  addHeader ("Accept", "application/json")+        <> addHeader ("Authorization", bearerPrefix `B.append` T.encodeUtf8 accessToken)+        <> requestModifications+        )+        (basicResponder AC.responder)+    where+        bearerPrefix :: B.ByteString+        bearerPrefix = BC.pack "Bearer " +        url = T.decodeUtf8 $ BC.pack productBaseUrlString `B.append` productRelativeUrl `B.append` renderQuery True queryParams+        productBaseUrlString = show . AC.getURI . AC.baseUrl $ tenant+ generateJWTToken :: CD.PluginKey -> P.POSIXTime -> T.Text -> StdMethod -> URI -> T.Text -> Maybe T.Text generateJWTToken pluginKey' fromTime sharedSecret' method' ourURL requestURL = do   queryStringHash <- QSH.createQueryStringHash method' ourURL requestURL@@ -153,8 +158,8 @@ createClaims :: CD.PluginKey -> P.POSIXTime -> T.Text -> JWT.JWTClaimsSet createClaims (CD.PluginKey pluginKey) fromTime queryStringHash = JWT.JWTClaimsSet     { JWT.iss = JWT.stringOrURI pluginKey-    , JWT.iat = JWT.intDate fromTime-    , JWT.exp = JWT.intDate expiryTime+    , JWT.iat = JWT.numericDate fromTime+    , JWT.exp = JWT.numericDate expiryTime     , JWT.sub = Nothing     , JWT.aud = Nothing     , JWT.nbf = Nothing@@ -168,15 +173,6 @@         -- Our default expiry period when talking to the host product directly         expiryPeriod :: Minute         expiryPeriod = 1--responder :: FromJSON a => Int -> BL.ByteString -> Either ProductErrorResponse (Maybe a)-responder responseCode body-   | responseCode == 204 = Right Nothing-   | 200 <= responseCode && responseCode < 300 =-      case eitherDecode body of-         Right jsonResponse -> Right . Just $ jsonResponse-         Left err -> Left $ ProductErrorResponse responseCode (T.pack $ "Could not parse the json response: " ++ show err)-   | otherwise = Left $ ProductErrorResponse responseCode (T.decodeUtf8 . BL.toStrict $ body)  -- Wrapper around another function setPostParams :: [(B.ByteString, B.ByteString)] -> Endo Request
+ src/Snap/AtlassianConnect/NetworkCommon.hs view
@@ -0,0 +1,59 @@+{-# LANGUAGE DeriveGeneric     #-}+{-# LANGUAGE OverloadedStrings #-}++module Snap.AtlassianConnect.NetworkCommon +    ( HttpResponseCode+    , ProductErrorResponse(..)+    , AccessTokenResponse(..)+    , AccessToken(..)+    , AccessTokenType(..)+    , responder+    ) where++import           Data.Aeson+import qualified Data.ByteString.Lazy                  as BL+import           Data.Maybe                            (fromMaybe)+import qualified Data.Text                             as T+import qualified Data.Text.Encoding                    as T+import           Data.Time.Units+import           GHC.Generics+import           Text.Read                             (readMaybe)++type HttpResponseCode = Int++-- | If you fail to properly communicate with the Host application then you will get a product error response back.+data ProductErrorResponse = ProductErrorResponse+   { perCode    :: HttpResponseCode -- ^ The HTTP error code.+   , perMessage :: T.Text           -- ^ The error message.+   } deriving (Show, Generic)++-- | This represents the response that an OAuth Access token request should expect to recieve from auth.atlassian.io.+data AccessTokenResponse = AccessTokenResponse+    { atrAccessToken :: AccessToken -- ^ The access token that can be used in subsequent HTTP requests.+    , atrExpiresIn :: Second -- ^ The duration until this access token expires and can no longer be used.+    , atrTokenType :: AccessTokenType -- ^ The type of token that was returned from auth.atlassian.io.+    }++instance FromJSON AccessTokenResponse where+    parseJSON = withObject "AccessTokenResponse" $ \v -> AccessTokenResponse +        <$> (AccessToken <$> v .: "access_token")+        <*> (fromInteger <$> v .: "expires_in")+        <*> ((fromMaybe UnknownAccessTokenType . readMaybe) <$> v .: "token_type")++-- | An access token that can be used in subsequent requests.+data AccessToken = AccessToken T.Text++-- | The type of access token that was returned from the token generation service.+data AccessTokenType+    = Bearer -- ^ An OAuth 2 bearer token.+    | UnknownAccessTokenType -- ^ A token type that could not be parsed.+    deriving (Eq, Enum, Read)++responder :: FromJSON a => Int -> BL.ByteString -> Either ProductErrorResponse (Maybe a)+responder responseCode body+   | responseCode == 204 = Right Nothing+   | 200 <= responseCode && responseCode < 300 =+      case eitherDecode body of+         Right jsonResponse -> Right . Just $ jsonResponse+         Left err -> Left $ ProductErrorResponse responseCode (T.pack $ "Could not parse the json response: " ++ show err)+   | otherwise = Left $ ProductErrorResponse responseCode (T.decodeUtf8 . BL.toStrict $ body)
+ src/Snap/AtlassianConnect/OAuth.hs view
@@ -0,0 +1,142 @@+{-# LANGUAGE OverloadedStrings #-}++{-|+Module      : Snap.AtlassianConnect.OAuth+Description : Allows you to impersonate users inside an Atlassian Product (like JIRA or Confluence).+Copyright   : (c) Robert Massioli, 2017+License     : APACHE-2+Maintainer  : rmassaioli@atlassian.com+Stability   : experimental++By default, all HTTP requests that your add-on makes to an Atlassian application (like JIRA or Confluence) will+be performed as your add-on user. For example, if your add-on service makes a HTTP request to comment on a JIRA +issue then the comment will be made as your add-on. However, if your addon requests the "ACT_AS_USER" scope then+is is capable of using the OAuth service to generate an "access token" for a particular user on a tenant. This+access token can be used to make make HTTP requests from your add-on service to an Atlassian product as a user+inside that product. This allows you to make HTTP requests that can impersonate users on a given instance.++Using our previous example, if you generated an access token for the user "Bob" and then made the same request,+but with that new access token, then the comment made on the issue would appear to come from Bob.++You can read more about access token on the <https://developer.atlassian.com/cloud/jira/platform/oauth-2-jwt-bearer-token-authorization-grant-type/ official documentation>.+-}+module Snap.AtlassianConnect.OAuth +    ( requestAccessToken+    , AC.AccessTokenResponse(..)+    , AC.AccessToken+    , AC.AccessTokenType(..)+    ) where++import           Data.Aeson+import qualified Data.ByteString.Char8                 as BSC+import qualified Data.ByteString.Lazy                  as BL+import qualified Data.Connect.Descriptor               as D+import           Data.List                             (isPrefixOf)+import qualified Data.Map                              as M+import qualified Data.Text                             as T+import qualified Data.Text.Encoding                    as T+import qualified Data.Time.Clock.POSIX                 as P+import           Data.Time.Units                       (Minute)+import           Data.TimeUnitUTC+import           Network.Api.Support+import           Network.HTTP.Client+import           Network.HTTP.Client.TLS               (tlsManagerSettings)+import           Network.HTTP.Types+import           Network.URI+import           Snap.AtlassianConnect.AtlassianTypes+import qualified Snap.AtlassianConnect.Data            as AC+import qualified Snap.AtlassianConnect.Instances       as AC+import qualified Snap.AtlassianConnect.NetworkCommon   as AC+import qualified Snap.AtlassianConnect.Tenant          as AC+import qualified Web.JWT                               as JWT++-- | Request an access token for a tenant and user with the requested scopes. +-- +-- This token will allow you to post subsequent HTTP requests using the Snap.AtlassianConnect.HostRequest module+-- as an impersonated user.+--+-- The access token that is returned should be reused as much as possible in order to avoid hitting rate limits +-- from the token generation service. Each token is specific to the tenant, user and scopes that+-- it was created for. This means that the token reuse must be tenant, user and scopes specific.+requestAccessToken +    :: AC.Tenant -- ^ The tenant to generate the Access Token for.+    -> UserKey -- ^ The user key of the user on the tenant that should be impersonated.+    -> Maybe [D.ProductScope] -- ^ The collection of scopes that will be used for impersonation. Providing none requests all scopes that the add-on provides.+    -> IO (Either AC.ProductErrorResponse (Maybe AC.AccessTokenResponse)) -- ^ Returns either a HTTP error or the result of attempting to parse the AccessTokenResponse.+requestAccessToken tenant userKey possibleScopes = do+    potentialAssertion <- generateJWTAssertion tenant userKey+    case potentialAssertion of+        Nothing -> return . Right $ Nothing+        Just assertion ->+            runRequest tlsManagerSettings POST url+                ( addHeader ("Host", "auth.atlassian.io")+                <> addHeader ("Accept", "application/json")+                <> addHeader ("Content-Type", "application/x-www-form-urlencoded")+                <> setBody (renderSimpleQuery False (formParams assertion))+                )+                (basicResponder AC.responder)+    where+        url = authBaseUrl `T.append` "/oauth2/token"++        formParams :: T.Text -> [(BSC.ByteString, BSC.ByteString)]+        formParams assertion = +            [ ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")+            , ("assertion", T.encodeUtf8 assertion)+            ] ++ scopeParam++        scopeParam = case possibleScopes of+            Nothing -> []+            Just scopes -> (: []) . (,) "scope" . BSC.intercalate " " . fmap printScope . filter ((/=) D.ActAsUser) $ scopes++printScope :: D.ProductScope -> BSC.ByteString +printScope D.Read         = "READ"+printScope D.Write        = "WRITE"+printScope D.Delete       = "DELETE"+printScope D.ProjectAdmin = "PROJECT_ADMIN"+printScope D.SpaceAdmin   = "SPACE_ADMIN"+printScope D.Admin        = "ADMIN"+printScope D.ActAsUser    = "ACT_AS_USER"++generateJWTAssertion :: AC.Tenant -> UserKey -> IO (Maybe T.Text)+generateJWTAssertion tenant userKey = do+    currentTime <- P.getPOSIXTime+    case generateAssertionClaims currentTime tenant userKey of+        Nothing -> return Nothing+        Just claims -> return . Just $ JWT.encodeSigned JWT.HS256 jwtSecret claims+    where+        jwtSecret = JWT.secret . AC.sharedSecret $ tenant++-- These claims are documented here: https://developer.atlassian.com/cloud/jira/platform/oauth-2-jwt-bearer-token-authorization-grant-type/+{-+`iss`	String	the issuer of the claim. For example: `urn:atlassian:connect:clientid:{oauthClientId}`+`sub`	String	The subject of the token. For example: `urn:atlassian:connect:userkey:{userkey of the user to run services on behalf of}` **NOTE:** The `userkey` is different from the `username`. For example, to get the `userkey` for username alex use the REST endpoint `/rest/api/2/user?username=alex` in JIRA or `/rest/api/user?username=alex` in Confluence.+`tnt`	String	The instance the add-on is installed on. For example: `https://{your-instance}.atlassian.net`+`aud`	String	The Atlassian authentication server: `https://auth.atlassian.io`+`iat`	Long	Issue time in seconds since the epoch UTC.+`exp`	Long	Expiry time in seconds since the epoch UTC. Must be no later that 60 seconds in the future.+-}+generateAssertionClaims :: P.POSIXTime -> AC.Tenant -> UserKey -> Maybe JWT.JWTClaimsSet+generateAssertionClaims fromTime tenant userKey = do+    oid <- AC.oauthClientId tenant+    return JWT.JWTClaimsSet+        { JWT.iss = JWT.stringOrURI $ "urn:atlassian:connect:clientid:" `T.append` oid+        , JWT.sub = JWT.stringOrURI $ "urn:atlassian:connect:userkey:" `T.append` userKey+        , JWT.aud = Left <$> JWT.stringOrURI authBaseUrl+        , JWT.iat = JWT.numericDate fromTime+        , JWT.exp = JWT.numericDate expiryTime+        , JWT.nbf = Nothing+        , JWT.jti = Nothing+        , JWT.unregisteredClaims = M.fromList [("tnt", String . T.pack $ tenantBaseUrlString)]+        }+    where+        tenantBaseUrlString = show . AC.getURI . AC.baseUrl $ tenant++        expiryTime :: P.POSIXTime+        expiryTime = fromTime + timeUnitToDiffTime expiryPeriod++        -- One minute is the maximum expiry time for this token+        expiryPeriod :: Minute+        expiryPeriod = 1++authBaseUrl :: T.Text +authBaseUrl = "https://auth.atlassian.io"