···11package rs.averyrive.darkfeed
2233+import AuthAccount
44+import AuthManager
35import io.ktor.http.*
46import kotlinx.coroutines.launch
57import kotlinx.coroutines.runBlocking
···1820 /** Hostname of the feed generator server. */
1921 val hostname: String,
2022 /** Record key for the feed generator record. */
2121- val recordKey: String = "darkfeed",
2323+ val recordKey: String = "darkfeed-dev",
2224 /** Display name for the feed. */
2323- val feedDisplayName: String = "DarkFeed",
2525+ val feedDisplayName: String = "DarkFeed (Dev)",
2426 /** Description for the feed. */
2527 val description: String = "hi :3",
2628)
···7880 ?: printMessageAndExit("error: variable HOSTNAME not set"),
7981 )
80828181- // Create API instance.
8282- val bskyApi = BskyApi(buildUrl {
8383- protocol = URLProtocol.HTTPS
8484- host = ctx.ownerPds
8585- })
8383+ // Create Bluesky API instance.
8484+ val bskyApi = BskyApi(
8585+ authManager = AuthManager(
8686+ authAccount = AuthAccount(
8787+ username = ctx.ownerDid,
8888+ password = ctx.ownerPassword,
8989+ pdsHost = ctx.ownerPds,
9090+ ),
9191+ )
9292+ )
86938794 // Verify and update the feed generator record.
8895 launch {
8989- bskyApi.login(ctx.ownerDid, ctx.ownerPassword)
9090-9196 try {
9297 verifyAndUpdateFeedGeneratorRecord(bskyApi, ctx)
9398 println("main: feed generator record verified")
+163
darkfeed/src/main/kotlin/api/AuthManager.kt
···11+import io.ktor.client.*
22+import io.ktor.client.call.*
33+import io.ktor.client.engine.cio.*
44+import io.ktor.client.plugins.contentnegotiation.*
55+import io.ktor.client.plugins.logging.*
66+import io.ktor.client.request.*
77+import io.ktor.http.*
88+import io.ktor.serialization.kotlinx.json.*
99+import kotlinx.serialization.Serializable
1010+import kotlinx.serialization.json.Json
1111+import org.slf4j.Logger
1212+import org.slf4j.LoggerFactory
1313+1414+/**
1515+ * Stores auth tokens and handles all auth related requests.
1616+ *
1717+ * @param authAccount ATProto account to use for authorization.
1818+ * @param httpClient Client to use for requests.
1919+ */
2020+class AuthManager(
2121+ private val authAccount: AuthAccount,
2222+ private val httpClient: HttpClient = HttpClient(CIO) {
2323+ install(ContentNegotiation) {
2424+ json(Json {
2525+ explicitNulls = false
2626+ ignoreUnknownKeys = true
2727+ })
2828+ }
2929+3030+ install(Logging)
3131+ },
3232+) {
3333+ /** The current bearer tokens. */
3434+ var authTokens: AuthTokens? = null
3535+ private set
3636+3737+ /** Logger to use for this class. */
3838+ private val log: Logger = LoggerFactory.getLogger(this.javaClass)
3939+4040+ /** Create a new auth session. */
4141+ suspend fun createSession() {
4242+ @Serializable
4343+ data class Request(val identifier: String, val password: String)
4444+4545+ val createSessionUrl = buildUrl {
4646+ protocol = URLProtocol.HTTPS
4747+ host = this@AuthManager.authAccount.pdsHost
4848+ path("/xrpc/com.atproto.server.createSession")
4949+ }
5050+5151+ val requestBody = Request(
5252+ this.authAccount.username,
5353+ this.authAccount.password,
5454+ )
5555+5656+ log.debug(
5757+ "Creating new session for '{}' at '{}'.",
5858+ requestBody.identifier,
5959+ createSessionUrl.hostWithPortIfSpecified,
6060+ )
6161+6262+ val response = this.httpClient.post(createSessionUrl) {
6363+ contentType(ContentType.Application.Json)
6464+ setBody(requestBody)
6565+ }
6666+6767+ log.debug(
6868+ "Received response for new session with status '{}'.",
6969+ response.status,
7070+ )
7171+7272+ when (response.status) {
7373+ HttpStatusCode.OK -> {
7474+ try {
7575+ val authSession: AuthSession = response.body()
7676+ this.authTokens = authSession.into()
7777+ log.debug("New session created, updated auth tokens.")
7878+ } catch (error: Exception) {
7979+ TODO("Handle deserialization errors")
8080+ }
8181+ }
8282+8383+ else -> {
8484+ TODO("Handle failures")
8585+ }
8686+ }
8787+ }
8888+8989+ /** Refresh the current auth session. */
9090+ suspend fun refreshSession() {
9191+ val refreshSessionUrl = buildUrl {
9292+ protocol = URLProtocol.HTTPS
9393+ host = this@AuthManager.authAccount.pdsHost
9494+ path("/xrpc/com.atproto.server.refreshSession")
9595+ }
9696+9797+ val refreshToken = this.authTokens?.refreshToken!!
9898+ val authHeaderValue = "Bearer ${refreshToken}"
9999+100100+ log.debug("Refreshing current session at '{}'", refreshSessionUrl.hostWithPortIfSpecified)
101101+102102+ val response = this.httpClient.post(refreshSessionUrl) {
103103+ header(HttpHeaders.Authorization, authHeaderValue)
104104+ }
105105+106106+ log.debug("Received session refresh response with status {}", response.status)
107107+108108+ when (response.status) {
109109+ HttpStatusCode.OK -> {
110110+ try {
111111+ val authSession: AuthSession = response.body()
112112+ this.authTokens = authSession.into()
113113+ log.debug("Session refreshed, updated auth tokens")
114114+ } catch (error: Exception) {
115115+ TODO("Handle deserialization errors")
116116+ }
117117+ }
118118+119119+ else -> {
120120+ TODO("Handle failures")
121121+ }
122122+ }
123123+ }
124124+}
125125+126126+/**
127127+ * ATProto account details.
128128+ *
129129+ * @param username Account handle or DID.
130130+ * @param password Account password or app password.
131131+ * @param pdsHost Hostname of account's PDS.
132132+ */
133133+data class AuthAccount(
134134+ val username: String,
135135+ val password: String,
136136+ val pdsHost: String,
137137+)
138138+139139+/**
140140+ * ATProto bearer tokens.
141141+ *
142142+ * @param accessToken Token used for normal authorized requests.
143143+ * @param refreshToken Token used for session refresh requests.
144144+ */
145145+data class AuthTokens(
146146+ val accessToken: String,
147147+ val refreshToken: String,
148148+)
149149+150150+/**
151151+ * ATProto auth session details.
152152+ *
153153+ * @param accessJwt: Token used for normal authorized requests.
154154+ * @param refreshJwt: Token used to session refresh requests.
155155+ */
156156+@Serializable
157157+data class AuthSession(
158158+ val accessJwt: String,
159159+ val refreshJwt: String
160160+) {
161161+ /** Create `AuthTokens` from an `AuthSession`. */
162162+ fun into(): AuthTokens = AuthTokens(this.accessJwt, this.refreshJwt)
163163+}
+158
darkfeed/src/main/kotlin/api/AuthPlugin.kt
···11+package rs.averyrive.darkfeed.api
22+33+import AuthManager
44+import io.ktor.client.call.*
55+import io.ktor.client.plugins.api.*
66+import io.ktor.client.statement.*
77+import io.ktor.http.*
88+import kotlinx.coroutines.sync.Mutex
99+import kotlinx.coroutines.sync.withLock
1010+import kotlinx.serialization.Serializable
1111+import org.slf4j.LoggerFactory
1212+1313+const val PLUGIN_NAME: String = "AuthPlugin"
1414+1515+val AuthPlugin = createClientPlugin(PLUGIN_NAME, ::AuthPluginConfig) {
1616+ val authManager = pluginConfig.authManager ?: throw AuthPluginConfigurationError("Auth manager is required")
1717+ val authMutex = Mutex()
1818+ val log = LoggerFactory.getLogger(PLUGIN_NAME)
1919+2020+ // Add authorization header to requests.
2121+ onRequest { request, _ ->
2222+ // Format the request's endpoint as '<protocol>://<host>/<path>' for use in logs.
2323+ val endpoint = with(request.url) { "${protocol.name}://${host}${encodedPath}" }
2424+2525+ // Remove any existing `Authorization` headers.
2626+ if (request.headers.contains(HttpHeaders.Authorization)) {
2727+ log.info("Replacing 'Authorization' header on request to '{}'.", endpoint)
2828+ request.headers.remove(HttpHeaders.Authorization)
2929+ }
3030+3131+ // If another request is already refreshing tokens, this request will
3232+ // pause until the other request is finished, then use the new tokens.
3333+ val accessToken = authMutex.withLock {
3434+ authManager.authTokens?.accessToken
3535+ }
3636+3737+ // If the auth manager doesn't have auth tokens, try running the request
3838+ // normally. This is a last resort in case the request doesn't require
3939+ // authorization.
4040+ if (accessToken == null) {
4141+ log.debug(
4242+ "No access token retrieved from 'AuthManager'. Sending request to '{}' without 'Authorization' header.",
4343+ endpoint
4444+ )
4545+ return@onRequest
4646+ }
4747+4848+ // Add the authorization header.
4949+ request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
5050+5151+ log.debug("'Authorization' header added to request to '{}'.", endpoint)
5252+ }
5353+5454+ // Check responses for authorization failures.
5555+ on(Send) { request ->
5656+ // Format the request's endpoint as '<protocol>://<host>/<path>' for use in logs.
5757+ val endpoint = with(request.url) { "${protocol.name}://${host}${encodedPath}" }
5858+5959+ // Send the request.
6060+ val originalCall = proceed(request)
6161+6262+ // Get the original access token from the `Authoriation` header.
6363+ val originalAccessToken = originalCall.request.headers[HttpHeaders.Authorization]?.removePrefix("Bearer ")
6464+6565+ // Try to get a new access token to use when retrying the request.
6666+ val newAccessToken = when (originalCall.response.status) {
6767+ // An unauthorized response means a new session needs to be created.
6868+ HttpStatusCode.Unauthorized -> {
6969+ val newAccessToken = authMutex.withLock { authManager.authTokens?.accessToken }
7070+7171+ if (originalAccessToken != newAccessToken) {
7272+ // Another request has already retrieved new tokens.
7373+ log.debug("New tokens already retrieved.")
7474+ newAccessToken
7575+ } else {
7676+ log.debug("Request to '{}' received '401 Unauthorized'. Creating new session.", endpoint)
7777+7878+ // Create a new session. By locking the auth mutex here,
7979+ // other requests will block when they try to get the
8080+ // tokens.
8181+ authMutex.withLock {
8282+ authManager.createSession()
8383+ authManager.authTokens?.accessToken
8484+ }
8585+ }
8686+ }
8787+8888+ // If the access token is expired, the response will have error code
8989+ // 400 with the error `ExpiredToken`. The session needs to be
9090+ // refreshed in that case.
9191+ HttpStatusCode.BadRequest -> {
9292+ log.debug(
9393+ "Request to '{}' received '400 Bad Request'. Response: '{}'.",
9494+ endpoint,
9595+ originalCall.response.bodyAsText()
9696+ )
9797+9898+ // Try to deserialize the error response. If the response isn't
9999+ // what's expected, just let the error go to the caller.
100100+ val errorResponse = try {
101101+ originalCall.response.body<ErrorResponse>()
102102+ } catch (e: Exception) {
103103+ null
104104+ }
105105+106106+ if (errorResponse?.error == "ExpiredToken") {
107107+ val newAccessToken = authMutex.withLock { authManager.authTokens?.accessToken }
108108+109109+ if (originalAccessToken != newAccessToken) {
110110+ // Another request has already retrieved new tokens.
111111+ log.debug("New tokens already retrieved.")
112112+ newAccessToken
113113+ } else {
114114+ log.debug("Received error 'ExpiredToken'. Refreshing session.")
115115+116116+ // Create a new session. By locking the auth mutex here,
117117+ // other requests will block when they try to get the
118118+ // tokens.
119119+ authMutex.withLock {
120120+ authManager.refreshSession()
121121+ authManager.authTokens?.accessToken
122122+ }
123123+ }
124124+ } else {
125125+ // A non auth-related error occurred.
126126+ return@on originalCall
127127+ }
128128+ }
129129+130130+ // A non-auth related response was received.
131131+ else -> return@on originalCall
132132+ }
133133+134134+ log.debug("Retrying request with new access token.")
135135+136136+ // Retry the original request with the new access token.
137137+ originalCall.run {
138138+ request.headers.remove(HttpHeaders.Authorization)
139139+ request.headers.append(HttpHeaders.Authorization, "Bearer $newAccessToken")
140140+141141+ proceed(request)
142142+ }
143143+ }
144144+}
145145+146146+class AuthPluginConfig {
147147+ var authManager: AuthManager? = null
148148+}
149149+150150+@Serializable
151151+data class ErrorResponse(
152152+ val error: String,
153153+ val message: String,
154154+)
155155+156156+open class AuthPluginError(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
157157+158158+class AuthPluginConfigurationError(message: String) : AuthPluginError(message)
+6-175
darkfeed/src/main/kotlin/api/BskyApi.kt
···11package rs.averyrive.darkfeed.api
2233+import AuthManager
34import io.ktor.client.*
45import io.ktor.client.call.*
56import io.ktor.client.engine.cio.*
67import io.ktor.client.plugins.*
77-import io.ktor.client.plugins.auth.providers.*
88import io.ktor.client.plugins.contentnegotiation.*
99import io.ktor.client.plugins.logging.*
1010import io.ktor.client.request.*
1111import io.ktor.client.statement.*
1212import io.ktor.http.*
1313import io.ktor.serialization.kotlinx.json.*
1414-import kotlinx.coroutines.sync.Mutex
1515-import kotlinx.coroutines.sync.withLock
1614import kotlinx.serialization.Serializable
1715import kotlinx.serialization.json.Json
1816import org.slf4j.Logger
···2220import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.defs.PostView
23212422class BskyApi(
2323+ private val authManager: AuthManager,
2524 private val pdsUrl: Url = Url("https://bsky.social"),
2626-2727- private var bearerTokens: BearerTokens? = null,
2828-2929- private val bearerTokensMutex: Mutex = Mutex(),
3030-3131- private val httpClient: HttpClient = HttpClient(CIO) {
2525+) {
2626+ private val httpClient = HttpClient(CIO) {
3227 install(ContentNegotiation) {
3328 json(Json {
3429 explicitNulls = false
···38333934 install(Logging)
40354141- defaultRequest {
4242- url {
4343- protocol = pdsUrl.protocol
4444- host = pdsUrl.host
4545- path("xrpc/")
4646- }
4747- }
4848- },
4949-5050- private val authHttpClient: HttpClient = HttpClient(CIO) {
5151- install(ContentNegotiation) {
5252- json(Json {
5353- explicitNulls = false
5454- ignoreUnknownKeys = true
5555- })
3636+ install(AuthPlugin) {
3737+ this.authManager = this@BskyApi.authManager
5638 }
5757-5858- install(Logging)
59396040 defaultRequest {
6141 url {
···6444 path("xrpc/")
6545 }
6646 }
6767- },
6868-) {
6969- companion object {
7070- val unauthorizedPaths = setOf(
7171- "com.atproto.server.createSession",
7272- "com.atproto.server.refreshSession",
7373- "com.atproto.repo.getRecord",
7474- "com.atproto.repo.listRecords",
7575- )
7647 }
77487849 private val log: Logger = LoggerFactory.getLogger(this::class.java)
79508080- init {
8181- httpClient.plugin(HttpSend).intercept { request ->
8282- log.debug(
8383- "Intercepting request to {}://{}{}",
8484- request.url.protocol.name,
8585- request.url.host,
8686- request.url.encodedPath
8787- )
8888-8989- // If this request does not require authorization, send it normally.
9090- if (unauthorizedPaths.any { request.url.encodedPath.contains(it) }) {
9191- log.debug("Request does not require authentication, sending normally")
9292- return@intercept execute(request)
9393- }
9494-9595- // Get the current access token. If another coroutine is currently
9696- // refreshing the tokens, this will block until finished and get
9797- // new tokens.
9898- val accessToken = bearerTokensMutex.withLock {
9999- bearerTokens?.accessToken ?: throw RuntimeException("No auth tokens")
100100- }
101101-102102- // Add authorization header to request.
103103- request.headers.remove(HttpHeaders.Authorization)
104104- request.headers.append(HttpHeaders.Authorization, "Bearer $accessToken")
105105-106106- // Send request.
107107- val call = execute(request)
108108-109109- // Check the response.
110110- val newAccessToken = when (call.response.status) {
111111- HttpStatusCode.Unauthorized -> {
112112- // Get new tokens using username and app password.
113113- log.debug("Received {}, refreshing session with username and password", call.response.status)
114114-115115- TODO("Session refresh with username and password is not implemented yet")
116116- }
117117-118118- HttpStatusCode.BadRequest -> {
119119- log.debug("Received {}, error: {}", call.response.status, call.response.bodyAsText())
120120-121121- // Check error code.
122122- val errorResponse = try {
123123- call.response.body<ErrorResponse>()
124124- } catch (e: Exception) {
125125- null
126126- }
127127-128128- // Access token is expired, use the refresh token to get new tokens.
129129- if (errorResponse?.error == "ExpiredToken") {
130130- // Get the new access token.
131131- val newAccessToken = bearerTokensMutex.withLock { bearerTokens?.accessToken }
132132-133133- // If the tokens have changed since the original call,
134134- // then another coroutine has updated them and the new
135135- // access token should be used.
136136- if (newAccessToken == accessToken) {
137137- log.debug("Access token is expired, using refresh token to get new tokens")
138138-139139- // Get new tokens using the refresh token.
140140- bearerTokensMutex.withLock {
141141- val refreshToken =
142142- bearerTokens?.refreshToken ?: throw RuntimeException("No refresh token")
143143-144144- @Serializable
145145- data class Response(
146146- val accessJwt: String,
147147- val refreshJwt: String,
148148- val handle: String,
149149- val did: String,
150150- )
151151-152152- val refreshRequest = authHttpClient.post("com.atproto.server.refreshSession") {
153153- header(HttpHeaders.Authorization, "Bearer $refreshToken")
154154- }
155155-156156- // TODO: Check status codes.
157157-158158- val response: Response = refreshRequest.body()
159159-160160- bearerTokens = BearerTokens(
161161- accessToken = response.accessJwt,
162162- refreshToken = response.refreshJwt,
163163- )
164164-165165- // Return the newly refreshed access token.
166166- bearerTokens?.accessToken!!
167167- }
168168- } else {
169169- log.debug("Tokens refreshed by another coroutine")
170170-171171- // Return the newly retrieved access token.
172172- newAccessToken!!
173173- }
174174- } else {
175175- // Another error has occurred. Return the original call.
176176- return@intercept call
177177- }
178178- }
179179-180180- // Another status code was returned. Return the original call.
181181- else -> return@intercept call
182182- }
183183-184184- // Resend the request with the new access token.
185185- // TODO: Check if this is necessary. If this request gets intercepted, this won't be necessary.
186186- request.headers.remove(HttpHeaders.Authorization)
187187- request.headers.append(HttpHeaders.Authorization, "Bearer $newAccessToken")
188188-189189- log.debug("Retrying original request with new access token")
190190-191191- execute(request)
192192- }
193193- }
194194-19551 @Serializable
19652 data class ErrorResponse(
19753 val error: String,
19854 val message: String,
19955 )
200200-201201- suspend fun login(identifier: String, password: String) {
202202- @Serializable
203203- data class Request(val identifier: String, val password: String)
204204-205205- @Serializable
206206- data class Response(val did: String, val accessJwt: String, val refreshJwt: String)
207207-208208- val response = httpClient.post("com.atproto.server.createSession") {
209209- contentType(ContentType.Application.Json)
210210- setBody(Request(identifier, password))
211211- }
212212-213213- when (response.status) {
214214- HttpStatusCode.OK -> {
215215- val tokens: Response = response.body()
216216- bearerTokens = BearerTokens(tokens.accessJwt, tokens.refreshJwt)
217217- }
218218-219219- HttpStatusCode.BadRequest,
220220- HttpStatusCode.Unauthorized -> throw RuntimeException("Failed to create session: ${response.bodyAsText()}")
221221-222222- else -> throw RuntimeException("Unexpected response received: ${response.bodyAsText()}")
223223- }
224224- }
2255622657 suspend fun getFeedGeneratorRecord(repo: String, rkey: String): Generator? {
22758 @Serializable