Skip to content

Latest commit

 

History

History
659 lines (539 loc) · 16 KB

File metadata and controls

659 lines (539 loc) · 16 KB

Auth Adapters

Version: 1.0
Purpose: Pluggable authentication for NQL

Overview

NQL does not dictate authentication. Instead, it provides an adapter interface that allows applications to integrate their existing auth systems.

Why Pluggable Auth?

Applications have diverse authentication requirements:

  • Firebase Authentication
  • NextAuth.js
  • Auth0
  • Custom JWT
  • OAuth 2.0
  • Session-based
  • API keys
  • Multi-tenant isolation

NQL adapts to YOUR auth system rather than forcing you to change.

Architecture

┌────────────────────────────────────────┐
│ HTTP Request                           │
│ Authorization: Bearer <token>          │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│ Your Auth Adapter                      │
│ ┌────────────────────────────────────┐ │
│ │ extractContext(request)            │ │
│ │   → { user_id, role, ... }         │ │
│ └────────────────────────────────────┘ │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│ NQL Client                             │
│ Uses context for authorization & RLS   │
└────────────────────────────────────────┘

AuthAdapter Interface

Every auth adapter must implement three methods:

interface AuthAdapter {
  // Extract user context from request
  extractContext(request: Request): Promise<UserContext>;
  
  // Check if operation is allowed
  checkPermission(
    userContext: UserContext,
    operation: string,
    resource: string
  ): Promise<boolean>;
  
  // Apply row-level security filters
  applyRLS(
    nqlQuery: NQLQuery,
    userContext: UserContext,
    resource: string
  ): Promise<NQLQuery>;
}

interface UserContext {
  user_id: string;
  [key: string]: any;  // Additional context (role, permissions, etc.)
}

Implementation Examples

Example 1: Simple JWT Auth

class SimpleJWTAdapter {
  constructor(jwtSecret) {
    this.jwtSecret = jwtSecret;
  }
  
  async extractContext(request) {
    const token = this.extractToken(request);
    
    if (!token) {
      throw new Error('NQL_3001: Authentication required');
    }
    
    try {
      const decoded = jwt.verify(token, this.jwtSecret);
      return {
        user_id: decoded.sub,
        email: decoded.email,
        role: decoded.role || 'user'
      };
    } catch (error) {
      throw new Error('NQL_3001: Invalid token');
    }
  }
  
  async checkPermission(userContext, operation, resource) {
    // Simple role-based check
    if (userContext.role === 'admin') {
      return true;  // Admins can do anything
    }
    
    // Regular users can only read
    return operation === 'read';
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    // Non-admins see only their own records
    if (userContext.role !== 'admin') {
      nqlQuery.params.filter = {
        $and: [
          nqlQuery.params.filter || {},
          { user_id: { $eq: userContext.user_id } }
        ]
      };
    }
    
    return nqlQuery;
  }
  
  extractToken(request) {
    const authHeader = request.headers.authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
    return null;
  }
}

// Usage
const client = new NQLClient({
  schema: './schema.yaml',
  database: dbConfig,
  authAdapter: new SimpleJWTAdapter(process.env.JWT_SECRET)
});

Example 2: Firebase Authentication

const admin = require('firebase-admin');

class FirebaseAuthAdapter {
  async extractContext(request) {
    const token = this.extractToken(request);
    
    if (!token) {
      throw new Error('NQL_3001: Authentication required');
    }
    
    try {
      const decodedToken = await admin.auth().verifyIdToken(token);
      
      return {
        user_id: decodedToken.uid,
        email: decodedToken.email,
        email_verified: decodedToken.email_verified,
        role: decodedToken.custom_claims?.role || 'user',
        permissions: decodedToken.custom_claims?.permissions || []
      };
    } catch (error) {
      throw new Error('NQL_3001: Invalid Firebase token');
    }
  }
  
  async checkPermission(userContext, operation, resource) {
    // Check custom claims
    const requiredPermission = `${operation}:${resource}`;
    
    if (userContext.role === 'admin') {
      return true;
    }
    
    return userContext.permissions.includes(requiredPermission);
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    // Apply RLS based on resource
    const rlsRules = {
      'users': () => {
        // Users can only access their own record
        if (userContext.role !== 'admin') {
          nqlQuery.params.filter = {
            $and: [
              nqlQuery.params.filter || {},
              { id: { $eq: userContext.user_id } }
            ]
          };
        }
      },
      'orders': () => {
        // Users see only their orders
        if (userContext.role !== 'admin') {
          nqlQuery.params.filter = {
            $and: [
              nqlQuery.params.filter || {},
              { customer_id: { $eq: userContext.user_id } }
            ]
          };
        }
      }
    };
    
    const rule = rlsRules[resource];
    if (rule) {
      rule();
    }
    
    return nqlQuery;
  }
  
