OAuth 로그인 문제 해결

GitSalt OAuth 연동 서비스에서 로그인이 안 될 때 원인을 찾는 순서

0. 제1원칙 — 브라우저에 묻지 말고, 서버가 말하게 만든다

가장 크게 낭비한 것은 사용자에게 DevTools 관찰을 부탁한 왕복이었습니다.
서버가 스스로 진단을 내놓게 만드세요. 이것만 있으면 클릭 한 번으로 원인이 갈립니다.

  • /api/config 의 auth.credentials — client_id·secret 짝이 맞는지 (기동 시 자동 점검)
  • /api/config 의 auth.recent — 최근 시도가 어느 단계까지 갔는지 (login_start → login_ok)

규칙은 하나입니다: 단계 이름과 «몇 초 전» 만. 아이디·토큰·시크릿은 넣지 않습니다.
/api/config는 로그인 없이 열려야 하는 응답이기 때문입니다.

자격 점검은 일부러 틀린 code로 토큰 교환을 한 번 해 보는 것으로 충분합니다. GitSalt가 세 경우를 다르게 답합니다:

없는 client_id   → {"error":"invalid_client","error_description":"cannot load client with client id: …"}
시크릿 불일치     → {"error":"unauthorized_client","error_description":"invalid client secret"}
자격이 맞으면     → code 를 탓하는 다른 오류  ⇒ credentials: "ok"

1. 배포 순서 — 환경변수가 먼저다

이 저장소는 푸시가 곧 배포입니다. 로그인은 설정이 없으면 잠기도록(fail-closed) 만들어져 있으므로 순서가 뒤바뀌면 그 사이 사이트 전체가 잠깁니다.

  1. GitSalt → 설정 → 애플리케이션 → OAuth2 애플리케이션 등록.
    리다이렉트 URI는 https://<도메인>/auth/callback — 등록값과 정확히 같아야 합니다.
  2. bee-cast 테넌트 환경변수에 4개 등록: GITSALT_OAUTH_CLIENT_ID · GITSALT_OAUTH_CLIENT_SECRET · SESSION_SECRET · ALLOWED_USERS(또는 ALLOWED_ORG).
  3. 그 다음에 푸시.

ID 와 SECRET 은 같은 앱에서 나온 짝이어야 합니다. 앱을 다시 만들거나 시크릿을 재발급하면 값이 바뀌므로, 한쪽만 갈아 넣어 어긋나기 쉬운 지점입니다.

허용 목록을 필수로 세는 이유: GitSalt는 가입이 열려 있어서 「로그인하면 통과」로 두면 사실상 공개 상태가 됩니다.

2. 진단 사다리 — 싼 것부터

① 설정이 다 들어갔나

curl -s https://<도메인>/api/config
  • auth.mode: "unset" → auth.missing 이 빠진 변수 «이름»을 알려줍니다. 등록하고 재배포.
  • auth.credentials: "secret_mismatch" → ID·SECRET이 다른 앱의 것입니다.
  • auth.credentials: "client_id_unknown" → client_id가 GitSalt에 없습니다.
  • auth.mode: "on" · credentials: "ok" → 설정은 끝. ②로.

② 서버가 로그인 요청을 내주나

curl -s -o /dev/null -D - https://<도메인>/auth/login | grep -iE "^(HTTP|location|set-cookie)"

봐야 할 것: 302 · location에 code_challenge_method=S256과 state · redirect_uri가 https이고 GitSalt 등록값과 동일 · set-cookie: xc_oauth=…; HttpOnly; SameSite=Lax; Secure

redirect_uri가 http로 나오면 nginx 뒤에서 x-forwarded-proto를 못 읽는 것이니 PUBLIC_ORIGIN으로 고정하세요.

③ 클릭 한 번 → recent 읽기

여기서부터는 사용자에게 버튼 한 번만 부탁하고, 나머지는 서버 기록으로 봅니다.

recent뜻다음
비어 있음클릭이 서버에 닿지 않았다§3 서비스워커 확인
login_start 만GitSalt로 갔지만 콜백으로 안 돌아왔다등록 URI · GitSalt 화면 확인
callback_no_cookie왕복 쿠키가 없다/만료10분 초과, 또는 쿠키 차단
callback_state_mismatchstate 불일치오래된 탭에서 재시도했는지
callback_token_failed토큰 교환 실패credentials를 함께 보기
callback_not_allowed왕복 성공, 허용 목록에 없음ALLOWED_USERS에 GitSalt 아이디 추가
login_ok 인데 화면은 로그인세션 쿠키가 브라우저에 남지 않았다§4 set-cookie 확인

