A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add GUI authentication and fix build configuration

Major improvements to build system, authentication flow, and user experience:

Build System:
- Configure common source set for shared security/network code
- Fix compilation errors by properly organizing source sets
- Add Mod Menu integration for configuration GUI

Client-Side Authentication:
- Replace Slingshot with official Bluesky identity resolution APIs
- Use com.atproto.identity.resolveHandle for standard handle resolution
- Improve error handling and user feedback
- Add /atproto config and /atproto gui commands

New Features:
- Add AtProtoConfigScreen for GUI-based authentication
- Integrate with Mod Menu for easy access to settings
- No-typing-required login flow with visual feedback
- Client-side password handling with security notices

Code Organization:
- Move security classes to src/common/kotlin/com/jollywhoppers/security/
- Move network classes to src/common/kotlin/com/jollywhoppers/network/
- Organize server code under atproto/server/ package
- Organize client code under atproto/client/ package
- Move example code to docs/examples/

This update resolves all compilation errors and provides a much better
user experience with GUI-based authentication.

Ewan 6b2d25db 1c136822

+767 -247
+23
build.gradle
··· 18 18 // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. 19 19 // See https://docs.gradle.org/current/userguide/declaring_repositories.html 20 20 // for more information about repositories. 21 + 22 + // Modrinth Maven for Mod Menu 23 + maven { 24 + name = "Modrinth" 25 + url = "https://api.modrinth.com/maven" 26 + } 27 + } 28 + 29 + // Create a common source set for shared code 30 + sourceSets { 31 + common { 32 + kotlin { 33 + srcDir 'src/common/kotlin' 34 + } 35 + } 21 36 } 22 37 23 38 loom { ··· 27 42 "atproto-connect" { 28 43 sourceSet sourceSets.main 29 44 sourceSet sourceSets.client 45 + sourceSet sourceSets.common 30 46 } 31 47 } 32 48 ··· 41 57 // Fabric API. This is technically optional, but you probably want it anyway. 42 58 modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" 43 59 modImplementation "net.fabricmc:fabric-language-kotlin:${project.fabric_kotlin_version}" 60 + 61 + // Mod Menu for configuration UI (from Modrinth) 62 + modImplementation "maven.modrinth:modmenu:11.0.3" 63 + 64 + // Make main and client depend on common 65 + implementation sourceSets.common.output 66 + clientImplementation sourceSets.common.output 44 67 } 45 68 46 69 processResources {
+9 -10
src/client/kotlin/com/jollywhoppers/AtprotoconnectClient.kt
··· 1 1 package com.jollywhoppers 2 2 3 - import com.jollywhoppers.atproto.ClientAtProtoClient 4 - import com.jollywhoppers.atproto.ClientAtProtoCommands 5 - import com.jollywhoppers.atproto.ClientSessionManager 3 + import com.jollywhoppers.atproto.client.ClientAtProtoClient 4 + import com.jollywhoppers.atproto.client.ClientAtProtoCommands 5 + import com.jollywhoppers.atproto.client.ClientSessionManager 6 6 import com.jollywhoppers.network.AtProtoPackets 7 7 import net.fabricmc.api.ClientModInitializer 8 8 import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback ··· 31 31 try { 32 32 // Initialize client-side AT Protocol client 33 33 atProtoClient = ClientAtProtoClient( 34 - slingshotUrl = "https://slingshot.microcosm.blue", 35 - fallbackPdsUrl = "https://bsky.social" 34 + identityServiceUrl = "https://bsky.social" 36 35 ) 37 36 logger.info("Client-side AT Protocol client initialized") 38 37 ··· 55 54 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 56 55 logger.info("atproto-connect client successfully initialized!") 57 56 logger.info("Security features:") 58 - logger.info(" ✓ Client-side authentication") 59 - logger.info(" ✓ Passwords never sent to server") 60 - logger.info(" ✓ Local session storage") 57 + logger.info(" [OK] Client-side authentication") 58 + logger.info(" [OK] Passwords never sent to server") 59 + logger.info(" [OK] Local session storage") 61 60 logger.info("Use /atproto help to see available commands") 62 61 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 63 62 } catch (e: Exception) { ··· 80 79 context.client().execute { 81 80 if (packet.success) { 82 81 Minecraft.getInstance().gui.chat.addMessage( 83 - Component.literal("§a✓ Server confirmed authentication!") 82 + Component.literal("§a[SUCCESS] Server confirmed authentication!") 84 83 .append(Component.literal("\n§7${packet.message}")) 85 84 .append(Component.literal("\n§aYou can now sync your Minecraft data to AT Protocol!")) 86 85 ) 87 86 logger.info("Server confirmed authentication: ${packet.message}") 88 87 } else { 89 88 Minecraft.getInstance().gui.chat.addMessage( 90 - Component.literal("§c✗ Server rejected authentication") 89 + Component.literal("§c[FAILED] Server rejected authentication") 91 90 .append(Component.literal("\n§7${packet.message}")) 92 91 ) 93 92 logger.error("Server rejected authentication: ${packet.message}")
-211
src/client/kotlin/com/jollywhoppers/atproto/ClientAtProtoClient.kt
··· 1 - package com.jollywhoppers.atproto 2 - 3 - import kotlinx.serialization.Serializable 4 - import kotlinx.serialization.json.Json 5 - import org.slf4j.LoggerFactory 6 - import java.net.InetAddress 7 - import java.net.URI 8 - import java.net.http.HttpClient 9 - import java.net.http.HttpRequest 10 - import java.net.http.HttpResponse 11 - import java.time.Duration 12 - 13 - /** 14 - * Client-side AT Protocol client. 15 - * Handles identity resolution, authentication, and XRPC requests directly from the client. 16 - * 17 - * SECURITY: All authentication happens on the client - passwords never leave the player's computer. 18 - */ 19 - class ClientAtProtoClient( 20 - private val slingshotUrl: String = "https://slingshot.microcosm.blue", 21 - private val fallbackPdsUrl: String = "https://bsky.social" 22 - ) { 23 - private val logger = LoggerFactory.getLogger("atproto-connect-client") 24 - 25 - private val httpClient = HttpClient.newBuilder() 26 - .connectTimeout(Duration.ofSeconds(10)) 27 - .followRedirects(HttpClient.Redirect.NEVER) 28 - .build() 29 - 30 - private val json = Json { 31 - ignoreUnknownKeys = true 32 - isLenient = true 33 - prettyPrint = false 34 - } 35 - 36 - @Serializable 37 - data class MiniDoc( 38 - val did: String, 39 - val handle: String, 40 - val pds: String, 41 - val pdsKnown: Boolean = false 42 - ) 43 - 44 - @Serializable 45 - data class CreateSessionRequest( 46 - val identifier: String, 47 - val password: String 48 - ) 49 - 50 - @Serializable 51 - data class CreateSessionResponse( 52 - val did: String, 53 - val handle: String, 54 - val email: String? = null, 55 - val accessJwt: String, 56 - val refreshJwt: String 57 - ) 58 - 59 - @Serializable 60 - data class RefreshSessionRequest( 61 - val refreshJwt: String 62 - ) 63 - 64 - /** 65 - * Resolves an identifier to a MiniDoc using Slingshot. 66 - */ 67 - suspend fun resolveMiniDoc(identifier: String): Result<MiniDoc> = runCatching { 68 - logger.info("Resolving identifier via Slingshot: ${sanitize(identifier)}") 69 - 70 - val url = "$slingshotUrl/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}" 71 - 72 - val request = HttpRequest.newBuilder() 73 - .uri(URI.create(url)) 74 - .GET() 75 - .timeout(Duration.ofSeconds(10)) 76 - .build() 77 - 78 - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 79 - 80 - if (response.statusCode() != 200) { 81 - throw Exception("Resolution failed with status ${response.statusCode()}") 82 - } 83 - 84 - val miniDoc = json.decodeFromString<MiniDoc>(response.body()) 85 - logger.info("Resolved ${miniDoc.handle} -> PDS: ${miniDoc.pds}") 86 - miniDoc 87 - } 88 - 89 - /** 90 - * Creates an authenticated session. 91 - * This is where the actual login happens on the client. 92 - * Password is never sent to your Minecraft server - only to AT Protocol servers. 93 - */ 94 - suspend fun createSession(identifier: String, password: String): Result<CreateSessionResponse> = runCatching { 95 - logger.info("Creating session for: ${sanitize(identifier)}") 96 - 97 - // Resolve to find the correct PDS 98 - val pdsUrl = try { 99 - val miniDoc = resolveMiniDoc(identifier).getOrThrow() 100 - miniDoc.pds 101 - } catch (e: Exception) { 102 - logger.warn("Could not resolve PDS via Slingshot, using fallback") 103 - fallbackPdsUrl 104 - } 105 - 106 - val requestBody = CreateSessionRequest( 107 - identifier = identifier, 108 - password = password // Password only sent to AT Protocol servers, never to Minecraft server 109 - ) 110 - 111 - val url = "$pdsUrl/xrpc/com.atproto.server.createSession" 112 - 113 - val request = HttpRequest.newBuilder() 114 - .uri(URI.create(url)) 115 - .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(CreateSessionRequest.serializer(), requestBody))) 116 - .header("Content-Type", "application/json") 117 - .timeout(Duration.ofSeconds(15)) 118 - .build() 119 - 120 - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 121 - 122 - if (response.statusCode() != 200) { 123 - logger.error("Session creation failed: HTTP ${response.statusCode()}") 124 - throw Exception("Authentication failed - check your credentials") 125 - } 126 - 127 - val session = json.decodeFromString<CreateSessionResponse>(response.body()) 128 - logger.info("Session created successfully") 129 - session 130 - } 131 - 132 - /** 133 - * Refreshes an existing session. 134 - */ 135 - suspend fun refreshSession(refreshJwt: String, pdsUrl: String): Result<CreateSessionResponse> = runCatching { 136 - logger.info("Refreshing session") 137 - 138 - val requestBody = RefreshSessionRequest(refreshJwt = refreshJwt) 139 - 140 - val url = "$pdsUrl/xrpc/com.atproto.server.refreshSession" 141 - 142 - val request = HttpRequest.newBuilder() 143 - .uri(URI.create(url)) 144 - .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(RefreshSessionRequest.serializer(), requestBody))) 145 - .header("Content-Type", "application/json") 146 - .header("Authorization", "Bearer $refreshJwt") 147 - .timeout(Duration.ofSeconds(15)) 148 - .build() 149 - 150 - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 151 - 152 - if (response.statusCode() != 200) { 153 - throw Exception("Session refresh failed") 154 - } 155 - 156 - json.decodeFromString<CreateSessionResponse>(response.body()) 157 - } 158 - 159 - /** 160 - * Makes an authenticated XRPC request. 161 - */ 162 - suspend fun xrpcRequest( 163 - method: String, 164 - endpoint: String, 165 - accessJwt: String, 166 - pdsUrl: String, 167 - body: String? = null 168 - ): Result<String> = runCatching { 169 - val url = "$pdsUrl/xrpc/$endpoint" 170 - 171 - val requestBuilder = HttpRequest.newBuilder() 172 - .uri(URI.create(url)) 173 - .header("Authorization", "Bearer $accessJwt") 174 - .header("Content-Type", "application/json") 175 - .timeout(Duration.ofSeconds(15)) 176 - 177 - val request = when (method.uppercase()) { 178 - "GET" -> requestBuilder.GET().build() 179 - "POST" -> requestBuilder.POST( 180 - HttpRequest.BodyPublishers.ofString(body ?: "{}") 181 - ).build() 182 - "PUT" -> requestBuilder.PUT( 183 - HttpRequest.BodyPublishers.ofString(body ?: "{}") 184 - ).build() 185 - "DELETE" -> requestBuilder.DELETE().build() 186 - else -> throw IllegalArgumentException("Unsupported HTTP method") 187 - } 188 - 189 - val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 190 - 191 - if (response.statusCode() !in 200..299) { 192 - throw Exception("Request failed with status ${response.statusCode()}") 193 - } 194 - 195 - response.body() 196 - } 197 - 198 - private fun encodeURIComponent(value: String): String { 199 - return URI(null, null, null, -1, null, null, null) 200 - .resolve(value) 201 - .rawSchemeSpecificPart 202 - .replace("+", "%20") 203 - } 204 - 205 - private fun sanitize(input: String): String { 206 - return when { 207 - input.length <= 8 -> "***" 208 - else -> "${input.take(4)}...${input.takeLast(4)}" 209 - } 210 - } 211 - }
+26 -2
src/client/kotlin/com/jollywhoppers/atproto/ClientAtProtoCommands.kt src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoCommands.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.client 2 2 3 3 import com.jollywhoppers.network.AtProtoPackets 4 + import com.jollywhoppers.screen.AtProtoConfigScreen 4 5 import com.mojang.brigadier.CommandDispatcher 5 6 import com.mojang.brigadier.arguments.StringArgumentType 6 7 import com.mojang.brigadier.context.CommandContext ··· 51 52 .then( 52 53 ClientCommandManager.literal("help") 53 54 .executes { context -> help(context) } 55 + ) 56 + .then( 57 + ClientCommandManager.literal("config") 58 + .executes { context -> openConfigScreen(context) } 59 + ) 60 + .then( 61 + ClientCommandManager.literal("gui") 62 + .executes { context -> openConfigScreen(context) } 54 63 ) 55 64 .executes { context -> help(context) } 56 65 ) ··· 153 162 } 154 163 155 164 /** 165 + * Opens the configuration/login screen. 166 + */ 167 + private fun openConfigScreen(context: CommandContext<FabricClientCommandSource>): Int { 168 + Minecraft.getInstance().execute { 169 + Minecraft.getInstance().setScreen(AtProtoConfigScreen(Minecraft.getInstance().screen)) 170 + } 171 + return 1 172 + } 173 + 174 + /** 156 175 * Shows help information. 157 176 */ 158 177 private fun help(context: CommandContext<FabricClientCommandSource>): Int { 159 178 context.source.sendFeedback( 160 179 Component.literal("§b━━━ AT Protocol Commands (Client-Side) ━━━") 180 + .append(Component.literal("\n§f/atproto config §7or §f/atproto gui")) 181 + .append(Component.literal("\n §7Open the settings GUI (Recommended!)")) 182 + .append(Component.literal("\n §7Easy login interface with no typing needed")) 183 + .append(Component.literal("\n")) 161 184 .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 162 - .append(Component.literal("\n §7Authenticate with your AT Protocol account")) 185 + .append(Component.literal("\n §7Authenticate via command (if you prefer)")) 163 186 .append(Component.literal("\n §7Example: §f/atproto login alice.bsky.social my-app-password")) 164 187 .append(Component.literal("\n §c§lIMPORTANT: Use an App Password, not your main password!")) 165 188 .append(Component.literal("\n §7Get one from: Settings → App Passwords → Add App Password")) ··· 170 193 .append(Component.literal("\n§f/atproto status")) 171 194 .append(Component.literal("\n §7Check your authentication status")) 172 195 .append(Component.literal("\n")) 196 + .append(Component.literal("\n§e💡 Tip: You can also access settings via Mod Menu!")) 173 197 .append(Component.literal("\n§eNote: Authentication happens entirely on your computer.")) 174 198 .append(Component.literal("\n§eYour password never leaves your machine!")) 175 199 )
+1 -1
src/client/kotlin/com/jollywhoppers/atproto/ClientSessionManager.kt src/client/kotlin/com/jollywhoppers/atproto/client/ClientSessionManager.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.client 2 2 3 3 import kotlinx.serialization.Serializable 4 4 import kotlinx.serialization.json.Json
+322
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoClient.kt
··· 1 + package com.jollywhoppers.atproto.client 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.json.Json 5 + import org.slf4j.LoggerFactory 6 + import java.net.URI 7 + import java.net.http.HttpClient 8 + import java.net.http.HttpRequest 9 + import java.net.http.HttpResponse 10 + import java.time.Duration 11 + 12 + /** 13 + * Client-side AT Protocol client using OFFICIAL Bluesky identity resolution. 14 + * Handles identity resolution, authentication, and XRPC requests directly from the client. 15 + * 16 + * SECURITY: All authentication happens on the client - passwords never leave the player's computer. 17 + */ 18 + class ClientAtProtoClient( 19 + private val identityServiceUrl: String = "https://bsky.social" 20 + ) { 21 + private val logger = LoggerFactory.getLogger("atproto-connect-client") 22 + 23 + private val httpClient = HttpClient.newBuilder() 24 + .connectTimeout(Duration.ofSeconds(10)) 25 + .followRedirects(HttpClient.Redirect.NEVER) 26 + .build() 27 + 28 + private val json = Json { 29 + ignoreUnknownKeys = true 30 + isLenient = true 31 + prettyPrint = false 32 + } 33 + 34 + @Serializable 35 + data class ResolveHandleResponse( 36 + val did: String 37 + ) 38 + 39 + @Serializable 40 + data class DIDDocument( 41 + val id: String, 42 + val alsoKnownAs: List<String>? = null, 43 + val verificationMethod: List<VerificationMethod>? = null, 44 + val service: List<Service>? = null 45 + ) 46 + 47 + @Serializable 48 + data class VerificationMethod( 49 + val id: String, 50 + val type: String, 51 + val controller: String, 52 + val publicKeyMultibase: String? = null 53 + ) 54 + 55 + @Serializable 56 + data class Service( 57 + val id: String, 58 + val type: String, 59 + val serviceEndpoint: String 60 + ) 61 + 62 + @Serializable 63 + data class CreateSessionRequest( 64 + val identifier: String, 65 + val password: String 66 + ) 67 + 68 + @Serializable 69 + data class CreateSessionResponse( 70 + val did: String, 71 + val handle: String, 72 + val email: String? = null, 73 + val accessJwt: String, 74 + val refreshJwt: String 75 + ) 76 + 77 + @Serializable 78 + data class RefreshSessionRequest( 79 + val refreshJwt: String 80 + ) 81 + 82 + @Serializable 83 + data class ErrorResponse( 84 + val error: String? = null, 85 + val message: String? = null 86 + ) 87 + 88 + /** 89 + * Resolves a handle to a DID using the OFFICIAL Bluesky identity resolution endpoint. 90 + */ 91 + suspend fun resolveHandle(handle: String): Result<String> = runCatching { 92 + logger.info("Resolving handle via official endpoint: ${sanitize(handle)}") 93 + 94 + // OFFICIAL: Use com.atproto.identity.resolveHandle on bsky.social 95 + val url = "$identityServiceUrl/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}" 96 + 97 + val request = HttpRequest.newBuilder() 98 + .uri(URI.create(url)) 99 + .GET() 100 + .timeout(Duration.ofSeconds(10)) 101 + .build() 102 + 103 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 104 + 105 + if (response.statusCode() != 200) { 106 + throw Exception("Handle resolution failed with status ${response.statusCode()}") 107 + } 108 + 109 + val resolveResponse = json.decodeFromString<ResolveHandleResponse>(response.body()) 110 + logger.info("Resolved handle to DID: ${sanitize(resolveResponse.did)}") 111 + resolveResponse.did 112 + } 113 + 114 + /** 115 + * Resolves a DID to its DID Document to find the PDS endpoint. 116 + */ 117 + suspend fun resolveDID(did: String): Result<String> = runCatching { 118 + logger.info("Resolving DID document: ${sanitize(did)}") 119 + 120 + // OFFICIAL: Resolve DID document 121 + when { 122 + did.startsWith("did:plc:") -> { 123 + // PLC directory 124 + val url = "https://plc.directory/$did" 125 + val request = HttpRequest.newBuilder() 126 + .uri(URI.create(url)) 127 + .GET() 128 + .timeout(Duration.ofSeconds(10)) 129 + .build() 130 + 131 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 132 + 133 + if (response.statusCode() != 200) { 134 + throw Exception("DID resolution failed with status ${response.statusCode()}") 135 + } 136 + 137 + val didDoc = json.decodeFromString<DIDDocument>(response.body()) 138 + 139 + // Find the PDS service endpoint 140 + val pdsService = didDoc.service?.find { it.type == "AtprotoPersonalDataServer" } 141 + ?: throw Exception("No PDS service found in DID document") 142 + 143 + logger.info("Found PDS: ${pdsService.serviceEndpoint}") 144 + pdsService.serviceEndpoint 145 + } 146 + did.startsWith("did:web:") -> { 147 + // did:web resolution 148 + val domain = did.removePrefix("did:web:") 149 + val url = "https://$domain/.well-known/did.json" 150 + 151 + val request = HttpRequest.newBuilder() 152 + .uri(URI.create(url)) 153 + .GET() 154 + .timeout(Duration.ofSeconds(10)) 155 + .build() 156 + 157 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 158 + 159 + if (response.statusCode() != 200) { 160 + throw Exception("DID resolution failed with status ${response.statusCode()}") 161 + } 162 + 163 + val didDoc = json.decodeFromString<DIDDocument>(response.body()) 164 + 165 + val pdsService = didDoc.service?.find { it.type == "AtprotoPersonalDataServer" } 166 + ?: throw Exception("No PDS service found in DID document") 167 + 168 + logger.info("Found PDS: ${pdsService.serviceEndpoint}") 169 + pdsService.serviceEndpoint 170 + } 171 + else -> throw Exception("Unsupported DID method") 172 + } 173 + } 174 + 175 + /** 176 + * Creates an authenticated session. 177 + * This uses OFFICIAL identity resolution to find the user's PDS, then authenticates there. 178 + * Password is never sent to your Minecraft server - only to AT Protocol servers. 179 + */ 180 + suspend fun createSession(identifier: String, password: String): Result<CreateSessionResponse> = runCatching { 181 + logger.info("Creating session for: ${sanitize(identifier)}") 182 + 183 + // Step 1: Resolve identifier to find PDS 184 + val pdsUrl = if (identifier.startsWith("did:")) { 185 + // Already a DID, resolve directly 186 + resolveDID(identifier).getOrElse { 187 + logger.warn("Could not resolve DID, using fallback PDS") 188 + identityServiceUrl 189 + } 190 + } else { 191 + // It's a handle, resolve to DID first, then to PDS 192 + try { 193 + val did = resolveHandle(identifier).getOrThrow() 194 + resolveDID(did).getOrElse { 195 + logger.warn("Could not resolve PDS from DID, using fallback") 196 + identityServiceUrl 197 + } 198 + } catch (e: Exception) { 199 + logger.warn("Could not resolve identity, using fallback PDS: ${e.message}") 200 + identityServiceUrl 201 + } 202 + } 203 + 204 + logger.info("Authenticating to PDS: $pdsUrl") 205 + 206 + // Step 2: Authenticate to the discovered PDS 207 + val requestBody = CreateSessionRequest( 208 + identifier = identifier, 209 + password = password 210 + ) 211 + 212 + val url = "$pdsUrl/xrpc/com.atproto.server.createSession" 213 + 214 + val request = HttpRequest.newBuilder() 215 + .uri(URI.create(url)) 216 + .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(CreateSessionRequest.serializer(), requestBody))) 217 + .header("Content-Type", "application/json") 218 + .timeout(Duration.ofSeconds(15)) 219 + .build() 220 + 221 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 222 + 223 + if (response.statusCode() != 200) { 224 + logger.error("Session creation failed: HTTP ${response.statusCode()}") 225 + logger.error("Response body: ${response.body()}") 226 + 227 + // Try to parse error response 228 + val errorMessage = try { 229 + val errorResponse = json.decodeFromString<ErrorResponse>(response.body()) 230 + errorResponse.message ?: errorResponse.error ?: "Authentication failed with status ${response.statusCode()}" 231 + } catch (e: Exception) { 232 + "Authentication failed with status ${response.statusCode()}" 233 + } 234 + 235 + throw Exception(errorMessage) 236 + } 237 + 238 + val session = json.decodeFromString<CreateSessionResponse>(response.body()) 239 + logger.info("Session created successfully") 240 + session 241 + } 242 + 243 + /** 244 + * Refreshes an existing session. 245 + */ 246 + suspend fun refreshSession(refreshJwt: String, pdsUrl: String): Result<CreateSessionResponse> = runCatching { 247 + logger.info("Refreshing session") 248 + 249 + val requestBody = RefreshSessionRequest(refreshJwt = refreshJwt) 250 + 251 + val url = "$pdsUrl/xrpc/com.atproto.server.refreshSession" 252 + 253 + val request = HttpRequest.newBuilder() 254 + .uri(URI.create(url)) 255 + .POST(HttpRequest.BodyPublishers.ofString(json.encodeToString(RefreshSessionRequest.serializer(), requestBody))) 256 + .header("Content-Type", "application/json") 257 + .header("Authorization", "Bearer $refreshJwt") 258 + .timeout(Duration.ofSeconds(15)) 259 + .build() 260 + 261 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 262 + 263 + if (response.statusCode() != 200) { 264 + throw Exception("Session refresh failed") 265 + } 266 + 267 + json.decodeFromString<CreateSessionResponse>(response.body()) 268 + } 269 + 270 + /** 271 + * Makes an authenticated XRPC request. 272 + */ 273 + suspend fun xrpcRequest( 274 + method: String, 275 + endpoint: String, 276 + accessJwt: String, 277 + pdsUrl: String, 278 + body: String? = null 279 + ): Result<String> = runCatching { 280 + val url = "$pdsUrl/xrpc/$endpoint" 281 + 282 + val requestBuilder = HttpRequest.newBuilder() 283 + .uri(URI.create(url)) 284 + .header("Authorization", "Bearer $accessJwt") 285 + .header("Content-Type", "application/json") 286 + .timeout(Duration.ofSeconds(15)) 287 + 288 + val request = when (method.uppercase()) { 289 + "GET" -> requestBuilder.GET().build() 290 + "POST" -> requestBuilder.POST( 291 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 292 + ).build() 293 + "PUT" -> requestBuilder.PUT( 294 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 295 + ).build() 296 + "DELETE" -> requestBuilder.DELETE().build() 297 + else -> throw IllegalArgumentException("Unsupported HTTP method") 298 + } 299 + 300 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 301 + 302 + if (response.statusCode() !in 200..299) { 303 + throw Exception("Request failed with status ${response.statusCode()}") 304 + } 305 + 306 + response.body() 307 + } 308 + 309 + private fun encodeURIComponent(value: String): String { 310 + return URI(null, null, null, -1, null, null, null) 311 + .resolve(value) 312 + .rawSchemeSpecificPart 313 + .replace("+", "%20") 314 + } 315 + 316 + private fun sanitize(input: String): String { 317 + return when { 318 + input.length <= 8 -> "***" 319 + else -> "${input.take(4)}...${input.takeLast(4)}" 320 + } 321 + } 322 + }
+17
src/client/kotlin/com/jollywhoppers/config/ModMenuIntegration.kt
··· 1 + package com.jollywhoppers.config 2 + 3 + import com.terraformersmc.modmenu.api.ConfigScreenFactory 4 + import com.terraformersmc.modmenu.api.ModMenuApi 5 + import com.jollywhoppers.screen.AtProtoConfigScreen 6 + 7 + /** 8 + * Mod Menu integration for ATProto Connect. 9 + * Provides a configuration screen for authentication and settings. 10 + */ 11 + class ModMenuIntegration : ModMenuApi { 12 + override fun getModConfigScreenFactory(): ConfigScreenFactory<*> { 13 + return ConfigScreenFactory { parent -> 14 + AtProtoConfigScreen(parent) 15 + } 16 + } 17 + }
+340
src/client/kotlin/com/jollywhoppers/screen/AtProtoConfigScreen.kt
··· 1 + package com.jollywhoppers.screen 2 + 3 + import com.jollywhoppers.AtprotoconnectClient 4 + import com.jollywhoppers.network.AtProtoPackets 5 + import kotlinx.coroutines.CoroutineScope 6 + import kotlinx.coroutines.Dispatchers 7 + import kotlinx.coroutines.launch 8 + import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking 9 + import net.minecraft.client.Minecraft 10 + import net.minecraft.client.gui.GuiGraphics 11 + import net.minecraft.client.gui.components.Button 12 + import net.minecraft.client.gui.components.EditBox 13 + import net.minecraft.client.gui.screens.Screen 14 + import net.minecraft.network.chat.Component 15 + import net.minecraft.util.FormattedCharSequence 16 + import org.slf4j.LoggerFactory 17 + 18 + /** 19 + * Configuration screen for ATProto Connect. 20 + * Provides GUI-based authentication without requiring commands. 21 + * 22 + * Security Features: 23 + * - Client-side only authentication 24 + * - Passwords never sent to server 25 + * - Local session storage 26 + * - Clear visual feedback 27 + */ 28 + class AtProtoConfigScreen(private val parent: Screen?) : Screen(Component.literal("ATProto Connect Settings")) { 29 + 30 + private val logger = LoggerFactory.getLogger("atproto-connect-ui") 31 + private val coroutineScope = CoroutineScope(Dispatchers.IO) 32 + 33 + // UI Components 34 + private var handleField: EditBox? = null 35 + private var passwordField: EditBox? = null 36 + private var loginButton: Button? = null 37 + private var logoutButton: Button? = null 38 + private var statusText: Component? = null 39 + private var isAuthenticating = false 40 + 41 + // Session state 42 + private val sessionManager = AtprotoconnectClient.sessionManager 43 + 44 + override fun init() { 45 + super.init() 46 + 47 + val centerX = width / 2 48 + val startY = 60 49 + 50 + // Title is rendered automatically by Screen 51 + 52 + // Handle input field 53 + handleField = EditBox( 54 + minecraft!!.font, 55 + centerX - 150, 56 + startY, 57 + 300, 58 + 20, 59 + Component.literal("Handle or DID") 60 + ).apply { 61 + setHint(Component.literal("alice.bsky.social or did:plc:...")) 62 + setMaxLength(256) 63 + 64 + if (!sessionManager.hasSession()) { 65 + value = "" 66 + } 67 + } 68 + addRenderableWidget(handleField!!) 69 + 70 + // Password input field 71 + passwordField = EditBox( 72 + minecraft!!.font, 73 + centerX - 150, 74 + startY + 30, 75 + 300, 76 + 20, 77 + Component.literal("App Password") 78 + ).apply { 79 + setHint(Component.literal("App Password (NOT main password)")) 80 + setMaxLength(256) 81 + // Note: Password masking not available in this API version 82 + // The password is client-side only and never sent to the server 83 + } 84 + addRenderableWidget(passwordField!!) 85 + 86 + // Login button 87 + loginButton = Button.builder( 88 + Component.literal("Login"), 89 + Button.OnPress { onLoginClicked() } 90 + ) 91 + .bounds(centerX - 155, startY + 60, 150, 20) 92 + .build() 93 + .also { addRenderableWidget(it) } 94 + 95 + // Logout button 96 + logoutButton = Button.builder( 97 + Component.literal("Logout"), 98 + Button.OnPress { onLogoutClicked() } 99 + ) 100 + .bounds(centerX + 5, startY + 60, 150, 20) 101 + .build() 102 + .also { addRenderableWidget(it) } 103 + 104 + // Done button 105 + addRenderableWidget( 106 + Button.builder( 107 + Component.literal("Done"), 108 + Button.OnPress { onClose() } 109 + ) 110 + .bounds(centerX - 75, height - 30, 150, 20) 111 + .build() 112 + ) 113 + 114 + // Get app password help button 115 + addRenderableWidget( 116 + Button.builder( 117 + Component.literal("How to get App Password?"), 118 + Button.OnPress { onHelpClicked() } 119 + ) 120 + .bounds(centerX - 100, startY + 90, 200, 20) 121 + .build() 122 + ) 123 + 124 + updateUIState() 125 + } 126 + 127 + override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { 128 + // Render background 129 + renderBackground(graphics, mouseX, mouseY, partialTick) 130 + 131 + // Render title 132 + graphics.drawCenteredString( 133 + font, 134 + title, 135 + width / 2, 136 + 15, 137 + 0xFFFFFF 138 + ) 139 + 140 + // Render labels 141 + graphics.drawString( 142 + font, 143 + "Handle or DID:", 144 + width / 2 - 150, 145 + 48, 146 + 0xA0A0A0 147 + ) 148 + 149 + graphics.drawString( 150 + font, 151 + "App Password:", 152 + width / 2 - 150, 153 + 78, 154 + 0xA0A0A0 155 + ) 156 + 157 + // Render status 158 + statusText?.let { status -> 159 + val lines = font.split(status, width - 40) 160 + var y = 140 161 + for (line in lines) { 162 + graphics.drawCenteredString( 163 + font, 164 + line, 165 + width / 2, 166 + y, 167 + 0xFFFFFF 168 + ) 169 + y += 12 170 + } 171 + } 172 + 173 + // Render security notice 174 + val securityNotice = Component.literal("Your password never leaves your computer") 175 + graphics.drawCenteredString( 176 + font, 177 + securityNotice, 178 + width / 2, 179 + height - 50, 180 + 0x40FF40 181 + ) 182 + 183 + super.render(graphics, mouseX, mouseY, partialTick) 184 + } 185 + 186 + private fun onLoginClicked() { 187 + val handle = handleField?.value?.trim() ?: "" 188 + val password = passwordField?.value ?: "" 189 + 190 + if (handle.isEmpty()) { 191 + statusText = Component.literal("§cPlease enter your handle or DID") 192 + return 193 + } 194 + 195 + if (password.isEmpty()) { 196 + statusText = Component.literal("§cPlease enter your app password") 197 + return 198 + } 199 + 200 + isAuthenticating = true 201 + loginButton?.active = false 202 + logoutButton?.active = false 203 + statusText = Component.literal("§eAuthenticating with AT Protocol...") 204 + 205 + coroutineScope.launch { 206 + try { 207 + // Authenticate with AT Protocol servers (client-side only) 208 + val session = sessionManager.createSession(handle, password).getOrThrow() 209 + 210 + // Send authenticated session to server for verification 211 + val packet = AtProtoPackets.AuthenticatePacket( 212 + did = session.did, 213 + handle = session.handle, 214 + pdsUrl = session.pdsUrl, 215 + accessJwt = session.accessJwt, 216 + refreshJwt = session.refreshJwt 217 + ) 218 + ClientPlayNetworking.send(packet) 219 + 220 + // Update UI on main thread 221 + minecraft?.execute { 222 + statusText = Component.literal("§a[SUCCESS] Successfully authenticated!") 223 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 224 + .append(Component.literal("\n§7DID: §f${session.did}")) 225 + .append(Component.literal("\n\n§aYou can now sync your Minecraft data!")) 226 + 227 + // Also send to chat for better visibility 228 + minecraft?.gui?.chat?.addMessage( 229 + Component.literal("§a[SUCCESS] Authenticated with AT Protocol!") 230 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 231 + .append(Component.literal("\n§7Waiting for server confirmation...")) 232 + ) 233 + 234 + // Clear password field 235 + passwordField?.value = "" 236 + 237 + updateUIState() 238 + 239 + logger.info("Successfully authenticated as ${session.handle}, sent session to server") 240 + } 241 + } catch (e: Exception) { 242 + minecraft?.execute { 243 + statusText = Component.literal("§c[FAILED] Authentication failed") 244 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 245 + .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your")) 246 + .append(Component.literal("\n§7AT Protocol account settings")) 247 + .append(Component.literal("\n§c§lNever use your main password!")) 248 + 249 + // Also send to chat 250 + minecraft?.gui?.chat?.addMessage( 251 + Component.literal("§c[FAILED] Authentication failed: ${e.message ?: "Unknown error"}") 252 + ) 253 + 254 + updateUIState() 255 + 256 + logger.error("Authentication failed: ${e.javaClass.simpleName} - ${e.message}") 257 + } 258 + } 259 + } 260 + } 261 + 262 + private fun onLogoutClicked() { 263 + sessionManager.deleteSession() 264 + 265 + // Notify server 266 + val packet = AtProtoPackets.LogoutPacket() 267 + ClientPlayNetworking.send(packet) 268 + 269 + statusText = Component.literal("§a[SUCCESS] Logged out successfully") 270 + .append(Component.literal("\n§7Session cleared from your computer")) 271 + 272 + // Also send to chat 273 + minecraft?.gui?.chat?.addMessage( 274 + Component.literal("§a[SUCCESS] Logged out from AT Protocol") 275 + ) 276 + 277 + // Clear fields 278 + handleField?.value = "" 279 + passwordField?.value = "" 280 + 281 + updateUIState() 282 + 283 + logger.info("Logged out, notified server") 284 + } 285 + 286 + private fun onHelpClicked() { 287 + val helpText = Component.literal("§e§lHow to get an App Password:") 288 + .append(Component.literal("\n\n§71. Go to your AT Protocol account settings")) 289 + .append(Component.literal("\n §7(e.g. Bluesky → Settings → Privacy & Security)")) 290 + .append(Component.literal("\n\n§72. Find \"App Passwords\"")) 291 + .append(Component.literal("\n\n§73. Create a new app password")) 292 + .append(Component.literal("\n §7(e.g. \"Minecraft Server\")")) 293 + .append(Component.literal("\n\n§74. Copy it immediately!")) 294 + .append(Component.literal("\n §7(You won't see it again)")) 295 + .append(Component.literal("\n\n§75. Use it in the password field above")) 296 + .append(Component.literal("\n\n§c§lNEVER use your main account password!")) 297 + .append(Component.literal("\n§cApp Passwords can be revoked anytime.")) 298 + 299 + statusText = helpText 300 + } 301 + 302 + private fun updateUIState() { 303 + val hasSession = sessionManager.hasSession() 304 + 305 + isAuthenticating = false 306 + 307 + handleField?.active = !hasSession 308 + passwordField?.active = !hasSession 309 + loginButton?.active = !hasSession && !isAuthenticating 310 + logoutButton?.active = hasSession 311 + 312 + if (hasSession && statusText == null) { 313 + // Show current session info 314 + coroutineScope.launch { 315 + try { 316 + val session = sessionManager.getSession().getOrThrow() 317 + minecraft?.execute { 318 + statusText = Component.literal("§a[SUCCESS] Currently logged in") 319 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 320 + .append(Component.literal("\n§7DID: §f${session.did}")) 321 + } 322 + } catch (e: Exception) { 323 + // Session exists but couldn't be retrieved 324 + minecraft?.execute { 325 + statusText = Component.literal("§e[WARNING] Session may be expired") 326 + .append(Component.literal("\n§7Try logging out and back in")) 327 + } 328 + } 329 + } 330 + } 331 + } 332 + 333 + override fun onClose() { 334 + minecraft?.setScreen(parent) 335 + } 336 + 337 + override fun isPauseScreen(): Boolean { 338 + return true 339 + } 340 + }
+5 -5
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 1 1 package com.jollywhoppers 2 2 3 - import com.jollywhoppers.atproto.AtProtoClient 4 - import com.jollywhoppers.atproto.AtProtoCommands 5 - import com.jollywhoppers.atproto.AtProtoSessionManager 6 - import com.jollywhoppers.atproto.PlayerIdentityStore 7 - import com.jollywhoppers.atproto.security.SecurityAuditor 3 + import com.jollywhoppers.atproto.server.AtProtoClient 4 + import com.jollywhoppers.atproto.server.AtProtoCommands 5 + import com.jollywhoppers.atproto.server.AtProtoSessionManager 6 + import com.jollywhoppers.atproto.server.PlayerIdentityStore 7 + import com.jollywhoppers.security.SecurityAuditor 8 8 import net.fabricmc.api.ModInitializer 9 9 import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback 10 10 import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents
+4 -4
src/main/kotlin/com/jollywhoppers/atproto/AtProtoClient.kt src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoClient.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.server 2 2 3 - import com.jollywhoppers.atproto.security.SecurityUtils 4 - import com.jollywhoppers.atproto.security.SecurityAuditor 3 + import com.jollywhoppers.security.SecurityUtils 4 + import com.jollywhoppers.security.SecurityAuditor 5 5 import kotlinx.serialization.Serializable 6 6 import kotlinx.serialization.json.Json 7 7 import org.slf4j.LoggerFactory ··· 47 47 val did: String, 48 48 val handle: String, 49 49 val pds: String, 50 - val pdsKnown: Boolean = false 50 + val signing_key: String 51 51 ) 52 52 53 53 @Serializable
+4 -4
src/main/kotlin/com/jollywhoppers/atproto/AtProtoCommands.kt src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoCommands.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.server 2 2 3 - import com.jollywhoppers.atproto.security.RateLimiter 4 - import com.jollywhoppers.atproto.security.SecurityAuditor 5 - import com.jollywhoppers.atproto.security.SecurityUtils 3 + import com.jollywhoppers.security.RateLimiter 4 + import com.jollywhoppers.security.SecurityAuditor 5 + import com.jollywhoppers.security.SecurityUtils 6 6 import com.mojang.brigadier.CommandDispatcher 7 7 import com.mojang.brigadier.arguments.StringArgumentType 8 8 import com.mojang.brigadier.context.CommandContext
+3 -3
src/main/kotlin/com/jollywhoppers/atproto/AtProtoSessionManager.kt src/main/kotlin/com/jollywhoppers/atproto/server/AtProtoSessionManager.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.server 2 2 3 - import com.jollywhoppers.atproto.security.SecurityUtils 4 - import com.jollywhoppers.atproto.security.SecurityAuditor 3 + import com.jollywhoppers.security.SecurityUtils 4 + import com.jollywhoppers.security.SecurityAuditor 5 5 import kotlinx.serialization.Serializable 6 6 import kotlinx.serialization.json.Json 7 7 import kotlinx.serialization.encodeToString
+2 -2
src/main/kotlin/com/jollywhoppers/atproto/PlayerIdentityStore.kt src/main/kotlin/com/jollywhoppers/atproto/server/PlayerIdentityStore.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.server 2 2 3 - import com.jollywhoppers.atproto.security.SecurityUtils 3 + import com.jollywhoppers.security.SecurityUtils 4 4 import kotlinx.serialization.Serializable 5 5 import kotlinx.serialization.json.Json 6 6 import kotlinx.serialization.encodeToString
+2 -2
src/main/kotlin/com/jollywhoppers/atproto/RecordManager.kt src/main/kotlin/com/jollywhoppers/atproto/server/RecordManager.kt
··· 1 - package com.jollywhoppers.atproto 1 + package com.jollywhoppers.atproto.server 2 2 3 - import com.jollywhoppers.atproto.security.SecurityUtils 3 + import com.jollywhoppers.security.SecurityUtils 4 4 import kotlinx.serialization.Serializable 5 5 import kotlinx.serialization.json.* 6 6 import kotlinx.serialization.serializer
src/main/kotlin/com/jollywhoppers/atproto/examples/RecordCreationExample.kt docs/examples/RecordCreationExample.kt
src/main/kotlin/com/jollywhoppers/atproto/examples/RecordManagerExamples.kt docs/examples/RecordManagerExamples.kt
+1 -1
src/main/kotlin/com/jollywhoppers/atproto/security/RateLimiter.kt src/common/kotlin/com/jollywhoppers/security/RateLimiter.kt
··· 1 - package com.jollywhoppers.atproto.security 1 + package com.jollywhoppers.security 2 2 3 3 import org.slf4j.LoggerFactory 4 4 import java.time.Instant
+1 -1
src/main/kotlin/com/jollywhoppers/atproto/security/SecurityAuditor.kt src/common/kotlin/com/jollywhoppers/security/SecurityAuditor.kt
··· 1 - package com.jollywhoppers.atproto.security 1 + package com.jollywhoppers.security 2 2 3 3 import org.slf4j.LoggerFactory 4 4 import java.nio.file.Files
+1 -1
src/main/kotlin/com/jollywhoppers/atproto/security/SecurityUtils.kt src/common/kotlin/com/jollywhoppers/security/SecurityUtils.kt
··· 1 - package com.jollywhoppers.atproto.security 1 + package com.jollywhoppers.security 2 2 3 3 import kotlinx.serialization.Serializable 4 4 import kotlinx.serialization.encodeToString
src/main/kotlin/com/jollywhoppers/network/AtProtoPackets.kt src/common/kotlin/com/jollywhoppers/network/AtProtoPackets.kt
src/main/kotlin/com/jollywhoppers/network/ServerNetworkHandler.kt src/common/kotlin/com/jollywhoppers/network/ServerNetworkHandler.kt
+6
src/main/resources/fabric.mod.json
··· 28 28 "value": "com.jollywhoppers.AtprotoconnectClient", 29 29 "adapter": "kotlin" 30 30 } 31 + ], 32 + "modmenu": [ 33 + { 34 + "value": "com.jollywhoppers.config.ModMenuIntegration", 35 + "adapter": "kotlin" 36 + } 31 37 ] 32 38 }, 33 39 "mixins": [