Closed jaakkom closed 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.
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.
@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.
@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.
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.
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)
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:
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:
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!
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)
mvn package
mvn package
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)
docker compose down
removes everything, including volumes. Remove that step, and you should be good.
Only removes volumes with -v
flag afaik
Yep, you're right. I'm not able to tell why you're losing your config.
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: