packages feed

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

-- | Public integration login example (POSIX only).
-- Create a public connection in https://www.notion.so/profile/integrations and
-- register http://localhost:8765/callback. Export NOTION_OAUTH_CLIENT_ID and
-- NOTION_OAUTH_CLIENT_SECRET, then run with login, whoami, refresh or revoke.
-- If localhost HTTP is refused, register an HTTPS tunnel URL and set
-- NOTION_OAUTH_REDIRECT_URI; keep its target listener port configured separately.
-- This confidential-client example runs in a trusted process. Distributed apps
-- must keep the client secret and exchange on a trusted backend.
module Main (main) where

import Control.Exception
import Control.Monad (unless, when)
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Vector qualified as V
import FileTokenStore
import Login
import Network.HTTP.Client.TLS (newTlsManager)
import Notion.V1
import Notion.V1.Common (UUID (..))
import Notion.V1.DataSources qualified as D
import Notion.V1.Error qualified as E
import Notion.V1.ListOf (ListOf (..))
import Notion.V1.OAuth qualified as O
import Notion.V1.OAuth.Authorization qualified as A
import Notion.V1.OAuth.Refresh qualified as R
import Notion.V1.Pages qualified as P
import Notion.V1.Search
import Notion.V1.Users qualified as U
import Servant.Client (mkClientEnv)
import System.Environment
import System.Exit
import System.IO
import Text.Read (readMaybe)

main :: IO ()
main =
  program `catch` \(e :: SomeException) -> case fromException e of
    Just (exit :: ExitCode) -> throwIO exit
    Nothing | Just (_ :: AsyncException) <- fromException e -> throwIO e
    Nothing -> hPutStrLn stderr (diagnostic e) >> exitFailure

program :: IO ()
program = do
  args <- getArgs
  command <- case args of
    [c] | c `elem` ["login", "whoami", "refresh", "revoke"] -> pure c
    _ -> usage
  cid <- required "NOTION_OAUTH_CLIENT_ID"
  secret <- required "NOTION_OAUTH_CLIENT_SECRET"
  redirect <- T.pack . fromMaybe "http://localhost:8765/callback" <$> lookupEnv "NOTION_OAUTH_REDIRECT_URI"
  portText <- fromMaybe "8765" <$> lookupEnv "NOTION_OAUTH_LISTEN_PORT"
  port <- case readMaybe portText of
    Just p | p > 0 && p <= 65535 -> pure p
    _ -> usage
  path <- fromMaybe ".notion-oauth-tokens.json" <$> lookupEnv "NOTION_OAUTH_TOKEN_FILE"
  withTokenFileLock path $ do
    http <- newTlsManager
    let env = mkClientEnv http defaultBaseUrl
        oauth = O.makeOAuthMethodsWith defaultClientConfig env (O.OAuthCredentials cid secret)
        store = fileTokenStore path
    case command of
      "login" -> do
        state <- A.newOAuthState
        let request = A.AuthorizationRequest cid redirect state
        result <- runLogin port (10 * 60 * 1000000) oauth store request $ do
          putStrLn "Open this URL in your browser:"
          T.putStrLn (A.authorizationUrl request)
          T.putStrLn ("Waiting for the redirect on " <> redirect)
        case result of
          Connected tokens -> connected path tokens
          SavePending tokens -> do
            putStrLn "Credentials received but storage failed. Keep this process open to retain them."
            retrySave (R.saveTokens store tokens)
            connected path tokens
          Cancelled -> putStrLn "Consent was cancelled"
          LoginExpired -> hPutStrLn stderr "Login timed out. Start login again" >> exitFailure
          LoginFailed -> hPutStrLn stderr "Connection failed. Check client credentials and start login again" >> exitFailure
      _ -> do
        exists <- tokenFileExists path
        unless exists (hPutStrLn stderr "Run login again" >> exitFailure)
        manager <- R.newOAuthTokenManager oauth store
        case command of
          "whoami" -> do
            let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)
                who = do
                  U.UserObject {name} <- retrieveMyUser api
                  T.putStrLn ("Bot: " <> fromMaybe "(unnamed)" name)
                  List {results} <- search api _SearchRequest {pageSize = Just 5}
                  if V.null results then putStrLn "No shared search results" else mapM_ (T.putStrLn . resultLabel) (V.toList (V.take 5 results))
            who `catch` \(_ :: R.TokenPersistenceError) -> do
              putStrLn "Refresh succeeded but its credentials were not saved."
              _ <- retrySave (R.retryTokenPersistence manager)
              who
          "refresh" -> do
            old <- R.currentTokens manager
            result <- try @R.TokenPersistenceError (R.refreshTokensNow manager)
            new <- either (const (retrySave (R.retryTokenPersistence manager))) pure result
            putStrLn ("access token changed: " <> show (R.accessToken old /= R.accessToken new))
            putStrLn ("refresh token changed: " <> maybe "not returned" (\_ -> show (R.refreshToken old /= R.refreshToken new)) (R.refreshToken new))
          "revoke" -> do
            tokens <- R.currentTokens manager
            _ <- O.revokeOAuthToken oauth (R.accessToken tokens)
            removeTokenFile path
            putStrLn "Local credentials removed. Run login again"
          _ -> usage

