packages feed

notion-client 0.9.0.0 → 0.10.0.0

raw patch · 24 files changed

+2257/−156 lines, 24 filesdep +directorydep +entropydep +memorydep ~aesondep ~basedep ~bytestringnew-component:exe:notion-client-oauth-example

Dependencies added: directory, entropy, memory, unix, wai, warp

Dependency ranges changed: aeson, base, bytestring, filepath, http-client-tls, http-types, servant-client, text, time, vector

Files

CHANGELOG.md view
@@ -1,5 +1,31 @@ # Changelog for notion-client +## 0.10.0.0 (2026-10-19)++### Breaking Changes++* Use `notion-client-effectful >=0.4 && <0.5`; the companion requires `notion-client >=0.10 && <0.11`.++* `RequestContext.contextAuthorization` is replaced by `contextTokenSource`; `standardHeaders` now returns `IO [Header]`. Use `withStandardHeaders` around non-Servant response acquisition for one reactive renewal, and keep body readers and user callbacks outside it.++* OAuth endpoint failures with OAuth error bodies now throw `OAuthError` instead of `UnknownHTTPResponseError`. Known OAuth codes in the alternate Notion error envelope also become `OAuthError`; ordinary Notion API codes remain `NotionError`.++### New Features++* POSIX `notion-client-oauth-example` demonstrates localhost login, durable private file persistence, explicit refresh and confirmed local logout. Offline callback, listener and file-store tests cover single use and recovery. Live public-integration acceptance passed login, user-owner decoding, explicit and automatic token rotation, persisted restart, consent cancellation and revoke/local logout on 2026-10-19.++* Per-request `TokenSource`, `makeMethodsWithTokenSource` and `Notion.V1.OAuth.Refresh` support automatic renewal once on typed `Unauthorized`, serialized token rotation and persistence-before-use. Failed saves retain the pair for `retryTokenPersistence`; uncertain exchanges block grant replay.++* `Notion.V1.OAuth.Authorization` provides secure state generation, consent URLs, strict state-first callback verification and code exchange.++* Typed `OAuthErrorCode` and route-local normalization of OAuth failures; OAuth requests never automatically replay grants. Default error rendering and OAuth debug logging omit server descriptions, headers and bodies.++### Migration++* Existing `makeMethods`, `makeMethodsWith` and `makeMethodsWithEnv` callers keep their fixed-token behavior. Opt into refresh with `makeMethodsWithTokenSource` and `tokenManagerSource`; neither `Methods` fields nor effectful operations change in this release.+* Construct custom request contexts with `requestContextFor` for a fixed token or `requestContextWithTokenSource` for a dynamic source. Call `standardHeaders` in IO for headers without renewal, or use `withStandardHeaders` for a replayable acquisition that can renew once. Close rejected responses and classify them before throwing; consume an opened response outside the retry wrappers.+* Handle `OAuthError` for OAuth endpoint failures. For a managed installation, handle `ReauthorizationRequired`, `TokenPersistenceError` and `RefreshOutcomeUnknown` at the IO boundary, including when using `runNotion`. Persistence recovery calls `retryTokenPersistence` on the same manager; reauthorization and uncertain refresh outcomes require a new login and manager.+ ## 0.9.0.0 (2026-10-12)  ### Breaking Changes
README.md view
@@ -6,23 +6,47 @@  - Type-safe API bindings using Servant - Comprehensive coverage of Notion API endpoints-- Support for all Notion object types: Pages, Databases, Data Sources, Blocks, Users, etc.+- Typed pages, databases, data sources, blocks, users, and other published API objects - Simple client interface with sensible defaults - Automatic retries of rate-limited and overloaded requests, honoring `retry-after` - Typed error codes with request IDs for support-- OAuth token exchange and URL-to-ID helpers+- OAuth login, automatic token refresh and URL-to-ID helpers  ## Installation -Add to your `package.yaml` or `.cabal` file:+For this release, use `notion-client` 0.10.x with `notion-client-effectful` 0.4.x+if you need the effectful adapter. Add to your `.cabal` file: +```cabal+build-depends: notion-client >=0.10 && <0.11+```++Or add to `package.yaml`:+ ```yaml dependencies:-  - notion-client+  - notion-client >=0.10 && <0.11 ``` +### Upgrading from 0.9++Fixed-token constructors (`makeMethods`, `makeMethodsWith`, `makeMethodsWithEnv`)+keep their signatures. Automatic refresh is opt-in through+`makeMethodsWithTokenSource`; `Methods` fields are unchanged.++Custom HTTP clients must replace `RequestContext.contextAuthorization` with+`contextTokenSource`. Prefer `requestContextFor` for a fixed token or+`requestContextWithTokenSource` for an installation's source. `standardHeaders`+now runs in IO and does not renew; use `withStandardHeaders` around replayable+response acquisition for renewal, keeping response bodies outside retry scopes.+OAuth endpoint failures now use `OAuthError`. See [OAuth](#oauth) for lifecycle+exceptions and the [changelog](CHANGELOG.md#01000-2026-10-19) for the full migration.+ ## Usage +Examples assume `GHC2024` with `OverloadedStrings` and `DuplicateRecordFields`.+Replace placeholder IDs with real IDs from pages shared with your connection.+ Here's a simple example of retrieving a Notion page:  ```haskell@@ -48,19 +72,23 @@  ### Creating a page with typed properties +The target data source must have properties with matching names and types;+this example uses a status property named `Status` and a select named `Priority`.+ ```haskell import Notion.V1 import Notion.V1.Common (Parent(..)) import Notion.V1.Pages import Notion.V1.PropertyValue qualified as PV+import Notion.V1.RichText (RichText) import Data.Map qualified as Map import Data.Vector qualified as Vector -createNewPage :: Methods -> IO PageObject-createNewPage Methods{createPage} = do+createNewPage :: Methods -> RichText -> IO PageObject+createNewPage Methods{createPage} titleRichText = do     let pageProperties = Map.fromList             [ ("title", PV.titleValue (Vector.singleton titleRichText))-            , ("Status", PV.selectValue "In Progress")+            , ("Status", PV.statusValue "In Progress")             , ("Priority", PV.selectValue "High")             , ("Due", PV.dateValue "2024-06-01" Nothing)             , ("Score", PV.numberValue 42)@@ -68,7 +96,7 @@             ]          newPage = mkCreatePage-            (DataSourceParent { dataSourceId = "data-source-id-here" })+            (DataSourceParent { dataSourceId = "data-source-id-here", parentDatabaseId = Nothing })             pageProperties      createPage newPage@@ -77,12 +105,15 @@ ### Reading typed properties  ```haskell-import Notion.V1.PropertyValue+import Data.Map qualified as Map+import Data.Text (Text)+import Notion.V1.Pages (PageObject(..))+import Notion.V1.PropertyValue (PropertyValue(StatusValue), SelectOptionValue(..))  readPageStatus :: PageObject -> Maybe Text readPageStatus page =     case Map.lookup "Status" (properties page) of-        Just (SelectValue _ (Just opt)) -> Just (name opt)+        Just (StatusValue _ (Just SelectOptionValue{name = optionName})) -> Just optionName         _ -> Nothing ``` @@ -120,12 +151,12 @@ ```haskell import Notion.V1 import Notion.V1.Common (Parent(..))-import Notion.V1.Pages+import Notion.V1.Pages (CreatePage(..), PageObject, mkCreatePage)  createMarkdownPage :: Methods -> IO PageObject createMarkdownPage Methods{createPage} = do     let newPage = (mkCreatePage-            (DataSourceParent { dataSourceId = "data-source-id" })+            (DataSourceParent { dataSourceId = "data-source-id", parentDatabaseId = Nothing })             mempty)             { markdown = Just "# Hello\n\nThis page was created with **markdown**." } @@ -135,6 +166,7 @@ ### Editing page content with markdown  ```haskell+import Data.Vector qualified as Vector import Notion.V1 import Notion.V1.Pages @@ -142,7 +174,7 @@ editPage Methods{updatePageMarkdown} pageId =     updatePageMarkdown pageId $         UpdateContent UpdateContentRequest-            { contentUpdates = fromList+            { contentUpdates = Vector.fromList                 [ ContentUpdate                     { oldStr = "old text"                     , newStr = "new text"@@ -161,9 +193,12 @@ ```haskell import Network.HTTP.Client.TLS (newTlsManager) import Notion.V1+import Data.Text qualified as Text+import System.Environment (getEnv)  main :: IO () main = do+    token <- Text.pack <$> getEnv "NOTION_TOKEN"     manager <- newTlsManager     let config = defaultClientConfig {logger = Just stderrLogger, logLevel = LogInfo}         methods = makeMethodsWith config manager token@@ -182,7 +217,9 @@ ```haskell import Control.Exception (catch) import Data.Text qualified as Text+import Notion.V1 (Methods(..)) import Notion.V1.Error+import Notion.V1.Pages (PageID)  safeRetrieve :: Methods -> PageID -> IO () safeRetrieve Methods{retrievePage} pageId =@@ -202,33 +239,106 @@  ### OAuth +Public integrations (public connections in Notion's portal) use a browser consent+flow. Create one in the [developer portal](https://www.notion.so/profile/integrations),+register an exact redirect URI, and keep its OAuth client ID and secret in your+trusted server environment. The client secret and exchange belong on a trusted+backend for distributed desktop or browser apps.++The protocol helpers build the URL, verify the callback, and exchange its code:+ ```haskell-import Notion.V1 (defaultBaseUrl, defaultClientConfig)+import Control.Exception (throwIO)+import Data.Text (Text)+import Network.HTTP.Types.URI (Query) import Notion.V1.OAuth-import Servant.Client (mkClientEnv)+import Notion.V1.OAuth.Authorization -exchangeCode :: Manager -> Text -> IO OAuthTokenResponse-exchangeCode manager authCode = do-    let oauth = makeOAuthMethodsWith defaultClientConfig-            (mkClientEnv manager defaultBaseUrl)-            OAuthCredentials {clientId = "...", clientSecret = "..."}-    createOAuthToken oauth $ AuthorizationCode AuthorizationCodeGrant-        { code = authCode-        , redirectUri = Just "https://example.com/callback"-        , externalAccount = Nothing-        }+beginLogin :: Text -> Text -> IO AuthorizationRequest+beginLogin client redirect = do+    freshState <- newOAuthState+    pure (AuthorizationRequest client redirect freshState)++-- Run this only after claiming the initiating session's pending request once.+exchangeVerifiedCode :: OAuthMethods -> AuthorizationRequest -> OAuthCode+                     -> IO OAuthTokenResponse+exchangeVerifiedCode = exchangeAuthorizationCode++verifyCallback :: AuthorizationRequest -> Query -> IO OAuthCode+verifyCallback request query =+    either throwIO pure (verifyAuthorizationCallback (state request) query) ``` -`revokeOAuthToken` and `introspectOAuthToken` take a token.+Send the browser to `authorizationUrl request`. Bind the complete request (state,+client ID and exact redirect URI) to the initiating session and expire it after a+short interval. Give the verifier an already-decoded WAI query or `parseQuery`+output; do not decode twice. After verification, atomically consume the pending+request before exchange. A state-validated `AuthorizationDenied` also consumes it;+malformed or forged callbacks leave it pending. The pure verifier supplies neither+session storage nor expiry/replay protection. Descriptions are untrusted text. +After a successful exchange, convert with `oauthTokensFromResponse now response`+and durably save the pair. If that first save fails, retain the received pair and+retry only its save; never exchange the code again. The runnable example below+implements this recovery. Then construct stable methods from your store:++```haskell+import Notion.V1 (ClientConfig, Methods, makeMethodsWithTokenSource)+import Notion.V1.OAuth (OAuthMethods)+import Notion.V1.OAuth.Refresh+import Servant.Client (ClientEnv)++connectInstallation :: ClientConfig -> ClientEnv -> OAuthMethods -> TokenStore+                    -> IO (OAuthTokenManager, Methods)+connectInstallation config env oauth store = do+    manager <- newOAuthTokenManager oauth store+    pure (manager, makeMethodsWithTokenSource config env (tokenManagerSource manager))+```++`TokenStore` supplies `loadTokens` and an idempotent `saveTokens`; the library also+provides `inMemoryTokenStore` for volatile use. The manager loads once, renews once+after typed `Unauthorized`, and persists the replacement before using it.+`refreshTokensNow` performs explicit refresh. Handle these IO exceptions:++- `OAuthError`: inspect `oauthErrorCode`; `OAuthInvalidClient` means client+  configuration failed, and `OAuthInvalidGrant` means a grant was rejected.+  OAuth endpoint calls never retry automatically. Default rendering/logging omits+  descriptions, raw bodies and headers; explicit metadata is available.+- `ReauthorizationRequired`: start a new login; the refresh token is absent or+  rejected. A null replacement refresh token remains absent.+- `TokenPersistenceError`: the manager retains the unsaved replacement and blocks+  requests/refresh. Call `retryTokenPersistence` to save that identical pair.+- `RefreshOutcomeUnknown`: a refresh may have succeeded remotely. Start a new login+  and construct a new manager; do not reload the old store and replay its token.++Use one manager per installation. Multiple managers/processes must coordinate the+entire load/refresh/save sequence externally; save-time compare-and-swap is too+late. Store hooks must not re-enter the manager. A crash between remote rotation+and durable save may require another login. `currentTokens` is for explicit+inspection/recovery and can return unsaved or unusable credentials. There is no+timer refresh because token responses supply no expiry.++`RequestContext` now carries `contextTokenSource`; `standardHeaders` reads current+headers in IO. Non-Servant clients use `withStandardHeaders` around replayable+response acquisition, with `withRetries` inside it. Close rejected responses before+throwing their classified error. Keep body consumption and user callbacks outside+both retry layers. Byte and file-backed multipart bodies can be replayed; arbitrary+one-shot streams cannot. OAuth methods retain Basic authentication.++The effectful adapter accepts these same `Methods`. Final `NotionError` values use+`Error NotionError`; OAuth lifecycle and persistence exceptions remain IO exceptions.+ ### URL helpers  ```haskell+import Notion.V1.Common (UUID) import Notion.V1.Helpers (extractBlockId, extractNotionId) -extractNotionId "https://www.notion.so/team/Tasks-abc123def456789012345678901234ab?v=..."+notionId :: Maybe UUID+notionId = extractNotionId "https://www.notion.so/team/Tasks-abc123def456789012345678901234ab?v=..." -- Just (UUID "abc123de-f456-7890-1234-5678901234ab")-extractBlockId "https://www.notion.so/Page-0123456789abcdef0123456789abcdef#block-fedcba9876543210fedcba9876543210"+blockId :: Maybe UUID+blockId = extractBlockId "https://www.notion.so/Page-0123456789abcdef0123456789abcdef#block-fedcba9876543210fedcba9876543210" -- Just (UUID "fedcba98-7654-3210-fedc-ba9876543210") ``` @@ -236,14 +346,15 @@  ```haskell import Notion.V1.Pagination (paginateAll)-import Notion.V1.DataSources (QueryDataSource(..))+import Data.Vector (Vector)+import Notion.V1 (Methods(..))+import Notion.V1.DataSources (DataSourceID, QueryDataSource(..), _QueryDataSource)+import Notion.V1.Search (PageOrDataSource) -allPages <- paginateAll $ \cursor ->-    queryDataSource methods dsId QueryDataSource-        { filter = Nothing, sorts = Nothing-        , startCursor = cursor, pageSize = Just 100-        , inTrash = Nothing, filterProperties = Nothing-        }+allPages :: Methods -> DataSourceID -> IO (Vector PageOrDataSource)+allPages methods dsId = paginateAll $ \cursor ->+    queryDataSource methods dsId+        _QueryDataSource {startCursor = cursor, pageSize = Just 100} ```  `paginateFoldM` and `paginateForM_` process every item while holding only one@@ -254,7 +365,7 @@ Callers that use the [`effectful`](https://hackage.haskell.org/package/effectful) effect system can opt into an `Eff`-typed surface via the companion package-[`notion-client-effectful`](./notion-client-effectful/). Every+[`notion-client-effectful`](https://hackage.haskell.org/package/notion-client-effectful). Every `Notion.V1.Methods` field is re-exposed as a smart constructor of a `Notion` effect, and a `runNotion` interpreter dispatches through a concrete `Methods` value. `NotionError` responses surface via the@@ -289,7 +400,7 @@   print result ``` -See [`notion-client-effectful/README.md`](./notion-client-effectful/README.md)+See the [companion README](https://github.com/shinzui/notion-client/tree/master/notion-client-effectful#readme) for the full import pattern (qualifying one of the two `Notion.V1` modules is required — every smart constructor shares its name with the matching `Methods` record selector).@@ -298,15 +409,18 @@  - **Databases**: Create, retrieve, update, and query databases - **Data Sources**: Create, retrieve, update, query data sources; list templates-- **Pages**: Create (with blocks or markdown), retrieve, update, move pages; retrieve and update page markdown+- **Pages**: Create (with blocks or markdown), retrieve, update, move pages; retrieve and update page markdown, including background operations - **Blocks**: Retrieve, update, append children (with position control), and delete blocks - **Users**: List, retrieve users and bot users - **Views**: Create, retrieve, update, delete, list, and query database views (all 10 view types) - **Search**: Search for pages and data sources-- **Comments**: Create and list comments+- **Comments**: Create, retrieve, update, delete and list comments+- **File Uploads**: Create, upload content, complete, retrieve and list uploads+- **Meeting Notes**: Create from recordings and query meeting notes+- **Async Tasks**: Retrieve status and wait for background operations - **Custom Emojis**: List workspace custom emojis - **Webhooks**: Event types (including view events) and signature verification-- **OAuth**: Exchange authorization codes and refresh tokens, revoke and introspect tokens+- **OAuth**: Browser login helpers, verified callbacks, automatic token refresh with persistence, revoke and introspect tokens  ## Running the Example @@ -326,17 +440,115 @@ cabal run notion-client-example ``` -### Obtaining API Credentials+### OAuth example -1. Create a Notion integration at [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations)-2. Get your integration token from the integration settings-3. Share any Notion pages or databases you want to access with your integration-   - Open the page/database in Notion-   - Click "Share" in the top right-   - Enter your integration name and click "Invite"+The POSIX-only `notion-client-oauth-example` runs a trusted local confidential+client. In the [developer portal](https://www.notion.so/profile/integrations),+choose **Build → Public connections**, create a test connection, and enable the+capabilities your application needs. Register `http://localhost:8765/callback`+under its OAuth configuration, then copy its OAuth client ID and secret from the+**Configuration** tab. See Notion's [public-connection setup guide](https://developers.notion.com/guides/get-started/public-connections).+Set the credentials in your shell (never commit them):++```bash+export NOTION_OAUTH_CLIENT_ID=...+export NOTION_OAUTH_CLIENT_SECRET=...+cabal run notion-client-oauth-example -- login+cabal run notion-client-oauth-example -- whoami+cabal run notion-client-oauth-example -- refresh+cabal run notion-client-oauth-example -- whoami+cabal run notion-client-oauth-example -- revoke+```++For a local checkout using direnv, the same exports can live in gitignored+`.envrc.local`, which `.envrc` loads after the development shell. The executable+reads environment variables; it does not load dotenv files itself.++If port 8765 is occupied, register another exact callback URI and set both+`NOTION_OAUTH_REDIRECT_URI=http://localhost:8767/callback` and+`NOTION_OAUTH_LISTEN_PORT=8767`. A real public integration accepted this localhost+HTTP callback during live validation on 2026-10-19. Always open the authorization+URL printed by `login`; visiting `/callback` directly returns “Invalid callback”.++`login` prints a consent URL after the listener is ready on `127.0.0.1`, validates+state, consumes the callback once, exchanges the code and saves credentials before+reporting success. It expires after ten minutes. Invalid callbacks leave it pending;+a matching-state cancellation reports cancellation without exchanging. `whoami`+prints the bot name and up to five shared results; an empty search is valid.+`refresh` prints full-value rotation comparisons as booleans and offers persistence+retry without sending a second grant. `revoke` removes local credentials only after+confirmed remote success. Subsequent commands require login; no remote token-family+invalidation is assumed.++`NOTION_OAUTH_TOKEN_FILE` defaults to `.notion-oauth-tokens.json`. The example requires+an owner-only regular token file in a directory owned by you and not writable by+group or others. It rejects token symlinks, creates private temporary files before+writing, synchronizes and atomically replaces them, and synchronizes the parent+directory. Every command holds `<token-file>.lock` across load/refresh/save. After+a crash, remove a stale lock directory manually only after checking no example+process is active. Keep the process alive to retry an unsaved pair; stopping it can+require another login. This file store is an example, not a production secret store.++If the portal refuses HTTP localhost redirects, register an HTTPS tunnel's exact+callback URL and set `NOTION_OAUTH_REDIRECT_URI` to it. Set+`NOTION_OAUTH_LISTEN_PORT` to its local target port (default `8765`). Redirect URIs+must have an absolute path and no query or fragment. The listener still binds+loopback. No framework adapter, PKCE, timer refresh, distributed store or Notion+MCP OAuth is provided. The executable and its POSIX file tests are disabled on+Windows; the core library and ordinary HTTP tests remain portable.++### Internal connection credentials++These credentials are for `notion-client-example`; the OAuth example above uses+a public connection's client ID and secret instead.++1. Create an internal connection in the [developer portal](https://www.notion.so/profile/integrations).+2. Copy its API token from **Configuration** and set `NOTION_TOKEN`.+3. Share the pages or databases you want to access: open the page's **•••** menu,+   choose **Add connections**, and select your connection. See the+   [authorization guide](https://developers.notion.com/guides/get-started/authorization). 4. Get the page/database IDs from their URLs:    - Page URL: `https://www.notion.so/Your-Page-Title-83715d7c1111424aaa11d7fc1111bd2a`    - Page ID: `83715d7c1111424aaa11d7fc1111bd2a` (the last part of the URL)++## Release preparation++The release pair is `notion-client-0.10.0.0` and+`notion-client-effectful-0.4.0.0`, targeting Notion API `2026-03-11` and validated+with GHC 9.12.4. Keep both Cabal versions, the companion's core bound and both+changelogs aligned. Run from the repository root:++```bash+cabal build all+env -u NOTION_TOKEN cabal test all --test-show-details=direct+cabal haddock all+cabal check+(cd notion-client-effectful && cabal check)+cabal sdist all+```++The offline suites currently contain 462 core tests and four companion runtime+tests. The separate live OAuth gate passed login, user-owner decoding, explicit+and reactive rotation, persisted restart, cancellation and revoke/local logout;+its evidence is in [plan 19](https://github.com/shinzui/notion-client/blob/master/docs/plans/19-demonstrate-and-document-the-public-integration-oauth-login-end-to-end.md).+After OAuth runtime changes, repeat that sequence with a disposable test+installation. The explicit automatic-refresh harness is:++```bash+cabal exec -- runghc -package=notion-client -XGHC2024 -XOverloadedStrings -XDuplicateRecordFields -XRecordWildCards -inotion-client-oauth-example scripts/OAuthLiveCheck.hs+```++Run it after login/explicit refresh and before revoke. It changes only the+in-memory access token, uses the current saved refresh token, and durably saves+the replacement. It is excluded from offline tests.++Inspect both archives under `dist-newstyle/sdist/` before upload. Each must contain+its README, changelog, license and all sources required by its tests and examples;+local environment and token files must be absent. Build and test both extracted+archives together in a fresh Cabal project to catch dependencies on the checkout.+Publish the core package before its companion so the companion's bound resolves.+These preparation commands generate local artifacts; uploading, tagging and+publishing are separate maintainer actions.  ## License 
+ notion-client-oauth-example/FileTokenStore.hs view
@@ -0,0 +1,103 @@+-- | 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 ()
+ notion-client-oauth-example/Login.hs view
@@ -0,0 +1,136 @@+-- | A single local login session, with ten-minute expiry and atomic single use.+module Login+  ( LoginResult (..),+    newLoginApplication,+    runLogin,+    callbackPath,+  )+where++import Control.Concurrent+import Control.Exception+import Control.Monad (void)+import Data.ByteString (ByteString)+import Data.ByteString.Char8 qualified as B8+import Data.ByteString.Lazy qualified as L+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Data.Time.Clock+import Network.HTTP.Types+import Network.Wai qualified as W+import Network.Wai.Handler.Warp qualified as Warp+import Notion.V1.OAuth (OAuthErrorCode (OAuthAccessDenied), OAuthMethods)+import Notion.V1.OAuth.Authorization+import Notion.V1.OAuth.Refresh+import System.Timeout qualified as Timeout++-- Secrets are retained only for explicit save recovery; Show is redacted.+data LoginResult = Connected OAuthTokens | SavePending OAuthTokens | Cancelled | LoginFailed | LoginExpired++instance Show LoginResult where+  show (Connected _) = "Connected"+  show (SavePending _) = "SavePending"+  show Cancelled = "Cancelled"+  show LoginFailed = "LoginFailed"+  show LoginExpired = "LoginExpired"++-- | Validate an absolute HTTP(S) redirect and use its exact encoded path.+-- No query, fragment or user-info is accepted. The exact URI still goes to Notion.+callbackPath :: Text -> Either Text ByteString+callbackPath uri = do+  rest <-+    maybe+      (Left "Redirect URI must be absolute HTTP(S)")+      Right+      (T.stripPrefix "http://" uri `orElse` T.stripPrefix "https://" uri)+  let (authority, path) = T.breakOn "/" rest+  if T.null authority+    || T.null path+    || T.any (`elem` ("?#@\\" :: String)) authority+    || T.any (`elem` ("?#\\" :: String)) path+    || T.any (<= ' ') uri+    then Left "Redirect URI needs an absolute path and no query or fragment"+    else Right (T.encodeUtf8 path)+  where+    orElse (Just a) _ = Just a+    orElse Nothing b = b++-- | Inject methods/store for network-free callback tests. Invalid requests leave+-- the login pending. A valid success or denial claims it once before exchange.+-- The caller keeps the application lifetime within the expiry interval.+newLoginApplication ::+  OAuthMethods ->+  TokenStore ->+  AuthorizationRequest ->+  ByteString ->+  IO (W.Application, IO LoginResult)+newLoginApplication oauth store request path = do+  claim <- newMVar False+  completed <- newEmptyMVar+  let complete result = void (tryPutMVar completed result)+      application req respond+        | W.requestMethod req /= methodGet || W.rawPathInfo req /= path = respond (page status404 "Not found")+        | otherwise = case verifyAuthorizationCallback (state request) (W.queryString req) of+            Left StateMismatch -> respond (page status400 "Invalid callback")+            Left MissingCode -> respond (page status400 "Invalid callback")+            Left MalformedCallback -> respond (page status400 "Invalid callback")+            verified -> mask $ \restore -> do+              already <- modifyMVar claim (\used -> pure (True, used))+              if already+                then respond (page status409 "Callback already used")+                else case verified of+                  Left (AuthorizationDenied OAuthAccessDenied _) -> do+                    response <- respond (page status200 "Consent was cancelled") `finally` complete Cancelled+                    pure response+                  Left (AuthorizationDenied _ _) -> do+                    response <- respond (page status400 "Authorization failed") `finally` complete LoginFailed+                    pure response+                  Right code -> do+                    exchange <- try @SomeException (restore (exchangeAuthorizationCode oauth request code))+                    case exchange of+                      Left _ -> do+                        response <- respond (page status500 "Connection failed; start login again") `finally` complete LoginFailed+                        pure response+                      Right tokenResponse -> do+                        now <- getCurrentTime+                        let tokens = oauthTokensFromResponse now tokenResponse+                        saved <- try @SomeException (restore (saveTokens store tokens))+                        case saved of+                          Left _ -> do+                            response <- respond (page status500 "Credentials received but storage failed; return to the console") `finally` complete (SavePending tokens)+                            pure response+                          Right () -> do+                            response <- respond (page status200 "Connected") `finally` complete (Connected tokens)+                            pure response+  pure (application, takeMVar completed)++page :: Status -> B8.ByteString -> W.Response+page status message =+  W.responseLBS+    status+    [(hContentType, "text/plain; charset=utf-8"), (hCacheControl, "no-store"), ("Referrer-Policy", "no-referrer")]+    (L.fromStrict message)++-- | Bind explicitly to loopback, propagate startup errors, announce only after+-- readiness, and bracket listener lifetime. Timeout also covers exchange/save.+-- A pending initial save can be retried by the caller using the retained pair.+runLogin :: Int -> Int -> OAuthMethods -> TokenStore -> AuthorizationRequest -> IO () -> IO LoginResult+runLogin port timeoutMicros oauth store request announce = do+  path <- either (ioError . userError . T.unpack) pure (callbackPath (redirectUri request))+  (application, awaitResult) <- newLoginApplication oauth store request path+  ready <- newEmptyMVar+  stopped <- newEmptyMVar+  let settings =+        Warp.setHost "127.0.0.1" $+          Warp.setPort port $+            Warp.setBeforeMainLoop (void (tryPutMVar ready (Right ()))) $+              Warp.setOnException (\_ _ -> pure ()) Warp.defaultSettings+      start = forkFinally (Warp.runSettings settings application) $ \outcome -> do+        void (tryPutMVar ready outcome)+        putMVar stopped outcome+      stop tid = killThread tid >> takeMVar stopped >> pure ()+  bracket start stop $ \_ -> do+    takeMVar ready >>= either throwIO pure+    announce+    Timeout.timeout timeoutMicros awaitResult >>= maybe (pure LoginExpired) pure
+ notion-client-oauth-example/Main.hs view
@@ -0,0 +1,161 @@+-- | 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)
notion-client.cabal view
@@ -1,12 +1,16 @@ cabal-version: 3.4 name: notion-client-version: 0.9.0.0+version: 0.10.0.0 synopsis: Type-safe Haskell client for the Notion API description:   This package provides comprehensive and type-safe bindings   to the Notion API, providing both a Servant interface and   non-Servant interface for convenience.   .+  Includes browser OAuth authorization helpers, typed OAuth errors,+  per-request token sources and automatic refresh with caller-supplied+  persistence. A POSIX example demonstrates the complete login flow.+  .   Read the @README@ below for a fully worked usage example.   .   Otherwise, browse the "Notion.V1" module, which is the@@ -20,12 +24,14 @@ homepage: https://github.com/shinzui/notion-client bug-reports: https://github.com/shinzui/notion-client/issues build-type: Simple-tested-with: ghc ==9.12.2+tested-with: ghc ==9.12.4 extra-doc-files:   CHANGELOG.md   README.md -extra-source-files: LICENSE+extra-source-files:+  LICENSE+  scripts/OAuthLiveCheck.hs  source-repository head   type: git@@ -42,11 +48,13 @@     bytestring >=0.11 && <0.13,     containers >=0.6 && <0.8,     cryptohash-sha256 >=0.11 && <0.12,+    entropy >=0.4.1.11 && <0.5,     filepath >=1.4 && <1.6,     http-api-data >=0.6 && <0.7,     http-client >=0.7.16 && <0.8,     http-client-tls >=0.3 && <0.4,     http-types >=0.12 && <0.13,+    memory >=0.18 && <0.19,     mtl >=2.2 && <2.4,     random >=1.2 && <1.4,     scientific >=0.3 && <0.4,@@ -81,6 +89,8 @@     Notion.V1.ListOf     Notion.V1.MeetingNotes     Notion.V1.OAuth+    Notion.V1.OAuth.Authorization+    Notion.V1.OAuth.Refresh     Notion.V1.Pages     Notion.V1.Pagination     Notion.V1.Properties@@ -88,6 +98,7 @@     Notion.V1.Retry     Notion.V1.RichText     Notion.V1.Search+    Notion.V1.TokenSource     Notion.V1.Users     Notion.V1.ViewConfig     Notion.V1.ViewQueries@@ -114,15 +125,20 @@   main-is: Main.hs   other-modules:     AsyncTaskTests+    AuthorizationTests     CommentTests     DataSourceSearchTests     FakeNotion     HelpersTests     MeetingNotesTests+    OAuthExamplePlatform     OAuthTests     ObjectFieldTests     ResponseJSONTests     RuntimeTests+    TokenHTTPTests+    TokenRefreshTests+    TokenSourceTests     ViewTests     WireFormatTests @@ -137,6 +153,7 @@     base,     bytestring,     containers,+    directory,     http-api-data,     http-client,     http-client-tls,@@ -151,9 +168,24 @@     text,     time,     vector,+    wai >=3.2.4 && <3.3,+    warp >=3.4.13 && <3.5, -  ghc-options: -Wall+  ghc-options:+    -Wall+    -threaded +  if !os(windows)+    hs-source-dirs: notion-client-oauth-example+    other-modules:+      FileTokenStore+      Login+      OAuthExampleTests++    build-depends:+      filepath,+      unix >=2.8 && <2.9,+ executable notion-client-example   default-language: GHC2024   hs-source-dirs: notion-client-example@@ -188,3 +220,40 @@     vector,    ghc-options: -Wall++executable notion-client-oauth-example+  default-language: GHC2024+  hs-source-dirs: notion-client-oauth-example+  main-is: Main.hs+  other-modules:+    FileTokenStore+    Login++  default-extensions:+    DuplicateRecordFields+    OverloadedStrings+    RecordWildCards++  ghc-options:+    -Wall+    -threaded++  if os(windows)+    buildable: False+  else+    build-depends:+      aeson,+      base,+      bytestring,+      directory >=1.3 && <1.4,+      filepath,+      http-client-tls,+      http-types,+      notion-client,+      servant-client,+      text,+      time,+      unix >=2.8 && <2.9,+      vector,+      wai >=3.2.4 && <3.3,+      warp >=3.4.13 && <3.5,
+ scripts/OAuthLiveCheck.hs view
@@ -0,0 +1,87 @@+{-# 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
src/Notion/V1.hs view
@@ -7,6 +7,7 @@ -- -- import Notion.V1 -- import Notion.V1.Pages+-- import Network.HTTP.Client.TLS (newTlsManager) -- import Data.Text qualified as Text -- import System.Environment qualified as Environment --@@ -28,6 +29,9 @@     makeMethods,     makeMethodsWith,     makeMethodsWithEnv,+    makeMethodsWithTokenSource,+    TokenSource (..),+    staticTokenSource,     Methods (..),      -- * Configuration@@ -46,6 +50,8 @@     -- * Runtime building blocks     RequestContext (..),     requestContextFor,+    requestContextWithTokenSource,+    withStandardHeaders,     standardHeaders,     responseTimeoutFor,     withRetries,@@ -79,11 +85,14 @@     legacyClientConfig,     noRetries,     requestContextFor,+    requestContextWithTokenSource,     responseTimeoutFor,     runClientWith,     standardHeaders,     stderrLogger,+    withAuthorizationHeader,     withRetries,+    withStandardHeaders,   ) import Notion.V1.Comments (CommentContent, CommentObject, CommentResponse) import Notion.V1.Comments qualified as Comments@@ -102,6 +111,7 @@ import Notion.V1.Pages qualified as Pages import Notion.V1.Search (SearchRequest) import Notion.V1.Search qualified as Search+import Notion.V1.TokenSource import Notion.V1.Users (UserID, UserObject) import Notion.V1.Users qualified as Users import Notion.V1.Views (ViewObject)@@ -146,10 +156,14 @@   -- | API token   Text ->   Methods-makeMethodsWithEnv config clientEnv token = Methods {..}+makeMethodsWithEnv config clientEnv token = makeMethodsWithTokenSource config clientEnv (staticTokenSource token)++-- | Keep Methods stable while reading a bearer token for each call. One typed+-- Unauthorized response can renew and replay the request once. Byte and+-- file-backed multipart bodies are replayable; arbitrary one-shot streams are not.+makeMethodsWithTokenSource :: ClientConfig -> ClientEnv -> TokenSource -> Methods+makeMethodsWithTokenSource config clientEnv source = Methods {..}   where-    context = requestContextFor config clientEnv token-    configuredEnv = configureClientEnv config clientEnv     ( ( createDatabase           :<|> retrieveDatabase           :<|> updateDatabase@@ -214,11 +228,12 @@           Proxy           run           (Client.client @API Proxy)-          (contextAuthorization context)+          "Bearer placeholder"           (notionVersion config)      run :: Client.ClientM a -> IO a-    run = runClientWith configuredEnv+    run clientM = withAuthorization source $ \auth ->+      runClientWith (configureClientEnv config (withAuthorizationHeader auth clientEnv)) clientM      -- Wrap retrievePageFiltered to provide backward-compatible retrievePage     retrievePage pid = retrievePageFiltered pid []
src/Notion/V1/Client.hs view
@@ -1,9 +1,11 @@ -- | Client runtime: configuration, timeout, retries and logging. -- -- 'Notion.V1.makeMethodsWith' builds 'Notion.V1.Methods' from a 'ClientConfig'.--- The building blocks below ('RequestContext', 'standardHeaders',--- 'responseTimeoutFor', 'withRetries') are exported so requests made outside--- Servant, such as streaming responses, behave exactly like 'Notion.V1.Methods'.+-- The building blocks below ('RequestContext', 'withStandardHeaders',+-- 'responseTimeoutFor', 'withRetries') let non-Servant requests share the runtime.+-- 'standardHeaders' reads current authorization in IO without renewal.+-- 'withStandardHeaders' can renew once around replayable response acquisition;+-- keep body consumption and user callbacks outside both retry wrappers. module Notion.V1.Client   ( -- * Configuration     ClientConfig (..),@@ -25,6 +27,9 @@     -- * Runtime building blocks     RequestContext (..),     requestContextFor,+    requestContextWithTokenSource,+    withStandardHeaders,+    withAuthorizationHeader,     standardHeaders,     configureClientEnv,     notionMiddleware,@@ -66,6 +71,7 @@     unknownResponseMessage,   ) import Notion.V1.Retry (RetryOptions (..), canRetry, defaultRetryOptions, noRetries, parseRetryAfter, retryDelay, validateRequestPath)+import Notion.V1.TokenSource import Paths_notion_client qualified import Servant.Client (BaseUrl (..), ClientEnv (..), ClientM, Scheme (..)) import Servant.Client qualified as Client@@ -155,28 +161,51 @@     contextManager :: HTTP.Manager,     -- | The effective base URL, for example @https://api.notion.com/v1@.     contextBaseUrl :: BaseUrl,-    -- | Full @Authorization@ header value, for example @Bearer secret_...@.-    contextAuthorization :: Text+    -- | Read afresh for every request; one source per installation.+    contextTokenSource :: TokenSource   }  -- | Build the context from the same inputs as 'Notion.V1.makeMethodsWithEnv': -- manager and base URL from the 'ClientEnv', and a bearer token. requestContextFor :: ClientConfig -> ClientEnv -> Text -> RequestContext-requestContextFor config ClientEnv {manager = m, baseUrl = b} token =-  RequestContext-    { contextConfig = config,-      contextManager = m,-      contextBaseUrl = b,-      contextAuthorization = "Bearer " <> token-    }+requestContextFor config env token = requestContextWithTokenSource config env (staticTokenSource token) --- | The @Authorization@, @Notion-Version@ and (when configured) @User-Agent@ headers.-standardHeaders :: RequestContext -> [Header]-standardHeaders RequestContext {contextConfig = ClientConfig {notionVersion, userAgent}, contextAuthorization} =-  [ ("Authorization", Text.encodeUtf8 contextAuthorization),-    ("Notion-Version", Text.encodeUtf8 notionVersion)-  ]+requestContextWithTokenSource :: ClientConfig -> ClientEnv -> TokenSource -> RequestContext+requestContextWithTokenSource config ClientEnv {manager = m, baseUrl = b} source =+  RequestContext config m b source++-- | Read the current token and attach standard headers, without renewal.+standardHeaders :: RequestContext -> IO [Header]+standardHeaders context@RequestContext {contextTokenSource} =+  headersWith context . ("Bearer " <>) <$> currentToken contextTokenSource++-- | Renew once on typed Unauthorized. Put normal retries inside the action.+-- Scope this to replayable request/response acquisition only. Never wrap stream+-- body consumption or a user callback, which must not be replayed.+withStandardHeaders :: RequestContext -> ([Header] -> IO a) -> IO a+withStandardHeaders context@RequestContext {contextTokenSource} action =+  withAuthorization contextTokenSource (action . headersWith context)++headersWith :: RequestContext -> Text -> [Header]+headersWith RequestContext {contextConfig = ClientConfig {notionVersion, userAgent}} auth =+  [("Authorization", Text.encodeUtf8 auth), ("Notion-Version", Text.encodeUtf8 notionVersion)]     <> userAgentHeader userAgent++-- | Replace authorization inside the runtime and outside caller middleware, so+-- each ordinary retry sees exactly one fresh header. OAuth Basic auth bypasses it.+withAuthorizationHeader :: Text -> ClientEnv -> ClientEnv+withAuthorizationHeader auth env =+  env+    { middleware = \app req ->+        middleware+          env+          app+          req+            { requestHeaders =+                Seq.singleton ("Authorization", Text.encodeUtf8 auth)+                  <> Seq.filter ((/= "Authorization") . fst) (requestHeaders req)+            }+    }  userAgentHeader :: Maybe Text -> [Header] userAgentHeader = maybe [] (\ua -> [("User-Agent", Text.encodeUtf8 ua)])
src/Notion/V1/OAuth.hs view
@@ -2,7 +2,8 @@ -- -- These endpoints authenticate with HTTP Basic auth using the integration's -- client ID and secret instead of a bearer token, so they have their own API--- type and methods record.+-- type and methods record. OAuth failures throw 'OAuthError'; requests are+-- never automatically retried, because grants may be consumed. -- -- @ -- manager <- newTlsManager@@ -30,6 +31,13 @@     OAuthRevokeResponse (..),     OAuthIntrospectResponse (..), +    -- * Errors+    OAuthErrorCode (..),+    oauthErrorCodeText,+    parseOAuthErrorCode,+    OAuthError (..),+    oauthErrorFromResponse,+     -- * Methods     OAuthMethods (..),     makeOAuthMethods,@@ -40,15 +48,21 @@   ) where +import Control.Applicative ((<|>))+import Control.Exception (Exception (..), catch, throwIO) import Data.Aeson ((.:), (.:?), (.=)) import Data.Aeson qualified as Aeson import Data.ByteString.Base64 qualified as Base64+import Data.List (find) import Data.Maybe (catMaybes) import Data.Proxy (Proxy (..))+import Data.Text qualified as T import Data.Text.Encoding qualified as Text import Notion.Prelude import Notion.V1.Client (ClientConfig (..), configureClientEnv, legacyClientConfig, runClientWith) import Notion.V1.Common (UUID)+import Notion.V1.Error qualified as Error+import Notion.V1.Retry (noRetries) import Servant.Client (ClientEnv) import Servant.Client qualified as Client import Prelude hiding (id)@@ -228,7 +242,107 @@       Client.hoistClient         @API         Proxy-        (runClientWith (configureClientEnv config env))+        (reinterpret . runClientWith (configureClientEnv safeConfig env))         (Client.client @API Proxy)         (basicAuthorization creds)         (notionVersion config)++    -- Only allow structured operational fields through the shared runtime logger.+    -- In particular, its debug response body and server-supplied message are secret-bearing.+    safeConfig = config {retryOptions = noRetries, logger = sanitize <$> logger config}+    sanitize write level _ fields =+      write level "OAuth request" (filter ((`elem` ["method", "path", "attempt", "delayMs", "code", "requestId"]) . fst) fields)++reinterpret :: IO a -> IO a+reinterpret action =+  action+    `catch` (\e@(Error.UnknownHTTPResponseError r) -> maybe (throwIO e) throwIO (oauthErrorFromResponse r))+    `catch` (\e@Error.NotionError {response} -> maybe (throwIO e) throwIO (response >>= oauthErrorFromResponse))++-- | OAuth token and authorization-redirect error codes (RFC 6749).+data OAuthErrorCode+  = OAuthInvalidRequest+  | OAuthInvalidClient+  | OAuthInvalidGrant+  | OAuthUnauthorizedClient+  | OAuthUnsupportedGrantType+  | OAuthInvalidScope+  | OAuthAccessDenied+  | OAuthUnsupportedResponseType+  | OAuthServerError+  | OAuthTemporarilyUnavailable+  | UnknownOAuthErrorCode Text+  deriving stock (Eq, Show)++knownOAuthCodes :: [(OAuthErrorCode, Text)]+knownOAuthCodes =+  [ (OAuthInvalidRequest, "invalid_request"),+    (OAuthInvalidClient, "invalid_client"),+    (OAuthInvalidGrant, "invalid_grant"),+    (OAuthUnauthorizedClient, "unauthorized_client"),+    (OAuthUnsupportedGrantType, "unsupported_grant_type"),+    (OAuthInvalidScope, "invalid_scope"),+    (OAuthAccessDenied, "access_denied"),+    (OAuthUnsupportedResponseType, "unsupported_response_type"),+    (OAuthServerError, "server_error"),+    (OAuthTemporarilyUnavailable, "temporarily_unavailable")+  ]++oauthErrorCodeText :: OAuthErrorCode -> Text+oauthErrorCodeText (UnknownOAuthErrorCode t) = t+oauthErrorCodeText c = maybe "" snd (find ((== c) . fst) knownOAuthCodes)++parseOAuthErrorCode :: Text -> OAuthErrorCode+parseOAuthErrorCode t = maybe (UnknownOAuthErrorCode t) fst (find ((== t) . snd) knownOAuthCodes)++instance FromJSON OAuthErrorCode where+  parseJSON = Aeson.withText "OAuthErrorCode" (pure . parseOAuthErrorCode)++instance ToJSON OAuthErrorCode where+  toJSON = String . oauthErrorCodeText++-- | Explicit metadata may contain secrets or untrusted server text. Default+-- rendering deliberately omits descriptions, bodies and headers.+data OAuthError = OAuthError+  { oauthErrorCode :: OAuthErrorCode,+    errorDescription :: Maybe Text,+    errorUri :: Maybe Text,+    requestId :: Maybe Text,+    response :: Maybe Error.HttpErrorResponse+  }+  deriving stock (Eq)++instance Show OAuthError where+  show OAuthError {oauthErrorCode, requestId, response} =+    "Notion OAuth request failed: "+      <> T.unpack (safeCode oauthErrorCode)+      <> maybe "" (\r -> " (HTTP " <> show (Error.httpStatus r) <> ")") response+      <> maybe "" (\r -> " [request " <> T.unpack r <> "]") requestId+    where+      safeCode (UnknownOAuthErrorCode _) = "unknown_oauth_error"+      safeCode c = oauthErrorCodeText c++instance Exception OAuthError where+  displayException = show++instance FromJSON OAuthError where+  parseJSON = Aeson.withObject "OAuthError" $ \o -> do+    c <- o .: "error"+    if T.null c+      then fail "Empty OAuth error"+      else+        OAuthError (parseOAuthErrorCode c) <$> o .:? "error_description" <*> o .:? "error_uri" <*> o .:? "request_id" <*> pure Nothing++-- | Recognize either OAuth's envelope or a known OAuth code in Notion's+-- documented envelope, only on OAuth routes.+oauthErrorFromResponse :: Error.HttpErrorResponse -> Maybe OAuthError+oauthErrorFromResponse r = do+  e@OAuthError {requestId} <- Aeson.decode (Error.errorBody r) <|> alternate+  pure e {response = Just r, requestId = requestId <|> Error.notionRequestId r}+  where+    alternate = do+      Error.NotionError {code, message, requestId} <- Aeson.decode (Error.errorBody r)+      let c = parseOAuthErrorCode (Error.apiErrorCodeText code)+      case c of+        UnknownOAuthErrorCode _ -> Nothing+        _ -> Just (OAuthError c (Just message) Nothing requestId Nothing)
+ src/Notion/V1/OAuth/Authorization.hs view
@@ -0,0 +1,122 @@+-- | Protocol helpers for confidential-client OAuth login. The application must+-- bind each request to its initiating session, expire it, and atomically consume+-- it once on verified success or denial, before exchanging the code. This pure+-- verifier does not provide session storage, expiry or replay protection.+module Notion.V1.OAuth.Authorization+  ( OAuthState (..),+    newOAuthState,+    sameOAuthState,+    AuthorizationRequest (..),+    authorizationUrl,+    authorizationUrlWith,+    OAuthCode (..),+    AuthorizationCallbackError (..),+    verifyAuthorizationCallback,+    authorizationCodeGrant,+    exchangeAuthorizationCode,+  )+where++import Control.Exception (Exception)+import Control.Monad (forM, when)+import Data.ByteArray (constEq)+import Data.ByteString.Base64.URL qualified as Base64+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Network.HTTP.Types.URI (Query, renderQuery)+import Notion.V1.Client (defaultBaseUrl)+import Notion.V1.OAuth (AuthorizationCodeGrant (..), OAuthErrorCode (..), OAuthMethods (..), OAuthTokenRequest (..), OAuthTokenResponse, parseOAuthErrorCode)+import Servant.Client (BaseUrl, showBaseUrl)+import System.Entropy (getEntropy)++-- | Serialize explicitly with 'oauthStateText'; default rendering is redacted.+newtype OAuthState = OAuthState {oauthStateText :: Text}++instance Show OAuthState where show _ = "OAuthState <redacted>"++-- | 32 bytes of operating-system entropy, encoded as 43 URL-safe characters.+newOAuthState :: IO OAuthState+newOAuthState = OAuthState . T.decodeUtf8 . Base64.encodeUnpadded <$> getEntropy 32++-- | Constant-time byte comparison for equal-length values.+sameOAuthState :: OAuthState -> OAuthState -> Bool+sameOAuthState (OAuthState a) (OAuthState b) = constEq (T.encodeUtf8 a) (T.encodeUtf8 b)++data AuthorizationRequest = AuthorizationRequest+  {clientId :: Text, redirectUri :: Text, state :: OAuthState}++-- | Build the consent URL. The redirect URI must exactly match registration.+authorizationUrl :: AuthorizationRequest -> Text+authorizationUrl = authorizationUrlWith defaultBaseUrl++authorizationUrlWith :: BaseUrl -> AuthorizationRequest -> Text+authorizationUrlWith base AuthorizationRequest {clientId, redirectUri, state} =+  T.pack (showBaseUrl base)+    <> "/oauth/authorize"+    <> T.decodeUtf8+      ( renderQuery+          True+          [ ("client_id", Just (T.encodeUtf8 clientId)),+            ("response_type", Just "code"),+            ("owner", Just "user"),+            ("redirect_uri", Just (T.encodeUtf8 redirectUri)),+            ("state", Just (T.encodeUtf8 (oauthStateText state)))+          ]+      )++-- | A convenience wrapper, not proof that a caller verified the callback.+newtype OAuthCode = OAuthCode {oauthCodeText :: Text} deriving stock (Eq)++instance Show OAuthCode where show _ = "OAuthCode <redacted>"++data AuthorizationCallbackError+  = AuthorizationDenied OAuthErrorCode (Maybe Text)+  | StateMismatch+  | MissingCode+  | MalformedCallback+  deriving stock (Eq)++instance Show AuthorizationCallbackError where+  show (AuthorizationDenied code _) =+    "AuthorizationDenied " <> case code of+      UnknownOAuthErrorCode _ -> "<unknown code>"+      _ -> show code+  show StateMismatch = "StateMismatch"+  show MissingCode = "MissingCode"+  show MalformedCallback = "MalformedCallback"++instance Exception AuthorizationCallbackError++-- | Input values are already percent-decoded by WAI or parseQuery. Unknown+-- fields are ignored. Descriptions are untrusted display data.+verifyAuthorizationCallback :: OAuthState -> Query -> Either AuthorizationCallbackError OAuthCode+verifyAuthorizationCallback expected query = do+  fields <- forM ["state", "code", "error", "error_description", "error_uri"] $ \key -> do+    value <- case [v | (k, v) <- query, k == key] of+      [] -> Right Nothing+      [Just bytes] -> either (const (Left MalformedCallback)) (Right . Just) (T.decodeUtf8' bytes)+      _ -> Left MalformedCallback+    pure (key, value)+  let get key = lookup key fields >>= id+      returnedState = get "state"+      code = get "code"+      err = get "error"+  when (code /= Nothing && err /= Nothing) (Left MalformedCallback)+  case returnedState of+    Just s | not (T.null s), not (T.null (oauthStateText expected)), sameOAuthState expected (OAuthState s) -> pure ()+    _ -> Left StateMismatch+  case err of+    Just "" -> Left MalformedCallback+    Just e -> Left (AuthorizationDenied (parseOAuthErrorCode e) (get "error_description"))+    Nothing -> case code of+      Just c | not (T.null c) -> Right (OAuthCode c)+      _ -> Left MissingCode++-- | Sends the exact redirect URI from the initiating request.+authorizationCodeGrant :: AuthorizationRequest -> OAuthCode -> OAuthTokenRequest+authorizationCodeGrant AuthorizationRequest {redirectUri} (OAuthCode code) =+  AuthorizationCode AuthorizationCodeGrant {code, redirectUri = Just redirectUri, externalAccount = Nothing}++exchangeAuthorizationCode :: OAuthMethods -> AuthorizationRequest -> OAuthCode -> IO OAuthTokenResponse+exchangeAuthorizationCode oauth req = createOAuthToken oauth . authorizationCodeGrant req
+ src/Notion/V1/OAuth/Refresh.hs view
@@ -0,0 +1,187 @@+-- | Rotation-safe token management for one installation in one process.+-- Use exactly one manager per installation. Separate managers/processes must+-- coordinate the whole load/refresh/save transaction externally. A save-time+-- compare-and-swap cannot prevent competing remote refreshes. Process crashes+-- between remote rotation and durable save may require fresh authorization.+module Notion.V1.OAuth.Refresh+  ( OAuthTokens (..),+    oauthTokensFromResponse,+    TokenStore (..),+    inMemoryTokenStore,+    ReauthorizationRequired (..),+    TokenPersistenceError,+    RefreshOutcomeUnknown,+    OAuthTokenManager,+    newOAuthTokenManager,+    tokenManagerSource,+    currentTokens,+    refreshTokensNow,+    retryTokenPersistence,+  )+where++import Control.Concurrent.MVar+import Control.Exception+import Data.IORef+import Data.Text qualified as T+import Data.Time.Clock+import Notion.Prelude+import Notion.V1.OAuth qualified as O+import Notion.V1.TokenSource++-- | Persisted credentials. Explicit JSON contains secrets; Show never does.+data OAuthTokens = OAuthTokens+  { accessToken :: Text,+    refreshToken :: Maybe Text,+    botId :: Text,+    workspaceId :: Text,+    workspaceName :: Maybe Text,+    obtainedAt :: UTCTime+  }+  deriving stock (Eq, Generic)++instance Show OAuthTokens where show _ = "OAuthTokens <redacted>"++instance ToJSON OAuthTokens where toJSON = genericToJSON aesonOptions++instance FromJSON OAuthTokens where parseJSON = genericParseJSON aesonOptions++oauthTokensFromResponse :: UTCTime -> O.OAuthTokenResponse -> OAuthTokens+oauthTokensFromResponse obtainedAt O.OAuthTokenResponse {accessToken, refreshToken, botId, workspaceId, workspaceName} = OAuthTokens {..}++-- | Save must be idempotent for the same pair, meet the application's durability+-- contract before returning, and never call back into its manager.+data TokenStore = TokenStore {loadTokens :: IO OAuthTokens, saveTokens :: OAuthTokens -> IO ()}++inMemoryTokenStore :: OAuthTokens -> IO TokenStore+inMemoryTokenStore tokens = do+  ref <- newIORef tokens+  pure (TokenStore (readIORef ref) (atomicWriteIORef ref))++newtype ReauthorizationRequired = ReauthorizationRequired {reauthorizationCause :: Maybe O.OAuthError}+  deriving stock (Show)++instance Exception ReauthorizationRequired++data TokenPersistenceError = TokenPersistenceError deriving stock (Eq, Show)++instance Exception TokenPersistenceError where+  displayException _ = "OAuth token persistence failed; retry saving the retained pair"++data RefreshOutcomeUnknown = RefreshOutcomeUnknown deriving stock (Eq, Show)++instance Exception RefreshOutcomeUnknown where+  displayException _ = "OAuth refresh outcome unknown; authorize again without replaying the refresh token"++-- Every state retains the last known pair for explicit recovery.+data Lifecycle+  = Ready OAuthTokens+  | PendingPersistence OAuthTokens+  | NeedsReauthorization OAuthTokens (Maybe O.OAuthError)+  | UncertainOutcome OAuthTokens++data OAuthTokenManager = OAuthTokenManager (MVar Lifecycle) O.OAuthMethods TokenStore++newOAuthTokenManager :: O.OAuthMethods -> TokenStore -> IO OAuthTokenManager+newOAuthTokenManager oauth store = do+  tokens <- loadTokens store+  OAuthTokenManager <$> newMVar (Ready tokens) <*> pure oauth <*> pure store++knownTokens :: Lifecycle -> OAuthTokens+knownTokens = \case+  Ready t -> t+  PendingPersistence t -> t+  NeedsReauthorization t _ -> t+  UncertainOutcome t -> t++-- | Inspection/recovery only. This may return an unusable or unsaved pair.+-- Authorize requests through 'tokenManagerSource', which enforces lifecycle.+currentTokens :: OAuthTokenManager -> IO OAuthTokens+currentTokens (OAuthTokenManager lock _ _) = knownTokens <$> readMVar lock++usable :: Lifecycle -> Either SomeException OAuthTokens+usable = \case+  Ready t -> Right t+  PendingPersistence _ -> Left (toException TokenPersistenceError)+  NeedsReauthorization _ cause -> Left (toException (ReauthorizationRequired cause))+  UncertainOutcome _ -> Left (toException RefreshOutcomeUnknown)++tokenManagerSource :: OAuthTokenManager -> TokenSource+tokenManagerSource manager@(OAuthTokenManager lock _ _) =+  TokenSource+    { currentToken = do+        state <- readMVar lock+        accessToken <$> either throwIO pure (usable state),+      renewToken = \rejected ->+        Just . accessToken+          <$> transition+            manager+            ( \restore state ->+                case usable state of+                  Left e -> pure (state, Left e)+                  Right tokens | accessToken tokens /= rejected -> pure (state, Right tokens)+                  Right tokens -> rotate manager restore tokens+            )+    }++-- The result is rethrown only after the new lifecycle has been published.+-- A surprise exception must never restore Ready with a possibly consumed grant.+transition ::+  OAuthTokenManager ->+  ((forall a. IO a -> IO a) -> Lifecycle -> IO (Lifecycle, Either SomeException b)) ->+  IO b+transition (OAuthTokenManager lock _ _) action = do+  result <- mask $ \restore -> do+    old <- takeMVar lock+    (new, outcome) <-+      action restore old `catch` \(e :: SomeException) ->+        pure (UncertainOutcome (knownTokens old), Left e)+    putMVar lock new+    pure outcome+  either throwIO pure result++rotate ::+  OAuthTokenManager ->+  (forall a. IO a -> IO a) ->+  OAuthTokens ->+  IO (Lifecycle, Either SomeException OAuthTokens)+rotate manager@(OAuthTokenManager _ oauth _) restore old = case refreshToken old of+  Nothing -> pure (NeedsReauthorization old Nothing, Left (toException (ReauthorizationRequired Nothing)))+  Just rt -> do+    response <- try @SomeException (restore (O.createOAuthToken oauth (O.RefreshToken rt)))+    case response of+      Left e+        | Just oe@O.OAuthError {oauthErrorCode = O.OAuthInvalidGrant} <- fromException e ->+            pure (NeedsReauthorization old (Just oe), Left (toException (ReauthorizationRequired (Just oe))))+      Left e | Just (_ :: O.OAuthError) <- fromException e -> pure (Ready old, Left e)+      Left e -> pure (UncertainOutcome old, Left e)+      Right r@O.OAuthTokenResponse {accessToken = access, tokenType, botId = bot, workspaceId = workspace}+        | T.null access || T.toLower tokenType /= "bearer" || bot /= botId old || workspace /= workspaceId old ->+            pure (UncertainOutcome old, Left (toException RefreshOutcomeUnknown))+        | otherwise -> do+            now <- getCurrentTime+            persist manager restore (oauthTokensFromResponse now r)++persist ::+  OAuthTokenManager ->+  (forall a. IO a -> IO a) ->+  OAuthTokens ->+  IO (Lifecycle, Either SomeException OAuthTokens)+persist (OAuthTokenManager _ _ store) restore tokens = do+  saved <- try @SomeException (restore (saveTokens store tokens))+  pure $ case saved of+    Right () -> (Ready tokens, Right tokens)+    Left _ -> (PendingPersistence tokens, Left (toException TokenPersistenceError))++-- | Explicit calls serialize, but each may rotate. Automatic rejections share+-- a completed rotation by comparing the rejected access token under the lock.+refreshTokensNow :: OAuthTokenManager -> IO OAuthTokens+refreshTokensNow manager = transition manager $ \restore state -> case usable state of+  Left e -> pure (state, Left e)+  Right t -> rotate manager restore t++-- | Save the identical retained pair without making another OAuth request.+retryTokenPersistence :: OAuthTokenManager -> IO OAuthTokens+retryTokenPersistence manager = transition manager $ \restore state -> case state of+  PendingPersistence t -> persist manager restore t+  _ -> pure (state, usable state)
src/Notion/V1/Pagination.hs view
@@ -54,13 +54,9 @@ -- -- @ -- allPages <- paginateAll $ \\cursor ->---   queryDataSource methods dsId QueryDataSource---     { filter = Nothing---     , sorts = Nothing---     , startCursor = cursor+--   queryDataSource methods dsId _QueryDataSource+--     { startCursor = cursor --     , pageSize = Just 100---     , inTrash = Nothing---     , filterProperties = Nothing --     } -- @ paginateAll :: (Maybe Text -> IO (ListOf a)) -> IO (Vector a)
+ src/Notion/V1/TokenSource.hs view
@@ -0,0 +1,30 @@+-- | Per-request bearer authorization and a single reactive renewal.+module Notion.V1.TokenSource (TokenSource (..), staticTokenSource, withAuthorization) where++import Control.Exception (throwIO, try)+import Data.Text (Text)+import Notion.V1.Error (APIErrorCode (Unauthorized), NotionError (..))++-- | One source per installation. Tokens exclude the @Bearer @ prefix.+data TokenSource = TokenSource+  { currentToken :: IO Text,+    -- | Renew after rejection, or return a token another request obtained.+    -- Nothing means no renewal is available. Exceptions propagate in IO.+    renewToken :: Text -> IO (Maybe Text)+  }++staticTokenSource :: Text -> TokenSource+staticTokenSource token = TokenSource (pure token) (const (pure Nothing))++-- | Replay once only on typed Unauthorized. The action must be replayable.+-- Scope this to request acquisition, never a stream body or user callback.+withAuthorization :: TokenSource -> (Text -> IO a) -> IO a+withAuthorization TokenSource {currentToken, renewToken} action = do+  token <- currentToken+  result <- try (action ("Bearer " <> token))+  case result of+    Right a -> pure a+    Left e@NotionError {code = Unauthorized} -> do+      replacement <- renewToken token+      maybe (throwIO e) (action . ("Bearer " <>)) replacement+    Left e -> throwIO e
+ tasty/AuthorizationTests.hs view
@@ -0,0 +1,73 @@+module AuthorizationTests (tests) where++import Data.Aeson qualified as A+import Data.ByteString qualified as BS+import Data.IORef (readIORef)+import Data.Text qualified as T+import FakeNotion+import Network.HTTP.Types.URI (parseQuery)+import Notion.V1 (defaultClientConfig)+import Notion.V1.OAuth+import Notion.V1.OAuth.Authorization+import OAuthTests (credentials, personOwner, tokenJson)+import Test.Tasty+import Test.Tasty.HUnit++tests :: TestTree+tests =+  testGroup+    "Authorization"+    [ testCase "authorization URL is exact" $+        authorizationUrl req @?= "https://api.notion.com/v1/oauth/authorize?client_id=client&response_type=code&owner=user&redirect_uri=http%3A%2F%2Flocalhost%3A8765%2Fcallback&state=s-123",+      testCase "secure state is 43 URL-safe characters and rendering redacts" $ do+        a <- newOAuthState+        b <- newOAuthState+        T.length (oauthStateText a) @?= 43+        assertBool "URL safe" (T.all (`elem` (['A' .. 'Z'] <> ['a' .. 'z'] <> ['0' .. '9'] <> "-_")) (oauthStateText a))+        assertBool "fresh" (not (sameOAuthState a b))+        show a @?= "OAuthState <redacted>"+        show (OAuthCode "secret") @?= "OAuthCode <redacted>"+        show (AuthorizationDenied (UnknownOAuthErrorCode "SECRET") (Just "SECRET")) @?= "AuthorizationDenied <unknown code>",+      testCase "constant-time primitive rejects differing bytes and lengths" $ do+        assertBool "equal" (sameOAuthState (OAuthState "aa") (OAuthState "aa"))+        mapM_ (\s -> assertBool "different" (not (sameOAuthState (OAuthState "aa") (OAuthState s)))) ["ab", "a", "bb"],+      testCase "callback success and one decoding pass" $ do+        verify "?code=c-1&state=s-123" @?= Right (OAuthCode "c-1")+        verify "?code=a%2Bb&state=s-123&unknown=x" @?= Right (OAuthCode "a+b"),+      testCase "denial requires state and preserves unknown codes" $ do+        verify "?error=access_denied&state=s-123" @?= Left (AuthorizationDenied OAuthAccessDenied Nothing)+        verify "?error=access_denied&state=s-123&error_description=User%20cancelled" @?= Left (AuthorizationDenied OAuthAccessDenied (Just "User cancelled"))+        verify "?error=slow_down&state=s-123" @?= Left (AuthorizationDenied (UnknownOAuthErrorCode "slow_down") Nothing)+        mapM_ (\q -> verify q @?= Left StateMismatch) ["?error=access_denied", "?error=access_denied&state=other", "?code=c-1", "?code=c-1&state=other"]+        verifyAuthorizationCallback (OAuthState "") (parseQuery "?code=c&state=") @?= Left StateMismatch,+      testCase "missing and empty code" $+        mapM_ (\q -> verify q @?= Left MissingCode) ["?state=s-123", "?code=&state=s-123"],+      testCase "malformed ambiguous callbacks rejected" $+        mapM_+          (\q -> verify q @?= Left MalformedCallback)+          [ "?code=a&code=a&state=s-123",+            "?state=s-123&state=s-123",+            "?error=x&error=x&state=s-123",+            "?code=c&error=x&state=s-123",+            "?code&state=s-123",+            "?state=s-123&error=",+            "?state=s-123&error_uri",+            "?state=s-123&code=%FF",+            "?state=s-123&error_description=%FF",+            "?state=s-123&error_uri=x&error_uri=x"+          ],+      testCase "code exchange includes redirect URI and Basic authentication" $ do+        A.toJSON (authorizationCodeGrant req (OAuthCode "c-1"))+          @?= A.object+            ["grant_type" A..= ("authorization_code" :: T.Text), "code" A..= ("c-1" :: T.Text), "redirect_uri" A..= ("http://localhost:8765/callback" :: T.Text)]+        (env, recorded) <- fakeClientEnv [jsonReply 200 (tokenJson personOwner)]+        _ <- exchangeAuthorizationCode (makeOAuthMethodsWith defaultClientConfig env credentials) req (OAuthCode "c-1")+        rs <- readIORef recorded+        map method rs @?= ["POST"]+        map path rs @?= ["/oauth/token"]+        map (lookupRecordedHeader "Authorization") rs @?= [Just "Basic Y2xpZW50OnNlY3JldA=="]+    ]+  where+    verify :: BS.ByteString -> Either AuthorizationCallbackError OAuthCode+    verify = verifyAuthorizationCallback (OAuthState "s-123") . parseQuery+    req = AuthorizationRequest "client" "http://localhost:8765/callback" (OAuthState "s-123")
tasty/FakeNotion.hs view
@@ -6,6 +6,7 @@   ( Recorded (..),     FakeReply (..),     fakeClientEnv,+    fakeClientEnvWith,     fakeBaseUrl,     jsonReply,     lookupRecordedHeader,@@ -19,7 +20,7 @@ import Data.ByteString.Builder (toLazyByteString) import Data.ByteString.Lazy qualified as LBS import Data.Foldable (toList)-import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef) import Network.HTTP.Client (defaultManagerSettings, newManager) import Network.HTTP.Types (Header, HeaderName, Method, http11, mkStatus) import Servant.Client (BaseUrl (..), ClientEnv (..), ClientError (..), Scheme (..), mkClientEnv)@@ -54,21 +55,22 @@ -- | A 'ClientEnv' answering from the script, and the requests it records. fakeClientEnv :: [FakeReply] -> IO (ClientEnv, IORef [Recorded]) fakeClientEnv script = do-  manager <- newManager defaultManagerSettings   remaining <- newIORef script+  fakeClientEnvWith $ \_ -> do+    next <- atomicModifyIORef' remaining (\case [] -> ([], Nothing); r : rs -> (rs, Just r))+    maybe (ioError (userError "FakeNotion: script exhausted")) pure next++-- | Header-dependent replies, with an atomic request recorder.+fakeClientEnvWith :: (Recorded -> IO FakeReply) -> IO (ClientEnv, IORef [Recorded])+fakeClientEnvWith reply = do+  manager <- newManager defaultManagerSettings   recorded <- newIORef []   let mw _realApp req = do-        liftIO $-          modifyIORef'-            recorded-            (<> [Recorded (requestMethod req) (toLazyByteString (requestPath req)) (toList (requestHeaders req))])-        next <- liftIO $ atomicModifyIORef' remaining (\case [] -> ([], Nothing); r : rs -> (rs, Just r))-        FakeReply {status, replyHeaders, body} <--          maybe (liftIO (ioError (userError "FakeNotion: script exhausted"))) pure next+        let request = Recorded (requestMethod req) (toLazyByteString (requestPath req)) (toList (requestHeaders req))+        liftIO $ atomicModifyIORef' recorded (\rs -> (rs <> [request], ()))+        FakeReply {status, replyHeaders, body} <- liftIO (reply request)         let resp = Response (mkStatus status "") (foldMap pure replyHeaders) http11 body         if status >= 200 && status < 300           then pure resp-          else-            throwError-              (FailureResponse (bimap (const ()) (\p -> (fakeBaseUrl, LBS.toStrict (toLazyByteString p))) req) resp)+          else throwError (FailureResponse (bimap (const ()) (\p -> (fakeBaseUrl, LBS.toStrict (toLazyByteString p))) req) resp)   pure ((mkClientEnv manager fakeBaseUrl) {middleware = mw}, recorded)
tasty/Main.hs view
@@ -1,6 +1,7 @@ module Main where  import AsyncTaskTests qualified+import AuthorizationTests qualified import CommentTests qualified import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key@@ -56,6 +57,7 @@ import Notion.V1.Users (BotUser (..), UserObject (..), WorkspaceLimits (..)) import Notion.V1.Views (Clearable (..), CreateView (..), UpdateView (..), ViewObject (..), ViewType (..)) import Notion.V1.Views qualified as Views+import OAuthExamplePlatform qualified import OAuthTests qualified import ObjectFieldTests qualified import ResponseJSONTests qualified@@ -63,6 +65,9 @@ import System.Environment qualified as Environment import Test.Tasty import Test.Tasty.HUnit+import TokenHTTPTests qualified+import TokenRefreshTests qualified+import TokenSourceTests qualified import ViewTests qualified import Web.HttpApiData (toQueryParam) import WireFormatTests qualified@@ -184,6 +189,11 @@         ResponseJSONTests.tests,         RuntimeTests.tests,         OAuthTests.tests,+        AuthorizationTests.tests,+        TokenSourceTests.tests,+        TokenRefreshTests.tests,+        TokenHTTPTests.tests,+        OAuthExamplePlatform.tests,         HelpersTests.tests,         ViewTests.tests,         DataSourceSearchTests.tests,
+ tasty/OAuthExamplePlatform.hs view
@@ -0,0 +1,18 @@+{-# LANGUAGE GHC2024 #-}+{-# LANGUAGE CPP #-}++module OAuthExamplePlatform (tests) where++#ifndef mingw32_HOST_OS+import OAuthExampleTests qualified+import Test.Tasty (TestTree)+#else+import Test.Tasty (TestTree, testGroup)+#endif++tests :: TestTree+#ifndef mingw32_HOST_OS+tests = OAuthExampleTests.tests+#else+tests = testGroup "OAuthExample (POSIX only)" []+#endif
+ tasty/OAuthExampleTests.hs view
@@ -0,0 +1,210 @@+{-# LANGUAGE GHC2024 #-}++module OAuthExampleTests (tests) where++import Control.Concurrent+import Control.Exception+import Data.Aeson qualified as A+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as L+import Data.IORef+import Data.Time.Clock+import FileTokenStore+import Login+import Network.HTTP.Client qualified as H+import Network.HTTP.Types qualified as T+import Network.Wai qualified as W+import Network.Wai.Handler.Warp qualified as Warp+import Notion.V1.OAuth qualified as O+import Notion.V1.OAuth.Authorization qualified as Auth+import Notion.V1.OAuth.Refresh qualified as R+import OAuthTests (personOwner, tokenJson)+import System.Directory qualified as D+import System.FilePath+import System.Posix.Files qualified as F+import System.Posix.Temp (mkdtemp)+import Test.Tasty+import Test.Tasty.HUnit+import TokenSourceTests (bounded)++request :: Auth.AuthorizationRequest+request = Auth.AuthorizationRequest "client" "http://localhost:8765/callback" (Auth.OAuthState "fixed-state")++fixture :: IO (R.OAuthTokens, O.OAuthTokenResponse)+fixture = do+  response <- either assertFailure pure (A.eitherDecode (tokenJson personOwner))+  now <- getCurrentTime+  pure (R.oauthTokensFromResponse now response, response)++oauth :: IO O.OAuthTokenResponse -> O.OAuthMethods+oauth action = O.OAuthMethods (const action) (const (pure (O.OAuthRevokeResponse Nothing))) (const (pure (O.OAuthIntrospectResponse False Nothing Nothing Nothing)))++get :: H.Manager -> Int -> String -> IO (H.Response L.ByteString)+get manager port query = H.parseRequest ("http://127.0.0.1:" <> show port <> query) >>= \r -> H.httpLbs r manager++withDirectory :: (FilePath -> IO a) -> IO a+withDirectory = bracket (mkdtemp "/tmp/notion-oauth-tests-") D.removePathForcibly++tests :: TestTree+tests =+  testGroup+    "OAuthExample"+    [ testCase "invalid callback leaves login pending; valid callback saves before success" $ bounded $ do+        (tokens, response) <- fixture+        store <- R.inMemoryTokenStore tokens+        calls <- newIORef (0 :: Int)+        let exchange = modifyIORef' calls (+ 1) >> pure response+        (app, result) <- newLoginApplication (oauth exchange) store request "/callback"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          bad <- get manager port "/callback?code=secret-code&state=forged"+          H.responseStatus bad @?= T.status400+          readIORef calls >>= (@?= 0)+          success <- get manager port "/callback?code=secret-code&state=fixed-state"+          H.responseStatus success @?= T.status200+          H.responseBody success @?= "Connected"+          lookup "Cache-Control" (H.responseHeaders success) @?= Just "no-store"+          lookup "Referrer-Policy" (H.responseHeaders success) @?= Just "no-referrer"+          result >>= \case Connected _ -> pure (); other -> assertFailure (show other)+          readIORef calls >>= (@?= 1)+          duplicate <- get manager port "/callback?code=secret-code&state=fixed-state"+          H.responseStatus duplicate @?= T.status409+          readIORef calls >>= (@?= 1),+      testCase "denial requires state, consumes once, and never exchanges" $ bounded $ do+        (tokens, _) <- fixture+        store <- R.inMemoryTokenStore tokens+        (app, result) <- newLoginApplication (oauth (assertFailure "must not exchange")) store request "/callback"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          bad <- get manager port "/callback?error=access_denied"+          H.responseStatus bad @?= T.status400+          cancelled <- get manager port "/callback?error=access_denied&state=fixed-state&error_description=%3Cscript%3Esecret%3C%2Fscript%3E"+          H.responseBody cancelled @?= "Consent was cancelled"+          result >>= \case Cancelled -> pure (); other -> assertFailure (show other)+          duplicate <- get manager port "/callback?error=access_denied&state=fixed-state"+          H.responseStatus duplicate @?= T.status409,+      testCase "concurrent duplicates exchange once; no success before save returns" $ bounded $ do+        (tokens, response) <- fixture+        calls <- newIORef (0 :: Int)+        saving <- newEmptyMVar+        release <- newEmptyMVar+        first <- newEmptyMVar+        let store = R.TokenStore (pure tokens) (\_ -> putMVar saving () >> takeMVar release)+        (app, _) <- newLoginApplication (oauth (modifyIORef' calls (+ 1) >> pure response)) store request "/callback"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          _ <- forkFinally (get manager port "/callback?code=one&state=fixed-state") (putMVar first)+          takeMVar saving+          isEmptyMVar first >>= assertBool "no response before persistence"+          duplicate <- get manager port "/callback?code=two&state=fixed-state"+          H.responseStatus duplicate @?= T.status409+          readIORef calls >>= (@?= 1)+          putMVar release ()+          takeMVar first >>= either (assertFailure . displayException) (\r -> H.responseBody r @?= "Connected"),+      testCase "initial save failure retains credentials for retry without another exchange" $ bounded $ do+        (tokens, response) <- fixture+        broken <- newIORef True+        saved <- newIORef []+        calls <- newIORef (0 :: Int)+        let store =+              R.TokenStore+                (pure tokens)+                ( \pair -> do+                    modifyIORef' saved (<> [pair])+                    readIORef broken >>= \b -> if b then ioError (userError "SECRET") else pure ()+                )+        (app, result) <- newLoginApplication (oauth (modifyIORef' calls (+ 1) >> pure response)) store request "/callback"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          failed <- get manager port "/callback?code=SECRET&state=fixed-state"+          H.responseStatus failed @?= T.status500+          assertBool "no reflected secret" (not ("SECRET" `BS.isInfixOf` L.toStrict (H.responseBody failed)))+          result >>= \case+            SavePending pair -> do+              show (SavePending pair) @?= "SavePending"+              writeIORef broken False+              R.saveTokens store pair+              readIORef saved >>= (@?= [pair, pair])+            other -> assertFailure (show other)+          readIORef calls >>= (@?= 1),+      testCase "exchange failures and unrelated paths produce generic pages" $ bounded $ do+        (tokens, _) <- fixture+        store <- R.inMemoryTokenStore tokens+        (app, result) <- newLoginApplication (oauth (ioError (userError "SECRET <script>"))) store request "/callback"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          notFound <- get manager port "/other?code=SECRET&state=fixed-state"+          H.responseStatus notFound @?= T.status404+          failed <- get manager port "/callback?code=SECRET&state=fixed-state"+          H.responseBody failed @?= "Connection failed; start login again"+          result >>= \case LoginFailed -> pure (); other -> assertFailure (show other),+      testCase "startup failure propagates before announcement; timeout closes listener" $ bounded $ do+        (tokens, response) <- fixture+        store <- R.inMemoryTokenStore tokens+        announced <- newIORef False+        let emptyApp _ respond = respond (W.responseLBS T.status200 [] "")+        Warp.testWithApplication (pure emptyApp) $ \busy -> do+          failure <- try @IOException (runLogin busy 20000 (oauth (pure response)) store request (writeIORef announced True))+          assertBool "bind failure" (either (const True) (const False) failure)+          readIORef announced >>= (@?= False)+        free <- Warp.testWithApplication (pure emptyApp) pure+        runLogin free 20000 (oauth (pure response)) store request (writeIORef announced True) >>= \case+          LoginExpired -> pure ()+          other -> assertFailure (show other)+        readIORef announced >>= (@?= True)+        -- Rebinding the same port after timeout proves the listener was released.+        runLogin free 1000 (oauth (pure response)) store request (pure ()) >>= \case+          LoginExpired -> pure ()+          other -> assertFailure (show other),+      testCase "redirect configuration requires a path without query or fragment" $ do+        Auth.oauthStateText (Auth.state request) @?= "fixed-state"+        callbackPath "http://localhost:8765/callback" @?= Right "/callback"+        callbackPath "https://tunnel.example/cb/path" @?= Right "/cb/path"+        mapM_ (\uri -> assertBool "rejected" (either (const True) (const False) (callbackPath uri))) ["/relative", "http://localhost", "http://localhost/cb?x=y", "http://localhost/cb#f", "http://user@localhost/cb"],+      testCase "file replacement is private complete and loads on restart" $ withDirectory $ \directory -> do+        (tokens, _) <- fixture+        let path = directory </> "tokens.json"+            store = fileTokenStore path+            replacement = tokens {R.accessToken = "new-access", R.refreshToken = Just "new-refresh"}+        tokenFileExists path >>= (@?= False)+        R.saveTokens store tokens+        mode <- F.fileMode <$> F.getFileStatus path+        mode .&. 0o077 @?= 0+        R.saveTokens store replacement+        R.loadTokens (fileTokenStore path) >>= (@?= replacement)+        L.readFile path >>= \bytes -> A.decode bytes @?= Just replacement+        D.listDirectory directory >>= (@?= ["tokens.json"]),+      testCase "pre-rename failure preserves old destination and cleans owned temporary" $ withDirectory $ \directory -> do+        (tokens, _) <- fixture+        let path = directory </> "tokens.json"+            store = fileTokenStore path+        R.saveTokens store tokens+        failed <- try @SomeException (R.saveTokens store tokens {R.workspaceName = Just (error "injected encoding failure")})+        assertBool "failed" (either (const True) (const False) failed)+        R.loadTokens store >>= (@?= tokens)+        D.listDirectory directory >>= (@?= ["tokens.json"]),+      testCase "reject symlink token paths and unsafe destination directories" $ withDirectory $ \directory -> do+        (tokens, _) <- fixture+        let target = directory </> "target.json"+            link = directory </> "tokens.json"+        R.saveTokens (fileTokenStore target) tokens+        F.createSymbolicLink target link+        failure <- try @IOException (R.saveTokens (fileTokenStore link) tokens)+        assertBool "symlink rejected" (either (const True) (const False) failure)+        R.loadTokens (fileTokenStore target) >>= (@?= tokens)+        F.setFileMode directory 0o777+        unsafe <- try @IOException (R.saveTokens (fileTokenStore target) tokens)+        F.setFileMode directory 0o700+        assertBool "unsafe directory rejected" (either (const True) (const False) unsafe),+      testCase "command lock excludes a second command and confirmed logout clears file" $ withDirectory $ \directory -> do+        (tokens, _) <- fixture+        let path = directory </> "tokens.json"+        withTokenFileLock path $ do+          R.saveTokens (fileTokenStore path) tokens+          second <- try @IOException (withTokenFileLock path (assertFailure "must not acquire"))+          assertBool "second command refused" (either (const True) (const False) second)+          R.loadTokens (fileTokenStore path) >>= (@?= tokens)+        withTokenFileLock path (removeTokenFile path)+        tokenFileExists path >>= (@?= False)+    ]
tasty/OAuthTests.hs view
@@ -1,12 +1,19 @@ -- | Tests for the OAuth endpoints.-module OAuthTests (tests) where+module OAuthTests (tests, tokenJson, personOwner, credentials) where +import Control.Exception (SomeException, displayException, fromException, throwIO, try)+import Control.Monad (forM_)+import Control.Monad.IO.Class (liftIO) import Data.Aeson qualified as Aeson import Data.ByteString.Lazy.Char8 qualified as L8-import Data.IORef (readIORef)+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.List (isInfixOf) import FakeNotion import Notion.V1 (defaultClientConfig)+import Notion.V1.Client (ClientConfig (..), LogLevel (..))+import Notion.V1.Error qualified as E import Notion.V1.OAuth+import Servant.Client (ClientEnv (..)) import Test.Tasty import Test.Tasty.HUnit @@ -14,65 +21,67 @@ tests =   testGroup     "OAuth"-    [ testCase "basicAuthorization encodes client_id:client_secret" $-        basicAuthorization credentials @?= "Basic Y2xpZW50OnNlY3JldA==",-      testCase "authorization_code request encodes" $ do-        let grant = AuthorizationCodeGrant "code-123" (Just "https://example.com/callback") Nothing-        Aeson.toJSON (AuthorizationCode grant)-          @?= Aeson.object-            [ "grant_type" Aeson..= ("authorization_code" :: String),-              "code" Aeson..= ("code-123" :: String),-              "redirect_uri" Aeson..= ("https://example.com/callback" :: String)-            ]-        Aeson.toJSON (AuthorizationCode grant {externalAccount = Just (ExternalAccount "acct-1" "Tanaka Hanako")})-          @?= Aeson.object-            [ "grant_type" Aeson..= ("authorization_code" :: String),-              "code" Aeson..= ("code-123" :: String),-              "redirect_uri" Aeson..= ("https://example.com/callback" :: String),-              "external_account" Aeson..= Aeson.object ["key" Aeson..= ("acct-1" :: String), "name" Aeson..= ("Tanaka Hanako" :: String)]-            ],-      testCase "refresh_token request encodes" $-        Aeson.toJSON (RefreshToken "nrt_abc")-          @?= Aeson.object ["grant_type" Aeson..= ("refresh_token" :: String), "refresh_token" Aeson..= ("nrt_abc" :: String)],-      testCase "token response with person owner decodes" $ do-        OAuthTokenResponse {owner, refreshToken} <- decodeOrFail (tokenJson personOwner)-        refreshToken @?= Just "nrt_tanaka_refresh"-        case owner of-          OAuthUserOwner OAuthOwnerUser {email, name} -> do-            email @?= Just "hanako@example.com"-            name @?= Just "Tanaka Hanako"-          other -> assertFailure ("expected a user owner, got " <> show other),-      testCase "partial user owner and workspace owner decode" $ do-        OAuthTokenResponse {owner = partial} <--          decodeOrFail (tokenJson "{\"type\":\"user\",\"user\":{\"id\":\"0e1d2c3b-4a59-4687-b7a6-958473625140\",\"object\":\"user\"}}")-        case partial of-          OAuthUserOwner OAuthOwnerUser {email, type_} -> do-            email @?= Nothing-            type_ @?= Nothing-          other -> assertFailure ("expected a user owner, got " <> show other)-        OAuthTokenResponse {owner = workspace} <- decodeOrFail (tokenJson "{\"type\":\"workspace\",\"workspace\":true}")-        workspace @?= OAuthWorkspaceOwner-        OAuthTokenResponse {owner = team} <- decodeOrFail (tokenJson "{\"type\":\"team\"}")-        case team of-          UnknownOAuthOwner _ -> pure ()-          other -> assertFailure ("expected UnknownOAuthOwner, got " <> show other),-      testCase "introspect response decodes" $ do-        r1 <- decodeOrFail "{\"active\":true,\"scope\":\"read_content\",\"iat\":1757890000,\"request_id\":\"r-1\"}"-        r1 @?= OAuthIntrospectResponse True (Just "read_content") (Just 1757890000) (Just "r-1")-        r2 <- decodeOrFail "{\"active\":false}"-        r2 @?= OAuthIntrospectResponse False Nothing Nothing Nothing,-      testCase "createOAuthToken sends Basic auth to POST /oauth/token" $ do-        (env, recorded) <- fakeClientEnv [jsonReply 200 (tokenJson personOwner)]-        let oauth = makeOAuthMethodsWith defaultClientConfig env credentials-        _ <- createOAuthToken oauth (RefreshToken "nrt_tanaka_refresh")-        readIORef recorded >>= \case-          [r@Recorded {method, path}] -> do-            method @?= "POST"-            path @?= "/oauth/token"-            lookupRecordedHeader "Authorization" r @?= Just "Basic Y2xpZW50OnNlY3JldA=="-            length [() | ("Authorization", _) <- headers r] @?= 1-          rs -> assertFailure ("expected one request, got " <> show (length rs))-    ]+    ( errorTests+        <> [ testCase "basicAuthorization encodes client_id:client_secret" $+               basicAuthorization credentials @?= "Basic Y2xpZW50OnNlY3JldA==",+             testCase "authorization_code request encodes" $ do+               let grant = AuthorizationCodeGrant "code-123" (Just "https://example.com/callback") Nothing+               Aeson.toJSON (AuthorizationCode grant)+                 @?= Aeson.object+                   [ "grant_type" Aeson..= ("authorization_code" :: String),+                     "code" Aeson..= ("code-123" :: String),+                     "redirect_uri" Aeson..= ("https://example.com/callback" :: String)+                   ]+               Aeson.toJSON (AuthorizationCode grant {externalAccount = Just (ExternalAccount "acct-1" "Tanaka Hanako")})+                 @?= Aeson.object+                   [ "grant_type" Aeson..= ("authorization_code" :: String),+                     "code" Aeson..= ("code-123" :: String),+                     "redirect_uri" Aeson..= ("https://example.com/callback" :: String),+                     "external_account" Aeson..= Aeson.object ["key" Aeson..= ("acct-1" :: String), "name" Aeson..= ("Tanaka Hanako" :: String)]+                   ],+             testCase "refresh_token request encodes" $+               Aeson.toJSON (RefreshToken "nrt_abc")+                 @?= Aeson.object ["grant_type" Aeson..= ("refresh_token" :: String), "refresh_token" Aeson..= ("nrt_abc" :: String)],+             testCase "token response with person owner decodes" $ do+               OAuthTokenResponse {owner, refreshToken} <- decodeOrFail (tokenJson personOwner)+               refreshToken @?= Just "nrt_tanaka_refresh"+               case owner of+                 OAuthUserOwner OAuthOwnerUser {email, name} -> do+                   email @?= Just "hanako@example.com"+                   name @?= Just "Tanaka Hanako"+                 other -> assertFailure ("expected a user owner, got " <> show other),+             testCase "partial user owner and workspace owner decode" $ do+               OAuthTokenResponse {owner = partial} <-+                 decodeOrFail (tokenJson "{\"type\":\"user\",\"user\":{\"id\":\"0e1d2c3b-4a59-4687-b7a6-958473625140\",\"object\":\"user\"}}")+               case partial of+                 OAuthUserOwner OAuthOwnerUser {email, type_} -> do+                   email @?= Nothing+                   type_ @?= Nothing+                 other -> assertFailure ("expected a user owner, got " <> show other)+               OAuthTokenResponse {owner = workspace} <- decodeOrFail (tokenJson "{\"type\":\"workspace\",\"workspace\":true}")+               workspace @?= OAuthWorkspaceOwner+               OAuthTokenResponse {owner = team} <- decodeOrFail (tokenJson "{\"type\":\"team\"}")+               case team of+                 UnknownOAuthOwner _ -> pure ()+                 other -> assertFailure ("expected UnknownOAuthOwner, got " <> show other),+             testCase "introspect response decodes" $ do+               r1 <- decodeOrFail "{\"active\":true,\"scope\":\"read_content\",\"iat\":1757890000,\"request_id\":\"r-1\"}"+               r1 @?= OAuthIntrospectResponse True (Just "read_content") (Just 1757890000) (Just "r-1")+               r2 <- decodeOrFail "{\"active\":false}"+               r2 @?= OAuthIntrospectResponse False Nothing Nothing Nothing,+             testCase "createOAuthToken sends Basic auth to POST /oauth/token" $ do+               (env, recorded) <- fakeClientEnv [jsonReply 200 (tokenJson personOwner)]+               let oauth = makeOAuthMethodsWith defaultClientConfig env credentials+               _ <- createOAuthToken oauth (RefreshToken "nrt_tanaka_refresh")+               readIORef recorded >>= \case+                 [r@Recorded {method, path}] -> do+                   method @?= "POST"+                   path @?= "/oauth/token"+                   lookupRecordedHeader "Authorization" r @?= Just "Basic Y2xpZW50OnNlY3JldA=="+                   length [() | ("Authorization", _) <- headers r] @?= 1+                 rs -> assertFailure ("expected one request, got " <> show (length rs))+           ]+    )  credentials :: OAuthCredentials credentials = OAuthCredentials {clientId = "client", clientSecret = "secret"}@@ -89,3 +98,69 @@   "{\"access_token\":\"secret_tanaka_access\",\"token_type\":\"bearer\",\"refresh_token\":\"nrt_tanaka_refresh\",\"bot_id\":\"2f8e6d4c-1b3a-4c5d-8e7f-9a0b1c2d3e4f\",\"workspace_icon\":null,\"workspace_name\":\"Tanaka Hanako's Workspace\",\"workspace_id\":\"7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d\",\"owner\":"     <> owner     <> ",\"duplicated_template_id\":null,\"request_id\":\"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\"}"++errorTests :: [TestTree]+errorTests =+  [ testCase "OAuth timeout propagates with one attempt" $ do+      (env, _) <- fakeClientEnv []+      calls <- newIORef (0 :: Int)+      let failing = env {middleware = \_ _ -> liftIO (modifyIORef' calls (+ 1) >> throwIO E.RequestTimeoutError)}+      result <- try @E.RequestTimeoutError (createOAuthToken (makeOAuthMethodsWith defaultClientConfig failing credentials) (RefreshToken "secret"))+      assertBool "timeout" (either (const True) (const False) result)+      readIORef calls >>= (@?= 1),+    testCase "OAuth errors retain metadata and redact secrets" $ do+      (env, recorded) <- fakeClientEnv [FakeReply 400 [("Content-Type", "application/json"), ("x-notion-request-id", "r-oauth-2"), ("Secret", "SENTINEL")] "{\"error\":\"invalid_grant\",\"error_description\":\"SENTINEL\"}"]+      logs <- newIORef []+      let config = defaultClientConfig {logger = Just (\_ msg fields -> modifyIORef' logs (<> [show (msg, fields)])), logLevel = LogDebug}+      r <- try @OAuthError (createOAuthToken (makeOAuthMethodsWith config env credentials) (RefreshToken "SENTINEL"))+      case r of+        Left e@OAuthError {oauthErrorCode, errorDescription, requestId, response} -> do+          oauthErrorCode @?= OAuthInvalidGrant+          errorDescription @?= Just "SENTINEL"+          requestId @?= Just "r-oauth-2"+          fmap E.httpStatus response @?= Just 400+          assertBool "show redacted" (not ("SENTINEL" `isInfixOf` (show e <> displayException e)))+        Right _ -> assertFailure "expected OAuthError"+      readIORef logs >>= assertBool "debug logs redacted" . all (not . isInfixOf "SENTINEL")+      length <$> readIORef recorded >>= (@?= 1),+    testCase "OAuth and documented Notion envelopes normalize locally" $ do+      forM_+        [ ("{\"error\":\"invalid_client\",\"request_id\":\"body-id\"}", OAuthInvalidClient),+          ("{\"object\":\"error\",\"code\":\"invalid_grant\",\"message\":\"bad\"}", OAuthInvalidGrant),+          ("{\"error\":\"slow_down\"}", UnknownOAuthErrorCode "slow_down")+        ]+        $ \(body, expected) -> do+          (env, _) <- fakeClientEnv [jsonReply 401 body]+          result <- try @OAuthError (revokeOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")+          case result of+            Left OAuthError {oauthErrorCode, response} -> do+              oauthErrorCode @?= expected+              fmap E.httpStatus response @?= Just 401+            Right _ -> assertFailure "expected OAuthError",+    testCase "ordinary and malformed errors retain original exception" $ do+      forM_ ["validation_error", "brand_new_code"] $ \c -> do+        (env, _) <- fakeClientEnv [jsonReply 400 ("{\"object\":\"error\",\"code\":\"" <> c <> "\",\"message\":\"bad\"}")]+        result <- try @E.NotionError (revokeOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")+        assertBool "NotionError" (either (const True) (const False) result)+      forM_ ["oops", "{\"error\":\"\"}", "{\"error\":17}", "{}"] $ \body -> do+        (env, _) <- fakeClientEnv [jsonReply 502 body]+        r <- try @E.UnknownHTTPResponseError (introspectOAuthToken (makeOAuthMethodsWith defaultClientConfig env credentials) "token")+        assertBool "UnknownHTTPResponseError" (either (const True) (const False) r),+    testCase "OAuth never retries grants revoke or introspect" $ do+      forM_ ["rate_limited", "service_overload"] $ \code ->+        forM_+          [ \o -> () <$ createOAuthToken o (AuthorizationCode (AuthorizationCodeGrant "c" Nothing Nothing)),+            \o -> () <$ createOAuthToken o (RefreshToken "r"),+            \o -> () <$ revokeOAuthToken o "t",+            \o -> () <$ introspectOAuthToken o "t"+          ]+          $ \call -> do+            (env, recorded) <- fakeClientEnv [jsonReply 429 ("{\"object\":\"error\",\"code\":\"" <> code <> "\",\"message\":\"bad\"}")]+            r <- try @SomeException (call (makeOAuthMethodsWith defaultClientConfig env credentials))+            assertBool "original NotionError" (either (maybe False (const True) . fromException @E.NotionError) (const False) r)+            length <$> readIORef recorded >>= (@?= 1),+    testCase "all known codes and JSON roundtrip" $+      forM_ [OAuthInvalidRequest, OAuthInvalidClient, OAuthInvalidGrant, OAuthUnauthorizedClient, OAuthUnsupportedGrantType, OAuthInvalidScope, OAuthAccessDenied, OAuthUnsupportedResponseType, OAuthServerError, OAuthTemporarilyUnavailable, UnknownOAuthErrorCode "future"] $ \c -> do+        parseOAuthErrorCode (oauthErrorCodeText c) @?= c+        Aeson.decode (Aeson.encode c) @?= Just c+  ]
tasty/RuntimeTests.hs view
@@ -72,7 +72,7 @@       (env, _) <- fakeClientEnv []       let context = requestContextFor legacyClientConfig env "secret_tanaka"           sent = [(n, v) | (n, v) <- headers r, n `elem` ["Authorization", "Notion-Version", "User-Agent"]]-      standardHeaders context @?= sent+      standardHeaders context >>= (@?= sent)       contextBaseUrl context @?= fakeBaseUrl   ]   where
+ tasty/TokenHTTPTests.hs view
@@ -0,0 +1,133 @@+module TokenHTTPTests (tests) where++import Control.Exception+import Control.Monad (void)+import Control.Monad.IO.Class qualified+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as L+import Data.Foldable qualified+import Data.IORef+import Network.HTTP.Client qualified as H+import Network.HTTP.Types qualified as T+import Network.Wai qualified as W+import Network.Wai.Handler.Warp qualified as Warp+import Notion.V1+import Notion.V1.Client (applyTimeout)+import Notion.V1.Error+import Notion.V1.FileUploads (mkSendFileUpload)+import Notion.V1.Search qualified+import Servant.Client qualified as S+import Servant.Client.Core qualified+import System.Directory (getTemporaryDirectory, removeFile)+import System.IO+import Test.Tasty+import Test.Tasty.HUnit+import TokenSourceTests (bounded)++rejection :: L.ByteString+rejection = "{\"object\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid token\"}"++tests :: TestTree+tests =+  testGroup+    "TokenSource HTTP"+    [ testCase "real acquisition closes rejection before renewal; opened callbacks never replay" $ bounded $ do+        requests <- newIORef []+        closed <- newIORef False+        renewals <- newIORef (0 :: Int)+        let app req respond = do+              atomicModifyIORef' requests (\rs -> (rs <> [W.requestHeaders req], ()))+              respond $+                if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old"+                  then W.responseLBS T.status401 [("Content-Type", "application/json")] rejection+                  else W.responseLBS T.status200 [("Content-Type", "text/event-stream")] "data: ok\n\n"+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          let base = S.BaseUrl S.Http "127.0.0.1" port "/v1"+              source =+                TokenSource+                  (pure "old")+                  ( \_ -> do+                      readIORef closed >>= assertBool "rejected response already closed"+                      modifyIORef' renewals (+ 1)+                      pure (Just "new")+                  )+              context = requestContextWithTokenSource defaultClientConfig (S.mkClientEnv manager base) source+          request <- H.parseRequest (S.showBaseUrl base <> "/sessions")+          let acquire = withStandardHeaders context $ \headers -> withRetries defaultClientConfig "POST" "sessions.stream" $ mask $ \restore -> do+                response <- restore (H.responseOpen (applyTimeout defaultClientConfig request {H.requestHeaders = headers, H.method = "POST"}) manager)+                if T.statusIsSuccessful (H.responseStatus response)+                  then pure response+                  else do+                    chunks <- restore (H.brConsume (H.responseBody response)) `finally` (H.responseClose response >> writeIORef closed True)+                    throwIO (notionErrorFromResponse (H.responseStatus response) (H.responseHeaders response) (L.fromChunks chunks))+          outcome <- try @NotionError $ bracket acquire H.responseClose $ \res -> do+            chunk <- H.brRead (H.responseBody res)+            assertBool "stream opened" (not (BS.null chunk))+            throwIO (NotionError "error" 401 Unauthorized "callback failed" Nothing Nothing Nothing Nothing)+          either (\e -> code e @?= Unauthorized) (const (assertFailure "callback should throw")) outcome+          readIORef renewals >>= (@?= 1)+          hs <- readIORef requests+          map (lookup "Authorization") hs @?= [Just "Bearer old", Just "Bearer new"]+          map (lookup "Notion-Version") hs @?= replicate 2 (Just "2026-03-11")+          assertBool "user agent on every open" (all ((/= Nothing) . lookup "User-Agent") hs),+      testCase "caller middleware and request builder retained; bytes replay identically" $ bounded $ do+        received <- newIORef []+        middle <- newIORef []+        built <- newIORef (0 :: Int)+        renewals <- newIORef (0 :: Int)+        let app req respond = do+              body <- W.strictRequestBody req+              atomicModifyIORef' received (\rs -> (rs <> [(W.requestHeaders req, body)], ()))+              respond $+                W.responseLBS+                  (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then T.status401 else T.status200)+                  [("Content-Type", "application/json")]+                  (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then rejection else "{\"object\":\"list\",\"results\":[],\"has_more\":false,\"next_cursor\":null}")+        Warp.testWithApplication (pure app) $ \port -> do+          manager <- H.newManager H.defaultManagerSettings+          let env = S.mkClientEnv manager (S.BaseUrl S.Http "127.0.0.1" port "/v1")+              custom =+                env+                  { S.makeClientRequest = \base req -> modifyIORef' built (+ 1) >> S.makeClientRequest env base req,+                    S.middleware = \next req -> do+                      Control.Monad.IO.Class.liftIO $ modifyIORef' middle (<> [Data.Foldable.toList (Servant.Client.Core.requestHeaders req)])+                      next req+                  }+              source = TokenSource (pure "old") (\_ -> modifyIORef' renewals (+ 1) >> pure (Just "new"))+          void (search (makeMethodsWithTokenSource defaultClientConfig custom source) (Notion.V1.Search.SearchRequest Nothing Nothing Nothing Nothing Nothing))+          rs <- readIORef received+          case rs of+            [(_, a), (_, b)] -> a @?= b+            _ -> assertFailure "expected two byte requests"+          length <$> readIORef middle >>= (@?= 2)+          readIORef middle >>= mapM_ (\hs -> length (filter ((== "Authorization") . fst) hs) @?= 1)+          readIORef built >>= (@?= 2)+          readIORef renewals >>= (@?= 1),+      testCase "file-backed multipart body can be replayed after rejection" $+        bounded $+          bracket (getTemporaryDirectory >>= \directory -> openBinaryTempFile directory "notion-upload") (\(path, h) -> hClose h `finally` removeFile path) $ \(path, h) -> do+            BS.hPut h "fixture bytes: Tanaka Hanako"+            hFlush h+            hClose h+            received <- newIORef []+            let app req respond = do+                  body <- W.strictRequestBody req+                  atomicModifyIORef' received (\rs -> (rs <> [body], ()))+                  respond $+                    W.responseLBS+                      (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then T.status401 else T.status200)+                      [("Content-Type", "application/json")]+                      (if lookup "Authorization" (W.requestHeaders req) == Just "Bearer old" then rejection else "{}")+            Warp.testWithApplication (pure app) $ \port -> do+              manager <- H.newManager H.defaultManagerSettings+              let env = S.mkClientEnv manager (S.BaseUrl S.Http "127.0.0.1" port "/v1")+                  source = TokenSource (pure "old") (const (pure (Just "new")))+              -- The deliberately partial success fixture fails decoding after acquisition.+              void (try @S.ClientError (sendFileUploadContent (makeMethodsWithTokenSource defaultClientConfig env source) "upload-id" (mkSendFileUpload path "hanako.txt" "text/plain")))+              readIORef received >>= \case+                [a, b] -> do+                  a @?= b+                  assertBool "file bytes present" ("fixture bytes: Tanaka Hanako" `BS.isInfixOf` L.toStrict b)+                _ -> assertFailure "expected multipart replay"+    ]
+ tasty/TokenRefreshTests.hs view
@@ -0,0 +1,219 @@+{-# LANGUAGE GHC2024 #-}+{-# LANGUAGE AllowAmbiguousTypes #-}++module TokenRefreshTests (tests) where++import Control.Concurrent+import Control.Exception+import Control.Monad (forM_, void)+import Data.Aeson qualified as A+import Data.IORef+import Data.List (isInfixOf)+import Data.Time.Clock+import FakeNotion+import Notion.V1+import Notion.V1.Error (NotionError)+import Notion.V1.OAuth qualified as O+import Notion.V1.OAuth.Refresh qualified as R+import OAuthTests (credentials, personOwner, tokenJson)+import Test.Tasty+import Test.Tasty.HUnit+import TokenSourceTests (bounded, unauthorized, userJson)++fixture :: IO (R.OAuthTokens, O.OAuthTokenResponse)+fixture = do+  response <- either assertFailure pure (A.eitherDecode (tokenJson personOwner))+  now <- getCurrentTime+  let old = (R.oauthTokensFromResponse now response) {R.accessToken = "old", R.refreshToken = Just "old-refresh"}+  pure (old, response)++methods :: (O.OAuthTokenRequest -> IO O.OAuthTokenResponse) -> O.OAuthMethods+methods f = O.OAuthMethods f (const (pure (O.OAuthRevokeResponse Nothing))) (const (pure (O.OAuthIntrospectResponse False Nothing Nothing Nothing)))++forkResult :: IO a -> IO (ThreadId, MVar (Either SomeException a))+forkResult action = do+  result <- newEmptyMVar+  tid <- forkFinally action (putMVar result)+  pure (tid, result)++succeeds :: MVar (Either SomeException a) -> IO a+succeeds result = bounded (takeMVar result) >>= either (assertFailure . displayException) pure++expect :: forall e a. (Exception e) => IO a -> IO ()+expect action = try @e action >>= either (const (pure ())) (const (assertFailure "expected exception"))++counting :: O.OAuthTokenResponse -> IO (O.OAuthMethods, IO Int)+counting response = do+  ref <- newIORef (0 :: Int)+  pure (methods (\_ -> atomicModifyIORef' ref (\n -> (n + 1, ())) >> pure response), readIORef ref)++tests :: TestTree+tests =+  testGroup+    "TokenRefresh"+    [ testCase "reject refresh persist retry and timestamp" $ do+        (old, response) <- fixture+        (oauthEnv, grants) <- fakeClientEnv [jsonReply 200 (tokenJson personOwner)]+        store <- R.inMemoryTokenStore old+        manager <- R.newOAuthTokenManager (O.makeOAuthMethodsWith defaultClientConfig oauthEnv credentials) store+        (env, recorded) <- fakeClientEnvWith $ \request ->+          pure $+            if lookupRecordedHeader "Authorization" request == Just "Bearer old" then unauthorized else jsonReply 200 userJson+        before <- getCurrentTime+        void (retrieveMyUser (makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager)))+        after <- getCurrentTime+        tokens <- R.currentTokens manager+        R.accessToken tokens @?= O.accessToken response+        R.botId tokens @?= R.botId old+        R.workspaceId tokens @?= R.workspaceId old+        assertBool "obtained at response" (R.obtainedAt tokens >= before && R.obtainedAt tokens <= after)+        R.loadTokens store >>= (@?= tokens)+        length <$> readIORef grants >>= (@?= 1)+        map (lookupRecordedHeader "Authorization") <$> readIORef recorded >>= (@?= [Just "Bearer old", Just "Bearer secret_tanaka_access"]),+      testCase "concurrent old rejections share rotation and wait for durable save" $ bounded $ do+        (old, response) <- fixture+        (oauth, count) <- counting response+        bothOld <- newEmptyMVar+        reject <- newEmptyMVar+        saving <- newEmptyMVar+        durable <- newEmptyMVar+        seenOld <- newIORef (0 :: Int)+        let store = R.TokenStore (pure old) (\_ -> putMVar saving () >> readMVar durable)+        manager <- R.newOAuthTokenManager oauth store+        (env, recorded) <- fakeClientEnvWith $ \request ->+          if lookupRecordedHeader "Authorization" request == Just "Bearer old"+            then do+              n <- atomicModifyIORef' seenOld (\n -> (n + 1, n + 1))+              if n == 2 then putMVar bothOld () else pure ()+              readMVar reject+              pure unauthorized+            else pure (jsonReply 200 userJson)+        let call = retrieveMyUser (makeMethodsWithTokenSource defaultClientConfig env (R.tokenManagerSource manager))+        (_, a) <- forkResult call+        (_, b) <- forkResult call+        takeMVar bothOld+        putMVar reject ()+        takeMVar saving+        rs <- readIORef recorded+        length rs @?= 2+        map (lookupRecordedHeader "Authorization") rs @?= replicate 2 (Just "Bearer old")+        count >>= (@?= 1)+        putMVar durable ()+        void (succeeds a)+        void (succeeds b)+        renewToken (R.tokenManagerSource manager) "old" >>= (@?= Just "secret_tanaka_access")+        count >>= (@?= 1),+      testCase "invalid grant and missing refresh become remembered terminal states" $ do+        (old, _) <- fixture+        forM_ [False, True] $ \missing -> do+          attempts <- newIORef (0 :: Int)+          let failure = O.OAuthError O.OAuthInvalidGrant Nothing Nothing Nothing Nothing+              oauth = methods (\_ -> modifyIORef' attempts (+ 1) >> throwIO failure)+          store <- R.inMemoryTokenStore (if missing then old {R.refreshToken = Nothing} else old)+          manager <- R.newOAuthTokenManager oauth store+          expect @R.ReauthorizationRequired (R.refreshTokensNow manager)+          expect @R.ReauthorizationRequired (currentToken (R.tokenManagerSource manager))+          expect @R.ReauthorizationRequired (R.refreshTokensNow manager)+          expect @R.ReauthorizationRequired (renewToken (R.tokenManagerSource manager) "old")+          readIORef attempts >>= (@?= if missing then 0 else 1),+      testCase "save failure retains exact pair and persistence retry never rotates" $ do+        (old, response) <- fixture+        (oauth, count) <- counting response+        broken <- newIORef True+        saved <- newIORef []+        manager <-+          R.newOAuthTokenManager+            oauth+            ( R.TokenStore+                (pure old)+                ( \t -> do+                    modifyIORef' saved (<> [t])+                    readIORef broken >>= \b -> if b then ioError (userError "SENTINEL") else pure ()+                )+            )+        expect @R.TokenPersistenceError (R.refreshTokensNow manager)+        pending <- R.currentTokens manager+        R.accessToken pending @?= O.accessToken response+        expect @R.TokenPersistenceError (currentToken (R.tokenManagerSource manager))+        expect @R.TokenPersistenceError (R.refreshTokensNow manager)+        count >>= (@?= 1)+        writeIORef broken False+        R.retryTokenPersistence manager >>= (@?= pending)+        R.retryTokenPersistence manager >>= (@?= pending)+        readIORef saved >>= (@?= [pending, pending])+        currentToken (R.tokenManagerSource manager) >>= (@?= O.accessToken response)+        count >>= (@?= 1),+      testCase "lost response blocks reuse and preserves last known credentials" $ do+        (old, _) <- fixture+        calls <- newIORef (0 :: Int)+        store <- R.inMemoryTokenStore old+        manager <- R.newOAuthTokenManager (methods (\_ -> modifyIORef' calls (+ 1) >> ioError (userError "lost response"))) store+        expect @IOException (R.refreshTokensNow manager)+        expect @R.RefreshOutcomeUnknown (currentToken (R.tokenManagerSource manager))+        expect @R.RefreshOutcomeUnknown (R.refreshTokensNow manager)+        expect @R.RefreshOutcomeUnknown (R.retryTokenPersistence manager)+        R.currentTokens manager >>= (@?= old)+        readIORef calls >>= (@?= 1),+      testCase "cancellation during exchange publishes uncertainty and releases lock" $ bounded $ do+        (old, _) <- fixture+        started <- newEmptyMVar+        blocked <- newEmptyMVar+        store <- R.inMemoryTokenStore old+        manager <- R.newOAuthTokenManager (methods (\_ -> putMVar started () >> takeMVar blocked)) store+        (tid, result) <- forkResult (R.refreshTokensNow manager)+        takeMVar started+        killThread tid+        bounded (takeMVar result) >>= \r -> assertBool "cancelled" (either (const True) (const False) r)+        expect @R.RefreshOutcomeUnknown (R.refreshTokensNow manager)+        R.currentTokens manager >>= (@?= old),+      testCase "cancellation after response during save retains pending pair" $ bounded $ do+        (old, response) <- fixture+        (oauth, count) <- counting response+        started <- newEmptyMVar+        blocked <- newEmptyMVar+        broken <- newIORef True+        let save _ = readIORef broken >>= \b -> if b then putMVar started () >> takeMVar blocked else pure ()+        manager <- R.newOAuthTokenManager oauth (R.TokenStore (pure old) save)+        (tid, result) <- forkResult (R.refreshTokensNow manager)+        takeMVar started+        killThread tid+        takeMVar result >>= \r -> assertBool "persistence error" (either (maybe False (const True) . fromException @R.TokenPersistenceError) (const False) r)+        pending <- R.currentTokens manager+        R.accessToken pending @?= O.accessToken response+        expect @R.TokenPersistenceError (currentToken (R.tokenManagerSource manager))+        writeIORef broken False+        R.retryTokenPersistence manager >>= (@?= pending)+        count >>= (@?= 1),+      testCase "null refresh stays absent; invalid identity or type cannot authorize" $ do+        (old, response) <- fixture+        (oauth, count) <- counting response {O.refreshToken = Nothing}+        store <- R.inMemoryTokenStore old+        manager <- R.newOAuthTokenManager oauth store+        replacement <- R.refreshTokensNow manager+        R.refreshToken replacement @?= Nothing+        expect @R.ReauthorizationRequired (R.refreshTokensNow manager)+        count >>= (@?= 1)+        forM_ [response {O.tokenType = "mac"}, response {O.botId = "other"}, response {O.workspaceId = "other"}, response {O.accessToken = ""}] $ \invalid -> do+          (badOAuth, _) <- counting invalid+          freshStore <- R.inMemoryTokenStore old+          bad <- R.newOAuthTokenManager badOAuth freshStore+          expect @R.RefreshOutcomeUnknown (R.refreshTokensNow bad)+          expect @R.RefreshOutcomeUnknown (currentToken (R.tokenManagerSource bad)),+      testCase "explicit OAuth rejection remains distinguishable without invalidating pair" $ do+        (old, _) <- fixture+        store <- R.inMemoryTokenStore old+        let failure = O.OAuthError O.OAuthInvalidClient Nothing Nothing Nothing Nothing+        manager <- R.newOAuthTokenManager (methods (\_ -> throwIO failure)) store+        expect @O.OAuthError (R.refreshTokensNow manager)+        currentToken (R.tokenManagerSource manager) >>= (@?= "old"),+      testCase "JSON roundtrip, load once, and redacted rendering" $ do+        (old, response) <- fixture+        A.decode (A.encode old) @?= Just old+        assertBool "tokens redacted" (not ("old-refresh" `isInfixOf` show old))+        loaded <- newIORef (0 :: Int)+        (oauth, _) <- counting response+        manager <- R.newOAuthTokenManager oauth (R.TokenStore (modifyIORef' loaded (+ 1) >> pure old) (const (pure ())))+        void (currentToken (R.tokenManagerSource manager))+        void (R.refreshTokensNow manager)+        readIORef loaded >>= (@?= 1)+    ]
+ tasty/TokenSourceTests.hs view
@@ -0,0 +1,74 @@+module TokenSourceTests (tests, unauthorized, userJson, bounded) where++import Control.Exception+import Control.Monad (forM_, void)+import Data.ByteString.Lazy qualified as L+import Data.IORef+import FakeNotion+import Notion.V1+import Notion.V1.Error+import Notion.V1.OAuth qualified as O+import Notion.V1.TokenSource (withAuthorization)+import OAuthTests (credentials)+import System.Timeout qualified as Timeout+import Test.Tasty+import Test.Tasty.HUnit++unauthorized :: FakeReply+unauthorized = jsonReply 401 "{\"object\":\"error\",\"code\":\"unauthorized\",\"message\":\"invalid token\"}"++userJson :: L.ByteString+userJson = "{\"object\":\"user\",\"id\":\"6f1c2b9e-4d3a-4e8f-9b7c-2a1d0e5f3c4b\",\"name\":\"Sato Kenji\",\"type\":\"bot\",\"bot\":{\"owner\":{\"type\":\"workspace\",\"workspace\":true}}}"++bounded :: IO a -> IO a+bounded action = Timeout.timeout 5000000 action >>= maybe (assertFailure "timed out") pure++tests :: TestTree+tests =+  testGroup+    "TokenSource"+    [ testCase "source follows changes on every call with one Authorization header" $ do+        ref <- newIORef "t1"+        (env, recorded) <- fakeClientEnv (replicate 2 (jsonReply 200 userJson))+        let api = makeMethodsWithTokenSource defaultClientConfig env (TokenSource (readIORef ref) (const (pure Nothing)))+        void (retrieveMyUser api)+        writeIORef ref "t2"+        void (retrieveMyUser api)+        rs <- readIORef recorded+        map (lookupRecordedHeader "Authorization") rs @?= [Just "Bearer t1", Just "Bearer t2"]+        map (length . filter ((== "Authorization") . fst) . headers) rs @?= [1, 1],+      testCase "renewal once, static rejection, and second rejection do not loop" $ do+        forM_ [False, True] $ \secondFails -> do+          (env, recorded) <- fakeClientEnv [unauthorized, if secondFails then unauthorized else jsonReply 200 userJson]+          calls <- newIORef []+          let source = TokenSource (pure "old") (\old -> modifyIORef' calls (<> [old]) >> pure (Just "new"))+          result <- try @NotionError (retrieveMyUser (makeMethodsWithTokenSource defaultClientConfig env source))+          either (\e -> code e @?= Unauthorized) (const (assertBool "successful" (not secondFails))) result+          readIORef calls >>= (@?= ["old"])+          map (lookupRecordedHeader "Authorization") <$> readIORef recorded >>= (@?= [Just "Bearer old", Just "Bearer new"])+        (env, rs) <- fakeClientEnv [unauthorized]+        result <- try @NotionError (retrieveMyUser (makeMethodsWithEnv defaultClientConfig env "old"))+        either (\e -> code e @?= Unauthorized) (const (assertFailure "expected rejection")) result+        length <$> readIORef rs >>= (@?= 1),+      testCase "ordinary retry budget runs independently before and after renewal" $ do+        let rateLimited = jsonReply 429 "{\"object\":\"error\",\"code\":\"rate_limited\",\"message\":\"slow down\"}"+            config = defaultClientConfig {retryOptions = RetryOptions 1 0.000001 0.000001}+        (env, recorded) <- fakeClientEnv [rateLimited, unauthorized, rateLimited, jsonReply 200 userJson]+        renewals <- newIORef (0 :: Int)+        let source = TokenSource (pure "old") (\_ -> modifyIORef' renewals (+ 1) >> pure (Just "new"))+        void (retrieveMyUser (makeMethodsWithTokenSource config env source))+        length <$> readIORef recorded >>= (@?= 4)+        readIORef renewals >>= (@?= 1),+      testCase "decode timeout untyped 401 and failed POST never renew" $ do+        renewals <- newIORef (0 :: Int)+        let source = TokenSource (pure "old") (\_ -> modifyIORef' renewals (+ 1) >> pure (Just "new"))+        forM_ [jsonReply 401 "{}", jsonReply 200 "{}", jsonReply 500 "{\"object\":\"error\",\"code\":\"internal_server_error\",\"message\":\"bad\"}"] $ \reply -> do+          (env, _) <- fakeClientEnv [reply]+          void (try @SomeException (retrieveMyUser (makeMethodsWithTokenSource defaultClientConfig {retryOptions = noRetries} env source)))+        void (try @RequestTimeoutError (withAuthorization source (\_ -> throwIO RequestTimeoutError)))+        readIORef renewals >>= (@?= 0),+      testCase "Basic OAuth authentication bypasses bearer source" $ do+        (env, recorded) <- fakeClientEnv [jsonReply 200 "{\"active\":false}"]+        void (O.introspectOAuthToken (O.makeOAuthMethodsWith defaultClientConfig env credentials) "token")+        map (lookupRecordedHeader "Authorization") <$> readIORef recorded >>= (@?= [Just "Basic Y2xpZW50OnNlY3JldA=="])+    ]