Use bundle entities

This commit is contained in:
Jan Böhmer 2026-07-26 20:29:58 +02:00
parent c2948f7d84
commit 01a747da1d
30 changed files with 665 additions and 1954 deletions

View file

@ -1,12 +1,13 @@
## OAuth2 authorization server used for API/MCP app auto-provisioning (see docs/api/authentication.md). ## OAuth2 authorization server used for API/MCP app auto-provisioning (see docs/api/authentication.md).
# #
# The bundle's own persistence layer (its Client/AccessToken/RefreshToken/AuthCode Doctrine entities) is # Uses the bundle's own Doctrine persistence (its Client/AccessToken/RefreshToken/AuthorizationCode
# NOT used - "persistence: in_memory" here just satisfies the bundle's config schema (it registers a set # entities, tables prefixed "oauth2_") rather than custom entities/repositories, so upstream bundle
# of trivial in-memory managers that some of the bundle's console commands/services still need to exist, # upgrades keep this working without us having to touch repository code every time (as happened going
# even though we never use them). The actual League\OAuth2\Server\Repositories\* interfaces are overridden # from league/oauth2-server 8.5 to 9.2). OAuth-issued tokens are therefore a distinct credential type
# in config/services.yaml to point at our own Doctrine-backed repositories (App\Security\OAuth\Repository), # from App\Entity\UserSystem\ApiToken (Personal Access Tokens) - see App\Security\OAuth\OAuthBearerAuthenticator
# which make an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken row - see # for how incoming requests are routed to the right validator, and role_prefix below for how OAuth scopes
# App\Security\OAuth\Repository\AccessTokenRepository for why. # end up granting the exact same ROLE_API_* roles the existing permission system (config/permissions.yaml)
# already understands.
league_oauth2_server: league_oauth2_server:
authorization_server: authorization_server:
private_key: '%kernel.project_dir%/var/oauth2/private.key' private_key: '%kernel.project_dir%/var/oauth2/private.key'
@ -26,18 +27,24 @@ league_oauth2_server:
enable_refresh_token_grant: true enable_refresh_token_grant: true
require_code_challenge_for_public_clients: true require_code_challenge_for_public_clients: true
revoke_refresh_tokens: true revoke_refresh_tokens: true
response_type_class: App\Security\OAuth\OpaqueBearerTokenResponse # No response_type_class override: access tokens are real JWTs (the bundle's default). Clients
# only ever treat access_token as an opaque string per the OAuth2 spec, so this is not a
# compatibility concern - and it means the RSA keypair below is actually used, unlike the earlier
# custom-repository design.
resource_server: resource_server:
# Unused: we protect /api and /mcp with our own App\Security\ApiTokenAuthenticator, not the
# bundle's resource-server/security integration. Still required by the bundle's config schema.
public_key: '%kernel.project_dir%/var/oauth2/public.key' public_key: '%kernel.project_dir%/var/oauth2/public.key'
scopes: scopes:
# Scopes are just the existing App\Entity\UserSystem\ApiTokenLevel enum - see App\Security\OAuth\OAuthScope. # Scopes map 1:1 onto the existing App\Entity\UserSystem\ApiTokenLevel enum names. Combined with
# role_prefix below, an OAuth token scoped "edit" ends up with role ROLE_API_EDIT - the exact same
# role App\Entity\UserSystem\ApiTokenLevel::EDIT grants a Personal Access Token, so
# config/permissions.yaml's apiTokenRole mappings apply unchanged to both credential types (see
# App\Services\UserSystem\VoterHelper for the permission-ceiling check both types go through).
available: ['read_only', 'edit', 'admin', 'full'] available: ['read_only', 'edit', 'admin', 'full']
default: ['read_only'] default: ['read_only']
persistence: persistence:
in_memory: ~ doctrine:
table_prefix: 'oauth2_'
client: client:
# Irrelevant to us (we bypass the bundle's own client model entirely - see the top comment),
# set to false just to silence the bundle's deprecation notice for the true default.
allow_plaintext_secrets: false allow_plaintext_secrets: false
# Roles end up as ROLE_API_<SCOPE>, e.g. ROLE_API_READ_ONLY - see the "scopes" comment above.
role_prefix: 'ROLE_API_'

View file

@ -26,6 +26,7 @@ security:
custom_authenticators: custom_authenticators:
- App\Security\ApiTokenAuthenticator - App\Security\ApiTokenAuthenticator
- App\Security\OAuth\OAuthBearerAuthenticator
two_factor: two_factor:
auth_form_path: 2fa_login auth_form_path: 2fa_login

View file

@ -140,22 +140,6 @@ services:
$update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%' $update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%'
####################################################################################################################
# OAuth2 authorization server (auto-provisioning of API/MCP tokens, see docs/api/authentication.md)
#
# league/oauth2-server-bundle provides the /authorize and /token routes/controllers and builds the
# League\OAuth2\Server\AuthorizationServer service from config/packages/league_oauth2_server.yaml, but
# its own persistence layer is not used (see that file's top comment) - these overrides point the
# core league/oauth2-server repository interfaces at our own Doctrine-backed implementations instead
# of the bundle's, so an OAuth2 access token stays a plain App\Entity\UserSystem\ApiToken row.
####################################################################################################################
League\OAuth2\Server\Repositories\ClientRepositoryInterface: '@App\Security\OAuth\Repository\ClientRepository'
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface: '@App\Security\OAuth\Repository\AccessTokenRepository'
League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface: '@App\Security\OAuth\Repository\RefreshTokenRepository'
League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface: '@App\Security\OAuth\Repository\AuthCodeRepository'
League\OAuth2\Server\Repositories\ScopeRepositoryInterface: '@App\Security\OAuth\Repository\ScopeRepository'
security.access_token_extractor.header.token: security.access_token_extractor.header.token:
class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor
arguments: arguments:

View file

