packages feed

notion-client-0.8.0.0: src/Notion/V1/OAuth.hs

-- | @\/v1\/oauth@: exchange, revoke and introspect OAuth tokens.
--
-- 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.
--
-- @
-- manager <- newTlsManager
-- let oauth = makeOAuthMethodsWith defaultClientConfig
--       (mkClientEnv manager defaultBaseUrl)
--       OAuthCredentials {clientId = "...", clientSecret = "..."}
-- token <- createOAuthToken oauth (AuthorizationCode AuthorizationCodeGrant
--   {code = codeFromRedirect, redirectUri = Just callbackUrl, externalAccount = Nothing})
-- @
module Notion.V1.OAuth
  ( -- * Credentials
    OAuthCredentials (..),
    basicAuthorization,

    -- * Requests
    OAuthTokenRequest (..),
    AuthorizationCodeGrant (..),
    ExternalAccount (..),
    TokenBody (..),

    -- * Responses
    OAuthTokenResponse (..),
    OAuthOwner (..),
    OAuthOwnerUser (..),
    OAuthRevokeResponse (..),
    OAuthIntrospectResponse (..),

    -- * Methods
    OAuthMethods (..),
    makeOAuthMethods,
    makeOAuthMethodsWith,

    -- * Servant
    API,
  )
where

