packages feed

bloodhound-1.0.0.0: tests/Test/SecuritySsoSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}

module Test.SecuritySsoSpec (spec) where

import Data.Aeson
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy.Char8 qualified as L
import TestsUtils.Import
import Prelude

------------------------------------------------------------------------------
-- Fixtures (verbatim from the ES 7.17 REST docs)
------------------------------------------------------------------------------

-- | @POST /_security/oauth2/token@ response — @password@ grant with the
-- nested @authentication@ block (the access + refresh token pair).
sampleGetTokenPasswordBytes :: L.ByteString
sampleGetTokenPasswordBytes =
  "{\
  \  \"access_token\": \"dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==\",\
  \  \"type\": \"Bearer\",\
  \  \"expires_in\": 1200,\
  \  \"refresh_token\": \"vLBPvmAB6KvwvJZr27cS\",\
  \  \"authentication\": {\
  \    \"username\": \"test_admin\",\
  \    \"roles\": [\"superuser\"],\
  \    \"full_name\": null,\
  \    \"email\": null,\
  \    \"metadata\": {},\
  \    \"enabled\": true,\
  \    \"authentication_realm\": {\"name\": \"file\", \"type\": \"file\"},\
  \    \"lookup_realm\": {\"name\": \"file\", \"type\": \"file\"},\
  \    \"authentication_type\": \"realm\"\
  \  }\
  \}"

-- | @POST /_security/oauth2/token@ response — @client_credentials@ grant
-- (NO refresh token).
sampleGetTokenClientCredentialsBytes :: L.ByteString
sampleGetTokenClientCredentialsBytes =
  "{\
  \  \"access_token\": \"dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==\",\
  \  \"type\": \"Bearer\",\
  \  \"expires_in\": 1200,\
  \  \"authentication\": {\
  \    \"username\": \"test_admin\",\
  \    \"roles\": [\"superuser\"],\
  \    \"full_name\": null,\
  \    \"email\": null,\
  \    \"metadata\": {},\
  \    \"enabled\": true,\
  \    \"authentication_realm\": {\"name\": \"file\", \"type\": \"file\"},\
  \    \"lookup_realm\": {\"name\": \"file\", \"type\": \"file\"},\
  \    \"authentication_type\": \"realm\"\
  \  }\
  \}"

-- | @DELETE /_security/oauth2/token@ response with a partial failure
-- (recursive @caused_by@ in the error chain).
sampleInvalidateTokenBytes :: L.ByteString
sampleInvalidateTokenBytes =
  "{\
  \  \"invalidated_tokens\": 9,\
  \  \"previously_invalidated_tokens\": 15,\
  \  \"error_count\": 2,\
  \  \"error_details\": [\
  \    {\
  \      \"type\": \"exception\",\
  \      \"reason\": \"Elasticsearch exception [type=exception, reason=foo]\",\
  \      \"caused_by\": {\
  \        \"type\": \"illegal_argument_exception\",\
  \        \"reason\": \"Elasticsearch exception [type=illegal_argument_exception, reason=bar]\"\
  \      }\
  \    }\
  \  ]\
  \}"

-- | @POST /_security/saml/authenticate@ response (access + refresh token,
-- username, realm; no @type@).
sampleSamlAuthenticateBytes :: L.ByteString
sampleSamlAuthenticateBytes =
  "{\
  \  \"access_token\": \"46ToAxZVaXVVZTVKOVF5YU04ZFJVUDVSZlV3\",\
  \  \"username\": \"Bearer\",\
  \  \"expires_in\": 1200,\
  \  \"refresh_token\": \"mJdXLtmvTUSpoLwMvdBt_w\",\
  \  \"realm\": \"saml1\"\
  \}"

-- | @POST /_security/oidc/authenticate@ response (access + refresh token,
-- @type@; no @username@/@realm@).
sampleOidcAuthenticateBytes :: L.ByteString
sampleOidcAuthenticateBytes =
  "{\
  \  \"access_token\": \"dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ==\",\
  \  \"type\": \"Bearer\",\
  \  \"expires_in\": 1200,\
  \  \"refresh_token\": \"vLBPvmAB6KvwvJZr27cS\"\
  \}"

