TypeScriptAuthentication

Access Control

Roles, scopes, and combined authentication methods

Authentication tells you who is calling. Access control decides what they may do. In NitroStack both are guards: an authentication guard identifies the caller, and further guards check roles, scopes, or anything else before the tool runs.

Combine methods

Some tools should accept more than one way of signing in; others should require two.

Either a JWT or an API key

A single guard can try each method in turn and accept the first that succeeds.

export class MultiAuthGuard implements Guard {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Try JWT first
    const jwt = context.metadata?._jwt || context.metadata?.authorization;
    if (jwt) {
      // Validate JWT...
      return true;
    }
    
    // Try API key
    const apiKey = context.metadata?.apiKey;
    if (apiKey) {
      return await ApiKeyModule.validate(apiKey);
    }
    
    throw new Error('Either JWT or API key required');
  }
}

// Use on tool
@Tool({ name: 'flexible_tool' })
@UseGuards(MultiAuthGuard)  // Accepts JWT OR API key
async flexibleTool() {
  // Accessible with either auth method
}

Both a JWT and an API key

List several guards and a call must pass all of them, in order.

@Tool({ name: 'critical_operation' })
@UseGuards(JWTGuard, ApiKeyGuard)  // Both required!
async criticalOperation() {
  // Requires BOTH JWT token AND API key
}

Keys stored in a database

For many keys, look them up in a database instead of environment variables. A service can validate a key and return its owner and permissions.

@Injectable()
export class ApiKeyService {
  constructor(private db: DatabaseService) {}
  
  async validate(key: string): Promise<any | null> {
    const result = await this.db.queryOne(
      'SELECT * FROM api_keys WHERE key_hash = ?',
      [this.hashKey(key)]
    );
    
    return result;
  }
  
  async create(userId: string, name: string, scopes: string[]): Promise<string> {
    const key = this.generateKey();
    const keyHash = this.hashKey(key);
    
    await this.db.execute(
      'INSERT INTO api_keys (user_id, name, key_hash, scopes) VALUES (?, ?, ?, ?)',
      [userId, name, keyHash, JSON.stringify(scopes)]
    );
    
    return key;  // Return once, never shown again
  }
  
  private generateKey(): string {
    // Generate secure random key
    return `sk_${randomBytes(32).toString('hex')}`;
  }
  
  private hashKey(key: string): string {
    return createHash('sha256').update(key).digest('hex');
  }
}

Roles

A role guard runs after an authentication guard and checks what the identified user is allowed to do.

Admin-only tools

This guard lets a call through only when the authenticated user has the admin role.

@Injectable()
export class AdminGuard implements Guard {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Assumes JWTGuard has already run
    const role = context.auth?.role;
    
    if (role !== 'admin') {
      context.logger.warn(`Access denied: User role is ${role}, requires admin`);
      return false;
    }
    
    return true;
  }
}

Apply it

Put the authentication guard first so context.auth is set when the role guard runs.

@Tool({ name: 'delete_all_users' })
@UseGuards(JWTGuard, AdminGuard)  // ← JWT + Admin role
async deleteAllUsers(input: any, ctx: ExecutionContext) {
  // Only admins can call this
}

Any role

A small factory returns a guard for whichever roles a tool needs.

export function RoleGuard(...roles: string[]) {
  class DynamicRoleGuard implements Guard {
    async canActivate(context: ExecutionContext): Promise<boolean> {
      const userRole = context.auth?.role;
      return roles.includes(userRole);
    }
  }
  return DynamicRoleGuard;
}

// Usage
@Tool({ name: 'moderator_action' })
@UseGuards(JWTGuard, RoleGuard('admin', 'moderator'))
async moderatorAction(input: any, ctx: ExecutionContext) {
  // Admins OR moderators can call this
}

Scopes

Scopes are finer-grained than roles: each one grants a single permission, such as orders:write.

A scope guard

This guard checks that the caller's token carries every scope the tool requires. For OAuth tokens, the built-in createScopeGuard() does the same.

@Injectable()
export class ScopeGuard implements Guard {
  constructor(private requiredScopes: string[]) {}
  
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const userScopes = context.auth?.scopes || [];
    
    const hasAllScopes = this.requiredScopes.every(
      scope => userScopes.includes(scope)
    );
    
    if (!hasAllScopes) {
      context.logger.warn(
        `Missing scopes. Required: ${this.requiredScopes}, Has: ${userScopes}`
      );
      return false;
    }
    
    return true;
  }
}

// Usage
@Tool({ name: 'write_data' })
@UseGuards(ApiKeyGuard, ScopeGuard(['read', 'write']))
async writeData(input: any, ctx: ExecutionContext) {
  // Requires API key with read AND write scopes
}

Next steps

On this page