~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1

Grain signs in at the account's own PDS, as an atproto OAuth client

Merged chadtmiller.com asked to merge into main from grain.social/grain-android:feature/rust-auth
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/navigation/DeepLink.kt+3−3
@@ -32,9 +32,9 @@ sealed interface DeepLink {
* Parse a `https://grain.social/...` or `grain://...` link, or null if
* it names nothing this app can show.
*
* The OAuth callback (`grain://oauth/callback`) deliberately falls
* through to null — [social.grain.MainActivity] handles that one before
* it gets here.
* The OAuth callback (`social.grain:/oauth/callback`) is under another
* scheme and falls through to null: [social.grain.MainActivity]
* handles that one before it gets here.
*/
fun from(uri: Uri): DeepLink? {
// On grain:// the first segment is the host, not part of the path.
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
}
app/src/main/AndroidManifest.xml+7−2
app/src/test/java/social/grain/support/StubServer.kt+81−2
app/src/test/java/social/grain/support/ViewModelHarness.kt+3−0
app/src/test/java/social/grain/AuthManagerTest.kt+356−33
app/src/test/java/social/grain/EndpointsTest.kt+125−31
app/src/test/java/social/grain/FeedScreenTest.kt+2−2
app/src/test/java/social/grain/FeedViewModelTest.kt+4−4
app/src/test/java/social/grain/GalleryCommentsViewModelTest.kt+9−9
app/src/test/java/social/grain/GalleryDetailViewModelTest.kt+15−15
app/src/test/java/social/grain/GalleryFeedViewModelTest.kt+4−4
app/src/test/java/social/grain/GalleryUploadTest.kt+23−23
app/src/test/java/social/grain/LoginScreenTest.kt+2−1
app/src/test/java/social/grain/LoginViewModelTest.kt+17−2
app/src/test/java/social/grain/MainViewModelTest.kt+8−6
app/src/test/java/social/grain/ModerationViewModelTest.kt+3−3
app/src/test/java/social/grain/ProfileScreenTest.kt+3−3
app/src/test/java/social/grain/ProfileViewModelTest.kt+8−8
app/src/test/java/social/grain/ScreenShellTest.kt+1−1
app/src/test/java/social/grain/SearchViewModelTest.kt+4−4
app/src/test/java/social/grain/StoryCommentsViewModelTest.kt+8−8
app/src/test/java/social/grain/StoryViewerScreenTest.kt+4−4
app/src/test/java/social/grain/StoryViewerViewModelTest.kt+4−4
app/src/test/java/social/grain/XrpcClientTest.kt+102−3
README.md+1−1