packages feed

shomei-core-0.2.0.0: test/Shomei/OAuthClientStoreSpec.hs

{-# LANGUAGE DataKinds #-}

-- | Pure tests for the in-memory 'Shomei.OAuth.Client.Store' interpreter
-- ('Shomei.Test.InMemory.runOAuthClientStore').
--
-- They prove the persistence contract EP-5's authorization-code flow builds on, against the fake
-- 'World': a client can be created and found by its client id; a public client stores no secret
-- hash at all; a client can be revoked (status flips, @revoked_at@ is stamped, and the row
-- survives so the lookup still resolves and the authorize endpoint can refuse it); and the
-- listing is newest-first. The same behavior is re-proven against real PostgreSQL by
-- @shomei-postgres@'s integration test.
--
-- 'isRegisteredRedirectUri' is tested here too: it is the single rule that keeps
-- @GET \/oauth\/authorize@ from being an open redirector, and it is pure.
module Shomei.OAuthClientStoreSpec (tests) where

import Control.Monad.IO.Class (MonadIO)
import Data.IORef (IORef, newIORef)
import Data.Maybe (isNothing)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Shomei.Authorization.Claims.Domain (Scope (..))
import Shomei.Id (OAuthClientId, genOAuthClientId, idText)
import Shomei.OAuth.Client.Domain
  ( ClientType (..),
    NewOAuthClient (..),
    OAuthClient (..),
    OAuthClientStatus (..),
    isRegisteredRedirectUri,
  )
import Shomei.OAuth.Client.Store
  ( createOAuthClient,
    findOAuthClientByClientId,
    listOAuthClients,
    revokeOAuthClient,
  )
import Shomei.Test.InMemory (World, emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "OAuthClientStore (in-memory)"
    [ testCase "create then find by client id" createAndFind,
      testCase "a public client stores no secret hash" publicClientHasNoSecret,
      testCase "find by an unknown client id returns Nothing" findUnknown,
      testCase "revoke flips status, stamps revoked_at, and keeps the row" revoke,
      testCase "list is newest-first" listNewestFirst,
      testCase "a redirect uri matches only by exact string equality" redirectUriExactMatch
    ]

-- Field accessors: OverloadedRecordDot is unreliable for these DuplicateRecordFields
-- records (MasterPlan 3 discovery), so read them by record-pattern matching.

ocStatus :: OAuthClient -> OAuthClientStatus
ocStatus OAuthClient {status} = status

ocSecretHash :: OAuthClient -> Maybe Text
ocSecretHash OAuthClient {secretHash} = secretHash

ocRevokedAt :: OAuthClient -> Maybe UTCTime
ocRevokedAt OAuthClient {revokedAt} = revokedAt

ocClientId :: OAuthClient -> Text
ocClientId OAuthClient {clientId} = clientId

ocAllowedScopes :: OAuthClient -> Set Scope
ocAllowedScopes OAuthClient {allowedScopes} = allowedScopes

ocId :: OAuthClient -> OAuthClientId
ocId OAuthClient {oauthClientId} = oauthClientId

ocDisplayName :: OAuthClient -> Text
ocDisplayName OAuthClient {displayName} = displayName

t0 :: UTCTime
t0 = UTCTime (fromGregorian 2026 7 10) 0

newWorld :: IO (IORef World)
newWorld = newIORef (emptyWorld t0)

openidScope :: Set Scope
openidScope = Set.singleton (Scope "openid")

callbackUri :: Text
callbackUri = "https://app.example.com/callback"

-- | Build a 'NewOAuthClient' whose @client_id@ is its id's TypeID text, exactly as the CLI does.
mkNew :: (MonadIO m) => ClientType -> UTCTime -> Text -> m NewOAuthClient
mkNew clientType createdAt displayName = do
  ocid <- genOAuthClientId
  pure
    NewOAuthClient
      { oauthClientId = ocid,
        clientId = idText ocid,
        secretHash = case clientType of
          ConfidentialClient -> Just "hash-one"
          PublicClient -> Nothing,
        clientType,
        displayName,
        redirectUris = [callbackUri],
        allowedScopes = openidScope,
        createdAt
      }

createAndFind :: IO ()
createAndFind = do
  ref <- newWorld
  (created, found) <- runInMemory ref do
    new <- mkNew ConfidentialClient t0 "grafana"
    created <- createOAuthClient new
    found <- findOAuthClientByClientId (ocClientId created)
    pure (created, found)
  ocStatus created @?= OAuthClientActive
  ocRevokedAt created @?= Nothing
  ocSecretHash created @?= Just "hash-one"
  ocAllowedScopes created @?= openidScope
  fmap ocId found @?= Just (ocId created)

-- | A public client is issued no secret, rather than one that is stored and never checked.
publicClientHasNoSecret :: IO ()
publicClientHasNoSecret = do
  ref <- newWorld
  found <- runInMemory ref do
    new <- mkNew PublicClient t0 "spa"
    created <- createOAuthClient new
    findOAuthClientByClientId (ocClientId created)
  fmap ocSecretHash found @?= Just Nothing

findUnknown :: IO ()
findUnknown = do
  ref <- newWorld
  found <- runInMemory ref (findOAuthClientByClientId "oauthclient_nope")
  assertBool "unknown client id must not resolve" (isNothing found)

revoke :: IO ()
revoke = do
  ref <- newWorld
  let revokedTime = addUTCTime 7200 t0
  found <- runInMemory ref do
    new <- mkNew ConfidentialClient t0 "grafana"
    created <- createOAuthClient new
    revokeOAuthClient (ocId created) revokedTime
    -- The row survives revocation: the authorize endpoint must be able to see that this client
    -- exists and is revoked, so it refuses without redirecting.
    findOAuthClientByClientId (ocClientId created)
  fmap ocStatus found @?= Just OAuthClientRevoked
  fmap ocRevokedAt found @?= Just (Just revokedTime)

listNewestFirst :: IO ()
listNewestFirst = do
  ref <- newWorld
  clients <- runInMemory ref do
    older <- mkNew ConfidentialClient t0 "older"
    newer <- mkNew PublicClient (addUTCTime 60 t0) "newer"
    _ <- createOAuthClient older
    _ <- createOAuthClient newer
    listOAuthClients
  map ocDisplayName clients @?= ["newer", "older"]

-- | Every near-miss here is an open-redirector attempt: a prefix match, a suffix match, a
-- traversal, and a trailing slash all name a target the operator never registered.
redirectUriExactMatch :: IO ()
redirectUriExactMatch = do
  ref <- newWorld
  client <- runInMemory ref (createOAuthClient =<< mkNew ConfidentialClient t0 "grafana")
  assertBool "the registered uri matches" (isRegisteredRedirectUri client callbackUri)
  mapM_
    (\uri -> assertBool ("must not match: " <> show uri) (not (isRegisteredRedirectUri client uri)))
    [ "https://app.example.com/callback/",
      "https://app.example.com/callback/../evil",
      "https://app.example.com/callback?x=1",
      "https://app.example.com.evil.test/callback",
      "https://evil.test/https://app.example.com/callback",
      "http://app.example.com/callback"
    ]