Tutorial 5: Using OAuth 2.0 for apps

The authorization server can be an OAuth 2.0 server or a JWT server. In this tutorial, we will use OAuth 2.0 server and uses IdentityServer4 as the OAuth 2.0 authorization server framework.

Task 1: Creating an OAuth 2.0 Web API

Purpose

This task creates an OAuth 2.0 Web API to achieve the following results:

  • Defines the OAuth 2.0 client credentials and password, including grant type, scope, client ID, and client secret.

    You can define the client credentials by yourself or obtain them from the existing service such as Google API Console (login is required).

  • Adds a SQL Server data context (if the OAuth 2.0 Web API needs to connect to a database for validating the credentials and password).

  • Authenticates the user credentials and returns an access token or an error.


You can also use an existing OAuth 2.0 server such as Google OAuth 2.0 Authorization Server, instead of creating one.

Instructions

Step 1: Create an ASP.NET Core Web API project in SnapDevelop and name it OAuthServer.

Step 2: Install IdentityServer4 to the project through NuGet Package Manager.

In this tutorial, we use IdentityServer4 as the OAuth 2.0 authorization server framework. You can choose the other OAuth 2.0 framework according to your needs.


Step 3: Add a class to the project and name it OAuth2Config.cs, and add the following scripts to OAuth2Config.cs.

The following scripts define the OAuth client credentials and password, including grant type, scope, client ID, and client secret.

using IdentityServer4.Models;
using System.Collections.Generic;

namespace OAuthServer
{
    public class OAuth2Config
    {
        public static IEnumerable<ApiScope> ApiScopes =>
            new List<ApiScope>
            {
                new ApiScope("scope.readaccess", "Your API1")
            };
            
        // scopes: define the resources that are accessible
        public static IEnumerable<ApiResource> ApiResources =>
             new List<ApiResource>
            {
                new ApiResource("scope.readaccess", "Your API1")
            };
            
        public static IEnumerable<IdentityResource> IdentityResources =>
           new List<IdentityResource>
           {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
           };
           
        // client credentials want to access resources
        public static IEnumerable<Client> Clients =>
            new List<Client>
            {
                new Client
                            {
                                ClientId = "YourClientIdThatCanOnlyRead",
                                AllowedGrantTypes = GrantTypes.ResourceOwnerPasswordAndClientCredentials,
                                
                                ClientSecrets =
                                {
                                    new Secret("yoursecret1".Sha256())
                                },
                                AllowedScopes = { "scope.readaccess" },
                                AccessTokenLifetime = 3600  //seconds
                            }
            };
    }
}

Step 4: Add the DefaultDataContext.cs class and add the following scripts, in order to connect to the database to validate user credentials.

The following scripts derive the class from SqlServerDataContext (taking SQL Server database as an example).

using SnapObjects.Data;
using SnapObjects.Data.SqlServer;

namespace OAuthServer
{
    public class DefaultDataContext: SqlServerDataContext
    {
        public DefaultDataContext(string connectionString)
            : this(new SqlServerDataContextOptions<DefaultDataContext>(connectionString))
        {
        }
        
        public DefaultDataContext(IDataContextOptions<DefaultDataContext> options)
            : base(options)
        {
        }
        
        public DefaultDataContext(IDataContextOptions options)
            : base(options)
        {
        }
    }
}

Step 5: Add the UserValidator.cs class and add the following scripts.

The following scripts derive the class from IResourceOwnerPasswordValidator. If you have stored the username and password in a database, you should uncomment the following scripts and modify the connection string to connect to your own database and validate the user credentials against the database.

For demonstration purpose in this tutorial, the username ("user") and password ("pass") are directly hard-coded in the scripts (instead of reading from the database).

Here is another sample script of the UserValidator.cs class that connects to an LDAP server to authenticate the user credentials.

using IdentityServer4.Models;
using IdentityServer4.Validation;
using System.Threading.Tasks;