@ -1,279 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260726170000 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add OAuth2 authorization server tables (oauth_clients, oauth_auth_codes, oauth_refresh_tokens) and link api_tokens to the OAuth client that issued them';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth_clients (
id INT AUTO_INCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
redirect_uris JSON NOT NULL,
registration_access_token VARCHAR(255) NOT NULL,
dynamically_registered TINYINT(1) NOT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE INDEX UNIQ_13CE8101772E836A (identifier),
UNIQUE INDEX UNIQ_13CE8101105C05AC (registration_access_token),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth_auth_codes (
id INT AUTO_INCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
scope_identifiers JSON NOT NULL,
expiry_date_time DATETIME NOT NULL,
redirect_uri VARCHAR(255) DEFAULT NULL,
revoked TINYINT(1) NOT NULL,
client_id INT NOT NULL,
user_id INT NOT NULL,
UNIQUE INDEX UNIQ_BB493F83772E836A (identifier),
INDEX IDX_BB493F8319EB6921 (client_id),
INDEX IDX_BB493F83A76ED395 (user_id),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth_refresh_tokens (
id INT AUTO_INCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
expiry_date_time DATETIME NOT NULL,
revoked TINYINT(1) NOT NULL,
family_id VARCHAR(255) NOT NULL,
api_token_id INT NOT NULL,
client_id INT NOT NULL,
UNIQUE INDEX UNIQ_5AB687772E836A (identifier),
INDEX IDX_5AB68792E52D36 (api_token_id),
INDEX IDX_5AB68719EB6921 (client_id),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F8319EB6921');
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F83A76ED395');
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68792E52D36');
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68719EB6921');
$this->addSql('ALTER TABLE api_tokens DROP FOREIGN KEY FK_2CAD560EDCA49ED');
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED ON api_tokens');
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
$this->addSql('DROP TABLE oauth_auth_codes');
$this->addSql('DROP TABLE oauth_refresh_tokens');
$this->addSql('DROP TABLE oauth_clients');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
redirect_uris CLOB NOT NULL,
registration_access_token VARCHAR(255) NOT NULL,
dynamically_registered BOOLEAN NOT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth_auth_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
scope_identifiers CLOB NOT NULL,
expiry_date_time DATETIME NOT NULL,
redirect_uri VARCHAR(255) DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth_refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
identifier VARCHAR(255) NOT NULL,
expiry_date_time DATETIME NOT NULL,
revoked BOOLEAN NOT NULL,
family_id VARCHAR(255) NOT NULL,
api_token_id INTEGER NOT NULL,
client_id INTEGER NOT NULL,
CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
//SQLite can't ALTER TABLE ADD COLUMN with a foreign key constraint, so the table is rebuilt
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__api_tokens AS
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
FROM api_tokens
SQL);
$this->addSql('DROP TABLE api_tokens');
$this->addSql(<<<'SQL'
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id INTEGER DEFAULT NULL,
name VARCHAR(255) NOT NULL,
valid_until DATETIME DEFAULT NULL,
token VARCHAR(68) NOT NULL,
level SMALLINT NOT NULL,
last_time_used DATETIME DEFAULT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
oauth_client_id INTEGER DEFAULT NULL,
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO api_tokens (id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added)
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
FROM __temp__api_tokens
SQL);
$this->addSql('DROP TABLE __temp__api_tokens');
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE oauth_auth_codes');
$this->addSql('DROP TABLE oauth_refresh_tokens');
$this->addSql('DROP TABLE oauth_clients');
$this->addSql(<<<'SQL'
CREATE TEMPORARY TABLE __temp__api_tokens AS
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
FROM api_tokens
SQL);
$this->addSql('DROP TABLE api_tokens');
$this->addSql(<<<'SQL'
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name VARCHAR(255) NOT NULL,
valid_until DATETIME DEFAULT NULL,
token VARCHAR(68) NOT NULL,
level SMALLINT NOT NULL,
last_time_used DATETIME DEFAULT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
user_id INTEGER DEFAULT NULL,
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO api_tokens (id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id)
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
FROM __temp__api_tokens
SQL);
$this->addSql('DROP TABLE __temp__api_tokens');
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth_clients (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
identifier VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
redirect_uris JSON NOT NULL,
registration_access_token VARCHAR(255) NOT NULL,
dynamically_registered BOOLEAN NOT NULL,
last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth_auth_codes (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
identifier VARCHAR(255) NOT NULL,
scope_identifiers JSON NOT NULL,
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
redirect_uri VARCHAR(255) DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth_refresh_tokens (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
identifier VARCHAR(255) NOT NULL,
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
revoked BOOLEAN NOT NULL,
family_id VARCHAR(255) NOT NULL,
api_token_id INT NOT NULL,
client_id INT NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F8319EB6921');
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F83A76ED395');
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68792E52D36');
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68719EB6921');
$this->addSql('ALTER TABLE api_tokens DROP CONSTRAINT FK_2CAD560EDCA49ED');
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED');
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
$this->addSql('DROP TABLE oauth_auth_codes');
$this->addSql('DROP TABLE oauth_refresh_tokens');
$this->addSql('DROP TABLE oauth_clients');
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260726180454 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add league/oauth2-server-bundle\'s own Doctrine tables (oauth2_client, oauth2_access_token, oauth2_refresh_token, oauth2_authorization_code) for OAuth2 authorization server support (API/MCP app auto-provisioning)';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_client (
name VARCHAR(128) NOT NULL,
secret VARCHAR(128) DEFAULT NULL,
redirect_uris TEXT DEFAULT NULL,
grants TEXT DEFAULT NULL,
scopes TEXT DEFAULT NULL,
active TINYINT NOT NULL,
allow_plain_text_pkce TINYINT DEFAULT 0 NOT NULL,
identifier VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_access_token (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes TEXT DEFAULT NULL,
revoked TINYINT NOT NULL,
client VARCHAR(32) NOT NULL,
INDEX IDX_454D9673C7440455 (client),
PRIMARY KEY (identifier)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_authorization_code (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes TEXT DEFAULT NULL,
revoked TINYINT NOT NULL,
client VARCHAR(32) NOT NULL,
INDEX IDX_509FEF5FC7440455 (client),
PRIMARY KEY (identifier)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_refresh_token (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
revoked TINYINT NOT NULL,
access_token CHAR(80) DEFAULT NULL,
INDEX IDX_4DD90732B6A2DD68 (access_token),
PRIMARY KEY (identifier)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL');
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth2_access_token DROP FOREIGN KEY FK_454D9673C7440455');
$this->addSql('ALTER TABLE oauth2_authorization_code DROP FOREIGN KEY FK_509FEF5FC7440455');
$this->addSql('ALTER TABLE oauth2_refresh_token DROP FOREIGN KEY FK_4DD90732B6A2DD68');
$this->addSql('DROP TABLE oauth2_access_token');
$this->addSql('DROP TABLE oauth2_authorization_code');
$this->addSql('DROP TABLE oauth2_refresh_token');
$this->addSql('DROP TABLE oauth2_client');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_client (
name VARCHAR(128) NOT NULL,
secret VARCHAR(128) DEFAULT NULL,
redirect_uris CLOB DEFAULT NULL,
grants CLOB DEFAULT NULL,
scopes CLOB DEFAULT NULL,
active BOOLEAN NOT NULL,
allow_plain_text_pkce BOOLEAN DEFAULT 0 NOT NULL,
identifier VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier)
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_access_token (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes CLOB DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier),
CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_authorization_code (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes CLOB DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier),
CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_refresh_token (
identifier CHAR(80) NOT NULL,
expiry DATETIME NOT NULL,
revoked BOOLEAN NOT NULL,
access_token CHAR(80) DEFAULT NULL,
PRIMARY KEY (identifier),
CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE oauth2_access_token');
$this->addSql('DROP TABLE oauth2_authorization_code');
$this->addSql('DROP TABLE oauth2_refresh_token');
$this->addSql('DROP TABLE oauth2_client');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_client (
name VARCHAR(128) NOT NULL,
secret VARCHAR(128) DEFAULT NULL,
redirect_uris TEXT DEFAULT NULL,
grants TEXT DEFAULT NULL,
scopes TEXT DEFAULT NULL,
active BOOLEAN NOT NULL,
allow_plain_text_pkce BOOLEAN DEFAULT false NOT NULL,
identifier VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier)
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_access_token (
identifier CHAR(80) NOT NULL,
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes TEXT DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier)
)
SQL);
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_authorization_code (
identifier CHAR(80) NOT NULL,
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
user_identifier VARCHAR(128) DEFAULT NULL,
scopes TEXT DEFAULT NULL,
revoked BOOLEAN NOT NULL,
client VARCHAR(32) NOT NULL,
PRIMARY KEY (identifier)
)
SQL);
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
$this->addSql(<<<'SQL'
CREATE TABLE oauth2_refresh_token (
identifier CHAR(80) NOT NULL,
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
revoked BOOLEAN NOT NULL,
access_token CHAR(80) DEFAULT NULL,
PRIMARY KEY (identifier)
)
SQL);
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
$this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
$this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL');
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth2_access_token DROP CONSTRAINT FK_454D9673C7440455');
$this->addSql('ALTER TABLE oauth2_authorization_code DROP CONSTRAINT FK_509FEF5FC7440455');
$this->addSql('ALTER TABLE oauth2_refresh_token DROP CONSTRAINT FK_4DD90732B6A2DD68');
$this->addSql('DROP TABLE oauth2_access_token');
$this->addSql('DROP TABLE oauth2_authorization_code');
$this->addSql('DROP TABLE oauth2_refresh_token');
$this->addSql('DROP TABLE oauth2_client');
}
}

View file

@ -34,10 +34,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
/** /**
* Generates the RSA keypair required by league/oauth2-server-bundle's AuthorizationServer (for API/MCP * Generates the RSA keypair required by league/oauth2-server-bundle's AuthorizationServer (for API/MCP
* app auto-provisioning, see config/packages/league_oauth2_server.yaml). This keypair is only used by * app auto-provisioning, see config/packages/league_oauth2_server.yaml). Unlike an inert placeholder,
* the underlying library's (unused, in our setup) JWT-signing code path - see * this keypair actually signs the JWT access tokens handed to OAuth clients - keep the private key
* App\Security\OAuth\OpaqueBearerTokenResponse - so its content is not otherwise security-critical, but * secret and do not regenerate it while tokens signed with the old one are still outstanding (they
* the library still requires a syntactically valid keypair to exist at the configured paths. * would fail validation against the new public key).
*/ */
#[AsCommand('partdb:oauth:generate-keys', 'Generates the RSA keypair used by the OAuth2 authorization server (for API/MCP app auto-provisioning)')] #[AsCommand('partdb:oauth:generate-keys', 'Generates the RSA keypair used by the OAuth2 authorization server (for API/MCP app auto-provisioning)')]
class OAuthGenerateKeysCommand extends Command class OAuthGenerateKeysCommand extends Command
@ -61,7 +61,7 @@ class OAuthGenerateKeysCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
if (!$input->getOption('force') && (is_file($this->privateKeyPath) || is_file($this->publicKeyPath))) { if (!$input->getOption('force') && (is_file($this->privateKeyPath) || is_file($this->publicKeyPath))) {
$io->error('A keypair already exists. Use --force to overwrite it (this invalidates nothing, since the keypair is not used to sign anything a client relies on - see OpaqueBearerTokenResponse).'); $io->error('A keypair already exists. Use --force to overwrite it - note this invalidates every outstanding OAuth2 access token, since they are JWTs signed with the old private key.');
return Command::FAILURE; return Command::FAILURE;
} }

View file

@ -30,7 +30,6 @@ use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter; use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Base\TimestampTrait; use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface; use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\UserSystem\OAuth\OAuthClient;
use App\Repository\UserSystem\ApiTokenRepository; use App\Repository\UserSystem\ApiTokenRepository;
use App\State\CurrentApiTokenProvider; use App\State\CurrentApiTokenProvider;
use App\Validator\Constraints\Year2038BugWorkaround; use App\Validator\Constraints\Year2038BugWorkaround;
@ -92,15 +91,6 @@ class ApiToken implements TimeStampableInterface
#[Groups('token:read')] #[Groups('token:read')]
private ?\DateTimeImmutable $last_time_used = null; private ?\DateTimeImmutable $last_time_used = null;
/**
* The OAuth2 client this token was issued to, if it originates from the OAuth authorization code
* flow (see src/Security/OAuth) rather than being manually created by the user.
*/
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Groups('token:read')]
private ?OAuthClient $oauthClient = null;
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN) public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
{ {
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long. // Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
@ -205,15 +195,4 @@ class ApiToken implements TimeStampableInterface
return substr($this->token, -4); return substr($this->token, -4);
} }
public function getOauthClient(): ?OAuthClient
{
return $this->oauthClient;
}
public function setOauthClient(?OAuthClient $oauthClient): self
{
$this->oauthClient = $oauthClient;
return $this;
}
} }

View file

@ -31,13 +31,6 @@ enum ApiTokenType: string
{ {
case PERSONAL_ACCESS_TOKEN = 'tcp'; case PERSONAL_ACCESS_TOKEN = 'tcp';
/**
* A token issued through the OAuth2 authorization code flow (see src/Security/OAuth), rather than
* created manually by a user. Functionally just an ApiToken like any other; the prefix only exists
* so the UI can tell them apart.
*/
case OAUTH_ACCESS_TOKEN = 'oat';
/** /**
* Get the prefix of the token including the underscore * Get the prefix of the token including the underscore
* @return string * @return string
@ -60,4 +53,20 @@ enum ApiTokenType: string
} }
return self::from($parts[0]); return self::from($parts[0]);
} }
/**
* Checks if the given string looks like one of our own tokens (i.e. has a recognized prefix),
* without throwing. Used by App\Security\ApiTokenAuthenticator and
* App\Security\OAuth\OAuthBearerAuthenticator to decide, up front, which of the two authenticators
* should even attempt to handle a given bearer credential.
*/
public static function isRecognizedToken(string $token): bool
{
try {
self::getTypeFromToken($token);
return true;
} catch (\InvalidArgumentException|\ValueError) {
return false;
}
}
} }

View file

@ -1,141 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\UserSystem\OAuth;
use App\Entity\UserSystem\User;
use App\Repository\UserSystem\OAuth\OAuthAuthCodeRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* A revocation/single-use record for a short-lived OAuth2 authorization code (RFC 6749 section 4.1).
*
* league/oauth2-server hands the client an encrypted, self-contained code and never asks for this
* data again - it only calls App\Security\OAuth\Repository\AuthCodeRepository::isAuthCodeRevoked()/
* revokeAuthCode() by identifier. So unlike OAuthClient, this entity does not need to implement
* AuthCodeEntityInterface; App\Security\OAuth\Entity\TransientAuthCode fills that role during issuance.
* The client/user/scopes/redirect_uri columns exist purely for admin auditing.
*/
#[ORM\Entity(repositoryClass: OAuthAuthCodeRepository::class)]
#[ORM\Table(name: 'oauth_auth_codes')]
class OAuthAuthCode
{
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
private int $id;
#[ORM\Column(type: Types::STRING, unique: true)]
private string $identifier;
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private OAuthClient $client;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private User $user;
/**
* @var string[]
*/
#[ORM\Column(type: Types::JSON)]
private array $scopeIdentifiers = [];
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $expiryDateTime;
#[ORM\Column(type: Types::STRING, nullable: true)]
private ?string $redirectUri = null;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $revoked = false;
/**
* @param string[] $scopeIdentifiers
*/
public function __construct(
string $identifier,
OAuthClient $client,
User $user,
array $scopeIdentifiers,
\DateTimeImmutable $expiryDateTime,
?string $redirectUri,
) {
$this->identifier = $identifier;
$this->client = $client;
$this->user = $user;
$this->scopeIdentifiers = $scopeIdentifiers;
$this->expiryDateTime = $expiryDateTime;
$this->redirectUri = $redirectUri;
}
public function getId(): int
{
return $this->id;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getClient(): OAuthClient
{
return $this->client;
}
public function getUser(): User
{
return $this->user;
}
/**
* @return string[]
*/
public function getScopeIdentifiers(): array
{
return $this->scopeIdentifiers;
}
public function getExpiryDateTime(): \DateTimeImmutable
{
return $this->expiryDateTime;
}
public function getRedirectUri(): ?string
{
return $this->redirectUri;
}
public function isRevoked(): bool
{
return $this->revoked;
}
public function setRevoked(bool $revoked): self
{
$this->revoked = $revoked;
return $this;
}
}

View file

@ -1,161 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\UserSystem\OAuth;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface;
use App\Repository\UserSystem\OAuth\OAuthClientRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use League\OAuth2\Server\Entities\ClientEntityInterface;
/**
* An OAuth2 client application that has been registered against this Part-DB instance, either via
* Dynamic Client Registration (RFC 7591) or (in the future) manually by an admin.
*
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
*
* Note: this class deliberately does NOT use league/oauth2-server's ClientTrait/EntityTrait. Those
* traits declare their properties (e.g. $identifier, $name) without a type, and Doctrine attribute
* mapping requires typed properties; PHP considers a typed redeclaration of an untyped trait property
* "incompatible" (fatal error), so the interface is implemented manually instead.
*/
#[ORM\Entity(repositoryClass: OAuthClientRepository::class)]
#[ORM\Table(name: 'oauth_clients')]
#[ORM\HasLifecycleCallbacks]
class OAuthClient implements ClientEntityInterface, TimeStampableInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
protected int $id;
/**
* The public client_id handed out to the client. This is what league/oauth2-server calls the
* "identifier", it is NOT the same as the Doctrine primary key.
*/
#[ORM\Column(type: Types::STRING, unique: true)]
private string $identifier;
#[ORM\Column(type: Types::STRING)]
private string $name;
/**
* @var string[]
*/
#[ORM\Column(type: Types::JSON)]
private array $redirectUris = [];
/**
* The opaque bearer token used to authenticate against the RFC 7591 client-configuration endpoint
* (GET/PUT/DELETE /oauth/register/{client_id}). Not related to the OAuth access tokens issued to users.
*/
#[ORM\Column(type: Types::STRING, unique: true)]
private string $registrationAccessToken;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $dynamicallyRegistered = true;
/**
* @param string[] $redirectUris
*/
public function __construct(string $clientId, string $name, array $redirectUris, string $registrationAccessToken)
{
$this->identifier = $clientId;
$this->name = $name;
$this->redirectUris = $redirectUris;
$this->registrationAccessToken = $registrationAccessToken;
}
public function getId(): int
{
return $this->id;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* @return string[]
*/
public function getRedirectUri(): array
{
return $this->redirectUris;
}
/**
* @return string[]
*/
public function getRedirectUris(): array
{
return $this->redirectUris;
}
/**
* @param string[] $redirectUris
*/
public function setRedirectUris(array $redirectUris): self
{
$this->redirectUris = $redirectUris;
return $this;
}
/**
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
*/
public function isConfidential(): bool
{
return false;
}
public function getRegistrationAccessToken(): string
{
return $this->registrationAccessToken;
}
public function isDynamicallyRegistered(): bool
{
return $this->dynamicallyRegistered;
}
public function setDynamicallyRegistered(bool $dynamicallyRegistered): self
{
$this->dynamicallyRegistered = $dynamicallyRegistered;
return $this;
}
}

View file

@ -1,132 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\UserSystem\OAuth;
use App\Entity\UserSystem\ApiToken;
use App\Repository\UserSystem\OAuth\OAuthRefreshTokenRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* A revocation record for a refresh token issued alongside an OAuth2 access token (which is itself
* just an ApiToken, see AccessTokenRepository). Refresh tokens are rotated on every use; reuse of an
* already-revoked refresh token revokes the whole token family (see
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked()).
*
* league/oauth2-server hands the client an encrypted, self-contained refresh token and never asks for
* this data again - it only calls RefreshTokenRepository::isRefreshTokenRevoked()/revokeRefreshToken()
* by identifier. So unlike OAuthClient, this entity does not need to implement
* RefreshTokenEntityInterface; App\Security\OAuth\Entity\TransientRefreshToken fills that role during
* issuance.
*/
#[ORM\Entity(repositoryClass: OAuthRefreshTokenRepository::class)]
#[ORM\Table(name: 'oauth_refresh_tokens')]
class OAuthRefreshToken
{
#[ORM\Id]
#[ORM\Column(type: Types::INTEGER)]
#[ORM\GeneratedValue]
private int $id;
#[ORM\Column(type: Types::STRING, unique: true)]
private string $identifier;
/**
* The ApiToken that this refresh token can be exchanged for a new access token for. Cascading the
* delete means revoking/deleting the ApiToken (e.g. via the "revoke" button in user settings)
* automatically invalidates the refresh token too.
*/
#[ORM\ManyToOne(targetEntity: ApiToken::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ApiToken $apiToken;
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private OAuthClient $client;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $expiryDateTime;
#[ORM\Column(type: Types::BOOLEAN)]
private bool $revoked = false;
/**
* Shared by every refresh token in the same rotation chain (i.e. copied forward on each rotation,
* starting from the one issued at the original authorization). Lets
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked() revoke the whole
* chain's *currently active* token when an old, already-rotated-out refresh token is replayed -
* not just re-delete the (already dead) token from when this one was issued.
*/
#[ORM\Column(type: Types::STRING)]
private string $familyId;
public function __construct(string $identifier, ApiToken $apiToken, OAuthClient $client, \DateTimeImmutable $expiryDateTime, string $familyId)
{
$this->identifier = $identifier;
$this->apiToken = $apiToken;
$this->client = $client;
$this->expiryDateTime = $expiryDateTime;
$this->familyId = $familyId;
}
public function getId(): int
{
return $this->id;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getApiToken(): ApiToken
{
return $this->apiToken;
}
public function getClient(): OAuthClient
{
return $this->client;
}
public function getExpiryDateTime(): \DateTimeImmutable
{
return $this->expiryDateTime;
}
public function getFamilyId(): string
{
return $this->familyId;
}
public function isRevoked(): bool
{
return $this->revoked;
}
public function setRevoked(bool $revoked): self
{
$this->revoked = $revoked;
return $this;
}
}

View file

@ -1,29 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Repository\UserSystem\OAuth;
use Doctrine\ORM\EntityRepository;
class OAuthAuthCodeRepository extends EntityRepository
{
}

View file

@ -1,29 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Repository\UserSystem\OAuth;
use Doctrine\ORM\EntityRepository;
class OAuthClientRepository extends EntityRepository
{
}

View file

@ -1,29 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Repository\UserSystem\OAuth;
use Doctrine\ORM\EntityRepository;
class OAuthRefreshTokenRepository extends EntityRepository
{
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Security; namespace App\Security;
use App\Entity\UserSystem\ApiToken; use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenType;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -88,7 +89,15 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
public function supports(Request $request): ?bool public function supports(Request $request): ?bool
{ {
return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null; $accessToken = $this->accessTokenExtractor->extractAccessToken($request);
if ($accessToken === null) {
return false;
}
//Only claim tokens that look like our own (tcp_... Personal Access Tokens); anything else
//(e.g. an OAuth2-issued JWT) is left for App\Security\OAuth\OAuthBearerAuthenticator, which is
//mutually exclusive with this authenticator for exactly this reason - see that class.
return ApiTokenType::isRecognizedToken($accessToken);
} }
public function authenticate(Request $request): Passport public function authenticate(Request $request): Passport

View file

@ -1,49 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Entity;
use App\Entity\UserSystem\User;
use League\OAuth2\Server\Entities\UserEntityInterface;
/**
* Adapts App\Entity\UserSystem\User (whose Symfony UserInterface identifier is its username) to
* league/oauth2-server's UserEntityInterface, which identifies a user by an opaque scalar - we use the
* numeric database id, since that's what App\Security\OAuth\Repository\AccessTokenRepository and
* AuthCodeRepository resolve back into a User via EntityManager::find().
*/
class OAuthUserEntity implements UserEntityInterface
{
public function __construct(private readonly int $id)
{
}
public static function fromUser(User $user): self
{
return new self($user->getID());
}
public function getIdentifier(): string
{
return (string) $this->id;
}
}

View file

@ -1,43 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Entity;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
/**
* A purely in-memory AccessTokenEntityInterface used only while league/oauth2-server is issuing a
* token. It is never persisted itself: our AccessTokenRepository::persistNewAccessToken() creates a
* real App\Entity\UserSystem\ApiToken from it and overwrites its identifier with the ApiToken's own
* "oat_..." token string, so the value returned to the client and the value stored in the api_tokens
* table are identical - see AccessTokenRepository for why (keeps ApiTokenAuthenticator as the single
* validation path for both PATs and OAuth-issued tokens).
*/
class TransientAccessToken implements AccessTokenEntityInterface
{
use AccessTokenTrait;
use EntityTrait;
use TokenEntityTrait;
}

View file

@ -1,42 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Entity;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
/**
* A purely in-memory AuthCodeEntityInterface used only while league/oauth2-server is issuing an
* authorization code. The code_challenge/method and all other request data end up embedded directly
* in the encrypted code handed to the client (league/oauth2-server never asks the repository for them
* again); our AuthCodeRepository only needs to persist a small revocation record
* (App\Entity\UserSystem\OAuth\OAuthAuthCode) keyed by identifier, for single-use enforcement.
*/
class TransientAuthCode implements AuthCodeEntityInterface
{
use AuthCodeTrait;
use EntityTrait;
use TokenEntityTrait;
}

View file

@ -1,40 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Entity;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
/**
* A purely in-memory RefreshTokenEntityInterface used only while league/oauth2-server is issuing a
* refresh token. league/oauth2-server itself never stores the refresh token's content server-side -
* it hands the client an encrypted, self-contained payload and decrypts/validates it directly on the
* next request. Our RefreshTokenRepository only needs to persist a small revocation record
* (App\Entity\UserSystem\OAuth\OAuthRefreshToken) keyed by identifier; see that repository.
*/
class TransientRefreshToken implements RefreshTokenEntityInterface
{
use EntityTrait;
use RefreshTokenTrait;
}

View file

@ -0,0 +1,158 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth;
use App\Entity\UserSystem\ApiTokenType;
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationException;
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationFailedException;
use League\Bundle\OAuth2ServerBundle\Security\Passport\Badge\ScopeBadge;
use League\Bundle\OAuth2ServerBundle\Security\User\ClientCredentialsUser;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\ResourceServer;
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
/**
* Validates OAuth2-issued Bearer tokens (auto-provisioned API/MCP app credentials, see
* config/packages/league_oauth2_server.yaml) against the bundle's own ResourceServer/token storage.
*
* This is functionally almost identical to the bundle's own
* League\Bundle\OAuth2ServerBundle\Security\Authenticator\OAuth2Authenticator - reimplemented here
* (rather than reused) for one reason: supports() must be mutually exclusive with
* App\Security\ApiTokenAuthenticator. Symfony's AuthenticatorManager runs *every* authenticator whose
* supports() matches a request, not just the first one that succeeds - so if both authenticators
* claimed every "Authorization: Bearer ..." request, whichever one runs second would immediately
* re-validate (and fail on) a token meant for the other, overwriting an already-successful
* authentication with a 401. Restricting each authenticator to the token shapes it actually owns
* (ApiTokenAuthenticator: our own "tcp_..." Personal Access Tokens; this class: everything else, i.e.
* OAuth2-issued JWTs) avoids that collision entirely. See App\Entity\UserSystem\ApiTokenType::isRecognizedToken().
*/
class OAuthBearerAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface
{
/**
* Must match config/packages/league_oauth2_server.yaml's authorization_server.role_prefix.
*/
private const ROLE_PREFIX = 'ROLE_API_';
public function __construct(
#[Autowire(service: 'league.oauth2_server.factory.psr_http')]
private readonly HttpMessageFactoryInterface $httpMessageFactory,
private readonly ResourceServer $resourceServer,
#[Autowire(service: 'security.user.provider.concrete.app_user_provider')]
private readonly UserProviderInterface $userProvider,
) {
}
public function supports(Request $request): bool
{
$header = $request->headers->get('Authorization', '');
if (!str_starts_with((string) $header, 'Bearer ')) {
return false;
}
return !ApiTokenType::isRecognizedToken(substr($header, 7));
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return new Response($authException?->getMessage() ?? 'Authentication required', Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => 'Bearer']);
}
public function authenticate(Request $request): Passport
{
try {
$psr7Request = $this->resourceServer->validateAuthenticatedRequest($this->httpMessageFactory->createRequest($request));
} catch (OAuthServerException $e) {
throw OAuth2AuthenticationFailedException::create('The resource server rejected the request.', $e);
}
/** @var string $userIdentifier */
$userIdentifier = $psr7Request->getAttribute('oauth_user_id', '');
/** @var string $accessTokenId */
$accessTokenId = $psr7Request->getAttribute('oauth_access_token_id');
/** @var list<string> $scopes */
$scopes = $psr7Request->getAttribute('oauth_scopes', []);
/** @var non-empty-string $oauthClientId */
$oauthClientId = $psr7Request->getAttribute('oauth_client_id', '');
$userLoader = function (string $userIdentifier) use ($oauthClientId): UserInterface {
if ($oauthClientId === $userIdentifier) {
return new ClientCredentialsUser($oauthClientId);
}
return $this->userProvider->loadUserByIdentifier($userIdentifier);
};
$passport = new SelfValidatingPassport(new UserBadge($userIdentifier, $userLoader), [
new ScopeBadge($scopes),
]);
$passport->setAttribute('accessTokenId', $accessTokenId);
$passport->setAttribute('oauthClientId', $oauthClientId);
return $passport;
}
public function createToken(Passport $passport, string $firewallName): TokenInterface
{
/** @var string $accessTokenId */
$accessTokenId = $passport->getAttribute('accessTokenId');
/** @var ScopeBadge $scopeBadge */
$scopeBadge = $passport->getBadge(ScopeBadge::class);
/** @var string $oauthClientId */
$oauthClientId = $passport->getAttribute('oauthClientId', '');
return new OAuth2Token($passport->getUser(), $accessTokenId, $oauthClientId, $scopeBadge->getScopes(), self::ROLE_PREFIX);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
if ($exception instanceof OAuth2AuthenticationException) {
return new Response($exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders());
}
throw $exception;
}
}

View file

@ -1,54 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth;
use App\Entity\UserSystem\ApiTokenLevel;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
/**
* An OAuth2 scope. There is no separate scope taxonomy: scopes are just the existing ApiTokenLevel
* enum values (read_only/edit/admin/full), so an OAuth-granted token gets exactly the same Symfony
* roles as an equivalent manually-created Personal Access Token. See OAuthScopeRepository.
*/
class OAuthScope implements ScopeEntityInterface
{
use EntityTrait;
use ScopeTrait;
public function __construct(private readonly ApiTokenLevel $level)
{
$this->identifier = self::scopeIdentifierFor($level);
}
public function getLevel(): ApiTokenLevel
{
return $this->level;
}
public static function scopeIdentifierFor(ApiTokenLevel $level): string
{
return strtolower($level->name);
}
}

View file

@ -1,90 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth;
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse;
use LogicException;
use Psr\Http\Message\ResponseInterface;
/**
* league/oauth2-server's default BearerTokenResponse returns a signed JWT as the "access_token" value
* (via AccessTokenEntityInterface::__toString()/convertToJWT()). We don't want that: our access tokens
* ARE App\Entity\UserSystem\ApiToken rows (see App\Security\OAuth\Repository\AccessTokenRepository),
* validated by the existing App\Security\ApiTokenAuthenticator via a plain DB lookup, so the string
* handed to the client must be exactly ApiToken::getToken() rather than a JWT wrapping it.
*
* This is otherwise an exact copy of BearerTokenResponse::generateHttpResponse(), the only change
* being `$this->accessToken->getIdentifier()` instead of `(string) $this->accessToken`. Overriding this
* method (rather than just the identifier) is necessary because the base class doesn't expose a smaller
* extension point for it - see the "extra params" hook, which is for adding *additional* fields, not
* for changing how access_token itself is rendered.
*/
class OpaqueBearerTokenResponse extends BearerTokenResponse
{
public function generateHttpResponse(ResponseInterface $response): ResponseInterface
{
$expireDateTime = $this->accessToken->getExpiryDateTime()->getTimestamp();
$responseParams = [
'token_type' => 'Bearer',
'expires_in' => $expireDateTime - time(),
'access_token' => $this->accessToken->getIdentifier(),
];
//$refreshToken is a typed, non-nullable property with no default - isset() (rather than an
//instanceof/truthiness check, which would throw on an uninitialized typed property) is the
//correct way to test whether setRefreshToken() was ever called for this response.
if (isset($this->refreshToken)) {
$refreshTokenPayload = json_encode([
'client_id' => $this->accessToken->getClient()->getIdentifier(),
'refresh_token_id' => $this->refreshToken->getIdentifier(),
'access_token_id' => $this->accessToken->getIdentifier(),
'scopes' => $this->accessToken->getScopes(),
'user_id' => $this->accessToken->getUserIdentifier(),
'expire_time' => $this->refreshToken->getExpiryDateTime()->getTimestamp(),
]);
if ($refreshTokenPayload === false) {
throw new LogicException('Error encountered JSON encoding the refresh token payload');
}
$responseParams['refresh_token'] = $this->encrypt($refreshTokenPayload);
}
$responseParams = json_encode(array_merge($this->getExtraParams($this->accessToken), $responseParams));
if ($responseParams === false) {
throw new LogicException('Error encountered JSON encoding response parameters');
}
$response = $response
->withStatus(200)
->withHeader('pragma', 'no-cache')
->withHeader('cache-control', 'no-store')
->withHeader('content-type', 'application/json; charset=UTF-8');
$response->getBody()->write($responseParams);
return $response;
}
}

View file

@ -1,128 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Repository;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\ApiTokenType;
use App\Entity\UserSystem\OAuth\OAuthClient;
use App\Entity\UserSystem\User;
use App\Security\OAuth\Entity\TransientAccessToken;
use App\Security\OAuth\OAuthScope;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
/**
* Makes an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken (with a new
* ApiTokenType::OAUTH_ACCESS_TOKEN prefix), so App\Security\ApiTokenAuthenticator validates it with no
* changes at all - no parallel JWT-verification path, no second revocation mechanism. See
* TransientAccessToken for why the AccessTokenEntityInterface object itself is never persisted.
*/
class AccessTokenRepository implements AccessTokenRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, ?string $userIdentifier = null): AccessTokenEntityInterface
{
$accessToken = new TransientAccessToken();
$accessToken->setClient($clientEntity);
foreach ($scopes as $scope) {
$accessToken->addScope($scope);
}
if ($userIdentifier !== null) {
$accessToken->setUserIdentifier($userIdentifier);
}
return $accessToken;
}
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
{
$client = $accessTokenEntity->getClient();
if (!$client instanceof OAuthClient) {
throw new \LogicException('Expected an OAuthClient instance.');
}
$user = $this->entityManager->find(User::class, $accessTokenEntity->getUserIdentifier());
if (!$user instanceof User) {
throw new \LogicException('Could not resolve the user the access token was issued to.');
}
//ScopeRepository::finalizeScopes() always collapses the requested scopes down to a single
//ApiTokenLevel, so there is exactly one scope here.
$level = ApiTokenLevel::READ_ONLY;
foreach ($accessTokenEntity->getScopes() as $scope) {
if ($scope instanceof OAuthScope) {
$level = $scope->getLevel();
break;
}
}
$apiToken = new ApiToken(ApiTokenType::OAUTH_ACCESS_TOKEN);
$apiToken->setUser($user);
$apiToken->setLevel($level);
$apiToken->setName(sprintf('OAuth: %s', $client->getName()));
$apiToken->setValidUntil($accessTokenEntity->getExpiryDateTime());
$apiToken->setOauthClient($client);
$this->entityManager->persist($apiToken);
$this->entityManager->flush();
//Overwrite league's randomly generated identifier with our own ApiToken's token string, so the
//bearer token string returned to the client and api_tokens.token are the same value.
$accessTokenEntity->setIdentifier($apiToken->getToken());
}
public function revokeAccessToken(string $tokenId): void
{
//A bulk DQL delete instead of load+remove()+flush(): during refresh token rotation,
//RefreshTokenGrant::respondToAccessTokenRequest() revokes this access token and then, in the
//same request, touches the OLD OAuthRefreshToken row that still references it - which is the
//very same managed object sitting in the identity map since issuance. Loading (and therefore
//re-managing) this ApiToken via the ORM here makes Doctrine try to validate that association on
//the next flush() and fail ("a new entity was found through the relationship..."). A bulk delete
//never touches the UnitOfWork's per-entity changeset tracking, so it can't trigger that.
$this->entityManager->createQueryBuilder()
->delete(ApiToken::class, 't')
->where('t.token = :token')
->setParameter('token', $tokenId)
->getQuery()
->execute();
}
public function isAccessTokenRevoked(string $tokenId): bool
{
$apiToken = $this->findByToken($tokenId);
return !$apiToken instanceof ApiToken || !$apiToken->isValid();
}
private function findByToken(string $token): ?ApiToken
{
return $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $token]);
}
}

View file

@ -1,100 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Repository;
use App\Entity\UserSystem\OAuth\OAuthAuthCode;
use App\Entity\UserSystem\OAuth\OAuthClient;
use App\Entity\UserSystem\User;
use App\Security\OAuth\Entity\TransientAuthCode;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
/**
* See TransientAuthCode and OAuthAuthCode for why this only ever persists a small revocation record,
* never the code's actual content.
*/
class AuthCodeRepository implements AuthCodeRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getNewAuthCode(): AuthCodeEntityInterface
{
return new TransientAuthCode();
}
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
$client = $authCodeEntity->getClient();
if (!$client instanceof OAuthClient) {
throw new \LogicException('Expected an OAuthClient instance.');
}
$user = $this->entityManager->find(User::class, $authCodeEntity->getUserIdentifier());
if (!$user instanceof User) {
throw new \LogicException('Could not resolve the user the authorization code was issued to.');
}
$scopeIdentifiers = array_map(
static fn ($scope) => $scope->getIdentifier(),
$authCodeEntity->getScopes()
);
$authCode = new OAuthAuthCode(
$authCodeEntity->getIdentifier(),
$client,
$user,
$scopeIdentifiers,
$authCodeEntity->getExpiryDateTime(),
$authCodeEntity->getRedirectUri(),
);
$this->entityManager->persist($authCode);
$this->entityManager->flush();
}
public function revokeAuthCode(string $codeId): void
{
$authCode = $this->findByIdentifier($codeId);
if ($authCode instanceof OAuthAuthCode) {
$authCode->setRevoked(true);
$this->entityManager->flush();
}
}
public function isAuthCodeRevoked(string $codeId): bool
{
$authCode = $this->findByIdentifier($codeId);
//A code we have no record of can't be validated, so treat it as revoked/invalid.
return !$authCode instanceof OAuthAuthCode || $authCode->isRevoked();
}
private function findByIdentifier(string $codeId): ?OAuthAuthCode
{
return $this->entityManager->getRepository(OAuthAuthCode::class)
->findOneBy(['identifier' => $codeId]);
}
}

