Skip to main content

Authentication

The AuthManager is the central authorization gateway in Oidis. It controls who can call which @Extern methods, access which pages, and see which tabs.

AuthManager Architecture​

The Hub provides a base AuthManager composed from multiple concerns via the @Partial decorator:

@Partial(
UsersManager,
GroupsManager,
BasicAuthenticationAPI,
TwoFactorAuthenticationAPI,
OAuth2Helper,
PasswordResetManager,
SessionManager,
RegistrationManager,
PermissionsManager,
TokenManager,
AuditManager
)
export class AuthManager extends BaseAuthManager {
// Composite class with methods from all 11 partials
}

Each project extends the Hub's AuthManager and registers its own permissions:

export class AuthManager extends HubAuthManager {

constructor() {
super();

// Register methods available to all authorized users
this.generateAuthMethodsFrom<InventoryManager>(
AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS,
InventoryManager,
($proto) => [
$proto.ListOrders,
$proto.SaveOrder,
$proto.GetOrderDetail
]
);

// Restrict specific methods to a role
this.addSystemPermissions("Administrator",
...this.generateAuthMethodsFrom<InventoryManager>(InventoryManager, ($proto) => [
$proto.DeleteOrder,
$proto.PurgeData
])
);
}
}

Three Permission Categories​

CategoryAccess LevelRegistration
FOR_ALL_USERSNo authentication neededLogin, Register, public endpoints
ONLY_FOR_AUTHORIZED_USERSAny logged-in usergenerateAuthMethodsFrom()
System permissionsRole-based restrictionaddSystemPermissions()

FOR_ALL_USERS​

Methods that anyone can call, even without logging in:

// Hub registers these by default
// Login, Register, ForgotPassword, etc.

ONLY_FOR_AUTHORIZED_USERS​

Registered with the three-argument generateAuthMethodsFrom(). Any authenticated user can call these:

this.generateAuthMethodsFrom<MyManager>(
AuthMethodCategory.ONLY_FOR_AUTHORIZED_USERS,
MyManager,
($proto) => [
$proto.ListItems, // Any logged-in user
$proto.SaveItem, // Any logged-in user
$proto.GetItem // Any logged-in user
]
);

System Permissions (Role-Based)​

Restrict methods to specific roles. addSystemPermissions($name, ...$permissions) takes method pattern strings, not prototype references — build them with the two-argument generateAuthMethodsFrom() and spread the result:

this.addSystemPermissions("Administrator",
...this.generateAuthMethodsFrom<MyManager>(MyManager, ($proto) => [
$proto.DeleteItem,
$proto.ManageUsers
])
);

Only users with the "Administrator" role can call these methods. Others get an authorization error.

What the Built-in Groups Already Hold​

Hub's own AuthManager constructor seeds the system groups before your subclass runs. The grants are not what the names suggest:

GroupWhat Hub grants it
Admin"*" — every method in the application
Agent"*" plus the handshake method (flagged in CORE as too broad)
SysAdminonly the SYSADMIN feature — no wildcard
OrganizationOwnerthe ORGANIZATION_OWNER feature plus seven organization methods
Memberonly the ORGANIZATION_MEMBER feature
AuthAdminonly the TOKEN_MANAGER feature

"*" matches everything, because StringUtils.PatternMatched("*", anything) is true. Two consequences drive every authorization decision you make:

A missing registration is invisible to you if you test as an admin. A method that appears in no category and no group is denied to everyone except Admin (and Agent). It is not "open" — it is unreachable for SysAdmin-only accounts, for every custom role, and for ordinary users. Forgetting to register a method therefore does not surface as a security hole; it surfaces later as "why does this button work for me but not for the accountant?".

Never put an unguarded method in ONLY_FOR_AUTHORIZED_USERS. That category grants every authenticated session. It is the right home only for a method whose body performs its own role check (requirePrivilegedActor(), guardAdmin(), an explicit isPrivileged throw, …); there the category merely decides whether the call reaches that check, so adding it is security-neutral and restores access for the non-admin roles the method was written for. A method with no internal check belongs in addSystemPermissions(). Getting this backwards is the single most damaging mistake available here, and a comment claiming the manager checks the role inside is not evidence — read the body.

