Building a SAML-to-OIDC Protocol Translation Bridge with Micronaut for Serverless Function Authentication


The central problem is enabling modern, stateless serverless functions to securely interact with a legacy monolithic application governed by a SAML 2.0 Identity Provider (IdP). The new functions, deployed on OpenFaaS and built with Micronaut, are architected to use OpenID Connect (OIDC), the de facto standard for modern federated identity. A full migration of the enterprise IdP from SAML to OIDC is off the table for the next 18 months due to organizational inertia and risk aversion. This creates an immediate architectural impasse: how to bridge two fundamentally different security protocols without compromising security or polluting the new serverless domain with legacy concerns.

Architectural Decision Point: Analyzing the Alternatives

In a real-world project, several paths present themselves, each with significant trade-offs regarding complexity, cost, and long-term maintainability.

Option A: Per-Function SAML Integration (The Naive Approach)

The most direct approach is to make each OpenFaaS function a SAML Service Provider (SP). This involves embedding a SAML library within each function, configuring it to communicate with the legacy IdP, and managing the SAML assertion lifecycle.

Pros:

  • No new infrastructure components are introduced. The communication path is direct: Function -> IdP.

Cons:

  • Complexity Proliferation: SAML is a complex, XML-heavy protocol. Forcing every development team to understand its nuances, including XML digital signatures, encryption, and binding types (HTTP-Redirect, HTTP-POST), is a massive cognitive burden and a source of potential security vulnerabilities.
  • Stateful Nature: The SAML SP-initiated flow is inherently stateful, often requiring a session to correlate requests and responses. This directly contradicts the stateless, ephemeral nature of serverless functions and can lead to complex and inefficient state management solutions using external stores.
  • Performance Overhead: SAML libraries are typically heavyweight. For a platform like Micronaut, prized for its low memory footprint and fast startup, adding a bulky SAML dependency to every function negates many of its advantages, especially concerning cold starts in a serverless environment.
  • Configuration Duplication: Each function would require its own SAML SP configuration (entity ID, metadata, certificates), leading to significant configuration drift and operational overhead. A change in the IdP’s certificate would necessitate a coordinated redeployment of all functions.

This approach is fundamentally flawed as it pushes legacy complexity into the modern service tier, creating a tightly coupled and brittle architecture. It is a tactical fix that generates immense long-term technical debt.

Option B: Commercial API Gateway with Protocol Translation

The market offers API Gateways and Identity-Aware Proxies that explicitly support protocol translation. These products can be configured to present an OIDC/OAuth 2.0 interface to downstream services while acting as a SAML SP to the upstream legacy IdP.

Pros:

  • Reduced Development Effort: This is an off-the-shelf solution, abstracting away the protocol translation complexity.
  • Centralized Policy Enforcement: Security policies, including authentication and coarse-grained authorization, are managed centrally at the gateway.

Cons:

  • Cost and Vendor Lock-in: These solutions are often expensive, both in licensing and operational expertise. They can create a strong dependency on a specific vendor’s ecosystem.
  • Black Box Behavior: When complex, non-standard attribute mappings or custom session handling logic is required to satisfy the legacy system’s quirks, a commercial gateway might lack the necessary flexibility. Debugging integration issues can be difficult without full visibility into the translation process.
  • Performance Bottleneck: The gateway becomes a critical choke point for all traffic. While typically performant, it introduces an additional network hop and processing latency for every request. Its resource requirements might be substantial.
  • Architectural Misfit for East-West Traffic: While excellent for north-south (ingress) traffic, using a full-blown API gateway to mediate internal service-to-service calls can be overkill and introduce unnecessary latency and complexity.

This option is viable but trades control and transparency for convenience, often at a significant financial cost. It’s a pragmatic choice if the integration requirements are standard and the budget allows.

Final Choice: A Bespoke Micronaut-based Identity Bridge

The chosen architecture is the development of a dedicated, lightweight service: a SAML-to-OIDC Protocol Translation Bridge. This service will act as a single, centralized point of integration with the legacy SAML IdP. To new OpenFaaS functions, it will present itself as a standard OIDC Provider.

