Pravin Barton · Sep 14, 2021 go to post

Hi Joseph, I agree on using Client Credentials for this use case. As far as I know this is the only OAuth 2.0 grant type that authorizes server-to-server communication without the context of a user agent logging in. You can implement this in InterSystems IRIS by overriding the ValidateClient() method of the OAuth validation class: https://docs.intersystems.com/irislatest/csp/documatic/%25CSP.Documatic…

One thing to keep in mind is that by default anybody can register a new client with your authorization server by using the dynamic client registration endpoint. So the presence of a valid client isn't enough to authorize the API call. You will need some additional authorization logic.

Pravin Barton · Aug 12, 2021 go to post

The SSO system we use for this Developer Community has a "forgot password" implementation. Unfortunately it is down right now, but under normal circumstances  you would be able to try it out here: https://login.intersystems.com/login/SSO.UI.PasswordReset.cls

It works as follows:

  • The user enters their email address into a form. They are then taken to another form with an input for a token.
  • If the email address exists in the system, they are sent an email with a secure random token to input. Otherwise they are sent an email with instructions on how to register for an account.
  • Once the user inputs the token from their email to the page, they are taken to another form to set their new password.

It's important to avoid user enumeration by not revealing in the UI whether or not a user with the provided username or email address exists in the system. You should also hash the password reset tokens before storing them in a database, give them a short lifetime before they expire, and invalidate the token after it's used once.

I highly recommend OWASP for more resources on how to do this securely: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sh…

Pravin Barton · Aug 12, 2021 go to post

Here are a couple of ways to avoid <STORE> errors by increasing the per-process memory available to IRIS processes:

  • Increase the 'bbsiz' parameter, either by editing the CPF file or in the System Management Portal under System Administration > Configuration > System Configuration > Memory and Startup.
  • In code in the specific process throwing the <STORE> error, set the $zstorage special variable to increase the memory available to that process.
Pravin Barton · Aug 12, 2021 go to post

Hello Martin,

Using "DROP COLUMN" deletes the property from the class definition and modifies the storage definition by removing the property name. The storage definition will still have a "Value" item for the data, but it no longer includes the name of the property.

If you have the class definition in source control, the easiest way to truly delete the data is to revert to the previous version. Then you can run DROP COLUMN again with the %DELDATA option to remove the column and delete the data.

If this is not possible, I would look at the storage definition and find the empty slot in the storage definition. The "name" property on that slot will give you the storage index. You could then iterate through the global where the data is stored and do something like set $list(value, name) = "" to delete the data. I would recommend contacting Support before doing this to see if they have better suggestions.

Pravin Barton · Feb 19, 2021 go to post

Hi Neil,

Using OAuth2 in a mirrored environment would require some additional scripting to keep the configuration in sync between mirror members, since as you note it's stored in %SYS.

The Server Configuration on the auth server won't be changing much over time so I'd recommend writing an installation script that sets up all relevant configuration. Below are some snippets from an installation class I'm using on a Caché authorization server:

ClassMethod CreateServerConfiguration(pOrigNamespace As %String = "%SYS", Output interval As %Integer, Output server) As %Status
{
	Set server = ##class(OAuth2.Server.Configuration).Open(.tSC)
 	If $IsObject(server) {
	 	Set tSC = server.Delete()
	 	If $$$ISERR(tSC) Quit tSC
 	}
	
	Set interval = 3600

	Set server = ##class(OAuth2.Server.Configuration).%New()
	Set server.Description = "Single Sign-On"
	Set issuer = ##class(OAuth2.Endpoint).%New()
	Set issuer.Host = ..#IssuerHost
	Set issuer.Prefix = ..#IssuerPrefix
	Set server.IssuerEndpoint = issuer
	
	Set scopes = ##class(%ArrayOfDataTypes).%New()
	Do scopes.SetAt("OpenID Connect","openid")
	Do scopes.SetAt("E-mail Address","email")
	Do scopes.SetAt("User Profile","profile")
	// Add whatever other custom scopes you need
	Set server.SupportedScopes = scopes
	
	Set server.AllowUnsupportedScope = 0
	Set server.SupportedGrantTypes = "APCI"
	Set server.ReturnRefreshToken = ""
	Set server.AudRequired = 0
	
	Set server.CustomizationRoles = "%DB_CACHESYS,%Manager"
	Set server.CustomizationNamespace = pOrigNamespace
	Set server.AuthenticateClass = ..#CustomAuthenticateClassName
	Set server.ValidateUserClass = ..#CustomValidateClassName
	Set server.GenerateTokenClass = "%OAuth2.Server.JWT"
	
	Set server.AccessTokenInterval = interval
	Set server.RefreshTokenInterval = 3*interval
	Set server.AuthorizationCodeInterval = 120
	Set server.ServerCredentials = ..#ServerX509Name
	Set server.SigningAlgorithm = "RS512"
	Set server.KeyAlgorithm = ""
	Set server.EncryptionAlgorithm = ""
	Set server.SSLConfiguration = ..#SSLConfig
	
	Quit server.Save()
}

