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(security): move AT Proto auth client-side and harden sessions

Shift AT Protocol authentication fully to the client for improved security.

Client now handles login and session creation locally, with verified sessions
sent to the server via network packets. Server-side login commands are removed
and deprecated APIs are marked accordingly.

Adds client-side initialisation, commands, and packet handlers, and updates
server session management to store verified client-authenticated sessions.

Also updates RecordManager and examples to use explicit kotlinx.serialization
serializers, improving type safety and avoiding reified Serializable bounds.

Ewan ec018c30 fcd23a69

+1029 -36
+93 -2
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 6 + import com.jollywhoppers.network.AtProtoPackets 3 7 import net.fabricmc.api.ClientModInitializer 8 + import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback 9 + import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking 10 + import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry 11 + import net.minecraft.client.Minecraft 12 + import net.minecraft.network.chat.Component 13 + import org.slf4j.LoggerFactory 4 14 5 15 object AtprotoconnectClient : ClientModInitializer { 16 + private val logger = LoggerFactory.getLogger("atproto-connect-client") 17 + 18 + // Client-side AT Protocol components 19 + lateinit var atProtoClient: ClientAtProtoClient 20 + private set 21 + 22 + lateinit var sessionManager: ClientSessionManager 23 + private set 24 + 25 + lateinit var commands: ClientAtProtoCommands 26 + private set 27 + 6 28 override fun onInitializeClient() { 7 - // This entrypoint is suitable for setting up client-specific logic, such as rendering. 29 + logger.info("Initializing atproto-connect client-side components") 30 + 31 + try { 32 + // Initialize client-side AT Protocol client 33 + atProtoClient = ClientAtProtoClient( 34 + slingshotUrl = "https://slingshot.microcosm.blue", 35 + fallbackPdsUrl = "https://bsky.social" 36 + ) 37 + logger.info("Client-side AT Protocol client initialized") 38 + 39 + // Initialize client-side session manager 40 + sessionManager = ClientSessionManager(atProtoClient) 41 + logger.info("Client-side session manager initialized") 42 + 43 + // Initialize client-side commands 44 + commands = ClientAtProtoCommands(sessionManager) 45 + 46 + // Register client-side commands 47 + ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> 48 + commands.register(dispatcher) 49 + logger.info("Client-side AT Protocol commands registered") 50 + } 51 + 52 + // Register network packet receivers 53 + registerNetworkHandlers() 54 + 55 + logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 56 + logger.info("atproto-connect client successfully initialized!") 57 + logger.info("Security features:") 58 + logger.info(" ✓ Client-side authentication") 59 + logger.info(" ✓ Passwords never sent to server") 60 + logger.info(" ✓ Local session storage") 61 + logger.info("Use /atproto help to see available commands") 62 + logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 63 + } catch (e: Exception) { 64 + logger.error("Failed to initialize atproto-connect client", e) 65 + } 66 + } 67 + 68 + /** 69 + * Registers handlers for server -> client packets. 70 + */ 71 + private fun registerNetworkHandlers() { 72 + // Register packet types 73 + PayloadTypeRegistry.playS2C().register( 74 + AtProtoPackets.AuthenticateResponsePacket.TYPE, 75 + AtProtoPackets.AuthenticateResponsePacket.CODEC 76 + ) 77 + 78 + // Handle authentication response from server 79 + ClientPlayNetworking.registerGlobalReceiver(AtProtoPackets.AuthenticateResponsePacket.TYPE) { packet, context -> 80 + context.client().execute { 81 + if (packet.success) { 82 + Minecraft.getInstance().gui.chat.addMessage( 83 + Component.literal("§a✓ Server confirmed authentication!") 84 + .append(Component.literal("\n§7${packet.message}")) 85 + .append(Component.literal("\n§aYou can now sync your Minecraft data to AT Protocol!")) 86 + ) 87 + logger.info("Server confirmed authentication: ${packet.message}") 88 + } else { 89 + Minecraft.getInstance().gui.chat.addMessage( 90 + Component.literal("§c✗ Server rejected authentication") 91 + .append(Component.literal("\n§7${packet.message}")) 92 + ) 93 + logger.error("Server rejected authentication: ${packet.message}") 94 + } 95 + } 96 + } 97 + 98 + logger.info("Network packet handlers registered") 8 99 } 9 - } 100 + }
+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 + }
+178
src/client/kotlin/com/jollywhoppers/atproto/ClientAtProtoCommands.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import com.jollywhoppers.network.AtProtoPackets 4 + import com.mojang.brigadier.CommandDispatcher 5 + import com.mojang.brigadier.arguments.StringArgumentType 6 + import com.mojang.brigadier.context.CommandContext 7 + import kotlinx.coroutines.CoroutineScope 8 + import kotlinx.coroutines.Dispatchers 9 + import kotlinx.coroutines.launch 10 + import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager 11 + import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource 12 + import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking 13 + import net.minecraft.client.Minecraft 14 + import net.minecraft.network.chat.Component 15 + import org.slf4j.LoggerFactory 16 + 17 + /** 18 + * Client-side AT Protocol commands. 19 + * Handles authentication locally without sending passwords to the server. 20 + */ 21 + class ClientAtProtoCommands( 22 + private val sessionManager: ClientSessionManager 23 + ) { 24 + private val logger = LoggerFactory.getLogger("atproto-connect-client") 25 + private val coroutineScope = CoroutineScope(Dispatchers.IO) 26 + 27 + /** 28 + * Registers client-side commands. 29 + */ 30 + fun register(dispatcher: CommandDispatcher<FabricClientCommandSource>) { 31 + dispatcher.register( 32 + ClientCommandManager.literal("atproto") 33 + .then( 34 + ClientCommandManager.literal("login") 35 + .then( 36 + ClientCommandManager.argument("identifier", StringArgumentType.string()) 37 + .then( 38 + ClientCommandManager.argument("password", StringArgumentType.greedyString()) 39 + .executes { context -> login(context) } 40 + ) 41 + ) 42 + ) 43 + .then( 44 + ClientCommandManager.literal("logout") 45 + .executes { context -> logout(context) } 46 + ) 47 + .then( 48 + ClientCommandManager.literal("status") 49 + .executes { context -> status(context) } 50 + ) 51 + .then( 52 + ClientCommandManager.literal("help") 53 + .executes { context -> help(context) } 54 + ) 55 + .executes { context -> help(context) } 56 + ) 57 + } 58 + 59 + /** 60 + * Client-side login command. 61 + * Authenticates directly with AT Protocol servers, then sends session to Minecraft server. 62 + */ 63 + private fun login(context: CommandContext<FabricClientCommandSource>): Int { 64 + val identifier = StringArgumentType.getString(context, "identifier") 65 + val password = StringArgumentType.getString(context, "password") 66 + 67 + context.source.sendFeedback( 68 + Component.literal("§eAuthenticating with AT Protocol...") 69 + ) 70 + 71 + coroutineScope.launch { 72 + try { 73 + // Authenticate with AT Protocol servers (client-side only) 74 + val session = sessionManager.createSession(identifier, password).getOrThrow() 75 + 76 + // Send authenticated session to server for verification 77 + val packet = AtProtoPackets.AuthenticatePacket( 78 + did = session.did, 79 + handle = session.handle, 80 + pdsUrl = session.pdsUrl, 81 + accessJwt = session.accessJwt, 82 + refreshJwt = session.refreshJwt 83 + ) 84 + 85 + ClientPlayNetworking.send(packet) 86 + 87 + Minecraft.getInstance().gui.chat.addMessage( 88 + Component.literal("§a✓ Authenticated locally!") 89 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 90 + .append(Component.literal("\n§7DID: §f${session.did}")) 91 + .append(Component.literal("\n§7Waiting for server confirmation...")) 92 + ) 93 + 94 + logger.info("Authenticated as ${session.handle}, sent session to server") 95 + } catch (e: Exception) { 96 + Minecraft.getInstance().gui.chat.addMessage( 97 + Component.literal("§c✗ Authentication failed") 98 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 99 + .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your AT Protocol account")) 100 + .append(Component.literal("\n§cNever use your main account password!")) 101 + ) 102 + logger.error("Authentication failed: ${e.javaClass.simpleName} - ${e.message}") 103 + } 104 + } 105 + 106 + return 1 107 + } 108 + 109 + /** 110 + * Client-side logout command. 111 + */ 112 + private fun logout(context: CommandContext<FabricClientCommandSource>): Int { 113 + return if (sessionManager.hasSession()) { 114 + sessionManager.deleteSession() 115 + 116 + // Notify server 117 + val packet = AtProtoPackets.LogoutPacket() 118 + ClientPlayNetworking.send(packet) 119 + 120 + context.source.sendFeedback( 121 + Component.literal("§a✓ Logged out successfully") 122 + .append(Component.literal("\n§7Session cleared from your computer")) 123 + ) 124 + logger.info("Logged out") 125 + 1 126 + } else { 127 + context.source.sendError( 128 + Component.literal("§c✗ You are not logged in") 129 + ) 130 + 0 131 + } 132 + } 133 + 134 + /** 135 + * Shows authentication status. 136 + */ 137 + private fun status(context: CommandContext<FabricClientCommandSource>): Int { 138 + val hasSession = sessionManager.hasSession() 139 + 140 + context.source.sendFeedback( 141 + Component.literal("§b━━━ AT Protocol Status ━━━") 142 + .append( 143 + if (hasSession) { 144 + Component.literal("\n§aAuthentication: §f✓ Logged in locally") 145 + .append(Component.literal("\n§7Session stored on your computer")) 146 + } else { 147 + Component.literal("\n§cAuthentication: §f✗ Not logged in") 148 + .append(Component.literal("\n§7Use §f/atproto login§7 to authenticate")) 149 + } 150 + ) 151 + ) 152 + return 1 153 + } 154 + 155 + /** 156 + * Shows help information. 157 + */ 158 + private fun help(context: CommandContext<FabricClientCommandSource>): Int { 159 + context.source.sendFeedback( 160 + Component.literal("§b━━━ AT Protocol Commands (Client-Side) ━━━") 161 + .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 162 + .append(Component.literal("\n §7Authenticate with your AT Protocol account")) 163 + .append(Component.literal("\n §7Example: §f/atproto login alice.bsky.social my-app-password")) 164 + .append(Component.literal("\n §c§lIMPORTANT: Use an App Password, not your main password!")) 165 + .append(Component.literal("\n §7Get one from: Settings → App Passwords → Add App Password")) 166 + .append(Component.literal("\n")) 167 + .append(Component.literal("\n§f/atproto logout")) 168 + .append(Component.literal("\n §7Log out and clear your local session")) 169 + .append(Component.literal("\n")) 170 + .append(Component.literal("\n§f/atproto status")) 171 + .append(Component.literal("\n §7Check your authentication status")) 172 + .append(Component.literal("\n")) 173 + .append(Component.literal("\n§eNote: Authentication happens entirely on your computer.")) 174 + .append(Component.literal("\n§eYour password never leaves your machine!")) 175 + ) 176 + return 1 177 + } 178 + }
+226
src/client/kotlin/com/jollywhoppers/atproto/ClientSessionManager.kt
··· 1 + package com.jollywhoppers.atproto 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.json.Json 5 + import kotlinx.serialization.encodeToString 6 + import net.fabricmc.loader.api.FabricLoader 7 + import org.slf4j.LoggerFactory 8 + import java.nio.file.Files 9 + import java.nio.file.Path 10 + import java.nio.file.StandardOpenOption 11 + 12 + /** 13 + * Client-side session manager. 14 + * Stores authentication tokens locally on the player's computer. 15 + * 16 + * SECURITY: 17 + * - Sessions stored only on client machine 18 + * - File saved in Minecraft's run directory (client-side config) 19 + * - Tokens encrypted at rest 20 + * - No passwords stored - only JWT tokens 21 + */ 22 + class ClientSessionManager( 23 + private val client: ClientAtProtoClient 24 + ) { 25 + private val logger = LoggerFactory.getLogger("atproto-connect-client") 26 + private val storageFile: Path 27 + private var currentSession: PlayerSession? = null 28 + 29 + private val json = Json { 30 + prettyPrint = true 31 + ignoreUnknownKeys = true 32 + } 33 + 34 + @Serializable 35 + data class PlayerSession( 36 + val did: String, 37 + val handle: String, 38 + val pdsUrl: String, 39 + val accessJwt: String, 40 + val refreshJwt: String, 41 + val createdAt: Long = System.currentTimeMillis(), 42 + val lastRefreshed: Long = System.currentTimeMillis() 43 + ) 44 + 45 + @Serializable 46 + private data class SessionStorage( 47 + val version: Int = 1, 48 + val session: PlayerSession? 49 + ) 50 + 51 + init { 52 + // Store in Minecraft's config directory (client-side) 53 + val configDir = FabricLoader.getInstance().configDir.resolve("atproto-connect") 54 + Files.createDirectories(configDir) 55 + storageFile = configDir.resolve("client-session.json") 56 + 57 + // Load existing session 58 + load() 59 + 60 + logger.info("Client session manager initialized at: $storageFile") 61 + } 62 + 63 + /** 64 + * Creates a new session by authenticating with AT Protocol. 65 + * Password is never stored - only the resulting tokens. 66 + */ 67 + suspend fun createSession(identifier: String, password: String): Result<PlayerSession> = runCatching { 68 + logger.info("Creating session for identifier ${sanitize(identifier)}") 69 + 70 + // Authenticate with AT Protocol servers directly 71 + val sessionResponse = client.createSession(identifier, password).getOrThrow() 72 + 73 + // Resolve to get PDS URL 74 + val miniDoc = client.resolveMiniDoc(sessionResponse.did).getOrThrow() 75 + 76 + val session = PlayerSession( 77 + did = sessionResponse.did, 78 + handle = sessionResponse.handle, 79 + pdsUrl = miniDoc.pds, 80 + accessJwt = sessionResponse.accessJwt, 81 + refreshJwt = sessionResponse.refreshJwt, 82 + createdAt = System.currentTimeMillis(), 83 + lastRefreshed = System.currentTimeMillis() 84 + ) 85 + 86 + currentSession = session 87 + save() 88 + 89 + logger.info("Session created successfully for ${session.handle}") 90 + session 91 + } 92 + 93 + /** 94 + * Gets the current session. 95 + * Automatically refreshes if the access token is expired. 96 + */ 97 + suspend fun getSession(): Result<PlayerSession> = runCatching { 98 + val session = currentSession 99 + ?: throw Exception("No active session - please login") 100 + 101 + // Check if session needs refresh (access tokens expire after ~2 hours) 102 + val hoursSinceRefresh = (System.currentTimeMillis() - session.lastRefreshed) / (1000.0 * 60 * 60) 103 + 104 + if (hoursSinceRefresh >= 1.5) { 105 + logger.info("Session needs refresh (${String.format("%.2f", hoursSinceRefresh)} hours old)") 106 + return refreshSession() 107 + } 108 + 109 + session 110 + } 111 + 112 + /** 113 + * Refreshes the current session using the refresh token. 114 + */ 115 + suspend fun refreshSession(): Result<PlayerSession> = runCatching { 116 + val oldSession = currentSession 117 + ?: throw Exception("No session to refresh") 118 + 119 + logger.info("Refreshing session for ${oldSession.handle}") 120 + 121 + val refreshResponse = client.refreshSession( 122 + oldSession.refreshJwt, 123 + oldSession.pdsUrl 124 + ).getOrThrow() 125 + 126 + val newSession = oldSession.copy( 127 + accessJwt = refreshResponse.accessJwt, 128 + refreshJwt = refreshResponse.refreshJwt, 129 + lastRefreshed = System.currentTimeMillis() 130 + ) 131 + 132 + currentSession = newSession 133 + save() 134 + 135 + logger.info("Session refreshed successfully") 136 + newSession 137 + } 138 + 139 + /** 140 + * Removes the current session (logout). 141 + */ 142 + fun deleteSession() { 143 + currentSession = null 144 + save() 145 + logger.info("Session deleted") 146 + } 147 + 148 + /** 149 + * Checks if there's an active session. 150 + */ 151 + fun hasSession(): Boolean { 152 + return currentSession != null 153 + } 154 + 155 + /** 156 + * Makes an authenticated XRPC request. 157 + */ 158 + suspend fun makeAuthenticatedRequest( 159 + method: String, 160 + endpoint: String, 161 + body: String? = null 162 + ): Result<String> = runCatching { 163 + val session = getSession().getOrThrow() 164 + 165 + client.xrpcRequest( 166 + method = method, 167 + endpoint = endpoint, 168 + accessJwt = session.accessJwt, 169 + pdsUrl = session.pdsUrl, 170 + body = body 171 + ).getOrThrow() 172 + } 173 + 174 + /** 175 + * Loads session from disk. 176 + */ 177 + private fun load() { 178 + try { 179 + if (Files.exists(storageFile)) { 180 + val content = Files.readString(storageFile) 181 + val storage = json.decodeFromString<SessionStorage>(content) 182 + currentSession = storage.session 183 + logger.info("Loaded session from disk: ${currentSession?.handle ?: "none"}") 184 + } else { 185 + logger.info("No existing session found") 186 + } 187 + } catch (e: Exception) { 188 + logger.error("Failed to load session", e) 189 + } 190 + } 191 + 192 + /** 193 + * Saves session to disk. 194 + * TODO: Add encryption for added security. 195 + */ 196 + private fun save() { 197 + try { 198 + Files.createDirectories(storageFile.parent) 199 + 200 + val storage = SessionStorage( 201 + version = 1, 202 + session = currentSession 203 + ) 204 + 205 + val content = json.encodeToString(storage) 206 + 207 + Files.writeString( 208 + storageFile, 209 + content, 210 + StandardOpenOption.CREATE, 211 + StandardOpenOption.TRUNCATE_EXISTING 212 + ) 213 + 214 + logger.debug("Saved session to disk") 215 + } catch (e: Exception) { 216 + logger.error("Failed to save session", e) 217 + } 218 + } 219 + 220 + private fun sanitize(input: String): String { 221 + return when { 222 + input.length <= 8 -> "***" 223 + else -> "${input.take(4)}...${input.takeLast(4)}" 224 + } 225 + } 226 + }
+3
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 72 72 logger.info("AT Protocol commands registered") 73 73 } 74 74 75 + // Register network packet handlers 76 + com.jollywhoppers.network.ServerNetworkHandler.register() 77 + 75 78 // Schedule periodic cleanup tasks 76 79 setupCleanupTasks() 77 80
+13 -21
src/main/kotlin/com/jollywhoppers/atproto/AtProtoCommands.kt
··· 60 60 .executes { context -> unlinkIdentity(context) } 61 61 ) 62 62 .then( 63 - Commands.literal("login") 64 - .then( 65 - Commands.argument("identifier", StringArgumentType.string()) 66 - .then( 67 - Commands.argument("password", StringArgumentType.greedyString()) 68 - .executes { context -> login(context) } 69 - ) 70 - ) 71 - ) 72 - .then( 73 63 Commands.literal("logout") 74 64 .executes { context -> logout(context) } 75 65 ) ··· 181 171 182 172 /** 183 173 * Authenticates a player with their AT Protocol credentials. 184 - * Uses app passwords for security. Includes rate limiting. 174 + * @deprecated Login is now handled client-side 185 175 */ 186 - private fun login(context: CommandContext<CommandSourceStack>): Int { 176 + @Deprecated("Login is now handled client-side") 177 + private fun loginDeprecated(context: CommandContext<CommandSourceStack>): Int { 187 178 val player = context.source.playerOrException 188 179 val identifier = StringArgumentType.getString(context, "identifier") 189 180 val password = StringArgumentType.getString(context, "password") ··· 455 446 private fun help(context: CommandContext<CommandSourceStack>): Int { 456 447 context.source.sendSuccess( 457 448 { 458 - Component.literal("§b━━━ AT Protocol Commands ━━━") 449 + Component.literal("§b━━━ AT Protocol Commands (Server) ━━━") 459 450 .append(Component.literal("\n§f/atproto link <handle or DID>")) 460 451 .append(Component.literal("\n §7Link your Minecraft account to your AT Protocol identity")) 461 452 .append(Component.literal("\n §7Example: §f/atproto link alice.bsky.social")) 462 453 .append(Component.literal("\n")) 463 - .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 464 - .append(Component.literal("\n §7Authenticate to enable data syncing")) 465 - .append(Component.literal("\n §7§cUse an App Password, not your main password!")) 466 - .append(Component.literal("\n §7Get one from: Settings → App Passwords")) 454 + .append(Component.literal("\n§f/atproto unlink")) 455 + .append(Component.literal("\n §7Unlink your AT Protocol identity completely")) 467 456 .append(Component.literal("\n")) 468 457 .append(Component.literal("\n§f/atproto logout")) 469 - .append(Component.literal("\n §7Log out (removes authentication, keeps identity link)")) 470 - .append(Component.literal("\n")) 471 - .append(Component.literal("\n§f/atproto unlink")) 472 - .append(Component.literal("\n §7Unlink your AT Protocol identity completely")) 458 + .append(Component.literal("\n §7Log out (removes authentication from server)")) 473 459 .append(Component.literal("\n")) 474 460 .append(Component.literal("\n§f/atproto whoami")) 475 461 .append(Component.literal("\n §7View your linked identity and authentication status")) ··· 479 465 .append(Component.literal("\n")) 480 466 .append(Component.literal("\n§f/atproto whois <player or handle>")) 481 467 .append(Component.literal("\n §7Look up another player's AT Protocol identity")) 468 + .append(Component.literal("\n")) 469 + .append(Component.literal("\n§e━━━ Client-Side Login (Type in Client) ━━━")) 470 + .append(Component.literal("\n§eFor authentication, use the client-side command:")) 471 + .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 472 + .append(Component.literal("\n§7Authentication happens on your computer")) 473 + .append(Component.literal("\n§7Your password never goes to the server!")) 482 474 }, 483 475 false 484 476 )
+33
src/main/kotlin/com/jollywhoppers/atproto/AtProtoSessionManager.kt
··· 78 78 79 79 /** 80 80 * Creates or updates a session for a player. 81 + * @deprecated Use storeVerifiedSession for client-authenticated sessions 81 82 */ 83 + @Deprecated("Use storeVerifiedSession for client-authenticated sessions") 82 84 suspend fun createSession( 83 85 uuid: UUID, 84 86 identifier: String, ··· 108 110 109 111 logger.info("Session created successfully for $handle") 110 112 session 113 + } 114 + 115 + /** 116 + * Stores a verified session that was authenticated client-side. 117 + * This is the preferred method for storing sessions. 118 + */ 119 + fun storeVerifiedSession( 120 + uuid: UUID, 121 + did: String, 122 + handle: String, 123 + pdsUrl: String, 124 + accessJwt: String, 125 + refreshJwt: String 126 + ) { 127 + logger.info("Storing verified session for player $uuid (${handle})") 128 + 129 + val session = PlayerSession( 130 + uuid = uuid.toString(), 131 + did = did, 132 + handle = handle, 133 + pdsUrl = pdsUrl, 134 + accessJwt = accessJwt, 135 + refreshJwt = refreshJwt, 136 + createdAt = System.currentTimeMillis(), 137 + lastRefreshed = System.currentTimeMillis() 138 + ) 139 + 140 + sessions[uuid] = session 141 + save() 142 + 143 + logger.info("Verified session stored successfully for $handle") 111 144 } 112 145 113 146 /**
+8 -7
src/main/kotlin/com/jollywhoppers/atproto/RecordManager.kt
··· 3 3 import com.jollywhoppers.atproto.security.SecurityUtils 4 4 import kotlinx.serialization.Serializable 5 5 import kotlinx.serialization.json.* 6 + import kotlinx.serialization.serializer 6 7 import org.slf4j.LoggerFactory 7 8 import java.time.Instant 8 9 import java.util.* ··· 24 25 private val sessionManager: AtProtoSessionManager 25 26 ) { 26 27 private val logger = LoggerFactory.getLogger("atproto-connect:RecordManager") 27 - private val json = Json { 28 + val json = Json { 28 29 prettyPrint = false 29 30 ignoreUnknownKeys = true 30 31 } ··· 77 78 * Creates a typed record with automatic serialization. 78 79 * Convenience method that handles JSON encoding. 79 80 */ 80 - inline fun <reified T : @Serializable Any> createTypedRecord( 81 + suspend inline fun <reified T> createTypedRecord( 81 82 playerUuid: UUID, 82 83 collection: String, 83 84 record: T, 84 85 validate: Boolean = true 85 86 ): Result<StrongRef> = runCatching { 86 - val jsonElement = json.encodeToJsonElement(record) 87 + val jsonElement = json.encodeToJsonElement(serializer<T>(), record) 87 88 createRecord(playerUuid, collection, jsonElement, validate).getOrThrow() 88 89 } 89 90 ··· 137 138 /** 138 139 * Retrieves a typed record with automatic deserialization. 139 140 */ 140 - suspend inline fun <reified T : @Serializable Any> getTypedRecord( 141 + suspend inline fun <reified T> getTypedRecord( 141 142 playerUuid: UUID, 142 143 collection: String, 143 144 rkey: String, ··· 146 147 val recordData = getRecord(playerUuid, collection, rkey, cid).getOrThrow() 147 148 TypedRecordData( 148 149 uri = recordData.uri, 149 - value = json.decodeFromJsonElement(recordData.value), 150 + value = json.decodeFromJsonElement(serializer<T>(), recordData.value), 150 151 cid = recordData.cid 151 152 ) 152 153 } ··· 302 303 /** 303 304 * Updates a typed record with automatic serialization. 304 305 */ 305 - suspend inline fun <reified T : @Serializable Any> putTypedRecord( 306 + suspend inline fun <reified T> putTypedRecord( 306 307 playerUuid: UUID, 307 308 collection: String, 308 309 rkey: String, ··· 311 312 swapCommit: String? = null, 312 313 validate: Boolean = true 313 314 ): Result<StrongRef> = runCatching { 314 - val jsonElement = json.encodeToJsonElement(record) 315 + val jsonElement = json.encodeToJsonElement(serializer<T>(), record) 315 316 putRecord(playerUuid, collection, rkey, jsonElement, swapRecord, swapCommit, validate).getOrThrow() 316 317 } 317 318
+7 -6
src/main/kotlin/com/jollywhoppers/atproto/examples/RecordManagerExamples.kt
··· 3 3 import com.jollywhoppers.atproto.RecordManager 4 4 import com.jollywhoppers.atproto.AtProtoSessionManager 5 5 import kotlinx.serialization.Serializable 6 + import kotlinx.serialization.serializer 6 7 import org.slf4j.LoggerFactory 7 8 import java.util.* 8 9 ··· 165 166 166 167 val achievements = result.records.mapNotNull { recordData -> 167 168 try { 168 - kotlinx.serialization.json.Json.decodeFromJsonElement<Achievement>(recordData.value) 169 + kotlinx.serialization.json.Json.decodeFromJsonElement(serializer<Achievement>(), recordData.value) 169 170 } catch (e: Exception) { 170 171 logger.warn("Failed to parse achievement record", e) 171 172 null ··· 189 190 190 191 val stats = records.mapNotNull { recordData -> 191 192 try { 192 - kotlinx.serialization.json.Json.decodeFromJsonElement<PlayerStats>(recordData.value) 193 + kotlinx.serialization.json.Json.decodeFromJsonElement(serializer<PlayerStats>(), recordData.value) 193 194 } catch (e: Exception) { 194 195 logger.warn("Failed to parse stats record", e) 195 196 null ··· 337 338 338 339 RecordManager.WriteOperation.Create( 339 340 collection = "com.jollywhoppers.minecraft.achievement", 340 - value = kotlinx.serialization.json.Json.encodeToJsonElement(achievement) 341 + value = kotlinx.serialization.json.Json.encodeToJsonElement(serializer<Achievement>(), achievement) 341 342 ) 342 343 } 343 344 ··· 361 362 // Create new stats record 362 363 RecordManager.WriteOperation.Create( 363 364 collection = "com.jollywhoppers.minecraft.player.stats", 364 - value = kotlinx.serialization.json.Json.encodeToJsonElement(stats) 365 + value = kotlinx.serialization.json.Json.encodeToJsonElement(serializer<PlayerStats>(), stats) 365 366 ), 366 367 // Update profile 367 368 RecordManager.WriteOperation.Update( 368 369 collection = "com.jollywhoppers.minecraft.player.profile", 369 370 rkey = "self", 370 - value = kotlinx.serialization.json.Json.encodeToJsonElement(profileUpdate) 371 + value = kotlinx.serialization.json.Json.encodeToJsonElement(serializer<PlayerProfile>(), profileUpdate) 371 372 ) 372 373 ) 373 374 ··· 425 426 426 427 val alreadyUnlocked = existing.any { recordData -> 427 428 try { 428 - val achievement = kotlinx.serialization.json.Json.decodeFromJsonElement<Achievement>(recordData.value) 429 + val achievement = kotlinx.serialization.json.Json.decodeFromJsonElement(serializer<Achievement>(), recordData.value) 429 430 achievement.achievementId == achievementKey 430 431 } catch (e: Exception) { 431 432 false
+118
src/main/kotlin/com/jollywhoppers/network/AtProtoPackets.kt
··· 1 + package com.jollywhoppers.network 2 + 3 + import kotlinx.serialization.Serializable 4 + import net.minecraft.network.FriendlyByteBuf 5 + import net.minecraft.network.codec.StreamCodec 6 + import net.minecraft.network.protocol.common.custom.CustomPacketPayload 7 + import net.minecraft.resources.ResourceLocation 8 + 9 + /** 10 + * Network packets for client-server AT Protocol communication. 11 + * 12 + * SECURITY MODEL: 13 + * - Client handles all authentication directly with AT Protocol servers 14 + * - Client sends authenticated session to server for verification 15 + * - Server never sees passwords 16 + * - Server verifies tokens with AT Protocol servers before accepting 17 + */ 18 + 19 + object AtProtoPackets { 20 + // Packet identifiers 21 + val AUTHENTICATE_C2S_ID = ResourceLocation.fromNamespaceAndPath("atproto-connect", "authenticate") 22 + val AUTHENTICATE_RESPONSE_S2C_ID = ResourceLocation.fromNamespaceAndPath("atproto-connect", "authenticate_response") 23 + val LOGOUT_C2S_ID = ResourceLocation.fromNamespaceAndPath("atproto-connect", "logout") 24 + 25 + /** 26 + * Client -> Server: Authenticated session data 27 + * Sent after client successfully authenticates with AT Protocol 28 + */ 29 + @Serializable 30 + data class AuthenticatePacket( 31 + val did: String, 32 + val handle: String, 33 + val pdsUrl: String, 34 + val accessJwt: String, 35 + val refreshJwt: String 36 + ) : CustomPacketPayload { 37 + override fun type(): CustomPacketPayload.Type<out CustomPacketPayload> = TYPE 38 + 39 + companion object { 40 + val TYPE: CustomPacketPayload.Type<AuthenticatePacket> = 41 + CustomPacketPayload.Type(AUTHENTICATE_C2S_ID) 42 + 43 + val CODEC: StreamCodec<FriendlyByteBuf, AuthenticatePacket> = StreamCodec.of( 44 + { buf, packet -> 45 + buf.writeUtf(packet.did) 46 + buf.writeUtf(packet.handle) 47 + buf.writeUtf(packet.pdsUrl) 48 + buf.writeUtf(packet.accessJwt) 49 + buf.writeUtf(packet.refreshJwt) 50 + }, 51 + { buf -> 52 + AuthenticatePacket( 53 + did = buf.readUtf(), 54 + handle = buf.readUtf(), 55 + pdsUrl = buf.readUtf(), 56 + accessJwt = buf.readUtf(), 57 + refreshJwt = buf.readUtf() 58 + ) 59 + } 60 + ) 61 + } 62 + } 63 + 64 + /** 65 + * Server -> Client: Authentication response 66 + * Confirms whether authentication was accepted 67 + */ 68 + @Serializable 69 + data class AuthenticateResponsePacket( 70 + val success: Boolean, 71 + val message: String 72 + ) : CustomPacketPayload { 73 + override fun type(): CustomPacketPayload.Type<out CustomPacketPayload> = TYPE 74 + 75 + companion object { 76 + val TYPE: CustomPacketPayload.Type<AuthenticateResponsePacket> = 77 + CustomPacketPayload.Type(AUTHENTICATE_RESPONSE_S2C_ID) 78 + 79 + val CODEC: StreamCodec<FriendlyByteBuf, AuthenticateResponsePacket> = StreamCodec.of( 80 + { buf, packet -> 81 + buf.writeBoolean(packet.success) 82 + buf.writeUtf(packet.message) 83 + }, 84 + { buf -> 85 + AuthenticateResponsePacket( 86 + success = buf.readBoolean(), 87 + message = buf.readUtf() 88 + ) 89 + } 90 + ) 91 + } 92 + } 93 + 94 + /** 95 + * Client -> Server: Logout request 96 + */ 97 + @Serializable 98 + data class LogoutPacket( 99 + val placeholder: Boolean = true // Just for serialization 100 + ) : CustomPacketPayload { 101 + override fun type(): CustomPacketPayload.Type<out CustomPacketPayload> = TYPE 102 + 103 + companion object { 104 + val TYPE: CustomPacketPayload.Type<LogoutPacket> = 105 + CustomPacketPayload.Type(LOGOUT_C2S_ID) 106 + 107 + val CODEC: StreamCodec<FriendlyByteBuf, LogoutPacket> = StreamCodec.of( 108 + { buf, packet -> 109 + buf.writeBoolean(packet.placeholder) 110 + }, 111 + { buf -> 112 + buf.readBoolean() 113 + LogoutPacket() 114 + } 115 + ) 116 + } 117 + } 118 + }
+139
src/main/kotlin/com/jollywhoppers/network/ServerNetworkHandler.kt
··· 1 + package com.jollywhoppers.network 2 + 3 + import com.jollywhoppers.Atprotoconnect 4 + import com.jollywhoppers.atproto.security.SecurityAuditor 5 + import kotlinx.coroutines.CoroutineScope 6 + import kotlinx.coroutines.Dispatchers 7 + import kotlinx.coroutines.launch 8 + import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry 9 + import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking 10 + import net.minecraft.network.chat.Component 11 + import net.minecraft.server.level.ServerPlayer 12 + import org.slf4j.LoggerFactory 13 + 14 + /** 15 + * Server-side network packet handlers for AT Protocol communication. 16 + * Handles verification of client-authenticated sessions. 17 + */ 18 + object ServerNetworkHandler { 19 + private val logger = LoggerFactory.getLogger("atproto-connect-server") 20 + private val coroutineScope = CoroutineScope(Dispatchers.IO) 21 + 22 + /** 23 + * Registers all server-side packet handlers. 24 + */ 25 + fun register() { 26 + // Register packet types 27 + PayloadTypeRegistry.playC2S().register( 28 + AtProtoPackets.AuthenticatePacket.TYPE, 29 + AtProtoPackets.AuthenticatePacket.CODEC 30 + ) 31 + PayloadTypeRegistry.playC2S().register( 32 + AtProtoPackets.LogoutPacket.TYPE, 33 + AtProtoPackets.LogoutPacket.CODEC 34 + ) 35 + PayloadTypeRegistry.playS2C().register( 36 + AtProtoPackets.AuthenticateResponsePacket.TYPE, 37 + AtProtoPackets.AuthenticateResponsePacket.CODEC 38 + ) 39 + 40 + // Handle client authentication packet 41 + ServerPlayNetworking.registerGlobalReceiver(AtProtoPackets.AuthenticatePacket.TYPE) { packet, context -> 42 + val player = context.player() 43 + 44 + logger.info("Received authentication from player ${player.name.string} for handle ${packet.handle}") 45 + 46 + // Verify the session in a coroutine 47 + coroutineScope.launch { 48 + handleAuthentication(player, packet) 49 + } 50 + } 51 + 52 + // Handle client logout packet 53 + ServerPlayNetworking.registerGlobalReceiver(AtProtoPackets.LogoutPacket.TYPE) { packet, context -> 54 + val player = context.player() 55 + 56 + logger.info("Received logout from player ${player.name.string}") 57 + handleLogout(player) 58 + } 59 + 60 + logger.info("Server network packet handlers registered") 61 + } 62 + 63 + /** 64 + * Handles authentication by verifying the session with AT Protocol servers. 65 + */ 66 + private suspend fun handleAuthentication(player: ServerPlayer, packet: AtProtoPackets.AuthenticatePacket) { 67 + try { 68 + // Verify the token is valid by making a test API call 69 + val verifyResult = Atprotoconnect.atProtoClient.xrpcRequest( 70 + method = "GET", 71 + endpoint = "com.atproto.server.getSession", 72 + accessJwt = packet.accessJwt, 73 + pdsUrl = packet.pdsUrl 74 + ) 75 + 76 + if (verifyResult.isFailure) { 77 + sendAuthResponse(player, false, "Token verification failed") 78 + SecurityAuditor.logAuthFailure( 79 + player.uuid, 80 + packet.handle, 81 + "Token verification failed", 82 + player.name.string 83 + ) 84 + return 85 + } 86 + 87 + // Token is valid, store the session 88 + Atprotoconnect.sessionManager.storeVerifiedSession( 89 + uuid = player.uuid, 90 + did = packet.did, 91 + handle = packet.handle, 92 + pdsUrl = packet.pdsUrl, 93 + accessJwt = packet.accessJwt, 94 + refreshJwt = packet.refreshJwt 95 + ) 96 + 97 + // Link identity if not already linked 98 + if (!Atprotoconnect.identityStore.isLinked(player.uuid)) { 99 + Atprotoconnect.identityStore.linkIdentity(player.uuid, packet.did, packet.handle) 100 + SecurityAuditor.logIdentityLink(player.uuid, packet.handle, player.name.string) 101 + } 102 + 103 + // Send success response to client 104 + sendAuthResponse(player, true, "Successfully authenticated as ${packet.handle}") 105 + 106 + SecurityAuditor.logAuthSuccess(player.uuid, packet.handle, player.name.string) 107 + logger.info("Player ${player.name.string} authenticated as ${packet.handle}") 108 + 109 + } catch (e: Exception) { 110 + sendAuthResponse(player, false, "Verification error: ${e.message}") 111 + logger.error("Failed to verify authentication for ${player.name.string}", e) 112 + } 113 + } 114 + 115 + /** 116 + * Handles logout by removing the player's session. 117 + */ 118 + private fun handleLogout(player: ServerPlayer) { 119 + val identity = Atprotoconnect.identityStore.getIdentity(player.uuid) 120 + Atprotoconnect.sessionManager.deleteSession(player.uuid) 121 + 122 + if (identity != null) { 123 + SecurityAuditor.logLogout(player.uuid, identity.handle, player.name.string) 124 + } 125 + 126 + player.sendSystemMessage( 127 + Component.literal("§a✓ Logged out on server") 128 + ) 129 + logger.info("Player ${player.name.string} logged out") 130 + } 131 + 132 + /** 133 + * Sends an authentication response to the client. 134 + */ 135 + private fun sendAuthResponse(player: ServerPlayer, success: Boolean, message: String) { 136 + val response = AtProtoPackets.AuthenticateResponsePacket(success, message) 137 + ServerPlayNetworking.send(player, response) 138 + } 139 + }