I wanted to browse Azure Blob Storage from inside my IDE without alt-tabbing to a desktop app,
so I started writing a JetBrains plugin. First instinct was to grab azure-storage-blob and be
done in an afternoon.
Then I looked at what comes with it. Netty. Reactor. Jackson. In a service I wouldn't care —
it's my process. A plugin isn't my process. Everything I ship gets loaded next to whatever the
IDE and every other plugin already loaded, and I get to re-check all of it every time the IDE
updates.
And for what I actually needed — list containers, walk a prefix, read a file — it's like four
REST calls. So I wrote the auth myself. That part took a day longer than it should have, which
is really what this post is about.
The signature
Blob storage wants an HMAC-SHA256 over a canonical string. If that string is off by a single
newline you get a 403 with no hint about which part you got wrong. Ask me how I know.
private fun sign(
method: String,
date: String,
path: String,
query: Map<String, String>,
extraHeaders: Map<String, String>,
): String {
val canonHeaders = (extraHeaders + mapOf("x-ms-date" to date, "x-ms-version" to API_VERSION))
.entries
.filter { it.key.startsWith("x-ms-") }
.sortedBy { it.key.lowercase(Locale.US) }
.joinToString("") { "${it.key.lowercase(Locale.US)}:${it.value}\n" }
val canonResource = buildString {
append('/').append(account)
if (emulator) append('/').append(account) // yes, twice. more on this below
append(path)
query.entries.sortedBy { it.key }
.forEach { append('\n').append(it.key).append(':').append(it.value) }
}
val stringToSign = buildString {
append(method).append('\n')
repeat(11) { append('\n') }
append(canonHeaders)
append(canonResource)
}
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.toByteArray(UTF_8)))
}
That repeat(11) looks like nonsense until you know what it is. Those are Content-Encoding,
Content-Language, Content-Length, Content-MD5, Content-Type, Date, If-Modified-Since, If-Match,
If-None-Match, If-Unmodified-Since, Range. A read request fills in none of them, but the empty
lines still have to be there. I originally wrote 10 and spent a while convinced my key was wrong.
The other one that got me: the query values in the canonical resource are the decoded ones,
sorted by key, even though the URL you send has them encoded.
The emulator doubles your account name
This is the one I'd have paid money to know up front.
Real Azure puts the account in the hostname, so the resource path you sign is
/myaccount/container/blob. Azurite puts the account in the path instead, and the string you
sign becomes /myaccount/myaccount/container/blob.
One if. But before I found it, every single request against the emulator came back 403, and a
403 from Blob storage doesn't tell you whether it's the key, the clock, or the string.
Listing is XML and you want it one level deep
GET /{container}?restype=container&comp=list&delimiter=/
BlobPrefix elements are your folders, Blob elements are the files at that level. There are no
real directories in blob storage — the delimiter is the only reason logs/2026-08-18/app.json
looks like a tree at all.
Keep the delimiter. If you drop it to "just get everything", a container with a few million blobs
under one prefix will page at you until you give up.
I parse it with the JDK's DocumentBuilder, with entities turned off, because this is a response
from the network:
val f = DocumentBuilderFactory.newInstance().apply {
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
isXIncludeAware = false
isExpandEntityReferences = false
}
You can build the whole thing without an Azure account
Azurite is Microsoft's own local emulator for Blob, Queue and Table. No subscription, no card:
npm install azurite
npx azurite-blob --location ./azurite-data --blobPort 10000
Use devstoreaccount1 and the development key that's printed in the Azurite docs — it's public,
it's not a secret. Same protocol as the real service, so whatever you get working here works
there. My entire test suite runs against it.
Two things that wasted my time: the npm package is azurite, not azurite-blob (that binary
shows up after you install), and container names have to be at least 3 characters. I named a
test container qa and got back OutOfRangeInput with a 400, which is a very unhelpful way of
saying "your name is too short".
Was it worth it
The plugin ships with zero third-party dependencies. One jar, 66 KB. Nothing to collide with,
nothing extra loading when the IDE starts, no dependency bumps to chase.
I wouldn't do this for everything though. This is a read path. If you're uploading at scale, or
you need Entra ID instead of an account key, or you want lifecycle management, just use the SDK —
you'd be reimplementing a lot for no reason.
One more thing since it's a credential: an account key is full access to that storage account.
In a plugin that means the IDE's password safe, not a settings file in the project that someone
commits by accident. I've seen that go badly.
I build JetBrains plugins as sellerkit — this came out of writing
Azure Blob Browser. None of the
above needs it, it's just where the bruises came from.
Top comments (0)