1
0
forked from baron/baron-sso

fix: resolve OIDC session state issue and synchronize portal sessions

Details:
- Backend: Extract Kratos session cookies and propagate via SetCookies in AuthInfo.
- Backend: Include sessionJwt and token during OIDC flows in PasswordLogin.
- UserFront: Add _silentSessionRecovery in main.dart to recover session via cookies if localStorage token is missing.
- UserFront: Update AuthProxyService, AuthTokenStore, AuthNotifier to support silent recovery and immediate local state update before redirect.
- AdminFront/DevFront: Fix OIDC authority to point directly to Gateway proxy and add recovery/error UI components.
This commit is contained in:
2026-04-21 14:10:27 +09:00
parent 1024ad17d3
commit 0f79b7635b
12 changed files with 199 additions and 5 deletions

View File

@@ -64,6 +64,26 @@ function LoginPage() {
</div>
</div>
{auth.error && (
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
<div className="font-bold flex items-center gap-2 mb-1">
<ShieldHalf size={16} />
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
variant="link"
className="p-0 h-auto text-destructive underline mt-2"
onClick={() => {
window.location.href =
window.location.origin + window.location.pathname;
}}
>
</Button>
</div>
)}
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">

View File

@@ -3,12 +3,13 @@ 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
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,
};

View File

@@ -53,6 +53,7 @@ type AuthInfo struct {
RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string
SetCookies []*http.Cookie
}
// LinkLoginInit는 링크 로그인 초기화 결과입니다.

View File

@@ -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(),
}

View File

@@ -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
}

View File

@@ -9,7 +9,20 @@ export default function AuthGuard() {
}
if (auth.error) {
return <div>Auth Error: {auth.error.message}</div>;
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-red-500">
<h2 className="text-xl font-bold">Authentication Error</h2>
<p>{auth.error.message}</p>
</div>
<button
onClick={() => void auth.signinRedirect()}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
Start Login Again
</button>
</div>
);
}
if (!auth.isAuthenticated) {

View File

@@ -3,7 +3,7 @@ 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
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",

View File

@@ -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<void> onLoginSuccess(String token, {String? provider}) async {
AuthTokenStore.setToken(token, provider: provider);
AuthTokenStore.clearPendingProvider();
notifyListeners();
}
void notify() {
notifyListeners();
}

View File

@@ -92,6 +92,32 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> getMe({String? token, bool useCookie = true}) async {
final url = Uri.parse('$_baseUrl/api/v1/user/me');
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(
url,
headers: headers,
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw _error(
'err.userfront.auth_proxy.profile_load',
'프로필을 불러오지 못했습니다: {{error}}',
detail: response.body,
);
} finally {
client.close();
}
}
static Future<int> getSessionStatus({
String? token,
bool useCookie = false,

View File

@@ -2,6 +2,11 @@ import 'auth_token_store_stub.dart'
if (dart.library.js_interop) 'auth_token_store_web.dart';
class AuthTokenStore {
static bool hasToken() {
final token = getToken();
return token != null && token.isNotEmpty;
}
static String? getToken() => authTokenStore.getToken();
static String? getProvider() => authTokenStore.getProvider();

View File

@@ -348,8 +348,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
loginChallenge,
token: token,
);
// IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state.
final jwt = res['sessionJwt'] ?? res['token'] ?? token;
if (jwt != null && jwt.isNotEmpty) {
final provider = res['provider'] as String? ?? AuthTokenStore.getProvider();
await AuthNotifier.instance.onLoginSuccess(jwt, provider: provider);
}
final redirectTo = res['redirectTo'] as String?;
if (redirectTo != null && redirectTo.isNotEmpty) {
// Give 50ms delay for localStorage to settle
await Future.delayed(const Duration(milliseconds: 50));
return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login');
}
} catch (e) {
@@ -1294,10 +1304,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
loginChallenge,
token: token,
);
// IMPORTANT: If backend returned a token during OIDC flow, save it to fix login state.
final jwt = res['sessionJwt'] ?? res['token'] ?? token;
if (jwt != null && jwt.isNotEmpty) {
await AuthNotifier.instance.onLoginSuccess(
jwt,
provider: res['provider'] as String? ?? providerName,
);
}
final nextRedirectTo = res['redirectTo'] as String?;
if (nextRedirectTo != null && nextRedirectTo.isNotEmpty) {
loginChallengeLoopGuard.clear(loginChallenge);
// Give 50ms delay for localStorage to settle
await Future.delayed(const Duration(milliseconds: 50));
webWindow.redirectTo(nextRedirectTo); // Removed await
return;
} else {}

View File

@@ -73,6 +73,44 @@ Future<void> _loadBundledFonts() async {
}
}
Future<void> _silentSessionRecovery() async {
_log.info("[SessionRecovery] Starting silent session recovery check...");
// 1. Local token check
final hasLocalToken = AuthTokenStore.hasToken();
if (hasLocalToken) {
_log.info("[SessionRecovery] Local token found. Skipping recovery.");
return;
}
_log.info(
"[SessionRecovery] Local token missing. Checking for browser cookies...",
);
try {
// 2. Try fetching user info (backend will use cookies if present)
final userInfo = await AuthProxyService.getMe();
final subject = userInfo['id'] ?? userInfo['identity_id'] ?? '';
if (subject.isNotEmpty) {
_log.info(
"[SessionRecovery] Valid session found via cookies. Recovering login state...",
);
// For cookie-based auth, we don't necessarily have a JWT in local storage,
// but AuthNotifier needs to know we are logged in.
final jwt = userInfo['sessionJwt'] ?? userInfo['token'] ?? 'cookie-session';
await AuthNotifier.instance.onLoginSuccess(jwt);
_log.info("[SessionRecovery] Recovery complete. Subject: $subject");
} else {
_log.warning("[SessionRecovery] Session found but subject is empty.");
}
} catch (e) {
_log.info(
"[SessionRecovery] No valid cookie session found or request failed: $e",
);
}
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
@@ -115,6 +153,9 @@ void main() async {
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
// 2. Silent Session Recovery (from cookies)
await _silentSessionRecovery();
runApp(
// URL(/en, /ko)이 있으면 우선 적용해서 첫 렌더부터 올바른 언어로 시작합니다.
() {