Adding custom claims to in OAuth 2.0?

I have an OAuth 2.0 development environment where Caché is serving all three roles as the Authorization Server, Client and Resource Server based on a great 3-part series on OAuth 2.0 by @Daniel Kutac. I have a simple password grant type where an x-www-form-urlencoded body (as described in this post) is sent as a POST to the token endpoint at https://localhost:57773/oauth2/token and a response body with a HTTP Response 200 header is returned. The response body looks something like this.

{

"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJqdGkiOiJodHRwczovL2NhY2NvbnMxLmhwc3Mubi1pLm5ocy51azo1Nzc3My9vYXV0aDIuazZKZlpSZnpBRU02NHpzRDJYUFZsSHRBOElnIiwiaXNzIjoiaHR0cHM6Ly9jYWNjb25zMS5ocHNzLm4taS5uaHMudWs6NTc3NzMvb2F1dGgyIiwic3ViIjoidGVzdDEiLCJleHAiOjE1NjU2ODc4OTcsImF1ZCI6IkhwSHlfTDA2MVJLVExsaW1OS3FnWjJHR2xkQnE3dWJTNWlZNE5UNFNfVFkifQ.",

"token_type": "bearer",

"expires_in": 180,

"scope": "createModify openid profile publish"

}

In this example, the token generation class is the %OAuth2.Server.JWT class.

The expires_in property is the 'Access Token Interval' set in seconds via System Management Portal OAuth options. I have a low interval for the purposes of testing token expiry responses.

Scopes are sorted alphabetically by default. I have added the custom scopes 'createModify' and 'publish' here but these don't really make sense as they are passed into the Request body before the user is authenticated via the token endpoint. You don't know what application roles a user has until they have been authenticated so I would like to return these application roles as claims to the response body. I think I should remove these scopes and replace them with 'MyCustomApplication'.  This should be the same for all users.  I can sometimes get confused between scopes and application roles! Any thoughts?

I want to customize this response body to retrieve a set of custom claims the user has when they successfully generate an access token. An example of this using a .NET Core demo I was playing with looks something like this. 

{


"bearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJQU2hlcmlmZiIsImp0aSI6IjliMzJhNGJhLTdkOWEtNGQ5MS04NWMwLTA2NGM5MTlkNWNmZCIsIkNhbkFjY2Vzc1Byb2R1Y3RzIjoidHJ1ZSIsIkNhbkFkZFByb2R1Y3QiOiJ0cnVlIiwiQ2FuU2F2ZVByb2R1Y3QiOiJ0cnVlIiwiQ2FuQWNjZXNzQ2F0ZWdvcmllcyI6InRydWUiLCJDYW5BZGRDYXRlZ29yeSI6InRydWUiLCJuYmYiOjE1NjU2OTIyNDksImV4cCI6MTU2NTY5MjMwOSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1MDAwIiwiYXVkIjoiUFRDVXNlcnMifQ.wLXZ-b7Q-xu2WWYDCvnKVN_8vurEtkpftjFOAHHu8Fs",

"expires": "13 August 2019 11:31:49",


"claims": [

{


"claimType": "CanAccessProducts",

"claimValue": "true"

},

{


"claimType": "CanAddProduct",

"claimValue": "true"

},

{


"claimType": "CanSaveProduct",

"claimValue": "true"

},

{


"claimType": "CanAccessCategories",

"claimValue": "true"

},

{


"claimType": "CanAddCategory",

"claimValue": "true"

}

]

}

So what if I wanted to add these additional claims to the response body. Where can this be done?

I found an interesting comment in the classmethod ##class(%OAuth2.Server.Validate).ValidateUser() that read 'Use the Cache roles for the user to setup a custom property' and I can see the roles for my user being set  via Do properties.CustomProperties.SetAt(roles,"roles") but I can't see the roles being written to the JSON in the response. In addition to roles, I cannot see any of the OpenID Connect claims such as sub,  preferred_username, email, updated_at being written to the response body even though they are setup in this classmethod.

Summary

  1. How do I customize what is returned in the response body of the token endpoint?
  2. Any thoughts on scope design principles vs. user-based application roles?

 

  • + 1
  • 0
  • 89
  • 2
  • 1

Respuestas

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

Thanks @Barton Pravin for clarifying scope and providing the code snippets!  If you want the claim to be part of the Token endpoint response message, you can use Do properties.ResponseProperties.SetAt(roles,"roles") 

Heres's a sample of ValidateUser customization

// Use the Cache roles for the user to setup a custom property.
Set sc=##class(Security.Roles).RecurseRoleSet(prop("Roles"),.roles)
If $$$ISERR(sc) Quit 0


Set roles=prop("Roles")
Do properties.CustomProperties.SetAt(roles,"roles") 

// SETUP CUSTOM CLAIMS HERE
Set tClaim = ##class(%OAuth2.Server.Claim).%New()
Do properties.ResponseProperties.SetAt(roles,"roles")
Do properties.IntrospectionClaims.SetAt(tClaim,"roles")
Do properties.UserinfoClaims.SetAt(tClaim,"roles")
Do properties.JWTClaims.SetAt(tClaim,"roles") 
Do properties.SetClaimValue("roles",roles)

