Hello again,
Here is me, and i solve the problem. Lets do it step by step.
Part 1. Backend app. Secured by OIDC
- I’ve created Composite project for monorep
- Add service “backend” with type “Jmix REST” that doesn’t contain UI modules due to backend
- Add addons: REST DataStore (to prove that the auth won’t broke) and OIDC (Keycloak support) + remove Oauth Server (we are using keycloak)
- Run Keycloak, for
docker-compose
:
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "7080:8080"
command: start-dev
-
Login. Create new realm: jmixapp.
-
Create new client inside the realm (i will enable whole props, as i know we needs only client auth, authorization, standard flow, service accounts roles and direct access grants, but just check all)
-
Create Realm User
-
Inside jmixapp-client (realm client) create role named "system-full-access"
-
Assign role to created user. Don’t forget assign password (not temporary) to the user.
-
Go to roles Scope (inside second page of Client Scopes page), tab mappers, for our better usablility of keycloak, we will add role mapper (configured) only for current client - to have claims without nested objects(alternatively, you can create new scope and add mapper, make it default or else new scope with new mapper and always mention scope in auth flow):
10.1. Add mapper → By configuration → User Client Role
10.2 Configure mapper: token claim name will configure how the key would be named that contains all assigned roles
-
Fill props to secure Jmix backend app via OIDC:
spring.security.oauth2.client.registration.keycloak.client-id=jmixapp-client
spring.security.oauth2.client.registration.keycloak.client-secret=xxxxxxxxxx
spring.security.oauth2.client.registration.keycloak.scope=openid, profile, address, email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:7080/realms/jmixapp
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:7080/realms/jmixapp
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
jmix.oidc.default-claims-roles-mapper.roles-claim-name=jmix-roles
- Lets test, obtain token:
- Call Rest:
Part two. Frontend app (same client for front and back)
- Create fullstack app
- Same steps until obtaining token. (Secure UI app with OIDC). Basically, just fill same properties:
spring.security.oauth2.client.registration.keycloak.client-id=jmixapp-client
spring.security.oauth2.client.registration.keycloak.client-secret=xxxxxxxxxxxxxxxxx
spring.security.oauth2.client.registration.keycloak.scope=openid, profile, address, email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:7080/realms/jmixapp
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:7080/realms/jmixapp
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username
jmix.oidc.default-claims-roles-mapper.roles-claim-name=jmix-roles
Just don’t forget about add-ons
- We are running apps on same host, so we need to run in on different ports. Also, if UI apps more then one, supply different COOKIE JSESSION_ID to prevent session mixture and undefined behavior at development when running several UIs apps in browser:
server.servlet.session.cookie.name=FRONTEND_APP1_JSESSIONID
server.port=8081
- Put data store reference into properties file to backend app:
backend.baseUrl = http://localhost:8080
backend.clientId = jmixapp-client
backend.clientSecret = xxxxxxxxxxxxx
backend.authenticator = restds_OidcRestPasswordAuthenticator
- OIDC is not OAUTH, so there is difference in authentication the client service request to backend:
@Component("restds_OidcRestPasswordAuthenticator")
@Scope("prototype")
public class OidcRestPasswordAuthenticator extends RestPasswordAuthenticator {
private static final Logger log = LoggerFactory.getLogger(OidcRestPasswordAuthenticator.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private RestClient client;
private String dataStoreName;
private String clientId;
private String clientSecret;
@Autowired
private RestTokenHolder tokenHolder;
@Autowired
private ApplicationContext applicationContext;
public OidcRestPasswordAuthenticator() {
}
public void setDataStoreName(String name) {
this.dataStoreName = name;
this.initClient();
}
private void initClient() {
Environment environment = this.applicationContext.getEnvironment();
String baseUrl = environment.getRequiredProperty(this.dataStoreName + ".baseUrl");
this.clientId = environment.getRequiredProperty(this.dataStoreName + ".clientId");
this.clientSecret = environment.getRequiredProperty(this.dataStoreName + ".clientSecret");
this.client = RestClient.builder().baseUrl(baseUrl).requestInterceptor(new LoggingClientHttpRequestInterceptor()).build();
}
public ClientHttpRequestInterceptor getAuthenticationInterceptor() {
return new RetryingClientHttpRequestInterceptor();
}
public void authenticate(String username, String password) {
MultiValueMap<String, String> params = new LinkedMultiValueMap();
params.add("grant_type", "password");
params.add("username", username);
params.add("password", password);
ResponseEntity authResponse;
try {
authResponse = ((RestClient.RequestBodySpec)((RestClient.RequestBodySpec)this.client.post().uri("/oauth2/token", new Object[0])).headers((httpHeaders) -> {
httpHeaders.setBasicAuth(this.clientId, this.clientSecret);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
})).body(params).retrieve().onStatus((statusCode) -> {
return statusCode == HttpStatus.BAD_REQUEST;
}, (request, response) -> {
throw new BadCredentialsException(IOUtils.toString(response.getBody(), StandardCharsets.UTF_8));
}).toEntity(String.class);
} catch (ResourceAccessException var9) {
ResourceAccessException e = var9;
throw new RestDataStoreAccessException(this.dataStoreName, e);
}
try {
JsonNode rootNode = this.objectMapper.readTree((String)authResponse.getBody());
String accessToken = rootNode.get("access_token").asText();
if (!rootNode.has("refresh_token")) {
throw new IllegalStateException("Refresh token is not provided. Add 'refresh_token' to authorization server grant types.");
} else {
String refreshToken = rootNode.get("refresh_token").asText();
this.tokenHolder.setTokens(accessToken, refreshToken);
}
} catch (JsonProcessingException var8) {
JsonProcessingException e = var8;
throw new RuntimeException(e);
}
}
private String authenticate(String refreshToken) {
MultiValueMap<String, String> params = new LinkedMultiValueMap();
params.add("grant_type", "refresh_token");
params.add("refresh_token", refreshToken);
ResponseEntity authResponse;
try {
authResponse = ((RestClient.RequestBodySpec)((RestClient.RequestBodySpec)this.client.post().uri("/oauth2/token", new Object[0])).headers((httpHeaders) -> {
httpHeaders.setBasicAuth(this.clientId, this.clientSecret);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
})).body(params).retrieve().onStatus((statusCode) -> {
return statusCode == HttpStatus.BAD_REQUEST;
}, (request, response) -> {
throw new InvalidRefreshTokenException(this.dataStoreName);
}).toEntity(String.class);
} catch (ResourceAccessException var7) {
ResourceAccessException e = var7;
throw new RestDataStoreAccessException(this.dataStoreName, e);
}
try {
JsonNode rootNode = this.objectMapper.readTree((String)authResponse.getBody());
String accessToken = rootNode.get("access_token").asText();
this.tokenHolder.setTokens(accessToken, refreshToken);
return accessToken;
} catch (JsonProcessingException var6) {
JsonProcessingException e = var6;
throw new RuntimeException(e);
}
}
private String getAccessToken() {
DefaultJmixOidcUser principal = (DefaultJmixOidcUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
OidcIdToken idToken = principal.getDelegate().getIdToken();
String accessToken = idToken.getTokenValue();
if (accessToken == null) {
throw new IllegalStateException("Access token is not stored. Authenticate with username and password first.");
} else {
return accessToken;
}
}
private String getAccessTokenByRefreshToken() {
String refreshToken = this.tokenHolder.getRefreshToken();
if (refreshToken == null) {
throw new IllegalStateException("Refresh token is not stored. Authenticate with username and password first.");
} else {
String accessToken = this.authenticate(refreshToken);
return accessToken;
}
}
private static class LoggingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
private LoggingClientHttpRequestInterceptor() {
}
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
log.debug("Request: {} {}", request.getMethod(), request.getURI());
ClientHttpResponse response = execution.execute(request, body);
log.debug("Response: {}", response.getStatusCode());
return response;
}
}
private class RetryingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
private RetryingClientHttpRequestInterceptor() {
}
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().setBearerAuth(OidcRestPasswordAuthenticator.this.getAccessToken());
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().is4xxClientError() && response.getStatusCode().value() == 401) {
request.getHeaders().setBearerAuth(OidcRestPasswordAuthenticator.this.getAccessTokenByRefreshToken());
response = execution.execute(request, body);
}
return response;
}
}
}
Basically, i just copy-paste the code from RestPasswordAuthenticator
, but change this:
private String getAccessToken() {
DefaultJmixOidcUser principal = (DefaultJmixOidcUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
OidcIdToken idToken = principal.getDelegate().getIdToken();
String accessToken = idToken.getTokenValue();
if (accessToken == null) {
throw new IllegalStateException("Access token is not stored. Authenticate with username and password first.");
} else {
return accessToken;
}
}
As i think, this is enough to work fine (every time User token expires, it refreshes inside Spring automatically).
!BUT This is not clear solution, in best way we need manually go to keycloak and ontain new token via oidc protocol. IDK, i don’t think too much about it. But other parts of authenticaior should not work:
private String authenticate(String refreshToken) {
MultiValueMap<String, String> params = new LinkedMultiValueMap();
params.add("grant_type", "refresh_token");
params.add("refresh_token", refreshToken);
ResponseEntity authResponse;
try {
authResponse = ((RestClient.RequestBodySpec)((RestClient.RequestBodySpec)this.client.post().uri("/oauth2/token", new Object[0])).headers((httpHeaders) -> {
httpHeaders.setBasicAuth(this.clientId, this.clientSecret);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
})).body(params).retrieve().onStatus((statusCode) -> {
return statusCode == HttpStatus.BAD_REQUEST;
}, (request, response) -> {
throw new InvalidRefreshTokenException(this.dataStoreName);
}).toEntity(String.class);
} catch (ResourceAccessException var7) {
ResourceAccessException e = var7;
throw new RestDataStoreAccessException(this.dataStoreName, e);
}
try {
JsonNode rootNode = this.objectMapper.readTree((String)authResponse.getBody());
String accessToken = rootNode.get("access_token").asText();
this.tokenHolder.setTokens(accessToken, refreshToken);
return accessToken;
} catch (JsonProcessingException var6) {
JsonProcessingException e = var6;
throw new RuntimeException(e);
}
}
Because keycloak doesn’t grant /oauth2/token
becuase its not oauth2 but {domain}/realms/{realm}/protocol/openid-connect/token
So, also you need to think how would you refresh the token instead of this solution of authorictaion server.
But enough thinking, go next.
Checking the solution:
-
Create Region
entity inside backend app, add name and code string fields, run app, generate changelog.
-
Create RegionDTO in frontend app, same fields:
@RestDataStoreEntity(remoteName = "Region")
@Store(name = "backend")
@JmixEntity
public class RegionDto {
@JmixGeneratedValue
@JmixId
private UUID id;
@InstanceName
private String name;
.....
}
- Create list and detail view for RegionDto, check results:
Should work fine. Github project here.
Result retrospective
There is you getting the solution where you backend
and frontend
app using same keycloak client, so they are sharing same client roles, same permission etc.
Advs:
- pretty simple
- for you the solution is enough as i understand from context.
- same token for both apps could be used
- supports only one client
Disadvs:
- I m not sure about authentication between service is ready for production.
- Not the best practice to use same client
Alternative variants
Accessing backend via service account (frontend and backend are also user (service-user-account aka service account)
- Authentication difference:
@Primary
@Component("petclinic_OidcRestClientCredentialsAuthenticator")
@Scope("prototype")
public class PetClinicRestClientCredentialsAuthenticator extends RestClientCredentialsAuthenticator {
private static final Logger log = LoggerFactory.getLogger(RestClientCredentialsAuthenticator.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private RestClient client;
private String dataStoreName;
private String clientId;
private String clientSecret;
private String authToken;
private final ReadWriteLock authLock = new ReentrantReadWriteLock();
@Autowired
private Environment environment;
@Override
public void setDataStoreName(String name) {
this.dataStoreName = name;
initClient();
}
private void initClient() {
String baseUrl = environment.getRequiredProperty(dataStoreName + ".baseUrl");
clientId = environment.getRequiredProperty(dataStoreName + ".clientId");
clientSecret = environment.getRequiredProperty(dataStoreName + ".clientSecret");
client = RestClient.builder()
.baseUrl(baseUrl)
.requestInterceptor(new LoggingClientHttpRequestInterceptor())
.build();
}
@Override
public ClientHttpRequestInterceptor getAuthenticationInterceptor() {
return new RetryingClientHttpRequestInterceptor();
}
public String getAuthenticationToken() {
authLock.readLock().lock();
try {
if (authToken != null) {
return authToken;
}
} finally {
authLock.readLock().unlock();
}
authLock.writeLock().lock();
try {
if (authToken == null) {
authToken = obtainAuthToken(clientId, clientSecret);
}
return authToken;
} finally {
authLock.writeLock().unlock();
}
}
public void resetAuthToken() {
authLock.writeLock().lock();
try {
authToken = null;
} finally {
authLock.writeLock().unlock();
}
}
private String obtainAuthToken(String clientId, String clientSecret) {
ResponseEntity<String> authResponse;
try {
authResponse = client.post()
.uri("http://localhost:7080/realms/master/protocol/openid-connect/token")
.headers(httpHeaders -> {
httpHeaders.setBasicAuth(clientId, clientSecret);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
})
.body("grant_type=client_credentials")
.retrieve()
.toEntity(String.class);
} catch (ResourceAccessException e) {
throw new RestDataStoreAccessException(dataStoreName, e);
}
try {
JsonNode rootNode = objectMapper.readTree(authResponse.getBody());
return rootNode.get("access_token").asText();
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public void revokeAuthenticationToken() {
authLock.readLock().lock();
try {
if (authToken == null) {
log.warn("No auth token in use");
return;
}
client.post()
.uri("/oauth2/revoke")
.headers(httpHeaders -> {
httpHeaders.setBasicAuth(clientId, clientSecret);
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
})
.body("token=" + authToken)
.retrieve()
.toBodilessEntity();
} catch (ResourceAccessException e) {
throw new RestDataStoreAccessException(dataStoreName, e);
} finally {
authLock.readLock().unlock();
}
}
private class RetryingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().setBearerAuth(getAuthenticationToken());
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().is4xxClientError() && response.getStatusCode().value() == 401) {
resetAuthToken();
request.getHeaders().setBearerAuth(getAuthenticationToken());
response = execution.execute(request, body);
}
return response;
}
}
private static class LoggingClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
log.debug("Request: {} {}", request.getMethod(), request.getURI());
ClientHttpResponse response = execution.execute(request, body);
log.debug("Response: {}", response.getStatusCode());
return response;
}
}
}
There is difference between OAUTH and OIDC only in 1 line of code:
only in URL, other the same.
- You need to configure service account. Pretty simple. Create role inside keycloak for service account or use
system-full-access
if enough, assign to service account (in your case the user service account named service-account-jmixapp-client
. Grant necessary role to pass throught authentication and permitions. E.g. grant system-full-access for it.
-
All done.
-
Check role permission before call dataManager is the operation allowed before go to backend app.
-
Optional, you can R&D how you can add specific context when obtaining token, probably put username to service account token and then in backend obtain roles from keycloak via second client, check roles currently for backend, not fromend (roles stored in first client for frontend app, second - backend, they are not the same, not the same permissions in backend and frontend)
Github repo for second example. But im here using one keycloak client, but solution is same, here im just lazy, but this is really same
Advs:
- still simple
- 100% fine solution, no hidden dangers
- as i know, many microservices based on this idea
Disadvs:
- roles for service account would be not same as user roles, must be complicated to think how to check permissions in backend.
God-tier solution
- For each service you create new keycloak client.
- User login into service with user token
- If user doing operation that requires to go to other service - we using service account (that user log into). Then we collecting the service account and user token (we need name and maybe other credentionals, i forgot about it already).
- We using service account and user name to impersonate service account and make service account pretend to be user.
Advs:
- Still your soltuion
- You can customize permissions very detailed
Disadvs:
- VERY VERY complicated and complex
UPD:
Notice that work on REST Data Store still in progress (for Nov. 2024), we had not tested Data Store + OIDC Auth so this may cause some troubles with security on your production (re-read what i wrote about first way to connect apps).
In second variant im sure, because I tested in in petclinic and production.
Best regards,
Dmitry