import Data.Aeson ((.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Base64 qualified as Base64
import Data.Maybe (catMaybes)
import Data.Proxy (Proxy (..))
import Data.Text.Encoding qualified as Text
import Notion.Prelude
import Notion.V1.Client (ClientConfig (..), configureClientEnv, legacyClientConfig, runClientWith)
import Notion.V1.Common (UUID)
import Servant.Client (ClientEnv)
import Servant.Client qualified as Client
import Prelude hiding (id)

-- | An integration's OAuth client ID and secret.
data OAuthCredentials = OAuthCredentials
  { clientId :: Text,
    clientSecret :: Text
  }

-- | @Basic base64(client_id:client_secret)@
basicAuthorization :: OAuthCredentials -> Text
basicAuthorization OAuthCredentials {clientId, clientSecret} =
  "Basic " <> Text.decodeUtf8 (Base64.encode (Text.encodeUtf8 (clientId <> ":" <> clientSecret)))

-- | Body of @POST /v1/oauth/token@.
data OAuthTokenRequest
  = -- | Exchange the code from the OAuth redirect.
    AuthorizationCode AuthorizationCodeGrant
  | -- | Exchange a refresh token.
    RefreshToken Text
  deriving stock (Eq, Show)

data AuthorizationCodeGrant = AuthorizationCodeGrant
  { code :: Text,
    redirectUri :: Maybe Text,
    externalAccount :: Maybe ExternalAccount
  }
  deriving stock (Eq, Show)

data ExternalAccount = ExternalAccount
  { key :: Text,
    name :: Text
  }
  deriving stock (Eq, Show)

instance ToJSON ExternalAccount where
  toJSON ExternalAccount {key, name} = Aeson.object ["key" .= key, "name" .= name]

instance ToJSON OAuthTokenRequest where
  toJSON = \case
    AuthorizationCode AuthorizationCodeGrant {code, redirectUri, externalAccount} ->
      Aeson.object $
        ["grant_type" .= ("authorization_code" :: Text), "code" .= code]
          <> catMaybes
            [ ("redirect_uri" .=) <$> redirectUri,
              ("external_account" .=) <$> externalAccount
            ]
    RefreshToken token ->
      Aeson.object ["grant_type" .= ("refresh_token" :: Text), "refresh_token" .= token]

-- | Body of the revoke and introspect endpoints.
newtype TokenBody = TokenBody {token :: Text}
  deriving stock (Eq, Show)

instance ToJSON TokenBody where
  toJSON TokenBody {token} = Aeson.object ["token" .= token]

-- | Response of @POST /v1/oauth/token@.
data OAuthTokenResponse = OAuthTokenResponse
  { accessToken :: Text,
    -- | Always @"bearer"@.
    tokenType :: Text,
    refreshToken :: Maybe Text,
    botId :: Text,
    workspaceIcon :: Maybe Text,
    workspaceName :: Maybe Text,
    workspaceId :: Text,
    owner :: OAuthOwner,
    duplicatedTemplateId :: Maybe Text,
    requestId :: Maybe Text
  }
  deriving stock (Eq, Generic, Show)

instance FromJSON OAuthTokenResponse where
  parseJSON = genericParseJSON aesonOptions

-- | Who owns the integration's access.
data OAuthOwner
  = OAuthUserOwner OAuthOwnerUser
  | OAuthWorkspaceOwner
  | -- | An owner kind this library does not model yet; holds the raw object.
    UnknownOAuthOwner Value
  deriving stock (Eq, Show)

instance FromJSON OAuthOwner where
  parseJSON = Aeson.withObject "OAuthOwner" $ \o -> do
    ownerType :: Text <- o .: "type"
    case ownerType of
      "user" -> OAuthUserOwner <$> o .: "user"
      "workspace" -> pure OAuthWorkspaceOwner
      _ -> pure (UnknownOAuthOwner (Object o))

-- | A full person user or a partial user (only @id@ and @object@); the
-- person-only fields are 'Nothing' for a partial user.
data OAuthOwnerUser = OAuthOwnerUser
  { id :: UUID,
    object :: Text,
    type_ :: Maybe Text,
    name :: Maybe Text,
    avatarUrl :: Maybe Text,
    -- | From @person.email@.
    email :: Maybe Text
  }
  deriving stock (Eq, Show)

instance FromJSON OAuthOwnerUser where
  parseJSON = Aeson.withObject "OAuthOwnerUser" $ \o -> do
    id <- o .: "id"
    object <- o .: "object"
    type_ <- o .:? "type"
    name <- o .:? "name"
    avatarUrl <- o .:? "avatar_url"
    person <- o .:? "person"
    email <- maybe (pure Nothing) (.:? "email") person
    pure OAuthOwnerUser {..}

-- | Response of @POST /v1/oauth/revoke@.
newtype OAuthRevokeResponse = OAuthRevokeResponse {requestId :: Maybe Text}
  deriving stock (Eq, Show)

instance FromJSON OAuthRevokeResponse where
  parseJSON = Aeson.withObject "OAuthRevokeResponse" $ \o -> OAuthRevokeResponse <$> o .:? "request_id"

-- | Response of @POST /v1/oauth/introspect@.
data OAuthIntrospectResponse = OAuthIntrospectResponse
  { active :: Bool,
    scope :: Maybe Text,
    -- | Issued-at time, in seconds since the Unix epoch.
    iat :: Maybe Integer,
    requestId :: Maybe Text
  }
  deriving stock (Eq, Generic, Show)

instance FromJSON OAuthIntrospectResponse where
  parseJSON = genericParseJSON aesonOptions

-- | Servant API
type API =
  Header' [Required, Strict] "Authorization" Text
    :> Header' [Required, Strict] "Notion-Version" Text
    :> "oauth"
    :> ( "token"
           :> ReqBody '[JSON] OAuthTokenRequest
           :> Post '[JSON] OAuthTokenResponse
           :<|> "revoke"
           :> ReqBody '[JSON] TokenBody
           :> Post '[JSON] OAuthRevokeResponse
           :<|> "introspect"
           :> ReqBody '[JSON] TokenBody
           :> Post '[JSON] OAuthIntrospectResponse
       )

-- | OAuth endpoints, authenticated with the integration's credentials.
data OAuthMethods = OAuthMethods
  { createOAuthToken :: OAuthTokenRequest -> IO OAuthTokenResponse,
    revokeOAuthToken :: Text -> IO OAuthRevokeResponse,
    introspectOAuthToken :: Text -> IO OAuthIntrospectResponse
  }

-- | OAuth methods with 'legacyClientConfig', like 'Notion.V1.makeMethods'.
makeOAuthMethods :: ClientEnv -> OAuthCredentials -> OAuthMethods
makeOAuthMethods = makeOAuthMethodsWith legacyClientConfig

-- | OAuth methods with a configuration. The routes are relative to the
-- 'ClientEnv' base URL (normally @https://api.notion.com/v1@) and use the same
-- runtime (retries, timeout, logging) as 'Notion.V1.Methods'.
makeOAuthMethodsWith :: ClientConfig -> ClientEnv -> OAuthCredentials -> OAuthMethods
makeOAuthMethodsWith config env creds =
  OAuthMethods
    { createOAuthToken,
      revokeOAuthToken = revoke_ . TokenBody,
      introspectOAuthToken = introspect_ . TokenBody
    }
  where
    createOAuthToken :<|> revoke_ :<|> introspect_ =
      Client.hoistClient
        @API
        Proxy
        (runClientWith (configureClientEnv config env))
        (Client.client @API Proxy)
        (basicAuthorization creds)
        (notionVersion config)