anarsultanov / keycloak-multi-tenancy

Keycloak extension for creating multi-tenant IAM for B2B SaaS applications.
Apache License 2.0
103 stars 11 forks source link

Enhancement Request: Use of Action Token for Invitation Evaluation Triggers #12

Closed oleaasbo closed 4 months ago

oleaasbo commented 6 months ago

Issue Description

Encountered a ModelDuplicateException when issuing a tenant invitation due to non-unique email addresses because the setting "Duplicate Emails" is enabled. Error message received: Caused by: org.keycloak.models.ModelDuplicateException: Multiple users with email 'example@mail.com' exist in Keycloak.

Suggested Enhancement

Replace the reliance on unique email addresses for user identification with subject identifiers (sub token claim). This would resolve issues arising from shared email addresses and align better with OIDC standards. Additionally, suggest incorporating actionTokens to enable invite distribution through various transports, including SMS.

Rationale

Reliability: Subject identifiers provide a more reliable and unique method for user identification. Flexibility: Accommodates scenarios where multiple users share the same email address and account linking disabled/enabled. Transport Flexibility: ActionTokens enable sending invites via alternative methods, such as SMS, broadening the scope of user outreach. OIDC Alignment: Better conforms to OIDC practices by utilizing standard token claims for identification.

I appreciate your feedback on this suggestion, particularly regarding the feasibility and implications of integrating actionTokens in this context. This should allow OIDC logins where email is not registered

anarsultanov commented 6 months ago

Hi @oleaasbo

Thank you for your suggestion. After careful consideration, I have decided not to implement these changes. The current invitation system is designed to work for users who may not yet have an account and a unique identifier in the system, and I prefer to maintain it as it is.

Regarding the integration of action tokens, I also believe that their necessity in this context is not clear. The existing system allows for inviting a user, who can then address the invitation upon their next login. If the concern is about the email notification for invitations, a possible improvement could be to make email notifications optional. This way, invitations can be created without sending an email, and you can notify the user through a different method that suits your needs better. I'd be interested in hearing your thoughts on this approach.

oleaasbo commented 6 months ago

Thank you for your response and for considering my suggestion.

I realize my initial explanation might not have been completely clear regarding the use of the subject identifier (SUB claim). To clarify, I understand that the SUB claim isn't known before a user logs in, and therefore it cannot be referenced during the invitation creation process. My suggestion was to utilize the SUB claim when establishing the membership, post user login and invitation acceptance. This would be applicable in cases where the user, prior to being invited, does not already exist in the user table.

Regarding the reliance on the email claim, this suggestion was aimed at better alignment with OIDC providers that may not store user email, or in scenarios where users opt not to share their email. This is increasingly relevant given the variety of OIDC providers and the diverse preferences of users regarding privacy and data sharing.

As for Action Tokens, I mentioned them as a potential solution to support the existing invitation mechanism, particularly in cases where user email isn't available from the OIDC provider during the ReviewTenantInvitation.java - evaluationTrigger process. An Action Token could serve as an alternative mechanism to trigger the invitation challenge in the absence of an email claim.

I hope this further explanation clarifies the intent and rationale behind my suggestions

oleaasbo commented 6 months ago

Upon further review, especially of the grantMembership function, I've realized that there is no reliance on email for membership creation, only on userId. This means my earlier concern regarding the use of the SUB claim may have been misplaced, and I appreciate your patience as I navigated these details.

However, the issue of triggering the invitation challenge when an email is not provided by the OIDC provider still remains.

anarsultanov commented 6 months ago

Regarding your suggestions, I would like to point out that using anything rather than verified email addresses for invitations may lead to security risk. I'm also not entirely sure if it's possible to create action tokens for users who don't have an account, and since non-account users are typically invited, supporting this use case in the extension seems impractical.

However, have you thought about whether the invitation process is essential for your scenario? Maybe a simpler method, like directly adding users to a tenant, would work better. This could bypass the need for invitations, especially if emails aren't used. Would this suit your project?

