Captain-P-Goldfish / scim-for-keycloak

a third party module that extends keycloak by SCIM functionality
BSD 3-Clause "New" or "Revised" License
182 stars 46 forks source link

UMA compatibiliy #91

Closed jacen05 closed 7 months ago

jacen05 commented 9 months ago

Hello,

Thank you for the "SCIM for Keycloak" plugin. I got it working on latest Keycloak version, currently syncing Azure AD users thanks to the tutorial. However, I'm not able to make it work with a custom app that uses the SCIM-node library (made by Gluu but adapted for our use-case).

This library expects, as per the norm, to get a 401 HTTP return code when accessing an UMA-protected resource. This 401 should contain a permission ticket. However in my testing the ticket information is not present when getting a 401 on /auth/realms/myrealm/scim/v2/Users.

Can you tell me if this setup is supposed to work (so UMA is supported and the problem is somewhere else on my side), or if UMA compatibility is at the moment not supported.

Captain-P-Goldfish commented 9 months ago

the SCIM endpoint are indeed not respecting the UMA protocol. A failed authentication attempt is simply responded with 401 unauthenticated or if successfully authenticated with a 403 forbidden if the necessary roles for accessing the resources are missing.

What is it you want to achieve by using the UMA protocol on the SCIM endpoints? I have considered the SCIM endpoints to be in an administrational function only to be used by very few authorized parties. What is your use-case?

jacen05 commented 9 months ago

OK, thanks for your answer. The need is only to make SCIM to work correctly in my app, replacing an existing Gluu server with Keycloak. The use-case is that the webapp allows users with enough privileges to list and change some user attributes through SCIM. This boils down to calling getUser() et editUser() from this library. This library is not really flexible: it expects to be able to authenticate through UMA.

Do you know any good SCIM library for NodeJS that will work with your plugin?

Captain-P-Goldfish commented 9 months ago

unfortunately I cannot give you a good advice here, since I am using my own implementation of a SCIM client in my JS applications that is very simplified though.

import {useUserLogin} from "../provider/login-provider";
import {Optional} from "./utils";

export function useScimClient()
{

  const auth = useUserLogin();

  function createResource(resourcePath, resource, onSuccess, onError)
  {
    auth.accessToken.then(accessToken =>
    {
      fetch(resourcePath, {
        method: "POST",
        headers: {
          'Content-Type': 'application/scim+json',
          'Authorization': 'Bearer ' + accessToken
        },
        body: JSON.stringify(resource)
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 201,
          status: response.status,
          resource: response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  function getResource(resourcePath, id, params, onSuccess, onError)
  {
    auth.accessToken.then(accessToken =>
    {
      let searchParams = new Optional(params).map(parameters => "?" + new URLSearchParams(parameters).toString())
                                             .orElse("");
      let url = resourcePath + new Optional(id).map(val => "/" + encodeURIComponent(val)).orElse("") + searchParams;

      fetch(url, {
        method: "GET",
        headers: {
          'Authorization': 'Bearer ' + accessToken
        }
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 200,
          status: response.status,
          resource: response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  async function listResources({
                                 resourcePath,
                                 startIndex,
                                 count,
                                 filter,
                                 sortBy,
                                 sortOrder,
                                 attributes,
                                 excludedAttributes,
                                 onSuccess,
                                 onError
                               } = {})
  {
    auth.accessToken.then(accessToken =>
    {
      let startIndexParam = new Optional(startIndex).map(val => "startIndex=" + val).orElse(null);
      let countParam = new Optional(count).map(val => "count=" + val).orElse(null);
      let filterParam = new Optional(filter).map(val => "filter=" + encodeURI(val)).orElse(null);
      let sortByParam = new Optional(sortBy).map(val => "sortBy=" + encodeURI(val)).orElse(null);
      let sortOrderParam = new Optional(sortOrder).map(val => "sortOrder=" + val).orElse(null);
      let attributesParam = new Optional(attributes).map(val => "attributes=" + encodeURI(val)).orElse(null);
      let excludedAttributesParam = new Optional(excludedAttributes).map(
        val => "excludedAttributes=" + encodeURI(val)).orElse(null);

      let query = Array.of(startIndexParam, countParam, filterParam, sortByParam, sortOrderParam, attributesParam,
        excludedAttributesParam)
                       .filter(val => val != null)
                       .join("&");

      query = new Optional(query).filter(val => val.length > 0).map(val => "?" + val).orElse("");

      let requestUrl = resourcePath + query;

      fetch(requestUrl, {
        method: "GET",
        headers: {
          'Authorization': 'Bearer ' + accessToken
        }
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 200,
          status: response.status,
          resource: response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  function updateResource(resourcePath, id, resource, onSuccess, onError)
  {
    auth.accessToken.then(accessToken =>
    {
      fetch(resourcePath + "/" + encodeURIComponent(id), {
        method: "PUT",
        headers: {
          'Content-Type': 'application/scim+json',
          'Authorization': 'Bearer ' + accessToken
        },
        body: JSON.stringify(resource)
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 200,
          status: response.status,
          resource: response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  function patchResource(resourcePath, id, patchBody, onSuccess, onError)
  {
    auth.accessToken.then(accessToken =>
    {
      fetch(resourcePath + "/" + encodeURIComponent(id), {
        method: "PATCH",
        headers: {
          'Content-Type': 'application/scim+json',
          'Authorization': 'Bearer ' + accessToken
        },
        body: JSON.stringify(patchBody)
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 200,
          status: response.status,
          resource: response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  function deleteResource(resourcePath, id, requestBody, onSuccess, onError)
  {
    auth.accessToken.then(accessToken =>
    {
      fetch(resourcePath + "/" + encodeURIComponent(id), {
        method: "DELETE",
        headers: {
          'Authorization': 'Bearer ' + accessToken
        },
        body: requestBody ? JSON.stringify(requestBody) : null
      }).then(response =>
      {
        let tmpResponse = {
          success: response.status === 204,
          status: response.status,
          resource: response.status === 204 ? undefined : response.json()
        };
        if (tmpResponse.success)
        {
          new Optional(onSuccess).ifPresent(func => func());
        }
        else
        {
          new Optional(onError).ifPresent(func => tmpResponse.resource.then(json => func(json)));
        }
        return tmpResponse;
      });
    });
  }

  return {
    createResource: createResource,
    getResource: getResource,
    listResources: listResources,
    updateResource: updateResource,
    patchResource: patchResource,
    deleteResource: deleteResource
  };
}
jacen05 commented 9 months ago

Thanks, I will try to get it working with my app, and report back probably next week.

jacen05 commented 8 months ago

In the provided code, I guess that you're using the user access token to authenticate the requests.

Captain-P-Goldfish commented 8 months ago
  1. There is a yes and a no
  2. The SCIM endpoints are not accessible if no Client in the authorization section was assigned (If the authentication is turned on). And users authenticating over other applications (other Clients) will not be granted access.

Thx. This question pointed out that the documentation on the website on this topic should be brought more to the foreground

Each realm has its own individual security configuration. So the configuration for realm A will have no effect for the configuration on realm B

Captain-P-Goldfish commented 8 months ago

I will try to create some graphics with enough explanations for the website

jacen05 commented 8 months ago

Thanks. So, it seems that there is no way to restrict a SCIM client to manage only "its" users (the roles are bound to a Resource Type, not a resource). This implies that I need to make use of a different realm per business client (a client being in that case an OIDC client + potentially another OIDC client for AzureAD and SCIM if not using the local user DB). This has some big impacts on our application, so I need to assess the solutions available for multi-tenancy.

Captain-P-Goldfish commented 8 months ago

Ah I see what you are getting at. This is indeed not possible with this solution. I once had a similiar problem where we wanted to share the same users for different tenants but with different access-rights on these tenants and its resources. The easiest way in my opinion is to use different realms and try to synchronize users between realms. It would probably also be possible to add an create the users in one realm and to add a delegation identity-provider from realm B to realm A so that the users from there are accepted too in realm B. I actually never tried this its just a theory that might work :-)

jacen05 commented 8 months ago

Well, the problem for us is more that we wanted the authentication server to be the entry point for all users, and after authentication the user would be redirected to the correct applicative instance by the reverse-proxy. However Keycloak assumes that users will reach the correct realm login URL. So :

jacen05 commented 7 months ago

FYI, we made our own implementation of NodeJS SCIM library (only the few functions needed). This work great! Now we'll have to find a solution for the mutli-tenancy problem. Anyway this has nothing to do with UMA compatibility, so you can close the issue if you want, as we do not need it anymore, or keep it open if you think this feature can be interesting for the project.