EaglesNest

EaglesNest

Certificate-based OAuth 2.0 API — Developer Guide

Version 1.0  ·  .NET 8  ·  Microsoft Entra ID  ·  FluentFTP  ·  Dapper

Overview

EaglesNest is an ASP.NET Core Web API that acts as a secure gateway between CargoWise (or any eAdapter-compatible sender) and downstream systems. It provides:

Prerequisites

ItemDetails
Azure AD TenantTenant ID + Client ID configured in appsettings.json → AzureAd
SQL ServerConnection strings: eAdapterNext (main DB) and Stockwell (Odyssey)
FTP ServerRequired only for XML routing — configure URI/credentials in tblRoutes
Log directoryC:\eAdapterNext\Logs\ (configurable via eAdapterSettings:Logs)
XML directoryC:\eAdapterNext\XmlFolder\ (configurable via eAdapterSettings:XMLFolder)

Base URL & Common Headers

https://localhost:7196   # Development
https://your-server/api  # Production

Common Request Headers

HeaderValueRequired on
AuthorizationBearer {token}All protected endpoints
Content-Typeapplication/jsonPOST / PUT / PATCH bodies
Content-Typeapplication/xml or text/xmlPOST /api/cargowisedata/receive
eAdaptor-RecipientIDYour recipient identifierPOST /api/cargowisedata/receive
eAdaptor-EDIClientNameYour EDI client namePOST /api/cargowisedata/receive
traceparentW3C trace ID (optional — auto-generated)POST /api/cargowisedata/receive

Error Responses

All endpoints return a consistent JSON envelope:

{
  "success": false,
  "message": "Unauthorized",
  "reason":  "Token expired at 2025-04-10 12:00:00Z. Request a new token.",
  "hint":    "Obtain a valid token via POST /api/auth/token or from Microsoft Entra ID.",
  "errors":  []
}
HTTP StatusMeaning
200Success
201Created — check Location header
400Bad request — validation error, malformed XML, or duplicate
401Unauthorized — missing, expired, or invalid Bearer token. Response body contains reason.
403Forbidden — sender not registered in tblMessageConfig or config disabled
404Resource not found
500Internal server error — check system log at C:\eAdapterNext\Logs\system-*.log

Authentication

Option A — Swagger UI (Interactive)

Best for exploring the API during development.

1
Open Swagger UINavigate to swagger in your browser.
2
Click the 🔒 Authorize buttonSelect oauth2, then click Authorize.
3
Sign in with MicrosoftUse your Azure AD account. Swagger attaches the token to every request automatically.

Option B — Postman / Client Credentials

Best for testing with Postman or any HTTP client using a client secret.

POST https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id={your-client-id}
&client_secret={your-client-secret}
&scope=api://ef770885-965b-4152-90c8-ddb4e78110d6/.default

Use the returned access_token as Authorization: Bearer {token}.

Option C — Certificate M2M (CargoWise / Production)

Automated service-to-service flow using RS256 certificate-based client assertions.

1
Register your client — POST /api/auth/register Leave csrPem blank to let the server generate the key pair.
{
  "clientName":   "My CargoWise App",
  "azureClientId": "your-azure-app-client-id"
}
Save the response values:
FieldUse
certificatePemSteps 2, 3, and upload to Azure AD
privateKeyPemStep 2 only — keep secret, not stored server-side
2
Upload certificate to Azure AD In the Azure Portal → App Registrations → Certificates & secrets → Upload the certificatePem from Step 1.
3
Generate client assertion — POST /api/auth/generate-assertion
{
  "certificatePem": "-----BEGIN CERTIFICATE-----\n...",
  "privateKeyPem":  "-----BEGIN RSA PRIVATE KEY-----\n..."
}
Returns a signed JWT valid for 10 minutes.
4
Exchange for access token — POST /api/auth/token
{
  "certificatePem":  "-----BEGIN CERTIFICATE-----\n...",
  "clientAssertion": "eyJhbGciOiJS..."
}
Returns an accessToken — use as Authorization: Bearer {token}.
AADSTS700027 — Troubleshooting
"Key not found" → Certificate not uploaded to Azure AD → upload certificatePem in the portal.
"Key found, signature failed" → Private key doesn't match the certificate → re-register and generate a fresh matching pair.

