Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support OIDC token to authenticate in API Catalog #3925

Open
wants to merge 15 commits into
base: v3.x.x
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.springframework.web.bind.annotation.RestController;
import org.zowe.apiml.apicatalog.exceptions.ContainerStatusRetrievalThrowable;
import org.zowe.apiml.apicatalog.model.APIContainer;
import org.zowe.apiml.apicatalog.security.OidcUtils;
import org.zowe.apiml.apicatalog.services.cached.CachedApiDocService;
import org.zowe.apiml.apicatalog.services.cached.CachedProductFamilyService;
import org.zowe.apiml.message.log.ApimlLogger;
Expand All @@ -34,6 +35,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.StreamSupport;

/**
Expand All @@ -51,6 +53,8 @@ public class ApiCatalogController {
@InjectApimlLogger
private final ApimlLogger apimlLog = ApimlLogger.empty();

private AtomicReference<List<String>> oidcProviderCache = new AtomicReference<>();

/**
* Create the controller and autowire in the repository services
*
Expand All @@ -64,6 +68,15 @@ public ApiCatalogController(CachedProductFamilyService cachedProductFamilyServic
this.cachedApiDocService = cachedApiDocService;
}

@GetMapping(value = "/oidc/provider", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<String>> getOidcProvider() {
if (oidcProviderCache.get() == null) {
oidcProviderCache.set(OidcUtils.getOidcProvider());
}

return new ResponseEntity<>(oidcProviderCache.get(), oidcProviderCache.get().isEmpty() ? HttpStatus.NO_CONTENT : HttpStatus.OK);
}


/**
* Get all containers
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.apicatalog.security;

import lombok.experimental.UtilityClass;
import org.apache.commons.lang3.StringUtils;

import java.util.List;

@UtilityClass
public class OidcUtils {

private String PREFIX = "ZWE_components_gateway_spring_security_oauth2_client_";

public List<String> getOidcProvider() {
return System.getenv().keySet().stream()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe store the value for subsequent calls

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in the controller

.filter(key -> StringUtils.startsWith(key, PREFIX))
.map(key -> key.substring(PREFIX.length()))
.map(key -> key.split("_"))
.filter(parts -> parts.length > 2)
.map(parts -> parts[1])
.distinct()
.sorted()
.toList();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import org.zowe.apiml.security.common.content.BasicContentFilter;
import org.zowe.apiml.security.common.content.BearerContentFilter;
import org.zowe.apiml.security.common.content.CookieContentFilter;
import org.zowe.apiml.security.common.content.OidcContentFilter;
import org.zowe.apiml.security.common.filter.CategorizeCertsFilter;
import org.zowe.apiml.security.common.login.LoginFilter;
import org.zowe.apiml.security.common.login.ShouldBeAlreadyAuthenticatedFilter;
Expand Down Expand Up @@ -151,7 +152,8 @@ public WebSecurityCustomizer webSecurityCustomizer() {
"/favicon.ico",
"/v3/api-docs",
"/index.html",
"/application/info"
"/application/info",
"/oidc/provider"
};
return web -> web.ignoring().requestMatchers(noSecurityAntMatchers);
}
Expand Down Expand Up @@ -232,7 +234,8 @@ public void configure(HttpSecurity http) throws Exception {
.addFilterBefore(loginFilter(authConfigurationProperties.getServiceLoginEndpoint(), authenticationManager), ShouldBeAlreadyAuthenticatedFilter.class)
.addFilterBefore(basicFilter(authenticationManager), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(cookieFilter(authenticationManager), BasicContentFilter.class)
.addFilterAfter(bearerContentFilter(authenticationManager), CookieContentFilter.class);
.addFilterAfter(bearerContentFilter(authenticationManager), CookieContentFilter.class)
.addFilterAfter(oidcFilter(authenticationManager), BearerContentFilter.class);
}

private LoginFilter loginFilter(String loginEndpoint, AuthenticationManager authenticationManager) {
Expand Down Expand Up @@ -278,6 +281,18 @@ private BearerContentFilter bearerContentFilter(AuthenticationManager authentica
handlerInitializer.getResourceAccessExceptionHandler()
);
}

/**
* Secures content with a OIDC token
*/
private OidcContentFilter oidcFilter(AuthenticationManager authenticationManager) {
return new OidcContentFilter(
authenticationManager,
handlerInitializer.getAuthenticationFailureHandler(),
handlerInitializer.getResourceAccessExceptionHandler()
);
}

}

@Bean
Expand Down
1 change: 1 addition & 0 deletions api-catalog-services/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ eureka:

authentication:
sso: true
scheme: zoweJwt
client:
healthcheck:
enabled: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@
import com.netflix.appinfo.InstanceInfo;
import com.netflix.discovery.shared.Application;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.zowe.apiml.apicatalog.exceptions.ContainerStatusRetrievalThrowable;
Expand All @@ -26,15 +23,15 @@
import org.zowe.apiml.apicatalog.services.cached.CachedProductFamilyService;
import org.zowe.apiml.apicatalog.services.cached.CachedServicesService;