namespace OAuthServer
{
    public class UserValidator : IResourceOwnerPasswordValidator
    {
        public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            ///To validate username and password against the database, uncomment the following scripts and modify the connection string  
            //String Constr = @"Data Source=172.16.1.10,1433;Initial Catalog=pb_cloud;Integrated Security=False;User ID=sa;Password=1234;Pooling=True;Min Pool Size=0;Max Pool Size=100;MultipleActiveResultSets=False;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite";
            //DefaultDataContext _context = new DefaultDataContext(Constr);
            //string sql = "select username from users where isValid = 1 and username = '" + context.UserName + "' and password = '" + context.Password + "'";
            //var users = _context.SqlExecutor.Select<DynamicModel>(sql);
            
            //if (users.Count >= 1)
            //{
            //    context.Result = new GrantValidationResult(subject: context.UserName, authenticationMethod: "custom");
            //}
            //else
            //{
            //    context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, context.UserName + " not exist or password is wrong");
            //}
            
            //For demo purpose, hard code the username and password instead of reading from database
            if (context.UserName == "user" && context.Password == "pass")
            {
                context.Result = new GrantValidationResult(subject: context.UserName, authenticationMethod: "custom");
            }
            else
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, context.UserName + " not exist or password is wrong");
            }
            
            
            return Task.FromResult(0);
        }
    }
}

Step 6: Modify the Startup.cs class.

1) Add the IdentityServer service to the ConfigureServices method:

var builder = services.AddIdentityServer()
             .AddInMemoryIdentityResources(OAuth2Config.IdentityResources)
             .AddInMemoryApiScopes(OAuth2Config.ApiScopes)
             .AddInMemoryApiResources(OAuth2Config.ApiResources)
             .AddInMemoryClients(OAuth2Config.Clients)
             .AddResourceOwnerValidator<UserValidator>();
            builder.AddDeveloperSigningCredential();


2) Enable the IdentityServer service in the Configure method.

app.UseIdentityServer();


Step 7: Save the changes and click Run from the SnapDevelop toolbar to start the OAuth Web API.


Step 8: Test the OAuth Web API by sending a request which includes the grant type, scope, client ID, client secret, and user credentials.

  1. Right click in the code block of a method, and select Run Test(s) from the popup menu.

    The Web API Tester is launched.

  2. In the Web API Tester, click the plus (+) sign to create a new request:

    URL: https://localhost:5001/connect/token or http://localhost:5000/connect/token (you can modify the IP address and port number in the launchSettings.json file)

    HTTP method: POST

    Content-Type: application/x-www-form-urlencoded

    Request 1 (when grant type is client credentials):

    grant_type=client_credentials&scope=scope.readaccess&client_id=YourClientIdThatCanOnlyRead&client_secret=yoursecret1

    Request 2 (when grant type is resource owner password):

    grant_type=password&scope=scope.readaccess&client_id=YourClientIdThatCanOnlyRead&client_secret=yoursecret1&username=user&password=pass
  3. Click Send to send the request, and the API returns the token information if validation is successful.


Sample scripts

Here is another sample script of the UserValidator.cs class that connects to an LDAP server to authenticate the user credentials.

This sample makes references to the NuGet package Microsoft.Windows.Compatibility, therefore, Microsoft.Windows.Compatibility must be installed to the project first, if you want to run the following scripts successfully.

using IdentityServer4.Models;
using IdentityServer4.Validation;
using System;
using System.DirectoryServices;
using System.Threading.Tasks;

