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
| Category | Access Level | Registration |
|---|---|---|
| FOR_ALL_USERS | No authentication needed | Login, Register, public endpoints |
| ONLY_FOR_AUTHORIZED_USERS | Any logged-in user | generateAuthMethodsFrom() |
| System permissions | Role-based restriction | addSystemPermissions() |
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:
| Group | What Hub grants it |
|---|---|
Admin | "*" — every method in the application |
Agent | "*" plus the handshake method (flagged in CORE as too broad) |
SysAdmin | only the SYSADMIN feature — no wildcard |
OrganizationOwner | the ORGANIZATION_OWNER feature plus seven organization methods |
Member | only the ORGANIZATION_MEMBER feature |
AuthAdmin | only 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
trueto allow access,falseto 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.
The validator is what makes a deep link survive the login
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:
- Add to AuthManager constructor — an unregistered method is denied to everyone except the wildcard groups, so this is not optional
- Pick the category from the body, not the intent — self-gated method →
ONLY_FOR_AUTHORIZED_USERS; no internal role check →addSystemPermissions() - Test as a non-admin — testing as
Adminproves nothing, the wildcard passes everything - Page-level guards — Add
accessValidator()to PageControllers for restricted pages - Tab-level guards — Add
AccessValidator()to PanelControllers for restricted tabs, so anonymous deep links are stored and replayed after sign-in - Re-register subclassed managers — a
Loadersymbol mapping changes the auth key, so Hub's grant for the parent class does not cover your subclass
Summary
| Mechanism | Level | Where | Method |
|---|---|---|---|
generateAuthMethodsFrom | @Extern method | AuthManager constructor | For authenticated users |
addSystemPermissions | @Extern method | AuthManager constructor | For specific roles |
accessValidator() | Page | PageController | Override (lowercase a) |
AccessValidator() | Tab/Panel | PanelController | Override (uppercase A) |
scope: MenuItemScope | Tab visibility | Page getTabsConfiguration() | Menu item display |
See also: LCP & RPC | Routing | UserContext | System Groups | @Extern