3. 함정 1 — 서비스워커가 /auth/*를 가로챈다

증상: 로그인 버튼을 누르면 화면이 «번쩍» 하고 제자리. GitSalt로 넘어가지 않습니다.

지문 (이 조합이면 거의 확정):

  • 시크릿 창에서는 정상 (워커가 없으니까) → 서버 탓으로 오해하게 만드는 가장 큰 함정
  • xc_oauth 쿠키는 남아 있다 (요청이 서버까지 갔다는 증거)
  • 주소창에 code=가 없고, 화면에 붉은 오류 문구도 없다
  • Application → Service Workers → Unregister 로도 안 풀린다 — main.tsx가 새로고침 때 곧바로 다시 등록하기 때문입니다.

가장 빠른 확인: DevTools → Application → Service Workers → Bypass for network 체크 후 클릭.

원인과 수정: vite.config.ts의 runtimeCaching에 /auth/*가 걸려 있으면 워커가 그 이동을 직접 처리합니다. NetworkOnly라는 이름이 「캐시 안 함」처럼 보이지만 실제로는 「워커가 응답한다」는 뜻입니다.

// 나쁨 — 워커가 /auth/* 이동을 대신 처리한다
urlPattern: ({ url }) =>
  url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/") || url.pathname === "/health",
handler: "NetworkOnly",

// 좋음 — 걸리는 규칙이 없으면 워커는 응답하지 않고 브라우저가 직접 이동한다
urlPattern: ({ url }) => url.pathname.startsWith("/api/") || url.pathname === "/health",
handler: "NetworkOnly",

navigateFallbackDenylist의 /^\/auth\//는 그대로 두세요. 그쪽은 워커가 앱 셸을 대신 내주는 것을 막는, 반드시 필요한 항목입니다.

수정을 배포한 뒤에도 예전 워커가 살아 있는 창에서는 계속 같은 증상입니다. 새로고침 후 «새 버전» 확인창을 수락하거나, 그 사이트 탭을 전부 닫고 다시 열어야 합니다.

증상: 서버 기록은 login_ok인데 브라우저에 xc_session이 없고 계속 로그인 화면.
§3과 겉모습이 똑같이 «번쩍하고 로그인 화면» 입니다. 원인은 전혀 다릅니다.

원인: Web Response → node:http 변환에서 set-cookie를 흘리는 방식.

// 나쁨 — Headers.forEach 는 set-cookie 를 값마다 한 번씩 넘기고 setHeader 는 덮어쓴다.
// 콜백은 쿠키를 둘 보내므로(세션 발급 + 왕복 쿠키 삭제) 마지막 줄만 남고,
// 그 마지막이 xc_oauth=; Max-Age=0 — 삭제 쿠키만 나가고 세션은 나가지도 않는다.
response.headers.forEach((value, key) => res.setHeader(key, value));

// 좋음
const cookies = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
response.headers.forEach((value, key) => {
  if (key.toLowerCase() === "set-cookie") return;
  res.setHeader(key, value);
});
if (cookies.length) res.setHeader("set-cookie", cookies);

이 실패는 화면만 봐서는 찾을 수 없습니다. 서버는 성공을 찍고 브라우저는 로그인 화면입니다.
테스트로 못 박아 두세요 — scripts/http-cookie.test.mjs가 고치기 전에 먼저 돌려서 실패하는 것을 확인하고 나서 고치세요.

5. 증상 사전 — 같은 증상, 다른 원인

증상확인할 것원인
로그인 화면 대신 «설정이 끝나지 않았습니다»auth.missing환경변수 미등록
번쩍, xc_oauth 남음, code= 없음Bypass for network§3 워커가 가로챔
번쩍, 서버는 login_ok, xc_session 없음recent§4 set-cookie 유실
붉은 문구 invalid client secretcredentialsID·SECRET 짝 불일치
붉은 문구 «계정은 허용 목록에 없습니다»그 문구의 아이디ALLOWED_USERS 추가 필요
붉은 문구 «로그인 요청이 만료됐습니다»쿠키 차단 여부10분 초과 / 쿠키 차단
API가 전부 401, 화면은 정상로그인 안 한 상태정상 동작
API가 전부 503auth.missingfail-closed

6. 배포 후 검증

# 설정·자격
curl -s https://<도메인>/api/config

# 로그인 시작 (302 · S256 · state · Secure 쿠키)
curl -s -o /dev/null -D - https://<도메인>/auth/login | grep -iE "^(HTTP|location|set-cookie)"

# 문지기 (쿠키 없이 401, 설정 없으면 503)
for p in /api/models /api/git/config; do curl -s -o /dev/null -w "$p %{http_code}\n" https://<도메인>$p; done

# 로그아웃은 POST 만 (GET 은 405)
curl -s -o /dev/null -w "GET %{http_code}\n" https://<도메인>/auth/logout

그리고 npm test — scripts/auth.test.mjs(서명 위조·만료·문지기)와 scripts/http-cookie.test.mjs(쿠키 여러 개)가 이 문서의 두 함정을 지킵니다.

← 도움말로 돌아가기