If not, another option might be to implement a custom required action, that would be triggered when a user has a specific attribute (instead of invitation). This would allow you to add this attribute to users and notify them about it in a way that fits your needs. But this approach may be beyond the scope of this extension, and I would prefer not to introduce it.

oleaasbo commented 6 months ago

Based on a generative code analysis, it seems possible to use action tokens in Keycloak to present users with the option to accept or decline a membership. This approach could enable sharing invitations through various channels like email, SMS, or any text based communication.

While I have limited experience in developing Keycloak extensions, the preliminary analysis indicates the potential for this feature. Create an action token, include it in a URL pointing to Keycloak, and upon navigation, present a webpage (hosted by Keycloak) that asks the user to accept or decline a membership. The membership will be granted to the authenticated userId

InvitationActionToken.java

import org.keycloak.authentication.actiontoken.DefaultActionToken;

public class InvitationActionToken extends DefaultActionToken {
    // Additional properties for membership action
    private String tenantId;
    private String createdByUserId;
    private String[] roles;

    public InvitationActionToken(String actionTokenId, int absoluteExpirationInSecs, String tenantId, String createdByUserId, String[] roles) {
        super(actionTokenId, "INVITE", absoluteExpirationInSecs, null, null);
        this.tenantId = tenantId;
        this.createdByUserId = createdByUserId;
        this.roles = roles;
        // Initialize any additional properties if necessary
    }

    public String serialize() {
        return serializedToken;
    }

    // Add getters and setters for properties 
}

InvitationActionTokenHandler.java

import org.keycloak.authentication.actiontoken.ActionTokenHandler;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.sessions.AuthenticationSessionModel;

public class InvitationActionTokenHandler implements ActionTokenHandler<InvitationActionToken> {

    private KeycloakSession session;

    public InvitationActionTokenHandler(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public InvitationActionToken createFromActionToken(String tokenString, AuthenticationSessionModel authenticationSession, UserModel user, RealmModel realm) {
        // Deserialize token
    }

    @Override
    public boolean canUseTokenRepeatedly(InvitationActionToken token, AuthenticationSessionModel authenticationSession) {
        // Define token usage policy
    }

    @Override
    public void executeAction(InvitationActionToken token, AuthenticationSessionModel authenticationSession, UserModel user, RealmModel realm) {
        // Define the action to be executed when the token is processed
    }

    // Other necessary overridden methods
}

MembershipActionResource.java

import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;

@Path("/membership-action")
public class MembershipActionResource {

    @GET
    public Response handleToken(@QueryParam("token") String tokenString) {
        // Deserialize and verify the token
        // Show the custom page or process the response
    }
}

Custom Page Template (HTML/FTL)

<!-- custom-page.ftl -->
<html>
<head>
    <title>Membership Action</title>
    <!-- Include necessary CSS and JS -->
</head>
<body>
    <h1>Membership Confirmation</h1>
    <p>Please confirm your membership.</p>
    <!-- Form or buttons to accept/decline -->
</body>
</html>

TenantInvitationsResource.java

package dev.sultanov.keycloak.multitenancy.resource;

// ... (existing imports)
import org.keycloak.models.utils.KeycloakModelUtils;

// Add imports for token handling
import dev.sultanov.keycloak.multitenancy.path.tokens.InvitationActionToken;

public class TenantInvitationsResource extends AbstractAdminResource<TenantAdminAuth> {

    private final TenantModel tenant;

    public TenantInvitationsResource(RealmModel realm, TenantModel tenant) {
        super(realm);
        this.tenant = tenant;
    }

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    @Operation(operationId = "createInvitation", summary = "Create invitation")
    @APIResponse(responseCode = "201", description = "Created")
    public Response createAlternativeInvitation(@RequestBody(required = true) TenantInvitationRepresentation request) {
        try {
            // Create a custom action token for the invitation
            String actionTokenId = KeycloakModelUtils.generateId();
            InvitationActionToken actionToken = new InvitationActionToken(actionTokenId, 60 * 60 * 24, tenant.getId(), auth.getUser(), request.getRoles());

            // Serialize the token
            String serializedToken = actionToken.serialize();

            // Construct the URL with the token
            URI tokenUri = URI.create(KeycloakModelUtils.getOrigin(session.getContext().getUri()) + "/auth/realms/"
                    + realm.getName() + "/membership-action?token=" + serializedToken);

            return Response.created(tokenUri).build();
        } catch (Exception e) {
            throw new InternalServerErrorException(e);
        }
    }