namespace OAuthServer
{
    public class UserValidator: IResourceOwnerPasswordValidator
    {
        public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            string strError = string.Empty;
            bool lb_succes = false;
            string ls_server = "ldap.appeon.com";
            string ls_user = context.UserName;
            string ls_pass = context.Password;
            using (DirectoryEntry adsEntry = new DirectoryEntry("LDAP://" + ls_server, ls_user, ls_pass, AuthenticationTypes.Secure))
            {
                using (DirectorySearcher adsSearcher = new DirectorySearcher(adsEntry))
                {
                    adsSearcher.Filter = "(SAMAccountName=" + ls_user + ")";
                    adsSearcher.PropertiesToLoad.Add("cn");
                    try
                    {
                        SearchResult adsSearchResult = adsSearcher.FindOne();
                        if (adsSearchResult == null)
                        {
                            lb_succes = false;
                        }
                    }
                    catch (Exception ex)
                    {
                        strError = ex.Message;
                    }
                    finally
                    {
                        adsEntry.Close();
                    }
                }
            }
            if (strError.Length == 0)
            {
                lb_succes = true;
            }
            
            if ( lb_succes )
            {
                context.Result = new GrantValidationResult(subject: context.UserName, authenticationMethod: "custom");
            }
            else
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, context.UserName + " not exist or password is wrong");
            }
            return Task.FromResult(0);
        }
        
    }
}

Task 2: Adding OAuth support to PowerBuilder client app

Purpose

This task modifies the PowerBuilder application source code and the PowerServer project settings to achieve the following results:

  • Sends the user credentials and/or password to the OAuth 2.0 Web API and gets an access token.

  • Uses the access token to access data from the PowerServer Web API.

  • Refreshes the access token when necessary.


Preparations

Before you make changes to the PowerBuilder client app, make sure the application can run successfully. In this tutorial, we will take Sales Demo as an example.

Step 1: Select Windows Start | Appeon PowerBuilder 2021, and then right-click Example Sales App and select More | Run as administrator.

Step 2: When the SalesDemo workspace is loaded in the PowerBuilder IDE, click the Run button in the PowerBuilder toolbar.

Step 3: When the application main window is opened, click the Address icon in the application ribbon bar and make sure data can be successfully retrieved.

Step 4: After that, follow instructions in the Quick Start guide to configure and deploy the application as an installable cloud app using the PowerServer deployment option. Make sure the installable cloud app can run successfully.

Add scripts

Step 1: Declare the following global variables.

//Token expiresin
Long gl_Expiresin
//Refresh token clockskew 
Long gl_ClockSkew = 3

Step 2: Define a global function and name it f_Authorization().

Select from menu File > New; in the New dialog, select the PB Object tab and then select Function and click OK to add a global function.

This global function uses the HTTP Post method to send the user credentials to the authorization server and then gets the access token from the HTTP Authorization header.

Add scripts to the f_Authorization() function according to the following scenarios:

  • Scenario 1: Supports Client Credentials (GrantType="client_credentials") and gets the client ID and secret from the application.

    When the application starts, the application uses the client ID and secret stored in the application to get the token, and when the token expires, it automatically refreshes the token.

  • Scenario 2: Supports Resource Owner Password (GrantType="password") and gets the username and password from a login window.

    When the application starts, the application uses the username and password from the login window to get the token, and when the token expires, the login window displays for the user to input the username and password again.

  • Scenario 3: Supports Resource Owner Password (GrantType="password") and gets the username and password from the INI file.

    When the application starts, the application uses the username and password from the INI file to get the token, and when the token expires, it automatically refreshes the token.

Scripts for scenario 1:

//f_Authorization() for client_credentials
//The token location is stored in the INI file
OAuthClient    loac_Client
TokenRequest   ltr_Request
TokenResponse  ltr_Response
String  ls_url, ls_UserName, ls_UserPass
String  ls_TokenType, ls_AccessToken
String  ls_type, ls_description, ls_uri, ls_state
Integer  li_Return, li_rtn

li_rtn = -1
ls_url = profilestring("CloudSetting.ini","setup","OauthURL","")

//TokenRequest
ltr_Request.tokenlocation = ls_url
ltr_Request.Method = "POST"
ltr_Request.clientid = "YourClientIdThatCanOnlyRead"
ltr_Request.clientsecret = "yoursecret1"
ltr_Request.scope = "scope.readaccess"
ltr_Request.granttype = "client_credentials"