-- | @POST /_security/saml/prepare@ response.
sampleSamlPrepareBytes :: L.ByteString
sampleSamlPrepareBytes =
  "{\
  \  \"redirect\": \"https://my-idp.org/login?SAMLRequest=fVJdc6...\",\
  \  \"realm\": \"saml1\",\
  \  \"id\": \"_989a34500a4f5bf0f00d195aa04a7804b4ed42a1\"\
  \}"

-- | @POST /_security/oidc/prepare@ response (server-generated state/nonce).
sampleOidcPrepareBytes :: L.ByteString
sampleOidcPrepareBytes =
  "{\
  \  \"redirect\": \"http://127.0.0.1:8080/c2id-login?scope=openid&response_type=id_token\",\
  \  \"state\": \"4dbrihtIAt3wBTwo6DxK-vdk-sSyDBV8Yf0AjdkdT5I\",\
  \  \"nonce\": \"WaBPH0KqPVdG5HHdSxPRjfoZbXMCicm5v1OiAj0DUFM\",\
  \  \"realm\": \"oidc1\"\
  \}"

spec :: Spec
spec = do
  describe "OAuth2GrantType" $ do
    it "round-trips the four documented grant types" $ do
      encode OAuth2GrantPassword `shouldBe` "\"password\""
      encode OAuth2GrantKerberos `shouldBe` "\"_kerberos\""
      encode OAuth2GrantClientCredentials
        `shouldBe` "\"client_credentials\""
      encode OAuth2GrantRefreshToken `shouldBe` "\"refresh_token\""
      decode "\"_kerberos\"" `shouldBe` (Just OAuth2GrantKerberos :: Maybe OAuth2GrantType)

    it "rejects an unknown grant_type" $ do
      decode "\"weird\""
        `shouldBe` (Nothing :: Maybe OAuth2GrantType)

  describe "GetTokenRequest" $ do
    it "encodes only client_credentials when that grant is selected" $ do
      let req = defaultGetTokenRequest
      encode req `shouldBe` "{\"grant_type\":\"client_credentials\"}"

    it "encodes a password grant and round-trips it" $ do
      let req =
            defaultGetTokenRequest
              { gtqGrantType = OAuth2GrantPassword,
                gtqUsername = Just "test_admin",
                gtqPassword = Just "x-pack-test-password"
              }
      let Just rt = decode (encode req) :: Maybe GetTokenRequest
      gtqGrantType rt `shouldBe` OAuth2GrantPassword
      gtqUsername rt `shouldBe` Just "test_admin"
      gtqPassword rt `shouldBe` Just "x-pack-test-password"

    it "round-trips a refresh_token grant" $ do
      let req =
            defaultGetTokenRequest
              { gtqGrantType = OAuth2GrantRefreshToken,
                gtqRefreshToken = Just "vLBPvmAB6KvwvJZr27cS"
              }
      let Just rt = decode (encode req) :: Maybe GetTokenRequest
      gtqGrantType rt `shouldBe` OAuth2GrantRefreshToken
      gtqRefreshToken rt `shouldBe` Just "vLBPvmAB6KvwvJZr27cS"

    it "round-trips a _kerberos grant" $ do
      let req =
            defaultGetTokenRequest
              { gtqGrantType = OAuth2GrantKerberos,
                gtqKerberosTicket = Just "YIIB6wYJKoZIhvcSAQICAQ"
              }
      let Just rt = decode (encode req) :: Maybe GetTokenRequest
      gtqGrantType rt `shouldBe` OAuth2GrantKerberos
      gtqKerberosTicket rt `shouldBe` Just "YIIB6wYJKoZIhvcSAQICAQ"

  describe "GetTokenResponse" $ do
    it "decodes the password-grant response with nested authentication" $ do
      let Just r = decode sampleGetTokenPasswordBytes :: Maybe GetTokenResponse
      gtrAccessToken r `shouldBe` "dGhpcyBpcyBub3QgYSByZWFsIHRva2VuIGJ1dCBpdCBpcyBvbmx5IHRlc3QgZGF0YS4gZG8gbm90IHRyeSB0byByZWFkIHRva2VuIQ=="
      gtrType r `shouldBe` Just "Bearer"
      gtrExpiresIn r `shouldBe` 1200
      gtrRefreshToken r `shouldBe` Just "vLBPvmAB6KvwvJZr27cS"
      case gtrAuthentication r of
        Just auth -> do
          taUsername auth `shouldBe` Just "test_admin"
          taRoles auth `shouldBe` Just ["superuser"]
          -- null full_name/email decode to Nothing.
          taFullName auth `shouldBe` Nothing
          taEmail auth `shouldBe` Nothing
          taEnabled auth `shouldBe` Just True
          taAuthenticationType auth `shouldBe` Just "realm"
          case taAuthenticationRealm auth of
            Just realm -> do
              sriName realm `shouldBe` Just "file"
              sriType realm `shouldBe` Just "file"
            Nothing -> expectationFailure "expected authentication_realm"
        Nothing -> expectationFailure "expected authentication block"

    it "decodes the client_credentials response WITHOUT a refresh token" $ do
      let Just r =
            decode sampleGetTokenClientCredentialsBytes ::
              Maybe GetTokenResponse
      gtrRefreshToken r `shouldBe` Nothing
      gtrAuthentication r `shouldSatisfy` isJust

    it "preserves unknown authentication sibling fields in extras" $ do
      let bytes =
            "{\
            \  \"access_token\": \"a\",\
            \  \"type\": \"Bearer\",\
            \  \"expires_in\": 1200,\
            \  \"authentication\": {\
            \    \"username\": \"u\",\
            \    \"roles\": [],\
            \    \"full_name\": null,\
            \    \"email\": null,\
            \    \"metadata\": {},\
            \    \"enabled\": true,\
            \    \"authentication_realm\": {\"name\": \"file\", \"type\": \"file\"},\
            \    \"lookup_realm\": {\"name\": \"file\", \"type\": \"file\"},\
            \    \"authentication_type\": \"realm\",\
            \    \"future_field\": 42\
            \  }\
            \}"
      let Just r = decode bytes :: Maybe GetTokenResponse
      case gtrAuthentication r of
        Just auth ->
          KM.lookup "future_field" (taExtras auth) `shouldSatisfy` isJust
        Nothing -> expectationFailure "expected authentication block"
      -- Stable round-trip: extras survive encode . decode.
      let Just rt = decode (encode r) :: Maybe GetTokenResponse
      case gtrAuthentication rt of
        Just auth ->
          KM.lookup "future_field" (taExtras auth) `shouldSatisfy` isJust
        Nothing -> expectationFailure "expected authentication block"

  describe "TokenAuthentication" $ do
    it "tolerates a null email/full_name (decode to Nothing)" $ do
      let bytes =
            "{\
            \  \"username\": \"u\",\
            \  \"roles\": [\"r\"],\
            \  \"full_name\": null,\
            \  \"email\": null\
            \}"
      let Just auth = decode bytes :: Maybe TokenAuthentication
      taUsername auth `shouldBe` Just "u"
      taFullName auth `shouldBe` Nothing
      taEmail auth `shouldBe` Nothing

  describe "InvalidateTokenRequest" $ do
    it "encodes a single access-token selector" $ do
      let req =
            defaultInvalidateTokenRequest {ittToken = Just "abc"}
      encode req `shouldBe` "{\"token\":\"abc\"}"

    it "round-trips a realm_name + username bulk selector" $ do
      let req =
            defaultInvalidateTokenRequest
              { ittRealmName = Just "saml1",
                ittUsername = Just "myuser"
              }
      let Just rt = decode (encode req) :: Maybe InvalidateTokenRequest
      ittRealmName rt `shouldBe` Just "saml1"
      ittUsername rt `shouldBe` Just "myuser"

  describe "InvalidateTokenResponse" $ do
    it "decodes a partial failure with a recursive caused_by chain" $ do
      let Just r = decode sampleInvalidateTokenBytes :: Maybe InvalidateTokenResponse
      itrInvalidatedTokens r `shouldBe` 9
      itrPreviouslyInvalidatedTokens r `shouldBe` 15
      itrErrorCount r `shouldBe` Just 2
      case itrErrorDetails r of
        (e : _) -> case iteCausedBy e of
          Just cb -> do
            iteType cb `shouldBe` Just "illegal_argument_exception"
            iteReason cb `shouldSatisfy` isJust
          Nothing -> expectationFailure "expected caused_by"
        [] -> expectationFailure "expected error details"

  describe "SsoTokenResponse" $ do
    it "decodes a SAML authenticate response (username/realm, no type)" $ do
      let Just r = decode sampleSamlAuthenticateBytes :: Maybe SsoTokenResponse
      strAccessToken r `shouldBe` "46ToAxZVaXVVZTVKOVF5YU04ZFJVUDVSZlV3"
      strUsername r `shouldBe` Just "Bearer"
      strRealm r `shouldBe` Just "saml1"
      strRefreshToken r `shouldBe` Just "mJdXLtmvTUSpoLwMvdBt_w"
      strType r `shouldBe` Nothing

    it "decodes an OIDC authenticate response (type, no username/realm)" $ do
      let Just r = decode sampleOidcAuthenticateBytes :: Maybe SsoTokenResponse
      strType r `shouldBe` Just "Bearer"
      strUsername r `shouldBe` Nothing
      strRealm r `shouldBe` Nothing
      strRefreshToken r `shouldBe` Just "vLBPvmAB6KvwvJZr27cS"

  describe "SamlPrepareRequest / SamlPrepareResponse" $ do
    it "encodes a realm selector and round-trips" $ do
      let req = defaultSamlPrepareRequest {sprRealm = Just "saml1"}
      encode req `shouldBe` "{\"realm\":\"saml1\"}"
    it "encodes an acs selector" $ do
      let req =
            defaultSamlPrepareRequest
              { sprAcs = Just "https://kibana.org/api/security/saml/callback"
              }
      encode req
        `shouldBe` "{\"acs\":\"https://kibana.org/api/security/saml/callback\"}"
    it "decodes the prepare response (id/realm/redirect)" $ do
      let Just r = decode sampleSamlPrepareBytes :: Maybe SamlPrepareResponse
      sppId r `shouldBe` "_989a34500a4f5bf0f00d195aa04a7804b4ed42a1"
      sppRealm r `shouldBe` "saml1"
      sppRedirect r `shouldBe` "https://my-idp.org/login?SAMLRequest=fVJdc6..."

  describe "SamlAuthenticateRequest / SamlLogoutRequest" $ do
    it "round-trips an authenticate request (content/ids/realm)" $ do
      let req =
            SamlAuthenticateRequest
              { sarContent = "PHNhbWxwOlJlc3BvbnNl...",
                sarIds = ["4fee3b046395c4e751011e97f8900b5273d56685"],
                sarRealm = Just "saml1"
              }
      let Just rt = decode (encode req) :: Maybe SamlAuthenticateRequest
      sarContent rt `shouldBe` "PHNhbWxwOlJlc3BvbnNl..."
      sarIds rt `shouldBe` ["4fee3b046395c4e751011e97f8900b5273d56685"]
      sarRealm rt `shouldBe` Just "saml1"
    it "round-trips a logout request (token + refresh_token)" $ do
      let req =
            SamlLogoutRequest
              { slrToken = "46ToAxZVaXVVZTVKOVF5YU04ZFJVUDVSZlV3",
                slrRefreshToken = Just "mJdXLtmvTUSpoLwMvdBt_w"
              }
      let Just rt = decode (encode req) :: Maybe SamlLogoutRequest
      slrToken rt `shouldBe` "46ToAxZVaXVVZTVKOVF5YU04ZFJVUDVSZlV3"
      slrRefreshToken rt `shouldBe` Just "mJdXLtmvTUSpoLwMvdBt_w"

  describe "OidcPrepareRequest / OidcPrepareResponse" $ do
    it "encodes a realm selector" $ do
      let req = defaultOidcPrepareRequest {oprRealm = Just "oidc1"}
      encode req `shouldBe` "{\"realm\":\"oidc1\"}"
    it "round-trips the 3rd-party-SSO iss + login_hint variant" $ do
      let req =
            defaultOidcPrepareRequest
              { oprIss = Just "http://127.0.0.1:8080",
                oprLoginHint = Just "this_is_an_opaque_string"
              }
      let Just rt = decode (encode req) :: Maybe OidcPrepareRequest
      oprIss rt `shouldBe` Just "http://127.0.0.1:8080"
      oprLoginHint rt `shouldBe` Just "this_is_an_opaque_string"
    it "encodes caller-supplied state + nonce" $ do
      let req =
            defaultOidcPrepareRequest
              { oprRealm = Just "oidc1",
                oprState = Just "lGYK0EcSLjqH6pkT5EVZjC6eIW5YCGgywj2sxROO",
                oprNonce = Just "zOBXLJGUooRrbLbQk5YCcyC8AXw3iloynvluYhZ5"
              }
      let Just rt = decode (encode req) :: Maybe OidcPrepareRequest
      oprState rt `shouldBe` Just "lGYK0EcSLjqH6pkT5EVZjC6eIW5YCGgywj2sxROO"
      oprNonce rt `shouldBe` Just "zOBXLJGUooRrbLbQk5YCcyC8AXw3iloynvluYhZ5"
    it "decodes the prepare response (redirect/state/nonce/realm)" $ do
      let Just r = decode sampleOidcPrepareBytes :: Maybe OidcPrepareResponse
      oppRealm r `shouldBe` Just "oidc1"
      oppState r `shouldBe` "4dbrihtIAt3wBTwo6DxK-vdk-sSyDBV8Yf0AjdkdT5I"
      oppNonce r `shouldBe` "WaBPH0KqPVdG5HHdSxPRjfoZbXMCicm5v1OiAj0DUFM"

  describe "OidcAuthenticateRequest / OidcLogoutRequest" $ do
    it "round-trips an authenticate request (redirect_uri/state/nonce)" $ do
      let req =
            OidcAuthenticateRequest
              { oarRedirectUri =
                  "https://oidc-kibana.elastic.co:5603/api/security/oidc/callback?code=jtI3Ntt8v3_XvcLzCFGq&state=4dbrihtIAt3wBTwo6DxK-vdk-sSyDBV8Yf0AjdkdT5I",
                oarState = "4dbrihtIAt3wBTwo6DxK-vdk-sSyDBV8Yf0AjdkdT5I",
                oarNonce = "WaBPH0KqPVdG5HHdSxPRjfoZbXMCicm5v1OiAj0DUFM",
                oarRealm = Just "oidc1"
              }
      let Just rt = decode (encode req) :: Maybe OidcAuthenticateRequest
      oarState rt `shouldBe` "4dbrihtIAt3wBTwo6DxK-vdk-sSyDBV8Yf0AjdkdT5I"
      oarNonce rt `shouldBe` "WaBPH0KqPVdG5HHdSxPRjfoZbXMCicm5v1OiAj0DUFM"
      oarRealm rt `shouldBe` Just "oidc1"
    it "encodes with the token wire spelling (not access_token)" $ do
      let req =
            OidcLogoutRequest
              { olrToken = "dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu...",
                olrRefreshToken = Just "vLBPvmAB6KvwvJZr27cS"
              }
      let Just obj = decode (encode req) :: Maybe Object
      KM.member "token" obj `shouldBe` True
      KM.member "access_token" obj `shouldBe` False
      KM.member "refresh_token" obj `shouldBe` True
    it "decodes a logout request that used the access_token spelling" $ do
      let bytes =
            "{\"access_token\":\"dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu\",\"refresh_token\":\"vLBPvmAB6KvwvJZr27cS\"}"
      let Just r = decode bytes :: Maybe OidcLogoutRequest
      olrToken r `shouldBe` "dGhpcyBpcyBub3QgYSByZWFsIHRva2Vu"
      olrRefreshToken r `shouldBe` Just "vLBPvmAB6KvwvJZr27cS"

  describe "SsoRedirectResponse" $ do
    it "decodes a redirect URL (SAML/OIDC logout)" $ do
      let bytes = "{\"redirect\": \"https://my-idp.org/logout/SAMLRequest=....\"}"
      let Just r = decode bytes :: Maybe SsoRedirectResponse
      srdRedirect r `shouldBe` Just "https://my-idp.org/logout/SAMLRequest=...."
    it "tolerates an absent redirect" $ do
      let Just r = decode "{}" :: Maybe SsoRedirectResponse
      srdRedirect r `shouldBe` Nothing