├── .gitignore ├── project.clj ├── LICENSE ├── test └── aws_simple_sign │ └── core_test.clj ├── README.md └── src └── aws_simple_sign └── core.clj /.gitignore: -------------------------------------------------------------------------------- 1 | # Clojure files and tools 2 | .calva 3 | .classpath 4 | .clj-kondo/.cache 5 | .cpcache 6 | .java-version 7 | .lein-* 8 | .lsp/.cache 9 | .lsp/sqlite.db 10 | .nrepl-history 11 | .nrepl-port 12 | .portal 13 | .project 14 | .socket-repl-port 15 | .sw* 16 | .vscode 17 | *.class 18 | *.jar 19 | *.swp 20 | *~ 21 | /checkouts 22 | /classes 23 | /target 24 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject dk.emcken/aws-simple-sign "2.3.0-SNAPSHOT" 2 | :description "A library to sign HTTP requests & generate presigned URL's for AWS" 3 | :url "https://github.com/jacobemcken/aws-simple-sign" 4 | :license {:name "The MIT License" 5 | :url "http://opensource.org/licenses/MIT"} 6 | :dependencies [[org.clojure/clojure "1.11.1"]] 7 | :profiles {:dev {:dependencies [[org.babashka/http-client "0.3.11"] 8 | [com.cognitect.aws/api "0.8.692"] 9 | [com.cognitect.aws/endpoints "1.1.12.701"] 10 | [com.cognitect.aws/s3 "868.2.1580.0"]]}}) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2025 Jacob Emcken 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | “Software”), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 19 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 20 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 21 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/aws_simple_sign/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns aws-simple-sign.core-test 2 | (:require [clojure.test :refer [deftest is testing]] 3 | [aws-simple-sign.core :as sut]) 4 | (:import (java.io ByteArrayInputStream))) 5 | 6 | (def credentials 7 | {:aws/access-key-id "AKIAIOSFODNN7EXAMPLE" 8 | :aws/secret-access-key "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}) 9 | 10 | ;; Testing example from: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html 11 | (deftest sign 12 | (is (= "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" 13 | (sut/signature credentials 14 | "/test.txt" 15 | {:timestamp "20130524T000000Z" 16 | :region "us-east-1" 17 | :service "s3" 18 | :scope "20130524/us-east-1/s3/aws4_request" 19 | :content-sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 20 | :signed-headers {"host" "examplebucket.s3.amazonaws.com" 21 | "range" "bytes=0-9" 22 | "x-amz-content-sha256" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 23 | "x-amz-date" "20130524T000000Z"}})))) 24 | 25 | (deftest canonical-request-generation 26 | (testing "Exact example from official documentation" 27 | (let [canonical-request (sut/canonical-request-str 28 | "/test.txt" 29 | {:signed-headers {"Host" "examplebucket.s3.amazonaws.com" 30 | "Range" "bytes=0-9" 31 | "x-amz-content-sha256" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 32 | "X-AMZ-date" "20130524T000000Z"} 33 | :content-sha256 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})] 34 | (is (= "GET 35 | /test.txt 36 | 37 | host:examplebucket.s3.amazonaws.com 38 | range:bytes=0-9 39 | x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 40 | x-amz-date:20130524T000000Z 41 | 42 | host;range;x-amz-content-sha256;x-amz-date 43 | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 44 | canonical-request)) 45 | (is (= "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972" 46 | (-> canonical-request 47 | (sut/hash-sha256) 48 | (sut/hex-encode-str)))))) 49 | 50 | (testing "Sorting of query params" 51 | (is (= "GET 52 | /test.txt 53 | marker=someMarker&max-keys=20&prefix=somePrefix 54 | host:examplebucket.s3.amazonaws.com 55 | 56 | host 57 | UNSIGNED-PAYLOAD" 58 | (sut/canonical-request-str 59 | "/test.txt" 60 | {:method :get 61 | :signed-headers {"Host" "examplebucket.s3.amazonaws.com"} 62 | :query-params {"prefix" "somePrefix" ;notice the unsorted order 63 | "marker" "someMarker" 64 | "max-keys" "20"}}))))) 65 | 66 | (deftest hashing-payloads 67 | (testing "hashing an empty payload" 68 | (is (= "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 69 | (sut/hash-input ""))) 70 | (is (= "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 71 | (sut/hash-input nil)))) 72 | (testing "hasing 'user@example.com'" 73 | ;; Example taken from: https://stackoverflow.com/questions/71042721/how-to-base64-encode-a-sha256-hex-character-string 74 | (is (= "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514" 75 | (sut/hash-input "user@example.com")))) 76 | (testing "hasing 'user@example.com'" 77 | ;; Example taken from: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html 78 | (is (= "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072" 79 | (sut/hash-input "Welcome to Amazon S3.")))) 80 | (testing "hasing a resetable InputStream" 81 | (is (= "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514" 82 | (sut/hash-input (ByteArrayInputStream. (.getBytes "user@example.com"))))))) 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-simple-sign 2 | 3 | A Clojure library for pre-signing S3 URLs and signing HTTP requests for AWS. 4 | The library only depends on Java core (no external Java dependencies), 5 | making it lightweight. 6 | 7 | [![bb compatible](https://raw.githubusercontent.com/babashka/babashka/master/logo/badge.svg)](https://book.babashka.org#badges) 8 | 9 | If you stumble upon problems, 10 | feel free to reach out either by creating an issue 11 | or ping me via Clojurian Slack. 12 | 13 | 14 | ## Usage 15 | 16 | Include the dependency in your project: 17 | 18 | [![Clojars Project](https://img.shields.io/clojars/v/dk.emcken/aws-simple-sign.svg?include_prereleases)](https://clojars.org/dk.emcken/aws-simple-sign) 19 | 20 | For general usage with small examples see below. 21 | For a more in depth tutorial about using S3 (including presigned URLs), 22 | checkout the blog post [Local S3 storage with MinIO for your Clojure dev environment][5]. 23 | 24 | 25 | ### AWS Credentials 26 | 27 | The library needs "client" information containing credentials etc. 28 | 29 | Both [Cognitect AWS API client][1] and [awyeah][2] can produce compatible clients. 30 | These clients will look for credentials in all the usual places 31 | honoring how [AWS specific environment variables][3] and configuration, 32 | except for endpoint which needs to be provided in code (see `:endpoint-override` below). 33 | 34 | > 💡 Only `awyeah-api` works with [Babashka][4] at the time of writing. 35 | 36 | The following example uses the `awyeah-api` lib. 37 | ```clojure 38 | (require '[com.grzm.awyeah.client.api :as aws]) 39 | 40 | (def client 41 | (aws/client {:api :s3 42 | ;; :endpoint-override is commented out 43 | ;; and usually only relevant for non-Amazon or local setups 44 | #_#_:endpoint-override {:protocol :http 45 | :hostname "localhost" 46 | :port 9000}})) 47 | ``` 48 | 49 | Alternatively, the same data structure can be provided manually: 50 | 51 | ```clojure 52 | (def client 53 | {:credentials #:aws{:access-key-id "some-access-key" 54 | :secret-access-key "wild_secr3t" 55 | :session-token "FwoG..."} 56 | :region "us-east-1" 57 | :endpoint {:protocol :https 58 | :hostname "s3.amazonaws.com"}}) 59 | ``` 60 | 61 | 62 | ### Presigned URL's 63 | 64 | To generate a pre-signed URL for a S3 object: 65 | 66 | ```clojure 67 | (require '[aws-simple-sign.core :as aws]) 68 | 69 | (aws/generate-presigned-url client "somebucket" "someobject.txt" {}) 70 | "https://somebucket.s3.us-east-1.amazonaws.com/someobject.txt?X-Amz-Security-Token=FwoG..." 71 | ``` 72 | 73 | By default, the URLs returned will use "virtual hosted-style". 74 | But having an S3 bucket with dots (`.`) in the name, the SSL certificate cannot be verified. 75 | This can cause many types of errors depending on the code consuming the URL, but one could be: 76 | 77 | > No subject alternative DNS name matching 78 | 79 | To avoid this problem, it is possible to generate URLs using "path style" instead. 80 | This, of course, has its own disadvantages 81 | but can be a way out when it is impossible to rename the bucket. 82 | 83 | ```clojure 84 | (aws/generate-presigned-url client "somebucket" "someobject.txt" {:path-style true}) 85 | "https://s3.us-east-1.amazonaws.com/somebucket/someobject.txt?X-Amz-Security-Token=FwoG..." 86 | ``` 87 | 88 | For more information about "virtual hosted vs. path style" in the official announcements: 89 | - 08 MAY 2019 https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ 90 | - 22 SEP 2020 https://aws.amazon.com/blogs/storage/update-to-amazon-s3-path-deprecation-plan/ 91 | 92 | 93 | ### Signed HTTP requests 94 | 95 | The following example illustrates how signing can be used from within a Babashka script: 96 | 97 | ```clojure 98 | (require '[aws-simple-sign.core :as aws]) 99 | (require '[babashka.http-client :as http]) 100 | 101 | (let [request {:url "https://someurl/some-api-endpoint" 102 | :method :post 103 | :headers {"accept" "application/json"} 104 | :body "{\"somekey\": \"with some value\"}"} 105 | signed-request (aws/sign-request client request {:region "us-west-1"})] 106 | 107 | (http/post (:url signed-request) 108 | (-> signed-request 109 | (select-keys [:body :headers])))) 110 | ``` 111 | 112 | #### Payload hash 113 | 114 | If the request `body` is a `String`, a payload hash is calculated automatically, 115 | while non-`String` bodies will leave the payload unsigned. 116 | If the body data is an `InputStream` with text, 117 | consider converting it to a string prior calling `sign-request` 118 | to avoid the "consumeable once" problem. 119 | (Not recommended for binary data and large amounts of data): 120 | 121 | ```clojure 122 | (aws/sign-request client (update request :body slurp) {}) 123 | ``` 124 | 125 | Alternatively, provide the payload-hash manually: 126 | ```clojure 127 | (aws/sign-request client request {:payload-hash "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514"}) 128 | 129 | ; or calculate from InputStream - remember the stream will be consumed (not reuseable) 130 | (aws/sign-request client request {:payload-hash (aws/hash-input some-input-stream)}) 131 | ``` 132 | 133 | 134 | [1]: https://github.com/cognitect-labs/aws-api 135 | [2]: https://github.com/grzm/awyeah-api 136 | [3]: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html 137 | [4]: https://github.com/babashka/babashka 138 | [5]: https://www.emcken.dk/programming/2025/04/21/local-s3-storage-with-minio-for-clojure-dev-env/ -------------------------------------------------------------------------------- /src/aws_simple_sign/core.clj: -------------------------------------------------------------------------------- 1 | (ns aws-simple-sign.core 2 | "Relevant AWS documentation: 3 | 4 | - https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html 5 | - https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html 6 | 7 | When the documentation references a client it is either a [awyeah][1] client, 8 | a [Cognitect AWS API][2] client or a map with the following structure: 9 | 10 | {:credentials #:aws{:access-key-id \"some-access-key\" 11 | :secret-access-key \"wild_secr3t\" 12 | :session-token \"FwoG...\"} 13 | :region \"us-east-1\" 14 | :endpoint {:protocol :https 15 | :hostname \"s3.amazonaws.com\"}} 16 | 17 | Notice: `:endpoint` is optional. 18 | 19 | [1]: https://github.com/grzm/awyeah-api 20 | [2]: https://github.com/cognitect-labs/aws-api" 21 | (:require [clojure.string :as str]) 22 | (:import [java.io InputStream] 23 | [java.net URL] 24 | [java.time ZoneId ZoneOffset] 25 | [java.time.format DateTimeFormatter] 26 | [java.security MessageDigest] 27 | (java.util Date) 28 | (javax.crypto Mac) 29 | (javax.crypto.spec SecretKeySpec))) 30 | 31 | (defmulti hash-sha256 32 | "Takes input like String or InputStream and returns a SHA256 hash." 33 | (fn [input] 34 | (cond 35 | (instance? java.io.InputStream input) 36 | :input-stream 37 | 38 | (= String (type input)) 39 | :string))) 40 | 41 | (defmethod hash-sha256 :string 42 | [^String input] 43 | (let [hash (MessageDigest/getInstance "SHA-256")] 44 | (.update hash (.getBytes input)) 45 | (.digest hash))) 46 | 47 | (defmethod hash-sha256 :input-stream 48 | [^InputStream input] 49 | (let [hash (MessageDigest/getInstance "SHA-256") 50 | buffer (byte-array 8192)] 51 | (loop [] 52 | (let [n (.read input buffer)] 53 | (when (pos? n) 54 | (.update hash buffer 0 n) 55 | (recur)))) 56 | (.digest hash))) 57 | 58 | (defmethod hash-sha256 :default 59 | [input] 60 | (throw (ex-info "Unsupported input for calculating hash. Use String or InputStream." 61 | {:input-type (str (type input))}))) 62 | 63 | (def ^:no-doc digits 64 | (char-array "0123456789abcdef")) 65 | 66 | (defn ^:no-doc hex-encode 67 | [bytes] 68 | (->> bytes 69 | (mapcat #(list (get digits (bit-shift-right (bit-and 0xF0 %) 4)) 70 | (get digits (bit-and 0x0F %)))))) 71 | 72 | (defn ^:no-doc hex-encode-str 73 | [bytes] 74 | (->> bytes 75 | (hex-encode) 76 | (apply str))) 77 | 78 | ;; Clojure implementation of signature 79 | ;; https://gist.github.com/souenzzo/21f3e81b899ba3f04d5f8858b4ecc2e9 80 | ;; https://github.com/joseferben/clj-aws-sign/ (ring middelware) 81 | 82 | (defn ^:no-doc hmac-sha-256 83 | [key ^String data] 84 | (let [algo "HmacSHA256" 85 | mac (Mac/getInstance algo)] 86 | (.init mac (SecretKeySpec. key algo)) 87 | (.doFinal mac (.getBytes data "UTF-8")))) 88 | 89 | (defn ^:no-doc char-range 90 | [start end] 91 | (map char (range (int start) (inc (int end))))) 92 | 93 | (def ^:no-doc unreserved-chars 94 | (->> (concat '(\- \. \_ \~) 95 | (char-range \A \Z) 96 | (char-range \a \z) 97 | (char-range \0 \9)) 98 | (into #{}))) 99 | 100 | (def ^:no-doc url-unreserved-chars 101 | (conj unreserved-chars \/)) 102 | 103 | (defn ^:no-doc encode 104 | [skip-chars c] 105 | (if (skip-chars c) 106 | c 107 | (let [byte-val (int c)] 108 | (format "%%%X" byte-val)))) 109 | 110 | (defn ^:no-doc uri-encode 111 | [skip-chars uri] 112 | (->> uri 113 | (map (partial encode skip-chars)) 114 | (apply str))) 115 | 116 | (def ^DateTimeFormatter ^:no-doc formatter 117 | (-> (DateTimeFormatter/ofPattern "yyyyMMdd'T'HHmmss'Z'") 118 | (.withZone (ZoneId/from ZoneOffset/UTC)))) 119 | 120 | (defn ^:no-doc compute-signature 121 | [{:keys [credentials str-to-sign region service short-date]}] 122 | (-> (str "AWS4" (:aws/secret-access-key credentials)) 123 | (.getBytes) 124 | (hmac-sha-256 short-date) 125 | (hmac-sha-256 region) 126 | (hmac-sha-256 service) 127 | (hmac-sha-256 "aws4_request") 128 | (hmac-sha-256 str-to-sign) 129 | hex-encode-str)) 130 | 131 | (def ^:no-doc algorithm 132 | "AWS4-HMAC-SHA256") 133 | 134 | (defn ^:no-doc ->query-str 135 | [query-params] 136 | (->> query-params 137 | (map (fn [[k v]] [(uri-encode unreserved-chars k) (uri-encode unreserved-chars v)])) 138 | (into (sorted-map)) ; sort AFTER URL encoding 139 | (map (fn [[k v]] (str k "=" v))) 140 | (str/join "&"))) 141 | 142 | (defn ^:no-doc ->headers-str 143 | [headers] 144 | (->> headers 145 | (map (fn [[k v]] (str k ":" (some-> v str/trim) "\n"))) 146 | (apply str))) 147 | 148 | (defn canonical-request-str 149 | "Generates a canonical request string as specified here: 150 | https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#canonical-request" 151 | [canonical-url {:keys [content-sha256 method query-params signed-headers] :as _opts}] 152 | (let [sorted-signed-headers (->> signed-headers 153 | (map (fn [[k v]] [(str/lower-case k) v])) 154 | (into (sorted-map)))] 155 | (str (-> (or method :get) name str/upper-case) "\n" 156 | (uri-encode url-unreserved-chars canonical-url) "\n" 157 | (->query-str query-params) "\n" 158 | (->headers-str sorted-signed-headers) "\n" 159 | (str/join ";" (map key sorted-signed-headers)) "\n" 160 | (or content-sha256 "UNSIGNED-PAYLOAD")))) 161 | 162 | (defn signature 163 | "AWS specification: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html 164 | 165 | Inspired by https://gist.github.com/souenzzo/21f3e81b899ba3f04d5f8858b4ecc2e9" 166 | [credentials canonical-url {:keys [scope timestamp region service] :as opts}] 167 | (let [canonical-request (canonical-request-str canonical-url (select-keys opts [:content-sha256 :method :query-params :signed-headers])) 168 | str-to-sign (str algorithm "\n" 169 | timestamp "\n" 170 | scope "\n" 171 | (hex-encode-str (hash-sha256 canonical-request)))] 172 | (compute-signature {:credentials credentials 173 | :str-to-sign str-to-sign 174 | :region region 175 | :service service 176 | :short-date (subs timestamp 0 8)}))) 177 | 178 | (defn hash-input 179 | "Takes input as either `String` or `InputStream` 180 | to calculate and return a hash." 181 | [payload] 182 | (some-> (or payload "") 183 | (hash-sha256) 184 | (hex-encode-str))) 185 | 186 | (defn ^:no-doc get-query-params 187 | [params-str] 188 | (when (seq params-str) 189 | (->> (str/split params-str #"&") 190 | (map (fn [param] 191 | (let [[k v] (str/split param #"=" 2)] 192 | ;; ensure vector with exactly 2 elements (key/value) for `into` to work 193 | [k v]))) 194 | (into (sorted-map))))) 195 | 196 | (defn sign-request 197 | "Takes a client and a Ring style request map. 198 | Returns an enriched Ring style map with the required headers 199 | needed for AWS signing." 200 | ([client {:keys [body headers method url] :as request} 201 | {:keys [ref-time region service payload-hash] 202 | :or {region (:region client) 203 | service "execute-api" 204 | ref-time (Date.)} 205 | :as _opts}] 206 | (let [credentials (:credentials client) 207 | url-obj (URL. url) 208 | port (.getPort url-obj) 209 | host (cond-> (.getHost url-obj) 210 | (pos? port) (str ":" port)) 211 | timestamp (.format formatter (.toInstant ^Date ref-time)) 212 | scope (str (subs timestamp 0 8) "/" region "/" service "/aws4_request") 213 | content-sha256 (or payload-hash 214 | (when (string? body) ; protect against consuming InputStreams which can only be consumed once. 215 | (hash-input body)) 216 | "UNSIGNED-PAYLOAD") 217 | signed-headers (-> headers 218 | (assoc "Host" host 219 | "x-amz-content-sha256" content-sha256 220 | "x-amz-date" timestamp 221 | "x-amz-security-token" (:aws/session-token credentials))) 222 | signature-str (signature credentials 223 | (.getPath url-obj) 224 | {:scope scope 225 | :timestamp timestamp 226 | :region region 227 | :service service 228 | :method method 229 | :signed-headers signed-headers 230 | :query-params (get-query-params (.getQuery url-obj)) 231 | :content-sha256 content-sha256})] 232 | (-> request 233 | (assoc :headers (dissoc signed-headers "Host")) ; overwrite headers to include necessary x-amz-* ones 234 | (update :headers assoc 235 | "Authorization" (str algorithm " Credential=" (:aws/access-key-id credentials) "/" scope ", " 236 | "SignedHeaders=" (str/join ";" (map key signed-headers)) ", " 237 | "Signature=" signature-str)))))) 238 | 239 | (defn presign 240 | "Take an URL for a S3 object and returns a string with a presigned URL 241 | for that particular object. 242 | Takes the following options (a map) as the last argument, 243 | the map value shows the default values: 244 | 245 | {:ref-time (java.util.Date.) ; timestamp incorporated into the signature 246 | :expires \"3600\" ; signature expires x seconds after ref-time 247 | :region \"us-east-1\" ; signature locked to AWS region 248 | :method :get} ; http method the url is to be called with 249 | 250 | By default credentials are read from standard AWS location." 251 | ([credentials url] 252 | (presign credentials url {})) 253 | ([credentials url {:keys [ref-time region expires method] 254 | :or {ref-time (Date.) region "us-east-1" expires "3600"}}] 255 | (let [url-obj (URL. url) 256 | port (.getPort url-obj) 257 | host (cond-> (.getHost url-obj) 258 | (pos? port) (str ":" port)) 259 | service "s3" 260 | timestamp (.format formatter (.toInstant ^Date ref-time)) 261 | scope (str (subs timestamp 0 8) "/" region "/" service "/aws4_request") 262 | query-params (conj {"X-Amz-Algorithm" algorithm 263 | "X-Amz-Credential" (str (:aws/access-key-id credentials) "/" scope) 264 | "X-Amz-Date" timestamp 265 | "X-Amz-SignedHeaders" "host"} 266 | (when-let [session-token (:aws/session-token credentials)] 267 | ["X-Amz-Security-Token" session-token]) 268 | (when expires 269 | ["X-Amz-Expires" expires])) 270 | signature (signature credentials 271 | (.getPath url-obj) 272 | {:timestamp timestamp 273 | :region region 274 | :service service 275 | :scope scope 276 | :method method 277 | :query-params query-params 278 | :signed-headers {"host" host}})] 279 | (str (.getProtocol url-obj) "://" host (.getPath url-obj) "?" 280 | (->query-str query-params) 281 | "&X-Amz-Signature=" signature)))) 282 | 283 | (defn ^:no-doc construct-endpoint-str 284 | "Helper function to deal with the endpoints data structure from Cognitect client 285 | which can be quite confusing." 286 | ;; to keyword :protocol (singular) and :port only seems to exist 287 | ;; when :endpoint-override is used to set up the client 288 | ;; Also, :protocol is a keyword while :protocols contain a vector of strings 289 | ;; On top there seems to be a region on both the client (root) and inside endpoint 290 | [{:keys [hostname protocols protocol region port] :as _endpoint}] 291 | (str (or (when protocol (name protocol)) 292 | (-> protocols sort last)) ; sort to prefer https 293 | "://" (if (= "s3.amazonaws.com" hostname) 294 | (str/replace hostname #"^s3\." (str "s3." region ".")) 295 | (str hostname (when port (str ":" port)))) 296 | "/")) 297 | 298 | (defn generate-presigned-url 299 | "Takes client, bucket name, object key and an options map 300 | with the following default values: 301 | 302 | {:path-style false ; path-style is the 'old way' of URL's 303 | :endpoint nil} ; alternative endpoint eg. \"http://localhost:9000\" 304 | 305 | The options map is 'forwarded' to `presign`, 306 | see that function for more relevant options. 307 | Returns a presigned URL." 308 | [client bucket object-key {:keys [endpoint path-style region] :as opts}] 309 | (let [endpoint-str (or endpoint 310 | (construct-endpoint-str (:endpoint client))) 311 | url (-> (if path-style 312 | (str endpoint-str bucket "/") 313 | (str/replace endpoint-str #"://" (str "://" bucket "."))) 314 | (str object-key))] 315 | (presign (:credentials client) url (assoc opts :region (or region (:region client)))))) 316 | --------------------------------------------------------------------------------