    // ... (existing endpoints for invitations)
}
anarsultanov commented 6 months ago

I never said it was impossible, although I wouldn't use generated code for this :)

In my earlier comment, I highlighted that action tokens in Keycloak are primarily generated for users who already have accounts. They are linked to these existing accounts, enabling various actions like password resets or account confirmations. When it comes to inviting new users - those who do not yet have an account - this method will not work for them, while most of the invitations are aimed at such users.

Therefore, in my opinion, using action tokens for invitations is not a viable solution (in the context of this extension) since they need to support both users with and without accounts.

oleaasbo commented 6 months ago

I appreciate your insights on the use of action tokens for existing accounts. However, according to my research, it's not necessary to specify a user when creating an action token. Action tokens can be used for unauthenticated actions as well.

My understanding is that we can design the system to prompt the user for authentication upon navigating to the invite link. This authentication can include the user's first-ever login, which would then add the user to Keycloak. After logging in, the user would be presented with the option to accept or decline the invite. If they accept, the currently logged-in user would be granted membership to the tenant.

This flow should work:

  1. Token Creation: When creating the action token, you don't need to specify a user ID. Your token can be more generic, carrying only the information necessary to identify the action to be performed.

  2. User Login Requirement: The endpoint handling the action token (/membership-action) should enforce user authentication. If a user accesses the URL with the token and is not logged in, Keycloak's default behavior would redirect them to the login page.

  3. Post-login Token Processing: After the user logs in, the same endpoint (/membership-action) will be revisited. This time, however, the user will be authenticated, and you can access their user context. You can then perform the necessary logic using the authenticated user's information.

Therefore, in my opinion, using action tokens for invitations is viable!

anarsultanov commented 6 months ago

The approach you're suggesting is interesting, but it deviates from the standard usage of action tokens in Keycloak. Action tokens are processed by a specific endpoint, which then delegates to a token handler and do not require creation of a new endpoint. For a clearer understanding, I recommend reviewing documentation on Action Token Handler SPI.

While your proposed method might be viable, it's important to note that it would involve creating a new type of token with a completely custom flow, which is not the same as using the existing action token mechanism.

oleaasbo commented 6 months ago

If you find value in this feature, I am willing to implementing a proof of concept. Please let me know if this aligns with your vision for the extension, and I'll see if I can allocate some time to develop it.

anarsultanov commented 6 months ago

This is a great idea! If you can develop a proof of concept, I'd be happy to further refine it, as my schedule doesn't allow me to build this feature from the ground up in the coming weeks. However, I can suggest a potential API structure:

  1. POST /tenants/{tenantId}/invitations/tokens - To create an invitation token.
  2. GET /tenants/{tenantId}/invitations/tokens - To list all invitation tokens.
  3. DELETE /tenants/{tenantId}/invitations/tokens/{tokenId} - To revoke an invitation token.

Given that the /tenants/{tenantId}/* endpoints are limited to tenant admins, we'll need additional endpoints for handling invitation tokens. These could be within the tenants resource or a separate one. For instance:

  1. GET /tenants/invitation-tokens/{tokenId} - To display the invitation token form.
  2. POST /tenants/invitation-tokens/{tokenId} - To process the invitation token action.

Or even just tokens instead of invitation-tokens in case we need to add another type of token later.

Let me know your thoughts on this structure!

github-actions[bot] commented 4 months ago

This issue is stale because it has been open for 30 days with no activity. If this issue still applies please comment otherwise it will be closed in 7 days.

github-actions[bot] commented 4 months ago

This issue was closed because it has been inactive for 7 days since being marked as stale.