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:
- 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.
- 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.
- 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.
- 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.
- 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.