anarsultanov / keycloak-multi-tenancy

Keycloak extension for creating multi-tenant IAM for B2B SaaS applications.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Invitation link

oleaasbo opened this issue · comments

Hi. Great project! I would like to change the link sent to invited users. How is this done?

Right now it points to: ${keycloak_url}/auth/realms/${realm}/account
I need it to point to my application.

My app will redirect the user to login and/or show a message before login.
After login the invite confirmation is displayed to the user (if the user logged in with an account with the same email. I only use IDPs)

One additional feature request would be to use a action token instead of matching the email to validate if invite should be displayed to the user. Example:

  1. I invite user with email: user1@domain.com
  2. User receives email and navigate to invite page using invite link in email (Link to application and not keycloak but with action token in params)
  3. Application will redirect to keycloak login page where user can choose IDP. Step 3 can be skipped if user already is logged in.
  4. After login the user might have chosen to log in with an IDP with a different email then user1@domain.com. If keycloak somehow evaluates if the redirect URL contains an actiontoken (instead of evaluation against email) then the invite confirmation page can show.

Hi @oleaasbo,

Thank you for your feedback!

To customize the link in the invitation email, you have a few options here:

  1. You can follow the standard Keycloak email customization process by creating a custom theme . Within this theme, you can either customize the email template (invitation-email.ftl) or modify the email messages (invitationEmailBody and invitationEmailBodyHtml). This allows you to tailor the email content, including the link, to your specific needs.
  2. Alternatively, you can make changes in the extension source code by updating the same files, and then rebuild it.

Regarding your feature request, it's an interesting idea. However, please keep in mind that the extension doesn't have direct control over the application's behavior. Asking every user to provide their application URL for email customization and implementing a redirect back to Keycloak might not be the most user-friendly approach.

A potentially more elegant solution could be to introduce an endpoint within the extension to generate action tokens that allow users to join a tenant. Then, it would be up to individual applications to request and provide these tokens to invited users. While I can't promise immediate implementation, you're welcome to submit a pull request if you'd like to contribute to this feature's development.

If you have any more questions or suggestions, please don't hesitate to reach out.

I like option 1, however I am not able to get it to work. I get an error when email should be sent.

2023-09-13 23:01:47,091 ERROR [org.keycloak.services] (executor-thread-3) KC-SERVICES0029: Failed to send email: org.keycloak.email.EmailException: Failed to template email
2023-09-14T01:01:47.094530966+02:00 	at org.keycloak.email.freemarker.FreeMarkerEmailTemplateProvider.processTemplate(FreeMarkerEmailTemplateProvider.java:242)
2023-09-14T01:01:47.094542358+02:00 	at org.keycloak.email.freemarker.FreeMarkerEmailTemplateProvider.send(FreeMarkerEmailTemplateProvider.java:257)
	at org.keycloak.email.freemarker.FreeMarkerEmailTemplateProvider.send(FreeMarkerEmailTemplateProvider.java:252)
2023-09-14T01:01:47.094557368+02:00 	at dev.sultanov.keycloak.multitenancy.util.EmailUtil.sendEmail(EmailUtil.java:50)
2023-09-14T01:01:47.094564008+02:00 	at dev.sultanov.keycloak.multitenancy.util.EmailUtil.sendInvitationEmail(EmailUtil.java:27)
	at dev.sultanov.keycloak.multitenancy.resource.TenantInvitationsResource.createInvitation(TenantInvitationsResource.java:73)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
