Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.mysql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ services:
# and the value of "workspaceFolder" in .devcontainer/devcontainer.json
- ..:/var/www/html
- hashtopolis-server-dev:/usr/local/share/hashtopolis:Z
# - ./jwks.json:/keys/jwks.json:ro
networks:
- hashtopolis_dev
hashtopolis-db-dev:
Expand Down
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ services:
# and the value of "workspaceFolder" in .devcontainer/devcontainer.json
- ..:/var/www/html
- hashtopolis-server-dev:/usr/local/share/hashtopolis:Z
# - ./jwks.json:/keys/jwks.json:ro
networks:
- hashtopolis_dev
hashtopolis-db-dev:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ src/files/*
*.phpproj.user
*.lock*

# the public keys for oauth
jwks.json

# dynamically created by installer
src/install/.htaccess

Expand Down
1 change: 1 addition & 0 deletions docker-compose.mysql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
volumes:
- hashtopolis:/usr/local/share/hashtopolis:Z
# - ./ssmtp.conf:/etc/ssmtp/ssmtp.conf
# - ./jwks.json:/keys/jwks.json:ro
environment:
HASHTOPOLIS_DB_TYPE: mysql
HASHTOPOLIS_DB_USER: $MYSQL_USER
Expand Down
1 change: 1 addition & 0 deletions docker-compose.postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
volumes:
- hashtopolis:/usr/local/share/hashtopolis:Z
# - ./ssmtp.conf:/etc/ssmtp/ssmtp.conf
# - ./jwks.json:/keys/jwks.json:ro
environment:
HASHTOPOLIS_DB_TYPE: postgres
HASHTOPOLIS_DB_USER: $POSTGRES_USER
Expand Down
31 changes: 31 additions & 0 deletions jwks.json.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Example jwks file for the keys that are needed for Open ID Connect
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file contains a comment on line 1 starting with '#', but JSON does not support comments. This will cause the JSON to be invalid. If comments are needed, this should be a separate documentation file, or the comment should be removed.

Copilot uses AI. Check for mistakes.
{
"keys": [
{
"kid": "3VcAf_wFO6KQz4RiKowja6IW35QJ40RkXSBgkgcfTfw",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "qvuxqeloNtBwAwIOlfu48bd9-VnELl2D0DdGfUGKh_0_5gFgbXiGytGG11a_IC6qqlmmIWU4xuy-2Q2uytrQAkrZMTPmsT88ZrT84HCMUlxgqU5QWUPRGmlwGDuuPNXyeYDPbEtX9du8PQb6DNuu2kWMLmm_xjYwQzIzMPxR49xsR9h0N-wMHwc-fmSgkR02Io96I1NkQX3DHCuVvPFBp5cUhfb5lXwHGe1cdx3D4koA0y0NJ1EsOjfuMDv4AUtZFqqUKDEbg-ADoYA4HtfHOjcciMYXkEbb5FejlVCsppF_HMWuFtNqF6_V0FOfKvmNnJ0WzUD9NR6BJ_VqDxGNGQ",
"e": "AQAB",
"x5c": [
"MIICmzCCAYMCBgGbjmGlkjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMTA1MTMzNzAyWhcNMzYwMTA1MTMzODQyWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq+7Gp6Wg20HADAg6V+7jxt335WcQuXYPQN0Z9QYqH/T/mAWBteIbK0YbXVr8gLqqqWaYhZTjG7L7ZDa7K2tACStkxM+axPzxmtPzgcIxSXGCpTlBZQ9EaaXAYO6481fJ5gM9sS1f127w9BvoM267aRYwuab/GNjBDMjMw/FHj3GxH2HQ37AwfBz5+ZKCRHTYij3ojU2RBfcMcK5W88UGnlxSF9vmVfAcZ7Vx3HcPiSgDTLQ0nUSw6N+4wO/gBS1kWqpQoMRuD4AOhgDge18c6NxyIxheQRtvkV6OVUKymkX8cxa4W02oXr9XQU58q+Y2cnRbNQP01HoEn9WoPEY0ZAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABLaxd6HGIbKKd3hU56XbQHMcwbFTpPtL/P56JBUhcCVG1sCoLSE4csVW0o9Si1toKqh8hAtWDYA0/tm/IEjkwt3TcXdWjA/XoKGDbkz508aLDM2ni2CXq1p3wXwesCiGhkblg+liiNPyb2wU9RpmYRKkn16Qsb2qEw6AS1uaph0/+XLAPWENr5/pjJOgXQqok2VIOiAcrsnayE6zPDFQ2d2uYAKLOKNFgUKZ7K92DGH+qD8IheV8F6Wjs7cea7LZcgq0dlV85lmIQ8dZyQunL2QwGewIGVSShvT97vivk/xS86Zf9qQcaANAuvff/g1lCdxg2bFr8PewOXp3zQlqdU="
],
"x5t": "e0BNs1RTbxcrnk9Rnjs1n4pxcuY",
"x5t#S256": "wCTM8mdXANWTaYDj3w5cRm0yD5ybINNlULtxpAuA7gw"
},
{
"kid": "UzDpZMBnNvfqtOmhny4gqZdjQLGWYuy2gAN3Wk_hm4Y",
"kty": "RSA",
"alg": "RSA-OAEP",
"use": "enc",
"n": "xGHOeXyy6B8_V1BsELJb5XWHfWJHS4w45oxvYbT--Dl6miixwrRizOCnpGz81JIPJZ_Dg-qGi372tQo10xegeg71h7GRMeCcGDA7QN7PXSLnwphkBQvV_uBzYnxDm98ZRLsBmyMnLRCEPdVxJX1_nxaqCk3-KbZxLVuEJM-AAMPlA0TcC5ZIB8pSWeYA_DhGswRb_t67GMEyXHKzNAvA_Bhc7E-FZ-C66WLH5e0bv47W2KSzCtJZNpRHGb-CdeJYzg7rR4M9PzGnCmtc1YEocWsgqHJ0lDO3Sl1g4XtIW84UPi2wBEvoBgQuT2UPhni9TqVd62yJ1F8F_MC4ZEIgpw",
"e": "AQAB",
"x5c": [
"MIICmzCCAYMCBgGbjmGl7DANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjYwMTA1MTMzNzAyWhcNMzYwMTA1MTMzODQyWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEYc55fLLoHz9XUGwQslvldYd9YkdLjDjmjG9htP74OXqaKLHCtGLM4KekbPzUkg8ln8OD6oaLfva1CjXTF6B6DvWHsZEx4JwYMDtA3s9dIufCmGQFC9X+4HNifEOb3xlEuwGbIyctEIQ91XElfX+fFqoKTf4ptnEtW4Qkz4AAw+UDRNwLlkgHylJZ5gD8OEazBFv+3rsYwTJccrM0C8D8GFzsT4Vn4LrpYsfl7Ru/jtbYpLMK0lk2lEcZv4J14ljODutHgz0/MacKa1zVgShxayCocnSUM7dKXWDhe0hbzhQ+LbAES+gGBC5PZQ+GeL1OpV3rbInUXwX8wLhkQiCnAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAMLa57gi0EgnVStdw6JGbjOYURhto8Gfahs/STDrpkmkMuvAh0bRzwi8yiJs75jX7ykBFNrAslsdnohMicXyjrHaMNbMwPeip9/XMISS7kR5sDqyz1AA+s28oyWAB9HWu5ntiD93LlJj+UU4qZ5+SpmKzRDs2MMhL8aWozuOwABGI4VrfvFRJL8O3J6ewxUeCikpEfB9UWkE+B+N/q8Wsn92n76z8UhqsdLOJVp1LwIuwOIcK9oCFZnSwfiGXSfK4e2QfxF6hWVAEdkQaKXsNZmxrqWE9CxdQp6ouOGaqiplzWUBDuWptNoaM57tLNo0jl0d6C1XPFUlzO9TfQHilEU="
],
"x5t": "HlsH_q2fqXiyZrxi4iWlbXoO51w",
"x5t#S256": "ByDXBjBIXVPzmIEts-GeqhlxhMQL1S2tM-8npsv2-jo"
}
]
}
4 changes: 2 additions & 2 deletions src/api/v2/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public function get($key): string {
include(dirname(__FILE__) . '/../../inc/confv2.php');

$decoder = new FirebaseDecoder(
new Secret($PEPPER[0], 'HS256')
new Secret($PEPPER[0], 'HS256', hash("sha256", $PEPPER[0]))
Comment on lines 145 to +146
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Secret initialization with three parameters (key, algorithm, kid) is added here but the corresponding change should ensure that the JWT verification process can properly match tokens using the kid (key ID) claim. The implementation should verify that tokens can be validated using the kid field to ensure backward compatibility with existing tokens that may not have this field.

Copilot uses AI. Check for mistakes.
);

$options = new Options(
Expand All @@ -153,7 +153,7 @@ public function get($key): string {
);

$rules = [
new RequestPathRule(ignore: ["/api/v2/auth/token", "/api/v2/helper/resetUserPassword", "/api/v2/openapi.json"]),
new RequestPathRule(ignore: ["/api/v2/auth/token", "/api/v2/auth/oauth-token", "/api/v2/helper/resetUserPassword", "/api/v2/openapi.json"]),
new RequestMethodRule(ignore: ["OPTIONS"])
];
return new JwtAuthentication($options, $decoder, $rules);
Expand Down
148 changes: 107 additions & 41 deletions src/inc/apiv2/auth/token.routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,60 +10,124 @@
use DBA\QueryFilter;
use DBA\User;
use DBA\Factory;
use Firebase\JWT\JWK;

require_once(dirname(__FILE__) . "/../../load.php");

function generateTokenForUser(Request $request, string $userName, int $expires) {
include(dirname(__FILE__) . '/../../confv2.php');
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code includes the configuration file on every call to generateTokenForUser(). This is inefficient as the configuration should ideally be loaded once and passed as a parameter or accessed from a singleton. The repeated include() on line 18 will be executed on every token generation request.

Copilot uses AI. Check for mistakes.
$jti = bin2hex(random_bytes(16));

$requested_scopes = $request->getParsedBody() ?: ["todo.all"];

$valid_scopes = [
"todo.create",
"todo.read",
"todo.update",
"todo.delete",
"todo.list",
"todo.all"
];

$scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) {
return in_array($needle, $valid_scopes);
});
// FIXME: This is duplicated and should be passed by HttpBasicMiddleware
$filter = new QueryFilter(User::USERNAME, $userName, "=");
$check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]);
$user = $check[0];
Comment on lines +36 to +38
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array access on line 38 uses direct indexing without checking if the array is empty or contains elements. If no user is found, this will cause an "Undefined array key 0" error before the empty check on line 40. The code should check the array count or use a safer access pattern.

Copilot uses AI. Check for mistakes.

if (empty($user)) {
throw new HttpError("No user with this userName in the database");
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message contains a grammatical error. "No user with this userName" would be clearer as "No user with this username" or "User not found with username".

Copilot uses AI. Check for mistakes.
}

$secret = $PEPPER[0];
$now = new DateTime();

$payload = [
"iat" => $now->getTimeStamp(),
"exp" => $expires,
"jti" => $jti,
"userId" => $user->getId(),
"scope" => $scopes,
"iss" => "Hashtopolis",
"kid" => hash("sha256", $secret)
];

$token = JWT::encode($payload, $secret, "HS256");

return $token;
}

function extractBearerToken(Request $request): ?string {
$header = $request->getHeaderLine('Authorization');

if (!$header) {
return null;
}

if (!preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) {
return null;
}

return trim($matches[1]);
}

// Exchanges an oauth token for a application JWT token
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describes the function as exchanging an "oauth token" but the proper capitalization should be "OAuth token" when referring to the OAuth protocol.

Copilot uses AI. Check for mistakes.
$app->group("/api/v2/auth/oauth-token", function (RouteCollectorProxy $group) {

$group->post('', function (Request $request, Response $response, array $args): Response {
$jwks_file = file_get_contents("/keys/jwks.json");
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded file path "/keys/jwks.json" is not configurable and may not work in all deployment environments. This path should be configurable through environment variables or configuration files to accommodate different deployment scenarios.

Copilot uses AI. Check for mistakes.
if (!$jwks_file) {
throw new HttpError("No jwks.json found, upload the jwks public keys to /keys/jwks.json to use OIDC authentication");
Comment on lines +80 to +82
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file_get_contents() function can fail and return false for various reasons beyond the file not existing, such as permission issues or I/O errors. The error message should be more generic to cover all failure scenarios, not just missing files.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message contains a spelling error: "hte" should be "the".

Copilot uses AI. Check for mistakes.
}
$jwks = json_decode($jwks_file, true);
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for json_decode result. The code doesn't check if json_decode returned null or if the decoded JSON has the expected structure before passing it to JWK::parseKeySet. This could cause errors if the jwks.json file contains invalid JSON or an unexpected structure. Add validation to ensure the JSON is properly decoded and has the expected 'keys' structure.

Suggested change
$jwks = json_decode($jwks_file, true);
$jwks = json_decode($jwks_file, true);
if ($jwks === null && json_last_error() !== JSON_ERROR_NONE) {
throw new HttpError("Invalid jwks.json: JSON decoding failed (" . json_last_error_msg() . ")");
}
if (!is_array($jwks) || !isset($jwks['keys']) || !is_array($jwks['keys']) || empty($jwks['keys'])) {
throw new HttpError("Invalid jwks.json format: expected a 'keys' array");
}

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The json_decode() call does not check if the decoding was successful. If the jwks.json file contains invalid JSON, this will result in $jwks being null, which will then cause an error when passed to JWK::parseKeySet(). The code should check json_last_error() to handle malformed JSON gracefully.

Copilot uses AI. Check for mistakes.

if ($jwks === null) {
throw new HttpError("Incorrect json inside jwks.json, make sure to upload a valid json file");
}
$keys = JWK::parseKeySet($jwks);
$jwt = extractBearerToken($request);
if ($jwt === null) {
throw new HttpError("No jwt Token found in the Bearer header");
}
$decoded_jwt = JWT::decode($jwt, $keys);

if(!property_exists($decoded_jwt, "preferred_username")) {
throw new HttpError("The OAUTH token doesnt have a 'preferred_username' claim, which is needed to validate the user");
}
$userName = $decoded_jwt->preferred_username;
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preferred_username claim is accessed without checking if it exists in the decoded token. If the OAuth token does not contain this claim, this will cause an undefined property error. The code should validate that the claim exists before accessing it.

Copilot uses AI. Check for mistakes.

$future = new DateTime("now +2 hours");
$token = generateTokenForUser($request, $userName, $future->getTimestamp());
$data["token"] = $token;
$data["expires"] = $future->getTimestamp();

$body = $response->getBody();
$body->write(json_encode($data, JSON_UNESCAPED_SLASHES));

return $response->withStatus(201)
->withHeader("Content-Type", "application/json");
});
});

// This routes needs to be protected by httpbasicauthentication middleware
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment contains a typo: "This routes needs" should be "This route needs".

Copilot uses AI. Check for mistakes.
$app->group("/api/v2/auth/token", function (RouteCollectorProxy $group) {
/* Allow preflight requests */
$group->options('', function (Request $request, Response $response, array $args): Response {
return $response;
});

