Refresh access token when needed, automatically auth when page opens

This commit is contained in:
Mikko Ahlroth 2023-09-23 19:37:33 +03:00
parent b08bd39fef
commit faf95b0401
5 changed files with 125 additions and 49 deletions

View file

@ -38,6 +38,11 @@ pub type Tokens {
) )
} }
pub type Credentials {
WithToken(String)
WithUsernamePassword(username: String, password: String)
}
pub type B2CError { pub type B2CError {
HashError(inner: crypto.HashingError) HashError(inner: crypto.HashingError)
RequestError(inner: fetch.FetchError) RequestError(inner: fetch.FetchError)
@ -59,6 +64,42 @@ type Confirmed {
Confirmed(code: String) 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 /// Authenticate to API with B2C authentication, returning the tokens needed
/// for using the API. /// for using the API.
pub fn authenticate( pub fn authenticate(
@ -241,38 +282,34 @@ fn get_tokens(
use resp <- promise.try_await(run_req(req)) 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) json.decode(resp.body, using: dynamic.dynamic)
|> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)), |> b2c_error("Get tokens JSON parsing error: " <> string.inspect(resp.body)),
)) ))
use token <- promise.try_await(promise.resolve(data_get( parse_tokens(data)
data, }
"access_token",
dynamic.string, fn parse_tokens(data: dynamic.Dynamic) {
))) use token <- result.try(data_get(data, "access_token", dynamic.string))
use expires_in <- promise.try_await(promise.resolve(data_get( use expires_in <- result.try(data_get(data, "expires_in", dynamic.int))
data, use refresh_token <- result.try(data_get(
"expires_in",
dynamic.int,
)))
use refresh_token <- promise.try_await(promise.resolve(data_get(
data, data,
"refresh_token", "refresh_token",
dynamic.string, dynamic.string,
))) ))
use refresh_token_expires_in <- promise.try_await(promise.resolve(data_get( use refresh_token_expires_in <- result.try(data_get(
data, data,
"refresh_token_expires_in", "refresh_token_expires_in",
dynamic.int, dynamic.int,
))) ))
promise.resolve(Ok(Tokens( Ok(Tokens(
access_token: token, access_token: token,
access_token_expires_in: expires_in, access_token_expires_in: expires_in,
refresh_token: refresh_token, refresh_token: refresh_token,
refresh_token_expires_in: refresh_token_expires_in, refresh_token_expires_in: refresh_token_expires_in,
))) ))
} }
fn hash_challenge( fn hash_challenge(

View file

@ -10,7 +10,7 @@ import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/http import geo_t/pump_api/http
import geo_t/helpers/date import geo_t/helpers/date
import geo_t/helpers/parsing 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/promise as promise_helpers
import geo_t/helpers/fetch as fetch_helpers import geo_t/helpers/fetch as fetch_helpers
import geo_t/config.{Config} import geo_t/config.{Config}
@ -18,13 +18,12 @@ import geo_t/pump_api/api.{
ApiError, ApiRequestFailed, AuthError, InvalidData, NotOkResponse, ApiError, ApiRequestFailed, AuthError, InvalidData, NotOkResponse,
} }
pub fn auth( pub fn auth_or_refresh(
config: Config, config: Config,
username: String, credentials: Credentials,
password: String,
) -> Promise(Result(User, ApiError)) { ) -> Promise(Result(User, ApiError)) {
use tokens <- promise.try_await( use tokens <- promise.try_await(
b2c.authenticate(config, username, password) b2c.authenticate_or_refresh(config, credentials)
|> promise_helpers.map_error(AuthError), |> promise_helpers.map_error(AuthError),
) )

View file

@ -1,3 +1,4 @@
import gleam/order
import geo_t/helpers/date.{Date} import geo_t/helpers/date.{Date}
pub type Tokens { pub type Tokens {
@ -8,3 +9,12 @@ pub type Tokens {
refresh_token_expiry: Date, 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
}
}

View file

@ -8,6 +8,7 @@ import lustre/element/html
import lustre/effect.{Effect} import lustre/effect.{Effect}
import lustre/element import lustre/element
import plinth/javascript/storage.{Storage} import plinth/javascript/storage.{Storage}
import geo_t/azure/b2c.{Credentials, WithToken, WithUsernamePassword}
import geo_t/helpers/timers import geo_t/helpers/timers
import geo_t/helpers/date import geo_t/helpers/date
import geo_t/config.{Config} 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/installations_view
import geo_t/web/pump_view import geo_t/web/pump_view
import geo_t/pump_api/auth/user.{User} 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/api.{ApiError}
import geo_t/pump_api/auth/api as auth_api import geo_t/pump_api/auth/api as auth_api
import geo_t/web/auth.{AuthInfo, AuthInfoStorage} import geo_t/web/auth.{AuthInfo, AuthInfoStorage}
@ -63,16 +65,21 @@ fn init(_) {
let auth_refresh_effect = let auth_refresh_effect =
effect.from(fn(dispatch) { auth_refresh_check_timer(dispatch) }) 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) -> Some(info) ->
case tokens.is_access_expired(info.user.tokens, 0) {
True -> #(installations_view.init(), effect.none())
False ->
update_installations( update_installations(
installations_view.init(), installations_view.init(),
installations_view.LoadInstallations(config, info.user), installations_view.LoadInstallations(config, info.user),
) )
}
None -> #(installations_view.init(), effect.none()) None -> #(installations_view.init(), effect.none())
} }
#( let model =
Model( Model(
config: config, config: config,
login: login_view.init(), login: login_view.init(),
@ -83,8 +90,11 @@ fn init(_) {
auth: auth_info, auth: auth_info,
logging_in: False, logging_in: False,
logged_in: option.is_some(auth_info), 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,15 +103,36 @@ fn update(model: Model, msg: Msg) {
AuthRefreshCheck -> #(model, auth_refresh_check(model)) AuthRefreshCheck -> #(model, auth_refresh_check(model))
RefreshLogin | LoginView(login_view.AttemptLogin) -> { RefreshLogin | LoginView(login_view.AttemptLogin) -> {
#( 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(
..model, ..model,
logging_in: True, logging_in: True,
login: login_view.update(model.login, login_view.AttemptLogin), login: login_view.update(model.login, login_view.AttemptLogin),
), ),
login(model), login(
model.config,
WithUsernamePassword(model.login.username, model.login.password),
),
) )
} }
}
}
}
LoginView(other) -> #( LoginView(other) -> #(
Model(..model, login: login_view.update(model.login, other)), Model(..model, login: login_view.update(model.login, other)),
@ -212,20 +243,19 @@ fn auth_refresh_check(model: Model) {
case model.auth { case model.auth {
Some(auth) -> { Some(auth) -> {
let now = date.now() case
let assert Ok(last_valid_time) = tokens.is_access_expired(
date.from_unix( auth.user.tokens,
date.to_unix(auth.user.tokens.access_token_expiry) - model.config.api_auth_token_refresh_diff, model.config.api_auth_token_refresh_diff,
) )
{
case date.compare(now, last_valid_time) { True -> {
order.Gt -> {
io.println("[AuthCheck] Auth expiring soon, refreshing...") io.println("[AuthCheck] Auth expiring soon, refreshing...")
dispatch(RefreshLogin) dispatch(RefreshLogin)
Nil Nil
} }
order.Lt | order.Eq -> { False -> {
io.println("[AuthCheck] Auth not expiring yet, no need to refresh.") 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() 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.map(LoginResult)
|> promise.tap(dispatch) |> promise.tap(dispatch)

View file

@ -37,7 +37,7 @@ pub fn load(storage: AuthInfoStorage) {
|> result.map_error(LoadError), |> 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) order.Lt -> Ok(info)
_ -> Error(ExpiredError) _ -> Error(ExpiredError)
} }