@multiplatform.one/wallet 7.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +8 -0
  2. package/dist/cjs/SiweKeycloakProvider.cjs +273 -0
  3. package/dist/cjs/SiweKeycloakProvider.native.js +309 -0
  4. package/dist/cjs/SiweKeycloakProvider.native.js.map +1 -0
  5. package/dist/cjs/base64.cjs +51 -0
  6. package/dist/cjs/base64.native.js +55 -0
  7. package/dist/cjs/base64.native.js.map +1 -0
  8. package/dist/cjs/base64.spec.cjs +57 -0
  9. package/dist/cjs/base64.spec.native.js +79 -0
  10. package/dist/cjs/base64.spec.native.js.map +1 -0
  11. package/dist/cjs/context.cjs +29 -0
  12. package/dist/cjs/context.native.js +32 -0
  13. package/dist/cjs/context.native.js.map +1 -0
  14. package/dist/cjs/hooks/context.cjs +29 -0
  15. package/dist/cjs/hooks/context.native.js +32 -0
  16. package/dist/cjs/hooks/context.native.js.map +1 -0
  17. package/dist/cjs/hooks/index.cjs +20 -0
  18. package/dist/cjs/hooks/index.native.js +23 -0
  19. package/dist/cjs/hooks/index.native.js.map +1 -0
  20. package/dist/cjs/hooks/useSiweKeycloak.cjs +61 -0
  21. package/dist/cjs/hooks/useSiweKeycloak.native.js +64 -0
  22. package/dist/cjs/hooks/useSiweKeycloak.native.js.map +1 -0
  23. package/dist/cjs/index.cjs +133 -0
  24. package/dist/cjs/index.native.js +189 -0
  25. package/dist/cjs/index.native.js.map +1 -0
  26. package/dist/cjs/render.cjs +78 -0
  27. package/dist/cjs/render.native.js +82 -0
  28. package/dist/cjs/render.native.js.map +1 -0
  29. package/dist/cjs/siwe.cjs +76 -0
  30. package/dist/cjs/siwe.native.js +94 -0
  31. package/dist/cjs/siwe.native.js.map +1 -0
  32. package/dist/cjs/types.cjs +18 -0
  33. package/dist/cjs/types.native.js +21 -0
  34. package/dist/cjs/types.native.js.map +1 -0
  35. package/dist/cjs/util.cjs +53 -0
  36. package/dist/cjs/util.native.js +56 -0
  37. package/dist/cjs/util.native.js.map +1 -0
  38. package/dist/cjs/util.spec.cjs +53 -0
  39. package/dist/cjs/util.spec.native.js +62 -0
  40. package/dist/cjs/util.spec.native.js.map +1 -0
  41. package/dist/esm/SiweKeycloakProvider.mjs +237 -0
  42. package/dist/esm/SiweKeycloakProvider.mjs.map +1 -0
  43. package/dist/esm/SiweKeycloakProvider.native.js +270 -0
  44. package/dist/esm/SiweKeycloakProvider.native.js.map +1 -0
  45. package/dist/esm/base64.mjs +26 -0
  46. package/dist/esm/base64.mjs.map +1 -0
  47. package/dist/esm/base64.native.js +27 -0
  48. package/dist/esm/base64.native.js.map +1 -0
  49. package/dist/esm/base64.spec.mjs +58 -0
  50. package/dist/esm/base64.spec.mjs.map +1 -0
  51. package/dist/esm/base64.spec.native.js +77 -0
  52. package/dist/esm/base64.spec.native.js.map +1 -0
  53. package/dist/esm/context.mjs +4 -0
  54. package/dist/esm/context.mjs.map +1 -0
  55. package/dist/esm/context.native.js +4 -0
  56. package/dist/esm/context.native.js.map +1 -0
  57. package/dist/esm/hooks/context.mjs +4 -0
  58. package/dist/esm/hooks/context.mjs.map +1 -0
  59. package/dist/esm/hooks/context.native.js +4 -0
  60. package/dist/esm/hooks/context.native.js.map +1 -0
  61. package/dist/esm/hooks/index.mjs +2 -0
  62. package/dist/esm/hooks/index.mjs.map +1 -0
  63. package/dist/esm/hooks/index.native.js +2 -0
  64. package/dist/esm/hooks/index.native.js.map +1 -0
  65. package/dist/esm/hooks/useSiweKeycloak.mjs +36 -0
  66. package/dist/esm/hooks/useSiweKeycloak.mjs.map +1 -0
  67. package/dist/esm/hooks/useSiweKeycloak.native.js +36 -0
  68. package/dist/esm/hooks/useSiweKeycloak.native.js.map +1 -0
  69. package/dist/esm/index.mjs +107 -0
  70. package/dist/esm/index.mjs.map +1 -0
  71. package/dist/esm/index.native.js +160 -0
  72. package/dist/esm/index.native.js.map +1 -0
  73. package/dist/esm/render.mjs +42 -0
  74. package/dist/esm/render.mjs.map +1 -0
  75. package/dist/esm/render.native.js +43 -0
  76. package/dist/esm/render.native.js.map +1 -0
  77. package/dist/esm/siwe.mjs +51 -0
  78. package/dist/esm/siwe.mjs.map +1 -0
  79. package/dist/esm/siwe.native.js +66 -0
  80. package/dist/esm/siwe.native.js.map +1 -0
  81. package/dist/esm/types.mjs +2 -0
  82. package/dist/esm/types.mjs.map +1 -0
  83. package/dist/esm/types.native.js +2 -0
  84. package/dist/esm/types.native.js.map +1 -0
  85. package/dist/esm/util.mjs +27 -0
  86. package/dist/esm/util.mjs.map +1 -0
  87. package/dist/esm/util.native.js +27 -0
  88. package/dist/esm/util.native.js.map +1 -0
  89. package/dist/esm/util.spec.mjs +54 -0
  90. package/dist/esm/util.spec.mjs.map +1 -0
  91. package/dist/esm/util.spec.native.js +60 -0
  92. package/dist/esm/util.spec.native.js.map +1 -0
  93. package/package.json +81 -0
  94. package/src/SiweKeycloakProvider.tsx +297 -0
  95. package/src/base64.spec.ts +65 -0
  96. package/src/base64.ts +26 -0
  97. package/src/context.ts +10 -0
  98. package/src/hooks/context.ts +10 -0
  99. package/src/hooks/index.ts +1 -0
  100. package/src/hooks/useSiweKeycloak.ts +23 -0
  101. package/src/index.ts +127 -0
  102. package/src/main/java/com/bitspur/keycloak/siwe/Errors.java +21 -0
  103. package/src/main/java/com/bitspur/keycloak/siwe/LiteAuthenticationService.java +167 -0
  104. package/src/main/java/com/bitspur/keycloak/siwe/SiweCredentials.java +36 -0
  105. package/src/main/java/com/bitspur/keycloak/siwe/SiweSession.java +36 -0
  106. package/src/main/java/com/bitspur/keycloak/siwe/SiweUtil.java +599 -0
  107. package/src/main/java/com/bitspur/keycloak/siwe/Token.java +85 -0
  108. package/src/main/java/com/bitspur/keycloak/siwe/authenticators/browser/SiweBrowserAuthenticator.java +109 -0
  109. package/src/main/java/com/bitspur/keycloak/siwe/authenticators/browser/SiweBrowserAuthenticatorFactory.java +93 -0
  110. package/src/main/java/com/bitspur/keycloak/siwe/authenticators/directgrant/SiweDirectGrantAuthenticator.java +153 -0
  111. package/src/main/java/com/bitspur/keycloak/siwe/authenticators/directgrant/SiweDirectGrantAuthenticatorFactory.java +93 -0
  112. package/src/main/java/com/bitspur/keycloak/siwe/jpa/nonce/SiweNonce.java +95 -0
  113. package/src/main/java/com/bitspur/keycloak/siwe/jpa/nonce/SiweNonceJpaEntityProvider.java +82 -0
  114. package/src/main/java/com/bitspur/keycloak/siwe/jpa/nonce/SiweNonceJpaEntityProviderFactory.java +46 -0
  115. package/src/main/java/com/bitspur/keycloak/siwe/jpa/wallet/SiweWalletAddress.java +103 -0
  116. package/src/main/java/com/bitspur/keycloak/siwe/jpa/wallet/SiweWalletJpaEntityProvider.java +43 -0
  117. package/src/main/java/com/bitspur/keycloak/siwe/jpa/wallet/SiweWalletJpaEntityProviderFactory.java +46 -0
  118. package/src/main/java/com/bitspur/keycloak/siwe/protocol/SiweWalletProtocolMapper.java +85 -0
  119. package/src/main/java/com/bitspur/keycloak/siwe/resource/SiweResource.java +210 -0
  120. package/src/main/java/com/bitspur/keycloak/siwe/resource/SiweResourceProvider.java +34 -0
  121. package/src/main/java/com/bitspur/keycloak/siwe/resource/SiweResourceProviderFactory.java +46 -0
  122. package/src/main/java/com/bitspur/keycloak/siwe/siu/SiuCredentials.java +52 -0
  123. package/src/main/java/com/bitspur/keycloak/siwe/siu/SiuMessage.java +362 -0
  124. package/src/main/java/com/bitspur/keycloak/siwe/siu/SiuParseException.java +23 -0
  125. package/src/main/java/com/bitspur/keycloak/siwe/siu/SiuUtil.java +547 -0
  126. package/src/main/java/com/bitspur/keycloak/siwe/siu/authenticators/browser/SiuBrowserAuthenticator.java +112 -0
  127. package/src/main/java/com/bitspur/keycloak/siwe/siu/authenticators/browser/SiuBrowserAuthenticatorFactory.java +96 -0
  128. package/src/main/java/com/bitspur/keycloak/siwe/siu/authenticators/directgrant/SiuDirectGrantAuthenticator.java +169 -0
  129. package/src/main/java/com/bitspur/keycloak/siwe/siu/authenticators/directgrant/SiuDirectGrantAuthenticatorFactory.java +96 -0
  130. package/src/main/java/com/bitspur/keycloak/siwe/siu/resource/SiuResource.java +244 -0
  131. package/src/main/java/com/bitspur/keycloak/siwe/siu/resource/SiuResourceProvider.java +34 -0
  132. package/src/main/java/com/bitspur/keycloak/siwe/siu/resource/SiuResourceProviderFactory.java +48 -0
  133. package/src/main/java/com/bitspur/keycloak/siwe/siws/SiwsCredentials.java +52 -0
  134. package/src/main/java/com/bitspur/keycloak/siwe/siws/SiwsMessage.java +362 -0
  135. package/src/main/java/com/bitspur/keycloak/siwe/siws/SiwsParseException.java +23 -0
  136. package/src/main/java/com/bitspur/keycloak/siwe/siws/SiwsUtil.java +564 -0
  137. package/src/main/java/com/bitspur/keycloak/siwe/siws/authenticators/browser/SiwsBrowserAuthenticator.java +112 -0
  138. package/src/main/java/com/bitspur/keycloak/siwe/siws/authenticators/browser/SiwsBrowserAuthenticatorFactory.java +96 -0
  139. package/src/main/java/com/bitspur/keycloak/siwe/siws/authenticators/directgrant/SiwsDirectGrantAuthenticator.java +169 -0
  140. package/src/main/java/com/bitspur/keycloak/siwe/siws/authenticators/directgrant/SiwsDirectGrantAuthenticatorFactory.java +96 -0
  141. package/src/main/java/com/bitspur/keycloak/siwe/siws/resource/SiwsResource.java +244 -0
  142. package/src/main/java/com/bitspur/keycloak/siwe/siws/resource/SiwsResourceProvider.java +34 -0
  143. package/src/main/java/com/bitspur/keycloak/siwe/siws/resource/SiwsResourceProviderFactory.java +48 -0
  144. package/src/main/resources/META-INF/keycloak-themes.json +8 -0
  145. package/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +6 -0
  146. package/src/main/resources/META-INF/services/org.keycloak.connections.jpa.entityprovider.JpaEntityProviderFactory +2 -0
  147. package/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +1 -0
  148. package/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory +3 -0
  149. package/src/main/resources/META-INF/siwe-nonce-changelog.xml +29 -0
  150. package/src/main/resources/META-INF/siwe-wallet-changelog.xml +46 -0
  151. package/src/render.tsx +47 -0
  152. package/src/siwe.tsx +57 -0
  153. package/src/types.ts +6 -0
  154. package/src/util.spec.ts +72 -0
  155. package/src/util.ts +28 -0
@@ -0,0 +1,547 @@
1
+ /**
2
+ * File: /src/main/java/com/bitspur/keycloak/siwe/siu/SiuUtil.java
3
+ * Project: siwe-keycloak
4
+ * File Created: 24-01-2026
5
+ * Author: Clay Risser
6
+ * -----
7
+ * BitSpur (c) Copyright 2022 - 2026
8
+ *
9
+ * Utility functions for Sign-In With Sui (SIU) authentication.
10
+ */
11
+
12
+ package com.bitspur.keycloak.siwe.siu;
13
+
14
+ import com.bitspur.keycloak.siwe.Errors.SiweKeycloakException;
15
+ import com.bitspur.keycloak.siwe.SiweSession;
16
+ import com.bitspur.keycloak.siwe.jpa.nonce.SiweNonce;
17
+ import com.bitspur.keycloak.siwe.jpa.wallet.SiweWalletAddress;
18
+ import com.bitspur.keycloak.siwe.siu.authenticators.browser.SiuBrowserAuthenticatorFactory;
19
+ import com.bitspur.keycloak.siwe.siu.authenticators.directgrant.SiuDirectGrantAuthenticatorFactory;
20
+ import com.iwebpp.crypto.TweetNaclFast;
21
+
22
+ import java.nio.charset.StandardCharsets;
23
+ import java.security.SecureRandom;
24
+ import java.util.ArrayList;
25
+ import java.util.Base64;
26
+ import java.util.List;
27
+
28
+ import jakarta.persistence.EntityManager;
29
+ import jakarta.persistence.NoResultException;
30
+ import jakarta.persistence.TypedQuery;
31
+ import jakarta.ws.rs.core.Cookie;
32
+ import jakarta.ws.rs.core.MultivaluedHashMap;
33
+ import jakarta.ws.rs.core.MultivaluedMap;
34
+
35
+ import org.jboss.logging.Logger;
36
+ import org.keycloak.authentication.AuthenticationFlowContext;
37
+ import org.keycloak.authentication.AuthenticationFlowError;
38
+ import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
39
+ import org.keycloak.connections.jpa.JpaConnectionProvider;
40
+ import org.keycloak.events.Details;
41
+ import org.keycloak.events.Errors;
42
+ import org.keycloak.forms.login.LoginFormsProvider;
43
+ import org.keycloak.http.HttpRequest;
44
+ import org.keycloak.models.AuthenticationExecutionModel;
45
+ import org.keycloak.models.AuthenticationFlowModel;
46
+ import org.keycloak.models.AuthenticatorConfigModel;
47
+ import org.keycloak.models.KeycloakSession;
48
+ import org.keycloak.models.ModelDuplicateException;
49
+ import org.keycloak.models.RealmModel;
50
+ import org.keycloak.models.UserModel;
51
+ import org.keycloak.services.ServicesLogger;
52
+ import org.keycloak.services.messages.Messages;
53
+ import org.keycloak.userprofile.UserProfile;
54
+ import org.keycloak.userprofile.UserProfileContext;
55
+ import org.keycloak.userprofile.UserProfileProvider;
56
+ import org.keycloak.models.utils.KeycloakModelUtils;
57
+
58
+ public class SiuUtil {
59
+ private static final Logger logger = Logger.getLogger(SiuUtil.class);
60
+ private static final SecureRandom random = new SecureRandom();
61
+
62
+ /**
63
+ * Address type for Sui wallets.
64
+ */
65
+ public static final String SUI_ADDRESS_TYPE = "sui";
66
+
67
+ /**
68
+ * Cookie name for SIU session.
69
+ */
70
+ public static final String KEYCLOAK_SESSION_SIU = "KEYCLOAK_SESSION_SIU";
71
+
72
+ // Hex characters for decoding
73
+ private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();
74
+
75
+ /**
76
+ * Decodes a hex encoded string to bytes.
77
+ * Handles both lowercase and uppercase hex, with or without 0x prefix.
78
+ *
79
+ * @param hex Hex encoded string
80
+ * @return Decoded bytes
81
+ * @throws SiweKeycloakException if decoding fails
82
+ */
83
+ public static byte[] decodeHex(String hex) throws SiweKeycloakException {
84
+ if (hex == null || hex.isEmpty()) {
85
+ throw new SiweKeycloakException("Cannot decode null or empty hex string");
86
+ }
87
+
88
+ // Remove 0x prefix if present
89
+ if (hex.startsWith("0x") || hex.startsWith("0X")) {
90
+ hex = hex.substring(2);
91
+ }
92
+
93
+ if (hex.length() % 2 != 0) {
94
+ throw new SiweKeycloakException("Invalid hex string length: must be even");
95
+ }
96
+
97
+ byte[] result = new byte[hex.length() / 2];
98
+ for (int i = 0; i < hex.length(); i += 2) {
99
+ int high = Character.digit(hex.charAt(i), 16);
100
+ int low = Character.digit(hex.charAt(i + 1), 16);
101
+ if (high == -1 || low == -1) {
102
+ throw new SiweKeycloakException("Invalid hex character at position " + i);
103
+ }
104
+ result[i / 2] = (byte) ((high << 4) + low);
105
+ }
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Encodes bytes to hex string with 0x prefix.
111
+ *
112
+ * @param bytes Bytes to encode
113
+ * @return Hex encoded string with 0x prefix
114
+ */
115
+ public static String encodeHex(byte[] bytes) {
116
+ StringBuilder sb = new StringBuilder("0x");
117
+ for (byte b : bytes) {
118
+ sb.append(HEX_CHARS[(b >> 4) & 0xF]);
119
+ sb.append(HEX_CHARS[b & 0xF]);
120
+ }
121
+ return sb.toString();
122
+ }
123
+
124
+ /**
125
+ * Verifies an ed25519 signature for a SIU message.
126
+ *
127
+ * @param message The message that was signed
128
+ * @param signature The Base64-encoded signature
129
+ * @param publicKey The hex-encoded public key (Sui address, with 0x prefix)
130
+ * @return true if the signature is valid
131
+ * @throws SiweKeycloakException if verification fails
132
+ */
133
+ public static boolean verifySignature(String message, String signature, String publicKey)
134
+ throws SiweKeycloakException {
135
+ try {
136
+ byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
137
+ byte[] signatureBytes = Base64.getDecoder().decode(signature);
138
+ byte[] publicKeyBytes = decodeHex(publicKey);
139
+
140
+ // Verify signature length (64 bytes for ed25519)
141
+ if (signatureBytes.length != 64) {
142
+ throw new SiweKeycloakException("Invalid signature length: expected 64 bytes, got " + signatureBytes.length);
143
+ }
144
+
145
+ // Verify public key length (32 bytes for ed25519)
146
+ if (publicKeyBytes.length != 32) {
147
+ throw new SiweKeycloakException("Invalid public key length: expected 32 bytes, got " + publicKeyBytes.length);
148
+ }
149
+
150
+ // Use TweetNaCl for ed25519 signature verification
151
+ // Create a Signature instance with the public key for verification
152
+ TweetNaclFast.Signature sig = new TweetNaclFast.Signature(publicKeyBytes, null);
153
+ return sig.detached_verify(messageBytes, signatureBytes);
154
+ } catch (SiweKeycloakException e) {
155
+ throw e;
156
+ } catch (IllegalArgumentException e) {
157
+ throw new SiweKeycloakException("Invalid Base64 encoding for signature");
158
+ } catch (Exception e) {
159
+ throw new SiweKeycloakException("Signature verification failed: " + e.getMessage());
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Verifies SIU credentials including signature, nonce, and domain.
165
+ *
166
+ * @param session Keycloak session
167
+ * @param configModel Authenticator config
168
+ * @param siuCredentials SIU credentials to verify
169
+ * @return true if credentials are valid
170
+ * @throws SiweKeycloakException if verification fails
171
+ */
172
+ public static Boolean verifySiuCredentials(KeycloakSession session,
173
+ AuthenticatorConfigModel configModel, SiuCredentials siuCredentials)
174
+ throws SiweKeycloakException {
175
+ HttpRequest httpRequest = session.getContext().getHttpRequest();
176
+
177
+ // Get and validate nonce
178
+ String nonce = getNonce(session);
179
+ if (nonce == null) {
180
+ throw new SiweKeycloakException("Missing nonce in session");
181
+ }
182
+
183
+ // Verify nonce matches
184
+ if (!nonce.equals(siuCredentials.message.getNonce())) {
185
+ throw new SiweKeycloakException("Nonce mismatch");
186
+ }
187
+
188
+ // Reconstruct the message and verify signature
189
+ String reconstructedMessage = siuCredentials.message.toMessage();
190
+ String address = siuCredentials.message.getAddress();
191
+
192
+ return verifySignature(reconstructedMessage, siuCredentials.signature, address);
193
+ }
194
+
195
+ /**
196
+ * Finds an existing user by wallet address or creates a new user if none exists.
197
+ *
198
+ * @param context Authentication flow context
199
+ * @param siuCredentials SIU credentials
200
+ * @return UserModel or null if creation/lookup fails
201
+ * @throws SiweKeycloakException if an error occurs
202
+ */
203
+ public static UserModel findOrCreateUser(AuthenticationFlowContext context,
204
+ SiuCredentials siuCredentials) throws SiweKeycloakException {
205
+ return findOrCreateUser(context, siuCredentials, true);
206
+ }
207
+
208
+ /**
209
+ * Finds an existing user by wallet address or creates a new user if none exists.
210
+ *
211
+ * @param context Authentication flow context
212
+ * @param siuCredentials SIU credentials
213
+ * @param failureChallenge Whether to issue a failure challenge on error
214
+ * @return UserModel or null if creation/lookup fails
215
+ * @throws SiweKeycloakException if an error occurs
216
+ */
217
+ public static UserModel findOrCreateUser(AuthenticationFlowContext context,
218
+ SiuCredentials siuCredentials, Boolean failureChallenge) throws SiweKeycloakException {
219
+ String address = siuCredentials.message.getAddress();
220
+ if (address == null) {
221
+ if (failureChallenge) {
222
+ context.getEvent().error(Errors.USER_NOT_FOUND);
223
+ LoginFormsProvider form = context.form();
224
+ form.setExecution(context.getExecution().getId());
225
+ form.setError(Messages.INVALID_USER);
226
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
227
+ }
228
+ return null;
229
+ }
230
+
231
+ UserModel user = null;
232
+ EntityManager entityManager = context.getSession().getProvider(JpaConnectionProvider.class).getEntityManager();
233
+
234
+ try {
235
+ // Look up existing user by Sui wallet address
236
+ List<SiweWalletAddress> wallets = entityManager
237
+ .createNamedQuery("findByRealmAddressType", SiweWalletAddress.class)
238
+ .setParameter("realmId", context.getRealm().getId())
239
+ .setParameter("address", address.trim().toLowerCase())
240
+ .setParameter("addressType", SUI_ADDRESS_TYPE)
241
+ .getResultList();
242
+
243
+ if (!wallets.isEmpty()) {
244
+ user = context.getSession().users().getUserById(context.getRealm(), wallets.get(0).getUserId());
245
+ }
246
+ } catch (ModelDuplicateException mde) {
247
+ if (failureChallenge) {
248
+ ServicesLogger.LOGGER.modelDuplicateException(mde);
249
+ context.getEvent().error(Errors.USERNAME_IN_USE);
250
+ LoginFormsProvider form = context.form();
251
+ form.setError(Messages.USERNAME_EXISTS);
252
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
253
+ }
254
+ return null;
255
+ }
256
+
257
+ // If no existing user found, create a new one
258
+ if (user == null) {
259
+ if (verifySiuCredentials(context.getSession(), context.getAuthenticatorConfig(), siuCredentials)) {
260
+ KeycloakSession session = context.getSession();
261
+ UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
262
+ MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
263
+ String username = generateRandomUsername(session, context.getRealm());
264
+ context.getEvent().detail(Details.USERNAME, username);
265
+ context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
266
+ formData.add("username", username);
267
+ UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION, formData);
268
+ user = profile.create();
269
+ user.setEnabled(true);
270
+
271
+ // Add Sui wallet address (normalized to lowercase)
272
+ SiweWalletAddress wallet = new SiweWalletAddress();
273
+ wallet.setAddress(address.trim().toLowerCase());
274
+ wallet.setAddressType(SUI_ADDRESS_TYPE);
275
+ wallet.setUserId(user.getId());
276
+ wallet.setRealmId(context.getRealm().getId());
277
+ entityManager.persist(wallet);
278
+
279
+ return user;
280
+ }
281
+
282
+ if (failureChallenge) {
283
+ context.getEvent().error(Errors.USER_NOT_FOUND);
284
+ LoginFormsProvider form = context.form();
285
+ form.setExecution(context.getExecution().getId());
286
+ form.setError(Messages.INVALID_USER);
287
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
288
+ }
289
+ return null;
290
+ }
291
+
292
+ String username = user.getUsername();
293
+ context.getEvent().detail(Details.USERNAME, username);
294
+ context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
295
+ return user;
296
+ }
297
+
298
+ /**
299
+ * Verifies that an existing user exists for the given wallet address.
300
+ *
301
+ * @param session Keycloak session
302
+ * @param configModel Authenticator config
303
+ * @param siuCredentials SIU credentials
304
+ * @return true if user exists
305
+ */
306
+ public static Boolean verifyExistingUser(KeycloakSession session,
307
+ AuthenticatorConfigModel configModel, SiuCredentials siuCredentials) {
308
+ String address = siuCredentials.message.getAddress().trim().toLowerCase();
309
+ RealmModel realm = session.getContext().getRealm();
310
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
311
+ List<SiweWalletAddress> wallets = entityManager
312
+ .createNamedQuery("findByRealmAddressType", SiweWalletAddress.class)
313
+ .setParameter("realmId", realm.getId())
314
+ .setParameter("address", address)
315
+ .setParameter("addressType", SUI_ADDRESS_TYPE)
316
+ .getResultList();
317
+ return !wallets.isEmpty();
318
+ }
319
+
320
+ /**
321
+ * Performs full verification including existing user, credentials, etc.
322
+ */
323
+ public static Boolean verify(KeycloakSession session, AuthenticatorConfigModel configModel,
324
+ SiuCredentials siuCredentials) throws SiweKeycloakException {
325
+ return verifyExistingUser(session, configModel, siuCredentials)
326
+ && verifySiuCredentials(session, configModel, siuCredentials);
327
+ }
328
+
329
+ /**
330
+ * Generates a SIU session with a new nonce.
331
+ */
332
+ public static SiweSession generateSiuSession(KeycloakSession session) {
333
+ String nonce = generateNonce();
334
+ String sessionKey = generateSessionKey(session.getContext().getRealm().getName());
335
+ setNonce(session, sessionKey, nonce);
336
+ return new SiweSession(sessionKey, nonce);
337
+ }
338
+
339
+ /**
340
+ * Gets the authenticator config for SIU.
341
+ */
342
+ public static AuthenticatorConfigModel getAuthenticatorConfig(KeycloakSession session, Boolean isDirectGrantFlow) {
343
+ RealmModel realm = session.getContext().getRealm();
344
+ AuthenticationFlowModel flow = realm.getBrowserFlow();
345
+ if (isDirectGrantFlow) {
346
+ flow = realm.getDirectGrantFlow();
347
+ }
348
+ AuthenticationExecutionModel execution = realm.getAuthenticationExecutionsStream(flow.getId())
349
+ .filter(e -> {
350
+ String authenticator = e.getAuthenticator();
351
+ return authenticator != null
352
+ && authenticator.equals(isDirectGrantFlow ? SiuDirectGrantAuthenticatorFactory.ID
353
+ : SiuBrowserAuthenticatorFactory.ID);
354
+ })
355
+ .findFirst().orElse(null);
356
+ if (execution == null) {
357
+ return null;
358
+ }
359
+ return realm.getAuthenticatorConfigById(execution.getAuthenticatorConfig());
360
+ }
361
+
362
+ /**
363
+ * Gets the session key from the SIU cookie.
364
+ */
365
+ public static String getSessionKey(KeycloakSession session) throws SiweKeycloakException {
366
+ HttpRequest request = session.getContext().getHttpRequest();
367
+ Cookie cookie = request.getHttpHeaders().getCookies().get(KEYCLOAK_SESSION_SIU);
368
+ String sessionKey = null;
369
+ if (cookie != null) {
370
+ sessionKey = cookie.getValue();
371
+ }
372
+ if (sessionKey == null) {
373
+ throw new SiweKeycloakException("missing siu session");
374
+ }
375
+ return sessionKey;
376
+ }
377
+
378
+ /**
379
+ * Gets the nonce from the session.
380
+ */
381
+ public static String getNonce(KeycloakSession session) throws SiweKeycloakException {
382
+ String sessionKey = getSessionKey(session);
383
+ return getNonce(session, sessionKey);
384
+ }
385
+
386
+ /**
387
+ * Gets the nonce for a specific session key.
388
+ */
389
+ public static String getNonce(KeycloakSession session, String key) {
390
+ SiweNonce siweNonce = getSiweNonce(session, key);
391
+ if (siweNonce == null) {
392
+ return null;
393
+ }
394
+ return siweNonce.getNonce();
395
+ }
396
+
397
+ /**
398
+ * Sets the nonce for a session key.
399
+ */
400
+ public static String setNonce(KeycloakSession session, String key, String nonce) {
401
+ RealmModel realm = session.getContext().getRealm();
402
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
403
+ SiweNonce siweNonce = getSiweNonce(session, key);
404
+ if (siweNonce == null) {
405
+ siweNonce = new SiweNonce();
406
+ siweNonce.setKey(key);
407
+ siweNonce.setRealmId(realm.getId());
408
+ }
409
+ siweNonce.setNonce(nonce);
410
+ entityManager.merge(siweNonce);
411
+ return nonce;
412
+ }
413
+
414
+ /**
415
+ * Removes a nonce from the session.
416
+ */
417
+ public static Void removeNonce(KeycloakSession session, String key) {
418
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
419
+ SiweNonce siweNonce = getSiweNonce(session, key);
420
+ if (siweNonce == null) {
421
+ return null;
422
+ }
423
+ entityManager.remove(siweNonce);
424
+ return null;
425
+ }
426
+
427
+ /**
428
+ * Finds a user by Sui wallet address.
429
+ */
430
+ public static UserModel findUserByWalletAddress(KeycloakSession session, String address) {
431
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
432
+ try {
433
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
434
+ SiweWalletAddress.class);
435
+ query.setParameter("address", address.trim().toLowerCase());
436
+ query.setParameter("realmId", session.getContext().getRealm().getId());
437
+ query.setParameter("addressType", SUI_ADDRESS_TYPE);
438
+ List<SiweWalletAddress> wallets = query.getResultList();
439
+ if (!wallets.isEmpty()) {
440
+ return session.users().getUserById(session.getContext().getRealm(), wallets.get(0).getUserId());
441
+ }
442
+ return null;
443
+ } catch (NoResultException e) {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Adds a Sui wallet address to a user.
450
+ */
451
+ public static boolean addWalletAddress(KeycloakSession session, UserModel user, String address) {
452
+ String normalizedAddress = address.trim().toLowerCase();
453
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
454
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
455
+ SiweWalletAddress.class);
456
+ query.setParameter("address", normalizedAddress);
457
+ query.setParameter("realmId", session.getContext().getRealm().getId());
458
+ query.setParameter("addressType", SUI_ADDRESS_TYPE);
459
+ List<SiweWalletAddress> existingWallets = query.getResultList();
460
+ for (SiweWalletAddress wallet : existingWallets) {
461
+ if (wallet.getUserId().equals(user.getId())) {
462
+ return false;
463
+ }
464
+ }
465
+ SiweWalletAddress wallet = new SiweWalletAddress();
466
+ wallet.setAddress(normalizedAddress);
467
+ wallet.setAddressType(SUI_ADDRESS_TYPE);
468
+ wallet.setUserId(user.getId());
469
+ wallet.setRealmId(session.getContext().getRealm().getId());
470
+ entityManager.persist(wallet);
471
+ return true;
472
+ }
473
+
474
+ /**
475
+ * Removes a Sui wallet address.
476
+ */
477
+ public static boolean removeWalletAddress(KeycloakSession session, String address) {
478
+ String normalizedAddress = address.trim().toLowerCase();
479
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
480
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
481
+ SiweWalletAddress.class);
482
+ query.setParameter("address", normalizedAddress);
483
+ query.setParameter("realmId", session.getContext().getRealm().getId());
484
+ query.setParameter("addressType", SUI_ADDRESS_TYPE);
485
+ try {
486
+ List<SiweWalletAddress> wallets = query.getResultList();
487
+ if (!wallets.isEmpty()) {
488
+ entityManager.remove(wallets.get(0));
489
+ return true;
490
+ }
491
+ return false;
492
+ } catch (NoResultException e) {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Gets all Sui wallet addresses for a user.
499
+ */
500
+ public static List<SiweWalletAddress> getWalletAddresses(KeycloakSession session, UserModel user) {
501
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
502
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByUserId", SiweWalletAddress.class);
503
+ query.setParameter("userId", user.getId());
504
+ List<SiweWalletAddress> allWallets = query.getResultList();
505
+ // Filter to only Sui wallets
506
+ List<SiweWalletAddress> suiWallets = new ArrayList<>();
507
+ for (SiweWalletAddress wallet : allWallets) {
508
+ if (SUI_ADDRESS_TYPE.equals(wallet.getAddressType())) {
509
+ suiWallets.add(wallet);
510
+ }
511
+ }
512
+ return suiWallets;
513
+ }
514
+
515
+ // Private helper methods
516
+
517
+ private static SiweNonce getSiweNonce(KeycloakSession session, String key) {
518
+ RealmModel realm = session.getContext().getRealm();
519
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
520
+ SiweNonce siweNonce = entityManager.createNamedQuery("findByRealmKey", SiweNonce.class)
521
+ .setParameter("key", key)
522
+ .setParameter("realmId", realm.getId())
523
+ .getResultList().stream().findFirst().orElse(null);
524
+ return siweNonce;
525
+ }
526
+
527
+ private static String generateNonce() {
528
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(random.generateSeed(8))
529
+ .replaceAll("[^A-Za-z0-9]", "0")
530
+ .substring(0, 8);
531
+ }
532
+
533
+ private static String generateSessionKey(String realmName) {
534
+ byte[] keyBytes = new byte[32];
535
+ random.nextBytes(keyBytes);
536
+ return realmName + "/" + (Base64.getEncoder().encodeToString(keyBytes));
537
+ }
538
+
539
+ private static String generateRandomUsername(KeycloakSession session, RealmModel realm) {
540
+ while (true) {
541
+ String username = String.format("sui_%010d", Math.abs(random.nextLong() % 10000000000L));
542
+ if (KeycloakModelUtils.findUserByNameOrEmail(session, realm, username) == null) {
543
+ return username;
544
+ }
545
+ }
546
+ }
547
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * File: /src/main/java/com/bitspur/keycloak/siwe/siu/authenticators/browser/SiuBrowserAuthenticator.java
3
+ * Project: siwe-keycloak
4
+ * File Created: 24-01-2026
5
+ * Author: Clay Risser
6
+ * -----
7
+ * BitSpur (c) Copyright 2022 - 2026
8
+ *
9
+ * Browser authenticator for Sign-In With Sui (SIU).
10
+ */
11
+
12
+ package com.bitspur.keycloak.siwe.siu.authenticators.browser;
13
+
14
+ import com.bitspur.keycloak.siwe.Errors.SiweKeycloakException;
15
+ import com.bitspur.keycloak.siwe.siu.SiuCredentials;
16
+ import com.bitspur.keycloak.siwe.siu.SiuUtil;
17
+
18
+ import jakarta.ws.rs.core.MultivaluedMap;
19
+ import jakarta.ws.rs.core.Response;
20
+
21
+ import org.jboss.logging.Logger;
22
+ import org.keycloak.authentication.AuthenticationFlowContext;
23
+ import org.keycloak.authentication.AuthenticationFlowError;
24
+ import org.keycloak.authentication.Authenticator;
25
+ import org.keycloak.forms.login.LoginFormsProvider;
26
+ import org.keycloak.models.KeycloakSession;
27
+ import org.keycloak.models.RealmModel;
28
+ import org.keycloak.models.UserModel;
29
+ import org.keycloak.services.messages.Messages;
30
+
31
+ /**
32
+ * Browser-based SIU authenticator.
33
+ * Handles the login form submission with Sui wallet signature.
34
+ */
35
+ public class SiuBrowserAuthenticator implements Authenticator {
36
+ private static final Logger logger = Logger.getLogger(SiuBrowserAuthenticator.class);
37
+
38
+ @Override
39
+ public void authenticate(AuthenticationFlowContext context) {
40
+ // Display the SIU login form
41
+ LoginFormsProvider form = context.form();
42
+ form.setExecution(context.getExecution().getId());
43
+ Response response = form.createLoginUsernamePassword();
44
+ context.challenge(response);
45
+ }
46
+
47
+ @Override
48
+ public void action(AuthenticationFlowContext context) {
49
+ MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
50
+ String signature = formData.getFirst("username"); // Signature is passed as username
51
+ String message = formData.getFirst("password"); // Base64 SIU message is passed as password
52
+
53
+ if (signature == null || signature.isEmpty() || message == null || message.isEmpty()) {
54
+ context.getEvent().error("missing_credentials");
55
+ LoginFormsProvider form = context.form();
56
+ form.setExecution(context.getExecution().getId());
57
+ form.setError(Messages.INVALID_USER);
58
+ context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, form.createLoginUsernamePassword());
59
+ return;
60
+ }
61
+
62
+ try {
63
+ SiuCredentials siuCredentials = new SiuCredentials(signature, message);
64
+ UserModel user = SiuUtil.findOrCreateUser(context, siuCredentials, true);
65
+
66
+ if (user == null) {
67
+ // findOrCreateUser already issued failure challenge
68
+ return;
69
+ }
70
+
71
+ // Verify the credentials
72
+ if (!SiuUtil.verifySiuCredentials(context.getSession(), context.getAuthenticatorConfig(), siuCredentials)) {
73
+ context.getEvent().error("invalid_signature");
74
+ LoginFormsProvider form = context.form();
75
+ form.setExecution(context.getExecution().getId());
76
+ form.setError(Messages.INVALID_USER);
77
+ context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, form.createLoginUsernamePassword());
78
+ return;
79
+ }
80
+
81
+ context.setUser(user);
82
+ context.success();
83
+ } catch (SiweKeycloakException e) {
84
+ logger.error("SIU authentication error", e);
85
+ context.getEvent().error("siu_error");
86
+ LoginFormsProvider form = context.form();
87
+ form.setExecution(context.getExecution().getId());
88
+ form.setError(e.getMessage());
89
+ context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, form.createLoginUsernamePassword());
90
+ }
91
+ }
92
+
93
+ @Override
94
+ public boolean requiresUser() {
95
+ return false;
96
+ }
97
+
98
+ @Override
99
+ public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
100
+ return true;
101
+ }
102
+
103
+ @Override
104
+ public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
105
+ // No required actions
106
+ }
107
+
108
+ @Override
109
+ public void close() {
110
+ // Nothing to close
111
+ }
112
+ }