-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathelfin-auth.el
More file actions
215 lines (197 loc) · 9.15 KB
/
elfin-auth.el
File metadata and controls
215 lines (197 loc) · 9.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
;;; elfin-auth.el --- Jellyfin authentication -*- lexical-binding: t -*-
;;; Commentary:
;; Authenticate to Jellyfin.
;;; Code:
(require 'elfin-vars)
(require 'elfin-api)
(eval-when-compile
(require 'cl-lib))
(defun elfin-auth--ensure-session-file ()
"Create `elfin-session-file' if it doesn't exist and set permissions to 600."
(unless (file-exists-p elfin-session-file)
(make-empty-file elfin-session-file t))
(set-file-modes elfin-session-file #o600))
(defun elfin-save-sessions ()
"Save `elfin--sessions' to `elfin-session-file'."
(when elfin-session-file
(elfin-auth--ensure-session-file)
(with-temp-file elfin-session-file
(prin1 elfin--sessions (current-buffer)))))
(defun elfin-restore-sessions ()
"Restore sessions from `elfin-session-file'.
When called interactively, prompt for which session to activate.
Otherwise, activate the first session."
(interactive)
(when (and elfin-session-file (file-exists-p elfin-session-file))
(setq elfin--sessions
(with-temp-buffer
(insert-file-contents elfin-session-file)
(read (current-buffer))))
(setq elfin--active-session
(if-let* (((and (called-interactively-p 'any) (cdr elfin--sessions)))
(candidates
(mapcar (lambda (s)
(cons (format "%s @ %s"
(plist-get s :username)
(plist-get s :server))
s))
elfin--sessions))
(choice (completing-read "Activate session: "
candidates nil t)))
(cdr (assoc choice candidates))
(car elfin--sessions)))))
;; These macros are variants of the ones in elfin-api that are explicitly
;; designed to be used before we have a session token.
(defmacro elfin-auth--get (server endpoint params &rest body)
"Make unauthenticated GET request to SERVER at ENDPOINT.
PARAMS is a plist of query parameters, or nil. BODY is evaluated
with the response data bound to `response'. If BODY contains
`:else FORM', FORM is used as the error handler with `err' bound;
otherwise a default message is shown."
(declare (indent defun))
(let* ((else-pos (cl-position :else body))
(then (if else-pos (cl-subseq body 0 else-pos) body))
(else-form (when else-pos (nth (1+ else-pos) body))))
`(let* ((query-string (elfin--plist-to-query-string ,params))
(url (concat (string-remove-suffix "/" ,server) ,endpoint query-string)))
(plz 'get url
:headers `(("Authorization" . ,(elfin--auth-header)))
:as #'json-parse-buffer
:then (lambda (response)
(elfin--api-log (format "GET %s -> %S" url response))
,@then)
:else (lambda (err)
(elfin--api-log (format "GET %s -> ERROR %S" url err))
,(or else-form '(message "Request failed: %S" err)))))))
(defmacro elfin-auth--post (server endpoint body &rest rest)
"Make unauthenticated POST request to SERVER at ENDPOINT.
BODY is a JSON-serializable plist. REST is evaluated with the
response data bound to `response'. If REST contains `:else FORM',
FORM is used as the error handler with `err' bound; otherwise a
default message is shown."
(declare (indent defun))
(let* ((else-pos (cl-position :else rest))
(then (if else-pos (cl-subseq rest 0 else-pos) rest))
(else-form (when else-pos (nth (1+ else-pos) rest))))
`(let ((url (concat (string-remove-suffix "/" ,server) ,endpoint)))
(plz 'post url
:headers `(("Content-Type" . "application/json")
("Authorization" . ,(elfin--auth-header)))
:body (json-serialize ,body)
:as #'json-parse-buffer
:then (lambda (response)
(elfin--api-log (format "POST %s -> %S" url response))
,@then)
:else (lambda (err)
(elfin--api-log (format "POST %s -> ERROR %S" url err))
,(or else-form '(message "Request failed: %S" err)))))))
(defun elfin-auth--do-authenticate (server user pass)
"Authenticate with Jellyfin SERVER using USER and PASS.
Save the session in `elfin--sessions' on success."
(elfin-auth--post server "/Users/AuthenticateByName"
`(:Username ,user :Pw ,pass)
(let* ((access-token (gethash "AccessToken" response))
(user-data (gethash "User" response))
(user-id (gethash "Id" user-data))
(session `(:server ,(string-remove-suffix "/" server)
:user-id ,user-id
:access-token ,access-token
:username ,user)))
(push session elfin--sessions)
(setq elfin--active-session session)
(elfin-save-sessions)
(message "Authenticated as %s on %s" user server))
:else (message "Authentication failed for %s: %S" user err)))
(defun elfin-authenticate (server &optional user pass)
"Authenticate with Jellyfin SERVER using USER and PASS.
When called interactively, prompt for the server URL and then
fetch the public user list for completion. USER and PASS may be
provided for non-interactive use."
(interactive
(list (read-string "Jellyfin server URL: ")))
(if (and user pass)
(elfin-auth--do-authenticate server user pass)
(elfin-auth--get server "/Users/Public" nil
(let* ((names (seq-map (lambda (u) (gethash "Name" u)) response))
(user (completing-read "Username: " names nil nil))
(pass (read-passwd "Password: ")))
(elfin-auth--do-authenticate server user pass))
:else (message "Could not reach server %s: %S" server err))))
(defun elfin-auth--qc-poll (server secret code)
"Poll Quick Connect status for SECRET on SERVER.
CODE is the user-facing code shown in messages."
(message "Checking Quick Connect code...")
(elfin-auth--get server "/QuickConnect/Connect" `(:secret ,secret)
(if (eq (gethash "Authenticated" response) t)
(elfin-auth--qc-finish server secret)
(read-string
(format "Not yet approved (code: %s). Press enter to recheck: " code))
(elfin-auth--qc-poll server secret code))
:else (message "Quick Connect polling failed: %S" err)))
(defun elfin-auth--qc-finish (server secret)
"Exchange Quick Connect SECRET for a session token on SERVER."
(elfin-auth--post server "/Users/AuthenticateWithQuickConnect"
`(:Secret ,secret)
(let* ((access-token (gethash "AccessToken" response))
(user-data (gethash "User" response))
(user-id (gethash "Id" user-data))
(username (gethash "Name" user-data))
(session `(:server ,(string-remove-suffix "/" server)
:user-id ,user-id
:access-token ,access-token
:username ,username)))
(push session elfin--sessions)
(setq elfin--active-session session)
(elfin-save-sessions)
(message "Authenticated as %s on %s via Quick Connect" username server))
:else (message "Quick Connect authentication failed: %S" err)))
(defun elfin-quick-connect (server)
"Authenticate with Jellyfin SERVER using Quick Connect.
Initiates a Quick Connect request, displays the code, and waits
for the user to approve it in the Jellyfin dashboard."
(interactive
(list (read-string "Jellyfin server URL: ")))
(elfin-auth--post server "/QuickConnect/Initiate" nil
(let ((secret (gethash "Secret" response))
(code (gethash "Code" response)))
(kill-new code)
(read-string
(format "Quick Connect code: %s (copied). Press enter when approved: " code))
(elfin-auth--qc-poll server secret code))
:else (message "Quick Connect not available on %s: %S" server err)))
(defun elfin-delete-session ()
"Delete a session from `elfin--sessions'.
Prompt for which session to delete. If the deleted session was
active, clear `elfin--active-session'."
(interactive)
(unless elfin--sessions
(user-error "No sessions to delete"))
(let* ((candidates
(mapcar (lambda (s)
(cons (format "%s @ %s"
(plist-get s :username)
(plist-get s :server))
s))
elfin--sessions))
(choice (completing-read "Delete session: " candidates nil t))
(session (cdr (assoc choice candidates))))
(setq elfin--sessions (delq session elfin--sessions))
(when (equal elfin--active-session session)
(setq elfin--active-session nil))
(elfin-save-sessions)
(message "Deleted session: %s" choice)))
(defun elfin-delete-all-sessions (no-confirm)
"Delete all sessions from `elfin--sessions'.
Prompt the user for confirmation unless NO-CONFIRM is non-nil."
(interactive "P")
(unless elfin--sessions
(user-error "No sessions to delete"))
(when (or no-confirm
(yes-or-no-p
(format "Delete all %d session(s)?" (length elfin--sessions))))
(setq elfin--sessions nil
elfin--active-session nil)
(elfin-save-sessions)
(message "All sessions deleted")))
(provide 'elfin-auth)
;;; elfin-auth.el ends here