View file

@ -1,50 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Repository;
use App\Entity\UserSystem\OAuth\OAuthClient;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
/**
* Only public clients (Authorization Code + PKCE, no client secret) are supported, so validateClient()
* never checks a secret - it just checks the client is registered.
*/
class ClientRepository implements ClientRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface
{
return $this->entityManager->getRepository(OAuthClient::class)
->findOneBy(['identifier' => $clientIdentifier]);
}
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{
return $this->getClientEntity($clientIdentifier) instanceof OAuthClient;
}
}

View file

@ -1,172 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Repository;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\OAuth\OAuthClient;
use App\Entity\UserSystem\OAuth\OAuthRefreshToken;
use App\Security\OAuth\Entity\TransientRefreshToken;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
/**
* Refresh tokens are rotated on every use. Reuse of an already-revoked (i.e. already rotated-out)
* refresh token is a signal of token theft, so isRefreshTokenRevoked() responds by revoking the whole
* token family - deleting the linked ApiToken, not just this refresh token - per OAuth 2.1 guidance.
*
* revokeRefreshToken()/isRefreshTokenRevoked()'s family-kill path deliberately use bulk DQL
* updates/deletes instead of loading entities through the ORM: during rotation,
* League\OAuth2\Server\Grant\RefreshTokenGrant::respondToAccessTokenRequest() revokes the old access
* token and old refresh token in the same request/EntityManager that originally persisted them, so the
* OLD OAuthRefreshToken is still the exact same managed object sitting in the identity map from
* issuance. Loading and mutating it via the ORM after AccessTokenRepository::revokeAccessToken() has
* already deleted its linked ApiToken makes Doctrine try to validate that (now-stale) "apiToken"
* association on the next flush() and fail ("a new entity was found through the relationship...").
* Bulk DQL bypasses the UnitOfWork's per-entity changeset/association checks entirely.
*/
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
{
/**
* The family id of the refresh token most recently revoked (via revokeRefreshToken()) in this
* request, so the token about to be persisted (via persistNewRefreshToken(), which
* league/oauth2-server always calls immediately afterward during rotation - see RefreshTokenGrant::
* respondToAccessTokenRequest()) can carry the same family id forward instead of starting a new one.
*/
private ?string $pendingFamilyId = null;
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
{
return new TransientRefreshToken();
}
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
{
$accessToken = $refreshTokenEntity->getAccessToken();
$apiToken = $this->entityManager->getRepository(ApiToken::class)
->findOneBy(['token' => $accessToken->getIdentifier()]);
if (!$apiToken instanceof ApiToken) {
throw new \LogicException('Could not resolve the ApiToken this refresh token belongs to.');
}
$client = $accessToken->getClient();
if (!$client instanceof OAuthClient) {
throw new \LogicException('Expected an OAuthClient instance.');
}
$familyId = $this->pendingFamilyId ?? bin2hex(random_bytes(16));
$this->pendingFamilyId = null;
$refreshToken = new OAuthRefreshToken(
$refreshTokenEntity->getIdentifier(),
$apiToken,
$client,
$refreshTokenEntity->getExpiryDateTime(),
$familyId,
);
$this->entityManager->persist($refreshToken);
$this->entityManager->flush();
}
public function revokeRefreshToken(string $tokenId): void
{
$familyId = $this->entityManager->createQueryBuilder()
->select('r.familyId')
->from(OAuthRefreshToken::class, 'r')
->where('r.identifier = :identifier')
->setParameter('identifier', $tokenId)
->getQuery()
->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR);
if ($familyId === null) {
return;
}
$this->pendingFamilyId = $familyId;
$this->entityManager->createQueryBuilder()
->update(OAuthRefreshToken::class, 'r')
->set('r.revoked', ':revoked')
->where('r.identifier = :identifier')
->setParameter('revoked', true)
->setParameter('identifier', $tokenId)
->getQuery()
->execute();
}
public function isRefreshTokenRevoked(string $tokenId): bool
{
$row = $this->entityManager->createQueryBuilder()
->select('r.revoked', 'r.familyId')
->from(OAuthRefreshToken::class, 'r')
->where('r.identifier = :identifier')
->setParameter('identifier', $tokenId)
->getQuery()
->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY);
if ($row === null) {
//No record at all - can't validate, treat as revoked/invalid.
return true;
}
if ($row['revoked']) {
//Reuse of an already-rotated-out refresh token: kill the whole family - every refresh token
//(and thus ApiToken) descended from the same original grant, not just this one, since the
//currently active token in the chain is what actually needs to stop working.
$apiTokenIds = $this->entityManager->createQueryBuilder()
->select('IDENTITY(r.apiToken) AS apiTokenId')
->from(OAuthRefreshToken::class, 'r')
->where('r.familyId = :familyId')
->setParameter('familyId', $row['familyId'])
->getQuery()
->getSingleColumnResult();
$this->entityManager->createQueryBuilder()
->delete(OAuthRefreshToken::class, 'r')
->where('r.familyId = :familyId')
->setParameter('familyId', $row['familyId'])
->getQuery()
->execute();
if ($apiTokenIds !== []) {
$this->entityManager->createQueryBuilder()
->delete(ApiToken::class, 't')
->where('t.id IN (:ids)')
->setParameter('ids', $apiTokenIds)
->getQuery()
->execute();
}
return true;
}
return false;
}
}

