From a5a4ad7b180731e80a60d731ef04ae540ffc4b5c Mon Sep 17 00:00:00 2001 From: IanM Date: Tue, 5 May 2026 14:55:43 +0100 Subject: [PATCH 1/2] wip: flarum oauth provider --- composer.json | 6 + extensions/oauth-provider/CHANGELOG.md | 12 + extensions/oauth-provider/LICENSE.md | 21 + extensions/oauth-provider/composer.json | 93 +++++ extensions/oauth-provider/extend.php | 43 ++ extensions/oauth-provider/js/admin.ts | 1 + extensions/oauth-provider/js/forum.ts | 1 + extensions/oauth-provider/js/package.json | 28 ++ .../src/admin/components/ClientFormModal.tsx | 129 ++++++ .../admin/components/NewClientSecretModal.tsx | 49 +++ .../src/admin/components/OAuthClientsPage.tsx | 178 ++++++++ .../oauth-provider/js/src/admin/extend.tsx | 12 + .../oauth-provider/js/src/admin/index.ts | 7 + .../js/src/admin/models/OAuthClient.ts | 12 + .../oauth-provider/js/src/forum/index.ts | 71 ++++ extensions/oauth-provider/js/tsconfig.json | 10 + .../oauth-provider/js/webpack.config.js | 1 + ...00_create_oauth_provider_clients_table.php | 26 ++ ...create_oauth_provider_auth_codes_table.php | 25 ++ ...ate_oauth_provider_access_tokens_table.php | 25 ++ ...te_oauth_provider_refresh_tokens_table.php | 25 ++ ...ate_oauth_provider_user_consents_table.php | 25 ++ .../2026_04_23_000000_add_oidc_columns.php | 32 ++ .../oauth-provider/resources/less/admin.less | 99 +++++ .../oauth-provider/resources/locale/en.yml | 48 +++ .../resources/views/consent.blade.php | 55 +++ .../src/Api/Resource/ClientResource.php | 157 ++++++++ .../oauth-provider/src/Extend/Scope.php | 40 ++ .../Http/Controller/AuthorizeController.php | 224 ++++++++++ .../src/Http/Controller/JwksController.php | 63 +++ .../OpenIdConfigurationController.php | 70 ++++ .../src/Http/Controller/TokenController.php | 35 ++ .../Http/Controller/UserInfoController.php | 67 +++ extensions/oauth-provider/src/KeyManager.php | 101 +++++ .../oauth-provider/src/Models/AccessToken.php | 64 +++ .../oauth-provider/src/Models/AuthCode.php | 57 +++ .../oauth-provider/src/Models/Client.php | 65 +++ .../oauth-provider/src/Models/Consent.php | 66 +++ .../src/Models/RefreshToken.php | 44 ++ .../Provider/OAuthProviderServiceProvider.php | 29 ++ .../src/Scope/ScopeRegistry.php | 43 ++ .../src/Server/AuthorizationServerFactory.php | 73 ++++ .../src/Server/Entity/AccessTokenEntity.php | 45 +++ .../src/Server/Entity/AuthCodeEntity.php | 45 +++ .../src/Server/Entity/ClientEntity.php | 35 ++ .../src/Server/Entity/RefreshTokenEntity.php | 20 + .../src/Server/Entity/ScopeEntity.php | 23 ++ .../src/Server/Entity/UserEntity.php | 18 + .../src/Server/Grant/OidcAuthCodeGrant.php | 89 ++++ .../src/Server/IdTokenBuilder.php | 100 +++++ .../Repository/AccessTokenRepository.php | 76 ++++ .../Server/Repository/AuthCodeRepository.php | 61 +++ .../Server/Repository/ClientRepository.php | 67 +++ .../Repository/RefreshTokenRepository.php | 57 +++ .../src/Server/Repository/ScopeRepository.php | 44 ++ .../src/Server/Repository/UserRepository.php | 32 ++ .../Server/ResponseType/IdTokenResponse.php | 37 ++ .../tests/.phpunit.cache/test-results | 1 + .../tests/integration/api/ClientCrudTest.php | 296 ++++++++++++++ .../forum/AuthorizeEndpointTest.php | 381 ++++++++++++++++++ .../tests/integration/forum/IdTokenTest.php | 248 ++++++++++++ .../integration/forum/OidcDiscoveryTest.php | 106 +++++ .../integration/forum/TokenExchangeTest.php | 294 ++++++++++++++ .../tests/integration/setup.php | 12 + .../tests/phpunit.integration.xml | 24 ++ .../oauth-provider/tests/phpunit.unit.xml | 23 ++ .../tests/unit/ClientModelTest.php | 50 +++ .../tests/unit/ConsentModelTest.php | 65 +++ .../tests/unit/ScopeRegistryTest.php | 59 +++ flarum-monorepo.json | 5 + 70 files changed, 4545 insertions(+) create mode 100644 extensions/oauth-provider/CHANGELOG.md create mode 100644 extensions/oauth-provider/LICENSE.md create mode 100644 extensions/oauth-provider/composer.json create mode 100644 extensions/oauth-provider/extend.php create mode 100644 extensions/oauth-provider/js/admin.ts create mode 100644 extensions/oauth-provider/js/forum.ts create mode 100644 extensions/oauth-provider/js/package.json create mode 100644 extensions/oauth-provider/js/src/admin/components/ClientFormModal.tsx create mode 100644 extensions/oauth-provider/js/src/admin/components/NewClientSecretModal.tsx create mode 100644 extensions/oauth-provider/js/src/admin/components/OAuthClientsPage.tsx create mode 100644 extensions/oauth-provider/js/src/admin/extend.tsx create mode 100644 extensions/oauth-provider/js/src/admin/index.ts create mode 100644 extensions/oauth-provider/js/src/admin/models/OAuthClient.ts create mode 100644 extensions/oauth-provider/js/src/forum/index.ts create mode 100644 extensions/oauth-provider/js/tsconfig.json create mode 100644 extensions/oauth-provider/js/webpack.config.js create mode 100644 extensions/oauth-provider/migrations/2026_04_22_000000_create_oauth_provider_clients_table.php create mode 100644 extensions/oauth-provider/migrations/2026_04_22_000001_create_oauth_provider_auth_codes_table.php create mode 100644 extensions/oauth-provider/migrations/2026_04_22_000002_create_oauth_provider_access_tokens_table.php create mode 100644 extensions/oauth-provider/migrations/2026_04_22_000003_create_oauth_provider_refresh_tokens_table.php create mode 100644 extensions/oauth-provider/migrations/2026_04_22_000005_create_oauth_provider_user_consents_table.php create mode 100644 extensions/oauth-provider/migrations/2026_04_23_000000_add_oidc_columns.php create mode 100644 extensions/oauth-provider/resources/less/admin.less create mode 100644 extensions/oauth-provider/resources/locale/en.yml create mode 100644 extensions/oauth-provider/resources/views/consent.blade.php create mode 100644 extensions/oauth-provider/src/Api/Resource/ClientResource.php create mode 100644 extensions/oauth-provider/src/Extend/Scope.php create mode 100644 extensions/oauth-provider/src/Http/Controller/AuthorizeController.php create mode 100644 extensions/oauth-provider/src/Http/Controller/JwksController.php create mode 100644 extensions/oauth-provider/src/Http/Controller/OpenIdConfigurationController.php create mode 100644 extensions/oauth-provider/src/Http/Controller/TokenController.php create mode 100644 extensions/oauth-provider/src/Http/Controller/UserInfoController.php create mode 100644 extensions/oauth-provider/src/KeyManager.php create mode 100644 extensions/oauth-provider/src/Models/AccessToken.php create mode 100644 extensions/oauth-provider/src/Models/AuthCode.php create mode 100644 extensions/oauth-provider/src/Models/Client.php create mode 100644 extensions/oauth-provider/src/Models/Consent.php create mode 100644 extensions/oauth-provider/src/Models/RefreshToken.php create mode 100644 extensions/oauth-provider/src/Provider/OAuthProviderServiceProvider.php create mode 100644 extensions/oauth-provider/src/Scope/ScopeRegistry.php create mode 100644 extensions/oauth-provider/src/Server/AuthorizationServerFactory.php create mode 100644 extensions/oauth-provider/src/Server/Entity/AccessTokenEntity.php create mode 100644 extensions/oauth-provider/src/Server/Entity/AuthCodeEntity.php create mode 100644 extensions/oauth-provider/src/Server/Entity/ClientEntity.php create mode 100644 extensions/oauth-provider/src/Server/Entity/RefreshTokenEntity.php create mode 100644 extensions/oauth-provider/src/Server/Entity/ScopeEntity.php create mode 100644 extensions/oauth-provider/src/Server/Entity/UserEntity.php create mode 100644 extensions/oauth-provider/src/Server/Grant/OidcAuthCodeGrant.php create mode 100644 extensions/oauth-provider/src/Server/IdTokenBuilder.php create mode 100644 extensions/oauth-provider/src/Server/Repository/AccessTokenRepository.php create mode 100644 extensions/oauth-provider/src/Server/Repository/AuthCodeRepository.php create mode 100644 extensions/oauth-provider/src/Server/Repository/ClientRepository.php create mode 100644 extensions/oauth-provider/src/Server/Repository/RefreshTokenRepository.php create mode 100644 extensions/oauth-provider/src/Server/Repository/ScopeRepository.php create mode 100644 extensions/oauth-provider/src/Server/Repository/UserRepository.php create mode 100644 extensions/oauth-provider/src/Server/ResponseType/IdTokenResponse.php create mode 100644 extensions/oauth-provider/tests/.phpunit.cache/test-results create mode 100644 extensions/oauth-provider/tests/integration/api/ClientCrudTest.php create mode 100644 extensions/oauth-provider/tests/integration/forum/AuthorizeEndpointTest.php create mode 100644 extensions/oauth-provider/tests/integration/forum/IdTokenTest.php create mode 100644 extensions/oauth-provider/tests/integration/forum/OidcDiscoveryTest.php create mode 100644 extensions/oauth-provider/tests/integration/forum/TokenExchangeTest.php create mode 100644 extensions/oauth-provider/tests/integration/setup.php create mode 100644 extensions/oauth-provider/tests/phpunit.integration.xml create mode 100644 extensions/oauth-provider/tests/phpunit.unit.xml create mode 100644 extensions/oauth-provider/tests/unit/ClientModelTest.php create mode 100644 extensions/oauth-provider/tests/unit/ConsentModelTest.php create mode 100644 extensions/oauth-provider/tests/unit/ScopeRegistryTest.php diff --git a/composer.json b/composer.json index f12dfa885b..8ada588af8 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "Flarum\\Mentions\\": "extensions/mentions/src", "Flarum\\Nicknames\\": "extensions/nicknames/src", "Flarum\\ExtensionManager\\": "extensions/package-manager/src", + "Flarum\\OAuthProvider\\": "extensions/oauth-provider/src", "Flarum\\Pusher\\": "extensions/pusher/src", "Flarum\\Realtime\\": "extensions/realtime/src", "Flarum\\Statistics\\": "extensions/statistics/src", @@ -75,6 +76,7 @@ "Flarum\\Mentions\\Tests\\": "extensions/mentions/tests", "Flarum\\Nicknames\\Tests\\": "extensions/nicknames/tests", "Flarum\\ExtensionManager\\Tests\\": "extensions/package-manager/tests", + "Flarum\\OAuthProvider\\Tests\\": "extensions/oauth-provider/tests", "Flarum\\Pusher\\Tests\\": "extensions/pusher/tests", "Flarum\\Realtime\\Tests\\": "extensions/realtime/tests", "Flarum\\Statistics\\Tests\\": "extensions/statistics/tests", @@ -102,6 +104,7 @@ "flarum/mentions": "self.version", "flarum/nicknames": "self.version", "flarum/extension-manager": "self.version", + "flarum/oauth-provider": "self.version", "flarum/pusher": "self.version", "flarum/realtime": "self.version", "flarum/statistics": "self.version", @@ -146,8 +149,10 @@ "laminas/laminas-diactoros": "^3.0", "laminas/laminas-httphandlerrunner": "^2.6", "laminas/laminas-stratigility": "^3.10", + "lcobucci/jwt": "^5.0", "league/flysystem": "^3.15", "league/flysystem-memory": "^3.15", + "league/oauth2-server": "^8.5", "matthiasmullie/minify": "^1.3", "middlewares/base-path": "^v2.1", "middlewares/base-path-router": "^2.0.1", @@ -204,6 +209,7 @@ "extensions/mentions", "extensions/nicknames", "extensions/package-manager", + "extensions/oauth-provider", "extensions/pusher", "extensions/realtime", "extensions/statistics", diff --git a/extensions/oauth-provider/CHANGELOG.md b/extensions/oauth-provider/CHANGELOG.md new file mode 100644 index 0000000000..7cdef3fc18 --- /dev/null +++ b/extensions/oauth-provider/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [Unreleased] + +### Added + +- Initial release. Turn Flarum into an OAuth 2.0 authorization server. +- Authorization code grant with optional PKCE (via league/oauth2-server). +- Refresh token grant. +- OpenID Connect-style `/oauth/userinfo` endpoint with `openid`, `profile`, and `email` scopes. +- Admin UI for managing OAuth client registrations. +- User consent screen before issuing authorization codes. diff --git a/extensions/oauth-provider/LICENSE.md b/extensions/oauth-provider/LICENSE.md new file mode 100644 index 0000000000..9fe7ef1c7c --- /dev/null +++ b/extensions/oauth-provider/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2021 Stichting Flarum (Flarum Foundation) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/oauth-provider/composer.json b/extensions/oauth-provider/composer.json new file mode 100644 index 0000000000..1e7e104a0f --- /dev/null +++ b/extensions/oauth-provider/composer.json @@ -0,0 +1,93 @@ +{ + "name": "flarum/oauth-provider", + "description": "Turn Flarum into an OAuth 2.0 / OpenID Connect provider.", + "type": "flarum-extension", + "keywords": [ + "oauth", + "oauth2", + "openid", + "sso", + "authentication" + ], + "license": "MIT", + "support": { + "issues": "https://github.com/flarum/framework/issues", + "source": "https://github.com/flarum/oauth-provider", + "forum": "https://discuss.flarum.org" + }, + "homepage": "https://flarum.org", + "funding": [ + { + "type": "website", + "url": "https://flarum.org/donate/" + } + ], + "require": { + "flarum/core": "^2.0.0-rc.1", + "league/oauth2-server": "^8.5", + "lcobucci/jwt": "^4.3 || ^5.0" + }, + "autoload": { + "psr-4": { + "Flarum\\OAuthProvider\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + }, + "flarum-extension": { + "title": "OAuth Provider", + "category": "authentication", + "icon": { + "name": "fas fa-key", + "backgroundColor": "#3B4252", + "color": "#ffffff" + } + }, + "flarum-cli": { + "modules": { + "admin": true, + "forum": true, + "js": true, + "jsCommon": false, + "css": true, + "gitConf": true, + "githubActions": true, + "prettier": true, + "typescript": true, + "bundlewatch": false, + "backendTesting": true, + "editorConfig": true, + "styleci": true + } + } + }, + "scripts": { + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit -c tests/phpunit.unit.xml", + "test:integration": "phpunit -c tests/phpunit.integration.xml", + "test:setup": "@php tests/integration/setup.php" + }, + "require-dev": { + "flarum/testing": "^2.0", + "flarum/core": "2.x-dev" + }, + "scripts-descriptions": { + "test": "Runs all tests.", + "test:unit": "Runs all unit tests.", + "test:integration": "Runs all integration tests.", + "test:setup": "Sets up a database for use with integration tests. Execute this only once." + }, + "repositories": [ + { + "type": "path", + "url": "../../*/*" + } + ], + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/extensions/oauth-provider/extend.php b/extensions/oauth-provider/extend.php new file mode 100644 index 0000000000..abf4e1465c --- /dev/null +++ b/extensions/oauth-provider/extend.php @@ -0,0 +1,43 @@ +js(__DIR__.'/js/dist/admin.js') + ->css(__DIR__.'/resources/less/admin.less') + ->jsDirectory(__DIR__.'/js/dist/admin'), + + (new Extend\Frontend('forum')) + ->js(__DIR__.'/js/dist/forum.js'), + + new Extend\Locales(__DIR__.'/resources/locale'), + + (new Extend\Routes('forum')) + ->get('/oauth/authorize', 'oauthProvider.authorize', Http\Controller\AuthorizeController::class) + ->post('/oauth/authorize', 'oauthProvider.authorize.post', Http\Controller\AuthorizeController::class) + ->post('/oauth/token', 'oauthProvider.token', Http\Controller\TokenController::class) + ->get('/oauth/userinfo', 'oauthProvider.userinfo', Http\Controller\UserInfoController::class) + ->get('/.well-known/openid-configuration', 'oauthProvider.discovery', Http\Controller\OpenIdConfigurationController::class) + ->get('/.well-known/jwks.json', 'oauthProvider.jwks', Http\Controller\JwksController::class), + + (new Extend\Csrf()) + ->exemptRoute('oauthProvider.token'), + + new Extend\ApiResource(Api\Resource\ClientResource::class), + + (new Extend\View()) + ->namespace('flarum-oauth-provider', __DIR__.'/resources/views'), + + (new Extend\ServiceProvider()) + ->register(Provider\OAuthProviderServiceProvider::class), +]; diff --git a/extensions/oauth-provider/js/admin.ts b/extensions/oauth-provider/js/admin.ts new file mode 100644 index 0000000000..3e69ff3b97 --- /dev/null +++ b/extensions/oauth-provider/js/admin.ts @@ -0,0 +1 @@ +export * from './src/admin'; diff --git a/extensions/oauth-provider/js/forum.ts b/extensions/oauth-provider/js/forum.ts new file mode 100644 index 0000000000..facb26fab0 --- /dev/null +++ b/extensions/oauth-provider/js/forum.ts @@ -0,0 +1 @@ +export * from './src/forum'; diff --git a/extensions/oauth-provider/js/package.json b/extensions/oauth-provider/js/package.json new file mode 100644 index 0000000000..c22c3c498b --- /dev/null +++ b/extensions/oauth-provider/js/package.json @@ -0,0 +1,28 @@ +{ + "name": "@flarum/oauth-provider", + "version": "0.0.0", + "private": true, + "prettier": "@flarum/prettier-config", + "devDependencies": { + "prettier": "^2.5.1", + "flarum-webpack-config": "^3.0.0", + "webpack": "^5.104.1", + "webpack-cli": "^4.9.1", + "@flarum/prettier-config": "^1.0.0", + "flarum-tsconfig": "^2.0.0", + "typescript": "^4.5.4", + "typescript-coverage-report": "^0.6.1" + }, + "scripts": { + "dev": "webpack --mode development --watch", + "build": "webpack --mode production", + "analyze": "cross-env ANALYZER=true yarn run build", + "format": "prettier --write src", + "format-check": "prettier --check src", + "clean-typings": "npx rimraf dist-typings && mkdir dist-typings", + "build-typings": "yarn run clean-typings && ([ -e src/@types ] && cp -r src/@types dist-typings/@types || true) && tsc && yarn run post-build-typings", + "post-build-typings": "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i 's,../src/@types,@types,g'", + "check-typings": "tsc --noEmit --emitDeclarationOnly false", + "check-typings-coverage": "typescript-coverage-report" + } +} diff --git a/extensions/oauth-provider/js/src/admin/components/ClientFormModal.tsx b/extensions/oauth-provider/js/src/admin/components/ClientFormModal.tsx new file mode 100644 index 0000000000..6c43477515 --- /dev/null +++ b/extensions/oauth-provider/js/src/admin/components/ClientFormModal.tsx @@ -0,0 +1,129 @@ +import app from 'flarum/admin/app'; +import FormModal, { IFormModalAttrs } from 'flarum/common/components/FormModal'; +import Button from 'flarum/common/components/Button'; +import Switch from 'flarum/common/components/Switch'; +import Stream from 'flarum/common/utils/Stream'; +import type Mithril from 'mithril'; + +import OAuthClient from '../models/OAuthClient'; + +interface ClientFormModalAttrs extends IFormModalAttrs { + client?: OAuthClient; + onSaved: (client: OAuthClient, plainSecret: string | null) => void; +} + +export default class ClientFormModal extends FormModal { + name!: Stream; + redirectUris!: Stream; + scopes!: Stream; + confidential!: Stream; + revoked!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + const client = this.attrs.client; + + this.name = Stream(client?.name() ?? ''); + this.redirectUris = Stream((client?.redirectUris() ?? []).join('\n')); + this.scopes = Stream((client?.scopes() ?? ['openid', 'profile', 'email']).join(' ')); + this.confidential = Stream(client?.confidential() ?? true); + this.revoked = Stream(client?.revoked() ?? false); + } + + className() { + return 'ClientFormModal Modal--medium'; + } + + title() { + return this.attrs.client + ? app.translator.trans('flarum-oauth-provider.admin.form.edit_title') + : app.translator.trans('flarum-oauth-provider.admin.form.new_title'); + } + + content() { + return ( +
+
+
+ + +
+ +
+ +