loac_Client = Create OAuthClient
li_Return = loac_Client.AccessToken( ltr_Request, ltr_Response )
If li_Return = 1 and ltr_Response.GetStatusCode () = 200 Then
 ls_TokenType = ltr_Response.gettokentype( )
 ls_AccessToken = ltr_Response.GetAccessToken()
 //Application sets the authorization header
 Getapplication().SetHttpRequestHeader("Authorization", ls_TokenType + " " +ls_AccessToken, true)
 //Set the global variables
 gl_Expiresin = ltr_Response.getexpiresin( )
 li_rtn = 1
Else
 li_Return = ltr_Response.GetTokenError(ls_type, ls_description, ls_uri, ls_state)
 MessageBox( "AccessToken Failed", "Return: " + String ( li_Return ) + "~r~n" + ls_description )
End If

If IsValid ( loac_Client ) Then DesTroy ( loac_Client )

Return li_rtn

Scripts for scenario 2:

The following scripts will work only after you implement a login window and return the username and password to the f_Authorization() function.

//f_Authorization() for password from login window
//The token location is stored in the INI file
//username & password are input in the login window by the end user
OAuthClient    loac_Client
TokenRequest   ltr_Request
TokenResponse  ltr_Response
String  ls_url, ls_UserName, ls_UserPass
String  ls_TokenType, ls_AccessToken
String  ls_type, ls_description, ls_uri, ls_state
Integer  li_Return, li_rtn

li_rtn = -1
ls_url = profilestring("CloudSetting.ini","setup","OauthURL","")

//TokenRequest
ltr_Request.tokenlocation = ls_url
ltr_Request.Method = "POST"
ltr_Request.clientid = "YourClientIdThatCanOnlyRead"
ltr_Request.clientsecret = "yoursecret1"
ltr_Request.scope = "scope.readaccess"
ltr_Request.granttype = "password"

//login window can be implemented as actual needs
//Open(w_login) 
//Return username & password

If IsNull ( ls_UserName ) Or Len ( ls_UserName ) = 0 Then
 MessageBox( "Tips", "UserName is empty!" )
 Return li_rtn
End If
If IsNull ( ls_UserPass ) Or Len ( ls_UserPass ) = 0 Then
 MessageBox( "Tips", "Password is empty!" )
 Return li_rtn
End If

ltr_Request.UserName = ls_UserName
ltr_Request.Password = ls_UserPass

loac_Client = Create OAuthClient
li_Return = loac_Client.AccessToken( ltr_Request, ltr_Response )
If li_Return = 1 and ltr_Response.GetStatusCode () = 200 Then
 ls_TokenType = ltr_Response.gettokentype( )
 ls_AccessToken = ltr_Response.GetAccessToken()
 //Application sets the authorization header
 Getapplication().SetHttpRequestHeader("Authorization", ls_TokenType + " " +ls_AccessToken, true)
 //Set the global variables
 gl_Expiresin = ltr_Response.getexpiresin( )
 
 li_rtn = 1
Else
 li_Return = ltr_Response.GetTokenError(ls_type, ls_description, ls_uri, ls_state)
 MessageBox( "AccessToken Failed", "Return: " + String ( li_Return ) + "~r~n" + ls_description )
End If

If IsValid ( loac_Client ) Then DesTroy ( loac_Client )

Return li_rtn

Scripts for scenario 3:

//f_Authorization() for password from INI file
//The token location is stored in the INI file
//username & password are stored in the INI file
OAuthClient    loac_Client
TokenRequest   ltr_Request
TokenResponse  ltr_Response
String  ls_url, ls_UserName, ls_UserPass
String  ls_TokenType, ls_AccessToken
String  ls_type, ls_description, ls_uri, ls_state
Integer  li_Return, li_rtn

li_rtn = -1
ls_url = profilestring("CloudSetting.ini","setup","OauthURL","")

