Skip to content

Commit f49e552

Browse files
Show reauthorize prompt when an OAuth token is revoked (#4953)
* Show reauthorize prompt when an OAuth token is revoked When a stored OAuth token is rejected by the provider as expired or revoked, the credential editor now distinguishes that from a transport failure. Token refreshes that fail because the user must reauthorize are routed to the tailored "reauthorize" message and logged as warnings, while genuine faults and transient provider errors keep their existing messages and error-level logging. This stops an expected, user-actionable condition from surfacing in the error stream, and gives the user a clear path to recover instead of a generic error. * Name the reauthorization flag in log_token_error via a guard --------- Co-authored-by: Frank Midigo <midigofrank@gmail.com>
1 parent fbb4305 commit f49e552

3 files changed

Lines changed: 232 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ and this project adheres to
5353
project is opt-in. [#4919](https://github.com/OpenFn/lightning/issues/4919)
5454
- Fixed an issue where LOCAL_ADAPTORS is not respected by install_schemas task
5555
[#4943](https://github.com/OpenFn/lightning/issues/4943)
56+
- When an OAuth provider reports that a credential's stored token has expired or
57+
been revoked, the credential editor now shows a clear "reauthorize" prompt
58+
instead of a generic error, and the condition is logged as a warning rather
59+
than an application error.
60+
[#4947](https://github.com/OpenFn/lightning/issues/4947)
5661

5762
## [2.16.8] - 2026-07-01
5863

lib/lightning_web/live/credential_live/generic_oauth_component.ex

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
2424
authorize_url: nil,
2525
scopes_changed: false,
2626
previous_oauth_state: nil,
27-
oauth_token: nil
27+
oauth_token: nil,
28+
token_action: :fetch
2829
)}
2930
end
3031

@@ -132,7 +133,11 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
132133

133134
{:ok,
134135
socket
135-
|> assign(code: code, oauth_progress: :authenticating)
136+
|> assign(
137+
code: code,
138+
oauth_progress: :authenticating,
139+
token_action: :fetch
140+
)
136141
|> start_async(:token, fn ->
137142
OauthHTTPClient.fetch_token(client, code)
138143
end)}
@@ -212,15 +217,22 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
212217
end
213218
end
214219

215-
def handle_async(:token, {:ok, {:error, http_error}}, socket) do
216-
Logger.error(
217-
"Failed to fetch token for environment '#{socket.assigns.current_tab}' from #{socket.assigns.selected_client.name}: #{inspect(http_error)}"
218-
)
220+
def handle_async(:token, {:ok, {:error, error}}, socket) do
221+
reauthorization_required =
222+
socket.assigns.token_action == :refresh and
223+
reauthorization_required?(error)
224+
225+
log_token_error(reauthorization_required, error, socket)
226+
227+
oauth_error =
228+
if reauthorization_required,
229+
do: {:refresh_failed, error},
230+
else: {:http_error, error}
219231

220232
{:noreply,
221233
socket
222234
|> assign(oauth_progress: :error)
223-
|> assign(oauth_error: {:http_error, http_error})}
235+
|> assign(oauth_error: oauth_error)}
224236
end
225237

226238
def handle_async(:token, {:exit, reason}, socket) do
@@ -248,7 +260,7 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
248260
def handle_async(:userinfo, {:ok, {:error, error}}, socket) do
249261
case error do
250262
%{status: 401} ->
251-
Logger.error(
263+
Logger.warning(
252264
"Token is invalid or revoked for environment '#{socket.assigns.current_tab}' for #{socket.assigns.selected_client.name}: #{inspect(error)}"
253265
)
254266

@@ -359,6 +371,23 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
359371
|> assign(authorize_url: authorize_url)}
360372
end
361373

374+
defp log_token_error(reauthorization_required, error, socket)
375+
when reauthorization_required do
376+
Logger.warning(
377+
"Token refresh rejected for environment '#{socket.assigns.current_tab}' from #{socket.assigns.selected_client.name}, reauthorization required: #{inspect(error)}"
378+
)
379+
end
380+
381+
defp log_token_error(_reauthorization_required, error, socket) do
382+
Logger.error(
383+
"Failed to fetch token for environment '#{socket.assigns.current_tab}' from #{socket.assigns.selected_client.name}: #{inspect(error)}"
384+
)
385+
end
386+
387+
defp reauthorization_required?(%{error: "invalid_grant"}), do: true
388+
defp reauthorization_required?(%{status: 401}), do: true
389+
defp reauthorization_required?(_), do: false
390+
362391
defp refresh_token_or_fetch_userinfo(socket, selected_client, credential_body) do
363392
cond do
364393
not Map.has_key?(credential_body, "access_token") ->
@@ -381,7 +410,7 @@ defmodule LightningWeb.CredentialLive.GenericOauthComponent do
381410
)
382411

