packages feed

notion-client-0.10.0.0: src/Notion/V1/OAuth/Authorization.hs

-- | 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