import java.lang.reflect.Field;
import java.util.*;

import static io.restassured.module.mockmvc.RestAssuredMockMvc.standaloneSetup;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static io.restassured.module.mockmvc.RestAssuredMockMvc.standaloneSetup;

class ApiCatalogControllerTests {
private final String pathToContainers = "/containers";
Expand Down Expand Up @@ -142,9 +139,9 @@ void thenPopulateApiDocForServices() throws ContainerStatusRetrievalThrowable {

containers.getBody().forEach(apiContainer ->
apiContainer.getServices().forEach(apiService -> {
Assertions.assertEquals(apiService.getServiceId(), apiService.getApiDoc());
Assertions.assertEquals(apiVersions, apiService.getApiVersions());
Assertions.assertEquals(defaultApiVersion, apiService.getDefaultApiVersion());
assertEquals(apiService.getServiceId(), apiService.getApiDoc());
assertEquals(apiVersions, apiService.getApiVersions());
assertEquals(defaultApiVersion, apiService.getDefaultApiVersion());
}));
}

Expand All @@ -158,8 +155,8 @@ void thenPopulateApiDocForServicesExceptOneWhichFails() throws ContainerStatusRe
containers.getBody().forEach(apiContainer ->
apiContainer.getServices().forEach(apiService -> {
if (apiService.getServiceId().equals("service1")) {
Assertions.assertEquals(apiService.getServiceId(), apiService.getApiDoc());
Assertions.assertEquals(apiService.getApiVersions(), apiVersions);
assertEquals(apiService.getServiceId(), apiService.getApiDoc());
assertEquals(apiService.getApiVersions(), apiVersions);
}
if (apiService.getServiceId().equals("service2")) {
Assertions.assertNull(apiService.getApiDoc());
Expand All @@ -177,11 +174,11 @@ void thenPopulateApiVersionsForServicesExceptOneWhichFails() throws ContainerSta
containers.getBody().forEach(apiContainer ->
apiContainer.getServices().forEach(apiService -> {
if (apiService.getServiceId().equals("service1")) {
Assertions.assertEquals(apiService.getServiceId(), apiService.getApiDoc());
Assertions.assertEquals(apiService.getApiVersions(), apiVersions);
assertEquals(apiService.getServiceId(), apiService.getApiDoc());
assertEquals(apiService.getApiVersions(), apiVersions);
}
if (apiService.getServiceId().equals("service2")) {
Assertions.assertEquals(apiService.getServiceId(), apiService.getApiDoc());
assertEquals(apiService.getServiceId(), apiService.getApiDoc());
Assertions.assertNull(apiService.getApiVersions());
}
}));
Expand Down Expand Up @@ -238,4 +235,52 @@ private InstanceInfo getStandardInstance(String serviceId, InstanceInfo.Instance
null, null, null, null, null, null, null, 0, null, "hostname", status, null, null, null, null, null,
null, null, null, null);
}

@Nested
class OidcProviders {

private String[] env = {
"ZWE_components_gateway_spring_security_oauth2_client_provider_oidc1_authorizationUri",
"ZWE_components_gateway_spring_security_oauth2_client_registration_oidc2_clientId",
"ZWE_components_gateway_spring_security_oauth2_client_provider_oidc1_tokenUri"
};

Map<String, String> getEnvMap() {
try {
Class<?> envVarClass = System.getenv().getClass();
Field mField = envVarClass.getDeclaredField("m");
mField.setAccessible(true);
return (Map<String, String>) mField.get(System.getenv());
} catch (NoSuchFieldException | IllegalAccessException e) {
fail(e);
return null;
}
}

@AfterEach
void tearDown() {
Arrays.stream(env).forEach(k -> getEnvMap().remove(k));
}

@Test
void givenSystemEnv_whenInvokeOidcProviders_thenReturnTheList() {
Arrays.stream(env).forEach(k -> getEnvMap().put(k, "anyValue"));
List<String> oidcProviders = RestAssuredMockMvc.given()
.when().get("/oidc/provider")
.getBody().jsonPath().getList(".");
assertEquals(2, oidcProviders.size());
assertTrue(oidcProviders.contains("oidc1"));
assertTrue(oidcProviders.contains("oidc2"));
}

@Test
void givenNoSystemEnv_whenInvokeOidcProviders_thenReturnAnEmptyList() {
List<String> oidcProviders = RestAssuredMockMvc.given()
.when().get("/oidc/provider")
.getBody().jsonPath().getList(".");
assertEquals(0, oidcProviders.size());
}

}

}
30 changes: 30 additions & 0 deletions api-catalog-ui/frontend/src/components/Login/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import WarningIcon from '@material-ui/icons/Warning';
import Spinner from '../Spinner/Spinner';
import './Login.css';
import Footer from '../Footer/Footer';
import getBaseUrl from "../../helpers/urls";

function Login(props) {
const [username, setUsername] = useState('');
Expand All @@ -42,6 +43,9 @@ function Login(props) {
const [warning, setWarning] = useState(false);
const [submitted, setSubmitted] = useState(false);

const [oidcProvidersLoadingStarted, setOidcProviderLoadingStarted] = useState(false);
const [oidcProviders, setOidcProviders] = useState([]);

const { returnToLogin, login, authentication, isFetching, validateInput } = props;
const enterNewPassMsg = 'Enter a new password for account';
const invalidPassMsg = 'The specified username or password is invalid.';
Expand Down Expand Up @@ -122,6 +126,21 @@ function Login(props) {
setSubmitted(true);
}

function loadOidcProviders() {
if (!oidcProvidersLoadingStarted) {
setOidcProviderLoadingStarted(true);
fetch(`${getBaseUrl()}/oidc/provider`).then(resp =>
resp.json().then(json => {
console.log('json')
setOidcProviders(json)
return json
})
).catch(() => setOidcProviderLoadingStarted(false))
}
}

loadOidcProviders();

let errorData = { messageText: null, expired: false, invalidNewPassword: true, invalidCredentials: false };
if (
authentication !== undefined &&
Expand Down Expand Up @@ -299,6 +318,17 @@ function Login(props) {
)}
</FormControl>
<div className="login-btns" id="loginButton">
{
oidcProviders.map(oidcProvider =>
<Button
variant="contained"
style={{ marginRight: '10px' }}
onClick={() => window.location = `/gateway/oauth2/authorization/${oidcProvider}?returnUrl=${encodeURIComponent(window.location.href.split('#')[0])}`}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"window.location" breaks unit tests

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, it was a different reason

>
Login with {oidcProvider}
</Button>)
}

<Button
variant="contained"
color="primary"
Expand Down
14 changes: 14 additions & 0 deletions api-catalog-ui/frontend/src/components/Login/Login.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ import '@testing-library/jest-dom';
import Login from './Login';

describe('>>> Login page component tests', () => {

beforeEach(() => {
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([])
})
);
});

afterEach(() => {
global.fetch.mockRestore();
});

it('should display password update form', () => {
render(
<Login
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class AuthConfigurationProperties {
private String gatewayQueryEndpoint = "/gateway/api/v1/auth/query";
private String gatewayTicketEndpoint = "/gateway/api/v1/auth/ticket";

private String gatewayOidcValidateEndpoint = "/gateway/api/v1/auth/oidc-token/validate";

private String zaasLoginEndpoint = "/zaas/api/v1/auth/login";
private String zaasLogoutEndpoint = "/zaas/api/v1/auth/logout";
private String zaasQueryEndpoint = "/zaas/api/v1/auth/query";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ protected Optional<AbstractAuthenticationToken> extractContent(HttpServletReques
).map(
header -> {
header = header.replaceFirst(ApimlConstants.BEARER_AUTHENTICATION_PREFIX, "").trim();
return new TokenAuthentication(header);
return new TokenAuthentication(header, TokenAuthentication.Type.JWT);
}
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ public Optional<AbstractAuthenticationToken> extractContent(HttpServletRequest r
.filter(cookie -> cookie.getName().equals(authConfigurationProperties.getCookieProperties().getCookieName()))
.filter(cookie -> !cookie.getValue().isEmpty())
.findFirst()
.map(cookie -> new TokenAuthentication(cookie.getValue()));
.map(cookie -> new TokenAuthentication(cookie.getValue(), TokenAuthentication.Type.JWT));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* This program and the accompanying materials are made available under the terms of the
* Eclipse Public License v2.0 which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-v20.html
*
* SPDX-License-Identifier: EPL-2.0
*
* Copyright Contributors to the Zowe Project.
*/

package org.zowe.apiml.security.common.content;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.zowe.apiml.security.common.error.ResourceAccessExceptionHandler;
import org.zowe.apiml.security.common.handler.FailedAuthenticationHandler;
import org.zowe.apiml.security.common.token.TokenAuthentication;

import java.util.Optional;

import static org.zowe.apiml.constants.ApimlConstants.HEADER_OIDC_TOKEN;
import static org.zowe.apiml.security.common.token.TokenAuthentication.Type.OIDC;

public class OidcContentFilter extends AbstractSecureContentFilter {

public OidcContentFilter(AuthenticationManager authenticationManager, FailedAuthenticationHandler authenticationFailureHandler, ResourceAccessExceptionHandler resourceAccessExceptionHandler) {
super(authenticationManager, authenticationFailureHandler, resourceAccessExceptionHandler, new String[0]);
}

@Override
protected Optional<AbstractAuthenticationToken> extractContent(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(HEADER_OIDC_TOKEN))
.map(token -> new TokenAuthentication(token, OIDC));
}

}
Loading
Loading