notion-client-0.10.0.0: src/Notion/V1/OAuth/Refresh.hs
-- | 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)