1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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<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(), 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
}
}