BARON-SSO 로그인 API 요청경로 수정
This commit is contained in:
127
baron-sso_login_guide/headless-login-demo-guide.md
Normal file
127
baron-sso_login_guide/headless-login-demo-guide.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Headless Login Demo (OIDC Integration)
|
||||||
|
|
||||||
|
이 프로젝트는 **baron-sso (OIDC IdP)**와 연동하여 백채널(Back-channel)을 통한 **Headless 인증**을 구현한 데모 애플리케이션입니다.
|
||||||
|
|
||||||
|
## 주요 특징
|
||||||
|
|
||||||
|
- **Headless 인증**: IdP가 제공하는 UI를 거치지 않고, RP(데모 앱)가 사용자 자격 증명을 직접 받아 백채널로 인증을 수행합니다.
|
||||||
|
- **동적 UI 전환**: 입력값(숫자 vs 문자)을 실시간으로 분석하여 '전화번호 SSO 인증' 또는 '사번 로그인' 모드로 자동 전환됩니다.
|
||||||
|
- **Trusted RP 구현**:
|
||||||
|
- **OIDC Discovery**: `sso-test.hmac.kr`의 메타데이터를 동적으로 로드합니다.
|
||||||
|
- **JWKS Endpoint**: 서버 시작 시 생성된 RSA 공개키를 `/.well-known/jwks.json`을 통해 서빙하여 IdP와의 신뢰 관계를 형성합니다.
|
||||||
|
- **세션 유지**: 인증 완료 후 `express-session`을 통해 로그인 상태를 유지하고 사용자 정보를 관리합니다.
|
||||||
|
- **PM-fork 스타일 디자인**: 기존 프로젝트의 디자인 토큰(그라데이션, 라운드 처리 등)을 Vanilla CSS로 재현했습니다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- **Backend**: Node.js, Express, express-session
|
||||||
|
- **OIDC**: openid-client (v5.x), jose
|
||||||
|
- **Frontend**: Vanilla JS, Modern CSS (Flexbox, CSS Variables)
|
||||||
|
|
||||||
|
## 시작하기
|
||||||
|
|
||||||
|
### 1. 환경 설정
|
||||||
|
|
||||||
|
프로젝트 루트에 `.env` 파일을 생성하고 아래와 같이 설정합니다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
CLIENT_ID=15cfb85c-f75f-4b51-a13d-d04f87d39739
|
||||||
|
ISSUER=https://sso-test.hmac.kr/oidc
|
||||||
|
REDIRECT_URI=http://localhost:3000/callback
|
||||||
|
JWKS_URI=http://localhost:3000/.well-known/jwks.json
|
||||||
|
# 필요 시 전화번호용 headless link endpoint를 별도로 덮어쓸 수 있음
|
||||||
|
PHONE_HEADLESS_LINK_INIT_ENDPOINT=
|
||||||
|
PHONE_HEADLESS_LINK_POLL_ENDPOINT=
|
||||||
|
BARON_API_BASE_URL=
|
||||||
|
BARON_BACKCHANNEL_JWKS_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 의존성 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 서버 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
├── server.js # Express 서버 및 OIDC 로직 (Discovery, JWKS, API)
|
||||||
|
├── public/ # 프론트엔드 정적 파일
|
||||||
|
│ ├── index.html # 로그인 화면
|
||||||
|
│ ├── home.html # 인증 후 메인 화면
|
||||||
|
│ ├── app.js # 입력값 분석 및 API 통신 로직
|
||||||
|
│ └── styles.css # PM-fork 스타일 시트
|
||||||
|
├── keys.json # (자동생성) 서비스용 RSA 키 쌍
|
||||||
|
└── .env # 환경 변수 설정
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심 구현 로직
|
||||||
|
|
||||||
|
### 1. 입력값 분류 (Classify Input)
|
||||||
|
|
||||||
|
사용자가 입력한 값이 숫자만 포함되어 있으면 전화번호(`phone`) 모드로, 문자가 포함되어 있으면 사번(`employee`) 모드로 인식합니다.
|
||||||
|
|
||||||
|
- **Phone**: 전화번호를 SSO headless 인증 흐름에 전달합니다.
|
||||||
|
- **Employee**: 비밀번호 입력란 노출 및 OIDC Password Grant 요청 실행.
|
||||||
|
|
||||||
|
### 2. SSO Headless 인증 (Real Communication)
|
||||||
|
|
||||||
|
데모 앱은 사용자로부터 받은 식별자와 자격 증명을 SSO 서버의 headless 인증 엔드포인트로 직접 전달합니다.
|
||||||
|
|
||||||
|
- SSO 서버가 해당 방식을 허용하도록 설정되어 있어야 하며, 화이트리스트에 등록된 `REDIRECT_URI`와 일치해야 합니다.
|
||||||
|
- 기존 자동 전화번호 로그인은 `POST /api/v1/auth/headless/link/init`로 링크를 발송한 뒤 `POST /api/v1/auth/headless/link/poll`로 승인 완료를 기다리는 흐름입니다.
|
||||||
|
- 새 전화번호 로그인 탭은 `POST /api/v1/auth/headless/phone-login`으로 직접 전송합니다.
|
||||||
|
- 필요하면 `PHONE_HEADLESS_LINK_INIT_ENDPOINT`, `PHONE_HEADLESS_LINK_POLL_ENDPOINT`, `PHONE_HEADLESS_LOGIN_ENDPOINT`로 오버라이드할 수 있습니다.
|
||||||
|
|
||||||
|
### 3. Tenant 접근 제한 처리
|
||||||
|
|
||||||
|
Headless RP도 일반 로그인과 동일하게 RP metadata의 `tenant_access_restricted`, `allowed_tenants` 설정 영향을 받습니다.
|
||||||
|
|
||||||
|
- headless password login 자체는 성공해도, 후속 consent 단계에서 tenant 제한이 걸릴 수 있습니다.
|
||||||
|
- 이 경우 Baron SSO는 `403 tenant_not_allowed`를 반환합니다.
|
||||||
|
- 데모 앱은 이 응답을 소비해 `userfront`의 locale 포함 에러 화면으로 이동시킵니다.
|
||||||
|
- 기본 경로: `/ko/error?error=tenant_not_allowed&error_description=...&details=...`
|
||||||
|
- locale 경로를 바꾸고 싶으면 `.env`에 `ERROR_LOCALE_PATH`를 추가해 덮어쓸 수 있습니다.
|
||||||
|
|
||||||
|
### 4. Back-Channel Logout
|
||||||
|
|
||||||
|
이 데모는 Baron SSO가 발행한 `logout_token`을 받아 RP 세션을 즉시 파기할 수 있습니다.
|
||||||
|
|
||||||
|
- `POST /backchannel-logout` 엔드포인트가 필요합니다.
|
||||||
|
- Baron이 서명한 `logout_token`은 `BARON_BACKCHANNEL_JWKS_URL`의 공개키로 검증합니다.
|
||||||
|
- 로그인 성공 시 `id_token`의 `sid` 또는 `sub`를 기준으로 RP 세션이 메모리에 바인딩됩니다.
|
||||||
|
- Baron에서 back-channel logout이 발생하면 해당 `sid` 또는 `sub`에 연결된 세션이 삭제됩니다.
|
||||||
|
- 기본 JWKS 주소는 `BARON_API_BASE_URL` 기준으로 `<BARON_API_BASE_URL>/api/v1/auth/backchannel/jwks.json` 입니다.
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ERROR_LOCALE_PATH=/ko/error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 테스트 체크리스트
|
||||||
|
|
||||||
|
다른 사용자가 headless login 작업을 검증할 때는 아래 순서로 확인하는 것이 가장 빠릅니다.
|
||||||
|
|
||||||
|
1. tenant 제한 없이 로그인해 headless 기본 흐름이 성공하는지 먼저 확인합니다.
|
||||||
|
2. RP에 `tenant_access_restricted=true`, `allowed_tenants=[...]`를 설정합니다.
|
||||||
|
3. 허용된 tenant 계정으로 로그인해 정상 성공 여부를 확인합니다.
|
||||||
|
4. 허용되지 않은 tenant 계정으로 로그인해 `userfront` 에러 화면으로 이동하는지 확인합니다.
|
||||||
|
5. 실패 시 `docker logs --tail 100 headless-login-demo`, `docker logs --tail 100 baron_backend`를 같이 확인합니다.
|
||||||
|
|
||||||
|
### 5. 장애 분석 포인트
|
||||||
|
|
||||||
|
- `Invalid URL`이 보이면 대부분 consent `403`을 RP가 처리하지 못한 경우입니다.
|
||||||
|
- `audience mismatch`가 보이면 `client_assertion`의 `aud` 또는 Baron SSO의 expected audience 구성이 어긋난 상태입니다.
|
||||||
|
- `tenant_not_allowed`가 backend 로그에 보이면 Baron SSO의 tenant 제한은 정상 동작 중이며, 이후 처리 위치는 RP 쪽입니다.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
이 프로젝트는 내부 테스트 및 데모 목적으로 제작되었습니다.
|
||||||
330
docs/plans/PLAYWRIGHT_E2E_ADOPTION_PLAN.md
Normal file
330
docs/plans/PLAYWRIGHT_E2E_ADOPTION_PLAN.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Playwright E2E 도입 계획
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
|
||||||
|
이 문서는 현재 Dachs 시스템 구조를 기준으로 Playwright를 도입하여 배포 전후 품질 검증을 자동화하기 위한 실행 계획을 정리한다.
|
||||||
|
|
||||||
|
현재 시스템은 다음 특성을 가진다.
|
||||||
|
|
||||||
|
- Vite 프런트엔드 + Express 백엔드 구조
|
||||||
|
- Docker Compose 기반 테스트/운영 배포
|
||||||
|
- Baron SSO 연동 및 세션 기반 인증
|
||||||
|
- Gitea 워크플로를 통한 코드 체크, 이미지 빌드, 운영 배포
|
||||||
|
|
||||||
|
이 구조에서는 단순 컴포넌트 테스트보다 실제 브라우저 흐름을 검증하는 E2E/smoke 테스트가 더 큰 효과를 가진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 도입 원칙
|
||||||
|
|
||||||
|
### 2.1. Playwright 역할 정의
|
||||||
|
|
||||||
|
Playwright는 다음 역할에 집중한다.
|
||||||
|
|
||||||
|
- 배포 전 사용자 관점 smoke 테스트
|
||||||
|
- 핵심 화면 진입 가능 여부 검증
|
||||||
|
- 인증 이후 세션 유지 및 로그아웃 동작 검증
|
||||||
|
- 프런트/백엔드/프록시 통합 상태 검증
|
||||||
|
|
||||||
|
다음 항목은 초기 도입 범위에서 제외한다.
|
||||||
|
|
||||||
|
- Baron 실서비스 의존도가 높은 완전 자동 로그인 승인 테스트
|
||||||
|
- SMS 수신 자체 검증
|
||||||
|
- 운영 Baron 실제 전화번호 인증 전체 플로우의 상시 CI 자동화
|
||||||
|
|
||||||
|
### 2.2. 테스트 계층 원칙
|
||||||
|
|
||||||
|
Playwright 테스트는 3계층으로 나눈다.
|
||||||
|
|
||||||
|
1. 로컬/CI smoke 테스트
|
||||||
|
2. mock 기반 인증 UI 테스트
|
||||||
|
3. 수동 또는 반자동 실연동 검증
|
||||||
|
|
||||||
|
이렇게 나누는 이유는 Baron 외부 시스템 의존성을 CI 불안정성으로 끌고 오지 않기 위해서다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 현재 구조에서의 권장 도입 방식
|
||||||
|
|
||||||
|
### 3.1. 기본 방향
|
||||||
|
|
||||||
|
현재 저장소 구조에서는 다음 흐름이 가장 적합하다.
|
||||||
|
|
||||||
|
1. `docker-compose.test.yaml`로 테스트 스택 기동
|
||||||
|
2. Playwright가 `http://127.0.0.1:8080` 기준으로 브라우저 테스트 수행
|
||||||
|
3. 실패 시 trace/screenshot/video 아티팩트 수집
|
||||||
|
4. 성공 시에만 이후 빌드/배포 단계 진행
|
||||||
|
|
||||||
|
### 3.2. 왜 이 방식이 적합한가
|
||||||
|
|
||||||
|
- 실제 nginx 프록시 경로를 포함한 상태를 검증할 수 있다.
|
||||||
|
- 세션 쿠키, API 라우팅, 정적 파일 서빙까지 함께 검증할 수 있다.
|
||||||
|
- 현재 시스템의 리스크는 단순 함수 로직보다 통합 동작에서 더 자주 발생한다.
|
||||||
|
- 운영과 유사한 경로를 테스트하면서도 Baron 실서비스 의존성은 줄일 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 권장 테스트 범위
|
||||||
|
|
||||||
|
### 4.1. 1차 도입 범위
|
||||||
|
|
||||||
|
초기 도입은 아래 항목만 자동화한다.
|
||||||
|
|
||||||
|
1. 로그인 페이지 렌더링
|
||||||
|
2. 로고, 안내 문구, 전화번호 입력창, 인증 버튼 존재 확인
|
||||||
|
3. `/api/auth/session` 결과에 따른 로그인 화면/앱 화면 분기 확인
|
||||||
|
4. 로그인 후 헤더 렌더링 확인
|
||||||
|
5. 핵심 메뉴 노출 확인
|
||||||
|
6. 로그아웃 버튼 노출 및 클릭 후 로그인 화면 복귀 확인
|
||||||
|
7. 주요 정적 리소스 정상 로딩 확인
|
||||||
|
|
||||||
|
### 4.2. 2차 도입 범위
|
||||||
|
|
||||||
|
1. 전화번호 로그인 시작 버튼 클릭 후 pending UI 상태 전환
|
||||||
|
2. `/api/auth/headless/phone/poll`의 pending/authenticated/expired/error 상태별 UI 검증
|
||||||
|
3. 서버/PC/스토리지 등 핵심 탭 진입 smoke 테스트
|
||||||
|
4. 자주 깨지는 뷰 하나 이상에 대한 회귀 테스트
|
||||||
|
|
||||||
|
### 4.3. 3차 도입 범위
|
||||||
|
|
||||||
|
1. mock 기반 인증 완료 흐름
|
||||||
|
2. 관리자/실무자 모드 전환 검증
|
||||||
|
3. 주요 CRUD 진입 경로 검증
|
||||||
|
4. 배포 후 production smoke 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Baron SSO 연동 테스트 전략
|
||||||
|
|
||||||
|
### 5.1. 자동화하지 말아야 할 영역
|
||||||
|
|
||||||
|
다음 항목은 초기 CI 자동화 대상에서 제외한다.
|
||||||
|
|
||||||
|
- 실제 SMS 수신 검증
|
||||||
|
- 실제 모바일 승인 검증
|
||||||
|
- Baron 외부 서비스 응답시간에 의존하는 full E2E 인증 테스트
|
||||||
|
|
||||||
|
### 5.2. 자동화 가능한 영역
|
||||||
|
|
||||||
|
다음 항목은 Playwright에서 자동화 가능하다.
|
||||||
|
|
||||||
|
- 전화번호 입력 UI 검증
|
||||||
|
- 인증 링크 요청 버튼 동작 검증
|
||||||
|
- `init` 성공/실패 메시지 렌더링 확인
|
||||||
|
- `poll` 상태별 화면 상태 전환 검증
|
||||||
|
|
||||||
|
### 5.3. 권장 방식
|
||||||
|
|
||||||
|
초기에는 다음 방식으로 구현한다.
|
||||||
|
|
||||||
|
1. 브라우저 레벨에서 API 응답 mock 사용
|
||||||
|
2. `pending`, `authenticated`, `expired`, `tenant_not_allowed` 응답 시나리오를 고정
|
||||||
|
3. Baron 실서비스 연동은 별도 수동 체크리스트로 유지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 구현 Task 목록
|
||||||
|
|
||||||
|
## 6.1. Phase 1: 기본 세팅
|
||||||
|
|
||||||
|
1. `@playwright/test` devDependency 추가
|
||||||
|
2. `playwright.config.ts` 생성
|
||||||
|
3. `tests/e2e` 디렉터리 생성
|
||||||
|
4. `package.json`에 아래 스크립트 추가
|
||||||
|
- `test:e2e`
|
||||||
|
- `test:e2e:headed`
|
||||||
|
- `test:e2e:ui`
|
||||||
|
5. 브라우저 설치 명령 정리
|
||||||
|
6. `.gitignore`에 Playwright 산출물 경로 정리
|
||||||
|
|
||||||
|
## 6.2. Phase 2: CI 실행 환경 정비
|
||||||
|
|
||||||
|
1. CI용 `.env` 또는 `.env.e2e` 규칙 정의
|
||||||
|
2. test compose 스택 기동 명령 정리
|
||||||
|
3. readiness check 절차 추가
|
||||||
|
4. 테스트 종료 후 compose down 정리
|
||||||
|
5. trace/screenshot/video 저장 경로 정의
|
||||||
|
|
||||||
|
## 6.3. Phase 3: 1차 smoke 테스트 작성
|
||||||
|
|
||||||
|
1. 로그인 페이지 로드 테스트
|
||||||
|
2. 로고 이미지 노출 테스트
|
||||||
|
3. SMS 안내 문구 노출 테스트
|
||||||
|
4. 전화번호 입력창/인증 버튼 존재 테스트
|
||||||
|
5. 비로그인 상태 앱 화면 차단 확인
|
||||||
|
6. 로그인된 세션 상태에서 앱 레이아웃 표시 확인
|
||||||
|
7. 헤더 우측 로그아웃 버튼 노출 확인
|
||||||
|
8. 로그아웃 클릭 후 로그인 화면 복귀 확인
|
||||||
|
|
||||||
|
## 6.4. Phase 4: 인증 UI 상태 테스트
|
||||||
|
|
||||||
|
1. `phone/init` 성공 응답 mock 테스트
|
||||||
|
2. `phone/poll` pending 상태 mock 테스트
|
||||||
|
3. `phone/poll` authenticated 상태 mock 테스트
|
||||||
|
4. `phone/poll` expired 상태 mock 테스트
|
||||||
|
5. `phone/poll` 에러 메시지 표시 테스트
|
||||||
|
|
||||||
|
## 6.5. Phase 5: 화면 회귀 smoke 확대
|
||||||
|
|
||||||
|
1. 서버 탭 진입 테스트
|
||||||
|
2. PC 탭 진입 테스트
|
||||||
|
3. 스토리지 탭 진입 테스트
|
||||||
|
4. 대시보드 진입 테스트
|
||||||
|
5. 공통 헤더/메뉴 렌더링 회귀 테스트
|
||||||
|
|
||||||
|
## 6.6. Phase 6: 배포 파이프라인 통합
|
||||||
|
|
||||||
|
1. `itam_playwright_check.yml` 신규 추가
|
||||||
|
2. `main`, `Dockerizing`, `pull_request`에서 자동 실행
|
||||||
|
3. `docker compose -f docker-compose.test.yaml up -d --build` 후 Playwright 실행
|
||||||
|
4. 실패 시 아티팩트 업로드
|
||||||
|
5. 필요 시 production deploy 전 필수 게이트로 연결
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 워크플로 통합 권장안
|
||||||
|
|
||||||
|
### 7.1. 신규 워크플로
|
||||||
|
|
||||||
|
권장 워크플로 이름:
|
||||||
|
|
||||||
|
- `itam_playwright_check.yml`
|
||||||
|
|
||||||
|
### 7.2. 실행 순서
|
||||||
|
|
||||||
|
권장 순서는 아래와 같다.
|
||||||
|
|
||||||
|
1. `itam_code_check.yml`
|
||||||
|
2. `itam_docker_build_check.yml`
|
||||||
|
3. `itam_playwright_check.yml`
|
||||||
|
4. `itam_production_deploy.yml`
|
||||||
|
|
||||||
|
### 7.3. 이유
|
||||||
|
|
||||||
|
- 코드/빌드 오류를 먼저 빠르게 걸러낸다.
|
||||||
|
- Playwright는 상대적으로 무거우므로 빌드 검증 이후에 실행하는 것이 효율적이다.
|
||||||
|
- 배포 전에 실제 브라우저 smoke를 마지막 게이트로 둘 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 디렉터리 구조 제안
|
||||||
|
|
||||||
|
```text
|
||||||
|
playwright.config.ts
|
||||||
|
tests/
|
||||||
|
e2e/
|
||||||
|
auth.login-page.spec.ts
|
||||||
|
auth.logout.spec.ts
|
||||||
|
auth.phone-flow.mock.spec.ts
|
||||||
|
smoke.navigation.spec.ts
|
||||||
|
fixtures/
|
||||||
|
utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
권장 파일 역할:
|
||||||
|
|
||||||
|
- `auth.login-page.spec.ts`: 로그인 화면 존재/문구/입력 요소 검증
|
||||||
|
- `auth.logout.spec.ts`: 로그인 후 로그아웃 버튼 동작 검증
|
||||||
|
- `auth.phone-flow.mock.spec.ts`: pending/authenticated/expired 상태 검증
|
||||||
|
- `smoke.navigation.spec.ts`: 주요 GNB 회귀 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 운영/테스트 환경 변수 정책
|
||||||
|
|
||||||
|
### 9.1. Playwright 기본 대상
|
||||||
|
|
||||||
|
- 기본 대상: `http://127.0.0.1:8080`
|
||||||
|
- 실행 대상: `docker-compose.test.yaml`로 띄운 테스트 스택
|
||||||
|
|
||||||
|
### 9.2. 실연동 테스트 분리
|
||||||
|
|
||||||
|
운영 Baron 실연동 검증은 다음과 같이 분리한다.
|
||||||
|
|
||||||
|
- 기본 CI에는 포함하지 않음
|
||||||
|
- 수동 실행 workflow 또는 로컬 수동 검증으로 유지
|
||||||
|
- 필요 시 nightly/manual workflow로만 별도 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 위험 요소 및 대응
|
||||||
|
|
||||||
|
### 10.1. Baron 외부 의존성
|
||||||
|
|
||||||
|
위험:
|
||||||
|
|
||||||
|
- 응답 지연
|
||||||
|
- 인증 정책 변경
|
||||||
|
- SMS/모바일 승인 필요
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
- mock 기반 테스트 우선
|
||||||
|
- 실연동은 수동 체크 유지
|
||||||
|
|
||||||
|
### 10.2. 세션 스토어 한계
|
||||||
|
|
||||||
|
위험:
|
||||||
|
|
||||||
|
- 현재 기본 메모리 세션 사용
|
||||||
|
- 멀티 인스턴스/재시작 시 일관성 한계
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
- Playwright 도입 자체는 가능
|
||||||
|
- 장기적으로 Redis 세션 스토어 검토
|
||||||
|
|
||||||
|
### 10.3. 테스트 데이터 불안정
|
||||||
|
|
||||||
|
위험:
|
||||||
|
|
||||||
|
- DB 상태에 따라 화면 결과 달라짐
|
||||||
|
|
||||||
|
대응:
|
||||||
|
|
||||||
|
- 테스트용 fixture 데이터 도입
|
||||||
|
- 최소 seed 또는 고정 상태 준비
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 도입 완료 기준
|
||||||
|
|
||||||
|
다음 조건을 만족하면 1차 도입 완료로 본다.
|
||||||
|
|
||||||
|
1. Playwright 설정 파일이 저장소에 추가됨
|
||||||
|
2. 테스트 스택 기준 smoke 테스트가 로컬에서 통과함
|
||||||
|
3. Gitea CI에서 Playwright 체크가 자동 실행됨
|
||||||
|
4. 실패 시 trace/screenshot 아티팩트 확인 가능함
|
||||||
|
5. 로그인 페이지/로그아웃/핵심 헤더 회귀 테스트가 동작함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 권장 실행 순서
|
||||||
|
|
||||||
|
### 1차 구현 권장 순서
|
||||||
|
|
||||||
|
1. Playwright 설치 및 설정 파일 추가
|
||||||
|
2. 로그인 페이지 smoke 테스트 작성
|
||||||
|
3. 로그아웃 smoke 테스트 작성
|
||||||
|
4. test compose 기반 CI 워크플로 연결
|
||||||
|
5. mock 기반 phone flow 테스트 추가
|
||||||
|
|
||||||
|
### 2차 확장 권장 순서
|
||||||
|
|
||||||
|
1. 핵심 탭 smoke 테스트 추가
|
||||||
|
2. 실무자/관리자 모드 전환 테스트 추가
|
||||||
|
3. 배포 전 필수 게이트 연결
|
||||||
|
4. 운영 smoke 또는 nightly 검사 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 결론
|
||||||
|
|
||||||
|
현재 Dachs 구조에서 Playwright는 다음 방식으로 도입하는 것이 가장 적합하다.
|
||||||
|
|
||||||
|
- Docker Compose test 스택 기반 E2E/smoke
|
||||||
|
- Baron 실서비스 전체 플로우는 초기 CI 자동화에서 제외
|
||||||
|
- 로그인 화면, 세션, 로그아웃, 핵심 내비게이션부터 단계적으로 확대
|
||||||
|
- Gitea 워크플로의 배포 전 게이트로 통합
|
||||||
|
|
||||||
|
즉, 도입 초점은 “완전한 외부 인증 자동화”가 아니라 “현재 시스템의 실제 사용자 흐름이 배포 전에 깨지지 않는지 검증하는 것”에 둔다.
|
||||||
117
server.js
117
server.js
@@ -16,7 +16,10 @@ const {
|
|||||||
REDIRECT_URI,
|
REDIRECT_URI,
|
||||||
JWKS_URI,
|
JWKS_URI,
|
||||||
SESSION_SECRET,
|
SESSION_SECRET,
|
||||||
ERROR_LOCALE_PATH
|
ERROR_LOCALE_PATH,
|
||||||
|
PHONE_HEADLESS_LINK_INIT_ENDPOINT,
|
||||||
|
PHONE_HEADLESS_LINK_POLL_ENDPOINT,
|
||||||
|
PHONE_HEADLESS_LOGIN_ENDPOINT
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
const SESSION_SECRET_VALUE = SESSION_SECRET || 'itam-headless-session-secret';
|
const SESSION_SECRET_VALUE = SESSION_SECRET || 'itam-headless-session-secret';
|
||||||
@@ -207,6 +210,8 @@ const ensureSsoConfig = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildIssuerEndpoint = (overrideUrl, fallbackPath) => new URL(overrideUrl || fallbackPath, ISSUER).toString();
|
||||||
|
|
||||||
const base64Url = (input) => Buffer.from(input).toString('base64url');
|
const base64Url = (input) => Buffer.from(input).toString('base64url');
|
||||||
|
|
||||||
const sha256Base64Url = (input) => crypto.createHash('sha256').update(input).digest('base64url');
|
const sha256Base64Url = (input) => crypto.createHash('sha256').update(input).digest('base64url');
|
||||||
@@ -561,9 +566,36 @@ const runHeadlessSsoLogin = async ({ loginId, password }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveAuthenticatedPhoneLogin = async ({ redirectTo, cookies, discovery, authState }) => {
|
||||||
|
const resolution = await resolveRedirects(redirectTo, cookies);
|
||||||
|
if (resolution.isErrorRedirect) {
|
||||||
|
return { status: 'error_redirect', redirectTo: resolution.finalUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolution.code) {
|
||||||
|
throw new Error('Authorization code not found after phone redirect resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await exchangeAuthorizationCode(
|
||||||
|
resolution.code,
|
||||||
|
discovery,
|
||||||
|
authState.codeVerifier
|
||||||
|
);
|
||||||
|
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'authenticated',
|
||||||
|
tokens: tokenResponse,
|
||||||
|
profile: idTokenPayload
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const initHeadlessPhoneLogin = async ({ loginId }) => {
|
const initHeadlessPhoneLogin = async ({ loginId }) => {
|
||||||
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
|
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
|
||||||
const headlessEndpoint = new URL('/api/v1/auth/headless/link/init', ISSUER).toString();
|
const headlessEndpoint = buildIssuerEndpoint(
|
||||||
|
PHONE_HEADLESS_LOGIN_ENDPOINT || PHONE_HEADLESS_LINK_INIT_ENDPOINT,
|
||||||
|
'/api/v1/auth/headless/phone-login'
|
||||||
|
);
|
||||||
|
|
||||||
const initRes = await fetch(headlessEndpoint, {
|
const initRes = await fetch(headlessEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -581,11 +613,25 @@ const initHeadlessPhoneLogin = async ({ loginId }) => {
|
|||||||
|
|
||||||
const nextCookies = appendCookies(cookies, initRes);
|
const nextCookies = appendCookies(cookies, initRes);
|
||||||
const initBody = await parseJsonSafely(initRes);
|
const initBody = await parseJsonSafely(initRes);
|
||||||
if (!initRes.ok || !initBody?.pendingRef) {
|
if (!initRes.ok) {
|
||||||
|
throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initBody?.redirectTo) {
|
||||||
|
return resolveAuthenticatedPhoneLogin({
|
||||||
|
redirectTo: initBody.redirectTo,
|
||||||
|
cookies: nextCookies,
|
||||||
|
discovery,
|
||||||
|
authState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initBody?.pendingRef) {
|
||||||
throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
|
throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status: 'pending',
|
||||||
discovery,
|
discovery,
|
||||||
cookies: nextCookies,
|
cookies: nextCookies,
|
||||||
pendingRef: initBody.pendingRef,
|
pendingRef: initBody.pendingRef,
|
||||||
@@ -597,7 +643,10 @@ const initHeadlessPhoneLogin = async ({ loginId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pollHeadlessPhoneLogin = async (pendingContext) => {
|
const pollHeadlessPhoneLogin = async (pendingContext) => {
|
||||||
const pollEndpoint = new URL('/api/v1/auth/headless/link/poll', ISSUER).toString();
|
const pollEndpoint = buildIssuerEndpoint(
|
||||||
|
PHONE_HEADLESS_LINK_POLL_ENDPOINT,
|
||||||
|
'/api/v1/auth/headless/link/poll'
|
||||||
|
);
|
||||||
const pollRes = await fetch(pollEndpoint, {
|
const pollRes = await fetch(pollEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -615,27 +664,12 @@ const pollHeadlessPhoneLogin = async (pendingContext) => {
|
|||||||
const pollBody = await parseJsonSafely(pollRes);
|
const pollBody = await parseJsonSafely(pollRes);
|
||||||
|
|
||||||
if (pollRes.ok && pollBody?.redirectTo) {
|
if (pollRes.ok && pollBody?.redirectTo) {
|
||||||
const resolution = await resolveRedirects(pollBody.redirectTo, nextCookies);
|
return resolveAuthenticatedPhoneLogin({
|
||||||
if (resolution.isErrorRedirect) {
|
redirectTo: pollBody.redirectTo,
|
||||||
return { status: 'error_redirect', redirectTo: resolution.finalUrl };
|
cookies: nextCookies,
|
||||||
}
|
discovery: pendingContext.discovery,
|
||||||
|
authState: pendingContext.authState
|
||||||
if (!resolution.code) {
|
});
|
||||||
throw new Error('Authorization code not found after phone redirect resolution');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResponse = await exchangeAuthorizationCode(
|
|
||||||
resolution.code,
|
|
||||||
pendingContext.discovery,
|
|
||||||
pendingContext.authState.codeVerifier
|
|
||||||
);
|
|
||||||
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'authenticated',
|
|
||||||
tokens: tokenResponse,
|
|
||||||
profile: idTokenPayload
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusCode = pollBody?.code || pollBody?.error;
|
const statusCode = pollBody?.code || pollBody?.error;
|
||||||
@@ -745,17 +779,40 @@ app.post('/api/auth/headless/phone/init', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pendingLogin = await initHeadlessPhoneLogin({ loginId });
|
const phoneLoginResult = await initHeadlessPhoneLogin({ loginId });
|
||||||
|
|
||||||
|
if (phoneLoginResult.status === 'error_redirect') {
|
||||||
|
return res.status(403).json({ redirectTo: phoneLoginResult.redirectTo, code: 'tenant_not_allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phoneLoginResult.status === 'authenticated') {
|
||||||
|
req.session.user = {
|
||||||
|
loginId,
|
||||||
|
profile: phoneLoginResult.profile,
|
||||||
|
tokens: {
|
||||||
|
accessToken: phoneLoginResult.tokens.access_token,
|
||||||
|
idToken: phoneLoginResult.tokens.id_token,
|
||||||
|
expiresIn: phoneLoginResult.tokens.expires_in,
|
||||||
|
scope: phoneLoginResult.tokens.scope,
|
||||||
|
tokenType: phoneLoginResult.tokens.token_type
|
||||||
|
}
|
||||||
|
};
|
||||||
|
registerSessionIdentity(req.sessionID, req.session.user);
|
||||||
|
await saveSession(req);
|
||||||
|
return res.json({ success: true, status: 'authenticated', user: req.session.user });
|
||||||
|
}
|
||||||
|
|
||||||
req.session.pendingPhoneLogin = {
|
req.session.pendingPhoneLogin = {
|
||||||
...pendingLogin,
|
...phoneLoginResult,
|
||||||
startedAt: Date.now()
|
startedAt: Date.now()
|
||||||
};
|
};
|
||||||
await saveSession(req);
|
await saveSession(req);
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
pendingRef: pendingLogin.pendingRef,
|
status: 'pending',
|
||||||
intervalMs: pendingLogin.intervalMs,
|
pendingRef: phoneLoginResult.pendingRef,
|
||||||
expiresInMs: pendingLogin.expiresInMs,
|
intervalMs: phoneLoginResult.intervalMs,
|
||||||
|
expiresInMs: phoneLoginResult.expiresInMs,
|
||||||
message: '인증 링크를 발송했습니다. 모바일에서 승인 후 잠시만 기다려주세요.'
|
message: '인증 링크를 발송했습니다. 모바일에서 승인 후 잠시만 기다려주세요.'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -371,6 +371,12 @@ function showLoginScreen(errorMessage?: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.status === 'authenticated') {
|
||||||
|
clearPhonePollTimer();
|
||||||
|
initializeAppDirectly();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setMessage(phoneLoginStatus, payload.message || '인증 링크를 발송했습니다. 모바일에서 승인해 주세요.');
|
setMessage(phoneLoginStatus, payload.message || '인증 링크를 발송했습니다. 모바일에서 승인해 주세요.');
|
||||||
pollPhoneLogin(payload.pendingRef, payload.intervalMs || 3000);
|
pollPhoneLogin(payload.pendingRef, payload.intervalMs || 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user