$group->post('', function (Request $request, Response $response, array $args): Response {
include(dirname(__FILE__) . '/../../confv2.php');

$requested_scopes = $request->getParsedBody() ?: ["todo.all"];

$valid_scopes = [
"todo.create",
"todo.read",
"todo.update",
"todo.delete",
"todo.list",
"todo.all"
];

$scopes = array_filter($requested_scopes, function ($needle) use ($valid_scopes) {
return in_array($needle, $valid_scopes);
});

$now = new DateTime();
$future = new DateTime("now +2 hours");
$server = $request->getServerParams();

$jti = bin2hex(random_bytes(16));

// FIXME: This is duplicated and should be passed by HttpBasicMiddleware
$filter = new QueryFilter(User::USERNAME, $request->getAttribute('user'), "=");
$check = Factory::getUserFactory()->filter([Factory::FILTER => $filter]);
$user = $check[0];

$payload = [
"iat" => $now->getTimeStamp(),
"exp" => $future->getTimeStamp(),
"jti" => $jti,
"userId" => $user->getId(),
"scope" => $scopes
];

$secret = $PEPPER[0];
$token = JWT::encode($payload, $secret, "HS256");
$token = generateTokenForUser($request, $request->getAttribute('user'), $future->getTimestamp());

$data["token"] = $token;
$data["expires"] = $future->getTimeStamp();
$data["expires"] = $future->getTimestamp();

$body = $response->getBody();
$body->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
$body->write(json_encode($data, JSON_UNESCAPED_SLASHES));

return $response->withStatus(201)
->withHeader("Content-Type", "application/json");
Expand All @@ -84,22 +148,24 @@

$jti = bin2hex(random_bytes(16));

$secret = $PEPPER[0];
$payload = [
"iat" => $now->getTimeStamp(),
"exp" => $future->getTimeStamp(),
"jti" => $jti,
"userId" => $request->getAttribute(('userId')),
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra set of parentheses around 'userId' causing a double parentheses issue. This should be $request->getAttribute('userId') instead of $request->getAttribute(('userId')).

Copilot uses AI. Check for mistakes.
"scope" => $request->getAttribute("scope")
"scope" => $request->getAttribute("scope"),
"iss" => "Hashtopolis",
"kid" => hash("sha256", $secret)
];

$secret = $PEPPER[0];
$token = JWT::encode($payload, $secret, "HS256");

$data["token"] = $token;
$data["expires"] = $future->getTimeStamp();
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response format for the "expires" field is inconsistent across authentication endpoints. The new OAuth endpoint (line 100) and updated /api/v2/auth/token endpoint (line 123) return a DateTime object, while the /api/v2/auth/refresh endpoint (line 161) still returns a timestamp via getTimeStamp(). For API consistency, all three endpoints should return the same format. Consider updating the refresh endpoint to also return a DateTime object.

Copilot uses AI. Check for mistakes.

$body = $response->getBody();
$body->write(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
$body->write(json_encode($data, JSON_UNESCAPED_SLASHES));

return $response->withStatus(201)
->withHeader("Content-Type", "application/json");
Expand Down