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