From ff18febc5b75cd01ad220a6e1c112d7084288a96 Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Fri, 20 Mar 2026 16:03:08 -0700 Subject: [PATCH 1/5] use totp for magic codes --- .../migrations/100_totp_magic_code.down.sql | 2 + .../migrations/100_totp_magic_code.up.sql | 2 + server/src/instant/admin/routes.clj | 2 +- server/src/instant/flags.clj | 26 +++++ server/src/instant/model/app.clj | 29 +++++ .../src/instant/model/app_user_magic_code.clj | 105 ++++++++++++------ .../src/instant/runtime/magic_code_auth.clj | 8 +- server/src/instant/runtime/routes.clj | 8 +- server/src/instant/totp.clj | 80 +++++++++++++ server/src/instant/util/crypt.clj | 2 +- server/test/instant/totp_test.clj | 34 ++++++ 11 files changed, 255 insertions(+), 43 deletions(-) create mode 100644 server/resources/migrations/100_totp_magic_code.down.sql create mode 100644 server/resources/migrations/100_totp_magic_code.up.sql create mode 100644 server/src/instant/totp.clj create mode 100644 server/test/instant/totp_test.clj diff --git a/server/resources/migrations/100_totp_magic_code.down.sql b/server/resources/migrations/100_totp_magic_code.down.sql new file mode 100644 index 0000000000..6b8c8fd971 --- /dev/null +++ b/server/resources/migrations/100_totp_magic_code.down.sql @@ -0,0 +1,2 @@ +alter table apps drop column totp_secret_key_enc; +alter table apps drop column totp_expiry_minutes; diff --git a/server/resources/migrations/100_totp_magic_code.up.sql b/server/resources/migrations/100_totp_magic_code.up.sql new file mode 100644 index 0000000000..bd14053f99 --- /dev/null +++ b/server/resources/migrations/100_totp_magic_code.up.sql @@ -0,0 +1,2 @@ +alter table apps add column totp_secret_key_enc bytea; +alter table apps add column totp_expiry_minutes integer; diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index b323da88ab..a2a6462405 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -500,7 +500,7 @@ (defn magic-code-post [req] (let [{:keys [app-id]} (req->app-id-authed! req :data/write) email (ex/get-param! req [:body :email] email/coerce) - {:keys [code]} (app-user-magic-code-model/create! {:app-id app-id :email email})] + code (app-user-magic-code-model/create! {:app-id app-id :email email})] (response/ok {:code code}))) (defn send-magic-code-post [req] diff --git a/server/src/instant/flags.clj b/server/src/instant/flags.clj index 837ca25d45..532a44a478 100644 --- a/server/src/instant/flags.clj +++ b/server/src/instant/flags.clj @@ -334,3 +334,29 @@ Set to 0 to disable." [] (flag :magic-code-rate-limit-per-hour 20)) + +;; TOTP flags +;; Process for rolling out TOTP +;; 1. Deploy the code and wait for 24 hours +;; 2. Flip the validate-with-totp? toggle to true +;; 3. Wait for a while to make sure everything looks good +;; 4. Flip the dual-write-totp toggle to false +;; 5. Remove the code that writes to the magic codes table +;; 6. Later--remove the magic codes completely + +(defn generate-with-totp? + "Returns true if we should generate codes with totp. Need to run this for 24 hours before + turning on validate-with-totp" + [] + (toggled? :generate-totp true)) + +(defn validate-with-totp? + "Returns true if we should validate codes with totp. Need to generate-with-totp for 24 hours before + turning on validate-with-totp" + [] + (toggled? :validate-totp false)) + +(defn dual-write-totp? + "Returns true if we should write our totp codes to the magic codes table." + [] + (toggled? :dual-write-totp true)) diff --git a/server/src/instant/model/app.clj b/server/src/instant/model/app.clj index 5458e4dc90..95fa97eca7 100644 --- a/server/src/instant/model/app.clj +++ b/server/src/instant/model/app.clj @@ -346,6 +346,35 @@ WHERE a.id = ?::uuid" new-creator-id id]))))) +(defn ensure-totp-secret! + ([params] + (ensure-totp-secret! (aurora/conn-pool :write) params)) + ([conn {:keys [id]}] + (let [secret-key (crypt-util/random-bytes 32) + enc-secret-key (crypt-util/aead-encrypt {:plaintext secret-key + :associated-data (uuid-util/->bytes id)})] + (with-cache-invalidation id + (sql/execute-one! ::ensure-totp-secret! + conn + (hsql/format {:update :apps + :set {:totp-secret-key-enc enc-secret-key} + :where [:and + [:= :id id] + [:= :totp-secret-key-enc nil]]})))))) + +(defn get-totp-secret-key + ([params] + (get-totp-secret-key (aurora/conn-pool :read) + (aurora/conn-pool :write) + params)) + ([read-conn write-conn {:keys [id]}] + (let [secret-key-enc (or (:totp_secret_key_enc (get-by-id! read-conn {:id id})) + (:totp_secret_key_enc (ensure-totp-secret! write-conn {:id id})) + ;; 2nd read in case two txes tried to initialize the key at the same time + (:totp_secret_key_enc (get-by-id! read-conn {:id id})))] + (crypt-util/aead-decrypt {:ciphertext secret-key-enc + :associated-data (uuid-util/->bytes id)})))) + (defn clear-by-id! "Deletes attrs, rules, and triples for the specified app_id" ([params] (clear-by-id! (aurora/conn-pool :write) params)) diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index e1a1e8299b..6a305341e5 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -1,15 +1,19 @@ (ns instant.model.app-user-magic-code (:require + [instant.flags :as flags] [instant.jdbc.aurora :as aurora] [instant.model.app :as app-model] [instant.model.app-user :as app-user-model] [instant.model.instant-user :as instant-user-model] [instant.system-catalog-ops :refer [update-op]] + [instant.totp :as totp] [instant.util.crypt :as crypt-util] [instant.util.exception :as ex] [instant.util.string :refer [rand-num-str]]) (:import - (java.util Date UUID))) + (java.time Duration) + (java.util Date UUID) + (org.postgresql.util PGInterval))) (def etype "$magicCodes") @@ -24,47 +28,82 @@ (let [created-at ^Date (:created_at magic-code)] (< (+ (.getTime created-at) ttl-ms) (System/currentTimeMillis))))) +(defn totp-secret-key [app-id ^String email] + (let [app-secret-key (app-model/get-totp-secret-key {:id app-id}) + derived-key (crypt-util/hmac-256 app-secret-key (.getBytes email))] + derived-key)) + +(defn generate-totp [app-id ^String email] + (let [secret-key (totp-secret-key app-id email)] + (totp/generate-totp secret-key))) + (defn create! ([params] (create! (aurora/conn-pool :write) params)) - ([conn {:keys [app-id email id code]}] + ([conn {:keys [app-id email id]}] (let [id (or id (random-uuid)) - code (or code (rand-code))] - (update-op - conn - {:app-id app-id - :etype etype} - (fn [{:keys [transact! get-entity]}] - (transact! [{:id id - :etype etype - :codeHash (-> code - crypt-util/str->sha256 - crypt-util/bytes->hex-string) - :email email}]) - (assoc (get-entity id) - :code code)))))) + code (if (flags/generate-with-totp?) + (generate-totp app-id email) + (rand-code))] + + (when (or (not (flags/validate-with-totp?)) + (not (flags/generate-with-totp?)) + (flags/dual-write-totp?)) + (update-op + conn + {:app-id app-id + :etype etype} + (fn [{:keys [transact!]}] + (transact! [{:id id + :etype etype + :codeHash (-> code + crypt-util/str->sha256 + crypt-util/bytes->hex-string) + :email email}])))) + code))) + + +(defn validate-totp! [app-id ^String email ^String code] + (let [secret-key (totp-secret-key app-id email) + expiry-periods (or (some-> (app-model/get-by-id! {:id app-id}) + :totp_expiry_minutes + (max 5) ;; Minimum of 5 minutes + (min 1440) ;; Maximum of 1 day + (* 60) + (/ totp/default-time-step) + (Math/ceil)) + ;; Default to 10 minutes + (/ 600 totp/default-time-step))] + ;; Have to add 1 extra period in case the code was generated near the + ;; end of a period + (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) + (ex/throw-expiration-err! :app-user-magic-code {:args [{:code code + :email email}]})))) (defn consume! ([params] (consume! (aurora/conn-pool :write) params)) ([conn {:keys [email code app-id] :as params}] - (update-op - conn - {:app-id app-id - :etype etype} - (fn [{:keys [get-entity-where delete-entity!]}] - (let [code-hash (-> code - crypt-util/str->sha256 - crypt-util/bytes->hex-string) - {code-id :id} (get-entity-where - {:codeHash code-hash - :email email})] - (ex/assert-record! code-id :app-user-magic-code {:args [params]}) - (let [code (delete-entity! code-id)] - (ex/assert-record! code :app-user-magic-code {:args [params]}) - (when (expired? code) - (ex/throw-expiration-err! :app-user-magic-code {:args [params]})) - code)))))) + (when (or (not (flags/validate-with-totp?)) + (flags/dual-write-totp?)) + (update-op + conn + {:app-id app-id + :etype etype} + (fn [{:keys [get-entity-where delete-entity!]}] + (let [code-hash (-> code + crypt-util/str->sha256 + crypt-util/bytes->hex-string) + {code-id :id} (get-entity-where + {:codeHash code-hash + :email email})] + (ex/assert-record! code-id :app-user-magic-code {:args [params]}) + (let [code (delete-entity! code-id)] + (ex/assert-record! code :app-user-magic-code {:args [params]}) + (when (expired? code) + (ex/throw-expiration-err! :app-user-magic-code {:args [params]}))))))) + (when (flags/validate-with-totp?) + (validate-totp! app-id email code)))) (comment (def instant-user (instant-user-model/get-by-email diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index ff2a940725..291a898fd0 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -83,7 +83,7 @@ (defn send! [{:keys [app-id email] :as req}] (check-rate-limit! req) (let [app (app-model/get-by-id! {:id app-id}) - {:keys [code]} (app-user-magic-code-model/create! (select-keys req [:app-id :email])) + code (app-user-magic-code-model/create! (select-keys req [:app-id :email])) template (app-email-template-model/get-by-app-id-and-email-type {:app-id app-id :email-type "magic-code"}) @@ -178,10 +178,10 @@ (def app (first (app-model/get-all-for-user {:user-id (:id instant-user)}))) (def runtime-user (app-user-model/get-by-email {:app-id (:id app) :email "stopa@instantdb.com"})) - (def m - (:magic-code (app-user-magic-code-model/create! {:app-id (:id app) :email "stopa@instantdb.com"}))) + (def code + (app-user-magic-code-model/create! {:app-id (:id app) :email "stopa@instantdb.com"})) (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code "0"}) - (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code (:code m)})) + (verify! {:app-id (:id app) :email "stopa@instantdb.com" :code code})) diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 8a80cc48b0..ce8238aafb 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -117,12 +117,12 @@ :email "stopa@instantdb.com"})) (def m (app-user-magic-code-model/create! - {:id (random-uuid) :user-id (:id runtime-user) :code (app-user-magic-code-model/rand-code) + {:id (random-uuid) :user-id (:id runtime-user) :app-id (:id app)})) - (verify-magic-code-post {:body {:email "stopainstantdb.com" :code (:code m)}}) - (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m)}}) + (verify-magic-code-post {:body {:email "stopainstantdb.com" :code m}}) + (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m}}) (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code "0" :app-id (:id app)}}) - (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code (:code m) :app-id (:id app)}})) + (verify-magic-code-post {:body {:email "stopa@instantdb.com" :code m :app-id (:id app)}})) ;; ----- ;; Guest sign in diff --git a/server/src/instant/totp.clj b/server/src/instant/totp.clj new file mode 100644 index 0000000000..cdb3fe6f13 --- /dev/null +++ b/server/src/instant/totp.clj @@ -0,0 +1,80 @@ +(ns instant.totp + (:require + [instant.util.crypt :as crypt-util] + [clojure.string]) + (:import + (java.time Instant))) + +;; Time step is 5 minutes (in seconds), which half the default expiration. If an +;; app accepts tokens for longer, we just check all of the previous 5 minute +;; intervals until we find a matching token or exceed the max time. In the worst +;; case (24 hours), we'll have to do 288 checks. +;; We can't change this without adding extra code for backwards compatibility +(def default-time-step 300) + +(def digit-count 6) + +;; TOTP generation follows the reference impl in https://www.rfc-editor.org/rfc/rfc6238#page-9 + +(defn left-pad [s len] + (let [pad-len (- len (count s))] + (if (pos? pad-len) + (str (clojure.string/join (repeat pad-len "0")) s) + s))) + +(def digits-power + [1 + 10 + 100 + 1000 + 10000 + 100000 + 1000000 + 10000000 + 100000000]) + +(defn generate-totp + "Generates a TOTP code. For testing, it accepts the number of + digits and a time step, but you should always use the default + values in production." + ([^bytes secret-key] + (generate-totp secret-key (Instant/now))) + ([^bytes secret-key ^Instant time] + (generate-totp secret-key time 6 default-time-step)) + ([^bytes secret-key ^Instant time code-digits time-step] + (let [t (/ (tool/inspect (.getEpochSecond time)) + time-step) + t-bytes (-> t + (Long/toHexString) + (.toUpperCase) + (left-pad 16) + (crypt-util/hex-string->bytes)) + hash (crypt-util/hmac-256 secret-key t-bytes) + offset (bit-and (aget hash (dec (alength hash))) + 0xf) + binary (bit-or + (bit-shift-left (bit-and (aget hash offset) 0x7f) + 24) + (bit-shift-left (bit-and (aget hash (+ offset 1)) 0xff) + 16) + (bit-shift-left (bit-and (aget hash (+ offset 2)) 0xff) + 8) + (bit-and (aget hash (+ offset 3)) 0xff)) + otp (mod binary (nth digits-power code-digits))] + (left-pad (Integer/toString otp) code-digits)))) + +(defn valid-totp? + "Returns true if the totp code is valid. Will go back up to max-10-minute-intervals." + ([^bytes secret-key max-5-minute-intervals ^String code] + (valid-totp? secret-key (Instant/now) max-5-minute-intervals code)) + ([^bytes secret-key + ^Instant time + max-5-minute-intervals + ^String code] + (loop [remaining-intervals max-5-minute-intervals + time time] + (when (pos? (tool/inspect remaining-intervals)) + (if (crypt-util/constant-string= code (tool/inspect (generate-totp secret-key (tool/inspect time)))) + true + (recur (dec remaining-intervals) + (.minusSeconds time default-time-step))))))) diff --git a/server/src/instant/util/crypt.clj b/server/src/instant/util/crypt.clj index 18e2c1b856..0dda561de6 100644 --- a/server/src/instant/util/crypt.clj +++ b/server/src/instant/util/crypt.clj @@ -82,7 +82,7 @@ (defn random-hex [^Long size] (bytes->hex-string (Random/randBytes size))) -(defn hmac-256 [^bytes secret-key ^bytes b] +(defn hmac-256 ^bytes [^bytes secret-key ^bytes b] (let [mac (Mac/getInstance "HmacSHA256")] (.init mac (SecretKeySpec. secret-key "HmacSHA256")) (.doFinal mac b))) diff --git a/server/test/instant/totp_test.clj b/server/test/instant/totp_test.clj new file mode 100644 index 0000000000..774c672905 --- /dev/null +++ b/server/test/instant/totp_test.clj @@ -0,0 +1,34 @@ +(ns instant.totp-test + (:require + [clojure.test :refer [deftest is testing]] + [instant.totp :as totp] + [instant.util.crypt :as crypt-util]) + (:import + (java.time Instant))) + +(def secret-key (crypt-util/hex-string->bytes (str "3132333435363738393031323334353637383930" + "313233343536373839303132"))) + +(deftest totp-reference-impl + ;; Tests against the reference implementation https://www.rfc-editor.org/rfc/rfc6238#page-14 + (doseq [[time expected] [[59 "46119246"] + [1111111109 "68084774"] + [1111111111 "67062674"] + [1234567890 "91819424"] + [2000000000 "90698825"] + [20000000000 "77737706"]]] + (testing (format "%s -> %s" time expected) + (is (= (totp/generate-totp secret-key (Instant/ofEpochMilli (* time 1000)) 8 30) + expected))))) + +(deftest totp-smoke-test + (let [t0 (Instant/ofEpochMilli 1774046599905) + code "508623"] + (is (= code (totp/generate-totp secret-key t0))) + (testing "valid for 5 minutes by default" + (is (totp/valid-totp? secret-key t0 1 code)) + (is (not (totp/valid-totp? secret-key (.plusSeconds t0 (* 10 60)) 1 code)))) + + (testing "can extend validity in 5 minute increments" + (is (totp/valid-totp? secret-key (.plusSeconds t0 (* 9 60)) 3 code)) + (is (not (totp/valid-totp? secret-key (.plusSeconds t0 (* 16 60)) 3 code)))))) From 070725164e94716bfd6e31758405edc7f44463ce Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Fri, 20 Mar 2026 16:06:52 -0700 Subject: [PATCH 2/5] make linter happy --- server/src/instant/model/app_user_magic_code.clj | 4 +--- server/test/instant/reactive/session_test.clj | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index 6a305341e5..9f45c0e229 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -11,9 +11,7 @@ [instant.util.exception :as ex] [instant.util.string :refer [rand-num-str]]) (:import - (java.time Duration) - (java.util Date UUID) - (org.postgresql.util PGInterval))) + (java.util Date UUID))) (def etype "$magicCodes") diff --git a/server/test/instant/reactive/session_test.clj b/server/test/instant/reactive/session_test.clj index fc5e2b5b91..324aa0a2da 100644 --- a/server/test/instant/reactive/session_test.clj +++ b/server/test/instant/reactive/session_test.clj @@ -1275,7 +1275,7 @@ {:keys [offset]} (blocking-send-msg :start-stream-ok socket-2 {:op :start-stream :client-id "stream-1" - :reconnect-token (str reconnect-token)}) + :reconnect-token reconnect-token}) _ (is (= offset 8)) _ (send-msg socket-2 {:op :append-stream @@ -1287,7 +1287,7 @@ (testing "if someone steals our session, we can't write to it" (blocking-send-msg :start-stream-ok socket-3 {:op :start-stream :client-id "stream-1" - :reconnect-token (str reconnect-token)}) + :reconnect-token reconnect-token}) (blocking-send-msg :error socket-2 {:op :append-stream :client-id "stream-1" :chunks ["GHI"] From 500ea129c9b6c2aff3e272d81dd29908b9d394dd Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Fri, 20 Mar 2026 16:11:38 -0700 Subject: [PATCH 3/5] set the default expiry to 1 day --- server/src/instant/flags.clj | 4 ++++ server/src/instant/model/app_user_magic_code.clj | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/instant/flags.clj b/server/src/instant/flags.clj index 532a44a478..61bbc1eedc 100644 --- a/server/src/instant/flags.clj +++ b/server/src/instant/flags.clj @@ -360,3 +360,7 @@ "Returns true if we should write our totp codes to the magic codes table." [] (toggled? :dual-write-totp true)) + +(defn totp-default-expiry-seconds + [] + (flag :totp-default-expiry-seconds 86400)) diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index 9f45c0e229..2f23286627 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -71,7 +71,7 @@ (/ totp/default-time-step) (Math/ceil)) ;; Default to 10 minutes - (/ 600 totp/default-time-step))] + (/ (flags/totp-default-expiry-seconds) totp/default-time-step))] ;; Have to add 1 extra period in case the code was generated near the ;; end of a period (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) From 8ba6ddf43809186a29338986f96a08033db77deb Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Fri, 20 Mar 2026 17:07:11 -0700 Subject: [PATCH 4/5] tests --- server/src/instant/flags.clj | 38 ++++++-- .../src/instant/model/app_user_magic_code.clj | 5 +- server/src/instant/totp.clj | 13 +-- server/test/instant/runtime/routes_test.clj | 90 +++++++++---------- 4 files changed, 87 insertions(+), 59 deletions(-) diff --git a/server/src/instant/flags.clj b/server/src/instant/flags.clj index 61bbc1eedc..bc7f7fa309 100644 --- a/server/src/instant/flags.clj +++ b/server/src/instant/flags.clj @@ -245,15 +245,41 @@ (defn test-rule-wheres? [] (:rule-where-testing (query-result))) +(def ^:dynamic *toggle-overrides* nil) + (defn toggled? ([key] - (get-in (query-result) [:toggles key])) + (if *toggle-overrides* + (-> (query-result) + (get :toggles) + (merge *toggle-overrides*) + (get key)) + (get-in (query-result) [:toggles key]))) ([key not-found] - (get-in (query-result) [:toggles key] not-found))) + (if *toggle-overrides* + (-> (query-result) + (get :toggles) + (merge *toggle-overrides*) + (get key not-found)) + (get-in (query-result) [:toggles key] not-found)))) + +(def ^:dynamic *flag-overrides* nil) (defn flag - ([key] (get-in (query-result) [:flags key])) - ([key not-found] (get-in (query-result) [:flags key] not-found))) + ([key] + (if *flag-overrides* + (-> (query-result) + (get :flags) + (merge *flag-overrides*) + (get key)) + (get-in (query-result) [:flags key]))) + ([key not-found] + (if *flag-overrides* + (-> (query-result) + (get :flags) + (merge *flag-overrides*) + (get key not-found)) + (get-in (query-result) [:flags key] not-found)))) (defn handle-receive-timeout [app-id] (get-in (query-result) [:handle-receive-timeout app-id])) @@ -348,13 +374,13 @@ "Returns true if we should generate codes with totp. Need to run this for 24 hours before turning on validate-with-totp" [] - (toggled? :generate-totp true)) + (toggled? :generate-with-totp true)) (defn validate-with-totp? "Returns true if we should validate codes with totp. Need to generate-with-totp for 24 hours before turning on validate-with-totp" [] - (toggled? :validate-totp false)) + (toggled? :validate-with-totp false)) (defn dual-write-totp? "Returns true if we should write our totp codes to the magic codes table." diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index 2f23286627..40a3c5268d 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -70,13 +70,12 @@ (* 60) (/ totp/default-time-step) (Math/ceil)) - ;; Default to 10 minutes (/ (flags/totp-default-expiry-seconds) totp/default-time-step))] ;; Have to add 1 extra period in case the code was generated near the ;; end of a period (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) - (ex/throw-expiration-err! :app-user-magic-code {:args [{:code code - :email email}]})))) + (ex/throw-expiration-err! :app-user-magic-code (tool/inspect {:args [{:code code + :email email}]}))))) (defn consume! ([params] diff --git a/server/src/instant/totp.clj b/server/src/instant/totp.clj index cdb3fe6f13..05c2dcd848 100644 --- a/server/src/instant/totp.clj +++ b/server/src/instant/totp.clj @@ -33,16 +33,19 @@ 10000000 100000000]) +;; For use in testing +(def ^:dynamic *now* nil) + (defn generate-totp "Generates a TOTP code. For testing, it accepts the number of digits and a time step, but you should always use the default values in production." ([^bytes secret-key] - (generate-totp secret-key (Instant/now))) + (generate-totp secret-key (or *now* (Instant/now)))) ([^bytes secret-key ^Instant time] (generate-totp secret-key time 6 default-time-step)) ([^bytes secret-key ^Instant time code-digits time-step] - (let [t (/ (tool/inspect (.getEpochSecond time)) + (let [t (/ (.getEpochSecond time) time-step) t-bytes (-> t (Long/toHexString) @@ -66,15 +69,15 @@ (defn valid-totp? "Returns true if the totp code is valid. Will go back up to max-10-minute-intervals." ([^bytes secret-key max-5-minute-intervals ^String code] - (valid-totp? secret-key (Instant/now) max-5-minute-intervals code)) + (valid-totp? secret-key (or *now* (Instant/now)) max-5-minute-intervals code)) ([^bytes secret-key ^Instant time max-5-minute-intervals ^String code] (loop [remaining-intervals max-5-minute-intervals time time] - (when (pos? (tool/inspect remaining-intervals)) - (if (crypt-util/constant-string= code (tool/inspect (generate-totp secret-key (tool/inspect time)))) + (when (pos? remaining-intervals) + (if (crypt-util/constant-string= code (generate-totp secret-key time)) true (recur (dec remaining-intervals) (.minusSeconds time default-time-step))))))) diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index a10aff13c0..1df5e6ce7c 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -1,9 +1,9 @@ (ns instant.runtime.routes-test (:require - [clj-http.client :as http] [clojure.test :refer [deftest is testing]] - [instant.config :as config] + [instant.core :as core] [instant.db.model.attr :as attr-model] + [instant.flags :as flags] [instant.fixtures :refer [with-empty-app]] [instant.jdbc.aurora :as aurora] [instant.jdbc.sql :as sql] @@ -18,25 +18,33 @@ [instant.system-catalog :as system-catalog] [instant.util.coll :as coll] [instant.util.crypt :as crypt-util] - [instant.util.json :refer [->json]] - [instant.flags :as flags] + [instant.util.json :refer [->json <-json]] [instant.runtime.magic-code-auth :as magic-code-auth] + [instant.totp :as totp] [instant.util.cache :as cache] [instant.util.test :as test-util] [instant.util.tracer :as tracer]) (:import - [clojure.lang ExceptionInfo])) + (clojure.lang ExceptionInfo) + (java.io ByteArrayInputStream) + (java.time Instant) + (java.time.temporal ChronoUnit))) (defn request [opts] (with-redefs [tracer/*silence-exceptions?* (atom true)] - (http/request - (merge-with - merge - {:headers {:Content-Type "application/json"} - :as :json} - (-> opts - (coll/update-when :url #(str config/server-origin %)) - (coll/update-when :body ->json)))))) + (let [req (merge-with merge + {:headers {"content-type" "application/json"} + :request-method (:method opts) + :uri (:url opts)} + (-> opts + (coll/update-when :body (fn [body] + (ByteArrayInputStream. (.getBytes ^String (->json body) "UTF-8")))))) + resp (-> ((core/handler) req) + (update :body (fn [body] + (<-json body true))))] + (if (not= 200 (:status resp)) + (throw (ex-info (str "status " (:status resp)) resp)) + resp)))) (defn send-code-runtime [app body] (let [letter (atom nil)] @@ -110,11 +118,7 @@ (with-empty-app (fn [{app-id :id :as app}] (testing "auth for new user" - (let [code (send-code app {:email "a@b.c"}) - code2 (send-code app {:email "a@b.c"})] - - (testing "can generate two codes" - (is (not= code code2))) + (let [code (send-code app {:email "a@b.c"})] (testing "can't use different code" (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code "000000"})))) @@ -128,19 +132,8 @@ (is (= "a@b.c" (:email user))) (is (some? (:refresh_token user))))) - (testing "can't reuse code" - (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) - - (testing "can use second unused code" - (let [user (verify-code app {:email "a@b.c" :code code2})] - (is (= (str app-id) (:app_id user))) - (is (= "a@b.c" (:email user))) - (is (some? (:refresh_token user))))) - (testing "auth for existing user" (let [code3 (send-code app {:email "a@b.c"}) - _ (is (not= code code3)) - _ (is (not= code2 code3)) user (verify-code app {:email "a@b.c" :code code3})] (is (= (str app-id) (:app_id user))) (is (= "a@b.c" (:email user))) @@ -156,7 +149,7 @@ created_at = ?created-at WHERE app_id = ?app-id - AND entity_id = ( + AND entity_id in ( SELECT entity_id FROM @@ -182,7 +175,15 @@ (fn [{app-id :id :as app}] (let [code (send-code app {:email "a@b.c"})] (update-created-at app-id code (- (System/currentTimeMillis) (* 25 60 60 1000))) - (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) + (binding [totp/*now* (.plus (Instant/now) 1 ChronoUnit/DAYS)] + (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code})))) + + ;; Test that it works without dual-write + (binding [totp/*now* (.plus (.plus (Instant/now) 1 ChronoUnit/DAYS) + 10 ChronoUnit/MINUTES) + flags/*toggle-overrides* {:dual-write-totp false + :validate-with-totp true}] + (is (thrown-with-msg? ExceptionInfo #"status 400" (verify-code app {:email "a@b.c" :code code}))))) (let [code (send-code app {:email "a@b.c"})] (update-created-at app-id code (- (System/currentTimeMillis) (* 23 60 60 1000))) @@ -338,25 +339,25 @@ (deftest upsert-oauth-link-disambiguates-with-email (with-empty-app (fn [app] - ;; Apple OAuth lets you provide "relay" emails: - ;; these are anonymous emails that forward to the user's real email. + ;; Apple OAuth lets you provide "relay" emails: + ;; these are anonymous emails that forward to the user's real email. - ;; This opens up a potential problem. + ;; This opens up a potential problem. - ;; Consider the following scenario: - ;; (1) User signs in with magic code: stopa@instantdb.com - ;; (2) User signs in with with Apple, private relay on: foo@privaterelay.appleid.com + ;; Consider the following scenario: + ;; (1) User signs in with magic code: stopa@instantdb.com + ;; (2) User signs in with with Apple, private relay on: foo@privaterelay.appleid.com - ;; At this point we'll have _2_ separate users. + ;; At this point we'll have _2_ separate users. - ;; Now - ;; (3) The user signs in with Apple, private relay off: stopa@instantdb.com. + ;; Now + ;; (3) The user signs in with Apple, private relay off: stopa@instantdb.com. - ;; Which user should we link this 3rd sign up too? It matches _both_ the - ;; existing email user, and the existing Apple Oauth link. + ;; Which user should we link this 3rd sign up too? It matches _both_ the + ;; existing email user, and the existing Apple Oauth link. - ;; Currently, we choose the existing email user. - ;; This means that the user with the private relay email will get stranded. + ;; Currently, we choose the existing email user. + ;; This means that the user with the private relay email will get stranded. ;; However, in the worst case scenario, they can be recovered manually. (let [provider (provider-model/create! {:app-id (:id app) :provider-name "apple"}) @@ -717,4 +718,3 @@ (permissioned-tx/transact! ctx [[:add-triple user-id (:id id-attr) user-id]])))))))) - From ed63104255966762a57b92d1c25aa907c08e48df Mon Sep 17 00:00:00 2001 From: Daniel Woelfel Date: Fri, 20 Mar 2026 17:18:53 -0700 Subject: [PATCH 5/5] remove debug code --- server/src/instant/model/app_user_magic_code.clj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/instant/model/app_user_magic_code.clj b/server/src/instant/model/app_user_magic_code.clj index 40a3c5268d..c118824a18 100644 --- a/server/src/instant/model/app_user_magic_code.clj +++ b/server/src/instant/model/app_user_magic_code.clj @@ -74,8 +74,8 @@ ;; Have to add 1 extra period in case the code was generated near the ;; end of a period (when-not (totp/valid-totp? secret-key (inc expiry-periods) code) - (ex/throw-expiration-err! :app-user-magic-code (tool/inspect {:args [{:code code - :email email}]}))))) + (ex/throw-expiration-err! :app-user-magic-code {:args [{:code code + :email email}]})))) (defn consume! ([params]