p2-inc / keycloak-orgs

Single realm, multi-tenancy for SaaS apps
Other
367 stars 65 forks source link

Idea: Let keycloak select "active" organization and provide only roles and scopes based on selection #71

Closed jaakkom closed 1 year ago

jaakkom commented 1 year ago

If keycloak would select the active organization and provide only specific roles and scopes into JWT, it would make checking permissions a lot easier on app side.

Example:

Benefits:

xgp commented 1 year ago

@jaakkom Thanks for the suggestion.

For the use case where you expect to have users with lots of Organizations and Organization Roles, users have been mapping them to the userinfo endpoint, rather than the JWT. This is also a common way to overcome JWT/header size problems when a user has a large number of Groups.

There's also an API method that allows you to query for the Organizations and Organization Roles of the logged in user: https://phasetwo.io/api/get-me

Regarding the proposal, you can certainly implement something that works for your use case, or even submit a PR here, but there are several use cases already commonly used by our customers that an "active" Organization either isn't relevant, or wouldn't work for them.

maaft commented 1 year ago

I'm looking for the same thing:

I.e. I want the user to be able to act "on behalf of some org".

I mainly want to use this in combination with hasura which does permission checks against JWT data. Therefore, the "org_id" for the organization the user currently works with, has to be included. Storing groups in userinfo is not an option, since I can't tell hasura to call a different endpoint upon every request. Also it would be very slow to do this.

@xgp Or do you maybe know any alternative ways how I could achieve this? I thought about the json-remote-claim mapper but even if I get this to work and let it call the org info from my own endpoint, I don't know how I can "parameterize" the login-flow to let the user select a different org.

xgp commented 1 year ago

@maaft I'm not familiar with Hasura. Is it necessary to have the org_id in the top level as a claim, or is it sufficient to have it as part of a JSON claim, like we do with organization mapper today? E.g.

  "organizations": {
    "5aeb9aeb-97a3-4deb-af9f-516615b59a2d" : {
      "name": "foo",
      "roles": [ "admin", "viewer" ]
    }
  }

If you need the org_id in the top level, I've written a custom Authenticator to add an "active" org to the token. It works by adding the org_id that's a param in the redirect_uri to a user session note. Then you can use a standard Keycloak token mapper that maps that user session note into the claim. Every time you want to update the org_id, you have to send a login request with different redirect_uri. The Authenticator looks something like this:

  @Override
  public void authenticate(AuthenticationFlowContext context) {
    setNote(context);
  }

  @Override
  public void action(AuthenticationFlowContext context) {
    setNote(context);
  }

  private void setNote(AuthenticationFlowContext context) {
    try {
      String redirectUri = context.getAuthenticationSession().getClientNote("redirect_uri");
      URI u = new URI(redirectUri);
      List<NameValuePair> params = URLEncodedUtils.parse(u, Charset.forName("UTF-8"));
      for (NameValuePair param : params) {
        if (param.getName().equals("org_id")) {
          String orgId = param.getValue();
          log.infof("Found org_id in redirect uri %s", orgId);
          context.getAuthenticationSession().setUserSessionNote("org_id", orgId);
        }
      }
    } catch (Exception e) {
      log.warn("Error looking for org_id in redirect_uri", e);
    }
    context.attempted(); // There was no failure or challenge.
  }

Let me know if this helps, or if you have other ideas.

maaft commented 1 year ago

@xgp I think that would work. Thanks!

I'm fairly new to keycloak and also maven. So forgive me, if my questions are maybe a bit stupid:

How do I check if the user is indeed a member of the requested org_id? I don't see that in your code sample.

Also for adding this, I'll try to clone "keycloak-magic-link" and try to integrate it their, since all the required boilerplate is already there. When it works, I'll try to setup a separate maven project.

xgp commented 1 year ago

use OrganizationProvider. here's the method with the check:

import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationProvider;

  private void setNote(AuthenticationFlowContext context) {
    try {
      String redirectUri = context.getAuthenticationSession().getClientNote("redirect_uri");
      URI u = new URI(redirectUri);
      List<NameValuePair> params = URLEncodedUtils.parse(u, Charset.forName("UTF-8"));
      for (NameValuePair param : params) {
        if (param.getName().equals("org_id")) {
          String orgId = param.getValue();
          log.infof("Found org_id in redirect uri %s", orgId);
          OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class);
          OrganizationModel org = orgs.getOrganizationById(context.getRealm(), orgId);
          if (org.hasMembership(context.getUser()) {
            context.getAuthenticationSession().setUserSessionNote("org_id", orgId);
          } else {
            log.info("user not in org");
          }
        }
      }
    } catch (Exception e) {
      log.warn("Error looking for org_id in redirect_uri", e);
    }
    context.attempted(); // There was no failure or challenge.
  }