Option D — Basic Authentication

Use Basic Auth when your client cannot obtain a Microsoft Entra ID token. Credentials are validated against the local tblUsers table. The scheme is auto-selected by the API when the Authorization header starts with Basic.

How it works

  1. Create a local user via POST /api/user (or use an existing account).
  2. Base64-encode username:password.
  3. Pass the result in the Authorization header on every request.

Header format

Authorization: Basic {base64(username:password)}

Encoding example

StepValue
Plain credentialsjohn.doe:P@ssw0rd!
Base64-encodedam9obi5kb2U6UEBzc3cwcmQh
Final headerAuthorization: Basic am9obi5kb2U6UEBzc3cwcmQh

Generate the Base64 value

Use any of the following:

# PowerShell
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("john.doe:P@ssw0rd!"))

# Linux / macOS
echo -n "john.doe:P@ssw0rd!" | base64

# Online
# https://www.base64encode.org/

Full request example

POST /api/cargowisedata/receive HTTP/1.1
Authorization: Basic am9obi5kb2U6UEBzc3cwcmQh
Content-Type: application/xml
eAdaptor-RecipientID: MYRECIPIENT
eAdaptor-EDIClientName: CargoWiseClient

<?xml version="1.0"?>
<UniversalShipment>...</UniversalShipment>

Validation logic

The BasicAuthenticationService performs the following checks on every request:

  1. Authorization header present and scheme is Basic
  2. Header value is valid Base64 with exactly one : separator
  3. Username found in tblUsers
  4. Password matches: SHA-256(salt + password) equals stored hash
  5. Account Status = true (active)

Switching between Basic and Bearer

The API uses a dynamic scheme selector — you can mix Basic and Bearer in the same deployment. The scheme is chosen automatically based on the Authorization header prefix:

Header prefixScheme used
Bearer ...Microsoft Entra ID JWT Bearer
Basic ...Local tblUsers Basic Auth
Security note — Basic Auth credentials are only Base64-encoded, not encrypted. Always use HTTPS in production to prevent credentials from being intercepted in transit.

Create a user (prerequisite)

POST /api/user
Content-Type: application/json

{
  "username": "john.doe",
  "password": "P@ssw0rd!"
}
Passwords are stored as SHA-256(salt + password) — plain text is never saved. The same hashing is used by both the Basic Auth handler and the POST /api/user/login endpoint.

Common errors

ResponseCauseFix
401 Missing Authorization HeaderNo Authorization header sentAdd Authorization: Basic ...
401 Invalid Authorization SchemeHeader uses Bearer instead of BasicChange scheme to Basic
401 Invalid Authorization HeaderBase64 value is malformed or missing :Re-encode username:password correctly
401 Username not found!Username doesn't exist in tblUsersCreate the user via POST /api/user
401 Invalid password!Password doesn't match the stored hashCheck for typos or reset via PUT /api/user/{id}

Message Configuration Setup

Every sender (eAdaptor-EDIClientName + eAdaptor-RecipientID pair) must be registered in tblMessageConfig before XML can be received. The message filter controls whether routed delivery to FTP is enabled.

1
Register the sender — POST /api/messageconfig
{
  "ediClientName": "CargoWiseClient",
  "recipientId":   "MYRECIPIENT",
  "procedureId":   1,
  "status":        true,
  "enableRouting": true
}
FieldEffect
status: trueInbound messages from this sender are accepted
status: falseAll messages rejected with 403
enableRouting: trueMessages forwarded to FTP after saving to disk
enableRouting: falseMessages saved to disk only — no FTP delivery
Save the id (GUID) from the response — you'll need it in Step 2.
2
Add FTP delivery route — POST /api/messageconfig/{id}/routes
{
  "type":     "FTP",
  "uri":      "ftp://ftp.example.com/incoming/cargowise/",
  "username": "ftpuser",
  "password": "P@ssw0rd",
  "senderId": "SYD"
}

