-module(voauth).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/voauth.gleam").
-export([config/1, with_on_refresh/2, with_call_timeout_ms/2, with_init_timeout_ms/2, with_refresh_at_percent/2, with_min_refresh_delay_ms/2, with_retry_backoff_ms/2, token_decoder/0, refresh_response_decoder/0, merge_response/2, get_token/1, refresh_now/1, set_token/2, start/1, supervised/1]).
-export_type([vault/0, refresh_error/0, vault_error/0, token/0, refresh_response/0, config/0, message/0, state/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" OAuth2 access-token vault with proactive refresh and bounded retry.\n"
"\n"
" The vault holds the current access token, refreshes it before it\n"
" expires, retries transient failures, and gives up with a typed\n"
" error when the refresh token has been revoked. It does not perform\n"
" the OAuth flow itself; you provide a `Refresh` callback that talks\n"
" to your provider's token endpoint.\n"
"\n"
" One vault per service. To handle multiple providers, start one\n"
" `Vault` per provider and supervise them with your application's\n"
" own supervisor.\n"
"\n"
" A vault always starts without a token. Install one with `set_token`\n"
" after the user completes the OAuth flow, or after rehydrating from\n"
" durable storage at boot.\n"
"\n"
" See the README for a quickstart.\n"
).
-opaque vault() :: {vault, gleam@erlang@process:subject(message()), integer()}.
-type refresh_error() :: {refresh_retryable, binary()} |
{refresh_unauthorized, binary()}.
-type vault_error() :: {refresh_failed, refresh_error()} |
no_refresh_token |
{start_error, binary()}.
-type token() :: {token,
binary(),
integer(),
gleam@option:option(binary()),
binary(),
binary()}.
-type refresh_response() :: {refresh_response,
binary(),
gleam@option:option(integer()),
gleam@option:option(binary()),
gleam@option:option(binary()),
gleam@option:option(binary())}.
-opaque config() :: {config,
fun((binary()) -> {ok, refresh_response()} | {error, refresh_error()}),
gleam@option:option(fun((token()) -> {ok, nil} | {error, binary()})),
integer(),
integer(),
integer(),
integer(),
list(integer())}.
-type message() :: {get_token,
gleam@erlang@process:subject({ok, binary()} | {error, vault_error()})} |
{refresh_now,
gleam@erlang@process:subject({ok, nil} | {error, vault_error()})} |
{scheduled_refresh, integer()} |
{set_token, gleam@erlang@process:subject(nil), token()}.
-type state() :: {state,
config(),
gleam@option:option(token()),
integer(),
integer(),
gleam@option:option(gleam@erlang@process:timer()),
gleam@erlang@process:subject(message())}.
-file("src/voauth.gleam", 137).
?DOC(
" Build a `Config` with defaults. The provider-specific `Refresh`\n"
" callback is the only required argument.\n"
"\n"
" Defaults:\n"
" - `on_refresh`: `None`\n"
" - `call_timeout_ms`: 30_000 — must exceed worst-case `Refresh`\n"
" HTTP latency.\n"
" - `init_timeout_ms`: 1_000\n"
" - `refresh_at_percent`: 80 — proactive refresh at 80% of `expires_in`.\n"
" - `min_refresh_delay_ms`: 60_000 — floor on the proactive delay.\n"
" - `retry_backoff_ms`: `[30_000, 60_000, 120_000]` (30s/1m/2m).\n"
).
-spec config(
fun((binary()) -> {ok, refresh_response()} | {error, refresh_error()})
) -> config().
config(Refresh) ->
{config, Refresh, none, 30000, 1000, 80, 60000, [30000, 60000, 120000]}.
-file("src/voauth.gleam", 151).
?DOC(
" Persist tokens after every successful refresh. Runs inside the\n"
" vault's mailbox; keep it fast.\n"
).
-spec with_on_refresh(config(), fun((token()) -> {ok, nil} | {error, binary()})) -> config().
with_on_refresh(Config, Callback) ->
{config,
erlang:element(2, Config),
{some, Callback},
erlang:element(4, Config),
erlang:element(5, Config),
erlang:element(6, Config),
erlang:element(7, Config),
erlang:element(8, Config)}.
-file("src/voauth.gleam", 158).
?DOC(
" Timeout (ms) for synchronous calls into the vault. Must exceed\n"
" worst-case `Refresh` HTTP latency, because `get_token` blocks on\n"
" a refresh when the cached token has expired.\n"
).
-spec with_call_timeout_ms(config(), integer()) -> config().
with_call_timeout_ms(Config, Ms) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
Ms,
erlang:element(5, Config),
erlang:element(6, Config),
erlang:element(7, Config),
erlang:element(8, Config)}.
-file("src/voauth.gleam", 163).
?DOC(" Timeout (ms) for the actor's initialise phase.\n").
-spec with_init_timeout_ms(config(), integer()) -> config().
with_init_timeout_ms(Config, Ms) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
erlang:element(4, Config),
Ms,
erlang:element(6, Config),
erlang:element(7, Config),
erlang:element(8, Config)}.
-file("src/voauth.gleam", 168).
?DOC(" Proactive refresh fires at this percent of `expires_in`. Default 80.\n").
-spec with_refresh_at_percent(config(), integer()) -> config().
with_refresh_at_percent(Config, Percent) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
erlang:element(4, Config),
erlang:element(5, Config),
Percent,
erlang:element(7, Config),
erlang:element(8, Config)}.
-file("src/voauth.gleam", 174).
?DOC(
" Floor (ms) on the proactive delay. Guards against providers that\n"
" hand out very short `expires_in` values.\n"
).
-spec with_min_refresh_delay_ms(config(), integer()) -> config().
with_min_refresh_delay_ms(Config, Ms) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
erlang:element(4, Config),
erlang:element(5, Config),
erlang:element(6, Config),
Ms,
erlang:element(8, Config)}.
-file("src/voauth.gleam", 180).
?DOC(
" Backoff schedule for retrying a failed scheduled refresh, indexed\n"
" by failed-attempt number. `[]` disables retries.\n"
).
-spec with_retry_backoff_ms(config(), list(integer())) -> config().
with_retry_backoff_ms(Config, Schedule) ->
{config,
erlang:element(2, Config),
erlang:element(3, Config),
erlang:element(4, Config),
erlang:element(5, Config),
erlang:element(6, Config),
erlang:element(7, Config),
Schedule}.
-file("src/voauth.gleam", 189).
?DOC(" Decoder for an OAuth2 token JSON object.\n").
-spec token_decoder() -> gleam@dynamic@decode:decoder(token()).
token_decoder() ->
gleam@dynamic@decode:field(
<<"access_token"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Access_token) ->
gleam@dynamic@decode:field(
<<"expires_in"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_int/1},
fun(Expires_in) ->
gleam@dynamic@decode:field(
<<"refresh_token"/utf8>>,
gleam@dynamic@decode:optional(
{decoder, fun gleam@dynamic@decode:decode_string/1}
),
fun(Refresh_token) ->
gleam@dynamic@decode:field(
<<"scope"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_string/1},
fun(Scope) ->
gleam@dynamic@decode:field(
<<"token_type"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_string/1},
fun(Token_type) ->
gleam@dynamic@decode:success(
{token,
Access_token,
Expires_in,
Refresh_token,
Scope,
Token_type}
)
end
)
end
)
end
)
end
)
end
).
-file("src/voauth.gleam", 209).
?DOC(
" Decoder for a refresh response. Fields other than `access_token`\n"
" are optional per RFC 6749 §6 and decode to `None` when absent.\n"
).
-spec refresh_response_decoder() -> gleam@dynamic@decode:decoder(refresh_response()).
refresh_response_decoder() ->
gleam@dynamic@decode:field(
<<"access_token"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Access_token) ->
gleam@dynamic@decode:optional_field(
<<"expires_in"/utf8>>,
none,
gleam@dynamic@decode:optional(
{decoder, fun gleam@dynamic@decode:decode_int/1}
),
fun(Expires_in) ->
gleam@dynamic@decode:optional_field(
<<"refresh_token"/utf8>>,
none,
gleam@dynamic@decode:optional(
{decoder, fun gleam@dynamic@decode:decode_string/1}
),
fun(Refresh_token) ->
gleam@dynamic@decode:optional_field(
<<"scope"/utf8>>,
none,
gleam@dynamic@decode:optional(
{decoder,
fun gleam@dynamic@decode:decode_string/1}
),
fun(Scope) ->
gleam@dynamic@decode:optional_field(
<<"token_type"/utf8>>,
none,
gleam@dynamic@decode:optional(
{decoder,
fun gleam@dynamic@decode:decode_string/1}
),
fun(Token_type) ->
gleam@dynamic@decode:success(
{refresh_response,
Access_token,
Expires_in,
Refresh_token,
Scope,
Token_type}
)
end
)
end
)
end
)
end
)
end
).
-file("src/voauth.gleam", 242).
?DOC(
" Merge a refresh response onto the previous token, carrying\n"
" forward any field the server omitted.\n"
).
-spec merge_response(token(), refresh_response()) -> token().
merge_response(Previous, Resp) ->
{token,
erlang:element(2, Resp),
gleam@option:unwrap(
erlang:element(3, Resp),
erlang:element(3, Previous)
),
gleam@option:'or'(erlang:element(4, Resp), erlang:element(4, Previous)),
gleam@option:unwrap(
erlang:element(5, Resp),
erlang:element(5, Previous)
),
gleam@option:unwrap(
erlang:element(6, Resp),
erlang:element(6, Previous)
)}.
-file("src/voauth.gleam", 276).
?DOC(
" Return the current valid access token. Refreshes synchronously if\n"
" the cached token has expired. Returns `NoRefreshToken` if no token\n"
" has been installed yet.\n"
).
-spec get_token(vault()) -> {ok, binary()} | {error, vault_error()}.
get_token(Vault) ->
gleam@erlang@process:call(
erlang:element(2, Vault),
erlang:element(3, Vault),
fun(Field@0) -> {get_token, Field@0} end
).
-file("src/voauth.gleam", 282).
?DOC(
" Force a refresh now, regardless of remaining validity. Returns\n"
" `NoRefreshToken` if no token has been installed yet.\n"
).
-spec refresh_now(vault()) -> {ok, nil} | {error, vault_error()}.
refresh_now(Vault) ->
gleam@erlang@process:call(
erlang:element(2, Vault),
erlang:element(3, Vault),
fun(Field@0) -> {refresh_now, Field@0} end
).
-file("src/voauth.gleam", 289).
?DOC(
" Install or replace the vault's token. Schedules a fresh proactive\n"
" refresh. Use it after the OAuth flow completes, or after a\n"
" re-authorisation hands you a new token.\n"
).
-spec set_token(vault(), token()) -> nil.
set_token(Vault, Token) ->
gleam@erlang@process:call(
erlang:element(2, Vault),
erlang:element(3, Vault),
fun(Reply) -> {set_token, Reply, Token} end
).
-file("src/voauth.gleam", 511).
-spec run_on_refresh(config(), token()) -> {ok, nil} | {error, binary()}.
run_on_refresh(Config, Token) ->
case erlang:element(3, Config) of
none ->
{ok, nil};
{some, Callback} ->
Callback(Token)
end.
-file("src/voauth.gleam", 518).
-spec describe_error(vault_error()) -> binary().
describe_error(Err) ->
case Err of
{refresh_failed, {refresh_retryable, Reason}} ->
<<"retryable: "/utf8, Reason/binary>>;
{refresh_failed, {refresh_unauthorized, Reason@1}} ->
<<"unauthorized: "/utf8, Reason@1/binary>>;
no_refresh_token ->
<<"no refresh token"/utf8>>;
{start_error, Reason@2} ->
<<"vault start error: "/utf8, Reason@2/binary>>
end.
-file("src/voauth.gleam", 533).
?DOC(
" Look up the backoff delay for the given failed-attempt number.\n"
" Returns `None` if the schedule is exhausted (caller should give up).\n"
).
-spec retry_backoff_at(list(integer()), integer()) -> gleam@option:option(integer()).
retry_backoff_at(Schedule, Failed_attempt) ->
_pipe = Schedule,
_pipe@1 = gleam@list:drop(_pipe, Failed_attempt),
_pipe@2 = gleam@list:first(_pipe@1),
gleam@option:from_result(_pipe@2).
-file("src/voauth.gleam", 540).
-spec schedule_proactive(
gleam@erlang@process:subject(message()),
config(),
integer()
) -> gleam@erlang@process:timer().
schedule_proactive(Self, Config, Expires_in_seconds) ->
Delay_ms = ((Expires_in_seconds * 1000) * erlang:element(6, Config)) div 100,
Delay_ms@1 = gleam@int:max(Delay_ms, erlang:element(7, Config)),
gleam@erlang@process:send_after(Self, Delay_ms@1, {scheduled_refresh, 0}).
-file("src/voauth.gleam", 552).
?DOC(
" Schedule the next retry attempt after a failed scheduled refresh.\n"
" Returns `None` if the backoff schedule is exhausted.\n"
).
-spec schedule_retry(
gleam@erlang@process:subject(message()),
list(integer()),
integer()
) -> gleam@option:option(gleam@erlang@process:timer()).
schedule_retry(Self, Schedule, Failed_attempt) ->
case retry_backoff_at(Schedule, Failed_attempt) of
{some, Delay} ->
{some,
gleam@erlang@process:send_after(
Self,
Delay,
{scheduled_refresh, Failed_attempt + 1}
)};
none ->
none
end.
-file("src/voauth.gleam", 436).
-spec handle_refresh_failure(integer(), vault_error(), state()) -> gleam@otp@actor:next(state(), message()).
handle_refresh_failure(Attempt, Err, State) ->
case Err of
{refresh_failed, {refresh_unauthorized, _}} ->
logging:log(
warning,
<<"voauth: refresh giving up (not retryable): "/utf8,
(describe_error(Err))/binary>>
),
gleam@otp@actor:continue(State);
no_refresh_token ->
logging:log(
warning,
<<"voauth: refresh giving up (not retryable): "/utf8,
(describe_error(Err))/binary>>
),
gleam@otp@actor:continue(State);
{start_error, _} ->
logging:log(
warning,
<<"voauth: refresh giving up (not retryable): "/utf8,
(describe_error(Err))/binary>>
),
gleam@otp@actor:continue(State);
{refresh_failed, {refresh_retryable, _}} ->
case schedule_retry(
erlang:element(7, State),
erlang:element(8, erlang:element(2, State)),
Attempt
) of
{some, Timer} ->
gleam@otp@actor:continue(
{state,
erlang:element(2, State),
erlang:element(3, State),
erlang:element(4, State),
erlang:element(5, State),
{some, Timer},
erlang:element(7, State)}
);
none ->
logging:log(
warning,
<<<<<<"voauth: refresh giving up after "/utf8,
(erlang:integer_to_binary(Attempt + 1))/binary>>/binary,
" attempts: "/utf8>>/binary,
(describe_error(Err))/binary>>
),
gleam@otp@actor:continue(State)
end
end.
-file("src/voauth.gleam", 564).
-spec cancel_timer_in_state(state()) -> state().
cancel_timer_in_state(State) ->
case erlang:element(6, State) of
{some, Timer} ->
gleam@erlang@process:cancel_timer(Timer),
{state,
erlang:element(2, State),
erlang:element(3, State),
erlang:element(4, State),
erlang:element(5, State),
none,
erlang:element(7, State)};
none ->
State
end.
-file("src/voauth.gleam", 417).
-spec handle_set_token(gleam@erlang@process:subject(nil), token(), state()) -> gleam@otp@actor:next(state(), message()).
handle_set_token(Reply, Token, State) ->
State@1 = cancel_timer_in_state(State),
Timer = schedule_proactive(
erlang:element(7, State@1),
erlang:element(2, State@1),
erlang:element(3, Token)
),
New_state = {state,
erlang:element(2, State@1),
{some, Token},
voauth_ffi:monotonic_ms(),
erlang:element(3, Token) * 1000,
{some, Timer},
erlang:element(7, State@1)},
gleam@erlang@process:send(Reply, nil),
gleam@otp@actor:continue(New_state).
-file("src/voauth.gleam", 470).
-spec attempt_refresh(state()) -> {state(), {ok, nil} | {error, vault_error()}}.
attempt_refresh(State) ->
State@1 = cancel_timer_in_state(State),
case erlang:element(3, State@1) of
{some, Current_token} ->
case erlang:element(4, Current_token) of
{some, Refresh_token} ->
case (erlang:element(2, erlang:element(2, State@1)))(
Refresh_token
) of
{ok, Resp} ->
Token = merge_response(Current_token, Resp),
case run_on_refresh(
erlang:element(2, State@1),
Token
) of
{ok, nil} ->
nil;
{error, Reason} ->
logging:log(
error,
<<"voauth: on_refresh callback failed: "/utf8,
Reason/binary>>
)
end,
Timer = schedule_proactive(
erlang:element(7, State@1),
erlang:element(2, State@1),
erlang:element(3, Token)
),
New_state = {state,
erlang:element(2, State@1),
{some, Token},
voauth_ffi:monotonic_ms(),
erlang:element(3, Token) * 1000,
{some, Timer},
erlang:element(7, State@1)},
{New_state, {ok, nil}};
{error, Refresh_error} ->
{State@1, {error, {refresh_failed, Refresh_error}}}
end;
none ->
{State@1, {error, no_refresh_token}}
end;
none ->
{State@1, {error, no_refresh_token}}
end.
-file("src/voauth.gleam", 347).
-spec handle_get_token(
gleam@erlang@process:subject({ok, binary()} | {error, vault_error()}),
state()
) -> gleam@otp@actor:next(state(), message()).
handle_get_token(Reply, State) ->
case erlang:element(3, State) of
none ->
gleam@erlang@process:send(Reply, {error, no_refresh_token}),
gleam@otp@actor:continue(State);
{some, Token} ->
Elapsed = voauth_ffi:monotonic_ms() - erlang:element(4, State),
case Elapsed < erlang:element(5, State) of
true ->
gleam@erlang@process:send(
Reply,
{ok, erlang:element(2, Token)}
),
gleam@otp@actor:continue(State);
false ->
{New_state, Result} = attempt_refresh(State),
Reply_value = case Result of
{ok, _} ->
T@1 = case erlang:element(3, New_state) of
{some, T} -> T;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"voauth"/utf8>>,
function => <<"handle_get_token"/utf8>>,
line => 367,
value => _assert_fail,
start => 12046,
'end' => 12082,
pattern_start => 12057,
pattern_end => 12064})
end,
{ok, erlang:element(2, T@1)};
{error, Err} ->
{error, Err}
end,
gleam@erlang@process:send(Reply, Reply_value),
gleam@otp@actor:continue(New_state)
end
end.
-file("src/voauth.gleam", 380).
-spec handle_refresh_now(
gleam@erlang@process:subject({ok, nil} | {error, vault_error()}),
state()
) -> gleam@otp@actor:next(state(), message()).
handle_refresh_now(Reply, State) ->
case erlang:element(3, State) of
none ->
gleam@erlang@process:send(Reply, {error, no_refresh_token}),
gleam@otp@actor:continue(State);
{some, _} ->
{New_state, Result} = attempt_refresh(State),
gleam@erlang@process:send(Reply, Result),
gleam@otp@actor:continue(New_state)
end.
-file("src/voauth.gleam", 397).
-spec handle_scheduled_refresh(integer(), state()) -> gleam@otp@actor:next(state(), message()).
handle_scheduled_refresh(Attempt, State) ->
State@1 = {state,
erlang:element(2, State),
erlang:element(3, State),
erlang:element(4, State),
erlang:element(5, State),
none,
erlang:element(7, State)},
case erlang:element(3, State@1) of
{some, _} ->
{New_state, Result} = attempt_refresh(State@1),
case Result of
{ok, _} ->
gleam@otp@actor:continue(New_state);
{error, Err} ->
handle_refresh_failure(Attempt, Err, New_state)
end;
none ->
gleam@otp@actor:continue(State@1)
end.
-file("src/voauth.gleam", 338).
-spec handle_message(state(), message()) -> gleam@otp@actor:next(state(), message()).
handle_message(State, Message) ->
case Message of
{get_token, Reply} ->
handle_get_token(Reply, State);
{refresh_now, Reply@1} ->
handle_refresh_now(Reply@1, State);
{scheduled_refresh, Attempt} ->
handle_scheduled_refresh(Attempt, State);
{set_token, Reply@2, Token} ->
handle_set_token(Reply@2, Token, State)
end.
-file("src/voauth.gleam", 317).
-spec do_start(config()) -> {ok, gleam@otp@actor:started(vault())} |
{error, gleam@otp@actor:start_error()}.
do_start(Config) ->
Initialise = fun(Self) ->
State = {state, Config, none, 0, 0, none, Self},
_pipe = gleam@otp@actor:initialised(State),
_pipe@1 = gleam@otp@actor:returning(
_pipe,
{vault, Self, erlang:element(4, Config)}
),
{ok, _pipe@1}
end,
_pipe@2 = gleam@otp@actor:new_with_initialiser(
erlang:element(5, Config),
Initialise
),
_pipe@3 = gleam@otp@actor:on_message(_pipe@2, fun handle_message/2),
gleam@otp@actor:start(_pipe@3).
-file("src/voauth.gleam", 258).
?DOC(
" Start a vault. The vault begins without a token; install one with\n"
" `set_token` before calling `get_token` or `refresh_now`.\n"
).
-spec start(config()) -> {ok, vault()} | {error, vault_error()}.
start(Config) ->
_pipe = do_start(Config),
_pipe@1 = gleam@result:map(
_pipe,
fun(Started) -> erlang:element(3, Started) end
),
gleam@result:map_error(
_pipe@1,
fun(E) -> {start_error, gleam@string:inspect(E)} end
).
-file("src/voauth.gleam", 269).
?DOC(
" Build a child specification for a `gleam_otp` supervisor. On\n"
" restart the supervisor calls `start` with the same `config`. To\n"
" rehydrate a persisted token on restart, write your own\n"
" `supervision.worker` that reads from durable storage and calls\n"
" `start` and `set_token`.\n"
).
-spec supervised(config()) -> gleam@otp@supervision:child_specification(vault()).
supervised(Config) ->
gleam@otp@supervision:worker(fun() -> do_start(Config) end).