diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f7f567..dff150a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - #822: The CPF resource processor now supports system expressions and macros in CPF merge files - #578 Added functionality to record and display IPM history of install, uninstall, load, and update - #961: Adding creation of a lock file for a module by using the `-create-lockfile` flag on install. - +- #973: Enables CORS and JWT configuration for WebApplications in module xml ### Changed - #316: All parameters, except developer mode, included with a `load`, `install` or `update` command will be propagated to dependencies - #885: Always synchronously load dependencies and let each module do multi-threading as needed diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index 8734ec9a..540d23b0 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -1329,6 +1329,7 @@ ClassMethod GenerateModuleXML(ByRef pCommandInfo) As %Status [ Internal ] do ##class(%Library.Prompt).GetString("Enter module keywords:", .tKeywords) + #dim tTemplate As %IPM.Storage.ModuleTemplate set tTemplate = ##class(%IPM.Storage.ModuleTemplate).NewTemplate(tPath, tName, tVersion, tDescription, tKeywords) return:'$isobject(tTemplate) @@ -1361,6 +1362,44 @@ ClassMethod GenerateModuleXML(ByRef pCommandInfo) As %Status [ Internal ] } do ##class(%Library.Prompt).GetString(" Enter a comma separated list of web applications or * for all:", .tWebAppList) +#if $$$CacheVersionMajor>=2025 + //cors + write !,"Cross-Origin Settings:" + do ##class(%Library.Prompt).GetYesNo("Configure Access-Control-Allow-Credentials:",.enableCors) + if enableCors { + do ##class(%Library.Prompt).GetString(" Enter a comma separated list of Allowed Headers:",.allowedHeaders,,32767,"comma seperated values e.g: Access-Control-Allow-Origin,Access-Control-Allow-Headers") + while (1) { + do ##class(%Library.Prompt).GetString(" Enter a comma separated list of Allowed Origins:",.allowedOrigins,,32767,"comma seperated values e.g: http://www.example.com") + if allowedOrigins="" quit + set sc = tTemplate.ValidateCorsOrigin(allowedOrigins) + if $$$ISERR(sc) { + do ..DisplayError(sc) + set allowedOrigins = "" + continue + } + quit + } + set corsConfig("enableCors") = enableCors + set corsConfig("allowedHeaders") = allowedHeaders + set corsConfig("allowedOrigins") = allowedOrigins + do tTemplate.SetCORSProps(tWebAppList,.corsConfig) + } +#endif + +#if $$$CacheVersionMajor>=2024 + //jwt + write !,"JWT Authentication" + do ##class(%Library.Prompt).GetYesNo("Configure JWT Authentication: ",.enableJWT) + if enableJWT { + do ##class(%Library.Prompt).GetNumber(" Enter JWT Access Token Timeout:",.JWTAccessTokenTO,1) + do ##class(%Library.Prompt).GetNumber(" Enter JWT Refresh Token Timeout:",.JWTRefreshTokenTO,1) + set jwtconfig("enableJWT") = enableJWT + set jwtconfig("JWTAccessTokenTO") = JWTAccessTokenTO + set jwtconfig("JWTRefreshTokenTO") = JWTRefreshTokenTO + do tTemplate.SetJWTProps(tWebAppList,.jwtconfig) + } +#endif + do tTemplate.AddWebApps(tWebAppList,.tCSPapps) // tCSP - list of CSP (not REST apps) for i=1:1:$listlength(tCSPapps) { set tCSPPath = "" diff --git a/src/cls/IPM/Storage/ModuleTemplate.cls b/src/cls/IPM/Storage/ModuleTemplate.cls index c2b948a4..fca2b5ff 100644 --- a/src/cls/IPM/Storage/ModuleTemplate.cls +++ b/src/cls/IPM/Storage/ModuleTemplate.cls @@ -185,7 +185,7 @@ Method AddWebApps( { set tAppList = "" set pApps = $zstrip(pApps,"<>W") - if ( pApps = "*" ) { + if (pApps = "*") { do ..GetCSPApplications(.tAppList) } else { set tAppList = $listfromstring(pApps,",") @@ -284,6 +284,24 @@ Method SetSourcePathForCSPApp( set ..TemplateResources(pCSPApp,"Path") = pPath } +Method DefineReminWebAppProps(ByRef props) +{ + //add cors origin details + set props("CorsAllowlist") = "" + set props("CorsCredentialsAllowed") = 0 + // add cors headers + set props("CorsHeadersList") = "" + set props("DeepSeeEnabled") = 0 + set props("JWTAuthEnabled") = 0 + set props("JWTRefreshTokenTimeout") = 900 + set props("JWTAccessTokenTimeout") = 60 + set props("WSGIAppLocation") = "" + set props("WSGIAppName") = "" + set props("WSGICallable") = "" + set props("WSGIDebug") = "" + set props("WSGIType") = "" +} + ClassMethod GetGlobalsList(Output globals As %List) As %Status { set globals="" @@ -352,6 +370,58 @@ Method SetAuthorProps( return $$$OK } +Method SetCORSProps( + apps As %String = "", + ByRef cors) +{ + set appList = "" + set apps = $zstrip(apps,"<>W") + if (apps = "*") { + $$$ThrowOnError(..GetCSPApplications(.appList)) + } else { + set appList = $listfromstring(apps,",") + } + set ptr = 0 + while $listnext(appList, ptr, webpApp) { + set ..TemplateResources(webpApp,"CorsCredentialsAllowed") = cors("enableCors") + set ..TemplateResources(webpApp,"CorsHeadersList") = cors("allowedHeaders") + set ..TemplateResources(webpApp,"CorsAllowlist") = cors("allowedOrigins") + } +} + +Method SetJWTProps( + apps As %String = "", + ByRef jwt) +{ + set appList = "" + set apps = $zstrip(apps,"<>W") + if (apps = "*") { + $$$ThrowOnError(..GetCSPApplications(.appList)) + } else { + set appList = $listfromstring(apps,",") + } + set ptr = 0 + while $listnext(appList, ptr, webpApp) { + set ..TemplateResources(webpApp,"JWTAccessTokenTimeout") = jwt("JWTAccessTokenTO") + set ..TemplateResources(webpApp,"JWTAuthEnabled") = jwt("allowedHeaders") + set ..TemplateResources(webpApp,"JWTRefreshTokenTimeout") = jwt("JWTRefreshTokenTO") + } +} + +Method ValidateCorsOrigin(pAllowedOrigins As %String = "") As %Status +{ + new $namespace + set $namespace = "%SYS" + set originsList = $listfromstring(pAllowedOrigins,",") + set ptr = 0 + set sc = $$$OK + while $listnext(originsList, ptr, origin){ + set sc = ##class(Security.Applications).ValidateCorsOrigin(origin) + if $$$ISERR(sc) quit + } + return sc +} + ClassMethod NewTemplate( pPath, pName, @@ -525,6 +595,11 @@ Method SetTemplateProps() As %Status set ..TemplateResources("rest","UnauthenticatedEnabled") = 0 set ..TemplateResources("rest","Recurse") = 1 + //CORS + set ..TemplateResources("rest","CorsAllowlist") = "http://www.example.com" + set ..TemplateResources("rest","CorsCredentialsAllowed") = 1 + set ..TemplateResources("rest","CorsHeadersList") = "Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Allow-Credentials, Access-Control-Max-Age, Access-Control-Expose-Headers, Origin, Access-Control-Request-Method, Access-Control-Request-Headers" + // WEB APP set ..TemplateResources("web") = "/web" set ..TemplateResources("web","Url") = "/web" @@ -600,9 +675,9 @@ XData XSLT - + - + diff --git a/tests/integration_tests/Test/PM/Integration/ResourceProcessor/WebApplication.cls b/tests/integration_tests/Test/PM/Integration/ResourceProcessor/WebApplication.cls new file mode 100644 index 00000000..ae2a9afc --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/ResourceProcessor/WebApplication.cls @@ -0,0 +1,72 @@ +/// This class validates that CORS headers and allowed origins are configured correctly, +/// and that JWT authentication is properly set up in the <WebApplication> configuration section. +Class Test.PM.Integration.ResourceProcessor.WebApplication Extends Test.PM.Integration.Base +{ + +Parameter CommonPathPrefix As STRING = "cors-rest-apps"; + +Parameter WebAppName As STRING = "/testcors"; + +/// Test.PM.Integration.Uninstall +Method TestCORSEnabledWebAppViaModule() +{ + #define NormalizeDirectory(%path) ##class(%File).NormalizeDirectory(%path) + #define UTRoot ^UnitTestRoot + + set testRoot = $$$NormalizeDirectory($get($$$UTRoot)) + set moduleDir = $$$NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/"_..#CommonPathPrefix_"/") + set moduleFile = ##class(%File).NormalizeFilename("module.xml",moduleDir) + if ##class(%File).DirectoryExists(moduleFile) { + do $$$AssertStatusOK(1,"module.xml File exist on "_moduleDir) + } + set status = ##class(%IPM.Main).Shell("load "_moduleDir) + do $$$AssertStatusOK(status,"Loaded "_..#CommonPathPrefix_" module successfully from "_moduleDir) + do ..VerifyCORSConfiguration() + do ..VerifyJWTConfiguration() + + set status = ##class(%IPM.Main).Shell("uninstall "_..#CommonPathPrefix) + do $$$AssertStatusOK(status,"uninstalled "_..#CommonPathPrefix_" module successfully.") +} + +Method VerifyCORSConfiguration() +{ + new $namespace + set $namespace = "%SYS" + set status = ##class(Security.Applications).Get(..#WebAppName, .props) + do $$$AssertStatusOK(status,"Web applciation "_..#WebAppName_" created scuccessfully") + if $data(props("CorsAllowlist"),corsAllowlist) { + do $$$AssertStatusOK(1,"CorsAllowlist values are defined") + do $$$LogMessage(corsAllowlist) + } + if $data(props("CorsCredentialsAllowed"),corsAllow) { + do $$$AssertStatusOK(1,"CorsCredentialsAllowed values are defined") + do $$$LogMessage(corsAllow) + } + if $data(props("CorsHeadersList"),corsHeadersList) { + do $$$AssertStatusOK(1,"CorsHeadersList values are defined") + do $$$LogMessage(corsHeadersList) + } +} + +Method VerifyJWTConfiguration() +{ + new $namespace + set $namespace = "%SYS" + set status = ##class(Security.Applications).Get(..#WebAppName, .props) + do $$$AssertStatusOK(status,"Web applciation "_..#WebAppName_" created scuccessfully") + do $$$LogMessage("Validating JWT configuration") + if $data(props("JWTAccessTokenTimeout"),JWTAccessTokenTimeout) { + do $$$AssertStatusOK(1,"JWTAccessTokenTimeout value is defined") + do $$$LogMessage(JWTAccessTokenTimeout) + } + if $data(props("JWTAuthEnabled"),JWTAuthEnabled) { + do $$$AssertStatusOK(1,"JWTAuthEnabled value is defined") + do $$$LogMessage(JWTAuthEnabled) + } + if $data(props("JWTRefreshTokenTimeout"),JWTRefreshTokenTimeout) { + do $$$AssertStatusOK(1,"JWTRefreshTokenTimeout value is defined") + do $$$LogMessage(JWTRefreshTokenTimeout) + } +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/module.xml b/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/module.xml new file mode 100644 index 00000000..bc003eef --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/module.xml @@ -0,0 +1,28 @@ + + + + + cors-rest-apps + 1.0.0 + cors enabling testing on webapplication + cors + module + + + %IPM.Lifecycle.Module + src + + + \ No newline at end of file diff --git a/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/src/cls/CorsTest/Rest/Cors.cls b/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/src/cls/CorsTest/Rest/Cors.cls new file mode 100644 index 00000000..93d6dd75 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/cors-rest-apps/src/cls/CorsTest/Rest/Cors.cls @@ -0,0 +1,17 @@ +Class CorsTest.Rest.Cors Extends %RegisteredObject +{ + +XData UrlMap [ XMLNamespace = "http://www.intersystems.com/urlmap" ] +{ + + + +} + +ClassMethod GetInfo() As %Status +{ + write "Hello, World!" + quit $$$OK +} + +}