feel free to ask more questions as you implement.

maaft commented 1 year ago

Hi! I'm currently stuck again.

Here is my authenticator code:

ActiveOrgNoteAuthenticator.java

package io.phasetwo.service.auth;

import java.net.URI;
import java.nio.charset.Charset;
import java.util.List;
import java.util.Map;

import lombok.extern.jbosslog.JBossLog;

import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticationFlowContext;

import org.keycloak.models.*;
import io.phasetwo.service.model.OrganizationModel;
import io.phasetwo.service.model.OrganizationProvider;

@JBossLog
class ActiveOrgNoteAuthenticator implements Authenticator {

  public static final String DEFAULT_ORG_ID_KEY = "default-org-id";

  @Override
  public boolean requiresUser() {
    return true;
  }

  @Override
  public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
    return true;
  }

  @Override
  public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
    // NOOP
  }

  @Override
  public void authenticate(AuthenticationFlowContext context) {
    setNote(context);
  }

  @Override
  public void close() {
    // NOOP
  }

  @Override
  public void action(AuthenticationFlowContext context) {
    setNote(context);
  }

  private void setNote(AuthenticationFlowContext context) {

    boolean orgSet = false;

    log.info("setNote called");

    try {

      // read org_id from URL params
      String redirectUri = context.getAuthenticationSession().getClientNote("redirect_uri");
      URI u = new URI(redirectUri);
      List<NameValuePair> params = URLEncodedUtils.parse(u, Charset.forName("UTF-8"));
      for (NameValuePair param : params) {
        if (param.getName().equals("org_id")) {
          String orgId = param.getValue();
          log.infof("Found org_id in redirect uri %s", orgId);
          OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class);
          OrganizationModel org = orgs.getOrganizationById(context.getRealm(), orgId);
          if (org.hasMembership(context.getUser())) {
            context.getAuthenticationSession().setUserSessionNote("org_id", orgId);
            orgSet = true;
          } else {
            log.info("user not in org");
          }
        }
      }

      // if org_id not present in query params, use default group id which is stored
      // in user attributes
      if (!orgSet) {
        Map<String, List<String>> attributes = context.getUser().getAttributes();

        List<String> defaultGroupList = attributes.get(DEFAULT_ORG_ID_KEY);

        if (defaultGroupList.size() == 1) {
          String orgId = defaultGroupList.get(0);
          OrganizationProvider orgs = context.getSession().getProvider(OrganizationProvider.class);
          OrganizationModel org = orgs.getOrganizationById(context.getRealm(), orgId);
          if (org.hasMembership(context.getUser())) {
            context.getAuthenticationSession().setUserSessionNote("org_id", orgId);
            orgSet = true;
          } else {
            log.info("user not in org where org was set to default");
          }
        }
      }

      // if user has a missing default-org-id attribute or not access to the org
      // defined by default-org-id, select any of his organizations and update
      // default-org-id attribute
      // if (!orgSet) {
      // OrganizationProvider orgs =
      // context.getSession().getProvider(OrganizationProvider.class);
      // String orgId = orgs.getOrganizationsStream(context.getRealm()).filter(org ->
      // org.hasMembership(context.getUser())

      // OrganizationModel org = orgs.getOrganizationById(context.getRealm(), orgId);
      // if (org.hasMembership(context.getUser())) {
      // context.getAuthenticationSession().setUserSessionNote("org_id", orgId);
      // orgSet = true;
      // } else {
      // log.info("user not in org where org was set to default");
      // }
      // }

    } catch (Exception e) {
      log.warn("Error looking for org_id in redirect_uri", e);
    }

    // context.attempted(); // There was no failure or challenge.
    context.success();
  }
}

ActiveOrgNoteAuthenticatorFactory.java

package io.phasetwo.service.auth;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;
import java.util.ArrayList;

public class ActiveOrgNoteAuthenticatorFactory implements AuthenticatorFactory {

  private static final String PROVIDER_ID = "ext-auth-active-org-auth-note";

  public static final ActiveOrgNoteAuthenticator GROUP_AUTHENTICATOR = new ActiveOrgNoteAuthenticator();

  @Override
  public String getReferenceCategory() {
    return null;
  }

  @Override
  public boolean isConfigurable() {
    return true;
  }

