shomei-postgres-0.2.0.0: src/Shomei/Passkey/Postgres.hs
-- | PostgreSQL interpreter for the registered-passkey store.
module Shomei.Passkey.Postgres
( runPasskeyStorePostgres,
)
where
import Contravariant.Extras (contrazip10, contrazip2, contrazip3)
import Data.Aeson (Result (..), Value)
import Data.ByteString (ByteString)
import Data.Int (Int64)
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
( genPasskeyId,
passkeyIdFromUUID,
passkeyIdToUUID,
userIdFromUUID,
userIdToUUID,
)
import Shomei.Passkey.Domain
( NewPasskeyCredential (..),
PasskeyCredential (..),
PublicKeyBytes (..),
SignatureCounter (..),
UserHandle (..),
WebAuthnCredentialId (..),
)
import Shomei.Passkey.Store (PasskeyStore (..))
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude
-- | The stored-credential row, column order matching @shomei_webauthn_credentials@:
-- @(passkey_id, user_id, credential_id, user_handle, public_key, sign_counter, transports,
-- label, created_at, last_used_at)@. The 'Word32' signature counter is stored as a signed
-- @bigint@ (it overflows @int4@ but fits @int8@); @transports :: [Text]@ rides as @jsonb@.
type PasskeyRow = (UUID, UUID, ByteString, ByteString, ByteString, Int64, Value, Maybe Text, UTCTime, Maybe UTCTime)
runPasskeyStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (PasskeyStore : es) a ->
Eff es a
runPasskeyStorePostgres = interpret_ \case
CreatePasskey NewPasskeyCredential {userId, credentialId, userHandle, publicKey, signCounter, transports, label, createdAt} -> do
pid <- genPasskeyId
let pc =
PasskeyCredential
{ passkeyId = pid,
userId,
credentialId,
userHandle,
publicKey,
signCounter,
transports,
label,
createdAt,
lastUsedAt = Nothing
}
res <- runSession (Session.statement (toRow pc) insertStmt)
either dbFail (const (pure pc)) res
FindPasskeysByUser uid -> do
res <- runSession (Session.statement (userIdToUUID uid) findByUserStmt)
rows <- either dbFail pure res
traverse rebuild rows
FindPasskeyByCredentialId (WebAuthnCredentialId cid) -> do
res <- runSession (Session.statement cid findByCredentialIdStmt)
row <- either dbFail pure res
traverse rebuild row
FindPasskeysByUserHandle (UserHandle uh) -> do
res <- runSession (Session.statement uh findByUserHandleStmt)
rows <- either dbFail pure res
traverse rebuild rows
UpdatePasskeySignCounter pid (SignatureCounter c) t -> do
res <- runSession (Session.statement (passkeyIdToUUID pid, fromIntegral c :: Int64, t) updateSignCounterStmt)
accepted <- either dbFail pure res
pure (isJust accepted)
DeletePasskey uid pid -> do
res <- runSession (Session.statement (userIdToUUID uid, passkeyIdToUUID pid) deletePasskeyStmt)
either dbFail (const (pure ())) res
CountPasskeysByUser uid -> do
res <- runSession (Session.statement (userIdToUUID uid) countByUserStmt)
n <- either dbFail pure res
pure (fromIntegral n)
where
dbFail = throwError . postgresUnavailable
rebuild r = either (throwError . InternalAuthError) pure (rebuildPasskey r)
-- | Flatten a 'PasskeyCredential' into its stored row (unwrapping the byte newtypes and
-- widening the 'Word32' counter to 'Int64').
toRow :: PasskeyCredential -> PasskeyRow
toRow
PasskeyCredential
{ passkeyId,
userId,
credentialId = WebAuthnCredentialId cid,
userHandle = UserHandle uh,
publicKey = PublicKeyBytes pk,
signCounter = SignatureCounter sc,
transports,
label,
createdAt,
lastUsedAt
} =
( passkeyIdToUUID passkeyId,
userIdToUUID userId,
cid,
uh,
pk,
fromIntegral sc,
toJSON transports,
label,
createdAt,
lastUsedAt
)
rebuildPasskey :: PasskeyRow -> Either Text PasskeyCredential
rebuildPasskey (pid, uid, cid, uh, pk, sc, tj, lbl, ca, lua) = do
ts <- case fromJSON tj of
Success ts -> Right ts
Error msg -> Left ("invalid transports json: " <> Text.pack msg)
pure
PasskeyCredential
{ passkeyId = passkeyIdFromUUID pid,
userId = userIdFromUUID uid,
credentialId = WebAuthnCredentialId cid,
userHandle = UserHandle uh,
publicKey = PublicKeyBytes pk,
signCounter = SignatureCounter (fromIntegral sc),
transports = ts,
label = lbl,
createdAt = ca,
lastUsedAt = lua
}
passkeyRowDecoder :: D.Row PasskeyRow
passkeyRowDecoder =
(,,,,,,,,,)
<$> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.bytea)
<*> D.column (D.nonNullable D.bytea)
<*> D.column (D.nonNullable D.bytea)
<*> D.column (D.nonNullable D.int8)
<*> D.column (D.nonNullable D.jsonb)
<*> D.column (D.nullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
passkeyRowEncoder :: E.Params PasskeyRow
passkeyRowEncoder =
contrazip10
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.bytea))
(E.param (E.nonNullable E.bytea))
(E.param (E.nonNullable E.bytea))
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.jsonb))
(E.param (E.nullable E.text))
(E.param (E.nonNullable E.timestamptz))
(E.param (E.nullable E.timestamptz))
-- | The SELECT column list (matches 'PasskeyRow' / 'passkeyRowDecoder' order).
selectCols :: Text
selectCols =
"passkey_id, user_id, credential_id, user_handle, public_key, sign_counter, transports, label, created_at, last_used_at"
insertStmt :: Statement PasskeyRow ()
insertStmt =
preparable
"""
INSERT INTO shomei.shomei_webauthn_credentials
(passkey_id, user_id, credential_id, user_handle, public_key, sign_counter,
transports, label, created_at, last_used_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"""
passkeyRowEncoder
D.noResult
findByUserStmt :: Statement UUID [PasskeyRow]
findByUserStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_webauthn_credentials WHERE user_id = $1")
(E.param (E.nonNullable E.uuid))
(D.rowList passkeyRowDecoder)
findByCredentialIdStmt :: Statement ByteString (Maybe PasskeyRow)
findByCredentialIdStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_webauthn_credentials WHERE credential_id = $1")
(E.param (E.nonNullable E.bytea))
(D.rowMaybe passkeyRowDecoder)
findByUserHandleStmt :: Statement ByteString [PasskeyRow]
findByUserHandleStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_webauthn_credentials WHERE user_handle = $1")
(E.param (E.nonNullable E.bytea))
(D.rowList passkeyRowDecoder)
updateSignCounterStmt :: Statement (UUID, Int64, UTCTime) (Maybe UUID)
updateSignCounterStmt =
preparable
"""
UPDATE shomei.shomei_webauthn_credentials
SET sign_counter = $2, last_used_at = $3
WHERE passkey_id = $1
AND (sign_counter < $2 OR ($2 = 0 AND sign_counter = 0))
RETURNING passkey_id
"""
( contrazip3
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.int8))
(E.param (E.nonNullable E.timestamptz))
)
(D.rowMaybe (D.column (D.nonNullable D.uuid)))
deletePasskeyStmt :: Statement (UUID, UUID) ()
deletePasskeyStmt =
preparable
"""
DELETE FROM shomei.shomei_webauthn_credentials
WHERE user_id = $1 AND passkey_id = $2
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.uuid)))
D.noResult
countByUserStmt :: Statement UUID Int64
countByUserStmt =
preparable
"""
SELECT count(*) FROM shomei.shomei_webauthn_credentials WHERE user_id = $1
"""
(E.param (E.nonNullable E.uuid))
(D.singleRow (D.column (D.nonNullable D.int8)))