shomei-postgres-0.2.0.0: src/Shomei/OAuth/Client/Postgres.hs
-- | PostgreSQL interpreter for the EP-5 OAuth-client store.
module Shomei.OAuth.Client.Postgres
( runOAuthClientStorePostgres,
)
where
import Contravariant.Extras (contrazip2, contrazip8)
import Data.Aeson (Result (..), Value)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Error (AuthError (..))
import Shomei.Id (oauthClientIdFromUUID, oauthClientIdToUUID)
import Shomei.OAuth.Client.Domain
( ClientType (..),
NewOAuthClient (..),
OAuthClient (..),
OAuthClientStatus (..),
)
import Shomei.OAuth.Client.Store (OAuthClientStore (..))
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude
-- | The stored row, column order matching @shomei_oauth_clients@:
-- @(oauth_client_id, client_id, secret_hash, client_type, display_name, redirect_uris,
-- allowed_scopes, status, created_at, revoked_at)@. @redirect_uris@ and @allowed_scopes@ ride as
-- @jsonb@ arrays of text, as @shomei_service_accounts.allowed_scopes@ does.
type OAuthClientRow = (UUID, Text, Maybe Text, Text, Text, Value, Value, Text, UTCTime, Maybe UTCTime)
-- | The @client_type@ column's two values, in one place so encoder and decoder cannot drift.
renderClientType :: ClientType -> Text
renderClientType = \case
ConfidentialClient -> "confidential"
PublicClient -> "public"
parseClientType :: Text -> Either Text ClientType
parseClientType = \case
"confidential" -> Right ConfidentialClient
"public" -> Right PublicClient
other -> Left ("invalid oauth client_type: " <> other)
renderStatus :: OAuthClientStatus -> Text
renderStatus = \case
OAuthClientActive -> "active"
OAuthClientRevoked -> "revoked"
parseStatus :: Text -> Either Text OAuthClientStatus
parseStatus = \case
"active" -> Right OAuthClientActive
"revoked" -> Right OAuthClientRevoked
other -> Left ("invalid oauth client status: " <> other)
runOAuthClientStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (OAuthClientStore : es) a ->
Eff es a
runOAuthClientStorePostgres = interpret_ \case
CreateOAuthClient NewOAuthClient {oauthClientId, clientId, secretHash, clientType, displayName, redirectUris, allowedScopes, createdAt} -> do
let oc =
OAuthClient
{ oauthClientId,
clientId,
secretHash,
clientType,
displayName,
redirectUris,
allowedScopes,
status = OAuthClientActive,
createdAt,
revokedAt = Nothing
}
res <- runSession (Session.statement (toInsertRow oc) insertStmt)
either dbFail (const (pure oc)) res
FindOAuthClientByClientId cid -> do
res <- runSession (Session.statement cid findByClientIdStmt)
row <- either dbFail pure res
traverse rebuild row
ListOAuthClients -> do
res <- runSession (Session.statement () listStmt)
rows <- either dbFail pure res
traverse rebuild rows
RevokeOAuthClient cid t -> do
res <- runSession (Session.statement (oauthClientIdToUUID cid, t) revokeStmt)
either dbFail (const (pure ())) res
where
dbFail = throwError . postgresUnavailable
rebuild r = either (throwError . InternalAuthError) pure (rebuildOAuthClient r)
-- | The eight columns an INSERT writes. @revoked_at@ is always NULL on a fresh row, so it is a
-- literal in the statement rather than a parameter.
type InsertRow = (UUID, Text, Maybe Text, Text, Text, Value, Value, UTCTime)
toInsertRow :: OAuthClient -> InsertRow
toInsertRow OAuthClient {oauthClientId, clientId, secretHash, clientType, displayName, redirectUris, allowedScopes, createdAt} =
( oauthClientIdToUUID oauthClientId,
clientId,
secretHash,
renderClientType clientType,
displayName,
toJSON redirectUris,
toJSON (Set.toList allowedScopes),
createdAt
)
rebuildOAuthClient :: OAuthClientRow -> Either Text OAuthClient
rebuildOAuthClient (ocid, cid, sh, ct, dn, urisJson, scopesJson, st, ca, ra) = do
redirectUris <- case fromJSON urisJson of
Success us -> Right us
Error msg -> Left ("invalid redirect_uris json: " <> Text.pack msg)
scopes <- case fromJSON scopesJson of
Success ss -> Right (Set.fromList ss)
Error msg -> Left ("invalid allowed_scopes json: " <> Text.pack msg)
clientType <- parseClientType ct
status <- parseStatus st
pure
OAuthClient
{ oauthClientId = oauthClientIdFromUUID ocid,
clientId = cid,
secretHash = sh,
clientType,
displayName = dn,
redirectUris,
allowedScopes = scopes,
status,
createdAt = ca,
revokedAt = ra
}
oauthClientRowDecoder :: D.Row OAuthClientRow
oauthClientRowDecoder =
(,,,,,,,,,)
<$> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.jsonb)
<*> D.column (D.nonNullable D.jsonb)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
insertRowEncoder :: E.Params InsertRow
insertRowEncoder =
contrazip8
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.jsonb))
(E.param (E.nonNullable E.jsonb))
(E.param (E.nonNullable E.timestamptz))
-- | The SELECT column list (matches 'OAuthClientRow' / 'oauthClientRowDecoder' order).
selectCols :: Text
selectCols =
"oauth_client_id, client_id, secret_hash, client_type, display_name, redirect_uris, allowed_scopes, status, created_at, revoked_at"
insertStmt :: Statement InsertRow ()
insertStmt =
preparable
"""
INSERT INTO shomei.shomei_oauth_clients
(oauth_client_id, client_id, secret_hash, client_type, display_name, redirect_uris,
allowed_scopes, status, created_at, revoked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, NULL)
"""
insertRowEncoder
D.noResult
findByClientIdStmt :: Statement Text (Maybe OAuthClientRow)
findByClientIdStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_oauth_clients WHERE client_id = $1")
(E.param (E.nonNullable E.text))
(D.rowMaybe oauthClientRowDecoder)
-- | Newest first, tie-broken by id so the order is total (the in-memory interpreter sorts the
-- same way, and the servant suite walks both).
listStmt :: Statement () [OAuthClientRow]
listStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_oauth_clients ORDER BY created_at DESC, oauth_client_id DESC")
E.noParams
(D.rowList oauthClientRowDecoder)
revokeStmt :: Statement (UUID, UTCTime) ()
revokeStmt =
preparable
"""
UPDATE shomei.shomei_oauth_clients
SET status = 'revoked', revoked_at = $2
WHERE oauth_client_id = $1
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
D.noResult