From fe70fd216baa615efb43b38d29f8f5d7972420a1 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 6 Apr 2026 11:15:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=EC=9A=A9=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/auth_handler_login_test.go | 151 +++++++++++ .../handler/auth_handler_sessions_test.go | 244 ++++++++++++++++++ .../tests/session-cross-browser-debug.spec.ts | 200 ++++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 userfront-e2e/tests/session-cross-browser-debug.spec.ts diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 3d098bbe..747f476e 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -7,6 +7,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/middleware" "baron-sso-backend/internal/service" "bytes" "context" @@ -637,6 +638,156 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { } } +func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "devfront", + ClientName: "DevFront", + Metadata: map[string]interface{}{"status": "active"}, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + + log := auditRepo.logs[0] + if log.EventType != "POST /api/v1/auth/password/login" { + t.Fatalf("expected password login audit event, got %q", log.EventType) + } + if log.UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) + } + + details, err := parseAuditDetails(log.Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "devfront" { + t.Fatalf("expected client_id devfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "DevFront" { + t.Fatalf("expected client_name DevFront, got %v", details["client_name"]) + } +} + +func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/password/login", h.PasswordLogin) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + if auditRepo.logs[0].UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", auditRepo.logs[0].UserID) + } + + details, err := parseAuditDetails(auditRepo.logs[0].Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "userfront" { + t.Fatalf("expected client_id userfront, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "UserFront" { + t.Fatalf("expected client_name UserFront, got %v", details["client_name"]) + } +} + func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index e0c95d41..39e6f599 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -103,6 +103,250 @@ func TestListMySessions_Success(t *testing.T) { mockKratos.AssertExpectations(t) } +func TestListMySessions_UsesConsentGrantForAppName(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now, + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`, + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) { + now := time.Date(2026, 4, 2, 4, 40, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "c7c721ea-session", + Active: true, + AuthenticatedAt: now.Add(-5 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "consent.granted", + SessionID: "c7c721ea-session", + Timestamp: now.Add(-30 * time.Second), + IPAddress: "203.0.113.10", + Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`, + }, + { + UserID: "user-123", + EventType: "login_success", + SessionID: "c7c721ea-session", + Timestamp: now, + IPAddress: "10.0.0.12", + UserAgent: "Mozilla/5.0", + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + IPAddress string `json:"ip_address"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) + assert.Equal(t, "DevFront", body.Items[1].AppName) + assert.Equal(t, "devfront", body.Items[1].ClientID) + assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) + } + + mockKratos.AssertExpectations(t) +} + +func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) { + now := time.Date(2026, 4, 6, 1, 2, 3, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + }, nil).Once() + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: &mockAuditRepo{}, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("X-Forwarded-For", "203.0.113.25") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + IsCurrent bool `json:"is_current"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "current-sid", body.Items[0].SessionID) + assert.True(t, body.Items[0].IsCurrent) + assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress) + assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0") + assert.Equal(t, "userfront", body.Items[0].ClientID) + assert.Equal(t, "UserFront", body.Items[0].AppName) + } + + mockKratos.AssertExpectations(t) +} + func TestDeleteMySession_Success(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") var hydraRevokeCalls int diff --git a/userfront-e2e/tests/session-cross-browser-debug.spec.ts b/userfront-e2e/tests/session-cross-browser-debug.spec.ts new file mode 100644 index 00000000..b22a025a --- /dev/null +++ b/userfront-e2e/tests/session-cross-browser-debug.spec.ts @@ -0,0 +1,200 @@ +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso-test.hmac.kr'; +const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? 'http://localhost:5173'; +const LOGIN_ID = process.env.E2E_LOGIN_ID ?? ''; +const PASSWORD = process.env.E2E_PASSWORD ?? ''; + +type SessionApiResponse = { + items?: Array<{ + session_id?: string; + client_id?: string; + app_name?: string; + is_current?: boolean; + user_agent?: string; + ip_address?: string; + }>; +}; + +function ensureCredentials(): void { + if (!LOGIN_ID || !PASSWORD) { + test.skip(true, 'E2E credentials are required'); + } +} + +async function enableFlutterAccessibility(page: Page): Promise { + await page.waitForTimeout(300); + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + try { + await button.click({ force: true }); + } catch { + return; + } + const placeholder = page.locator('flt-semantics-placeholder'); + if (await placeholder.count()) { + await placeholder.first().click({ force: true }); + } + await page.waitForTimeout(800); + } +} + +async function clickPasswordTab(page: Page): Promise { + await page.waitForTimeout(900); + const pane = page.locator('flt-glass-pane'); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(120); + await pane.click({ + position: { x: 522, y: 158 }, + force: true, + }); + await page.waitForTimeout(200); +} + +async function fillAt(page: Page, x: number, y: number, value: string): Promise { + const pane = page.locator('flt-glass-pane'); + await pane.click({ position: { x, y }, force: true }); + await page.waitForTimeout(100); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Backspace'); + await page.keyboard.type(value); +} + +async function loginViaUserFront(page: Page): Promise { + await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 }); + const loginIdInput = page.getByPlaceholder(/이메일 또는 휴대폰 번호|email|phone/i); + const passwordInput = page.getByPlaceholder(/비밀번호|password/i); + const submitButton = page.getByRole('button', { name: /로그인|Login/i }); + + if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) { + await loginIdInput.first().fill(LOGIN_ID); + await passwordInput.first().fill(PASSWORD); + await submitButton.click(); + return; + } + + await clickPasswordTab(page); + await fillAt(page, 640, 245, LOGIN_ID); + await fillAt(page, 640, 311, PASSWORD); + await page.locator('flt-glass-pane').click({ + position: { x: 640, y: 381 }, + force: true, + }); +} + +async function ensureConsentIfNeeded(page: Page): Promise { + if (!/\/ko\/consent/.test(page.url())) { + return; + } + + const allowButton = page + .getByRole('button') + .filter({ hasText: /허용|동의|Accept|Allow/i }) + .first(); + + if (await allowButton.count()) { + await allowButton.click({ force: true }); + } +} + +async function captureUserSessionsOnReload(page: Page): Promise { + const responsePromise = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + response.url().includes('/api/v1/user/sessions'), + { timeout: 30_000 }, + ); + + await page.reload({ waitUntil: 'domcontentloaded' }); + const response = await responsePromise; + return (await response.json()) as SessionApiResponse; +} + +async function loginUserFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, { + waitUntil: 'domcontentloaded', + }); + await loginViaUserFront(page); + await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 }); + return page; +} + +async function loginAdminFront(context: BrowserContext): Promise { + const page = await context.newPage(); + await page.goto(ADMINFRONT_URL, { waitUntil: 'domcontentloaded' }); + const ssoButton = page.getByRole('button', { name: /SSO 계정으로 로그인|SSO/i }); + if (await ssoButton.count()) { + await ssoButton.click({ force: true }); + await page.waitForTimeout(1500); + } + if (/\/login$/.test(page.url())) { + const authorizeUrl = await page.evaluate(() => { + const origin = window.location.origin; + const authority = 'https://sso-test.hmac.kr/oidc'; + const params = new URLSearchParams({ + client_id: 'adminfront', + redirect_uri: `${origin}/auth/callback`, + response_type: 'code', + scope: 'openid offline_access profile email', + state: `pw-${Date.now()}`, + nonce: `pw-${Date.now()}`, + code_challenge: 'test-code-challenge-test-code-challenge-test', + code_challenge_method: 'plain', + }); + return `${authority}/oauth2/auth?${params.toString()}`; + }); + await page.goto(authorizeUrl, { waitUntil: 'domcontentloaded' }); + } + await loginViaUserFront(page); + await ensureConsentIfNeeded(page); + await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, { + timeout: 60_000, + }); + return page; +} + +test.describe('cross-browser session debug', () => { + test('userfront session card should map adminfront session metadata across contexts', async ({ + browser, + }, testInfo) => { + ensureCredentials(); + + const userfrontContext = await browser.newContext({ locale: 'ko-KR' }); + const adminfrontContext = await browser.newContext({ locale: 'ko-KR' }); + + const userfrontPage = await loginUserFront(userfrontContext); + const adminfrontPage = await loginAdminFront(adminfrontContext); + + const sessionsPayload = await captureUserSessionsOnReload(userfrontPage); + const items = sessionsPayload.items ?? []; + const adminfrontItems = items.filter((item) => + (item.client_id ?? '').toLowerCase().includes('adminfront'), + ); + const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents(); + const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents(); + + await testInfo.attach('user-sessions.json', { + body: JSON.stringify(sessionsPayload, null, 2), + contentType: 'application/json', + }); + await testInfo.attach('card-summary.json', { + body: JSON.stringify( + { + unknownCards, + adminFrontCards, + currentUrl: userfrontPage.url(), + adminfrontUrl: adminfrontPage.url(), + }, + null, + 2, + ), + contentType: 'application/json', + }); + + expect(adminfrontItems.length).toBeGreaterThan(0); + }); +});