diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx
index 9f42e384..85eafa13 100644
--- a/adminfront/src/components/layout/AppLayout.tsx
+++ b/adminfront/src/components/layout/AppLayout.tsx
@@ -80,9 +80,23 @@ function AppLayout() {
};
}, []);
- const { data: profile } = useQuery({
+ const {
+ data: profile,
+ isLoading: isProfileLoading,
+ error: profileError,
+ } = useQuery({
queryKey: ["me"],
- queryFn: fetchMe,
+ queryFn: async () => {
+ console.debug("[AppLayout] Fetching profile...");
+ try {
+ const data = await fetchMe();
+ console.debug("[AppLayout] Profile fetched successfully:", data.email);
+ return data;
+ } catch (err) {
+ console.error("[AppLayout] Failed to fetch profile:", err);
+ throw err;
+ }
+ },
enabled:
(auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development" ||
@@ -170,7 +184,15 @@ function AppLayout() {
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
+
+ console.debug("[AppLayout] Auth state check:", {
+ isLoading: auth.isLoading,
+ isAuthenticated: auth.isAuthenticated,
+ isTest,
+ });
+
if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
+ console.warn("[AppLayout] Not authenticated, redirecting to /login");
navigate("/login");
}
}, [auth.isLoading, auth.isAuthenticated, navigate]);
diff --git a/adminfront/src/features/auth/AuthCallbackPage.tsx b/adminfront/src/features/auth/AuthCallbackPage.tsx
index ed1e0630..b7754b0a 100644
--- a/adminfront/src/features/auth/AuthCallbackPage.tsx
+++ b/adminfront/src/features/auth/AuthCallbackPage.tsx
@@ -8,6 +8,11 @@ function AuthCallbackPage() {
const navigate = useNavigate();
useEffect(() => {
+ console.debug("[AuthCallbackPage] State:", {
+ isAuthenticated: auth.isAuthenticated,
+ isLoading: auth.isLoading,
+ error: auth.error,
+ });
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
@@ -21,12 +26,16 @@ function AuthCallbackPage() {
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
+ console.info(
+ "[AuthCallbackPage] Auth successful, navigating to",
+ returnTo,
+ );
navigate(returnTo, { replace: true });
} else if (auth.error) {
- console.error("Auth Error:", auth.error);
+ console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true });
}
- }, [auth.isAuthenticated, auth.error, navigate, auth.user]);
+ }, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return (
diff --git a/adminfront/src/features/auth/LoginPage.tsx b/adminfront/src/features/auth/LoginPage.tsx
index bc6d780d..cdd807ab 100644
--- a/adminfront/src/features/auth/LoginPage.tsx
+++ b/adminfront/src/features/auth/LoginPage.tsx
@@ -20,10 +20,19 @@ function LoginPage() {
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
+ console.debug("[LoginPage] Auth state check:", {
+ isAuthenticated: auth.isAuthenticated,
+ isLoading: auth.isLoading,
+ returnTo,
+ });
if (auth.isAuthenticated) {
+ console.info(
+ "[LoginPage] User is authenticated, redirecting to",
+ returnTo,
+ );
navigate(returnTo, { replace: true });
}
- }, [auth.isAuthenticated, navigate, returnTo]);
+ }, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => {
if (!shouldAutoLogin) {
@@ -64,6 +73,26 @@ function LoginPage() {
+ {auth.error && (
+
+
+
+ 인증 오류가 발생했습니다
+
+
{auth.error.message}
+
+
+ )}
+
diff --git a/adminfront/src/lib/apiClient.ts b/adminfront/src/lib/apiClient.ts
index aae7b63b..10782e7f 100644
--- a/adminfront/src/lib/apiClient.ts
+++ b/adminfront/src/lib/apiClient.ts
@@ -1,4 +1,5 @@
import axios from "axios";
+import { userManager } from "./auth";
const apiClient = axios.create({
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
@@ -7,14 +8,17 @@ const apiClient = axios.create({
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
});
-apiClient.interceptors.request.use((config) => {
- // TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
- const sessionToken = window.localStorage.getItem("admin_session");
+apiClient.interceptors.request.use(async (config) => {
+ // IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다.
+ const user = await userManager.getUser();
+ const sessionToken =
+ user?.access_token || window.localStorage.getItem("admin_session");
+
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
}
- // TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
+ // 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
@@ -33,10 +37,29 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
- (error) => {
+ async (error) => {
if (error.response?.status === 401) {
+ console.warn(
+ "[apiClient] 401 Unauthorized detected. Clearing session state.",
+ );
+
+ // 로컬 스토리지의 세션 키 제거
window.localStorage.removeItem("admin_session");
- window.location.href = "/login";
+
+ // oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
+ // 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
+ await userManager.removeUser();
+
+ const isAuthPath = window.location.pathname.startsWith("/auth/callback");
+ const isLoginPath = window.location.pathname === "/login";
+
+ if (!isAuthPath && !isLoginPath) {
+ console.info(
+ "[apiClient] Redirecting to /login from",
+ window.location.pathname,
+ );
+ window.location.href = "/login";
+ }
}
return Promise.reject(error);
},
diff --git a/adminfront/src/lib/auth.ts b/adminfront/src/lib/auth.ts
index aab02a2b..6bf02cd7 100644
--- a/adminfront/src/lib/auth.ts
+++ b/adminfront/src/lib/auth.ts
@@ -2,13 +2,13 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
- authority:
- import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
+ authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
+ popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
diff --git a/adminfront/src/lib/sessionSliding.ts b/adminfront/src/lib/sessionSliding.ts
index be152778..9caff6cd 100644
--- a/adminfront/src/lib/sessionSliding.ts
+++ b/adminfront/src/lib/sessionSliding.ts
@@ -29,18 +29,32 @@ export function shouldAttemptSlidingSessionRenew({
}
if (typeof expiresAtSec !== "number") {
+ console.debug(
+ "[sessionSliding] expiresAtSec is not a number, skipping renew",
+ );
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
- if (remainingMs <= 0 || remainingMs > thresholdMs) {
+ const remainingMin = Math.floor(remainingMs / 1000 / 60);
+
+ if (remainingMs <= 0) {
+ console.debug("[sessionSliding] Session already expired, skipping renew");
+ return false;
+ }
+
+ if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
+ console.debug("[sessionSliding] Throttling renewal attempt");
return false;
}
+ console.info(
+ `[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
+ );
return true;
}
@@ -60,17 +74,33 @@ export function shouldAttemptUnlimitedSessionRenew({
}
if (typeof expiresAtSec !== "number") {
+ console.debug(
+ "[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
+ );
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
- if (remainingMs <= 0 || remainingMs > thresholdMs) {
+ const remainingMin = Math.floor(remainingMs / 1000 / 60);
+
+ if (remainingMs <= 0) {
+ console.debug(
+ "[sessionSliding] Session already expired, skipping unlimited renew",
+ );
+ return false;
+ }
+
+ if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
+ console.debug("[sessionSliding] Throttling unlimited renewal attempt");
return false;
}
+ console.info(
+ `[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
+ );
return true;
}
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index c2aedead..f82ff3af 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -687,7 +687,8 @@ func main() {
// Admin User Management
admin.Get("/users", requireAnyUser, userHandler.ListUsers)
- admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param admin.Post("/users", requireAdmin, userHandler.CreateUser)
+ admin.Get("/users/export", userHandler.ExportUsersCSV) // Removed requireAdmin to handle mock role in query param
+ admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Post("/users/bulk", requireAdmin, userHandler.BulkCreateUsers)
admin.Put("/users/bulk", requireAdmin, userHandler.BulkUpdateUsers)
admin.Delete("/users/bulk", requireAdmin, userHandler.BulkDeleteUsers)
diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go
index e22b9392..fd9b9168 100644
--- a/backend/internal/domain/idp_models.go
+++ b/backend/internal/domain/idp_models.go
@@ -53,6 +53,7 @@ type AuthInfo struct {
RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string
+ SetCookies []*http.Cookie
}
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index f7aed790..3c3ea26f 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -17,6 +17,7 @@ import (
"io"
"log/slog"
"math/rand"
+ "net"
"net/http"
"net/url"
"os"
@@ -1641,6 +1642,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
sessionToken := authInfo.SessionToken.JWT
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
+
+ // Write Kratos session cookies to the response
+ h.writeAuthCookies(c, authInfo.SetCookies)
+
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
@@ -1752,6 +1757,9 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken)
+ // Write Kratos session cookies to the response
+ h.writeAuthCookies(c, authInfo.SetCookies)
+
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
@@ -2414,6 +2422,10 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string)
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
+
+ // Write Kratos session cookies to the response
+ h.writeAuthCookies(c, authInfo.SetCookies)
+
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
@@ -2784,6 +2796,40 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error {
})
}
+func (h *AuthHandler) writeAuthCookies(c *fiber.Ctx, cookies []*http.Cookie) {
+ if len(cookies) == 0 {
+ return
+ }
+
+ host := c.Hostname()
+ domain := ""
+
+ // IP address or localhost check
+ if ip := net.ParseIP(host); ip != nil || host == "localhost" {
+ domain = host
+ } else {
+ // Extract root domain (e.g., .hmac.kr from sso.hmac.kr)
+ parts := strings.Split(host, ".")
+ if len(parts) >= 2 {
+ domain = "." + strings.Join(parts[len(parts)-2:], ".")
+ }
+ }
+
+ for _, cookie := range cookies {
+ c.Cookie(&fiber.Cookie{
+ Name: cookie.Name,
+ Value: cookie.Value,
+ Path: "/",
+ Domain: domain,
+ MaxAge: cookie.MaxAge,
+ Expires: cookie.Expires,
+ Secure: true,
+ HTTPOnly: true,
+ SameSite: "Lax",
+ })
+ }
+}
+
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
startTime := time.Now()
ale := logger.NewAuditLogEntry(c, "login")
@@ -2832,6 +2878,10 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
c.Locals("user_id", authInfo.Subject)
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
+
+ // Write Kratos session cookies to the response
+ h.writeAuthCookies(c, authInfo.SetCookies)
+
if req.LoginChallenge == "" {
attachAuditClientDetails(c, domain.HydraClient{
ClientID: "userfront",
@@ -2864,16 +2914,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
}
logOidcRedirectSummary("password_login", acceptResp.RedirectTo)
+
+ // IMPORTANT: Also return sessionJwt and token during OIDC flow to ensure portal session.
return c.JSON(fiber.Map{
"redirectTo": acceptResp.RedirectTo,
"status": "ok",
"provider": h.IdpProvider.Name(),
+ "sessionJwt": authInfo.SessionToken.JWT,
+ "token": authInfo.SessionToken.JWT,
+ "subject": authInfo.Subject,
})
}
// --- OIDC 로그인 흐름 처리 끝 ---
resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
+ "token": authInfo.SessionToken.JWT,
"status": "ok",
"provider": h.IdpProvider.Name(),
}
diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go
index 08d06e2b..64833990 100644
--- a/backend/internal/handler/auth_handler_login_test.go
+++ b/backend/internal/handler/auth_handler_login_test.go
@@ -633,8 +633,11 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
if got["redirectTo"] != "http://rp/cb" {
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
}
- if _, ok := got["sessionJwt"]; ok {
- t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"])
+ if got["sessionJwt"] != "valid-jwt" {
+ t.Errorf("expected sessionJwt to be valid-jwt, got %v", got["sessionJwt"])
+ }
+ if got["token"] != "valid-jwt" {
+ t.Errorf("expected token to be valid-jwt, got %v", got["token"])
}
}
diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go
index 0eb08d68..1cb13ab6 100644
--- a/backend/internal/service/ory_service.go
+++ b/backend/internal/service/ory_service.go
@@ -247,7 +247,8 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
Expiration: result.SessionTokenExpiresAt,
SessionID: result.Session.ID,
},
- Subject: result.Session.Identity.ID,
+ Subject: result.Session.Identity.ID,
+ SetCookies: resp.Cookies(),
}, nil
}
@@ -693,7 +694,8 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut
Expiration: result.SessionTokenExpiresAt,
SessionID: result.Session.ID,
},
- Subject: result.Session.Identity.ID,
+ Subject: result.Session.Identity.ID,
+ SetCookies: resp.Cookies(),
}, nil
}
diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts
index 1910fa4c..c4ad1449 100644
--- a/devfront/playwright.config.ts
+++ b/devfront/playwright.config.ts
@@ -63,8 +63,8 @@ export default defineConfig({
? undefined
: {
command: process.env.CI
- ? "npm run build && npm run preview -- --port 5174"
- : "npm run dev -- --port 5174",
+ ? "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run build && npm run preview -- --port 5174"
+ : "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc npm run dev -- --port 5174",
url: baseURL,
reuseExistingServer: !process.env.CI,
},
diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx
index 26069583..a0791fba 100644
--- a/devfront/src/features/auth/AuthGuard.tsx
+++ b/devfront/src/features/auth/AuthGuard.tsx
@@ -9,7 +9,21 @@ export default function AuthGuard() {
}
if (auth.error) {
- return Auth Error: {auth.error.message}
;
+ return (
+
+
+
Authentication Error
+
{auth.error.message}
+
+
+
+ );
}
if (!auth.isAuthenticated) {
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index a19ee5ed..91590fba 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -524,16 +524,17 @@ function ClientGeneralPage() {
if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`);
}
- alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
+ toast(t("msg.dev.clients.general.saved", "설정이 저장되었습니다."));
},
onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
- alert(
+ toast(
t(
"msg.dev.clients.general.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
),
+ "error",
);
return;
}
@@ -542,7 +543,7 @@ function ClientGeneralPage() {
axiosError.response?.data?.error ??
(err as Error)?.message ??
t("msg.common.unknown_error", "unknown error");
- alert(
+ toast(
t(
"msg.dev.clients.general.save_error",
"저장에 실패했습니다: {{error}}",
@@ -550,6 +551,7 @@ function ClientGeneralPage() {
error: errorMessage,
},
),
+ "error",
);
},
});
@@ -558,17 +560,18 @@ function ClientGeneralPage() {
mutationFn: (id: string) => deleteClient(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["clients"] });
- alert(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
+ toast(t("msg.dev.clients.deleted", "앱이 삭제되었습니다."));
navigate("/clients");
},
onError: (err) => {
const errorMessage =
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error)?.message;
- alert(
+ toast(
t("msg.dev.clients.delete_error", "삭제 실패: {{error}}", {
error: errorMessage,
}),
+ "error",
);
},
});
diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx
index 23be2dfb..e93e9260 100644
--- a/devfront/src/features/clients/ClientRelationsPage.tsx
+++ b/devfront/src/features/clients/ClientRelationsPage.tsx
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
+import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -33,7 +34,6 @@ import {
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
-import { useAuth } from "react-oidc-context";
import { ClientDetailTabs } from "./ClientDetailTabs";
const relationOptions = [
diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts
index d0f0772e..f0e7ea3a 100644
--- a/devfront/src/lib/auth.ts
+++ b/devfront/src/lib/auth.ts
@@ -2,8 +2,7 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
- authority:
- import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
+ authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
diff --git a/userfront/lib/core/notifiers/auth_notifier.dart b/userfront/lib/core/notifiers/auth_notifier.dart
index 24282ca5..2591960a 100644
--- a/userfront/lib/core/notifiers/auth_notifier.dart
+++ b/userfront/lib/core/notifiers/auth_notifier.dart
@@ -1,8 +1,15 @@
import 'package:flutter/foundation.dart';
+import '../services/auth_token_store.dart';
class AuthNotifier extends ChangeNotifier {
static final AuthNotifier instance = AuthNotifier();
+ Future onLoginSuccess(String token, {String? provider}) async {
+ AuthTokenStore.setToken(token, provider: provider);
+ AuthTokenStore.clearPendingProvider();
+ notifyListeners();
+ }
+
void notify() {
notifyListeners();
}
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index 252fdf32..59f3f0d5 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -92,6 +92,32 @@ class AuthProxyService {
}
}
+ static Future