Skip to content

Commit 220531d

Browse files
fix: rename credentialId to better show which one it is (keycloak, device) (#92)
Signed-off-by: Dominik Schlosser <dominik.schlosser@gmail.com>
1 parent d12b319 commit 220531d

39 files changed

+194
-140
lines changed

docs/flow-details.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
This document provides detailed technical information about each phase of the enrollment and login flows.
44

5+
## Credential ID Concepts
6+
7+
Each Push MFA credential has **two distinct IDs**:
8+
9+
| ID | Where | Purpose |
10+
|----|-------|---------|
11+
| **Device credential ID** (`deviceCredentialId`) | Set by the device during enrollment; stored in `PushCredentialData`; carried in tokens as `credId`, events as `push_mfa_credential_id`, and push notifications | The mobile app uses this to match incoming pushes to its local credential store. Appears in all external-facing protocols. |
12+
| **Keycloak credential ID** (`keycloakCredentialId`) | UUID assigned by Keycloak when the `CredentialModel` is persisted; stored on `PushChallenge` | Used internally by the server to load the correct `CredentialModel` from storage. The app never sees this value. |
13+
14+
**Why challenges only store the Keycloak credential ID:** The server picks which credential to use for a challenge and records its Keycloak UUID so it can reload the `CredentialModel` later. The device credential ID is redundant on the challenge because:
15+
- The app already knows its own device credential ID (it chose it during enrollment).
16+
- The app receives the device credential ID via the `credId` claim in the confirm token.
17+
- The server accesses the device credential ID through `PushCredentialData` after loading the `CredentialModel` by its Keycloak UUID.
18+
519
## Enrollment Flow
620

721
1. **Enrollment challenge (RequiredAction):** Keycloak renders a QR code that encodes the realm-signed `enrollmentToken` (the default theme emits `my-secure://enroll?token=<enrollmentToken>`, but you can change the URI scheme/payload in your own theme or override the server-side prefix via `--spi-required-action-push-mfa-register-app-uri-prefix=...`). The token is a JWT signed with the realm key and contains user id (`sub`), username, `enrollmentId`, and a Base64URL nonce.

docs/spi-reference.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ public final class MyPushSender implements PushNotificationSender {
1313
RealmModel realm,
1414
UserModel user,
1515
String confirmToken,
16-
String credentialId,
16+
String deviceCredentialId,
1717
String challengeId,
1818
String pushProviderId,
1919
String clientId) {
2020
// Serialize confirmToken into the mobile push message and deliver it via FCM/APNs/etc.
21+
// deviceCredentialId is the device-chosen credential ID (not the Keycloak-internal UUID).
2122
}
2223
}
2324
```
@@ -57,10 +58,10 @@ The extension provides an event SPI that allows you to react to push MFA lifecyc
5758
| `ChallengeAcceptedEvent` | User approved the challenge on their device | `challengeId`, `challengeType`, `userId`, `deviceId` |
5859
| `ChallengeDeniedEvent` | User denied the challenge on their device | `challengeId`, `challengeType`, `userId`, `deviceId` |
5960
| `ChallengeResponseInvalidEvent` | Response validation failed (bad signature, wrong PIN, etc.) | `challengeId`, `userId`, `reason` |
60-
| `EnrollmentCompletedEvent` | Device enrollment finished successfully | `challengeId`, `userId`, `credentialId`, `deviceId`, `deviceType` |
61-
| `KeyRotatedEvent` | Device key was successfully rotated | `userId`, `credentialId`, `deviceId` |
62-
| `KeyRotationDeniedEvent` | Key rotation request failed validation | `userId`, `credentialId`, `reason` |
63-
| `DpopAuthenticationFailedEvent` | DPoP authentication failed for device API request | `userId`, `credentialId`, `reason`, `httpMethod`, `requestPath` |
61+
| `EnrollmentCompletedEvent` | Device enrollment finished successfully | `challengeId`, `userId`, `deviceCredentialId`, `deviceId`, `deviceType` |
62+
| `KeyRotatedEvent` | Device key was successfully rotated | `userId`, `deviceCredentialId`, `deviceId` |
63+
| `KeyRotationDeniedEvent` | Key rotation request failed validation | `userId`, `deviceCredentialId`, `reason` |
64+
| `DpopAuthenticationFailedEvent` | DPoP authentication failed for device API request | `userId`, `deviceCredentialId`, `reason`, `httpMethod`, `requestPath` |
6465

6566
All events include `realmId`, `userId` (may be null for early auth failures), and `timestamp`.
6667

@@ -134,7 +135,7 @@ public void onEvent(Event event) {
134135
| `EVENT_TYPE` | `push_mfa_event_type` |
135136
| `CHALLENGE_ID` | `push_mfa_challenge_id` |
136137
| `CHALLENGE_TYPE` | `push_mfa_challenge_type` |
137-
| `CREDENTIAL_ID` | `push_mfa_credential_id` |
138+
| `DEVICE_CREDENTIAL_ID` | `push_mfa_credential_id` |
138139
| `DEVICE_ID` | `push_mfa_device_id` |
139140
| `DEVICE_TYPE` | `push_mfa_device_type` |
140141
| `USER_VERIFICATION` | `push_mfa_user_verification` |

src/main/java/de/arbeitsagentur/keycloak/push/auth/ChallengeIssuer.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ protected void fireChallengeCreatedEvent(
192192
pushChallenge.getUserId(),
193193
pushChallenge.getId(),
194194
pushChallenge.getType(),
195-
credentialData.getCredentialId(),
195+
credentialData.getDeviceCredentialId(),
196196
pushChallenge.getClientId(),
197197
pushChallenge.getUserVerificationMode(),
198198
pushChallenge.getExpiresAt(),
@@ -219,7 +219,7 @@ protected String buildConfirmToken(
219219
return PushConfirmTokenBuilder.build(
220220
context.getSession(),
221221
context.getRealm(),
222-
credentialData.getCredentialId(),
222+
credentialData.getDeviceCredentialId(),
223223
pushChallenge.getId(),
224224
pushChallenge.getExpiresAt(),
225225
context.getUriInfo().getBaseUri());
@@ -234,7 +234,7 @@ protected void logChallengeCreation(PushCredentialData credentialData) {
234234
"Push message prepared {version=%d,type=%d,credentialId=%s}",
235235
PushMfaConstants.PUSH_MESSAGE_VERSION,
236236
PushMfaConstants.PUSH_MESSAGE_TYPE,
237-
credentialData.getCredentialId());
237+
credentialData.getDeviceCredentialId());
238238
}
239239

240240
/**
@@ -253,7 +253,7 @@ protected void sendPushNotification(
253253
context.getUser(),
254254
clientId,
255255
confirmToken,
256-
credentialData.getCredentialId(),
256+
credentialData.getDeviceCredentialId(),
257257
pushChallenge.getId(),
258258
credentialData.getPushProviderType(),
259259
credentialData.getPushProviderId());

src/main/java/de/arbeitsagentur/keycloak/push/auth/ChallengeUrlBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public static String buildSameDeviceToken(
7474
return PushConfirmTokenBuilder.build(
7575
context.getSession(),
7676
context.getRealm(),
77-
credentialData.getCredentialId(),
77+
credentialData.getDeviceCredentialId(),
7878
challenge.getId(),
7979
challenge.getExpiresAt(),
8080
context.getUriInfo().getBaseUri(),

src/main/java/de/arbeitsagentur/keycloak/push/auth/PushMfaAuthenticator.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -333,11 +333,11 @@ protected void issueAndShowChallenge(
333333
protected void showWaitingFormForExisting(AuthenticationFlowContext context, PushChallenge ch) {
334334
CredentialModel cred = resolveCredentialForChallenge(context.getUser(), ch);
335335
PushCredentialData data = cred != null ? PushCredentialService.readCredentialData(cred) : null;
336-
String confirmToken = (data != null && data.getCredentialId() != null)
336+
String confirmToken = (data != null && data.getDeviceCredentialId() != null)
337337
? PushConfirmTokenBuilder.build(
338338
context.getSession(),
339339
context.getRealm(),
340-
data.getCredentialId(),
340+
data.getDeviceCredentialId(),
341341
ch.getId(),
342342
ch.getExpiresAt(),
343343
context.getUriInfo().getBaseUri())
@@ -368,7 +368,7 @@ protected Response createForm(
368368
form.setAttribute("challengeId", ch != null ? ch.getId() : null)
369369
.setAttribute("pushUsername", context.getUser().getUsername())
370370
.setAttribute("pushConfirmToken", token)
371-
.setAttribute("pushCredentialId", data != null ? data.getCredentialId() : null)
371+
.setAttribute("pushCredentialId", data != null ? data.getDeviceCredentialId() : null)
372372
.setAttribute("pushMessageVersion", String.valueOf(PushMfaConstants.PUSH_MESSAGE_VERSION))
373373
.setAttribute("pushMessageType", String.valueOf(PushMfaConstants.PUSH_MESSAGE_TYPE))
374374
.setAttribute("appUniversalLink", appLink)
@@ -481,7 +481,7 @@ protected CredentialAndData resolveCredential(UserModel user) {
481481
}
482482
CredentialModel cred = credentials.get(0);
483483
PushCredentialData data = PushCredentialService.readCredentialData(cred);
484-
if (data == null || data.getCredentialId() == null) {
484+
if (data == null || data.getDeviceCredentialId() == null) {
485485
return null;
486486
}
487487
return new CredentialAndData(cred, data);
@@ -492,8 +492,8 @@ protected CredentialAndData resolveCredential(UserModel user) {
492492
* Override to customize credential resolution for existing challenges.
493493
*/
494494
protected CredentialModel resolveCredentialForChallenge(UserModel user, PushChallenge ch) {
495-
if (ch.getCredentialId() != null) {
496-
CredentialModel byId = PushCredentialService.getCredentialById(user, ch.getCredentialId());
495+
if (ch.getKeycloakCredentialId() != null) {
496+
CredentialModel byId = PushCredentialService.getCredentialById(user, ch.getKeycloakCredentialId());
497497
if (byId != null) {
498498
return byId;
499499
}

src/main/java/de/arbeitsagentur/keycloak/push/challenge/PushChallenge.java

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,16 @@ public enum UserVerificationMode {
3838
private final String realmId;
3939
private final String userId;
4040
private final byte[] nonce;
41-
private final String credentialId;
41+
/**
42+
* The Keycloak-internal credential UUID ({@code CredentialModel.getId()}).
43+
*
44+
* <p>The server selects which credential to use for a challenge and stores this ID so it can
45+
* reload the {@code CredentialModel} later. The mobile app never sees this value; it receives
46+
* the device credential ID ({@code PushCredentialData.getDeviceCredentialId()}) via the
47+
* {@code credId} claim in the confirm token.
48+
*/
49+
private final String keycloakCredentialId;
50+
4251
private final String clientId;
4352
private final String watchSecret;
4453
private final String rootSessionId;
@@ -56,7 +65,7 @@ public PushChallenge(
5665
String realmId,
5766
String userId,
5867
byte[] nonce,
59-
String credentialId,
68+
String keycloakCredentialId,
6069
String clientId,
6170
String watchSecret,
6271
String rootSessionId,
@@ -70,7 +79,7 @@ public PushChallenge(
7079
realmId,
7180
userId,
7281
nonce,
73-
credentialId,
82+
keycloakCredentialId,
7483
clientId,
7584
watchSecret,
7685
rootSessionId,
@@ -89,7 +98,7 @@ public PushChallenge(
8998
String realmId,
9099
String userId,
91100
byte[] nonce,
92-
String credentialId,
101+
String keycloakCredentialId,
93102
String clientId,
94103
String watchSecret,
95104
String rootSessionId,
@@ -105,7 +114,7 @@ public PushChallenge(
105114
this.realmId = Objects.requireNonNull(realmId);
106115
this.userId = Objects.requireNonNull(userId);
107116
this.nonce = Arrays.copyOf(Objects.requireNonNull(nonce), nonce.length);
108-
this.credentialId = credentialId;
117+
this.keycloakCredentialId = keycloakCredentialId;
109118
this.clientId = clientId;
110119
this.watchSecret = watchSecret;
111120
this.rootSessionId = rootSessionId;
@@ -136,8 +145,8 @@ public byte[] getNonce() {
136145
return Arrays.copyOf(nonce, nonce.length);
137146
}
138147

139-
public String getCredentialId() {
140-
return credentialId;
148+
public String getKeycloakCredentialId() {
149+
return keycloakCredentialId;
141150
}
142151

143152
public String getClientId() {

src/main/java/de/arbeitsagentur/keycloak/push/challenge/PushChallengeStore.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public PushChallenge create(
5656
byte[] nonceBytes,
5757
PushChallenge.Type type,
5858
Duration ttl,
59-
String credentialId,
59+
String keycloakCredentialId,
6060
String clientId,
6161
String watchSecret,
6262
String rootSessionId) {
@@ -66,7 +66,7 @@ public PushChallenge create(
6666
nonceBytes,
6767
type,
6868
ttl,
69-
credentialId,
69+
keycloakCredentialId,
7070
clientId,
7171
watchSecret,
7272
rootSessionId,
@@ -81,7 +81,7 @@ public PushChallenge create(
8181
byte[] nonceBytes,
8282
PushChallenge.Type type,
8383
Duration ttl,
84-
String credentialId,
84+
String keycloakCredentialId,
8585
String clientId,
8686
String watchSecret,
8787
String rootSessionId,
@@ -100,8 +100,8 @@ public PushChallenge create(
100100
data.put("type", type.name());
101101
data.put("status", PushChallengeStatus.PENDING.name());
102102
data.put("createdAt", now.toString());
103-
if (credentialId != null) {
104-
data.put("credentialId", credentialId);
103+
if (keycloakCredentialId != null) {
104+
data.put("credentialId", keycloakCredentialId);
105105
}
106106
if (clientId != null) {
107107
data.put("clientId", clientId);
@@ -134,7 +134,7 @@ public PushChallenge create(
134134
realmId,
135135
userId,
136136
nonceBytes,
137-
credentialId,
137+
keycloakCredentialId,
138138
clientId,
139139
watchSecret,
140140
rootSessionId,

src/main/java/de/arbeitsagentur/keycloak/push/credential/PushCredentialData.java

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,34 @@
2121
import de.arbeitsagentur.keycloak.push.util.PushMfaConstants;
2222
import org.keycloak.utils.StringUtil;
2323

24+
/**
25+
* Credential data stored with each Push MFA credential.
26+
*
27+
* <p>This class holds the device-provided data that was submitted during enrollment, including the
28+
* device's public key (JWK), push provider details, and the device credential ID.
29+
*
30+
* <p><strong>Credential ID distinction:</strong> Each Push MFA credential has two IDs:
31+
* <ul>
32+
* <li>{@code deviceCredentialId} (this class) - chosen by the device during enrollment and used
33+
* in tokens, events, and push notifications. The mobile app uses this ID to match incoming
34+
* push messages to its local credentials.</li>
35+
* <li>{@code keycloakCredentialId} (on {@link de.arbeitsagentur.keycloak.push.challenge.PushChallenge})
36+
* - the UUID assigned by Keycloak's credential storage. Used internally to load the
37+
* {@code CredentialModel} from the store. The server picks which credential to use for a
38+
* challenge and stores this ID; the app never needs to know it.</li>
39+
* </ul>
40+
*
41+
* <p>The JSON property name {@code "credentialId"} is kept for backward compatibility with
42+
* existing stored credentials.
43+
*/
2444
public class PushCredentialData {
2545

2646
private final String publicKeyJwk;
2747
private final long createdAt;
2848
private final String deviceType;
2949
private final String pushProviderId;
3050
private final String pushProviderType;
31-
private final String credentialId;
51+
private final String deviceCredentialId;
3252
private final String deviceId;
3353

3454
@JsonCreator
@@ -38,15 +58,15 @@ public PushCredentialData(
3858
@JsonProperty("deviceType") String deviceType,
3959
@JsonProperty("pushProviderId") String pushProviderId,
4060
@JsonProperty("pushProviderType") String pushProviderType,
41-
@JsonProperty("credentialId") String credentialId,
61+
@JsonProperty("credentialId") String deviceCredentialId,
4262
@JsonProperty("deviceId") String deviceId) {
4363
this.publicKeyJwk = publicKeyJwk;
4464
this.createdAt = createdAt;
4565
this.deviceType = deviceType;
4666
this.pushProviderId = pushProviderId;
4767
this.pushProviderType =
4868
StringUtil.isBlank(pushProviderType) ? PushMfaConstants.DEFAULT_PUSH_PROVIDER_TYPE : pushProviderType;
49-
this.credentialId = credentialId;
69+
this.deviceCredentialId = deviceCredentialId;
5070
this.deviceId = deviceId;
5171
}
5272

@@ -70,8 +90,15 @@ public String getPushProviderType() {
7090
return pushProviderType;
7191
}
7292

73-
public String getCredentialId() {
74-
return credentialId;
93+
/**
94+
* Returns the device-chosen credential ID, set during enrollment.
95+
*
96+
* <p>This is NOT the Keycloak-internal credential UUID. See the class-level Javadoc for the
97+
* distinction between the two credential IDs.
98+
*/
99+
@JsonProperty("credentialId")
100+
public String getDeviceCredentialId() {
101+
return deviceCredentialId;
75102
}
76103

77104
public String getDeviceId() {

src/main/java/de/arbeitsagentur/keycloak/push/credential/PushCredentialProvider.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ public CredentialModel createCredential(RealmModel realm, UserModel user, Creden
5151
}
5252

5353
@Override
54-
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
55-
return user.credentialManager().removeStoredCredentialById(credentialId);
54+
public boolean deleteCredential(RealmModel realm, UserModel user, String keycloakCredentialId) {
55+
return user.credentialManager().removeStoredCredentialById(keycloakCredentialId);
5656
}
5757

5858
@Override

src/main/java/de/arbeitsagentur/keycloak/push/credential/PushCredentialService.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,11 @@ public static void updateCredential(UserModel user, CredentialModel credential,
5454
user.credentialManager().updateStoredCredential(credential);
5555
}
5656

57-
public static CredentialModel getCredentialById(UserModel user, String credentialId) {
58-
if (StringUtil.isBlank(credentialId)) {
57+
public static CredentialModel getCredentialById(UserModel user, String keycloakCredentialId) {
58+
if (StringUtil.isBlank(keycloakCredentialId)) {
5959
return null;
6060
}
61-
CredentialModel model = user.credentialManager().getStoredCredentialById(credentialId);
61+
CredentialModel model = user.credentialManager().getStoredCredentialById(keycloakCredentialId);
6262
if (model == null || !PushMfaConstants.CREDENTIAL_TYPE.equals(model.getType())) {
6363
return null;
6464
}

0 commit comments

Comments
 (0)