@quandis/qbo4.documents.microsoft
v4.0.1-CI-20260505-235328
Published
A .NET 9 library that integrates the **Microsoft WOPI (Web Application Open Platform Interface)** protocol into the Quandis qbo4 platform, enabling in-browser editing and viewing of Office documents (Word, Excel, PowerPoint, and others) via Microsoft Offi
Keywords
Readme
qbo4.Document.MicrosoftWopi
A .NET 9 library that integrates the Microsoft WOPI (Web Application Open Platform Interface) protocol into the Quandis qbo4 platform, enabling in-browser editing and viewing of Office documents (Word, Excel, PowerPoint, and others) via Microsoft Office Online Server or Microsoft 365.
Table of Contents
- Overview
- Architecture
- Prerequisites
- Configuration
- Registration / Dependency Injection
- API Endpoints
- Services
- Security Model
- Lock Management
- WOPI Discovery
- Proof Key Validation
- Data Models
- Swagger / OpenAPI
- Local Development with NGrok
- Running Tests
- Error Handling Reference
Overview
WOPI is a REST-based protocol that allows a web application (the WOPI Host) to expose documents to a WOPI Client — in practice, Microsoft Office Online. The WOPI Host is responsible for:
- Providing file metadata (
CheckFileInfo) - Serving file contents (
GetFile) - Receiving edited file contents (
PutFile) - Managing distributed locks to prevent write conflicts
- Authenticating every request from the WOPI Client via proof-key cryptography
This library implements the complete WOPI Host contract as an ASP.NET Core Area (office), backed by the Quandis AttachmentObject storage abstraction and Redis for distributed locking.
High-Level Flow
Browser qbo4 WOPI Host Microsoft Office Online
│ │ │
│─── GET /office/wopi/host/{fileId}?action=edit ──────► │
│ │ │
│ (generates token, │ │
│ discovers action URL) │ │
│ │ │
│◄── HTML form (auto-POST) ─┤ │
│ │ │
│─── POST to Office Online ─┼───────────────────────────►│
│ (access_token, WOPISrc)│ │
│ │ │
│ │◄── CheckFileInfo ──────────│
│ │◄── GetFile ────────────────│
│ │ (user edits document) │
│ │◄── PutFile ────────────────│
│ │◄── Lock / Unlock ──────────│Architecture
qbo4.Document.MicrosoftWopi/
├── Controllers/
│ └── WopiController.cs # All WOPI HTTP endpoints (Area: office)
├── Extensions/
│ └── ServiceCollectionExtensions.cs # DI registration helpers
├── Models/
│ ├── WopiDiscovery.cs # Root discovery model
│ ├── WopiNetZone.cs # Network zone (external-https, etc.)
│ ├── WopiApp.cs # App descriptor (Word, Excel, etc.)
│ ├── WopiAction.cs # Action descriptor (view, edit, editnew)
│ ├── ProofKey.cs # RSA proof-key public key data
│ └── WopiDiscoveryParser.cs # XML → model parser (XXE-safe)
├── Services/
│ ├── ILockManager.cs # Distributed lock interface
│ ├── RedisLockManager.cs # Redis-backed lock implementation
│ ├── ITokenService.cs # JWT token interface
│ ├── TokenService.cs # JWT generation & validation
│ ├── IWopiDiscoveryService.cs # Discovery service interface
│ ├── WopiDiscoveryService.cs # 3-tier cached discovery fetcher
│ ├── WopiDiscoveryWorker.cs # Background refresh worker
│ ├── IWopiProofValidator.cs # Proof validation interface
│ └── WopiProofValidator.cs # RSA-SHA256 proof validator
├── WopiOptions.cs # "Office" section configuration
├── WopiDiscoveryServiceOptions.cs # "WopiDiscovery" section configuration
└── wwwroot/
└── office.html # Static host page (non-embedded copy)Prerequisites
| Requirement | Details |
|---|---|
| .NET | 9.0 (also supports 8.0 via conditional package references) |
| Redis | Required for distributed locking. A ConnectionStrings:Redis entry is mandatory — the service fails fast at startup if absent. |
| Microsoft Office Online | An accessible WOPI Discovery endpoint (e.g., https://<oos-host>/hosting/discovery) |
| HTTPS | The WOPI spec requires TLS in production. Requests behind a reverse proxy must forward X-Forwarded-Proto. |
Configuration
Add the following sections to appsettings.json (or environment-specific overrides):
{
"ConnectionStrings": {
"Redis": "localhost:6379"
},
"WopiDiscovery": {
"DiscoveryUrl": "https://<your-office-online-host>/hosting/discovery",
"CacheExpiration": "24:00:00",
"RefreshInterval": "12:00:00",
"RetryInterval": "01:00:00"
},
"Office": {
"FileObjectAssembly": "Test",
"Swagger": {
"IncludeSwagger": true,
"IncludeEmbeddedDocumentation": true
}
}
}Configuration Reference
WopiDiscovery Section
| Key | Type | Default | Description |
|---|---|---|---|
| DiscoveryUrl | string | (required) | Full URL to Microsoft's WOPI Discovery XML endpoint. |
| CacheExpiration | TimeSpan | 24:00:00 | How long the discovery XML is cached in the distributed cache (Redis). |
| RefreshInterval | TimeSpan | 12:00:00 | How often the background worker proactively refreshes the cache. |
| RetryInterval | TimeSpan | 01:00:00 | How long the worker waits before retrying after a failed refresh. |
Office Section
| Key | Type | Default | Description |
|---|---|---|---|
| FileObjectAssembly | string | (required) | Storage provider name passed to AttachmentObject (e.g., "Test", "azure"). |
| Swagger:IncludeSwagger | bool | false | Registers the WOPI API group in Swagger/OpenAPI. |
| Swagger:IncludeEmbeddedDocumentation | bool | false | Exposes this readme.md as Markdown documentation in Swagger UI. |
Registration / Dependency Injection
The library uses the qbo4 [ServiceCollectionMethod] attribute pattern. The two extension methods are invoked automatically by the qbo4 configuration framework when the corresponding configuration sections are present.
To register manually:
// Program.cs / Startup.cs
// Registers: WopiDiscoveryService, WopiDiscoveryWorker, RedisLockManager,
// TokenService, WopiProofValidator, IConnectionMultiplexer
builder.Services.AddMicrosoftWopi(builder.Configuration.GetSection("WopiDiscovery"));
// Registers: WopiOptions, SwaggerInfoProvider (optional), ReadMeDocumentationProvider (optional)
builder.Services.AddOfficeWeb(builder.Configuration.GetSection("Office"));Service Lifetimes
| Service | Lifetime | Reason |
|---|---|---|
| WopiDiscoveryService | Singleton | Holds in-memory cache of discovery XML |
| WopiDiscoveryWorker | Hosted Service | Background thread for cache refresh |
| WopiDiscoveryParser | Singleton | Stateless XML parser |
| IConnectionMultiplexer | Singleton | Redis connection is expensive to create |
| RedisLockManager | Singleton | Shares the multiplexer |
| TokenService | Singleton | Stateless JWT operations |
| WopiProofValidator | Scoped | Holds per-request RSA key state after initialization |
API Endpoints
All endpoints are served under the office area. Base route: /office/wopi
WopiHost — Launch Office Online
GET /office/wopi/host/{fileId}?action={view|edit|editnew}Authentication: [Authorize] — requires an authenticated qbo4 session.
Purpose: The browser entry point. Resolves the file's Office Online action URL from discovery, generates a scoped JWT access token, and returns a self-submitting HTML form that redirects the browser into the Microsoft Office Online frame.
Query Parameters:
| Parameter | Default | Description |
|---|---|---|
| action | view | view, edit, or editnew |
Response: 200 OK with Content-Type: text/html containing a form that auto-POSTs to the Office Online client.
Flow:
- Validates
fileIdand loads the attachment (enforcing row-level security viaAttachmentObject.SelectAsync). - Generates a file-scoped JWT token (10-hour TTL).
- Queries the discovery service for the Office Online action URL for the file's extension.
- Substitutes locale placeholders (
<UI_LLCC>,<DC_LLCC>) withen-US. - Appends
WOPISrc(URL-encoded WOPI file endpoint) to the action URL. - Returns a minimal HTML page with a hidden-field form that auto-submits.
Error Responses:
| Status | Condition |
|---|---|
| 400 | fileId is null/empty, file has no extension, or action not supported for that extension |
| 404 | File not found |
| 500 | Unexpected error |
GetDocuments — List Files
GET /office/wopi/documentsAuthentication: [Authorize] + [HasPermission("AttachmentSearch")]
Purpose: Returns a JSON stream of AttachmentItem records visible to the current user. Used by the frontend document browser to populate a file list.
Response: 200 OK with application/json stream written directly to Response.Body.
CheckFileInfo — File Metadata
GET /office/wopi/files/{fileId}Authentication: Via access_token query parameter (validated by SafetyCheck).
Purpose: Called by Microsoft Office Online immediately after launch. Returns file properties and capability flags that control what Office Online can do with the document.
Response Body (JSON):
{
"BaseFileName": "Budget Q1.xlsx",
"OwnerId": "[email protected]",
"UserId": "42",
"UserFriendlyName": "Alice Smith",
"Size": 204800,
"SHA256": "base64-encoded-sha256-hash",
"LastModifiedTime": "2025-06-01T12:00:00.0000000Z",
"Version": "638500000000000000",
"FileExtension": ".xlsx",
"FileNameMaxLength": 250,
"SupportsLocks": true,
"SupportsUpdate": true,
"SupportsGetLock": true,
"SupportsExtendedLockLength": true,
"UserCanWrite": true,
"ReadOnly": false,
"SupportsFileCreation": true,
"SupportsRename": true,
"UserCanRename": false,
"SupportsDeleteFile": true,
"SupportsPutRelativeFile": true,
"SupportsContainers": false,
"BreadcrumbBrandName": "Quandis WOPI",
"BreadcrumbDocName": "Budget Q1.xlsx",
"BreadcrumbFolderName": "My Documents",
"HostViewUrl": "https://host/office/wopi/host/123?action=view",
"HostEditUrl": "https://host/office/wopi/host/123?action=edit",
"PostMessageOrigin": "https://host",
"UserCanNotWriteRelative": true
}Write Permission Logic: UserCanWrite is currently hardcoded to true pending full verification of the qbo4 identity/claims pipeline in this context. The intended logic is to parse currentUserId to long and compare it against attachment.OwnerID and attachment.UpdatedPersonID. See the TODO comment in WopiController.CheckFileInfoAsync for the full planned implementation.
Error Responses:
| Status | Condition |
|---|---|
| 401 | Invalid or expired access token |
| 404 | File not found or row-level security denied access |
| 500 | File hash calculation failed or document URI invalid |
GetFile — Download File
GET /office/wopi/files/{fileId}/contentsAuthentication: Via access_token query parameter.
Purpose: Streams the raw file bytes to Microsoft Office Online for rendering.
Response Headers:
| Header | Value |
|---|---|
| X-WOPI-ItemVersion | LastModified.Ticks — used by Office Online for versioning |
| Content-Type | application/octet-stream |
Behaviour:
- Respects
X-WOPI-MaxExpectedSizeheader: returns412 Precondition Failedif the file exceeds the specified size. - Uses
_manager.ToPipeStreamfor efficient streaming without buffering the entire file in memory. - Handles client disconnects (
OperationCanceledException) gracefully.
PutFile — Upload Edited File
POST /office/wopi/files/{fileId}/contentsAuthentication: Via access_token query parameter.
Purpose: Receives the edited document from Microsoft Office Online and persists it via AttachmentObject.WriteAsync / SaveAsync.
Lock Semantics:
| Scenario | Behaviour |
|---|---|
| No X-WOPI-Lock header, file is locked | Returns 409 with X-WOPI-Lock = current lock ID |
| No X-WOPI-Lock header, file is unlocked but non-empty | Returns 409 (lock required for non-empty files) |
| No X-WOPI-Lock header, file is empty (0 bytes) | Allowed — covers new file creation scenario |
| X-WOPI-Lock provided, matches current lock | Proceeds with write |
| X-WOPI-Lock provided, mismatch with current lock | Returns 409 with current lock ID |
Write Process:
- Deletes the existing file (prevents corruption on certain formats like
.xlsx). - Writes request body stream directly to storage via
attachment.WriteAsync(Request.Body). - Persists the database record via
attachment.SaveAsync(). - Sets
X-WOPI-ItemVersionresponse header to the new modification timestamp.
CreateFile — New Document
POST /office/wopi/createfileAuthentication: [Authorize]
Request Body (JSON):
{
"fileName": "Meeting Notes",
"extension": "docx"
}Purpose: Creates an empty document in the storage backend and returns the attachmentId that the frontend can use immediately as a fileId for WopiHost.
Response:
{ "attachmentId": 42 }Storage Path: Files are stored at /wopi/documents/{userId}/{fileName}.{extension} to prevent cross-user collisions.
Security: Path.GetFileName is applied to the caller-supplied name to strip path traversal attempts.
HandleWopiRequest — Lock Operations
POST /office/wopi/files/{id}
Header: X-WOPI-Override: LOCK | UNLOCK | GET_LOCK | REFRESH_LOCK | UNLOCK_AND_RELOCK | PUT_FILE | PUT_RELATIVEAuthentication: Via access_token query parameter.
Purpose: Dispatcher endpoint for WOPI protocol operations that use HTTP POST with the X-WOPI-Override header to differentiate actions.
| X-WOPI-Override | Action |
|---|---|
| LOCK | Acquires an exclusive lock on the file |
| UNLOCK | Releases the lock (must supply matching lock ID) |
| GET_LOCK | Returns the current lock ID in X-WOPI-Lock response header |
| REFRESH_LOCK | Extends an existing lock's TTL (delegates to LOCK) |
| UNLOCK_AND_RELOCK | Atomically replaces a lock ID (delegates to LOCK) |
| PUT_FILE | Alias for PutFile — writes file contents |
| PUT_RELATIVE | Not implemented — returns 501 |
Lock Conflict Response: 409 Conflict with headers:
X-WOPI-Lock: <current-lock-id>
X-WOPI-LockFailureReason: <human-readable reason>GetDiscoveryIcons — App Icons
GET /office/wopi/iconsAuthentication: None required.
Purpose: Returns the list of Office Online application icons from the discovery XML, used by the frontend document browser to display app-appropriate icons (Word, Excel, PowerPoint).
Response:
[
{ "name": "Word", "favIconUrl": "https://.../Word.ico" },
{ "name": "Excel", "favIconUrl": "https://.../Excel.ico" }
]GetActionsForExtension — Supported Actions
GET /office/wopi/actions/{extension}Authentication: None required.
Purpose: Returns the list of WOPI action names supported for a given file extension. The frontend uses this to conditionally render View/Edit buttons — for example, .csv files only support view.
Response:
["view", "edit"]Extension matching is case-insensitive and strips a leading dot if present.
Services
TokenService
Interface: ITokenService
Implementation: TokenService
Lifetime: Singleton
Generates and validates file-scoped JWT access tokens that are passed from WopiHost to Microsoft and back on every WOPI API call.
Token Generation
string token = tokenService.GenerateToken(claimsPrincipal, fileId, out long ttlMilliseconds);- Signs with HMAC-SHA512 using a 64-byte minimum key sourced from
qbo4.Security.DataProtection. - Embeds a
fileIdclaim so the token is scoped to a single document. - Embeds the user's session ID as a claim pointing to the
ITicketStorekey. - TTL is 600 minutes (10 hours), returned as an absolute epoch-millisecond timestamp (required by the WOPI specification).
Token Validation
ClaimsPrincipal principal = await tokenService.ValidateTokenAsync(token, fileId);- Verifies signature, expiry, and issuer.
- Security: Validates that the token's
fileIdclaim matches thefileIdin the request path. A token issued for file123cannot be used to access file456. - Retrieves the full
ClaimsPrincipalfrom theITicketStore(session store) so all user claims are available downstream.
WopiDiscoveryService
Interface: IWopiDiscoveryService
Implementation: WopiDiscoveryService
Lifetime: Singleton
Fetches and caches Microsoft's WOPI Discovery XML, which describes the capabilities and action URLs of the Office Online server.
Caching Strategy (3-tier)
Request
│
├─ L1: In-memory (_discoveryData field) ← fastest, per-process
│ hit → return immediately
│
├─ L2: IDistributedCache (Redis) ← shared across all instances
│ hit → deserialize, populate L1, return
│
└─ L3: HTTP fetch from DiscoveryUrl ← network call
success → parse XML, populate L1 + L2 (TTL = CacheExpiration)A double-check locking pattern (SemaphoreSlim) prevents multiple concurrent requests from all hitting L3 simultaneously on a cold start.
Key Methods
| Method | Description |
|---|---|
| GetDiscoveryDataAsync() | Returns the full parsed WopiDiscovery object |
| GetProofKeyDataAsync() | Returns the ProofKey containing RSA public key material |
| GetProofKeysAsync() | Returns the proof key bytes for signature verification |
| GetActionUrlAsync(extension, action) | Returns the Office Online URL for a specific file type and action (e.g., "docx", "edit") |
Extension Resolution
GetActionUrlAsync searches all net-zones and apps. Extension matching normalises the input (strips leading ., lowercases). The first matching action URL is returned.
WopiDiscoveryWorker
Type: BackgroundService
Lifetime: Hosted Service
Runs on a background thread to keep the discovery cache warm, preventing any user request from blocking on a cold network fetch.
Startup Behaviour: Waits 1 second after application start, then calls GetDiscoveryDataAsync().
Refresh Loop:
- On success: waits
RefreshInterval(default 12 hours) before the next refresh. - On failure: logs the exception and waits
RetryInterval(default 1 hour) before retrying.
WopiProofValidator
Interface: IWopiProofValidator
Implementation: WopiProofValidator
Lifetime: Scoped
Validates that incoming WOPI requests were signed by Microsoft, preventing request forgery.
Initialization
await proofValidator.InitializeAsync();Loads RSA public keys from the discovery service. Idempotent — subsequent calls are no-ops. Supports two key formats:
- Preferred:
Modulus+Exponent(Base64-encoded RSA parameters) - Fallback:
ValueCSP blob (legacy Microsoft format)
Both current and old (pre-rotation) keys are loaded to support key rotation scenarios.
Proof Construction
The expected proof bytes are constructed per the WOPI specification:
[4 bytes: access_token length (big-endian)]
[N bytes: access_token (UTF-8)]
[4 bytes: URL length (big-endian)]
[M bytes: URL uppercased (UTF-8)]
[8 bytes: timestamp (big-endian int64 ticks)]3-Check Verification Strategy
Microsoft can rotate its signing keys. To handle both the old and new key being in circulation simultaneously, three checks are performed in order:
| Check | Proof Header | Key Used | Handles |
|---|---|---|---|
| 1 | X-WOPI-Proof | Current key | Normal case |
| 2 | X-WOPI-ProofOld | Current key | Client signed with old key, server has new |
| 3 | X-WOPI-Proof | Old key | Server has old key, client has already rotated |
Validation passes if any one of the three checks succeeds.
Timestamp Validation
Requests are rejected if the X-WOPI-Timestamp is:
- More than 20 minutes in the past (replay attack prevention)
- More than 5 minutes in the future (clock skew tolerance)
Reverse Proxy Support
When a X-Forwarded-Proto header is present, the validator uses that scheme (typically https) instead of the raw request scheme when reconstructing the signed URL. This ensures validation succeeds behind load balancers and API gateways.
RedisLockManager
Interface: ILockManager
Implementation: RedisLockManager
Lifetime: Singleton
Provides distributed, atomic file locking using Redis. Prevents data corruption when multiple clients attempt to edit the same document concurrently.
Lock TTL
Locks expire after 30 minutes (WOPI standard). Office Online refreshes locks periodically via REFRESH_LOCK while a document is open.
Atomic Operations
All lock state changes use Lua scripts executed atomically by Redis, preventing race conditions between the read and write phases of a lock check.
API
| Method | Description |
|---|---|
| AcquireLock(fileId, lockId, oldLockId) | Acquires a new lock or refreshes an existing one. If oldLockId is provided, swaps the lock (UNLOCK_AND_RELOCK). |
| ReleaseLock(fileId, lockId) | Releases the lock only if the provided lockId matches the stored lock. |
| RefreshLock(fileId, lockId) | Extends lock TTL — delegates to AcquireLock. |
| GetLock(fileId) | Returns the current lock ID, or null if unlocked. |
| LockExists(fileId) | Returns true if a lock is currently held. |
Conflict Responses
On a lock conflict, methods return the current lock ID so that the WOPI client can include it in the X-WOPI-Lock response header. Office Online uses this to coordinate with other sessions.
Redis connection failures are handled gracefully: GetLock returns null on failure rather than throwing; AcquireLock propagates the error with an HTTP 500 indication.
Security Model
This library implements a defence-in-depth approach:
1. WOPI Proof Validation (Request Authentication)
Every request from Microsoft Office Online carries X-WOPI-Proof and X-WOPI-ProofOld headers containing an RSA-SHA256 signature over the access token, request URL, and timestamp. These are verified against Microsoft's public keys obtained from the discovery endpoint. All WOPI API endpoints call SafetyCheck() before processing.
2. File-Scoped JWT Tokens (User Authentication)
Access tokens are JWTs signed with HMAC-SHA512. Each token carries a fileId claim, meaning a token obtained for one file cannot be used to access a different file — even if the signature is valid.
3. Row-Level Security (Authorisation)
All file operations call AttachmentObject.SelectAsync(id), which enforces the qbo4 row-level security model. If the authenticated user lacks read access to the attachment record, PathURL will be empty and the request returns 404.
4. Write Permission Checks
UserCanWrite (exposed in CheckFileInfo) is intended to be derived by comparing the numeric user ID from the token principal against attachment.OwnerID and attachment.UpdatedPersonID. This check is currently set to true unconditionally while the qbo4 identity pipeline (specifically what value ClaimTypes.NameIdentifier carries relative to the numeric person IDs stored on AttachmentObject) is being fully verified. See the TODO in WopiController.CheckFileInfoAsync.
5. Distributed Exclusive Locking
Only the session holding the current lock ID can write to a file. Conflicting write attempts receive 409 Conflict with the blocking lock ID, allowing Office Online to serialise edits.
6. Timestamp Replay Prevention
WOPI proof timestamps are validated within a ±20-minute / +5-minute window. Requests older than 20 minutes are rejected.
7. XXE Attack Prevention
The WopiDiscoveryParser disables DTD processing when parsing the discovery XML (XmlReaderSettings.DtdProcessing = DtdProcessing.Prohibit), preventing XML External Entity injection attacks.
8. Path Traversal Prevention
CreateFile applies Path.GetFileName to caller-supplied file names before constructing storage paths.
Lock Management
The WOPI lock protocol is stateful and closely tied to the Office Online editing session:
Client opens document
│
├─ LOCK (acquires exclusive lock for this session)
│
│ [User edits — Office Online sends REFRESH_LOCK every ~10 minutes]
│
├─ PUTFILE (writes changes — must supply matching lock ID)
│
└─ UNLOCK (releases lock when session ends)UNLOCK_AND_RELOCK is used when Office Online needs to change the lock ID (e.g., after a co-authoring conflict resolution). It atomically releases the old lock and acquires a new one.
Key rotation during a session: If Office Online rotates its signing key mid-session, X-WOPI-OldLock carries the previous lock ID alongside X-WOPI-Lock to allow an atomic swap.
WOPI Discovery
Microsoft publishes a discovery XML document that describes the Office Online server's capabilities. Example structure:
<wopi-discovery>
<net-zone name="external-https">
<app name="Word" favIconUrl="https://.../Word.ico">
<action name="view" ext="docx" urlsrc="https://word-view.officeapps.live.com/..."/>
<action name="edit" ext="docx" urlsrc="https://word-edit.officeapps.live.com/..."/>
</app>
<app name="Excel" favIconUrl="https://.../Excel.ico">
<action name="view" ext="xlsx" urlsrc="..."/>
<action name="edit" ext="xlsx" urlsrc="..."/>
<action name="view" ext="csv" urlsrc="..."/>
</app>
</net-zone>
<proof-key modulus="..." exponent="..." oldmodulus="..." oldexponent="..."/>
</wopi-discovery>The WopiDiscoveryParser converts this XML into strongly-typed models. The URL source template contains placeholders such as <UI_LLCC> (locale) and <DC_LLCC> (data-centre locale) which are substituted at launch time.
Proof Key Validation
The proof validation algorithm follows the Microsoft WOPI proof key specification:
Expected proof bytes =
[int32 BE: len(access_token)] + access_token
+ [int32 BE: len(UPPER(url))] + UPPER(url)
+ [int32 BE: 8] + [int64 BE: timestamp_ticks]
Valid if: RSA.VerifyData(expectedProofBytes, SHA256, Base64Decode(X-WOPI-Proof))
using one of the current or old public keysData Models
WopiDiscovery
Root model for the discovery XML. Contains a list of WopiNetZone objects.
WopiNetZone
Represents a network zone (e.g., external-https, internal-https). Contains a list of WopiApp objects.
WopiApp
Represents an Office application (Word, Excel, PowerPoint). Properties: Name, FavIconUrl, Actions.
WopiAction
Describes a single supported action for a file type. Properties:
| Property | Description |
|---|---|
| Name | Action name: view, edit, editnew |
| Ext | File extension (without dot): docx, xlsx, pptx |
| UrlSrc | Office Online URL template |
| CheckLicense | Whether a license check is required |
ProofKey
RSA public key material from the discovery XML.
| Property | Description |
|---|---|
| Modulus | Base64-encoded RSA modulus (current key) |
| Exponent | Base64-encoded RSA exponent (current key) |
| OldModulus | Base64-encoded RSA modulus (previous key) |
| OldExponent | Base64-encoded RSA exponent (previous key) |
| Value | Legacy CSP blob (current key) |
| OldValue | Legacy CSP blob (previous key) |
Swagger / OpenAPI
When Office:Swagger:IncludeSwagger is true, the WOPI endpoints are exposed in the Swagger UI under the office API group via SwaggerInfoProvider.
When Office:Swagger:IncludeEmbeddedDocumentation is also true, this readme.md file (compiled as an embedded resource) is served as Markdown documentation alongside the API spec via ReadMeDocumentationProvider.
Local Development with NGrok
Microsoft's Office Online servers run in the public cloud and must be able to reach your WOPI host over HTTPS. During local development, your machine is not directly reachable from the internet, so a secure tunnel is required. NGrok is the recommended tool for this.
Why NGrok is Required
Browser (localhost)
│
│ GET /office/wopi/host/{fileId}
▼
qbo4 WOPI Host (localhost:57704)
│
│ Returns HTML form targeting Office Online + WOPISrc=https://wopi.quandis.net/...
▼
Microsoft Office Online (public cloud)
│
│ CheckFileInfo / GetFile / PutFile ──► NGrok tunnel ──► localhost:57704
▼
(renders document in browser iframe)Without a public HTTPS URL, the WOPISrc parameter embedded in WopiHost's response would point to localhost, which Microsoft's servers cannot reach. NGrok creates a stable public HTTPS endpoint that forwards all traffic to your local process.
Step-by-Step Setup
1. Start Redis
Redis is required for distributed lock management. Use Docker to run a local instance:
# Create and start the Redis container
docker run --name wopi-redis -p 6379:6379 -d redis
# If the container already exists, just start it
docker start wopi-redisVerify Redis is running:
docker ps --filter name=wopi-redisEnsure your appsettings.Development.json (or appsettings.json) contains:
{
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}Note: If
ConnectionStrings:Redisis absent, the service defaults tolocalhost:6379rather than failing at startup during development. This fallback is intentional for local environments — production deployments should always supply the connection string explicitly.
2. Configure Your Application Port
Check launchSettings.json for the HTTPS port your application listens on:
{
"profiles": {
"YourAppName": {
"applicationUrl": "https://localhost:57704;http://localhost:57706"
}
}
}Take note of the HTTPS port (e.g., 57704). NGrok must tunnel to this exact port.
3. Install and Start NGrok
Download NGrok from ngrok.com and place the executable somewhere on your PATH (e.g., C:\Tools\ngrok.exe).
The Quandis development environment uses a reserved static domain (wopi.quandis.net) so the WOPISrc URL is stable across restarts. Start the tunnel with:
ngrok http --url=wopi.quandis.net https://localhost:57704Replace 57704 with whatever HTTPS port your app is configured to use. Once running, the NGrok console will confirm the tunnel is active:
Forwarding https://wopi.quandis.net -> https://localhost:57704Important: All WOPI callbacks from Microsoft will arrive at
https://wopi.quandis.net/office/wopi/files/{fileId}. TheWopiProofValidatorreconstructs the full request URL for signature verification — if it differs from what Microsoft signed, proof validation will fail. Keep the tunnel running for the entire development session.
4. Configure the Discovery URL
Set WopiDiscovery:DiscoveryUrl in appsettings.Development.json to the Microsoft Office Online discovery endpoint:
{
"WopiDiscovery": {
"DiscoveryUrl": "https://oneshell.public.one.microsoft.com/hosting/discovery"
}
}5. Start the Application
dotnet run --project src/qbo4.Document.MicrosoftWopiNavigate to the document list at https://localhost:57704/office/wopi/documents, click a document, and Office Online should open in-browser through the NGrok tunnel.
Reverse Proxy / HTTPS Headers
When running behind NGrok (or any reverse proxy), the raw Request.Scheme seen by ASP.NET Core will be http because the TLS is terminated at the proxy. The WopiProofValidator already handles this:
- If the request contains an
X-Forwarded-Proto: httpsheader, that scheme is used when reconstructing the signed URL. - NGrok sets this header automatically.
- No additional ASP.NET Core
UseForwardedHeadersmiddleware is required for proof validation specifically, but you should configure it for other middleware that relies on the scheme (e.g., redirect generation).
Troubleshooting NGrok / WOPI Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Proof validation fails (500 on CheckFileInfo) | NGrok tunnel not running or wrong port | Ensure ngrok http --url=... https://localhost:{port} matches launchSettings.json |
| 400 Missing required WOPI headers | Request did not come from Office Online (e.g., direct browser hit) | Expected — SafetyCheck only passes on requests signed by Microsoft |
| Discovery data not available on startup | WopiDiscovery:DiscoveryUrl missing or unreachable | Check the URL is reachable from your machine; inspect WopiDiscoveryWorker logs |
| Office Online shows "Something went wrong" | Timestamp drift or mismatched URL in proof bytes | Sync system clock; confirm NGrok static domain matches WOPISrc |
| 409 Conflict on PutFile | Stale lock in Redis from a previous crashed session | Flush the specific key: redis-cli DEL wopi:lock:{fileId} |
| Redis connection error at startup | Redis not running | docker start wopi-redis |
Running Tests
The test project (qbo4.Document.MicrosoftWopi.Tests) requires:
- SQL Server LocalDB (for integration tests via
Fixture.cs) - A Redis instance (for
RedisLockManagerFacts)
dotnet test tests/qbo4.Document.MicrosoftWopi.Tests/Test Coverage Summary
| Test Class | Area Covered |
|---|---|
| TokenServiceFacts | JWT generation, TTL calculation, fileId scope enforcement, token tampering, expiry, key rotation |
| WopiDiscoveryServiceFacts | 3-tier cache hierarchy, network fetch, XML parsing, proof key extraction, action URL lookup |
| RedisLockManagerFacts | Acquire, refresh, swap, release, conflict detection, Redis failure handling |
| WopiProofValidatorFacts | RSA key loading (modulus/exponent + CSP blob), 3-check verification, timestamp bounds, reverse proxy headers |
| WopiDiscoveryParserTests | XML parsing for single/multiple zones and apps, edge cases |
| WopiControllerFacts | End-to-end controller behaviour |
Error Handling Reference
| Status Code | Meaning |
|---|---|
| 200 OK | Operation succeeded |
| 204 No Content | Client disconnected during streaming (not an error) |
| 400 Bad Request | Missing required header, invalid file ID format, or unsupported action |
| 401 Unauthorized | Invalid, expired, or file-mismatched access token |
| 404 Not Found | File does not exist or access denied by row-level security |
| 409 Conflict | Lock conflict — X-WOPI-Lock header contains the current lock ID |
| 412 Precondition Failed | File exceeds X-WOPI-MaxExpectedSize |
| 500 Internal Server Error | Unexpected error; check application logs |
| 501 Not Implemented | PUT_RELATIVE operation |
