SAP CAP Blog Posts
cancel
Showing results for 
Search instead for 
Did you mean: 
michal_majer
Contributor
915

This guide presenets practical example to implement custom authentication in SAP CAP (Node.js). I hav

Project Setup

Configuration Entry Point

The key to enabling custom authentication in CAP is the auth configuration in package.json. This tells the CAP runtime to use your custom middleware handler for all incoming requests, allowing you to control the identity verification process.

{
  "cds": {
    "requires": {
      "auth": {
        "impl": "auth/basic-auth.ts"
      }
    }
  }
}
 

Implementation: Core Components

1. Security Best Practices

When handling passwords, security must be the top priority. The following code demonstrates secure handling, specifically addressing the timing attack vector, which is often overlooked.

// auth/basic-auth.ts
import crypto from 'crypto';

const PASSWORD_PEPPER = process.env.PASSWORD_PEPPER || ""; 

// Timing-safe comparison prevents timing attacks
function secureCompare(a: string, b: string): boolean {
  // Ensure we compare strings of the same length
  if (a.length !== b.length) return false; 
  // crypto.timingSafeEqual is CRITICAL for password security
  return crypto.timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
}

function hashPassword(password: string): string {
  if (!password) {
    throw new Error("Password cannot be empty");
  }
  // NOTE: SHA-256 is used here ONLY for simplicity in a Basic Auth example.
  // DO NOT use SHA-256 in production. Use bcrypt or argon2.
  return crypto
    .createHash('sha256')
    .update(password + PASSWORD_PEPPER)
    .digest('hex');
}

Key Security Features:

  • crypto.timingSafeEqual(): Guarantees constant-time comparison, preventing attackers from using timing measurements to guess password characters.

  • Password Pepper: An extra layer of security beyond salting.

  • Production Note: In a production system, always use bcrypt or argon2 instead of simple hash functions.


2. Database Schema

The user schema must be designed to support both local password-based authentication (for Basic Auth) and future external OAuth integrations.

// db/schema.cds
entity Users {
  key ID          : UUID;
      email       : String(255) @assert.unique;
      username    : String(100) @assert.unique;
      passwordHash: String(255);
      firstName   : String(100);
      lastName    : String(100);
      provider    : String(50);   // 'local', 'google', 'oidc'
      providerId  : String(255);  // ID from external provider (for OAuth)
      isActive    : Boolean default true;
}

3. Authentication Logic

This core function handles credential verification against the database.

const credentialsAuthentication = async ({ 
  email, 
  password 
}: { 
  email: string, 
  password: string 
}) => {
  const { Users } = cds.entities;
  try {
    // 1. Validate Input
    if (!email || !password) {
      return { success: false, message: "Email and password are required", user: null };
    }
    
    // Normalize email for consistent lookups
    const normalizedEmail = email.trim().toLowerCase();
    
    // 2. Query User
    const user = await SELECT.one
      .from(Users)
      .where({ 
         email: normalizedEmail, 
         isActive: true 
       });
       
    // 3. Security Check: Prevent User Enumeration Attack
    // Use the same error message whether the user is missing or the password is wrong.
    if (!user) {
      return { success: false, message: "Invalid credentials", user: null };
    }
    
    // 4. Secure Password Verification
    const passwordHash = hashPassword(password);
    const isPasswordValid = secureCompare(user.passwordHash, passwordHash); 
    
    if (!isPasswordValid) {
      return { success: false, message: "Invalid credentials", user: null };
    }
          
    return {
      success: true,
      message: 'Login successful',
      user: {
        id: user.ID,
        email: user.email,
        username: user.username,
        firstName: user.firstName,
        lastName: user.lastName,
      }
    };
  } catch (err) {
    cds.log('auth').error('Authentication error:', err);
    return { success: false, message: "Authentication failed", user: null };
  }
}

Key Technical Points:

  • Preventing User Enumeration: Using the generic "Invalid credentials" message.

  • Email Normalization: Ensures query consistency.


4. Express Middleware Integration

The authentication handler is implemented as Express Middleware. It parses the Basic Auth header and creates the essential CAP-compatible user object.

import { Request, Response, NextFunction } from 'express';
import cds from '@sap/cds';
// ... import credentialsAuthentication ... (from section 3)

