packages feed

neptune-backend-0.1.2: lib/Neptune/OAuth.hs

{-|
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