Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,31 @@ Additional example (rating boards):
- CSS: use CSS variables (`--warning-color`, `--valid-color`, `--invalid-color`) and semantic class names instead of inline styles.
- Frontend API: use `postData()` function for authenticated requests instead of direct `fetch()` - handles CSRF tokens and authentication cookies automatically.

## App-linking (OAuth-style client authorisation)
- Feature: client applications can request a per-user API key by opening a popup using their `X-Client-API-Key`. The user confirms, and the returned key is accepted by all `[ApiKey()]`-protected endpoints.
- DB table: `UserAppKeys` (`Id`, `UserId` → `Users`, `DataObjectId` → `DataObject` (App type), `APIKey`, `Created`, `LastUsed`, `Revoked`). Added in migration `hasheous-1034.sql`.
- API validation: `APIKeyMiddleware.GetUserFromApiKey` falls through from `UserAPIKeys` to `UserAppKeys` (non-revoked). Use `ApiKey.PurgeApiKeyCache(rawKey)` after revoking to invalidate Redis caches immediately.
- Backend endpoints:
- `GET /api/v1/AppLink/AppInfo?clientApiKey=xxx` (anonymous) – resolves a client key to its App DataObject and returns `{ dataObjectId, name, logoUrl }`.
- `POST /api/v1/AppLink/Authorize` (requires `[Authorize]`) – body `{ clientApiKey }` – upserts a `UserAppKeys` row and returns the raw API key.
- `GET /api/v1/Account/AppLinks` (requires `[Authorize]`) – lists the current user's linked apps.
- `DELETE /api/v1/Account/AppLinks/{id}` (requires `[Authorize]`) – revokes a specific link.
- Frontend popup: `hasheous/wwwroot/pages/link-app.html` + `link-app.js`. Open with:
```js
const popup = window.open(
'/pages/link-app.html?clientApiKey=YOUR_KEY&targetOrigin=' + encodeURIComponent(window.location.origin),
'hasheousLink', 'width=480,height=640'
);
window.addEventListener('message', (e) => {
if (e.data?.type === 'hasheous-link') {
if (e.data.cancelled) { /* user cancelled */ }
else { const apiKey = e.data.hasheousApiKey; /* store and use as X-API-Key */ }
}
});
```
- Account page: `account.html`/`account.js` now renders a "Linked Applications" section with Revoke buttons (`DELETE /api/v1/Account/AppLinks/{id}`).
- Cache prefix affected: `ApiKeys` (same as user API keys – shared namespace; entries are per raw key string).

## async guidance
- Prefer Task-returning actions: `public async Task<IActionResult> Action(...)`.
- Await DB calls (`ExecuteCMDAsync`/`ExecuteCMDDictAsync`) and long-running operations.
Expand Down
52 changes: 50 additions & 2 deletions hasheous-lib/Classes/Auth/Classes/APIKeyMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,10 @@ public string SetApiKey(string userId)
}
}

// If not cached, fetch from database
// If not cached, fetch from database — check UserAPIKeys first, then UserAppKeys
Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = "SELECT * FROM UserAPIKeys WHERE `Key` = @apikey";

string sql = "SELECT `UserId` FROM UserAPIKeys WHERE `Key` = @apikey";
DataTable data = db.ExecuteCMD(sql, new Dictionary<string, object>{
{ "apikey", apiKey }
});
Expand All @@ -167,6 +168,38 @@ public string SetApiKey(string userId)
UserStore userStore = new UserStore(db);
user = await userStore.FindByIdAsync(userId, default);
}
else
{
// Fall through to app-linked keys
string appKeySql = "SELECT `UserId` FROM UserAppKeys WHERE `APIKey` = @apikey AND `Revoked` = 0";
DataTable appKeyData = db.ExecuteCMD(appKeySql, new Dictionary<string, object>{
{ "apikey", apiKey }
});

if (appKeyData.Rows.Count > 0)
{
string userId = appKeyData.Rows[0]["UserId"].ToString();
UserStore userStore = new UserStore(db);
user = await userStore.FindByIdAsync(userId, default);

if (user != null)
{
// Update LastUsed timestamp asynchronously (fire-and-forget, non-critical)
_ = Task.Run(() =>
{
try
{
Database updateDb = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
updateDb.ExecuteNonQuery(
"UPDATE UserAppKeys SET `LastUsed` = @lastused WHERE `APIKey` = @apikey",
new Dictionary<string, object> { { "lastused", DateTime.UtcNow }, { "apikey", apiKey } }
);
}
catch { /* best-effort */ }
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
});
}
}
}

// Cache the user
string serializedUser = Newtonsoft.Json.JsonConvert.SerializeObject(user);
Expand Down Expand Up @@ -194,5 +227,20 @@ private string GenerateApiKey()

return base64String[..keyLength];
}

