{-| Module : Neptune.OAuth Description : Neptune Client Copyright : (c) Jiasen Wu, 2020 License : BSD-3-Clause -} {-# LANGUAGE TemplateHaskell #-} module Neptune.OAuth where import Control.Concurrent (ThreadId, forkIO) import Control.Lens import qualified Data.Aeson as Aeson import Data.Aeson.Lens import Data.Time.Clock (NominalDiffTime) import Data.Time.Clock.POSIX (getPOSIXTime) import Network.HTTP.Req (JsonResponse, POST (..), ReqBodyUrlEnc (..), defaultHttpConfig, jsonResponse, req, responseBody, runReq, useHttpsURI, (=:)) import RIO hiding (Lens', (.~), (^.)) import qualified RIO.Text as T import Text.URI (mkURI) import qualified Web.JWT as JWT data OAuth2Session = OAuth2Session { _oas_client_id :: Text , _oas_access_token :: Text -- ^ access token for APIs , _oas_refresh_token :: Text -- ^ refresh token after expires , _oas_expires_in :: NominalDiffTime -- ^ duration in which the access token is valid , _oas_refresh_url :: Text -- ^ refresh url } makeLenses ''OAuth2Session -- | Setup a background thread to refresh OAuth2 token. oauth2Setup :: Text -> Text -> IO (ThreadId, MVar OAuth2Session) oauth2Setup access_token refresh_token = do let decoded = JWT.decode $ access_token claims = JWT.claims (decoded ^?! _Just) issuer = JWT.iss claims ^?! _Just & JWT.stringOrURIToText refresh_url = T.append issuer "/protocol/openid-connect/token" client_name = JWT.unClaimsMap (JWT.unregisteredClaims claims) ^?! ix "azp" . _String expires_at = JWT.exp claims ^?! _Just & JWT.secondsSinceEpoch now <- getPOSIXTime let expires_in = expires_at - now session = OAuth2Session client_name access_token refresh_token expires_in refresh_url oauth_session_var <- newMVar session refresh_thread <- forkIO (oauthRefresher oauth_session_var) return (refresh_thread, oauth_session_var) oauthRefresher :: MVar OAuth2Session -> IO () oauthRefresher session_var = update >> oauthRefresher session_var where update = do session <- readMVar session_var let wait_sec = floor $ 1000000 * session ^. oas_expires_in threadDelay wait_sec modifyMVar_ session_var $ \session -> do let url = session ^. oas_refresh_url tok = session ^. oas_refresh_token body = ReqBodyUrlEnc $ mconcat [ "grant_type" =: ("refresh_token" :: Text) , "refresh_token" =: tok , "client_id" =: ("neptune-cli" :: Text) ] {-- Accept: application/json Content-Type: application/x-www-form-urlencoded;charset=UTF-8 grant_type=refresh_token &refresh_token=... &client_id=neptune-cli --} case useHttpsURI =<< mkURI url of Nothing -> error $ "Bad refresh url " ++ T.unpack url Just (url, opt) -> do -- the default retrying pocily is 50ms delay, upto 5 times resp <- runReq defaultHttpConfig $ req POST url body jsonResponse opt :: IO (JsonResponse Aeson.Value) resp <- pure $ responseBody resp -- resp is a json object of keys: -- "access_token", "expires_in", "refresh_expires_in", "refresh_token", -- "token_type", "not-before-policy", "session_state", "scope" let access_token = resp ^?! key "access_token" . _String refresh_token = resp ^?! key "refresh_token" . _String expires_in = fromIntegral (resp ^?! key "expires_in" . _Integral :: Int) return $ session & oas_access_token .~ access_token & oas_refresh_token .~ refresh_token & oas_expires_in .~ expires_in