|
- <!-- #include file="../aspunit/Lib/ASPUnit.asp" -->
- <!-- #include file="../bootstrap.asp" -->
- <!-- #include file="../../core/lib.json.asp" -->
- <!-- #include file="../../core/lib.Keycloak.asp" -->
-
- <%
- Call ASPUnit.AddModule( _
- ASPUnit.CreateModule( _
- "Keycloak Auth Tests", _
- Array( _
- ASPUnit.CreateTest("KeycloakEndpointsUseRealmBaseUrl"), _
- ASPUnit.CreateTest("KeycloakBuildLoginUrlIncludesOidcParameters"), _
- ASPUnit.CreateTest("KeycloakBuildLogoutUrlIncludesClientAndRedirect"), _
- ASPUnit.CreateTest("KeycloakTokenClaimsDecodeJwtPayload"), _
- ASPUnit.CreateTest("KeycloakAuthDefaultsHttpTimeoutsAndClockSkew"), _
- ASPUnit.CreateTest("KeycloakOperationalConfigurationAllowsProductionSafeHttpsUrls"), _
- ASPUnit.CreateTest("KeycloakOperationalConfigurationRejectsProductionHttpRedirectUri"), _
- ASPUnit.CreateTest("KeycloakOperationalConfigurationAllowsLocalHttpDuringDevelopment"), _
- ASPUnit.CreateTest("KeycloakSetPostLoginRedirectPathStoresRelativePath"), _
- ASPUnit.CreateTest("KeycloakSetPostLoginRedirectPathRejectsAbsoluteUrl"), _
- ASPUnit.CreateTest("KeycloakConsumePostLoginRedirectPathReturnsStoredValueAndClearsIt"), _
- ASPUnit.CreateTest("KeycloakHasRealmRoleReadsIdTokenClaims"), _
- ASPUnit.CreateTest("KeycloakHasClientRoleReadsIdTokenClaims"), _
- ASPUnit.CreateTest("KeycloakValidateIdTokenAcceptsExpectedClaims"), _
- ASPUnit.CreateTest("KeycloakValidateIdTokenRejectsNonceMismatch"), _
- ASPUnit.CreateTest("KeycloakValidateIdTokenRejectsExpiredTokens"), _
- ASPUnit.CreateTest("KeycloakValidateIdTokenAcceptsMultipleAudiencesWhenAzpMatches") _
- ), _
- ASPUnit.CreateLifeCycle("SetupKeycloakAuth", "TeardownKeycloakAuth") _
- ) _
- )
-
- Call ASPUnit.Run()
-
- Sub SetupKeycloakAuth()
- Call ResetTestRuntime()
- KeycloakAuth_Class__Singleton = Empty
- Session.Contents.Remove "Keycloak_IdToken"
- Session.Contents.Remove "Keycloak_PostLoginRedirectPath"
- End Sub
-
- Sub TeardownKeycloakAuth()
- Session.Contents.Remove "Keycloak_IdToken"
- Session.Contents.Remove "Keycloak_PostLoginRedirectPath"
- KeycloakAuth_Class__Singleton = Empty
- Call ResetTestRuntime()
- End Sub
-
- Function NewTestKeycloakAuth()
- Dim auth
- Set auth = New KeycloakAuth_Class
- Call auth.Configure("https://login.example.test/", "survey", "classic-app", "secret", "https://app.example.test/auth/callback")
- auth.LogoutRedirectUri = "https://app.example.test/"
- Set NewTestKeycloakAuth = auth
- End Function
-
- Function KeycloakEndpointsUseRealmBaseUrl()
- Dim auth
- Set auth = NewTestKeycloakAuth()
-
- Call ASPUnit.Equal(auth.RealmBaseUrl(), "https://login.example.test/realms/survey", "RealmBaseUrl should trim a trailing Keycloak base URL slash")
- Call ASPUnit.Equal(auth.AuthorizationEndpoint(), "https://login.example.test/realms/survey/protocol/openid-connect/auth", "AuthorizationEndpoint should use the realm OIDC auth endpoint")
- Call ASPUnit.Equal(auth.TokenEndpoint(), "https://login.example.test/realms/survey/protocol/openid-connect/token", "TokenEndpoint should use the realm OIDC token endpoint")
- Call ASPUnit.Equal(auth.UserInfoEndpoint(), "https://login.example.test/realms/survey/protocol/openid-connect/userinfo", "UserInfoEndpoint should use the realm OIDC userinfo endpoint")
- End Function
-
- Function KeycloakBuildLoginUrlIncludesOidcParameters()
- Dim auth, loginUrl, expectedUrl
- Set auth = NewTestKeycloakAuth()
-
- loginUrl = auth.BuildLoginUrl("state-123", "nonce-456")
- expectedUrl = "https://login.example.test/realms/survey/protocol/openid-connect/auth?client_id=classic%2Dapp&response_type=code&scope=openid+profile+email&redirect_uri=https%3A%2F%2Fapp%2Eexample%2Etest%2Fauth%2Fcallback&state=state%2D123&nonce=nonce%2D456"
-
- Call ASPUnit.Equal(loginUrl, expectedUrl, "BuildLoginUrl should include the encoded OIDC authorization-code parameters")
- End Function
-
- Function KeycloakBuildLogoutUrlIncludesClientAndRedirect()
- Dim auth, logoutUrl, expectedUrl
- Set auth = NewTestKeycloakAuth()
-
- logoutUrl = auth.BuildLogoutUrl("")
- expectedUrl = "https://login.example.test/realms/survey/protocol/openid-connect/logout?client_id=classic%2Dapp&post_logout_redirect_uri=https%3A%2F%2Fapp%2Eexample%2Etest%2F"
-
- Call ASPUnit.Equal(logoutUrl, expectedUrl, "BuildLogoutUrl should include the encoded client id and post-logout redirect URI")
- End Function
-
- Function KeycloakTokenClaimsDecodeJwtPayload()
- Dim auth, token, claims
- Set auth = NewTestKeycloakAuth()
-
- token = "e30.eyJzdWIiOiJ1c2VyLTEyMyIsInByZWZlcnJlZF91c2VybmFtZSI6ImRhbmEiLCJlbWFpbCI6ImRhbmFAZXhhbXBsZS50ZXN0In0.signature"
- Set claims = auth.GetTokenClaims(token)
-
- Call ASPUnit.Ok(Not claims Is Nothing, "GetTokenClaims should return a dictionary for a valid JWT")
- Call ASPUnit.Equal(claims.Item("sub"), "user-123", "GetTokenClaims should decode the sub claim")
- Call ASPUnit.Equal(claims.Item("preferred_username"), "dana", "GetTokenClaims should decode the preferred username")
- Call ASPUnit.Equal(claims.Item("email"), "dana@example.test", "GetTokenClaims should decode the email")
- End Function
-
- Function KeycloakAuthDefaultsHttpTimeoutsAndClockSkew()
- Dim auth
- Set auth = NewTestKeycloakAuth()
-
- Call ASPUnit.Ok(auth.HttpResolveTimeoutMs > 0, "KeycloakAuth should default the DNS resolve timeout to a positive value")
- Call ASPUnit.Ok(auth.HttpConnectTimeoutMs > 0, "KeycloakAuth should default the connect timeout to a positive value")
- Call ASPUnit.Ok(auth.HttpSendTimeoutMs > 0, "KeycloakAuth should default the send timeout to a positive value")
- Call ASPUnit.Ok(auth.HttpReceiveTimeoutMs > 0, "KeycloakAuth should default the receive timeout to a positive value")
- Call ASPUnit.Ok(auth.AllowedClockSkewSeconds >= 0, "KeycloakAuth should default the allowed clock skew to a non-negative value")
- End Function
-
- Function KeycloakOperationalConfigurationAllowsProductionSafeHttpsUrls()
- Dim auth, result
- Set auth = NewTestKeycloakAuth()
-
- result = auth.ValidateOperationalConfiguration("Production")
-
- Call ASPUnit.Ok(CBool(result), "ValidateOperationalConfiguration should accept HTTPS Keycloak URLs in Production")
- Call ASPUnit.Equal(auth.ErrorMessage, "", "ValidateOperationalConfiguration should not leave an error message for production-safe URLs")
- End Function
-
- Function KeycloakOperationalConfigurationRejectsProductionHttpRedirectUri()
- Dim auth, result
- Set auth = NewTestKeycloakAuth()
- auth.RedirectUri = "http://app.example.test/auth/callback"
-
- result = auth.ValidateOperationalConfiguration("Production")
-
- Call ASPUnit.Ok(Not CBool(result), "ValidateOperationalConfiguration should reject a non-HTTPS redirect URI in Production")
- Call ASPUnit.Ok(InStr(LCase(auth.ErrorMessage), "https") > 0 And InStr(LCase(auth.ErrorMessage), "redirecturi") > 0, "ValidateOperationalConfiguration should explain redirect URI HTTPS failures")
- End Function
-
- Function KeycloakOperationalConfigurationAllowsLocalHttpDuringDevelopment()
- Dim auth, result
- Set auth = NewTestKeycloakAuth()
- auth.BaseUrl = "http://localhost:8180"
- auth.RedirectUri = "http://localhost:8080/auth/callback"
- auth.LogoutRedirectUri = "http://localhost:8080/"
-
- result = auth.ValidateOperationalConfiguration("Development")
-
- Call ASPUnit.Ok(CBool(result), "ValidateOperationalConfiguration should allow local HTTP URLs outside Production")
- End Function
-
- Function KeycloakSetPostLoginRedirectPathStoresRelativePath()
- Dim auth
- Set auth = NewTestKeycloakAuth()
-
- Call auth.SetPostLoginRedirectPath("/reports/weekly?site=42")
-
- Call ASPUnit.Equal(Session("Keycloak_PostLoginRedirectPath"), "/reports/weekly?site=42", "SetPostLoginRedirectPath should store a safe relative return path in Session")
- End Function
-
- Function KeycloakSetPostLoginRedirectPathRejectsAbsoluteUrl()
- Dim auth, storedValue
- Set auth = NewTestKeycloakAuth()
-
- Call auth.SetPostLoginRedirectPath("https://evil.example.test/phish")
-
- On Error Resume Next
- storedValue = Session("Keycloak_PostLoginRedirectPath")
- If Err.Number <> 0 Then
- storedValue = ""
- Err.Clear
- End If
- On Error GoTo 0
-
- Call ASPUnit.Equal(CStr(storedValue), "", "SetPostLoginRedirectPath should ignore absolute URLs to avoid open redirects")
- End Function
-
- Function KeycloakConsumePostLoginRedirectPathReturnsStoredValueAndClearsIt()
- Dim auth, redirectPath, storedValue
- Set auth = NewTestKeycloakAuth()
- Session("Keycloak_PostLoginRedirectPath") = "/surveys/open/7"
-
- redirectPath = auth.ConsumePostLoginRedirectPath("/")
-
- On Error Resume Next
- storedValue = Session("Keycloak_PostLoginRedirectPath")
- If Err.Number <> 0 Then
- storedValue = ""
- Err.Clear
- End If
- On Error GoTo 0
-
- Call ASPUnit.Equal(redirectPath, "/surveys/open/7", "ConsumePostLoginRedirectPath should return the stored post-login destination")
- Call ASPUnit.Equal(CStr(storedValue), "", "ConsumePostLoginRedirectPath should clear the stored post-login destination after reading it")
- End Function
-
- Function KeycloakHasRealmRoleReadsIdTokenClaims()
- Dim auth
- Set auth = NewTestKeycloakAuth()
- Session("Keycloak_IdToken") = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":""classic-app"",""exp"":2147483647,""iat"":1700000000,""realm_access"":{""roles"":[""admin"",""author""]}}")
-
- Call ASPUnit.Ok(auth.HasRealmRole("author"), "HasRealmRole should read realm roles from the stored ID token claims")
- Call ASPUnit.Ok(Not auth.HasRealmRole("approver"), "HasRealmRole should return False when the requested realm role is missing")
- End Function
-
- Function KeycloakHasClientRoleReadsIdTokenClaims()
- Dim auth
- Set auth = NewTestKeycloakAuth()
- Session("Keycloak_IdToken") = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":""classic-app"",""exp"":2147483647,""iat"":1700000000,""resource_access"":{""classic-app"":{""roles"":[""editor"",""reviewer""]}}}")
-
- Call ASPUnit.Ok(auth.HasClientRole("classic-app", "reviewer"), "HasClientRole should read client roles from the stored ID token claims")
- Call ASPUnit.Ok(Not auth.HasClientRole("classic-app", "publisher"), "HasClientRole should return False when the requested client role is missing")
- End Function
-
- Function KeycloakValidateIdTokenAcceptsExpectedClaims()
- Dim auth, token, result
- Set auth = NewTestKeycloakAuth()
-
- token = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":""classic-app"",""exp"":2147483647,""iat"":1700000000,""nonce"":""nonce-456""}")
- result = auth.ValidateIdToken(token, "nonce-456", True)
-
- Call ASPUnit.Ok(CBool(result), "ValidateIdToken should accept a token whose issuer, audience, expiry, and nonce all match")
- Call ASPUnit.Equal(auth.ErrorMessage, "", "ValidateIdToken should not leave an error message behind for a valid token")
- End Function
-
- Function KeycloakValidateIdTokenRejectsNonceMismatch()
- Dim auth, token, result
- Set auth = NewTestKeycloakAuth()
-
- token = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":""classic-app"",""exp"":2147483647,""iat"":1700000000,""nonce"":""unexpected-nonce""}")
- result = auth.ValidateIdToken(token, "expected-nonce", True)
-
- Call ASPUnit.Ok(Not CBool(result), "ValidateIdToken should reject a token when the nonce claim differs from the login session nonce")
- Call ASPUnit.Ok(InStr(LCase(auth.ErrorMessage), "nonce") > 0, "ValidateIdToken should mention the nonce in the failure message when the nonce is wrong")
- End Function
-
- Function KeycloakValidateIdTokenRejectsExpiredTokens()
- Dim auth, token, result
- Set auth = NewTestKeycloakAuth()
- auth.AllowedClockSkewSeconds = 0
-
- token = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":""classic-app"",""exp"":946684800,""iat"":946684200,""nonce"":""nonce-456""}")
- result = auth.ValidateIdToken(token, "nonce-456", True)
-
- Call ASPUnit.Ok(Not CBool(result), "ValidateIdToken should reject an ID token whose exp claim is already in the past")
- Call ASPUnit.Ok(InStr(LCase(auth.ErrorMessage), "expired") > 0, "ValidateIdToken should mention expiration when the token has expired")
- End Function
-
- Function KeycloakValidateIdTokenAcceptsMultipleAudiencesWhenAzpMatches()
- Dim auth, token, result
- Set auth = NewTestKeycloakAuth()
-
- token = BuildUnsignedJwt("{""iss"":""" & auth.RealmBaseUrl() & """,""sub"":""user-123"",""aud"":[""account"",""classic-app""],""azp"":""classic-app"",""exp"":2147483647,""iat"":1700000000,""nonce"":""nonce-456""}")
- result = auth.ValidateIdToken(token, "nonce-456", True)
-
- Call ASPUnit.Ok(CBool(result), "ValidateIdToken should accept a multi-audience ID token when azp matches the configured client")
- End Function
-
- Function BuildUnsignedJwt(ByVal payloadJson)
- Dim xml, node, stream, bytes, base64Value
-
- Set stream = Server.CreateObject("ADODB.Stream")
- stream.Type = 2
- stream.Charset = "utf-8"
- stream.Open
- stream.WriteText payloadJson
- stream.Position = 0
- stream.Type = 1
- bytes = stream.Read
- stream.Close
-
- On Error Resume Next
- Set xml = Server.CreateObject("MSXML2.DOMDocument.6.0")
- If Err.Number <> 0 Then
- Err.Clear
- Set xml = Server.CreateObject("MSXML2.DOMDocument")
- End If
- On Error GoTo 0
-
- Set node = xml.createElement("base64")
- node.DataType = "bin.base64"
- node.nodeTypedValue = bytes
-
- base64Value = Replace(Replace(node.Text, vbCr, ""), vbLf, "")
- base64Value = Replace(base64Value, "+", "-")
- base64Value = Replace(base64Value, "/", "_")
- base64Value = Replace(base64Value, "=", "")
-
- BuildUnsignedJwt = "e30." & base64Value & ".signature"
-
- Set node = Nothing
- Set xml = Nothing
- Set stream = Nothing
- End Function
- %>
|