Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import panda.std.Blank
import panda.std.Result
import panda.std.Result.error
import panda.std.Result.ok
import io.javalin.http.HttpStatus.BAD_GATEWAY
import io.javalin.http.HttpStatus.FORBIDDEN
import io.javalin.http.HttpStatus.UNAUTHORIZED
import java.io.InputStream
import java.time.Clock
import java.time.Instant
Expand Down Expand Up @@ -220,7 +223,7 @@ internal class MirrorService(
}
if (result.error.status != 404) {
allMissing = false
lastUpstreamError = result.error
lastUpstreamError = result.error.asRemoteFailure(host.host)
}
}
return when {
Expand All @@ -229,6 +232,13 @@ internal class MirrorService(
}
}

/**
* A remote host that refuses the configured credentials is a failure of this instance, not of the caller. Relaying its
* 401 or 403 would reach the browser as a refused session, so it is answered as a bad gateway.
*/
private fun ErrorResponse.asRemoteFailure(host: String): ErrorResponse =
if (status == UNAUTHORIZED.code || status == FORBIDDEN.code) ErrorResponse(BAD_GATEWAY, "$host refused the configured credentials") else this

private enum class DisallowedReason {
EXTENSION,
GROUP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.reposilite.auth.AuthenticationFacade
import com.reposilite.auth.api.Credentials
import com.reposilite.maven.api.LookupRequest
import com.reposilite.shared.ErrorResponse
import com.reposilite.shared.notFound
import com.reposilite.shared.http.AuthenticationMethod.LOOPBACK_LINK
import com.reposilite.shared.http.RemoteClient
import com.reposilite.shared.http.RemoteCredentials
Expand All @@ -28,6 +29,7 @@ import com.reposilite.storage.api.FileDetails
import com.reposilite.storage.api.Location
import com.reposilite.storage.api.toLocation
import com.reposilite.token.AccessTokenIdentifier
import io.javalin.http.HttpStatus.FORBIDDEN
import io.javalin.http.HttpStatus.UNAUTHORIZED
import panda.std.Option
import panda.std.Result
Expand All @@ -46,7 +48,7 @@ internal class RepositoryLoopbackClient(
repository = repositoryName,
gav = toGav(uri)
)
)
).mapErr { it.refusedAsMissing() }

override fun get(uri: String, credentials: RemoteCredentials?, connectTimeoutInSeconds: Int, readTimeoutInSeconds: Int): Result<InputStream, ErrorResponse> =
repositoryService.findFile(
Expand All @@ -55,7 +57,7 @@ internal class RepositoryLoopbackClient(
repository = repositoryName,
gav = toGav(uri)
)
).map { (_, content) -> content }
).map { (_, content) -> content }.mapErr { it.refusedAsMissing() }

private fun RemoteCredentials?.toAccessToken(): AccessTokenIdentifier? =
Option.of(this)
Expand All @@ -67,6 +69,14 @@ internal class RepositoryLoopbackClient(
{ null }
)

/**
* A member that refuses the mirror identity of this reference does not hold the file for the group. The refusal is about
* the instance and not about the caller who browses the group, so it is answered as missing: a 401 or 403 here would
* reach the browser as a refused session, and the browser would end a session that is still valid.
*/
private fun ErrorResponse.refusedAsMissing(): ErrorResponse =
if (status == UNAUTHORIZED.code || status == FORBIDDEN.code) notFound("Cannot find '$repositoryName' with the mirror identity") else this

private fun toGav(uri: String): Location =
uri.substring(repositoryName.length + 1).toLocation()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2023 dzikoysk
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.reposilite.maven

import com.reposilite.maven.RepositoryVisibility.PRIVATE
import com.reposilite.maven.RepositoryVisibility.PUBLIC
import com.reposilite.maven.api.LookupRequest
import com.reposilite.maven.application.MirrorCredentials
import com.reposilite.maven.application.MirroredRepositorySettings
import com.reposilite.maven.application.RepositorySettings
import com.reposilite.maven.specification.MavenSpecification
import com.reposilite.shared.http.AuthenticationMethod.BASIC
import com.reposilite.shared.http.AuthenticationMethod.LOOPBACK_LINK
import com.reposilite.storage.api.toLocation
import com.reposilite.token.AccessTokenIdentifier
import com.reposilite.token.RoutePermission.READ
import io.javalin.http.HttpStatus.BAD_GATEWAY
import io.javalin.http.HttpStatus.NOT_FOUND
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import panda.std.ResultAssertions.assertOk

