release(v3.2.4): OIDC group-claim mapping + extra scopes (Authentik & Keycloak-friendly) + sponsor list update

- OIDC: add configurable group claim + extra scopes (Admin + env overrides)
- OIDC: extract group tags from both userinfo and ID token, supports dot-path claims (e.g. realm_access.roles)
- Admin: surface effective & locked groupClaim + extraScopes values and include them in OIDC debug snapshot
- Docs OpenAPI: document new OIDC config fields
- Admin: add new Pro supporter name to thanks list
This commit is contained in:
Ryan
2026-01-30 00:29:37 -05:00
committed by GitHub
parent 55ba6918e3
commit 7b63de5584
9 changed files with 373 additions and 22 deletions
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## Changes 01/30/2026 (v3.2.4)
`release(v3.2.4): OIDC group-claim mapping + extra scopes (Authentik & Keycloak-friendly) + sponsor list update`
**Commit message**
```text
release(v3.2.4): OIDC group-claim mapping + extra scopes (Authentik & Keycloak-friendly) + sponsor list update
- OIDC: add configurable group claim + extra scopes (Admin + env overrides)
- OIDC: extract group tags from both userinfo and ID token, supports dot-path claims (e.g. realm_access.roles)
- Admin: surface effective & locked groupClaim + extraScopes values and include them in OIDC debug snapshot
- Docs OpenAPI: document new OIDC config fields
- Admin: add new Pro supporter name to thanks list
```
**Added**
- **OIDC: configurable group claim name**
- Admin setting: `oidc.groupClaim` (default behavior remains `groups`)
- Env override: `FR_OIDC_GROUP_CLAIM` (locks Admin field when set)
- Supports **dot-path claims** (example: `realm_access.roles`)
- **OIDC: extra scopes**
- Admin setting: `oidc.extraScopes` (space/comma separated)
- Env override: `FR_OIDC_EXTRA_SCOPES` (locks Admin field when set)
- Effective scopes become: `openid profile email` + your extras
- **OIDC debug snapshot improvements**
- `/api/admin/oidcDebugInfo.php` now shows:
- `groupClaim` + source (`env|config|default`)
- `extraScopes` + source (`env|config|none`)
- final `scopes[]` list
**Changed**
- **Group mapping reads both claim sets**
- Group tags are extracted from:
- Userinfo response, and
- ID Token payload (when available from the OIDC library)
- This improves compatibility with IdPs that only place groups/roles in one of those.
**Fixed**
- Group mapping reliability with IdPs like Authentik/Keycloak where:
- groups are not under the default `groups` claim, and/or
- groups require requesting an additional scope.
**Security / Hardening**
- `groupClaim` and `extraScopes` inputs are sanitized on save (control chars stripped + length capped).
- No user-controlled HTML is introduced; config values are escaped in the Admin UI.
- No secrets are logged or echoed back.
---
## Changes 01/29/2026 (v3.2.3)
`release(v3.2.3): resumable upload UX fixes + stale chunk cleanup + folder re-upload conflict handling (closes #100, closes #101, closes #102)`
+6
View File
@@ -83,6 +83,12 @@ if (!defined('FR_OIDC_GROUP_CLAIM')) {
);
}
// Optional extra OIDC scopes to request (space/comma separated).
if (!defined('FR_OIDC_EXTRA_SCOPES')) {
$envVal = getenv('FR_OIDC_EXTRA_SCOPES');
define('FR_OIDC_EXTRA_SCOPES', ($envVal !== false) ? trim((string)$envVal) : '');
}
// Name of an IdP group that should be treated as "FileRise admin".
if (!defined('FR_OIDC_ADMIN_GROUP')) {
$envVal = getenv('FR_OIDC_ADMIN_GROUP');
+49 -1
View File
@@ -69,6 +69,50 @@ try {
$loginOptions = is_array($cfg['loginOptions'] ?? null) ? $cfg['loginOptions'] : [];
$envGroupClaim = getenv('FR_OIDC_GROUP_CLAIM');
$cfgGroupClaim = isset($oidcCfg['groupClaim']) ? trim((string)$oidcCfg['groupClaim']) : '';
$groupClaimSource = 'default';
$groupClaimEffective = (defined('FR_OIDC_GROUP_CLAIM') && trim((string)FR_OIDC_GROUP_CLAIM) !== '')
? trim((string)FR_OIDC_GROUP_CLAIM)
: 'groups';
if ($envGroupClaim !== false && trim((string)$envGroupClaim) !== '') {
$groupClaimEffective = trim((string)$envGroupClaim);
$groupClaimSource = 'env';
} elseif ($cfgGroupClaim !== '') {
$groupClaimEffective = $cfgGroupClaim;
$groupClaimSource = 'config';
}
$envExtraScopes = getenv('FR_OIDC_EXTRA_SCOPES');
$cfgExtraScopes = isset($oidcCfg['extraScopes']) ? trim((string)$oidcCfg['extraScopes']) : '';
$extraScopesSource = 'none';
$extraScopesEffective = '';
if ($envExtraScopes !== false && trim((string)$envExtraScopes) !== '') {
$extraScopesEffective = trim((string)$envExtraScopes);
$extraScopesSource = 'env';
} elseif ($cfgExtraScopes !== '') {
$extraScopesEffective = $cfgExtraScopes;
$extraScopesSource = 'config';
}
$scopes = ['openid', 'profile', 'email'];
if ($extraScopesEffective !== '') {
foreach (preg_split('/[,\s]+/', $extraScopesEffective) ?: [] as $scope) {
$scope = trim($scope);
if ($scope !== '') {
$scopes[] = $scope;
}
}
}
$scopeSet = [];
foreach ($scopes as $scope) {
$key = strtolower($scope);
if (!isset($scopeSet[$key])) {
$scopeSet[$key] = $scope;
}
}
$scopes = array_values($scopeSet);
$info = [
'providerUrl' => $oidcCfg['providerUrl'] ?? ($cfg['oidc_provider_url'] ?? null),
'redirectUri' => $oidcCfg['redirectUri'] ?? ($cfg['oidc_redirect_uri'] ?? null),
@@ -86,7 +130,11 @@ try {
],
'tokenEndpointAuthMethod' => $tokenAuthMethod ?: '(library default)',
'scopes' => ['openid', 'profile', 'email'],
'groupClaim' => $groupClaimEffective,
'groupClaimSource' => $groupClaimSource,
'extraScopes' => $extraScopesEffective,
'extraScopesSource' => $extraScopesSource,
'scopes' => $scopes,
'loginOptions' => [
'disableFormLogin' => !empty($loginOptions['disableFormLogin']),
+88
View File
@@ -2589,6 +2589,8 @@ function captureInitialAdminConfig() {
oidcDebugLogging: !!document.getElementById("oidcDebugLogging")?.checked,
oidcRedirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim(),
oidcAllowDemote: !!document.getElementById("oidcAllowDemote")?.checked,
oidcGroupClaim: (document.getElementById("oidcGroupClaim")?.value || "").trim(),
oidcExtraScopes: (document.getElementById("oidcExtraScopes")?.value || "").trim(),
// UI is now “enable” toggles
enableFormLogin: !!document.getElementById("enableFormLogin")?.checked,
@@ -2651,6 +2653,8 @@ function hasUnsavedChanges() {
getVal("oidcRedirectUri") !== o.oidcRedirectUri ||
getChk("oidcAllowDemote") !== o.oidcAllowDemote ||
getChk("oidcDebugLogging") !== o.oidcDebugLogging ||
getVal("oidcGroupClaim") !== (o.oidcGroupClaim || "") ||
getVal("oidcExtraScopes") !== (o.oidcExtraScopes || "") ||
// new enable-toggles
getChk("enableFormLogin") !== o.enableFormLogin ||
@@ -5430,6 +5434,40 @@ export function openAdminPanel() {
const oidcDebugEnabled = !!(config.oidc && config.oidc.debugLogging);
const oidcAllowDemote = !!(config.oidc && config.oidc.allowDemote);
const oidcPublicClient = !!(config.oidc && config.oidc.publicClient);
const oidcGroupClaimCfg = (config.oidc && typeof config.oidc.groupClaim === 'string')
? config.oidc.groupClaim
: '';
const oidcGroupClaimEffective = (config.oidc && typeof config.oidc.groupClaimEffective === 'string')
? config.oidc.groupClaimEffective
: '';
const oidcGroupClaimLocked = !!(config.oidc && config.oidc.groupClaimLockedByEnv);
const oidcGroupClaimValue = oidcGroupClaimLocked ? oidcGroupClaimEffective : oidcGroupClaimCfg;
const oidcExtraScopesCfg = (config.oidc && typeof config.oidc.extraScopes === 'string')
? config.oidc.extraScopes
: '';
const oidcExtraScopesEffective = (config.oidc && typeof config.oidc.extraScopesEffective === 'string')
? config.oidc.extraScopesEffective
: '';
const oidcExtraScopesLocked = !!(config.oidc && config.oidc.extraScopesLockedByEnv);
const oidcExtraScopesValue = oidcExtraScopesLocked ? oidcExtraScopesEffective : oidcExtraScopesCfg;
const oidcGroupClaimHelpDefault = tf(
"oidc_group_claim_help",
"Claim name for IdP groups (supports dot paths like realm_access.roles). Leave blank to use \"groups\"."
);
const oidcGroupClaimHelpLocked = tf(
"oidc_group_claim_locked",
"Locked by FR_OIDC_GROUP_CLAIM env override."
);
const oidcGroupClaimHelp = oidcGroupClaimLocked ? oidcGroupClaimHelpLocked : oidcGroupClaimHelpDefault;
const oidcExtraScopesHelpDefault = tf(
"oidc_extra_scopes_help",
"Space/comma-separated scopes to request in addition to openid/profile/email (example: groups)."
);
const oidcExtraScopesHelpLocked = tf(
"oidc_extra_scopes_locked",
"Locked by FR_OIDC_EXTRA_SCOPES env override."
);
const oidcExtraScopesHelp = oidcExtraScopesLocked ? oidcExtraScopesHelpLocked : oidcExtraScopesHelpDefault;
const oidcHtml = `
<hr class="admin-divider">
@@ -5497,6 +5535,24 @@ export function openAdminPanel() {
</small>
</div>
<div class="form-group">
<label for="oidcGroupClaim">${tf("oidc_group_claim_label", "Group claim name")}:</label>
<input type="text" id="oidcGroupClaim" class="form-control"
placeholder="groups"
value="${escapeHTML(oidcGroupClaimValue || "")}"
${oidcGroupClaimLocked ? "disabled data-locked='1'" : ""} />
<small class="text-muted">${escapeHTML(oidcGroupClaimHelp)}</small>
</div>
<div class="form-group">
<label for="oidcExtraScopes">${tf("oidc_extra_scopes_label", "Extra OIDC scopes")}:</label>
<input type="text" id="oidcExtraScopes" class="form-control"
placeholder="groups"
value="${escapeHTML(oidcExtraScopesValue || "")}"
${oidcExtraScopesLocked ? "disabled data-locked='1'" : ""} />
<small class="text-muted">${escapeHTML(oidcExtraScopesHelp)}</small>
</div>
<hr class="admin-divider">
<div class="form-group" style="margin-top:4px;">
@@ -6792,6 +6848,36 @@ ${t("shared_max_upload_size_bytes")}
if (ooCont) wireReplaceButtons(ooCont);
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
const oidcGroupClaimEl = document.getElementById("oidcGroupClaim");
if (oidcGroupClaimEl) {
const locked = !!(config.oidc && config.oidc.groupClaimLockedByEnv);
const value = locked
? (config.oidc.groupClaimEffective || "")
: (config.oidc.groupClaim || "");
oidcGroupClaimEl.value = value;
if (locked) {
oidcGroupClaimEl.disabled = true;
oidcGroupClaimEl.dataset.locked = "1";
} else {
oidcGroupClaimEl.disabled = false;
oidcGroupClaimEl.removeAttribute("data-locked");
}
}
const oidcExtraScopesEl = document.getElementById("oidcExtraScopes");
if (oidcExtraScopesEl) {
const locked = !!(config.oidc && config.oidc.extraScopesLockedByEnv);
const value = locked
? (config.oidc.extraScopesEffective || "")
: (config.oidc.extraScopes || "");
oidcExtraScopesEl.value = value;
if (locked) {
oidcExtraScopesEl.disabled = true;
oidcExtraScopesEl.dataset.locked = "1";
} else {
oidcExtraScopesEl.disabled = false;
oidcExtraScopesEl.removeAttribute("data-locked");
}
}
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
const oidcDebugEl = document.getElementById('oidcDebugLogging');
if (oidcDebugEl) {
@@ -6915,6 +7001,8 @@ function handleSave() {
debugLogging: !!document.getElementById("oidcDebugLogging")?.checked,
allowDemote: !!document.getElementById("oidcAllowDemote")?.checked,
publicClient: oidcPublicClient,
groupClaim: (document.getElementById("oidcGroupClaim")?.value || "").trim(),
extraScopes: (document.getElementById("oidcExtraScopes")?.value || "").trim(),
// clientId/clientSecret added conditionally below
},
globalOtpauthUrl: document
+2 -1
View File
@@ -34,7 +34,8 @@ const DEFAULT_SUPPORTERS = [
'Edisto Pirates of SC',
'@jubnl',
'Juozaitis S',
'Cocoseb31'
'Cocoseb31',
'nulltrope'
];
/**
+41
View File
@@ -309,6 +309,16 @@ class AdminController
$ignoreCfg = (string)($config['ignoreRegex'] ?? '');
$ignoreEffective = $ignoreLockedByEnv ? trim((string)$envIgnore) : $ignoreCfg;
$envGroupClaim = getenv('FR_OIDC_GROUP_CLAIM');
$groupClaimLockedByEnv = ($envGroupClaim !== false && trim((string)$envGroupClaim) !== '');
$groupClaimCfg = (string)($config['oidc']['groupClaim'] ?? '');
$groupClaimEffective = $groupClaimLockedByEnv ? trim((string)$envGroupClaim) : $groupClaimCfg;
$envExtraScopes = getenv('FR_OIDC_EXTRA_SCOPES');
$extraScopesLockedByEnv = ($envExtraScopes !== false && trim((string)$envExtraScopes) !== '');
$extraScopesCfg = (string)($config['oidc']['extraScopes'] ?? '');
$extraScopesEffective = $extraScopesLockedByEnv ? trim((string)$envExtraScopes) : $extraScopesCfg;
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
@@ -320,6 +330,12 @@ class AdminController
'debugLogging' => !empty($config['oidc']['debugLogging']),
'allowDemote' => !empty($config['oidc']['allowDemote']),
'publicClient' => !empty($config['oidc']['publicClient']),
'groupClaim' => $groupClaimCfg,
'groupClaimEffective' => $groupClaimEffective,
'groupClaimLockedByEnv' => $groupClaimLockedByEnv,
'extraScopes' => $extraScopesCfg,
'extraScopesEffective' => $extraScopesEffective,
'extraScopesLockedByEnv' => $extraScopesLockedByEnv,
]),
'onlyoffice' => [
'enabled' => $effEnabled,
@@ -2417,6 +2433,8 @@ class AdminController
'clientId' => '',
'clientSecret' => '',
'redirectUri' => '',
'groupClaim' => '',
'extraScopes' => '',
'publicClient' => false,
],
'branding' => [
@@ -2569,6 +2587,29 @@ class AdminController
}
}
// OIDC group claim + extra scopes (env overrides lock these fields)
$envGroupClaim = getenv('FR_OIDC_GROUP_CLAIM');
$groupClaimLockedByEnv = ($envGroupClaim !== false && trim((string)$envGroupClaim) !== '');
if (!$groupClaimLockedByEnv && array_key_exists('groupClaim', $data['oidc'] ?? [])) {
$raw = trim((string)$data['oidc']['groupClaim']);
$raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
if (strlen($raw) > 200) {
$raw = substr($raw, 0, 200);
}
$merged['oidc']['groupClaim'] = $raw;
}
$envExtraScopes = getenv('FR_OIDC_EXTRA_SCOPES');
$extraScopesLockedByEnv = ($envExtraScopes !== false && trim((string)$envExtraScopes) !== '');
if (!$extraScopesLockedByEnv && array_key_exists('extraScopes', $data['oidc'] ?? [])) {
$raw = trim((string)$data['oidc']['extraScopes']);
$raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
if (strlen($raw) > 500) {
$raw = substr($raw, 0, 500);
}
$merged['oidc']['extraScopes'] = $raw;
}
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
+125 -18
View File
@@ -114,6 +114,61 @@ class AuthController
return $oidcAction ?: null;
}
protected function resolveOidcGroupClaim(array $cfg = []): string
{
$envVal = getenv('FR_OIDC_GROUP_CLAIM');
if ($envVal !== false && trim((string)$envVal) !== '') {
return trim((string)$envVal);
}
$oidcCfg = is_array($cfg['oidc'] ?? null) ? $cfg['oidc'] : [];
$cfgVal = isset($oidcCfg['groupClaim']) ? trim((string)$oidcCfg['groupClaim']) : '';
if ($cfgVal !== '') {
return $cfgVal;
}
if (defined('FR_OIDC_GROUP_CLAIM') && trim((string)FR_OIDC_GROUP_CLAIM) !== '') {
return (string)FR_OIDC_GROUP_CLAIM;
}
return 'groups';
}
protected function resolveOidcExtraScopes(array $cfg = []): string
{
$envVal = getenv('FR_OIDC_EXTRA_SCOPES');
if ($envVal !== false && trim((string)$envVal) !== '') {
return trim((string)$envVal);
}
$oidcCfg = is_array($cfg['oidc'] ?? null) ? $cfg['oidc'] : [];
return isset($oidcCfg['extraScopes']) ? trim((string)$oidcCfg['extraScopes']) : '';
}
protected function getOidcScopes(array $cfg = []): array
{
$scopes = ['openid', 'profile', 'email'];
$extra = $this->resolveOidcExtraScopes($cfg);
if ($extra !== '') {
foreach (preg_split('/[,\s]+/', $extra) ?: [] as $scope) {
$scope = trim($scope);
if ($scope !== '') {
$scopes[] = $scope;
}
}
}
$unique = [];
foreach ($scopes as $scope) {
$key = strtolower($scope);
if (!isset($unique[$key])) {
$unique[$key] = $scope;
}
}
return array_values($unique);
}
protected function handleOidcFlow(): bool
{
$oidcAction = $this->getOidcAction();
@@ -190,7 +245,7 @@ class AuthController
}
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
$oidc->addScope(['openid', 'profile', 'email']);
$oidc->addScope($this->getOidcScopes($cfg));
if ($oidcAction === 'callback') {
try {
@@ -214,7 +269,13 @@ class AuthController
// Pull full userinfo once so we can inspect groups / roles
$userinfo = $oidc->requestUserInfo();
$normalizedTags = $this->extractOidcGroupTags($userinfo);
$groupClaim = $this->resolveOidcGroupClaim($cfg);
$idTokenClaims = null;
if (method_exists($oidc, 'getIdTokenPayload')) {
$idTokenClaims = $oidc->getIdTokenPayload();
}
$normalizedTags = $this->extractOidcGroupTags($userinfo, $idTokenClaims, $groupClaim);
$this->logOidcDebug('OIDC userinfo summary', [
'username' => $username,
@@ -326,11 +387,31 @@ class AuthController
return true;
}
protected function extractOidcGroupTags(object $userinfo): array
protected function extractOidcGroupTags($userinfo, $idTokenClaims = null, string $groupClaim = ''): array
{
// Collect groups/roles from various fields (same logic as before)
$rawTags = [];
$readClaim = function ($root, string $path) {
if ($path === '') {
return null;
}
if (!is_object($root) && !is_array($root)) {
return null;
}
$cur = $root;
foreach (explode('.', $path) as $seg) {
if (is_object($cur) && isset($cur->$seg)) {
$cur = $cur->$seg;
} elseif (is_array($cur) && array_key_exists($seg, $cur)) {
$cur = $cur[$seg];
} else {
return null;
}
}
return $cur;
};
$addTags = function ($val) use (&$rawTags) {
if (is_array($val)) {
foreach ($val as $v) {
@@ -351,24 +432,50 @@ class AuthController
};
// 1) Common flat claims (includes "usergroups" which you mentioned)
foreach (['groups', 'group', 'usergroups', 'user_groups', 'roles'] as $field) {
if (isset($userinfo->$field)) {
$addTags($userinfo->$field);
$customClaim = trim($groupClaim);
if ($customClaim === '' && defined('FR_OIDC_GROUP_CLAIM')) {
$customClaim = trim((string)FR_OIDC_GROUP_CLAIM);
}
$fields = [];
if ($customClaim !== '') {
$fields[] = $customClaim;
}
$fields = array_merge($fields, ['groups', 'group', 'usergroups', 'user_groups', 'roles']);
$fields = array_values(array_unique($fields));
$claimSets = [$userinfo];
if ($idTokenClaims !== null) {
$claimSets[] = $idTokenClaims;
}
foreach ($claimSets as $claims) {
foreach ($fields as $field) {
$value = $readClaim($claims, $field);
if ($value !== null) {
$addTags($value);
}
}
}
// 2) realm_access.roles (Keycloak realm roles)
if (isset($userinfo->realm_access) && is_object($userinfo->realm_access)
&& isset($userinfo->realm_access->roles) && is_array($userinfo->realm_access->roles)
) {
$addTags($userinfo->realm_access->roles);
}
// 2) realm_access.roles (Keycloak realm roles)
$realmRoles = $readClaim($claims, 'realm_access.roles');
if ($realmRoles !== null) {
$addTags($realmRoles);
}
// 3) resource_access.<client>.roles (Keycloak client roles)
if (isset($userinfo->resource_access) && is_object($userinfo->resource_access)) {
foreach (get_object_vars($userinfo->resource_access) as $clientObj) {
if (is_object($clientObj) && isset($clientObj->roles) && is_array($clientObj->roles)) {
$addTags($clientObj->roles);
// 3) resource_access.<client>.roles (Keycloak client roles)
$resourceAccess = null;
if (is_object($claims) && isset($claims->resource_access) && is_object($claims->resource_access)) {
$resourceAccess = get_object_vars($claims->resource_access);
} elseif (is_array($claims) && isset($claims['resource_access']) && is_array($claims['resource_access'])) {
$resourceAccess = $claims['resource_access'];
}
if (is_array($resourceAccess)) {
foreach ($resourceAccess as $clientObj) {
if (is_object($clientObj) && isset($clientObj->roles)) {
$addTags($clientObj->roles);
} elseif (is_array($clientObj) && isset($clientObj['roles'])) {
$addTags($clientObj['roles']);
}
}
}
}
+5 -1
View File
@@ -830,12 +830,14 @@ class AdminModel
'clientId' => '',
'clientSecret' => '',
'redirectUri' => '',
'groupClaim' => '',
'extraScopes' => '',
'debugLogging' => false,
'allowDemote' => false,
'publicClient' => false,
];
} else {
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $k) {
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri', 'groupClaim', 'extraScopes'] as $k) {
if (!isset($config['oidc'][$k]) || !is_string($config['oidc'][$k])) {
$config['oidc'][$k] = '';
}
@@ -1097,6 +1099,8 @@ class AdminModel
'clientId' => '',
'clientSecret' => '',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback',
'groupClaim' => '',
'extraScopes' => '',
'debugLogging' => false,
'allowDemote' => false,
'publicClient' => false,
+3 -1
View File
@@ -191,7 +191,9 @@ use OpenApi\Annotations as OA;
* @OA\Property(property="providerUrl", type="string", format="uri", example="https://issuer.example.com"),
* @OA\Property(property="clientId", type="string", example="my-client-id"),
* @OA\Property(property="clientSecret", type="string", writeOnly=true, example="***"),
* @OA\Property(property="redirectUri", type="string", format="uri", example="https://app.example.com/auth/callback")
* @OA\Property(property="redirectUri", type="string", format="uri", example="https://app.example.com/auth/callback"),
* @OA\Property(property="groupClaim", type="string", example="groups", nullable=true),
* @OA\Property(property="extraScopes", type="string", example="groups", nullable=true)
* )
* )
* )