Multiple routes can be added. All active routes receive the file concurrently on each delivery.

TypeStatus
FTP✅ Implemented — FluentFTP, PASV, auto-FTPS
SFTP🔜 Planned
HTTP / HTTPS🔜 Planned
OUTBOUND / OUTBOUNDNEXT🔜 Planned
SOAP / ENVELOP / CUSTOM🔜 Planned
3
Toggle routing on/off — PATCH /api/messageconfig/{id}/filter
{ "routingEnabled": false }
Pauses FTP delivery without deleting routes or config. Messages continue to be saved to disk.
4
Monitor delivery counts — GET /api/messageconfig/{id}/counts

Returns per-route delivery totals from tblMessageCount. Incremented automatically on each successful FTP upload.

Receiving CargoWise XML

Endpoint: POST /api/cargowisedata/receive

Required Headers

Authorization: Bearer {access_token}
Content-Type: application/xml
eAdaptor-RecipientID: MYRECIPIENT
eAdaptor-EDIClientName: CargoWiseClient
traceparent: 4bf92f3577b34da6a3ce929d0e0e4736   (optional)

Body

<?xml version="1.0" encoding="utf-8"?>
<UniversalShipment xmlns="http://www.cargowise.com/Schemas/Universal/2011/11">
  <Shipment>...</Shipment>
</UniversalShipment>

Validation Pipeline (in order)

  1. Required headers present (eAdaptor-RecipientID, eAdaptor-EDIClientName)
  2. Message filter gate — sender must be registered and active in tblMessageConfig
  3. Content-Type is application/xml or text/xml
  4. Payload size within configured limit (default 10 MB)
  5. Well-formed XML document
  6. Root element in allowed list (if configured)

On Success

FTP routing failures do not return an error to the caller — the XML is always safely on disk first. Check the system log for routing errors.

Success Response

{
  "success": true,
  "message": "XML received, validated and saved successfully.",
  "data": {
    "traceParent":   "4bf92f3577b34da6a3ce929d0e0e4736",
    "recipientId":   "MYRECIPIENT",
    "ediClientName": "CargoWiseClient",
    "receivedAt":    "2025-04-10T03:00:00Z",
    "rootElement":   "UniversalShipment",
    "elementCount":  1,
    "callerAppId":   "ef770885-965b-4152-90c8-ddb4e78110d6",
    "callerName":    "CargoWise Integration",
    "savedFileName": "20250410030000000_-_CargoWiseClient_-_MYRECIPIENT_4bf92f35.xml"
  }
}

Sending XML to CargoWise

Endpoint: POST /api/cargowisedata/send

Authenticates via Basic Auth. The authenticated username is used as the partitionKey — no route parameter required. The API fetches a Bearer token from eAdapterNext.Token using the username, then forwards the raw XML body to the CargoWise eAdaptorNext endpoint. An admin alert email is sent automatically (throttled — 1 per username per hour) for any of these failures: no Azure Table record for the username, CargoWise returning a non-2xx response, or a network/unexpected error.

Flow

1
POST the XML using Basic Auth
POST /api/cargowisedata/send
Authorization: Basic {base64(username:password)}
Content-Type: application/xml

<?xml version="1.0" encoding="utf-8"?>
<UniversalTransaction xmlns="http://www.cargowise.com/Schemas/Universal/2011/11">
  <Header>
    <SenderID>SIPSYD</SenderID>
    ...
  </Header>
</UniversalTransaction>

The username must be at least 6 characters and must match a row in the eAdapterNext.Token Azure Table that holds the OAuth2 credentials for the target CargoWise environment.

2
API derives SenderID from the username

The SenderID is the first 6 characters of the authenticated username:

var senderId = username[..6];  // e.g. "SIPSYD" from "SIPSYDclient"

Target URL is built as:

https://{senderId}services.wisegrid.net/eAdaptorNext

