Refresh access token when needed, automatically auth when page opens
This commit is contained in:
parent
b08bd39fef
commit
faf95b0401
5 changed files with 125 additions and 49 deletions
|
@ -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(
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue