Skip to content

Custom SAML Signers

This guide explains how to implement custom SAML signing logic by implementing the SamlSigner interface.

The SamlSigner Interface

All SAML signers must implement the SamlSigner interface:

typescript
interface SamlSigner {
  signSAML(xml: string): Promise<string>;
}

The interface has a single method:

  • signSAML(xml: string): Promise<string> - Takes unsigned SAML XML and returns signed XML

Basic Custom Signer

Here's a basic example of a custom signer:

typescript
import type { SamlSigner } from "authhero";
import { init } from "authhero";

class MyCustomSigner implements SamlSigner {
  async signSAML(xml: string): Promise<string> {
    // Your signing logic here
    const signedXml = await yourSigningFunction(xml);
    return signedXml;
  }
}

// Use it
const app = init({
  dataAdapter,
  samlSigner: new MyCustomSigner(),
});

Example Implementations

1. AWS KMS Signer

Sign SAML responses using AWS Key Management Service:

typescript
import { KMSClient, SignCommand } from "@aws-sdk/client-kms";
import type { SamlSigner } from "authhero";

class AwsKmsSigner implements SamlSigner {
  private kms: KMSClient;
  private keyId: string;

  constructor(keyId: string, region: string = "us-east-1") {
    this.kms = new KMSClient({ region });
    this.keyId = keyId;
  }

  async signSAML(xml: string): Promise<string> {
    // Parse XML and find the element to sign
    const elementToSign = this.extractElementToSign(xml);

    // Create digest
    const digest = await this.createDigest(elementToSign);

    // Sign with KMS
    const command = new SignCommand({
      KeyId: this.keyId,
      Message: Buffer.from(digest),
      MessageType: "DIGEST",
      SigningAlgorithm: "RSASSA_PKCS1_V1_5_SHA_256",
    });

    const { Signature } = await this.kms.send(command);

    // Insert signature into XML
    return this.insertSignature(xml, Signature);
  }

  private extractElementToSign(xml: string): string {
    // Implementation depends on your XML parsing library
    // Extract the element that needs to be signed
    return elementToSign;
  }

  private async createDigest(data: string): Promise<string> {
    // Create SHA-256 digest
    const encoder = new TextEncoder();
    const dataBuffer = encoder.encode(data);
    const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer);
    return Buffer.from(hashBuffer).toString("base64");
  }

  private insertSignature(xml: string, signature: Uint8Array): string {
    // Insert the signature into the XML document
    // This depends on your XML manipulation library
    return signedXml;
  }
}

// Usage
const signer = new AwsKmsSigner("your-kms-key-id", "us-west-2");

const app = init({
  dataAdapter,
  samlSigner: signer,
});

2. Cached Signer

Wrap another signer with caching to improve performance:

typescript
import type { SamlSigner } from "authhero";

class CachedSigner implements SamlSigner {
  private cache = new Map<string, string>();
  private ttl: number;
  private innerSigner: SamlSigner;

  constructor(innerSigner: SamlSigner, ttlSeconds: number = 300) {
    this.innerSigner = innerSigner;
    this.ttl = ttlSeconds * 1000;
  }

  async signSAML(xml: string): Promise<string> {
    // Create cache key
    const cacheKey = this.createHash(xml);

    // Check cache
    const cached = this.cache.get(cacheKey);
    if (cached) {
      return cached;
    }

    // Sign with inner signer
    const signed = await this.innerSigner.signSAML(xml);

    // Cache result
    this.cache.set(cacheKey, signed);

    // Auto-expire
    setTimeout(() => {
      this.cache.delete(cacheKey);
    }, this.ttl);

    return signed;
  }

  private createHash(data: string): string {
    // Simple hash for demo - use crypto.subtle.digest in production
    return btoa(data).slice(0, 32);
  }
}

// Usage
import { HttpSamlSigner } from "authhero";

const httpSigner = new HttpSamlSigner("https://signing-service.com/sign");
const cachedSigner = new CachedSigner(httpSigner, 300); // 5 minute cache

const app = init({
  dataAdapter,
  samlSigner: cachedSigner,
});

3. Retry Signer

Add automatic retry logic with exponential backoff:

typescript
import type { SamlSigner } from "authhero";

class RetrySigner implements SamlSigner {
  private innerSigner: SamlSigner;
  private maxRetries: number;
  private baseDelay: number;

  constructor(
    innerSigner: SamlSigner,
    maxRetries: number = 3,
    baseDelay: number = 1000,
  ) {
    this.innerSigner = innerSigner;
    this.maxRetries = maxRetries;
    this.baseDelay = baseDelay;
  }

  async signSAML(xml: string): Promise<string> {
    let lastError: Error;

    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await this.innerSigner.signSAML(xml);
      } catch (error) {
        lastError = error as Error;

        if (attempt < this.maxRetries) {
          // Exponential backoff
          const delay = this.baseDelay * Math.pow(2, attempt);
          await this.sleep(delay);
          console.warn(
            `Retry ${attempt + 1}/${this.maxRetries} after ${delay}ms`,
          );
        }
      }
    }

    throw new Error(
      `Failed to sign SAML after ${this.maxRetries} retries: ${lastError.message}`,
    );
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

// Usage
import { HttpSamlSigner } from "authhero";

const httpSigner = new HttpSamlSigner("https://signing-service.com/sign");
const retrySigner = new RetrySigner(httpSigner, 3, 1000);

const app = init({
  dataAdapter,
  samlSigner: retrySigner,
});

4. Multi-Key Signer

Rotate between multiple signing keys:

typescript
import type { SamlSigner } from "authhero";
import { LocalSamlSigner } from "@authhero/saml/local-signer";

class MultiKeySigner implements SamlSigner {
  private signers: SamlSigner[];
  private currentIndex: number = 0;

  constructor(signers: SamlSigner[]) {
    if (signers.length === 0) {
      throw new Error("At least one signer required");
    }
    this.signers = signers;
  }

  async signSAML(xml: string): Promise<string> {
    const signer = this.getCurrentSigner();
    return await signer.signSAML(xml);
  }

  private getCurrentSigner(): SamlSigner {
    return this.signers[this.currentIndex];
  }

  // Call this to rotate to next key
  rotate(): void {
    this.currentIndex = (this.currentIndex + 1) % this.signers.length;
  }

  // Call this to set a specific key
  useKey(index: number): void {
    if (index < 0 || index >= this.signers.length) {
      throw new Error(`Invalid key index: ${index}`);
    }
    this.currentIndex = index;
  }
}

// Usage
const signer1 = new LocalSamlSigner();
const signer2 = new LocalSamlSigner();
const multiKeySigner = new MultiKeySigner([signer1, signer2]);

const app = init({
  dataAdapter,
  samlSigner: multiKeySigner,
});

// Rotate keys periodically
setInterval(
  () => {
    multiKeySigner.rotate();
    console.log("Rotated to next signing key");
  },
  24 * 60 * 60 * 1000,
); // Daily rotation

5. Logging/Monitoring Signer

Wrap another signer with logging and monitoring:

typescript
import type { SamlSigner } from "authhero";

class MonitoredSigner implements SamlSigner {
  private innerSigner: SamlSigner;
  private logger: (message: string, metadata?: any) => void;

  constructor(
    innerSigner: SamlSigner,
    logger: (message: string, metadata?: any) => void = console.log,
  ) {
    this.innerSigner = innerSigner;
    this.logger = logger;
  }

  async signSAML(xml: string): Promise<string> {
    const startTime = Date.now();

    this.logger("SAML signing started", {
      xmlLength: xml.length,
      timestamp: new Date().toISOString(),
    });

    try {
      const result = await this.innerSigner.signSAML(xml);
      const duration = Date.now() - startTime;

      this.logger("SAML signing completed", {
        duration,
        resultLength: result.length,
        success: true,
      });

      return result;
    } catch (error) {
      const duration = Date.now() - startTime;

      this.logger("SAML signing failed", {
        duration,
        error: error.message,
        success: false,
      });

      throw error;
    }
  }
}

// Usage
import { HttpSamlSigner } from "authhero";

const httpSigner = new HttpSamlSigner("https://signing-service.com/sign");
const monitoredSigner = new MonitoredSigner(httpSigner, (msg, meta) => {
  // Send to your monitoring service
  console.log(msg, meta);
});

const app = init({
  dataAdapter,
  samlSigner: monitoredSigner,
});

Best Practices

1. Error Handling

Always handle errors appropriately:

typescript
class MySigner implements SamlSigner {
  async signSAML(xml: string): Promise<string> {
    try {
      return await this.performSigning(xml);
    } catch (error) {
      // Log the error
      console.error("Signing failed:", error);

      // Optionally wrap with more context
      throw new Error(`SAML signing failed: ${error.message}`);
    }
  }
}

2. Validation

Validate inputs before processing:

typescript
class ValidatingSigner implements SamlSigner {
  async signSAML(xml: string): Promise<string> {
    if (!xml || xml.trim().length === 0) {
      throw new Error("XML cannot be empty");
    }

    if (!xml.includes("<saml")) {
      throw new Error("Invalid SAML XML");
    }

    return await this.sign(xml);
  }
}

3. Timeout Handling

Implement timeouts for external services:

typescript
class TimeoutSigner implements SamlSigner {
  constructor(
    private innerSigner: SamlSigner,
    private timeoutMs: number = 5000,
  ) {}

  async signSAML(xml: string): Promise<string> {
    return Promise.race([
      this.innerSigner.signSAML(xml),
      this.timeout(this.timeoutMs),
    ]);
  }

  private timeout(ms: number): Promise<never> {
    return new Promise((_, reject) => {
      setTimeout(() => reject(new Error("Signing timeout")), ms);
    });
  }
}

4. Testing

Make your signers testable:

typescript
class MockSigner implements SamlSigner {
  async signSAML(xml: string): Promise<string> {
    // Return unsigned XML for testing
    return xml.replace(
      "</samlp:Response>",
      "<ds:Signature>mock-signature</ds:Signature></samlp:Response>",
    );
  }
}

// In tests
const app = init({
  dataAdapter: mockAdapter,
  samlSigner: new MockSigner(),
});

Composing Signers

You can compose multiple signer wrappers:

typescript
import { HttpSamlSigner } from "authhero";

// Base signer
const baseSigner = new HttpSamlSigner("https://signing-service.com/sign");

// Add retry logic
const withRetry = new RetrySigner(baseSigner, 3);

// Add caching
const withCache = new CachedSigner(withRetry, 300);

// Add monitoring
const withMonitoring = new MonitoredSigner(withCache, logger);

// Use the composed signer
const app = init({
  dataAdapter,
  samlSigner: withMonitoring,
});

This gives you: monitoring → caching → retry → HTTP signing in a clean, composable way.

Released under the MIT License.