View file

@ -1,74 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Security\OAuth\Repository;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Security\OAuth\OAuthScope;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
/**
* Scopes are just the existing ApiTokenLevel enum (read_only/edit/admin/full) - see OAuthScope. There
* is no fine-grained per-endpoint scoping; an OAuth-granted token gets exactly the same Symfony roles
* as an equivalent manually-created Personal Access Token of that level.
*/
class ScopeRepository implements ScopeRepositoryInterface
{
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface
{
$level = $this->levelFromIdentifier($identifier);
return $level !== null ? new OAuthScope($level) : null;
}
/**
* @param ScopeEntityInterface[] $scopes
* @return ScopeEntityInterface[]
*/
public function finalizeScopes(array $scopes, string $grantType, ClientEntityInterface $clientEntity, ?string $userIdentifier = null, ?string $authCodeId = null): array
{
//ApiTokenLevel is cumulative (a higher level already includes everything a lower one grants),
//so a token only ever has a single level. If several scopes were requested, keep only the
//highest one; if none were requested/valid, default to the least-privileged level.
$highest = ApiTokenLevel::READ_ONLY;
foreach ($scopes as $scope) {
if ($scope instanceof OAuthScope && $scope->getLevel()->value > $highest->value) {
$highest = $scope->getLevel();
}
}
return [new OAuthScope($highest)];
}
private function levelFromIdentifier(string $identifier): ?ApiTokenLevel
{
foreach (ApiTokenLevel::cases() as $level) {
if (OAuthScope::scopeIdentifierFor($level) === $identifier) {
return $level;
}
}
return null;
}
}

View file

@ -27,6 +27,7 @@ use App\Entity\UserSystem\User;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Security\ApiTokenAuthenticatedToken; use App\Security\ApiTokenAuthenticatedToken;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter; use Symfony\Component\Security\Core\Authorization\Voter\Voter;
@ -88,8 +89,13 @@ final class VoterHelper
$user = $this->resolveUser($token); $user = $this->resolveUser($token);
} }
//If the token is a APITokenAuthenticated //If the token is a APITokenAuthenticated token (Personal Access Token) or an OAuth2Token (an
if ($token instanceof ApiTokenAuthenticatedToken) { //OAuth2-issued token, see App\Security\OAuth\OAuthBearerAuthenticator), the token can only ever
//grant a *subset* of what the user could already do - apply the same API-level ceiling check to
//both, keyed off the token's own role list (ROLE_API_* either way - see
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles() and
//config/packages/league_oauth2_server.yaml's role_prefix/scopes).
if ($token instanceof ApiTokenAuthenticatedToken || $token instanceof OAuth2Token) {
//Use the special API token checker //Use the special API token checker
return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation); return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation);
} }

View file

@ -1,246 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Security\OAuth;
use App\Entity\UserSystem\ApiToken;
use App\Entity\UserSystem\ApiTokenLevel;
use App\Entity\UserSystem\ApiTokenType;
use App\Entity\UserSystem\OAuth\OAuthClient;
use App\Entity\UserSystem\User;
use App\Security\OAuth\Entity\OAuthUserEntity;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Drives League\OAuth2\Server\AuthorizationServer directly (no HTTP layer yet - that's Phase 2/3 of the
* OAuth auto-provisioning work) to verify the foundations laid in Phase 1: our Doctrine-backed
* repositories, the opaque (non-JWT) access token response, and - most importantly - that the token
* handed back to the client round-trips as a real App\Entity\UserSystem\ApiToken row, findable exactly
* the way App\Security\ApiTokenAuthenticator finds any other token.
*/
final class AuthorizationServerTest extends KernelTestCase
{
private AuthorizationServer $authServer;
private EntityManagerInterface $entityManager;
private Psr17Factory $psr17Factory;
protected function setUp(): void
{
self::bootKernel();
$this->authServer = self::getContainer()->get(AuthorizationServer::class);
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->psr17Factory = new Psr17Factory();
}
private function createTestClient(string $redirectUri): OAuthClient
{
$client = new OAuthClient(
'test-client-'.bin2hex(random_bytes(8)),
'Test Client',
[$redirectUri],
bin2hex(random_bytes(16)),
);
$this->entityManager->persist($client);
$this->entityManager->flush();
return $client;
}
private function getAdminUser(): User
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
self::assertInstanceOf(User::class, $user);
return $user;
}
/**
* @return array{0: string, 1: string} [code_verifier, code_challenge]
*/
private function generatePkcePair(): array
{
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return [$verifier, $challenge];
}
/**
* Runs the full authorize -> approve -> token exchange flow and returns the decoded token response.
*
* @return array<string, mixed>
*/
private function runAuthorizationCodeFlow(OAuthClient $client, User $user, string $redirectUri, string $scope): array
{
[$verifier, $challenge] = $this->generatePkcePair();
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->getIdentifier(),
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
'scope' => $scope,
]));
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
$authRequest->setUser(OAuthUserEntity::fromUser($user));
$authRequest->setAuthorizationApproved(true);
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
$location = $redirectResponse->getHeaderLine('Location');
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect: '.$location);
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
->withParsedBody([
'grant_type' => 'authorization_code',
'client_id' => $client->getIdentifier(),
'redirect_uri' => $redirectUri,
'code' => $query['code'],
'code_verifier' => $verifier,
]);
$tokenResponse = $this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
$body = (string) $tokenResponse->getBody();
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
public function testAuthorizationCodeFlowIssuesRealApiToken(): void
{
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($redirectUri);
$user = $this->getAdminUser();
$data = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'edit');
self::assertSame('Bearer', $data['token_type']);
self::assertArrayHasKey('access_token', $data);
self::assertArrayHasKey('refresh_token', $data);
//The access token must NOT look like a JWT (no dots) - see OpaqueBearerTokenResponse.
self::assertStringStartsWith(ApiTokenType::OAUTH_ACCESS_TOKEN->getTokenPrefix(), $data['access_token']);
self::assertStringNotContainsString('.', $data['access_token']);
$this->entityManager->clear();
$apiToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $data['access_token']]);
self::assertInstanceOf(ApiToken::class, $apiToken);
self::assertTrue($apiToken->isValid());
self::assertSame(ApiTokenType::OAUTH_ACCESS_TOKEN, $apiToken->getTokenType());
self::assertSame(ApiTokenLevel::EDIT, $apiToken->getLevel());
self::assertSame($user->getID(), $apiToken->getUser()?->getID());
self::assertSame($client->getId(), $apiToken->getOauthClient()?->getId());
}
public function testInvalidPkceVerifierIsRejected(): void
{
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($redirectUri);
$user = $this->getAdminUser();
[, $challenge] = $this->generatePkcePair();
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $client->getIdentifier(),
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]));
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
$authRequest->setUser(OAuthUserEntity::fromUser($user));
$authRequest->setAuthorizationApproved(true);
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
->withParsedBody([
'grant_type' => 'authorization_code',
'client_id' => $client->getIdentifier(),
'redirect_uri' => $redirectUri,
'code' => $query['code'],
//Wrong verifier - does not match the code_challenge used above.
'code_verifier' => str_repeat('a', 43),
]);
$this->expectException(OAuthServerException::class);
$this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
}
public function testRefreshTokenRotationIssuesNewApiTokenAndRevokesOld(): void
{
$redirectUri = 'https://client.example.invalid/callback';
$client = $this->createTestClient($redirectUri);
$user = $this->getAdminUser();
$first = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'read_only');
$originalAccessToken = $first['access_token'];
$refreshRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
->withParsedBody([
'grant_type' => 'refresh_token',
'client_id' => $client->getIdentifier(),
'refresh_token' => $first['refresh_token'],
]);
$refreshResponse = $this->authServer->respondToAccessTokenRequest($refreshRequest, $this->psr17Factory->createResponse());
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
self::assertNotSame($originalAccessToken, $second['access_token']);
$this->entityManager->clear();
//The old access token must have been revoked (deleted) as part of rotation.
$oldToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $originalAccessToken]);
self::assertNull($oldToken, 'The original access token should have been revoked on refresh.');
$newToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
self::assertInstanceOf(ApiToken::class, $newToken);
self::assertSame(ApiTokenLevel::READ_ONLY, $newToken->getLevel());
//Reusing the now-rotated-out refresh token must fail and revoke the whole family.
$reuseRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
->withParsedBody([
'grant_type' => 'refresh_token',
'client_id' => $client->getIdentifier(),
'refresh_token' => $first['refresh_token'],
]);
try {
$this->authServer->respondToAccessTokenRequest($reuseRequest, $this->psr17Factory->createResponse());
self::fail('Expected reuse of a rotated-out refresh token to be rejected.');
} catch (OAuthServerException) {
//Expected.
}
$this->entityManager->clear();
$newTokenAfterReuse = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
self::assertNull($newTokenAfterReuse, 'Reuse of a revoked refresh token should revoke the whole token family.');
}
}

