mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 14:11:47 +00:00
Started implementing an Oauth server
This commit is contained in:
parent
51ce453d1a
commit
c2948f7d84
31 changed files with 2551 additions and 90 deletions
7
.env
7
.env
|
|
@ -5,6 +5,12 @@
|
|||
# Share that value with nobody and keep it secret
|
||||
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
|
||||
|
||||
# Used to encrypt OAuth2 authorization codes and refresh tokens (only relevant if you use OAuth2 client
|
||||
# auto-provisioning for API/MCP apps). Change this to a random value with "bin/console part-db:oauth:generate-keys"
|
||||
# or by generating your own via Defuse\Crypto\Key::createNewRandomKey(). Keep it secret, and do NOT change
|
||||
# it once set, or all outstanding refresh tokens/in-flight authorization codes become invalid.
|
||||
OAUTH2_ENCRYPTION_KEY=def000000623b5c5664b11de7629fa777a91638b6cf0136ff4d7ef645bdc3d7665a1d605edefcf60a0fc3951b1e74529332005a3bd3cc402f0f46cf1e34d437b0b53e63b
|
||||
|
||||
# Change this and uncomment the following line to set the trusted hosts for your Part-DB installation, aka under which
|
||||
# domain names it is reachable. This is makes things more secure, because it can prevent certain header attacks.
|
||||
# You have to escape dots in the domain name with a backslash
|
||||
|
|
@ -191,3 +197,4 @@ APP_SHARE_DIR=var/share
|
|||
###> symfony/ai-open-router-platform ###
|
||||
OPENROUTER_API_KEY=
|
||||
###< symfony/ai-open-router-platform ###
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@
|
|||
"api-platform/doctrine-orm": "^4.1",
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/mcp": "^v4.3.17",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"api-platform/metadata": "^v4.3.17",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.17.0",
|
||||
"brick/schema": "^0.2.0",
|
||||
"composer/ca-bundle": "^1.5",
|
||||
"composer/package-versions-deprecated": "^1.11.99.5",
|
||||
"defuse/php-encryption": "^2.4",
|
||||
"doctrine/data-fixtures": "^2.0.0",
|
||||
"doctrine/dbal": "^4.0.0",
|
||||
"doctrine/doctrine-bundle": "^2.0",
|
||||
|
|
@ -40,8 +41,10 @@
|
|||
"league/commonmark": "^2.7",
|
||||
"league/csv": "^9.8.0",
|
||||
"league/html-to-markdown": "^5.0.1",
|
||||
"league/oauth2-server": "^9.2",
|
||||
"league/oauth2-server-bundle": "^1.2.1",
|
||||
"liip/imagine-bundle": "^2.2",
|
||||
"maennchen/zipstream-php": "2.1",
|
||||
"maennchen/zipstream-php": "^3.1.2",
|
||||
"mcp/sdk": "v0.7.0 as v0.6.0",
|
||||
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
|
||||
"nelexa/zip": "^4.0",
|
||||
|
|
|
|||
473
composer.lock
generated
473
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "df7fa4865832ec3d95601bb711eb96d6",
|
||||
"content-hash": "1dc6446e2bc72ec66b42ac1069ec70a0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "amphp/amp",
|
||||
|
|
@ -2930,6 +2930,73 @@
|
|||
},
|
||||
"time": "2024-04-12T12:12:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "defuse/php-encryption",
|
||||
"version": "v2.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/defuse/php-encryption.git",
|
||||
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828",
|
||||
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"paragonie/random_compat": ">= 2",
|
||||
"php": ">=5.6.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5|^6|^7|^8|^9|^10",
|
||||
"yoast/phpunit-polyfills": "^2.0.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/generate-defuse-key"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Defuse\\Crypto\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Hornby",
|
||||
"email": "taylor@defuse.ca",
|
||||
"homepage": "https://defuse.ca/"
|
||||
},
|
||||
{
|
||||
"name": "Scott Arciszewski",
|
||||
"email": "info@paragonie.com",
|
||||
"homepage": "https://paragonie.com"
|
||||
}
|
||||
],
|
||||
"description": "Secure PHP Encryption Library",
|
||||
"keywords": [
|
||||
"aes",
|
||||
"authenticated encryption",
|
||||
"cipher",
|
||||
"crypto",
|
||||
"cryptography",
|
||||
"encrypt",
|
||||
"encryption",
|
||||
"openssl",
|
||||
"security",
|
||||
"symmetric key cryptography"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/defuse/php-encryption/issues",
|
||||
"source": "https://github.com/defuse/php-encryption/tree/v2.4.0"
|
||||
},
|
||||
"time": "2023-06-19T06:10:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
|
|
@ -6323,6 +6390,65 @@
|
|||
],
|
||||
"time": "2025-12-27T15:18:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/event",
|
||||
"version": "3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/event.git",
|
||||
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
|
||||
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2.0",
|
||||
"psr/event-dispatcher": "^1.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/event-dispatcher-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^2.16",
|
||||
"phpstan/phpstan": "^0.12.45",
|
||||
"phpunit/phpunit": "^8.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Event\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frenky.net"
|
||||
}
|
||||
],
|
||||
"description": "Event package",
|
||||
"keywords": [
|
||||
"emitter",
|
||||
"event",
|
||||
"listener"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/event/issues",
|
||||
"source": "https://github.com/thephpleague/event/tree/3.0.3"
|
||||
},
|
||||
"time": "2024-09-04T16:06:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/html-to-markdown",
|
||||
"version": "5.1.1",
|
||||
|
|
@ -6477,6 +6603,187 @@
|
|||
},
|
||||
"time": "2025-11-25T22:17:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/oauth2-server",
|
||||
"version": "9.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/oauth2-server.git",
|
||||
"reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c",
|
||||
"reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"defuse/php-encryption": "^2.4",
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"lcobucci/jwt": "^5.6",
|
||||
"league/event": "^3.0",
|
||||
"league/uri": "^7.8",
|
||||
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"psr/clock": "^1.0",
|
||||
"psr/http-message": "^2.0",
|
||||
"psr/http-server-middleware": "^1.0"
|
||||
},
|
||||
"replace": {
|
||||
"league/oauth2server": "*",
|
||||
"lncd/oauth2": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laminas/laminas-diactoros": "^3.8",
|
||||
"paragonie/random_compat": "^9.99.100",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^2.1.38",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0.12",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.50",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"slevomat/coding-standard": "^8.27.1",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\OAuth2\\Server\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alex Bilbie",
|
||||
"email": "hello@alexbilbie.com",
|
||||
"homepage": "http://www.alexbilbie.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Andy Millington",
|
||||
"email": "andrew@noexceptions.io",
|
||||
"homepage": "https://www.noexceptions.io",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.",
|
||||
"homepage": "https://oauth2.thephpleague.com/",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"api",
|
||||
"auth",
|
||||
"authorisation",
|
||||
"authorization",
|
||||
"oauth",
|
||||
"oauth 2",
|
||||
"oauth 2.0",
|
||||
"oauth2",
|
||||
"protect",
|
||||
"resource",
|
||||
"secure",
|
||||
"server"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/oauth2-server/issues",
|
||||
"source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sephster",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-25T15:24:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/oauth2-server-bundle",
|
||||
"version": "v1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/oauth2-server-bundle.git",
|
||||
"reference": "6e1d8775ca64c9d93e4be9fadd7a0a15b8898532"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/oauth2-server-bundle/zipball/6e1d8775ca64c9d93e4be9fadd7a0a15b8898532",
|
||||
"reference": "6e1d8775ca64c9d93e4be9fadd7a0a15b8898532",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-openssl": "*",
|
||||
"league/oauth2-server": "^9.2",
|
||||
"nyholm/psr7": "^1.4",
|
||||
"php": "^8.1",
|
||||
"psr/http-factory": "^1.0",
|
||||
"symfony/deprecation-contracts": "^3.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.4|^8.0",
|
||||
"symfony/filesystem": "^6.4|^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.4|^8.0",
|
||||
"symfony/password-hasher": "^6.4|^7.4|^8.0",
|
||||
"symfony/psr-http-message-bridge": "^6.4|^7.4|^8.0",
|
||||
"symfony/security-bundle": "^6.4|^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/doctrine-bundle": "<2.8.0",
|
||||
"doctrine/orm": "<2.14",
|
||||
"symfony/console": "<6.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-bundle": "^2.8|^3.0",
|
||||
"doctrine/orm": "^2.14|^3.0",
|
||||
"ext-pdo": "*",
|
||||
"ext-pdo_sqlite": "*",
|
||||
"php-cs-fixer/shim": "^3.38",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"rector/rector": "^2.5",
|
||||
"symfony/browser-kit": "^6.4|^7.4|^8.0",
|
||||
"symfony/phpunit-bridge": "^8.1"
|
||||
},
|
||||
"suggest": {
|
||||
"doctrine/doctrine-bundle": "Allow usage of doctrine as persistence layer",
|
||||
"doctrine/orm": "Allow usage of doctrine as persistence layer"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Bundle\\OAuth2ServerBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Robin Chalas",
|
||||
"email": "robin.chalas@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "All contributors",
|
||||
"homepage": "https://github.com/thephpleague/oauth2-server-bundle/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony bundle .",
|
||||
"homepage": "https://github.com/thephpleague/oauth2-server-bundle",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authorization",
|
||||
"bundle",
|
||||
"oauth2",
|
||||
"server"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/oauth2-server-bundle/issues",
|
||||
"source": "https://github.com/thephpleague/oauth2-server-bundle/tree/v1.2.1"
|
||||
},
|
||||
"time": "2026-07-22T08:57:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/uri",
|
||||
"version": "7.8.1",
|
||||
|
|
@ -6906,29 +7213,36 @@
|
|||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "2.1.0",
|
||||
"version": "3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58"
|
||||
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/c4c5803cc1f93df3d2448478ef79394a5981cc58",
|
||||
"reference": "c4c5803cc1f93df3d2448478ef79394a5981cc58",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"myclabs/php-enum": "^1.5",
|
||||
"php": ">= 7.1",
|
||||
"psr/http-message": "^1.0",
|
||||
"symfony/polyfill-mbstring": "^1.0"
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"guzzlehttp/guzzle": ">= 6.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.16",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"phpunit/phpunit": ">= 7.5"
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
|
|
@ -6965,19 +7279,15 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/2.1.0"
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://opencollective.com/zipstream",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2020-05-30T13:11:16+00:00"
|
||||
"time": "2025-01-27T12:07:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
|
|
@ -7504,69 +7814,6 @@
|
|||
],
|
||||
"time": "2026-01-02T08:56:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/php-enum",
|
||||
"version": "1.8.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/myclabs/php-enum.git",
|
||||
"reference": "e7be26966b7398204a234f8673fdad5ac6277802"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802",
|
||||
"reference": "e7be26966b7398204a234f8673fdad5ac6277802",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^7.3 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"squizlabs/php_codesniffer": "1.*",
|
||||
"vimeo/psalm": "^4.6.2 || ^5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MyCLabs\\Enum\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"stubs/Stringable.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP Enum contributors",
|
||||
"homepage": "https://github.com/myclabs/php-enum/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PHP Enum implementation",
|
||||
"homepage": "https://github.com/myclabs/php-enum",
|
||||
"keywords": [
|
||||
"enum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/myclabs/php-enum/issues",
|
||||
"source": "https://github.com/myclabs/php-enum/tree/1.8.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/mnapoli",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-01-14T11:49:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nbgrp/onelogin-saml-bundle",
|
||||
"version": "v2.1.0",
|
||||
|
|
@ -8635,6 +8882,56 @@
|
|||
},
|
||||
"time": "2025-09-24T15:06:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/random_compat",
|
||||
"version": "v9.99.100",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paragonie/random_compat.git",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">= 7"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "4.*|5.*",
|
||||
"vimeo/psalm": "^1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
|
||||
},
|
||||
"type": "library",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paragon Initiative Enterprises",
|
||||
"email": "security@paragonie.com",
|
||||
"homepage": "https://paragonie.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
|
||||
"keywords": [
|
||||
"csprng",
|
||||
"polyfill",
|
||||
"pseudorandom",
|
||||
"random"
|
||||
],
|
||||
"support": {
|
||||
"email": "info@paragonie.com",
|
||||
"issues": "https://github.com/paragonie/random_compat/issues",
|
||||
"source": "https://github.com/paragonie/random_compat"
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "part-db/exchanger",
|
||||
"version": "v3.1.0",
|
||||
|
|
@ -9777,16 +10074,16 @@
|
|||
},
|
||||
{
|
||||
"name": "psr/http-message",
|
||||
"version": "1.1",
|
||||
"version": "2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-message.git",
|
||||
"reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba"
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
|
||||
"reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
|
||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -9795,7 +10092,7 @@
|
|||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.1.x-dev"
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
|
|
@ -9810,7 +10107,7 @@
|
|||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "http://www.php-fig.org/"
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP messages",
|
||||
|
|
@ -9824,9 +10121,9 @@
|
|||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-message/tree/1.1"
|
||||
"source": "https://github.com/php-fig/http-message/tree/2.0"
|
||||
},
|
||||
"time": "2023-04-04T09:50:52+00:00"
|
||||
"time": "2023-04-04T09:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-server-handler",
|
||||
|
|
|
|||
|
|
@ -35,4 +35,5 @@ return [
|
|||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
|
||||
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||
League\Bundle\OAuth2ServerBundle\LeagueOAuth2ServerBundle::class => ['all' => true],
|
||||
];
|
||||
|
|
|
|||
43
config/packages/league_oauth2_server.yaml
Normal file
43
config/packages/league_oauth2_server.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
## 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
|
||||
# NOT used - "persistence: in_memory" here just satisfies the bundle's config schema (it registers a set
|
||||
# of trivial in-memory managers that some of the bundle's console commands/services still need to exist,
|
||||
# even though we never use them). The actual League\OAuth2\Server\Repositories\* interfaces are overridden
|
||||
# in config/services.yaml to point at our own Doctrine-backed repositories (App\Security\OAuth\Repository),
|
||||
# which make an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken row - see
|
||||
# App\Security\OAuth\Repository\AccessTokenRepository for why.
|
||||
league_oauth2_server:
|
||||
authorization_server:
|
||||
private_key: '%kernel.project_dir%/var/oauth2/private.key'
|
||||
private_key_passphrase: null
|
||||
encryption_key: '%env(OAUTH2_ENCRYPTION_KEY)%'
|
||||
encryption_key_type: 'defuse'
|
||||
access_token_ttl: 'PT1H'
|
||||
refresh_token_ttl: 'P30D'
|
||||
auth_code_ttl: 'PT10M'
|
||||
# Only Authorization Code (+ PKCE) and Refresh Token are supported - no client secrets, no
|
||||
# password grant, no implicit grant, no device code.
|
||||
enable_client_credentials_grant: false
|
||||
enable_password_grant: false
|
||||
enable_implicit_grant: false
|
||||
enable_device_code_grant: false
|
||||
enable_auth_code_grant: true
|
||||
enable_refresh_token_grant: true
|
||||
require_code_challenge_for_public_clients: true
|
||||
revoke_refresh_tokens: true
|
||||
response_type_class: App\Security\OAuth\OpaqueBearerTokenResponse
|
||||
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'
|
||||
scopes:
|
||||
# Scopes are just the existing App\Entity\UserSystem\ApiTokenLevel enum - see App\Security\OAuth\OAuthScope.
|
||||
available: ['read_only', 'edit', 'admin', 'full']
|
||||
default: ['read_only']
|
||||
persistence:
|
||||
in_memory: ~
|
||||
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
|
||||
|
|
@ -1136,6 +1136,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* failure_forward?: bool|Param, // Default: false
|
||||
* failure_path_parameter?: scalar|Param|null, // Default: "_failure_path"
|
||||
* },
|
||||
* oauth2?: array<mixed>,
|
||||
* login_link?: array{
|
||||
* check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
|
||||
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
|
||||
|
|
@ -3230,6 +3231,60 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type LeagueOauth2ServerConfig = array{
|
||||
* authorization_server?: array{
|
||||
* private_key?: scalar|Param|null, // Full path to the private key file. How to generate a private key: https://oauth2.thephpleague.com/installation/#generating-public-and-private-keys
|
||||
* private_key_passphrase?: scalar|Param|null, // Passphrase of the private key, if any // Default: null
|
||||
* encryption_key?: scalar|Param|null, // The plain string or the ascii safe string used to create a Defuse\Crypto\Key to be used as an encryption key. How to generate an encryption key: https://oauth2.thephpleague.com/installation/#string-password
|
||||
* encryption_key_type?: scalar|Param|null, // The type of value of 'encryption_key' Should be either 'plain' or 'defuse' // Default: "plain"
|
||||
* access_token_ttl?: scalar|Param|null, // How long the issued access token should be valid for. The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters // Default: "PT1H"
|
||||
* refresh_token_ttl?: scalar|Param|null, // How long the issued refresh token should be valid for. The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters // Default: "P1M"
|
||||
* auth_code_ttl?: scalar|Param|null, // How long the issued auth code should be valid for. The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters // Default: "PT10M"
|
||||
* device_code_ttl?: scalar|Param|null, // How long the issued device code should be valid for. The value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters // Default: "PT10M"
|
||||
* enable_client_credentials_grant?: bool|Param, // Whether to enable the client credentials grant // Default: true
|
||||
* enable_password_grant?: bool|Param, // Whether to enable the password grant
|
||||
* enable_refresh_token_grant?: bool|Param, // Whether to enable the refresh token grant // Default: true
|
||||
* enable_auth_code_grant?: bool|Param, // Whether to enable the authorization code grant // Default: true
|
||||
* require_code_challenge_for_public_clients?: bool|Param, // Whether to require code challenge for public clients for the auth code grant // Default: true
|
||||
* enable_implicit_grant?: bool|Param, // Whether to enable the implicit grant
|
||||
* persist_access_token?: bool|Param, // Whether to enable access token saving to persistence layer // Default: true
|
||||
* response_type_class?: scalar|Param|null, // Define a custom ResponseType // Default: null
|
||||
* revoke_refresh_tokens?: bool|Param, // Whether to revoke refresh tokens after they were used for all grant types // Default: true
|
||||
* enable_device_code_grant?: bool|Param, // Whether to enable the device code grant // Default: false
|
||||
* device_code_verification_uri?: scalar|Param|null, // The full URI the user will need to visit to enter the user code // Default: ""
|
||||
* enable_device_code_verification_uri_complete_generation?: bool|Param, // Whether to enable the generation of verification_uri_complete // Default: true
|
||||
* device_code_polling_interval?: scalar|Param|null, // How soon (in seconds) can the device code be used to poll for the access token without being throttled // Default: 5
|
||||
* enable_device_code_polling_interval_visibility?: bool|Param, // Whether to enable the visibility of polling interval // Default: true
|
||||
* },
|
||||
* resource_server?: array{
|
||||
* public_key?: scalar|Param|null, // Full path to the public key file How to generate a public key: https://oauth2.thephpleague.com/installation/#generating-public-and-private-keys
|
||||
* jwt_leeway?: scalar|Param|null, // The leeway in seconds to allow for clock skew in JWT verification. Default PT0S (no leeway). // Default: null
|
||||
* },
|
||||
* scopes?: array{
|
||||
* available?: list<scalar|Param|null>,
|
||||
* default?: list<scalar|Param|null>,
|
||||
* },
|
||||
* persistence?: array{ // Configures different persistence methods that can be used by the bundle for saving client and token data. Only one persistence method can be configured at a time.
|
||||
* doctrine?: array{
|
||||
* entity_manager?: scalar|Param|null, // Name of the entity manager that you wish to use for managing clients and tokens. // Default: "default"
|
||||
* table_prefix?: scalar|Param|null, // Table name prefix. // Default: "oauth2_"
|
||||
* },
|
||||
* in_memory?: scalar|Param|null,
|
||||
* custom?: array{
|
||||
* access_token_manager?: scalar|Param|null, // Service id of the custom access token manager
|
||||
* authorization_code_manager?: scalar|Param|null, // Service id of the custom authorization code manager
|
||||
* client_manager?: scalar|Param|null, // Service id of the custom client manager
|
||||
* refresh_token_manager?: scalar|Param|null, // Service id of the custom refresh token manager
|
||||
* device_code_manager?: scalar|Param|null, // Service id of the custom device code manager
|
||||
* credentials_revoker?: scalar|Param|null, // Service id of the custom credentials revoker
|
||||
* },
|
||||
* },
|
||||
* client?: array{
|
||||
* classname?: scalar|Param|null, // Set a custom client class. Must be a League\Bundle\OAuth2ServerBundle\Model\AbstractClient // Default: "League\\Bundle\\OAuth2ServerBundle\\Model\\Client"
|
||||
* allow_plaintext_secrets?: bool|Param, // Whether to allow plaintext client secrets. // Default: true
|
||||
* },
|
||||
* role_prefix?: scalar|Param|null, // Set a custom prefix that replaces the default 'ROLE_OAUTH2_' role prefix // Default: "ROLE_OAUTH2_"
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
|
|
@ -3261,6 +3316,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* league_oauth2_server?: LeagueOauth2ServerConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
|
|
@ -3296,6 +3352,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* league_oauth2_server?: LeagueOauth2ServerConfig,
|
||||
* },
|
||||
* "when@docker"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3328,6 +3385,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* league_oauth2_server?: LeagueOauth2ServerConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3360,6 +3418,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* league_oauth2_server?: LeagueOauth2ServerConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
|
@ -3395,6 +3454,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||
* api_platform?: ApiPlatformConfig,
|
||||
* ai?: AiConfig,
|
||||
* mcp?: McpConfig,
|
||||
* league_oauth2_server?: LeagueOauth2ServerConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
|
|
|||
3
config/routes/league_oauth2_server.yaml
Normal file
3
config/routes/league_oauth2_server.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
oauth2_server:
|
||||
resource: '@LeagueOAuth2ServerBundle/config/routes.php'
|
||||
type: php
|
||||
|
|
@ -140,6 +140,22 @@ services:
|
|||
$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:
|
||||
class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor
|
||||
arguments:
|
||||
|
|
|
|||
279
migrations/Version20260726170000.php
Normal file
279
migrations/Version20260726170000.php
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
103
src/Command/OAuthGenerateKeysCommand.php
Normal file
103
src/Command/OAuthGenerateKeysCommand.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Defuse\Crypto\Key;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the underlying library's (unused, in our setup) JWT-signing code path - see
|
||||
* App\Security\OAuth\OpaqueBearerTokenResponse - so its content is not otherwise security-critical, but
|
||||
* the library still requires a syntactically valid keypair to exist at the configured paths.
|
||||
*/
|
||||
#[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
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%/var/oauth2/private.key')]
|
||||
private readonly string $privateKeyPath,
|
||||
#[Autowire('%kernel.project_dir%/var/oauth2/public.key')]
|
||||
private readonly string $publicKeyPath,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite an existing keypair');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
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).');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$dir = \dirname($this->privateKeyPath);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
$io->error(sprintf('Could not create directory "%s".', $dir));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$resource = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
if ($resource === false) {
|
||||
$io->error('Could not generate an RSA keypair: '.openssl_error_string());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
openssl_pkey_export($resource, $privateKeyPem);
|
||||
$publicKeyPem = openssl_pkey_get_details($resource)['key'];
|
||||
|
||||
file_put_contents($this->privateKeyPath, $privateKeyPem);
|
||||
chmod($this->privateKeyPath, 0600);
|
||||
file_put_contents($this->publicKeyPath, $publicKeyPem);
|
||||
chmod($this->publicKeyPath, 0600);
|
||||
|
||||
$io->success(sprintf('Generated a new OAuth2 server RSA keypair at "%s" / "%s".', $this->privateKeyPath, $this->publicKeyPath));
|
||||
|
||||
if (empty($_ENV['OAUTH2_ENCRYPTION_KEY'])) {
|
||||
$io->warning('OAUTH2_ENCRYPTION_KEY is not set. Add the following to your .env.local (keep it secret, do not change it later):');
|
||||
$io->writeln('OAUTH2_ENCRYPTION_KEY='.Key::createNewRandomKey()->saveToAsciiSafeString());
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ use ApiPlatform\OpenApi\Model\Operation;
|
|||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\Entity\Base\TimestampTrait;
|
||||
use App\Entity\Contracts\TimeStampableInterface;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Repository\UserSystem\ApiTokenRepository;
|
||||
use App\State\CurrentApiTokenProvider;
|
||||
use App\Validator\Constraints\Year2038BugWorkaround;
|
||||
|
|
@ -91,6 +92,15 @@ class ApiToken implements TimeStampableInterface
|
|||
#[Groups('token:read')]
|
||||
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)
|
||||
{
|
||||
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
|
||||
|
|
@ -195,5 +205,15 @@ class ApiToken implements TimeStampableInterface
|
|||
return substr($this->token, -4);
|
||||
}
|
||||
|
||||
public function getOauthClient(): ?OAuthClient
|
||||
{
|
||||
return $this->oauthClient;
|
||||
}
|
||||
|
||||
public function setOauthClient(?OAuthClient $oauthClient): self
|
||||
{
|
||||
$this->oauthClient = $oauthClient;
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -31,6 +31,13 @@ enum ApiTokenType: string
|
|||
{
|
||||
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
|
||||
* @return string
|
||||
|
|
|
|||
141
src/Entity/UserSystem/OAuth/OAuthAuthCode.php
Normal file
141
src/Entity/UserSystem/OAuth/OAuthAuthCode.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
161
src/Entity/UserSystem/OAuth/OAuthClient.php
Normal file
161
src/Entity/UserSystem/OAuth/OAuthClient.php
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
132
src/Entity/UserSystem/OAuth/OAuthRefreshToken.php
Normal file
132
src/Entity/UserSystem/OAuth/OAuthRefreshToken.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
29
src/Repository/UserSystem/OAuth/OAuthAuthCodeRepository.php
Normal file
29
src/Repository/UserSystem/OAuth/OAuthAuthCodeRepository.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?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
|
||||
{
|
||||
}
|
||||
29
src/Repository/UserSystem/OAuth/OAuthClientRepository.php
Normal file
29
src/Repository/UserSystem/OAuth/OAuthClientRepository.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?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
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?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
|
||||
{
|
||||
}
|
||||
49
src/Security/OAuth/Entity/OAuthUserEntity.php
Normal file
49
src/Security/OAuth/Entity/OAuthUserEntity.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
43
src/Security/OAuth/Entity/TransientAccessToken.php
Normal file
43
src/Security/OAuth/Entity/TransientAccessToken.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?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;
|
||||
}
|
||||
42
src/Security/OAuth/Entity/TransientAuthCode.php
Normal file
42
src/Security/OAuth/Entity/TransientAuthCode.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?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;
|
||||
}
|
||||
40
src/Security/OAuth/Entity/TransientRefreshToken.php
Normal file
40
src/Security/OAuth/Entity/TransientRefreshToken.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?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;
|
||||
}
|
||||
54
src/Security/OAuth/OAuthScope.php
Normal file
54
src/Security/OAuth/OAuthScope.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
90
src/Security/OAuth/OpaqueBearerTokenResponse.php
Normal file
90
src/Security/OAuth/OpaqueBearerTokenResponse.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
128
src/Security/OAuth/Repository/AccessTokenRepository.php
Normal file
128
src/Security/OAuth/Repository/AccessTokenRepository.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?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]);
|
||||
}
|
||||
}
|
||||
100
src/Security/OAuth/Repository/AuthCodeRepository.php
Normal file
100
src/Security/OAuth/Repository/AuthCodeRepository.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?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]);
|
||||
}
|
||||
}
|
||||
50
src/Security/OAuth/Repository/ClientRepository.php
Normal file
50
src/Security/OAuth/Repository/ClientRepository.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
172
src/Security/OAuth/Repository/RefreshTokenRepository.php
Normal file
172
src/Security/OAuth/Repository/RefreshTokenRepository.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
74
src/Security/OAuth/Repository/ScopeRepository.php
Normal file
74
src/Security/OAuth/Repository/ScopeRepository.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
13
symfony.lock
13
symfony.lock
|
|
@ -169,6 +169,19 @@
|
|||
"league/html-to-markdown": {
|
||||
"version": "4.8.2"
|
||||
},
|
||||
"league/oauth2-server-bundle": {
|
||||
"version": "0.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "0.4",
|
||||
"ref": "7f26963037fce3b69c1b3f6a75a3265edb1e1caa"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/league_oauth2_server.yaml",
|
||||
"config/routes/league_oauth2_server.yaml"
|
||||
]
|
||||
},
|
||||
"liip/imagine-bundle": {
|
||||
"version": "2.7",
|
||||
"recipe": {
|
||||
|
|
|
|||
246
tests/Security/OAuth/AuthorizationServerTest.php
Normal file
246
tests/Security/OAuth/AuthorizationServerTest.php
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<?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.');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue