packages feed

oauth2-server-0.3.0.0: test/Web/OAuth2/TokenSpec.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}

module Web.OAuth2.TokenSpec (tests) where

import Control.Concurrent.Async (concurrently)
import Control.Concurrent.MVar (MVar, readMVar)
import Crypto.Hash (SHA256 (..), hashWith)
import Data.Aeson (Value (..), eitherDecode)
import Data.Aeson.KeyMap qualified as KM
import Data.ByteArray qualified as BA
import Data.ByteString qualified as BS
import Data.ByteString.Base64.URL qualified as B64URL
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust, isNothing)
import Data.Text (Text)
import Data.Text.Encoding qualified as TE
import Data.Time.Clock
import Network.HTTP.Types (hContentType, methodPost, status200, status400, status401)
import Network.Wai (Application, requestHeaders, requestMethod)
import Network.Wai.Test
import Servant.API.ResponseHeaders (getHeaders, getResponse)
import Test.Tasty
import Test.Tasty.HUnit
import Web.OAuth2.Internal (TokenRequest (..), TokenResponse (..), TokenResponseHeaders, handleTokenRequest)
import Web.OAuth2.TestUtils
import Web.OAuth2.Types hiding (error)
import Web.OAuth2.Types qualified as OAuthTypes

tests :: TestTree
tests =
  testGroup
    "Token endpoint"
    [ tokenEndpointIntegrationTests
    , handlerLevelTests
    ]

tokenEndpointIntegrationTests :: TestTree
tokenEndpointIntegrationTests =
  testGroup
    "Integration"
    [ rejectsMissingVerifier
    , rejectsInvalidVerifier
    , rejectsExpiredAuthCode
    , confidentialClientsRequireSecret
    , rejectsUnknownRefreshToken
    , rejectsRefreshScopeEscalation
    , authorizationCodeSingleUseConcurrent
    , refreshTokenSingleUseConcurrent
    , rejectsDisallowedGrantType
    , rejectsRefreshTokenClientMismatch
    , pkceS256AcceptsValidVerifier
    , pkceDefaultsToPlainWhenMethodOmitted
    , rejectsUnsupportedPkceMethod
    ]

handlerLevelTests :: TestTree
handlerLevelTests =
  testGroup
    "Handler behaviour"
    [ noRefreshTokenIssuedForClientsWithoutGrant
    , refreshTokenIssuedWhenAllowed
    ]

withFreshApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a
withFreshApp action = do
  (stateVar, _ctx, app) <- createTestApplication
  action stateVar app

decodeOAuthError :: LBS.ByteString -> IO OAuthError
decodeOAuthError body =
  case eitherDecode body of
    Left err -> assertFailure ("Failed to decode OAuthError: " <> err)
    Right val -> pure val

postToken :: Application -> LBS.ByteString -> IO SResponse
postToken app body = do
  let req =
        SRequest
          ( setPath defaultRequest "/token"
              ){ requestMethod = methodPost
               , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
               }
          body
  runSession (srequest req) app

mkAuthCodeEntry
  :: Text
  -> Text
  -> Text
  -> Text
  -> UTCTime
  -> Maybe Text
  -> Maybe Text
  -> AuthCode TestUser
mkAuthCodeEntry value clientId redirect scope expiry challenge method =
  AuthCode
    { auth_code_value = value
    , auth_code_client_id = clientId
    , auth_code_user = testUser
    , auth_code_redirect_uri = redirect
    , auth_code_scope = scope
    , auth_code_expiry = expiry
    , auth_code_challenge = challenge
    , auth_code_challenge_method = method
    }

rejectsMissingVerifier :: TestTree
rejectsMissingVerifier = testCase "enforces PKCE code_verifier when challenge stored" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-1" ["http://localhost:4000/cb"] "read write")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-pkce" "pub-1" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "challenge") (Just "plain")
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-pkce")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-1")
            ]
        )
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_request"

rejectsInvalidVerifier :: TestTree
rejectsInvalidVerifier = testCase "rejects mismatched code_verifier" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-2" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-wrong" "pub-2" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "correct") (Just "plain")
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-wrong")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-2")
            , ("code_verifier", "incorrect")
            ]
        )
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_grant"
    OAuthTypes.error_description errResp @?= Just "Invalid code verifier"

rejectsExpiredAuthCode :: TestTree
rejectsExpiredAuthCode = testCase "rejects expired authorization codes" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-3" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-expired" "pub-3" "http://localhost:4000/cb" "read" (addUTCTime (-30) now) (Just "verifier") (Just "plain")
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-expired")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-3")
            , ("code_verifier", "verifier")
            ]
        )
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_grant"
    OAuthTypes.error_description errResp @?= Just "Authorization code expired"

confidentialClientsRequireSecret :: TestTree
confidentialClientsRequireSecret = testCase "confidential clients must provide client_secret" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkConfidentialClient "conf-1" "top-secret" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-conf" "conf-1" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "secret") Nothing
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-conf")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "conf-1")
            , ("code_verifier", "secret")
            ]
        )
    simpleStatus res @?= status401
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_client"
    st <- readMVar stateVar
    Map.member "code-conf" (auth_codes st) @?= True

rejectsUnknownRefreshToken :: TestTree
rejectsUnknownRefreshToken = testCase "rejects refresh_token grant when token missing" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-4" ["http://localhost:4000/cb"] "read")
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "refresh_token")
            , ("refresh_token", "does-not-exist")
            , ("client_id", "pub-4")
            ]
        )
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_grant"

rejectsRefreshScopeEscalation :: TestTree
rejectsRefreshScopeEscalation = testCase "rejects refresh token with scope outside client allowance" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-5" ["http://localhost:4000/cb"] "read")
    let refreshTok =
          RefreshToken
            { refresh_token_value = "rt-1"
            , refresh_token_client_id = "pub-5"
            , refresh_token_user = testUser
            , refresh_token_scope = "write"
            }
    addRefreshTokenToState stateVar refreshTok
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "refresh_token")
            , ("refresh_token", "rt-1")
            , ("client_id", "pub-5")
            ]
        )
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_scope"

authorizationCodeSingleUseConcurrent :: TestTree
authorizationCodeSingleUseConcurrent = testCase "authorization codes cannot be redeemed concurrently" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-6" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-concurrent" "pub-6" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "verifier") (Just "plain")
        body =
          encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-concurrent")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-6")
            , ("code_verifier", "verifier")
            ]
    addAuthCodeToState stateVar authCode
    (resA, resB) <- concurrently (postToken app body) (postToken app body)
    let statuses = sort [simpleStatus resA, simpleStatus resB]
    statuses @?= [status200, status400]
    let (failureRes, successRes) = if simpleStatus resA == status400 then (resA, resB) else (resB, resA)
    lookup hContentType (simpleHeaders failureRes) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse failureRes
    errResp <- decodeOAuthError (simpleBody failureRes)
    OAuthTypes.error errResp @?= "invalid_grant"
    simpleStatus successRes @?= status200
    assertNoStoreHeadersResponse successRes
    stateAfter <- readMVar stateVar
    Map.member "code-concurrent" (auth_codes stateAfter) @?= False

refreshTokenSingleUseConcurrent :: TestTree
refreshTokenSingleUseConcurrent = testCase "refresh tokens rotate under concurrent use" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-7" ["http://localhost:4000/cb"] "read")
    let originalToken =
          RefreshToken
            { refresh_token_value = "rt-concurrent"
            , refresh_token_client_id = "pub-7"
            , refresh_token_user = testUser
            , refresh_token_scope = "read"
            }
        body =
          encodeForm
            [ ("grant_type", "refresh_token")
            , ("refresh_token", "rt-concurrent")
            , ("client_id", "pub-7")
            ]
    addRefreshTokenToState stateVar originalToken
    (resA, resB) <- concurrently (postToken app body) (postToken app body)
    let statuses = sort [simpleStatus resA, simpleStatus resB]
    statuses @?= [status200, status400]
    let (failureRes, successRes) = if simpleStatus resA == status400 then (resA, resB) else (resB, resA)
    lookup hContentType (simpleHeaders failureRes) @?= Just "application/json; charset=utf-8"
    assertNoStoreHeadersResponse failureRes
    errResp <- decodeOAuthError (simpleBody failureRes)
    OAuthTypes.error errResp @?= "invalid_grant"
    simpleStatus successRes @?= status200
    assertNoStoreHeadersResponse successRes
    successValue <-
      case eitherDecode (simpleBody successRes) :: Either String Value of
        Left err -> assertFailure ("Failed to decode success token response: " <> err)
        Right val -> pure val
    newRefresh <-
      case successValue of
        Object obj ->
          case KM.lookup "refresh_token" obj of
            Just (String t) -> pure t
            _ -> assertFailure "refresh_token missing from successful response"
        _ -> assertFailure "Expected object in token response"
    assertBool "rotation produced new token" (newRefresh /= refresh_token_value originalToken)
    stateAfter <- readMVar stateVar
    let persistence = refresh_token_persistence stateAfter
    oldToken <- lookupRefreshToken persistence (refresh_token_value originalToken)
    assertBool "old refresh token removed" (isNothing oldToken)
    newToken <- lookupRefreshToken persistence newRefresh
    assertBool "new refresh token persisted" (isJust newToken)

