Skip to content

Commit

Permalink
Magic link extension (#68)
Browse files Browse the repository at this point in the history
* Magic link extension

* updated the handler overriding the startFreshAuthenticationSession method

Signed-off-by: Garth <[email protected]>

* Create logic for magic link continuation

* Authentication email improvements

* Fix code review comments

---------

Signed-off-by: Garth <[email protected]>
Co-authored-by: razvantufisi <[email protected]>
  • Loading branch information
xgp and rtufisi authored Feb 16, 2024
1 parent 0a5d63a commit 2d9cc0f
Show file tree
Hide file tree
Showing 19 changed files with 712 additions and 46 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# keycloak-magic-link

Magic link implementation. Inspired by the [experiment](https://github.com/stianst/keycloak-experimental/tree/main/magic-link) by [@stianst](https://github.com/stianst).
It comes in two types: magic link and magic link continuation;

There is also a simple Email OTP authenticator implementation here.
This extension is used in the [Phase Two](https://phasetwo.io) cloud offering, and is released here as part of its commitment to making its [core extensions](https://phasetwo.io/docs/introduction/open-source) open source. Please consult the [license](COPYING) for information regarding use.
Expand All @@ -27,6 +28,24 @@ The authenticator can be configured to create a user with the given email addres

![Configure Magic Link Authenticator with options](docs/assets/magic-link-config.png)

## Magic link continuation

This Magic link continuation authenticator is similar to the Magic Link authenticator in implementation, but has a different behaviour. Instead of creating a session on the device where the link is clicked, the flow continues the login on the initial login page. The login page is polling the authentication page each 5 seconds until the session is confirmed or the authentication flow expires. The default expiration for the Magic link continuation flow is 10 minutes.


### Authenticator

![Install Magic Link continuation Authenticator in Browser Flow](docs/assets/magic-link-continuation-authenticator.png)

The authenticator can be configured to set the expiration of the authentication flow.

![Configure Magic Link continuation Authenticator with options](docs/assets/magic-link-continuation-config.png)

When the period is exceeded the authentication flow will reset.

![Magic Link continuation expired](docs/assets/magic-link-continuation-expiration.png)


### Resource

A Resource you can call with `manage-users` role, which allows you to specify the email, clientId, redirectUri, tokenExpiry and optionally if the email is sent, or the link is just returned to the caller.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/magic-link-continuation-config.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 94 additions & 3 deletions src/main/java/io/phasetwo/keycloak/magic/MagicLink.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package io.phasetwo.keycloak.magic;

import static org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import io.phasetwo.keycloak.magic.auth.MagicLinkAuthenticatorFactory;
import io.phasetwo.keycloak.magic.auth.token.MagicLinkActionToken;
import io.phasetwo.keycloak.magic.auth.token.MagicLinkContinuationActionToken;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
Expand All @@ -14,7 +20,9 @@
import java.util.stream.Collectors;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.actiontoken.DefaultActionToken;
import org.keycloak.common.util.Time;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailTemplateProvider;
Expand Down Expand Up @@ -104,6 +112,26 @@ public static MagicLinkActionToken createActionToken(
return createActionToken(user, clientId, validity, rememberMe, authSession, true);
}

public static MagicLinkContinuationActionToken createExpandedActionToken(
UserModel user, String clientId, int validityInSecs, AuthenticationSessionModel authSession) {
log.infof(
"Attempting MagicLinkContinuationAuthenticator for %s, %s, %s, %s",
user.getEmail(), clientId, authSession.getParentSession().getId(), authSession.getTabId());

String nonce = authSession.getClientNote(OIDCLoginProtocol.NONCE_PARAM);
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
MagicLinkContinuationActionToken token =
new MagicLinkContinuationActionToken(
user.getId(),
absoluteExpirationInSecs,
clientId,
nonce,
authSession.getParentSession().getId(),
authSession.getTabId(),
authSession.getRedirectUri());
return token;
}

public static MagicLinkActionToken createActionToken(
UserModel user,
String clientId,
Expand Down Expand Up @@ -176,7 +204,7 @@ public static MagicLinkActionToken createActionToken(
}

public static String linkFromActionToken(
KeycloakSession session, RealmModel realm, MagicLinkActionToken token) {
KeycloakSession session, RealmModel realm, DefaultActionToken token) {
UriInfo uriInfo = session.getContext().getUri();

// This is a workaround for situations where the realm you are using to call this (e.g. master)
Expand Down Expand Up @@ -241,6 +269,33 @@ public static boolean sendMagicLinkEmail(KeycloakSession session, UserModel user
return false;
}

public static boolean sendMagicLinkContinuationEmail(
KeycloakSession session, UserModel user, String link) {
RealmModel realm = session.getContext().getRealm();
try {
EmailTemplateProvider emailTemplateProvider =
session.getProvider(EmailTemplateProvider.class);
String realmName = getRealmName(realm);
List<Object> subjAttr = ImmutableList.of(realmName);
Map<String, Object> bodyAttr = Maps.newHashMap();
bodyAttr.put("realmName", realmName);
bodyAttr.put("magicLink", link);
emailTemplateProvider
.setRealm(realm)
.setUser(user)
.setAttribute("realmName", realmName)
.send(
"magicLinkContinuationSubject",
subjAttr,
"magic-link-continuation-email.ftl",
bodyAttr);
return true;
} catch (EmailException e) {
log.error("Failed to send magic link continuation email", e);
}
return false;
}

public static boolean sendOtpEmail(KeycloakSession session, UserModel user, String code) {
RealmModel realm = session.getContext().getRealm();
try {
Expand Down Expand Up @@ -272,8 +327,7 @@ public static String getRealmName(RealmModel realm) {
public static final String IDP_REDIRECTOR_PROVIDER_ID =
org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory
.PROVIDER_ID;
public static final String MAGIC_LINK_PROVIDER_ID =
io.phasetwo.keycloak.magic.auth.MagicLinkAuthenticatorFactory.PROVIDER_ID;
public static final String MAGIC_LINK_PROVIDER_ID = MagicLinkAuthenticatorFactory.PROVIDER_ID;

public static void realmPostCreate(
KeycloakSessionFactory factory, RealmModel.RealmPostCreateEvent event) {
Expand Down Expand Up @@ -384,4 +438,41 @@ private static void addExecutionToFlow(
execution = realm.addAuthenticatorExecution(execution);
}
}

public static boolean isValidEmail(String email) {
try {
InternetAddress a = new InternetAddress(email);
a.validate();
return true;
} catch (AddressException e) {
return false;
}
}

public static String getAttemptedUsername(AuthenticationFlowContext context) {
if (context.getUser() != null && context.getUser().getEmail() != null) {
return context.getUser().getEmail();
}
String username =
trimToNull(context.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME));
if (username != null) {
if (MagicLink.isValidEmail(username)) {
return username;
}
UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username);
if (user != null && user.getEmail() != null) {
return user.getEmail();
}
}
return null;
}

public static String trimToNull(final String s) {
if (s == null) {
return null;
}
String trimmed = s.trim();
if ("".equalsIgnoreCase(trimmed)) trimmed = null;
return trimmed;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

import io.phasetwo.keycloak.magic.MagicLink;
import io.phasetwo.keycloak.magic.auth.token.MagicLinkActionToken;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.util.Map;
Expand Down Expand Up @@ -36,7 +34,7 @@ public class MagicLinkAuthenticator extends UsernamePasswordForm {
@Override
public void authenticate(AuthenticationFlowContext context) {
log.debug("MagicLinkAuthenticator.authenticate");
String attemptedUsername = getAttemptedUsername(context);
String attemptedUsername = MagicLink.getAttemptedUsername(context);
if (attemptedUsername == null) {
super.authenticate(context);
} else {
Expand All @@ -53,11 +51,11 @@ public void action(AuthenticationFlowContext context) {

MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();

String email = trimToNull(formData.getFirst(AuthenticationManager.FORM_USERNAME));
String email = MagicLink.trimToNull(formData.getFirst(AuthenticationManager.FORM_USERNAME));
// check for empty email
if (email == null) {
// - first check for email from previous authenticator
email = getAttemptedUsername(context);
email = MagicLink.getAttemptedUsername(context);
}
log.debugf("email in action is %s", email);
// - throw error if still empty
Expand All @@ -83,7 +81,9 @@ public void action(AuthenticationFlowContext context) {
MagicLink.registerEvent(event));

// check for no/invalid email address
if (user == null || trimToNull(user.getEmail()) == null || !isValidEmail(user.getEmail())) {
if (user == null
|| MagicLink.trimToNull(user.getEmail()) == null
|| !MagicLink.isValidEmail(user.getEmail())) {
context.getEvent().event(EventType.LOGIN_ERROR).error(Errors.INVALID_EMAIL);
Response challengeResponse =
challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME);
Expand Down Expand Up @@ -153,43 +153,6 @@ private boolean is(AuthenticationFlowContext context, String propName, boolean d
return v.trim().toLowerCase().equals("true");
}

private static boolean isValidEmail(String email) {
try {
InternetAddress a = new InternetAddress(email);
a.validate();
return true;
} catch (AddressException e) {
return false;
}
}

private String getAttemptedUsername(AuthenticationFlowContext context) {
if (context.getUser() != null && context.getUser().getEmail() != null) {
return context.getUser().getEmail();
}
String username =
trimToNull(context.getAuthenticationSession().getAuthNote(ATTEMPTED_USERNAME));
if (username != null) {
if (isValidEmail(username)) {
return username;
}
UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username);
if (user != null && user.getEmail() != null) {
return user.getEmail();
}
}
return null;
}

private static String trimToNull(final String s) {
if (s == null) {
return null;
}
String trimmed = s.trim();
if ("".equalsIgnoreCase(trimmed)) trimmed = null;
return trimmed;
}

@Override
protected boolean validateForm(
AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
Expand Down
Loading

0 comments on commit 2d9cc0f

Please sign in to comment.