@evgenkoshevoy/better-auth-application
v0.0.3
Published
Application plugin for better-auth with per-app roles and deny-override access control
Readme
application — application registry plugin for better-auth
An application registry (analogous to Atlassian: Jira / Bitbucket / Confluence) with access control at the user, organization, and OAuth client (OIDC Provider) level. Supports CASL-compatible access rules stored in application metadata.
Two Collections
| Table | Purpose |
|---|---|
| application | Application catalog (slug, name, enabled, roles, defaultRole, url, ...). Each application has its own set of roles. url — one or more trusted origin URLs for this application (required) |
| applicationAccess | Unified access table. Polymorphic subject: subjectType (user / organization / oauthClient) + subjectId, plus effect (allow/deny), role, enabled |
Roles
application.roles — an ordered array from weakest to strongest (order defines seniority when multiple grants apply), e.g. ["viewer", "developer", "admin"]. defaultRole — role for an allow-grant without an explicit role; it is recommended to always set this explicitly. When granting access, the role is validated against the application's roles. The final role is the strongest among all applicable grants.
FK (references + onDelete: cascade) is only on applicationId. There is no FK on subjectId — it is polymorphic. Orphaned rows are cleaned up by an after-hook when a user or organization is deleted.
Permission Model (deny-override)
For Users
checkAccess(user, app, org):
- The application must be
enabled; - Only rows with
enabled = trueare considered; - An explicit
denyfor the user overrides everything → access is denied; - Otherwise access is granted if the user has
allowor their organization (where they are amember) hasallow; - The final role = the strongest among all applicable grants.
source in the response: user-allow / org-grant / user-deny / app-disabled / no-grant.
For OAuth Clients
Direct grant check by clientId (no org hierarchy):
- The application must be
enabled; - An active
allow-grant must exist forsubjectType: "oauthClient",subjectId: clientId; - A deny-grant blocks access.
source in the response: client-allow / client-deny / app-disabled / no-grant.
Setup
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { application } from "@evgenkoshevoy/better-auth-application";
const appPlugin = application(); // isAdmin default: session.user.role === "admin"
export const auth = betterAuth({
trustedOrigins: appPlugin.getTrustedOrigins, // load trusted origins from all registered applications
plugins: [
organization(),
appPlugin,
],
});Trusted Origins
appPlugin.getTrustedOrigins is an async function compatible with better-auth's trustedOrigins option. On the first request it queries the database for all registered applications, collects their url values, and caches them in memory. The cache is automatically updated when applications are created, and invalidated (re-fetched on next call) when applications are updated or deleted.
Custom admin predicate:
application({ isAdmin: (s) => s.user.role === "superadmin" })Client:
import { createAuthClient } from "better-auth/client";
import { organizationClient } from "better-auth/client/plugins";
import { applicationClient } from "@evgenkoshevoy/better-auth-application/client";
export const authClient = createAuthClient({
plugins: [
organizationClient(),
applicationClient({ appId: "jira" }), // id or slug; can be a function for dynamic resolution
],
});Migrations:
npx @better-auth/cli generate
npx @better-auth/cli migratex-app-id Header
The client plugin adds x-app-id to all requests — the server knows which application the user is coming from. The value is an id or slug (the server resolves both).
checkAccess and myAbilities without an explicit applicationId take the application from this header:
const { data } = await authClient.application.checkAccess();Read it in your own endpoint: request.headers.get("x-app-id"). For server-side route protection:
await auth.api.checkAccess({ headers: request.headers })Examples
Application Management (admin)
// Create an application with roles and trusted URL(s)
await authClient.application.createApplication({
slug: "jira",
name: "Jira",
roles: ["viewer", "developer", "admin"],
defaultRole: "viewer",
url: ["https://jira.example.com"], // required; string or array of strings
});
// Grant access to a user
await authClient.application.setAccess({
applicationId, subjectType: "user", subjectId: userId, role: "developer",
});
// Grant access to an organization
await authClient.application.setAccess({
applicationId, subjectType: "organization", subjectId: orgId, role: "developer",
});
// Grant access to an OAuth client (clientId from oidc-provider)
await authClient.application.setAccess({
applicationId, subjectType: "oauthClient", subjectId: "my-client-id", role: "viewer",
});
// Explicit user deny — overrides their organization's grant
await authClient.application.setAccess({
applicationId, subjectType: "user", subjectId: userId, effect: "deny",
});
// Revoke a grant
await authClient.application.revokeAccess({ applicationId, subjectType: "user", subjectId: userId });
// All grants for an application
await authClient.application.listAccess({ query: { applicationId } });Access Check (user)
// Check current user's access (org is taken from the active session)
const { data } = await authClient.application.checkAccess({ query: { applicationId } });
// data => { hasAccess: true, role: "developer", source: "user-allow" }
// Applications available to the current user
const { data } = await authClient.application.myApplications();Access Check (OAuth client)
An OAuth client arrives with its own access token at an external API. That API checks whether the client has access, knowing its own applicationId:
// External API (server-side)
const response = await fetch("/api/auth/application/client-abilities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: bearerTokenFromRequest, // token received from the OAuth client
applicationId: "jira", // the application's own ID
}),
});
const { hasAccess, role, rules } = await response.json();CASL Rules (Variant B)
Access rules are stored in application.permissions — an object of the form { [role]: AbilityRule[] } in CASL format. Rules can be changed without a deploy via updateApplication.
Permissions Structure
await authClient.application.createApplication({
slug: "jira",
name: "Jira",
roles: ["viewer", "licenseManager", "admin"],
defaultRole: "viewer",
url: "https://jira.example.com",
permissions: {
viewer: [
{ action: "list", subject: "License" },
{ action: "read", subject: "License" },
],
licenseManager: [
{ action: ["list", "read", "renew"], subject: "License" },
{ action: "create", subject: "License", conditions: { type: "simple" } },
],
admin: [
{ action: "manage", subject: "all" },
],
},
});Supported rule fields (AbilityRule):
| Field | Type | Description |
|---|---|---|
| action | string \| string[] | Action: "list", "read", "create", "manage", ... |
| subject | string \| string[] | Resource: "License", "all", ... |
| conditions | Record<string, unknown> | MongoDB-style conditions for ABAC |
| fields | string[] | Field-level restriction |
| inverted | boolean | true — a deny rule (cannot) |
Get Ready-to-Use CASL Rules
For a user:
const { data } = await authClient.application.myAbilities({
query: { applicationId: "jira" },
});
// data => { hasAccess: true, role: "licenseManager", rules: [...] }
import { createMongoAbility, subject } from "@casl/ability";
const ability = createMongoAbility(data.rules);
ability.can("list", "License") // → true
ability.can("create", subject("License", { type: "simple" })) // → true
ability.can("create", subject("License", { type: "advanced" })) // → falseFor an OAuth client (returns the same rules along with the access result):
// POST /application/client-abilities
// { accessToken, applicationId } → { hasAccess, role, rules }If permissions is not set, rules will be [] — the application itself decides how to handle the absence of rules.
Notes
- Deprecated roles: if a role is removed from
application.roles, grants with that role are lazily nulled in the DB (role → null) on the next access check and resolved todefaultRole. The order of elements inrolesonly affects the selection of the strongest role when multiple grants apply — not fallback behavior. - OAuth clients:
subjectIdforoauthClientis theclientIdfromoauthApplication(OIDC Provider). When an OAuth client is deleted, its grants must be cleaned up manually viarevokeAccess— there is no automatic hook. - Composite uniqueness (
applicationId+subjectType+subjectId) is enforced via upsert in code. For a DB-level guarantee, add a unique index via migration. myApplicationsresolves each application individually (N queries). For a large catalog, replace with a batch query onapplicationAccessrows and in-memory resolution.- The organization deletion hook path (
/organization/delete...) should be verified against your version of the organization plugin — adjust thematcherif needed.