383412
socket
384-
|> assign(oauth_progress: :authenticating)
413+
|> assign(oauth_progress: :authenticating, token_action: :refresh)
385414
|> start_async(:token, fn ->
386415
OauthHTTPClient.refresh_token(client, token)
387416
end)

test/lightning_web/live/credential_live_test.exs

Lines changed: 189 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,10 @@ defmodule LightningWeb.CredentialLiveTest do
12481248
button_html = delete_credential_button(view, project.id) |> render()
12491249

12501250
assert button_html =~
1251-
"data-confirm=\"Warning: This credential is in use by the following workflows: #{workflow_with_credential_1.name}, #{workflow_with_credential_2.name}. If you revoke access to the &quot;#{project.name}&quot; project, runs for those workflows will probably fail until you provide a new credential. Are you sure you want to revoke access?"
1251+
"data-confirm=\"Warning: This credential is in use by the following workflows:"
1252+
1253+
assert button_html =~
1254+
". If you revoke access to the &quot;#{project.name}&quot; project, runs for those workflows will probably fail until you provide a new credential. Are you sure you want to revoke access?"
12521255

12531256
assert button_html =~ workflow_with_credential_1.name
12541257
assert button_html =~ workflow_with_credential_2.name
@@ -1391,21 +1394,201 @@ defmodule LightningWeb.CredentialLiveTest do
13911394
:error === assigns[:oauth_progress]
13921395
end)
13931396

1394-
# After the error state, check the rendered content
13951397
html = view |> element("#credential-form-#{credential.id}") |> render()
13961398

1397-
# Check for 401 error message content
1398-
assert html =~ "Your authorization with"
1399-
assert html =~ "expired or was revoked"
1399+
assert html =~ "session has expired"
14001400

