OAuth 2.0 Client Application Developer Guide

Some Implementation Notes

A Note about Access Tokens

The access tokens provided by Projector are similar to Bearer Tokens, but are not exactly Bearer Tokens.  They are Projector Session Tickets, the same kind of token returned by the Soap Service PwsAuthenticate.  When you are granted an access token (AKA Session Ticket) via the OAuth 2 flow, you will also get the information about both the soap endpoint authority and the rest endpoint authority at which that token is valid.  You will also be provided the scopes applied to the access token, a refresh token, and expiration information about the access token.  Projector Session Tickets may be used to invoke any of the Soap Services provided by Projector or any of the Restful Reporting Services implemented by Projector, as long as the authenticated user has the permissions to use the requested services and the client app requested any necessary scopes, which map to Projector Permissions.

A Note about PKCE (Code-Challenge)

Projector’s OAuth Server does not require the use of PKCE, but it is STRONGLY recommended and all client apps are further recommended to set the code_challenge_method to S256 if the client environment in use supports it.

PKCE Sample Utility Code in C# - feel free to copy or use as model in your client.
using System;
using System.Text;
using System.Security.Cryptography;
using System.Linq;
					
public class Program
{
	public static void Main()
	{
		foreach(string codeChallengeMethod in new [] { "plain", "S256" })
		{
			string codeVerifier = PkceGenerateCodeVerifier();
			string codeChallenge = PkceGetCodeChallenge(codeVerifier, codeChallengeMethod);
			Console.WriteLine("Client-Derived " + codeChallengeMethod + " Code Challenge sent to server as part of authentication code request is: " + codeChallenge);
			Console.WriteLine("On receipt of authentication code request, server determines that code challenge is " + (PkceCodeChallengeIsValid(codeChallenge, codeChallengeMethod) ? string.Empty : "in") + "valid.");
			
			foreach (string sentCodeVerifier in new [] { codeVerifier, "ILLEGAL-CODE-VERIFIER-SIMULATING-ATTEMPT-TO-CRACK-THE-SYSTEM" })
			{
				Console.WriteLine("On token aquisition request, Client-generated " + codeChallengeMethod + " code verifier sent to server is: " + sentCodeVerifier);
				Console.WriteLine("Based on PKCE vervication, server does " + (PkceVerify(codeChallenge, sentCodeVerifier, codeChallengeMethod) ? string.Empty : "NOT ") + "grant access token to client.");
			}

			Console.WriteLine();
		}
	}
	
	static string PkceGenerateCodeVerifier()
	{
		int len = random.Next(43, 129);  // random.Next() documented to be inclusive of lower bound, but exclusive of upper bound.
 		char[] chars = new char[len];
		for (int i = 0; i < len; i++)
		{
			chars[i] = validChars[random.Next(0, validCharsLength)];  // random.Next() documented to be inclusive of lower bound, but exclusive of upper bound.
		}
		return new string(chars);
	}
	
	private static readonly Random random = new Random();
	private const string validChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.~";
	private static readonly int validCharsLength = validChars.Length;
	
	static string PkceGetCodeChallenge(string codeVerifier, string codeChallengeMethod)
	{
		switch(codeChallengeMethod)
		{
			case "plain":
				return PkceGetPlainCodeChallenge(codeVerifier);
			case "S256":
				return PkceGetS256CodeChallenge(codeVerifier);
			default:
				throw new ArgumentException("Must be \"plain\" or \"S256\"", nameof(codeChallengeMethod));
		}
	}
	
	static bool PkceCodeChallengeIsValid(string codeChallenge, string codeChallengeMethod)
	{
		switch(codeChallengeMethod)
		{
			case "plain":
				return PkceCodeVerifierIsValid(codeChallenge);
			case "S256":
				try
				{
					byte[] codeChallengeBytes = Base64UrlDecode(codeChallenge);
					if (codeChallengeBytes.Length != 32)
					{
						throw new ArgumentException("does not decode to byte array of length 32.", nameof(codeChallenge));
					}
					return true;
				}
				catch(Exception ex)
				{
					Console.WriteLine("Caught invalid S256 Code Challenge of " + codeChallenge);
					Console.WriteLine(ex);
					return false;
				}
			default:
				throw new ArgumentException("Must be \"plain\" or \"S256\"", nameof(codeChallengeMethod));
		}
	}
	
	static bool PkceVerify(string codeChallenge, string codeVerifier, string codeChallengeMethod)
	{
		switch(codeChallengeMethod)
		{
			case "plain":
				return codeChallenge == codeVerifier;
			case "S256":
				return Base64UrlDecode(codeChallenge).SequenceEqual(GetS256Hash(codeVerifier, Encoding.ASCII));
			default:
				throw new ArgumentException("Must be \"plain\" or \"S256\"", nameof(codeChallengeMethod));
		}
	}
	
	static bool PkceCodeVerifierIsValid(string codeVerifier)
	{
		return codeVerifier != null && codeVerifier.Length >= 43 && codeVerifier.Length <= 128 && codeVerifier.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.' || c == '~');
	}
	
	static string ThrowIfInvalidPkceCodeVerifier(string codeVerifier)
	{
		return PkceCodeVerifierIsValid(codeVerifier) ? codeVerifier : throw new ArgumentException("Must be between 43 and 128 characters in length and entirely made up of letters and digits and periods, hyphens, underscores, and tildes.",nameof(codeVerifier));
	}
	
	static string PkceGetPlainCodeChallenge(string codeVerifier)
	{
		return ThrowIfInvalidPkceCodeVerifier(codeVerifier);
	}
	
	static string PkceGetS256CodeChallenge(string codeVerifier)
	{
		return Base64UrlEncode(GetS256Hash(ThrowIfInvalidPkceCodeVerifier(codeVerifier), Encoding.ASCII));  
	}
	
	static byte[] GetS256Hash(string str, Encoding encoding)
	{
		if (str == null)
		{
			throw new ArgumentNullException(nameof(str));
		}
		if (encoding == null)
		{
			throw new ArgumentNullException(nameof(encoding));
		}
		using (SHA256 sha256 = SHA256.Create())
		{
			return sha256.ComputeHash(encoding.GetBytes(str));
		}
	}
	
	static string Base64UrlEncode(byte[] bytes)
	{
		return Convert.ToBase64String(bytes).TrimEnd(padding).Replace('+', '-').Replace('/', '_');
	}
	
	static byte[] Base64UrlDecode(string str)
	{
		string incoming = str.Replace('_', '/').Replace('-', '+');
		switch(str.Length % 4)
		{
			case 2: incoming += "=="; break;
			case 3: incoming += "="; break;
		}
		return Convert.FromBase64String(incoming);
	}
	
	private static readonly char[] padding = { '=' };
}

Registering an OAuth Client App in Projector

To register your OAuth App, or to edit the registration parameters of the OAuth client app, you will need to use the Management Portal and go to the OAuth panel on the Integration Tab.  There you can create, update and delete registrations for your app (as well as manage enablement for globally available OAuth client apps, and manage user connections to the apps).  This will give you the opportunity to establish or update the name, description, redirect_url and optional logo for your client app, and to retrieve the client_id and client_secret.

Obtaining a New OAuth Token from Projector

The authorization endpoint in Projector includes your account code.  If (in this example) your account code is “acme-industries”, your authorization endpoint for your app would be:
              https://app.projectorpsa.com/oauth2authorize/acme-industries

Only users in your account will be allowed to authenticate.  When the user is redirected to the authentication page, their account code will be locked to the account code you specified in the URL.

To begin the OAuth flow to obtain a new token, the client app points an interactive browser tab to one of the endpoints above, and then appends a query string including the following URL-Encoded parameters, which adhere to the OAuth V2 standard:

  • response_type (Required as per OAuth standard.  Projector supports only the response_type of “code”.)
  • client_id (Required.  Must match the client ID in your app registration).
  • redirect_uri (Required.  Must match the redirect URI in your app registration.  Also must be a valid URI to which the browser tab can be redirected with the authorization code).
  • state (Optional.  A URL-safe string that will be returned verbatim to the redirect url with the code.)
  • scope (Optional.  If no scope is provided, the token that is subsequently acquired will have no elevated Projector Permissions.  Scopes and Projector Permissions are further discussed in their own section.)
  • code_challenge (Highly recommended but not required.  Must be present if code_challenge_method is specified).
  • code_challenge_method (If specified, must be either “plain” or “S256”.  If framework supports it, “S256” is highly recommended).