2023-09-14T01:01:47.094595511+02:00 	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
2023-09-14T01:01:47.094609565+02:00 	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:154)
2023-09-14T01:01:47.094615843+02:00 	at org.jboss.resteasy.core.MethodInjectorImpl.invoke(MethodInjectorImpl.java:118)
2023-09-14T01:01:47.094622140+02:00 	at org.jboss.resteasy.core.ResourceMethodInvoker.internalInvokeOnTarget(ResourceMethodInvoker.java:560)
2023-09-14T01:01:47.094628410+02:00 	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTargetAfterFilter(ResourceMethodInvoker.java:452)
2023-09-14T01:01:47.094634778+02:00 	at org.jboss.resteasy.core.ResourceMethodInvoker.lambda$invokeOnTarget$2(ResourceMethodInvoker.java:413)
2023-09-14T01:01:47.094641462+02:00 	at org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:321)
	at org.jboss.resteasy.core.ResourceMethodInvoker.invokeOnTarget(ResourceMethodInvoker.java:415)
	at org.jboss.resteasy.core.ResourceMethodInvoker.invoke(ResourceMethodInvoker.java:378)
2023-09-14T01:01:47.094674122+02:00 	at org.jboss.resteasy.core.ResourceLocatorInvoker.invokeOnTargetObject(ResourceLocatorInvoker.java:174)
	at org.jboss.resteasy.core.ResourceLocatorInvoker.invoke(ResourceLocatorInvoker.java:142)
2023-09-14T01:01:47.094686789+02:00 	at org.jboss.resteasy.core.ResourceLocatorInvoker.invokeOnTargetObject(ResourceLocatorInvoker.java:168)
	at org.jboss.resteasy.core.ResourceLocatorInvoker.invoke(ResourceLocatorInvoker.java:142)
2023-09-14T01:01:47.094699397+02:00 	at org.jboss.resteasy.core.ResourceLocatorInvoker.invokeOnTargetObject(ResourceLocatorInvoker.java:168)
	at org.jboss.resteasy.core.ResourceLocatorInvoker.invoke(ResourceLocatorInvoker.java:131)
2023-09-14T01:01:47.094712221+02:00 	at org.jboss.resteasy.core.ResourceLocatorInvoker.invoke(ResourceLocatorInvoker.java:33)
	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:429)
2023-09-14T01:01:47.094745726+02:00 	at org.jboss.resteasy.core.SynchronousDispatcher.lambda$invoke$4(SynchronousDispatcher.java:240)
2023-09-14T01:01:47.094751991+02:00 	at org.jboss.resteasy.core.SynchronousDispatcher.lambda$preprocess$0(SynchronousDispatcher.java:154)
	at org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:321)
	at org.jboss.resteasy.core.SynchronousDispatcher.preprocess(SynchronousDispatcher.java:157)
2023-09-14T01:01:47.094771284+02:00 	at org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:229)
2023-09-14T01:01:47.094777737+02:00 	at io.quarkus.resteasy.runtime.standalone.RequestDispatcher.service(RequestDispatcher.java:82)
2023-09-14T01:01:47.094783849+02:00 	at io.quarkus.resteasy.runtime.standalone.VertxRequestHandler.dispatch(VertxRequestHandler.java:147)
	at io.quarkus.resteasy.runtime.standalone.VertxRequestHandler.handle(VertxRequestHandler.java:84)
	at io.quarkus.resteasy.runtime.standalone.VertxRequestHandler.handle(VertxRequestHandler.java:44)
2023-09-14T01:01:47.094845151+02:00 	at io.vertx.ext.web.impl.RouteState.handleContext(RouteState.java:1284)
2023-09-14T01:01:47.094856938+02:00 	at io.vertx.ext.web.impl.RoutingContextImplBase.iterateNext(RoutingContextImplBase.java:177)
2023-09-14T01:01:47.094876798+02:00 	at io.vertx.ext.web.impl.RoutingContextWrapper.next(RoutingContextWrapper.java:200)
	at io.quarkus.vertx.http.runtime.options.HttpServerCommonHandlers$1.handle(HttpServerCommonHandlers.java:58)
	at io.quarkus.vertx.http.runtime.options.HttpServerCommonHandlers$1.handle(HttpServerCommonHandlers.java:36)
	at io.vertx.ext.web.impl.RouteState.handleContext(RouteState.java:1284)
	at io.vertx.ext.web.impl.RoutingContextImplBase.iterateNext(RoutingContextImplBase.java:177)
