shomei-servant-0.3.0.0: src/Shomei/Session/Dto.hs
-- | Session, token, and login wire types.
module Shomei.Session.Dto
( TokenPairResponse (..),
LoginRequest (..),
LoginResponse (..),
RefreshRequest (..),
SessionResponse (..),
SessionsPage (..),
SessionPageCursor (..),
encodeSessionCursor,
decodeSessionCursor,
tokenPairToResponse,
loginResultToResponse,
sessionToResponse,
)
where
import Data.Aeson (Value, object, withObject, (.:))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types (Parser)
import Data.Maybe (catMaybes)
import Data.Text qualified as Text
import Data.Time.Format.ISO8601 (iso8601ParseM, iso8601Show)
import Data.UUID qualified as UUID
import Shomei.Account.User.Dto (UserResponse, userToResponse)
import Shomei.Config (ShomeiConfig (..), transportIncludesBodyTokens)
import Shomei.Id (idText, sessionIdFromUUID, sessionIdToUUID)
import Shomei.Prelude
import Shomei.Session.Authentication.Workflow (LoginResult (..), MfaChallenge (..))
import Shomei.Session.Domain (Session (..), SessionStatus (..))
import Shomei.Session.RefreshToken.Domain (RefreshToken (..))
import Shomei.Session.Store (SessionCursor (..))
import Shomei.Session.Token.Domain (AccessToken (..), TokenPair (..))
import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
data TokenPairResponse = TokenPairResponse
{ accessToken :: !(Maybe Text),
refreshToken :: !(Maybe Text),
expiresIn :: !Int,
-- | whether the session behind these tokens is remembered (signed in with @remember@), so a
-- browser-facing embedder can decide between persistent and browser-session cookies.
-- Always present in Shōmei's own responses; optional on the wire for older servers.
remembered :: !(Maybe Bool)
}
deriving stock (Generic)
instance ToJSON TokenPairResponse where
toJSON response =
object $
catMaybes
[ ("accessToken" Aeson..=) <$> response.accessToken,
("refreshToken" Aeson..=) <$> response.refreshToken,
Just ("expiresIn" Aeson..= response.expiresIn),
("remembered" Aeson..=) <$> response.remembered
]
instance FromJSON TokenPairResponse where
parseJSON = withObject "TokenPairResponse" \objectValue ->
TokenPairResponse
<$> objectValue Aeson..:? "accessToken"
<*> objectValue Aeson..:? "refreshToken"
<*> objectValue .: "expiresIn"
<*> objectValue Aeson..:? "remembered"
data LoginRequest = LoginRequest
{ loginId :: !Text,
password :: !Text,
-- | stay signed in: a remembered session lasts @rememberedSessionTTL@. Absent means no.
remember :: !(Maybe Bool)
}
deriving stock (Generic)
deriving anyclass (FromJSON, ToJSON)
data LoginResponse
= LoginCompleteResponse
{ user :: !UserResponse,
token :: !TokenPairResponse
}
| LoginMfaRequiredResponse
{ ceremonyId :: !Text,
options :: !Value,
methods :: ![Text]
}
deriving stock (Generic)
instance ToJSON LoginResponse where
toJSON = \case
LoginCompleteResponse user token ->
object ["status" Aeson..= ("complete" :: Text), "user" Aeson..= user, "token" Aeson..= token]
LoginMfaRequiredResponse ceremonyId options methods ->
object
[ "status" Aeson..= ("mfa_required" :: Text),
"ceremonyId" Aeson..= ceremonyId,
"options" Aeson..= options,
"methods" Aeson..= methods
]
instance FromJSON LoginResponse where
parseJSON = withObject "LoginResponse" \objectValue -> do
status <- objectValue .: "status" :: Parser Text
case status of
"complete" -> LoginCompleteResponse <$> objectValue .: "user" <*> objectValue .: "token"
"mfa_required" -> LoginMfaRequiredResponse <$> objectValue .: "ceremonyId" <*> objectValue .: "options" <*> objectValue .: "methods"
other -> fail ("unknown login status: " <> Text.unpack other)
newtype RefreshRequest = RefreshRequest {refreshToken :: Maybe Text}
deriving stock (Generic)
deriving anyclass (FromJSON, ToJSON)
data SessionResponse = SessionResponse
{ sessionId :: !Text,
userId :: !Text,
createdAt :: !Text,
expiresAt :: !Text,
status :: !Text,
revokedAt :: !(Maybe Text)
}
deriving stock (Generic)
deriving anyclass (FromJSON, ToJSON)
tokenPairToResponse :: ShomeiConfig -> TokenPair -> TokenPairResponse
tokenPairToResponse config pair =
TokenPairResponse
{ accessToken = bodyToken (unAccess pair.accessToken),
refreshToken = bodyToken (unRefresh pair.refreshToken),
expiresIn = round (realToFrac pair.expiresIn :: Double),
remembered = Just pair.remembered
}
where
bodyToken token = if transportIncludesBodyTokens config.tokenTransport then Just token else Nothing
unAccess (AccessToken token) = token
unRefresh (RefreshToken token) = token
loginResultToResponse :: ShomeiConfig -> LoginResult -> LoginResponse
loginResultToResponse config = \case
LoginComplete user pair ->
LoginCompleteResponse {user = userToResponse user, token = tokenPairToResponse config pair}
MfaRequired (MfaChallenge ceremonyId options methods) ->
LoginMfaRequiredResponse {ceremonyId = idText ceremonyId, options = options, methods = methods}
sessionToResponse :: Session -> SessionResponse
sessionToResponse session =
SessionResponse
{ sessionId = idText session.sessionId,
userId = idText session.userId,
createdAt = Text.pack (iso8601Show session.createdAt),
expiresAt = Text.pack (iso8601Show session.expiresAt),
status = renderStatus session.status,
revokedAt = Text.pack . iso8601Show <$> session.revokedAt
}
where
renderStatus = \case
SessionActive -> "active"
SessionRevoked -> "revoked"
SessionExpired -> "expired"
data SessionsPage = SessionsPage
{ sessions :: ![SessionResponse],
nextCursor :: !(Maybe Text)
}
deriving stock (Generic)
deriving anyclass (FromJSON, ToJSON)
newtype SessionPageCursor = SessionPageCursor {sessionCursor :: SessionCursor}
deriving stock (Eq, Show)
instance FromHttpApiData SessionPageCursor where
parseUrlPiece value = maybe (Left "invalid session cursor") (Right . SessionPageCursor) (decodeSessionCursor value)
instance ToHttpApiData SessionPageCursor where
toUrlPiece = encodeSessionCursor . (.sessionCursor)
encodeSessionCursor :: SessionCursor -> Text
encodeSessionCursor cursor = Text.pack (iso8601Show cursor.cursorCreatedAt) <> ";" <> UUID.toText (sessionIdToUUID cursor.cursorSessionId)
decodeSessionCursor :: Text -> Maybe SessionCursor
decodeSessionCursor value = case Text.breakOn ";" value of
(timestamp, rest)
| Just identifier <- Text.stripPrefix ";" rest ->
SessionCursor <$> iso8601ParseM (Text.unpack timestamp) <*> (sessionIdFromUUID <$> UUID.fromText identifier)
_ -> Nothing