<% 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 %>