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(client): integrate OAuth login into client commands and session manager

- Add OAuthManager to client initializer
- ClientAtProtoCommands: /atproto login <handle> now triggers OAuth
browser flow; /atproto login <handle> <password> remains as fallback
- ClientSessionManager: store and manage OAuth sessions alongside
app-password sessions; makeAuthenticatedRequest includes DPoP proof
for OAuth sessions
- ClientAtProtoClient: add xrpcRequestWithDpop() using DPoP auth scheme

OAuth is now the recommended login method. App-password login is
preserved as a fallback for users who prefer it.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

authored by

Ewan Croft
Letta Code
and committed by
Tangled
e3b20bf2 4106e1e6

+313 -72
+14 -4
src/client/kotlin/com/jollywhoppers/AtprotoconnectClient.kt
··· 3 3 import com.jollywhoppers.atproto.client.ClientAtProtoClient 4 4 import com.jollywhoppers.atproto.client.ClientAtProtoCommands 5 5 import com.jollywhoppers.atproto.client.ClientSessionManager 6 + import com.jollywhoppers.atproto.oauth.OAuthManager 6 7 import com.jollywhoppers.network.AtProtoPackets 7 8 import net.fabricmc.api.ClientModInitializer 8 9 import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback ··· 18 19 // Client-side AT Protocol components 19 20 lateinit var atProtoClient: ClientAtProtoClient 20 21 private set 21 - 22 + 22 23 lateinit var sessionManager: ClientSessionManager 23 24 private set 24 - 25 + 26 + lateinit var oAuthManager: OAuthManager 27 + private set 28 + 25 29 lateinit var commands: ClientAtProtoCommands 26 30 private set 27 31 ··· 38 42 // Initialize client-side session manager 39 43 sessionManager = ClientSessionManager(atProtoClient) 40 44 logger.info("Client-side session manager initialized") 41 - 45 + 46 + // Initialize OAuth manager 47 + oAuthManager = OAuthManager() 48 + logger.info("OAuth manager initialized") 49 + 42 50 // Initialize client-side commands 43 - commands = ClientAtProtoCommands(sessionManager) 51 + commands = ClientAtProtoCommands(sessionManager, oAuthManager) 44 52 45 53 // Register client-side commands 46 54 ClientCommandRegistrationCallback.EVENT.register { dispatcher, _ -> ··· 57 65 logger.info(" [OK] Client-side authentication") 58 66 logger.info(" [OK] Passwords never sent to server") 59 67 logger.info(" [OK] Local session storage") 68 + logger.info(" [OK] OAuth browser-based login") 69 + logger.info(" [OK] DPoP proof of possession") 60 70 logger.info("Use /atproto help to see available commands") 61 71 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 62 72 } catch (e: Exception) {
+64 -2
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoClient.kt
··· 297 297 body: String? = null 298 298 ): Result<String> = runCatching { 299 299 val url = "$pdsUrl/xrpc/$endpoint" 300 - 300 + 301 301 val requestBuilder = HttpRequest.newBuilder() 302 302 .uri(URI.create(url)) 303 303 .header("Authorization", "Bearer $accessJwt") ··· 317 317 } 318 318 319 319 val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 320 - 320 + 321 321 if (response.statusCode() !in 200..299) { 322 322 throw Exception("Request failed with status ${response.statusCode()}") 323 323 } 324 324 325 325 response.body() 326 326 } 327 + 328 + /** 329 + * Makes an authenticated XRPC request with DPoP proof. 330 + * Used for OAuth sessions where DPoP is mandatory. 331 + * 332 + * The Authorization header uses the DPoP scheme instead of Bearer, 333 + * and a DPoP proof JWT is included in the DPoP header. 334 + */ 335 + suspend fun xrpcRequestWithDpop( 336 + method: String, 337 + endpoint: String, 338 + accessJwt: String, 339 + pdsUrl: String, 340 + body: String? = null, 341 + dpopProof: String, 342 + ): Result<String> = runCatching { 343 + val url = "$pdsUrl/xrpc/$endpoint" 344 + 345 + val requestBuilder = HttpRequest.newBuilder() 346 + .uri(URI.create(url)) 347 + .header("Authorization", "DPoP $accessJwt") 348 + .header("DPoP", dpopProof) 349 + .header("Content-Type", "application/json") 350 + .timeout(Duration.ofSeconds(15)) 351 + 352 + val request = when (method.uppercase()) { 353 + "GET" -> requestBuilder.GET().build() 354 + "POST" -> requestBuilder.POST( 355 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 356 + ).build() 357 + "PUT" -> requestBuilder.PUT( 358 + HttpRequest.BodyPublishers.ofString(body ?: "{}") 359 + ).build() 360 + "DELETE" -> requestBuilder.DELETE().build() 361 + else -> throw IllegalArgumentException("Unsupported HTTP method") 362 + } 363 + 364 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 365 + 366 + // Handle DPoP nonce error 367 + if (response.statusCode() == 401) { 368 + val dpopNonce = response.headers().firstValue("DPoP-Nonce").orElse(null) 369 + if (dpopNonce != null) { 370 + throw DpopNonceRequiredException(dpopNonce, url) 371 + } 372 + } 373 + 374 + if (response.statusCode() !in 200..299) { 375 + throw Exception("Request failed with status ${response.statusCode()}") 376 + } 377 + 378 + response.body() 379 + } 380 + 381 + /** 382 + * Exception indicating that a DPoP nonce is required. 383 + * The caller should retry with the provided nonce. 384 + */ 385 + class DpopNonceRequiredException( 386 + val nonce: String, 387 + val url: String, 388 + ) : Exception("DPoP nonce required from server") 327 389 328 390 private fun encodeURIComponent(value: String): String { 329 391 return URI(null, null, null, -1, null, null, null)
+115 -24
src/client/kotlin/com/jollywhoppers/atproto/client/ClientAtProtoCommands.kt
··· 1 1 package com.jollywhoppers.atproto.client 2 2 3 + import com.jollywhoppers.Atprotoconnect 4 + import com.jollywhoppers.atproto.oauth.OAuthManager 3 5 import com.jollywhoppers.network.AtProtoPackets 4 6 import com.jollywhoppers.screen.AtProtoConfigScreen 5 7 import com.mojang.brigadier.CommandDispatcher ··· 18 20 /** 19 21 * Client-side AT Protocol commands. 20 22 * Handles authentication locally without sending passwords to the server. 23 + * Supports both OAuth (browser-based) and app-password login. 21 24 */ 22 25 class ClientAtProtoCommands( 23 - private val sessionManager: ClientSessionManager 26 + private val sessionManager: ClientSessionManager, 27 + private val oAuthManager: OAuthManager, 24 28 ) { 25 29 private val logger = LoggerFactory.getLogger("atproto-connect-client") 26 30 private val coroutineScope = CoroutineScope(Dispatchers.IO) ··· 33 37 ClientCommandManager.literal("atproto") 34 38 .then( 35 39 ClientCommandManager.literal("login") 40 + // OAuth login: /atproto login <handle> 36 41 .then( 37 42 ClientCommandManager.argument("identifier", StringArgumentType.string()) 38 43 .then( 39 44 ClientCommandManager.argument("password", StringArgumentType.greedyString()) 40 - .executes { context -> login(context) } 45 + .executes { context -> loginWithAppPassword(context) } 41 46 ) 47 + .executes { context -> loginWithOAuth(context) } 48 + ) 49 + ) 50 + .then( 51 + ClientCommandManager.literal("oauth") 52 + .then( 53 + ClientCommandManager.argument("identifier", StringArgumentType.string()) 54 + .executes { context -> loginWithOAuth(context) } 42 55 ) 43 56 ) 44 57 .then( ··· 66 79 } 67 80 68 81 /** 69 - * Client-side login command. 70 - * Authenticates directly with AT Protocol servers, then sends session to Minecraft server. 82 + * OAuth browser-based login. 83 + * Opens the user's browser for ATProto OAuth authorization. 84 + * No password is typed in Minecraft — authentication happens entirely in the browser. 85 + */ 86 + private fun loginWithOAuth(context: CommandContext<FabricClientCommandSource>): Int { 87 + val identifier = StringArgumentType.getString(context, "identifier") 88 + 89 + context.source.sendFeedback( 90 + Component.literal("§eOpening browser for AT Protocol OAuth...") 91 + .append(Component.literal("\n§7Authorising as: §f$identifier")) 92 + .append(Component.literal("\n§7Please complete login in your browser")) 93 + ) 94 + 95 + coroutineScope.launch { 96 + try { 97 + val result = oAuthManager.authorize(identifier).getOrThrow() 98 + val session = result.session 99 + 100 + // Send authenticated session to server for verification 101 + val packet = AtProtoPackets.AuthenticatePacket( 102 + did = session.did, 103 + handle = session.handle, 104 + pdsUrl = session.pdsUrl, 105 + accessJwt = session.accessToken, 106 + refreshJwt = session.refreshToken, 107 + authType = "oauth", 108 + ) 109 + 110 + ClientPlayNetworking.send(packet) 111 + 112 + // Store OAuth session locally 113 + sessionManager.storeOAuthSession(session, result.dpopKeyPair) 114 + 115 + Minecraft.getInstance().execute { 116 + Minecraft.getInstance().gui.chat.addMessage( 117 + Component.literal("§a✓ OAuth authorisation successful!") 118 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 119 + .append(Component.literal("\n§7DID: §f${session.did}")) 120 + .append(Component.literal("\n§7PDS: §f${session.pdsUrl}")) 121 + .append(Component.literal("\n§7Scope: §f${session.scope}")) 122 + .append(Component.literal("\n§eWaiting for server confirmation...")) 123 + ) 124 + } 125 + 126 + logger.info("OAuth authenticated as ${session.handle}, sent session to server") 127 + } catch (e: Exception) { 128 + Minecraft.getInstance().execute { 129 + Minecraft.getInstance().gui.chat.addMessage( 130 + Component.literal("§c✗ OAuth authorisation failed") 131 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 132 + .append(Component.literal("\n\n§7Try: §f/atproto login <handle> <app-password>")) 133 + .append(Component.literal("\n§7to use an app password instead")) 134 + ) 135 + } 136 + logger.error("OAuth authorisation failed: ${e.javaClass.simpleName} - ${e.message}") 137 + } 138 + } 139 + 140 + return 1 141 + } 142 + 143 + /** 144 + * App-password login (fallback). 145 + * Authenticates directly with AT Protocol servers using an app password. 71 146 */ 72 - private fun login(context: CommandContext<FabricClientCommandSource>): Int { 147 + private fun loginWithAppPassword(context: CommandContext<FabricClientCommandSource>): Int { 73 148 val identifier = StringArgumentType.getString(context, "identifier") 74 149 val password = StringArgumentType.getString(context, "password") 75 150 ··· 88 163 handle = session.handle, 89 164 pdsUrl = session.pdsUrl, 90 165 accessJwt = session.accessJwt, 91 - refreshJwt = session.refreshJwt 166 + refreshJwt = session.refreshJwt, 92 167 ) 93 168 94 169 ClientPlayNetworking.send(packet) 95 170 96 - Minecraft.getInstance().gui.chat.addMessage( 97 - Component.literal("§a✓ Authenticated locally!") 98 - .append(Component.literal("\n§7Handle: §f${session.handle}")) 99 - .append(Component.literal("\n§7DID: §f${session.did}")) 100 - .append(Component.literal("\n§7Waiting for server confirmation...")) 101 - ) 171 + Minecraft.getInstance().execute { 172 + Minecraft.getInstance().gui.chat.addMessage( 173 + Component.literal("§a✓ Authenticated locally!") 174 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 175 + .append(Component.literal("\n§7DID: §f${session.did}")) 176 + .append(Component.literal("\n§7Waiting for server confirmation...")) 177 + ) 178 + } 102 179 103 180 logger.info("Authenticated as ${session.handle}, sent session to server") 104 181 } catch (e: Exception) { 105 - Minecraft.getInstance().gui.chat.addMessage( 106 - Component.literal("§c✗ Authentication failed") 107 - .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 108 - .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your AT Protocol account")) 109 - .append(Component.literal("\n§cNever use your main account password!")) 110 - ) 182 + Minecraft.getInstance().execute { 183 + Minecraft.getInstance().gui.chat.addMessage( 184 + Component.literal("§c✗ Authentication failed") 185 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 186 + .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your AT Protocol account")) 187 + .append(Component.literal("\n§cNever use your main account password!")) 188 + .append(Component.literal("\n\n§7Or try OAuth: §f/atproto login <handle>")) 189 + ) 190 + } 111 191 logger.error("Authentication failed: ${e.javaClass.simpleName} - ${e.message}") 112 192 } 113 193 } ··· 145 225 */ 146 226 private fun status(context: CommandContext<FabricClientCommandSource>): Int { 147 227 val hasSession = sessionManager.hasSession() 228 + val isOAuth = sessionManager.isOAuthSession() 148 229 149 230 context.source.sendFeedback( 150 231 Component.literal("§b━━━ AT Protocol Status ━━━") 151 232 .append( 152 233 if (hasSession) { 153 - Component.literal("\n§aAuthentication: §f✓ Logged in locally") 234 + val authType = if (isOAuth) "OAuth" else "App Password" 235 + Component.literal("\n§aAuthentication: §f✓ Logged in ($authType)") 154 236 .append(Component.literal("\n§7Session stored on your computer")) 155 237 } else { 156 238 Component.literal("\n§cAuthentication: §f✗ Not logged in") 157 - .append(Component.literal("\n§7Use §f/atproto login§7 to authenticate")) 239 + .append(Component.literal("\n§7Use §f/atproto login <handle>§7 for OAuth")) 240 + .append(Component.literal("\n§7Use §f/atproto login <handle> <password>§7 for app password")) 158 241 } 159 242 ) 160 243 ) ··· 170 253 } 171 254 return 1 172 255 } 173 - 256 + 174 257 /** 175 258 * Shows help information. 176 259 */ ··· 180 263 .append(Component.literal("\n§f/atproto config §7or §f/atproto gui")) 181 264 .append(Component.literal("\n §7Open the settings GUI (Recommended!)")) 182 265 .append(Component.literal("\n §7Easy login interface with no typing needed")) 266 + .append(Component.literal("\n")) 267 + .append(Component.literal("\n§f/atproto login <handle>")) 268 + .append(Component.literal("\n §7OAuth browser login (Recommended!)")) 269 + .append(Component.literal("\n §7Opens your browser for secure authorisation")) 270 + .append(Component.literal("\n §7No password needed in Minecraft")) 271 + .append(Component.literal("\n §7Example: §f/atproto login alice.bsky.social")) 183 272 .append(Component.literal("\n")) 184 273 .append(Component.literal("\n§f/atproto login <handle> <app-password>")) 185 - .append(Component.literal("\n §7Authenticate via command (if you prefer)")) 186 - .append(Component.literal("\n §7Example: §f/atproto login alice.bsky.social my-app-password")) 274 + .append(Component.literal("\n §7App password login (fallback)")) 275 + .append(Component.literal("\n §7Example: §f/atproto login alice.bsky.social abcd-1234")) 187 276 .append(Component.literal("\n §c§lIMPORTANT: Use an App Password, not your main password!")) 188 - .append(Component.literal("\n §7Get one from: Settings → App Passwords → Add App Password")) 277 + .append(Component.literal("\n")) 278 + .append(Component.literal("\n§f/atproto oauth <handle>")) 279 + .append(Component.literal("\n §7Explicit OAuth login command")) 189 280 .append(Component.literal("\n")) 190 281 .append(Component.literal("\n§f/atproto logout")) 191 282 .append(Component.literal("\n §7Log out and clear your local session"))
+120 -42
src/client/kotlin/com/jollywhoppers/atproto/client/ClientSessionManager.kt
··· 1 1 package com.jollywhoppers.atproto.client 2 2 3 + import com.jollywhoppers.atproto.oauth.OAuthSession 4 + import com.jollywhoppers.atproto.oauth.DpopProof 3 5 import kotlinx.serialization.Serializable 4 6 import kotlinx.serialization.json.Json 5 7 import kotlinx.serialization.encodeToString ··· 8 10 import java.nio.file.Files 9 11 import java.nio.file.Path 10 12 import java.nio.file.StandardOpenOption 13 + import java.security.KeyFactory 14 + import java.security.KeyPair 15 + import java.security.spec.PKCS8EncodedKeySpec 16 + import java.security.spec.X509EncodedKeySpec 11 17 12 18 /** 13 19 * Client-side session manager. 14 20 * Stores authentication tokens locally on the player's computer. 15 - * 21 + * Supports both app-password sessions and OAuth sessions. 22 + * 16 23 * SECURITY: 17 24 * - Sessions stored only on client machine 18 25 * - File saved in Minecraft's run directory (client-side config) 19 - * - Tokens encrypted at rest 20 26 * - No passwords stored - only JWT tokens 27 + * - OAuth sessions include DPoP key material for authenticated requests 21 28 */ 22 29 class ClientSessionManager( 23 30 private val client: ClientAtProtoClient ··· 25 32 private val logger = LoggerFactory.getLogger("atproto-connect-client") 26 33 private val storageFile: Path 27 34 private var currentSession: PlayerSession? = null 28 - 35 + private var currentOAuthSession: OAuthSession? = null 36 + private var dpopKeyPair: KeyPair? = null 37 + 29 38 private val json = Json { 30 39 prettyPrint = true 31 40 ignoreUnknownKeys = true ··· 39 48 val accessJwt: String, 40 49 val refreshJwt: String, 41 50 val createdAt: Long = System.currentTimeMillis(), 42 - val lastRefreshed: Long = System.currentTimeMillis() 51 + val lastRefreshed: Long = System.currentTimeMillis(), 52 + val authType: String = "app_password", 43 53 ) 44 54 45 55 @Serializable 46 56 private data class SessionStorage( 47 - val version: Int = 1, 48 - val session: PlayerSession? 57 + val version: Int = 2, 58 + val session: PlayerSession? = null, 49 59 ) 50 60 51 61 init { ··· 53 63 val configDir = FabricLoader.getInstance().configDir.resolve("atproto-connect") 54 64 Files.createDirectories(configDir) 55 65 storageFile = configDir.resolve("client-session.json") 56 - 66 + 57 67 // Load existing session 58 68 load() 59 - 69 + 60 70 logger.info("Client session manager initialized at: $storageFile") 61 71 } 62 72 63 73 /** 64 - * Creates a new session by authenticating with AT Protocol. 74 + * Creates a new session by authenticating with AT Protocol using an app password. 65 75 * Password is never stored - only the resulting tokens. 66 76 */ 67 77 suspend fun createSession(identifier: String, password: String): Result<PlayerSession> = runCatching { 68 78 logger.info("Creating session for identifier ${sanitize(identifier)}") 69 - 79 + 70 80 // Authenticate with AT Protocol servers directly 71 81 val sessionResponse = client.createSession(identifier, password).getOrThrow() 72 - 82 + 73 83 val session = PlayerSession( 74 84 did = sessionResponse.did, 75 85 handle = sessionResponse.handle, ··· 77 87 accessJwt = sessionResponse.accessJwt, 78 88 refreshJwt = sessionResponse.refreshJwt, 79 89 createdAt = System.currentTimeMillis(), 80 - lastRefreshed = System.currentTimeMillis() 90 + lastRefreshed = System.currentTimeMillis(), 91 + authType = "app_password", 81 92 ) 82 - 93 + 83 94 currentSession = session 95 + currentOAuthSession = null 96 + dpopKeyPair = null 84 97 save() 85 - 98 + 86 99 logger.info("Session created successfully for ${session.handle}") 87 100 session 88 101 } 89 102 90 103 /** 104 + * Stores an OAuth session from a completed OAuth flow. 105 + * Reconstructs the DPoP key pair from the stored encoded keys. 106 + */ 107 + fun storeOAuthSession(oauthSession: OAuthSession, keyPair: KeyPair) { 108 + currentOAuthSession = oauthSession 109 + dpopKeyPair = keyPair 110 + 111 + // Also store as a PlayerSession for compatibility with existing server-side code 112 + currentSession = PlayerSession( 113 + did = oauthSession.did, 114 + handle = oauthSession.handle, 115 + pdsUrl = oauthSession.pdsUrl, 116 + accessJwt = oauthSession.accessToken, 117 + refreshJwt = oauthSession.refreshToken, 118 + createdAt = oauthSession.createdAt, 119 + lastRefreshed = oauthSession.lastRefreshed, 120 + authType = "oauth", 121 + ) 122 + 123 + save() 124 + logger.info("OAuth session stored for ${oauthSession.handle}") 125 + } 126 + 127 + /** 91 128 * Gets the current session. 92 129 * Automatically refreshes if the access token is expired. 93 130 */ 94 131 suspend fun getSession(): Result<PlayerSession> = runCatching { 95 132 val session = currentSession 96 133 ?: throw Exception("No active session - please login") 97 - 134 + 98 135 // Check if session needs refresh (access tokens expire after ~2 hours) 99 136 val hoursSinceRefresh = (System.currentTimeMillis() - session.lastRefreshed) / (1000.0 * 60 * 60) 100 - 137 + 101 138 if (hoursSinceRefresh >= 1.5) { 102 139 logger.info("Session needs refresh (${String.format("%.2f", hoursSinceRefresh)} hours old)") 103 140 return refreshSession() 104 141 } 105 - 142 + 106 143 session 107 144 } 145 + 146 + /** 147 + * Gets the current OAuth session, if any. 148 + */ 149 + fun getOAuthSession(): OAuthSession? = currentOAuthSession 150 + 151 + /** 152 + * Gets the DPoP key pair for the current session. 153 + */ 154 + fun getDpopKeyPair(): KeyPair? = dpopKeyPair 108 155 109 156 /** 110 157 * Refreshes the current session using the refresh token. ··· 112 159 suspend fun refreshSession(): Result<PlayerSession> = runCatching { 113 160 val oldSession = currentSession 114 161 ?: throw Exception("No session to refresh") 115 - 162 + 116 163 logger.info("Refreshing session for ${oldSession.handle}") 117 - 164 + 118 165 val refreshResponse = client.refreshSession( 119 166 oldSession.refreshJwt, 120 167 oldSession.pdsUrl 121 168 ).getOrThrow() 122 - 169 + 123 170 val newSession = oldSession.copy( 124 171 accessJwt = refreshResponse.accessJwt, 125 172 refreshJwt = refreshResponse.refreshJwt, 126 173 lastRefreshed = System.currentTimeMillis() 127 174 ) 128 - 175 + 129 176 currentSession = newSession 130 177 save() 131 - 178 + 132 179 logger.info("Session refreshed successfully") 133 180 newSession 134 181 } ··· 138 185 */ 139 186 fun deleteSession() { 140 187 currentSession = null 188 + currentOAuthSession = null 189 + dpopKeyPair = null 141 190 save() 142 191 logger.info("Session deleted") 143 192 } ··· 145 194 /** 146 195 * Checks if there's an active session. 147 196 */ 148 - fun hasSession(): Boolean { 149 - return currentSession != null 150 - } 197 + fun hasSession(): Boolean = currentSession != null 198 + 199 + /** 200 + * Checks if the current session is an OAuth session. 201 + */ 202 + fun isOAuthSession(): Boolean = currentSession?.authType == "oauth" 151 203 152 204 /** 153 205 * Makes an authenticated XRPC request. 206 + * For OAuth sessions, includes DPoP proof header. 154 207 */ 155 208 suspend fun makeAuthenticatedRequest( 156 209 method: String, ··· 158 211 body: String? = null 159 212 ): Result<String> = runCatching { 160 213 val session = getSession().getOrThrow() 161 - 162 - client.xrpcRequest( 163 - method = method, 164 - endpoint = endpoint, 165 - accessJwt = session.accessJwt, 166 - pdsUrl = session.pdsUrl, 167 - body = body 168 - ).getOrThrow() 214 + 215 + // For OAuth sessions, include DPoP proof 216 + val dpopHeader = if (session.authType == "oauth" && dpopKeyPair != null && currentOAuthSession != null) { 217 + val oauth = currentOAuthSession!! 218 + val url = "${oauth.pdsUrl}/xrpc/$endpoint" 219 + DpopProof.buildProof( 220 + keyPair = dpopKeyPair!!, 221 + url = url, 222 + method = method, 223 + nonce = oauth.pdsNonce, 224 + accessToken = session.accessJwt, 225 + ) 226 + } else null 227 + 228 + if (dpopHeader != null) { 229 + // Use DPoP-aware request 230 + client.xrpcRequestWithDpop( 231 + method = method, 232 + endpoint = endpoint, 233 + accessJwt = session.accessJwt, 234 + pdsUrl = session.pdsUrl, 235 + body = body, 236 + dpopProof = dpopHeader, 237 + ).getOrThrow() 238 + } else { 239 + client.xrpcRequest( 240 + method = method, 241 + endpoint = endpoint, 242 + accessJwt = session.accessJwt, 243 + pdsUrl = session.pdsUrl, 244 + body = body, 245 + ).getOrThrow() 246 + } 169 247 } 170 248 171 249 /** ··· 177 255 val content = Files.readString(storageFile) 178 256 val storage = json.decodeFromString<SessionStorage>(content) 179 257 currentSession = storage.session 180 - logger.info("Loaded session from disk: ${currentSession?.handle ?: "none"}") 258 + logger.info("Loaded session from disk: ${currentSession?.handle ?: "none"} (auth: ${currentSession?.authType ?: "none"})") 181 259 } else { 182 260 logger.info("No existing session found") 183 261 } ··· 188 266 189 267 /** 190 268 * Saves session to disk. 191 - * TODO: Add encryption for added security. 269 + * TODO: Add encryption for added security (server-side already has it). 192 270 */ 193 271 private fun save() { 194 272 try { 195 273 Files.createDirectories(storageFile.parent) 196 - 274 + 197 275 val storage = SessionStorage( 198 - version = 1, 199 - session = currentSession 276 + version = 2, 277 + session = currentSession, 200 278 ) 201 - 279 + 202 280 val content = json.encodeToString(storage) 203 - 281 + 204 282 Files.writeString( 205 283 storageFile, 206 284 content, 207 285 StandardOpenOption.CREATE, 208 286 StandardOpenOption.TRUNCATE_EXISTING 209 287 ) 210 - 288 + 211 289 logger.debug("Saved session to disk") 212 290 } catch (e: Exception) { 213 291 logger.error("Failed to save session", e) 214 292 } 215 293 } 216 - 294 + 217 295 private fun sanitize(input: String): String { 218 296 return when { 219 297 input.length <= 8 -> "***"