Skip to content
39 changes: 39 additions & 0 deletions src/cls/IPM/Main.cls
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,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)

Expand Down Expand Up @@ -1304,6 +1305,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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: use if instead of #if

Copy link
Contributor Author

@AshokThangavel AshokThangavel Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the code based on the comment

//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){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing space between ) and {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the space issue

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 = ""
Expand Down
78 changes: 76 additions & 2 deletions src/cls/IPM/Storage/ModuleTemplate.cls
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,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=""
Expand Down Expand Up @@ -343,6 +361,58 @@ Method SetAuthorProps(
return $$$OK
}

Method SetCORSProps(
pApps As %String = "",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: remove the p- prefixes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the p-Prefix from the parameters pApps and pCors and variables

ByRef pCors As %String = "")
{
set appList = ""
set pApps = $zstrip(pApps,"<>W")
if ( pApps = "*" ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: remove extra leading/trailing spaces inside the parentheses here and elsewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the space issue.

do ..GetCSPApplications(.appList)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: check status

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the code!

} else {
set appList = $listfromstring(pApps,",")
}
set ptr = 0
while $listnext(appList, ptr, webpApp){
set ..TemplateResources(webpApp,"CorsCredentialsAllowed") = pCors("enableCors")
set ..TemplateResources(webpApp,"CorsHeadersList") = pCors("allowedHeaders")
set ..TemplateResources(webpApp,"CorsAllowlist") = pCors("allowedOrigins")
}
}

Method SetJWTProps(
pApps As %String = "",
ByRef pJWT As %String = "")
{
set appList = ""
set pApps = $zstrip(pApps,"<>W")
if ( pApps = "*" ) {
do ..GetCSPApplications(.appList)
} else {
set appList = $listfromstring(pApps,",")
}
set ptr = 0
while $listnext(appList, ptr, webpApp){
set ..TemplateResources(webpApp,"JWTAccessTokenTimeout") = pJWT("JWTAccessTokenTO")
set ..TemplateResources(webpApp,"JWTAuthEnabled") = pJWT("allowedHeaders")
set ..TemplateResources(webpApp,"JWTRefreshTokenTimeout") = pJWT("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){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing space between ) and {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the space issue

set sc = ##class(Security.Applications).ValidateCorsOrigin(origin)
if $$$ISERR(sc) quit
}
return sc
}

ClassMethod NewTemplate(
pPath,
pName,
Expand Down Expand Up @@ -515,6 +585,10 @@ Method SetTemplateProps() As %Status
set ..TemplateResources("rest","PasswordAuthEnabled") = 1
set ..TemplateResources("rest","UnauthenticatedEnabled") = 0
set ..TemplateResources("rest","Recurse") = 1
;cors
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: use // for comment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modified to // for comments

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"
Expand Down Expand Up @@ -591,9 +665,9 @@ XData XSLT
</Export>
</xsl:template>
<xsl:template match="Resource[@Url]">
<CSPApplication>
<WebApplication>
<xsl:apply-templates select="@*[local-name() != 'Name']" />
</CSPApplication>
</WebApplication>
</xsl:template>
<xsl:template match="node()">
<xsl:copy>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <b>&lt;WebApplication&gt;</b> configuration section.
Class Test.PM.Integration.ConfigCorsAndJWTInWebAppTest Extends Test.PM.Integration.Base
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test class captures the behavior so I don't think the unit tests are needed. Furthermore, in general, if unit testing a single feature, we prefer to keep tests in a single class and have multiple methods rather than splitting it across classes..
Lastly, I would name this Test.PM.Integration.ResourceProcessor.WebApplication so any other WebApplication related tests can be added here in future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback.
I’ve made the changes based on your suggestions:

  • Renamed the integration test class to Test.PM.Integration.ResourceProcessor.WebApplication
  • Removed separate unit test classes since the existing test class already captures the required behavior.
    Thank you!

{

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)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<Export generator="Cache" version="25">
<Document name="cors-rest-apps.ZPM">
<Module>
<Name>cors-rest-apps</Name>
<Version>1.0.0</Version>
<Description>cors enabling testing on webapplication</Description>
<Keywords>cors</Keywords>
<Packaging>module</Packaging>
<Resource Name="CorsTest.PKG"/>
<WebApplication
CookiePath="/testcors"
CorsAllowlist="https://www.example.com,https://pm.intersystems.com"
CorsCredentialsAllowed="1"
JWTAccessTokenTimeout="60"
JWTAuthEnabled="1"
JWTRefreshTokenTimeout="900"
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, Access-Control-Request-Method, Access-Control-Request-Headers"
PasswordAuthEnabled="1"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For WebApplication, these properties for auth are all encapsulated as part of AutheEnabled and not separate properties.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isc-kiyer
Could you please provide an example of how the authentication settings are represented when they are encapsulated under the AuthEnabled attribute in the element?
A sample XML snippet would be very helpful.
Thank you!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably this refers to how AutheEnabled is a bit string with these properties, e.g. AutheEnabled=32 means PasswordAuthEnabled=1
(see https://docs.intersystems.com/latest/csp/documatic/%25CSP.Documatic.cls?LIBRARY=%25SYS&CLASSNAME=Security.Applications#PROPERTY_AutheEnabled)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @isc-kiyer , @isc-dchui
Regarding the encapsulation of authentication properties within AutheEnabled. However, based on the class definition for Security.Applications in IRIS 2025.1, I believe these properties must remain discrete attributes within the <WebApplication> tag for the following reasons:

  1. Bitmask vs. Discrete Properties: The AutheEnabled property is a bitmask specifically for legacy authentication mechanisms such as Kerberos (4), Password (32), and Unauthenticated (64). In contrast, JWT is implemented via standalone properties: JWTAuthEnabled, JWTRefreshTokenTimeout, and JWTAccessTokenTimeout. These are not bits within the AutheEnabled integer and cannot be mathematically encapsulated into it.
  2. CORS Configuration: Similarly, CORS settings (such as CorsHeadersList and CorsAllowlist) are distinct string and boolean properties in the Security.Applications class. They do not have a representation within the authentication bitmask.
  3. Automated Handling by IPM: By including these as individual attributes in the <WebApplication> tag, the %IPM.ResourceProcessor.WebApplication class can automatically parse, map, and persist these values during the installation process. This ensures that the module.xml remains the declarative source of truth for the application's security posture.
  4. Consistency: Treating these as separate attributes aligns with how other non-bitmask properties (like DispatchClass or CookiePath) are handled within the IPM schema.
<WebApplication
    Url="/testcors"
    CookiePath="/testcors"
    AutheEnabled="32"
    JWTAuthEnabled="1"
    JWTAccessTokenTimeout="60"
    JWTRefreshTokenTimeout="900"
    CorsCredentialsAllowed="1"
    CorsAllowlist="https://www.example.com"
    CorsHeadersList="Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers"
    PasswordAuthEnabled="1"
    UnauthenticatedEnabled="0"
    Recurse="1"
    UseCookies="2" />

Could you check and suggest.
Thank you!

Recurse="1"
UnauthenticatedEnabled="0"
Url="/testcors"
UseCookies="2"/>
<LifecycleClass>%IPM.Lifecycle.Module</LifecycleClass>
<SourcesRoot>src</SourcesRoot>
</Module>
</Document>
</Export>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Class CorsTest.Rest.Cors Extends %RegisteredObject
{

XData UrlMap [ XMLNamespace = "http://www.intersystems.com/urlmap" ]
{
<Routes>
<Route Url="/" Method="GET" Call="GetInfo" Cors="true"/>
</Routes>
}

ClassMethod GetInfo() As %Status
{
write "Hello, World!"
quit $$$OK
}

}
80 changes: 80 additions & 0 deletions tests/unit_tests/Test/PM/Unit/WebAppCorsTest.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
Class Test.PM.Unit.WebAppCorsTest Extends %UnitTest.TestCase
{

Parameter CommonPathPrefix As STRING = "cors-rest-apps";

Parameter WebAppName As STRING = "/testcors";

/// create web application using <WebApplcation> tag
Method TestCORSEnabledViaWebAppTag()
{
do $$$LogMessage("loading via <WebApplcation> tag")
set testRoot = ##class(%File).NormalizeDirectory($get(^UnitTestRoot))
set moduleDir = ##class(%File).NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/"_..#CommonPathPrefix_"/webapp/")
if '##class(%File).DirectoryExists(moduleDir) {
do ##class(%File).CreateDirectoryChain(moduleDir)
}
set moduleFile = ##class(%File).NormalizeFilename("module.xml",moduleDir)
if ##class(%File).Exists(moduleFile){
set status= ##class(%File).Delete(moduleFile)
do $$$AssertStatusOK(status,"Removed the 'module.xml' file from "_moduleDir)
}
do $$$LogMessage("Creating module.xml with <WebApplcation> tag")
set moduleStream = ##class(%Dictionary.XDataDefinition).%OpenId($classname()_"||ModuleWithWebApTag").Data
set fileStream = ##class(%Stream.FileBinary).%New()
set fileStream.Filename=moduleFile
set status= fileStream.CopyFromAndSave(moduleStream)
do $$$AssertStatusOK(status,"Created module.xml manually on "_moduleDir_" successfully.")

set status = ##class(%IPM.Main).Shell("load "_moduleDir)
do $$$AssertStatusOK(status,"Loaded "_..#CommonPathPrefix_" module successfully. "_moduleDir)
do ..VerifyCORSSettings()

set status = ##class(%IPM.Main).Shell("uninstall "_..#CommonPathPrefix)
do $$$AssertStatusOK(status,"uninstalled "_..#CommonPathPrefix_" module successfully.")
}

Method VerifyCORSSettings()
{
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)&&(corsAllowlist'="") {
do $$$AssertStatusOK(1,"CorsAllowlist values are defined")
do $$$LogMessage(corsAllowlist)
}
if $data(props("CorsCredentialsAllowed"),corsAllow)&&(corsAllow'="") {
do $$$AssertStatusOK(1,"CorsCredentialsAllowed values are defined")
do $$$LogMessage(corsAllow)
}
if $data(props("CorsHeadersList"),corsHeadersList)&&(corsHeadersList'="") {
do $$$AssertStatusOK(1,"CorsHeadersList values are defined")
do $$$LogMessage(corsHeadersList)
}
}

XData ModuleWithWebApTag [ MimeType = application/xml ]
{
<?xml version="1.0" encoding="UTF-8"?>
<Export generator="Cache" version="25">
<Document name="cors-rest-apps.ZPM">
<Module>
<Name>cors-rest-apps</Name>
<Version>1.0.0</Version>
<Description>cors enabling testing on webapplication</Description>
<Keywords>cors</Keywords>
<Packaging>module</Packaging>
<WebApplication Url="/testcors" CookiePath="/testcors"
CorsAllowlist="https://pm.intersystems.com"
CorsCredentialsAllowed="1"
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"
PasswordAuthEnabled="1" Recurse="1" UnauthenticatedEnabled="0" UseCookies="2"/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For WebApplication, these properties for auth are all encapsulated as part of AutheEnabled and not separate properties.

<LifecycleClass>%IPM.Lifecycle.Module</LifecycleClass>
<SourcesRoot>src</SourcesRoot>
</Module>
</Document>
</Export>
}

}
Loading