packages feed

openid-connect-0.1.0.0: test/Client/AuthorizationCodeTest.hs

{-# LANGUAGE QuasiQuotes #-}

{-|

Copyright:

  This file is part of the package openid-connect.  It is subject to
  the license terms in the LICENSE file found in the top-level
  directory of this distribution and at:

    https://code.devalot.com/sthenauth/openid-connect

  No part of this package, including this file, may be copied,
  modified, propagated, or distributed except according to the terms
  contained in the LICENSE file.

License: BSD-2-Clause

-}
module Client.AuthorizationCodeTest
  ( test
  ) where

--------------------------------------------------------------------------------
-- Imports:
import Control.Lens ((&), (?~), (#), (.~), (^?))
import Control.Monad.Except
import Crypto.JOSE (JWK, JWKSet(..))
import Crypto.JOSE.Compact
import Crypto.JWT (ClaimsSet)
import qualified Crypto.JWT as JWT
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy.Char8 as LChar8
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Time.Clock (UTCTime, getCurrentTime, addUTCTime)
import HttpHelper
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types
import qualified Network.URI as Network
import qualified Network.URI.Static as Network
import OpenID.Connect.Client.Flow.AuthorizationCode
import OpenID.Connect.Provider.Key
import OpenID.Connect.TokenResponse
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit
import Web.Cookie (SetCookie, setCookieValue)

--------------------------------------------------------------------------------
test :: TestTree
test = testGroup "Authorization Code Flow"
  [ testCase "auth code" testAuthCodeRedir
  , testCase "token exchange" testTokenExchange
  ]

--------------------------------------------------------------------------------
credentials :: Credentials
credentials =
  Credentials
    { assignedClientId = "phiKei4uZeeGhaizaiph"
    , clientSecret = AssignedSecretText "oxei7ohsh0hoo7buoSui"
    , clientRedirectUri = [Network.uri|https://example.com/redir|]
    }

--------------------------------------------------------------------------------
authRequest :: AuthenticationRequest
authRequest = defaultAuthenticationRequest openid credentials

--------------------------------------------------------------------------------
redirUriAndCookie :: Discovery -> IO (Network.URI, SetCookie)
redirUriAndCookie disco =
  authenticationRedirect disco authRequest >>= \case
    Left _                        -> assertFailure "did not expect failure"
    Right (RedirectTo uri cookie) -> pure (uri, cookie "foo")

--------------------------------------------------------------------------------
providerTestKeys :: IO (JWK, JWKSet)
providerTestKeys = do
  Just (JWKSet others) <- Aeson.decodeFileStrict "test/data/certs.txt"
  key <- newSigningJWK
  pure (key, JWKSet (others <> [key]))

--------------------------------------------------------------------------------
testClaims :: UTCTime -> Discovery -> Text -> ClaimsSet
testClaims now disco nonce
  = JWT.emptyClaimsSet
  & JWT.claimIss ?~ (JWT.uri # getURI (issuer disco))
  & JWT.claimAud ?~ JWT.Audience [JWT.string # assignedClientId credentials]
  & JWT.claimIat ?~ JWT.NumericDate (addUTCTime (-30) now)
  & JWT.claimExp ?~ JWT.NumericDate (addUTCTime 300 now)
  & JWT.claimSub ?~ (JWT.string # "ABC123")
  & JWT.addClaim "nonce" (Aeson.toJSON nonce)

--------------------------------------------------------------------------------
testAuthCodeRedir :: Assertion
testAuthCodeRedir = do
    Just disco <- Aeson.decodeFileStrict "test/data/discovery.txt"
    (uri, cookie) <- redirUriAndCookie disco

    Network.uriPath uri @?= "/o/oauth2/v2/auth"
    uriStart uri @?= (uriToText (getURI (authorizationEndpoint disco)) <> "?")
    assertBool "cookie value" (cookieBytes cookie > 0)

  where
    -- The URI up to and including the ?
    uriStart :: Network.URI -> Text
    uriStart u = Text.dropWhileEnd (/= '?') (uriToText u)

    cookieBytes :: SetCookie -> Int
    cookieBytes = Char8.length . setCookieValue

--------------------------------------------------------------------------------
extractQueryParam :: Network.URI -> Char8.ByteString -> IO Char8.ByteString
extractQueryParam uri name =
  case join (lookup name (parseQuery (Char8.pack (Network.uriQuery uri)))) of
    Nothing -> assertFailure ("missing query param: " <> show name)
    Just p  -> pure p

--------------------------------------------------------------------------------
userReturn :: Network.URI -> SetCookie -> IO UserReturnFromRedirect
userReturn uri cookie = do
  stateParam <- extractQueryParam uri "state"
  pure UserReturnFromRedirect
    { afterRedirectSessionCookie = setCookieValue cookie
    , afterRedirectCodeParam     = "aezoh0fahzu5iekeeX3u"
    , afterRedirectStateParam    = stateParam
    }

--------------------------------------------------------------------------------
testTokenExchange :: Assertion
testTokenExchange = do
    Just disco <- Aeson.decodeFileStrict "test/data/discovery.txt"
    (uri, cookie) <- redirUriAndCookie disco
    (key, keyset) <- providerTestKeys
    now <- getCurrentTime
    browser <- userReturn uri cookie
    nonce <- extractQueryParam uri "nonce"

    let makeRequest = makeRequest_ now disco key
        claims = testClaims now disco (Text.decodeUtf8 nonce)

    -- Happy path:
    makeRequest claims keyset browser
      >>= validateRequest
      >>= assertResponseSuccess

    -- Wrong cookie:
    makeRequest claims keyset
      (browser { afterRedirectSessionCookie = "foo"
               }) >>= assertNoRequestMade
                  >>= assertResponseFailed

    -- Wrong state field:
    makeRequest claims keyset
      (browser { afterRedirectStateParam = "foo"
               }) >>= assertNoRequestMade
                  >>= assertResponseFailed

    -- Multiple audience entries without an AZP:
    let dupAud = JWT.Audience
          [ JWT.string # assignedClientId credentials
          , JWT.string # assignedClientId credentials
          ]
    makeRequest (claims & JWT.claimAud ?~ dupAud)
      keyset browser >>= validateRequest
                     >>= assertResponseFailed

    -- Multiple audience entries with an AZP:
    let withAzp = JWT.addClaim "azp" (Aeson.String (assignedClientId credentials))
    makeRequest (claims & JWT.claimAud ?~ dupAud & withAzp)
      keyset browser >>= validateRequest
                     >>= assertResponseSuccess

    -- Wrong nonce:
    let wrongNonce = "foo" :: Text
    makeRequest (claims & JWT.addClaim "nonce" (Aeson.toJSON wrongNonce))
      keyset browser >>= validateRequest
                     >>= assertResponseFailed

    -- Wrong audience:
    makeRequest (claims & JWT.claimAud ?~ JWT.Audience [JWT.string # "foo"])
      keyset browser >>= validateRequest
                     >>= assertResponseFailed

    -- Wrong issuer:
    let wrongIssuer = "foo" :: Text
    makeRequest (claims & JWT.claimIss .~ wrongIssuer ^? JWT.stringOrUri)
      keyset browser >>= validateRequest
                     >>= assertResponseFailed

    -- Expired claims:
    makeRequest (claims & JWT.claimExp ?~ JWT.NumericDate (addUTCTime (-300) now))
      keyset browser >>= validateRequest
                     >>= assertResponseFailed

    -- Wrong Keys:
    Just wrongKeys <- Aeson.decodeFileStrict "test/data/certs.txt"
    makeRequest claims wrongKeys browser
      >>= validateRequest
      >>= assertResponseFailed

  where
    makeRequest_
      :: UTCTime
      -> Discovery
      -> JWK
      -> ClaimsSet
      -> JWKSet
      -> UserReturnFromRedirect
      -> IO (Either FlowError (TokenResponse ClaimsSet), HTTP.Request)
    makeRequest_ time disco key claims keyset browser = do
      claims' <- runExceptT
        (do algo <- JWT.bestJWSAlg key
            JWT.signClaims key (JWT.newJWSHeader ((), algo)) claims)
        >>= \case
          Left (e :: JWT.JWTError) -> fail (show e)
          Right a -> pure a

      let token = TokenResponse
            { accessToken = "Iegoe0sheeSeo3veesoo"
            , tokenType   = "Bearer"
            , expiresIn   = Just 3600
            , refreshToken = Nothing
            , scope = Nothing
            , idToken = Text.decodeUtf8 (LChar8.toStrict (encodeCompact claims'))
            , atHash = Nothing
            }

      let https = fakeHttpsFromByteString (Aeson.encode token) & mkHTTPS
          provider = Provider disco keyset
      runHTTPS (authenticationSuccess https time provider credentials browser)

    validateRequest :: (a, HTTP.Request) -> IO a
    validateRequest (x, req) = do
      HTTP.method req @?= "POST"
      Network.uriScheme (HTTP.getUri req) @?= "https:"
      assertBool "should be a secure connection" (HTTP.secure req)
      pure x

    assertNoRequestMade :: (a, HTTP.Request) -> IO a
    assertNoRequestMade (x, req) = do
      HTTP.method req @?= "NONE" -- See HttpHelper.hs
      pure x

    assertResponseSuccess :: Either FlowError a -> Assertion
    assertResponseSuccess = \case
      Left e  -> assertFailure ("didn't expect Failed: " <> show e)
      Right _ -> pure ()

    assertResponseFailed :: Either FlowError a -> Assertion
    assertResponseFailed = \case
      Right _ -> assertFailure "didn't expect Success"
      Left _  -> pure ()