Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
4.8 kB · 112 lines
Kotlin
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113package social.grain
import androidx.test.ext.junit.runners.AndroidJUnit4import okhttp3.FormBodyimport okhttp3.OkHttpClientimport okhttp3.Requestimport org.json.JSONObjectimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertTrueimport org.junit.Testimport org.junit.runner.RunWithimport social.grain.data.api.DPoPimport social.grain.data.auth.AuthManagerimport java.util.concurrent.TimeUnit
/** * Exercises the real DPoP signer against the live grain.social PAR endpoint. * * 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. * * Needs network. Skipped implicitly if grain.social is unreachable — 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 url = "${AuthManager.SERVER_URL}/oauth/par"
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(" ")) // Without a `login_hint` hatk answers PAR by itself and never // forwards it to a PDS — which is where the scopes are checked // against the client metadata. A hint is what makes this test see // an undeclared scope instead of a 200 that means nothing. .add("login_hint", "grain.social") .build()
fun send(nonce: String?): Triple<Int, String, String?> { 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().orEmpty(), 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 }
// hatk answers PAR with 200; RFC 9126 says 201. Accept either 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/dev.hatk.getFeed?feed=recent")
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/dev.hatk.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 }}