packages feed

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

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

module Web.OAuth2.AuthorizeCallbackSpec (tests) where

import Control.Concurrent.MVar (MVar, readMVar)
import Data.Aeson (eitherDecode)
import Data.ByteString.Lazy qualified as LBS
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock (addUTCTime, getCurrentTime)
import Network.HTTP.Types (hContentType, hLocation, methodPost, status303, status400)
import Network.Wai (Application, requestHeaders, requestMethod)
import Network.Wai.Test
import Test.Tasty
import Test.Tasty.HUnit
import Web.OAuth2.TestUtils
import Web.OAuth2.Types

tests :: TestTree
tests =
  testGroup
    "Authorize callback endpoint"
    [ successfulLoginIssuesAuthCode
    , invalidCredentialsRedirectBack
    , rejectsTamperedRedirectUri
    , rejectsInvalidScope
    , rejectsMissingPkce
    , successfulLoginWithoutStateSkipsEcho
    ]

withApp :: (MVar (OAuthState TestUser) -> Application -> IO a) -> IO a
withApp action = do
  (stateVar, _ctx, app) <- createTestApplication
  addRegisteredClientToState stateVar (mkPublicClient "client-1" ["http://localhost:4000/cb"] "read write")
  action stateVar app

successfulLoginIssuesAuthCode :: TestTree
successfulLoginIssuesAuthCode = testCase "stores auth code and redirects with state" $
  withApp $ \stateVar app -> do
    now <- getCurrentTime
    let challenge = "verifier123"
        formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "testpass")
            , ("client_id", "client-1")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("scope", "read")
            , ("state", "my-state")
            , ("code_challenge", challenge)
            , ("code_challenge_method", "plain")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status303
    let location = lookup hLocation (simpleHeaders res)
    case location of
      Nothing -> assertFailure "Location header missing"
      Just loc -> do
        let locText = TE.decodeUtf8 loc
        assertBool "state propagated" ("state=my-state" `T.isInfixOf` locText)
        assertBool "authorization code parameter present" ("code=" `T.isInfixOf` locText)

    st <- readMVar stateVar
    let codes = Map.elems (auth_codes st)
    assertBool "auth code stored" (not (null codes))
    stored <- case codes of
          (x:_) -> pure x
          []    -> assertFailure "no auth codes stored"
    auth_code_client_id stored @?= "client-1"
    auth_code_scope stored @?= "read"
    auth_code_challenge stored @?= Just challenge
    auth_code_challenge_method stored @?= Just "plain"
    assertBool "expiry in future" (auth_code_expiry stored > addUTCTime (-1) now)

invalidCredentialsRedirectBack :: TestTree
invalidCredentialsRedirectBack = testCase "redirects back to authorize with error=invalid_password" $
  withApp $ \stateVar app -> do
    let formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "wrong")
            , ("client_id", "client-1")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("scope", "read write")
            , ("state", "orig-state")
            , ("code_challenge", "abc")
            , ("code_challenge_method", "S256")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status303
    let location = lookup hLocation (simpleHeaders res)
    case location of
      Nothing -> assertFailure "Location header missing"
      Just loc -> do
        let locText = TE.decodeUtf8 loc
        assertBool "redirected to authorize" ("../authorize" `T.isPrefixOf` locText)
        assertBool "state preserved" ("state=orig-state" `T.isInfixOf` locText)
        assertBool "error flag propagated" ("error=invalid_password" `T.isInfixOf` locText)

    st <- readMVar stateVar
    Map.size (auth_codes st) @?= 0

rejectsTamperedRedirectUri :: TestTree
rejectsTamperedRedirectUri = testCase "rejects redirect_uri not registered for client" $
  withApp $ \stateVar app -> do
    let formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "testpass")
            , ("client_id", "client-1")
            , ("redirect_uri", "https://attacker.invalid/cb")
            , ("scope", "read")
            , ("state", "state-1")
            , ("code_challenge", "challenge")
            , ("code_challenge_method", "plain")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    err <- decodeError (simpleBody res)
    Web.OAuth2.Types.error err @?= "unauthorized_client"
    st <- readMVar stateVar
    Map.size (auth_codes st) @?= 0

rejectsInvalidScope :: TestTree
rejectsInvalidScope = testCase "rejects scope escalation attempts" $
  withApp $ \stateVar app -> do
    let formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "testpass")
            , ("client_id", "client-1")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("scope", "admin")
            , ("state", "state-2")
            , ("code_challenge", "challenge")
            , ("code_challenge_method", "plain")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    err <- decodeError (simpleBody res)
    Web.OAuth2.Types.error err @?= "invalid_scope"
    st <- readMVar stateVar
    Map.size (auth_codes st) @?= 0

rejectsMissingPkce :: TestTree
rejectsMissingPkce = testCase "rejects requests missing PKCE code_challenge" $
  withApp $ \stateVar app -> do
    let formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "testpass")
            , ("client_id", "client-1")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("scope", "read")
            , ("state", "state-3")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status400
    lookup hContentType (simpleHeaders res) @?= Just "application/json; charset=utf-8"
    err <- decodeError (simpleBody res)
    Web.OAuth2.Types.error err @?= "invalid_request"
    st <- readMVar stateVar
    Map.size (auth_codes st) @?= 0

successfulLoginWithoutStateSkipsEcho :: TestTree
successfulLoginWithoutStateSkipsEcho = testCase "does not add state parameter when none provided" $
  withApp $ \stateVar app -> do
    let challenge = "verifier456"
        formBody =
          encodeForm
            [ ("username", "testuser")
            , ("password", "testpass")
            , ("client_id", "client-1")
            , ("redirect_uri", "http://localhost:4000/cb")
            , ("scope", "read")
            , ("code_challenge", challenge)
            , ("code_challenge_method", "plain")
            ]
        req =
          SRequest
            ( setPath defaultRequest "/authorize/callback"
                ){ requestMethod = methodPost
                 , requestHeaders = [(hContentType, "application/x-www-form-urlencoded")]
                 }
            formBody
    res <- runSession (srequest req) app
    simpleStatus res @?= status303
    let location = lookup hLocation (simpleHeaders res)
    case location of
      Nothing -> assertFailure "Location header missing"
      Just loc -> do
        let locText = TE.decodeUtf8 loc
        assertBool "redirected to provided redirect_uri" ("http://localhost:4000/cb" `T.isPrefixOf` locText)
        assertBool "state parameter absent" (not ("state=" `T.isInfixOf` locText))
    st <- readMVar stateVar
    Map.size (auth_codes st) @?= 1

decodeError :: LBS.ByteString -> IO OAuthError
decodeError body =
  case eitherDecode body of
    Left msg -> assertFailure ("Failed to decode OAuth error: " <> msg)
    Right v -> pure v