Files
breadcrumb-the-shire/core/Service/Auth/ApiTokenEndpointService.php
fs 0e86925464 refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root
immediately communicates which code is core platform and which lives
in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the
Composer PSR-4 path mapping moves from lib/ to core/.

Module-internal lib/ directories (modules/*/lib/) are untouched.

Updated across the full stack:
- composer.json PSR-4 mapping
- bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php)
- tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer)
- 26 architecture contract tests
- enforcement-policy, guard-catalog, quality-gates
- all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/)
- bin/qa-extended.sh search paths
- .claude/settings.local.json permission paths

Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner →
Executor → Code Review (4 findings fixed) → Security Review →
Acceptance Test → Finalizer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:20:42 +02:00

279 lines
9.7 KiB
PHP

<?php
namespace MintyPHP\Service\Auth;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Service\Tenant\TenantScopeService;
class ApiTokenEndpointService
{
public function __construct(
private readonly ApiTokenRepository $apiTokenRepository,
private readonly TenantRepository $tenantRepository,
private readonly TenantScopeService $authScopeGateway
) {
}
public function normalizeLimit(mixed $rawLimit, int $default = 50, int $min = 1, int $max = 100): int
{
$limit = (int) $rawLimit;
if ($limit < $min) {
return $default;
}
if ($limit > $max) {
return $max;
}
return $limit;
}
/**
* @return array<int, array<string, mixed>>
*/
public function listSelfTokens(int $userId, int $limit, ?int $scopedTenantId, ?int $currentTokenId): array
{
if ($userId <= 0) {
return [];
}
$tokens = $this->apiTokenRepository->listByUserId($userId, $limit);
$tenantUuidById = $this->buildTenantUuidByIdMap($tokens);
$rows = [];
foreach ($tokens as $token) {
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
continue;
}
$rows[] = [
'uuid' => (string) ($token['uuid'] ?? ''),
'name' => (string) ($token['name'] ?? ''),
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
'tenant_uuid' => $tokenTenantId !== null ? ($tenantUuidById[$tokenTenantId] ?? null) : null,
'last_used_at' => $token['last_used_at'] ?? null,
'last_ip' => (string) ($token['last_ip'] ?? ''),
'expires_at' => $token['expires_at'] ?? null,
'revoked_at' => $token['revoked_at'] ?? null,
'created' => $token['created'] ?? null,
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
];
}
return $rows;
}
/**
* @return array<string, mixed>|null
*/
public function findSelfToken(string $tokenUuid, int $userId, ?int $scopedTenantId, ?int $currentTokenId): ?array
{
$tokenUuid = trim($tokenUuid);
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
return null;
}
$token = $this->apiTokenRepository->findByUuidForUser($tokenUuid, $userId);
if (!$token) {
return null;
}
$tokenTenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
if ($scopedTenantId !== null && $scopedTenantId > 0 && $tokenTenantId !== $scopedTenantId) {
return null;
}
return [
'uuid' => (string) ($token['uuid'] ?? ''),
'name' => (string) ($token['name'] ?? ''),
'selector_prefix' => substr((string) ($token['selector'] ?? ''), 0, 8),
'tenant_uuid' => $this->resolveTenantUuidById($tokenTenantId),
'last_used_at' => $token['last_used_at'] ?? null,
'last_ip' => (string) ($token['last_ip'] ?? ''),
'expires_at' => $token['expires_at'] ?? null,
'revoked_at' => $token['revoked_at'] ?? null,
'created' => $token['created'] ?? null,
'is_current_token' => (int) ($token['id'] ?? 0) === (int) ($currentTokenId ?? 0),
];
}
public function revokeSelfToken(string $tokenUuid, int $userId): bool
{
$tokenUuid = trim($tokenUuid);
if ($userId <= 0 || !$this->apiTokenRepository->isUuid($tokenUuid)) {
return false;
}
return $this->apiTokenRepository->revokeByUuidForUser($tokenUuid, $userId);
}
/**
* @return array{ok: bool, tenant_id?: int|null, tenant_uuid?: string|null, status?: int, error?: string, field?: string}
*/
public function resolveSelfCreateTenant(int $userId, ?int $scopedTenantId, string $tenantUuidInput): array
{
$tenantUuidInput = trim($tenantUuidInput);
$tenantId = null;
$tenantUuid = null;
if ($tenantUuidInput !== '') {
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
$tenantId = (int) ($tenant['id'] ?? 0);
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
if ($tenantId <= 0 || $tenantUuid === '') {
return [
'ok' => false,
'status' => 422,
'error' => 'invalid_tenant_uuid',
'field' => 'tenant_uuid',
];
}
}
if ($scopedTenantId !== null && $scopedTenantId > 0) {
$scopedTenantUuid = $this->resolveTenantUuidById($scopedTenantId);
if ($scopedTenantUuid === null) {
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
}
if ($tenantId !== null && $tenantId !== $scopedTenantId) {
return ['ok' => false, 'status' => 403, 'error' => 'tenant_scoped_token_forbidden'];
}
$tenantId = $scopedTenantId;
$tenantUuid = $scopedTenantUuid;
}
if ($tenantId !== null) {
$tenantIds = $this->authScopeGateway->getUserTenantIds($userId);
if (!in_array($tenantId, $tenantIds, true)) {
return ['ok' => false, 'status' => 403, 'error' => 'tenant_not_assigned'];
}
}
return [
'ok' => true,
'tenant_id' => $tenantId,
'tenant_uuid' => $tenantUuid,
];
}
/**
* @return array{ok: bool, expires_at?: string|null, error?: string}
*/
public function parseExpiresAt(string $expiresInput): array
{
$expiresInput = trim($expiresInput);
if ($expiresInput === '') {
return ['ok' => true, 'expires_at' => null];
}
try {
$expiresDate = new \DateTimeImmutable($expiresInput);
$expiresDateUtc = $expiresDate->setTimezone(new \DateTimeZone('UTC'));
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
if ($expiresDateUtc <= $nowUtc) {
return ['ok' => false, 'error' => 'must_be_in_future'];
}
return ['ok' => true, 'expires_at' => $expiresDateUtc->format('Y-m-d H:i:s')];
} catch (\Throwable $exception) {
return ['ok' => false, 'error' => 'invalid_datetime'];
}
}
public function capExpiryToParent(?string $requestedExpiresAt, ?string $parentExpiresAt): ?string
{
$parentExpiresAt = trim((string) ($parentExpiresAt ?? ''));
if ($parentExpiresAt === '') {
return $requestedExpiresAt;
}
try {
$parentExpiry = new \DateTimeImmutable($parentExpiresAt, new \DateTimeZone('UTC'));
if ($requestedExpiresAt === null || trim($requestedExpiresAt) === '') {
return $parentExpiresAt;
}
$requestedExpiry = new \DateTimeImmutable($requestedExpiresAt, new \DateTimeZone('UTC'));
if ($requestedExpiry > $parentExpiry) {
return $parentExpiresAt;
}
} catch (\Throwable $exception) {
return $requestedExpiresAt;
}
return $requestedExpiresAt;
}
/**
* @return array{ok: bool, tenant_id?: int|null, error?: string, status?: int}
*/
public function resolveLoginTenant(int $userId, string $tenantUuidInput): array
{
$tenantUuidInput = trim($tenantUuidInput);
if ($tenantUuidInput === '') {
return ['ok' => true, 'tenant_id' => null];
}
$tenant = $this->tenantRepository->findByUuid($tenantUuidInput);
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'invalid_tenant_uuid', 'status' => 422];
}
$userTenantIds = $this->authScopeGateway->getUserTenantIds($userId);
if (!in_array($tenantId, $userTenantIds, true)) {
return ['ok' => false, 'error' => 'tenant_not_assigned', 'status' => 403];
}
return ['ok' => true, 'tenant_id' => $tenantId];
}
public function resolveTenantUuidById(?int $tenantId): ?string
{
$tenantId = (int) ($tenantId ?? 0);
if ($tenantId <= 0) {
return null;
}
$tenant = $this->tenantRepository->find($tenantId);
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
return $tenantUuid !== '' ? $tenantUuid : null;
}
/**
* @param array<int, array<string, mixed>> $tokens
* @return array<int, string>
*/
private function buildTenantUuidByIdMap(array $tokens): array
{
$tenantIds = [];
foreach ($tokens as $token) {
$tenantId = $this->normalizeTenantId($token['tenant_id'] ?? null);
if ($tenantId !== null) {
$tenantIds[] = $tenantId;
}
}
$tenantIds = array_values(array_unique($tenantIds));
if (!$tenantIds) {
return [];
}
$map = [];
foreach ($this->tenantRepository->listByIds($tenantIds) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
$tenantUuid = trim((string) ($tenant['uuid'] ?? ''));
if ($tenantId > 0 && $tenantUuid !== '') {
$map[$tenantId] = $tenantUuid;
}
}
return $map;
}
private function normalizeTenantId(mixed $tenantId): ?int
{
$tenantId = (int) ($tenantId ?? 0);
return $tenantId > 0 ? $tenantId : null;
}
}