  extractToken(request) {
    const authHeader = request.headers.authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
    return null;
  }
}

Example 3: NextAuth.js

import { getSession } from 'next-auth/react';

class NextAuthAdapter {
  async extractContext(request) {
    const session = await getSession({ req: request });
    
    if (!session || !session.user) {
      throw new Error('NQL_3001: Not authenticated');
    }
    
    return {
      user_id: session.user.id,
      email: session.user.email,
      role: session.user.role,
      permissions: session.user.permissions || []
    };
  }
  
  async checkPermission(userContext, operation, resource) {
    const requiredPerm = `${operation}:${resource}`;
    
    if (userContext.role === 'admin') {
      return true;
    }
    
    return userContext.permissions.includes(requiredPerm);
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    if (userContext.role !== 'admin') {
      nqlQuery.params.filter = {
        $and: [
          nqlQuery.params.filter || {},
          { user_id: { $eq: userContext.user_id } }
        ]
      };
    }
    
    return nqlQuery;
  }
}

Example 4: Multi-Tenant SaaS

class MultiTenantAdapter {
  constructor(sessionStore) {
    this.sessionStore = sessionStore;
  }
  
  async extractContext(request) {
    const sessionId = request.cookies.session_id;
    const session = await this.sessionStore.get(sessionId);
    
    if (!session) {
      throw new Error('NQL_3001: Invalid session');
    }
    
    return {
      user_id: session.userId,
      tenant_id: session.tenantId,  // Organization ID
      role: session.role,
      department: session.department
    };
  }
  
  async checkPermission(userContext, operation, resource) {
    // Tenant admins can do anything in their tenant
    if (userContext.role === 'tenant_admin') {
      return true;
    }
    
    // Department managers can read/create
    if (userContext.role === 'dept_manager') {
      return ['read', 'create'].includes(operation);
    }
    
    // Regular users can only read
    return operation === 'read';
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    // ALWAYS filter by tenant (critical for multi-tenant)
    nqlQuery.params.filter = {
      $and: [
        nqlQuery.params.filter || {},
        { tenant_id: { $eq: userContext.tenant_id } }
      ]
    };
    
    // Additional filters based on role
    if (userContext.role === 'dept_manager') {
      // See only their department
      nqlQuery.params.filter.$and.push(
        { department_id: { $eq: userContext.department } }
      );
    } else if (userContext.role === 'user') {
      // See only own records
      nqlQuery.params.filter.$and.push(
        { user_id: { $eq: userContext.user_id } }
      );
    }
    
    return nqlQuery;
  }
}

Example 5: OAuth 2.0 + RBAC

class OAuthRBACAdapter {
  constructor(oauthProvider, permissionService) {
    this.oauth = oauthProvider;
    this.permissionService = permissionService;
  }
  
  async extractContext(request) {
    const token = this.extractToken(request);
    
    if (!token) {
      throw new Error('NQL_3001: Authentication required');
    }
    
    // Verify with OAuth provider
    const userInfo = await this.oauth.getUserInfo(token);
    
    // Fetch roles from your database
    const roles = await this.permissionService.getUserRoles(userInfo.sub);
    
    return {
      user_id: userInfo.sub,
      email: userInfo.email,
      roles: roles,
      scopes: token.scope.split(' ')
    };
  }
  
  async checkPermission(userContext, operation, resource) {
    // Check OAuth scopes
    const requiredScope = `${operation}:${resource}`;
    if (!userContext.scopes.includes(requiredScope)) {
      return false;
    }
    
    // Check RBAC roles
    const allowedRoles = await this.permissionService
      .getRolesForOperation(operation, resource);
    
    return userContext.roles.some(role => allowedRoles.includes(role));
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    const hasAdminRole = userContext.roles.includes('admin');
    
    if (!hasAdminRole) {
      nqlQuery.params.filter = {
        $and: [
          nqlQuery.params.filter || {},
          { user_id: { $eq: userContext.user_id } }
        ]
      };
    }
    
    return nqlQuery;
  }
  
  extractToken(request) {
    const authHeader = request.headers.authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
    return null;
  }
}

Example 6: Custom Session-Based

class CustomSessionAdapter {
  constructor(sessionStore, db) {
    this.sessionStore = sessionStore;
    this.db = db;
  }
  
  async extractContext(request) {
    const sessionId = request.cookies.session_id;
    const session = await this.sessionStore.get(sessionId);
    
    if (!session) {
      throw new Error('NQL_3001: Invalid session');
    }
    
    // Fetch additional context from database
    const userDetails = await this.db.query(`
      SELECT 
        u.id, u.role,
        d.id as department_id,
        d.manager_id
      FROM users u
      LEFT JOIN departments d ON u.department_id = d.id
      WHERE u.id = $1
    `, [session.userId]);
    
    return {
      user_id: session.userId,
      role: userDetails.role,
      department_id: userDetails.department_id,
      is_manager: userDetails.manager_id === session.userId
    };
  }
  
  async checkPermission(userContext, operation, resource) {
    // Custom permission logic
    if (userContext.role === 'admin') {
      return true;
    }
    
    if (userContext.is_manager) {
      return ['read', 'create', 'update'].includes(operation);
    }
    
    return operation === 'read';
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    if (userContext.role === 'admin') {
      return nqlQuery;  // No filtering for admins
    }
    
    if (userContext.is_manager) {
      // Managers see their department
      nqlQuery.params.filter = {
        $and: [
          nqlQuery.params.filter || {},
          { department_id: { $eq: userContext.department_id } }
        ]
      };
    } else {
      // Regular users see only own records
      nqlQuery.params.filter = {
        $and: [
          nqlQuery.params.filter || {},
          { user_id: { $eq: userContext.user_id } }
        ]
      };
    }
    
    return nqlQuery;
  }
}

Helper Base Class

class BaseAuthAdapter {
  async extractContext(request) {
    throw new Error('extractContext must be implemented');
  }
  
  async checkPermission(userContext, operation, resource) {
    throw new Error('checkPermission must be implemented');
  }
  
  async applyRLS(nqlQuery, userContext, resource) {
    // Default: no RLS filtering
    return nqlQuery;
  }
  
  // Helper methods
  extractToken(request) {
    const authHeader = request.headers.authorization;
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.substring(7);
    }
    return null;
  }
  
  isAdmin(userContext) {
    return userContext.role === 'admin' || 
           userContext.roles?.includes('admin');
  }
  
  hasPermission(userContext, permission) {
    return userContext.permissions?.includes(permission);
  }
  
  injectFilter(nqlQuery, field, value) {
    nqlQuery.params.filter = {
      $and: [
        nqlQuery.params.filter || {},
        { [field]: { $eq: value } }
      ]
    };
    return nqlQuery;
  }
}

Testing Adapters

No-Auth Adapter (Development)

class NoAuthAdapter {
  async extractContext(request) {
    return {
      user_id: 'anonymous',
      role: 'public'
    };
  }
  
  async checkPermission() {
    return true;  // Allow everything
  }
  
  async applyRLS(nqlQuery) {
    return nqlQuery;  // No filtering
  }
}

// Use for testing
const client = new NQLClient({
  schema: './schema.yaml',
  database: dbConfig,
  authAdapter: new NoAuthAdapter()
});

Mock Adapter (Unit Tests)

class MockAuthAdapter {
  constructor(mockUser) {
    this.mockUser = mockUser;
  }
  
  async extractContext(request) {
    return this.mockUser;
  }
  
  async checkPermission() {
    return true;
  }
  
  async applyRLS(nqlQuery) {
    return nqlQuery;
  }
}

// In tests
const mockUser = { user_id: 'test-123', role: 'admin' };
const adapter = new MockAuthAdapter(mockUser);

Integration Example

// server.js
const express = require('express');
const { NQLClient } = require('@nql/core');
const { FirebaseAuthAdapter } = require('./auth/firebase');

const app = express();

const nqlClient = new NQLClient({
  schema: './schema.yaml',
  database: {
    type: 'postgresql',
    connection: process.env.DATABASE_URL
  },
  authAdapter: new FirebaseAuthAdapter()
});

app.post('/api/query', async (req, res) => {
  try {
    const result = await nqlClient.execute(
      req.body.query,
      req  // Pass request for auth extraction
    );
    
    res.json(result);
  } catch (error) {
    if (error.message.startsWith('NQL_3001')) {
      res.status(401).json({ error: 'Unauthorized' });
    } else {
      res.status(500).json({ error: error.message });
    }
  }
});

Best Practices

  1. Extract minimal context: Only extract what you need
  2. Cache user data: Don't fetch from DB on every request
  3. Fail secure: Default to deny if permission check fails
  4. Log auth failures: Track unauthorized access attempts
  5. Use strong typing: TypeScript helps catch auth bugs
  6. Test thoroughly: Write unit tests for auth logic
  7. Handle token expiry: Return appropriate error codes
  8. Validate input: Don't trust auth tokens blindly

Security Considerations

  1. Always verify tokens: Don't trust client-provided context
  2. Use HTTPS: Protect tokens in transit
  3. Rotate secrets: Change JWT secrets periodically
  4. Rate limit: Prevent brute force attacks
  5. Log suspicious activity: Monitor for attack patterns
  6. Validate permissions: Check both scope and role
  7. Enforce RLS: Always filter by tenant/user in multi-tenant apps

Version: 1.0
Status: Draft
License: MIT
Author: nagibaba

For questions: https://github.com/nagibaba/nql