2023-09-14T01:01:47.094938496+02:00 	at io.vertx.ext.web.impl.RoutingContextWrapper.next(RoutingContextWrapper.java:200)
2023-09-14T01:01:47.094947009+02:00 	at org.keycloak.quarkus.runtime.integration.web.QuarkusRequestFilter.lambda$createBlockingHandler$0(QuarkusRequestFilter.java:82)
2023-09-14T01:01:47.094954007+02:00 	at io.quarkus.vertx.core.runtime.VertxCoreRecorder$14.runWith(VertxCoreRecorder.java:576)
	at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
	at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:29)
	at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:29)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.UnsupportedOperationException
	at dev.sultanov.keycloak.multitenancy.util.EmailUtil$Recipient.getFirstAttribute(EmailUtil.java:128)
2023-09-14T01:01:47.095026494+02:00 	at org.keycloak.locale.DefaultLocaleSelectorProvider.getUserProfileSelection(DefaultLocaleSelectorProvider.java:108)
	at org.keycloak.locale.DefaultLocaleSelectorProvider.getUserLocale(DefaultLocaleSelectorProvider.java:71)
2023-09-14T01:01:47.095050287+02:00 	at org.keycloak.locale.DefaultLocaleSelectorProvider.resolveLocale(DefaultLocaleSelectorProvider.java:50)
	at org.keycloak.services.DefaultKeycloakContext.resolveLocale(DefaultKeycloakContext.java:133)
	at org.keycloak.email.freemarker.FreeMarkerEmailTemplateProvider.processTemplate(FreeMarkerEmailTemplateProvider.java:212)
2023-09-14T01:01:47.095070825+02:00 	... 50 more
2023-09-14T01:01:47.095077050+02:00 

I have created a custom theme and selected it under realm settings -> themes -> Email themes
/opt/keycloak/themes/tenant-invites/email/theme.properties

parent=base
import=common/keycloak

/opt/keycloak/themes/tenant-invites/email/messages/messages_en.properties

reviewInvitationsHeader=Review invitations
reviewInvitationsInfo=You have been invited to join the following tenants. Uncheck the box to decline the invitation.
selectTenantHeader=Select tenant
selectTenantInfo=Select the tenant you would like to log in to.
createTenantHeader=Create tenant
createTenantInfo=You need to create a tenant.
tenantName=Tenant name
tenantEmptyError=Please specify value.
tenantExistsError=Tenant already exists.
invitationEmailSubject=Invitation to {0}
invitationEmailBody=You have been invited to join {0}. To accept or decline this invitation, log in to your account or register using the link below.\n\n{1}
invitationEmailBodyHtml=<p>You have been invited to join {0}. To accept or decline this invitation, log in to your account or register using the link below.</p><p><a href="{1}">Link to account page</a></p>
invitationAcceptedEmailSubject=Your invitation has been accepted
invitationAcceptedEmailBody=Your invitation for {0} to join {1} has been accepted.
invitationAcceptedEmailBodyHtml=<p>Your invitation for {0} to join {1} has been accepted.</p>
invitationDeclinedEmailSubject=Your invitation has been declined
invitationDeclinedEmailBody=Your invitation for {0} to join {1} has been declined.
invitationDeclinedEmailBodyHtml=<p>Your invitation for {0} to join {1} has been declined.</p>

The messages are identical to the original (copied from keycloak-multi-tenancy/resources/theme-resources/messages/messages_en.properties)

What do i do wrong?
Thanks for the help!

I'm sorry, but I'm not entirely sure what might be causing the issue in your specific case.

I attempted to create a Docker image with the extension and custom theme, and after configuring it in Realm, it was able to successfully use my custom email messages. Below is the Dockerfile I used for this, assuming the extension is located in the providers directory and the custom theme is in themes:

FROM quay.io/keycloak/keycloak:22.0.1 as builder
COPY /providers/ /opt/keycloak/providers/

RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:22.0.1
COPY --from=builder /opt/keycloak/ /opt/keycloak/
COPY /themes/ /opt/keycloak/themes/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

It's possible that it worked for me because I separated the build step. You might want to consider doing the same and building an optimized image if you haven't already.

I did not separate the build stage like you did here but tried it with no different outcome.

FROM quay.io/keycloak/keycloak:latest as builder
ENV KC_CACHE=ispn
ENV KC_CACHE_STACK=kubernetes
ENV KC_TRANSACTION_XA_ENABLED=true
# Enable health and metrics support
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true

ENV KC_HTTP_RELATIVE_PATH=/auth/
ENV KC_FEATURES=docker,account-api,admin-api,authorization,client-policies

# Configure a database vendor
ENV KC_DB=postgres

# Organization plugin
COPY ./keycloak-multi-tenancy/target/keycloak-multi-tenancy-22.0.0.jar /opt/keycloak/providers
# Apple IdP plugin
ADD --chown=keycloak:keycloak https://github.com/klausbetz/apple-identity-provider-keycloak/releases/download/1.7.0/apple-identity-provider-1.7.0.jar /opt/keycloak/providers/apple-identity-provider-1.7.0.jar
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:latest
COPY --from=builder /opt/keycloak/ /opt/keycloak/
# Copy all themes
COPY --chown=keycloak:root ./themes /opt/keycloak/themes

ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "--spi-theme-static-max-age=-1", "--spi-theme-cache-themes=false", "--spi-theme-cache-templates=false"]

No matter what email theme i select i get the errors from earlier. Does not seem like i am able to deselect themes now after selecting one.
When you tested with a custom theme did you use the two files as described in my previous post?

Yes, I added exactly two files as you described.
I'm guessing something else in your configuration is causing this issue. Could you try enabling debug logging and see if you can get more information?

The logging did not provide additional information about this error. However the exception is thrown from:
keycloak-multi-tenancy/src/main/java/dev/sultanov/keycloak/multitenacy/util/EmailUtil.java

Caused by: java.lang.UnsupportedOperationException
at dev.sultanov.keycloak.multitenancy.util.EmailUtil$Recipient.getFirstAttribute(EmailUtil.java:128)

@Override
public String getFirstAttribute(String name) {
        throw new UnsupportedOperationException();
}

This is included in the error log from previous comment and did also appear now with KC_LOG_LEVEL="fatal,error,warn,info,debug"

The issue appears to be linked to internalization. After turning it on, I was able to reproduce the problem.

I'll dig deeper into this when I have some free time and your input could be valuable in finding a solution.
Since usually we invite a non-existent user, we can't determine their locale, therefore, one option could be using the default realm locale for invitations. Alternatively, we might think about using the sender's locale and/or improving the API to include locale as an option. What do you think about these solutions for your situation?

After disabling Internationalization we are back in business! Thank you so much for your help! Much appreciated.
I would love to see a combination of using the default realm locale if no locale is provided during the creation of the invite (Improving the API)

Going back to the feature request regarding action token invite link, I prefer you "elegant solution" of extending the API to allow me to send emails from my application. In this case the locale no longer relies on Keycloak settings and invites can easily be sent over other transports like SMS and social media, etc.

Should i open a new discussion about this feature or do you consider it relevant within this topic/discussion?

I am glad to help!
I would prefer that you create a separate feature request for the invitation token as I want to improve the handling of internationalization within this issue and close it.

In the new version with the latest changes, internationalization should work. I also added the ability to specify the locale of invitations.
@oleaasbo please check and if everything works for you, I will close the issue.

Thank you @anarsultanov!
Can confirm that it works! My test only included turning internalization on again.
Keycloak-multi-tenancy v22.1.0
Keycloak v22.0.3