Writing Custom Actions
Actions are custom JavaScript functions that execute at specific points in the Auris authentication pipeline. They allow you to extend the platform’s behavior without forking or modifying Auris itself — add custom claims to tokens, block logins based on external data, call webhooks, enforce business rules, or integrate with third-party services.
When to use Actions vs other extension points
| Mechanism | Best For |
|---|---|
| Actions | Logic that must run synchronously during the auth flow (block a login, add claims, modify user metadata) |
| Custom Claims | Static or attribute-based token enrichment that does not require external calls or conditional logic |
| Webhooks | Asynchronous notifications after events occur (audit logging, analytics, Slack alerts) |
If your use case requires an external API call that must succeed before the login completes, use an Action. If you only need to notify a system after the fact, use a Webhook.
The Sandbox Environment
Actions execute in a restricted JavaScript sandbox. The sandbox provides:
Available APIs
| API | Notes |
|---|---|
fetch() | Make HTTP requests to external services. Full Fetch API. |
JSON | JSON.parse() and JSON.stringify() |
Date | Date construction and manipulation |
Math | Mathematical operations |
console.log() | Output is captured and visible in action logs |
String, Number, Boolean, Array, Object | Standard built-in types |
Promise, async/await | Asynchronous code is fully supported |
URL, URLSearchParams | URL parsing and construction |
TextEncoder, TextDecoder | Text encoding utilities |
crypto.randomUUID() | UUID generation |
Blocked APIs
The following are blocked for security:
| Blocked | Reason |
|---|---|
require(), import | No module system access |
process | No access to environment variables or process info |
| Dynamic code execution functions | No runtime code generation |
global, globalThis | No access to the global scope |
fs, child_process | No filesystem or process spawning |
Actions have a configurable execution timeout (default: 5 seconds, max: 30 seconds). If an action exceeds the timeout, it is terminated and the configured failure behavior applies (allow or deny the request).
Trigger Types
Actions fire at specific points in the authentication flow. Each trigger receives a different context object:
| Trigger | When It Fires | Common Uses |
|---|---|---|
pre-login | Before credentials are verified | Block logins by email domain, check external blocklists |
post-login | After successful authentication, before token issuance | Add custom claims, sync with external systems, conditional MFA |
pre-register | Before a new user is created | Validate email domain, check invitation requirements |
post-register | After a new user is created | Send welcome webhook, assign to organization, set metadata |
post-change-password | After a password change | Notify external systems, invalidate cached sessions |
pre-token | Before an M2M token is issued | Restrict scopes, validate client against external rules |
The Context Object
Every action receives a context object with information about the current request. The shape varies by trigger:
pre-login and post-login
{
user: {
id: 'user-123',
email: '[email protected]',
username: 'alice',
firstName: 'Alice',
lastName: 'Smith',
roles: ['editor', 'viewer'],
metadata: { plan: 'pro', company: 'Acme' },
},
request: {
ip: '203.0.113.42',
userAgent: 'Mozilla/5.0...',
geoip: { country: 'IT', city: 'Milan' },
},
application: {
id: 'app-456',
name: 'Dashboard',
type: 'WEB',
},
tenant: {
id: 'tenant-789',
name: 'acme-corp',
},
}pre-token
{
client: {
id: 'client-abc',
name: 'Billing Service',
type: 'M2M',
},
requestedScopes: ['read:users', 'manage:billing'],
tenant: { id: 'tenant-789', name: 'acme-corp' },
}The ActionResult Return Type
Actions return an object that controls the outcome:
interface ActionResult {
allow: boolean // true = proceed, false = block the request
message?: string // Error message shown to the user when allow=false
claims?: Record<string, any> // Custom claims to add to the token (post-login, pre-token)
metadata?: Record<string, any> // Metadata to set on the user record
}If an action does not return a result (or returns undefined), the request proceeds as normal.
Practical Examples
Block logins by email domain
// Trigger: pre-login
// Block personal email providers from logging in
const blockedDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com']
const domain = context.user.email.split('@')[1]
if (blockedDomains.includes(domain)) {
return {
allow: false,
message: 'Personal email addresses are not allowed. Please use your corporate email.',
}
}
return { allow: true }Add custom claims based on roles
// Trigger: post-login
// Add a 'plan' claim and feature flags based on user metadata
const plan = context.user.metadata?.plan || 'free'
const featureFlags = {
free: { maxProjects: 3, analytics: false, exportEnabled: false },
pro: { maxProjects: 50, analytics: true, exportEnabled: true },
enterprise: { maxProjects: -1, analytics: true, exportEnabled: true },
}
return {
allow: true,
claims: {
plan,
features: featureFlags[plan] || featureFlags.free,
'https://myapp.com/roles': context.user.roles,
},
}Log authentication events to an external webhook
// Trigger: post-login
// Send a webhook notification for every login
try {
await fetch('https://hooks.example.com/auth-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'user.login',
userId: context.user.id,
email: context.user.email,
ip: context.request.ip,
country: context.request.geoip?.country,
timestamp: new Date().toISOString(),
}),
})
} catch (err) {
// Non-critical -- log but don't block the login
console.log('Webhook failed:', err.message)
}
return { allow: true }Restrict M2M scopes based on external policy
// Trigger: pre-token
// Check an external policy service before issuing M2M tokens
const response = await fetch('https://policy.internal/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: context.client.id,
scopes: context.requestedScopes,
}),
})
if (!response.ok) {
return {
allow: false,
message: 'Token request denied by policy service.',
}
}
const policy = await response.json()
return {
allow: true,
claims: {
scope: policy.allowedScopes.join(' '),
'policy-version': policy.version,
},
}Conditional MFA trigger
// Trigger: post-login
// Require MFA for admin roles or logins from new countries
const isAdmin = context.user.roles.includes('admin')
const isNewCountry = context.request.geoip?.country !== context.user.metadata?.lastCountry
if (isAdmin || isNewCountry) {
return {
allow: true,
metadata: {
lastCountry: context.request.geoip?.country,
requireMfa: true,
},
}
}
return {
allow: true,
metadata: { lastCountry: context.request.geoip?.country },
}Execution Order
When multiple actions are configured for the same trigger, they execute in the order defined by their order field (ascending). Each action receives the same original context — one action’s output does not modify the context for subsequent actions.
However, claims and metadata from all actions are merged. If two actions set the same claim key, the last action in order wins.
| Action Order | Action Name | Claims Output |
|---|---|---|
| 1 | Add plan claim | { plan: 'pro' } |
| 2 | Add feature flags | { features: { ... } } |
| 3 | Add region claim | { region: 'eu' } |
Final token claims: { plan: 'pro', features: { ... }, region: 'eu' }
If any action returns allow: false, the entire pipeline is halted and the request is denied, regardless of subsequent actions.
The Blueprint Visual Editor
For teams that prefer a visual approach, Auris provides a Blueprint editor — a node-based visual rule builder inspired by Unreal Engine’s Blueprint system.
The Blueprint editor represents actions as a graph of connected nodes:
| Node Type | Color | Purpose |
|---|---|---|
| Trigger | Orange | The entry point — which auth event fires the rule |
| Condition | Cyan | Check a field against a value (e.g., user.email contains @acme.com) |
| Logic Gate | Violet | Combine conditions with AND/OR |
| Deny | Pink | Block the request with an error message |
| Set Claims | Blue | Add key-value pairs to the token |
| Set Metadata | Emerald | Update user metadata |
| Log | Green | Write to action logs |
The Blueprint editor and the code editor are two views of the same underlying action. Changes in one are synchronized to the other. You can start with Blueprint for simple rules and switch to code for complex logic.
To access the Blueprint editor, open an action’s detail page in the Console and click the Blueprint tab.
Debugging Actions
Action Logs
Every action execution is logged. View logs in Console -> Actions -> [Action Name] -> Logs tab. Each log entry includes:
- Execution timestamp
- Trigger event
- Execution duration (ms)
- Status (success, error, timeout)
- Console.log output
- Return value
Using console.log
console.log() output is captured and stored in the action log. Use it for debugging:
console.log('User roles:', JSON.stringify(context.user.roles))
console.log('Request IP:', context.request.ip)
console.log('GeoIP data:', JSON.stringify(context.request.geoip))
// This output appears in the action's Logs tab
return { allow: true }Error Handling
If an action throws an unhandled error, the behavior depends on the action’s failure mode setting:
| Failure Mode | Behavior |
|---|---|
allow (default) | The request proceeds. The error is logged. |
deny | The request is blocked with a generic error message. |
Always use try/catch around external API calls to prevent failures from blocking logins:
try {
const result = await fetch('https://external-api.example.com/check')
// process result
} catch (err) {
console.log('External API failed, proceeding:', err.message)
// Don't block the login because an external service is down
}
return { allow: true }Performance Tips
- Keep actions fast — Aim for less than 100ms execution time. Every millisecond adds to login latency.
- Use try/catch for external calls — Never let a non-critical external API failure block a login.
- Avoid sequential external calls — If you need multiple API calls, use
Promise.all()to run them in parallel. - Cache expensive lookups — For data that changes infrequently, consider caching in user metadata and refreshing periodically rather than querying on every login.
- Set appropriate timeouts — Use
AbortControllerwithfetch()to set explicit timeouts shorter than the action timeout:
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000) // 3 second timeout
const response = await fetch('https://slow-api.example.com/check', {
signal: controller.signal,
})API Endpoints
/api/actionsRequires: admin:allList all actions for the tenant. Supports filtering by trigger type and status.
/api/actionsRequires: admin:allCreate a new action. Requires name, trigger, code, and optional order, timeout, status.
/api/actions/:idRequires: admin:allGet action details including code, trigger, execution count, and error count.
/api/actions/:idRequires: admin:allUpdate an action’s code, trigger, order, timeout, or status.
/api/actions/:idRequires: admin:allDelete an action.
/api/actions/:id/logsRequires: admin:allList execution logs for a specific action. Includes duration, status, console output, and return value.
Required Permissions
| Operation | Permission |
|---|---|
| View actions | admin:all |
| Create, edit, delete actions | admin:all |
| View action logs | admin:all |
Related Guides
- Custom JWT Claims — Declarative claim enrichment (no code required)
- Setting Up Webhooks — Asynchronous event notifications
- Roles & Permissions — Managing the permissions referenced in actions