And a sample of the REST API application

Class API.DemoBearerToken Extends %CSP.REST
{
Parameter APIHOST = "localhost";

Parameter APIPORT = 57773;

Parameter APIPATH = "/api/demobearertoken";

Parameter CHARSET = "utf-8";

Parameter CONTENTTYPE = "application/json";

Parameter OAUTH2CLIENTREDIRECTURI = "https://localhost:57773/api/demobearertoken/example";

Parameter OAUTH2APPNAME = "demobearertoken";

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


Classmethod AccessCheck(Output pAuthorized As %Boolean = 0) as %Status
{

Set dayNum = $p($H,",",1)
Set timeNum = $p($H,",",2)


Set accessToken = ..GetAccessTokenFromRequest(%request) 
Set scope = "createModify openid profile publish" 
Set isValidToken=##class(%SYS.OAuth2.Validation).ValidateJWT(..#OAUTH2APPNAME,.accessToken,,,.jsonValidationObject,.securityParameters,.error)
Set ^LOG(dayNum,timeNum,$UserName,"API.DemoBearerToken",$ztimestamp,"AccessCheck")=$zdatetime(dayNum_","_timeNum,4,1,,,4)_"*"_isValidToken

Set:isValidToken=1 pAuthorized=1
Set:isValidToken=0 pAuthorized=0 
Quit $$$OK
}
ClassMethod GetToken() As %Status
{
#dim %response as %CSP.Response
Set %response.Expires = 86400
Set %response.Headers("Cache-Control") = "max-age=86400"

Set dayNum = $p($H,",",1)
Set timeNum = $p($H,",",2) 
Set ^LOG(dayNum,timeNum,$UserName,"API.DemoBearerToken",$ztimestamp,"GetToken")=$zdatetime(dayNum_","_timeNum,4,1,,,4) 
Set accessToken = ..GetAccessTokenFromRequest(%request)
Set scope = "createModify openid profile publish" 
Set valid=##class(%SYS.OAuth2.Validation).ValidateJWT(..#OAUTH2APPNAME,.accessToken,,,.jsonValidationObject,.securityParameters,.error) 

Set introspectionStatus=##class(%SYS.OAuth2.AccessToken).GetIntrospection(..#OAUTH2APPNAME,accessToken,.introspectionJSON) 
Set userInfoStatus = ##class(%SYS.OAuth2.AccessToken).GetUserinfo(..#OAUTH2APPNAME,accessToken,,.userInfoJSON) 
Set jsonResponse = {}.%Set("OAUTH2APPNAME",..#OAUTH2APPNAME)
Do jsonResponse.%Set("ValidateJWT",valid) 

Do jsonResponse.%Set("jsonValidationObject",jsonValidationObject)
Do jsonResponse.%Set("IntrospectionJSON",introspectionJSON)
Do jsonResponse.%Set("sc_userinfo",$$$ISOK(userInfoStatus)) 

If $$$ISOK(userInfoStatus) {
 Do jsonResponse.%Set("UserInfoJSON",userInfoJSON)
}

Write jsonResponse.%ToJSON()

Quit $$$OK 
}

}

And the Postman response for GET https://{{SERVER}}:{{SSLPORT}}/api/demobearertoken/getToken

{

"OAUTH2APPNAME": "demobearertoken",

"ValidateJWT": 1,

"jsonValidationObject": {

"jti": "https://localhost:57773/oauth2.mxn0URwYVkmaX9BSKHGIzISi-cI",

"iss": "https://localhost:57773/oauth2",

"sub": "test1",

"exp": 1565708268,

"aud": "xxxxxxxxxxxxxxxxxxxxx",

"roles": "%DB_CODE,%Manager,createModify,publish"

},

"IntrospectionJSON": {

"active": true,

"scope": "createModify openid profile publish",

"client_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",

"username": "test1",

"token_type": "bearer",

"exp": 1565708268,

"sub": "test1",

"aud": "xxxxxxxxxxxxxxxxxxxxxxx",

"iss": "https://localhost:57773/oauth2",

"roles": "%DB_CODE,%Manager,createModify,publish"

},

"sc_userinfo": 1,

"UserInfoJSON": {

"sub": "test1",

"roles": "%DB_CODE,%Manager,createModify,publish"

}

You can see 'roles' has been added to JWT, Introspection and UserInfo claim types. In my real-world application it's probably sufficient to add it to the JWT. 

Also @Eduard Lebedyuk  suggested using the AccessCheck method to verify the token in the post From Cache how to Retrieve and Use/Reuse a Bearer Token to authenticate and send data to REST web service?  AccessCheck is called before anything else so if the token is invalid or has expired a 401 Unauthorized HTTP Response is returned. A web application consuming this REST API can then process this unauthorized status and return them to the login screen. 

Comentarios

I can also see in the classmethod ##class(OAuth2.Server.Token).ReturnToken(client,token) where there is a section on adding customized response properties but where are these set?

My AccessToken.ResponseProperties array appears to be empty

Set json.scope=token.Scope
// Add the customized response properties
Set key=""
For {
Set value=token.Properties.ResponseProperties.GetNext(.key)
If key="" Quit
Set $property(json,key)=value
}