Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions backend/app/Chat/OidcProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

/*
* This file is part of FeatherPanel.
*
* Copyright (C) 2025 MythicalSystems Studios
* Copyright (C) 2025 FeatherPanel Contributors
* Copyright (C) 2025 Cassian Gherman (aka NaysKutzu)
*
* 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.
*
* See the LICENSE file or <https://www.gnu.org/licenses/>.
*/

namespace App\Chat;

use App\App;

/**
* OIDC provider model for CRUD operations on the featherpanel_oidc_providers table.
*/
class OidcProvider
{
private static string $table = 'featherpanel_oidc_providers';

/**
* Create a new OIDC provider.
*
* @param array $data
*
* @return int|false
*/
public static function createProvider(array $data): int | false
{
$required = ['uuid', 'name', 'issuer_url', 'client_id', 'client_secret'];
foreach ($required as $field) {
if (!isset($data[$field]) || !is_string($data[$field]) || trim($data[$field]) === '') {
return false;
}
}

$pdo = Database::getPdoConnection();
$fields = array_keys($data);
$placeholders = array_map(fn ($f) => ':' . $f, $fields);
$sql = 'INSERT INTO ' . self::$table . ' (' . implode(', ', $fields) . ') VALUES (' . implode(', ', $placeholders) . ')';
$stmt = $pdo->prepare($sql);

if ($stmt->execute($data)) {
return (int) $pdo->lastInsertId();
}

return false;
}

/**
* Update provider by UUID.
*/
public static function updateProvider(string $uuid, array $data): bool
{
if (empty($data)) {
return false;
}
unset($data['id'], $data['uuid']);

$pdo = Database::getPdoConnection();
$fields = array_keys($data);
$set = implode(', ', array_map(fn ($f) => "$f = :$f", $fields));
$sql = 'UPDATE ' . self::$table . ' SET ' . $set . ' WHERE uuid = :uuid';

$params = $data;
$params['uuid'] = $uuid;
$stmt = $pdo->prepare($sql);

return $stmt->execute($params);
}

/**
* Delete provider by UUID.
*/
public static function deleteProvider(string $uuid): bool
{
$pdo = Database::getPdoConnection();
$stmt = $pdo->prepare('DELETE FROM ' . self::$table . ' WHERE uuid = :uuid');

return $stmt->execute(['uuid' => $uuid]);
}

/**
* Get provider by UUID.
*/
public static function getProviderByUuid(string $uuid): ?array
{
$pdo = Database::getPdoConnection();
$stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE uuid = :uuid LIMIT 1');
$stmt->execute(['uuid' => $uuid]);

return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
}

/**
* Get all providers.
*/
public static function getAllProviders(): array
{
$pdo = Database::getPdoConnection();
$stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' ORDER BY name ASC');
$stmt->execute();

return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}

/**
* Get all enabled providers (safe for public exposure).
*/
public static function getEnabledProviders(): array
{
$pdo = Database::getPdoConnection();
$stmt = $pdo->prepare('SELECT uuid, name FROM ' . self::$table . " WHERE enabled = 'true' ORDER BY name ASC");
$stmt->execute();

return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}

/**
* Generate a UUID for providers.
*/
public static function generateUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0F | 0x40);
$bytes[8] = chr(ord($bytes[8]) & 0x3F | 0x80);
$hex = bin2hex($bytes);

return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate generateUuid across multiple model classes

Low Severity

OidcProvider::generateUuid() is an exact copy of User::generateUuid(), which itself is identical to existing implementations in Node::generateUuid(), Server::generateUuid(), and Spell::generateUuid(). This PR adds two more copies of the same UUID v4 generation logic instead of extracting it to a shared utility.

Additional Locations (1)

Fix in Cursor Fix in Web

}

22 changes: 21 additions & 1 deletion backend/app/Chat/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public static function createUser(array $data, bool $skipEmailValidation = false
}

// Add optional fields if provided
$optionalFields = ['role_id', 'avatar', 'remember_token', 'first_ip', 'last_ip', 'banned', 'two_fa_enabled', 'two_fa_key', 'external_id', 'ticket_signature'];
$optionalFields = ['role_id', 'avatar', 'remember_token', 'first_ip', 'last_ip', 'banned', 'two_fa_enabled', 'two_fa_key', 'external_id', 'ticket_signature', 'oidc_provider', 'oidc_subject', 'oidc_email'];
foreach ($optionalFields as $field) {
if (isset($data[$field])) {
$insert[$field] = $data[$field];
Expand Down Expand Up @@ -411,4 +411,24 @@ public static function generateAccountToken(): string

return $tokenID;
}

/**
* Generate a cryptographically secure version 4 UUID.
*/
public static function generateUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0F | 0x40);
$bytes[8] = chr(ord($bytes[8]) & 0x3F | 0x80);
$hex = bin2hex($bytes);

return sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12)
);
}
}
20 changes: 20 additions & 0 deletions backend/app/Config/ConfigInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,26 @@ interface ConfigInterface
public const DISCORD_OAUTH_CLIENT_ID = 'discord_oauth_client_id';
public const DISCORD_OAUTH_CLIENT_SECRET = 'discord_oauth_client_secret';

/**
* OpenID Connect (OIDC) - generic SSO provider.
*
* These settings allow configuring a single OIDC provider in a
* provider-agnostic way (Keycloak, Authentik, Azure AD, etc.).
*/
public const OIDC_ENABLED = 'oidc_enabled';
public const OIDC_PROVIDER_NAME = 'oidc_provider_name';
public const OIDC_ISSUER_URL = 'oidc_issuer_url';
public const OIDC_CLIENT_ID = 'oidc_client_id';
public const OIDC_CLIENT_SECRET = 'oidc_client_secret';
public const OIDC_SCOPES = 'oidc_scopes';
public const OIDC_AUTO_PROVISION = 'oidc_auto_provision';
public const OIDC_REQUIRE_EMAIL_VERIFIED = 'oidc_require_email_verified';
public const OIDC_EMAIL_CLAIM = 'oidc_email_claim';
public const OIDC_SUBJECT_CLAIM = 'oidc_subject_claim';
public const OIDC_ALLOWED_GROUP_CLAIM = 'oidc_allowed_group_claim';
public const OIDC_ALLOWED_GROUP_VALUE = 'oidc_allowed_group_value';
public const OIDC_DISABLE_LOCAL_LOGIN = 'oidc_disable_local_login';

/**
* Servers Related Configs.
*/
Expand Down
8 changes: 8 additions & 0 deletions backend/app/Config/PublicConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ public static function getPublicSettingsWithDefaults(): array
ConfigInterface::DISCORD_OAUTH_ENABLED => 'false',
ConfigInterface::DISCORD_OAUTH_CLIENT_ID => 'XXXX',

// OIDC (generic OpenID Connect SSO) settings
// Only non-sensitive values are exposed here.
ConfigInterface::OIDC_ENABLED => 'false',
// Human friendly name displayed on the login button (e.g. "SSO", "Company SSO").
ConfigInterface::OIDC_PROVIDER_NAME => 'SSO',
// When true, hide/disable the local username/password login form for non-admins.
ConfigInterface::OIDC_DISABLE_LOCAL_LOGIN => 'false',

// Servers related settings
ConfigInterface::SERVER_ALLOW_EGG_CHANGE => 'false',
ConfigInterface::SERVER_ALLOW_STARTUP_CHANGE => 'true',
Expand Down
Loading
Loading