@rodit/rodit-auth-be
v9.15.0
Published
RODiT-based authentication system for Express.js applications
Maintainers
Readme
RODiT Authentication SDK
A comprehensive Node.js SDK for implementing RODiT-based mutual authentication, authorization, self-configuration, and session management in Express.js applications.
Version: 9.15.0
License: Proprietary
Author: Discernible IO
Login POST /api/login: Use accountid (or roditid), timestamp, and base64url_signature. Sign UTF-8 bytes of identifier + timestamp_iso, and reject deprecated keys such as signature and account_id. See CHANGELOG.md.
9.15.0: Outbound send_webhook SSRF controls (private/metadata blocklist, webhook_cidr enforcement, DNS pin); login success exposes verified peer id as JSON roditid and req.authenticatedRoditId; optional enforceRateLimitFromClaims() for per-route claim quotas. See CHANGELOG.md.
9.13 federated login: A client RODiT issued for API A can log into API B in the same SR/CR family via login_server({ apiEndpoint }). See Federated login.
Table of Contents
- Quick Start
- Core Concepts
- Installation & Setup
- Authentication
- Authorization & Permissions
- Session Management
- Configuration
- Logging & Monitoring
- Performance Tracking
- Webhooks
- Advanced Usage
- API Reference
- Best Practices
- Troubleshooting
Quick Start
Installation
npm install @rodit/rodit-auth-beBasic Server Setup
const express = require('express');
const { RoditClient } = require('@rodit/rodit-auth-be');
const { setExpressSessionStore } = require('@rodit/rodit-auth-be/lib/auth/sessionmanager');
const { ulid } = require('ulid');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const app = express();
// Configure session storage BEFORE initializing RoditClient
const sessionStore = new SQLiteStore({
db: 'sessions.db',
dir: './data',
table: 'sessions',
});
setExpressSessionStore(sessionStore);
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || ulid();
req.startTime = Date.now();
next();
});
async function startServer() {
try {
const roditClient = await RoditClient.create('server');
app.locals.roditClient = roditClient;
const logger = roditClient.getLogger();
app.use(roditClient.getLoggingMiddleware());
const authenticate = (req, res, next) => roditClient.authenticate(req, res, next);
const authenticateLogout = (req, res, next) =>
roditClient.authenticateForLogout(req, res, next);
const authorize = (req, res, next) => roditClient.authorize(req, res, next);
app.post('/api/login', (req, res) => {
req.logAction = 'login-attempt';
roditClient.login_client(req, res);
});
app.post('/api/logout', authenticateLogout, (req, res) => {
req.logAction = 'logout-attempt';
roditClient.logout_client(req, res);
});
app.get('/api/protected', authenticate, (req, res) => {
res.json({ message: 'Protected data', user: req.user });
});
app.use('/api/admin', authenticate, authorize, adminRoutes);
const port = 3000;
app.listen(port, () => {
logger.info(`RODiT Authentication Server running on port ${port}`);
});
} catch (error) {
console.error('Server initialization failed:', error);
process.exit(1);
}
}
startServer();Core Concepts
The RoditClient Pattern
The SDK centers around the RoditClient class, which provides a unified interface for all RODiT operations:
- Single Initialization: Create once with
RoditClient.create(role)where role is'server','client', or'portal' - Shared Instance: Store in
app.localsfor access across routes and middleware - Self-Configuring: Automatically loads configuration from Vault, files, or environment variables
- Encapsulated: All SDK functionality accessed through the client instance
- Session Management: Built-in session tracking with pluggable storage backends
- Performance Monitoring: Integrated request tracking and metrics collection
App.locals Pattern
Store the initialized client in app.locals for consistent access across your application:
// In main app.js
const roditClient = await RoditClient.create('server')
app.locals.roditClient = roditClient
// In route modules
const router = express.Router()
router.get('/data', (req, res) => {
const client = req.app.locals.roditClient
const logger = client.getLogger()
logger.info('Processing request', {
component: 'DataRoute',
userId: req.user?.id
})
res.json({ data: 'example' })
})Authentication Middleware Pattern
Create middleware functions that delegate to the RoditClient:
// Create reusable middleware
const authenticate = (req, res, next) => {
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authentication service unavailable' })
}
client.authenticate(req, res, next)
}
const authorize = (req, res, next) => {
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authorization service unavailable' })
}
client.authorize(req, res, next)
}
// Use in routes
app.get('/api/protected', authenticate, handler)
app.post('/api/admin', authenticate, authorize, adminHandler)Installation & Setup
Dependencies
Required:
npm install @rodit/rodit-auth-be express config winstonRecommended for main:
npm install express-session connect-sqlite3Optional:
npm install node-vault # For Vault-based credentials
npm install winston-loki # For Grafana Loki loggingEnvironment Variables
Vault Configuration (main):
export RODIT_NEAR_CREDENTIALS_SOURCE=vault
export VAULT_ENDPOINT=https://vault.example.com
export VAULT_ROLE_ID=your-role-id
export VAULT_SECRET_ID=your-secret-id
export VAULT_RODIT_KEYVALUE_PATH=secret/rodit
export SERVICE_NAME=your-service-name
export NEAR_CONTRACT_ID=discernible-io.nearApplication Configuration:
export NODE_ENV=main # Environment: main, development, test
export LOG_LEVEL=info # Logging: error, warn, info, debug, trace
export API_DEFAULT_OPTIONS_DB_PATH=/app/data/database.sqliteSession Configuration:
export SESSION_STORAGE_TYPE=express-session # Storage: memory, express, express-session
export SESSION_CLEANUP_INTERVAL=3600000 # Cleanup interval in milliseconds (1 hour)
export SESSION_TOKEN_RETENTION_PERIOD=604800 # Token retention in seconds (7 days)
export SESSION_VALIDATION_CACHE_TTL=5000 # Cache TTL in milliseconds (5 seconds)Logging Configuration:
export LOKI_URL=https://loki.example.com:3100
export LOKI_BASIC_AUTH=username:passwordConfiguration Files
Create config/default.json:
{
"NEAR_CONTRACT_ID": "discernible-io.near",
"SERVICE_NAME": "your-service",
"SECURITY_OPTIONS": {
"SILENT_LOGIN_FAILURES": false,
"SESSION_TTL_SECONDS": 5200
"FALLBACK_JWT_DURATION": 3600
}
}Authentication
RODiT-Based Authentication
RODiT provides cryptographic mutual authentication using blockchain-verified identities.
Client Login Request
For API login documentation, use accountid with HTTP POST /api/login. The signed payload is accountid + timestamp_iso (no separator).
| Field | Description |
|-------|-------------|
| timestamp | Recommended; Unix seconds from GET /api/login/timestamp |
| base64url_signature | Ed25519 detached signature (base64url) over accountid + timestamp_iso |
| accountid | 64-hex implicit NEAR account login identifier |
{
"accountid": "<64-char-hex>",
"timestamp": 1640995200,
"base64url_signature": "base64url-encoded-signature"
}Use base64url_signature in login payloads for API login examples.
Rejected keys (HTTP 400, LOGIN_PAYLOAD_DEPRECATED): signature and account_id.
Server Response
{
"jwt_token": "<jwt-token>",
"requestId": "01HQXYZ123ABC"
}Authentication Flow
- Client sends RODiT credentials - RODiT ID or account ID, timestamp, and cryptographic signature
- SDK verifies signature - Validates against blockchain records (NEAR Protocol)
- Session created - New session stored in session manager
- JWT token issued - Token contains session ID, user claims, and always
rodit_subjectuniqueidentifier_url(nullsame-API; federated API URL when peer home ≠ issuing server). See Federated login. - Subsequent requests - Client sends JWT in
Authorization: Bearer <token>header - Token validation - SDK validates JWT (issuer rules differ for federated vs same-API) and checks session status
Security hardening in current implementation:
- JWT compact parts must be canonical base64url (non-canonical encodings are rejected).
- Session registration is enforced during JWT validation (unknown/inactive/expired sessions are rejected).
- Server session length defaults to
SECURITY_OPTIONS.SESSION_TTL_SECONDS(5200 s); see Session lifetime and TTL. - Token renewal uses
sessionManagerfor session checks and updates (nostateManagersession mutations). - Federated
login_server({ apiEndpoint })rejects MITM when the signedrodit_subjectuniqueidentifier_urldoes not match the intended endpoint.
Login Implementation
// routes/login.js
const express = require('express')
const router = express.Router()
router.post('/login', async (req, res) => {
req.logAction = 'login-attempt'
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authentication service unavailable' })
}
// Delegate to SDK's login_client method
await client.login_client(req, res)
})
module.exports = routerLogout Implementation
// Logout invalidates the JWT token and closes the session
// Use logout-specific auth so signature-valid expired tokens can still logout.
router.post('/logout', authenticateLogout, async (req, res) => {
req.logAction = 'logout-attempt'
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authentication service unavailable' })
}
// Delegate to SDK's logout_client method
await client.logout_client(req, res)
})Protected Routes
// Require authentication for access
app.get('/api/data', authenticate, (req, res) => {
// req.user contains authenticated user information
const logger = req.app.locals.roditClient.getLogger()
logger.info('Protected route accessed', {
component: 'API',
userId: req.user.id,
roditId: req.user.roditId,
requestId: req.requestId
})
res.json({
message: 'Authenticated data',
user: req.user,
requestId: req.requestId
})
})Authentication Middleware
The authenticate middleware validates JWT tokens and populates req.user:
const authenticate = (req, res, next) => {
const client = req.app.locals.roditClient
client.authenticate(req, res, next)
}
// After successful authentication, req.user contains:
// {
// id: 'user-unique-id',
// roditId: '01K4G3D95QF6NR0RSJK9WEK6KA',
// aud: 'audience',
// iss: 'issuer',
// exp: 1640999999,
// iat: 1640995200,
// session_id: '01HQXYZ123ABC'
// }Login Mode Control
The SDK provides configurable access control for RODiT authentication, allowing you to restrict which types of logins are accepted by your server.
Login Types
Partner Login (Client-Server)
- Definition: Authentication where the peer's service provider ID is different from the server's service provider ID
- Use Case: Traditional client-server authentication where a client authenticates to a service provider
- Example: A mobile app (client) authenticating to your API server
Peer Login (Peer-to-Peer)
- Definition: Authentication where the peer's service provider ID is the same as the server's service provider ID
- Use Case: Peer-to-peer authentication between entities with the same service provider
- Example: Two servers in the same organization authenticating to each other
Configuration Options
| Mode | Partner Logins | Peer Logins | Description |
|------|---------------|-------------|-------------|
| partner | ✅ Accepted | ❌ Rejected | Default - Only accept client-server authentication |
| promiscuous | ✅ Accepted | ✅ Accepted | Accept all valid logins regardless of type |
| p2p | ❌ Rejected | ✅ Accepted | Only accept peer-to-peer authentication |
Usage Examples
Default (Partner Only):
// No configuration needed - this is the default
// Only client-server authentication is acceptedAccept All Logins:
export SECURITY_OPTIONS_LOGIN_MODE=promiscuous
// Both Partner and Peer logins are acceptedPeer-to-Peer Only:
export SECURITY_OPTIONS_LOGIN_MODE=p2p
// Only peer-to-peer authentication is acceptedDocker/Podman:
podman run -e SECURITY_OPTIONS_LOGIN_MODE=partner ...GitHub Actions: Add repository variable:
- Name:
SECURITY_OPTIONS_LOGIN_MODE - Value:
partner|promiscuous|p2p
Federated login (same family, different API URL)
Enable a client RODiT issued for API A (subjectuniqueidentifier_url) to log
into API B in the same SR/CR family. Federation is login → remint a local
JWT on B; foreign JWTs are not accepted without re-login. Mutual auth remains
optional (no reverse login_server required).
Client call
const { RoditClient } = require('@rodit/rodit-auth-be');
const client = await RoditClient.create('client');
await client.login_server({
apiEndpoint: 'https://api-b.example.com', // federated API; omit for home API
});When apiEndpoint is omitted, login targets the client's own
subjectuniqueidentifier_url (same-API path).
JWT claim contract
Unset fields are always present as null (same rule as config_* in 9.12).
Do not omit rodit_subjectuniqueidentifier_url on same-API tokens.
| Claim | Always present? | Same-API login | Federated login |
|-------|-----------------|----------------|-----------------|
| iss | yes | peer home URL (= server URL in practice) | client home subjectuniqueidentifier_url |
| aud | yes | server owner_id | server owner_id (federated API) |
| rodit_subjectuniqueidentifier_url | yes | null | federated API subjectuniqueidentifier_url |
| config_iso639 / config_iso3166 / config_iso15924 / config_timeoptions | yes | null | null |
A JWT is treated as federated when:
rodit_subjectuniqueidentifier_url != null
&& String(rodit_subjectuniqueidentifier_url).trim() !== ""(Helper: isNonEmptyUrlClaim.)
Server validation
After signature verification:
- Federated JWT —
rodit_subjectuniqueidentifier_urlmust equal this server's URL;issmust equal the login client's home URL (resolved fromsubvia on-chain RODiT). - Same-API / 9.12 tokens — claim null/absent →
issmust equal this server's URL (unchanged 9.12 issuer rule). aud, family match, andLOGIN_MODEare unchanged.
Client MITM check
After crypto validation of the received JWT, login_server checks that a
federated attempt (apiEndpoint ≠ client home) has:
- Non-empty
rodit_subjectuniqueidentifier_url - That claim equal to the intended
apiEndpoint issequal to the client home URL
| Failure | errorCode |
|---------|-------------|
| Null/empty federated claim | FEDERATED_ISSUER_MISSING |
| Claim or iss mismatch | FEDERATED_ISSUER_MISMATCH |
Renewal
- Federated JWT renewals preserve the non-null claim string.
- Tokens minted by 9.12 (no claim) renew into
rodit_subjectuniqueidentifier_url: null.
Operational prerequisites (outside SDK)
- Federated server RODiT shares the same
bc=;sc=;id=SR;id=CRfamily. - Federated domain has DNS trust TXT (keyed off own
subjectuniqueidentifier_url). - Client calls
login_server({ apiEndpoint: '<federated URL>' }). - Client→server federation needs
LOGIN_MODE=partner(orpromiscuous); a server RODiT logging into a federated API underpartneris still rejected by existing role-separation policy.
Helpers (package root)
const {
normalizeUrlWithoutPort,
isNonEmptyUrlClaim,
isFederatedRoditLogin,
validateFederatedLoginTarget,
} = require('@rodit/rodit-auth-be');Logging and Monitoring
Successful Login:
{
"level": "info",
"message": "PARTNER login verified successfully",
"verificationType": "PARTNER",
"loginMode": "partner",
"duration": 1234
}Rejected Login:
{
"level": "warn",
"message": "PEER login rejected by LOGIN_MODE policy",
"verificationType": "PEER",
"loginMode": "partner",
"policyReason": "LOGIN_MODE=partner does not accept PEER logins"
}Metrics:
rodit_match_verificationwithresult: "success"- Successful authenticationrodit_match_verificationwithresult: "policy_rejected"- Rejected by policy
Security Considerations
- Default is Secure: The default
partnermode provides the most restrictive access control - Promiscuous Mode: Use only when you need to accept both types of authentication
- P2P Mode: Use when building peer-to-peer systems where only same-provider authentication is needed
- Policy Enforcement: Rejections are logged with clear reasons for audit trails
- Federated MITM: Always pass the real peer URL as
apiEndpoint; the client verifies the signed federated issuer claim against that URL after login
Troubleshooting
Login Rejected with "policy_rejected":
- If you see "PEER login rejected" and need to accept peer logins, set mode to
promiscuousorp2p - If you see "PARTNER login rejected" and need to accept partner logins, set mode to
promiscuousorpartner
Federated login failures:
FEDERATED_ISSUER_MISSING— JWT has null/emptyrodit_subjectuniqueidentifier_urlbutapiEndpointdiffers from client home (peer may be pre-9.13, or claim omitted incorrectly)FEDERATED_ISSUER_MISMATCH— claim orissdoes not match intendedapiEndpoint/ client home (wrong URL or MITM)Error 005: Invalid federated issueron the server — JWT federated claim is not this server'ssubjectuniqueidentifier_url
Check Current Mode: Look for the log message during authentication:
"Starting RODiT match verification" with "loginMode": "partner"Authorization & Permissions
Route-Based Permissions
Permissions are configured in your RODiT token metadata using the permissioned_routes field:
{
"permissioned_routes": {
"entities": {
"/": {
"methods": "+0"
},
"/api/echo": {
"methods": "+0"
},
"/api/cruda/create": {
"methods": "+0"
},
"/api/cruda/list": {
"methods": "+0"
},
"/api/admin": {
"methods": "+0"
}
}
}
}Permission Format:
Values are strings: optional scope prefix (+ / -) plus a numeric rate:
| Value | Meaning |
|-------|---------|
| "+0" | Allowed (entity + properties scope); unlimited rate |
| "+60" | Allowed; 60 requests per window (default window 60s) |
| "-0" | Allowed (properties-only scope); unlimited |
| (absent / no match) | Denied (403) |
validatepermissions / authorize checks allow/deny and sets req.rateLimit from the numeric part. That metadata is inert until you mount enforceRateLimitFromClaims() (see Dynamic Rate Limiting).
Permission Validation Middleware
The authorize middleware validates that the authenticated user has permission to access the requested route:
// After RoditClient.create('server') and app.locals.roditClient = roditClient
const authenticate = (req, res, next) => roditClient.authenticate(req, res, next)
const authorize = (req, res, next) => roditClient.authorize(req, res, next)
const enforceClaimLimits = roditClient.getClaimRateLimitMiddleware()
// Auth + permission allow/deny
app.use('/api/admin', authenticate, authorize, adminRoutes)
// Auth + permissions + per-route claim quotas from permissioned_routes
app.use('/api/cruda', authenticate, authorize, enforceClaimLimits(), crudaRoutes)Permission Enforcement
// Example: CRUDA routes with permission checking
const router = express.Router()
// All routes require authentication + authorization
router.post('/create', async (req, res) => {
// User must have permission for POST /api/cruda/create
const { comment, author } = req.body
// Create record in database
const result = await db.run(
'INSERT INTO comments (comment, author) VALUES (?, ?)',
[comment, author || req.user.roditId]
)
res.json({ id: result.lastID, requestId: req.requestId })
})
router.post('/list', async (req, res) => {
// User must have permission for POST /api/cruda/list
const records = await db.all('SELECT * FROM comments ORDER BY created_at DESC')
res.json({ records, requestId: req.requestId })
})
module.exports = routerDynamic Permission Checking
// Check permissions programmatically
const client = req.app.locals.roditClient
const hasPermission = client.isOperationPermitted('POST', '/api/admin/users')
if (!hasPermission) {
res.status(403).json({
error: 'Forbidden',
message: 'You do not have permission to access this resource',
requestId: req.requestId
})
}
// Proceed with operationPermission Validation in Client Token Minting
When minting client tokens via /api/signclient, the server validates that requested permissions are a subset of the server's own permissions:
// Client requests these permissions:
const requestedPermissions = {
"/": "+0",
"/api/echo": "+0",
"/api/cruda/create": "+0"
}
// Server validates against its own permissioned_routes
// If any requested route is not in server's config, request is rejected with HTTP 400Session Management
Overview
The SDK includes a comprehensive session management system that:
- Tracks active user sessions
- Validates JWT tokens against session state
- Supports pluggable storage backends
- Automatically cleans up expired sessions
- Integrates with performance metrics
Session lifetime and TTL
Server sessions and JWT access credentials use different clocks:
| Concept | Controlled by | Stored / carried as |
|---------|----------------|---------------------|
| Server session | SECURITY_OPTIONS.SESSION_TTL_SECONDS (host config) | sessionManager record expiresAt; JWT claim session_exp |
| Access credential (JWT exp) | Passport jwt_duration on peer/own RODiT metadata (+ renewal) | JWT exp; renewed until session_exp |
You do not need to change on-chain jwt_duration on the server RODiT token to control how long a session lasts. Set session length in application config instead.
SECURITY_OPTIONS.SESSION_TTL_SECONDS
| Property | Value |
|----------|--------|
| SDK default | 5200 (~87 minutes) |
| Valid range | 60 – 31536000 (365 days), or 0 to disable |
| Config path | SECURITY_OPTIONS.SESSION_TTL_SECONDS |
| Env example | SECURITY_OPTIONS_SESSION_TTL_SECONDS=2592000 (30 days, with node-config style mapping) |
At login the SDK computes:
session_expiresAt = login_time + SESSION_TTL_SECONDSThen applies passport caps: if either peer or own RODiT has a bounded not_after, the session cannot end later than the earlier of those dates.
Set SESSION_TTL_SECONDS to 0 to fall back to passport-derived session end (bounded not_after when present, otherwise max(peer, own) jwt_duration).
Examples
Default (5200 seconds):
// config/default.json — omit SESSION_TTL_SECONDS to use SDK default 5200
{
"SECURITY_OPTIONS": {
"FALLBACK_JWT_DURATION": 3600
}
}30-day sessions:
{
"SECURITY_OPTIONS": {
"SESSION_TTL_SECONDS": 2592000
}
}Passport-derived session length (legacy):
{
"SECURITY_OPTIONS": {
"SESSION_TTL_SECONDS": 0
}
}Enforcement on each API request
For normal API authentication (authenticate_apicall), the SDK:
- Checks stored session: exists,
status === 'active',expiresAtnot in the past. - Validates JWT signature and
exp(with renewal when eligible). - Requires JWT
session_expto match storedexpiresAtwhen session registration is enforced.
Portal/outbound login token validation can skip session registration when SECURITY_OPTIONS.RELAXED_SESSION_VALIDATION is true (default).
Related options
| Option | Purpose |
|--------|---------|
| FALLBACK_JWT_DURATION | Access-token lifetime when passport jwt_duration is missing or invalid (default 3600; max 7 days in validator) |
| JWT_MAX_DURATION_SECONDS_RODIT_UNBOUNDED | Cap on JWT exp when peer not_after is unbounded |
| RELAXED_SESSION_VALIDATION | Portal/outbound flows may skip server session lookup |
| SESSION_VALIDATION_CACHE_TTL | Cache TTL for session invalidation checks after logout |
Session Storage Backends
1. In-Memory Storage (Default)
No configuration needed - works out of the box:
const client = await RoditClient.create('server')
// Uses InMemorySessionStorage by defaultPros: Fast, zero configuration
Cons: Sessions lost on server restart, not suitable for multi-server deployments
2. SQLite Storage (Recommended for main)
Persistent storage using SQLite database:
const express = require('express')
const session = require('express-session')
const SQLiteStore = require('connect-sqlite3')(session)
const { RoditClient } = require('@rodit/rodit-auth-be')
const { setExpressSessionStore } = require('@rodit/rodit-auth-be/lib/auth/sessionmanager')
// Configure BEFORE initializing RoditClient
const sessionStore = new SQLiteStore({
db: 'sessions.db',
dir: './data',
table: 'sessions'
})
setExpressSessionStore(sessionStore)
// Now initialize client
const client = await RoditClient.create('server')Pros: Persistent across restarts, simple setup, uses existing database infrastructure
Cons: Not suitable for multi-server deployments
3. Redis Storage (For Multi-Server)
npm install express-session connect-redis redisconst session = require('express-session')
const RedisStore = require('connect-redis').default
const { createClient } = require('redis')
const { setExpressSessionStore } = require('@rodit/rodit-auth-be/lib/auth/sessionmanager')
// Create Redis client
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379'
})
await redisClient.connect()
// Create Redis store
const redisStore = new RedisStore({
client: redisClient,
prefix: 'rodit:sess:',
ttl: 86400 // 24 hours
})
setExpressSessionStore(redisStore)
const client = await RoditClient.create('server')Pros: Shared sessions across multiple servers, high performance
Cons: Requires Redis infrastructure
Session Storage Configuration
The SDK supports configurable session storage via the SESSION_STORAGE_TYPE environment variable.
Storage Type Options
1. "memory" (Default)
- Uses SDK's standalone
InMemorySessionStorage - No external dependencies required
- Sessions stored in JavaScript
Map - Sessions lost on server restart
- Suitable for development or single-instance deployments
export SESSION_STORAGE_TYPE=memory2. "express" or "express-session"
- Uses
express-sessioncompatible stores - Requires
express-sessionto be installed - Defaults to
express-sessionMemoryStore - Can be overridden with
setExpressSessionStore()for Redis, SQLite, etc. - Suitable for main with persistent storage
export SESSION_STORAGE_TYPE=express-sessionConfiguring Persistent Storage
SQLite Example:
const session = require('express-session')
const SQLiteStore = require('connect-sqlite3')(session)
const { setExpressSessionStore } = require('@rodit/rodit-auth-be/lib/auth/sessionmanager')
// Configure BEFORE initializing RoditClient
const sessionStore = new SQLiteStore({
db: 'sessions.db',
dir: './data',
table: 'sessions'
})
setExpressSessionStore(sessionStore)
// Now initialize client
const client = await RoditClient.create('server')Redis Example:
const session = require('express-session')
const RedisStore = require('connect-redis').default
const { createClient } = require('redis')
const { setExpressSessionStore } = require('@rodit/rodit-auth-be/lib/auth/sessionmanager')
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379'
})
await redisClient.connect()
const redisStore = new RedisStore({
client: redisClient,
prefix: 'rodit:sess:',
ttl: 86400
})
setExpressSessionStore(redisStore)Session Configuration Variables
// Storage backend type
export SESSION_STORAGE_TYPE=express-session
// Cleanup interval (milliseconds) - how often to remove expired sessions
export SESSION_CLEANUP_INTERVAL=3600000 # 1 hour
// Token retention period (seconds) - how long to keep closed sessions
export SESSION_TOKEN_RETENTION_PERIOD=604800 # 7 days
// Validation cache TTL (milliseconds) - trades security for performance
// Lower = more secure but more storage lookups
// Higher = faster but longer window after logout where token may still work
// Set to 0 to disable caching (always check session state)
export SESSION_VALIDATION_CACHE_TTL=5000 # 5 secondsSession Validation Cache:
The SDK caches token validation results to reduce storage lookups:
- Enabled by default with 5-second TTL
- Trade-off: Performance vs. security
- After logout: Cache is immediately invalidated for that session
- Recommendation: Keep default (5s) for most use cases
- High security: Set to
0to disable caching
// Get cache statistics
const sessionManager = roditClient.getSessionManager()
const cacheStats = sessionManager.getValidationCacheStats()
console.log('Cache stats:', cacheStats)
// Output: { totalEntries: 10, validEntries: 8, expiredEntries: 2, cacheTTL: 5000, cacheEnabled: true }Session Operations
// Get session manager
const sessionManager = roditClient.getSessionManager()
// Get active session count
const activeCount = await sessionManager.getActiveSessionCount()
// Get storage information
const storageInfo = await sessionManager.getStorageInfo()
console.log('Storage type:', storageInfo.type)
console.log('Session count:', storageInfo.sessionCount)
// Enumerate sessions via storage
const allSessions = await sessionManager.storage.getAll()
// Or fallback using keys() + get()
const sessionIds = await sessionManager.storage.keys()
const sessions = []
REPEAT: for (const id of sessionIds) {
const session = await sessionManager.storage.get(id)
if (session) sessions.push(session)
}
// Check if token is invalidated
const isInvalidated = await sessionManager.isTokenInvalidated(jwtToken)
// Get detailed invalidation info
const invalidationInfo = await sessionManager.getTokenInvalidationInfo(jwtToken)
if (invalidationInfo) {
console.log('Invalidation reason:', invalidationInfo.reason)
console.log('Invalidated at:', invalidationInfo.invalidatedAt)
}
// Manually close a session
await sessionManager.closeSession(sessionId, 'admin_action')
// Run manual cleanup (removes expired sessions)
const cleanup = await sessionManager.runManualCleanup()
console.log(`Removed ${cleanup.removedSessionsCount} expired sessions`)
// Get validation cache statistics
const cacheStats = sessionManager.getValidationCacheStats()
console.log('Cache entries:', cacheStats.totalEntries)
console.log('Cache TTL:', cacheStats.cacheTTL)Session Lifecycle
- Login - Session created, JWT token issued with session ID
- Active - Token validated on each request, session last_accessed updated
- Logout - Session closed, token invalidated, termination token issued
- Expiration - Sessions expire when stored
expiresAtis reached (SESSION_TTL_SECONDSfrom login, capped by passportnot_after) - Cleanup - Expired sessions removed by automatic cleanup process
Token Invalidation
The SDK validates tokens by checking session state:
// Authentication middleware checks:
// 1. JWT signature validity
// 2. JWT expiration
// 3. Session exists and is active
// 4. Session not expired
// After logout, tokens are invalidated because:
// - Session status set to 'closed'
// - Subsequent requests fail authenticationConfiguration
Configuration Priority
The SDK automatically configures itself from multiple sources with a clear priority hierarchy:
- Environment Variables (Highest priority) - Direct
process.envaccess - Host Application Config - Values from
configpackage (with env mappings) - SDK Fallback Defaults - Built-in defaults from
configsdk.js - Provided Default Value - Optional parameter to
config.get()
Example:
const config = roditClient.getConfig()
// Priority 1: Checks process.env.SESSION_STORAGE_TYPE
// Priority 2: Checks host config.get('SESSION_STORAGE_TYPE')
// Priority 3: Uses SDK default 'memory'
// Priority 4: Falls back to 'memory' if provided
const storageType = config.get('SESSION_STORAGE_TYPE', 'memory')This ensures that:
- CI/CD environment variables always take precedence
- Host applications can override SDK defaults
- SDK provides sensible defaults for all settings
- Configuration is predictable and debuggable
Automatic Configuration Loading
The SDK loads configuration from multiple sources:
- Environment Variables - Direct environment access
- Configuration Files - config/default.json, config/main.json, config/development.json
- Vault Credentials - Main credential storage
- SDK Defaults - Fallback values
Environment Configuration: NODE_ENV and LOG_LEVEL
The SDK uses two separate environment variables for configuration, following Node.js ecosystem standards:
NODE_ENV - Environment Type & Security Behavior
Controls environment-specific behavior and security settings:
Values:
main- Main branch deploy (strict security, no error details)development- Development branch deploy (relaxed security, detailed errors)test- Testing environment (allows bypasses for automated testing)
Default: development
Controls:
- ✅ Error detail exposure in API responses
- ✅ Peer public key requirement enforcement
- ✅ Webhook verification bypass (test mode only)
- ✅ Security-critical behavior
LOG_LEVEL - Logging Verbosity
Controls Winston logger verbosity independently from environment:
Values:
error- Only errorswarn- Warnings and errorsinfo- Informational messages, warnings, and errors (recommended for main)debug- Detailed debugging informationtrace- Maximum verbosity with full traces
Default: info
Controls:
- ✅ Winston logger output level
- ✅ Debug payload logging
- ✅ Log verbosity only (not security)
Separation of Concerns
// Environment detection (security)
const isMain = process.env.NODE_ENV === 'main'
const isDevelopment = process.env.NODE_ENV === 'development'
const isTest = process.env.NODE_ENV === 'test'
// Logging verbosity (independent)
const config = roditClient.getConfig()
const logLevel = config.get('LOG_LEVEL', 'info')Configuration Examples
Main (normal):
export NODE_ENV=main
export LOG_LEVEL=info
// Results in:
// - Strict security enforcement
// - No error details in responses
// - Minimal logging outputMain (troubleshooting):
export NODE_ENV=main
export LOG_LEVEL=debug
// Results in:
// - Strict security enforcement (still main)
// - No error details in responses (still secure)
// - Verbose logging for debuggingDevelopment:
export NODE_ENV=development
export LOG_LEVEL=debug
// Results in:
// - Relaxed security for development
// - Detailed error messages in responses
// - Verbose loggingTesting:
export NODE_ENV=test
export LOG_LEVEL=error
// Results in:
// - Test mode (allows bypasses)
// - Detailed error messages
// - Only errors logged (cleaner test output)Behavior Matrix
| Scenario | NODE_ENV | LOG_LEVEL | Security | Error Details | Logging |
|----------|----------|-----------|----------|---------------|---------|
| Main | main | info | ✅ Strict | ❌ Hidden | Minimal |
| Main Debug | main | debug | ✅ Strict | ❌ Hidden | Verbose |
| Development | development | debug | ⚠️ Relaxed | ✅ Shown | Verbose |
| Testing | test | error | ⚠️ Bypass OK | ✅ Shown | Errors only |
Vault-Based Configuration (main)
For main deployments, credentials are loaded from HashiCorp Vault:
// Environment variables for vault
export RODIT_NEAR_CREDENTIALS_SOURCE=vault
export VAULT_ENDPOINT=https://vault.example.com
export VAULT_ROLE_ID=your-role-id
export VAULT_SECRET_ID=your-secret-id
export VAULT_RODIT_KEYVALUE_PATH=secret/rodit
export SERVICE_NAME=your-service-name
export NEAR_CONTRACT_ID=discernible-io.nearFile-Based Configuration (Development)
For development, credentials can be loaded from files:
export RODIT_NEAR_CREDENTIALS_SOURCE=file
export CREDENTIALS_FILE_PATH=./credentials/rodit-credentials.jsonAccessing Configuration
// Get complete RODiT configuration
const configObject = await roditClient.getConfigOwnRodit()
const metadata = configObject.own_rodit.metadata
// Access RODiT token metadata
const jwtDuration = metadata.jwt_duration; // JWT expiration time
const maxRequests = metadata.max_requests; // Rate limit
const maxRqWindow = metadata.maxrq_window; // Rate limit window
const apiEndpoint = metadata.subjectuniqueidentifier_url; // API URL
const webhookUrl = metadata.webhook_url; // Webhook endpoint
// Parse permissioned routes
const permissionedRoutes = JSON.parse(metadata.permissioned_routes || '{}')
// Use SDK config for application settings
const config = roditClient.getConfig()
const logLevel = config.get('LOG_LEVEL', 'info')
const dbPath = config.get('API_DEFAULT_OPTIONS.DB_PATH')Dynamic Rate Limiting
Two complementary limiters:
- Global (
getRateLimitMiddleware/ratelimitmw) — from own RODiTmax_requests/maxrq_window(or fixed numbers). Applies across routes. - Per-route claim (
getClaimRateLimitMiddleware/enforceRateLimitFromClaims) — consumesreq.rateLimitset byvalidatepermissionsfrom each peer’spermissioned_routesvalue (e.g."+60"→ 60 req / 60s for that path). Returns429 RATE_LIMIT_EXCEEDEDwhen exceeded. In-memory, per process.
// 1) Global limiter from own RODiT metadata
const configObject = await roditClient.getConfigOwnRodit()
const metadata = configObject.own_rodit.metadata
if (metadata.max_requests && metadata.maxrq_window) {
const maxRequests = parseInt(metadata.max_requests, 10)
const windowSeconds = parseInt(metadata.maxrq_window, 10)
const rateLimiter = roditClient.getRateLimitMiddleware()
// Note: factory args are (maxRequests, windowMinutes)
app.use(rateLimiter(maxRequests, Math.ceil(windowSeconds / 60) || 1))
}
// 2) Per-route claim enforcement (after authenticate + validatepermissions)
const { authenticate_apicall, validatepermissions, enforceRateLimitFromClaims } =
require('@rodit/rodit-auth-be')
// Or: roditClient.getClaimRateLimitMiddleware()
app.use(
'/api',
(req, res, next) => roditClient.authenticate(req, res, next),
validatepermissions,
enforceRateLimitFromClaims()
)Environment Variables
Complete list of SDK environment variables:
Core Configuration
// Service identification
export SERVICE_NAME=your-service-name
export API_VERSION=1.0.0
// Environment and logging
export NODE_ENV=main # main, development, test
export LOG_LEVEL=info # error, warn, info, debug, traceCredentials and Authentication
// Credential source
export RODIT_NEAR_CREDENTIALS_SOURCE=vault # vault, file, env
// Vault configuration (main)
export VAULT_ENDPOINT=https://vault.example.com
export VAULT_ROLE_ID=your-role-id
export VAULT_SECRET_ID=your-secret-id
export VAULT_RODIT_KEYVALUE_PATH=secret/rodit
export VAULT_TOKEN_TTL=3600
// File-based credentials (development)
export CREDENTIALS_FILEPATH=./credentials/rodit.json
// NEAR blockchain
export NEAR_CONTRACT_ID=discernible-io.near
export NEAR_RPC_URL=https://rpc.mainnet.fastnear.com
export NEAR_RPC_CACHE_TTL=5000 # millisecondsSession Management
// Session storage configuration
export SESSION_STORAGE_TYPE=express-session # memory, express, express-session
export SESSION_CLEANUP_INTERVAL=3600000 # milliseconds (1 hour)
export SESSION_TOKEN_RETENTION_PERIOD=604800 # seconds (7 days)
export SESSION_VALIDATION_CACHE_TTL=5000 # milliseconds (5 seconds)Logging and Monitoring
// Loki logging
export LOKI_URL=https://loki.example.com:3100
export LOKI_BASIC_AUTH=username:password
export LOKI_TLS_SKIP_VERIFY=false # true to skip TLS verificationSecurity Options
// Webhook TLS (public destinations only; private/metadata URLs are always blocked)
export SECURITY_OPTIONS_WEBHOOK_TLS_SKIP_VERIFY=false # true for self-signed public webhook hosts
// Login mode control (see Login Mode section below)
export SECURITY_OPTIONS_LOGIN_MODE=partner # partner, promiscuous, or p2p
// Security thresholds
export SECURITY_OPTIONS_LAPSED_LIFETIME_PROPORTION_4RENEWAL_ELIGIBILITY=0.80
export SECURITY_OPTIONS_THRESHOLD_VALIDATION_TYPE=0.10
export SECURITY_OPTIONS_DURATIONRAMP=0.85
export SECURITY_OPTIONS_SERVERORCLIENT=SERVER-INITIATED
export SECURITY_OPTIONS_SILENT_LOGIN_FAILURES=false
// Server session lifetime (seconds from login; SDK default 5200)
export SECURITY_OPTIONS_SESSION_TTL_SECONDS=5200
// Access-token fallback when passport jwt_duration is invalid
export SECURITY_OPTIONS_FALLBACK_JWT_DURATION=3600Database Configuration
export API_DEFAULT_OPTIONS_DB_PATH=/app/data/database.sqliteLogging & Monitoring
Structured Logging
The SDK provides comprehensive structured logging:
const { logger } = require('@rodit/rodit-auth-be')
// Basic logging
logger.info('Operation completed', {
component: 'UserService',
operation: 'createUser',
userId: '123',
duration: 150
})
// Context-aware logging
logger.infoWithContext('Request processed', {
component: 'API',
method: 'POST',
path: '/api/users',
requestId: req.requestId,
userId: req.user?.id,
duration: Date.now() - req.startTime
})
// Error logging with metrics
logger.errorWithContext('Operation failed', {
component: 'UserService',
operation: 'createUser',
requestId: req.requestId,
error: error.message,
stack: error.stack
}, error)Loki with the SDK (canonical)
Use this as the authoritative guide for configuring logging with the SDK.
Environment variables
export LOKI_URL=https://<your-loki-host>:3100
export LOKI_BASIC_AUTH="username:password" # store in secrets
export LOKI_TLS_SKIP_VERIFY=true # only for self-signed/test
export LOG_LEVEL=info
export SERVICE_NAME=clienttest-idcThese are already mapped in config/custom-environment-variables.json, so container/CI env vars will flow into the app.
How the SDK selects/configures the logger
- Default: JSON to stdout only (no Loki). Honors
LOG_LEVEL, addsservice_name. - Main: Create a Winston logger with a
winston-lokitransport and inject it once:logger.setLogger(customLogger). - Access:
const { logger } = require('@rodit/rodit-auth-be')orroditClient.getLogger()both delegate to the same facade.
Direct-to-Loki via winston-loki (recommended)
const { logger } = require('@rodit/rodit-auth-be')
const winston = require('winston')
const LokiTransport = require('winston-loki')
const transports = [new winston.transports.Console({ format: winston.format.json() })]
if (process.env.LOKI_URL) {
const lokiOptions = {
host: process.env.LOKI_URL,
basicAuth: process.env.LOKI_BASIC_AUTH, // Basic Auth for Loki
labels: { app: process.env.SERVICE_NAME || 'clienttest-idc', component: 'rodit-sdk' },
json: true,
batching: true
}
if ((process.env.LOKI_TLS_SKIP_VERIFY || '').toLowerCase() === 'true') {
lokiOptions.ssl = { rejectUnauthorized: false }
}
transports.push(new LokiTransport(lokiOptions))
}
const customLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.json(),
transports
})
logger.setLogger(customLogger)CI/CD notes
.github/workflows/deploy.ymlpassesLOKI_URL,LOKI_TLS_SKIP_VERIFY,LOKI_BASIC_AUTHinto the container;src/app.jsconfig injects the transport at startup.- Store
LOKI_BASIC_AUTHin CI/CD secrets; never commit credentials.
Quick verification
- Start the app with
LOKI_URLandLOKI_BASIC_AUTHset. - Emit a test log:
logger.info('Loki test', { component: 'SmokeTest' }). - In Grafana Explore, query with
{app="clienttest-idc"}and confirm logs.
Performance Tracking
The SDK includes comprehensive performance tracking and metrics collection.
Performance Service
const performanceService = roditClient.getPerformanceService()
// Record incoming request
performanceService.recordRequest(req)
// Record custom metrics with labels
performanceService.recordMetric('operation_duration', 150, {
operation: 'db_query',
table: 'users',
status: 'success'
})
// Record errors
performanceService.recordMetric('error_count', 1, {
method: req.method,
path: req.path,
status: res.statusCode
})
// Get aggregated metrics
const metrics = performanceService.getMetrics()
console.log('Total requests:', metrics.totalRequests)
console.log('Error count:', metrics.errorCount)
console.log('Average response time:', metrics.avgResponseTime)Automatic Request Tracking
Integrate performance tracking into your middleware:
// Performance monitoring middleware
app.use((req, res, next) => {
req.startTime = Date.now()
const performanceService = roditClient.getPerformanceService()
if (performanceService) {
performanceService.recordRequest(req)
}
res.on('finish', () => {
const duration = Date.now() - req.startTime
if (performanceService) {
// Record request duration
performanceService.recordMetric('request_duration_ms', duration, {
method: req.method,
path: req.path,
status: res.statusCode
})
// Record errors
if (res.statusCode >= 400) {
performanceService.recordMetric('error_count', 1, {
method: req.method,
path: req.path,
status: res.statusCode
})
}
}
})
next()
})Session Performance Metrics
Track session-related performance:
const sessionManager = roditClient.getSessionManager()
// Get validation cache statistics
const cacheStats = sessionManager.getValidationCacheStats()
logger.info('Session cache performance', {
component: 'SessionManager',
totalEntries: cacheStats.totalEntries,
validEntries: cacheStats.validEntries,
expiredEntries: cacheStats.expiredEntries,
cacheTTL: cacheStats.cacheTTL,
cacheEnabled: cacheStats.cacheEnabled
})
// Get storage information
const storageInfo = await sessionManager.getStorageInfo()
logger.info('Session storage status', {
component: 'SessionManager',
storageType: storageInfo.type,
sessionCount: storageInfo.sessionCount,
timestamp: storageInfo.timestamp
})Custom Metrics
Record application-specific metrics:
const performanceService = roditClient.getPerformanceService()
// Database operation timing
const dbStart = Date.now()
const result = await db.query('SELECT * FROM users')
const dbDuration = Date.now() - dbStart
performanceService.recordMetric('db_query_duration', dbDuration, {
operation: 'select',
table: 'users',
rowCount: result.length
})
// External API call timing
const apiStart = Date.now()
const apiResponse = await fetch('https://api.example.com/data')
const apiDuration = Date.now() - apiStart
performanceService.recordMetric('external_api_duration', apiDuration, {
endpoint: 'api.example.com',
status: apiResponse.status,
success: apiResponse.ok
})
// Business metrics
performanceService.recordMetric('user_action', 1, {
action: 'comment_created',
userId: req.user.id,
timestamp: new Date().toISOString()
})Webhooks
Overview
The SDK supports sending webhooks to multiple endpoints for important events. Webhook URLs come from the peer JWT claim rodit_webhookurl (from that peer’s RODiT webhook_url metadata).
Key Features:
- Custom Endpoints - Send webhooks to any endpoint path (e.g.,
/hooks/wake,/hooks/agent,/webhook) - Ed25519 signed - Payload + timestamp signed; receivers verify via
X-Signature/ signer identity headers - Outbound SSRF controls (9.15+) - Before
fetch, reject userinfo and private / loopback / link-local / ULA / CGNAT / cloud-metadata hostnames and resolved A/AAAA; enforce peerrodit_webhookcidrwhen set; resolve-once and pin DNS for the request - Self-signed TLS (public only) -
SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=truestill allowed for public destinations
Peer RODiT metadata:
{
"webhook_url": "hooks.example.com",
"webhook_cidr": "0.0.0.0/0"
}webhook_url— host (optional path); SDK POSTshttps://{webhook_url}{endpoint}webhook_cidr— allowlist for the resolved destination IP (0.0.0.0/0/ empty = any public IP still subject to the SSRF blocklist)
Outbound rejection codes (returned as { isValid: false, error: { code, message, requestId } }):
| Code | Meaning |
|------|---------|
| WEBHOOK_URL_MISSING | No rodit_webhookurl on req.user |
| WEBHOOK_URL_INVALID | Bad URL / userinfo / scheme |
| WEBHOOK_URL_BLOCKED | Private, loopback, or metadata target |
| WEBHOOK_URL_UNRESOLVABLE | DNS lookup failed |
| WEBHOOK_CIDR_DENIED | Resolved IP outside peer webhook_cidr |
Sending Webhooks to Default Endpoint
Send webhooks to the default /webhook endpoint:
// Get webhook handler from client
const roditClient = req.app.locals.roditClient
// Send webhook for an event
const webhookPayload = {
event: 'comment_created',
data: {
id: comment.id,
author: comment.author,
timestamp: new Date().toISOString()
},
isError: false
}
try {
const result = await roditClient.sendWebhook(webhookPayload, req)
if (result.success) {
logger.info('Webhook sent successfully', {
component: 'CRUDA',
event: webhookPayload.event,
requestId: req.requestId
})
}
} catch (error) {
// Webhook failures don't crash the application
logger.warn('Webhook delivery failed', {
component: 'CRUDA',
event: webhookPayload.event,
error: error.message,
requestId: req.requestId
})
}Sending Webhooks to Custom Endpoints
Send webhooks to specific endpoints like /hooks/wake or /hooks/agent:
const roditClient = req.app.locals.roditClient
const webhookPayload = {
event: 'heartbeat_request',
data: {
timestamp: new Date().toISOString(),
source: '/api/testhola'
}
}
// Send to /hooks/wake endpoint (trigger immediate heartbeat)
await roditClient.sendWebhookToEndpoint(webhookPayload, '/hooks/wake', req)
// Send to /hooks/agent endpoint (run isolated agent task)
await roditClient.sendWebhookToEndpoint(webhookPayload, '/hooks/agent', req)
// Send to custom endpoint
await roditClient.sendWebhookToEndpoint(webhookPayload, '/hooks/custom', req)Convenience Methods for Common Endpoints
const roditClient = req.app.locals.roditClient
const payload = {
event: 'test_event',
data: { timestamp: new Date().toISOString() }
}
// Send to /hooks/wake (heartbeat confirmation)
await roditClient.sendWakeHook(payload, req)
// Send to /hooks/agent (agent task confirmation)
await roditClient.sendAgentHook(payload, req)Webhook Endpoint Purposes
| Endpoint | Purpose | Use Case |
|----------|---------|----------|
| /webhook | Default webhook endpoint | General event notifications |
| /hooks/wake | Trigger immediate heartbeat | Enqueue system event for main session |
| /hooks/agent | Run isolated agent task | Execute background tasks with optional reply to messaging channels |
Webhook Error Handling
// Graceful webhook handling in CRUDA operations
const logAndSendWebhook = async (payload, req = null) => {
try {
const roditClient = req?.app?.locals?.roditClient
if (!roditClient) {
logger.warn('RoditClient not available, skipping webhook', {
component: 'CRUDA',
event: payload?.event
})
{ success: false, error: 'RoditClient not available' }
}
await roditClient.sendWebhook(payload, req)
} catch (error) {
// Log but don't throw - webhook failures shouldn't crash the app
logger.error('Webhook delivery failed', {
component: 'CRUDA',
event: payload?.event,
error: error.message
})
{ success: false, error: error.message }
}
}Development/Testing Webhooks
The /api/testhola endpoint sends test webhooks in development mode (NODE_ENV === 'development'):
{
"event": "testhola_validation_success",
"data": {
"peerTokenId": "bcdfhjkmnpqr",
"serverTokenId": "bcdfhjkmnpqr",
"recipient": "MUNDO",
"timestamp": "2026-04-24T14:30:00.000Z",
"endpoint": "/api/testhola"
}
}Use Case: Test webhook delivery and signature validation during development without needing a main deployment.
Advanced Usage
Route Module Pattern
Create reusable route modules that access the shared RoditClient:
// routes/protected.js
const express = require('express')
const { logger } = require('@rodit/rodit-auth-be')
const router = express.Router()
// Middleware that uses the shared client
const authenticate = (req, res, next) => {
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authentication service unavailable' })
}
client.authenticate(req, res, next)
}
const authorize = (req, res, next) => {
const client = req.app.locals.roditClient
if (!client) {
res.status(503).json({ error: 'Authentication service unavailable' })
}
client.authorize(req, res, next)
}
// Protected route with full authentication and authorization
router.get('/data', authenticate, authorize, async (req, res) => {
const startTime = Date.now()
try {
// Your business logic here
const data = await processUserData(req.user.id)
logger.infoWithContext('Data retrieved successfully', {
component: 'ProtectedRoutes',
method: 'getData',
userId: req.user.id,
requestId: req.requestId,
duration: Date.now() - startTime
})
res.json({ data, requestId: req.requestId })
} catch (error) {
logger.errorWithContext('Failed to retrieve data', {
component: 'ProtectedRoutes',
method: 'getData',
userId: req.user.id,
requestId: req.requestId,
duration: Date.now() - startTime,
error: error.message
}, error)
res.status(500).json({
error: 'Internal server error',
requestId: req.requestId
})
}
})
module.exports = routerPortal Authentication (Server-to-Server)
For server-to-server authentication (e.g., minting client tokens):
// routes/signclient.js
const router = express.Router()
router.post('/signclient', authenticate, authorize, async (req, res) => {
const { tobesignedValues, mintingfee, mintingfeeaccount } = req.body
const client = req.app.locals.roditClient
const logger = client.getLogger()
try {
// Validate requested permissions against server's permissions
const configObject = await client.getConfigOwnRodit()
const serverPermissions = JSON.parse(
configObject.own_rodit.metadata.permissioned_routes || '{}'
)
const requestedPermissions = JSON.parse(
tobesignedValues.permissioned_routes || '{}'
)
// Validate that all requested routes exist in server config
// (Implementation details in actual code)
// Authenticate to portal and mint client token
const port = configObject.port || 8443
const result = await client.login_portal(configObject, port)
if (result.error) {
res.status(500).json({
error: 'Portal authentication failed',
details: result.message,
requestId: req.requestId
})
}
// Sign the client token via portal
const signedToken = await signPortalRodit(
port,
tobesignedValues,
mintingfee,
mintingfeeaccount,
client
)
res.json({
signedToken,
requestId: req.requestId
})
} catch (error) {
logger.errorWithContext('Client token minting failed', {
component: 'SignClient',
requestId: req.requestId,
error: error.message
}, error)
res.status(500).json({
error: 'Token minting failed',
requestId: req.requestId
})
}
})
module.exports = routerSignPortal URL Configuration
Overview
When performing server-to-server authentication with SignPortal (e.g., minting client tokens), the SDK automatically constructs the SignPortal URL from the serviceprovider_id field in your RODiT token metadata.
Smart Contract Name Format
The SignPortal URL is derived from the smart contract component (sc=) in your serviceprovider_id. The SDK supports two formats:
Standard Format (3+ components):
sc=<number>-<domain>-<tld>.nearExample:
serviceprovider_id: "bc=near.org;sc=10975-discernible-org.near;id=..."Parsing:
- Split by
.:["10975-discernible-org", "near"] - Take first part:
10975-discernible-org - Split by
-:["10975", "discernible", "org"] - Extract domain:
discernible(index 1) - Extract TLD:
org(index 2) - Result:
https://signportal.discernible.org:8443
Alternative Format (2 components):
sc=<domain>-<tld>.nearExample:
serviceprovider_id: "bc=near.org;sc=roditcorp-com.near;id=..."Parsing:
- Split by
.:["roditcorp-com", "near"] - Take first part:
roditcorp-com - Split by
-:["roditcorp", "com"] - Extract domain:
roditcorp(index 0) - Extract TLD:
com(index 1) - Result:
https://signportal.roditcorp.com:8443
serviceprovider_id Structure
The complete serviceprovider_id format:
bc=<blockchain>;sc=<smart-contract>;id=<identifier>[;id=<additional-id>]Components:
bc=- Blockchain identifier (e.g.,near.org)sc=- Smart contract name (used to construct SignPortal URL)id=- One or more identifier components
Example:
{
"serviceprovider_id": "bc=near.org;sc=roditcorp-com.near;id=01K8QECHMKFVNWQ54PJ2W2GMA7;id=01K8QECHMM1214VMDHSH7JM6H8"
}URL Construction Method
The SDK uses roditClient.getPortalUrl(serviceProviderId, port) to construct the SignPortal URL:
const client = req.app.locals.roditClient
const configObject = await client.getConfigOwnRodit()
const serviceProviderId = configObject.own_rodit.metadata.serviceprovider_id
const portalPort = 8443
// Automatically constructs: https://signportal.<domain>.<tld>:8443
const portalUrl = client.getPortalUrl(serviceProviderId, portalPort)Troubleshooting
Error: "Failed to parse URL from " (empty string)
- Cause:
serviceprovider_idis empty or undefined in your RODiT configuration - Solution: Verify your RODiT token has a valid
serviceprovider_idfield - Check: Run
./infra/roditwallet.sh <private-key> <token-id>to view token metadata
Error: "Invalid serviceprovider_id format: missing sc= component"
- Cause: The
serviceprovider_iddoesn't contain ansc=component - Solution: Ensure your token includes the smart contract identifier
- Format:
bc=near.org;sc=<contract-name>.near;id=...
Error: "Invalid domain format in smart contract"
- Cause: Smart contract name has fewer than 2 components when split by
- - Solution: Use format
<domain>-<tld>or<number>-<domain>-<tld> - Valid:
roditcorp-com.near,10975-discernible-org.near - Invalid:
roditcorp.near,mycontract.near
Configuration Verification
To verify your SignPortal URL configuration:
const client = req.app.locals.roditClient
const logger = client.getLogger()
try {
const configObject = await client.getConfigOwnRodit()
const serviceProviderId = configObject.own_rodit.metadata.serviceprovider_id
logger.info('RODiT Configuration', {
component: 'SignPortal',
serviceProviderId,
hasServiceProviderId: !!serviceProviderId
})
if (serviceProviderId) {
const portalUrl = client.getPortalUrl(serviceProviderId, 8443)
logger.info('SignPortal URL constructed', {
component: 'SignPortal',
portalUrl
})
}
} catch (error) {
logger.error('SignPortal URL construction failed', {
component: 'SignPortal',
error: error.message
})
}CRUDA Operations Example
Complete CRUD implementation with authentication, authorization, webhooks, and performance tracking:
// protected/cruda.js
const express = require('express')
const router = express.Router()
const { RoditClient } = require('@rodit/rodit-auth-be')
const sqlite3 = require('sqlite3')
const { open } = require('sqlite')
const { ulid } = require('ulid')
const sdkClient = new RoditClient()
const logger = sdkClient.getLogger()
let db
// Initialize database
const initializeDatabase = async () => {
const db = await open({
filename: '/app/data/database.sqlite',
driver: sqlite3.Database
})
await db.run(`CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
comment TEXT NOT NULL,
author TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
}
// Webhook helper
const logAndSendWebhook = async (payload, req) => {
try {
const roditClient = req?.app?.locals?.roditClient
if (!roditClient) return { success: false }
await roditClient.send_webhook(payload, req)
} catch (error) {
logger.error('Webhook failed', { error: error.message })
{ success: false, error: error.message }
}
}
// CREATE
router.post('/create', async (req, res) => {
const { comment, author } = req.body
const requestId = req.requestId || ulid()
try {
const result = await db.run(
'INSERT INTO comments (comment, author) VALUES (?, ?)',
[comment, author || req.user.roditId]
)
// Send webhook
await logAndSendWebhook({
event: 'comment_created',
data: { id: result.lastID, comment, author },
isError: false
}, req)
res.json({ id: result.lastID, requestId })
} catch (error) {
logger.errorWithContext('Create failed', {
component: 'CRUDA',
error: error.message,
requestId
}, error)
res.status(500).json({ error: 'Create failed', requestId })
}
})
// LIST
router.post('/list', async (req, res) => {
try {
const records = await db.all(
'SELECT * FROM comments ORDER BY created_at DESC'
)
res.json({ records, requestId: req.requestId })
} catch (error) {
res.status(500).json({ error: 'List failed', requestId: req.requestId })
}
})
// READ
router.post('/read', async (req, res) => {
const { id } = req.body
try {
const record = await db.get('SELECT * FROM comments WHERE id = ?', [id])
if (!record) {
res.status(404).json({ error: 'Not found', requestId: req.requestId })
}
res.json({ record, requestId: req.requestId })
} catch (error) {
res.status(500).json({ error: 'Read failed', requestId: req.requestId })
}
})
// UPDATE
router.post('/update', async (req, res) => {
const { id, comment } = req.body
try {
await db.run(
'UPDATE comments SET comment = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[comment, id]
)
await logAndSendWebhook({
event: 'comment_updated',
data: { id, comment },
isError: false
}, req)
res.json({ success: true, requestId: req.requestId })
} catch (error) {
res.status(500).json({ error: 'Update failed', requestId: req.requestId })
}
})
// DE