Example: username = "SIPSYDclient"senderId = "SIPSYD"https://SIPSYDservices.wisegrid.net/eAdaptorNext

3
API fetches a Bearer token from eAdapterNext.Token
POST {TokenApiBaseUrl}/api/token
Content-Type: application/json

{ "partitionKey": "{username}" }

The token is served from cache (Azure Table Storage) when still valid, or freshly acquired via OAuth2. If no row exists for the username, HTTP 502 is returned and an admin alert email is sent.

4
API forwards the XML to CargoWise
POST https://{senderId}services.wisegrid.net/eAdaptorNext
Authorization: Bearer {token}
Content-Type: application/xml

<!-- original XML body -->

Success Response

{
  "success": true,
  "message": "XML forwarded to CargoWise — HTTP 200.",
  "data": {
    "traceParent":  "a3f1c2d4e5b6...",
    "sentAt":       "2025-04-20T06:30:00Z",
    "cwStatusCode": 200,
    "cwResponse":   "<!-- CargoWise response body -->",
    "tokenSource":  "Cached",
    "targetUrl":    "https://SIPSYDservices.wisegrid.net/eAdaptorNext"
  }
}

Error cases

StatusCause
400Username shorter than 6 characters, or empty request body
401Missing or invalid Basic Auth credentials
500Failed to read the HTTP request body — admin alert sent
502No Azure Table config for username, Token API unreachable, or network error to CargoWise — admin alert sent
200 + success=falseCargoWise returned non-2xx (rejected the XML) — admin alert sent. Check cwResponse in the response body for detail.
Notifications — an admin alert email is automatically sent (throttled to one per username per hour) when:
  • No Azure Table row exists for the username in eAdapterNext.Token
  • CargoWise returns a non-2xx response (XML rejected)
  • Network error or unexpected exception during the send
The username must exist as a row in eAdapterNext.Token Azure Table Storage with valid ClientId, TenantId, certificate PEM, and private key.

FTP Routing Detail

URI Formats Accepted

