If you can model the authentication process as a state machine, have a pure
function which accepts auth-state and data and returns either a new state
or an operation to get new data to determine the next state.
E.g. (next-auth-state {:stage :not-authed :login "login" :password "pass"}
nil) => {:state {:stage :not-authed ...} :operations [{:kind
:get-auth-token :args {:login "login" ...}]}}. Then (next-auth-state
{:stage :not-authed} {:auth-token ""}) => {state {:stage :has-token :token
"..." :expires "..."} :operations [{:kind :get-session-token :args
{:session-token "token"}{:kind :invalidate-token :args {:session-token
"token"}}] etc.
In state machine graph terms, the :stage is the node you are on, the
:operations are actions which you can take to return data which represent
the edges the state machine can follow.
Then you have a higher-level reduction function which accepts any auth
state map and keeps running the state machine until it can return a map
that is suitable for authentication. This function is responsible (possibly
indirectly) for turning operation requests into http calls and collecting
the responses, i.e. is impure, but not necessarily stateful or mutable.
E.g. auth-state map could start in {:stage :not-authed :login "login"
:password "pass"}. (authorize auth-state) is a reduction function that runs
(get-auth-token auth-state), returns {:stage :token :expires #inst"..."},
then authorize inspects return value and runs (open-session {:stage :token
:expires "..."}) etc, until either a failure or success.
Lower-level client api calls receive only auth-maps with a non-expired
stage=:session. They must return, in addition to success or failure of the
call itself, any auth-related state info from the server which should alter
the auth-map somehow. Another function (maybe authorize, maybe something
else) integrates this state with the passed-in auth map and returns a new
(possibly unchanged) auth-map, which can be used for future calls.
Higher-level client api functions should combine api calls with auth-state
updating. If you keep your fn argument and return structure regular enough,
you might even be able to do this with a single higher-order function:
(call-with-auth low-level-client-api-fn auth-map args) -> {:result x
:auth-map new-auth-map-state}
Only at this point might it be convenient to have a higher-level, stateful
construct which completely hides the auth-state map from you. e.g.
(make-session login pass) => {:auth-state (atom {:state :not-authed :login
login :pass pass})},
then (call-with-session session client-fn args) => result-only, and mutates
the auth state in the atom for you. But the emphasis here is on programmer
convenience (not having to collect the new-auth-state return value and
propagate it around), not any necessity of modeling the auth flow.
You still need to think about concurrency at the communication level. If
two client api calls run at the same time, both with expired tokens, what
can happen? Can both independently run the update with separate http calls
and get different tokens (or will the server issue the same token to both)?
Should only one http communication happen on the client side and the client
is responsible for blocking callers that want a new token until a single
token is reissued?
Your decision affects the implementation of the state-machine driving
function (the one that executes operations and calls next-state
repeatedly): you might put a token and session cache in there, or put
operations related to the same login on their own queue so there is only a
single writer per login, or whatever. But the hard auth flow logic in your
next-state function remains the same.
On, Friday, April 15, 2016 at 7:40:48 AM UTC-5, Stefan Kamphausen wrote:
>
> Hi,
>
>
> Currently, I am in the process of writing a client to server API which is
> not trivial to consume. In particular it needs a 3-step authentication
> process: login with user name and password, get an authentication token,
> open a session with the token and finally consume the API with the
> session. Sessions and tokens can expire and the client should handle that
> transparently: if it has a token, create a new session, if the token
> expired and it has username and password, create a new token... So,
> sessions and tokens would have to be local, mutable, encapsulated state, as
> far as I can see.
>
> Now; I wonder how to best model this in Clojure.
>
> My favorite right now is, creating a closure over a local atom and return
> it to the user. The downside to this is that it feels unnatural to consume
> different parts of the API, e.g. (client :do-something ) vs (client
> :do-something-else & other-args). It would be nice to defined a protocol
> with function do-something and do-something-else but then I would have to
> pass the atom as an argument to the record which feels even worse.
>
> Am I missing an obvious other solution?