Merge branch 'fix/admin-role-scope-restricted' into 'main'
fix(auth): admin → RESTRICTED + TokenSync proactive re-auth при expired See merge request 2-6/2-6-4/terravault/ordinis!267
This commit is contained in:
@@ -23,6 +23,9 @@ import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig'
|
|||||||
export function TokenSync() {
|
export function TokenSync() {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const redirecting = useRef(false)
|
const redirecting = useRef(false)
|
||||||
|
// Anti-loop guard для проактивной re-auth при expiry — без него
|
||||||
|
// useEffect срабатывал бы повторно на каждый render пока expired=true.
|
||||||
|
const reauthing = useRef(false)
|
||||||
|
|
||||||
// 1) Token sync — single update path к accessToken holder в client.ts.
|
// 1) Token sync — single update path к accessToken holder в client.ts.
|
||||||
// ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
|
// ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token
|
||||||
@@ -93,5 +96,41 @@ export function TokenSync() {
|
|||||||
return () => setUnauthorizedHandler(null)
|
return () => setUnauthorizedHandler(null)
|
||||||
}, [auth])
|
}, [auth])
|
||||||
|
|
||||||
|
// 3) Проактивная re-auth при expiry. automaticSilentRenew: true должен
|
||||||
|
// обновлять access_token автоматически, но молча падает если:
|
||||||
|
// - refresh_token истёк (Keycloak SSO Session Idle ~30 мин)
|
||||||
|
// - silent renew iframe заблокирован 3rd-party cookie policy
|
||||||
|
// - silentRenewError не подцеплен глобально
|
||||||
|
//
|
||||||
|
// В этих случаях user.expired=true, accessToken=null, requests идут
|
||||||
|
// как anonymous → backend с requireInternal() отдаёт 403 (не 401),
|
||||||
|
// 401-handler не срабатывает → юзер видит «403 forbidden» вместо
|
||||||
|
// привычного relogin redirect. Симптом который пользователь репортит
|
||||||
|
// как «перелогин решает».
|
||||||
|
//
|
||||||
|
// Логика: если user был authenticated и стал expired — пытаемся
|
||||||
|
// signinSilent (вдруг refresh ещё жив), на failure full redirect.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.user?.expired) {
|
||||||
|
reauthing.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reauthing.current || auth.isLoading || auth.activeNavigator) return
|
||||||
|
if (typeof window !== 'undefined' && window.location.search.includes('error=')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reauthing.current = true
|
||||||
|
auth
|
||||||
|
.signinSilent()
|
||||||
|
.then(() => {
|
||||||
|
reauthing.current = false
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
auth.signinRedirect().catch(() => {
|
||||||
|
reauthing.current = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [auth, auth.user?.expired])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,15 +87,44 @@ public class ScopeContext {
|
|||||||
if (raw == null) return EnumSet.of(DataScope.PUBLIC);
|
if (raw == null) return EnumSet.of(DataScope.PUBLIC);
|
||||||
EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class);
|
EnumSet<DataScope> result = EnumSet.noneOf(DataScope.class);
|
||||||
if (raw instanceof Collection<?> col) {
|
if (raw instanceof Collection<?> col) {
|
||||||
for (Object o : col) addScope(result, normalizeRoleToScope(String.valueOf(o)));
|
for (Object o : col) processRole(result, String.valueOf(o));
|
||||||
} else {
|
} else {
|
||||||
for (String part : String.valueOf(raw).split("[,\\s]+")) {
|
for (String part : String.valueOf(raw).split("[,\\s]+")) {
|
||||||
addScope(result, normalizeRoleToScope(part));
|
processRole(result, part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result;
|
return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-role handler. Сначала проверяет admin-aliases (admin /
|
||||||
|
* ordinis-admin / ADMIN с любым префиксом) → дают RESTRICTED (видит всё).
|
||||||
|
* Иначе идёт через normalizeRoleToScope (PUBLIC/INTERNAL/RESTRICTED суффикс).
|
||||||
|
*
|
||||||
|
* <p>Раньше admin-роль молча проваливалась в normalizeRoleToScope (нет
|
||||||
|
* матча на PUBLIC/INTERNAL/RESTRICTED suffix) → user получал только PUBLIC
|
||||||
|
* → 403 на любом /admin/* endpoint'е с requireInternal(). UX чинит:
|
||||||
|
* админу не нужно явно иметь ordinis-internal в Keycloak, роль admin
|
||||||
|
* сама по себе даёт максимальный доступ.
|
||||||
|
*/
|
||||||
|
private static void processRole(EnumSet<DataScope> target, String role) {
|
||||||
|
if (isAdminRole(role)) {
|
||||||
|
target.add(DataScope.RESTRICTED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addScope(target, normalizeRoleToScope(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True если role tail (после `-`/`_`/`:` или целиком) равен "ADMIN". */
|
||||||
|
private static boolean isAdminRole(String role) {
|
||||||
|
if (role == null) return false;
|
||||||
|
String s = role.trim().toUpperCase();
|
||||||
|
if (s.isEmpty()) return false;
|
||||||
|
int sep = Math.max(Math.max(s.lastIndexOf('-'), s.lastIndexOf('_')), s.lastIndexOf(':'));
|
||||||
|
String tail = sep >= 0 ? s.substring(sep + 1) : s;
|
||||||
|
return "ADMIN".equals(tail);
|
||||||
|
}
|
||||||
|
|
||||||
/** Поддерживает nested путь "realm_access.roles" или просто "scopes". */
|
/** Поддерживает nested путь "realm_access.roles" или просто "scopes". */
|
||||||
private static Object readClaimByPath(Jwt token, String path) {
|
private static Object readClaimByPath(Jwt token, String path) {
|
||||||
String[] parts = path.split("\\.");
|
String[] parts = path.split("\\.");
|
||||||
|
|||||||
+35
-1
@@ -78,11 +78,45 @@ class ScopeContextTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unrecognizedRolesGivePublicFallback() {
|
void unrecognizedRolesGivePublicFallback() {
|
||||||
setJwtWithRealmRoles(List.of("admin", "viewer"));
|
// "viewer" — unrecognized; "admin" больше не unrecognized (см. тест ниже).
|
||||||
|
setJwtWithRealmRoles(List.of("viewer"));
|
||||||
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||||
.containsExactly(DataScope.PUBLIC);
|
.containsExactly(DataScope.PUBLIC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminRoleGetsRestricted() {
|
||||||
|
// Любая роль с tail'ом ADMIN (admin / ordinis-admin / ADMIN_FOO с
|
||||||
|
// суффиксом ADMIN) даёт RESTRICTED — RESTRICTED visibleTo всем уровням.
|
||||||
|
// Раньше admin молча проваливался в normalizeRoleToScope → PUBLIC →
|
||||||
|
// админ получал 403 на /admin/* endpoint'ах с requireInternal().
|
||||||
|
setJwtWithRealmRoles(List.of("admin"));
|
||||||
|
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||||
|
.containsExactly(DataScope.RESTRICTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinisDashAdminGetsRestricted() {
|
||||||
|
setJwtWithRealmRoles(List.of("ordinis-admin"));
|
||||||
|
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||||
|
.containsExactly(DataScope.RESTRICTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminColonSeparatedGetsRestricted() {
|
||||||
|
setJwtWithRealmRoles(List.of("ordinis:client:admin"));
|
||||||
|
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||||
|
.containsExactly(DataScope.RESTRICTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminAlongsideExplicitScopesUnionizes() {
|
||||||
|
// admin + ordinis-public → RESTRICTED (admin) + PUBLIC (ordinis-public).
|
||||||
|
setJwtWithRealmRoles(List.of("admin", "ordinis-public"));
|
||||||
|
assertThat(new ScopeContext(propsNoQuery).currentScopes())
|
||||||
|
.containsExactlyInAnyOrder(DataScope.RESTRICTED, DataScope.PUBLIC);
|
||||||
|
}
|
||||||
|
|
||||||
// ============= JWT vs query =============
|
// ============= JWT vs query =============
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user