export default async function custom_auth(
  req: Request, 
  res: Response, 
  next: NextFunction
) {
  try {
    const authHeader = req.headers.authorization;
    
    // Allow requests without Basic Auth header to proceed. 
    if (!authHeader || !authHeader.startsWith('Basic ')) {
      return next();
    }
    
    // Parse Basic Auth header
    const base64Credentials = authHeader.split(' ')[1];
    const credentials = Buffer
      .from(base64Credentials, 'base64')
      .toString('utf-8');
      
    const [email, password] = credentials.split(':', 2);
    
    if (!email?.trim() || !password) {
      res.status(401).json({ error: 'Invalid credentials format' });
      return;
    }
    
    const auth = await credentialsAuthentication({ email, password });
    const { success, user } = auth;
    
    if (success && user) {
      // CRITICAL: Create the CAP-compatible user object
      const roles = user.roles.length > 0 ? user.roles : ['authenticated'];
      
      const capUser = new cds.User({
        id: user.id,
        attr: {
          email: user.email,
          username: user.username,
          firstName: user.firstName,
          lastName: user.lastName
        }
      });
      
      // Set the user context in both CAP and Express
      cds.context.user = capUser;
      req.user = capUser;
      next();
    } else {
      res.status(401).json({ error: auth.message });
    }
  } catch (error) {
    cds.log('auth').error('Unexpected authentication error:', error);
    res.status(401).json({ error: 'Unauthorized' });
  }
}

Critical Implementation Details:

  • The user context must be set in both cds.context.user (for CAP Services) and req.user (for Express-based middleware).


5. Service-Level Authorization

With the custom handler successfully establishing the user context, you can immediately leverage CAP's built-in authorization features:

// srv/test-service.cds
service TestService @(requires: 'authenticated-user') { 
   entity Products as projection on template.Products;
}

Testing Your Implementation

Basic Authentication Test

 

Bash
 
# 1. Base64 encode your credentials (email:password)
echo -n 'john.doe@example.com:password' | base64

# 2. Test the endpoint using the encoded value in the Authorization header
curl -X GET http://localhost:4004/odata/v4/test/Products \
  -H "Authorization: Basic am9obi5kb2VAZXhhbXBsZS5jb206cGFzc3dvcmQ="

Expected: 200 OK with product data.

Unauthorized Access Test

 

Bash
 
# Test without the Authorization header
curl -X GET http://localhost:4004/odata/v4/test/Products

Expected: 401 Unauthorized.


When to Use Custom Authentication vs. Standard SAP Solutions

It's crucial to acknowledge that using XSUAA, IAS (Identity Authentication Service), or standard JWT-based authentication is the recommended and preferred approach for the vast majority of SAP CAP applications. These services offer enterprise-grade security, compliance, and integration with the SAP ecosystem.

However, the SAP CAP intentionally provides mechanisms for custom authentication. This approach is necessary when:

  • B2C Applications: Requiring fully custom registration and login user experiences.

  • Legacy System Migration: Integrating existing applications with established user databases and authentication logic.

If your use case aligns with standard enterprise authentication, you should prioritize XSUAA or IAS. Custom authentication should be a deliberate, technically driven choice.


What's Next?

This guide provides the robust foundation for custom Basic Authentication in SAP CAP. Stay tuned for the next parts of this series, where we will extend this implementation.

The journey continues!

2 Comments
Cocquerel
Active Contributor
0 Kudos

Would there be a way to create a custom authentication  that would, in fact, relies on standard XSUAA or IAS but extend it with additional custom security rules ?

michal_majer
Contributor

Yes, it should ne technically possible.

To create a custom authentication layer that extends IAS/XSUAA with your own security rules, you have two paths:

  1. Inspect the logic in the standard CAP file (e.g., in the Node.js implementation: \node_modules\@sap\cds\lib\srv\middlewares\auth\ias-auth.js) and replicate/adapt the essential token handling steps in your own custom middleware.

  2. Build from scratch 🙂

Preview of ias-auth.js:

const cds = require('../../../index.js')
const LOG = cds.log('auth')

const {
  createSecurityContext,
  Token,
  IdentityService,
  XsuaaService,
  XsuaaToken,
  errors: { ValidationError }
} = require('./xssec')