/// <summary>
/// Purges the cached API key entry for a given raw key string.
/// Call this after revoking an app-linked key so the cache does not serve the revoked key.
/// In-memory cache entries expire within the configured cache duration (2 hours).
/// </summary>
public static void PurgeApiKeyCache(string apiKey)
{
if (Config.RedisConfiguration.Enabled)
{
string cacheKey = ApiKeyCacheNamePrefix + ":" + apiKey;
hasheous.Classes.RedisConnection.GetDatabase(0).KeyDelete(cacheKey);
}
// In-memory cache entries will expire naturally after CacheDuration seconds
}
}
}
16 changes: 16 additions & 0 deletions hasheous-lib/Schema/hasheous-1034.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE `UserAppKeys` (
`Id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`UserId` VARCHAR(128) NOT NULL,
`DataObjectId` BIGINT(20) NOT NULL,
`APIKey` VARCHAR(128) NOT NULL,
`Created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`LastUsed` DATETIME NULL,
`Revoked` BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (`Id`),
UNIQUE KEY `APIKey` (`APIKey`),
UNIQUE KEY `UserApp` (`UserId`, `DataObjectId`),
INDEX `UserId` (`UserId`),
INDEX `DataObjectId` (`DataObjectId`),
CONSTRAINT `UserAppKeys_ibfk_1` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT `UserAppKeys_ibfk_2` FOREIGN KEY (`DataObjectId`) REFERENCES `DataObject` (`Id`) ON DELETE CASCADE ON UPDATE CASCADE
);
102 changes: 102 additions & 0 deletions hasheous/Controllers/V1.0/AccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Authentication;
using Classes;
using hasheous_server.Classes;
using hasheous_server.Models;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
Expand Down Expand Up @@ -735,5 +736,106 @@ public async Task<IActionResult> GetLinkedLogins()

return Ok(linkedLogins);
}

/// <summary>
/// Returns all app links (authorised client applications) for the current user.
/// </summary>
[HttpGet]
[Authorize]
[Route("AppLinks")]
public async Task<IActionResult> GetAppLinks()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Unauthorized();

Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);
string sql = @"
SELECT uak.`Id`, uak.`DataObjectId`, uak.`Created`, uak.`LastUsed`, uak.`Revoked`,
dobj.`Name` AS AppName
FROM UserAppKeys uak
JOIN DataObject dobj ON dobj.`Id` = uak.`DataObjectId`
WHERE uak.`UserId` = @userid
ORDER BY dobj.`Name`";

DataTable data = await db.ExecuteCMDAsync(sql, new Dictionary<string, object>
{
{ "userid", user.Id }
});

var result = new List<object>();
foreach (DataRow row in data.Rows)
{
long dataObjectId = (long)row["DataObjectId"];

// Resolve logo URL from DataObject attributes
string? logoUrl = null;
string attrSql = "SELECT `AttributeValue` FROM DataObject_Attributes WHERE `DataObjectId` = @id AND `AttributeName` = @attrname LIMIT 1";
DataTable attrData = await db.ExecuteCMDAsync(attrSql, new Dictionary<string, object>
{
{ "id", dataObjectId },
{ "attrname", (int)AttributeItem.AttributeName.Logo }
});
if (attrData.Rows.Count > 0 && attrData.Rows[0]["AttributeValue"] != DBNull.Value)
{
string logoValue = attrData.Rows[0]["AttributeValue"].ToString()!;
if (!string.IsNullOrEmpty(logoValue))
{
logoUrl = $"/api/v1/Images/{logoValue}";
}
}

result.Add(new
{
Id = (long)row["Id"],
DataObjectId = dataObjectId,
AppName = row["AppName"].ToString(),
LogoUrl = logoUrl,
Created = (DateTime)row["Created"],
LastUsed = row["LastUsed"] == DBNull.Value ? (DateTime?)null : (DateTime)row["LastUsed"],
Revoked = (bool)row["Revoked"]
});
}

return Ok(result);
}

/// <summary>
/// Revokes an app link by ID. Only the owner of the link may revoke it.
/// </summary>
/// <param name="id">The ID of the UserAppKeys row to revoke.</param>
[HttpDelete]
[Authorize]
[Route("AppLinks/{id}")]
public async Task<IActionResult> RevokeAppLink(long id)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Unauthorized();

Database db = new Database(Database.databaseType.MySql, Config.DatabaseConfiguration.ConnectionString);

// Fetch the row to confirm ownership and get the raw API key for cache purge
string selectSql = "SELECT `APIKey`, `UserId` FROM UserAppKeys WHERE `Id` = @id";
DataTable data = await db.ExecuteCMDAsync(selectSql, new Dictionary<string, object> { { "id", id } });

if (data.Rows.Count == 0)
return NotFound();

if (data.Rows[0]["UserId"].ToString() != user.Id)
return Forbid();

string apiKey = data.Rows[0]["APIKey"].ToString()!;

db.ExecuteNonQuery(
"UPDATE UserAppKeys SET `Revoked` = 1 WHERE `Id` = @id",
new Dictionary<string, object> { { "id", id } }
);

// Purge from cache so the revoked key is not accepted until the cache expires
ApiKey.PurgeApiKeyCache(apiKey);

return Ok();
}
}
}
Loading
Loading