Application catalog
A My apps launcher for your organization's tools, with assignments to people and groups, access requests, audited launches, and sign-in enforcement.
People in an organization use a dozen tools: the wiki, the CRM, CI, payroll. They hunt for bookmarks, ask where the expense tool lives, and keep access to tools they stopped using long ago. Administrators, meanwhile, cannot say who is supposed to have which tool, or who still uses it.
The application catalog is the organization's list of tools, and My apps is the launcher where each person opens the ones they have. An app is either an client of this deployment or a plain link to any other tool. Administrators give an app to everyone or assign it to people and , optionally until a date; people who lack an app request it through an ; every launch is recorded and audited; and the OAuth provider refuses people who do not have the app.
const crm = await iam.api.applications.create(admin, {
tenantId,
key: 'crm',
name: 'CRM',
launchUrl: 'https://crm.acme.test/login',
category: 'Sales',
});
await iam.api.applications.assign(admin, {
tenantId,
appId: crm.id,
subjectType: 'group',
subjectId: sales.id,
});
const apps = await iam.api.applications.mine(aliceSession, { tenantId });Register an app
create (iam:applications:manage) adds an app. key is permanent (lowercase letters, digits, dots, underscores or
hyphens, starting with a letter) and launchUrl is where launching sends people: the app's sign-in URL. Launch and
logo URLs must be https; while the deployment itself runs on localhost, 127.0.0.1 or [::1], they may also be
http on those hosts.
| Field | What it does |
|---|---|
name, description, category, logoUrl | What the launcher shows. The console groups apps by category. |
oauthClientId | The clientId of an OAuth client of this organization, named by one app at most. The app becomes oidc (otherwise link) and decides who may sign in to the client. |
visibility | assigned (the default) or everyone. |
enabled | false takes the app off every launcher and refuses launches and sign-ins to its client, keeping its assignments. |
requestPackageId | A requestable access package people without the app may ask for. |
ownerIds | Up to 20 people to contact about the app. It grants them nothing. |
A tenant holds at most 500 apps. update changes anything but the key (null clears an optional field), and
delete removes an app with its assignments and launch history. Changes are audited as app:create, app:update
and app:delete.
Releasing an OAuth client is explicit
Without its app, everyone in the organization may sign in to an OAuth client. So deleting an app that governs a
client, or changing or clearing its oauthClientId, answers CONFLICT unless you pass releaseClient: true. To
keep refusing everyone, disable the app instead.
Who has an app
A person has an app when it is enabled and:
- its visibility is
everyone, or - it is assigned to them, or
- it is assigned to a group they belong to,
counting only assignments and memberships that have not ended. Only active, unexpired people have apps; service
accounts and agents never do. The launcher says why each app is there (via): direct, else group, else
everyone.
assign (iam:applications:assign on the app) gives an app to one person or one group, with an optional expiresAt
up to ten years away; an app holds at most 10,000 assignments, so assign groups at scale. Assigning the same person or
group again replaces the assignment. unassign removes one (authorized on its app, so per-app administrators can),
and listAssignments shows the assignments of one app, or of every app without appId, with the group's name or,
for callers who may read the directory, the person's, and when the person last opened the app. Deleting a person or a
group removes their assignments.
await iam.api.applications.assign(admin, {
tenantId,
appId: crm.id,
subjectType: 'identity',
subjectId: contractorId,
expiresAt: Date.parse('2026-12-31T00:00:00Z'),
});An assignment stops counting the moment it ends, and iam.sweepExpired() deletes it later.
Let people request an app
Give the app a requestPackageId: a requestable access package
that grants a group the app is assigned to. The catalog checks that the package is requestable, not what it grants,
so point it at the right group.
People without the app then see it under "More apps" with a Request access button. The request is an ordinary
package request: it needs
iam:packages:request, and the package's approvers decide. Once the person joins the group, the app moves to their
launcher.
The launcher
People use the launcher from their own signed-in session, with no permission. API keys, role sessions, session tokens and agents acting for people are refused.
minelists the enabled apps the person has, most recently opened first, then the apps they may request (withrequestPackageIdand novia). It is not audited.launchchecks the person has the app, records the launch (first and last time, and a count), auditsapp:launch, and returns{ url }. It does not sign the person in to the app; the app's own sign-in runs as usual.
An administrator viewing as the person sees their launcher, but cannot
open apps from it: launch answers IMPERSONATION_RESTRICTED.
In React, useMyApps from @better-iam/react loads the launcher: apps to open, requestable apps to ask for, and
launch(appId), which records the launch, reloads, and resolves to the URL. It does not open the app: by the time it
resolves, the browser no longer treats a new tab as the result of the click and blocks it, so open the tab during the
click and point it at the URL afterwards.
function Launcher({ tenantId }: { tenantId: string }) {
const { apps, requestable, launch } = useMyApps({ tenantId });
async function open(appId: string) {
// Open the tab during the click (browsers block tabs opened later), then point it at the app.
const tab = window.open('about:blank', '_blank');
try {
const url = await launch(appId);
if (tab) {
tab.opener = null;
tab.location.href = url;
} else window.location.href = url;
} catch (error) {
tab?.close(); // not the person's app, or an administrator viewing as them
throw error;
}
}
return (
<>
{apps.map((app) => (
<button key={app.id} onClick={() => void open(app.id)}>
{app.name}
</button>
))}
{requestable.map((app) => (
<RequestAccessButton key={app.id} packageId={app.requestPackageId!} />
))}
</>
);
}Refuse people without the app
A launcher does not stop anyone from going to an app directly. For apps that sign in through this deployment's
OAuth provider, the provider does. iam.protocolHost gives it the optional hook
clientAllowed(identityId, tenantId, clientId), which asks the catalog whenever the provider loads a person's account
for a client: when it redeems an authorization code, refreshes tokens, and answers userinfo. A person without the app
gets no tokens for its client, and removing their app, or disabling it, also stops their refresh tokens. A provider
created without iam.protocolHost enforces nothing unless you pass a clientAllowed of your own.
The provider refuses only at the token endpoint, after the person has signed in. To tell them why instead, check in your interaction page before completing it:
const details = await issuer.interactionDetails(req, res);
const { identity } = await iam.authenticate(credential);
const access = await iam.applications.allowed({
tenantId: details.tenantId,
identityId: identity.id,
oauthClientId: details.clientId,
});
if (!access.allowed) {
// Not assigned: end the flow with access_denied, or show a page to request the app.
await issuer.completeInteraction(req, res, { credential, consent: false });
return;
}iam.applications.allowed takes exactly one of appId and oauthClientId and answers { allowed, governed }:
| Situation | Answer |
|---|---|
| No app in the catalog names the client | allowed: true, governed: false: the catalog does not govern it |
| The app exists and the person has it now | allowed: true, governed: true |
| The app exists and the person does not have it, or the app is disabled | allowed: false, governed: true |
It gives the same answer as the provider's hook, takes no credential and is not audited: it is for the deployment's
own code. Over the API the same check is applications.check, which needs iam:applications:read.
Clean up unused access
usage (iam:applications:read) reports for every app how many people have it (people), how many opened it in
the last 30 days, and unused: direct assignments to people, at least unusedDays old (90 by default), whose person
has not opened the app in that time. removeUnused (iam:applications:assign) removes them for one app and audits
each removal as app:unassign. Group assignments stay: the app goes with the group membership.
const report = await iam.api.applications.usage(admin, { tenantId, unusedDays: 60 });
for (const app of report.filter((item) => item.unused.length))
await iam.api.applications.removeUnused(admin, { tenantId, appId: app.appId, unusedDays: 60 });Permissions and audit
| Action | Methods |
|---|---|
iam:applications:read | list, listAssignments, usage, check |
iam:applications:manage | create, update, delete |
iam:applications:assign | assign, unassign, removeUnused |
| none (own session) | mine, launch |
Apps are audited as app:create, app:update and app:delete, assignments as app:assign and app:unassign, and
launches as app:launch; subscribe a to app:* to follow them. People's names
appear in listAssignments and usage only for callers who may also read the directory (iam:identities:read). The console pages are Access › Applications for
administrators (apps, assignments, usage, and a "Remove unused" button) and Home › My apps for everyone.
Better IAM is created by Sean Filimon
Last updated