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 +4/−2
- src/Snap/AtlassianConnect/HostRequest.hs +35/−39
- src/Snap/AtlassianConnect/NetworkCommon.hs +59/−0
- src/Snap/AtlassianConnect/OAuth.hs +142/−0
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"