사내 인증 앱의 WebView 승인 화면에는 승인이 끝난 뒤 요청한 앱으로 돌아가는 단계가 없었다. 결론은 기존 WebView 화면을 그대로 두고, 검증한 콜백 URI와 등록 패키지로만 자동 복귀하는 것이었다. 같은 날 메신저에 먼저 적용하고 두 시간이 안 돼 클라우드 앱까지 넓혔다.

승인은 끝났는데 사용자는 앱 사이에 남았다
협업 메신저 앱에 인증 앱을 통한 로그인 흐름이 추가됐다. 메신저가 회사 승인 페이지 주소를 넘기면, 인증 앱은 기존 로그인·승인 화면을 그대로 사용했다.
문제는 그다음이었다. 복귀 처리가 없으면 승인이 끝나도 인증 앱 화면에 머무르고, 사용자가 메신저로 직접 돌아가야 한다. 앱 간 인증 흐름에서 복귀 단계가 빠져 있었다.
그렇다고 "성공 화면이 보이면 아무 데로나 돌아가라"고 짤 수는 없었다. 복귀 주소를 밖에서 받는 순간, 그 주소가 곧 공격 지점이 되기 때문이다.

복귀 주소는 밖에서 오니까 먼저 의심했다
메신저는 승인 URL과 복귀 URI를 Intent extra로 넘겼다. 복귀 주소를 그대로 믿지 않고, 허용할 목적지와 승인 페이지 조건부터 정했다.
- 승인 URL은 회사 HTTPS 호스트의 지정된 설정 페이지와 승인 fragment만 허용
- 복귀 URI는 메신저 전용 스킴의
auth?request=...형태만 허용 - 최초·현재·성공 알림 URL은 허용된 승인 페이지이며 같은 승인 값을 가져야 함
- 복귀 Intent의 대상 패키지를 등록된 앱으로 고정
- 토큰·비밀번호·승인 코드는 복귀 URI에 포함하지 않음
- 복귀 요청 extra가 없는 일반 QR 사용에서는 자동 복귀하지 않음
- 복귀할 때 인증 앱을 로그아웃하지 않음
주소를 단순히 문자열 일부로 비교하지 않고 URI로 파싱했다. 각 구성 요소를 따로 확인하고, 쿼리는 request= 형식만 허용했다.
단위 테스트에는 일부러 비튼 주소를 잔뜩 넣었다. http로 바꾼 주소, 회사 도메인 뒤에 다른 도메인을 붙인 주소, /../로 경로를 돌린 주소, 쿼리에 redirect=를 끼운 주소가 모두 거부돼야 통과다.

승인 성공은 WebView 브리지로 전달받았다
기존 승인 화면은 웹 페이지라서, 성공 표시를 네이티브 앱이 알아야 했다. 회사 승인 페이지가 완료 시 보여 주는 #qrSuccess를 허용된 승인 URL에서만 관찰하도록 했다.
성공을 확인하면 JavascriptInterface 브리지 메서드가 복귀 처리를 호출했다. 한 번 처리하면 플래그로 중복 복귀를 막고, 새 Intent에서는 초기화했다. 화면이 파괴될 때 브리지도 제거했다.
복귀 Intent에는 대상 패키지와 CLEAR_TOP·SINGLE_TOP 플래그를 지정했다. 실행 뒤에는 extra를 지우고 인증 앱 화면을 닫았다. 대상 앱을 열 수 없으면 직접 열라는 토스트를 띄웠다.
다만 승인 성공 표시가 세션을 만들지는 않는다. 호출 앱은 서버 승인과 PKCE(인가 코드 탈취만으로 토큰을 얻지 못하게 하는 검증)를 별도로 확인한다. 화면만 보고 로그인이 끝났다고 판단하지 않았다.
두 시간이 안 돼 복귀 대상을 클라우드 앱까지 넓혔다
메신저용 복귀 커밋 뒤 약 1시간 43분 만에 클라우드 앱 경로가 추가됐다. 처음에는 메신저 복귀 여부만 판단했지만, 이제는 대상 앱 자체를 구별해야 했다.
그래서 참·거짓을 반환하던 검사를 등록된 복귀 대상 매핑으로 바꿨다. 아래 코드는 실제 스킴과 패키지명을 바꾼 개념 예시이며, 복귀 URI 검사의 핵심만 보여 준다.
// 개념 예시: 복귀 URI를 등록된 앱 하나로만 매핑한다
data class Target(val packageName: String, val displayName: String)
fun returnTarget(raw: String?): Target? {
val uri = runCatching { URI(raw) }.getOrNull() ?: return null
if (uri.host != "auth" || uri.port != -1 || uri.rawUserInfo != null ||
uri.rawFragment != null ||
!Regex("request=[A-Za-z0-9_-]{1,200}").matches(uri.rawQuery.orEmpty())) return null
return when {
uri.scheme == "teamapp" && uri.rawPath.orEmpty().isEmpty() ->
Target("com.example.team", "메신저")
uri.scheme == "cloudapp" && uri.rawPath == "/android" ->
Target("com.example.cloud", "클라우드")
else -> null
}
}
클라우드 쪽은 안드로이드 경로가 붙은 콜백만 받는다. 윈도우용 경로나 경로 없는 콜백은 거부한다. 기존 메신저 검사 함수는 새 함수의 결과를 보도록 감싸서, 예전 테스트도 그대로 살렸다.

클라우드 앱은 기존 서버의 QR 생성 API에서 승인 링크만 가져왔다. QR 이미지는 표시하지 않고 인증 앱으로 링크를 전달했다. 승인 서버 파일이나 윈도우 QR 화면은 배포하지 않았다.
에뮬레이터 검증과 남은 실기기 확인
메신저 복귀를 추가한 첫 커밋에서는 안드로이드 에뮬레이터로 인증 앱 로그인·비로그인 두 흐름을 실제 계정으로 확인했다. 단위 테스트와 Debug·Release 빌드, lint도 통과했다.
Release 난독화 뒤에도 JavascriptInterface 메서드가 유지되는지 확인했다. 그렇지 않으면 웹 화면에서 브리지 메서드를 호출할 수 없기 때문이다.
클라우드 경로를 넓힌 두 번째 커밋에는 전용 단위 테스트를 추가했지만, 에뮬레이터 확인 기록은 메신저 경로 기준이다. 실물 휴대폰 테스트와 스토어 배포도 이번 작업 범위 밖으로 남겼다. 자동 복귀 기능을 쓰려면 인증 앱과 호출 앱을 함께 업데이트해야 한다.
이번 작업에서 배운 것
- 복귀 주소는 URI 항목별로 검증하고, 실행할 앱 패키지도 고정한다.
- 화면의 성공 표시는 신호일 뿐이며, 호출 앱이 서버 승인과 PKCE를 확인한다.
- 복귀 대상이 늘어날 수 있다면 참·거짓 검사보다 대상 매핑이 적합했다.
다음에는 실물 기기에서 WebView 승인 뒤 앱 자동 복귀를 확인할 생각이다. 같은 사내 인증 서버가 중계하는 클라우드 앱 푸시 문제는 FCM 빈 알림의 범인은 android.notification 한 줄이었다에 정리했다. WebView 브리지는 안드로이드 공식 바인딩 문서를 참고했다.
'프로젝트 > 사내 인증 서비스' 카테고리의 다른 글
| FCM 빈 알림의 범인은 android.notification 한 줄이었다 (0) | 2026.10.17 |
|---|---|
| MySQL collation이 섞여 조회가 깨진 날 (0) | 2026.10.16 |