packages feed

notion-client-0.10.0.0: notion-client-oauth-example/FileTokenStore.hs

-- | Example-only POSIX persistence. The complete command must hold the lock.
-- Atomic replacement protects complete JSON; it cannot transact with Notion.
module FileTokenStore
  ( fileTokenStore,
    tokenFileExists,
    withTokenFileLock,
    removeTokenFile,
  )
where

import Control.Exception
import Control.Monad (unless, when)
import Data.Aeson qualified as A
import Data.Bits ((.&.))
import Data.ByteString.Lazy qualified as L
import Notion.V1.OAuth.Refresh
import System.Directory qualified as D
import System.FilePath
import System.IO
import System.IO.Error (isAlreadyExistsError, isDoesNotExistError)
import System.Posix.Directory qualified as PD
import System.Posix.Files qualified as F
import System.Posix.IO qualified as P
import System.Posix.Temp (mkstemp)
import System.Posix.Unistd (fileSynchronise)
import System.Posix.User (getEffectiveUserID)

fileTokenStore :: FilePath -> TokenStore
fileTokenStore path =
  TokenStore
    { loadTokens = do
        exists <- tokenFileExists path
        unless exists (ioError (userError "No saved credentials. Run login again"))
        bytes <- L.readFile path
        either (const (ioError (userError "Invalid token file. Run login again"))) pure (A.eitherDecode bytes),
      saveTokens = save path
    }

-- Check using lstat, including dangling symlinks. Do not follow token symlinks.
tokenFileExists :: FilePath -> IO Bool
tokenFileExists path = do
  checkDirectory (takeDirectory path)
  status <- optionalStatus path
  case status of
    Nothing -> pure False
    Just s -> do
      uid <- getEffectiveUserID
      unless
        (F.isRegularFile s && F.fileOwner s == uid && F.fileMode s .&. 0o077 == 0)
        (ioError (userError "Token path must be an owner-only regular file"))
      pure True

optionalStatus :: FilePath -> IO (Maybe F.FileStatus)
optionalStatus path =
  (Just <$> F.getSymbolicLinkStatus path) `catch` \(e :: IOException) ->
    if isDoesNotExistError e then pure Nothing else throwIO e

checkDirectory :: FilePath -> IO ()
checkDirectory path = do
  s <- F.getSymbolicLinkStatus path
  uid <- getEffectiveUserID
  unless
    (F.isDirectory s && F.fileOwner s == uid && F.fileMode s .&. 0o022 == 0)
    (ioError (userError "Token directory must be owned by you and not writable by group or others"))

save :: FilePath -> OAuthTokens -> IO ()
save path tokens = mask $ \restore -> do
  _ <- tokenFileExists path
  let directory = takeDirectory path
      cleanup (temporary, tempHandle) = do
        ignoreIO (hClose tempHandle)
        ignoreIO (D.removeFile temporary)
  -- mkstemp creates exclusively with mode 0600 before any secret bytes exist.
  bracketOnError (mkstemp (directory </> takeFileName path <> ".tmp-")) cleanup $ \(temporary, tempHandle) -> do
    restore (L.hPut tempHandle (A.encode tokens) >> hFlush tempHandle)
    fd <- P.handleToFd tempHandle
    bracket (pure fd) P.closeFd (restore . fileSynchronise)
    -- Recheck destination before replacement; the directory is not attacker-writable.
    _ <- tokenFileExists path
    D.renameFile temporary path
    synchronizeDirectory directory

synchronizeDirectory :: FilePath -> IO ()
synchronizeDirectory directory = bracket (P.openFd directory P.ReadOnly P.defaultFileFlags) P.closeFd fileSynchronise

-- | The lock covers load, remote exchange and durable save. A crash may leave
-- the lock directory behind; remove it only after checking no command is active.
withTokenFileLock :: FilePath -> IO a -> IO a
withTokenFileLock path = bracket acquire (const (D.removeDirectory lock)) . const
  where
    lock = path <> ".lock"
    acquire = do
      checkDirectory (takeDirectory path)
      PD.createDirectory lock 0o700 `catch` \(e :: IOException) ->
        if isAlreadyExistsError e then ioError (userError "OAuth command already locked; check for an active process before removing the lock directory") else throwIO e

removeTokenFile :: FilePath -> IO ()
removeTokenFile path = do
  exists <- tokenFileExists path
  when exists (D.removeFile path >> synchronizeDirectory (takeDirectory path))

ignoreIO :: IO () -> IO ()
ignoreIO action = action `catch` \(_ :: IOException) -> pure ()