feat: log in with google
This commit is contained in:
parent
bb2fa55cd5
commit
8a8b4caa18
11 changed files with 403 additions and 74 deletions
|
@ -33,6 +33,7 @@ public class AuthController(
|
|||
);
|
||||
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
|
||||
string? discord = null;
|
||||
string? google = null;
|
||||
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
|
||||
{
|
||||
discord =
|
||||
|
@ -42,7 +43,17 @@ public class AuthController(
|
|||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
||||
}
|
||||
|
||||
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
|
||||
if (config.GoogleAuth is { ClientId: not null, ClientSecret: not null })
|
||||
{
|
||||
google =
|
||||
"https://accounts.google.com/o/oauth2/auth?response_type=code"
|
||||
+ $"&client_id={config.GoogleAuth.ClientId}"
|
||||
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
|
||||
+ $"&prompt=select_account&state={state}"
|
||||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
|
||||
}
|
||||
|
||||
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, null));
|
||||
}
|
||||
|
||||
[HttpPost("force-log-out")]
|
||||
|
|
|
@ -0,0 +1,164 @@
|
|||
using System.Net;
|
||||
using System.Web;
|
||||
using EntityFramework.Exceptions.Common;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Dto;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Foxnouns.Backend.Services.Auth;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using JetBrains.Annotations;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||
|
||||
[Route("/api/internal/auth/google")]
|
||||
public class GoogleAuthController(
|
||||
[UsedImplicitly] Config config,
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
KeyCacheService keyCacheService,
|
||||
AuthService authService,
|
||||
RemoteAuthService remoteAuthService
|
||||
) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<GoogleAuthController>();
|
||||
|
||||
[HttpPost("callback")]
|
||||
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
await keyCacheService.ValidateAuthStateAsync(req.State);
|
||||
|
||||
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
|
||||
req.Code
|
||||
);
|
||||
User? user = await authService.AuthenticateUserAsync(AuthType.Google, remoteUser.Id);
|
||||
if (user != null)
|
||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||
|
||||
_logger.Debug(
|
||||
"Google user {Username} ({Id}) authenticated with no local account",
|
||||
remoteUser.Username,
|
||||
remoteUser.Id
|
||||
);
|
||||
|
||||
string ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"google:{ticket}", remoteUser, Duration.FromMinutes(20));
|
||||
|
||||
return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||
{
|
||||
RemoteAuthService.RemoteUser? remoteUser =
|
||||
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"google:{req.Ticket}");
|
||||
if (remoteUser == null)
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
if (
|
||||
await db.AuthMethods.AnyAsync(a =>
|
||||
a.AuthType == AuthType.Google && a.RemoteId == remoteUser.Id
|
||||
)
|
||||
)
|
||||
{
|
||||
_logger.Error(
|
||||
"Google user {Id} has valid ticket but is already linked to an existing account",
|
||||
remoteUser.Id
|
||||
);
|
||||
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||
}
|
||||
|
||||
User user = await authService.CreateUserWithRemoteAuthAsync(
|
||||
req.Username,
|
||||
AuthType.Google,
|
||||
remoteUser.Id,
|
||||
remoteUser.Username
|
||||
);
|
||||
|
||||
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||
}
|
||||
|
||||
[HttpGet("add-account")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddGoogleAccountAsync()
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
|
||||
CurrentUser!.Id,
|
||||
AuthType.Google
|
||||
);
|
||||
|
||||
string url =
|
||||
"https://accounts.google.com/o/oauth2/auth?response_type=code"
|
||||
+ $"&client_id={config.GoogleAuth.ClientId}"
|
||||
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
|
||||
+ $"&prompt=select_account&state={state}"
|
||||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
|
||||
|
||||
return Ok(new SingleUrlResponse(url));
|
||||
}
|
||||
|
||||
[HttpPost("add-account/callback")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
await remoteAuthService.ValidateAddAccountStateAsync(
|
||||
req.State,
|
||||
CurrentUser!.Id,
|
||||
AuthType.Google
|
||||
);
|
||||
|
||||
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
|
||||
req.Code
|
||||
);
|
||||
try
|
||||
{
|
||||
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
||||
CurrentUser.Id,
|
||||
AuthType.Google,
|
||||
remoteUser.Id,
|
||||
remoteUser.Username
|
||||
);
|
||||
_logger.Debug(
|
||||
"Added new Google auth method {AuthMethodId} to user {UserId}",
|
||||
authMethod.Id,
|
||||
CurrentUser.Id
|
||||
);
|
||||
|
||||
return Ok(
|
||||
new AddOauthAccountResponse(
|
||||
authMethod.Id,
|
||||
AuthType.Google,
|
||||
authMethod.RemoteId,
|
||||
authMethod.RemoteUsername
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (UniqueConstraintException)
|
||||
{
|
||||
throw new ApiError(
|
||||
"That account is already linked.",
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorCode.AccountAlreadyLinked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckRequirements()
|
||||
{
|
||||
if (!config.GoogleAuth.Enabled)
|
||||
{
|
||||
throw new ApiError.BadRequest("Google authentication is not enabled on this instance.");
|
||||
}
|
||||
}
|
||||
}
|
90
Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs
Normal file
90
Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs
Normal file
|
@ -0,0 +1,90 @@
|
|||
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Foxnouns.Backend.Services.Auth;
|
||||
|
||||
public partial class RemoteAuthService
|
||||
{
|
||||
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
|
||||
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
|
||||
|
||||
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
||||
string code,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
||||
_discordTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.DiscordAuth.ClientId! },
|
||||
{ "client_secret", config.DiscordAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri },
|
||||
}
|
||||
),
|
||||
ct
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
string respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
respBody
|
||||
);
|
||||
throw new FoxnounsError("Invalid Discord OAuth response");
|
||||
}
|
||||
|
||||
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(
|
||||
ct
|
||||
);
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Discord token response was null");
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
|
||||
|
||||
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
|
||||
resp2.EnsureSuccessStatusCode();
|
||||
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Discord user response was null");
|
||||
|
||||
return new RemoteUser(user.id, user.username);
|
||||
}
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordTokenResponse(string access_token, string token_type);
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordUserResponse(string id, string username);
|
||||
}
|
80
Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs
Normal file
80
Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Foxnouns.Backend.Services.Auth;
|
||||
|
||||
public partial class RemoteAuthService
|
||||
{
|
||||
private readonly Uri _googleTokenUri = new("https://oauth2.googleapis.com/token");
|
||||
|
||||
public async Task<RemoteUser> RequestGoogleTokenAsync(
|
||||
string code,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/google";
|
||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
||||
_googleTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.GoogleAuth.ClientId! },
|
||||
{ "client_secret", config.GoogleAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "scope", "openid https://www.googleapis.com/auth/userinfo.email" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri },
|
||||
}
|
||||
),
|
||||
ct
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
string respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
respBody
|
||||
);
|
||||
throw new FoxnounsError("Invalid Google OAuth response");
|
||||
}
|
||||
|
||||
GoogleTokenResponse? token = await resp.Content.ReadFromJsonAsync<GoogleTokenResponse>(ct);
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Google token response was null");
|
||||
|
||||
byte[] rawIdToken = Convert.FromBase64String(token.IdToken.Split(".")[1]);
|
||||
GoogleUser? user = JsonSerializer.Deserialize<GoogleUser>(
|
||||
Encoding.UTF8.GetString(rawIdToken)
|
||||
);
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Google user was null");
|
||||
|
||||
return new RemoteUser(user.Id, user.Email);
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
|
||||
private record GoogleTokenResponse([property: JsonPropertyName("id_token")] string IdToken);
|
||||
|
||||
private record GoogleUser(
|
||||
[property: JsonPropertyName("sub")] string Id,
|
||||
[property: JsonPropertyName("email")] string Email
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore;
|
|||
|
||||
namespace Foxnouns.Backend.Services.Auth;
|
||||
|
||||
public class RemoteAuthService(
|
||||
public partial class RemoteAuthService(
|
||||
Config config,
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
|
@ -20,75 +20,6 @@ public class RemoteAuthService(
|
|||
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
|
||||
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
|
||||
|
||||
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
||||
string code,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||
HttpResponseMessage resp = await _httpClient.PostAsync(
|
||||
_discordTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.DiscordAuth.ClientId! },
|
||||
{ "client_secret", config.DiscordAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri },
|
||||
}
|
||||
),
|
||||
ct
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
string respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
respBody
|
||||
);
|
||||
throw new FoxnounsError("Invalid Discord OAuth response");
|
||||
}
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(
|
||||
ct
|
||||
);
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Discord token response was null");
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
|
||||
|
||||
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
|
||||
resp2.EnsureSuccessStatusCode();
|
||||
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Discord user response was null");
|
||||
|
||||
return new RemoteUser(user.id, user.username);
|
||||
}
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordTokenResponse(string access_token, string token_type);
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordUserResponse(string id, string username);
|
||||
|
||||
public record RemoteUser(string Id, string Username);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -3,7 +3,6 @@ import { PUBLIC_API_BASE } from "$env/static/public";
|
|||
import type { HandleFetch } from "@sveltejs/kit";
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
|
||||
console.log(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST, PRIVATE_API_HOST);
|
||||
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
|
||||
request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST), request);
|
||||
} else if (request.url.startsWith(PUBLIC_API_BASE)) {
|
||||
|
|
|
@ -52,7 +52,9 @@
|
|||
"register-with-email": "Register with an email address",
|
||||
"email-label": "Your email address",
|
||||
"confirm-password-label": "Confirm password",
|
||||
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
|
||||
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue.",
|
||||
"register-with-google": "Register with a Google account",
|
||||
"remote-google-account-label": "Your Google account"
|
||||
},
|
||||
"error": {
|
||||
"bad-request-header": "Something was wrong with your input",
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
import type { Cookies } from "@sveltejs/kit";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
|
||||
// export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
|
||||
export const TOKEN_COOKIE_NAME = "pronounscc-token";
|
||||
|
||||
export const setToken = (cookies: Cookies, token: string) =>
|
||||
cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" });
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import createRegisterAction from "$lib/actions/register";
|
||||
|
||||
export const load = createCallbackLoader("google");
|
||||
|
||||
export const actions = {
|
||||
default: createRegisterAction("/auth/google/register"),
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import Error from "$components/Error.svelte";
|
||||
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
|
||||
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("auth.register-with-google")} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
{#if data.error}
|
||||
<h1>{$t("auth.register-with-google")}</h1>
|
||||
<Error error={data.error} />
|
||||
{:else if data.isLinkRequest}
|
||||
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
|
||||
{:else}
|
||||
<OauthRegistrationForm
|
||||
title={$t("auth.register-with-google")}
|
||||
remoteLabel={$t("auth.remote-google-account-label")}
|
||||
remoteUser={data.remoteUser!}
|
||||
ticket={data.ticket!}
|
||||
error={form?.error}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
|
@ -0,0 +1,12 @@
|
|||
import { apiRequest } from "$api";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = async ({ fetch, cookies }) => {
|
||||
const { url } = await apiRequest<{ url: string }>("GET", "/auth/google/add-account", {
|
||||
isInternal: true,
|
||||
fetch,
|
||||
cookies,
|
||||
});
|
||||
|
||||
redirect(303, url);
|
||||
};
|
Loading…
Reference in a new issue