/**
* A group that mirrors private repositories through loopback references. An internal refusal of the mirror identity
* must never reach the browser as 401, because the browser then ends a session that is still valid.
*/
internal class RepositoryLoopbackRefusalTest : MavenSpecification() {

// Maven metadata is mirror first under the default storage policy, so a refusal of a remote reaches the caller
// unless it is handled. A pom is local first and would hide the refusal behind a local not found.
private val artifact = "com/example/app/maven-metadata.xml"

override fun repositories() = listOf(
RepositorySettings("internal", visibility = PRIVATE),
RepositorySettings("other", visibility = PRIVATE),
// The shape of the production group: a private member read with a legacy basic credential the instance does not use.
RepositorySettings("team", visibility = PUBLIC, proxied = mutableListOf(
MirroredRepositorySettings(reference = "internal", authorization = MirrorCredentials(BASIC, "reposilite-mirror", "legacy-secret")),
)),
// The same member read through a mirror identity that the instance knows.
RepositorySettings("mirrored", visibility = PUBLIC, proxied = mutableListOf(
MirroredRepositorySettings(reference = "internal", authorization = MirrorCredentials(LOOPBACK_LINK, "mirror", "mirror-secret")),
)),
RepositorySettings("remote-team", visibility = PUBLIC, proxied = mutableListOf(
MirroredRepositorySettings(reference = REMOTE_REPOSITORY_REFUSING, authorization = REMOTE_AUTH),
)),
)

@Nested
inner class SignedInCaller {

@Test
fun `should answer not found, not unauthorized, for the metadata of a group whose member the mirror cannot read`() {
// given: a signed in token that has no route to the private member
val token = createAccessToken("alice", "secret", "other", "com/example", READ)

// when: the maven metadata of the group is requested, as the details card does
val response = mavenFacade.findFile(LookupRequest(token, "team", artifact.toLocation()))

// then: the browser sees that the file is not there, not that its session was refused
assertThat(response.error.status).isEqualTo(NOT_FOUND.code)
}
}

@Nested
inner class AnonymousCaller {

@Test
fun `should answer not found, not unauthorized, for the metadata of a group whose member the mirror cannot read`() {
// when: the maven metadata of the group is requested without a token
val response = mavenFacade.findFile(LookupRequest(null, "team", artifact.toLocation()))

// then: the refusal of the mirror identity is not an answer about the caller
assertThat(response.error.status).isEqualTo(NOT_FOUND.code)
}
}

@Nested
inner class MirrorIdentity {

@Test
fun `should serve the metadata of a member that the mirror identity can read`() {
// given: a mirror identity with a route to the private member, and a file in that member
createAccessToken("mirror", "mirror-secret", "internal", "com/example", READ)
addFileToRepository(FileSpec("internal", artifact, "<project/>"))

// when: the file is requested through the group
val response = mavenFacade.findFile(LookupRequest(null, "mirrored", artifact.toLocation()))

// then: the mirror identity reads the member and the file is served
assertOk(response)
}
}

@Nested
inner class ExternalMirror {

@Test
fun `should answer bad gateway for the metadata of a remote mirror that refuses its credentials`() {
// when: a file is requested through a remote mirror that refuses its credentials
val response = mavenFacade.findFile(LookupRequest(null, "remote-team", artifact.toLocation()))

// then: the failure belongs to the instance, so it is not relayed as 401 to the browser
assertThat(response.error.status).isEqualTo(BAD_GATEWAY.code)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.reposilite.shared.errorResponse
import com.reposilite.shared.http.FakeRemoteClientProvider
import com.reposilite.shared.notFoundError
import io.javalin.http.HttpStatus.BAD_GATEWAY
import io.javalin.http.HttpStatus.UNAUTHORIZED as HTTP_UNAUTHORIZED
import com.reposilite.statistics.DailyDateIntervalProvider
import com.reposilite.statistics.StatisticsFacade
import com.reposilite.statistics.infrastructure.InMemoryStatisticsRepository
Expand Down Expand Up @@ -85,6 +86,8 @@ internal abstract class MavenSpecification {
const val REMOTE_REPOSITORY_WITH_WHITELIST = "https://example.com/whitelist"
// Always responds with a transient 5xx error — for tests that need a non-404 upstream failure.
const val REMOTE_REPOSITORY_BROKEN = "https://broken.example/releases"
// Always refuses the configured credentials with a 401, as an upstream does when its mirror login has gone stale.
const val REMOTE_REPOSITORY_REFUSING = "https://refusing.example/releases"
val REMOTE_AUTH = MirrorCredentials(BASIC, "panda", "secret")
const val REMOTE_CONTENT = "content"
}
Expand Down Expand Up @@ -135,6 +138,8 @@ internal abstract class MavenSpecification {
remoteRequestsByUri.computeIfAbsent(uri) { AtomicInteger() }.incrementAndGet()
if (uri.startsWith(REMOTE_REPOSITORY_BROKEN))
errorResponse(BAD_GATEWAY, "Simulated upstream failure")
else if (uri.startsWith(REMOTE_REPOSITORY_REFUSING))
errorResponse(HTTP_UNAUTHORIZED, "Simulated refused credentials")
else if (uri.startsWith(REMOTE_REPOSITORY) && REMOTE_AUTH == credentials && !uri.isAllowed())
DocumentInfo(
name = uri.substringAfterLast('/'),
Expand All @@ -152,6 +157,8 @@ internal abstract class MavenSpecification {
remoteRequestsByUri.computeIfAbsent(uri) { AtomicInteger() }.incrementAndGet()
if (uri.startsWith(REMOTE_REPOSITORY_BROKEN))
errorResponse(BAD_GATEWAY, "Simulated upstream failure")
else if (uri.startsWith(REMOTE_REPOSITORY_REFUSING))
errorResponse(HTTP_UNAUTHORIZED, "Simulated refused credentials")
else if (uri.startsWith(REMOTE_REPOSITORY) && REMOTE_AUTH == credentials && !uri.isAllowed())
REMOTE_CONTENT.byteInputStream().asSuccess()
else if (uri.startsWith(REMOTE_REPOSITORY_WITH_WHITELIST) && uri.isAllowed())
Expand Down
12 changes: 11 additions & 1 deletion reposilite-frontend/src/store/client-cookie.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,17 @@ describe('cookie mode refresh on 401', () => {
await client.handleResponseError(Object.assign(responseError(401), { config: retried })).catch(() => {})

expect(axios.post, 'a second 401 on the same request must not loop').not.toHaveBeenCalled()
expect(handler, 'a second 401 ends the session').toHaveBeenCalledTimes(1)
})

it('keeps the session when the retried request is rejected, because the refresh just succeeded', async () => {
axios.post.mockResolvedValue({ data: {} })
axios.request.mockResolvedValue({})
await client.handleResponseError(rejected())
const retried = axios.request.mock.calls[0][0]

await client.handleResponseError(Object.assign(responseError(401), { config: retried })).catch(() => {})

expect(handler, 'a 401 on a listing or content request is the server refusing that request, not the session').not.toHaveBeenCalled()
})

it('runs the unauthorized handler and rejects when the refresh fails', async () => {
Expand Down
55 changes: 42 additions & 13 deletions reposilite-frontend/src/store/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,33 +91,62 @@ const refreshSession = () => {
return refreshing
}

// A 401 means the credentials were rejected. In cookie mode the session is refreshed once and
// the request retried once; only when that fails does the unauthorized handler run. Every other
// failure passes through untouched, and the error is always rethrown so that each caller still
// sees its own failure.
// The value of one request header, read from a plain object or from AxiosHeaders.
const headerOf = (config, name) =>
typeof config?.headers?.get === 'function' ? config.headers.get(name) : config?.headers?.[name]

// A 401 on a request does not by itself end the session: the server may be refusing that one request
// (a group whose private members refuse the mirror, or a listing the user may not read). The session
// is asked about with the same credentials, and only a 401 from that check ends it. A request without
// credentials of its own has no session to check, so the unauthorized handler decides as it always did.
const sessionRejected = (config) => {
const authorization = headerOf(config, 'Authorization')
if (!authorization) {
return Promise.resolve(true)
}
return axios
.get(createURL('/api/auth/me'), {
withCredentials: true,
headers: { Authorization: authorization },
_sessionCheck: true,
})
.then(
() => false,
(error) => error?.response?.status === 401
)
}

// A 401 means the credentials were rejected. In cookie mode the session is refreshed once and the
// request retried once. A refresh that is refused ends the session. A retry that is refused after a
// successful refresh says nothing about the session, because the refresh just proved it stands. In
// token mode the session check decides. Every failure is rethrown so that each caller still sees its own.
const handleResponseError = async (error) => {
if (error?.response?.status !== 401) {
return Promise.reject(error)
}
const config = error.config
if (isAuthEndpoint(config?.url)) {
if (isAuthEndpoint(config?.url) || config?._sessionCheck) {
return Promise.reject(error)
}
if (config?._sessionAlive) {
return Promise.reject(error)
}
if (cookieMode && config && !config._retried) {
config._retried = true
let refreshed = true
try {
await refreshSession()
} catch {
refreshed = false
}
if (refreshed) {
// A second 401 comes back through this handler with _retried set and ends the session.
return axios.request(config)
onUnauthorized(error)
return Promise.reject(error)
}
return axios.request({ ...config, _sessionAlive: true })
}
onUnauthorized(error)
return Promise.reject(error)
return sessionRejected(config).then(rejected => {
if (rejected) {
onUnauthorized(error)
}
return Promise.reject(error)
})
}

axios.interceptors.request.use(handleRequest)
Expand Down
48 changes: 48 additions & 0 deletions reposilite-frontend/src/store/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,54 @@ describe('client response interceptor', () => {
})
})

describe('token mode session check on 401', () => {
let axios
let client
let handler

beforeEach(async () => {
vi.resetModules()
axios = (await import('axios')).default
axios.get.mockReset()
client = await import('./client')
handler = vi.fn()
client.setUnauthorizedHandler(handler)
})

// A listing request sent with the access token of a token sign in.
const tokenRequestError = () => Object.assign(responseError(401), {
config: { url: '/api/maven/details/private', method: 'get', headers: { Authorization: 'xBasic dG9rZW46c2VjcmV0' } }
})

it('keeps the token session when the session check still accepts it', async () => {
axios.get.mockResolvedValue({ data: {} })
const error = tokenRequestError()

await expect(client.handleResponseError(error)).rejects.toBe(error)

expect(axios.get.mock.calls[0][0], 'the session is asked about with the same credentials').toMatch(/\/api\/auth\/me$/)
expect(axios.get.mock.calls[0][1].headers.Authorization).toBe('xBasic dG9rZW46c2VjcmV0')
expect(handler, 'a 401 on a listing is not a rejected token').not.toHaveBeenCalled()
})

it('ends the token session when the session check is refused too', async () => {
axios.get.mockRejectedValue(responseError(401))
const error = tokenRequestError()

await expect(client.handleResponseError(error)).rejects.toBe(error)

expect(handler, 'the server rejected the token, so the session ends').toHaveBeenCalledWith(error)
})

it('keeps the token session when the session check cannot reach the server', async () => {
axios.get.mockRejectedValue(new Error('Network Error'))

await client.handleResponseError(tokenRequestError()).catch(() => {})

expect(handler, 'an unreachable server says nothing about the token').not.toHaveBeenCalled()
})
})

describe('client repositories call', () => {
// The module and the axios mock are loaded together, so the client sees the mock the test inspects.
let http
Expand Down
Loading
Loading