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 @@ -34,6 +34,11 @@ public class UserSessionRepresentation {
private boolean rememberMe;
private Map<String, String> clients = new HashMap<>();
private boolean transientUser;
private String os;
private String osVersion;
private String browser;
private String device;
private boolean mobile;

public String getId() {
return id;
Expand Down Expand Up @@ -111,4 +116,44 @@ public boolean isTransientUser() {
public void setTransientUser(boolean transientUser) {
this.transientUser = transientUser;
}

public String getOs() {
return os;
}

public void setOs(String os) {
this.os = os;
}

public String getOsVersion() {
return osVersion;
}

public void setOsVersion(String osVersion) {
this.osVersion = osVersion;
}

public String getBrowser() {
return browser;
}

public void setBrowser(String browser) {
this.browser = browser;
}

public String getDevice() {
return device;
}

public void setDevice(String device) {
this.device = device;
}

public boolean isMobile() {
return mobile;
}

public void setMobile(boolean mobile) {
this.mobile = mobile;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ curl \
"http://localhost:8080{kc_realms_path}/master/protocol/openid-connect/token"
----

=== User session device information

The `GET /admin/realms/{realm}/users/{user-id}/sessions` response includes device information in each `UserSessionRepresentation` when it was recorded for the session. The following fields are derived from the user agent used to authenticate:

`os`:: Operating system family.
`osVersion`:: Operating system version.
`browser`:: Browser family and version.
`device`:: Device family.
`mobile`:: Whether the user agent represents a mobile device.

The string fields are omitted when no device information is attached to the session. In that case, `mobile` is `false`.

=== Additional resources
[role="_additional-resources"]
* {adminguide_link}[{adminguide_name}]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ export default interface UserSessionRepresentation {
userId?: string;
username?: string;
transientUser?: boolean;
os?: string;
osVersion?: string;
browser?: string;
device?: string;
mobile?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import org.keycloak.credential.CredentialMetadata;
import org.keycloak.credential.CredentialModel;
import org.keycloak.deployment.DeployedConfigurationsManager;
import org.keycloak.device.DeviceActivityManager;
import org.keycloak.events.Event;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.AuthDetails;
Expand Down Expand Up @@ -95,6 +96,7 @@
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.account.CredentialMetadataRepresentation;
import org.keycloak.representations.account.DeviceRepresentation;
import org.keycloak.representations.account.LocalizedMessage;
import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.AuthDetailsRepresentation;
Expand Down Expand Up @@ -851,6 +853,14 @@ public static UserSessionRepresentation toRepresentation(UserSessionModel sessio
rep.getClients().put(client.getId(), client.getClientId());
}
rep.setTransientUser(LightweightUserAdapter.isLightweightUser(session.getUser().getId()));
DeviceRepresentation device = DeviceActivityManager.getCurrentDevice(session);
if (device != null) {
rep.setOs(device.getOs());
rep.setOsVersion(device.getOsVersion());
rep.setBrowser(device.getBrowser());
rep.setDevice(device.getDevice());
rep.setMobile(device.isMobile());
}
return rep;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import java.util.List;

import jakarta.ws.rs.core.HttpHeaders;

import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
Expand Down Expand Up @@ -64,6 +66,8 @@ public class SessionTest {
// Dedicated public direct-access-grant client used only to authenticate the role-restricted admin
// clients. Kept separate from "test-app" so admin logins do not pollute the user-session lists under test.
private static final String ADMIN_AUTH_CLIENT_ID = "test-admin-client";
private static final String MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";

@InjectRealm(config = SessionTestRealmConfig.class)
ManagedRealm managedRealm;
Expand Down Expand Up @@ -105,9 +109,10 @@ public void testGetAppSessionCount() {
public void testGetUserSessions() {
ClientResource account = AdminApiUtil.findClientByClientId(managedRealm.admin(), "test-app");

oauth.openLoginForm();
loginPage.fillLogin(user.getUsername(), user.getPassword());
loginPage.submit();
AccessTokenResponse tokenResponse = oauth.passwordGrantRequest(user.getUsername(), user.getPassword())
.header(HttpHeaders.USER_AGENT, MOBILE_USER_AGENT)
.send();
assertEquals(200, tokenResponse.getStatusCode());

List<UserSessionRepresentation> sessions = account.getUserSessions(0, 5);
assertEquals(1, sessions.size());
Expand All @@ -124,6 +129,11 @@ public void testGetUserSessions() {
assertTrue(rep.getLastAccess() > 0);
assertTrue(rep.getStart() > 0);
assertFalse(rep.isRememberMe());
assertNotNull(rep.getOs());
assertNotNull(rep.getOsVersion());
assertNotNull(rep.getBrowser());
assertNotNull(rep.getDevice());
Comment thread
markstos marked this conversation as resolved.
assertTrue(rep.isMobile());

AccountHelper.logout(managedRealm.admin(), user.getUsername());
}
Expand Down
Loading