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 ( +
+
+
+ + +
+ +
+ +