View file

@ -0,0 +1,237 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Security\OAuth;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use League\Bundle\OAuth2ServerBundle\Entity\User as LeagueUser;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Model\Client as OAuthClientModel;
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\HttpFoundation\Response;
/**
* Drives the OAuth2 authorization code + PKCE flow end to end (against league/oauth2-server-bundle's
* own Doctrine persistence - see config/packages/league_oauth2_server.yaml), then makes *real* HTTP
* requests bearing the issued token against protected API routes. This specifically exercises the two
* pieces of custom code this design needs on top of the bundle: App\Security\OAuth\OAuthBearerAuthenticator
* (routing OAuth2 bearer tokens to the bundle's ResourceServer without colliding with
* App\Security\ApiTokenAuthenticator) and the App\Services\UserSystem\VoterHelper fix that makes an
* OAuth2-issued token's scope act as a permission ceiling, exactly like a Personal Access Token's level.
*
* All container/service access happens through the single kernel that static::createClient() boots for
* the test (via $client->getContainer()) - mixing that with a separately-booted kernel leads to
* inconsistent state (e.g. entities persisted through one EntityManager not visible to the other).
*/
final class OAuthBearerAuthenticationTest extends ApiTestCase
{
private function createTestClient(Client $client, string $redirectUri, array $scopes): OAuthClientModel
{
$oauthClient = new OAuthClientModel('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
$oauthClient->setRedirectUris(new RedirectUri($redirectUri));
$oauthClient->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
$oauthClient->setScopes(...array_map(static fn (string $s) => new Scope($s), $scopes));
$oauthClient->setActive(true);
$client->getContainer()->get(ClientManagerInterface::class)->save($oauthClient);
return $oauthClient;
}
/**
* @return array{0: string, 1: string} [code_verifier, code_challenge]
*/
private function generatePkcePair(): array
{
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return [$verifier, $challenge];
}
/**
* Drives AuthorizationServer directly (bypassing the not-yet-built consent-screen UI, which will
* sit in front of the bundle's own /authorize route) to get a real access/refresh token pair for
* the given scopes, exactly as if a user had approved that grant.
*
* @param string[] $scopes
* @return array<string, mixed>
*/
private function issueTokenForScopes(Client $client, OAuthClientModel $oauthClient, string $redirectUri, array $scopes): array
{
$authServer = $client->getContainer()->get(AuthorizationServer::class);
$psr17 = new Psr17Factory();
[$verifier, $challenge] = $this->generatePkcePair();
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
'scope' => implode(' ', $scopes),
]));
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
$leagueUser = new LeagueUser();
$leagueUser->setIdentifier('admin');
$authRequest->setUser($leagueUser);
$authRequest->setAuthorizationApproved(true);
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect.');
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
->withParsedBody([
'grant_type' => 'authorization_code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code' => $query['code'],
'code_verifier' => $verifier,
]);
$tokenResponse = $authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
return json_decode((string) $tokenResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
}
private function bearer(string $accessToken): array
{
return ['headers' => ['authorization' => 'Bearer '.$accessToken]];
}
public function testReadOnlyScopedTokenCanReadButNotEdit(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
self::assertArrayHasKey('access_token', $data);
//A real JWT, not one of our own "tcp_..." Personal Access Tokens.
self::assertStringContainsString('.', $data['access_token']);
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
self::assertResponseIsSuccessful();
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth read_only test']], $this->bearer($data['access_token'])));
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
}
public function testFullScopedTokenCanEdit(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
//A real "full"-level PAT gets the cumulative ROLE_API_READ_ONLY/EDIT/ADMIN/FULL roles (see
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles()); OAuth2 scopes are not cumulative,
//so the client/token must request every level it needs explicitly.
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
self::assertResponseIsSuccessful();
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth full test']], $this->bearer($data['access_token'])));
self::assertResponseIsSuccessful();
}
public function testInvalidPkceVerifierIsRejected(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
$psr17 = new Psr17Factory();
[, $challenge] = $this->generatePkcePair();
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
'response_type' => 'code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
'scope' => 'read_only',
]));
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
$leagueUser = new LeagueUser();
$leagueUser->setIdentifier('admin');
$authRequest->setUser($leagueUser);
$authRequest->setAuthorizationApproved(true);
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
->withParsedBody([
'grant_type' => 'authorization_code',
'client_id' => $oauthClient->getIdentifier(),
'redirect_uri' => $redirectUri,
'code' => $query['code'],
//Wrong verifier - does not match the code_challenge used above.
'code_verifier' => str_repeat('a', 43),
]);
$this->expectException(OAuthServerException::class);
$authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
}
public function testRefreshTokenRotationIssuesNewAccessTokenAndRevokesOld(): void
{
$httpClient = static::createClient();
$redirectUri = 'https://client.example.invalid/callback';
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
$first = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
$psr17 = new Psr17Factory();
$refreshRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
->withParsedBody([
'grant_type' => 'refresh_token',
'client_id' => $oauthClient->getIdentifier(),
'refresh_token' => $first['refresh_token'],
]);
$refreshResponse = $authServer->respondToAccessTokenRequest($refreshRequest, $psr17->createResponse());
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
self::assertNotSame($first['access_token'], $second['access_token']);
//The old access token must now be rejected by the resource server (revoked on rotation).
$httpClient->request('GET', '/api/parts', $this->bearer($first['access_token']));
self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
$httpClient->request('GET', '/api/parts', $this->bearer($second['access_token']));
self::assertResponseIsSuccessful();
}
}