noRefreshTokenIssuedForClientsWithoutGrant :: TestTree
noRefreshTokenIssuedForClientsWithoutGrant =
  testCase "authorization_code clients without refresh grant do not receive refresh tokens" $ do
    (persistence, readPersisted) <- mkTrackingPersistence
    let client =
          mkRegisteredClient
            "public-client"
            ["https://client.example/callback"]
            ["authorization_code"]
            ["code"]
            "read"
            "none"
            Nothing
    expiry <- addUTCTime 600 <$> getCurrentTime
    let user = testUser
        authCode = mkAuthCode "auth-code-1" client user expiry "https://client.example/callback" (Just "verifier") Nothing
    stateVar <- mkState persistence [client] [("auth-code-1", authCode)]
    jwtSettings <- mkJWTSettings
    let tokenRequest =
          TokenRequest
            { grant_type = "authorization_code"
            , code = Just "auth-code-1"
            , refresh_token = Nothing
            , redirect_uri = Just "https://client.example/callback"
            , client_id = "public-client"
            , client_secret = Nothing
            , code_verifier = Just "verifier"
            }
    result <- runHandler $ handleTokenRequest stateVar (jwtContext jwtSettings) tokenRequest
    tokenResponseHeaders <-
      either (assertFailure . ("Token handler failed: " <>) . show) pure result
    let tokenResponse = getResponse tokenResponseHeaders
    assertBool "refresh_token should be omitted" (isNothing (refresh_token_resp tokenResponse))
    persisted <- readPersisted
    assertBool "no refresh token should be persisted" (null persisted)
    stateAfter <- readMVar stateVar
    assertBool "authorization code should be cleared" (null (auth_codes stateAfter))
    assertNoStoreHeadersFromHeaders tokenResponseHeaders

refreshTokenIssuedWhenAllowed :: TestTree
refreshTokenIssuedWhenAllowed =
  testCase "authorization_code clients with refresh grant receive refresh tokens" $ do
    (persistence, readPersisted) <- mkTrackingPersistence
    let client =
          mkRegisteredClient
            "confidential-client"
            ["https://conf.example/cb"]
            ["authorization_code", "refresh_token"]
            ["code"]
            "read"
            "none"
            Nothing
    expiry <- addUTCTime 600 <$> getCurrentTime
    let user = testUser
        authCode = mkAuthCode "auth-code-2" client user expiry "https://conf.example/cb" (Just "verifier") Nothing
    stateVar <- mkState persistence [client] [("auth-code-2", authCode)]
    jwtSettings <- mkJWTSettings
    let tokenRequest =
          TokenRequest
            { grant_type = "authorization_code"
            , code = Just "auth-code-2"
            , refresh_token = Nothing
            , redirect_uri = Just "https://conf.example/cb"
            , client_id = "confidential-client"
            , client_secret = Nothing
            , code_verifier = Just "verifier"
            }
    result <- runHandler $ handleTokenRequest stateVar (jwtContext jwtSettings) tokenRequest
    tokenResponseHeaders <-
      either (assertFailure . ("Token handler failed: " <>) . show) pure result
    let tokenResponse = getResponse tokenResponseHeaders
    assertBool "refresh_token should be present" (isJust (refresh_token_resp tokenResponse))
    persisted <- readPersisted
    assertEqual "a refresh token should be persisted" 1 (length persisted)
    stateAfter <- readMVar stateVar
    assertBool "authorization code should be cleared" (null (auth_codes stateAfter))
    assertNoStoreHeadersFromHeaders tokenResponseHeaders

