package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.DPoP import social.grain.data.auth.AuthManager import social.grain.data.auth.IdentityResolver import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import java.util.concurrent.TimeUnit /** * Exercises the real DPoP signer against a live authorization server's PAR * endpoint: bsky.social's, found the way a sign-in finds it. * * This is the one part of the port that can't be checked by reading the code: * an ES256 proof with the wrong signature encoding, a malformed JWK thumbprint, * or a bad `htu` claim all come back as an opaque 400, and nothing upstream of * a successful `request_uri` proves the crypto is right. The server also fetches * the client metadata at [AuthManager.CLIENT_ID] and checks the scope against * it, so this fails until grain-serve publishes that document. * * Needs network. The assertion messages name what failed so a red run is * diagnosable. */ @RunWith(AndroidJUnit4::class) class DpopParTest { private val http = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS) .build() @Test fun dpopSignedParRequestIsAcceptedByServer() { val dpop = DPoP.createEphemeral() val server = runBlocking { IdentityResolver(http, Json { ignoreUnknownKeys = true }).authServer("https://bsky.social") } val url = server.parEndpoint val form = FormBody.Builder() .add("client_id", AuthManager.CLIENT_ID) .add("redirect_uri", AuthManager.REDIRECT_URI) .add("response_type", "code") // A fixed verifier/challenge pair: PAR only checks the challenge is // well-formed, and the token exchange this test doesn't reach is // where the pair actually has to match. .add("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") .add("code_challenge_method", "S256") .add("scope", AuthManager.REQUIRED_SCOPES.joinToString(" ")) .add("state", "dpop-par-test") .build() fun send(nonce: String?): Triple { val request = Request.Builder() .url(url) .post(form) .header("DPoP", dpop.createProof("POST", url, nonce = nonce)) .build() return http.newCall(request).execute().use { Triple(it.code, it.body.string(), it.header("DPoP-Nonce")) } } // The first DPoP-signed request of a session is answered with a nonce // challenge; replaying with it is the normal path, not a failure. var (code, body, nonce) = send(null) if (code == 400 && nonce != null) { val retry = send(nonce) code = retry.first body = retry.second } // RFC 9126 says 201; accept any 2xx rather than pinning the test to // one server's choice. assertTrue("PAR rejected the DPoP proof (HTTP $code). Body: $body", code in 200..299) val requestUri = JSONObject(body).optString("request_uri") assertTrue("PAR returned no request_uri. Body: $body", requestUri.startsWith("urn:")) } @Test fun proofIsAWellFormedThreeSegmentJwt() { val proof = DPoP.createEphemeral() .createProof("GET", "https://grain.social/xrpc/social.grain.feed.getFeed?limit=30") val segments = proof.split(".") assertEquals("A JWS has exactly three segments", 3, segments.size) val header = JSONObject(String(android.util.Base64.decode(segments[0], BASE64_FLAGS))) assertEquals("ES256", header.getString("alg")) assertEquals("dpop+jwt", header.getString("typ")) assertEquals("P-256", header.getJSONObject("jwk").getString("crv")) val payload = JSONObject(String(android.util.Base64.decode(segments[1], BASE64_FLAGS))) assertEquals("GET", payload.getString("htm")) // The query string must not survive into `htu` — servers normalise the // same way and compare literally. assertEquals("https://grain.social/xrpc/social.grain.feed.getFeed", payload.getString("htu")) val signature = android.util.Base64.decode(segments[2], BASE64_FLAGS) assertEquals("ES256 signatures are raw r||s, not DER", 64, signature.size) } private companion object { const val BASE64_FLAGS = android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP } }