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(oauth): add ATProto OAuth client with PKCE, DPoP, and PAR

Implements the core OAuth 2.1 client-side flow per ATProto spec:
- PkceUtils: RFC 7636 code verifier/challenge generation (S256)
- DpopProof: RFC 9449 DPoP proof JWT construction (ES256/P-256)
- OAuthCallbackServer: localhost HTTP server for browser redirect
- OAuthModels: serializable types for server metadata, PAR, tokens
- OAuthManager: full flow orchestration (discovery, PAR, token exchange)

All OAuth requests include DPoP proof headers. PAR is mandatory per
ATProto spec. Token exchange includes PKCE code verifier.

👾 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
4106e1e6 81ec664e

+1187
+170
src/client/kotlin/com/jollywhoppers/atproto/oauth/DpopProof.kt
··· 1 + package com.jollywhoppers.atproto.oauth 2 + 3 + import java.security.KeyPair 4 + import java.security.KeyPairGenerator 5 + import java.security.Signature 6 + import java.security.interfaces.ECPrivateKey 7 + import java.security.interfaces.ECPublicKey 8 + import java.util.Base64 9 + import java.util.UUID 10 + 11 + /** 12 + * DPoP (Demonstrating Proof of Possession) proof generation per RFC 9449. 13 + * 14 + * ATProto OAuth mandates DPoP for all clients. Every authenticated request 15 + * must include a fresh DPoP proof JWT in the DPoP header, and the 16 + * Authorization header must use the DPoP auth scheme. 17 + * 18 + * Key management: 19 + * - Generate one ES256 key pair per OAuth session 20 + * - Reuse the same key pair for all requests within that session 21 + * - Track nonces separately for the authorization server and the PDS 22 + */ 23 + object DpopProof { 24 + private const val ALGORITHM = "EC" 25 + private const val CURVE = "P-256" 26 + private const val SIGNATURE_ALGORITHM = "SHA256withECDSA" 27 + private const val JWT_ALGORITHM = "ES256" 28 + 29 + private val base64UrlEncoder = Base64.getUrlEncoder().withoutPadding() 30 + private val base64UrlDecoder = Base64.getUrlDecoder() 31 + 32 + /** 33 + * Generates a new ES256 (P-256) key pair for DPoP. 34 + * Should be called once per OAuth session and reused for all requests. 35 + */ 36 + fun generateKeyPair(): KeyPair { 37 + val keyGen = KeyPairGenerator.getInstance(ALGORITHM) 38 + keyGen.initialize(256) 39 + return keyGen.generateKeyPair() 40 + } 41 + 42 + /** 43 + * Builds a DPoP proof JWT for an authorization server request. 44 + * 45 + * @param keyPair The DPoP key pair for this session 46 + * @param url The target URL of the request 47 + * @param method The HTTP method (GET, POST, etc.) 48 + * @param nonce Optional server-issued nonce (from DPoP-Nonce header) 49 + * @param accessToken Optional access token hash for resource server requests 50 + * @return The signed DPoP proof JWT string 51 + */ 52 + fun buildProof( 53 + keyPair: KeyPair, 54 + url: String, 55 + method: String, 56 + nonce: String? = null, 57 + accessToken: String? = null 58 + ): String { 59 + val jwk = publicJwk(keyPair.public as ECPublicKey) 60 + 61 + val header = buildJsonObject { 62 + put("typ", "dpop+jwt") 63 + put("alg", JWT_ALGORITHM) 64 + put("jwk", jwk) 65 + } 66 + 67 + val now = System.currentTimeMillis() / 1000 68 + 69 + val payload = buildJsonObject { 70 + put("jti", UUID.randomUUID().toString()) 71 + put("htm", method.uppercase()) 72 + put("htu", url) 73 + put("iat", now) 74 + nonce?.let { put("nonce", it) } 75 + accessToken?.let { put("ath", sha256Base64Url(it)) } 76 + } 77 + 78 + val headerB64 = base64UrlEncoder.encodeToString(header.toByteArray(Charsets.UTF_8)) 79 + val payloadB64 = base64UrlEncoder.encodeToString(payload.toByteArray(Charsets.UTF_8)) 80 + 81 + val signingInput = "$headerB64.$payloadB64" 82 + 83 + val signature = sign(keyPair, signingInput) 84 + val signatureB64 = base64UrlEncoder.encodeToString(signature) 85 + 86 + return "$signingInput.$signatureB64" 87 + } 88 + 89 + /** 90 + * Computes the SHA-256 hash of the access token, base64url-encoded. 91 + * Used as the `ath` claim in DPoP proofs for resource server requests. 92 + */ 93 + fun sha256Base64Url(input: String): String { 94 + val digest = java.security.MessageDigest.getInstance("SHA-256") 95 + val hash = digest.digest(input.toByteArray(Charsets.US_ASCII)) 96 + return base64UrlEncoder.encodeToString(hash) 97 + } 98 + 99 + /** 100 + * Signs the input string with the ES256 private key. 101 + */ 102 + private fun sign(keyPair: KeyPair, input: String): ByteArray { 103 + val signature = Signature.getInstance(SIGNATURE_ALGORITHM) 104 + signature.initSign(keyPair.private as ECPrivateKey) 105 + signature.update(input.toByteArray(Charsets.US_ASCII)) 106 + return signature.sign() 107 + } 108 + 109 + /** 110 + * Builds the public JWK from an EC public key. 111 + * Only includes the minimum fields needed for DPoP (RFC 9449 §4.2). 112 + */ 113 + private fun publicJwk(publicKey: ECPublicKey): String { 114 + val params = publicKey.params 115 + val affineX = params.curve.field.fieldSize 116 + val x = base64UrlEncoder.encodeToString(publicKey.w.affineX.toByteArray().trimLeadingZeros()) 117 + val y = base64UrlEncoder.encodeToString(publicKey.w.affineY.toByteArray().trimLeadingZeros()) 118 + 119 + return buildJsonObject { 120 + put("kty", "EC") 121 + put("crv", CURVE) 122 + put("x", x) 123 + put("y", y) 124 + } 125 + } 126 + 127 + /** 128 + * Trims leading zero bytes from a BigInteger's byte array representation. 129 + * Required for correct base64url encoding of EC point coordinates. 130 + */ 131 + private fun ByteArray.trimLeadingZeros(): ByteArray { 132 + var offset = 0 133 + while (offset < this.size - 1 && this[offset] == 0.toByte()) { 134 + offset++ 135 + } 136 + return if (offset == 0) this else this.copyOfRange(offset, this.size) 137 + } 138 + 139 + /** 140 + * Simple JSON object builder. 141 + * Avoids pulling in a JSON dependency just for DPoP proof construction. 142 + */ 143 + private fun buildJsonObject(block: JsonObjectBuilder.() -> Unit): String { 144 + val builder = JsonObjectBuilder() 145 + builder.block() 146 + return builder.build() 147 + } 148 + 149 + /** 150 + * Minimal JSON object builder for DPoP proof JWTs. 151 + * Produces compact JSON without external dependencies. 152 + */ 153 + class JsonObjectBuilder { 154 + private val entries = mutableListOf<String>() 155 + 156 + fun put(key: String, value: String) { 157 + entries.add("\"$key\":\"$value\"") 158 + } 159 + 160 + fun put(key: String, value: Number) { 161 + entries.add("\"$key\":$value") 162 + } 163 + 164 + fun put(key: String, value: Boolean) { 165 + entries.add("\"$key\":$value") 166 + } 167 + 168 + fun build(): String = "{${entries.joinToString(",")}}" 169 + } 170 + }
+188
src/client/kotlin/com/jollywhoppers/atproto/oauth/OAuthCallbackServer.kt
··· 1 + package com.jollywhoppers.atproto.oauth 2 + 3 + import com.sun.net.httpserver.HttpExchange 4 + import com.sun.net.httpserver.HttpServer 5 + import org.slf4j.LoggerFactory 6 + import java.net.InetSocketAddress 7 + import java.util.concurrent.CompletableFuture 8 + import java.util.concurrent.TimeUnit 9 + 10 + /** 11 + * Lightweight localhost HTTP server that captures the OAuth redirect callback. 12 + * 13 + * ATProto OAuth supports `http://localhost` as a client_id for development 14 + * and desktop apps. This server listens on a random available port, captures 15 + * the authorization code from the redirect, and returns a simple HTML page 16 + * telling the user to return to Minecraft. 17 + * 18 + * Lifecycle: 19 + * 1. Start the server before opening the browser 20 + * 2. User authenticates in browser → browser redirects here with ?code=... 21 + * 3. Server captures the code and stops 22 + * 4. Caller retrieves the code via [awaitCode] 23 + */ 24 + class OAuthCallbackServer { 25 + private val logger = LoggerFactory.getLogger("atproto-connect:oauth-callback") 26 + private var server: HttpServer? = null 27 + private var port: Int = 0 28 + private val codeFuture = CompletableFuture<String>() 29 + private val errorFuture = CompletableFuture<String>() 30 + 31 + /** 32 + * Starts the callback server on a random available port. 33 + * @return The port the server is listening on 34 + */ 35 + fun start(): Int { 36 + val httpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) 37 + httpServer.createContext("/oauth/callback") { exchange -> handleCallback(exchange) } 38 + httpServer.createContext("/") { exchange -> handleRoot(exchange) } 39 + httpServer.executor = null // use calling thread (simple enough for a one-shot server) 40 + httpServer.start() 41 + 42 + server = httpServer 43 + port = httpServer.address.port 44 + logger.info("OAuth callback server started on http://127.0.0.1:$port") 45 + return port 46 + } 47 + 48 + /** 49 + * The redirect URI that should be registered with the OAuth flow. 50 + */ 51 + fun getRedirectUri(): String = "http://127.0.0.1:$port/oauth/callback" 52 + 53 + /** 54 + * Waits for the authorization code from the callback. 55 + * @param timeoutSeconds Maximum time to wait 56 + * @return The authorization code, or null on timeout 57 + */ 58 + fun awaitCode(timeoutSeconds: Long = 300): String? { 59 + return try { 60 + codeFuture.get(timeoutSeconds, TimeUnit.SECONDS) 61 + } catch (e: java.util.concurrent.TimeoutException) { 62 + logger.warn("OAuth callback timed out after ${timeoutSeconds}s") 63 + null 64 + } catch (e: Exception) { 65 + logger.error("Error waiting for OAuth callback", e) 66 + null 67 + } 68 + } 69 + 70 + /** 71 + * Checks if an OAuth error was received instead of a code. 72 + */ 73 + fun awaitError(timeoutSeconds: Long = 300): String? { 74 + return try { 75 + errorFuture.get(1, TimeUnit.SECONDS) 76 + } catch (e: Exception) { 77 + null 78 + } 79 + } 80 + 81 + /** 82 + * Stops the callback server. 83 + */ 84 + fun stop() { 85 + server?.stop(0) 86 + server = null 87 + logger.info("OAuth callback server stopped") 88 + } 89 + 90 + /** 91 + * Handles the OAuth redirect callback. 92 + * Extracts the authorization code or error from query parameters. 93 + */ 94 + private fun handleCallback(exchange: HttpExchange) { 95 + val query = exchange.requestURI.query ?: "" 96 + val params = parseQuery(query) 97 + 98 + val code = params["code"] 99 + val error = params["error"] 100 + val errorDescription = params["error_description"] 101 + 102 + val responseHtml = when { 103 + code != null -> { 104 + codeFuture.complete(code) 105 + """ 106 + <!DOCTYPE html> 107 + <html> 108 + <head><title>SocialSync - Authorised</title></head> 109 + <body style="font-family:sans-serif;text-align:center;padding:3rem;"> 110 + <h1 style="color:#4CAF50;">Authorised!</h1> 111 + <p>You can close this tab and return to Minecraft.</p> 112 + </body> 113 + </html> 114 + """.trimIndent() 115 + } 116 + error != null -> { 117 + val message = errorDescription ?: error 118 + errorFuture.complete(message) 119 + """ 120 + <!DOCTYPE html> 121 + <html> 122 + <head><title>SocialSync - Authorisation Failed</title></head> 123 + <body style="font-family:sans-serif;text-align:center;padding:3rem;"> 124 + <h1 style="color:#F44336;">Authorisation Failed</h1> 125 + <p>$message</p> 126 + <p>You can close this tab and return to Minecraft.</p> 127 + </body> 128 + </html> 129 + """.trimIndent() 130 + } 131 + else -> { 132 + """ 133 + <!DOCTYPE html> 134 + <html> 135 + <head><title>SocialSync - Error</title></head> 136 + <body style="font-family:sans-serif;text-align:center;padding:3rem;"> 137 + <h1 style="color:#F44336;">Unexpected Response</h1> 138 + <p>No authorisation code or error received.</p> 139 + </body> 140 + </html> 141 + """.trimIndent() 142 + } 143 + } 144 + 145 + val responseBytes = responseHtml.toByteArray(Charsets.UTF_8) 146 + exchange.responseHeaders.set("Content-Type", "text/html; charset=utf-8") 147 + exchange.sendResponseHeaders(200, responseBytes.size.toLong()) 148 + exchange.responseBody.write(responseBytes) 149 + exchange.responseBody.close() 150 + 151 + // Stop the server after handling the callback 152 + stop() 153 + } 154 + 155 + /** 156 + * Handles requests to the root path with a simple status page. 157 + */ 158 + private fun handleRoot(exchange: HttpExchange) { 159 + val response = """ 160 + <!DOCTYPE html> 161 + <html> 162 + <head><title>SocialSync OAuth</title></head> 163 + <body style="font-family:sans-serif;text-align:center;padding:3rem;"> 164 + <h1>SocialSync OAuth Callback</h1> 165 + <p>Waiting for authorisation redirect...</p> 166 + </body> 167 + </html> 168 + """.trimIndent() 169 + 170 + val responseBytes = response.toByteArray(Charsets.UTF_8) 171 + exchange.responseHeaders.set("Content-Type", "text/html; charset=utf-8") 172 + exchange.sendResponseHeaders(200, responseBytes.size.toLong()) 173 + exchange.responseBody.write(responseBytes) 174 + exchange.responseBody.close() 175 + } 176 + 177 + /** 178 + * Parses URL query parameters into a map. 179 + */ 180 + private fun parseQuery(query: String): Map<String, String> { 181 + return query.split("&") 182 + .filter { it.contains("=") } 183 + .associate { 184 + val (key, value) = it.split("=", limit = 2) 185 + key to java.net.URLDecoder.decode(value, Charsets.UTF_8) 186 + } 187 + } 188 + }
+569
src/client/kotlin/com/jollywhoppers/atproto/oauth/OAuthManager.kt
··· 1 + package com.jollywhoppers.atproto.oauth 2 + 3 + import kotlinx.serialization.json.Json 4 + import org.slf4j.LoggerFactory 5 + import java.net.URI 6 + import java.net.http.HttpClient 7 + import java.net.http.HttpRequest 8 + import java.net.http.HttpResponse 9 + import java.security.KeyPair 10 + import java.time.Duration 11 + import java.util.UUID 12 + 13 + /** 14 + * Manages the ATProto OAuth authorization flow for client-side login. 15 + * 16 + * Flow overview: 17 + * 1. Resolve the user's handle/DID to find their PDS 18 + * 2. Fetch PDS resource server metadata to discover the authorization server 19 + * 3. Fetch authorization server metadata to discover OAuth endpoints 20 + * 4. Generate PKCE code verifier + challenge 21 + * 5. Generate DPoP key pair for the session 22 + * 6. Submit a Pushed Authorization Request (PAR) with DPoP proof 23 + * 7. Open the browser at the authorization endpoint with the PAR request_uri 24 + * 8. Start a localhost callback server to capture the redirect 25 + * 9. Exchange the authorization code for tokens (with PKCE verifier + DPoP proof) 26 + * 10. Verify the returned DID matches the expected identity 27 + * 11. Store the OAuth session 28 + */ 29 + class OAuthManager { 30 + private val logger = LoggerFactory.getLogger("atproto-connect:oauth") 31 + private val json = Json { 32 + ignoreUnknownKeys = true 33 + isLenient = true 34 + prettyPrint = false 35 + } 36 + 37 + private val httpClient = HttpClient.newBuilder() 38 + .connectTimeout(Duration.ofSeconds(10)) 39 + .followRedirects(HttpClient.Redirect.NEVER) 40 + .build() 41 + 42 + /** 43 + * Result of a successful OAuth flow. 44 + */ 45 + data class OAuthResult( 46 + val session: OAuthSession, 47 + val dpopKeyPair: KeyPair, 48 + ) 49 + 50 + /** 51 + * Runs the full OAuth authorization flow for a given handle or DID. 52 + * 53 + * @param identifier The user's ATProto handle (e.g. alice.bsky.social) or DID 54 + * @param scope The OAuth scopes to request (default: atproto) 55 + * @param timeoutSeconds How long to wait for the browser callback 56 + * @return The OAuth result containing the session and DPoP key pair 57 + */ 58 + suspend fun authorize( 59 + identifier: String, 60 + scope: String = "atproto", 61 + timeoutSeconds: Long = 300, 62 + ): Result<OAuthResult> = runCatching { 63 + logger.info("Starting OAuth flow for: $identifier") 64 + 65 + // Step 1: Resolve identity to find PDS 66 + val (did, handle, pdsUrl) = resolveIdentity(identifier) 67 + logger.info("Resolved identity: $handle ($did) at PDS: $pdsUrl") 68 + 69 + // Step 2: Fetch PDS resource server metadata 70 + val resourceMetadata = fetchResourceServerMetadata(pdsUrl) 71 + logger.info("Fetched resource server metadata from PDS") 72 + 73 + // Step 3: Discover authorization server 74 + val authServerIssuer = resourceMetadata.authorizationServers?.firstOrNull() 75 + ?: resourceMetadata.issuer 76 + logger.info("Authorization server: $authServerIssuer") 77 + 78 + // Step 4: Fetch authorization server metadata 79 + val authMetadata = fetchAuthorizationServerMetadata(authServerIssuer) 80 + logger.info("Fetched authorization server metadata") 81 + 82 + // Step 5: Generate PKCE 83 + val codeVerifier = PkceUtils.generateCodeVerifier() 84 + val codeChallenge = PkceUtils.generateCodeChallenge(codeVerifier) 85 + logger.info("Generated PKCE code verifier and challenge") 86 + 87 + // Step 6: Generate DPoP key pair 88 + val dpopKeyPair = DpopProof.generateKeyPair() 89 + logger.info("Generated DPoP key pair") 90 + 91 + // Step 7: Start callback server 92 + val callbackServer = OAuthCallbackServer() 93 + val port = callbackServer.start() 94 + val redirectUri = callbackServer.getRedirectUri() 95 + logger.info("Callback server started at $redirectUri") 96 + 97 + // Step 8: Build client_id for localhost 98 + val clientId = buildLocalhostClientId(redirectUri, scope) 99 + 100 + // Step 9: Submit PAR 101 + val state = UUID.randomUUID().toString() 102 + val parRequest = ParRequest( 103 + clientId = clientId, 104 + redirectUri = redirectUri, 105 + scope = scope, 106 + state = state, 107 + codeChallenge = codeChallenge, 108 + codeChallengeMethod = PkceUtils.getChallengeMethod(), 109 + loginHint = identifier, 110 + ) 111 + 112 + val parEndpoint = authMetadata.parEndpoint 113 + ?: throw IllegalStateException("Authorization server does not support PAR") 114 + 115 + val dpopProofForPar = DpopProof.buildProof( 116 + keyPair = dpopKeyPair, 117 + url = parEndpoint, 118 + method = "POST", 119 + ) 120 + 121 + val parResponse = submitPar(parEndpoint, parRequest, dpopProofForPar) 122 + logger.info("PAR submitted successfully, got request_uri") 123 + 124 + // Step 10: Build authorization URL and open browser 125 + val authUrl = buildAuthorizationUrl( 126 + authorizationEndpoint = authMetadata.authorizationEndpoint, 127 + clientId = clientId, 128 + requestUri = parResponse.requestUri, 129 + ) 130 + 131 + logger.info("Opening browser for authorization") 132 + openBrowser(authUrl) 133 + 134 + // Step 11: Wait for callback 135 + val code = callbackServer.awaitCode(timeoutSeconds) 136 + ?: throw IllegalStateException("OAuth callback timed out or failed") 137 + 138 + logger.info("Received authorization code from callback") 139 + 140 + // Step 12: Exchange code for tokens 141 + val tokenRequest = TokenRequest( 142 + code = code, 143 + redirectUri = redirectUri, 144 + codeVerifier = codeVerifier, 145 + clientId = clientId, 146 + ) 147 + 148 + val dpopProofForToken = DpopProof.buildProof( 149 + keyPair = dpopKeyPair, 150 + url = authMetadata.tokenEndpoint, 151 + method = "POST", 152 + ) 153 + 154 + val tokenResponse = exchangeCodeForTokens( 155 + tokenEndpoint = authMetadata.tokenEndpoint, 156 + request = tokenRequest, 157 + dpopProof = dpopProofForToken, 158 + ) 159 + 160 + logger.info("Token exchange successful") 161 + 162 + // Step 13: Verify DID 163 + val returnedDid = tokenResponse.sub 164 + if (returnedDid != null && returnedDid != did) { 165 + throw SecurityException("DID mismatch: expected $did, got $returnedDid") 166 + } 167 + logger.info("DID verified: $did") 168 + 169 + // Step 14: Build session 170 + val session = OAuthSession( 171 + did = did, 172 + handle = handle, 173 + pdsUrl = pdsUrl, 174 + accessToken = tokenResponse.accessToken, 175 + refreshToken = tokenResponse.refreshToken ?: throw IllegalStateException("No refresh token in response"), 176 + authServerIssuer = authServerIssuer, 177 + tokenEndpoint = authMetadata.tokenEndpoint, 178 + scope = tokenResponse.scope ?: scope, 179 + clientId = clientId, 180 + dpopPrivateKeyEncoded = dpopKeyPair.private.encoded, 181 + dpopPublicKeyEncoded = dpopKeyPair.public.encoded, 182 + ) 183 + 184 + // Clean up 185 + callbackServer.stop() 186 + 187 + OAuthResult(session = session, dpopKeyPair = dpopKeyPair) 188 + } 189 + 190 + /** 191 + * Refreshes an OAuth session using the refresh token. 192 + */ 193 + suspend fun refreshSession( 194 + session: OAuthSession, 195 + dpopKeyPair: KeyPair, 196 + ): Result<OAuthSession> = runCatching { 197 + logger.info("Refreshing OAuth session for ${session.handle}") 198 + 199 + val refreshRequest = TokenRefreshRequest( 200 + refreshToken = session.refreshToken, 201 + clientId = session.clientId, 202 + ) 203 + 204 + val dpopProof = DpopProof.buildProof( 205 + keyPair = dpopKeyPair, 206 + url = session.tokenEndpoint, 207 + method = "POST", 208 + nonce = session.authServerNonce, 209 + ) 210 + 211 + val requestBody = json.encodeToString(TokenRefreshRequest.serializer(), refreshRequest) 212 + 213 + val request = HttpRequest.newBuilder() 214 + .uri(URI.create(session.tokenEndpoint)) 215 + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) 216 + .header("Content-Type", "application/x-www-form-urlencoded") 217 + .header("DPoP", dpopProof) 218 + .timeout(Duration.ofSeconds(15)) 219 + .build() 220 + 221 + // For token refresh, we need to send as form-encoded 222 + val formBody = buildFormBody(mapOf( 223 + "grant_type" to "refresh_token", 224 + "refresh_token" to session.refreshToken, 225 + "client_id" to session.clientId, 226 + )) 227 + 228 + val formRequest = HttpRequest.newBuilder() 229 + .uri(URI.create(session.tokenEndpoint)) 230 + .POST(HttpRequest.BodyPublishers.ofString(formBody)) 231 + .header("Content-Type", "application/x-www-form-urlencoded") 232 + .header("DPoP", dpopProof) 233 + .timeout(Duration.ofSeconds(15)) 234 + .build() 235 + 236 + val response = httpClient.send(formRequest, HttpResponse.BodyHandlers.ofString()) 237 + 238 + if (response.statusCode() != 200) { 239 + // Check for DPoP nonce error 240 + val dpopNonce = response.headers().firstValue("DPoP-Nonce").orElse(null) 241 + if (dpopNonce != null) { 242 + logger.info("Received new DPoP nonce from token endpoint, retrying") 243 + return retryRefreshWithNonce(session, dpopKeyPair, dpopNonce) 244 + } 245 + throw IllegalStateException("Token refresh failed: HTTP ${response.statusCode()}") 246 + } 247 + 248 + val tokenResponse = json.decodeFromString<TokenResponse>(response.body()) 249 + 250 + session.copy( 251 + accessToken = tokenResponse.accessToken, 252 + refreshToken = tokenResponse.refreshToken ?: session.refreshToken, 253 + lastRefreshed = System.currentTimeMillis(), 254 + ) 255 + } 256 + 257 + /** 258 + * Retries a token refresh with a new DPoP nonce. 259 + */ 260 + private suspend fun retryRefreshWithNonce( 261 + session: OAuthSession, 262 + dpopKeyPair: KeyPair, 263 + nonce: String, 264 + ): Result<OAuthSession> = runCatching { 265 + val dpopProof = DpopProof.buildProof( 266 + keyPair = dpopKeyPair, 267 + url = session.tokenEndpoint, 268 + method = "POST", 269 + nonce = nonce, 270 + ) 271 + 272 + val formBody = buildFormBody(mapOf( 273 + "grant_type" to "refresh_token", 274 + "refresh_token" to session.refreshToken, 275 + "client_id" to session.clientId, 276 + )) 277 + 278 + val request = HttpRequest.newBuilder() 279 + .uri(URI.create(session.tokenEndpoint)) 280 + .POST(HttpRequest.BodyPublishers.ofString(formBody)) 281 + .header("Content-Type", "application/x-www-form-urlencoded") 282 + .header("DPoP", dpopProof) 283 + .timeout(Duration.ofSeconds(15)) 284 + .build() 285 + 286 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 287 + 288 + if (response.statusCode() != 200) { 289 + throw IllegalStateException("Token refresh retry failed: HTTP ${response.statusCode()}") 290 + } 291 + 292 + val tokenResponse = json.decodeFromString<TokenResponse>(response.body()) 293 + 294 + session.copy( 295 + accessToken = tokenResponse.accessToken, 296 + refreshToken = tokenResponse.refreshToken ?: session.refreshToken, 297 + authServerNonce = nonce, 298 + lastRefreshed = System.currentTimeMillis(), 299 + ) 300 + } 301 + 302 + // ============================================================================ 303 + // IDENTITY RESOLUTION 304 + // ============================================================================ 305 + 306 + /** 307 + * Resolves a handle or DID to (did, handle, pdsUrl). 308 + */ 309 + private fun resolveIdentity(identifier: String): Triple<String, String, String> { 310 + // For now, delegate to the existing client-side identity resolution. 311 + // This will be replaced with a direct implementation that doesn't depend 312 + // on the existing ClientAtProtoClient. 313 + throw NotImplementedError("Identity resolution will be wired up in the integration step") 314 + } 315 + 316 + // ============================================================================ 317 + // SERVER METADATA DISCOVERY 318 + // ============================================================================ 319 + 320 + /** 321 + * Fetches the resource server metadata from a PDS. 322 + * Tries the well-known endpoint first. 323 + */ 324 + private fun fetchResourceServerMetadata(pdsUrl: String): ResourceServerMetadata { 325 + val url = "$pdsUrl/.well-known/oauth-protected-resource" 326 + val request = HttpRequest.newBuilder() 327 + .uri(URI.create(url)) 328 + .GET() 329 + .timeout(Duration.ofSeconds(10)) 330 + .build() 331 + 332 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 333 + if (response.statusCode() != 200) { 334 + throw IllegalStateException("Failed to fetch resource server metadata: HTTP ${response.statusCode()}") 335 + } 336 + 337 + return json.decodeFromString<ResourceServerMetadata>(response.body()) 338 + } 339 + 340 + /** 341 + * Fetches the authorization server metadata. 342 + */ 343 + private fun fetchAuthorizationServerMetadata(issuer: String): AuthorizationServerMetadata { 344 + val url = "$issuer/.well-known/oauth-authorization-server" 345 + val request = HttpRequest.newBuilder() 346 + .uri(URI.create(url)) 347 + .GET() 348 + .timeout(Duration.ofSeconds(10)) 349 + .build() 350 + 351 + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) 352 + if (response.statusCode() != 200) { 353 + throw IllegalStateException("Failed to fetch authorization server metadata: HTTP ${response.statusCode()}") 354 + } 355 + 356 + return json.decodeFromString<AuthorizationServerMetadata>(response.body()) 357 + } 358 + 359 + // ============================================================================ 360 + // PAR 361 + // ============================================================================ 362 + 363 + /** 364 + * Submits a Pushed Authorization Request. 365 + */ 366 + private fun submitPar( 367 + parEndpoint: String, 368 + request: ParRequest, 369 + dpopProof: String, 370 + ): ParResponse { 371 + val formBody = buildFormBody(mapOf( 372 + "response_type" to request.responseType, 373 + "client_id" to request.clientId, 374 + "redirect_uri" to request.redirectUri, 375 + "scope" to request.scope, 376 + "state" to request.state, 377 + "code_challenge" to request.codeChallenge, 378 + "code_challenge_method" to request.codeChallengeMethod, 379 + "login_hint" to (request.loginHint ?: ""), 380 + )) 381 + 382 + val httpRequest = HttpRequest.newBuilder() 383 + .uri(URI.create(parEndpoint)) 384 + .POST(HttpRequest.BodyPublishers.ofString(formBody)) 385 + .header("Content-Type", "application/x-www-form-urlencoded") 386 + .header("DPoP", dpopProof) 387 + .timeout(Duration.ofSeconds(15)) 388 + .build() 389 + 390 + val response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()) 391 + 392 + if (response.statusCode() != 201 && response.statusCode() != 200) { 393 + // Check for DPoP nonce 394 + val dpopNonce = response.headers().firstValue("DPoP-Nonce").orElse(null) 395 + if (dpopNonce != null) { 396 + logger.info("Received DPoP nonce from PAR endpoint, retrying") 397 + return retryParWithNonce(parEndpoint, request, dpopNonce) 398 + } 399 + throw IllegalStateException("PAR request failed: HTTP ${response.statusCode()}: ${response.body()}") 400 + } 401 + 402 + return json.decodeFromString<ParResponse>(response.body()) 403 + } 404 + 405 + /** 406 + * Retries PAR with a DPoP nonce. 407 + */ 408 + private fun retryParWithNonce( 409 + parEndpoint: String, 410 + request: ParRequest, 411 + nonce: String, 412 + ): ParResponse { 413 + // Re-generate DPoP key pair for retry (or reuse — spec allows either) 414 + val dpopKeyPair = DpopProof.generateKeyPair() 415 + val dpopProof = DpopProof.buildProof( 416 + keyPair = dpopKeyPair, 417 + url = parEndpoint, 418 + method = "POST", 419 + nonce = nonce, 420 + ) 421 + 422 + val formBody = buildFormBody(mapOf( 423 + "response_type" to request.responseType, 424 + "client_id" to request.clientId, 425 + "redirect_uri" to request.redirectUri, 426 + "scope" to request.scope, 427 + "state" to request.state, 428 + "code_challenge" to request.codeChallenge, 429 + "code_challenge_method" to request.codeChallengeMethod, 430 + "login_hint" to (request.loginHint ?: ""), 431 + )) 432 + 433 + val httpRequest = HttpRequest.newBuilder() 434 + .uri(URI.create(parEndpoint)) 435 + .POST(HttpRequest.BodyPublishers.ofString(formBody)) 436 + .header("Content-Type", "application/x-www-form-urlencoded") 437 + .header("DPoP", dpopProof) 438 + .timeout(Duration.ofSeconds(15)) 439 + .build() 440 + 441 + val response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()) 442 + 443 + if (response.statusCode() != 201 && response.statusCode() != 200) { 444 + throw IllegalStateException("PAR retry failed: HTTP ${response.statusCode()}: ${response.body()}") 445 + } 446 + 447 + return json.decodeFromString<ParResponse>(response.body()) 448 + } 449 + 450 + // ============================================================================ 451 + // TOKEN EXCHANGE 452 + // ============================================================================ 453 + 454 + /** 455 + * Exchanges an authorization code for access and refresh tokens. 456 + */ 457 + private fun exchangeCodeForTokens( 458 + tokenEndpoint: String, 459 + request: TokenRequest, 460 + dpopProof: String, 461 + ): TokenResponse { 462 + val formBody = buildFormBody(mapOf( 463 + "grant_type" to request.grantType, 464 + "code" to request.code, 465 + "redirect_uri" to request.redirectUri, 466 + "code_verifier" to request.codeVerifier, 467 + "client_id" to request.clientId, 468 + )) 469 + 470 + val httpRequest = HttpRequest.newBuilder() 471 + .uri(URI.create(tokenEndpoint)) 472 + .POST(HttpRequest.BodyPublishers.ofString(formBody)) 473 + .header("Content-Type", "application/x-www-form-urlencoded") 474 + .header("DPoP", dpopProof) 475 + .timeout(Duration.ofSeconds(15)) 476 + .build() 477 + 478 + val response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()) 479 + 480 + if (response.statusCode() != 200) { 481 + throw IllegalStateException("Token exchange failed: HTTP ${response.statusCode()}: ${response.body()}") 482 + } 483 + 484 + return json.decodeFromString<TokenResponse>(response.body()) 485 + } 486 + 487 + // ============================================================================ 488 + // AUTHORIZATION URL 489 + // ============================================================================ 490 + 491 + /** 492 + * Builds the authorization URL to redirect the user to. 493 + */ 494 + private fun buildAuthorizationUrl( 495 + authorizationEndpoint: String, 496 + clientId: String, 497 + requestUri: String, 498 + ): String { 499 + val params = buildString { 500 + append("client_id=${encodeURIComponent(clientId)}") 501 + append("&request_uri=${encodeURIComponent(requestUri)}") 502 + } 503 + return "$authorizationEndpoint?$params" 504 + } 505 + 506 + // ============================================================================ 507 + // LOCALHOST CLIENT ID 508 + // ============================================================================ 509 + 510 + /** 511 + * Builds the localhost client_id per ATProto OAuth spec. 512 + * 513 + * For development and native apps, the client_id is `http://localhost` 514 + * with redirect_uri and scope as query parameters. 515 + */ 516 + private fun buildLocalhostClientId(redirectUri: String, scope: String): String { 517 + return "http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}" 518 + } 519 + 520 + // ============================================================================ 521 + // BROWSER 522 + // ============================================================================ 523 + 524 + /** 525 + * Opens the user's default browser at the given URL. 526 + */ 527 + private fun openBrowser(url: String) { 528 + try { 529 + val osName = System.getProperty("os.name").lowercase() 530 + when { 531 + osName.contains("mac") -> Runtime.getRuntime().exec(arrayOf("open", url)) 532 + osName.contains("win") -> Runtime.getRuntime().exec(arrayOf("rundll32", "url.dll,FileProtocolHandler", url)) 533 + else -> Runtime.getRuntime().exec(arrayOf("xdg-open", url)) 534 + } 535 + logger.info("Opened browser at: $url") 536 + } catch (e: Exception) { 537 + logger.error("Failed to open browser", e) 538 + throw IllegalStateException("Could not open browser. Please open this URL manually: $url") 539 + } 540 + } 541 + 542 + // ============================================================================ 543 + // UTILITIES 544 + // ============================================================================ 545 + 546 + /** 547 + * Encodes a string for use in a URL query parameter. 548 + */ 549 + private fun encodeURIComponent(value: String): String { 550 + return java.net.URLEncoder.encode(value, Charsets.UTF_8) 551 + .replace("+", "%20") 552 + .replace("%21", "!") 553 + .replace("%27", "'") 554 + .replace("%28", "(") 555 + .replace("%29", ")") 556 + .replace("%7E", "~") 557 + } 558 + 559 + /** 560 + * Builds an application/x-www-form-urlencoded body from a map. 561 + */ 562 + private fun buildFormBody(params: Map<String, String>): String { 563 + return params.entries 564 + .filter { it.value.isNotEmpty() } 565 + .joinToString("&") { (key, value) -> 566 + "${encodeURIComponent(key)}=${encodeURIComponent(value)}" 567 + } 568 + } 569 + }
+209
src/client/kotlin/com/jollywhoppers/atproto/oauth/OAuthModels.kt
··· 1 + package com.jollywhoppers.atproto.oauth 2 + 3 + import kotlinx.serialization.SerialName 4 + import kotlinx.serialization.Serializable 5 + 6 + /** 7 + * Data models for ATProto OAuth flows. 8 + * 9 + * These models represent the various requests and responses involved in: 10 + * - Authorization server metadata discovery 11 + * - Pushed Authorization Requests (PAR) 12 + * - Token exchange (authorization code → access + refresh tokens) 13 + * - Token refresh 14 + * - Client metadata 15 + */ 16 + 17 + // ============================================================================ 18 + // AUTHORIZATION SERVER METADATA 19 + // ============================================================================ 20 + 21 + /** 22 + * Authorization server metadata per RFC 8414 and ATProto OAuth extensions. 23 + * Fetched from the PDS or entryway to discover OAuth endpoints. 24 + */ 25 + @Serializable 26 + data class AuthorizationServerMetadata( 27 + val issuer: String, 28 + @SerialName("authorization_endpoint") val authorizationEndpoint: String, 29 + @SerialName("token_endpoint") val tokenEndpoint: String, 30 + @SerialName("pushed_authorization_request_endpoint") val parEndpoint: String? = null, 31 + @SerialName("revocation_endpoint") val revocationEndpoint: String? = null, 32 + @SerialName("introspection_endpoint") val introspectionEndpoint: String? = null, 33 + @SerialName("scopes_supported") val scopesSupported: List<String>? = null, 34 + @SerialName("code_challenge_methods_supported") val codeChallengeMethodsSupported: List<String>? = null, 35 + @SerialName("response_types_supported") val responseTypesSupported: List<String>? = null, 36 + @SerialName("grant_types_supported") val grantTypesSupported: List<String>? = null, 37 + @SerialName("token_endpoint_auth_methods_supported") val tokenEndpointAuthMethodsSupported: List<String>? = null, 38 + @SerialName("dpop_signing_alg_values_supported") val dpopSigningAlgValuesSupported: List<String>? = null, 39 + ) 40 + 41 + // ============================================================================ 42 + // RESOURCE SERVER METADATA (PDS) 43 + // ============================================================================ 44 + 45 + /** 46 + * Resource server metadata for a PDS. 47 + * Used to discover the authorization server for a given PDS. 48 + */ 49 + @Serializable 50 + data class ResourceServerMetadata( 51 + val issuer: String, 52 + @SerialName("authorization_servers") val authorizationServers: List<String>? = null, 53 + @SerialName("token_endpoint") val tokenEndpoint: String? = null, 54 + @SerialName("pushed_authorization_request_endpoint") val parEndpoint: String? = null, 55 + ) 56 + 57 + // ============================================================================ 58 + // CLIENT METADATA 59 + // ============================================================================ 60 + 61 + /** 62 + * OAuth client metadata document. 63 + * Published at the client_id URL and fetched by the authorization server. 64 + * 65 + * For localhost development, ATProto supports `http://localhost` as a client_id 66 + * with virtual metadata constructed from query parameters. 67 + */ 68 + @Serializable 69 + data class OAuthClientMetadata( 70 + @SerialName("client_id") val clientId: String, 71 + @SerialName("client_name") val clientName: String? = null, 72 + @SerialName("client_uri") val clientUri: String? = null, 73 + @SerialName("redirect_uris") val redirectUris: List<String>, 74 + @SerialName("grant_types") val grantTypes: List<String> = listOf("authorization_code", "refresh_token"), 75 + @SerialName("response_types") val responseTypes: List<String> = listOf("code"), 76 + val scope: String = "atproto", 77 + @SerialName("token_endpoint_auth_method") val tokenEndpointAuthMethod: String = "none", 78 + @SerialName("application_type") val applicationType: String = "native", 79 + @SerialName("dpop_bound_access_tokens") val dpopBoundAccessTokens: Boolean = true, 80 + @SerialName("logo_uri") val logoUri: String? = null, 81 + @SerialName("tos_uri") val tosUri: String? = null, 82 + @SerialName("policy_uri") val policyUri: String? = null, 83 + ) 84 + 85 + // ============================================================================ 86 + // PUSHED AUTHORIZATION REQUEST (PAR) 87 + // ============================================================================ 88 + 89 + /** 90 + * PAR request body. 91 + * Sent to the PAR endpoint to register the authorization parameters 92 + * before redirecting the user to the authorization endpoint. 93 + */ 94 + @Serializable 95 + data class ParRequest( 96 + @SerialName("response_type") val responseType: String = "code", 97 + @SerialName("client_id") val clientId: String, 98 + @SerialName("redirect_uri") val redirectUri: String, 99 + val scope: String, 100 + val state: String, 101 + @SerialName("code_challenge") val codeChallenge: String, 102 + @SerialName("code_challenge_method") val codeChallengeMethod: String = "S256", 103 + @SerialName("login_hint") val loginHint: String? = null, 104 + ) 105 + 106 + /** 107 + * PAR response. 108 + * Returns a request_uri that is used in the authorization redirect. 109 + */ 110 + @Serializable 111 + data class ParResponse( 112 + @SerialName("request_uri") val requestUri: String, 113 + val expires: Int? = null, 114 + ) 115 + 116 + // ============================================================================ 117 + // TOKEN EXCHANGE 118 + // ============================================================================ 119 + 120 + /** 121 + * Token request body for exchanging an authorization code for tokens. 122 + */ 123 + @Serializable 124 + data class TokenRequest( 125 + @SerialName("grant_type") val grantType: String = "authorization_code", 126 + val code: String, 127 + @SerialName("redirect_uri") val redirectUri: String, 128 + @SerialName("code_verifier") val codeVerifier: String, 129 + @SerialName("client_id") val clientId: String, 130 + ) 131 + 132 + /** 133 + * Token response containing access and refresh tokens. 134 + * The `sub` field contains the account DID and must be verified. 135 + */ 136 + @Serializable 137 + data class TokenResponse( 138 + @SerialName("access_token") val accessToken: String, 139 + @SerialName("refresh_token") val refreshToken: String? = null, 140 + @SerialName("token_type") val tokenType: String = "DPoP", 141 + @SerialName("expires_in") val expiresIn: Int? = null, 142 + val scope: String? = null, 143 + val sub: String? = null, 144 + ) 145 + 146 + // ============================================================================ 147 + // TOKEN REFRESH 148 + // ============================================================================ 149 + 150 + /** 151 + * Token refresh request body. 152 + */ 153 + @Serializable 154 + data class TokenRefreshRequest( 155 + @SerialName("grant_type") val grantType: String = "refresh_token", 156 + @SerialName("refresh_token") val refreshToken: String, 157 + @SerialName("client_id") val clientId: String, 158 + ) 159 + 160 + // ============================================================================ 161 + // OAUTH SESSION 162 + // ============================================================================ 163 + 164 + /** 165 + * A complete OAuth session, including tokens, key material, and metadata. 166 + * Stored persistently and used for all authenticated requests. 167 + */ 168 + data class OAuthSession( 169 + val did: String, 170 + val handle: String, 171 + val pdsUrl: String, 172 + val accessToken: String, 173 + val refreshToken: String, 174 + val authServerIssuer: String, 175 + val tokenEndpoint: String, 176 + val pdsTokenEndpoint: String? = null, 177 + val scope: String, 178 + val clientId: String, 179 + val dpopPrivateKeyEncoded: ByteArray, 180 + val dpopPublicKeyEncoded: ByteArray, 181 + val authServerNonce: String? = null, 182 + val pdsNonce: String? = null, 183 + val createdAt: Long = System.currentTimeMillis(), 184 + val lastRefreshed: Long = System.currentTimeMillis(), 185 + ) { 186 + override fun equals(other: Any?): Boolean { 187 + if (this === other) return true 188 + if (other !is OAuthSession) return false 189 + return did == other.did && accessToken == other.accessToken 190 + } 191 + 192 + override fun hashCode(): Int { 193 + return 31 * did.hashCode() + accessToken.hashCode() 194 + } 195 + } 196 + 197 + // ============================================================================ 198 + // OAUTH ERROR 199 + // ============================================================================ 200 + 201 + /** 202 + * OAuth error response per RFC 6749 §5.2. 203 + */ 204 + @Serializable 205 + data class OAuthError( 206 + val error: String, 207 + @SerialName("error_description") val errorDescription: String? = null, 208 + @SerialName("error_uri") val errorUri: String? = null, 209 + )
+51
src/client/kotlin/com/jollywhoppers/atproto/oauth/PkceUtils.kt
··· 1 + package com.jollywhoppers.atproto.oauth 2 + 3 + import java.security.SecureRandom 4 + import java.util.Base64 5 + 6 + /** 7 + * PKCE (Proof Key for Code Exchange) utilities per RFC 7636. 8 + * 9 + * ATProto OAuth mandates PKCE for every authorization request. 10 + * The client generates a random code verifier, derives a code challenge 11 + * from it (S256 method), sends the challenge in the authorization request, 12 + * and proves possession of the verifier when exchanging the code for tokens. 13 + */ 14 + object PkceUtils { 15 + private const val VERIFIER_LENGTH = 32 // 256 bits of entropy 16 + private const val CHALLENGE_METHOD = "S256" 17 + 18 + private val secureRandom = SecureRandom() 19 + private val base64UrlEncoder = Base64.getUrlEncoder().withoutPadding() 20 + 21 + /** 22 + * Generates a PKCE code verifier. 23 + * 24 + * Per RFC 7636 §4.1: the verifier is a random string between 43 and 128 25 + * characters using the unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~". 26 + * 27 + * We generate 32 random bytes and base64url-encode them, producing a 43-character string. 28 + */ 29 + fun generateCodeVerifier(): String { 30 + val bytes = ByteArray(VERIFIER_LENGTH) 31 + secureRandom.nextBytes(bytes) 32 + return base64UrlEncoder.encodeToString(bytes) 33 + } 34 + 35 + /** 36 + * Derives a PKCE code challenge from a code verifier using S256. 37 + * 38 + * Per RFC 7636 §4.2: CODE_CHALLENGE = BASE64URL(SHA256(CODE_VERIFIER)) 39 + */ 40 + fun generateCodeChallenge(codeVerifier: String): String { 41 + val digest = java.security.MessageDigest.getInstance("SHA-256") 42 + val hash = digest.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) 43 + return base64UrlEncoder.encodeToString(hash) 44 + } 45 + 46 + /** 47 + * Returns the PKCE code challenge method used. 48 + * ATProto OAuth requires S256. 49 + */ 50 + fun getChallengeMethod(): String = CHALLENGE_METHOD 51 + }