(ns ird.auth
"Device login flow and API key management. The device endpoints work
anonymously; the API key management endpoints require an account JWT (API
keys cannot manage API keys). Every function returns a channel (see
ird.async)."
(:require [cljs.core.async :as async]
[ird.async :refer [go-try <?]]
[ird.errors :as err]
[ird.node :as node]
[ird.transport :as transport]))
(defn device-start
"POST /auth/device/start — Start a device login. Anonymous allowed.
Delivers the response map with :device_code, :user_code,
:verification_uri, :verification_uri_complete, :interval, :expires_in."
[client]
(transport/request client :post "/auth/device/start"))
(defn device-poll
"POST /auth/device/poll — Poll a device login. Anonymous allowed.
Delivers the response map with :approved."
[client device-code]
(transport/request client :post "/auth/device/poll"
{:body {:device_code device-code}}))
(defn device-complete
"POST /auth/device/complete — Complete a device login. Anonymous allowed.
Delivers the response map with :access_token, :refresh_token, :user."
[client device-code confirmation-code]
(transport/request client :post "/auth/device/complete"
{:body {:device_code device-code
:confirmation_code confirmation-code}}))
(defn list-api-keys
"GET /auth/api-keys — List API keys (account JWT only)."
[client]
(transport/request client :get "/auth/api-keys"))
(defn create-api-key
"POST /auth/api-keys — Create an API key (account JWT only).
BODY is a plain map with :name."
[client body]
(transport/request client :post "/auth/api-keys" {:body body}))
(defn revoke-api-key
"POST /auth/api-keys/{id}/revoke — Revoke an API key (account JWT only)."
[client id]
(transport/request client :post ["auth" "api-keys" id "revoke"]))
(defn device-login
"Convenience: run the whole device login flow.
Starts a device login, hands the start response to ON-PROMPT (default:
prints the verification URL and user code) so a person can approve it in a
browser, then polls until approved and completes the login with the
confirmation code from CONFIRM-FN (a fn of the start response returning a
channel of the code shown to the user after approval; default prompts on
stdin).
OPTS:
:on-prompt fn of the start response; default prints instructions.
:confirm-fn fn of the start response returning a channel of the
confirmation code; default reads a line from stdin.
:interval-ms poll interval; defaults to the server-suggested :interval
(seconds) from the start response.
:timeout-ms overall deadline; defaults to the server's :expires_in.
Delivers the completion map {:access_token :refresh_token :user}; feed
:access_token to ird.client/make-client as :api-key. Delivers an
:ird/timeout error when the login is not approved in time."
([client] (device-login client nil))
([client {:keys [on-prompt confirm-fn interval-ms timeout-ms]}]
(let [on-prompt (or on-prompt
(fn [{:keys [verification_uri_complete verification_uri user_code]}]
(js/console.log "To sign in, open:" (or verification_uri_complete verification_uri))
(js/console.log "Code:" user_code)))
confirm-fn (or confirm-fn
(fn [_] (node/question "Enter the confirmation code shown after approving: ")))]
(go-try
(let [start (<? (device-start client))
;; While the user hasn't approved, the API answers with
;; 400 authorization-pending; 429 slow-down asks for a longer
;; interval (poll-until's backoff grows it). Anything else
;; (401 expired, 403 denied) is terminal and delivered.
poll (fn []
(go-try
(let [resp (async/<! (device-poll client (:device_code start)))]
(if (and (err/api-error? resp)
(contains? #{400 429} (:status (ex-data resp))))
{:approved false}
resp))))]
(on-prompt start)
(<? (transport/poll-until poll :approved
{:interval-ms (or interval-ms (* 1000 (or (:interval start) 5)))
:timeout-ms (or timeout-ms (* 1000 (or (:expires_in start) 600)))
:message "Device login timed out before approval"
:sleep-fn (:sleep-fn client)}))
(<? (device-complete client (:device_code start) (<? (confirm-fn start)))))))))