Rationale:

  1. Decoupling and Encapsulation: It creates a strong boundary, an “Identity Anti-Corruption Layer,” that isolates the new serverless architecture from the legacy SAML IdP. The functions remain pure OIDC clients, unaware of the SAML complexity behind the bridge.
  2. Optimized for Purpose: Using Micronaut allows for the creation of a highly efficient, low-resource service. Micronaut’s ahead-of-time (AOT) compilation ensures minimal startup time and memory consumption, making it suitable for deployment as a containerized service that can be scaled effectively.
  3. Full Control and Extensibility: We retain complete control over the translation logic, attribute mapping, token generation, and session management. This is critical for handling any legacy idiosyncrasies and for future extensions, such as integrating custom authorization logic.
  4. Cost-Effectiveness: Building this component in-house avoids licensing fees associated with commercial gateways. The operational cost is limited to hosting the containerized Micronaut application.
  5. Strategic Asset: This bridge becomes a reusable piece of infrastructure. As more legacy applications are modernized, they can all leverage this central service for authentication, providing a consistent identity layer during a prolonged migration period.

This approach represents a strategic investment. It requires an initial development effort but yields a more flexible, maintainable, and cost-effective long-term solution that aligns with modern architectural principles.

Core Implementation

The architecture consists of three main parts: the legacy SAML IdP (which we assume exists), the Micronaut Identity Bridge, and a sample OpenFaaS function acting as an OIDC client.

sequenceDiagram
    participant User
    participant OpenFaaS Function (Micronaut)
    participant Micronaut Bridge
    participant Legacy SAML IdP

    User->>+OpenFaaS Function (Micronaut): Access secured endpoint
    Note over OpenFaaS Function (Micronaut): No valid session/token
    OpenFaaS Function (Micronaut)->>+Micronaut Bridge: Initiate OIDC Auth Code Flow (Redirect User)
    User->>Micronaut Bridge: Follow Redirect
    Note over Micronaut Bridge: User has no session with Bridge
    Micronaut Bridge->>+Legacy SAML IdP: Initiate SAML SP Flow (Redirect User)
    User->>Legacy SAML IdP: Follow Redirect, authenticates
    Legacy SAML IdP-->>-Micronaut Bridge: POST SAML Assertion
    Note over Micronaut Bridge: Validates Assertion, extracts attributes
    Micronaut Bridge-->>Micronaut Bridge: Creates local session for User
    Micronaut Bridge-->>-User: Redirect back to OIDC client
    User-->>OpenFaaS Function (Micronaut): Redirect with OIDC auth code
    OpenFaaS Function (Micronaut)-->>+Micronaut Bridge: Exchange auth code for tokens
    Micronaut Bridge-->>-OpenFaaS Function (Micronaut): Return ID Token & Access Token
    OpenFaaS Function (Micronaut)-->>OpenFaaS Function (Micronaut): Validates token, establishes session
    OpenFaaS Function (Micronaut)-->>-User: Return secured resource

1. Micronaut Bridge Project Setup

The build.gradle.kts file for the bridge service is crucial. It needs dependencies for both SAML and OIDC security stacks.

// build.gradle.kts

plugins {
    id("com.github.johnrengelman.shadow") version "7.1.2"
    id("io.micronaut.application") version "3.7.9"
}

// ... other standard Micronaut config ...

dependencies {
    implementation("io.micronaut:micronaut-http-client")
    implementation("io.micronaut:micronaut-runtime")
    implementation("io.micronaut.security:micronaut-security-jwt")
    implementation("io.micronaut.security:micronaut-security-oauth2")

    // Key dependency for acting as a SAML Service Provider
    implementation("io.micronaut.security:micronaut-security-saml")

    // For session management
    implementation("io.micronaut.session:micronaut-session")
    implementation("io.micronaut.cache:micronaut-cache-caffeine") // Example: In-memory session store

    // Logging and other utilities
    implementation("ch.qos.logback:logback-classic")
    runtimeOnly("org.yaml:snakeyaml")
    // ...
}

