@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,564 @@
1
+ /**
2
+ * File: /src/main/java/com/bitspur/keycloak/siwe/siws/SiwsUtil.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 Solana (SIWS) authentication.
10
+ */
11
+
12
+ package com.bitspur.keycloak.siwe.siws;
13
+
14
+ import com.bitspur.keycloak.siwe.Errors.SiweKeycloakException;
15
+ import com.bitspur.keycloak.siwe.SiweSession;
16
+ import com.bitspur.keycloak.siwe.SiweUtil;
17
+ import com.bitspur.keycloak.siwe.jpa.nonce.SiweNonce;
18
+ import com.bitspur.keycloak.siwe.jpa.wallet.SiweWalletAddress;
19
+ import com.bitspur.keycloak.siwe.siws.authenticators.browser.SiwsBrowserAuthenticatorFactory;
20
+ import com.bitspur.keycloak.siwe.siws.authenticators.directgrant.SiwsDirectGrantAuthenticatorFactory;
21
+ import com.iwebpp.crypto.TweetNaclFast;
22
+
23
+ import java.nio.charset.StandardCharsets;
24
+ import java.security.SecureRandom;
25
+ import java.util.ArrayList;
26
+ import java.util.Base64;
27
+ import java.util.HashMap;
28
+ import java.util.List;
29
+ import java.util.Map;
30
+
31
+ import jakarta.persistence.EntityManager;
32
+ import jakarta.persistence.NoResultException;
33
+ import jakarta.persistence.TypedQuery;
34
+ import jakarta.ws.rs.core.Cookie;
35
+ import jakarta.ws.rs.core.MultivaluedHashMap;
36
+ import jakarta.ws.rs.core.MultivaluedMap;
37
+
38
+ import org.jboss.logging.Logger;
39
+ import org.keycloak.authentication.AuthenticationFlowContext;
40
+ import org.keycloak.authentication.AuthenticationFlowError;
41
+ import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
42
+ import org.keycloak.connections.jpa.JpaConnectionProvider;
43
+ import org.keycloak.events.Details;
44
+ import org.keycloak.events.Errors;
45
+ import org.keycloak.forms.login.LoginFormsProvider;
46
+ import org.keycloak.http.HttpRequest;
47
+ import org.keycloak.models.AuthenticationExecutionModel;
48
+ import org.keycloak.models.AuthenticationFlowModel;
49
+ import org.keycloak.models.AuthenticatorConfigModel;
50
+ import org.keycloak.models.KeycloakSession;
51
+ import org.keycloak.models.ModelDuplicateException;
52
+ import org.keycloak.models.RealmModel;
53
+ import org.keycloak.models.UserModel;
54
+ import org.keycloak.services.ServicesLogger;
55
+ import org.keycloak.services.messages.Messages;
56
+ import org.keycloak.userprofile.UserProfile;
57
+ import org.keycloak.userprofile.UserProfileContext;
58
+ import org.keycloak.userprofile.UserProfileProvider;
59
+ import org.keycloak.models.utils.KeycloakModelUtils;
60
+
61
+ public class SiwsUtil {
62
+ private static final Logger logger = Logger.getLogger(SiwsUtil.class);
63
+ private static final SecureRandom random = new SecureRandom();
64
+
65
+ /**
66
+ * Address type for Solana wallets.
67
+ */
68
+ public static final String SOLANA_ADDRESS_TYPE = "solana";
69
+
70
+ /**
71
+ * Cookie name for SIWS session.
72
+ */
73
+ public static final String KEYCLOAK_SESSION_SIWS = "KEYCLOAK_SESSION_SIWS";
74
+
75
+ // Base58 alphabet used by Solana
76
+ private static final String BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
77
+ private static final int[] BASE58_INDEXES = new int[128];
78
+
79
+ static {
80
+ for (int i = 0; i < BASE58_INDEXES.length; i++) {
81
+ BASE58_INDEXES[i] = -1;
82
+ }
83
+ for (int i = 0; i < BASE58_ALPHABET.length(); i++) {
84
+ BASE58_INDEXES[BASE58_ALPHABET.charAt(i)] = i;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Decodes a Base58 encoded string to bytes.
90
+ *
91
+ * @param input Base58 encoded string
92
+ * @return Decoded bytes
93
+ * @throws SiweKeycloakException if decoding fails
94
+ */
95
+ public static byte[] decodeBase58(String input) throws SiweKeycloakException {
96
+ if (input == null || input.isEmpty()) {
97
+ throw new SiweKeycloakException("Cannot decode null or empty Base58 string");
98
+ }
99
+
100
+ // Count leading zeros
101
+ int zeros = 0;
102
+ for (int i = 0; i < input.length() && input.charAt(i) == '1'; i++) {
103
+ zeros++;
104
+ }
105
+
106
+ // Allocate enough space for the decoded output
107
+ byte[] b256 = new byte[input.length()];
108
+ int outputLen = 0;
109
+
110
+ for (int i = zeros; i < input.length(); i++) {
111
+ char c = input.charAt(i);
112
+ if (c >= 128 || BASE58_INDEXES[c] == -1) {
113
+ throw new SiweKeycloakException("Invalid Base58 character: " + c);
114
+ }
115
+
116
+ int carry = BASE58_INDEXES[c];
117
+ for (int j = 0; j < outputLen || carry != 0; j++) {
118
+ carry += 58 * (j < outputLen ? b256[j] & 0xFF : 0);
119
+ b256[j] = (byte) (carry % 256);
120
+ carry /= 256;
121
+ if (j >= outputLen) {
122
+ outputLen = j + 1;
123
+ }
124
+ }
125
+ }
126
+
127
+ // Skip leading zeros in b256
128
+ int skipZeros = 0;
129
+ while (skipZeros < outputLen && b256[outputLen - 1 - skipZeros] == 0) {
130
+ skipZeros++;
131
+ }
132
+
133
+ // Build result with leading zeros
134
+ byte[] result = new byte[zeros + outputLen - skipZeros];
135
+ for (int i = 0; i < zeros; i++) {
136
+ result[i] = 0;
137
+ }
138
+ for (int i = 0; i < outputLen - skipZeros; i++) {
139
+ result[zeros + i] = b256[outputLen - 1 - skipZeros - i];
140
+ }
141
+
142
+ return result;
143
+ }
144
+
145
+ /**
146
+ * Verifies an ed25519 signature for a SIWS message.
147
+ *
148
+ * @param message The message that was signed
149
+ * @param signature The Base58-encoded signature
150
+ * @param publicKey The Base58-encoded public key (Solana address)
151
+ * @return true if the signature is valid
152
+ * @throws SiweKeycloakException if verification fails
153
+ */
154
+ public static boolean verifySignature(String message, String signature, String publicKey)
155
+ throws SiweKeycloakException {
156
+ try {
157
+ byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
158
+ byte[] signatureBytes = decodeBase58(signature);
159
+ byte[] publicKeyBytes = decodeBase58(publicKey);
160
+
161
+ // Verify signature length (64 bytes for ed25519)
162
+ if (signatureBytes.length != 64) {
163
+ throw new SiweKeycloakException("Invalid signature length: expected 64 bytes, got " + signatureBytes.length);
164
+ }
165
+
166
+ // Verify public key length (32 bytes for ed25519)
167
+ if (publicKeyBytes.length != 32) {
168
+ throw new SiweKeycloakException("Invalid public key length: expected 32 bytes, got " + publicKeyBytes.length);
169
+ }
170
+
171
+ // Use TweetNaCl for ed25519 signature verification
172
+ // Create a Signature instance with the public key for verification
173
+ TweetNaclFast.Signature sig = new TweetNaclFast.Signature(publicKeyBytes, null);
174
+ return sig.detached_verify(messageBytes, signatureBytes);
175
+ } catch (SiweKeycloakException e) {
176
+ throw e;
177
+ } catch (Exception e) {
178
+ throw new SiweKeycloakException("Signature verification failed: " + e.getMessage());
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Verifies SIWS credentials including signature, nonce, and domain.
184
+ *
185
+ * @param session Keycloak session
186
+ * @param configModel Authenticator config
187
+ * @param siwsCredentials SIWS credentials to verify
188
+ * @return true if credentials are valid
189
+ * @throws SiweKeycloakException if verification fails
190
+ */
191
+ public static Boolean verifySiwsCredentials(KeycloakSession session,
192
+ AuthenticatorConfigModel configModel, SiwsCredentials siwsCredentials)
193
+ throws SiweKeycloakException {
194
+ HttpRequest httpRequest = session.getContext().getHttpRequest();
195
+
196
+ // Get and validate nonce
197
+ String nonce = getNonce(session);
198
+ if (nonce == null) {
199
+ throw new SiweKeycloakException("Missing nonce in session");
200
+ }
201
+
202
+ // Verify nonce matches
203
+ if (!nonce.equals(siwsCredentials.message.getNonce())) {
204
+ throw new SiweKeycloakException("Nonce mismatch");
205
+ }
206
+
207
+ // Reconstruct the message and verify signature
208
+ String reconstructedMessage = siwsCredentials.message.toMessage();
209
+ String address = siwsCredentials.message.getAddress();
210
+
211
+ return verifySignature(reconstructedMessage, siwsCredentials.signature, address);
212
+ }
213
+
214
+ /**
215
+ * Finds an existing user by wallet address or creates a new user if none exists.
216
+ *
217
+ * @param context Authentication flow context
218
+ * @param siwsCredentials SIWS credentials
219
+ * @return UserModel or null if creation/lookup fails
220
+ * @throws SiweKeycloakException if an error occurs
221
+ */
222
+ public static UserModel findOrCreateUser(AuthenticationFlowContext context,
223
+ SiwsCredentials siwsCredentials) throws SiweKeycloakException {
224
+ return findOrCreateUser(context, siwsCredentials, true);
225
+ }
226
+
227
+ /**
228
+ * Finds an existing user by wallet address or creates a new user if none exists.
229
+ *
230
+ * @param context Authentication flow context
231
+ * @param siwsCredentials SIWS credentials
232
+ * @param failureChallenge Whether to issue a failure challenge on error
233
+ * @return UserModel or null if creation/lookup fails
234
+ * @throws SiweKeycloakException if an error occurs
235
+ */
236
+ public static UserModel findOrCreateUser(AuthenticationFlowContext context,
237
+ SiwsCredentials siwsCredentials, Boolean failureChallenge) throws SiweKeycloakException {
238
+ String address = siwsCredentials.message.getAddress();
239
+ if (address == null) {
240
+ if (failureChallenge) {
241
+ context.getEvent().error(Errors.USER_NOT_FOUND);
242
+ LoginFormsProvider form = context.form();
243
+ form.setExecution(context.getExecution().getId());
244
+ form.setError(Messages.INVALID_USER);
245
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
246
+ }
247
+ return null;
248
+ }
249
+
250
+ UserModel user = null;
251
+ EntityManager entityManager = context.getSession().getProvider(JpaConnectionProvider.class).getEntityManager();
252
+
253
+ try {
254
+ // Look up existing user by Solana wallet address
255
+ List<SiweWalletAddress> wallets = entityManager
256
+ .createNamedQuery("findByRealmAddressType", SiweWalletAddress.class)
257
+ .setParameter("realmId", context.getRealm().getId())
258
+ .setParameter("address", address.trim())
259
+ .setParameter("addressType", SOLANA_ADDRESS_TYPE)
260
+ .getResultList();
261
+
262
+ if (!wallets.isEmpty()) {
263
+ user = context.getSession().users().getUserById(context.getRealm(), wallets.get(0).getUserId());
264
+ }
265
+ } catch (ModelDuplicateException mde) {
266
+ if (failureChallenge) {
267
+ ServicesLogger.LOGGER.modelDuplicateException(mde);
268
+ context.getEvent().error(Errors.USERNAME_IN_USE);
269
+ LoginFormsProvider form = context.form();
270
+ form.setError(Messages.USERNAME_EXISTS);
271
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
272
+ }
273
+ return null;
274
+ }
275
+
276
+ // If no existing user found, create a new one
277
+ if (user == null) {
278
+ if (verifySiwsCredentials(context.getSession(), context.getAuthenticatorConfig(), siwsCredentials)) {
279
+ KeycloakSession session = context.getSession();
280
+ UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
281
+ MultivaluedMap<String, String> formData = new MultivaluedHashMap<>();
282
+ String username = generateRandomUsername(session, context.getRealm());
283
+ context.getEvent().detail(Details.USERNAME, username);
284
+ context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
285
+ formData.add("username", username);
286
+ UserProfile profile = profileProvider.create(UserProfileContext.REGISTRATION, formData);
287
+ user = profile.create();
288
+ user.setEnabled(true);
289
+
290
+ // Add Solana wallet address
291
+ SiweWalletAddress wallet = new SiweWalletAddress();
292
+ wallet.setAddress(address.trim());
293
+ wallet.setAddressType(SOLANA_ADDRESS_TYPE);
294
+ wallet.setUserId(user.getId());
295
+ wallet.setRealmId(context.getRealm().getId());
296
+ entityManager.persist(wallet);
297
+
298
+ return user;
299
+ }
300
+
301
+ if (failureChallenge) {
302
+ context.getEvent().error(Errors.USER_NOT_FOUND);
303
+ LoginFormsProvider form = context.form();
304
+ form.setExecution(context.getExecution().getId());
305
+ form.setError(Messages.INVALID_USER);
306
+ context.failureChallenge(AuthenticationFlowError.INVALID_USER, form.createLoginUsernamePassword());
307
+ }
308
+ return null;
309
+ }
310
+
311
+ String username = user.getUsername();
312
+ context.getEvent().detail(Details.USERNAME, username);
313
+ context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
314
+ return user;
315
+ }
316
+
317
+ /**
318
+ * Verifies that an existing user exists for the given wallet address.
319
+ *
320
+ * @param session Keycloak session
321
+ * @param configModel Authenticator config
322
+ * @param siwsCredentials SIWS credentials
323
+ * @return true if user exists
324
+ */
325
+ public static Boolean verifyExistingUser(KeycloakSession session,
326
+ AuthenticatorConfigModel configModel, SiwsCredentials siwsCredentials) {
327
+ String address = siwsCredentials.message.getAddress().trim();
328
+ RealmModel realm = session.getContext().getRealm();
329
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
330
+ List<SiweWalletAddress> wallets = entityManager
331
+ .createNamedQuery("findByRealmAddressType", SiweWalletAddress.class)
332
+ .setParameter("realmId", realm.getId())
333
+ .setParameter("address", address)
334
+ .setParameter("addressType", SOLANA_ADDRESS_TYPE)
335
+ .getResultList();
336
+ return !wallets.isEmpty();
337
+ }
338
+
339
+ /**
340
+ * Performs full verification including existing user, credentials, etc.
341
+ */
342
+ public static Boolean verify(KeycloakSession session, AuthenticatorConfigModel configModel,
343
+ SiwsCredentials siwsCredentials) throws SiweKeycloakException {
344
+ return verifyExistingUser(session, configModel, siwsCredentials)
345
+ && verifySiwsCredentials(session, configModel, siwsCredentials);
346
+ }
347
+
348
+ /**
349
+ * Generates a SIWS session with a new nonce.
350
+ */
351
+ public static SiweSession generateSiwsSession(KeycloakSession session) {
352
+ String nonce = generateNonce();
353
+ String sessionKey = generateSessionKey(session.getContext().getRealm().getName());
354
+ setNonce(session, sessionKey, nonce);
355
+ return new SiweSession(sessionKey, nonce);
356
+ }
357
+
358
+ /**
359
+ * Gets the authenticator config for SIWS.
360
+ */
361
+ public static AuthenticatorConfigModel getAuthenticatorConfig(KeycloakSession session, Boolean isDirectGrantFlow) {
362
+ RealmModel realm = session.getContext().getRealm();
363
+ AuthenticationFlowModel flow = realm.getBrowserFlow();
364
+ if (isDirectGrantFlow) {
365
+ flow = realm.getDirectGrantFlow();
366
+ }
367
+ AuthenticationExecutionModel execution = realm.getAuthenticationExecutionsStream(flow.getId())
368
+ .filter(e -> {
369
+ String authenticator = e.getAuthenticator();
370
+ return authenticator != null
371
+ && authenticator.equals(isDirectGrantFlow ? SiwsDirectGrantAuthenticatorFactory.ID
372
+ : SiwsBrowserAuthenticatorFactory.ID);
373
+ })
374
+ .findFirst().orElse(null);
375
+ if (execution == null) {
376
+ return null;
377
+ }
378
+ return realm.getAuthenticatorConfigById(execution.getAuthenticatorConfig());
379
+ }
380
+
381
+ /**
382
+ * Gets the session key from the SIWS cookie.
383
+ */
384
+ public static String getSessionKey(KeycloakSession session) throws SiweKeycloakException {
385
+ HttpRequest request = session.getContext().getHttpRequest();
386
+ Cookie cookie = request.getHttpHeaders().getCookies().get(KEYCLOAK_SESSION_SIWS);
387
+ String sessionKey = null;
388
+ if (cookie != null) {
389
+ sessionKey = cookie.getValue();
390
+ }
391
+ if (sessionKey == null) {
392
+ throw new SiweKeycloakException("missing siws session");
393
+ }
394
+ return sessionKey;
395
+ }
396
+
397
+ /**
398
+ * Gets the nonce from the session.
399
+ */
400
+ public static String getNonce(KeycloakSession session) throws SiweKeycloakException {
401
+ String sessionKey = getSessionKey(session);
402
+ return getNonce(session, sessionKey);
403
+ }
404
+
405
+ /**
406
+ * Gets the nonce for a specific session key.
407
+ */
408
+ public static String getNonce(KeycloakSession session, String key) {
409
+ SiweNonce siweNonce = getSiweNonce(session, key);
410
+ if (siweNonce == null) {
411
+ return null;
412
+ }
413
+ return siweNonce.getNonce();
414
+ }
415
+
416
+ /**
417
+ * Sets the nonce for a session key.
418
+ */
419
+ public static String setNonce(KeycloakSession session, String key, String nonce) {
420
+ RealmModel realm = session.getContext().getRealm();
421
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
422
+ SiweNonce siweNonce = getSiweNonce(session, key);
423
+ if (siweNonce == null) {
424
+ siweNonce = new SiweNonce();
425
+ siweNonce.setKey(key);
426
+ siweNonce.setRealmId(realm.getId());
427
+ }
428
+ siweNonce.setNonce(nonce);
429
+ entityManager.merge(siweNonce);
430
+ return nonce;
431
+ }
432
+
433
+ /**
434
+ * Removes a nonce from the session.
435
+ */
436
+ public static Void removeNonce(KeycloakSession session, String key) {
437
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
438
+ SiweNonce siweNonce = getSiweNonce(session, key);
439
+ if (siweNonce == null) {
440
+ return null;
441
+ }
442
+ entityManager.remove(siweNonce);
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * Finds a user by Solana wallet address.
448
+ */
449
+ public static UserModel findUserByWalletAddress(KeycloakSession session, String address) {
450
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
451
+ try {
452
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
453
+ SiweWalletAddress.class);
454
+ query.setParameter("address", address.trim());
455
+ query.setParameter("realmId", session.getContext().getRealm().getId());
456
+ query.setParameter("addressType", SOLANA_ADDRESS_TYPE);
457
+ List<SiweWalletAddress> wallets = query.getResultList();
458
+ if (!wallets.isEmpty()) {
459
+ return session.users().getUserById(session.getContext().getRealm(), wallets.get(0).getUserId());
460
+ }
461
+ return null;
462
+ } catch (NoResultException e) {
463
+ return null;
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Adds a Solana wallet address to a user.
469
+ */
470
+ public static boolean addWalletAddress(KeycloakSession session, UserModel user, String address) {
471
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
472
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
473
+ SiweWalletAddress.class);
474
+ query.setParameter("address", address.trim());
475
+ query.setParameter("realmId", session.getContext().getRealm().getId());
476
+ query.setParameter("addressType", SOLANA_ADDRESS_TYPE);
477
+ List<SiweWalletAddress> existingWallets = query.getResultList();
478
+ for (SiweWalletAddress wallet : existingWallets) {
479
+ if (wallet.getUserId().equals(user.getId())) {
480
+ return false;
481
+ }
482
+ }
483
+ SiweWalletAddress wallet = new SiweWalletAddress();
484
+ wallet.setAddress(address.trim());
485
+ wallet.setAddressType(SOLANA_ADDRESS_TYPE);
486
+ wallet.setUserId(user.getId());
487
+ wallet.setRealmId(session.getContext().getRealm().getId());
488
+ entityManager.persist(wallet);
489
+ return true;
490
+ }
491
+
492
+ /**
493
+ * Removes a Solana wallet address.
494
+ */
495
+ public static boolean removeWalletAddress(KeycloakSession session, String address) {
496
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
497
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByRealmAddressType",
498
+ SiweWalletAddress.class);
499
+ query.setParameter("address", address.trim());
500
+ query.setParameter("realmId", session.getContext().getRealm().getId());
501
+ query.setParameter("addressType", SOLANA_ADDRESS_TYPE);
502
+ try {
503
+ List<SiweWalletAddress> wallets = query.getResultList();
504
+ if (!wallets.isEmpty()) {
505
+ entityManager.remove(wallets.get(0));
506
+ return true;
507
+ }
508
+ return false;
509
+ } catch (NoResultException e) {
510
+ return false;
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Gets all Solana wallet addresses for a user.
516
+ */
517
+ public static List<SiweWalletAddress> getWalletAddresses(KeycloakSession session, UserModel user) {
518
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
519
+ TypedQuery<SiweWalletAddress> query = entityManager.createNamedQuery("findByUserId", SiweWalletAddress.class);
520
+ query.setParameter("userId", user.getId());
521
+ List<SiweWalletAddress> allWallets = query.getResultList();
522
+ // Filter to only Solana wallets
523
+ List<SiweWalletAddress> solanaWallets = new ArrayList<>();
524
+ for (SiweWalletAddress wallet : allWallets) {
525
+ if (SOLANA_ADDRESS_TYPE.equals(wallet.getAddressType())) {
526
+ solanaWallets.add(wallet);
527
+ }
528
+ }
529
+ return solanaWallets;
530
+ }
531
+
532
+ // Private helper methods
533
+
534
+ private static SiweNonce getSiweNonce(KeycloakSession session, String key) {
535
+ RealmModel realm = session.getContext().getRealm();
536
+ EntityManager entityManager = session.getProvider(JpaConnectionProvider.class).getEntityManager();
537
+ SiweNonce siweNonce = entityManager.createNamedQuery("findByRealmKey", SiweNonce.class)
538
+ .setParameter("key", key)
539
+ .setParameter("realmId", realm.getId())
540
+ .getResultList().stream().findFirst().orElse(null);
541
+ return siweNonce;
542
+ }
543
+
544
+ private static String generateNonce() {
545
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(random.generateSeed(8))
546
+ .replaceAll("[^A-Za-z0-9]", "0")
547
+ .substring(0, 8);
548
+ }
549
+
550
+ private static String generateSessionKey(String realmName) {
551
+ byte[] keyBytes = new byte[32];
552
+ random.nextBytes(keyBytes);
553
+ return realmName + "/" + (Base64.getEncoder().encodeToString(keyBytes));
554
+ }
555
+
556
+ private static String generateRandomUsername(KeycloakSession session, RealmModel realm) {
557
+ while (true) {
558
+ String username = String.format("sol_%010d", Math.abs(random.nextLong() % 10000000000L));
559
+ if (KeycloakModelUtils.findUserByNameOrEmail(session, realm, username) == null) {
560
+ return username;
561
+ }
562
+ }
563
+ }
564
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * File: /src/main/java/com/bitspur/keycloak/siwe/siws/authenticators/browser/SiwsBrowserAuthenticator.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 Solana (SIWS).
10
+ */
11
+
12
+ package com.bitspur.keycloak.siwe.siws.authenticators.browser;
13
+
14
+ import com.bitspur.keycloak.siwe.Errors.SiweKeycloakException;
15
+ import com.bitspur.keycloak.siwe.siws.SiwsCredentials;
16
+ import com.bitspur.keycloak.siwe.siws.SiwsUtil;
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 SIWS authenticator.
33
+ * Handles the login form submission with Solana wallet signature.
34
+ */
35
+ public class SiwsBrowserAuthenticator implements Authenticator {
36
+ private static final Logger logger = Logger.getLogger(SiwsBrowserAuthenticator.class);
37
+
38
+ @Override
39
+ public void authenticate(AuthenticationFlowContext context) {
40
+ // Display the SIWS 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 SIWS 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
+ SiwsCredentials siwsCredentials = new SiwsCredentials(signature, message);
64
+ UserModel user = SiwsUtil.findOrCreateUser(context, siwsCredentials, true);
65
+
66
+ if (user == null) {
67
+ // findOrCreateUser already issued failure challenge
68
+ return;
69
+ }
70
+
71
+ // Verify the credentials
72
+ if (!SiwsUtil.verifySiwsCredentials(context.getSession(), context.getAuthenticatorConfig(), siwsCredentials)) {
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("SIWS authentication error", e);
85
+ context.getEvent().error("siws_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
+ }