ftp://ftp.example.com/incoming/cargowise/
ftps://secure.example.com/drop/
ftp.example.com/incoming/          (ftp:// assumed)
ftp.example.com                    (uploads to root)

Behaviour

Log Locations

LogPath
System / FTP eventsC:\eAdapterNext\Logs\system-{date}.log
Per-client receive logC:\eAdapterNext\Logs\{RecipientID}\{EdiClientName}\{date}.log

Stockwell — AP/AR Invoice Report

GET /api/stockwell/AP?token={your-token}

Returns all open (unpaid) AP and AR invoices from the Odyssey AccTransactionHeader table.

Current hardcoded configuration — these values are set in StockwellReport.cs and cannot be changed via query parameters today:
  • Company code: SYD
  • Ledger scope: both AR and AP
  • Transaction type: INV only
  • Outstanding filter: unpaid items only (OSTotal > 0.01)

Authentication

Pass the API token as a query parameter:

GET /api/stockwell/AP?token=eyJhbGci...

Token validation can be disabled by setting eAdapterSettings:activatedstokwelltoken = false.

Response Fields

FieldSourceDescription
LedgerAH_LedgerAR or AP
CreditorDebtorOrgHeader.OH_CodeCreditor/Debtor code
TransactionTypeAH_TransactionTypeAlways INV
TransactionNumAH_TransactionNumUnique reference number
ReferenceDescriptionAH_ConsolidatedInvoiceRef / AH_DescAR: invoice ref — AP: description
PostDateAH_PostDateDate posted
InvoiceDateAH_InvoiceDateInvoice date
DueDateAH_DueDatePayment due date
CurrencyRefCurrency.RX_CodeISO 4217 currency code
ExchangeRateAH_ExchangeRateRate to base currency (default 1)
TransactionAmountAH_InvoiceAmount + AH_GSTAmountInvoice + GST
OutstandingAmountAH_OSTotalUnpaid balance
CategoryOrgCompanyData.OB_ARCategoryAR category (null for AP)
PaymentRequisitionDateAH_RequisitionDateRequisition raised date
PaymentRequisitionStatusAH_RequisitionStatusRequisition status

Stockwell — Trial Balance

GET /api/stockwell/trialbalance?token={your-token}

Returns period-level GL movements from cvw_ProfitandLossMovement, grouped by account, period, branch, and department.

Current hardcoded configuration:
  • Company code: SYD
  • Period range: 202501202612 (Jan 2025 – Dec 2026)
  • Account types: P&L, BSH, NTE only
  • Excluded: BUD (Budget) and FOR (Forecast) transaction categories
  • AR and AP control accounts automatically excluded

Response Fields

FieldSourceDescription
CompanyGlbCompany.GC_CodeCompany code
AccountAccGLHeader.AG_AccountNumGL account number
TypeAG_AccountTypeP&L, BSH, or NTE
DescriptionAG_DescriptionAccount description
BrGLBBranch.GB_CodeBranch code
DeptGLBDepartment.GE_CodeDepartment code
DirectionDecoded from dept attributesImport or Export
ModeDecoded from dept attributesAir, Sea, or Road
YearExtracted from AA_PeriodFour-digit fiscal year
PeriodAA_PeriodAccounting period YYYYMM
AmountInPeriodSUM(AA_AmountSum)Net movement for the period
CurrencyRefCurrency.RX_CodeCompany local currency

User Management

Local user accounts with salted SHA-256 password hashing and session tokens (stored in tblUsers).

Create a user

POST /api/user
Content-Type: application/json

{
  "username": "john.doe",
  "password": "P@ssw0rd!"
}

Login

POST /api/user/login
Content-Type: application/json

{
  "username": "john.doe",
  "password": "P@ssw0rd!"
}
{
  "success": true,
  "data": {
    "user": { "userId": "...", "username": "john.doe", "status": true },
    "token":        "base64-session-token",
    "refreshToken": "base64-refresh-token"
  }
}

Refresh token

POST /api/user/refresh
{ "refreshToken": "base64-refresh-token" }

Logout

POST /api/user/logout
{ "token": "base64-session-token" }
Passwords are stored as SHA-256(salt + password) — plain text is never persisted. Changing the password via PUT /api/user/{id} automatically invalidates all existing session tokens.

Full Endpoint Reference

Auth

POST/api/auth/generate-keypairGenerate RSA key pair + CSR
POST/api/auth/registerRegister client + sign certificate
POST/api/auth/generate-assertionGenerate signed client assertion JWT
POST/api/auth/tokenExchange assertion for access token

CargoWise Data

POST/api/cargowisedata/receiveReceive XML — validate, save, route
POST/api/cargowisedata/send/{partitionKey}Send XML to CargoWise — token from eAdapterNext.Token

Message Configuration

GET/api/messageconfigList all configs (summary)
GET/api/messageconfig/{id}Full config + routes + counts
POST/api/messageconfigRegister new sender
PUT/api/messageconfig/{id}Update config fields
DELETE/api/messageconfig/{id}Hard-delete config + routes + counts
PATCH/api/messageconfig/{id}/filterEnable / disable FTP routing
GET/api/messageconfig/{id}/routesList routes + per-route counts
POST/api/messageconfig/{id}/routesAdd delivery route
PUT/api/messageconfig/{id}/routes/{routeId}Update route
DELETE/api/messageconfig/{id}/routes/{routeId}Remove route + count
GET/api/messageconfig/{id}/countsDelivery count breakdown

Stockwell Reports

GET/api/stockwell/AP?token=Open AP/AR invoices
GET/api/stockwell/trialbalance?token=Period-level GL movements

User Management

GET/api/userList all active users
GET/api/user/{id}Get user by ID
POST/api/userCreate user
PUT/api/user/{id}Update username / password / status
DELETE/api/user/{id}Deactivate user (soft delete)
POST/api/user/loginAuthenticate → returns session token
POST/api/user/refreshExchange refresh token for new pair
POST/api/user/logoutInvalidate session token