//TokenRequest
ltr_Request.tokenlocation = ls_url
ltr_Request.Method = "POST"
ltr_Request.clientid = "YourClientIdThatCanOnlyRead"
ltr_Request.clientsecret = "yoursecret1"
ltr_Request.scope = "scope.readaccess"
ltr_Request.granttype = "password"

//From CloudSetting.ini
ls_UserName = ProfileString("CloudSetting.ini", "users", "userName", "")
ls_UserPass = ProfileString("CloudSetting.ini", "users", "userPass", "")
If IsNull ( ls_UserName ) Or Len ( ls_UserName ) = 0 Then
 MessageBox( "Tips", "UserName is empty!" )
 Return li_rtn
End If
If IsNull ( ls_UserPass ) Or Len ( ls_UserPass ) = 0 Then
 MessageBox( "Tips", "Password is empty!" )
 Return li_rtn
End If
ltr_Request.UserName = ls_UserName
ltr_Request.Password = ls_UserPass


loac_Client = Create OAuthClient
li_Return = loac_Client.AccessToken( ltr_Request, ltr_Response )
If li_Return = 1 and ltr_Response.GetStatusCode () = 200 Then
 ls_TokenType = ltr_Response.gettokentype( )
 ls_AccessToken = ltr_Response.GetAccessToken()
 //Application sets the authorization header
 Getapplication().SetHttpRequestHeader("Authorization", ls_TokenType + " " +ls_AccessToken, true)
 //Set the global variables
 gl_Expiresin = ltr_Response.getexpiresin( )
 
 li_rtn = 1
Else
 li_Return = ltr_Response.GetTokenError(ls_type, ls_description, ls_uri, ls_state)
 MessageBox( "AccessToken Failed", "Return: " + String ( li_Return ) + "~r~n" + ls_description )
End If

If IsValid ( loac_Client ) Then DesTroy ( loac_Client )

Return li_rtn


Step 3: Insert a timing object (timing_1) to the application and add the following scripts to the Timer event of timing_1.

1) Open the application object and then select from menu Insert > Object > Timing to add a timing object to the application.

2) Add the following scripts to the Timer event of timing_1.

//Authorization
f_Authorization()


Step 4: Add the following scripts to the application Open event.

Place the scripts before the database connection is established.

//Authorization
If f_Authorization() <> 1 Then
 Return
End If

//StartSession
long ll_return
Try
 ll_return = Beginsession()
 If ll_return <> 0 Then
  Messagebox("Beginsession Failed:" + String(ll_return), GetHttpResponseStatusText())
 End if
Catch ( Throwable ex)
 MessageBox( "Throwable", ex.GetMessage())
 Return
End Try

//Refresh Token for timing
If gl_Expiresin > 0 And (gl_Expiresin - gl_ClockSkew) > 0 Then
 //Timer = Expiresin - ClockSkew 
 //3600 - 3
 timing_1.Start(gl_Expiresin - gl_ClockSkew)
End If

// Connect to db

// Profile PB Postgres V2021
//SQLCA.DBMS = "ODBC"
//SQLCA.AutoCommit = False
//SQLCA.DBParm = "ConnectString='DSN=PB Postgres V2021',TrimSpaces='Yes'"
//Connect Using SQLCA;


Step 5: Add the following scripts to the SystemError event.

Choose Case error.Number
 Case 220 to 229 //Session Error
  MessageBox ("Session Error", "Number: " + String(error.Number) + "~r~nText: " + error.Text )
 Case 230 to 239 //License Error
  MessageBox ("License Error", "Number: " + String(error.Number) + "~r~nText: " + error.Text )
 Case 240 to 249 //Token Error
  MessageBox ("Token Error", "Number: " + String(error.Number) + "~r~nText: " + error.Text )
  //Authorization
  f_Authorization()
 Case Else
  MessageBox ("SystemError", "Number: " + String(error.Number) + "~r~nText: " + error.Text )
End Choose


Add an INI file

Create the following INI file in the same location as the PBT file and name it CloudSetting.ini.

The INI file specifies the OAuth Web API URL.

[Setup]
OauthURL=https://localhost:5001/connect/token

To get the username and password from the INI file instead of from the database, you can add the following section to the INI file:

[users]
userName=user
userPass=pass
Start session manually by code

By default the user session is automatically created when the application starts; and the session includes no token. To enable the session to include the token, we will need to start the session manually instead of automatically.

To start the session manually,

Step 1: Enable "Begin session by code" in the PowerBuilder IDE. (Steps: Open the application object painter, click Additional Properties in the application's Properties dialog; in the Application dialog, select the PowerServer tab and then select the Begin session by code option.)

Step 2: Call the BeginSession function in the application Open event. (See the scripts added in step 4 in the section "Add scripts".)


Modify and re-deploy the PowerServer project

Step 1: Add the INI file CloudSetting.ini to the Files preloaded in uncompressed format section under the External Files tab.

Step 2: Select OAuth 2.0 Support and Compression Support under the Runtime tab.


Step 3: Specify the URL of the PowerServer Web API in the Web APIs tab. Make sure the port number is not occupied by any other program.

Tip: You can execute the command "netstat -ano | findstr 5009" to check if the port number is occupied by any other program.


Step 4: Save the changes and deploy the PowerServer project (using the "Build & Deploy PowerServer Project" option) so that the above settings can take effect in the installable cloud app.

Task 3: Adding OAuth support to PowerServer Web API

Purpose

This task modifies the PowerServer Web API to achieve the following results:

  • Validates the access token with the OAuth Web API and, if validation is successful, gets data from the database.


Instructions

Note

The Startup.cs and appsettings.json files will be restored to default when the PowerServer Web API solution gets updated from the PowerBuilder IDE. Therefore, make sure to make the following changes every time after the solution is updated or re-created.

Step 1: Open the PowerServer Web API solution (PowerServerApp.sln) in SnapDevelop. This solution is created when building the PowerServer project in PowerBuilder IDE.

Step 2: Modify the Startup.cs class file to specify the OAuth Web API URL and token time span.

1) Add the following namespace:

using System;

2) Locate services.AddAuthentication and modify the scripts to specify the OAuth Web API URL and token time span. Suppose the OAuth Web API URL is https://localhost:5001.

            services.AddAuthentication("Bearer")
            .AddJwtBearer("Bearer", options =>
            {
                options.Authority = "https://localhost:5001";
                options.RequireHttpsMetadata = false;
                options.IncludeErrorDetails = true;
                    
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateAudience = false,
                    ValidateIssuer = false,
                    ValidateLifetime = true, //Validate the token life time
                    ClockSkew = TimeSpan.FromSeconds(300) //Seconds. Set to 0 disables the time span, this is not recommended
                };
            });
            services.AddAuthentication(options =>
            {
                options.DefaultChallengeScheme = "oidc";
            })
            .AddOpenIdConnect("oidc", options =>
            {
                options.Authority = "https://localhost:5001";

                options.ClientId = "YourClientIdWithWriteAccess";
                options.ClientSecret = "Yoursecret3";
                options.ResponseType = "code";

                options.Scope.Add("scope.writeaccess");

                //options.SaveTokens = true;
            });



Step 3: Open the appsettings.json file and modify the value of "UsingAuthentication" to true to enable the authorization service.


Step 4: Save the changes and click Run from the SnapDevelop toolbar to start the PowerServer Web API. Make sure the console window displays "Application started...".

Now that the configuration has completed, you can start the OAuth Web API and the PowerServer Web API. If these two Web APIs are running on the same machine, make sure their port numbers do not conflict with each other. You can modify the port number in the launchSettings.json file.

After that you can run the installable cloud app, and view the logs in the API console windows to make sure the authorization is successful.