So a full URL that the browser is pointed to to begin the OAuth flow might be:

https://app.projectorpsa.com/oauth2authorize/acme-industries?response_type=code&client_id=29dd1cbb-953e-4126-9c2f-0bf8eeff5bab&redirect_uri=https%3A%2F%2Fmyapp.com%2Fprojector%2Foauth%2Fcode%2Fhandler&state=myurlencodedstate&scope=enterTime&code_challenge=ad463dfsdf23agrt56&code_challenge_method=s256

This URL, if it is valid, will redirect to an authorization page in which the user will be asked for Projector Credentials.  If those credentials are entered correctly, the browser will be redirected to yet another page in which the user will be asked to accept the OAuth connection, including some verbiage about scopes if requested.  If the user does accept the connection, the browser will be redirected to the registered (and provided) redirect uri with a short-lived authorization code and the provided state.

When the redirect URI is hit, the underlying program logic should issue an HTTP POST request to the token endpoint, which is:
              https://app.projctorpsa.com/oauth2token

Form-URL-encoded parameters on this POST request should be:

  • grant_type=code
  • code={The short-lived authorization code you got back from the authorize redirect}
  • client_id={The client ID for the registered OAuth client app, same id sent in authorize request}
  • client_secret={The client secret for the registered OAuth client app}
  • redirect_uri={Same one you sent on the OAuth request, which must also be the registered one}
  • code_verifier={If you used PKCE as recommended, code verifier, otherwise do not include}

Assuming all goes well, your response will be a block of JSON something like this:

{
  "access_token": "BpL+vLckFcvBby0aVEYKlQ==",
  "token_type": "projector_session_ticket",
  "expires_in": 604800,
  "refresh_token": "E2BgYNB04XXVZRbkKDI6LlB0WVeOt6BoPys1uSS_SNff3yXqq5YTR8ZNDwA",
  "scope": "enterTime", 
  "soap_service_authority": "https://secure2.projectorpsa.com",
  "rest_service_authority": "https://app2.projectorpsa.com",
  "messages": {
    "warnings": [
      "Warning Message Number One",
      "Warning Message Number Two"
    ],
    "info": [
      "Info Message Number One",
      "Info Message Number Two"
    ]
  }
}

Please note it is pretty unlikely that you will get any warning or info messages.  These are included for completeness in the context of how our architecture works.  If you do get any of these messages, you may want to consider logging them or sharing them to the end-user or both.

The access token is, under the hood, a Projector Session Ticket.  If you are using it for Projector Soap Services, you should target the base endpoint that is the “soap_service_authority”.  If you are using it for Projector Restful Reporting Services, you should target the base endpoint that is the “rest_service_authority”.

