From faf95b04013aef369d41062efded6e1f49014b1f Mon Sep 17 00:00:00 2001 From: Mikko Ahlroth Date: Sat, 23 Sep 2023 19:37:33 +0300 Subject: [PATCH] Refresh access token when needed, automatically auth when page opens --- src/geo_t/azure/b2c.gleam | 71 ++++++++++++++++++------ src/geo_t/pump_api/auth/api.gleam | 9 ++- src/geo_t/pump_api/auth/tokens.gleam | 10 ++++ src/geo_t/web.gleam | 82 +++++++++++++++++++--------- src/geo_t/web/auth.gleam | 2 +- 5 files changed, 125 insertions(+), 49 deletions(-) diff --git a/src/geo_t/azure/b2c.gleam b/src/geo_t/azure/b2c.gleam index 1616d05..33f78e6 100644 --- a/src/geo_t/azure/b2c.gleam +++ b/src/geo_t/azure/b2c.gleam @@ -38,6 +38,11 @@ pub type Tokens { ) } +pub type Credentials { + WithToken(String) + WithUsernamePassword(username: String, password: String) +} + pub type B2CError { HashError(inner: crypto.HashingError) RequestError(inner: fetch.FetchError) @@ -59,6 +64,42 @@ type Confirmed { Confirmed(code: String) } +/// Authenticate to API and get new tokens with a username/password combination, +/// or by using the refresh token. +pub fn authenticate_or_refresh( + config: Config, + credentials: Credentials, +) -> Promise(Result(Tokens, B2CError)) { + case credentials { + WithUsernamePassword(username, password) -> + authenticate(config, username, password) + WithToken(refresh_token) -> refresh(config, refresh_token) + } +} + +pub fn refresh( + config: Config, + token: String, +) -> Promise(Result(Tokens, B2CError)) { + let request_data = [ + #("refresh_token", token), + #("grant_type", "refresh_token"), + ..base_request_data(config) + ] + + let req = + build_req(get_token_url(config), http.Post) + |> request.set_body(uri_helpers.form_urlencoded_serialize(request_data)) + + use resp <- promise.try_await(run_req(req)) + use data <- promise.map_try(promise.resolve( + json.decode(resp.body, using: dynamic.dynamic) + |> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)), + )) + + parse_tokens(data) +} + /// Authenticate to API with B2C authentication, returning the tokens needed /// for using the API. pub fn authenticate( @@ -241,38 +282,34 @@ fn get_tokens( use resp <- promise.try_await(run_req(req)) - use data <- promise.try_await(promise.resolve( + use data <- promise.map_try(promise.resolve( json.decode(resp.body, using: dynamic.dynamic) |> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)), )) - use token <- promise.try_await(promise.resolve(data_get( - data, - "access_token", - dynamic.string, - ))) - use expires_in <- promise.try_await(promise.resolve(data_get( - data, - "expires_in", - dynamic.int, - ))) - use refresh_token <- promise.try_await(promise.resolve(data_get( + parse_tokens(data) +} + +fn parse_tokens(data: dynamic.Dynamic) { + use token <- result.try(data_get(data, "access_token", dynamic.string)) + use expires_in <- result.try(data_get(data, "expires_in", dynamic.int)) + use refresh_token <- result.try(data_get( data, "refresh_token", dynamic.string, - ))) - use refresh_token_expires_in <- promise.try_await(promise.resolve(data_get( + )) + use refresh_token_expires_in <- result.try(data_get( data, "refresh_token_expires_in", dynamic.int, - ))) + )) - promise.resolve(Ok(Tokens( + Ok(Tokens( access_token: token, access_token_expires_in: expires_in, refresh_token: refresh_token, refresh_token_expires_in: refresh_token_expires_in, - ))) + )) } fn hash_challenge( diff --git a/src/geo_t/pump_api/auth/api.gleam b/src/geo_t/pump_api/auth/api.gleam index 40126c6..5b2c7ea 100644 --- a/src/geo_t/pump_api/auth/api.gleam +++ b/src/geo_t/pump_api/auth/api.gleam @@ -10,7 +10,7 @@ import geo_t/pump_api/auth/installation_info.{InstallationInfo} import geo_t/pump_api/http import geo_t/helpers/date import geo_t/helpers/parsing -import geo_t/azure/b2c +import geo_t/azure/b2c.{Credentials} import geo_t/helpers/promise as promise_helpers import geo_t/helpers/fetch as fetch_helpers import geo_t/config.{Config} @@ -18,13 +18,12 @@ import geo_t/pump_api/api.{ ApiError, ApiRequestFailed, AuthError, InvalidData, NotOkResponse, } -pub fn auth( +pub fn auth_or_refresh( config: Config, - username: String, - password: String, + credentials: Credentials, ) -> Promise(Result(User, ApiError)) { use tokens <- promise.try_await( - b2c.authenticate(config, username, password) + b2c.authenticate_or_refresh(config, credentials) |> promise_helpers.map_error(AuthError), ) diff --git a/src/geo_t/pump_api/auth/tokens.gleam b/src/geo_t/pump_api/auth/tokens.gleam index 224a322..1c56ff1 100644 --- a/src/geo_t/pump_api/auth/tokens.gleam +++ b/src/geo_t/pump_api/auth/tokens.gleam @@ -1,3 +1,4 @@ +import gleam/order import geo_t/helpers/date.{Date} pub type Tokens { @@ -8,3 +9,12 @@ pub type Tokens { refresh_token_expiry: Date, ) } + +pub fn is_access_expired(tokens: Tokens, diff: Int) -> Bool { + let assert Ok(target) = + date.from_unix(date.to_unix(tokens.access_token_expiry) - diff) + case date.compare(date.now(), target) { + order.Gt -> True + _ -> False + } +} diff --git a/src/geo_t/web.gleam b/src/geo_t/web.gleam index 358aad1..2890b08 100644 --- a/src/geo_t/web.gleam +++ b/src/geo_t/web.gleam @@ -8,6 +8,7 @@ import lustre/element/html import lustre/effect.{Effect} import lustre/element import plinth/javascript/storage.{Storage} +import geo_t/azure/b2c.{Credentials, WithToken, WithUsernamePassword} import geo_t/helpers/timers import geo_t/helpers/date import geo_t/config.{Config} @@ -15,6 +16,7 @@ import geo_t/web/login_view import geo_t/web/installations_view import geo_t/web/pump_view import geo_t/pump_api/auth/user.{User} +import geo_t/pump_api/auth/tokens import geo_t/pump_api/api.{ApiError} import geo_t/pump_api/auth/api as auth_api import geo_t/web/auth.{AuthInfo, AuthInfoStorage} @@ -63,16 +65,21 @@ fn init(_) { let auth_refresh_effect = effect.from(fn(dispatch) { auth_refresh_check_timer(dispatch) }) - let #(inst_model, inst_effect) = case auth_info { + let #(inst_model, init_effect) = case auth_info { Some(info) -> - update_installations( - installations_view.init(), - installations_view.LoadInstallations(config, info.user), - ) + case tokens.is_access_expired(info.user.tokens, 0) { + True -> #(installations_view.init(), effect.none()) + + False -> + update_installations( + installations_view.init(), + installations_view.LoadInstallations(config, info.user), + ) + } None -> #(installations_view.init(), effect.none()) } - #( + let model = Model( config: config, login: login_view.init(), @@ -83,8 +90,11 @@ fn init(_) { auth: auth_info, logging_in: False, logged_in: option.is_some(auth_info), - ), - effect.batch([auth_refresh_effect, inst_effect]), + ) + + #( + model, + effect.batch([auth_refresh_effect, auth_refresh_check(model), init_effect]), ) } @@ -93,14 +103,35 @@ fn update(model: Model, msg: Msg) { AuthRefreshCheck -> #(model, auth_refresh_check(model)) RefreshLogin | LoginView(login_view.AttemptLogin) -> { - #( - Model( - ..model, - logging_in: True, - login: login_view.update(model.login, login_view.AttemptLogin), - ), - login(model), - ) + let model = Model(..model, logging_in: True) + + case model.auth { + Some(auth) -> #( + model, + login(model.config, WithToken(auth.user.tokens.refresh_token)), + ) + + None -> { + // We don't have a refresh token, we don't have a username and password -> reset view + case model.login.username { + "" -> #( + Model(..model, logging_in: False, logged_in: False, auth: None), + effect.none(), + ) + _ -> #( + Model( + ..model, + logging_in: True, + login: login_view.update(model.login, login_view.AttemptLogin), + ), + login( + model.config, + WithUsernamePassword(model.login.username, model.login.password), + ), + ) + } + } + } } LoginView(other) -> #( @@ -212,20 +243,19 @@ fn auth_refresh_check(model: Model) { case model.auth { Some(auth) -> { - let now = date.now() - let assert Ok(last_valid_time) = - date.from_unix( - date.to_unix(auth.user.tokens.access_token_expiry) - model.config.api_auth_token_refresh_diff, + case + tokens.is_access_expired( + auth.user.tokens, + model.config.api_auth_token_refresh_diff, ) - - case date.compare(now, last_valid_time) { - order.Gt -> { + { + True -> { io.println("[AuthCheck] Auth expiring soon, refreshing...") dispatch(RefreshLogin) Nil } - order.Lt | order.Eq -> { + False -> { io.println("[AuthCheck] Auth not expiring yet, no need to refresh.") } } @@ -236,10 +266,10 @@ fn auth_refresh_check(model: Model) { } } -fn login(model: Model) -> Effect(Msg) { +fn login(config: Config, credentials: Credentials) -> Effect(Msg) { use dispatch <- effect.from() - auth_api.auth(model.config, model.login.username, model.login.password) + auth_api.auth_or_refresh(config, credentials) |> promise.map(LoginResult) |> promise.tap(dispatch) diff --git a/src/geo_t/web/auth.gleam b/src/geo_t/web/auth.gleam index 3e2e397..e9ff7bf 100644 --- a/src/geo_t/web/auth.gleam +++ b/src/geo_t/web/auth.gleam @@ -37,7 +37,7 @@ pub fn load(storage: AuthInfoStorage) { |> result.map_error(LoadError), ) - case date.compare(date.now(), info.user.tokens.access_token_expiry) { + case date.compare(date.now(), info.user.tokens.refresh_token_expiry) { order.Lt -> Ok(info) _ -> Error(ExpiredError) }