packages feed

notion-client-0.10.0.0: scripts/OAuthLiveCheck.hs

{-# LANGUAGE GHC2024 #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | Explicit live acceptance harness, never invoked by the offline suite.
-- Run after login/refresh with a current refresh-capable installation. It replaces
-- only the in-memory access token, keeps the current refresh token, and durably
-- saves the one replacement pair. It never probes a superseded refresh token.
module Main (main) where

import Control.Exception
import Control.Monad (unless)
import Data.IORef
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import FileTokenStore
import Network.HTTP.Client.TLS (newTlsManager)
import Notion.V1
import Notion.V1.OAuth qualified as O
import Notion.V1.OAuth.Refresh qualified as R
import Servant.Client (mkClientEnv)
import System.Environment
import System.Exit

main :: IO ()
main =
  run `catch` \(e :: SomeException) -> case fromException e of
    Just (exit :: ExitCode) -> throwIO exit
    Nothing | Just (_ :: AsyncException) <- fromException e -> throwIO e
    Nothing -> putStrLn "Live check failed; reauthorize after an uncertain exchange. No credentials printed." >> exitFailure

run :: IO ()
run = do
  client <- T.pack <$> getEnv "NOTION_OAUTH_CLIENT_ID"
  secret <- T.pack <$> getEnv "NOTION_OAUTH_CLIENT_SECRET"
  path <- fromMaybe ".notion-oauth-tokens.json" <$> lookupEnv "NOTION_OAUTH_TOKEN_FILE"
  withTokenFileLock path $ do
    let fileStore = fileTokenStore path
    old <- R.loadTokens fileStore
    unless (R.refreshToken old /= Nothing) (putStrLn "Live check requires a current refresh token" >> exitFailure)
    http <- newTlsManager
    let env = mkClientEnv http defaultBaseUrl
        baseOAuth = O.makeOAuthMethodsWith defaultClientConfig env (O.OAuthCredentials client secret)
    calls <- newIORef (0 :: Int)
    let oauth =
          baseOAuth
            { O.createOAuthToken = \grant -> do
                modifyIORef' calls (+ 1)
                response <- O.createOAuthToken baseOAuth grant
                putStrLn $
                  "OAuth owner decoded: " <> case O.owner response of
                    O.OAuthUserOwner _ -> "user"
                    O.OAuthWorkspaceOwner -> "workspace"
                    O.UnknownOAuthOwner _ -> "unknown"
                pure response
            }
        -- Do not write the intentionally invalid access token to disk.
        injectedStore = fileStore {R.loadTokens = pure old {R.accessToken = "intentionally-invalid-live-acceptance-token"}}
    manager <- R.newOAuthTokenManager oauth injectedStore
    let api = makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)
    result <- try @R.TokenPersistenceError (retrieveMyUser api)
    case result of
      Right _ -> pure ()
      Left _ -> do
        retryPersistence manager
        _ <- retrieveMyUser api
        pure ()
    count <- readIORef calls
    saved <- R.loadTokens fileStore
    current <- R.currentTokens manager
    unless
      (count == 1 && saved == current && R.accessToken saved /= "intentionally-invalid-live-acceptance-token")
      (putStrLn "Live reactive-refresh assertions failed" >> exitFailure)
    unless (R.refreshToken saved /= Nothing) (putStrLn "No replacement refresh token; refreshing-installation gate remains pending" >> exitFailure)
    putStrLn "live unauthorized -> refresh -> retry succeeded: True"
    putStrLn "refresh requests: 1"
    putStrLn "persisted replacement: True"
    putStrLn ("access token changed: " <> show (R.accessToken old /= R.accessToken saved))
    putStrLn ("refresh token changed: " <> maybe "not returned" (\_ -> show (R.refreshToken old /= R.refreshToken saved)) (R.refreshToken saved))

retryPersistence :: R.OAuthTokenManager -> IO ()
retryPersistence manager = do
  putStrLn "Replacement retained in memory. Fix storage, then press Enter to retry saving; stopping may require login again."
  _ <- getLine
  result <- try @R.TokenPersistenceError (R.retryTokenPersistence manager)
  either (const (retryPersistence manager)) (const (pure ())) result