required :: String -> IO T.Text
required key =
  lookupEnv key >>= \case
    Just value | not (null value) -> pure (T.pack value)
    _ -> usage

usage :: IO a
usage = do
  hPutStrLn stderr "usage: notion-client-oauth-example (login | whoami | refresh | revoke)"
  hPutStrLn stderr "  requires NOTION_OAUTH_CLIENT_ID and NOTION_OAUTH_CLIENT_SECRET"
  exitWith (ExitFailure 2)

-- This action may save the same pair repeatedly, but must never exchange a grant.
retrySave :: IO a -> IO a
retrySave action = do
  putStrLn "Press Enter to retry persistence, or q to stop (unsaved credentials may require a new login)."
  hFlush stdout
  eof <- hIsEOF stdin
  when eof exitFailure
  answer <- getLine
  when (answer == "q") exitFailure
  attempt <- try @SomeException action
  case attempt of
    Right a -> pure a
    Left e | Just (_ :: AsyncException) <- fromException e -> throwIO e
    Left _ -> putStrLn "Persistence still failed; credentials are retained in this process." >> retrySave action

connected :: FilePath -> R.OAuthTokens -> IO ()
connected path R.OAuthTokens {workspaceName, workspaceId, botId} = do
  T.putStrLn ("Connected to workspace " <> fromMaybe "(unnamed)" workspaceName <> " (workspace " <> workspaceId <> ", bot " <> botId <> ")")
  putStrLn ("Tokens saved to " <> path)

resultLabel :: PageOrDataSource -> T.Text
resultLabel = \case
  PageResult P.PageObject {id = UUID value} -> "page " <> value
  PartialPageResult P.PartialPageObject {id = UUID value} -> "page " <> value
  DataSourceResult D.DataSourceObject {id = UUID value} -> "data source " <> value
  PartialDataSourceResult D.PartialDataSourceObject {id = UUID value} -> "data source " <> value
  UnknownResult _ -> "Unknown search result type"

diagnostic :: SomeException -> String
diagnostic e
  | Just (_ :: R.TokenPersistenceError) <- fromException e = "Token persistence failed; retain this process and retry the save"
  | Just (_ :: R.RefreshOutcomeUnknown) <- fromException e = "Refresh outcome unknown. Run login again; do not reuse the old refresh token"
  | Just (_ :: R.ReauthorizationRequired) <- fromException e = "Authorization required. Run login again"
  | Just oe@O.OAuthError {oauthErrorCode = O.OAuthInvalidClient} <- fromException e = "Check OAuth client credentials: " <> displayException oe
  | Just (oe :: O.OAuthError) <- fromException e = displayException oe
  | Just E.NotionError {code} <- fromException e = "Notion API request failed (" <> safeCode code <> ")"
  | Just (_ :: IOException) <- fromException e = "Local storage or listener failed; check the token directory, permissions, lock and port"
  | otherwise = "OAuth operation failed; start login again"
  where
    safeCode (E.UnknownErrorCode _) = "unknown_error"
    safeCode c = T.unpack (E.apiErrorCodeText c)