assertNoStoreHeadersFromHeaders :: TokenResponseHeaders -> Assertion
assertNoStoreHeadersFromHeaders headers = do
  let actualHeaders = fmap (BS8.unpack . snd) (getHeaders headers)
  assertBool "Cache-Control no-store" ("no-store" `elem` actualHeaders)
  assertBool "Pragma no-cache" ("no-cache" `elem` actualHeaders)

assertNoStoreHeadersResponse :: SResponse -> Assertion
assertNoStoreHeadersResponse res = do
  lookup "Cache-Control" (simpleHeaders res) @?= Just "no-store"
  lookup "Pragma" (simpleHeaders res) @?= Just "no-cache"

rejectsDisallowedGrantType :: TestTree
rejectsDisallowedGrantType = testCase "rejects grant_type not in client's allowed grants" $
  withFreshApp $ \stateVar app -> do
    let client = mkRegisteredClient "authonly" ["http://localhost:4000/cb"] ["authorization_code"] ["code"] "read" "none" Nothing
    addRegisteredClientToState stateVar client
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "refresh_token")
            , ("refresh_token", "rt-any")
            , ("client_id", "authonly")
            ]
        )
    simpleStatus res @?= status400
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "unauthorized_client"

rejectsRefreshTokenClientMismatch :: TestTree
rejectsRefreshTokenClientMismatch = testCase "rejects refresh token issued to a different client" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "client-a" ["http://localhost:4000/cb"] "read")
    addRegisteredClientToState stateVar (mkPublicClient "client-b" ["http://localhost:4000/cb"] "read")
    let refreshTok =
          RefreshToken
            { refresh_token_value = "rt-stolen"
            , refresh_token_client_id = "client-a"
            , refresh_token_user = testUser
            , refresh_token_scope = "read"
            }
    addRefreshTokenToState stateVar refreshTok
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "refresh_token")
            , ("refresh_token", "rt-stolen")
            , ("client_id", "client-b")
            ]
        )
    simpleStatus res @?= status400
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_grant"
    OAuthTypes.error_description errResp @?= Just "Client ID mismatch"

-- | Compute a PKCE S256 challenge from a verifier.
s256Challenge :: Text -> Text
s256Challenge verifier =
  let hash = hashWith SHA256 (TE.encodeUtf8 verifier)
      hashBytes = BS.pack (BA.unpack hash)
  in  TE.decodeUtf8 (B64URL.encodeUnpadded hashBytes)

pkceS256AcceptsValidVerifier :: TestTree
pkceS256AcceptsValidVerifier = testCase "accepts valid S256 PKCE code_verifier" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-s256" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
        challenge = s256Challenge verifier
        authCode = mkAuthCodeEntry "code-s256" "pub-s256" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just challenge) (Just "S256")
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-s256")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-s256")
            , ("code_verifier", verifier)
            ]
        )
    simpleStatus res @?= status200
    assertNoStoreHeadersResponse res

pkceDefaultsToPlainWhenMethodOmitted :: TestTree
pkceDefaultsToPlainWhenMethodOmitted = testCase "PKCE defaults to plain verification when method is omitted" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-nomethod" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let verifier = "my-plain-verifier"
        authCode = mkAuthCodeEntry "code-nomethod" "pub-nomethod" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just verifier) Nothing
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-nomethod")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-nomethod")
            , ("code_verifier", verifier)
            ]
        )
    simpleStatus res @?= status200
    assertNoStoreHeadersResponse res

rejectsUnsupportedPkceMethod :: TestTree
rejectsUnsupportedPkceMethod = testCase "rejects unsupported PKCE code_challenge_method" $
  withFreshApp $ \stateVar app -> do
    addRegisteredClientToState stateVar (mkPublicClient "pub-badmethod" ["http://localhost:4000/cb"] "read")
    now <- getCurrentTime
    let authCode = mkAuthCodeEntry "code-badmethod" "pub-badmethod" "http://localhost:4000/cb" "read" (addUTCTime 600 now) (Just "challenge") (Just "RS256")
    addAuthCodeToState stateVar authCode
    res <-
      postToken
        app
        ( encodeForm
            [ ("grant_type", "authorization_code")
            , ("code", "code-badmethod")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("client_id", "pub-badmethod")
            , ("code_verifier", "challenge")
            ]
        )
    simpleStatus res @?= status400
    assertNoStoreHeadersResponse res
    errResp <- decodeOAuthError (simpleBody res)
    OAuthTypes.error errResp @?= "invalid_grant"