Group membership is also data: AppSettings.DefaultAccessibleMethods, AppSettings.DefaultGroupAccess and each Group.AuthorizedMethods row live in the database and are unioned with the in-memory registrations at request time. Source code alone can tell you what is registered, never what is granted in a given deployment.

Authorization Flow​

When a frontend connector calls a backend @Extern method:

Frontend: connector.ListOrders()
│
▼ asyncInvoke("ListOrders")
LiveContentResolver
│
▼ isAuthorizedFor(method)
AuthManager
│
├── Is method FOR_ALL_USERS? → Execute
├── Is user authenticated? → No → Reject (401)
├── Is method ONLY_FOR_AUTHORIZED_USERS? → Execute
├── Does user have required system permission? → Execute
└── Otherwise → Reject (403)

The last branch is where an unregistered method lands, for every caller whose groups do not carry a wildcard. @Route endpoints go through the same gate as @Extern ones — both decorators derive the auth key from the runtime instance class name plus the method name. That matters when Loader maps a symbol onto a project subclass: a grant written by Hub against its own class produces a different key than the one the request resolves to, so the grant silently misses and the method has to be registered again under the subclass.

Adding a project-wide rule on top of the grants​

Every RPC and route call passes through AuthManager.IsAuthorizedFor, so a project AuthManager subclass can narrow access for a whole session state (e.g. a "presentation mode" that limits a signed-in admin to a whitelist of methods). Hub assigns IsAuthorizedFor as an instance field (public IsAuthorizedFor = BasicAuthenticationAPI.prototype.IsAuthorizedFor), not as a prototype method, so a plain method override in the subclass is shadowed by the parent's field and never runs, and super.IsAuthorizedFor does not exist. Override it as a field too, pointing at a method defined on a helper class:

export abstract class ProjectAuthorization extends BasicAuthenticationAPI {
public async IsAuthorizedFor(this : AuthManager, $method : string, $token : string | User = null) : Promise<boolean> {
if (!(await BasicAuthenticationAPI.prototype.IsAuthorizedFor.call(this, $method, $token))) {
return false;
}
return await MyRule.Allows(<string>$token || this.getCurrentToken(), $method);
}
}

export class AuthManager extends Parent {
public IsAuthorizedFor = ProjectAuthorization.prototype.IsAuthorizedFor;
}

$method arrives fully qualified (Io.Oidis.Labs.Utils.GuestPortal.ListMyProjects); $token may also be a User (MCP bearers resolved to their owner), so handle both.

Page-Level Access Control​

For restricting entire pages, override accessValidator() in the PageController:

@Resolver("/admin")
export class AdminPageController extends BasePageController<AdminPage> {

protected async accessValidator() : Promise<boolean> {
const auth = AuthManagerConnector.getClient();
return await auth.IsAdmin();
}

protected reloadToNotAuthorized() : void {
// Custom redirect (default: /ServerError/Http/NotAuthorized)
window.location.href = "/login";
}
}

The accessValidator() method:

  • Called during Process() before the controller lifecycle starts
  • Returns true to allow access, false to deny
  • On denial, reloadToNotAuthorized() handles the redirect

Tab-Level Access Control​

For restricting individual tabs within a page, override AccessValidator() (capital A) in the PanelController:

export class UserManagementPanelController extends BasePanelController<UserManagementPanel> {

public async AccessValidator() : Promise<boolean> {
const auth = AuthManagerConnector.getClient();
return await auth.HasPermission("admin.users.manage");
}
}

For the simplest case — "any authenticated user, no public visitors" — gate on the user context flag directly:

export class FeedbackPanelController extends BasePanelController<FeedbackPanel> {

public async AccessValidator() : Promise<boolean> {
return this.context.IsAuthenticated;
}
}

