diff --git a/independent-publisher-connectors/Workfront Event Subscription/README.md b/independent-publisher-connectors/Workfront Event Subscription/README.md new file mode 100644 index 0000000000..09d7d9d224 --- /dev/null +++ b/independent-publisher-connectors/Workfront Event Subscription/README.md @@ -0,0 +1,185 @@ +# Adobe Workfront Event Subscriber Connector + +This repository contains a Power Automate custom connector for Adobe Workfront event subscription API. + +The connector exposes a trigger for Workfront event subscriptions and handles Workfront authentication inside the custom connector policy script. It supports two authentication modes: + +- API key to `sessionID` exchange +- JWT exchange to OAuth access token + +The trigger creates a Workfront event subscription and receives webhook payloads from Workfront when supported objects are created, updated, or deleted. + +## Connector idea + +The connector is designed to bridge Adobe Workfront event subscriptions into Power Automate. + +At a high level it works like this: + +1. A flow uses the custom trigger `When a Workfront event occurs`. +2. The trigger creates a Workfront event subscription against `/attask/eventsubscription/api/v1/subscriptions`. +3. Power Automate provides the callback URL that Workfront should call. +4. Workfront sends event payloads to the flow when matching events happen. +5. The policy script in [script.csx](./script.csx) converts connector credentials into the authentication artifacts expected by Workfront. + +Supported authentication modes: + +- `api-auth`: uses a Workfront API key and exchanges it for `sessionID` +- `jwt-exchange`: signs a JWT with an RSA private key, exchanges it for an access token, and sends that token to Workfront as `sessionID` + +## How to set up Workfront + +### General prerequisites + +- You must know your Workfront domain, for example `mycompany.my.workfront.com` +- The user behind the connector must have enough rights to work with Event Subscriptions +- For Event Subscription API operations, Adobe documents that a Workfront administrator account is required +- Your Power Automate callback endpoint must be reachable by Workfront + +Adobe Workfront documentation: + +- Event Subscription API: +- Event subscription delivery requirements: + +### Option 1. API key authentication + +This connector still supports API key authentication for legacy environments. + +![Workfront API key setup](./images/001-workfront-api-key-setup.png) + +1. Generate or retrieve an API key for the Workfront user that will own the subscription. +2. Create a connector connection with: + - `Workfront Domain` + - `API Key` +3. The policy script exchanges the API key for a Workfront `sessionID`. + +Adobe notes that API keys are legacy and recommends JWT or OAuth2-based authentication for newer setups: + +- API basics: +- Manage API keys: + +### Option 2. JWT exchange authentication + +This is the preferred setup for machine-to-machine integrations. + +![Workfront OAuth JWT setup](./images/002-workfront-oath-jwt-setup.png) + +1. In Workfront, open `Setup` -> `System` -> `OAuth2 Applications`. +2. Create a new `Machine to Machine Application`. +3. Save the generated values: + - `Client ID` + - `Client Secret` +4. Add a public key to the app. + - You can either paste a public key generated outside Workfront, or + - use Workfront to generate a keypair and then securely export/store the private key +5. Note the `Customer ID` from the app details. +6. Identify the Workfront user ID that the JWT should impersonate as `sub`. +7. Create a connector connection with: + - `Workfront Domain` + - `Client ID` + - `Client Secret` + - `Customer ID` + - `Subject User` + - `RSA Private Key (Base64 JSON)` + +Adobe Workfront documentation: + +- Create OAuth2 applications: +- JWT flow: + +## How to generate keys for JWT exchange + +The connector code in [script.csx](./script.csx) does not expect a PEM file directly. It expects a base64-encoded JSON object containing RSA parameters: + +- `modulus` +- `exponent` +- `d` +- `p` +- `q` +- `dp` +- `dq` +- `inverseQ` + +Each value must itself be standard base64, and then the full JSON must be UTF-8 encoded and base64-encoded again before being pasted into the connector connection. + +### Recommended flow + +1. Generate an RSA keypair. +2. Upload the public key to the Workfront OAuth2 application. +3. Convert the private key into RSA parameters JSON. +4. Minify the JSON. +5. Base64-encode the full JSON string. +6. Paste the final single-line value into `RSA Private Key (Base64 JSON)`. + +### Example scripts + +You can find scripts with detailed instructions in this repository: + +### JWT payload used by this connector + +The policy script generates a short-lived JWT with: + +- `alg = RS256` +- `iss = clientId` +- `sub = subjectUserId` +- `iat = current unix time` +- `exp = current unix time + 60 seconds` + +It then posts the signed token to: + +- `https:///integrations/oauth2/api/v1/jwt/exchange` + +## Known issues + +### Private key must be serialized as base64 JSON + +The connector stores the private key as base64-encoded JSON with RSA parameters instead of a PEM block. + +Reason: + +- The Power Automate custom connector runtime produced errors when the private key was passed in a more conventional PEM-like representation +- Serializing the RSA parameters into JSON and then wrapping that JSON in base64 avoids the runtime parsing problem and allows the policy script to reconstruct `RSAParameters` reliably + +This behavior is implemented in [script.csx](./script.csx) in `ParseRsaParamsFromB64Json`. + +### Webhook delete does not work + +Deleting the Workfront event subscription from Power Automate currently does not work reliably. + +Power Automate Community thread: https://community.powerplatform.com/forums/thread/details/?threadid=ca556f3b-9e27-f111-8341-000d3a5747e9 + +Observed behavior: + +- Power Automate sends an unauthorized `DELETE` request when it attempts to remove the webhook subscription +- Because of that, the Workfront subscription can remain orphaned and may need to be deleted manually from Workfront + +Relevant Adobe reference for delete behavior: + +- Event Subscription API delete operation: + +### OAuth 2.0 Authorization Code with PKCE is not practical for this connector + +Using `Authentication through OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE)` is not practical for this connector because of the combination of Workfront tenant isolation and Power Automate custom connector deployment rules. + +Workfront limitation: + +- Workfront is effectively tenant-specific by domain +- Each tenant can require its own OAuth application registration and endpoints +- That means the OAuth client configuration is not naturally shared across all customer tenants + +Power Automate custom connector limitation: + +- For OAuth-based custom connector authentication, `clientId`, `authorizationUrl`, `refreshUrl`, and `tokenUrl` are defined statically in `apiProperties.json` +- The client secret is provided separately during connector deployment +- As a result, the OAuth configuration is effectively fixed for the connector deployment and cannot be varied dynamically per flow or per tenant connection + +Impact: + +- We cannot reliably support a per-tenant OAuth Authorization Code with PKCE setup in a single reusable connector package +- We cannot let each flow unit bring its own tenant-specific authorization endpoints and app registration details +- Because of this, the connector uses API key exchange or JWT-based machine-to-machine authentication instead + +## Repository contents + +- [apiDefinition.swagger.json](./apiDefinition.swagger.json): custom connector trigger definition +- [apiProperties.json](./apiProperties.json): connector properties, auth inputs, icon color, and policies +- [script.csx](./script.csx): policy script that performs API key or JWT exchange before forwarding requests diff --git a/independent-publisher-connectors/Workfront Event Subscription/apiDefinition.swagger.json b/independent-publisher-connectors/Workfront Event Subscription/apiDefinition.swagger.json new file mode 100644 index 0000000000..53a642f90e --- /dev/null +++ b/independent-publisher-connectors/Workfront Event Subscription/apiDefinition.swagger.json @@ -0,0 +1,254 @@ +{ + "swagger": "2.0", + "info": { + "title": "Workfront Event Subscription", + "version": "1.0.0", + "description": "Custom connector for Adobe Workfront.", + "contact": { + "name": "P-Product", + "email": "partners@p-product.com" + } + }, + "schemes": [ + "https" + ], + "host": "example.123.com", + "basePath": "/", + "x-ms-connector-metadata": [ + { + "propertyName": "Website", + "propertyValue": "https://business.adobe.com/products/workfront.html" + }, + { + "propertyName": "Privacy policy", + "propertyValue": "https://www.adobe.com/privacy.html" + }, + { + "propertyName": "Categories", + "propertyValue": "Productivity;Collaboration" + } + ], + "securityDefinitions": { + "api_key": { + "type": "apiKey", + "in": "header", + "name": "X-WORKFRONT-API-KEY" + } + }, + "security": [], + + "paths": { + "/attask/eventsubscription/api/v1/subscriptions": { + "x-ms-notification-content": { + "description": "Workfront event notification", + "schema": { + "type": "object", + "properties": { + "eventType": { + "type": "string", + "description": "Type of event (CREATE, UPDATE, DELETE)" + }, + "subscriptionId": { + "type": "string", + "description": "ID of the subscription" + }, + "eventTime": { + "type": "object", + "description": "Time when event occurred", + "properties": { + "epochSecond": { + "type": "integer", + "description": "Unix timestamp in seconds" + }, + "nano": { + "type": "integer", + "description": "Nanosecond precision" + } + } + }, + "newState": { + "type": "object", + "description": "New state of the object", + "additionalProperties": true + }, + "oldState": { + "type": "object", + "description": "Previous state of the object (empty for CREATE events)", + "additionalProperties": true + }, + "customerId": { + "type": "string", + "description": "Workfront customer ID" + }, + "userId": { + "type": "string", + "description": "ID of the user who triggered the event" + }, + "subscriptionVersion": { + "type": "string", + "description": "Version of the subscription (v1 or v2)" + }, + "eventVersion": { + "type": "string", + "description": "Version of the event format (v1 or v2)" + } + } + } + }, + "post": { + "summary": "When a Workfront event occurs", + "description": "Triggers when a CREATE, UPDATE, or DELETE event occurs in Workfront", + "operationId": "OnWorkfrontEvent", + "x-ms-trigger": "single", + "consumes": [ + "application/json" + ], + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "object", + "required": [ + "objCode", + "eventType", + "url" + ], + "properties": { + "objCode": { + "type": "string", + "description": "Workfront object type", + "title": "Object Type", + "x-ms-summary": "Object Type", + "enum": [ + "PROJ", + "TASK", + "OPTASK", + "USER", + "DOCU", + "PORT", + "PRGM", + "TMPL", + "TMTSK", + "HOUR", + "NOTE", + "APPROVAL", + "ASSGN", + "EXPNS", + "CUSTRECORD" + ], + "x-ms-visibility": "important" + }, + "eventType": { + "type": "string", + "description": "Type of event to trigger on", + "title": "Event Type", + "x-ms-summary": "Event Type", + "enum": [ + "CREATE", + "UPDATE", + "DELETE" + ], + "x-ms-visibility": "important" + }, + "url": { + "type": "string", + "description": "Webhook callback URL", + "title": "Callback URL", + "x-ms-notification-url": true, + "x-ms-visibility": "internal" + }, + "filters": { + "type": "array", + "description": "Optional filters to apply to events", + "title": "Filters (Optional)", + "x-ms-summary": "Event Filters", + "x-ms-visibility": "advanced", + "items": { + "type": "object", + "properties": { + "fieldName": { + "type": "string", + "description": "Field name to filter on (e.g., projectID, status, lastUpdatedByID)", + "title": "Field Name", + "x-ms-summary": "Field Name" + }, + "fieldValue": { + "type": "string", + "description": "Value to compare against", + "title": "Field Value", + "x-ms-summary": "Field Value" + }, + "state": { + "type": "string", + "description": "State to check (newState or oldState)", + "title": "State", + "x-ms-summary": "State", + "enum": [ + "newState", + "oldState" + ], + "default": "newState" + }, + "comparison": { + "type": "string", + "description": "Comparison operator", + "title": "Comparison", + "x-ms-summary": "Comparison Operator", + "enum": [ + "eq", + "ne", + "lt", + "lte", + "gt", + "gte", + "contains" + ], + "default": "eq" + } + }, + "required": [ + "fieldName", + "fieldValue" + ] + } + }, + "filterConnector": { + "type": "string", + "description": "Logical connector for multiple filters", + "title": "Filter Connector", + "x-ms-summary": "Filter Connector", + "enum": [ + "AND", + "OR" + ], + "default": "AND", + "x-ms-visibility": "advanced" + } + } + } + } + ], + "responses": { + "201": { + "description": "Subscription created", + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Subscription ID" + }, + "version": { + "type": "string", + "description": "Event subscription version assigned by Workfront" + } + } + } + } + } + } + } + } +} diff --git a/independent-publisher-connectors/Workfront Event Subscription/apiProperties.json b/independent-publisher-connectors/Workfront Event Subscription/apiProperties.json new file mode 100644 index 0000000000..9230b4ace3 --- /dev/null +++ b/independent-publisher-connectors/Workfront Event Subscription/apiProperties.json @@ -0,0 +1,195 @@ +{ + "properties": { + "connectionParameterSets": { + "uiDefinition": { + "displayName": "Authentication Type", + "description": "Type of authentication to be used." + }, + "values": [ + { + "name": "api-auth", + "uiDefinition": { + "displayName": "Use Worfront API key for the authentication.", + "description": "You need to generate API key in the Workfront dashboard." + }, + "parameters": { + "workfrontDomain": { + "type": "string", + "uiDefinition": { + "displayName": "Workfront Domain", + "description": "Your full Workfront domain (e.g., mycompany.my.workfront.com or dmeps.sb01.workfront.com)", + "tooltip": "Enter your Workfront domain without https://", + "constraints": { + "tabIndex": 1, + "required": "true" + } + } + }, + "api_key": { + "type": "securestring", + "uiDefinition": { + "displayName": "API Key", + "description": "The API key", + "tooltip": "Enter the API key", + "constraints": { + "tabIndex": 2, + "required": "true", + "clearText": false + } + } + } + } + }, + { + "name": "jwt-exchange", + "uiDefinition": { + "displayName": "Use Workfront JWT exchange", + "description": "Use Workfront JWT exchange approach for the machine-to-machine communication" + }, + "parameters": { + "workfrontDomain": { + "type": "string", + "uiDefinition": { + "displayName": "Workfront Domain", + "description": "Your full Workfront domain (e.g., mycompany.my.workfront.com or dmeps.sb01.workfront.com)", + "tooltip": "Enter your Workfront domain with https:// or http://", + "constraints": { + "tabIndex": 1, + "required": "true" + } + } + }, + "workfrontJwtClientId": { + "type": "string", + "uiDefinition": { + "displayName": "Client ID", + "description": "Workfront Client ID", + "tooltip": "Enter the Client ID", + "constraints": { + "tabIndex": 2, + "required": "true", + "clearText": false + } + } + }, + "workfrontJwtClientSecret": { + "type": "securestring", + "uiDefinition": { + "displayName": "Client Secret", + "description": "Workfront Client Secret", + "tooltip": "Enter the Client Secret", + "constraints": { + "tabIndex": 3, + "required": "true" + } + } + }, + "workfrontJwtCustomerId": { + "type": "string", + "uiDefinition": { + "displayName": "Customer ID", + "description": "Workfront Customer ID", + "tooltip": "Enter the Customer ID", + "constraints": { + "tabIndex": 4, + "required": "true", + "clearText": false + } + } + }, + "workfrontJwtSubjectUser": { + "type": "string", + "uiDefinition": { + "displayName": "Subject User", + "description": "Workfront Subject User", + "tooltip": "Enter the Subject User", + "constraints": { + "tabIndex": 5, + "required": "true", + "clearText": false + } + } + }, + "workfrontJwtRsaPrivateKeyInfo": { + "type": "string", + "uiDefinition": { + "displayName": "RSA Private Key (Base64 JSON)", + "description": "Base64-encoded minified JSON containing RSA private key parameters (modulus, exponent, d, p, q, dp, dq, inverseQ). Generated by the setup script and used to sign Workfront JWT assertions at runtime. Single-line value; do not include line breaks.", + "tooltip": "Base64-encoded minified JSON containing RSA private key parameters (modulus, exponent, d, p, q, dp, dq, inverseQ). Generated by the setup script and used to sign Workfront JWT assertions at runtime. Single-line value; do not include line breaks.", + "constraints": { + "tabIndex": 6, + "required": "true", + "clearText": false + } + } + } + } + } + ] + }, + "iconBrandColor": "#1B1E52", + "capabilities": [ + "actions" + ], + "policyTemplateInstances": [ + { + "templateId": "dynamichosturl", + "title": "Host URL", + "parameters": { + "x-ms-apimTemplateParameter.urlTemplate": "@connectionParameters('workfrontDomain')/" + } + }, + { + "templateId": "setheader", + "title": "Inject Client ID", + "parameters": { + "x-ms-apimTemplate-policySection": "Request", + "x-ms-apimTemplateParameter.name": "X-WORKFRONT-JWT-CLIENT-ID", + "x-ms-apimTemplateParameter.value": "@connectionParameters('workfrontJwtClientId', '')", + "x-ms-apimTemplateParameter.existsAction": "override" + } + }, + { + "templateId": "setheader", + "title": "Inject JWT client secret", + "parameters": { + "x-ms-apimTemplate-policySection": "Request", + "x-ms-apimTemplateParameter.name": "X-WORKFRONT-JWT-CLIENT-SECRET", + "x-ms-apimTemplateParameter.value": "@connectionParameters('workfrontJwtClientSecret', '')", + "x-ms-apimTemplateParameter.existsAction": "override" + } + }, + { + "templateId": "setheader", + "title": "Inject JWT Customer ID", + "parameters": { + "x-ms-apimTemplate-policySection": "Request", + "x-ms-apimTemplateParameter.name": "X-WORKFRONT-JWT-CUSTOMER-ID", + "x-ms-apimTemplateParameter.value": "@connectionParameters('workfrontJwtCustomerId', '')", + "x-ms-apimTemplateParameter.existsAction": "override" + } + }, + { + "templateId": "setheader", + "title": "Inject Subject User", + "parameters": { + "x-ms-apimTemplate-policySection": "Request", + "x-ms-apimTemplateParameter.name": "X-WORKFRONT-JWT-SUBJECT-USER", + "x-ms-apimTemplateParameter.value": "@connectionParameters('workfrontJwtSubjectUser', '')", + "x-ms-apimTemplateParameter.existsAction": "override" + } + }, + { + "templateId": "setheader", + "title": "Inject Private Key (CSP Base64)", + "parameters": { + "x-ms-apimTemplate-policySection": "Request", + "x-ms-apimTemplateParameter.name": "X-WORKFRONT-JWT-RSA-PRIVATE-KEY-INFO", + "x-ms-apimTemplateParameter.value": "@connectionParameters('workfrontJwtRsaPrivateKeyInfo', '')", + "x-ms-apimTemplateParameter.existsAction": "override" + } + } + ], + "publisher": "P-Product" + } +} diff --git a/independent-publisher-connectors/Workfront Event Subscription/images/001-workfront-api-key-setup.png b/independent-publisher-connectors/Workfront Event Subscription/images/001-workfront-api-key-setup.png new file mode 100644 index 0000000000..cd51e1ffd3 Binary files /dev/null and b/independent-publisher-connectors/Workfront Event Subscription/images/001-workfront-api-key-setup.png differ diff --git a/independent-publisher-connectors/Workfront Event Subscription/images/002-workfront-oath-jwt-setup.png b/independent-publisher-connectors/Workfront Event Subscription/images/002-workfront-oath-jwt-setup.png new file mode 100644 index 0000000000..bea261bb8a Binary files /dev/null and b/independent-publisher-connectors/Workfront Event Subscription/images/002-workfront-oath-jwt-setup.png differ diff --git a/independent-publisher-connectors/Workfront Event Subscription/images/003-power-automate-flow-test-run.png b/independent-publisher-connectors/Workfront Event Subscription/images/003-power-automate-flow-test-run.png new file mode 100644 index 0000000000..8ea50ffb10 Binary files /dev/null and b/independent-publisher-connectors/Workfront Event Subscription/images/003-power-automate-flow-test-run.png differ diff --git a/independent-publisher-connectors/Workfront Event Subscription/images/004-power-automate-operation-test-results.png b/independent-publisher-connectors/Workfront Event Subscription/images/004-power-automate-operation-test-results.png new file mode 100644 index 0000000000..900b200b45 Binary files /dev/null and b/independent-publisher-connectors/Workfront Event Subscription/images/004-power-automate-operation-test-results.png differ diff --git a/independent-publisher-connectors/Workfront Event Subscription/script.csx b/independent-publisher-connectors/Workfront Event Subscription/script.csx new file mode 100644 index 0000000000..8959619604 --- /dev/null +++ b/independent-publisher-connectors/Workfront Event Subscription/script.csx @@ -0,0 +1,233 @@ +public class Script : ScriptBase +{ + private readonly string HTTP_HEADER_NAME_API_KEY = "X-WORKFRONT-API-KEY"; + private readonly string HTTP_HEADER_NAME_AUTHORIZATION = "Authorization"; + private readonly string HTTP_HEADER_NAME_SESSION_ID = "sessionID"; + private readonly string HTTP_HEADER_NAME_JWT_CLIENT_ID = "X-WORKFRONT-JWT-CLIENT-ID"; + private readonly string HTTP_HEADER_NAME_JWT_CLIENT_SECRET = "X-WORKFRONT-JWT-CLIENT-SECRET"; + private readonly string HTTP_HEADER_NAME_JWT_CUSTOMER_ID = "X-WORKFRONT-JWT-CUSTOMER-ID"; + private readonly string HTTP_HEADER_NAME_JWT_SUBJECT_USER = "X-WORKFRONT-JWT-SUBJECT-USER"; + private readonly string HTTP_HEADER_NAME_JWT_RSA_PRIVATE_KEY_INFO = "X-WORKFRONT-JWT-RSA-PRIVATE-KEY-INFO"; + + private string BuildSessionApiAbsoluteUrl(string workfrontDomain, string apiKey) + => $"https://{workfrontDomain}/attask/api/v19.0/session?apiKey={apiKey}"; + + private string BuildJwtExchangeAbsoluteUrl (string workfrontDomain) + => $"https://{workfrontDomain}/integrations/oauth2/api/v1/jwt/exchange"; + public override async Task ExecuteAsync() + { + var request = this.Context.Request; + + // API key authentication + if (CheckIsHttpHeaderExistsAndHasValue(HTTP_HEADER_NAME_API_KEY)) + { + var sessionID = await GenerateSessionID( + request.Headers.GetValues(HTTP_HEADER_NAME_API_KEY).FirstOrDefault(), + request.RequestUri.Host) + .ConfigureAwait(false); + + SetSessionIdHttpHeader(sessionID); + RemoveHttpHeaderByName(HTTP_HEADER_NAME_API_KEY); + } + + // JWT token authentication + if (new[] + { + HTTP_HEADER_NAME_JWT_CLIENT_ID, + HTTP_HEADER_NAME_JWT_CLIENT_SECRET, + HTTP_HEADER_NAME_JWT_CUSTOMER_ID, + HTTP_HEADER_NAME_JWT_SUBJECT_USER, + HTTP_HEADER_NAME_JWT_RSA_PRIVATE_KEY_INFO, + } + .All(CheckIsHttpHeaderExistsAndHasValue)) + { + var accessToken = await ExchangeJwtForAccessToken( + workfrontHost: request.RequestUri.Host, + customerId: request.Headers.GetValues(HTTP_HEADER_NAME_JWT_CUSTOMER_ID).First(), + clientId: request.Headers.GetValues(HTTP_HEADER_NAME_JWT_CLIENT_ID).First(), + clientSecret: request.Headers.GetValues(HTTP_HEADER_NAME_JWT_CLIENT_SECRET).First(), + rsaParamsB64: request.Headers.GetValues(HTTP_HEADER_NAME_JWT_RSA_PRIVATE_KEY_INFO).First(), + subjectUserId: request.Headers.GetValues(HTTP_HEADER_NAME_JWT_SUBJECT_USER).First()) + .ConfigureAwait(false); + + SetSessionIdHttpHeader(accessToken); + } + + RemoveHttpHeaderByName(HTTP_HEADER_NAME_JWT_CLIENT_ID); + RemoveHttpHeaderByName(HTTP_HEADER_NAME_JWT_CLIENT_SECRET); + RemoveHttpHeaderByName(HTTP_HEADER_NAME_JWT_CUSTOMER_ID); + RemoveHttpHeaderByName(HTTP_HEADER_NAME_JWT_SUBJECT_USER); + RemoveHttpHeaderByName(HTTP_HEADER_NAME_JWT_RSA_PRIVATE_KEY_INFO); + + var response = await this.Context + .SendAsync(request, this.CancellationToken) + .ConfigureAwait(false); + + return response; + } + + private void SetSessionIdHttpHeader(string headerValue) + { + var request = this.Context.Request; + if (request.Headers.Contains(HTTP_HEADER_NAME_SESSION_ID)) + { + request.Headers.Remove(HTTP_HEADER_NAME_SESSION_ID); + } + request.Headers.Add(HTTP_HEADER_NAME_SESSION_ID, headerValue); + } + + private async Task GenerateSessionID(string apiKey, string workfrontDomain) + { + try + { + var sessionRequest = new HttpRequestMessage( + HttpMethod.Get, + BuildSessionApiAbsoluteUrl(workfrontDomain, apiKey)); + + var sessionResponse = await this.Context + .SendAsync(sessionRequest, this.CancellationToken) + .ConfigureAwait(false); + + if (sessionResponse.IsSuccessStatusCode) + { + var responseBody = await sessionResponse.Content + .ReadAsStringAsync() + .ConfigureAwait(false); + + var parsedResponseBody = JObject.Parse(responseBody); + + return (string?)parsedResponseBody.SelectToken("data.sessionID"); + } + } + catch (Exception exc) + { + this.Context.Logger.LogError(exc, "Error during SessionID generation"); + } + + return null; + } + + private bool CheckIsHttpHeaderExistsAndHasValue(string httpHeaderName) + { + var requestHeaders = this.Context.Request.Headers; + return requestHeaders.Contains(httpHeaderName) + && !string.IsNullOrEmpty(requestHeaders.GetValues(httpHeaderName)?.FirstOrDefault()?.Trim()); + } + + private void RemoveHttpHeaderByName(string httpHeaderName) + { + var request = this.Context.Request; + if (request.Headers.Contains(httpHeaderName)) + { + request.Headers.Remove(httpHeaderName); + } + } + + private async Task ExchangeJwtForAccessToken( + string workfrontHost, + string customerId, + string clientId, + string clientSecret, + string rsaParamsB64, + string subjectUserId) + { + var jwtToken = CreateJwtAssertion(customerId, subjectUserId, rsaParamsB64); + + var req = new HttpRequestMessage( + HttpMethod.Post, + BuildJwtExchangeAbsoluteUrl(workfrontHost)); + + req.Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["jwt_token"] = jwtToken + }); + + var res = await this.Context.SendAsync(req, this.CancellationToken).ConfigureAwait(false); + var body = await res.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!res.IsSuccessStatusCode) + { + AddErrorToLogAndThrowException($"JWT exchange failed ({(int)res.StatusCode}): {body}"); + } + + var json = JObject.Parse(body); + var accessToken = (string?)json["access_token"]; + if (string.IsNullOrWhiteSpace(accessToken)) + { + AddErrorToLogAndThrowException("JWT exchange response missing access_token."); + } + + return accessToken; + } + + private string CreateJwtAssertion(string clientId, string subjectUserId, string rsaParamsB64) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var exp = now + 60; // 1 min + + var headerJson = JsonConvert.SerializeObject(new + { + alg = "RS256", + typ = "JWT" + }); + + var payloadJson = JsonConvert.SerializeObject(new + { + iss = clientId, + sub = subjectUserId, + iat = now, + exp = exp + }); + + var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + var signingInput = headerB64 + "." + payloadB64; + + byte[] signatureBytes; + var rsaParams = ParseRsaParamsFromB64Json(rsaParamsB64); + using (var rsa = new RSACryptoServiceProvider()) + using (var sha = new SHA256CryptoServiceProvider()) + { + rsa.PersistKeyInCsp = false; + rsa.ImportParameters(rsaParams); + var hash = sha.ComputeHash(Encoding.ASCII.GetBytes(signingInput)); + signatureBytes = rsa.SignHash(hash, CryptoConfig.MapNameToOID("SHA256")); + } + + return signingInput + "." + Base64UrlEncode(signatureBytes); + } + + private RSAParameters ParseRsaParamsFromB64Json(string rsaParamsB64) + { + var json = Encoding.UTF8.GetString(Convert.FromBase64String(rsaParamsB64.Trim())); + var parsedJson = JObject.Parse(json); + + return new RSAParameters + { + Modulus = Convert.FromBase64String((string)parsedJson["modulus"]), + Exponent = Convert.FromBase64String((string)parsedJson["exponent"]), + D = Convert.FromBase64String((string)parsedJson["d"]), + P = Convert.FromBase64String((string)parsedJson["p"]), + Q = Convert.FromBase64String((string)parsedJson["q"]), + DP = Convert.FromBase64String((string)parsedJson["dp"]), + DQ = Convert.FromBase64String((string)parsedJson["dq"]), + InverseQ = Convert.FromBase64String((string)parsedJson["inverseQ"]) + }; + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private void AddErrorToLogAndThrowException(string errorMessage) + { + this.Context.Logger.LogError(errorMessage); + throw new Exception(errorMessage); + } +} \ No newline at end of file