Integrate Rest Datastore with OpenID connect

I use the sample at

I want use keycloak by add the addon “OpenID Connect” to backend and frontend of above sample
image
image
but I get error “IllegalStateException: Access token is not stored. Authenticate with username and password first.” on frontend side
image

Jmix version: 2.4.0-RC1

Hello,

Thank you for bringing this to our attention. We recognize that using the REST Datastore with OIDC (Keycloak) login is a common scenario for many enterprise Jmix applications. Both the addon and the authentication method are important to us. While our time is currently limited, this task is a high priority, and we will make sure to review and address this issue within the next 1-2 weeks (or about 2 weeks).

Best regards,
Dmitry

3 Likes

Hello again,

Here is me, and i solve the problem. Lets do it step by step.

Part 1. Backend app. Secured by OIDC

  1. I’ve created Composite project for monorep
  2. Add service “backend” with type “Jmix REST” that doesn’t contain UI modules due to backend :slight_smile:
  3. Add addons: REST DataStore (to prove that the auth won’t broke) and OIDC (Keycloak support) + remove Oauth Server (we are using keycloak)
  4. 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
  1. Login. Create new realm: jmixapp.

  2. 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)
    image
    image

  3. Create Realm User
    image

  4. Inside jmixapp-client (realm client) create role named "system-full-access"
    image

  5. Assign role to created user. Don’t forget assign password (not temporary) to the user.
    image

  6. 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
    image
    image
    10.2 Configure mapper: token claim name will configure how the key would be named that contains all assigned roles
    image

  7. 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
  1. Lets test, obtain token:

image

  1. Call Rest:

image

Part two. Frontend app (same client for front and back)

  1. Create fullstack app

image

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

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

  1. Create Region entity inside backend app, add name and code string fields, run app, generate changelog.

  2. 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;

    .....
}
  1. Create list and detail view for RegionDto, check results:

image

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)

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

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

image

  1. All done.

  2. Check role permission before call dataManager is the operation allowed before go to backend app.

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

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

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

1 Like