Grain signs in at the account's own PDS, as an atproto OAuth client
app/src/androidTest/java/social/grain/DpopParTest.kt+18−13
| @@ -11,18 +11,24 @@ 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 the live grain.social PAR endpoint. | ||
* 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. | ||
* 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. Skipped implicitly if grain.social is unreachable — the | ||
* assertion messages name what failed so a red run is diagnosable. | ||
* Needs network. The assertion messages name what failed so a red run is | ||
* diagnosable. | ||
*/ | ||
@RunWith(AndroidJUnit4::class) | ||
class DpopParTest { | ||
| @@ -35,7 +41,10 @@ class DpopParTest { | ||
@Test | ||
fun dpopSignedParRequestIsAcceptedByServer() { | ||
val dpop = DPoP.createEphemeral() | ||
val url = "${AuthManager.SERVER_URL}/oauth/par" | ||
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) | ||
| @@ -47,11 +56,7 @@ class DpopParTest { | ||
.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") | ||
.add("state", "dpop-par-test") | ||
.build() | ||
| ||
fun send(nonce: String?): Triple<Int, String, String?> { | ||
| @@ -61,7 +66,7 @@ class DpopParTest { | ||
.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")) | ||
Triple(it.code, it.body.string(), it.header("DPoP-Nonce")) | ||
} | ||
} | ||
| ||
| @@ -74,8 +79,8 @@ class DpopParTest { | ||
body = retry.second | ||
} | ||
| ||
// hatk answers PAR with 200; RFC 9126 says 201. Accept either rather | ||
// than pinning the test to one server's choice. | ||
// 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:")) | ||
app/src/androidTest/java/social/grain/SessionPersistenceTest.kt+9−2
| @@ -58,6 +58,12 @@ class SessionPersistenceTest { | ||
tokenStorage.activeDid = savedActiveDid | ||
} | ||
| ||
private val testServer = social.grain.data.auth.AccountServer( | ||
pds = "https://pds.example.test", | ||
issuer = "https://auth.example.test", | ||
tokenEndpoint = "https://auth.example.test/oauth/token", | ||
) | ||
| ||
private fun seedSession() { | ||
tokenStorage.storeDpopKey(fakeDid, DPoP.createEphemeral().encodedKeyPair()) | ||
tokenStorage.storeTokens( | ||
| @@ -66,10 +72,9 @@ class SessionPersistenceTest { | ||
refreshToken = "refresh-token-value", | ||
handle = "tester.grain.social", | ||
expiresAtMillis = System.currentTimeMillis() + 3_600_000, | ||
// The real server omits this, which is what made the ported | ||
// migration misfire. Seed it the same way. | ||
scope = null, | ||
) | ||
tokenStorage.storeServer(fakeDid, testServer) | ||
tokenStorage.activeDid = fakeDid | ||
tokenStorage.upsertAccount(StoredAccount(fakeDid, "tester.grain.social", null)) | ||
} | ||
| @@ -129,6 +134,7 @@ class SessionPersistenceTest { | ||
expiresAtMillis = System.currentTimeMillis() + 3_600_000, | ||
scope = null, | ||
) | ||
tokenStorage.storeServer(survivor, testServer) | ||
tokenStorage.upsertAccount(StoredAccount(survivor, "survivor.grain.social", null)) | ||
| ||
tokenStorage.storeDpopKey(doomed, DPoP.createEphemeral().encodedKeyPair()) | ||
| @@ -140,6 +146,7 @@ class SessionPersistenceTest { | ||
expiresAtMillis = System.currentTimeMillis() - 1_000, | ||
scope = null, | ||
) | ||
tokenStorage.storeServer(doomed, testServer) | ||
tokenStorage.upsertAccount(StoredAccount(doomed, "doomed.grain.social", null)) | ||
tokenStorage.activeDid = doomed | ||
| ||
app/src/main/java/social/grain/data/api/DpopNonces.ktnew+77−0
| @@ -0,0 +1,77 @@ | ||
package social.grain.data.api | ||
| ||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||
import java.util.concurrent.ConcurrentHashMap | ||
| ||
/** | ||
* The DPoP nonce each server last handed out, kept per origin. | ||
* | ||
* An account's authorization server and its PDS are different origins, and | ||
* each issues its own nonces: a proof sent to one carrying the other's nonce is | ||
* refused. Kept in memory only, as a nonce lives for minutes and a refusal | ||
* costs one replay to learn the current one. | ||
*/ | ||
class DpopNonces { | ||
private val nonces = ConcurrentHashMap<String, String>() | ||
| ||
/** The nonce to put in a proof for [url], if its server has given one. */ | ||
operator fun get(url: String): String? = origin(url)?.let(nonces::get) | ||
| ||
/** Keeps the nonce in a response from [url], if it carries one. */ | ||
fun keep(url: String, nonce: String?) { | ||
val origin = origin(url) ?: return | ||
if (nonce != null) nonces[origin] = nonce | ||
} | ||
| ||
companion object { | ||
/** `scheme://host[:port]`, the port only when it isn't the scheme's default. */ | ||
fun origin(url: String): String? { | ||
val parsed = url.toHttpUrlOrNull() ?: return null | ||
val default = if (parsed.scheme == "https") 443 else 80 | ||
val port = if (parsed.port == default) "" else ":${parsed.port}" | ||
return "${parsed.scheme}://${parsed.host}$port" | ||
} | ||
} | ||
} | ||
| ||
/** | ||
* Names derived from the appview's URL. | ||
* | ||
* The client ID, the redirect scheme and the proxy target all follow the one | ||
* server URL, so pointing a build at another grain-serve moves all three. | ||
*/ | ||
object ServiceIdentity { | ||
/** `did:web:<host>`, a non-default port encoded as `%3A`, as did:web asks. */ | ||
fun serviceDid(serverUrl: String): String { | ||
val url = requireNotNull(serverUrl.toHttpUrlOrNull()) { "Not a URL: $serverUrl" } | ||
val default = if (url.scheme == "https") 443 else 80 | ||
val port = if (url.port == default) "" else "%3A${url.port}" | ||
return "did:web:${url.host}$port" | ||
} | ||
| ||
/** The `atproto-proxy` value that sends a call through the PDS to grain-serve. */ | ||
fun appviewProxy(serverUrl: String): String = "${serviceDid(serverUrl)}#grain_appview" | ||
| ||
/** | ||
* The `rpc` scope for every call proxied to grain-serve. The `aud` is a | ||
* query value the PDS URL-decodes, so `%` is escaped first, then `#`: | ||
* `did:web:localhost%3A3000#grain_appview` is written | ||
* `did:web:localhost%253A3000%23grain_appview`. | ||
*/ | ||
fun rpcScope(serverUrl: String): String = | ||
"rpc:*?aud=" + appviewProxy(serverUrl).replace("%", "%25").replace("#", "%23") | ||
| ||
/** The client metadata document grain-serve publishes for the apps. */ | ||
fun clientId(serverUrl: String): String = "$serverUrl/oauth/app-client-metadata.json" | ||
| ||
/** The server's host reversed, the redirect scheme atproto asks of a native client. */ | ||
fun redirectScheme(serverUrl: String): String { | ||
val url = requireNotNull(serverUrl.toHttpUrlOrNull()) { "Not a URL: $serverUrl" } | ||
return url.host.split('.').reversed().joinToString(".") | ||
} | ||
| ||
/** `<scheme>:/oauth/callback`: one slash, as a private-use URI scheme takes. */ | ||
fun redirectUri(serverUrl: String): String = "${redirectScheme(serverUrl)}:$CALLBACK_PATH" | ||
| ||
const val CALLBACK_PATH = "/oauth/callback" | ||
} | ||
app/src/main/java/social/grain/data/api/Endpoints.kt+96−35
| @@ -206,18 +206,30 @@ data class PinnedFeed( | ||
} | ||
| ||
/** | ||
* One write inside a `dev.hatk.applyWrites` batch. | ||
* | ||
* Grain only ever creates, and always with an explicit rkey, so a batch that | ||
* has to be retried addresses the same records both times. | ||
* One write inside a `com.atproto.repo.applyWrites` batch, always with an | ||
* explicit rkey, so a batch that has to be retried addresses the same records | ||
* both times. [value] is required to create or update, and ignored to delete. | ||
*/ | ||
data class ApplyWrite(val collection: String, val rkey: String, val value: JsonObject) { | ||
data class ApplyWrite( | ||
val collection: String, | ||
val rkey: String, | ||
val value: JsonObject? = null, | ||
val action: Action = Action.Create, | ||
) { | ||
enum class Action(val type: String) { | ||
Create("com.atproto.repo.applyWrites#create"), | ||
Update("com.atproto.repo.applyWrites#update"), | ||
Delete("com.atproto.repo.applyWrites#delete"), | ||
} | ||
| ||
fun toJson(): JsonObject = buildJsonObject { | ||
put("\$type", "dev.hatk.applyWrites#create") | ||
put("\$type", action.type) | ||
put("collection", collection) | ||
put("rkey", rkey) | ||
put("value", value) | ||
if (action != Action.Delete) value?.let { put("value", it) } | ||
} | ||
| ||
fun uri(repo: String): String = "at://$repo/$collection/$rkey" | ||
} | ||
| ||
// MARK: - Discovery | ||
| @@ -550,20 +562,28 @@ suspend fun XrpcClient.getKnownFollowers( | ||
suspend fun XrpcClient.getRecord(uri: String, auth: AuthContext? = null): GetRecordResponse = | ||
query("dev.hatk.getRecord", mapOf("uri" to uri), auth) | ||
| ||
/** The account a write is made as: the PDS only takes writes to its own repo. */ | ||
private fun AuthContext?.repoDid(): String = this?.did ?: throw XrpcException.Unauthorized | ||
| ||
/** Create a record in the account's repo at its PDS, then have grain index it. */ | ||
suspend fun XrpcClient.createRecord( | ||
collection: String, | ||
repo: String, | ||
record: JsonObject, | ||
auth: AuthContext? = null, | ||
): CreateRecordResponse = procedure( | ||
"dev.hatk.createRecord", | ||
buildJsonObject { | ||
put("collection", collection) | ||
put("repo", repo) | ||
put("record", record) | ||
}, | ||
auth, | ||
) | ||
): CreateRecordResponse { | ||
val response: CreateRecordResponse = procedure( | ||
"com.atproto.repo.createRecord", | ||
buildJsonObject { | ||
put("repo", repo) | ||
put("collection", collection) | ||
put("record", record) | ||
}, | ||
auth, | ||
) | ||
indexRecords(listOfNotNull(response.uri), auth) | ||
return response | ||
} | ||
| ||
suspend fun XrpcClient.putRecord( | ||
collection: String, | ||
| @@ -571,46 +591,87 @@ suspend fun XrpcClient.putRecord( | ||
record: JsonObject, | ||
repo: String? = null, | ||
auth: AuthContext? = null, | ||
): CreateRecordResponse = procedure( | ||
"dev.hatk.putRecord", | ||
buildJsonObject { | ||
put("collection", collection) | ||
put("rkey", rkey) | ||
put("record", record) | ||
repo?.let { put("repo", it) } | ||
}, | ||
auth, | ||
) | ||
): CreateRecordResponse { | ||
val owner = repo ?: auth.repoDid() | ||
val response: CreateRecordResponse = procedure( | ||
"com.atproto.repo.putRecord", | ||
buildJsonObject { | ||
put("repo", owner) | ||
put("collection", collection) | ||
put("rkey", rkey) | ||
put("record", record) | ||
}, | ||
auth, | ||
) | ||
indexRecords(listOf(response.uri ?: "at://$owner/$collection/$rkey"), auth) | ||
return response | ||
} | ||
| ||
suspend fun XrpcClient.deleteRecord( | ||
collection: String, | ||
rkey: String, | ||
auth: AuthContext? = null, | ||
) { | ||
val repo = auth.repoDid() | ||
procedureVoid( | ||
"dev.hatk.deleteRecord", | ||
"com.atproto.repo.deleteRecord", | ||
buildJsonObject { | ||
put("repo", repo) | ||
put("collection", collection) | ||
put("rkey", rkey) | ||
}, | ||
auth, | ||
) | ||
indexRecords(listOf("at://$repo/$collection/$rkey"), auth) | ||
} | ||
| ||
/** | ||
* Apply every write in a single atomic PDS commit. Either all the records land | ||
* or none do — there is no partially-created gallery to clean up. | ||
* or none do: there is no partially-created gallery to clean up. | ||
*/ | ||
suspend fun XrpcClient.applyWrites( | ||
writes: List<ApplyWrite>, | ||
auth: AuthContext? = null, | ||
): ApplyWritesResponse = procedure( | ||
"dev.hatk.applyWrites", | ||
buildJsonObject { | ||
put("writes", buildJsonArray { writes.forEach { add(it.toJson()) } }) | ||
}, | ||
auth, | ||
) | ||
): ApplyWritesResponse { | ||
val repo = auth.repoDid() | ||
val response: ApplyWritesResponse = procedure( | ||
"com.atproto.repo.applyWrites", | ||
buildJsonObject { | ||
put("repo", repo) | ||
put("writes", buildJsonArray { writes.forEach { add(it.toJson()) } }) | ||
}, | ||
auth, | ||
) | ||
indexRecords(writes.map { it.uri(repo) }, auth) | ||
return response | ||
} | ||
| ||
/** | ||
* Ask grain-serve to index records just written to the PDS, so the next read | ||
* sees them without waiting on the firehose. Deleted records are named too, so | ||
* it drops them. At most [INDEX_BATCH] URIs a call. | ||
* | ||
* Best effort: the write has already landed, and the firehose brings it in | ||
* regardless, so a failure here is logged and never fails the user's action. | ||
*/ | ||
suspend fun XrpcClient.indexRecords(uris: List<String>, auth: AuthContext?) { | ||
if (uris.isEmpty() || auth == null) return | ||
for (chunk in uris.distinct().chunked(INDEX_BATCH)) { | ||
try { | ||
procedureVoid( | ||
"social.grain.sync.indexRecords", | ||
buildJsonObject { put("uris", buildJsonArray { chunk.forEach { add(JsonPrimitive(it)) } }) }, | ||
auth, | ||
) | ||
} catch (e: kotlinx.coroutines.CancellationException) { | ||
throw e | ||
} catch (e: Exception) { | ||
android.util.Log.w("XRPC", "indexRecords failed for ${chunk.size} records", e) | ||
} | ||
} | ||
} | ||
| ||
const val INDEX_BATCH = 25 | ||
| ||
/** | ||
* Deleting a gallery goes through the unspecced endpoint, not a plain | ||
app/src/main/java/social/grain/data/api/XrpcClient.kt+108−70
| @@ -41,30 +41,45 @@ sealed class XrpcException(message: String) : Exception(message) { | ||
XrpcException(description ?: "Sign-in failed ($code)") | ||
} | ||
| ||
/** Context for authenticated requests. */ | ||
/** | ||
* Context for authenticated requests: the account's token, the key it is bound | ||
* to, and the PDS it is spent at. With no [pdsUrl] the call goes to the | ||
* client's own base URL. | ||
*/ | ||
data class AuthContext( | ||
val accessToken: String, | ||
val dpop: DPoP, | ||
val nonce: String? = null, | ||
val did: String? = null, | ||
val pdsUrl: String? = null, | ||
) | ||
| ||
@Serializable | ||
data class UploadBlobResponse(val blob: BlobRef) | ||
| ||
/** | ||
* XRPC client for the hatk server. | ||
* XRPC client for grain's appview, reached directly when signed out and | ||
* through the account's PDS when signed in. | ||
* | ||
* A signed-in call goes to the PDS with `Authorization: DPoP <token>` and a | ||
* proof per request; every method outside `com.atproto.*` also carries | ||
* `atproto-proxy: <appview did>#grain_appview`, so the PDS forwards it to | ||
* grain-serve as the account. `com.atproto.*` calls, the record writes and | ||
* blob uploads, are the PDS's own. | ||
* | ||
* Two retry paths are folded in, both required by the AT Protocol OAuth flow. | ||
* A `DPoP-Nonce` header on a 400 or 401 means the server wants the proof | ||
* re-signed with its nonce — that's a single replay, not a failure. A bare 401 | ||
* means the access token is stale, so [onUnauthorized] gets one chance to | ||
* refresh it before the call is reported as failed. | ||
* Two retry paths are folded in, both required by atproto OAuth. A 401 whose | ||
* `WWW-Authenticate` says `use_dpop_nonce`, or a 400 whose body does, means the | ||
* server wants the proof re-signed with the nonce it just sent: a single | ||
* replay, not a failure. Any other 401 means the access token is stale, so | ||
* [onUnauthorized] gets one chance to refresh it before the call is reported | ||
* as failed. | ||
*/ | ||
class XrpcClient( | ||
val baseUrl: String, | ||
private val http: OkHttpClient, | ||
@property:PublishedApi internal val json: Json, | ||
private val onUnauthorized: (suspend () -> AuthContext?)? = null, | ||
private val nonces: DpopNonces = DpopNonces(), | ||
private val appviewProxy: String = ServiceIdentity.appviewProxy(baseUrl), | ||
) { | ||
| ||
/** Execute an XRPC query (GET). */ | ||
| @@ -85,11 +100,11 @@ class XrpcClient( | ||
nsid: String, | ||
params: Map<String, String> = emptyMap(), | ||
auth: AuthContext? = null, | ||
): String { | ||
val url = "$baseUrl/xrpc/$nsid".toHttpUrl().newBuilder().apply { | ||
): String = executeWithRetry(nsid, auth) { base -> | ||
val url = "$base/xrpc/$nsid".toHttpUrl().newBuilder().apply { | ||
params.forEach { (key, value) -> addQueryParameter(key, value) } | ||
}.build() | ||
return executeWithRetry(Request.Builder().url(url).get(), auth) | ||
Request.Builder().url(url).get() | ||
} | ||
| ||
suspend fun procedureRaw( | ||
| @@ -99,10 +114,9 @@ class XrpcClient( | ||
): String { | ||
val body = json.encodeToString(JsonElement.serializer(), input) | ||
.toRequestBody(JSON_MEDIA_TYPE) | ||
return executeWithRetry( | ||
Request.Builder().url("$baseUrl/xrpc/$nsid").post(body), | ||
auth, | ||
) | ||
return executeWithRetry(nsid, auth) { base -> | ||
Request.Builder().url("$base/xrpc/$nsid").post(body) | ||
} | ||
} | ||
| ||
/** Execute an XRPC procedure with no meaningful response body. */ | ||
| @@ -114,17 +128,19 @@ class XrpcClient( | ||
procedureRaw(nsid, input, auth) | ||
} | ||
| ||
/** Upload a blob. The body is the raw bytes; the mime type is the content type. */ | ||
/** | ||
* Upload a blob to the account's PDS. The body is the raw bytes; the mime | ||
* type is the content type. | ||
*/ | ||
suspend fun uploadBlob( | ||
data: ByteArray, | ||
mimeType: String, | ||
auth: AuthContext? = null, | ||
): UploadBlobResponse { | ||
val body = data.toRequestBody(mimeType.toMediaType()) | ||
val raw = executeWithRetry( | ||
Request.Builder().url("$baseUrl/xrpc/dev.hatk.uploadBlob").post(body), | ||
auth, | ||
) | ||
val raw = executeWithRetry(UPLOAD_BLOB, auth) { base -> | ||
Request.Builder().url("$base/xrpc/$UPLOAD_BLOB").post(body) | ||
} | ||
return decode(raw) | ||
} | ||
| ||
| @@ -139,67 +155,89 @@ class XrpcClient( | ||
// MARK: - Private | ||
| ||
private suspend fun executeWithRetry( | ||
builder: Request.Builder, | ||
nsid: String, | ||
auth: AuthContext?, | ||
retryCount: Int = 0, | ||
build: (base: String) -> Request.Builder, | ||
): String { | ||
return try { | ||
execute(builder.applyAuth(auth)) | ||
} catch (e: XrpcException.DpopNonceRequired) { | ||
if (retryCount >= 2) throw e | ||
Log.i(TAG, "DPoP nonce required, retrying with nonce") | ||
execute(builder.applyAuth(auth?.copy(nonce = e.nonce))) | ||
} catch (e: XrpcException.Unauthorized) { | ||
if (retryCount >= 1) throw e | ||
val refreshed = runCatching { onUnauthorized?.invoke() } | ||
.onFailure { Log.e(TAG, "Token refresh failed", it) } | ||
.getOrNull() | ||
?: throw e | ||
Log.i(TAG, "Token refreshed, retrying request") | ||
executeWithRetry(builder, refreshed, retryCount + 1) | ||
var current = auth | ||
var refreshed = false | ||
var replayed = false | ||
while (true) { | ||
try { | ||
return execute(nsid, current, build) | ||
} catch (e: XrpcException.DpopNonceRequired) { | ||
if (replayed || current == null) throw e | ||
replayed = true | ||
Log.i(TAG, "DPoP nonce required, retrying with nonce") | ||
} catch (e: XrpcException.Unauthorized) { | ||
if (refreshed) throw e | ||
refreshed = true | ||
current = runCatching { onUnauthorized?.invoke() } | ||
.onFailure { Log.e(TAG, "Token refresh failed", it) } | ||
.getOrNull() | ||
?: throw e | ||
replayed = false | ||
Log.i(TAG, "Token refreshed, retrying request") | ||
} | ||
} | ||
} | ||
| ||
private fun Request.Builder.applyAuth(auth: AuthContext?): Request.Builder = apply { | ||
if (auth == null) return@apply | ||
val request = build() | ||
header("Authorization", "DPoP ${auth.accessToken}") | ||
header( | ||
"DPoP", | ||
auth.dpop.createProof( | ||
httpMethod = request.method, | ||
url = request.url.toString(), | ||
accessToken = auth.accessToken, | ||
nonce = auth.nonce, | ||
), | ||
) | ||
} | ||
private suspend fun execute( | ||
nsid: String, | ||
auth: AuthContext?, | ||
build: (base: String) -> Request.Builder, | ||
): String = withContext(Dispatchers.IO) { | ||
val builder = build(auth?.pdsUrl ?: baseUrl) | ||
val url = builder.build().url.toString() | ||
if (auth != null) { | ||
builder.header("Authorization", "DPoP ${auth.accessToken}") | ||
builder.header( | ||
"DPoP", | ||
auth.dpop.createProof( | ||
httpMethod = builder.build().method, | ||
url = url, | ||
accessToken = auth.accessToken, | ||
nonce = nonces[url], | ||
), | ||
) | ||
} | ||
if (auth?.pdsUrl != null && !nsid.startsWith("com.atproto.")) { | ||
builder.header("atproto-proxy", appviewProxy) | ||
} | ||
| ||
private suspend fun execute(builder: Request.Builder): String = | ||
withContext(Dispatchers.IO) { | ||
http.newCall(builder.build()).await().use { response -> | ||
val body = response.body?.string().orEmpty() | ||
| ||
// A nonce on a 400 or a 401 both mean "re-sign the proof with | ||
// this and try again", not "the credential is bad". | ||
val nonce = response.header("DPoP-Nonce") | ||
if ((response.code == 400 || response.code == 401) && nonce != null) { | ||
throw XrpcException.DpopNonceRequired(nonce) | ||
} | ||
if (response.code == 401) { | ||
Log.e(TAG, "401: body=$body, wwwAuth=${response.header("WWW-Authenticate")}") | ||
throw XrpcException.Unauthorized | ||
} | ||
if (!response.isSuccessful) { | ||
Log.e(TAG, "HTTP ${response.code}: $body") | ||
throw XrpcException.Http(response.code, body) | ||
} | ||
body | ||
http.newCall(builder.build()).await().use { response -> | ||
val body = response.body.string() | ||
val nonce = response.header("DPoP-Nonce") | ||
nonces.keep(url, nonce) | ||
| ||
if (nonce != null && nonceRefused(response.code, response.header("WWW-Authenticate"), body)) { | ||
throw XrpcException.DpopNonceRequired(nonce) | ||
} | ||
if (response.code == 401) { | ||
Log.e(TAG, "401: body=$body, wwwAuth=${response.header("WWW-Authenticate")}") | ||
throw XrpcException.Unauthorized | ||
} | ||
if (!response.isSuccessful) { | ||
Log.e(TAG, "HTTP ${response.code}: $body") | ||
throw XrpcException.Http(response.code, body) | ||
} | ||
body | ||
} | ||
} | ||
| ||
private companion object { | ||
val JSON_MEDIA_TYPE = "application/json".toMediaType() | ||
const val UPLOAD_BLOB = "com.atproto.repo.uploadBlob" | ||
| ||
/** | ||
* Whether a response refuses the proof for want of a fresh nonce. A PDS | ||
* sends `DPoP-Nonce` on every response, a token it has rejected for good | ||
* included, so the header alone says nothing: the refusal is named in | ||
* `WWW-Authenticate` on a 401, or in the error body. | ||
*/ | ||
fun nonceRefused(code: Int, wwwAuthenticate: String?, body: String): Boolean = | ||
(code == 401 || code == 400) && | ||
(wwwAuthenticate?.contains("use_dpop_nonce") == true || body.contains("use_dpop_nonce")) | ||
} | ||
} | ||
| ||
app/src/main/java/social/grain/data/auth/AuthManager.kt+285−85
| @@ -3,6 +3,7 @@ package social.grain.data.auth | ||
import android.net.Uri | ||
import android.util.Base64 | ||
import android.util.Log | ||
import kotlinx.coroutines.CancellationException | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.Deferred | ||
import kotlinx.coroutines.Dispatchers | ||
| @@ -10,15 +11,23 @@ import kotlinx.coroutines.async | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
import kotlinx.coroutines.flow.StateFlow | ||
import kotlinx.coroutines.flow.asStateFlow | ||
import kotlinx.coroutines.launch | ||
import kotlinx.coroutines.sync.Mutex | ||
import kotlinx.coroutines.sync.withLock | ||
import kotlinx.coroutines.withContext | ||
import kotlinx.serialization.SerialName | ||
import kotlinx.serialization.Serializable | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonObject | ||
import kotlinx.serialization.json.JsonPrimitive | ||
import okhttp3.FormBody | ||
import okhttp3.HttpUrl.Companion.toHttpUrl | ||
import okhttp3.OkHttpClient | ||
import okhttp3.Request | ||
import social.grain.data.api.AuthContext | ||
import social.grain.data.api.DPoP | ||
import social.grain.data.api.DpopNonces | ||
import social.grain.data.api.ServiceIdentity | ||
import social.grain.data.api.XrpcClient | ||
import social.grain.data.api.XrpcException | ||
import social.grain.data.api.await | ||
| @@ -35,12 +44,18 @@ data class AuthState( | ||
val handle: String? = null, | ||
val avatar: String? = null, | ||
val accounts: List<StoredAccount> = emptyList(), | ||
/** Set when a launch-time scope migration forced the user to sign out. */ | ||
/** Why the login screen is showing when the app signed the user out itself. */ | ||
val reauthReason: String? = null, | ||
) | ||
| ||
/** | ||
* OAuth + DPoP authentication against the hatk server. | ||
* atproto OAuth, as a public native client of each account's own | ||
* authorization server. | ||
* | ||
* Sign-in resolves the handle to a DID and its PDS, asks the PDS for its | ||
* authorization server, and runs PAR, PKCE and DPoP there. The tokens are bound | ||
* to a DPoP key per account and spent at that account's PDS, which proxies | ||
* grain's own methods on to grain-serve. | ||
* | ||
* Several accounts can be signed in at once. Their credentials live side by | ||
* side in [TokenStorage] keyed by DID; this class tracks which one is active | ||
| @@ -49,7 +64,9 @@ data class AuthState( | ||
* The browser leg is split in two because Android can't suspend inside a | ||
* Custom Tab the way `ASWebAuthenticationSession` does on iOS: [beginLogin] | ||
* runs PAR and hands back the URL to open, and [completeLogin] is called by | ||
* `MainActivity` when the `grain://oauth/callback` intent comes back. | ||
* `MainActivity` when the redirect to [REDIRECT_URI] comes back. What the | ||
* second half needs is kept in [TokenStorage], so a process killed while the | ||
* browser is open still finishes the sign-in. | ||
*/ | ||
@Singleton | ||
class AuthManager @Inject constructor( | ||
| @@ -61,28 +78,64 @@ class AuthManager @Inject constructor( | ||
private val _state = MutableStateFlow(AuthState()) | ||
val state: StateFlow<AuthState> = _state.asStateFlow() | ||
| ||
/** Nonces per origin, shared by the token calls here and the PDS calls in [makeClient]. */ | ||
private val nonces = DpopNonces() | ||
| ||
private val resolver = IdentityResolver(http, json) | ||
| ||
/** Cached DPoP key and the DID it belongs to. Parsing a PKCS#8 key on every | ||
* request is wasteful, but a key cached without its owner is worse — see | ||
* request is wasteful, but a key cached without its owner is worse: see | ||
* [dpopFor]. */ | ||
private var dpop: DPoP? = null | ||
private var dpopDid: String? = null | ||
| ||
/** PKCE verifier + ephemeral key for the sign-in currently in the browser. */ | ||
private var pendingLogin: PendingLogin? = null | ||
| ||
private val refreshMutex = Mutex() | ||
private var refreshTask: Deferred<Unit>? = null | ||
| ||
private data class PendingLogin(val codeVerifier: String, val dpop: DPoP) | ||
/** | ||
* A sign-in waiting on the browser. [did], [handle] and [pds] are the | ||
* account the user named; all three are null for a sign-up, whose account | ||
* is known only once the token response names it. | ||
*/ | ||
@Serializable | ||
private data class PendingLogin( | ||
val codeVerifier: String, | ||
val state: String, | ||
val issuer: String, | ||
val tokenEndpoint: String, | ||
val revocationEndpoint: String? = null, | ||
val did: String? = null, | ||
val handle: String? = null, | ||
val pds: String? = null, | ||
) | ||
| ||
init { | ||
dropSessionsWithoutAServer() | ||
restoreSession() | ||
} | ||
| ||
// MARK: - Session restore | ||
| ||
/** | ||
* A session with no PDS or authorization server on record was issued by | ||
* grain's own server and can't be refreshed or spent anywhere, so it is | ||
* forgotten, and the login screen says why. | ||
*/ | ||
private fun dropSessionsWithoutAServer() { | ||
val stale = (tokenStorage.accounts.map { it.did } + listOfNotNull(tokenStorage.activeDid)) | ||
.distinct() | ||
.filter { tokenStorage.server(it) == null } | ||
if (stale.isEmpty()) return | ||
stale.forEach { | ||
Log.i(TAG, "Dropping $it: no PDS or authorization server on record") | ||
tokenStorage.removeAccount(it) | ||
} | ||
_state.value = AuthState(reauthReason = UPDATED_REAUTH_REASON) | ||
} | ||
| ||
private fun restoreSession() { | ||
val accounts = tokenStorage.accounts | ||
val reason = _state.value.reauthReason | ||
val did = tokenStorage.activeDid | ||
if (did != null && tokenStorage.hasCredentials(did)) { | ||
dpop = tokenStorage.dpopKey(did)?.let(DPoP::fromEncoded) | ||
| @@ -95,21 +148,38 @@ class AuthManager @Inject constructor( | ||
accounts = accounts, | ||
) | ||
} else { | ||
_state.value = AuthState(accounts = accounts) | ||
_state.value = AuthState(accounts = accounts, reauthReason = reason) | ||
} | ||
} | ||
| ||
// MARK: - Login | ||
| ||
/** | ||
* Run the Pushed Authorization Request and return the URL to open in a | ||
* Custom Tab. Set [createAccount] to land on the sign-up page instead. | ||
* Find [handle]'s authorization server, run the Pushed Authorization | ||
* Request there, and return the URL to open in a Custom Tab. With | ||
* [createAccount], the request goes to [SIGN_UP_PDS]'s server and asks for | ||
* its sign-up page instead. | ||
*/ | ||
suspend fun beginLogin(handle: String = "", createAccount: Boolean = false): String = | ||
withContext(Dispatchers.IO) { | ||
val identity: Identity? | ||
val server: AuthServer | ||
val input = handle.trim().removePrefix("@") | ||
if (createAccount) { | ||
identity = null | ||
server = discover(SIGN_UP_PDS) { resolver.authServer(SIGN_UP_PDS) } | ||
} else { | ||
if (input.isEmpty()) { | ||
throw XrpcException.AuthorizationFailed("missing_handle", "Enter your handle to sign in.") | ||
} | ||
val found = discover(input) { resolver.resolve(input) } | ||
identity = found | ||
server = discover(found.pds) { resolver.authServer(found.pds) } | ||
} | ||
| ||
val loginDpop = DPoP.createEphemeral() | ||
val verifier = generateCodeVerifier() | ||
pendingLogin = PendingLogin(verifier, loginDpop) | ||
val verifier = randomToken(32) | ||
val state = randomToken(16) | ||
| ||
val form = FormBody.Builder().apply { | ||
add("client_id", CLIENT_ID) | ||
| @@ -118,40 +188,50 @@ class AuthManager @Inject constructor( | ||
add("code_challenge", codeChallenge(verifier)) | ||
add("code_challenge_method", "S256") | ||
add("scope", REQUIRED_SCOPES.joinToString(" ")) | ||
if (createAccount) { | ||
add("state", state) | ||
if (identity == null) { | ||
add("prompt", "create") | ||
add("login_hint", "selfhosted.social") | ||
} else if (handle.isNotEmpty()) { | ||
add("login_hint", handle) | ||
} else { | ||
add("login_hint", identity.handle ?: input) | ||
} | ||
}.build() | ||
| ||
val parUrl = "$SERVER_URL/oauth/par" | ||
val requestUri = postWithDpopNonceRetry(parUrl, form, loginDpop) { body -> | ||
json.parseToJsonElement(body) | ||
.let { it as kotlinx.serialization.json.JsonObject }["request_uri"] | ||
?.let { (it as kotlinx.serialization.json.JsonPrimitive).content } | ||
val requestUri = postWithDpop(server.parEndpoint, form, loginDpop) { body -> | ||
(json.parseToJsonElement(body) as? JsonObject)?.get("request_uri") | ||
?.let { (it as? JsonPrimitive)?.content } | ||
?: throw XrpcException.Http(200, body) | ||
} | ||
| ||
Uri.parse("$SERVER_URL/oauth/authorize").buildUpon() | ||
.appendQueryParameter("request_uri", requestUri) | ||
.appendQueryParameter("client_id", CLIENT_ID) | ||
val pending = PendingLogin( | ||
codeVerifier = verifier, | ||
state = state, | ||
issuer = server.issuer, | ||
tokenEndpoint = server.tokenEndpoint, | ||
revocationEndpoint = server.revocationEndpoint, | ||
did = identity?.did, | ||
handle = identity?.handle, | ||
pds = identity?.pds, | ||
) | ||
tokenStorage.storePendingLogin(json.encodeToString(pending), loginDpop.encodedKeyPair()) | ||
| ||
server.authorizationEndpoint.toHttpUrl().newBuilder() | ||
.addQueryParameter("client_id", CLIENT_ID) | ||
.addQueryParameter("request_uri", requestUri) | ||
.build() | ||
.toString() | ||
} | ||
| ||
/** | ||
* Handle the `grain://oauth/callback` redirect. Throws | ||
* Handle the redirect back from the browser. Throws | ||
* [XrpcException.AuthorizationDenied] when the user tapped "Deny". | ||
*/ | ||
suspend fun completeLogin(callback: Uri) { | ||
val pending = pendingLogin ?: throw XrpcException.Unauthorized | ||
val (pending, loginDpop) = loadPendingLogin() ?: throw XrpcException.Unauthorized | ||
| ||
// hatk sends `error` instead of `code` when the PDS rejects the request, | ||
// most often because the user denied on the sign-in screen. | ||
// The authorization server sends `error` instead of `code` when it | ||
// turns the request down, most often because the user denied it. | ||
callback.getQueryParameter("error")?.let { code -> | ||
pendingLogin = null | ||
tokenStorage.clearPendingLogin() | ||
throw if (code == "access_denied") { | ||
XrpcException.AuthorizationDenied | ||
} else { | ||
| @@ -159,14 +239,35 @@ class AuthManager @Inject constructor( | ||
} | ||
} | ||
| ||
val iss = callback.getQueryParameter("iss") | ||
if (callback.getQueryParameter("state") != pending.state || (iss != null && iss != pending.issuer)) { | ||
tokenStorage.clearPendingLogin() | ||
throw XrpcException.AuthorizationFailed( | ||
"state_mismatch", | ||
"This sign-in didn't come from the request Grain made. Try again.", | ||
) | ||
} | ||
| ||
val code = callback.getQueryParameter("code") | ||
?: throw XrpcException.AuthorizationFailed("missing_code", null) | ||
if (code == null) { | ||
tokenStorage.clearPendingLogin() | ||
throw XrpcException.AuthorizationFailed("missing_code", null) | ||
} | ||
| ||
exchangeCode(code, pending) | ||
pendingLogin = null | ||
try { | ||
exchangeCode(code, pending, loginDpop) | ||
} finally { | ||
tokenStorage.clearPendingLogin() | ||
} | ||
} | ||
| ||
private fun loadPendingLogin(): Pair<PendingLogin, DPoP>? { | ||
val (value, key) = tokenStorage.pendingLogin() ?: return null | ||
val pending = runCatching { json.decodeFromString<PendingLogin>(value) }.getOrNull() ?: return null | ||
return pending to DPoP.fromEncoded(key) | ||
} | ||
| ||
private suspend fun exchangeCode(code: String, pending: PendingLogin) = | ||
private suspend fun exchangeCode(code: String, pending: PendingLogin, loginDpop: DPoP) = | ||
withContext(Dispatchers.IO) { | ||
val form = FormBody.Builder() | ||
.add("grant_type", "authorization_code") | ||
| @@ -176,30 +277,83 @@ class AuthManager @Inject constructor( | ||
.add("code_verifier", pending.codeVerifier) | ||
.build() | ||
| ||
val token = postWithDpopNonceRetry("$SERVER_URL/oauth/token", form, pending.dpop) { | ||
val token = postWithDpop(pending.tokenEndpoint, form, loginDpop) { | ||
json.decodeFromString<TokenResponse>(it) | ||
} | ||
| ||
val did = token.sub ?: throw XrpcException.Unauthorized | ||
val sub = token.sub | ||
?: throw XrpcException.AuthorizationFailed("missing_sub", "The sign-in server named no account.") | ||
| ||
val identity = if (pending.did != null) { | ||
if (sub != pending.did) { | ||
throw XrpcException.AuthorizationFailed( | ||
"wrong_account", | ||
"Signed in as a different account than the one asked for.", | ||
) | ||
} | ||
Identity(pending.did, pending.handle, requireNotNull(pending.pds)) | ||
} else { | ||
made(sub, pending.issuer) | ||
} | ||
val did = identity.did | ||
| ||
// The key that requested the token is the key the token is bound | ||
// to, so it has to be the one we persist under this DID. | ||
tokenStorage.storeDpopKey(did, pending.dpop.encodedKeyPair()) | ||
persistToken(did, token) | ||
dpop = pending.dpop | ||
// to, so it has to be the one persisted under this DID. | ||
tokenStorage.storeDpopKey(did, loginDpop.encodedKeyPair()) | ||
tokenStorage.storeServer( | ||
did, | ||
AccountServer( | ||
pds = identity.pds, | ||
issuer = pending.issuer, | ||
tokenEndpoint = pending.tokenEndpoint, | ||
revocationEndpoint = pending.revocationEndpoint, | ||
), | ||
) | ||
persistToken(did, token, identity.handle) | ||
dpop = loginDpop | ||
dpopDid = did | ||
tokenStorage.activeDid = did | ||
tokenStorage.upsertAccount(StoredAccount(did, token.handle, null)) | ||
tokenStorage.upsertAccount(StoredAccount(did, identity.handle, null)) | ||
| ||
_state.value = AuthState( | ||
isAuthenticated = true, | ||
did = did, | ||
handle = token.handle ?: tokenStorage.handle(did), | ||
handle = identity.handle ?: tokenStorage.handle(did), | ||
avatar = tokenStorage.avatar(did), | ||
accounts = tokenStorage.accounts, | ||
) | ||
} | ||
| ||
/** | ||
* The account [did] that a sign-up at [issuer] made. Only an account whose | ||
* own PDS signs in at that same server can be one it issued tokens for. | ||
*/ | ||
private suspend fun made(did: String, issuer: String): Identity { | ||
val identity = discover(did) { resolver.resolve(did) } | ||
val theirs = discover(identity.pds) { resolver.authServer(identity.pds) }.issuer | ||
if (theirs != issuer) { | ||
throw XrpcException.AuthorizationFailed( | ||
"wrong_issuer", | ||
"The new account signs in somewhere other than where it was made.", | ||
) | ||
} | ||
return identity | ||
} | ||
| ||
/** | ||
* Discovery with its failures said in words: a handle that doesn't resolve, | ||
* a PDS that names no server. OAuth errors pass through as they are. | ||
*/ | ||
private suspend fun <T> discover(subject: String, block: suspend () -> T): T = try { | ||
block() | ||
} catch (e: CancellationException) { | ||
throw e | ||
} catch (e: XrpcException.AuthorizationFailed) { | ||
throw e | ||
} catch (e: Exception) { | ||
Log.w(TAG, "Discovery for $subject failed", e) | ||
throw XrpcException.AuthorizationFailed("discovery_failed", "Couldn't find where $subject signs in.") | ||
} | ||
| ||
// MARK: - Refresh | ||
| ||
/** Refresh the access token only if it expires within 60 seconds. */ | ||
| @@ -234,6 +388,7 @@ class AuthManager @Inject constructor( | ||
val key = tokenStorage.dpopKey(did) ?: throw XrpcException.Unauthorized | ||
val accountDpop = DPoP.fromEncoded(key) | ||
val refreshToken = tokenStorage.refreshToken(did) ?: throw XrpcException.Unauthorized | ||
val server = tokenStorage.server(did) ?: throw XrpcException.Unauthorized | ||
| ||
val form = FormBody.Builder() | ||
.add("grant_type", "refresh_token") | ||
| @@ -242,12 +397,12 @@ class AuthManager @Inject constructor( | ||
.build() | ||
| ||
val token = try { | ||
postWithDpopNonceRetry("$SERVER_URL/oauth/token", form, accountDpop) { | ||
postWithDpop(server.tokenEndpoint, form, accountDpop) { | ||
json.decodeFromString<TokenResponse>(it) | ||
} | ||
} catch (e: XrpcException.Http) { | ||
// A 4xx, or a body that names the refresh token, means this grant | ||
// is gone for good — sign the account out rather than leaving it in | ||
// is gone for good: sign the account out rather than leaving it in | ||
// a state where every request 401s. A 5xx might just be the server | ||
// having a bad minute, so leave the credentials alone. | ||
val body = e.body.orEmpty().lowercase() | ||
| @@ -257,24 +412,24 @@ class AuthManager @Inject constructor( | ||
body.contains("revoked") || | ||
body.contains("expired") | ||
if (terminal) { | ||
signOut(did, reason = "refresh rejected with HTTP ${e.statusCode}") | ||
signOut(did, reason = "refresh rejected with HTTP ${e.statusCode}", revoke = false) | ||
} | ||
throw e | ||
} | ||
| ||
persistToken(did, token) | ||
persistToken(did, token, handle = null) | ||
if (_state.value.did == did) { | ||
dpop = accountDpop | ||
dpopDid = did | ||
} | ||
} | ||
| ||
private fun persistToken(did: String, token: TokenResponse) { | ||
private fun persistToken(did: String, token: TokenResponse, handle: String?) { | ||
tokenStorage.storeTokens( | ||
did = did, | ||
accessToken = token.accessToken, | ||
refreshToken = token.refreshToken, | ||
handle = token.handle, | ||
handle = handle, | ||
expiresAtMillis = System.currentTimeMillis() + (token.expiresIn ?: 3600) * 1000L, | ||
scope = token.scope, | ||
) | ||
| @@ -284,7 +439,7 @@ class AuthManager @Inject constructor( | ||
| ||
/** | ||
* A context for signing requests, refreshing the token first if it is about | ||
* to expire. Null when nobody is signed in — callers fall back to an | ||
* to expire. Null when nobody is signed in: callers fall back to an | ||
* unauthenticated request rather than failing. | ||
*/ | ||
suspend fun authContext(): AuthContext? { | ||
| @@ -295,12 +450,13 @@ class AuthManager @Inject constructor( | ||
// Re-read the account after the suspend rather than reusing what was | ||
// read before it. A refresh can end in a sign-out that promotes another | ||
// account, and pairing one account's token with another's key yields a | ||
// proof the PDS rejects — which reads as an expired session and signs | ||
// proof the PDS rejects, which reads as an expired session and signs | ||
// the user out for no reason. | ||
val did = _state.value.did ?: return null | ||
val token = tokenStorage.accessToken(did) ?: return null | ||
val key = dpopFor(did) ?: return null | ||
return AuthContext(accessToken = token, dpop = key) | ||
val server = tokenStorage.server(did) ?: return null | ||
return AuthContext(accessToken = token, dpop = key, did = did, pdsUrl = server.pds) | ||
} | ||
| ||
/** | ||
| @@ -326,6 +482,8 @@ class AuthManager @Inject constructor( | ||
onUnauthorized = { | ||
runCatching { refresh() }.getOrNull()?.let { authContext() } | ||
}, | ||
nonces = nonces, | ||
appviewProxy = APPVIEW_PROXY, | ||
) | ||
| ||
// MARK: - Account switching / sign-out | ||
| @@ -344,9 +502,16 @@ class AuthManager @Inject constructor( | ||
) | ||
} | ||
| ||
fun signOut(did: String? = null, reason: String = "user requested") { | ||
/** | ||
* Forget [did], or the active account. With [revoke], its refresh token is | ||
* first sent to the authorization server's revocation endpoint, in the | ||
* background and as far as the server lets it: the account is forgotten | ||
* here whether or not that lands. | ||
*/ | ||
fun signOut(did: String? = null, reason: String = "user requested", revoke: Boolean = true) { | ||
val target = did ?: _state.value.did ?: return | ||
Log.i(TAG, "Signing out $target: $reason") | ||
if (revoke) revokeInBackground(target) | ||
tokenStorage.removeAccount(target) | ||
| ||
// Hand the session to whatever account is left rather than dumping the | ||
| @@ -361,6 +526,21 @@ class AuthManager @Inject constructor( | ||
} | ||
} | ||
| ||
private fun revokeInBackground(did: String) { | ||
val endpoint = tokenStorage.server(did)?.revocationEndpoint ?: return | ||
val refreshToken = tokenStorage.refreshToken(did) ?: return | ||
val key = tokenStorage.dpopKey(did)?.let(DPoP::fromEncoded) ?: return | ||
scope.launch(Dispatchers.IO) { | ||
val form = FormBody.Builder() | ||
.add("token", refreshToken) | ||
.add("token_type_hint", "refresh_token") | ||
.add("client_id", CLIENT_ID) | ||
.build() | ||
runCatching { postWithDpop(endpoint, form, key) { } } | ||
.onFailure { Log.w(TAG, "Revoking $did's session failed", it) } | ||
} | ||
} | ||
| ||
fun clearReauthReason() { | ||
_state.value = _state.value.copy(reauthReason = null) | ||
} | ||
| @@ -381,41 +561,43 @@ class AuthManager @Inject constructor( | ||
// MARK: - HTTP helper | ||
| ||
/** | ||
* POST a form, replaying once with the server's nonce if it asks for one. | ||
* Both PAR and the token endpoint answer the first DPoP-signed request of a | ||
* session with `400 use_dpop_nonce`. | ||
* POST a form to the authorization server with a DPoP proof, replaying | ||
* once with the server's nonce when it refuses the proof for want of one. | ||
* The nonce is kept for that server's origin, so the next request there | ||
* carries it from the start. | ||
*/ | ||
private suspend fun <T> postWithDpopNonceRetry( | ||
private suspend fun <T> postWithDpop( | ||
url: String, | ||
form: FormBody, | ||
proofKey: DPoP, | ||
parse: (String) -> T, | ||
): T { | ||
suspend fun send(nonce: String?): Pair<Int, Pair<String, String?>> { | ||
suspend fun send(): Pair<Int, String> { | ||
val request = Request.Builder() | ||
.url(url) | ||
.post(form) | ||
.header("DPoP", proofKey.createProof("POST", url, nonce = nonce)) | ||
.header("DPoP", proofKey.createProof("POST", url, nonce = nonces[url])) | ||
.build() | ||
return http.newCall(request).await().use { response -> | ||
response.code to (response.body.string() to response.header("DPoP-Nonce")) | ||
nonces.keep(url, response.header("DPoP-Nonce")) | ||
response.code to response.body.string() | ||
} | ||
} | ||
| ||
var (code, payload) = send(null) | ||
if (code == 400 && payload.second != null) { | ||
val retry = send(payload.second) | ||
var (code, body) = send() | ||
if (code == 400 && body.contains("use_dpop_nonce") && nonces[url] != null) { | ||
val retry = send() | ||
code = retry.first | ||
payload = retry.second | ||
body = retry.second | ||
} | ||
if (code !in 200..299) throw XrpcException.Http(code, payload.first) | ||
return parse(payload.first) | ||
if (code !in 200..299) throw XrpcException.Http(code, body) | ||
return parse(body) | ||
} | ||
| ||
// MARK: - PKCE | ||
| ||
private fun generateCodeVerifier(): String = | ||
ByteArray(32).also { SecureRandom().nextBytes(it) } | ||
private fun randomToken(bytes: Int): String = | ||
ByteArray(bytes).also { SecureRandom().nextBytes(it) } | ||
.let { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) } | ||
| ||
private fun codeChallenge(verifier: String): String = | ||
| @@ -423,19 +605,37 @@ class AuthManager @Inject constructor( | ||
.let { Base64.encodeToString(it, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) } | ||
| ||
companion object { | ||
/** grain-serve: signed-out reads, and what the client ID and proxy name. */ | ||
const val SERVER_URL = "https://grain.social" | ||
const val CLIENT_ID = "grain-native://app" | ||
const val REDIRECT_URI = "grain://oauth/callback" | ||
| ||
/** | ||
* OAuth scopes the app requests at sign-in. | ||
* | ||
* Adding one only affects grants minted from here on. iOS carries a | ||
* one-shot migration that force-signs-out installs holding a narrower | ||
* grant; that's deliberately not ported. It exists for a population of | ||
* pre-existing installs Android doesn't have, and the server's token | ||
* response omits `scope` entirely — so the check would read every scope | ||
* as missing and sign the user out on every launch. | ||
* The app's client metadata, which grain-serve hosts. A PDS fetches it | ||
* over https, so a build pointed at a local server still needs an https | ||
* URL here (a tunnel) to sign in. | ||
*/ | ||
val CLIENT_ID = ServiceIdentity.clientId(SERVER_URL) | ||
| ||
/** `social.grain:/oauth/callback`; the manifest claims the scheme. */ | ||
val REDIRECT_URI = ServiceIdentity.redirectUri(SERVER_URL) | ||
val REDIRECT_SCHEME = ServiceIdentity.redirectScheme(SERVER_URL) | ||
| ||
/** grain-serve as a service of its DID, for `atproto-proxy`. */ | ||
val APPVIEW_DID = ServiceIdentity.serviceDid(SERVER_URL) | ||
val APPVIEW_PROXY = ServiceIdentity.appviewProxy(SERVER_URL) | ||
| ||
/** Where "Create account" signs up. */ | ||
const val SIGN_UP_PDS = "https://selfhosted.social" | ||
| ||
const val UPDATED_REAUTH_REASON = "Grain has been updated. Sign in again to keep using it." | ||
| ||
/** Whether [uri] is the browser coming back from a sign-in. */ | ||
fun isCallback(uri: Uri): Boolean = | ||
uri.scheme == REDIRECT_SCHEME && uri.path == ServiceIdentity.CALLBACK_PATH | ||
| ||
/** | ||
* OAuth scopes the app requests at sign-in, each one registered in the | ||
* client metadata grain-serve publishes: the records and blobs written | ||
* to the PDS, and `rpc` for every call proxied on to grain-serve. | ||
*/ | ||
val REQUIRED_SCOPES = listOf( | ||
"atproto", | ||
| @@ -446,21 +646,21 @@ class AuthManager @Inject constructor( | ||
"repo:social.grain.photo.exif", | ||
"repo:social.grain.actor.profile", | ||
"repo:social.grain.graph.follow", | ||
"repo:social.grain.graph.block", | ||
"repo:social.grain.favorite", | ||
"repo:social.grain.comment", | ||
"repo:social.grain.story", | ||
"repo:app.bsky.feed.post?action=create", | ||
"repo:social.grain.graph.block", | ||
"repo:app.bsky.feed.post?action=create&action=update", | ||
ServiceIdentity.rpcScope(SERVER_URL), | ||
) | ||
} | ||
} | ||
| ||
@kotlinx.serialization.Serializable | ||
@Serializable | ||
private data class TokenResponse( | ||
@kotlinx.serialization.SerialName("access_token") val accessToken: String, | ||
@kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, | ||
@kotlinx.serialization.SerialName("expires_in") val expiresIn: Long? = null, | ||
@SerialName("access_token") val accessToken: String, | ||
@SerialName("refresh_token") val refreshToken: String? = null, | ||
@SerialName("expires_in") val expiresIn: Long? = null, | ||
val sub: String? = null, | ||
val handle: String? = null, | ||
val scope: String? = null, | ||
) | ||
app/src/main/java/social/grain/data/auth/IdentityResolver.ktnew+158−0
| @@ -0,0 +1,158 @@ | ||
package social.grain.data.auth | ||
| ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.withContext | ||
import kotlinx.serialization.SerialName | ||
import kotlinx.serialization.Serializable | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonObject | ||
import kotlinx.serialization.json.contentOrNull | ||
import kotlinx.serialization.json.jsonArray | ||
import kotlinx.serialization.json.jsonObject | ||
import kotlinx.serialization.json.jsonPrimitive | ||
import okhttp3.HttpUrl.Companion.toHttpUrl | ||
import okhttp3.OkHttpClient | ||
import okhttp3.Request | ||
import social.grain.data.api.XrpcException | ||
import social.grain.data.api.await | ||
| ||
/** An account as its DID document names it. */ | ||
data class Identity(val did: String, val handle: String?, val pds: String) | ||
| ||
/** The endpoints of an OAuth authorization server, from its metadata. */ | ||
@Serializable | ||
data class AuthServer( | ||
val issuer: String, | ||
@SerialName("pushed_authorization_request_endpoint") val parEndpoint: String, | ||
@SerialName("authorization_endpoint") val authorizationEndpoint: String, | ||
@SerialName("token_endpoint") val tokenEndpoint: String, | ||
@SerialName("revocation_endpoint") val revocationEndpoint: String? = null, | ||
) | ||
| ||
@Serializable | ||
private data class ProtectedResource( | ||
@SerialName("authorization_servers") val authorizationServers: List<String> = emptyList(), | ||
) | ||
| ||
@Serializable | ||
private data class ResolvedHandle(val did: String) | ||
| ||
/** | ||
* Handle → DID → PDS → authorization server, as atproto OAuth discovers them. | ||
* | ||
* Handles resolve through `https://<handle>/.well-known/atproto-did` first, then | ||
* through [handleResolver]'s `com.atproto.identity.resolveHandle`: the same | ||
* resolvers, in the same order, as grain's CLI. DID documents come from | ||
* [plcUrl] for `did:plc` and from the host's `/.well-known/did.json` for | ||
* `did:web`. | ||
*/ | ||
class IdentityResolver( | ||
private val http: OkHttpClient, | ||
private val json: Json, | ||
private val plcUrl: String = PLC_URL, | ||
private val handleResolver: String = HANDLE_RESOLVER, | ||
) { | ||
| ||
/** The account [identifier] names, a handle (with or without `@`) or a DID. */ | ||
suspend fun resolve(identifier: String): Identity { | ||
val input = identifier.trim().removePrefix("@") | ||
val did = if (input.startsWith("did:")) input else resolveHandle(input.lowercase()) | ||
val doc = document(did) | ||
val pds = pdsOf(doc) ?: throw XrpcException.AuthorizationFailed( | ||
"no_pds", | ||
"$input has no PDS in its DID document.", | ||
) | ||
val handle = handleOf(doc) ?: input.takeUnless { it.startsWith("did:") } | ||
return Identity(did, handle, pds) | ||
} | ||
| ||
suspend fun resolveHandle(handle: String): String { | ||
val failures = mutableListOf<String>() | ||
try { | ||
val did = getText("https://$handle/.well-known/atproto-did").trim() | ||
if (did.startsWith("did:")) return did | ||
failures += "not a DID: $did" | ||
} catch (e: Exception) { | ||
if (e is kotlinx.coroutines.CancellationException) throw e | ||
failures += e.message.orEmpty() | ||
} | ||
try { | ||
val url = "$handleResolver/xrpc/com.atproto.identity.resolveHandle".toHttpUrl() | ||
.newBuilder().addQueryParameter("handle", handle).build().toString() | ||
val did = json.decodeFromString<ResolvedHandle>(getText(url)).did | ||
if (did.startsWith("did:")) return did | ||
failures += "not a DID: $did" | ||
} catch (e: Exception) { | ||
if (e is kotlinx.coroutines.CancellationException) throw e | ||
failures += e.message.orEmpty() | ||
} | ||
throw XrpcException.AuthorizationFailed( | ||
"unresolved_handle", | ||
"Couldn't find an account for $handle.", | ||
).also { it.addSuppressed(Exception(failures.joinToString("; "))) } | ||
} | ||
| ||
suspend fun document(did: String): JsonObject { | ||
val url = when { | ||
did.startsWith("did:plc:") -> "$plcUrl/$did" | ||
did.startsWith("did:web:") -> | ||
"https://${did.removePrefix("did:web:").replace("%3A", ":")}/.well-known/did.json" | ||
else -> throw XrpcException.AuthorizationFailed("unsupported_did", "Unsupported DID: $did") | ||
} | ||
return json.parseToJsonElement(getText(url)).jsonObject | ||
} | ||
| ||
/** | ||
* The authorization server [pds] names in its protected-resource metadata, | ||
* whose own metadata must call itself by that same URL. | ||
*/ | ||
suspend fun authServer(pds: String): AuthServer { | ||
val resource = json.decodeFromString<ProtectedResource>( | ||
getText("${pds.trimEnd('/')}/.well-known/oauth-protected-resource"), | ||
) | ||
val issuer = resource.authorizationServers.firstOrNull() | ||
?: throw XrpcException.AuthorizationFailed("no_authorization_server", "$pds names no sign-in server.") | ||
val server = json.decodeFromString<AuthServer>( | ||
getText("${issuer.trimEnd('/')}/.well-known/oauth-authorization-server"), | ||
) | ||
if (server.issuer != issuer) { | ||
throw XrpcException.AuthorizationFailed( | ||
"issuer_mismatch", | ||
"$issuer calls itself ${server.issuer}.", | ||
) | ||
} | ||
return server | ||
} | ||
| ||
private suspend fun getText(url: String): String = withContext(Dispatchers.IO) { | ||
val request = Request.Builder().url(url).header("Accept", "application/json").get().build() | ||
http.newCall(request).await().use { response -> | ||
val body = response.body.string() | ||
if (!response.isSuccessful) throw XrpcException.Http(response.code, body) | ||
body | ||
} | ||
} | ||
| ||
companion object { | ||
const val PLC_URL = "https://plc.directory" | ||
const val HANDLE_RESOLVER = "https://public.api.bsky.app" | ||
| ||
/** The `#atproto_pds` service endpoint of a DID document. */ | ||
fun pdsOf(doc: JsonObject): String? = | ||
doc["service"]?.let { runCatching { it.jsonArray }.getOrNull() } | ||
?.map { it.jsonObject } | ||
?.firstOrNull { service -> | ||
val id = service["id"]?.jsonPrimitive?.contentOrNull | ||
id == "#atproto_pds" || id?.endsWith("#atproto_pds") == true | ||
} | ||
?.get("serviceEndpoint")?.jsonPrimitive?.contentOrNull | ||
?.trimEnd('/') | ||
| ||
/** The handle in a DID document's `at://` alias. */ | ||
fun handleOf(doc: JsonObject): String? = | ||
doc["alsoKnownAs"]?.let { runCatching { it.jsonArray }.getOrNull() } | ||
?.mapNotNull { it.jsonPrimitive.contentOrNull } | ||
?.firstOrNull { it.startsWith("at://") } | ||
?.removePrefix("at://") | ||
} | ||
} | ||
app/src/main/java/social/grain/data/auth/TokenStorage.kt+51−1
| @@ -15,6 +15,18 @@ data class StoredAccount( | ||
val avatar: String? = null, | ||
) | ||
| ||
/** | ||
* Where an account signs in and is served: its PDS, and the authorization | ||
* server that issued its tokens, with the endpoints refresh and sign-out use. | ||
*/ | ||
@Serializable | ||
data class AccountServer( | ||
val pds: String, | ||
val issuer: String, | ||
val tokenEndpoint: String, | ||
val revocationEndpoint: String? = null, | ||
) | ||
| ||
/** | ||
* Secure storage for OAuth tokens and DPoP keys. | ||
* | ||
| @@ -68,6 +80,9 @@ class TokenStorage @Inject constructor(private val store: SecureStore) { | ||
| ||
fun avatar(did: String): String? = read(AVATAR, did) | ||
| ||
/** The scope the authorization server granted, when its token response said. */ | ||
fun scope(did: String): String? = read(SCOPE, did) | ||
| ||
fun setAvatar(did: String, avatar: String?) = write(AVATAR, did, avatar) | ||
| ||
/** Epoch millis at which the stored access token expires. */ | ||
| @@ -101,6 +116,39 @@ class TokenStorage @Inject constructor(private val store: SecureStore) { | ||
fun hasCredentials(did: String): Boolean = | ||
accessToken(did) != null && refreshToken(did) != null | ||
| ||
/** The account's PDS and authorization server, null for a session that has none. */ | ||
fun server(did: String): AccountServer? = | ||
read(SERVER, did)?.let { runCatching { json.decodeFromString<AccountServer>(it) }.getOrNull() } | ||
| ||
fun storeServer(did: String, server: AccountServer) = | ||
write(SERVER, did, json.encodeToString(server)) | ||
| ||
// MARK: - Sign-in in the browser | ||
| ||
/** | ||
* The sign-in waiting on the browser, as JSON, with the key its tokens will | ||
* be bound to. Kept on disk so a process killed while the Custom Tab is up | ||
* can still finish it. | ||
*/ | ||
fun pendingLogin(): Pair<String, EncodedKeyPair>? { | ||
val value = store.getString(PENDING_LOGIN) ?: return null | ||
val private = store.getBytes("$PENDING_LOGIN::$DPOP_PRIVATE") ?: return null | ||
val public = store.getBytes("$PENDING_LOGIN::$DPOP_PUBLIC") ?: return null | ||
return value to EncodedKeyPair(private, public) | ||
} | ||
| ||
fun storePendingLogin(value: String, keyPair: EncodedKeyPair) { | ||
store.putBytes("$PENDING_LOGIN::$DPOP_PRIVATE", keyPair.privateKey) | ||
store.putBytes("$PENDING_LOGIN::$DPOP_PUBLIC", keyPair.publicKey) | ||
store.putString(PENDING_LOGIN, value) | ||
} | ||
| ||
fun clearPendingLogin() { | ||
store.remove(PENDING_LOGIN) | ||
store.remove("$PENDING_LOGIN::$DPOP_PRIVATE") | ||
store.remove("$PENDING_LOGIN::$DPOP_PUBLIC") | ||
} | ||
| ||
// MARK: - DPoP keys | ||
| ||
fun dpopKey(did: String): EncodedKeyPair? { | ||
| @@ -120,7 +168,7 @@ class TokenStorage @Inject constructor(private val store: SecureStore) { | ||
* account, if any, takes over. | ||
*/ | ||
fun removeAccount(did: String) { | ||
for (name in listOf(ACCESS_TOKEN, REFRESH_TOKEN, HANDLE, AVATAR, EXPIRES_AT, SCOPE)) { | ||
for (name in listOf(ACCESS_TOKEN, REFRESH_TOKEN, HANDLE, AVATAR, EXPIRES_AT, SCOPE, SERVER)) { | ||
write(name, did, null) | ||
} | ||
store.remove("$DPOP_PRIVATE::$did") | ||
| @@ -143,6 +191,8 @@ class TokenStorage @Inject constructor(private val store: SecureStore) { | ||
const val AVATAR = "user_avatar" | ||
const val EXPIRES_AT = "token_expires_at" | ||
const val SCOPE = "token_scope" | ||
const val SERVER = "oauth_server" | ||
const val PENDING_LOGIN = "pending_login" | ||
const val DPOP_PRIVATE = "dpop_private_key" | ||
const val DPOP_PUBLIC = "dpop_public_key" | ||
} | ||
app/src/main/java/social/grain/MainActivity.kt+2−1
| @@ -32,6 +32,7 @@ import kotlinx.coroutines.launch | ||
import social.grain.data.local.Appearance | ||
import social.grain.data.push.GrainMessagingService | ||
import social.grain.feature.login.LoginScreen | ||
import social.grain.data.auth.AuthManager | ||
import social.grain.feature.update.UpdateRequiredScreen | ||
import social.grain.navigation.DeepLink | ||
import social.grain.navigation.GrainNavHost | ||
| @@ -112,7 +113,7 @@ class MainActivity : ComponentActivity() { | ||
} | ||
| ||
val data = intent?.data ?: return | ||
if (data.scheme == "grain" && data.host == "oauth") { | ||
if (AuthManager.isCallback(data)) { | ||
viewModel.handleOAuthCallback(data) | ||
return | ||
} | ||