module.exports = function ias_auth(config) {
  // cds.env.requires.auth.known_claims is not an official config!
  const { kind, credentials, config: serviceConfig = {}, known_claims = KNOWN_CLAIMS } = config
  const skipped_attrs = known_claims.reduce((a, x) => ((a[x] = 1), a), {})

  if (!credentials)
    throw new Error(
      `Authentication kind "${kind}" configured, but no IAS instance bound to application. ` +
        'Either bind an IAS instance, or switch to an authentication kind that does not require a binding.'
    )

  // enable signature cache by default
  serviceConfig.validation ??= {}
  if (!('signatureCache' in serviceConfig.validation)) serviceConfig.validation.signatureCache = { enabled: true }
  // activate decode cache if not already done or explicitely disabled by setting Token.decodeCache to false or undefined
  if (Token.decodeCache === null) Token.enableDecodeCache()

  const auth_service = new IdentityService(credentials, serviceConfig)
  const user_factory = get_user_factory(credentials, skipped_attrs)

  /*
   * re validation:
   *   if the request goes to the cert url, then we should validate the token.
   *   however, this requires header "x-forwarded-client-cert" which requires additional configuration in the approuter ("forwardAuthCertificates: true").
   *   also, we currently get the non-cert route attached to the application as well (-> adjust "cds add mta"?), for which validation would always fail.
   *   by default, the approuter is configured to use the non-cert route ("url: ~{srv-url}" instead of "url: ~{srv-cert-url}").
   *   if the developer explicitely changes to the cert route, then we can expect him/her to also configure cert forwarding.
   *   hence, if there is no explicit validation configuration by the app, we can and should create a service with validation enabled and use if for the cert route.
   *   this way, we validate if possible with the least amount of custom configuration.
   */

  const should_validate =
    process.env.VCAP_APPLICATION &&
    JSON.parse(process.env.VCAP_APPLICATION).application_uris?.some(uri => uri.match(/\.cert\.|\.mesh\.cf\./))
  const validation_configured = serviceConfig.validation?.x5t?.enabled != null || serviceConfig.validation?.proofToken?.enabled != null

  let validating_auth_service
  if (should_validate && !validation_configured) {
    const _serviceConfig = { ...serviceConfig }
    _serviceConfig.validation = { x5t: { enabled: true }, proofToken: { enabled: true } }
    validating_auth_service = new IdentityService(credentials, _serviceConfig)
  }

  // xsuaa fallback allows to also accept XSUAA tokens during migration to IAS
  // automatically enabled if xsuaa credentials are available
  let xsuaa_service, xsuaa_user_factory
  if (cds.env.requires.xsuaa?.credentials) {
    const { credentials: xsuaa_credentials, config: xsuaa_serviceConfig = {} } = cds.env.requires.xsuaa
    xsuaa_service = new XsuaaService(xsuaa_credentials, xsuaa_serviceConfig)
    const get_xsuaa_user_factory = require('./jwt-auth')._get_user_factory
    xsuaa_user_factory = get_xsuaa_user_factory(xsuaa_credentials, xsuaa_credentials.xsappname, 'xsuaa')
  }

  return async function ias_auth(req, _, next) {
    if (!req.headers.authorization) return next()

    try {
      const _auth_service =
        validating_auth_service && req.hostname.match(/\.cert\.|\.mesh\.cf\./) ? validating_auth_service : auth_service
      const securityContext = await createSecurityContext(xsuaa_service ? [_auth_service, xsuaa_service] : _auth_service, { req })
      const ctx = cds.context
      ctx.user = securityContext.token instanceof XsuaaToken ? xsuaa_user_factory(securityContext) : user_factory(securityContext)
      ctx.tenant = securityContext.token.getZoneId()
      // REVISIT: remove compat in cds^10
      Object.defineProperty(req, 'authInfo', {
        get() {
          cds.utils.deprecated({ kind: 'API', old: 'cds.context.http.req.authInfo', use: 'cds.context.user.authInfo' })
          return securityContext
        }
      })
    } catch (e) {
      if (e instanceof ValidationError) {
        LOG.warn('Unauthenticated request: ', e)
        return next(401)
      }
      LOG.error('Error while authenticating user: ', e)
      return next(500)
    }

    next()
  }
}

function get_user_factory(credentials, skipped_attrs) {
  return function user_factory(securityContext) {
    const tokenInfo = securityContext.token
    const payload = tokenInfo.getPayload()

    /*
     * NOTE:
     *  for easier migration, xssec will offer IAS without policies via so-called XsuaaFallback.
     *  in that case, we would need to add the roles here based on the tokenInfo (similar to xsuaa-auth).
     *  however, it is not yet clear where the roles will be stored in IAS' tokenInfo object.
     *  further, stakeholders would need to configure the "extension" programmatically (e.g., in a custom server.js).
     */

    const clientid = tokenInfo.getClientId()
    if (clientid === payload.sub) {
      //> grant_type === client_credentials or x509
      const roles = { 'system-user': 1 }
      if (Array.isArray(payload.ias_apis)) payload.ias_apis.forEach(r => (roles[r] = 1))
      if (clientid === credentials.clientid) roles['internal-user'] = 1
      else delete roles['internal-user']

      const user = new cds.User({ id: 'system', roles, authInfo: securityContext })
      // REVISIT: remove compat in cds^10
      Object.defineProperty(user, 'tokenInfo', {
        get() {
          // prettier-ignore
          cds.utils.deprecated({ kind: 'API', old: 'cds.context.user.tokenInfo', use: 'cds.context.user.authInfo.token' })
          return securityContext.token
        }
      })
      return user
    }

    // add all unknown attributes to req.user.attr in order to keep public API small
    const attr = {}
    for (const key in payload) {
      if (key in skipped_attrs)
        continue // REVISIT: Why do we need to do that?
      else attr[key] = payload[key]
    }

    // REVISIT: just don't such things, please! -> We're just piling up tech dept through tons of unoficcial long tail APIs like that!
    // REVISIT: looks like wrong direction to me, btw
    // same api as xsuaa-auth for easier migration
    if (attr.user_name) attr.logonName = attr.user_name
    if (attr.given_name) attr.givenName = attr.given_name
    if (attr.family_name) attr.familyName = attr.family_name

    const user = new cds.User({ id: payload.sub, attr, authInfo: securityContext })
    // REVISIT: remove compat in cds^10
    Object.defineProperty(user, 'tokenInfo', {
      get() {
        cds.utils.deprecated({ kind: 'API', old: 'cds.context.user.tokenInfo', use: 'cds.context.user.authInfo.token' })
        return securityContext.token
      }
    })
    return user
  }
}

// REVISIT: Why do we need to know and do that?
const KNOWN_CLAIMS = Object.values({
  /*
   * JWT claims (https://datatracker.ietf.org/doc/html/rfc7519#section-4)
   */
  ISSUER: 'iss',
  SUBJECT: 'sub',
  AUDIENCE: 'aud',
  EXPIRATION_TIME: 'exp',
  NOT_BEFORE: 'nbf',
  ISSUED_AT: 'iat',
  JWT_ID: 'jti',
  /*
   * TokenClaims (com.sap.cloud.security.token.TokenClaims)
   */
  // ISSUER: "iss", //> already in JWT claims
  IAS_ISSUER: 'ias_iss',
  // EXPIRATION: "exp", //> already in JWT claims
  // AUDIENCE: "aud", //> already in JWT claims
  // NOT_BEFORE: "nbf", //> already in JWT claims
  // SUBJECT: "sub", //> already in JWT claims
  // USER_NAME: 'user_name', //> do not exclude
  // GIVEN_NAME: 'given_name', //> do not exclude
  // FAMILY_NAME: 'family_name', //> do not exclude
  // EMAIL: 'email', //> do not exclude
  SAP_GLOBAL_SCIM_ID: 'scim_id',
  SAP_GLOBAL_USER_ID: 'user_uuid', //> exclude for now
  SAP_GLOBAL_ZONE_ID: 'zone_uuid',
  // GROUPS: 'groups', //> do not exclude
  AUTHORIZATION_PARTY: 'azp',
  CNF: 'cnf',
  CNF_X5T: 'x5t#S256',
  // own
  APP_TENANT_ID: 'app_tid'
})

 

Top kudoed authors