notion-client-0.10.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. OAuth failures throw 'OAuthError'; requests are
-- never automatically retried, because grants may be consumed.
--
-- @
-- 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 (..),
-- * Errors
OAuthErrorCode (..),
oauthErrorCodeText,
parseOAuthErrorCode,
OAuthError (..),
oauthErrorFromResponse,
-- * Methods
OAuthMethods (..),
makeOAuthMethods,
makeOAuthMethodsWith,
-- * Servant
API,
)
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)
-- | 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
(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)