  public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
      AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED
  };

  @Override
  public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
    return REQUIREMENT_CHOICES;
  }

  @Override
  public boolean isUserSetupAllowed() {
    return false;
  }

  @Override
  public String getDisplayType() {
    return "Select Active Organization";
  }

  @Override
  public String getHelpText() {
    return "Enables the user to select the active organization by adding a 'org_id' query param when logging in";
  }

  @Override
  public List<ProviderConfigProperty> getConfigProperties() {
    return new ArrayList<ProviderConfigProperty>();
  }

  @Override
  public void close() {
    // NOOP
  }

  @Override
  public Authenticator create(KeycloakSession session) {
    return GROUP_AUTHENTICATOR;
  }

  @Override
  public void init(Config.Scope config) {
    // NOOP
  }

  @Override
  public void postInit(KeycloakSessionFactory factory) {
    // NOOP
  }

  @Override
  public String getId() {
    return PROVIDER_ID;
  }
}

Here is my browser login flow: (step "select active organization) image

When logging in, I see that my authenticator is called and no exceptions are thrown, but the returned token does not contain org_id claim.

Token

{
  "exp": 1683880194,
  "iat": 1683879894,
  "auth_time": 1683879894,
  "jti": "49effa42-b75c-48dd-9a2a-94dcf17d65a7",
  "iss": "http://localhost:8888/realms/hub",
  "aud": "account",
  "sub": "e1ae7305-7c26-4cc5-8b78-cad611e77d08",
  "typ": "Bearer",
  "azp": "account-console",
  "nonce": "68d935d5-e3c2-48a2-81ca-621b72ad6630",
  "session_state": "acb427a6-a5d0-4314-9756-b6b27f3b6cde",
  "acr": "1",
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links"
      ]
    }
  },
  "scope": "openid email profile",
  "sid": "acb427a6-a5d0-4314-9756-b6b27f3b6cde",
  "email_verified": false,
  "preferred_username": "tenant",
  "given_name": "",
  "family_name": ""
}

I forgot to add the note-mapper: image

Now it works and I have my org_id claim in my token! :rocket:

Also maybe any ideas how I can change my implementation, rebuild keycloak docker but keep my current keycloak config ?

It's a bit annoying that I have to setup everything again after I rebuild keycloak with my modified authenticator.

Thanks a bunch!

@xgp Would you be interested to integrate such an authentication flow to phasetwo-containers? If yes, what do you think is missing?

From the top of my head:

xgp commented 1 year ago

Glad you figured it out.

Also maybe any ideas how I can change my implementation, rebuild keycloak docker but keep my current keycloak config ?

It shouldn't be a problem to add this to your docker image and then restart. Assuming you're using an external db, you shouldn't lose anything.

Would you be interested to integrate such an authentication flow to phasetwo-containers? If yes, what do you think is missing?

If you open a separate PR for it, we can have a discussion there.

Thanks!

maaft commented 1 year ago

It shouldn't be a problem to add this to your docker image and then restart. Assuming you're using an external db, you shouldn't lose anything.

Weird - here is what I do: (I use postgres)

  1. docker compose down
  2. cd keycloak-orgs & modify code
  3. mvn package
  4. cd phasetwo-containers/libs
  5. mvn package
  6. cd .. && docker build --tag keycloak . -f Dockerfile
  7. docker compose up

keycloak then waits for postgres (which definitely still has the data from the previous run) and after a while outputs "detected change in extensions, reconfiguring" (or similar, can't remember currently). Afterwards, when logging in to the admin panel, keycloak looks like a fresh install.

Heres my docker compose file

version: '3.6'
services:
  postgres_kc:
    image: postgres:15
    restart: always
    ports:
      - 5431:5432
    networks:
      - dev
    volumes:
      - keycloak_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgrespassword
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
  keycloak:
    image: keycloak
    ports:
      - 8888:8080
    command:
     - start-dev
    depends_on:
      postgres_kc:
        condition: service_healthy
    networks:
      - dev
    environment:
      - KEYCLOAK_ADMIN=kcadmin
      - KEYCLOAK_ADMIN_PASSWORD=kcadmin
      - DB_VENDOR=postgres
      - DB_ADDR=postgres_kc
      - DB_PORT=5431
      - DB_DATABASE=postgres
      - DB_USER=postgres
      - DB_PW=postgrespassword
      - TZ=Europe/Berlin

volumes:
  db_data:

networks:
  dev:

If you open a separate PR for it, we can have a discussion there.

will do (not now, but in the next few weeks)

xgp commented 1 year ago

docker compose down removes everything, including volumes. Remove that step, and you should be good.

maaft commented 1 year ago

Only removes volumes with -v flag afaik

xgp commented 1 year ago

Yep, you're right. I'm not able to tell why you're losing your config.