| name | authentication-xsuaa |
| description | Invoke this skill to set up XSUAA authentication and Application Router. Detects <auth-method>FORM</auth-method> in web.xml, security-constraint definitions, or UserProvider usage in Java code. Replaces Neo built-in auth with CF XSUAA. |
| disable-model-invocation | false |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob |
Authentication and Authorization
Set up XSUAA-based authentication and Application Router for Cloud Foundry.
Purpose
Replace Neo's built-in FORM authentication and UserManagementAccessor with Cloud Foundry's XSUAA service and Application Router for secure web application access.
Detection
This skill applies if any of these patterns are found:
In web.xml
<auth-method>FORM</auth-method>
<security-constraint>
<web-resource-collection>
<web-resource-name>Protected</web-resource-name>
<url-pattern>/protected/*</url-pattern>
</web-resource-collection>
</security-constraint>
<security-role>
<role-name>Everyone</role-name>
</security-role>
In Java source files
import com.sap.security.um.user.UserProvider;
import com.sap.security.um.user.User;
request.getUserPrincipal();
request.isUserInRole("Everyone");
Prerequisites
Working directory: This skill must run inside the -cf-migration copy of your app, created by jakarta-java25-migration or neo-to-cf-migration-orchestrator. If your current directory does not end in -cf-migration, switch to it before proceeding.
Before invoking this skill, ensure you have invoked:
- sdk-replacement -
Use the sdk-replacement skill
- Sets up SAP Cloud SDK
- REQUIRED before this skill
Transformation Steps
Step 1: Create xs-security.json
Create xs-security.json in your project root (or cf/ folder) - see assets/xs-security.json for template:
{
"xsappname": "${app-name}",
"tenant-mode": "dedicated",
"scopes": [
{
"name": "$XSAPPNAME.Everyone",
"description": "Everyone scope for authenticated users"
}
],
"role-templates": [
{
"name": "Everyone",
"scope-references": [
"$XSAPPNAME.Everyone"
]
}
],
"role-collections": [
{
"name": "${app-name}-Everyone",
"role-template-references": [
"$XSAPPNAME.Everyone"
]
}
]
}
Customize: Replace ${app-name} with your application name and Everyone with each actual role name from your Neo app. The naming convention for role collections is <app-name>-<role-name> (e.g. myapp-Admin, myapp-Viewer). Add one entry per role-template in the role-collections array. subaccount-roles-import reads these names to link deployed role-templates and assign users.
Step 2: Update web.xml Authentication Method
Before:
<login-config>
<auth-method>FORM</auth-method>
<form-login-config>
<form-login-page>/login.html</form-login-page>
<form-error-page>/login-error.html</form-error-page>
</form-login-config>
</login-config>
After:
<login-config>
<auth-method>XSUAA</auth-method>
</login-config>
Step 3: Add Security Library Dependency
Add to pom.xml:
<dependency>
<groupId>com.sap.cloud.security</groupId>
<artifactId>java-api</artifactId>
</dependency>
Note: The java-api artifact is managed by the cf-tomcat-bom BOM, so no version is needed.
Step 4: Create Application Router
Create approuter/ directory with these files:
approuter/package.json
Copy from assets/package.json:
{
"name": "approuter",
"dependencies": {
"@sap/approuter": "^16.0.0"
},
"scripts": {
"start": "node node_modules/@sap/approuter/approuter.js"
}
}
approuter/xs-app.json
See assets/xs-app.json for a complete template:
{
"authenticationMethod": "route",
"routes": [
{
"source": "^/protected(/.*)?$",
"target": "/protected$1",
"destination": "backend-app-destination",
"authenticationType": "xsuaa",
"scope": "$XSAPPNAME.Everyone",
"csrfProtection": false
},
{
"source": "^(/.*)",
"target": "$1",
"destination": "backend-app-destination",
"authenticationType": "none",
"csrfProtection": false
}
],
"logout": {
"logoutEndpoint": "/logout",
"logoutPage": "/"
}
}
Scopes in routes: Use "scope": "$XSAPPNAME.<ScopeName>" on each protected route to enforce XSUAA scope checks at the approuter level. The $XSAPPNAME placeholder is resolved at runtime to the bound XSUAA service's xsappname. Routes without a scope field only require authentication (when authenticationType is xsuaa).
Step 5: Update User Access Code
Before (Neo):
import com.sap.security.um.user.UserProvider;
import com.sap.security.um.user.User;
@Resource
private UserProvider userProvider;
public void doGet(HttpServletRequest request, HttpServletResponse response) {
User user = userProvider.getUser(request);
String userName = user.getName();
boolean isAdmin = request.isUserInRole("Admin");
}
After (Cloud Foundry — in JAX-RS endpoints or servlets):
import com.sap.cloud.security.token.Token;
import com.sap.cloud.security.token.TokenClaims;
import jakarta.servlet.http.HttpServletRequest;
public void doGet(HttpServletRequest request, HttpServletResponse response) {
java.security.Principal principal = request.getUserPrincipal();
String userName = principal != null ? principal.getName() : "anonymous";
boolean hasScope = request.isUserInRole("Everyone");
}
Step 6: Create MTA Descriptor with Approuter
Create or update mtad.yaml:
_schema-version: "3.2"
version: 0.0.1
ID: ${app-name}
parameters:
enable-parallel-deployments: true
modules:
- name: ${app-name}
type: java.tomcat
path: target/ROOT.war
parameters:
buildpack: sap_java_buildpack_jakarta
disk-quota: 1024M
memory: 1024M
properties:
ENABLE_SECURITY_JAVA_API_V2: true
JBP_CONFIG_COMPONENTS: "jres: ['com.sap.xs.java.buildpack.jdk.SAPMachineJDK']"
JBP_CONFIG_SAP_MACHINE_JDK: "{ version: 25.+ }"
TARGET_RUNTIME: tomcat
SET_LOGGING_LEVEL: 'ROOT: INFO'
requires:
- name: ${app-name}-xsuaa
- name: ${app-name}-destination
provides:
- name: ${app-name}-java-app
properties:
neo-app-url: '${default-url}'
- name: ${app-name}-approuter
type: nodejs
path: approuter
parameters:
disk-quota: 256M
memory: 256M
routes:
- route: '${protocol}://${app-name}.${default-domain}'
protocol: http1
properties:
XS_APP_LOG_LEVEL: debug
TENANT_HOST_PATTERN: '(.*).cfapps.sap.hana.ondemand.com'
CF_NODEJS_LOGGING_LEVEL: "info"
requires:
- name: ${app-name}-xsuaa
- name: ${app-name}-java-app
group: destinations
properties:
name: backend-app-destination
url: '~{neo-app-url}'
forwardAuthToken: true
resources:
- name: ${app-name}-xsuaa
type: org.cloudfoundry.managed-service
parameters:
service: xsuaa
service-plan: application
path: ./xs-security.json
- name: ${app-name}-destination
type: org.cloudfoundry.managed-service
parameters:
service: destination
service-plan: lite
Key points:
path: target/ROOT.war — WAR must be named ROOT so Tomcat serves at /. Configure maven-war-plugin with <warName>ROOT</warName>.
ENABLE_SECURITY_JAVA_API_V2: true — required for XSUAA JWT validation via the java-api library.
JBP_CONFIG_COMPONENTS + JBP_CONFIG_SAP_MACHINE_JDK — pin to SAPMachineJDK 17.
provides on the backend uses a custom property name (e.g. neo-app-url) and the approuter requires references it with ~{neo-app-url}. The url shorthand only works if the provides block uses a property literally named url.
disk-quota: 1024M minimum — 512M causes deployment failures with the SAP Java buildpack.
Step 7: Remove Neo-Specific Login Pages (Optional)
If you had custom login pages for FORM authentication, you can remove them as the Approuter handles authentication via redirect to the identity provider.
Files to consider removing:
login.html
login-error.html
- Related CSS/JS for login
Configuration Files
| File | Location | Purpose |
|---|
xs-security.json | Project root | XSUAA security configuration |
package.json | approuter/ | Approuter Node.js dependencies |
xs-app.json | approuter/ | Approuter routing configuration |
CF Services
| Service | Plan | Purpose |
|---|
xsuaa | application | OAuth 2.0 authorization server |
destination | lite | Internal routing (for approuter) |
Verification
1. Build and Deploy
mvn clean package
cf deploy . -f
2. Check Services
cf services
3. Test Authentication
- Open the approuter URL:
https://${app-name}.${domain}
- Should redirect to identity provider login
- After login, should access protected resources
4. Verify Token
Add debug endpoint to verify JWT token is received:
@WebServlet("/debug/token")
public class TokenDebugServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws IOException {
Principal principal = req.getUserPrincipal();
resp.getWriter().println("User: " + (principal != null ? principal.getName() : "null"));
resp.getWriter().println("Is Everyone: " + req.isUserInRole("Everyone"));
}
}
Common Issues
Issue: 401 Unauthorized after login
Cause: Role collection not assigned to user.
Solution: In BTP Cockpit, assign the role collection to your user.
Issue: Approuter returns 502 Bad Gateway
Cause: Backend app not reachable.
Solution: Check that the backend URL in provides/requires is correct.
Issue: All requests return 404 after successful deployment
Cause: WAR filename is not ROOT.war. The SAP Java buildpack deploys auth.war under the /auth context path. Requests to / or /currentuser return 404 because Tomcat only serves at /auth/*.
Solution: In pom.xml, set <warName>ROOT</warName> in the maven-war-plugin configuration:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<warName>ROOT</warName>
</configuration>
</plugin>
Update mtad.yaml path: to target/ROOT.war. Rebuild and redeploy.
Note: <finalName> in <build> does NOT control the WAR filename — only <warName> in the plugin configuration does.
Issue: CORS errors
Solution: Add CORS configuration to approuter or backend:
{
"cors": {
"allowedOrigins": ["*"],
"allowedMethods": ["GET", "POST", "PUT", "DELETE"]
}
}
Extended Approuter (Basic Auth Support)
For scenarios requiring Basic Authentication (e.g., API access), see references/extended-approuter.md for an extended approuter implementation.
Issue: getUserPrincipal() returns null and isUserInRole() always returns false — even though the user is authenticated via the approuter
Cause: The XSSecurityAuthenticator Catalina valve only validates JWT tokens for URLs that match a <security-constraint> in web.xml. If the REST API URL patterns (e.g., /rest/*, /api/*) are not covered by any security constraint, the valve skips JWT validation entirely. The approuter forwards a valid JWT token in the Authorization header, but the backend never processes it. As a result, getUserPrincipal() returns null and all isUserInRole() calls return false — regardless of whether you use @Context, @Inject, or SessionContext. This is the most common cause of authorization failures after migration and is easy to miss because the Neo-era constraints often covered only static pages (e.g., /index.html) while REST APIs were covered by auth-method-specific URL prefixes (/s/api/*, /b/api/*) that were removed during migration.
Diagnosis: Add a debug endpoint and check whether getUserPrincipal() returns null. If it does, the issue is missing security constraints, not the injection method.
Solution: See Step 5 above. Add a <security-constraint> covering /rest/* (or /* for all paths) with an <auth-constraint> requiring the Everyone role. Fine-grained role checks (admin, manager) should be done in Java code, not via URL-level constraints.
Issue: isUserInRole() returns false in @Stateless EJBs on TomEE — user has role but gets AuthorizationException
Cause: First verify this is not the missing security constraint issue above (check if getUserPrincipal() returns null). If the principal IS set but isUserInRole() still returns false: @Context HttpServletRequest (JAX-RS injection) does not carry the XSUAA security context when used in @Stateless or @Singleton EJBs that are not JAX-RS resources. The request object is a CXF-internal wrapper that doesn't delegate isUserInRole() to the Catalina/XSUAA security realm. SessionContext.isCallerInRole() also fails because TomEE's OpenEJBSecurityListener does not fully propagate XSUAA roles to the OpenEJB security context. This is commonly seen in CDI producer beans or service provider EJBs that check roles before returning a service implementation.
Solution: See Step 7 above (TomEE variant). Replace @Context with @Inject for HttpServletRequest. CDI injection provides a request-scoped proxy that delegates to the real Catalina request with the XSUAA security context. Both isUserInRole() and getUserPrincipal() then work correctly. See also the tomee-runtime skill for details.
Next Steps
After completing this skill, proceed to: