To begin, I have nothing but love and admiration for Flarum and this extension, if you're using a more enterprise IDP/etc. then Passport >>>>>>>>>> OAuth by miles, trust, just don't even try. I also don't care if anyone wants to take, improve, and formally publish my code. I'm not proficient in PHP by any meaningful degree and got through this after a day of reading Flarum's API docs, PHP src code, and trial-and-error.
For anyone looking to use Cognito I'm doing a write-up and YouTube series soon, but the nitty gritty Flarum-centric stuff I had to do was:
1.) Create a standalone PHP script to sync new/modified users on an every-minute basis
2.) Update the PassportController.php to properly ingest my Cognito attributes
3.) Disable open enrollment (Users could register via Cogntio, but there was no backend-driven way to keep all values locked and auto-enroll, sadge)
UPDATE settings SET value = '0' WHERE
key= 'allow_sign_up';
4.) Have these Cognito / Flarum attribute associations:
sub / username, nickname / nickname, email / email (Let Flarum handle its own local id field from users table)
5.) Make my own domain.tld/profile.html page to allow Cognito enrollment, MFA enablement, and nickname changes
Infra spans CloudFront, WAF, EC2 (VPC Origin), Cognito, SES, and S3. Main reason for doing this is my Flarum forum will be just one portion of my web ecosystem, forum.domain.tld, but other areas of my site will maintain the same users so people only have one login. This is good because say for my games.domain.tld (CloudFront + S3-hosted Godot games) I can still federate with Cognito in-game for leaderboards, cross-posts in Flarum, etc.
Here's my custom cognito.php which I have the cron job set up with, NOTE that there are far better ways to authenticate the PHP SDK with Cognito and this is a work in progress, but the meat will still stand:
<?php
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Flarum\User\User;
use Illuminate\Database\Capsule\Manager as DB;
require '/var/www/html/flarum/vendor/autoload.php';
$server = require '/var/www/html/flarum/site.php';
$server->bootApp();
DB::connection()->getPdo();
$cognitoUserPoolId = '$YourCognitoUserPoolId';
$awsKey = '$YourCognitoIamUserAccessKey';
$awsSecret = '$YourCognitoIamUserSecretKey';
$awsRegion = '$YourCognitoRegion';
$cognito = new CognitoIdentityProviderClient([
'version' => '2016-04-18',
'region' => $awsRegion,
'credentials' => [
'key' => $awsKey,
'secret' => $awsSecret,
],
]);
$result = $cognito->listUsers([
'UserPoolId' => $cognitoUserPoolId,
]);
$hasher = resolve(\Illuminate\Hashing\HashManager::class);
foreach ($result['Users'] as $cognitoUser) {
$sub = null;
$email = null;
$nickname = null;
foreach ($cognitoUser['Attributes'] as $attr) {
if ($attr['Name'] === 'sub') {
$sub = $attr['Value'];
} elseif ($attr['Name'] === 'email') {
$email = $attr['Value'];
} elseif ($attr['Name'] === 'nickname') {
$nickname = $attr['Value'];
}
}
if (!$sub || !$email) {
continue;
}
$user = User::where('username', $sub)->first();
if ($user) {
$updated = false;
$emailExists = User::where('email', $email)->where('id', '!=', $user->id)->exists();
if (!$emailExists && $user->email !== $email) {
$user->email = $email;
$updated = true;
}
if ($user->nickname !== $nickname) {
$user->nickname = $nickname;
$updated = true;
}
if (!$user->joined_at) {
$user->joined_at = now();
$updated = true;
}
if ($updated) {
$user->save();
echo "Updated user: $sub (Email: $email, Nickname: $nickname, Joined: {$user->joined_at})\n";
} else {
echo "No updates needed for: $sub\n";
}
} else {
if (User::where('email', $email)->exists()) {
echo "Skipped user creation for $sub - Email already exists.\n";
continue;
}
$user = new User();
$user->username = $sub;
$user->email = $email;
$user->nickname = $nickname;
$user->is_email_confirmed = true;
$user->password = $hasher->make(uniqid());
$user->joined_at = now();
$user->save();
}
}
shell_exec('php /var/www/html/flarum/flarum cache:clear');
And my modified PassportController.php code:
<?php
namespace FoF\Passport\Controllers;
use Exception;
use Flarum\Forum\Auth\Registration;
use Flarum\Forum\Auth\ResponseFactory;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use FoF\Passport\Events\SendingResponse;
use FoF\Passport\Providers\PassportProvider;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
class PassportController implements RequestHandlerInterface
{
protected $settings;
protected $response;
protected $events;
protected $url;
public function __construct(ResponseFactory $response, SettingsRepositoryInterface $settings, Dispatcher $events, UrlGenerator $url)
{
$this->response = $response;
$this->settings = $settings;
$this->events = $events;
$this->url = $url;
}
protected function getProvider($redirectUri)
{
return new PassportProvider([
'clientId' => $this->settings->get('fof-passport.app_id'),
'clientSecret' => $this->settings->get('fof-passport.app_secret'),
'redirectUri' => $redirectUri,
'settings' => $this->settings,
]);
}
protected function getAuthorizationUrlOptions()
{
$scopes = $this->settings->get('fof-passport.app_oauth_scopes', '');
return ['scope' => $scopes];
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$redirectUri = $this->url->to('forum')->route('auth.passport');
$provider = $this->getProvider($redirectUri);
$session = $request->getAttribute('session');
$queryParams = $request->getQueryParams();
if ($error = Arr::get($queryParams, 'error')) {
$hint = Arr::get($queryParams, 'hint');
throw new Exception("$error: $hint");
}
$code = Arr::get($queryParams, 'code');
if (!$code) {
$authUrl = $provider->getAuthorizationUrl($this->getAuthorizationUrlOptions());
$session->put('oauth2state', $provider->getState());
return new RedirectResponse($authUrl);
}
$state = Arr::get($queryParams, 'state');
if (!$state || $state !== $session->get('oauth2state')) {
$session->remove('oauth2state');
throw new Exception('Invalid state');
}
$token = $provider->getAccessToken('authorization_code', compact('code'));
$user = $provider->getResourceOwner($token);
$email = $user->getEmail() ?? null;
$nickname = $user->toArray()['nickname'] ?? null;
$sub = $user->getId() ?? $user->toArray()['sub'] ?? null;
$identifier = $sub ?? $email ?? 'unknown-user-' . uniqid();
$logger = new Logger('Cognito');
$logger->pushHandler(new StreamHandler(__DIR__ . '/../../../../storage/logs/cognito.log', Logger::INFO));
$logger->info('Cognito User Data:', [
'email' => $email,
'nickname' => $nickname,
'sub' => $sub,
'identifier' => $identifier
]);
$response = $this->response->make(
'passport',
$identifier,
function (Registration $registration) use ($email, $identifier) {
$registration
->provideTrustedEmail($email)
->setPayload(['sub' => $identifier])
->suggestUsername($identifier);
}
);
$this->events->dispatch(new SendingResponse($response, $user, $token));
return $response;
}
}
This was all in done in direct response to much frustration with Cognito's meeting with the FoF OAuth + GenericOAuth plugins, and even an attempt at my own FoF OAuth generic spinoff; FoF Passport has been MUCH better. Just got done (1) Registering new user in Cognito via my custom /profile.html page hosted in S3, (2) Having the cron job detect and create the user, and (3) Being able to login as this new user.
More to come, and if curious, where would be the best place to post a guide in the next week or two? Asking because I've seen some posts mention Cognito issues and at least wanted to make this post in FoF Passport so people facing issues similar to me have an idea of a path forward. I also find the Flarum Username/Nickname divide... Western-centric? Really not sure why the Username can't be anything URL-encode-able. The unnecessary Username field is so meh.
EDIT: Update using EC2 Instance Profile:
<?php
use Aws\Sdk;
use Aws\Credentials\CredentialProvider;
use Aws\CognitoIdentityProvider\CognitoIdentityProviderClient;
use Flarum\User\User;
use Illuminate\Database\Capsule\Manager as DB;
use Carbon\Carbon;
require '/var/www/html/flarum/vendor/autoload.php';
$server = require '/var/www/html/flarum/site.php';
$server->bootApp();
DB::connection()->getPdo();
$provider = CredentialProvider::instanceProfile();
$sdk = new Sdk([
'region' => 'us-west-2',
'version' => 'latest',
'credentials' => $provider,
]);
$client = $sdk->createCognitoIdentityProvider();
$cognitoUserPoolId = '$YourPoolId';
try {
$result = $client->listUsers([
'UserPoolId' => $cognitoUserPoolId,
]);
} catch (Exception $e) {
die("Error fetching Cognito users: " . $e->getMessage() . "\n");
}
$hasher = resolve(\Illuminate\Hashing\HashManager::class);
foreach ($result['Users'] as $cognitoUser) {
$sub = null;
$email = null;
$nickname = null;
foreach ($cognitoUser['Attributes'] as $attr) {
if ($attr['Name'] === 'sub') {
$sub = $attr['Value'];
} elseif ($attr['Name'] === 'email') {
$email = $attr['Value'];
} elseif ($attr['Name'] === 'nickname') {
$nickname = $attr['Value'];
}
}
$user = User::where('username', $sub)->first();
if ($user) {
$updated = false;
$emailExists = User::where('email', $email)->where('id', '!=', $user->id)->exists();
if (!$emailExists && $user->email !== $email) {
$user->email = $email;
$updated = true;
}
if ($user->nickname !== $nickname) {
$user->nickname = $nickname;
$updated = true;
}
if (!$user->joined_at) {
$user->joined_at = Carbon::now();
$updated = true;
}
if ($updated) {
$user->save();
}
} else {
if (User::where('email', $email)->exists()) {
continue;
}
$user = new User();
$user->username = $sub;
$user->email = $email;
$user->nickname = $nickname;
$user->is_email_confirmed = true;
$user->password = $hasher->make(uniqid());
$user->joined_at = Carbon::now();
$user->save();
}
}
shell_exec('php /var/www/html/flarum/flarum cache:clear');
Edit 2: Oh lawdy plz just let us use actual URL-encoded usernames, I feel so cursed but here's a real dirty Nickname slug driver to tie all this together:
Prep:
cd /var/www/html/flarum
sudo -u www-data mkdir extend && cd extend
sudo -u www-data nano NicknameSlugDriver.php
NicknameSlugDriver.php:
<?php
namespace Extend\NicknameSlugDriver;
use Flarum\User\User;
use Flarum\Http\SlugDriverInterface;
use Flarum\Database\AbstractModel;
class NicknameSlugDriver implements SlugDriverInterface
{
public function toSlug($model): string
{
return $model->nickname ?: $model->username;
}
public function fromSlug(string $slug, User $actor): AbstractModel
{
return User::where('nickname', $slug)
->orWhere('username', $slug)
->first();
}
}
Flarum root extend.php:
<?php
use Flarum\Extend;
use Flarum\User\User;
use Extend\NicknameSlugDriver\NicknameSlugDriver;
return [
(new Extend\ModelUrl(User::class))
->addSlugDriver('nickname', NicknameSlugDriver::class),
];
Flarum root composer.json, add this section before requires:
"autoload": {
"psr-4": {
"Extend\\NicknameSlugDriver\\": "extend/"
}
},
Deploy as below, then go in Admin -> Basic and change to nickname:
sudo -u www-data composer dump-autload
sudo -u www-data flarum cache:clear
I really hope FlarumV2 or 2.0 or whatever the future is streamlines enterprise OAuth/SSO/etc.