The scope returned is the actual scope granted to the access token.  This may or may not be exactly what the client app requested.  If the user who authenticated and authorized the connection does not have the projector permissions granted to their account that would be necessary to grant the requested scope, the scope returned will be different (and more restrictive) than the scope requested.  The client app may use this information in any way, including to revoke the token (if the user in question simply won't be able to accomplish what the app is for).  Please note that the order of permissions listed in the scope is in now way guaranteed or expected to be the same order that was specified in the authentication request.

Note that the “Authorize” portion of this flow can only be done via an HTTP GET to the authorize endpoint.  Once you get the authorization code, the documentation above shows how to exchange it for an access token using an HTTP POST request, which is part of the OAuth standard.  However, there is an alternative available if you prefer it to use the soap service PwsAcquireOauth2Token.

Refreshing a Projector OAuth Token

Perhaps, based on the expiry information that an access token in your possession has, your client code notices that the access token is about to expire, or perhaps a service invocation fails and the error indicates the reason is an expired or invalid session ticket.  In these cases, you will want to refresh the access token and most likely re-invoke the service with a new access token.

To refresh the access token, you should issue an HTTP POST request to the token endpoint.  If you are able to build the token endpoint by appending the string “/oauth2token” to the rest_service_authority on the access token you are refreshing, it will be most efficient and fastest.  Following our made-up example access token above, the token refresh endpoint would be:
              https://app2.projectorpsa.com/oauth2token
but if it is difficult or inconvenient to build it that way, you can use the default token refresh endpoint of:
              https://app.projectorpsa.com/oath2token

Either will work, but if you use the rest_service_authority version, you will get better performance.  Form URL-encoded parameters on this POST request should be:

  • grant_type=refresh_token
  • refresh_token={The refresh token you got back on the access token}
  • client_id={The client ID for the registered OAuth client app}
  • client_secret={The client secret for the registered OAuth client app}

Assuming all goes well, your response will be a block of JSON just like the one shown previously on the code exchange.  Note that it is possible (although likely rare) that a given user's Projector Permissions have changed between the time the previous access token was acquired and now when you have refreshed.  As such, it is also possible (and again likely rare) that the scope returned on the refresh will be different from what was returned previously.  If this is important to your client code, it should anticipate this possibility.

Again, if you prefer to use SOAP, you can use the PwsAcquireOauth2Token Soap service.

Revoking a Projector OAuth Token

If your code needs to revoke a token previously granted, thereby terminating the connection between your app and Projector, you should issue an HTTP POST request to the revocation endpoint.  If you are able to build the revocation endpoint by appending the string “/oauth2revoketoken” to the rest_service_authority on the access token you are revoking, it will be most efficient and fastest.  Following our made-up example access token above, the token refresh endpoint would be:
              https://app2.projectorpsa.com/oauth2revoketoken
but if it is difficult or inconvenient to build it that way, you can use the default token revocation endpoint of:
              https://app.projectorpsa.com/oauth2revoketoken

Either will work, but if you use the rest_service_authority version, you will get better performance.  Form URL-encoded parameters on this POST request should be:

  • client_id={The client ID for the registered OAuth client app}
  • client_secret={The client secret for the registered OAuth client app}
  • token={The refresh token you got back on the access token}
  • token_type={Optional, but if present may only be the default value of “refresh_token”}

If all goes well, your response will be empty with the HTTP Status Code of 200 OK.  Both the access token (AKA Session Ticket) and refresh token will be revoked and no longer valid.

If you prefer to use a SOAP service for token revocation, you can use the PwsRevokeOauth2RefreshToken service.

Please note that the OAuth standard anticipates the possibility of revoking just an access token (AKA Session Ticket), but leaving the associated refresh token intact.  In our server context this is something of a non-sequitur.  You can always revoke a projector session ticket with the PwsUnauthenticate Soap Service, but the restful revocation endpoint can only fully revoke refresh tokens at this time.

Scopes

The OAuth 2.0 Standard anticipates that client apps will request scopes  from the server when an Authentication Request is made.  The semantics of scopes is left up to the server implementation.  Projector has chosen to map scopes  to Projector Permissions, which are granted to users and applied to sessions for the session lifetime.  Please note that in the Projector OAuth Server implementation, the access tokens are session tickets, and designate a session.  The Projector Server implementation of scopes will ensure that an access token acquired through OAuth will point to a session which never has more permissions than are requested through scopes.  It may have fewer – if the authenticated user does not have the permissions being requested.

If an OAuth 2 Authentication Request is made to Projector without any scope, the resulting token when acquired will point to a session that has no elevated Projector Permissions.  There are still services that the Client App may successfully invoke without any Projector Permissions.

There is a "Special" scope that is available to a Client App when constructing an OAuth 2 Authentication request.  That "Special" scope is "allowFullPermissions".  if scope=allowFullPermissions is present on the Authentication Request query string, then the resulting access token will have the maximal permissions available to the user.  This scope designation may not be combined with any other scope.

The more general case is that scope  may be specified as a URL-Encoded space-delimited list of individual Projector Permission tags.  Projector Permission tags are strings that uniquely identify Projector Permissions.  Permissions that may be requested and masked through OAuth scopes are either Global Permissions or Cost Center Permissions.  Global Permission tags in the scope  list must be prefixed with the letter "V" (for View) or the letter "U" (for Update) and a colon.  Cost Center Permission tags in the scope  list are not prefixed, as these are binary permissions.


 Global Permission Tags and Supported Prefixes (Click to expand)

The following table shows the Global Permission Tags that may be used in scope  designations and their supported prefixes.  This list is complete as of this writing, and includes permissions without regard to whether any APIs or Services provided by Projector need them.  Please follow this link for further information about the semantics of Global Permissions.

Global Permission TagGlobal Permission NameSupported Prefixes

maintainSystemSettings

System Settings

V, U

maintainUsers

Users & Permissions

V, U

maintainSkills

Skills & Skill Groups

V, U

maintainCurrencies

Currencies and Foreign Exchange Rates

V, U

maintainCostCenters

Companies & Cost Centers

V, U

maintainLocations

Locations & Holidays

V, U

maintainPublicReports

Maintain Public Reports and Saved Searches

U

maintainDeptTitle

Rate & RDC Rate Card and Dept/Title Administration

V, U

viewRDCData

Resource Direct Cost (RDC) Data

V

viewProjectMargin

View Project Margin

V

viewFinDataInUtilRpt

Financial Data in Utilization Reports

V

viewRateCards

Rate Card Report

V

maintainClientList

Clients

V, U

maintainClientUsers

Client Users

V, U

exportData

Export Data

V

accountingInterface

Accounting Interface

V, U

overrideAccountingMappings

Override Accounting Mappings

U

webServicesAccess*

Web Services Access

NOT AVAILABLE

maintainInvoiceTemplates

Invoice Templates

V, U

maintainEmailTemplates

E-mail Templates

V, U

auditTrailReport

Audit Trail Report

V

resourceAvailableTimeOff

Resource Available Time Off

V, U

customGlobalPermission1

Custom Global Permission #1

U

customGlobalPermission2

Custom Global Permission #2

U

customGlobalPermission3

Custom Global Permission #3

U

customGlobalPermission4

Custom Global Permission #4

U

customGlobalPermission5

Custom Global Permission #5

U

 *The Web Services Access permission is a non-sequitur to anything a client app might request scopes for, and is explicitly disallowed in scope designations.

 Cost Center Permission Tags (Click to expand)

The following table shows the Cost Center Permission Tags that may be used in scope  designations.  Prefixes are not expected (or allowed) for these binary permissions.  This list is complete as of this writing, and includes permissions without regard to whether any APIs or Services provided by Projector need them.  Please follow this link for further information about the semantics of Cost Center Permissions.

Cost Center Permission TagCost Center Permission Name
maintainProjectsAndEngagementsMaintain Projects and Engagements
TimeEntryOnBehalfNotification that time has been entered on behalf of another resource
createProjectsAndEngagementsCreate Projects and Engagements
ExpenseEntryOnBehalfNotification that expense reports have been entered on behalf of another resource
maintainProjectRatesMaintain Project Rates
maintainProjectRDCMaintain Project RDC
maintainAdvancedEngagementProjectSetupMaintain Advanced Engagement and Project Setup
MissingTimeAlertNotification that draft, rejected, or missing time e-mail alerts are sent to a resource
maintainVendorsMaintain Vendors
browseResourcesView Resources
maintainResourcesMaintain Resources
maintainResourceVacationsApprove and Maintain Scheduled Time Off
maintainResourceResumesMaintain Resource Resumes
ProjectStageChangeNotification about project transition to/from a stage
scheduleResourcesRequest or Schedule Resources
scheduleEngagementsRequest or Schedule Engagements
preInvoiceAdjustmentsMake Pre-Invoicing Adjustments
invoicingCreate and Approve Invoices
voidAndDeleteInvoicesVoid and Delete Invoices
managementInvoiceApprovalManagement Approval of Invoices
enterTimeMaintain Time
viewEmployeeDashboardView Employee Home Page for Others
viewTimeOffPageView Time Off Page for Others
approveTimeApprove Reported Time Off and Time on Projects with Cost Center-Based Approval
approveExpenseReportsApprove Expenses on Projects with Cost Center-Based Approval
approveSkillsApprove Skills
browseResourceTimeView Resource Time
browseProjectTimeView Project Time
viewResourceVacationsView Scheduled Time Off
timeWorkflowAdministrationAdminister Time Workflow
browseDisbursedExpenseReportsView Resource/Disbursed Expense Reports
browseProjectExpensesView Project Expenses
browsePaymentVouchersView Payment Vouchers
browseDisbursedOtherExpenseDocumentsView Resource/Disbursed Other Expense Documents
expenseReportAdministrationAdminister Expense Document Approval Workflow
erPaymentWorkflowAdminister Expense Document Payment Workflow
erInvoicingWorkflowAdminister Expense Document Invoicing Workflow
viewProjectsView Projects
actAsProjectManagerAct as Project Manager
joinProjectEmailListJoin Project Email List
accessProjectWorkspacesAccess Project Workspaces
engagementPortfolioReportRun Engagement Portfolio Report
projectPortfolioReportRun Project Portfolio Report
forecastAccuracyReportRun Forecast Accuracy Report
ginsuReportRun Ginsu and Schedule Variance Report
invoiceReportRun Invoice Report
invoiceMilestoneReportRun Invoice Milestone Report
issueReportRun Issue Report
taskAnalysisReportRun Task Analysis Report
projectListReportRun Project List Report
projectRoleReportRun Project Role Report
resourceReportRun Resource Report
revenueRecognitionRevenue Recognition
utilizationReportRun Utilization Report
adjustmentAnalysisReportRun Adjustment Analysis Report
timeCardReportRun Time Card Report
costCardReportRun Cost Card Report
unbilledTimeAndCostReportRun Unbilled Time and Cost Report
baselineVarianceReportRun Baseline Variance Report
maintainVendorInvoicesMaintain Vendor Invoices
enterCostMaintain Expense Reports
maintainSoftCostsMaintain Soft Costs
maintainSubcontractorInvoicesMaintain Subcontractor Invoices
customPermission1Custom Cost Ctr Permission #1
customPermission2Custom Cost Ctr Permission #2
customPermission3Custom Cost Ctr Permission #3
customPermission4Custom Cost Ctr Permission #4
customPermission5Custom Cost Ctr Permission #5


So, for example, imagine that your client app seeks the permission to view Cost Center information (a global permission), to update User information (a global permission) and to enter time on behalf of others (a cost center permission) the scope would be designated on the query string of the authentication request URL as follows:

  1. Build up the space-delimited list of permissions:
     V:maintainCostCenters U:maintainUsers enterTime 

  2. URL-Encode that string:  
    V%3AmaintainCostCenters%20U%3AmaintainUsers%20enterTime 

  3. Include it in the authentication request URL:
    https://app.projectorpsa.com/oauth2authorize/acme-industries?response_type=code&client_id=29dd1cbb-953e-4126-9c2f-0bf8eeff5bab&redirect_uri=https%3A%2F%2Fmyapp.com%2Fprojector%2Foauth%2Fcode%2Fhandler&state=myurlencodedstate&scope=V%3AmaintainCostCenters%20U%3AmaintainUsers%20enterTime&code_challenge=ad463dfsdf23agrt56&code_challenge_method=s256

The list of permissions may be rendered in any order, but the request will fail with the invalid_scope  error if any of the following are true:

  • A global permission is designated with out a prefix, or with an unsupported prefix.
  • A cost center permission is designated with a prefix.
  • A tag does not point to a valid Projector permission.
  • A tag is supplied more than once in the same query string
  • The specifically disallowed global permission webServicesAccess is specified.
  • The special scope allowFullPermissions  is specified and any other permission tags are included

When the designation of scope  is valid on the authentication URL, and other parameters on that URL are correct, the user will be presented with an authentication page by Projector that solicits their credentials.  If the correct credentials are supplied, then the user is presented with an additional page that will show them which scopes were requested and granted (because they have those permissions) and which scopes were requested and denied (because they do not have those permissions).  If the user accepts that grant, the client app will get an authentication code (see above) and will then use that code to acquire an access token.  The access token will include in the payload the list of permission tags as actually granted to the session, which may or may not be the full set of permissions requested.  There is no guarantee of any particular order in the list returned as part of the access token payload.  The client app may parse that space-delimited list in order to restrict access to the user to some portion of the app and/or simply revoke the token and terminate the connection if the user does not have appropriate permissions.  This is all within the purview of the app and its specified use.

Please note that the grant of permissions based on the scope  request is valid for the lifetime of the access token.  When the access token is refreshed, the available scopes are re-calculated based on the user permissions at the time of refreshing.  It is possible (but likely rare) that the user's permissions will have changed.  If the investigation of actual scopes returned is important to the app, it should be done not just when the initial access token is acquired, but also any time it is refreshed.