Remember an OAuth sign-in across restarts
The durable broker token now lives in $XDG_CONFIG_HOME/frq/session.edn beside the handle and nick it belongs to, so a restart opens on the Bluesky tab and Connect resumes without a browser. It is a credential — it mints session tokens for the account — so the file is created with mode 600 before anything is written to it, and the single-use web-token is deliberately not saved. Saving happens on every sign-in rather than only the first: /session can hand back a rotated broker token, and the one it replaces may stop working the moment it does. A token the broker no longer honours is worth exactly one attempt. It is dropped from disk and memory and the browser flow runs again in the same Connect — the alternative was a saved credential that failed identically forever and could only be cleared by hand. A SASL refusal clears it too. While here, `describe` stopped prefixing every message with its exception class; the class is now shown only for a raw host condition, which prints as #object[:object] and would otherwise say nothing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4aa71e7 parent: 3d47bd9 modified
README.md +9 -2 | @@ -14,6 +14,7 @@ of an `AppState` struct. | ||
| 14 | 14 | ``` |
| 15 | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session |
| 17 | +src/frq/store.jolt the saved sign-in, mode 600 in the config directory | |
| 17 | 18 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 18 | 19 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 19 | 20 | src/frq/app.jolt the screens |
| @@ -50,6 +51,12 @@ job: POST the fragment back to itself. What comes back is a single-use SASL | ||
| 50 | 51 | `web-token` and a durable `broker_token`; later connections mint a fresh token |
| 51 | 52 | from the durable one at `/session` and skip the browser. |
| 52 | 53 | |
| 54 | +The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so | |
| 55 | +a restart resumes without one, along with the handle and nick it belongs to. | |
| 56 | +The web-token beside it is single-use and deliberately not saved. A token the | |
| 57 | +broker no longer honours is dropped — from disk and memory — and the browser | |
| 58 | +flow runs once more, rather than failing the same way on every Connect. | |
| 59 | + | |
| 53 | 60 | **App password** signs in without a browser, straight to the user's own PDS: |
| 54 | 61 | `resolveHandle` → DID → PDS from the DID document → `createSession`. The |
| 55 | 62 | password goes to that PDS and nowhere else, is never written to disk, and is |
| @@ -98,7 +105,7 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 98 | 105 | * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either |
| 99 | 106 | kind needs TLS, so it is desktop-only — the Android build connects as a |
| 100 | 107 | guest. |
| 101 | -* **The broker token lives in memory.** Nothing is persisted, so a restart | |
| 102 | - means another trip through the browser. | |
| 108 | +* **Only the broker token is persisted**, and only for OAuth. An app-password | |
| 109 | + sign-in is not remembered. | |
| 103 | 110 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 104 | 111 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
| @@ -14,6 +14,7 @@ of an `AppState` struct. | |||
| 14 | ``` | 14 | ``` |
| 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads | 15 | src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads |
| 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session | 16 | src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session |
| 17 | +src/frq/store.jolt the saved sign-in, mode 600 in the config directory | ||
| 17 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 18 | src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG |
| 18 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | 19 | src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` |
| 19 | src/frq/app.jolt the screens | 20 | src/frq/app.jolt the screens |
| @@ -50,6 +51,12 @@ job: POST the fragment back to itself. What comes back is a single-use SASL | |||
| 50 | `web-token` and a durable `broker_token`; later connections mint a fresh token | 51 | `web-token` and a durable `broker_token`; later connections mint a fresh token |
| 51 | from the durable one at `/session` and skip the browser. | 52 | from the durable one at `/session` and skip the browser. |
| 52 | 53 | ||
| 54 | +The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so | ||
| 55 | +a restart resumes without one, along with the handle and nick it belongs to. | ||
| 56 | +The web-token beside it is single-use and deliberately not saved. A token the | ||
| 57 | +broker no longer honours is dropped — from disk and memory — and the browser | ||
| 58 | +flow runs once more, rather than failing the same way on every Connect. | ||
| 59 | + | ||
| 53 | **App password** signs in without a browser, straight to the user's own PDS: | 60 | **App password** signs in without a browser, straight to the user's own PDS: |
| 54 | `resolveHandle` → DID → PDS from the DID document → `createSession`. The | 61 | `resolveHandle` → DID → PDS from the DID document → `createSession`. The |
| 55 | password goes to that PDS and nowhere else, is never written to disk, and is | 62 | password goes to that PDS and nowhere else, is never written to disk, and is |
| @@ -98,7 +105,7 @@ surface — that surface does not work on Android either, while the syscalls do. | |||
| 98 | * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either | 105 | * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either |
| 99 | kind needs TLS, so it is desktop-only — the Android build connects as a | 106 | kind needs TLS, so it is desktop-only — the Android build connects as a |
| 100 | guest. | 107 | guest. |
| 101 | -* **The broker token lives in memory.** Nothing is persisted, so a restart | 108 | +* **Only the broker token is persisted**, and only for OAuth. An app-password |
| 102 | - means another trip through the browser. | 109 | + sign-in is not remembered. |
| 103 | * **No scrollback trimming, avatars, reactions, threads, or calls.** | 110 | * **No scrollback trimming, avatars, reactions, threads, or calls.** |
| 104 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | 111 | * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. |
modified
src/frq/app.jolt +8 -3 | @@ -87,9 +87,11 @@ | ||
| 87 | 87 | :width-request 320 |
| 88 | 88 | :placeholder "alice.bsky.social" |
| 89 | 89 | :on-change #(reset! s/form-handle %)}] |
| 90 | - [:vbox {:key :remembered} | |
| 90 | + [:vbox {:key :remembered :spacing 4} | |
| 91 | 91 | (when @s/broker-token |
| 92 | - [:dim-label {:label "Session remembered — Connect will not need the browser."}])] | |
| 92 | + [:vbox {:spacing 4} | |
| 93 | + [:dim-label {:label "Session remembered — Connect will not need the browser."}] | |
| 94 | + [:button {:label "Forget saved session" :on-click s/forget-session!}]])] | |
| 93 | 95 | [:vbox {:key :login-url :spacing 4} |
| 94 | 96 | (when-let [url @s/login-url] |
| 95 | 97 | [:vbox {:spacing 4} |
| @@ -238,7 +240,7 @@ | ||
| 238 | 240 | (when @s/broker-token |
| 239 | 241 | [:button {:label "Forget Bluesky session" |
| 240 | 242 | :kind :destructive |
| 241 | - :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]] | |
| 243 | + :on-click s/forget-session!}])]] | |
| 242 | 244 | [:dim-label {:label "Guest — not signed in."}]) |
| 243 | 245 | [:separator {}] |
| 244 | 246 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| @@ -260,4 +262,7 @@ | ||
| 260 | 262 | [chats-screen])) |
| 261 | 263 | |
| 262 | 264 | (defn -main [& _] |
| 265 | + ;; Before the window: a saved sign-in decides which mode the connect screen | |
| 266 | + ;; opens in, and what it says. | |
| 267 | + (s/restore-session!) | |
| 263 | 268 | (ui/run app :title "frq" :width 520 :height 860)) |
| @@ -87,9 +87,11 @@ | |||
| 87 | :width-request 320 | 87 | :width-request 320 |
| 88 | :placeholder "alice.bsky.social" | 88 | :placeholder "alice.bsky.social" |
| 89 | :on-change #(reset! s/form-handle %)}] | 89 | :on-change #(reset! s/form-handle %)}] |
| 90 | - [:vbox {:key :remembered} | 90 | + [:vbox {:key :remembered :spacing 4} |
| 91 | (when @s/broker-token | 91 | (when @s/broker-token |
| 92 | - [:dim-label {:label "Session remembered — Connect will not need the browser."}])] | 92 | + [:vbox {:spacing 4} |
| 93 | + [:dim-label {:label "Session remembered — Connect will not need the browser."}] | ||
| 94 | + [:button {:label "Forget saved session" :on-click s/forget-session!}]])] | ||
| 93 | [:vbox {:key :login-url :spacing 4} | 95 | [:vbox {:key :login-url :spacing 4} |
| 94 | (when-let [url @s/login-url] | 96 | (when-let [url @s/login-url] |
| 95 | [:vbox {:spacing 4} | 97 | [:vbox {:spacing 4} |
| @@ -238,7 +240,7 @@ | |||
| 238 | (when @s/broker-token | 240 | (when @s/broker-token |
| 239 | [:button {:label "Forget Bluesky session" | 241 | [:button {:label "Forget Bluesky session" |
| 240 | :kind :destructive | 242 | :kind :destructive |
| 241 | - :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]] | 243 | + :on-click s/forget-session!}])]] |
| 242 | [:dim-label {:label "Guest — not signed in."}]) | 244 | [:dim-label {:label "Guest — not signed in."}]) |
| 243 | [:separator {}] | 245 | [:separator {}] |
| 244 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | 246 | [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] |
| @@ -260,4 +262,7 @@ | |||
| 260 | [chats-screen])) | 262 | [chats-screen])) |
| 261 | 263 | ||
| 262 | (defn -main [& _] | 264 | (defn -main [& _] |
| 265 | + ;; Before the window: a saved sign-in decides which mode the connect screen | ||
| 266 | + ;; opens in, and what it says. | ||
| 267 | + (s/restore-session!) | ||
| 263 | (ui/run app :title "frq" :width 520 :height 860)) | 268 | (ui/run app :title "frq" :width 520 :height 860)) |
modified
src/frq/state.jolt +56 -14 | @@ -8,7 +8,8 @@ | ||
| 8 | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 9 | [frq.atproto :as atproto] |
| 10 | 10 | [frq.irc :as irc] |
| 11 | - [frq.oauth :as oauth])) | |
| 11 | + [frq.oauth :as oauth] | |
| 12 | + [frq.store :as store])) | |
| 12 | 13 | |
| 13 | 14 | (def default-host "irc.freeq.at") |
| 14 | 15 | (def default-port "6697") |
| @@ -150,8 +151,10 @@ | ||
| 150 | 151 | ("904" "905" "906") (do (reset! session nil) |
| 151 | 152 | ;; The broker token may still be good — but a |
| 152 | 153 | ;; refusal is as likely to mean it is not, and a |
| 153 | - ;; stale one would fail the same way every time. | |
| 154 | + ;; stale one would fail the same way every time, | |
| 155 | + ;; including across restarts if it were kept. | |
| 154 | 156 | (reset! broker-token nil) |
| 157 | + (store/clear-session!) | |
| 155 | 158 | (reset! error (str "Bluesky sign-in refused: " |
| 156 | 159 | (or (last params) "no reason given")))) |
| 157 | 160 | "*DISCONNECTED*" (do (reset! conn nil) |
| @@ -171,9 +174,12 @@ | ||
| 171 | 174 | "What went wrong, in words. A jolt condition prints as #object[:object], so |
| 172 | 175 | the message and the ex-data are what has to be dug out by hand." |
| 173 | 176 | [e] |
| 174 | - (let [msg (or (ex-message e) (str e)) | |
| 175 | - data (ex-data e)] | |
| 176 | - (str (type e) ": " msg (when data (str " " (pr-str data)))))) | |
| 177 | + (let [msg (ex-message e)] | |
| 178 | + (if (seq msg) | |
| 179 | + msg | |
| 180 | + ;; A raw host condition prints as #object[:object] and says nothing, so | |
| 181 | + ;; its type is the only thing left worth showing. | |
| 182 | + (str (type e) ": " (str e))))) | |
| 177 | 183 | |
| 178 | 184 | (defn- dial! [host port nick tls? sess] |
| 179 | 185 | ;; stderr is the only console on Android — this line lands in logcat. |
| @@ -196,19 +202,34 @@ | ||
| 196 | 202 | ;; broker token in hand skips the browser entirely. |
| 197 | 203 | :bluesky |
| 198 | 204 | (let [handle (str/trim @form-handle) |
| 205 | + browser! (fn [] | |
| 206 | + (reset! status "Opening your browser to sign in…") | |
| 207 | + (oauth/await-callback! | |
| 208 | + oauth/default-broker handle | |
| 209 | + (fn [url] | |
| 210 | + (reset! login-url url) | |
| 211 | + (oauth/open-browser! url) | |
| 212 | + (reset! status "Waiting for the browser…")))) | |
| 199 | 213 | tokens (if-let [bt @broker-token] |
| 200 | - (do (reset! status "Refreshing your session…") | |
| 201 | - (oauth/refresh-session oauth/default-broker bt)) | |
| 202 | - (do (reset! status "Opening your browser to sign in…") | |
| 203 | - (oauth/await-callback! | |
| 204 | - oauth/default-broker handle | |
| 205 | - (fn [url] | |
| 206 | - (reset! login-url url) | |
| 207 | - (oauth/open-browser! url) | |
| 208 | - (reset! status "Waiting for the browser…"))))) | |
| 214 | + ;; A saved token that the broker no longer honours | |
| 215 | + ;; is worth exactly one attempt: drop it and go | |
| 216 | + ;; through the browser, rather than failing the | |
| 217 | + ;; same way on every future Connect. | |
| 218 | + (try (reset! status "Resuming your session…") | |
| 219 | + (oauth/refresh-session oauth/default-broker bt) | |
| 220 | + (catch Exception _ | |
| 221 | + (reset! broker-token nil) | |
| 222 | + (store/clear-session!) | |
| 223 | + (reset! status "Saved session expired — signing in again…") | |
| 224 | + (browser!))) | |
| 225 | + (browser!)) | |
| 209 | 226 | s (assoc tokens :kind :web-token)] |
| 210 | 227 | (reset! login-url nil) |
| 211 | 228 | (reset! broker-token (:broker-token tokens)) |
| 229 | + ;; Saved on every sign-in, not only the first: /session can | |
| 230 | + ;; hand back a rotated broker token, and the old one may stop | |
| 231 | + ;; working the moment it does. | |
| 232 | + (store/save-session! tokens) | |
| 212 | 233 | (reset! session s) |
| 213 | 234 | (when (seq (:handle tokens)) (reset! form-handle (:handle tokens))) |
| 214 | 235 | s) |
| @@ -275,6 +296,27 @@ | ||
| 275 | 296 | (reset! status "Not connected") |
| 276 | 297 | (reset! error (str "Could not connect: " (describe e)))))))) |
| 277 | 298 | |
| 299 | +(defn restore-session! | |
| 300 | + "Pick up a saved sign-in at startup. Only the durable broker token comes | |
| 301 | + back; the connection still mints a fresh web-token from it." | |
| 302 | + [] | |
| 303 | + (when-let [saved (store/load-session)] | |
| 304 | + (reset! broker-token (:broker-token saved)) | |
| 305 | + (when (seq (:handle saved)) (reset! form-handle (:handle saved))) | |
| 306 | + (when (seq (:nick saved)) (reset! form-nick (:nick saved))) | |
| 307 | + (reset! auth-mode :bluesky) | |
| 308 | + (reset! status (str "Signed in as " (:handle saved) " — Connect to resume")) | |
| 309 | + saved)) | |
| 310 | + | |
| 311 | +(defn forget-session! | |
| 312 | + "Drop the saved sign-in, on disk and in memory." | |
| 313 | + [] | |
| 314 | + (store/clear-session!) | |
| 315 | + (reset! broker-token nil) | |
| 316 | + (reset! session nil) | |
| 317 | + (reset! auth-mode :guest) | |
| 318 | + (reset! status "Not connected")) | |
| 319 | + | |
| 278 | 320 | (defn disconnect! [] |
| 279 | 321 | (when-let [c @conn] (irc/close! c)) |
| 280 | 322 | (reset! conn nil) |
| @@ -8,7 +8,8 @@ | |||
| 8 | [glimmer.ratom :as r :refer [atom]] | 8 | [glimmer.ratom :as r :refer [atom]] |
| 9 | [frq.atproto :as atproto] | 9 | [frq.atproto :as atproto] |
| 10 | [frq.irc :as irc] | 10 | [frq.irc :as irc] |
| 11 | - [frq.oauth :as oauth])) | 11 | + [frq.oauth :as oauth] |
| 12 | + [frq.store :as store])) | ||
| 12 | 13 | ||
| 13 | (def default-host "irc.freeq.at") | 14 | (def default-host "irc.freeq.at") |
| 14 | (def default-port "6697") | 15 | (def default-port "6697") |
| @@ -150,8 +151,10 @@ | |||
| 150 | ("904" "905" "906") (do (reset! session nil) | 151 | ("904" "905" "906") (do (reset! session nil) |
| 151 | ;; The broker token may still be good — but a | 152 | ;; The broker token may still be good — but a |
| 152 | ;; refusal is as likely to mean it is not, and a | 153 | ;; refusal is as likely to mean it is not, and a |
| 153 | - ;; stale one would fail the same way every time. | 154 | + ;; stale one would fail the same way every time, |
| 155 | + ;; including across restarts if it were kept. | ||
| 154 | (reset! broker-token nil) | 156 | (reset! broker-token nil) |
| 157 | + (store/clear-session!) | ||
| 155 | (reset! error (str "Bluesky sign-in refused: " | 158 | (reset! error (str "Bluesky sign-in refused: " |
| 156 | (or (last params) "no reason given")))) | 159 | (or (last params) "no reason given")))) |
| 157 | "*DISCONNECTED*" (do (reset! conn nil) | 160 | "*DISCONNECTED*" (do (reset! conn nil) |
| @@ -171,9 +174,12 @@ | |||
| 171 | "What went wrong, in words. A jolt condition prints as #object[:object], so | 174 | "What went wrong, in words. A jolt condition prints as #object[:object], so |
| 172 | the message and the ex-data are what has to be dug out by hand." | 175 | the message and the ex-data are what has to be dug out by hand." |
| 173 | [e] | 176 | [e] |
| 174 | - (let [msg (or (ex-message e) (str e)) | 177 | + (let [msg (ex-message e)] |
| 175 | - data (ex-data e)] | 178 | + (if (seq msg) |
| 176 | - (str (type e) ": " msg (when data (str " " (pr-str data)))))) | 179 | + msg |
| 180 | + ;; A raw host condition prints as #object[:object] and says nothing, so | ||
| 181 | + ;; its type is the only thing left worth showing. | ||
| 182 | + (str (type e) ": " (str e))))) | ||
| 177 | 183 | ||
| 178 | (defn- dial! [host port nick tls? sess] | 184 | (defn- dial! [host port nick tls? sess] |
| 179 | ;; stderr is the only console on Android — this line lands in logcat. | 185 | ;; stderr is the only console on Android — this line lands in logcat. |
| @@ -196,19 +202,34 @@ | |||
| 196 | ;; broker token in hand skips the browser entirely. | 202 | ;; broker token in hand skips the browser entirely. |
| 197 | :bluesky | 203 | :bluesky |
| 198 | (let [handle (str/trim @form-handle) | 204 | (let [handle (str/trim @form-handle) |
| 205 | + browser! (fn [] | ||
| 206 | + (reset! status "Opening your browser to sign in…") | ||
| 207 | + (oauth/await-callback! | ||
| 208 | + oauth/default-broker handle | ||
| 209 | + (fn [url] | ||
| 210 | + (reset! login-url url) | ||
| 211 | + (oauth/open-browser! url) | ||
| 212 | + (reset! status "Waiting for the browser…")))) | ||
| 199 | tokens (if-let [bt @broker-token] | 213 | tokens (if-let [bt @broker-token] |
| 200 | - (do (reset! status "Refreshing your session…") | 214 | + ;; A saved token that the broker no longer honours |
| 201 | - (oauth/refresh-session oauth/default-broker bt)) | 215 | + ;; is worth exactly one attempt: drop it and go |
| 202 | - (do (reset! status "Opening your browser to sign in…") | 216 | + ;; through the browser, rather than failing the |
| 203 | - (oauth/await-callback! | 217 | + ;; same way on every future Connect. |
| 204 | - oauth/default-broker handle | 218 | + (try (reset! status "Resuming your session…") |
| 205 | - (fn [url] | 219 | + (oauth/refresh-session oauth/default-broker bt) |
| 206 | - (reset! login-url url) | 220 | + (catch Exception _ |
| 207 | - (oauth/open-browser! url) | 221 | + (reset! broker-token nil) |
| 208 | - (reset! status "Waiting for the browser…"))))) | 222 | + (store/clear-session!) |
| 223 | + (reset! status "Saved session expired — signing in again…") | ||
| 224 | + (browser!))) | ||
| 225 | + (browser!)) | ||
| 209 | s (assoc tokens :kind :web-token)] | 226 | s (assoc tokens :kind :web-token)] |
| 210 | (reset! login-url nil) | 227 | (reset! login-url nil) |
| 211 | (reset! broker-token (:broker-token tokens)) | 228 | (reset! broker-token (:broker-token tokens)) |
| 229 | + ;; Saved on every sign-in, not only the first: /session can | ||
| 230 | + ;; hand back a rotated broker token, and the old one may stop | ||
| 231 | + ;; working the moment it does. | ||
| 232 | + (store/save-session! tokens) | ||
| 212 | (reset! session s) | 233 | (reset! session s) |
| 213 | (when (seq (:handle tokens)) (reset! form-handle (:handle tokens))) | 234 | (when (seq (:handle tokens)) (reset! form-handle (:handle tokens))) |
| 214 | s) | 235 | s) |
| @@ -275,6 +296,27 @@ | |||
| 275 | (reset! status "Not connected") | 296 | (reset! status "Not connected") |
| 276 | (reset! error (str "Could not connect: " (describe e)))))))) | 297 | (reset! error (str "Could not connect: " (describe e)))))))) |
| 277 | 298 | ||
| 299 | +(defn restore-session! | ||
| 300 | + "Pick up a saved sign-in at startup. Only the durable broker token comes | ||
| 301 | + back; the connection still mints a fresh web-token from it." | ||
| 302 | + [] | ||
| 303 | + (when-let [saved (store/load-session)] | ||
| 304 | + (reset! broker-token (:broker-token saved)) | ||
| 305 | + (when (seq (:handle saved)) (reset! form-handle (:handle saved))) | ||
| 306 | + (when (seq (:nick saved)) (reset! form-nick (:nick saved))) | ||
| 307 | + (reset! auth-mode :bluesky) | ||
| 308 | + (reset! status (str "Signed in as " (:handle saved) " — Connect to resume")) | ||
| 309 | + saved)) | ||
| 310 | + | ||
| 311 | +(defn forget-session! | ||
| 312 | + "Drop the saved sign-in, on disk and in memory." | ||
| 313 | + [] | ||
| 314 | + (store/clear-session!) | ||
| 315 | + (reset! broker-token nil) | ||
| 316 | + (reset! session nil) | ||
| 317 | + (reset! auth-mode :guest) | ||
| 318 | + (reset! status "Not connected")) | ||
| 319 | + | ||
| 278 | (defn disconnect! [] | 320 | (defn disconnect! [] |
| 279 | (when-let [c @conn] (irc/close! c)) | 321 | (when-let [c @conn] (irc/close! c)) |
| 280 | (reset! conn nil) | 322 | (reset! conn nil) |
added
src/frq/store.jolt +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +(ns frq.store | |
| 2 | + "The one thing worth keeping between runs: the durable broker token. | |
| 3 | + | |
| 4 | + It is a credential — anyone holding it can mint session tokens for the | |
| 5 | + account — so it lives in the user's config directory with the permissions of | |
| 6 | + an ssh key, and never anywhere else. The web-token beside it is single-use | |
| 7 | + and deliberately not saved." | |
| 8 | + (:require [clojure.edn :as edn] | |
| 9 | + [clojure.string :as str] | |
| 10 | + [jolt.host :as host])) | |
| 11 | + | |
| 12 | +(defn config-dir [] | |
| 13 | + (let [xdg (host/getenv "XDG_CONFIG_HOME") | |
| 14 | + home (host/getenv "HOME")] | |
| 15 | + (str (if (seq xdg) xdg (str home "/.config")) "/frq"))) | |
| 16 | + | |
| 17 | +(defn session-file [] (str (config-dir) "/session.edn")) | |
| 18 | + | |
| 19 | +(defn load-session | |
| 20 | + "The saved session, or nil. A file that will not parse is treated as absent — | |
| 21 | + a stale credential is not worth an error at startup." | |
| 22 | + [] | |
| 23 | + (let [path (session-file)] | |
| 24 | + (when (host/file-exists? path) | |
| 25 | + (try | |
| 26 | + (let [m (edn/read-string (slurp path))] | |
| 27 | + (when (and (map? m) (seq (:broker-token m))) m)) | |
| 28 | + (catch Exception _ nil))))) | |
| 29 | + | |
| 30 | +(defn save-session! | |
| 31 | + "Write {:broker-token :handle :did :nick}, readable by nobody else." | |
| 32 | + [session] | |
| 33 | + (let [dir (config-dir) | |
| 34 | + path (session-file)] | |
| 35 | + (try | |
| 36 | + (host/mkdirs! dir) | |
| 37 | + ;; Created before it is written, so the token is never on disk | |
| 38 | + ;; world-readable even for an instant. | |
| 39 | + (host/sh (str "install -m 600 /dev/null '" path "'")) | |
| 40 | + (spit path (pr-str (select-keys session [:broker-token :handle :did :nick]))) | |
| 41 | + (host/sh (str "chmod 600 '" path "'")) | |
| 42 | + true | |
| 43 | + (catch Exception _ false)))) | |
| 44 | + | |
| 45 | +(defn clear-session! [] | |
| 46 | + (try | |
| 47 | + (when (host/file-exists? (session-file)) | |
| 48 | + (host/delete-file! (session-file))) | |
| 49 | + true | |
| 50 | + (catch Exception _ false))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +(ns frq.store | ||
| 2 | + "The one thing worth keeping between runs: the durable broker token. | ||
| 3 | + | ||
| 4 | + It is a credential — anyone holding it can mint session tokens for the | ||
| 5 | + account — so it lives in the user's config directory with the permissions of | ||
| 6 | + an ssh key, and never anywhere else. The web-token beside it is single-use | ||
| 7 | + and deliberately not saved." | ||
| 8 | + (:require [clojure.edn :as edn] | ||
| 9 | + [clojure.string :as str] | ||
| 10 | + [jolt.host :as host])) | ||
| 11 | + | ||
| 12 | +(defn config-dir [] | ||
| 13 | + (let [xdg (host/getenv "XDG_CONFIG_HOME") | ||
| 14 | + home (host/getenv "HOME")] | ||
| 15 | + (str (if (seq xdg) xdg (str home "/.config")) "/frq"))) | ||
| 16 | + | ||
| 17 | +(defn session-file [] (str (config-dir) "/session.edn")) | ||
| 18 | + | ||
| 19 | +(defn load-session | ||
| 20 | + "The saved session, or nil. A file that will not parse is treated as absent — | ||
| 21 | + a stale credential is not worth an error at startup." | ||
| 22 | + [] | ||
| 23 | + (let [path (session-file)] | ||
| 24 | + (when (host/file-exists? path) | ||
| 25 | + (try | ||
| 26 | + (let [m (edn/read-string (slurp path))] | ||
| 27 | + (when (and (map? m) (seq (:broker-token m))) m)) | ||
| 28 | + (catch Exception _ nil))))) | ||
| 29 | + | ||
| 30 | +(defn save-session! | ||
| 31 | + "Write {:broker-token :handle :did :nick}, readable by nobody else." | ||
| 32 | + [session] | ||
| 33 | + (let [dir (config-dir) | ||
| 34 | + path (session-file)] | ||
| 35 | + (try | ||
| 36 | + (host/mkdirs! dir) | ||
| 37 | + ;; Created before it is written, so the token is never on disk | ||
| 38 | + ;; world-readable even for an instant. | ||
| 39 | + (host/sh (str "install -m 600 /dev/null '" path "'")) | ||
| 40 | + (spit path (pr-str (select-keys session [:broker-token :handle :did :nick]))) | ||
| 41 | + (host/sh (str "chmod 600 '" path "'")) | ||
| 42 | + true | ||
| 43 | + (catch Exception _ false)))) | ||
| 44 | + | ||
| 45 | +(defn clear-session! [] | ||
| 46 | + (try | ||
| 47 | + (when (host/file-exists? (session-file)) | ||
| 48 | + (host/delete-file! (session-file))) | ||
| 49 | + true | ||
| 50 | + (catch Exception _ false))) | ||