Skip to content

Conversation

@mirkoDeer
Copy link
Contributor

No description provided.

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
@ReflectionHint(types = [PropertyNamingStrategies.SnakeCaseStrategy::class])
internal class TokenResponse(
class BearerToken(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets make this a data class as well

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements a comprehensive authentication flow for container registries, replacing the previous authentication mechanism with a more robust session-based approach that supports both Bearer and Basic authentication schemes.

Changes:

  • Implemented custom authentication providers (RegistryAuthProvider, RegistryBearerAuthProvider, RegistryBasicAuthProvider) with token caching and request coalescing
  • Introduced authentication session management using coroutine context to share auth tokens across related requests
  • Refactored upload and download flows to wrap operations in auth sessions and initialize authentication upfront
  • Updated API layer to attach session IDs to all requests and simplified the execute method to handle transformations inline

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/RegistryAuthProvider.kt New auth provider base class and implementations for Bearer and Basic authentication with caching
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/AuthSession.kt Coroutine context-based session management for sharing auth tokens
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/AuthAttributes.kt Request attributes for authentication flow control
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/BearerToken.kt Renamed and updated token data class with expiry calculation
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/ContainerRegistryApiImpl.kt Refactored to attach session IDs and inline response transformations
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/ContainerRegistryApi.kt Added authChallenge method for initializing authentication
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/SuspendingContainerImageRegistryClientImpl.kt Added initializeAuth method and wrapped operations in auth sessions
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/delegate/ImageUploader.kt Wrapped upload flow in auth session with upfront authentication initialization
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/delegate/ImageDownloader.kt Wrapped download flow in auth session with upfront authentication initialization
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/client/SuspendingContainerImageClientFactory.kt Updated to use new auth providers and inlined HTTPS configuration
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/client/SuspendingContainerImageRegistryClient.kt Added initializeAuth and updated uploadBlobStream signature
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/utils/KtorInitUtils.kt Removed - functionality moved to auth providers
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/utils/InsecureSSLComponents.kt Removed - unused code
kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/utils/ResponseTransformation.kt Removed unused mapSuspending function
kirc-suspending/src/test/kotlin/de/cmdjulian/kirc/RegistryBearerAuthTest.kt Updated tests for new upload modes and removed @disabled annotation
kirc-suspending/src/test/kotlin/de/cmdjulian/kirc/RegistryBasicAuthTest.kt Added test for raw upload mode
kirc-suspending/src/test/kotlin/de/cmdjulian/kirc/AuthSessionTest.kt New test file for auth session behavior
kirc-core/src/main/kotlin/de/cmdjulian/kirc/client/RegistryCredentials.kt Changed from class to data class for proper equality semantics
docs/upload-graph.md New documentation with sequence diagram for upload process
docs/download-graph.md New documentation with sequence diagram for download process
kirc-suspending/build.gradle.kts Added Caffeine cache and coroutines-test dependencies, removed ktor-client-logging
Comments suppressed due to low confidence (2)

kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/BearerToken.kt:12

  • The BearerToken class is declared as public (no visibility modifier) while it was previously internal as TokenResponse. Since this class is in the impl.auth package and appears to be an internal implementation detail, it should likely be marked as internal to prevent exposing implementation details to consumers of the library.
    kirc-suspending/src/main/kotlin/de/cmdjulian/kirc/impl/auth/BearerToken.kt:19
  • The expiresAt method doesn't handle the case where expiresIn is null. When expiresIn is null but issuedAt is not null, the function returns issuedAt.plusSeconds(0), which means the token is immediately expired. This could lead to unexpected behavior. Consider either returning null when expiresIn is null, or using a sensible default expiry duration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 124 to 142
// 4. Slow Path: Request Coalescing (fetch token)
// Atomically get existing job or start a new one and
// wait for the specific key's job to finish
return coroutineScope {
inflightRequests.computeIfAbsent(id) {
async {
try {
// Fetch the token
val token = requestToken(realm, scope, service, credentials)
// Update Cache
cache.put(id, token)
token
} finally {
// Always remove from keys-in-flight map when done
inflightRequests.remove(id)
}
}
}.await()
}
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inflightRequests ConcurrentHashMap uses computeIfAbsent with an async block. However, computeIfAbsent itself is not atomic when the mapping function performs asynchronous operations. If two coroutines call computeIfAbsent simultaneously with the same key, both could enter the mapping function before either completes, potentially creating duplicate token requests. While the Deferred.await() will ensure they both get the same result, this could still result in multiple token requests for the same session. Consider using a proper coroutine-based synchronization mechanism like Mutex with a check-lock-check pattern instead of relying on ConcurrentHashMap for this use case.

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this might work:

package de.cmdjulian.kirc.impl.auth

import com.fasterxml.jackson.module.kotlin.readValue
import com.github.benmanes.caffeine.cache.Cache
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.Expiry
import de.cmdjulian.kirc.client.RegistryCredentials
import de.cmdjulian.kirc.impl.KircApiError
import de.cmdjulian.kirc.impl.serialization.JsonMapper
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.auth.AuthProvider
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.basicAuth
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.HttpHeaders
import io.ktor.http.auth.AuthScheme
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.http.auth.parseAuthorizationHeader
import io.ktor.util.Attributes
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

internal sealed class RegistryAuthProvider<T : Any>(private val credentials: RegistryCredentials?) : AuthProvider {

    protected companion object {
        val authHttpClient by lazy { HttpClient(CIO) }
    }

    @Deprecated("Please use sendWithoutRequest function instead")
    override val sendWithoutRequest get() = false

    // Mutex to guard the critical section of checking/creating inflight requests
    private val requestMutex = Mutex()
    
    // Kept as ConcurrentHashMap for thread-safe reads in non-suspend sendWithoutRequest
    protected val inflightRequests = ConcurrentHashMap<UUID, Deferred<T>>()
    
    protected val cache: Cache<UUID, T> = Caffeine.newBuilder()
        .expireAfter(
            object : Expiry<UUID, T> {
                override fun expireAfterCreate(key: UUID, value: T, currentTime: Long): Long = expireAfterCreate(value)
                override fun expireAfterUpdate(key: UUID, value: T, currentTime: Long, currentDuration: Long): Long = expireAfterCreate(value)
                override fun expireAfterRead(key: UUID, value: T, currentTime: Long, currentDuration: Long): Long = currentDuration
            },
        ).build()

    abstract fun expireAfterCreate(value: T): Long

    abstract override fun isApplicable(auth: HttpAuthHeader): Boolean

    abstract override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?)

    override fun sendWithoutRequest(request: HttpRequestBuilder): Boolean {
        // 1. Determine Identity
        val id = request.attributes.getSessionId() ?: return false

        // 2. Fast Path: Check Caffeine cache immediately
        cache.getIfPresent(id)?.let { return true }
        
        // This read is safe because inflightRequests is a ConcurrentHashMap
        return inflightRequests.containsKey(id)
    }

    override suspend fun refreshToken(response: HttpResponse): Boolean {
        val authHeader = parseAuthHeader(response) ?: return false

        if (authHeader.authScheme != AuthScheme.Bearer) return false

        val tokenResolved = resolveToken(response.request.attributes, authHeader) != null
        val skipRefresh = response.request.attributes.getOrNull(AuthAttributes.SKIP_REFRESH) == true
        return tokenResolved && !skipRefresh
    }

    protected suspend fun resolveToken(attributes: Attributes, authHeader: HttpAuthHeader?): T? {
        if (credentials == null) return null
        val id = attributes.getSessionId() ?: return null

        // 1. Fast Path: Check Caffeine cache immediately
        cache.getIfPresent(id)?.let { return it }

        if (authHeader !is HttpAuthHeader.Parameterized) return null
        val realm = authHeader.parameter("realm") ?: return null
        val scope = buildScope(attributes, authHeader)
        val service = authHeader.parameter("service")

        // 4. Slow Path: Request Coalescing (fetch token)
        return coroutineScope {
            // Use Mutex to ensure atomicity when checking/starting jobs
            val job = requestMutex.withLock {
                // Double-check: Did a job start while we were waiting for the lock?
                inflightRequests[id]?.let { return@withLock it }

                // Triple-check: Did a job finish and populate cache while we were waiting?
                cache.getIfPresent(id)?.let { return@withLock CompletableDeferred(it) }

                // Start new job
                val newJob = async {
                    try {
                        val token = requestToken(realm, scope, service, credentials)
                        cache.put(id, token)
                        token
                    } finally {
                        // Re-acquire lock to safely remove from map
                        requestMutex.withLock {
                            inflightRequests.remove(id)
                        }
                    }
                }
                inflightRequests[id] = newJob
                newJob
            }
            job.await()
        }
    }

    // ... (rest of the class remains the same: requestToken, parseAuthHeader, buildScope, getSessionId)
    
    protected abstract suspend fun requestToken(
        realm: String,
        scope: String?,
        service: String?,
        credentials: RegistryCredentials,
    ): T

    private fun parseAuthHeader(response: HttpResponse): HttpAuthHeader.Parameterized? {
        val headerValue = response.headers[HttpHeaders.WWWAuthenticate] ?: return null
        return try {
            parseAuthorizationHeader(headerValue) as? HttpAuthHeader.Parameterized
        } catch (_: Exception) {
            null
        }
    }

    private fun buildScope(attributes: Attributes, authHeader: HttpAuthHeader.Parameterized): String? {
        val repo = attributes.getOrNull(AuthAttributes.SCOPE_REPO)
        val type = attributes.getOrNull(AuthAttributes.SCOPE_TYPE)
        return if (repo != null && type != null) {
            "repository:$repo:$type"
        } else {
            authHeader.parameter("scope")
        }
    }

    private fun Attributes.getSessionId(): UUID? = getOrNull(AuthAttributes.SESSION_ID)?.let(UUID::fromString)
}

Comment on lines 70 to 71
*
* If not, a [KircApiError.Unknown] is created with the original exception
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The execute function documentation on line 71 contains redundant text that appears to be left over from editing. The phrase "If not, a [KircApiError.Unknown] is created with the original exception" is redundant and confusing since line 68 already states that if an exception is thrown, it is caught as KircApiError.Unknown. This redundant sentence should be removed.

Suggested change
*
* If not, a [KircApiError.Unknown] is created with the original exception

Copilot uses AI. Check for mistakes.
* [credentials] - The registry credentials to use for token retrieval. If null, no authentication will be performed.
*/
internal sealed class RegistryAuthProvider<T : Any>(private val credentials: RegistryCredentials?) : AuthProvider {

Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authHttpClient is created lazily as a shared singleton across all RegistryAuthProvider instances. This client is used for token retrieval but is never closed. Since HttpClient holds resources like connection pools and threads, this could lead to resource leaks if multiple provider instances are created and destroyed. Consider either closing the client when the provider is no longer needed or documenting that this is intentionally a long-lived singleton.

Suggested change
/**
* Shared HTTP client used for token retrieval across all [RegistryAuthProvider] instances.
*
* This client is intentionally a lazily initialized, process-wide singleton and is expected
* to live for the lifetime of the application. It is therefore not closed here; shutting
* it down, if required, must be handled at the application level.
*/

Copilot uses AI. Check for mistakes.

@Test
fun `upload - to registry`() {
fun `upload - raw`() {
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test function name "upload - raw" is inconsistent with the naming pattern used in other test functions. The other upload tests use "upload stream" and "upload chunked" patterns. For consistency, this test should be named something like "upload - stream without assertions" or have a more descriptive name that explains what differentiates it from the other upload tests.

Suggested change
fun `upload - raw`() {
fun `upload stream - without assertions`() {

Copilot uses AI. Check for mistakes.

override suspend fun initializeAuth(repository: Repository, type: ScopeType) {
api.authChallenge(repository, type).onFailure {
// 401 is expected if it is first request in scope no credentials are provided
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 61 contains a grammatical error. It should be "401 is expected if it is the first request in scope or if no credentials are provided" instead of "401 is expected if it is first request in scope no credentials are provided". The current wording is unclear and missing proper punctuation.

Suggested change
// 401 is expected if it is first request in scope no credentials are provided
// 401 is expected if it is the first request in scope or if no credentials are provided

Copilot uses AI. Check for mistakes.
Comment on lines 93 to 99
override suspend fun refreshToken(response: HttpResponse): Boolean {
// 1. Extract the WWW-Authenticate header from the 401 response
// 2. Parse it into an HttpAuthHeader
val authHeader = parseAuthHeader(response) ?: return false

// 3. Ensure it is a Bearer challenge
if (authHeader.authScheme != AuthScheme.Bearer) return false
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refreshToken method in the base class hardcodes a check for AuthScheme.Bearer on line 99, which means RegistryBasicAuthProvider will always return false from this method. While Basic authentication doesn't typically require token refresh (credentials are sent with each request), this implicit behavior through inheritance is not obvious. Consider either overriding refreshToken in RegistryBasicAuthProvider to explicitly return false with a comment explaining why, or checking against isApplicable(authHeader) instead of hardcoding the Bearer scheme check.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

Client ->> Registry: GET /v2/
Registry -->> Client: 401 Unauthorized
Note over Client, Auth: Authentication for pull
Client ->> Auth: GET /<realm>?scope=repository:<repo>:pull,push&service=<service>
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation incorrectly specifies the scope as "pull,push" for a download operation. For download/pull operations, the scope should only be "pull", not "pull,push". The "pull,push" scope should only be used for upload operations.

Suggested change
Client ->> Auth: GET /<realm>?scope=repository:<repo>:pull,push&service=<service>
Client ->> Auth: GET /<realm>?scope=repository:<repo>:pull&service=<service>

Copilot uses AI. Check for mistakes.
}
}

// Helper to retrieve the implicit ID or generate a new standalone one
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currentSession function returns a new random UUID when no AuthSession is present in the coroutine context. This could lead to unexpected behavior where requests outside of withAuthSession blocks get different UUIDs on each call, preventing proper auth caching. Consider either requiring AuthSession to be present (throwing an exception if not found) or documenting this fallback behavior more explicitly in the function's KDoc.

Suggested change
// Helper to retrieve the implicit ID or generate a new standalone one
/**
* Returns the current auth session ID from the coroutine context, if present.
*
* If an [AuthSession] is available in the current coroutine context, its [AuthSession.id]
* is returned. If no [AuthSession] is present, a new random [UUID] is generated and
* returned instead.
*
* Note:
* - The fallback UUID is **not** stored in the coroutine context.
* - Consecutive calls to this function outside of a [withAuthSession] block will therefore
* each produce a different UUID.
* - Callers that rely on a stable session ID for authentication caching must ensure they
* execute within a [withAuthSession] scope.
*/

Copilot uses AI. Check for mistakes.
Comment on lines 56 to 264
internal sealed class RegistryAuthProvider<T : Any>(private val credentials: RegistryCredentials?) : AuthProvider {

protected companion object {
val authHttpClient by lazy { HttpClient(CIO) }
}

@Deprecated("Please use sendWithoutRequest function instead")
override val sendWithoutRequest get() = false
protected val inflightRequests = ConcurrentHashMap<UUID, Deferred<T>>()
protected val cache: Cache<UUID, T> = Caffeine.newBuilder()
.expireAfter(
object : Expiry<UUID, T> {
override fun expireAfterCreate(key: UUID, value: T, currentTime: Long): Long = expireAfterCreate(value)

override fun expireAfterUpdate(key: UUID, value: T, currentTime: Long, currentDuration: Long): Long =
expireAfterCreate(value)

override fun expireAfterRead(key: UUID, value: T, currentTime: Long, currentDuration: Long): Long =
currentDuration
},
).build()

abstract fun expireAfterCreate(value: T): Long

abstract override fun isApplicable(auth: HttpAuthHeader): Boolean

abstract override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?)

override fun sendWithoutRequest(request: HttpRequestBuilder): Boolean {
// 1. Determine Identity
val id = request.attributes.getSessionId() ?: return false

// 2. Fast Path: Check Caffeine cache immediately
cache.getIfPresent(id)?.let { return true }
return inflightRequests.contains(id)
}

override suspend fun refreshToken(response: HttpResponse): Boolean {
// 1. Extract the WWW-Authenticate header from the 401 response
// 2. Parse it into an HttpAuthHeader
val authHeader = parseAuthHeader(response) ?: return false

// 3. Ensure it is a Bearer challenge
if (authHeader.authScheme != AuthScheme.Bearer) return false

// 4. Trigger the fetch logic. If resolveToken returns a token, it's now in cache.
// Check if we should skip auth refresh (e.g. for init auth requests)
val tokenResolved = resolveToken(response.request.attributes, authHeader) != null
val skipRefresh = response.request.attributes.getOrNull(AuthAttributes.SKIP_REFRESH) == true
return tokenResolved && !skipRefresh
}

// Helper function to resolve token from attributes and auth header

protected suspend fun resolveToken(attributes: Attributes, authHeader: HttpAuthHeader?): T? {
if (credentials == null) return null
// 1. Determine Identity
val id = attributes.getSessionId() ?: return null

// 2. Fast Path: Check Caffeine cache immediately
cache.getIfPresent(id)?.let { return it }

// 3. Determine Scope (either from triggered auth challenge to /v2/ or 401 challenge by failed request)
if (authHeader !is HttpAuthHeader.Parameterized) return null
val realm = authHeader.parameter("realm") ?: return null
val scope = buildScope(attributes, authHeader)
val service = authHeader.parameter("service")

// 4. Slow Path: Request Coalescing (fetch token)
// Atomically get existing job or start a new one and
// wait for the specific key's job to finish
return coroutineScope {
inflightRequests.computeIfAbsent(id) {
async {
try {
// Fetch the token
val token = requestToken(realm, scope, service, credentials)
// Update Cache
cache.put(id, token)
token
} finally {
// Always remove from keys-in-flight map when done
inflightRequests.remove(id)
}
}
}.await()
}
}

protected abstract suspend fun requestToken(
realm: String,
scope: String?,
service: String?,
credentials: RegistryCredentials,
): T

private fun parseAuthHeader(response: HttpResponse): HttpAuthHeader.Parameterized? {
val headerValue = response.headers[HttpHeaders.WWWAuthenticate] ?: return null
return try {
parseAuthorizationHeader(headerValue) as? HttpAuthHeader.Parameterized
} catch (_: Exception) {
null
}
}

private fun buildScope(attributes: Attributes, authHeader: HttpAuthHeader.Parameterized): String? {
val repo = attributes.getOrNull(AuthAttributes.SCOPE_REPO)
val type = attributes.getOrNull(AuthAttributes.SCOPE_TYPE)
return if (repo != null && type != null) {
"repository:$repo:$type"
} else {
authHeader.parameter("scope")
}
}

private fun Attributes.getSessionId(): UUID? = getOrNull(AuthAttributes.SESSION_ID)?.let(UUID::fromString)
}

/**
* An AuthProvider that handles Bearer authentication for container registries.
*
* Implements:
* - Expiry of token
* - Adding Bearer auth header to requests
* - Applicability check for Bearer auth headers
* - Requesting new Bearer tokens from auth server
*/
internal class RegistryBearerAuthProvider(credentials: RegistryCredentials?) :
RegistryAuthProvider<BearerToken>(credentials) {

override fun expireAfterCreate(value: BearerToken): Long {
val expiresIn = value.expiresIn?.seconds ?: 5.minutes
val expiresInWithSafetyMargin = expiresIn - 10.seconds
return expiresInWithSafetyMargin.inWholeNanoseconds
}

override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?) {
val token = resolveToken(request.attributes, authHeader) ?: return
request.bearerAuth(token.token)
}

override fun isApplicable(auth: HttpAuthHeader): Boolean =
auth is HttpAuthHeader.Parameterized && auth.authScheme == AuthScheme.Bearer

override suspend fun requestToken(
realm: String,
scope: String?,
service: String?,
credentials: RegistryCredentials,
): BearerToken = authHttpClient.get(realm) {
if (scope != null) parameter("scope", scope)
if (service != null) parameter("service", service)
basicAuth(credentials.username, credentials.password)
}.toBearerToken()

private suspend inline fun HttpResponse.toBearerToken(): BearerToken {
if (status.value !in 200..299) {
throw KircApiError.Bearer(
statusCode = status.value,
url = request.url,
method = request.method,
message = "Could not retrieve bearer token from auth server (status=${bodyAsText()})",
)
}
return try {
// using bodyAsText because client for bearer retrieval isn't configured with json feature
JsonMapper.readValue<BearerToken>(bodyAsText())
} catch (e: Exception) {
throw KircApiError.Json(
statusCode = status.value,
url = request.url,
method = request.method,
message = "Could not deserialize bearer token response",
cause = e,
)
}
}
}

/**
* An AuthProvider that handles Basic authentication for container registries.
*
* This provider uses the provided registry credentials to add Basic Auth headers to requests.
*
* Implements:
* - Expiry of token
* - Adding Basic auth header to requests
* - Applicability check for Basic auth headers
* - Returning credentials as token
*/
internal class RegistryBasicAuthProvider(credentials: RegistryCredentials?) :
RegistryAuthProvider<RegistryCredentials>(credentials) {

override fun expireAfterCreate(value: RegistryCredentials): Long = 5.minutes.inWholeNanoseconds

override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?) {
val token = resolveToken(request.attributes, authHeader) ?: return
request.basicAuth(token.username, token.password)
}

override fun isApplicable(auth: HttpAuthHeader): Boolean = auth.authScheme == AuthScheme.Basic

override suspend fun requestToken(
realm: String,
scope: String?,
service: String?,
credentials: RegistryCredentials,
): RegistryCredentials = credentials
}
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new authentication provider classes (RegistryAuthProvider, RegistryBearerAuthProvider, RegistryBasicAuthProvider) and BearerToken class lack direct unit test coverage. While integration tests exist for bearer and basic auth flows, there are no tests for edge cases such as token expiry calculation, request coalescing behavior, cache eviction, or error handling in the auth provider logic. Consider adding unit tests for these components.

Copilot uses AI. Check for mistakes.
}

@Test
fun `upload - raw`() {
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test function name "upload - raw" is inconsistent with the naming pattern used in other test functions. The other upload tests use "upload - stream" and similar patterns. For consistency and clarity, this test should have a more descriptive name that explains its purpose or how it differs from the "upload - stream" test below it.

Suggested change
fun `upload - raw`() {
fun `upload - stream without existence check`() {

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}
}

private fun Attributes.getSessionId(): UUID? = getOrNull(AuthAttributes.SESSION_ID)?.let(UUID::fromString)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UUID.fromString() call on line 185 can throw IllegalArgumentException if the session ID string is malformed. While this is unlikely in normal operation, consider catching this exception and returning null to make the function more robust against malformed attributes.

Suggested change
private fun Attributes.getSessionId(): UUID? = getOrNull(AuthAttributes.SESSION_ID)?.let(UUID::fromString)
private fun Attributes.getSessionId(): UUID? {
val sessionId = getOrNull(AuthAttributes.SESSION_ID) ?: return null
return try {
UUID.fromString(sessionId)
} catch (_: IllegalArgumentException) {
null
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +141 to +148
try {
val token = requestToken(realm, scope, service, credentials)
cache.put(id, token)
token
} finally {
// Re-acquire lock to safely remove from map
requestMutex.withLock {
inflightRequests.remove(id)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If requestToken throws an exception, it will be propagated to the awaiting job but the inflightRequests entry will still be removed in the finally block. However, if the exception occurs, the cache won't be populated, which is correct. But consider whether subsequent requests should retry immediately or if failed auth attempts should have a short cooldown period to avoid hammering the auth server on repeated failures.

Suggested change
try {
val token = requestToken(realm, scope, service, credentials)
cache.put(id, token)
token
} finally {
// Re-acquire lock to safely remove from map
requestMutex.withLock {
inflightRequests.remove(id)
var success = false
try {
val token = requestToken(realm, scope, service, credentials)
cache.put(id, token)
success = true
token
} finally {
if (success) {
// Re-acquire lock to safely remove from map
requestMutex.withLock {
inflightRequests.remove(id)
}

Copilot uses AI. Check for mistakes.
url = request.url,
method = request.method,
cause = e,
message = "Could not parse registry error response body (body=${bodyAsText()})",
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the toErrorResponse function, calling bodyAsText() after body() has already been called will fail because the response body can only be consumed once. If body() throws an exception, the body is already partially or fully consumed, so bodyAsText() in the error message may fail or return empty content. Consider reading the body as text first and then parsing it, or removing the bodyAsText() call from the error message.

Suggested change
message = "Could not parse registry error response body (body=${bodyAsText()})",
message = "Could not parse registry error response body",

Copilot uses AI. Check for mistakes.

// 2. Fast Path: Check Caffeine cache immediately
cache.getIfPresent(id)?.let { return true }
return inflightRequests.contains(id)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition in sendWithoutRequest between checking the cache (line 93) and checking inflightRequests (line 94). If a request completes and removes itself from inflightRequests between these two checks, the function might return false even though the token is now in the cache. While this is a minor issue that would just trigger an unnecessary auth flow, consider checking the cache again after checking inflightRequests, or checking both atomically.

Suggested change
return inflightRequests.contains(id)
// 3. If not in cache, check if there is an inflight request for this id
if (inflightRequests.containsKey(id)) return true
// 4. Race window handling:
// Between the inflight check and here, the inflight request might have completed
// and put the token into the cache. Re-check the cache to avoid an unnecessary
// auth flow in that case.
return cache.getIfPresent(id) != null

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +55
* Note:
* - The fallback UUID is **not** stored in the coroutine context.
* - Consecutive calls to this function outside a [withAuthSession] block will therefore each produce a different UUID.
* - Callers that rely on a stable session ID for authentication caching must ensure they execute within a [withAuthSession] scope.
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for currentSession() states that it returns "a new random UUID" as fallback when no AuthSession is present, but then warns that "Consecutive calls to this function outside a withAuthSession block will therefore each produce a different UUID." This behavior could lead to unexpected issues where each request gets a different session ID, preventing token reuse. Consider clarifying if this is intentional behavior or if callers should always use withAuthSession.

Suggested change
* Note:
* - The fallback UUID is **not** stored in the coroutine context.
* - Consecutive calls to this function outside a [withAuthSession] block will therefore each produce a different UUID.
* - Callers that rely on a stable session ID for authentication caching must ensure they execute within a [withAuthSession] scope.
* Important:
* - The fallback UUID is **not** stored in the coroutine context and therefore does **not**
* represent a real session. It is suitable only for non-critical use cases such as
* logging or correlation where a stable session ID is not required.
* - Consecutive calls to this function outside a [withAuthSession] block will each produce
* a different UUID, and no token or authentication state will be reused between calls.
* - Any code that relies on a stable, reusable session ID (for example, to reuse bearer
* authentication tokens or cache authentication state) **must** execute within a
* [withAuthSession] scope, and should obtain the ID via [currentSession] only from
* within that scope.

Copilot uses AI. Check for mistakes.
/**
* Error as returned by the registry.
*
* According to format definition [see](https://docker-docs.uclv.cu/registry/spec/api/#errors)
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation link appears to use an unusual domain "docker-docs.uclv.cu". Consider verifying this is the correct official Docker Registry documentation URL or update to use the official Docker documentation domain.

Suggested change
* According to format definition [see](https://docker-docs.uclv.cu/registry/spec/api/#errors)
* According to format definition [see](https://docs.docker.com/registry/spec/api/#errors)

Copilot uses AI. Check for mistakes.
internal class RegistryBearerAuthProvider(credentials: RegistryCredentials?) :
RegistryAuthProvider<BearerToken>(credentials) {

val authHttpClient by lazy { HttpClient(CIO) }
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authHttpClient is lazily initialized but never closed. This could lead to resource leaks since HttpClient holds resources like connection pools. Consider implementing a cleanup mechanism or using a shared client instance that is properly managed at the lifecycle level.

Copilot uses AI. Check for mistakes.
@cmdjulian cmdjulian merged commit d6e03ae into cmdjulian:main Jan 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants