Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public class TotpBean {
private final String totpSecret;
private final String totpSecretEncoded;
private final String totpSecretQrCode;
private final String totpSecretKeyUri;
private final boolean enabled;
private UriBuilder uriBuilder;
private final List<CredentialModel> otpCredentials;
Expand Down Expand Up @@ -73,6 +74,7 @@ public TotpBean(KeycloakSession session, RealmModel realm, UserModel user, UriBu
}
this.totpSecretEncoded = TotpUtils.encode(totpSecret);
this.totpSecretQrCode = TotpUtils.qrCode(session, totpSecret, realm, user);
this.totpSecretKeyUri = TotpUtils.keyUri(session, totpSecret, realm, user);
Comment on lines 75 to +77

OTPPolicy otpPolicy = realm.getOTPPolicy();
this.supportedApplications = session.getAllProviders(OTPApplicationProvider.class).stream()
Expand All @@ -97,6 +99,10 @@ public String getTotpSecretQrCode() {
return totpSecretQrCode;
}

public String getTotpSecretKeyUri() {
return totpSecretKeyUri;
}

public String getManualUrl() {
return uriBuilder.replaceQueryParam("session_code").replaceQueryParam("mode", "manual")
.replaceQueryParam("execution", UserModel.RequiredAction.CONFIGURE_TOTP.name()).build().toString();
Expand Down
21 changes: 14 additions & 7 deletions services/src/main/java/org/keycloak/utils/TotpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,25 @@ public static String qrCode(String totpSecret, RealmModel realm, UserModel user)
return qrCode(null, totpSecret, realm, user);
}

/**
* Builds the otpauth:// key URI. When a session is available, uses a locale-aware realm display name as issuer;
* otherwise uses the realm display name (or realm name if blank).
* This is the same URI that is encoded into the QR code, exposed directly so it can be offered as a deep link.
*/
public static String keyUri(KeycloakSession session, String totpSecret, RealmModel realm, UserModel user) {
if (session != null) {
String issuerName = getIssuerName(session, realm, user);
return realm.getOTPPolicy().getKeyURI(issuerName, user.getUsername(), totpSecret);
}
return realm.getOTPPolicy().getKeyURI(realm, user, totpSecret);
}

/**
* Generates a QR code using a locale-aware realm display name as issuer. Preferred when a session is available.
*/
public static String qrCode(KeycloakSession session, String totpSecret, RealmModel realm, UserModel user) {
try {
String keyUri;
if (session != null) {
String issuerName = getIssuerName(session, realm, user);
keyUri = realm.getOTPPolicy().getKeyURI(issuerName, user.getUsername(), totpSecret);
} else {
keyUri = realm.getOTPPolicy().getKeyURI(realm, user, totpSecret);
}
String keyUri = keyUri(session, totpSecret, realm, user);

int width = 246;
int height = 246;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.forms.login.freemarker.model;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Collections;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SubjectCredentialManager;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.HmacOTP;

import org.junit.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;

public class TotpBeanTest {

private static final String REALM_NAME = "myrealm";
private static final String USERNAME = "tester";
// 20 bytes -> exactly 32 Base32 chars, no padding, so the URI is deterministic
private static final String SECRET = "12345678901234567890";

@Test
public void keyUriIsExposedAndMatchesTheUriEncodedInTheQrCode() {

OTPPolicy policy = totpPolicy();
TotpBean bean = new TotpBean(session(), realm(policy, ""), user(), null, SECRET);

// The exposed key URI must be exactly the otpauth:// URI that TotpUtils encodes into the QR code,
// i.e. the canonical value produced by OTPPolicy#getKeyURI for the same issuer/account/secret.
String expected = policy.getKeyURI(REALM_NAME, USERNAME, SECRET);
assertThat(bean.getTotpSecretKeyUri(), is(expected));
}

@Test
public void keyUriContainsTypeIssuerAccountAndSecret() {

TotpBean bean = new TotpBean(session(), realm(totpPolicy(), ""), user(), null, SECRET);
String keyUri = bean.getTotpSecretKeyUri();

assertThat(keyUri, startsWith("otpauth://totp/"));
assertThat(keyUri, containsString(REALM_NAME + ":" + USERNAME));
assertThat(keyUri, containsString("issuer=" + REALM_NAME));
// Base32 of the secret, no padding for a 20-byte secret
assertThat(keyUri, containsString("secret=" + org.keycloak.models.utils.Base32.encode(SECRET.getBytes())));
}

@Test
public void keyUriIssuerFallsBackToRealmNameWhenDisplayNameIsBlank() {

TotpBean bean = new TotpBean(session(), realm(totpPolicy(), ""), user(), null, SECRET);

assertThat(bean.getTotpSecretKeyUri(), containsString("/" + REALM_NAME + ":"));
assertThat(bean.getTotpSecretKeyUri(), containsString("issuer=" + REALM_NAME));
}

private static OTPPolicy totpPolicy() {
OTPPolicy policy = new OTPPolicy();
policy.setAlgorithm(HmacOTP.HMAC_SHA1);
policy.setDigits(6);
policy.setType(OTPCredentialModel.TOTP);
policy.setPeriod(30);
return policy;
}

private static KeycloakSession session() {
return proxy(KeycloakSession.class, (proxy, method, args) -> {
if (method.getName().equals("getAllProviders")) {
return Collections.emptySet();
}
return null;
});
}

private static RealmModel realm(OTPPolicy policy, String displayName) {
return proxy(RealmModel.class, (proxy, method, args) -> {
switch (method.getName()) {
case "getOTPPolicy":
return policy;
case "getName":
return REALM_NAME;
case "getDisplayName":
return displayName;
default:
return null;
}
});
}

private static UserModel user() {
SubjectCredentialManager credentialManager = proxy(SubjectCredentialManager.class, (proxy, method, args) -> {
if (method.getName().equals("isConfiguredFor")) {
return Boolean.FALSE;
}
return null;
});

return proxy(UserModel.class, (proxy, method, args) -> {
switch (method.getName()) {
case "credentialManager":
return credentialManager;
case "getUsername":
return USERNAME;
default:
return null;
}
});
}

@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
return (T) Proxy.newProxyInstance(TotpBeanTest.class.getClassLoader(), new Class[]{type}, handler);
}
}
119 changes: 119 additions & 0 deletions services/src/test/java/org/keycloak/utils/TotpUtilsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2026 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.utils;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.utils.HmacOTP;

import org.junit.Test;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;

public class TotpUtilsTest {

private static final String REALM_NAME = "myrealm";
private static final String USERNAME = "tester";
// 20 bytes -> exactly 32 Base32 chars, no padding, so the URI is deterministic
private static final String SECRET = "12345678901234567890";

@Test
public void keyUriWithoutSessionUsesRealmDisplayNameAsIssuer() {

String keyUri = TotpUtils.keyUri(null, SECRET, realm("My Realm"), user());

assertThat(keyUri, startsWith("otpauth://totp/"));
// space is encoded as %20 in both label and issuer parameter
assertThat(keyUri, containsString("My%20Realm:" + USERNAME));
assertThat(keyUri, containsString("issuer=My%20Realm"));
}

@Test
public void keyUriWithoutSessionFallsBackToRealmNameWhenDisplayNameBlank() {

String keyUri = TotpUtils.keyUri(null, SECRET, realm(""), user());

assertThat(keyUri, containsString("/" + REALM_NAME + ":" + USERNAME));
assertThat(keyUri, containsString("issuer=" + REALM_NAME));
}

@Test
public void keyUriWithSessionFallsBackToRealmNameWhenDisplayNameBlank() {

// With a blank display name the issuer resolves to the realm name without touching the session,
// so the result must match the no-session path exactly.
String withSession = TotpUtils.keyUri(session(), SECRET, realm(""), user());
String withoutSession = TotpUtils.keyUri(null, SECRET, realm(""), user());

assertThat(withSession, is(withoutSession));
assertThat(withSession, containsString("issuer=" + REALM_NAME));
}

private static OTPPolicy totpPolicy() {
OTPPolicy policy = new OTPPolicy();
policy.setAlgorithm(HmacOTP.HMAC_SHA1);
policy.setDigits(6);
policy.setType(OTPCredentialModel.TOTP);
policy.setPeriod(30);
return policy;
}

private static KeycloakSession session() {
// A blank realm display name short-circuits issuer resolution, so no session method is invoked.
return proxy(KeycloakSession.class, (proxy, method, args) -> null);
}

private static RealmModel realm(String displayName) {
OTPPolicy policy = totpPolicy();
return proxy(RealmModel.class, (proxy, method, args) -> {
switch (method.getName()) {
case "getOTPPolicy":
return policy;
case "getName":
return REALM_NAME;
case "getDisplayName":
return displayName;
default:
return null;
}
});
}

private static UserModel user() {
return proxy(UserModel.class, (proxy, method, args) -> {
if (method.getName().equals("getUsername")) {
return USERNAME;
}
return null;
});
}

@SuppressWarnings("unchecked")
private static <T> T proxy(Class<T> type, InvocationHandler handler) {
return (T) Proxy.newProxyInstance(TotpUtilsTest.class.getClassLoader(), new Class[]{type}, handler);
}
}