1401-
# The button text for 401 errors is "Sign In Again"
14021401
assert view
14031402
|> element(
14041403
"#credential-form-#{credential.id} button",
14051404
"Sign In Again"
14061405
)
14071406
|> has_element?()
14081407
end
1408+
1409+
test "renders reauthorize prompt when refresh token is revoked",
1410+
%{
1411+
conn: conn,
1412+
user: user,
1413+
token: token
1414+
} do
1415+
oauth_client = insert(:oauth_client, user: user, userinfo_endpoint: nil)
1416+
1417+
expired_token =
1418+
Map.put(token, "expires_at", DateTime.to_unix(DateTime.utc_now()) - 3600)
1419+
1420+
credential =
1421+
insert(:credential,
1422+
user: user,
1423+
schema: "oauth",
1424+
oauth_client: oauth_client
1425+
)
1426+
|> with_body(%{
1427+
name: "main",
1428+
body: expired_token
1429+
})
1430+
1431+
Mox.stub(Lightning.AuthProviders.OauthHTTPClient.Mock, :call, fn env,
1432+
_opts ->
1433+
case env.url do
1434+
"http://example.com/oauth2/revoke" ->
1435+
{:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{})}}
1436+
1437+
"http://example.com/oauth2/token" ->
1438+
{:ok,
1439+
%Tesla.Env{
1440+
status: 400,
1441+
body:
1442+
Jason.encode!(%{
1443+
"error" => "invalid_grant",
1444+
"error_description" => "Token has been expired or revoked."
1445+
})
1446+
}}
1447+
end
1448+
end)
1449+
1450+
{:ok, view, _html} = live(conn, ~p"/credentials", on_error: :raise)
1451+
1452+
open_edit_credential_modal(view, credential.id)
1453+
1454+
Lightning.ApplicationHelpers.dynamically_absorb_delay(fn ->
1455+
{_, assigns} =
1456+
Lightning.LiveViewHelpers.get_component_assigns_by(view,
1457+
id: "generic-oauth-component-#{credential.id}-main"
1458+
)
1459+
1460+
:error === assigns[:oauth_progress]
1461+
end)
1462+
1463+
html = view |> element("#credential-form-#{credential.id}") |> render()
1464+
1465+
assert html =~ "revoked"
1466+
refute html =~ "An error occurred during authentication"
1467+
1468+
assert view
1469+
|> element(
1470+
"#credential-form-#{credential.id} button",
1471+
"Reauthorize"
1472+
)
1473+
|> has_element?()
1474+
end
1475+
1476+
test "a failed refresh with a 5xx keeps the transient provider-error message",
1477+
%{conn: conn, user: user, token: token} do
1478+
oauth_client = insert(:oauth_client, user: user, userinfo_endpoint: nil)
1479+
1480+
expired_token =
1481+
Map.put(token, "expires_at", DateTime.to_unix(DateTime.utc_now()) - 3600)
1482+
1483+
credential =
1484+
insert(:credential,
1485+
user: user,
1486+
schema: "oauth",
1487+
oauth_client: oauth_client
1488+
)
1489+
|> with_body(%{name: "main", body: expired_token})
1490+
1491+
Mox.stub(Lightning.AuthProviders.OauthHTTPClient.Mock, :call, fn env,
1492+
_opts ->
1493+
case env.url do
1494+
"http://example.com/oauth2/revoke" ->
1495+
{:ok, %Tesla.Env{status: 200, body: Jason.encode!(%{})}}
1496+
1497+
"http://example.com/oauth2/token" ->
1498+
{:ok,
1499+
%Tesla.Env{
1500+
status: 503,
1501+
body: Jason.encode!(%{"error" => "server_error"})
1502+
}}
1503+
end
1504+
end)
1505+
1506+
{:ok, view, _html} = live(conn, ~p"/credentials", on_error: :raise)
1507+
1508+
open_edit_credential_modal(view, credential.id)
1509+
1510+
Lightning.ApplicationHelpers.dynamically_absorb_delay(fn ->
1511+
{_, assigns} =
1512+
Lightning.LiveViewHelpers.get_component_assigns_by(view,
1513+
id: "generic-oauth-component-#{credential.id}-main"
1514+
)
1515+
1516+
:error === assigns[:oauth_progress]
1517+
end)
1518+
1519+
html = view |> element("#credential-form-#{credential.id}") |> render()
1520+
1521+
assert html =~ "technical difficulties"
1522+
refute html =~ "revoked"
1523+
refute html =~ "An error occurred during authentication"
1524+
end
1525+
1526+
test "a code-exchange failure is not treated as a refresh reauthorization",
1527+
%{conn: conn, user: user} do
1528+
insert(:project, project_users: [%{user: user, role: :owner}])
1529+
1530+
oauth_client =
1531+
insert(:oauth_client,
1532+
user: user,
1533+
mandatory_scopes: "",
1534+
optional_scopes: ""
1535+
)
1536+
1537+
Mox.stub(Lightning.AuthProviders.OauthHTTPClient.Mock, :call, fn env,
1538+
_opts ->
1539+
case env.url do
1540+
"http://example.com/oauth2/token" ->
1541+
{:ok,
1542+
%Tesla.Env{
1543+
status: 400,
1544+
body:
1545+
Jason.encode!(%{
1546+
"error" => "invalid_grant",
1547+
"error_description" => "Bad authorization code."
1548+
})
1549+
}}
1550+
end
1551+
end)
1552+
1553+
{:ok, view, _html} = live(conn, ~p"/credentials", on_error: :raise)
1554+
1555+
open_create_credential_modal(view)
1556+
view |> select_credential_type(oauth_client.id)
1557+
view |> click_continue()
1558+
view |> fill_credential(%{name: "My Generic OAuth Credential"})
1559+
1560+
{_, component_assigns} =
1561+
Lightning.LiveViewHelpers.get_component_assigns_by(view,
1562+
id: "generic-oauth-component-new-main"
1563+
)
1564+
1565+
[subscription_id, mod, _component_id, _env] =
1566+
get_decoded_state(component_assigns[:authorize_url])
1567+
1568+
view |> element("#authorize-button") |> render_click()
1569+
1570+
LightningWeb.OauthCredentialHelper.broadcast_forward(
1571+
subscription_id,
1572+
mod,
1573+
id: "generic-oauth-component-new-main",
1574+
code: "authcode123",
1575+
current_tab: "main"
1576+
)
1577+
1578+
Lightning.ApplicationHelpers.dynamically_absorb_delay(fn ->
1579+
{_, assigns} =
1580+
Lightning.LiveViewHelpers.get_component_assigns_by(view,
1581+
id: "generic-oauth-component-new-main"
1582+
)
1583+
1584+
:error === assigns[:oauth_progress]
1585+
end)
1586+
1587+
html = view |> render()
1588+
1589+
assert html =~ "An error occurred during authentication"
1590+
refute html =~ "Refresh Token Revoked"
1591+
end
14091592
end
14101593

14111594
describe "generic oauth credential when flow fails" do

0 commit comments

Comments
 (0)