ClassMethod CreateServerDefinition(Output server) As %Status
{
	Set tIssuer = ..#EndpointRoot
	
	Set server = ##class(OAuth2.ServerDefinition).%OpenId("singleton")
	Set:'$IsObject(server) server = ##class(OAuth2.ServerDefinition).%New()
	Set server.IssuerEndpoint = tIssuer
	Set server.AuthorizationEndpoint = tIssuer_"/authorize"
	Set server.TokenEndpoint = tIssuer_"/token"
	Set server.UserinfoEndpoint = tIssuer_"/userinfo"
	Set server.IntrospectionEndpoint = tIssuer_"/introspection"
	Set server.RevocationEndpoint = tIssuer_"/revocation"
	Set server.ServerCredentials = ..#ServerX509Name
	Quit server.%Save()
}

The client descriptions are likely to change over time as new clients are registered. I think to keep these in sync between mirror members you'll need to regularly export the relevant globals directly from the primary, transport them to the secondary, and import them into the %SYS namespace. Below are some methods that do the export and import:

ClassMethod ExportClientConfiguration(pDestFile As %String) As %Status
{
	new $namespace
	set $namespace = "%SYS"
	for type = "D","I" {
		set tList("OAuth2.Server.Client"_type_".GBL") = ""
		set tList("OAuth2.Client.Metadata"_type_".GBL") = ""
	}
	set tSC = ##class(%File).CreateDirectoryChain(##class(%File).GetDirectory(pDestFile))
	return:$$$ISERR(tSC) tSC
	return $System.OBJ.Export(.tList,pDestFile,,.errorlog)
}

ClassMethod ImportClientConfiguration(pSourceFile As %String) As %Status
{
	new $namespace
	set $namespace = "%SYS"
	return $System.OBJ.Load(.pSourceFile,,.errorlog)
}

You could use a task to do this regularly on a short schedule.

Pravin Barton · Jul 8, 2020 go to post

Thank you for the zbreak example and linking the tutorial. I've always wanted a way to set conditional breakpoints for debugging but never looked deep enough into the documentation to find it.

Pravin Barton · Aug 23, 2019 go to post

Thanks for the reply. How can my unit test configure a BPL process to call my mock operation instead of the real operation? Do I have to rewrite the business process to use indirection in the target of each <call> element and make the target a custom setting on the business host?

Pravin Barton · Aug 13, 2019 go to post

Hi Stephen, I think you're on the right track by using custom claims for access control. This is what I've done in the past. Scopes in OAuth are intended to be granted by the user, which is not quite what you want here.

As far as I know there's no way to customize the token response. Your best option is to add the custom claims to the userinfo response. This means adding logic to your ValidateUser() method that will set the claim values and also add them to the list of user info claims.

Set tClaim = ##class(%OAuth2.Server.Claim).%New()
Do properties.UserinfoClaims.SetAt(tClaim,"MyCustomNamespace/MyCustomClaim")
Do properties.SetClaimValue("MyCustomNamespace/MyCustomClaim","something based on the user")

Then when the "resource server" part of your app validates the access token, you can call the userinfo endpoint to get this claim and determine the user's permissions.

$$$ThrowOnError(##class(%SYS.OAuth2.AccessToken).GetUserinfo(appName,accessToken,,.pUserInfo))
set myCustomClaim = pUserInfo."MyCustomNamespace/MyCustomClaim"

If you'd rather not make a separate call to userinfo each time, your other option is to add them to the body of the JWT. That would look similar in the ValidateUser() method, but with properties.JWTClaims instead of UserinfoClaims. Then your resource server can validate the signature on the JWT and get the claim from the token body using the ##class(%SYS.OAuth2.Validation).ValidateJWT() method. This is a little more complicated because you have to enforce that the JWT does have signing enabled (unfortunately the ValidateJWT() method will accept a token with no signature.)

Pravin Barton · May 21, 2019 go to post

I don't know of anything similar to Hibernate in Caché. If you want to encapsulate some data access logic inside of a class, it's helpful to define a class query that other objects can access through dynamic SQL.

More specific to your getByCode example, I use the index auto-generated methods a lot. For example in your dictionary table I would create a unique index on Code Index CodeIndex On Code [ Unique ]; and then use ##class(Whatever.Dictionary).CodeIndexOpen() to open the object I want.

Pravin Barton · May 13, 2019 go to post

For the first part of your question, here's a sample method that gets a bearer token from the request:

ClassMethod GetAccessTokenFromRequest(pRequest As %CSP.Request = {%request}) As %String
{
	Set accessToken=""
	Set authorizationHeader=pRequest.GetCgiEnv("HTTP_AUTHORIZATION")
	If $zcvt($piece(authorizationHeader," ",1),"U")="BEARER" {
		If $length(authorizationHeader," ")=2 {
			Set accessToken=$piece(authorizationHeader," ",2)
		}
	}
	return accessToken
}

EDIT: And here is a full sample of a REST handler that retrieves a bearer token and reuses it to make a request against another REST service.

Class API.DemoBearerToken Extends %CSP.REST
{

Parameter APIHOST = "localhost";

Parameter APIPORT = 52773;

Parameter APIPATH = "/some/other/path";

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

ClassMethod example()
{
	set accessToken = ..GetAccessTokenFromRequest(%request)
	set req = ##class(%Net.HttpRequest).%New()
	set req.Https = 1
	set req.SSLConfiguration = "some ssl config"
	set req.Server = ..#APIHOST
	set req.Port = ..#APIPORT
	set req.Authorization = "Bearer "_accessToken
	$$$ThrowOnError(req.Get(..#APIPATH))
	set %response.Status = req.HttpResponse.StatusCode
	set %response.ContentType = req.HttpResponse.ContentType
	if req.HttpResponse.StatusCode <= 400 { //if not an error response
		set jsonData = {}.%FromJSON(req.HttpResponse.Data)
		write jsonData.%ToJSON()
	}
	return $$$OK
}

ClassMethod GetAccessTokenFromRequest(pRequest As %CSP.Request = {%request}) As %String
{
	Set accessToken=""
	Set authorizationHeader=pRequest.GetCgiEnv("HTTP_AUTHORIZATION")
	If $zcvt($piece(authorizationHeader," ",1),"U")="BEARER" {
		If $length(authorizationHeader," ")=2 {
			Set accessToken=$piece(authorizationHeader," ",2)
		}
	}
	return accessToken
}

}

Pravin Barton · Mar 27, 2019 go to post

I've had this problem before where scheduled tasks just stopped running, but if I ran ##class(%Sys.Task).CheckSchedule() all the previously scheduled tasks would run once. Restarting my Ensemble instance fixed it. I recommend contacting support if this recurs.

Pravin Barton · Feb 11, 2019 go to post

Hi Pilar,
Here's an example call using password grant that works for me. You might have to change the endpoint depending on your OAuth server configuration.

POST /oauth2/token
Content-type: application/x-www-form-urlencoded
grant_type=password
username=pravin
password=1234
client_id=xxxxxx
client_secret=xxxxxx
redirect_uri=xxxxxx
response_type=token
state=1234
scope=profile

This authenticates the user and returns JSON with the access token as expected.

Pravin Barton · Jan 2, 2019 go to post

You can add table-level privileges on all tables in the namespace by running "GRANT Select ON * TO Test", where Test is the name of the user or role. But if a new table is later added they won't automatically get access to it. I don't know of a way to do that with SQL privileges short of giving them %All.

Pravin Barton · Dec 5, 2018 go to post

Hi Arun,

The simplest solution is to use the CSP session with a custom login page. That way you can use the built-in CSP authentication. Sergey's answer to this post has a good example: https://community.intersystems.com/post/authentication-options-cach%C3%…. The downside is that it's not truly stateless, and it requires you to serve your web files through CSP.

If your web application isn't connected to CSP, I recommend using OAuth 2.0. This is a little more work since it involves setting up an authorization server. There's an excellent series of tutorials here:

https://community.intersystems.com/post/intersystems-iris-open-authoriz….

Pravin Barton · Nov 20, 2018 go to post

Here's the code I'm using to test btw. If you uncomment the commented line it gives the SAX parser error.

Class XML.Sample2 Extends (%RegisteredObject, %XML.Adaptor)
{

Property StringEmpty As %String;

Property StringZerowidth As %String;

Property IntegerEmpty As %Integer;

Property IntegerZerowidth As %Integer;

Property BoolTrue As %Boolean;

Property BoolFalse As %Boolean;

Property BoolZerowidth As %Boolean;

ClassMethod Test() As %Status
{
    set sample = ..%New()
    set sample.StringEmpty = ""
    set sample.StringZerowidth = $c(0)
    set sample.IntegerEmpty = ""
    //set sample.IntegerZerowidth = $c(0)
    set sample.BoolTrue = 1
    set sample.BoolFalse = 0
    set sample.BoolZerowidth = $c(0)
    set writer = ##class(%XML.Writer).%New()
    $$$QuitOnError(writer.OutputToString())
    $$$QuitOnError(writer.RootElement("root"))
    $$$QuitOnError(writer.Object(sample,"sample"))
    $$$QuitOnError(writer.EndRootElement())
    set string = writer.GetXMLString()
    w !, string
    set reader = ##class(%XML.Reader).%New()
    $$$QuitOnError(reader.OpenString(string))
    do reader.Correlate("sample","XML.Sample2")
    do reader.Next(.object, .sc)
    $$$QuitOnError(sc)
    for prop = "StringEmpty","StringZerowidth","IntegerEmpty","IntegerZerowidth","BoolTrue","BoolFalse","BoolZerowidth" {
        write !, prop, ": ", $replace($property(object,prop),$c(0),"$c(0)")
    }
    return $$$OK
}

}

Pravin Barton · Sep 6, 2018 go to post

Never mind, I answered my own question. I should be using the token endpoint instead of the authorize endpoint.

Pravin Barton · Jan 31, 2018 go to post

Thanks, that might work. Is there any danger of the ID from CachéStorage getting reused if an entry gets deleted?

Pravin Barton · Jan 31, 2018 go to post

We're uniting this person+holiday table with a different table of personal time off to create a general absences table. Client applications access this table through a web service in order to sync a schedule. They need a GUID on each absence entry so they know what needs to be updated. For example, if a holiday changes there's an absence for each person in that country, and the client needs to update each of those entries.

We're only sending across the absences that have been updated since the last sync, so the client can't just rebuild the whole schedule every time.

Pravin Barton · Jan 31, 2018 go to post

We have a table for holidays and for people. Both of these tables have a country column. Each country has a list of holidays and all the people in that country have all those holidays off. The result of the join means semantically: which people have which days off. I could create a third table for this but it would have to be updated any time a holiday or a person gets added.

Your suggestion of using a hash function is good, and I think I'll do that.

Pravin Barton · Jan 31, 2018 go to post

Currently we're using the GUID from one of the tables, but the problem is it's not unique anymore after the join.

Pravin Barton · Jan 31, 2018 go to post

Good thought, but I should have mentioned that the GUID for a specific row needs to stay the same over multiple calls of the query.

Pravin Barton · Nov 27, 2017 go to post

If the REST service is giving you JSON data, you'll have to use the Zen proxy object to consume it. Here's some documentation on how to convert JSON data to a proxy object (it doesn't actually require using Zen).

I'll add that Caché 2016.1 added some capabilities that make working with JSON much easier. See this introductory post, and this other post explaining how the syntax changed with 2016.2.

Pravin Barton · Nov 13, 2017 go to post

Hi Victor,

You can use the class queries  of %Library.SQLCatalog to find catalog details for tables.

The SQLTables query gives you a list of tables:

select * from %Library.SQLCatalog_SQLTables()

And the SQLFields query will give you a list of fields for a given table:

select * from %Library.SQLCatalog_SQLFields('sample table name')

You can run these queries in the command line using dynamic sql, for example:

set sql = ##class(%SQL.Statement).%New()
write sql.%PrepareClassQuery("%Library.SQLCatalog","SQLTables")
set rs = sql.%Execute()
do rs.%Display()
Pravin Barton · Nov 13, 2017 go to post

Hi Bob,

I've found the Terminal plugin useful for running server side code and viewing the output while I'm developing. You can dock it to the bottom of the Atelier window so it works like the Output window in Studio. The difference is that with Terminal you have to explicitly connect to the sever with SSH or Telnet. There's some relevant documentation here: https://docs.intersystems.com/atelier/latest/topic/com.intersys.eclipse…

Pravin Barton · Oct 31, 2017 go to post

Thanks Jean! That will work for me.

I also found a way of accessing the html element in javascript to set the maximum length:

zen('commentTextArea').findElement('control').setAttribute('maxlength',1000);

But creating the custom component is probably a better practice, since it doesn't break the Zen abstraction.

Pravin Barton · May 25, 2017 go to post

My question is where to put the #include statement so that runtime expressions can reference the included macros. I tried both putting it in a script tag, and directly at the top of the csp file. Both ways it fails to compile with a "Referenced macro not defined" error.

EDIT - Sorry, I just noticed that was a link. The include directive should do what I need. Thanks!

Pravin Barton · May 12, 2017 go to post

Hi Bob,

For this to work you also need to define the data controller element in the page contents. For example, you could add the following element to the XData:

<dataController id="spriteController" modelClass="User.SpriteDataModel" modelId="1"/> 

If you have a data model class defined called "User.SpriteDataModel".

I recommend taking a look at the "ZENMVC.MVCForm" class in the SAMPLES namespace if you'd like an example of defining a data controller and data model.

Pravin Barton · May 11, 2017 go to post

Hi Bob,

The databag API is what the Zen MVC uses internally, you shouldn't have to worry about it.

Here's some sample code modifying the ZENTest.SVGSpriteTest sample page to display data from a datacontroller:

ClientMethod initializeCanvas() [ Language = javascript ]
{
var canvas zenPage.getComponentById('svgCanvas');
if ((!canvas) || !canvas.document) {
// ensure we don't execute code before the SVG document has been fully loaded
setTimeout('zenPage.initializeCanvas();',10);
return;
}
var inspector this.getComponentById('objectInspector');
inspector.setCurrObject(canvas);
var controller zenPage.getComponentById('spriteController');
controller.setModelId(1);
// create initial set of sprites & connectors
var sprite new Array();
sprite[0] canvas.createSprite('sprite',200,100);
sprite[0].setProperty('onclick','zenPage.selectSprite(zenThis);');
sprite[0].setProperty('caption', controller.getDataByName("sprite1"));
sprite[1] canvas.createSprite('sprite',200,300);
sprite[1].setProperty('caption', controller.getDataByName("sprite2"));
sprite[2] canvas.createSprite('sprite',400,100);
sprite[2].setProperty('caption', controller.getDataByName("sprite3"));
sprite[3] canvas.createSprite('sprite',400,300);
sprite[3].setProperty('caption', controller.getDataByName("sprite4"));
var connect canvas.createConnector('connector');
connect.addOutput(sprite[0],1);
//connect.addOutput(sprite[2],1);
connect.addInput(sprite[1],0);
//connect.addInput(sprite[3],0);
// turn off layout; turn on edit mode
canvas.setProperty('layout','');
canvas.setProperty('editMode','drag');
}