shomei-servant-0.2.0.0: src/Shomei/OAuth/Result.hs
{-# LANGUAGE EmptyCase #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -Wno-orphans #-}
-- | RFC 6749/OIDC response sums. These deliberately do not mention 'ProblemDetails'.
module Shomei.OAuth.Result
( OAuthErrorHeaders,
OAuthErrorWithHeaders (..),
OAuthErrorResponses,
OAuthResponses,
OAuthEmptyResponses,
OAuthResult (..),
AuthorizeHeaders,
AuthorizeRedirect (..),
TokenHeaders,
TokenSuccess (..),
AuthorizeResponses,
AuthorizeResult,
TokenResponses,
TokenResult,
UserinfoResponses,
UserinfoResult,
IntrospectResponses,
IntrospectResult,
RevokeResponses,
RevokeResult,
OidcDiscoveryResponses,
OidcDiscoveryResult,
oauthServerErrorResult,
)
where
import Data.Aeson (Value, eitherDecode)
import Data.ByteString (ByteString)
import Data.Foldable (toList)
import Data.SOP (I (..), NP (..), NS (..))
import Data.Sequence (Seq)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TextEncoding
import Network.HTTP.Types qualified as HTTP
import Numeric.Natural (Natural)
import Servant (JSON, ServerError (..))
import Servant.API.MultiVerb
import Shomei.Prelude
import Shomei.Servant.OAuth (OAuthErrorResponse (..), TokenResponse)
import Text.Read (readMaybe)
import Web.HttpApiData (FromHttpApiData (parseHeader), ToHttpApiData (toHeader))
type OAuthErrorHeaders =
'[ DescHeader "Cache-Control" "OAuth responses are not cacheable" Text,
DescHeader "Pragma" "OAuth responses are not cacheable" Text,
OptHeader (DescHeader "WWW-Authenticate" "Client authentication challenge" Text),
OptHeader (DescHeader "Retry-After" "Seconds until retry" Natural)
]
data OAuthErrorWithHeaders = OAuthErrorWithHeaders
{ oauthErrorBody :: !OAuthErrorResponse,
oauthCacheControl :: !Text,
oauthPragma :: !Text,
oauthAuthenticate :: !(Maybe Text),
oauthRetryAfter :: !(Maybe Natural)
}
deriving stock (Eq, Show, Generic)
instance AsHeaders '[Text, Text, Maybe Text, Maybe Natural] OAuthErrorResponse OAuthErrorWithHeaders where
toHeaders response =
( I response.oauthCacheControl :* I response.oauthPragma :* I response.oauthAuthenticate :* I response.oauthRetryAfter :* Nil,
response.oauthErrorBody
)
fromHeaders (I oauthCacheControl :* I oauthPragma :* I oauthAuthenticate :* I oauthRetryAfter :* Nil, oauthErrorBody) =
OAuthErrorWithHeaders {oauthErrorBody, oauthCacheControl, oauthPragma, oauthAuthenticate, oauthRetryAfter}
-- servant 0.20.3's generic header decoder rejects absent optional fields. OAuth explicitly
-- permits WWW-Authenticate and Retry-After to be absent, so preserve that contract in clients.
instance {-# OVERLAPPING #-} ServantHeaders OAuthErrorHeaders '[Text, Text, Maybe Text, Maybe Natural] where
constructHeaders (I oauthCacheControl :* I oauthPragma :* I oauthAuthenticate :* I oauthRetryAfter :* Nil) =
requiredHeader "Cache-Control" oauthCacheControl
<> requiredHeader "Pragma" oauthPragma
<> optionalHeader "WWW-Authenticate" oauthAuthenticate
<> optionalHeader "Retry-After" oauthRetryAfter
extractHeaders headers = do
oauthCacheControl <- extractRequiredHeader "Cache-Control" headers
oauthPragma <- extractRequiredHeader "Pragma" headers
oauthAuthenticate <- extractOptionalHeader "WWW-Authenticate" headers
oauthRetryAfter <- extractOptionalHeader "Retry-After" headers
pure (I oauthCacheControl :* I oauthPragma :* I oauthAuthenticate :* I oauthRetryAfter :* Nil)
requiredHeader :: (ToHttpApiData a) => HTTP.HeaderName -> a -> [HTTP.Header]
requiredHeader name value = [(name, toHeader value)]
optionalHeader :: (ToHttpApiData a) => HTTP.HeaderName -> Maybe a -> [HTTP.Header]
optionalHeader name = maybe [] (requiredHeader name)
extractRequiredHeader :: (FromHttpApiData a) => HTTP.HeaderName -> Seq HTTP.Header -> Maybe a
extractRequiredHeader name headers = case matchingHeaderValues name headers of
[value] -> decodeHeader value
_ -> Nothing
extractOptionalHeader :: (FromHttpApiData a) => HTTP.HeaderName -> Seq HTTP.Header -> Maybe (Maybe a)
extractOptionalHeader name headers = case matchingHeaderValues name headers of
[] -> Just Nothing
[value] -> Just <$> decodeHeader value
_ -> Nothing
matchingHeaderValues :: HTTP.HeaderName -> Seq HTTP.Header -> [ByteString]
matchingHeaderValues name = map snd . filter ((== name) . fst) . toList
decodeHeader :: (FromHttpApiData a) => ByteString -> Maybe a
decodeHeader = either (const Nothing) Just . parseHeader
type OAuthErrorResponses =
'[ WithHeaders OAuthErrorHeaders OAuthErrorWithHeaders (RespondAs JSON 400 "OAuth request rejected" OAuthErrorResponse),
WithHeaders OAuthErrorHeaders OAuthErrorWithHeaders (RespondAs JSON 401 "OAuth authentication failed" OAuthErrorResponse),
WithHeaders OAuthErrorHeaders OAuthErrorWithHeaders (RespondAs JSON 404 "OAuth resource not found" OAuthErrorResponse),
WithHeaders OAuthErrorHeaders OAuthErrorWithHeaders (RespondAs JSON 500 "OAuth server error" OAuthErrorResponse),
WithHeaders OAuthErrorHeaders OAuthErrorWithHeaders (RespondAs JSON 503 "OAuth dependency unavailable" OAuthErrorResponse)
]
type OAuthResponses status description body = Respond status description body ': OAuthErrorResponses
type OAuthEmptyResponses status description = RespondEmpty status description ': OAuthErrorResponses
data OAuthResult a
= OAuthSuccess !a
| OAuthBadRequest !OAuthErrorWithHeaders
| OAuthAuthenticationFailed !OAuthErrorWithHeaders
| OAuthNotFound !OAuthErrorWithHeaders
| OAuthInternal !OAuthErrorWithHeaders
| OAuthUnavailable !OAuthErrorWithHeaders
deriving stock (Eq, Show, Generic, Functor)
instance AsUnion (Respond status description a ': OAuthErrorResponses) (OAuthResult a) where
toUnion = oauthToUnion
fromUnion = oauthFromUnion
instance AsUnion (RespondEmpty status description ': OAuthErrorResponses) (OAuthResult ()) where
toUnion = oauthToUnion
fromUnion = oauthFromUnion
instance AsUnion (WithHeaders headers a response ': OAuthErrorResponses) (OAuthResult a) where
toUnion = oauthToUnion
fromUnion = oauthFromUnion
oauthToUnion :: OAuthResult a -> NS I '[a, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders]
oauthToUnion = \case
OAuthSuccess value -> Z (I value)
OAuthBadRequest value -> S (Z (I value))
OAuthAuthenticationFailed value -> S (S (Z (I value)))
OAuthNotFound value -> S (S (S (Z (I value))))
OAuthInternal value -> S (S (S (S (Z (I value)))))
OAuthUnavailable value -> S (S (S (S (S (Z (I value))))))
oauthFromUnion :: NS I '[a, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders, OAuthErrorWithHeaders] -> OAuthResult a
oauthFromUnion = \case
Z (I value) -> OAuthSuccess value
S (Z (I value)) -> OAuthBadRequest value
S (S (Z (I value))) -> OAuthAuthenticationFailed value
S (S (S (Z (I value)))) -> OAuthNotFound value
S (S (S (S (Z (I value))))) -> OAuthInternal value
S (S (S (S (S (Z (I value)))))) -> OAuthUnavailable value
S (S (S (S (S (S impossible))))) -> case impossible of {}
type AuthorizeHeaders =
'[ DescHeader "Location" "Redirect target" Text,
DescHeader "Cache-Control" "Authorization redirects are not cacheable" Text
]
data AuthorizeRedirect = AuthorizeRedirect
{ authorizeLocation :: !Text,
authorizeCacheControl :: !Text
}
deriving stock (Eq, Show, Generic)
instance AsHeaders '[Text, Text] () AuthorizeRedirect where
toHeaders response = (I response.authorizeLocation :* I response.authorizeCacheControl :* Nil, ())
fromHeaders (I authorizeLocation :* I authorizeCacheControl :* Nil, ()) = AuthorizeRedirect {authorizeLocation, authorizeCacheControl}
type TokenHeaders =
'[ DescHeader "Cache-Control" "Token responses are not cacheable" Text,
DescHeader "Pragma" "Token responses are not cacheable" Text
]
data TokenSuccess = TokenSuccess
{ tokenBody :: !TokenResponse,
tokenCacheControl :: !Text,
tokenPragma :: !Text
}
deriving stock (Eq, Show, Generic)
instance AsHeaders '[Text, Text] TokenResponse TokenSuccess where
toHeaders response = (I response.tokenCacheControl :* I response.tokenPragma :* Nil, response.tokenBody)
fromHeaders (I tokenCacheControl :* I tokenPragma :* Nil, tokenBody) = TokenSuccess {tokenBody, tokenCacheControl, tokenPragma}
type AuthorizeResponses = WithHeaders AuthorizeHeaders AuthorizeRedirect (RespondEmpty 302 "Redirect") ': OAuthErrorResponses
type AuthorizeResult = OAuthResult AuthorizeRedirect
type TokenResponses = WithHeaders TokenHeaders TokenSuccess (Respond 200 "Token issued" TokenResponse) ': OAuthErrorResponses
type TokenResult = OAuthResult TokenSuccess
type UserinfoResponses = OAuthResponses 200 "OIDC user information" Value
type UserinfoResult = OAuthResult Value
type IntrospectResponses = OAuthResponses 200 "Token status" Value
type IntrospectResult = OAuthResult Value
type RevokeResponses = OAuthEmptyResponses 200 "Token revoked"
type RevokeResult = OAuthResult ()
type OidcDiscoveryResponses = OAuthResponses 200 "OIDC discovery document" Value
type OidcDiscoveryResult = OAuthResult Value
oauthServerErrorResult :: ServerError -> OAuthResult a
oauthServerErrorResult err = constructor response
where
body =
fromMaybe
(OAuthErrorResponse "server_error" "the authorization server encountered an unexpected condition")
(either (const Nothing) Just (eitherDecode err.errBody))
response =
OAuthErrorWithHeaders
{ oauthErrorBody = body,
oauthCacheControl = headerText "Cache-Control" "no-store",
oauthPragma = headerText "Pragma" "no-cache",
oauthAuthenticate = optionalHeaderText "WWW-Authenticate",
oauthRetryAfter = optionalHeaderText "Retry-After" >>= readMaybe . Text.unpack
}
constructor = case err.errHTTPCode of
400 -> OAuthBadRequest
401 -> OAuthAuthenticationFailed
404 -> OAuthNotFound
503 -> OAuthUnavailable
_ -> OAuthInternal
optionalHeaderText name = TextEncoding.decodeUtf8 <$> lookup name err.errHeaders
headerText name fallback = fromMaybe fallback (optionalHeaderText name)