2. Configuring the SAML Service Provider Endpoint

The bridge must first act as a SAML SP. This is configured in application.yml. A common mistake is misconfiguring the entity IDs or metadata endpoints, which are the root cause of most SAML integration failures.

# application.yml for the Micronaut Bridge

micronaut:
  application:
    name: identity-bridge
  security:
    enabled: true
    session:
      enabled: true # Session is critical for linking SAML auth to OIDC request
      http:
        cookie: true
        remember-me: false
    endpoints:
      login:
        path: /login # Micronaut Security default
      logout:
        path: /logout
    saml:
      enabled: true
      login-uri: /saml/login # URI to trigger SAML authentication
      acs-uri: /saml/callback # Assertion Consumer Service URI
      # Service Provider configuration (Our bridge's identity)
      sp:
        entity-id: "urn:micronaut:identity-bridge:sp"
        # Keystore for signing/decrypting SAML messages
        key-store:
          path: "classpath:saml/sp-keystore.jks"
          password: "secure_password"
          alias: "sp-key"
          alias-password: "secure_password"
      # Identity Provider configuration (The legacy system)
      idps:
        legacy-idp:
          entity-id: "urn:legacy:idp:main"
          metadata:
            url: "https://legacy-idp.example.com/saml/metadata"
          # Certificate for validating IdP signatures
          verification-key:
            path: "classpath:saml/idp-signing.crt"

This configuration tells Micronaut Security how to generate SAML authentication requests and how to validate the assertions it receives at /saml/callback. The session is explicitly enabled to hold the authenticated user’s state.

3. Configuring the OIDC Provider Endpoint

Simultaneously, the bridge must act as an OIDC Provider. This configuration coexists with the SAML settings. The key is to define how OIDC tokens are signed and issued.

# application.yml for the Micronaut Bridge (continued)

micronaut:
  security:
    # ... (SAML config from above) ...
    oauth2:
      enabled: true
      # Configuration for this service acting as an OIDC Provider
      openid:
        # Define how JWTs (ID Tokens) are signed
        jwks:
          generator:
            ec:
              # In a production system, use a secure key management service
              # This example uses a static key for simplicity
              d: "l9jJn4J3-Z2y_nJ..."
              crv: "P-256"
              x: "..."
              y: "..."
        # Defines the client applications allowed to connect
        clients:
          openfaas-fn-1:
            client-id: "fn-client"
            client-secret: "${OIDC_CLIENT_SECRET:very_secret_value}"
            scopes:
              - "openid"
              - "email"
              - "profile"
            redirect-uris:
              - "http://127.0.0.1:8081/oauth/callback/oidc" # Local testing URI for the function

This sets up the OIDC portion of the bridge. It defines a client (openfaas-fn-1) that represents our serverless function. When the function authenticates, the bridge will use the configured EC key to sign the ID Token.

4. The Core Translation and Token Logic

The default Micronaut Security behavior for SAML is to create an authenticated session upon successful assertion consumption. The OIDC provider logic can then leverage this session. To map SAML attributes to OIDC claims, we provide a custom OpenIdClaimsMapper.

// src/main/java/com/example/bridge/LegacyClaimsMapper.java

package com.example.bridge;

import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.oauth2.endpoint.token.response.DefaultOpenIdClaimsMapper;
import io.micronaut.security.oauth2.endpoint.token.response.OpenIdClaims;
import jakarta.inject.Singleton;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Singleton
public class LegacyClaimsMapper extends DefaultOpenIdClaimsMapper {

    private static final Logger LOG = LoggerFactory.getLogger(LegacyClaimsMapper.class);

    public LegacyClaimsMapper() {
        // We will define the standard claims to map
        super(Collections.emptySet());
    }