When AccessValidator() returns false, BasePageLayoutController automatically calls reloadToNotAuthorized() on the owning page controller — which by default redirects to the sign-in screen. Do not implement this gate manually inside FetchData() with an if (!this.context.IsAuthenticated) { ReloadTo("/ServerError/Http/NotAuthorized") } short-circuit. The framework already handles it through AccessValidator(), and inlining the check duplicates routing logic, skips Hub's SSO last-login persistence, and breaks the "panel never renders for unauthorized users" invariant — by the time FetchData runs, the panel HTML is already mounted.

When the validator returns false and the visitor is anonymous, the layout stores the requested path through SSOBinding().LastLoginUrl($path) before it redirects. That stored path is what returns the user to the page they asked for once they sign in. Leave the validator off and the deep link is lost: the panel loads for the anonymous visitor, its connector calls fail server-side with "not authorized", and after logging in the user lands on the default page instead of the document, invoice or ticket the link pointed at. Nothing is stored for an authenticated-but-unprivileged visitor, which is correct — signing in again cannot grant a right the account does not have.

Read the context, do not reload it​

Do not await this.context.Reload() inside a panel AccessValidator(). The layout has already loaded the context before the tab loop runs, so the reload adds a full round of auth calls to the critical path of every navigation, produces a second snapshot that can disagree with the menu built moments earlier, and — because UserContext.Load() degrades to IsAuthenticated = false when the profile fetch fails — can bounce a perfectly valid session on a transient error. Read the flags synchronously; the async signature is required by the base class, not by the body. (The page-level accessValidator() is a different case: it runs once per document load, and CORE's own page validators do reload there.)

A refusal is sticky​

The tab loop initialises authorized = true and never sets it back once a matching tab has refused; the !authorized branch then wins over a later tab that would have allowed the path. If two tabs match the same URL, their validators must agree — otherwise the stricter one decides for both, and the looser one is never consulted. Keep the predicates of a route family identical, ideally by having them all call the same helper.

Combined with the scope option in tab configuration:

// In Page's getTabsConfiguration()
{
content: UserManagementPanel,
controller: UserManagementPanelController,
title: "User Management",
scope: MenuItemScope.ADMINS // Only shown to admins in the menu
}

scope controls visibility in the menu. AccessValidator() controls access even if the user navigates directly to the URL.

Frontend AuthManager Connector​

The frontend accesses auth state through a connector:

const auth = AuthManagerConnector.getClient();

// Check authentication
const isLoggedIn = await auth.IsAuthorized();

// Check admin status
const isAdmin = await auth.IsAdmin();

// Check specific permission
const canManageUsers = await auth.HasPermission("admin.users");

// Get current user
const user = await auth.GetCurrentUser();

Registering Permissions — Checklist​

When adding new @Extern or @Route methods:

  1. Add to AuthManager constructor — an unregistered method is denied to everyone except the wildcard groups, so this is not optional
  2. Pick the category from the body, not the intent — self-gated method → ONLY_FOR_AUTHORIZED_USERS; no internal role check → addSystemPermissions()
  3. Test as a non-admin — testing as Admin proves nothing, the wildcard passes everything
  4. Page-level guards — Add accessValidator() to PageControllers for restricted pages
  5. Tab-level guards — Add AccessValidator() to PanelControllers for restricted tabs, so anonymous deep links are stored and replayed after sign-in
  6. Re-register subclassed managers — a Loader symbol mapping changes the auth key, so Hub's grant for the parent class does not cover your subclass

Summary​

MechanismLevelWhereMethod
generateAuthMethodsFrom@Extern methodAuthManager constructorFor authenticated users
addSystemPermissions@Extern methodAuthManager constructorFor specific roles
accessValidator()PagePageControllerOverride (lowercase a)
AccessValidator()Tab/PanelPanelControllerOverride (uppercase A)
scope: MenuItemScopeTab visibilityPage getTabsConfiguration()Menu item display

See also: LCP & RPC | Routing | UserContext | System Groups | @Extern