mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(blossom): bring read-auth tokens into line with BUD-11
Two deviations from BUD-11, both predating the read-auth rework and both carried forward by it. The `x` tag defeated the per-host token cache. BUD-11 lists `x` as optional for `GET /<sha256>`, but its Tag scoping rule is strict about what including one means: "When `x` tags are present, the token is only valid for operations on the specified blob hashes." Tokens are cached per host and replayed for every blob on it, so from the second image onward we were sending a token scoped to some other blob's hash. The old comment had the reasoning backwards — it kept `x` "for servers that check it", which is precisely the case that rejects a reused token. createGetAuth now takes a nullable hash, and the read-auth path passes null: the `server` tag alone scopes the token, which is what makes reuse legitimate. That widens the grant from one blob to any blob on the host for the token's hour, which is the inherent price of caching and is the shape BUD-11 sanctions. The token encoding was standard Base64. BUD-11: "MUST be encoded as Base64 URL-safe without padding (Base64url, as used by JWTs)". In practice the alphabets coincide — a token's JSON is printable ASCII and a sextet only reaches 62/63 when the third byte of its group is `>`, `~`, `?` or DEL, so `+` and `/` never appeared across 600 sampled tokens — but padding did, on 52% of them. NIP-98's encoder is deliberately left alone; it specifies no variant. Nothing in the tree decodes a Blossom auth header, so the encoding change is client-side only. Tests pin both rules at the event level and end-to-end on the token this path actually mints, with several content lengths for the padding case since whether padding appears depends on the JSON length mod 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
This commit is contained in:
+4
-2
@@ -59,8 +59,10 @@ class BlossomReadAuthFetcher(
|
||||
if (e.response.code != HTTP_UNAUTHORIZED) throw e
|
||||
|
||||
val httpUrl = url.toHttpUrlOrNull() ?: throw e
|
||||
val sha256 = BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
|
||||
val header = auth.header(httpUrl.host, sha256) ?: throw e
|
||||
// Gate only: read-auth applies to Blossom blob URLs, but the token is
|
||||
// scoped to the host (BUD-11 `server` tag) and carries no `x` tag.
|
||||
BlossomReadAuthInterceptor.blossomHashOrNull(httpUrl.encodedPath) ?: throw e
|
||||
val header = auth.header(httpUrl.host) ?: throw e
|
||||
|
||||
return build(header).fetch()
|
||||
}
|
||||
|
||||
+5
-3
@@ -69,7 +69,7 @@ class BlossomReadAuthInterceptor(
|
||||
/** Pure cache read — must not sign, must not block. */
|
||||
private val cachedHeaderProvider: (host: String) -> String?,
|
||||
/** Fire-and-forget: starts a signature for a host we just learned is gated. */
|
||||
private val onAuthRequired: (host: String, sha256: HexKey) -> Unit,
|
||||
private val onAuthRequired: (host: String) -> Unit,
|
||||
) : Interceptor {
|
||||
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
|
||||
// follows a handful of auth-gated servers at most) and shared across all
|
||||
@@ -86,7 +86,9 @@ class BlossomReadAuthInterceptor(
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
// The hash is a gate, not an input: read-auth applies only to Blossom
|
||||
// blob URLs. The token itself is host-scoped and carries no `x` tag.
|
||||
blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
|
||||
val host = request.url.host
|
||||
|
||||
// Known-gated host: skip the anonymous probe and sign the first attempt.
|
||||
@@ -107,7 +109,7 @@ class BlossomReadAuthInterceptor(
|
||||
// Start the signature but do not wait for it: this thread holds a
|
||||
// per-host dispatcher slot. BlossomReadAuthFetcher performs the signed
|
||||
// retry for this very request from a coroutine.
|
||||
onAuthRequired(host, sha256)
|
||||
onAuthRequired(host)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
+21
-21
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -48,11 +47,19 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* [inFlight] is what collapses them; the leader signs and every follower awaits
|
||||
* the same [CompletableDeferred].
|
||||
*
|
||||
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
|
||||
* grants reads for every blob on the host (thumbnails included), so one signed
|
||||
* event covers a whole feed's worth of images from an auth-gated host for the
|
||||
* life of the token. The blob hash of the request that first triggered signing
|
||||
* is still included as the `x` tag for BUD-01 servers that check it.
|
||||
* Tokens are cached per host, not per blob, and are therefore minted with a
|
||||
* BUD-11 `server` tag and **no** `x` tag. BUD-11 lists `x` as optional for
|
||||
* `GET /<sha256>` but is strict about what including one means: "When `x` tags
|
||||
* are present, the token is only valid for operations on the specified blob
|
||||
* hashes." A token carrying the hash of whichever blob happened to trigger
|
||||
* signing would therefore be invalid for every other blob it was reused for.
|
||||
* Server-scoped and hash-free, one signed event legitimately covers a whole
|
||||
* feed's worth of images from the host for the life of the token.
|
||||
*
|
||||
* The tradeoff that buys: the token authorizes reading any blob on that host
|
||||
* until it expires, rather than one. It is only ever sent to that host, over
|
||||
* TLS, and BUD-11 sanctions the shape — but it is a wider grant than a
|
||||
* per-blob token, which is the price of caching at all.
|
||||
*/
|
||||
class BlossomReadAuthTokenProvider(
|
||||
private val signerProvider: () -> NostrSigner?,
|
||||
@@ -78,12 +85,9 @@ class BlossomReadAuthTokenProvider(
|
||||
* blocking, so the caller must already be in a coroutine — on the image path
|
||||
* that is Coil's `Fetcher.fetch()`.
|
||||
*/
|
||||
suspend fun header(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): String? {
|
||||
suspend fun header(host: String): String? {
|
||||
cachedHeader(host)?.let { return it }
|
||||
return signOnce(host, sha256)?.await()
|
||||
return signOnce(host)?.await()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,12 +95,9 @@ class BlossomReadAuthTokenProvider(
|
||||
* cannot suspend (the interceptor) and only need the token to exist by the
|
||||
* time some later request needs it.
|
||||
*/
|
||||
fun warm(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
) {
|
||||
fun warm(host: String) {
|
||||
if (cachedHeader(host) != null) return
|
||||
signOnce(host, sha256)
|
||||
signOnce(host)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,10 +109,7 @@ class BlossomReadAuthTokenProvider(
|
||||
* that finishes immediately would run that removal *inside* the mapping
|
||||
* function, which `ConcurrentHashMap` forbids.
|
||||
*/
|
||||
private fun signOnce(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
): CompletableDeferred<String?>? {
|
||||
private fun signOnce(host: String): CompletableDeferred<String?>? {
|
||||
inFlight[host]?.let { return it }
|
||||
|
||||
val signer = signerProvider() ?: return null
|
||||
@@ -125,7 +123,9 @@ class BlossomReadAuthTokenProvider(
|
||||
try {
|
||||
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
|
||||
BlossomAuth.createGetAuth(
|
||||
hash = sha256,
|
||||
// No `x` tag: this token is reused for every blob
|
||||
// on the host. See the class kdoc.
|
||||
hash = null,
|
||||
alt = "Downloading media from $host",
|
||||
signer = signer,
|
||||
servers = listOf(host),
|
||||
|
||||
+6
-10
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -91,7 +90,7 @@ class BlossomReadAuthInterceptorTest {
|
||||
assertEquals("the 401 is surfaced for the fetcher to retry", 401, response.code)
|
||||
assertEquals("the interceptor must not retry itself", 1, chain.requests.size)
|
||||
assertNull("the only attempt is anonymous", chain.requests[0].header("Authorization"))
|
||||
assertEquals(host to sha, provider.warmed.single())
|
||||
assertEquals(host, provider.warmed.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@@ -102,7 +101,7 @@ class BlossomReadAuthInterceptorTest {
|
||||
|
||||
provider.interceptor().intercept(chain.asChain()).close()
|
||||
|
||||
assertEquals(sha, provider.warmed.single().second)
|
||||
assertEquals(host, provider.warmed.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -222,7 +221,7 @@ class BlossomReadAuthInterceptorTest {
|
||||
val blockingScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
val blockingProbe = BlossomReadAuthTokenProvider({ DelayingTestSigner(delayMs = SIGN_MS) }, blockingScope)
|
||||
val beforeAt = System.nanoTime()
|
||||
runBlocking { blockingProbe.header(host, sha) }
|
||||
runBlocking { blockingProbe.header(host) }
|
||||
val beforeMs = (System.nanoTime() - beforeAt) / 1_000_000
|
||||
blockingScope.cancel()
|
||||
|
||||
@@ -265,15 +264,12 @@ class BlossomReadAuthInterceptorTest {
|
||||
private class RecordingProvider(
|
||||
var cached: String?,
|
||||
) {
|
||||
val warmed = mutableListOf<Pair<String, HexKey>>()
|
||||
val warmed = mutableListOf<String>()
|
||||
|
||||
fun cachedHeader(host: String): String? = cached
|
||||
|
||||
fun warm(
|
||||
host: String,
|
||||
sha256: HexKey,
|
||||
) {
|
||||
warmed.add(host to sha256)
|
||||
fun warm(host: String) {
|
||||
warmed.add(host)
|
||||
}
|
||||
|
||||
fun interceptor() = BlossomReadAuthInterceptor(::cachedHeader, ::warm)
|
||||
|
||||
+42
-11
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -53,7 +55,7 @@ class BlossomReadAuthTokenProviderTest {
|
||||
fun signsAndFormatsHeader() =
|
||||
runBlocking {
|
||||
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
|
||||
val header = provider.header(host, sha)
|
||||
val header = provider.header(host)
|
||||
assertTrue("expected a Nostr auth header, got $header", header!!.startsWith("Nostr "))
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ class BlossomReadAuthTokenProviderTest {
|
||||
fun returnsNullWhenNoSigner() =
|
||||
runBlocking {
|
||||
val provider = BlossomReadAuthTokenProvider({ null }, scope)
|
||||
assertNull(provider.header(host, sha))
|
||||
assertNull(provider.header(host))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,7 +80,7 @@ class BlossomReadAuthTokenProviderTest {
|
||||
assertNull(provider.cachedHeader(host))
|
||||
assertEquals(0, lookups)
|
||||
|
||||
val minted = provider.header(host, sha)
|
||||
val minted = provider.header(host)
|
||||
assertEquals(minted, provider.cachedHeader(host))
|
||||
}
|
||||
|
||||
@@ -92,8 +94,8 @@ class BlossomReadAuthTokenProviderTest {
|
||||
signer
|
||||
}, scope, clock = { 0L })
|
||||
|
||||
val first = provider.header(host, sha)
|
||||
val second = provider.header(host, sha)
|
||||
val first = provider.header(host)
|
||||
val second = provider.header(host)
|
||||
|
||||
assertEquals("second call must be served from cache", first, second)
|
||||
assertEquals("signer must be resolved only once for the same host", 1, lookups)
|
||||
@@ -103,8 +105,8 @@ class BlossomReadAuthTokenProviderTest {
|
||||
fun differentHostSignsSeparately() =
|
||||
runBlocking {
|
||||
val provider = BlossomReadAuthTokenProvider({ signer }, scope, clock = { 0L })
|
||||
val a = provider.header(host, sha)
|
||||
val b = provider.header("other.example.com", sha)
|
||||
val a = provider.header(host)
|
||||
val b = provider.header("other.example.com")
|
||||
assertNotEquals(a, b)
|
||||
}
|
||||
|
||||
@@ -114,10 +116,10 @@ class BlossomReadAuthTokenProviderTest {
|
||||
var now = 0L
|
||||
val provider = BlossomReadAuthTokenProvider({ signer }, scope, clock = { now })
|
||||
|
||||
val first = provider.header(host, sha)
|
||||
val first = provider.header(host)
|
||||
now += 56L * 60L * 1000L
|
||||
assertNull("token must be gone from the pure read once expired", provider.cachedHeader(host))
|
||||
val second = provider.header(host, sha)
|
||||
val second = provider.header(host)
|
||||
|
||||
assertNotEquals("an expired token must be re-signed", first, second)
|
||||
}
|
||||
@@ -127,7 +129,7 @@ class BlossomReadAuthTokenProviderTest {
|
||||
runBlocking {
|
||||
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
|
||||
|
||||
provider.warm(host, sha)
|
||||
provider.warm(host)
|
||||
|
||||
// warm() returns immediately; the token lands shortly after.
|
||||
withTimeout(5_000) {
|
||||
@@ -138,6 +140,35 @@ class BlossomReadAuthTokenProviderTest {
|
||||
assertTrue(provider.cachedHeader(host)!!.startsWith("Nostr "))
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end BUD-11 check on the token this path actually mints: reused
|
||||
* across every blob on the host, so it must be `server`-scoped and carry no
|
||||
* `x` tag ("When `x` tags are present, the token is only valid for
|
||||
* operations on the specified blob hashes"), and be Base64url without
|
||||
* padding.
|
||||
*/
|
||||
@Test
|
||||
fun mintedTokenIsAReusableBud11GetToken() =
|
||||
runBlocking {
|
||||
val provider = BlossomReadAuthTokenProvider({ signer }, scope)
|
||||
|
||||
val token =
|
||||
provider.header(host)!!.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
|
||||
assertTrue("token must be base64url without padding, got: $token", token.none { it == '=' || it == '+' || it == '/' })
|
||||
|
||||
val event = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString()
|
||||
val parsed = JacksonMapper.fromJson(event) as BlossomAuthorizationEvent
|
||||
|
||||
assertEquals(BlossomAuthorizationEvent.KIND, parsed.kind)
|
||||
assertEquals("get", parsed.tags.first { it[0] == "t" }[1])
|
||||
assertEquals(host, parsed.tags.first { it[0] == "server" }[1])
|
||||
assertTrue("a host-cached token must not be blob-scoped", parsed.tags.none { it[0] == "x" })
|
||||
assertTrue(
|
||||
"BUD-11 requires an expiration in the future",
|
||||
parsed.tags.first { it[0] == "expiration" }[1].toLong() > parsed.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-flight guarantee. Before it existed the token cache was only
|
||||
* populated *after* a signature returned, so a cold burst of N images from a
|
||||
@@ -153,7 +184,7 @@ class BlossomReadAuthTokenProviderTest {
|
||||
val startedAt = System.nanoTime()
|
||||
val results =
|
||||
(1..CONCURRENT_CALLERS)
|
||||
.map { async(Dispatchers.Default) { provider.header(host, sha) } }
|
||||
.map { async(Dispatchers.Default) { provider.header(host) } }
|
||||
.awaitAll()
|
||||
val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000
|
||||
|
||||
|
||||
+10
-6
@@ -26,14 +26,18 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
|
||||
|
||||
object BlossomAuth {
|
||||
/**
|
||||
* BUD-01 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's
|
||||
* private media relay) require this on `GET /<sha256>`. The [servers] list
|
||||
* adds BUD-11 `server` tags so a single token can be scoped to a whole host
|
||||
* (which also covers derived blobs like `.thumb.jpg` whose hash differs
|
||||
* from [hash]).
|
||||
* BUD-11 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's
|
||||
* private media relay) require this on `GET /<sha256>`.
|
||||
*
|
||||
* [servers] adds BUD-11 `server` tags, scoping the token to those hosts.
|
||||
* [hash] adds an `x` tag, scoping it to that one blob — pass null to leave
|
||||
* it off, which is what makes a token reusable for every blob on the host
|
||||
* (derived blobs like `.thumb.jpg` included). BUD-11 allows either for
|
||||
* `GET`, but a token that carries `x` is valid *only* for that hash. See
|
||||
* [BlossomAuthorizationEvent.createGetAuth].
|
||||
*/
|
||||
suspend fun createGetAuth(
|
||||
hash: HexKey,
|
||||
hash: HexKey?,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
|
||||
+31
-8
@@ -36,25 +36,48 @@ class BlossomAuthorizationEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
/** Base64 of this event's JSON, as carried in the `Authorization` header value. */
|
||||
fun rawToken() = Base64.encode(toJson().encodeToByteArray())
|
||||
/**
|
||||
* This event's JSON as Base64url without padding, per BUD-11: "the
|
||||
* authorization token MUST be encoded as Base64 URL-safe without padding
|
||||
* (Base64url, as used by JWTs)".
|
||||
*
|
||||
* Deliberately NOT the same encoder as NIP-98's
|
||||
* [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.rawToken],
|
||||
* which stays on standard Base64 because NIP-98 does not specify a variant.
|
||||
* In practice the alphabets coincide here — a token's JSON is printable
|
||||
* ASCII, and a sextet can only reach 62/63 when the third byte of its group
|
||||
* is `>`, `~`, `?` or DEL — so the observable change is the dropped `=`.
|
||||
*/
|
||||
fun rawToken() = BASE64URL.encode(toJson().encodeToByteArray())
|
||||
|
||||
/**
|
||||
* The full `Authorization` header value for a BUD-01/BUD-02 request:
|
||||
* `Nostr <base64-event>`. Mirrors NIP-98's
|
||||
* [com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent.toAuthToken],
|
||||
* which Blossom auth reuses.
|
||||
* The full `Authorization` header value for a Blossom request:
|
||||
* `Nostr <base64url-event>` (BUD-11, HTTP Authorization Header).
|
||||
*/
|
||||
fun toAuthorizationHeader() = "$AUTH_HEADER_SCHEME${rawToken()}"
|
||||
|
||||
companion object {
|
||||
const val KIND = 24242
|
||||
|
||||
/** Scheme prefix for the `Authorization` header value (BUD-01). */
|
||||
/** Scheme prefix for the `Authorization` header value (BUD-11). */
|
||||
const val AUTH_HEADER_SCHEME = "Nostr "
|
||||
|
||||
/** BUD-11's required token encoding: URL-safe alphabet, no `=` padding. */
|
||||
val BASE64URL = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
|
||||
|
||||
/**
|
||||
* BUD-11 `t=get` read authorization.
|
||||
*
|
||||
* [hash] is optional because BUD-11 lists the `x` tag as *optional* for
|
||||
* `GET /<sha256>`, and its Tag scoping rule is strict about what adding
|
||||
* one means: "When `x` tags are present, the token is only valid for
|
||||
* operations on the specified blob hashes." So pass a hash only for a
|
||||
* token used to fetch that one blob; pass null for a token that will be
|
||||
* reused across blobs on a host, and let the `server` tag scope it.
|
||||
* A hash-scoped token replayed for a different blob is invalid.
|
||||
*/
|
||||
suspend fun createGetAuth(
|
||||
hash: HexKey,
|
||||
hash: HexKey?,
|
||||
alt: String,
|
||||
signer: NostrSigner,
|
||||
servers: List<String> = emptyList(),
|
||||
|
||||
+60
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nipB7Blossom
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
@@ -96,7 +95,66 @@ class BlossomAuthorizationEventTest {
|
||||
val header = event.toAuthorizationHeader()
|
||||
|
||||
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
|
||||
val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString()
|
||||
val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
|
||||
val decoded = BlossomAuthorizationEvent.BASE64URL.decode(token).decodeToString()
|
||||
assertEquals(event.toJson(), decoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-11: "the authorization token MUST be encoded as Base64 URL-safe
|
||||
* without padding (Base64url, as used by JWTs)". Padded standard Base64 was
|
||||
* what this produced before, so both halves are worth pinning. Several
|
||||
* lengths, because whether padding appears at all depends on the JSON
|
||||
* length mod 3 — a single sample passes by luck about half the time.
|
||||
*/
|
||||
@Test
|
||||
fun authorizationTokenIsBase64UrlWithoutPadding() =
|
||||
runTest {
|
||||
listOf("a", "List", "List blobs", "List all of the blobs", "List blobs \u00e1\u00e9\u00ed")
|
||||
.forEach { alt ->
|
||||
val header = BlossomAuthorizationEvent.createListAuth(signer, alt).toAuthorizationHeader()
|
||||
val token = header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)
|
||||
|
||||
assertTrue(token.none { it == '=' }, "padding must be absent for `$alt`, got: $token")
|
||||
assertTrue(
|
||||
token.none { it == '+' || it == '/' },
|
||||
"standard-alphabet chars must not appear for `$alt`, got: $token",
|
||||
)
|
||||
assertTrue(
|
||||
token.all { it.isLetterOrDigit() || it == '-' || it == '_' },
|
||||
"token must be base64url for `$alt`, got: $token",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BUD-11 lists `x` as optional for `GET /<sha256>`, and its Tag scoping rule
|
||||
* makes the omission load-bearing: "When `x` tags are present, the token is
|
||||
* only valid for operations on the specified blob hashes." A token cached
|
||||
* per host and reused across blobs must therefore carry no `x`.
|
||||
*/
|
||||
@Test
|
||||
fun getAuthOmitsTheBlobScopeWhenNoHashIsGiven() =
|
||||
runTest {
|
||||
val event =
|
||||
BlossomAuthorizationEvent.createGetAuth(
|
||||
hash = null,
|
||||
alt = "Downloading media from cdn.example.com",
|
||||
signer = signer,
|
||||
servers = listOf("https://cdn.example.com"),
|
||||
)
|
||||
|
||||
assertEquals("get", event.tags.first { it[0] == "t" }[1])
|
||||
assertTrue(event.tags.none { it[0] == "x" }, "a reusable get token must not be blob-scoped")
|
||||
assertEquals("cdn.example.com", event.tags.first { it[0] == "server" }[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAuthKeepsTheBlobScopeWhenAHashIsGiven() =
|
||||
runTest {
|
||||
val event = BlossomAuthorizationEvent.createGetAuth(hash, "Downloading one blob", signer)
|
||||
|
||||
assertEquals("get", event.tags.first { it[0] == "t" }[1])
|
||||
assertEquals(hash, event.tags.first { it[0] == "x" }[1])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user