-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 => <>, 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).