    @Override
    public OpenIdClaims map(Authentication authentication, String nonce) {
        OpenIdClaims claims = super.map(authentication, nonce);

        // The 'authentication' object holds attributes from the SAML assertion
        claims.setSubject(authentication.getName());

        // A common pitfall is assuming attribute names are consistent.
        // Always log and verify the exact attribute names from the SAML IdP.
        LOG.debug("Mapping claims from SAML attributes: {}", authentication.getAttributes());

        authentication.getAttributes().get("SAML_ATTR_email")
            .ifPresent(email -> claims.setEmail((String) email));

        authentication.getAttributes().get("SAML_ATTR_groups")
            .ifPresent(groups -> {
                if (groups instanceof List) {
                    claims.put("groups", groups);
                }
            });

        authentication.getAttributes().get("SAML_ATTR_givenName")
            .ifPresent(givenName -> claims.setGivenName((String) givenName));
        
        authentication.getAttributes().get("SAML_ATTR_familyName")
            .ifPresent(familyName -> claims.setFamilyName((String) familyName));

        // Set issuer, audience, etc.
        // These would be configured externally in a real system.
        claims.setIssuer("urn:micronaut:identity-bridge:sp");
        claims.setAudience(List.of("fn-client"));

        return claims;
    }
}

This class is the heart of the translation. It intercepts the OIDC token generation process, inspects the Authentication object (which was populated by the SAML module), and correctly maps SAML assertion attributes to standard and custom OIDC claims.

5. The OpenFaaS Function as an OIDC Client

Now, let’s create a simple Micronaut-based OpenFaaS function that uses our new bridge for authentication.

Function build.gradle.kts:

// ... standard Micronaut Function with AWS Lambda dependencies ...
dependencies {
    implementation("io.micronaut.security:micronaut-security-jwt")
    implementation("io.micronaut.security:micronaut-security-oauth2")
    // ...
}

Function application.yml:
This configuration points the function to our bridge.

micronaut:
  security:
    enabled: true
    oauth2:
      enabled: true
      clients:
        oidc: # Default client name
          client-id: "fn-client"
          client-secret: "${OIDC_CLIENT_SECRET:very_secret_value}"
          openid:
            issuer: "http://identity-bridge.local:8080" # URL of our bridge

Function Handler Code:

// src/main/java/com/example/fn/FunctionHandler.java

package com.example.fn;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.function.aws.MicronautRequestHandler;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import java.security.Principal;
import java.util.Map;
import jakarta.annotation.Nullable;

@Introspected
@Controller("/api")
@Secured(SecurityRule.IS_AUTHENTICATED) // Secure all endpoints in this controller
public class FunctionHandler extends MicronautRequestHandler<Map<String, Object>, Map<String, Object>> {

    @Get("/userinfo")
    public Map<String, Object> getUserInfo(@Nullable Principal principal) {
        if (principal == null) {
            // This should not happen due to the @Secured annotation
            return Map.of("error", "Not authenticated");
        }
        // Principal.getName() will be the subject from the ID Token.
        // For more claims, you would inject Authentication object.
        return Map.of(
            "message", "Successfully accessed secured function",
            "username", principal.getName()
        );
    }
}

This function is now a standard OIDC client. It knows nothing about SAML. The @Secured annotation ensures that any request to /api/userinfo must have a valid JWT issued by our bridge. The framework handles the token validation automatically based on the issuer configuration.

Extensibility and Limitations

The primary strength of this architecture is its extensibility. The bridge can be enhanced to perform more complex logic:

  • Role-Based Access Control (RBAC): It could fetch user roles from an external database based on the SAML uid and embed them as custom claims in the OIDC token.
  • Token Exchange: It could implement RFC 8693 for token exchange, allowing functions to trade their user-context token for a machine-context token to call downstream services.
  • High Availability: The bridge itself is stateful due to session management. For production, the in-memory Caffeine cache should be replaced with a distributed store like Redis or Hazelcast, and the service should be deployed with multiple replicas behind a load balancer.

However, this solution has boundaries. It is now a critical infrastructure component; if the bridge is down, no new function can authenticate. Its performance and availability must be monitored rigorously. The introduction of the bridge adds a network hop and processing latency to the initial authentication flow, though subsequent API calls using the issued JWTs are unaffected. This pattern is an elegant solution for a transitional period but does not eliminate the eventual need to address the underlying legacy IdP. It buys time and enables progress, which is often the most pragmatic engineering choice.


  TOC