ReSSO
요약. ReSSO: 자율 개선 회차 21회, 릴리즈 13건. 최근 릴리즈 v0.9.78 (자산 2개). 건강 D
- 21회차
- 1프로젝트
- 13배포 준비 완료
- 0릴리즈 진행 중
- 2병합 완료
- 3검토 대기
- 3검증 실패
- 0변경 없음
- 0실행 오류
- $105.15비용
- 4시간 29분에이전트 시간
현황
- 저장소
- https://github.com/hkjang/ReSSO
- 마지막 회차
- 2026-09-12 12:04 KST — • 기타 guarded files, PR open PR #19
- 최근 릴리즈
- v0.9.78 — released · 자산 2개 (이전 v0.9.77: 2개) 전체 릴리즈 →
회차 이력
| 일시 | 프로젝트 | 결과 |
|---|---|---|
| 2026-09-12 12:04 | ReSSO | 검토 대기 guarded files, PR open PR #19 |
| 2026-09-11 00:02 | ReSSO | 배포 준비 완료 merged PR #18, released v0.9.78 |
| 2026-09-10 18:58 | ReSSO | 배포 준비 완료 merged PR #17, released v0.9.77 |
| 2026-09-10 10:16 | ReSSO | 배포 준비 완료 merged PR #16, released v0.9.76 |
| 2026-09-09 14:47 | ReSSO | 배포 준비 완료 merged PR #15, released v0.9.75 |
| 2026-09-09 06:37 | ReSSO | 배포 준비 완료 merged PR #14, released v0.9.74 |
| 2026-09-09 04:17 | ReSSO | 배포 준비 완료 release-only, released v0.9.73 |
| 2026-09-08 23:42 | ReSSO | 검토 대기 guarded files, PR open PR #13 |
| 2026-09-08 19:04 | ReSSO | 배포 준비 완료 merged PR #12, released v0.9.72 |
| 2026-09-08 13:13 | ReSSO | 배포 준비 완료 merged PR #11, released v0.9.71 |
| 2026-09-07 07:36 | ReSSO | 검토 대기 review held, PR open PR #10 |
| 2026-09-06 22:41 | ReSSO | 검증 실패 verify failed: secrets in diff |
| 2026-09-06 00:59 | ReSSO | 검증 실패 verify failed: secrets in diff |
| 2026-09-05 18:02 | ReSSO | 검증 실패 verify failed: secrets in diff |
| 2026-09-04 23:18 | ReSSO | 배포 준비 완료 merged PR #9, released v0.9.70 |
| 2026-09-04 04:46 | ReSSO | 배포 준비 완료 merged PR #8, released v0.9.69 |
| 2026-09-03 20:51 | ReSSO | 병합 완료 merged PR #7, release missing |
| 2026-09-03 12:49 | ReSSO | 병합 완료 merged PR #6, release missing |
| 2026-09-03 07:05 | ReSSO | 배포 준비 완료 merged PR #5, released v0.9.68 |
| 2026-09-03 01:04 | ReSSO | 배포 준비 완료 merged PR #4, released v0.9.67 |
| 2026-09-02 16:40 | ReSSO | 배포 준비 완료 release-only, released v0.9.66 |
비용·사용량
| 시각 | 프로젝트 | 단계 | 시간 | 턴 | 비용 | 토큰 입력/출력 | 종료 |
|---|---|---|---|---|---|---|---|
| 12:03 | ReSSO | 개선 | 25분 | 114 | $14.12 | 18.9M / 84K | error_max_budget_usd |
| 23:49 | ReSSO | 릴리즈 | 10분 | 45 | $2.43 | 2.5M / 18K | success |
| 23:37 | ReSSO | review | 6분 | 43 | $3.16 | 3.3M / 20K | success |
| 23:32 | ReSSO | 개선 | 34분 | 160 | $15.61 | 22.1M / 83K | success |
| 18:47 | ReSSO | 릴리즈 | 6분 | 33 | $1.62 | 1.5M / 11K | success |
| 18:37 | ReSSO | review | 3분 | 16 | $0.96 | 642K / 10K | success |
| 18:34 | ReSSO | 개선 | 14분 | 56 | $4.08 | 4.4M / 32K | success |
| 10:05 | ReSSO | 릴리즈 | 6분 | 32 | $1.52 | 1.3M / 10K | success |
| 09:56 | ReSSO | review | 4분 | 22 | $1.15 | 912K / 12K | success |
| 09:52 | ReSSO | 개선 | 11분 | 78 | $6.07 | 7.4M / 41K | success |
| 14:35 | ReSSO | 릴리즈 | 5분 | 24 | $1.28 | 1.0M / 9K | success |
| 14:25 | ReSSO | review | 3분 | 16 | $0.89 | 609K / 10K | success |
| 14:22 | ReSSO | 개선 | 11분 | 56 | $3.93 | 4.3M / 30K | success |
| 06:24 | ReSSO | 릴리즈 | 6분 | 26 | $1.21 | 984K / 9K | success |
| 06:17 | ReSSO | review | 5분 | 22 | $1.14 | 902K / 12K | success |
| 06:12 | ReSSO | 개선 | 12분 | 64 | $4.43 | 5.1M / 32K | success |
| 04:06 | ReSSO | 릴리즈 | 7분 | 31 | $1.50 | 1.2M / 11K | success |
| 23:42 | ReSSO | 개선 | 12분 | 82 | $6.50 | 8.2M / 43K | success |
| 18:52 | ReSSO | 릴리즈 | 5분 | 25 | $1.20 | 1.0M / 9K | success |
| 18:43 | ReSSO | review | 4분 | 20 | $1.00 | 794K / 9K | success |
| 18:39 | ReSSO | 개선 | 2분 | 7 | $4.76 | 795K / 5K | success |
| 13:01 | ReSSO | 릴리즈 | 6분 | 28 | $1.47 | 1.3M / 10K | success |
| 12:53 | ReSSO | review | 5분 | 26 | $1.70 | 1.4M / 17K | success |
| 12:48 | ReSSO | 개선 | 18분 | 48 | $3.43 | 3.5M / 29K | success |
| 07:36 | ReSSO | review | 6분 | 30 | $1.53 | 1.3M / 17K | success |
| 07:30 | ReSSO | 개선 | 10분 | 53 | $3.25 | 3.4M / 26K | success |
| 22:41 | ReSSO | 개선 | 11분 | 90 | $6.81 | 8.7M / 44K | success |
| 00:59 | ReSSO | 개선 | 9분 | 61 | $3.12 | 3.5M / 22K | success |
| 18:01 | ReSSO | 개선 | 12분 | 79 | $5.29 | 6.0M / 42K | success |
아이디어 백로그 — 대기 14 / 전체 15
| 아이디어 | 가치/위험/크기 | 상태 | 메모 | 갱신 |
|---|---|---|---|---|
| 로그인의 두 가지 500이 같은 internal_error code로 반대의 지시를 준다 — CreateAuthorizationCode 실패에 authorization_code_failed code | 3/1/S | 대기 | writeLoginError의 500은 이 화면에서 재시도, CreateAuthorizationCode 실패의 500은 RP에서 재시도인데 code가 같아 화면이 구분할 수 없다. | 2026-09-12 |
| 방문 추적 화면 캡처를 관리자 가이드에 싣기 — scripts/guide-screenshots.mjs에 /admin/tracking 한 장을 더한다 | 2/1/S | 대기 | 새 아이디어. 이번 회차는 예산상 캡처 없이 문서만 더했다. 빈 상태를 찍지 않는 규칙에 따라 Momento 설정을 심은 뒤 찍어야 한다. | 2026-09-12 |
| 로그인 화면이 409 request_already_used에 어디로 가야 하는지 말하지 않는다 | 2/1/S | 대기 | challenge 404 문구('연결한 서비스에서 다시 시작하세요')를 재사용할 수 있다. | 2026-09-12 |
| UserInfo POST에서 form-encoded access_token 파라미터 수용 (RFC 6750 2.2) | 2/1/S | 대기 | 헤더와 본문이 둘 다 오면 400 invalid_request여야 한다. | 2026-09-12 |
| oidcCORS가 realmFromPath 실패에 조용히 CORS 헤더를 빼고 지나간다 — 로그 한 줄 | 2/1/S | 대기 | 미들웨어라 응답은 바꿀 수 없지만 브라우저 RP에는 원인 없는 CORS 오류로만 보인다. | 2026-09-12 |
| logoutClient가 Client를 못 읽어 post_logout_redirect_uri를 버린 것을 LOGOUT 감사 detail에도 남기기 | 2/1/S | 대기 | 로그는 있지만 트레일만 보는 사람에게는 RP가 등록하지 않은 목적지를 보낸 것과 같아 보인다. | 2026-09-12 |
| SessionByToken이 locked_until을 보지 않는 것은 의도이므로 문서화한다 | 2/1/S | 대기 | 잠금은 무차별 대입 방어이고 세션 종료로 확장하면 DoS가 된다. docs에 그 판단을 적는다. | 2026-09-12 |
| clientAuthLimiter가 인스턴스별 메모리라 다중 인스턴스에서 실효 잠금 한도가 20*N — docs/operations.md에 적기 | 2/1/S | 대기 | 구현을 바꿀 일은 아니다. | 2026-09-12 |
| 비화면 경로(/api·/realms·/mcp·/health·/metrics)의 CSP를 default-src 'none'으로 더 좁히기 | 2/2/S | 대기 | 새 아이디어. TRACKING-STANDARD 5절은 비화면 경로의 정책을 '오히려 더 좁힌다'고 한다. 지금은 모든 경로가 같은 basePolicy를 받는다. JSON 응답에는 영향이 없지만 헤더 테스트(TestIntegrationEveryAnswerCarriesTheSecurityHeaders)의 단언을 함께 고쳐야 한다. | 2026-09-12 |
| oidcLogout이 id_token_hint의 sub를 쿠키 세션의 사용자와 대조하지 않는다 | 2/2/M | 대기 | RP-Initiated Logout 1.0 상 SHOULD. 공용 브라우저에서 다른 사람 세션이 끊기는 것이 실제 피해. | 2026-09-12 |
| id_token_hint의 aud를 요청한 Client와 대조하지 않는다 | 2/2/M | 대기 | internal/oidc/tokens.go의 IDTokenHint는 iss와 typ만 확인한다. 인가와 로그아웃 두 곳이 같은 함수를 쓴다. | 2026-09-12 |
| 가이드 화면 캡처를 CI나 릴리즈 절차에서 다시 찍게 하기 | 2/2/M | 대기 | scripts/guide-screenshots.mjs는 있지만 아무도 부르지 않는다. docker·chrome이 필요하므로 CI 필수 단계로 만들면 안 된다. | 2026-09-12 |
| authChallenge가 없는 요청에 404 not_found를 답해 login의 400 expired_request와 코드명이 갈린다 | 1/1/S | 대기 | 사용자에게 보이는 차이는 2026-09-10 회차에 사라졌다. | 2026-09-12 |
| queryInt가 offset의 상한을 두지 않는다 | 1/1/S | 대기 | admin.go:76. Postgres는 정상 처리해 빈 목록을 답하므로 실제 피해는 없다. | 2026-09-12 |
| 방문 추적 스크립트 체계 (tracking-2026-09 캠페인): 화면 설정·요청별 nonce·정책 출처 자동 추출·차단 신고 기록·Momento 같은 오리진 프록시 | 5/2/L | 완료 | 2026-09-12 구현. internal/tracking 패키지, platform_settings 테이블(016), /admin/tracking 화면, /api/v1/tracking/csp-report, /momento/* 프록시. 기본값 꺼짐이며 꺼진 동안 정책·문서는 글자 그대로 이전과 같다. | 2026-09-12 |
교훈 (깨졌던 변경)
- 2026-09-08 rejected-by-human — 사람이 PR 을 반려함. 같은 접근은 피할 것. (링크)
원장 (에이전트가 남긴 기록)
2026-09-02
- 선택: JWKS Cache-Control이 writeJSON에 덮어써지던 문제 수정 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 35fd2d9)
- 요약:
jwks핸들러가 설정한Cache-Control: public, max-age=300을writeJSON이no-store로 무조건 덮어써서, 코드는 캐시 가능하다고 말하는데 응답은 아니라고 말하는 상태였다(직접 재현해 확인).writeJSON의no-store를 규칙이 아닌 기본값으로 바꾸고, JWKS의 max-age는 인스턴스 자신의 키 집합 캐시 수명인store.SigningKeyTTL(30초)에서 가져오도록 했다 — 기존 300초는 그 창의 10배라 회전 직후 최대 5분간 새kid검증이 실패할 수 있어 그대로 되살리지 않았다. 캐시 가능해진 응답의 CORS 헤더가 Origin에 의존하므로Vary: Origin을 허용된 Origin일 때만이 아니라 항상 붙이도록 했다. 검증: 새 테스트 2개(단위 + 연동)가 수정 전 코드에서 실제로 실패함을 확인했고,go test -race ./...전체 통과(연동 테스트 SKIP 0건),go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build모두 통과. 빌드가 만든webui/dist/index.html해시 변경은 무관하므로 되돌렸다. - 보류 아이디어:
- Discovery에
authorization_response_iss_parameter_supported: true추가 — 서버는 이미 인가 응답에 RFC 9207iss를 넣는데 메타데이터로 알리지 않아 RP가 강제 검증을 켜지 못한다 (가치 3 / 위험 1 / S) - UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) authorization엔드포인트가 기존 SSO Session 재사용 시 계정 상태를 다시 보지 않아, 토큰 교환에서야 거절될 코드를 발급하는 경로가 있는지 점검 (가치 2 / 위험 2 / M)- 잠긴(locked) 계정의 기존 SSO Session이 계속 새 인가 코드를 받는 동작을 의도된 것으로 문서화할지 검토 (가치 2 / 위험 2 / S)
login에서prompt=login재인증 시 기존 Session을 정리하지 않아 한 사용자에게 Session이 둘 남는 문제 (가치 2 / 위험 2 / M)
- Discovery에
- 릴리즈: v0.9.66 (2026-09-02)
2026-09-02 (2회차)
- 선택: Discovery에
authorization_response_iss_parameter_supported: true추가 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공 (커밋 1876209)
- 요약: 서버는 인가 응답 네 갈래(세션 조회 전 오류, 기존 SSO Session에서 발급한 코드, 로그인 폼이 JSON으로 돌려주는 redirect_to,
prompt=none미로그인)에 모두 RFC 9207iss를 넣고 있었는데 메타데이터로 알리지 않았다. RFC 9207상 클라이언트는 메타데이터가 약속할 때만iss없는 응답을 거절하므로, 플래그가 없으면 라이브러리는 계속iss없는 응답을 받아들인다 — 즉 방어가 꺼진 상태였다. 겸해서 discovery만strings.TrimRight(realm.IssuerURL, "/")로 자기 사본의 끝 슬래시를 떼고 있던 것을 제거해, 발행되는issuer와 모든 응답·토큰의iss가 같은 한 문자열이 되게 했다(store가 v0.1.0부터 입력 시 트림·검증하므로 API로 만든 Realm에는 슬래시가 없다. 그 트림은 불일치를 고쳐주지 못하고 가장 먼저 확인할 곳에서 숨기기만 했다).docs/compatibility.md에 한 줄 추가. 검증: 새 연동 테스트가 수정 전 코드에서 실제로 실패함을 확인했고(discovery advertises <nil>),make test전체 통과 —go test -race ./...전 패키지 ok, 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) SessionByToken이u.enabled=true만 보고locked_until은 보지 않아 잠긴 계정의 기존 SSO Session이 계속 새 인가 코드를 받는다 — 의도인지 결정하고 문서화하거나 막을 것 (가치 3 / 위험 2 / M)login에서prompt=login재인증 시 기존 Session을 정리하지 않아 한 사용자에게 Session이 둘 남는 문제 (가치 2 / 위험 2 / M)- Discovery에
prompt_values_supported·claim_types_supported등 남은 선택 메타데이터 추가 검토 (가치 1 / 위험 1 / S) scripts/test-services.sh가 PostgreSQL 포트 충돌 시 컨테이너를 Created 상태로 남기고 다음 실행에서 인증 실패로만 드러난다 — 포트 점유를 감지해 알려주기 (가치 2 / 위험 1 / S)
- UserInfo POST에서 form-encoded
2026-09-03
- 선택: userinfo가 Role을 못 읽었을 때 “Role 없음”으로 답하던 문제 수정 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 c5fb7b4)
- 요약:
roles스코프 뒤의 두 조회(RealmRolesForUser,ClientRolesForUser)가 에러를_로 버려서, 조회 실패가realm_access.roles: []+ 200으로 나갔다 — 서비스가 “이 계정은 아무 Role도 없다”고 단언하는 것이고, 관리자가 Role을 전부 회수한 경우와 글자 그대로 구별되지 않는다. 같은 스코프의 같은 두 호출이 토큰 발급(IssueUserTokens)에서는 이미 요청을 실패시키므로, userinfo만 못 읽은 Role을 에러가 아닌 claim으로 바꾸고 있었다. 이제 500server_error로 거절하고 어느 조회가 실패했는지 로그에 남긴다 — 여기의 다른 거절이 쓰는 401을 일부러 쓰지 않았다. 토큰은 멀쩡한데 invalid_token이라 하면 RP가 멀쩡한 자격증명을 버리고 사용자를 로그아웃시킨다./api/v1/me도 같은 에러를 버리고 있어 함께 고쳤다. 검증: 새 연동 테스트가 두 Role 테이블을 차례로 치우며(테스트마다 전용 스키마라 안전) 수정 전 코드에서 실제로 두 건 모두 실패함을 확인했다(answered 200 and map[realm_access:map[roles:[]]...]).make test전체 통과 —go test -race ./...전 패키지 ok, 연동 테스트 SKIP 0건(httpserver 76s / store 77s),go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) SessionByToken이locked_until을 보지 않는 것은 의도로 보인다 — 잠금은 무차별 대입 방어이고, 이를 세션 종료로 확장하면 공격자가 남의 비밀번호를 틀리는 것만으로 피해자를 계속 로그아웃시키는 DoS가 된다. 막지 말고 문서화하는 쪽으로 결론낼 것 (가치 2 / 위험 1 / S)authorization이id_token_hint·max_age를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (가치 2 / 위험 2 / M)oidcLogout이id_token_hint의 토큰 타입(Extra.Type)을 확인하지 않아 Access Token도 hint로 받아들인다 (가치 2 / 위험 1 / S)scripts/test-services.sh가 PostgreSQL 포트 충돌 시 컨테이너를 Created 상태로 남기고 다음 실행에서 인증 실패로만 드러난다 — 포트 점유를 감지해 알려주기 (가치 2 / 위험 1 / S)
- UserInfo POST에서 form-encoded
- 릴리즈: v0.9.67 (2026-09-03)
2026-09-03 (2회차)
- 선택: RP-initiated logout이 만료된
id_token_hint를 거절하던 문제 수정 (가치 4 / 위험 1 / 작업량 S) - 결과: 성공 (커밋 0044bd0)
- 요약:
oidcLogout이 hint를Verify(만료 검사 포함)로 통과시켜서, 로그아웃 시점에 RP가 들고 있는 보통 상태인 만료된 ID Token을 거절했다. 거절이 거절로 보이지도 않았다 —client가 nil로 남아post_logout_redirect_uri가 말없이 버려지고 브라우저는/login?logged_out=1에 남았다(세션은 끝나고 쿠키도 지워진 채). RP 입장에선 사람이 그냥 돌아오지 않는다. 만료를 보지 않는 이유는 같은 저장소의SubjectFromIDTokenHint에 이미 적혀 있었고(인가 엔드포인트의 같은 파라미터), RP-Initiated Logout 1.0 §4도 같은 것을 요구한다 — logout만 따르지 않고 있었다. 새IDTokenHint로 서명·issuer는 그대로 확인하고 만료만 빼며, 덤으로typ != "ID"를 확인해 Access Token이 hint로 통하던 것도 막았다(azp·iss·sub가 같아 그대로 통과하고 있었다). 리다이렉트 대상은 여전히 등록 목록과 대조하므로 open redirect가 되지 않고, 꺼진 Client도 여전히 거절된다. 검증: 새 연동 테스트가 수정 전 코드에서 두 건 모두 실제로 실패함을 확인했다(만료 hint →/login?logged_out=1, Access Token → 수락). Realm TTL 하한이 60초라 만료 토큰은 Realm 키로 직접 서명했다.make test전체 통과 —go test -race ./...전 패키지 ok, 연동 테스트 SKIP 0건(httpserver 75s / store 76s),go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) authorization이id_token_hint·max_age를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (가치 3 / 위험 2 / M)oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD, CSRF성 성가심 수준) (가치 2 / 위험 2 / M)SessionByToken이locked_until을 보지 않는 것은 의도로 보인다 — 막지 말고 문서화하는 쪽으로 결론낼 것 (가치 2 / 위험 1 / S)scripts/test-services.sh가 PostgreSQL 포트 충돌 시 컨테이너를 Created 상태로 남기고 다음 실행에서 인증 실패로만 드러난다 — 포트 점유를 감지해 알려주기 (가치 2 / 위험 1 / S)
- UserInfo POST에서 form-encoded
- 릴리즈: v0.9.68 (2026-09-03)
2026-09-03 (3회차)
- 선택: userinfo가 이쪽 장애를
invalid_token으로 답하던 문제 수정 (가치 4 / 위험 1 / 작업량 S) - 결과: 성공 (커밋 8ab04c0)
- 요약: userinfo가 claim을 읽기 전에 하는 조회 넷(
realmFromPath,Verify의 폐기 여부 확인,UserByID,SessionAuthTime)이 “없어서 실패”와 “조회 자체가 실패”를 모두 401invalid_token으로 답하고 있었다. 그 답은 지시다 — RP는 invalid_token을 받으면 자격증명을 버리고 사용자를 로그아웃시키며, 그게 올바른 처리다. 그래서 DB 장애가 이 엔드포인트를 저하시키는 게 아니라 Realm의 모든 토큰을 한꺼번에 무효화했고, 401은 여기서 평범한 응답(만료)이라 요청 카운터·액세스 로그에는 조용한 시간대로 보였다. 같은 파일이 이미 두 번 이 구분을 하고 있었는데(writeUserInfoUnavailable의 Role,recordUnjudgedIntrospection) 이 넷은 그보다 앞에서 돌아 장애가 먼저 여기 닿았고 뒤쪽 배려는 실행되지 않았다. 이제store.ErrNotFound만 401로 두고 나머지는 500server_error+ 어느 단계인지 로그에 남긴다. 폐기 여부를 못 읽은 토큰은ErrTokenStateUnavailable(revoke 엔드포인트가 이미 구분하던 것)로 판정 불가 취급한다. 꺼진 Realm은 여전히 401이라 테넌트 정지 동작은 그대로다. 검증: 새 연동 테스트가 네 테이블(realms·revoked_access_tokens·users·sso_sessions)을 차례로 RENAME으로 숨기며, 수정 전 코드에서 네 건 모두 401로 실패함을 확인했다(테스트마다 전용 스키마라 안전).make test전체 통과 —go test -race ./...전 패키지 ok(httpserver 94s / store 93s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
introspect의Verify단계만recordUnjudgedIntrospection을 부르지 않아, 폐기 여부를 못 읽은 경우가 카운터·로그 없이active=false로 나간다 (가치 3 / 위험 1 / S)authorization이id_token_hint를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (컬럼 추가 마이그레이션 필요) (가치 3 / 위험 2 / M)- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD) (가치 2 / 위험 2 / M)scripts/test-services.sh가 PostgreSQL 포트 충돌 시 컨테이너를 Created 상태로 남기고 다음 실행에서 인증 실패로만 드러난다 — 포트 점유를 감지해 알려주기 (가치 2 / 위험 1 / S)
2026-09-03 (4회차)
- 선택: introspect가 답을 못 낸 조회를 세지 않던 문제 수정 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 26d21d5)
- 요약:
recordUnjudgedIntrospection은 “죽었다고 판정한 토큰”과 “판정하지 못한 토큰”이 둘 다 200active=false로 나가 서비스가 내보내는 모든 신호에서 같은 호출로 보이기 때문에 만들어졌는데, 핸들러 맨 아래 두 조회(session·user)만 그것을 부르고 있었다. 그 앞에서 도는 조회 셋 —realmFromPath,Verify의 폐기 여부 확인(ErrTokenStateUnavailable),InspectRefreshToken— 은 “없다”와 “못 읽었다”를 똑같이active=false로 답했다. 즉 장애는 이 셋에 먼저 닿았고 아래쪽 배려는 실행되지 않았다: store 에러는 그 자리에서 버려지고, 응답은 200이라 요청 카운터에도 정상 호출로 잡히며, 운영 문서가 보라고 지목한 계열은 평평한 채로 모든 Resource Server가 모든 요청을 거절했다. 응답은 일부러 그대로 뒀다(5xx를 받은 RS가 fail-open할 수 있어 fail-closed가 맞는 방향이고, 이 판단은 함수 주석에 이미 적혀 있다). 바뀐 것은 실행되지 못한 조회를 stage 라벨과 함께 세고 로그에 남긴다는 것뿐이며, 없는 Realm·세션·Refresh Token은 진짜 답이므로 여전히 세지 않는다. 덤으로 폐기 여부를 못 읽은 토큰은 Refresh Token 조회로 흘러가지 않고 거기서 멈춘다 — 서명과 클레임이 이미 통과했으니 Refresh Token일 수 없고(불투명 문자열이라 파싱 단계를 넘지 못한다), 그대로 가면 같은 store에 두 번째로 실패할 뿐이었다. 검증: 새 연동 테스트가 세 테이블(realms·revoked_access_tokens·refresh_tokens)을 차례로 RENAME으로 숨기며 수정 전 코드에서 세 건 모두 실패함을 확인했다(said so nowhere: no resso_introspection_errors_total{stage="realm"} 1등). 정상 토큰과 아무 토큰도 아닌 문자열이 카운터를 건드리지 않는 것도 같은 테스트가 확인한다.make test전체 통과 —go test -race ./...전 패키지 ok(httpserver 82s / store 81s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test,npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
scripts/test-services.sh가 이미 떠 있는 컨테이너를 재사용할 때 그 컨테이너의 실제 매핑 포트가 아니라 기본값(55439)을 DSN으로 출력한다 — 이번 세션에서 실제로 걸렸다(컨테이너는 55450, 스크립트는 55439를 출력 →connection refused).docker port로 읽어 출력할 것 (가치 3 / 위험 1 / S)authorization이id_token_hint를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (컬럼 추가 마이그레이션 필요) (가치 3 / 위험 2 / M)- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD) (가치 2 / 위험 2 / M)docs/operations.md의resso_introspection_errors_total항목에 stage 라벨 값 목록을 적어, 어느 조회가 멈췄는지 대시보드에서 바로 읽게 하기 (가치 1 / 위험 1 / S)
2026-09-04
- 선택:
scripts/test-services.sh가 재사용 컨테이너의 실제 포트가 아니라 요청한 포트를 출력하던 문제 수정 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공 (커밋 b6d30b9)
- 요약: 스크립트 상단의 세 포트는 요청이지 사실이 아니다 — 이번 실행이 만든 컨테이너에만 적용되고, 이전 실행에서 남은 컨테이너는 처음 기동된 포트를 그대로 유지하는데 스크립트의 모든 경로가 기존 컨테이너를 보지도 않고 재사용한다. 그래서 출력된 환경은 요청을, 테스트는 현실을 가리켰다(이번 세션에서 실제로 재현: DSN은 55439, 컨테이너는 55450). 스크립트가 이걸 잡을 수 없었던 이유는 모든 준비 확인이
docker exec로 컨테이너 안에서 서비스에 닿기 때문이다 — 발행된 포트를 지나는 확인이 하나도 없어서, 아무 데도 닿지 않는 주소가 통합 테스트 예순 개가 한꺼번에 실패하는 것으로 처음 드러났다. 이제 출력 전에docker port로 실제 매핑을 읽고, 출력할 주소를 호스트에서 한 번 열어본다. 같은 독해에서 이웃 둘이 떨어졌다: 포트 충돌로 기동에 실패한 컨테이너는 Created로 남아 다음 실행이 “재사용”하므로 이제docker start로 살리거나 이유를 말하고 멈추며,--stop분기가 부르는log가 그 아래에 정의돼 있어 인증서 디렉터리를 못 지웠다고 말하려던 유일한 경로가log: command not found로 답하던 것도 고쳤다. 검증: 새 Go 테스트 2개가 docker 스텁과 실제 리스너로 스크립트를 그대로 실행해, 수정 전 코드에서 두 건 모두 실제로 실패함을 확인했다(요청 포트 55439/13890/13636을 그대로 출력, 아무도 듣지 않는 포트를 정상 환경으로 출력).make test전체 통과 —go test -race ./...전 패키지 ok(httpserver 82s / store 81s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test,npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
authorization이id_token_hint를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (컬럼 추가 마이그레이션 필요) (가치 3 / 위험 2 / M)- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD) (가치 2 / 위험 2 / M)docs/operations.md의resso_introspection_errors_total항목에 stage 라벨 값 목록을 적어, 어느 조회가 멈췄는지 대시보드에서 바로 읽게 하기 (가치 1 / 위험 1 / S)SessionByToken이locked_until을 보지 않는 것은 의도(잠금은 무차별 대입 방어이고 세션 종료로 확장하면 DoS가 된다) — 막지 말고 문서화하는 쪽으로 결론낼 것 (가치 2 / 위험 1 / S)
- 릴리즈: v0.9.69 (2026-09-04)
2026-09-04 (2회차)
- 선택: SSO 세션을 못 읽은 것을 세션이 없는 것으로 답하던 두 곳 수정 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (커밋 bcc6f60)
- 요약: 브라우저의 SSO 세션을 읽는 OIDC 엔드포인트 둘(
authorization·oidcLogout)이SessionByToken의 에러를 “없음”과 같이 취급했고, 그 결과 나가는 두 답이 모두 RP가 행동으로 옮기는 단언이었다. (1)authorization에서prompt=none의 답인login_required는 “세션이 없다”는 말이고, 조용한 갱신을 하는 RP는 이를 “사용자가 로그아웃했다”로 읽고 자기 세션도 끝낸다 — 올바른 처리다. 그래서 이쪽 장애가 저하가 아니라 전 RP 일괄 로그아웃이 됐다. 바로 한 줄 아래SessionAuthenticatedRecently는 이미 못 돌면server_error를 내는데, 이 조회가 그보다 먼저 돌아 장애가 여기 먼저 닿았다. (2)oidcLogout에서는endSession주석이 “이 서비스가 가진 가장 오해를 부르는 실패”라고 이미 적어둔 그 결과 — 세션은 살아 있고, 거기 묶인 Refresh Token은 계속 갱신되며, 폐기가 곧 통지이므로 back-channel logout도 나가지 않는다 — 가 한 단계 앞에서 벌어지는데 감사 기록이 아예 없었다. 로그인 안 한 브라우저의 로그아웃과 글자 그대로 같은 모습이었고, 쿠키는 지워지고 RP는 post-logout 페이지로 리다이렉트되어 다 끝난 것처럼 보였다. 이제store.ErrNotFound만 “로그인 안 함”이고, 나머지는 각각server_error와PARTIALLOGOUT 기록(+ Realm을 적은 로그)이다. 로그아웃 응답은 일부러 그대로 뒀다(endSession위에 이미 적힌 판단 — 그 시점엔 쿠키가 이미 지워졌고 사람이 할 수 있는 게 없다). 기록이 세션을 지목하지 못하는 이유(조회가 실패한 것이므로)와 IP·시각으로 찾으라는 안내를docs/operations.md에,prompt=none의 새 구분을docs/compatibility.md에 적었다. 검증: 새 연동 테스트가sso_sessions를 RENAME으로 숨기고(테스트마다 전용 스키마) 수정 전 코드에서 세 건 모두 실제로 실패함을 확인했다(prompt=none answered error="login_required", 인가 요청error="",logout ... was not audited at all). 쿠키 없는 로그아웃이 새 기록을 만들지 않는 것과 정상 상태에서prompt=none이 코드를 받는 것도 같은 테스트가 확인한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 85s / store 85s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test,npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. - 보류 아이디어:
requireSession미들웨어도 저장소 장애를 401authentication_required로 답해, DB 순단이 콘솔 사용자 전원을 로그인 화면으로 보낸다 — 프런트엔드 처리까지 봐야 해 이번엔 뺐다 (가치 3 / 위험 2 / M)authorization이id_token_hint를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (컬럼 추가 마이그레이션 필요) (가치 3 / 위험 2 / M)- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD) (가치 2 / 위험 2 / M)docs/operations.md의resso_introspection_errors_total항목에 stage 라벨 값 목록을 적어, 어느 조회가 멈췄는지 대시보드에서 바로 읽게 하기 (가치 1 / 위험 1 / S)
- 릴리즈: v0.9.70 (2026-09-04)
2026-09-05
- 선택: 콘솔의 세션·API Key 미들웨어가 이쪽 장애를 “로그인이 필요합니다”로 답하던 문제 수정 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 b25bc63)
- 요약: 관리 콘솔의 인증된 요청은 전부 같은 조회(
SessionByToken)에서 시작하는데,requireSession·requireSessionOrAPIKey가 그 에러를 “세션 없음”과 같이 401authentication_required로 답했다. 콘솔은 그 답을 보여주지 않고 실행한다 — API 계층이 알리고, auth provider가 캐시된 신원을 지우고, 사람은 “세션이 만료되었습니다”를 달고 로그인 화면에 도착한다. 그래서sso_sessions장애는 콘솔을 저하시킨 게 아니라 전원을 동시에 로그아웃시키고 거짓 이유를 붙였다: 세션은 멀쩡했고, 돌려보낸 로그인 화면도 같은 이유로 실패하고 있었다. 변경 요청은 더 나빴다 — 읽지 못한 쿠키가 쿠키를 안 보낸 것처럼 API Key 분기로 흘러가, 로그인한 사람의 모든 편집에browser_session_required(“브라우저 로그인이 필요합니다”)가 나갔다.AuthenticateAPIKey도 같은 뭉개기를 했고/metrics수집이 그 경로로 인증하므로, 서비스를 가장 봐야 할 순간에 모니터링은 “키가 폐기됐다”는 답을 받았다. 아무것도 드러나지 않았다: 여기서 401은 로그인 안 한 방문자의 평범한 답이라 요청 카운터·접근 로그에는 한산한 시간대였고, store 에러는 로그 한 줄 없이 버려졌다. 이건 인증된 모든 요청의 첫 번째 store 조회라, 전면 장애에서 가장 먼저 닿고 그 뒤의 배려(이미 둘을 구분하는 핸들러들, 그 아래writeStoreError)는 실행되지 않는다. 이제store.ErrNotFound만 “로그인 안 함”이고 나머지는 500internal_error+ 어느 조회가 멈췄는지 로그. 빈 쿠키와 빈 Bearer는 store에 닿지 않으므로 로그인 안 한 방문자의 답은 그대로고, 세션 테이블 장애에도 API Key 클라이언트는 200을 받는다. 콘솔 쪽은 그 구분을 그대로 따랐다 — 401은 거절이고 500은 아니므로, 신원 조회가 다른 이유로 실패하면 로그인 화면으로 보내지 않고 화면에 머문 채 오류와다시 시도를 보여준다(ErrorAlert의 5xx 안내가 이미 맞는 문구다). 검증: 새 연동 테스트가sso_sessions와personal_api_keys를 차례로 RENAME으로 숨기며(테스트마다 전용 스키마), 수정 전 코드에서 다섯 건 모두 실제로 실패함을 확인했다 — GET/api/v1/me·PUT/api/v1/me/profile·POST/api/v1/auth/logout·GET/api/admin/v1/realms가 모두 401이었고 API Key도 401이었다. 자격증명 없는 호출이 여전히 401인 것과 세션 테이블 장애 중에도 API Key가 200인 것도 같은 테스트가 확인한다. 콘솔 테스트 2개를 더해 500이 만료로 읽히지 않는 것과identityError가 서는 것을 고정했다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 84s / store 82s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/106테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md에 401/500 구분표와 Prometheus 수집 해석을 적었다. - 보류 아이디어:
login의 Realm 조회(RealmByName·RealmByID) 실패가 401invalid_credentials로 나가 사용자에게 “비밀번호가 틀렸다”고 말하고, 로그인 카운터도 움직이지 않는다 — 같은 핸들러의 다른 여섯 조회는 모두writeLoginError(500 +result="error")를 쓰는데 이것만 그 앞에서 돌아 예외다 (가치 4 / 위험 1 / S)login의AuthorizationRequestByToken실패가 400expired_request로 나가, 사용자는 RP에서 흐름을 다시 시작하고 같은 곳에서 다시 실패한다 (가치 3 / 위험 1 / S)authorization이id_token_hint를AuthorizationRequest에 저장하지 않아, 로그인 폼을 거친 뒤에는 hint가 지목한 계정과 다른 계정으로 로그인해도 코드가 나간다 (컬럼 추가 마이그레이션 필요) (가치 3 / 위험 2 / M)- UserInfo POST에서 form-encoded
access_token파라미터 수용 (RFC 6750 §2.2). 현재는 Authorization 헤더만 읽는다 (가치 2 / 위험 1 / S) oidcLogout이 hint의sub를 쿠키 세션의 사용자와 대조하지 않아, 다른 사람의 ID Token을 hint로 줘도 지금 로그인한 사람이 로그아웃된다 (스펙상 SHOULD) (가치 2 / 위험 2 / M)
2026-09-06
- 선택: login이 이쪽 장애를 “비밀번호가 틀렸다”·”요청이 만료됐다”로 답하던 문제 수정 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 9ba95ab)
- 요약: 로그인 핸들러는 인증할 대상을 갖기 전에 조회를 둘 하는데, 둘 다 “조회 실패”를 “없음”과 같이 취급했고 그 두 답은 모두 사람이 행동으로 옮기는 지시였다. (1)
RealmByName·RealmByID는 401invalid_credentials(“아이디 또는 비밀번호가 올바르지 않습니다”)로 답했다 — 맞는 비밀번호를 다시 치게 하고, 이어서 변형을 하나씩 시도하게 만든다. 같은 핸들러의 나머지 여섯 조회는 모두writeLoginError(500 +resso_login_attempts_total{result="error"})를 쓰는데, 이 조회가 그 여섯보다 먼저 돌아 장애가 여기 먼저 닿았고 뒤쪽 배려는 실행되지 않았다: 카운터는 움직이지 않았고, 로그인 경로의 401은 평범한 오타와 요청 카운터·접근 로그에서 구별되지 않으므로 전면 장애가 한산한 시간대로 보였다. 그 카운터가 존재하는 이유가 정확히 이것이다 — 완료되지 못한 시도는 성공도 실패도 아니라서 세지 않으면 시계열이 조용해질 뿐이다. (2)AuthorizationRequestByToken은 400expired_request로 답했고, 그러면 사람은 RP에서 흐름을 다시 시작해 새 request token을 받고 같은 조회에서 같은 이유로 다시 실패한다. 이제store.ErrNotFound만 옛 답을 유지한다 — 존재하지 않는 Realm은 계정·테넌트 열거 방지로 401 그대로, 소진·만료·발급된 적 없는 request token은 400 그대로. 나머지는 500internal_error+result="error"+ 어느 조회가 멈췄는지 로그다. 검증: 새 연동 테스트가realms와authorization_requests를 차례로 RENAME으로 숨기며(테스트마다 전용 스키마) Realm 이름 로그인과 request token 로그인 양쪽을 확인하고, 세 건 모두 카운터에 닿는지 본다. 수정 전 코드에서 네 건 모두 실제로 실패함을 확인했다(401 invalid_credentials 둘, 400 expired_request 하나, 카운터result="error"0). 존재하지 않는 Realm의 401과 발급된 적 없는 토큰의 400이 그대로인 것, 테이블 복구 후 로그인이 다시 되는 것도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 88s / store 85s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의result="error"경보 항목에 어느 답이 무엇을 뜻하는지 적었다. - 보류 아이디어:
requireSession·AuthenticateAPIKey의 같은 뭉개기(가치 4/위험 2/M — 다른 브랜치 b25bc63에서 이미 고쳐 병합 대기 중) /authorization이id_token_hint·max_age를 저장하지 않아 로그인 폼을 거치면 hint가 지목한 계정과 다른 계정으로도 코드가 나간다(가치 3/위험 2/M) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) /oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않는다(가치 2/위험 2/M) /docs/operations.md의resso_introspection_errors_total에 stage 라벨 값 목록 적기(가치 1/위험 1/S)
2026-09-06
- 선택: Token 엔드포인트가 이쪽 조회 실패를 invalid_grant로 답하던 문제 수정 (가치 5 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 7753752)
- 요약:
invalid_grant는 “다시 해보라”가 아니라 “들고 있는 Code·Refresh Token은 죽었다”는 단언이고, RP는 그것을 버리는 것이 올바른 처리다 — Refresh Token이면 그 RP에서 사람이 영구히 로그아웃된다. Token 엔드포인트가 Signing Key에 닿기 전에 하는 조회 여섯(realmFromPath,RedeemAuthorizationCode,UserByID둘,InspectRefreshToken,RotateRefreshToken)이 “판정해서 거절한 것”과 “조회를 완료하지 못한 것”을 모두 그 답으로 내보냈다. 그래서 DB 순단이 이 엔드포인트를 저하시킨 게 아니라 질문받은 세션을 전부 끝냈다. 근거는 이미 이 파일에 적혀 있었다 — 각 조회 바로 몇 줄 아래ErrNoActiveSigningKey처리에 “키가 복구되면 끝날 장애가 모든 세션을 데려간다”고 쓰여 있는데, 여섯 조회가 그보다 앞에서 돌아 장애가 먼저 닿았고 뒤쪽 배려는 실행되지 않았다. 아무것도 드러나지 않았다: 400invalid_grant는 만료된 Code·회전된 Token으로 하루 종일 나가는 답이라 요청 카운터·접근 로그에는 한산한 시간대로 보였고, store 에러는 그 자리에서 버려졌다.resso_token_errors_total이 존재하는 이유가 정확히 이것이다 — 완료되지 못한 요청은 발급도 거절도 아니라서 세지 않으면 시계열이 조용해질 뿐이다. 이제store.ErrNotFound·ErrCodeReuse·ErrTokenReuse와 핸들러 자신의 거절(다른 Client·redirect_uri로 발급된 코드, 맞지 않는code_verifier)만 400을 유지하고, 나머지는 500server_error+ 카운터 +stage로그(realm·authorization_code·refresh_token·user·rotation)다. PKCE 오류는 평범한errors.New라 store 실패와 타입으로 구분되지 않으므로, 판정하는 자리에서 세우는 플래그로 남겼다. 한 단계 아래RotateRefreshToken도 같은 뭉개기를 하고 있었다 — 세션 생존 확인 쿼리가 실행되지 못한 것을ErrNotFound로 답해,sso_sessions장애가 진행 중인 모든 갱신에 “세션이 끝났다”고 말했다. 라벨은grantLabel로 한정했다(grant_type은 호출자가 보내는 폼 필드이고 Realm 조회는 그것을 검사하기 전에 실패하므로, 그대로 쓰면 인증 없는 호출자가 요청마다 시계열을 하나씩 만든다 — 바로 위methodLabel과 같은 이유다). 검증: 새 연동 테스트가 다섯 테이블(realms·refresh_tokens·users·sso_sessions·authorization_codes)을 차례로 RENAME으로 숨기며 두 grant 여섯 경우를 확인하고, 수정 전 코드에서 여섯 건 모두 실제로 실패함을 확인했다(전부 400 invalid_grant, 카운터 0, stage 로그 없음). 발급된 적 없는 Refresh Token의 400과 맞지 않는code_verifier의 400이 카운터를 건드리지 않는 것, 테이블 복구 후 두 grant가 다시 도는 것도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 90s / store 86s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의resso_token_errors_total항목에 stage 값 목록과 400/500 구분을,docs/compatibility.md의 Refresh Token 행에 “RP는invalid_grant에서만 자격증명을 버리고 500에는 재시도한다”를 적었다. - 보류 아이디어:
authenticateOIDCClient의 store 실패가 401invalid_client로 나갈 뿐 아니라 실패 레이트리밋 버킷까지 채워 장애가 끝난 뒤에도 잠금이 남는다(가치 4/위험 2/M) /discovery·jwks가realmFromPath실패를 404realm_not_found로 답해 RP가 “issuer가 없다”로 읽는다(가치 3/위험 1/S) /authorization이id_token_hint·max_age를 저장하지 않아 로그인 폼을 거치면 hint가 지목한 계정과 다른 계정으로도 코드가 나간다(가치 3/위험 2/M) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S)
2026-09-07
- 선택: 자격증명을 대조하지 못한 것을 거절한 것으로 답하던 Client 인증 수정 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 acac754)
- 요약: Token·Introspection·Revocation 셋은
authenticateOIDCClient하나를 공유하는데, 그것이 “조회를 완료하지 못한 것”과 “Secret이 맞지 않은 것”을 똑같이 401invalid_client로 답했다.invalid_client는 호출자의 자격증명에 관한 단언이라 그 답을 받은 RP에게는 재시도할 것이 없다 — 서버가 방금 그 Secret을 받지 않겠다고 말했기 때문이다. 그래서clients테이블 장애가 이 셋을 저하시킨 게 아니라 Realm의 모든 연동에 “너희 자격증명이 거절됐다”고 한꺼번에 통지했다. 로그인·userinfo·introspection에 이미 그은 그 구분이 여기에는 없었다. 장애가 끝난 뒤까지 남는 것은 그 다음 줄이다: 실패 버킷 둘(Client별·출발지 주소별)은 Secret 대입을 막으려고 있는 건데, 자격증명을 검사조차 못 한 요청이 그 둘에 실패로 기록됐다. 장애 중 스무 번이면 Client 버킷이 차고, 그때부터는 맞는 Secret에도 5분 창이 끝날 때까지 429가 나간다 — 저장소는 이미 복구된 뒤에도. 드러나는 것도 없었다: 여기서 401은 오래된 Secret이 하루 종일 만드는 평범한 답이라 요청 카운터와 접근 로그에는 한산한 시간대로 보였고, store 에러는 그 자리에서 버려졌다. 이제store.ErrNotFound만 옛 답을 유지한다(없는 Client·틀린 Secret·Secret을 보낸 Public Client·비활성 Client는 모두 호출자에 관한 사실이므로 그대로). 나머지는 500server_error+ 어느 조회가 멈췄는지 로그이고, 어느 버킷에도 실패를 적지 않는다. 그 결과resso_client_auth_failures_total은 실제로 대조해서 거절한 것만 세게 되어 이름이 늘 주장하던 뜻이 됐다. 검증: 새 연동 테스트가 두 조회를 차례로 없애며(테이블clientsRENAME →ClientByIdentifier실패, 컬럼secret_hashRENAME →VerifyClientSecret실패) 엔드포인트 셋을 모두 확인하고, 이어서 버킷 하나를 가득 채울 만큼(21회) 장애 중 요청을 보낸 뒤 맞는 Secret으로 토큰을 요청한다. 수정 전 코드에서 일곱 건 모두 실제로 실패함을 확인했다(401 invalid_client 여섯, 21번째 요청 429).make test전체 통과(exit 0) —go test -race ./...11개 패키지 ok, FAIL 0, 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의resso_client_auth_failures_total항목과docs/compatibility.md의 Client 인증 행에 401/429/500 구분을 적었다. - 보류 아이디어:
discovery·jwks가realmFromPath실패를 404realm_not_found로 답해 RP가 “issuer가 없다”로 읽고 캐시한다(가치 3/위험 1/S) /authorization이id_token_hint·max_age를 저장하지 않아 로그인 폼을 거치면 hint가 지목한 계정과 다른 계정으로도 코드가 나간다(가치 3/위험 2/M) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) /oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않는다(가치 2/위험 2/M)
2026-09-08
- 선택: OIDC 엔드포인트들이 이쪽 장애를 “그런 Realm은 없다”로 답하던 문제 수정 (가치 4 / 위험 1 / 작업량 M)
- 결과: 성공 (커밋 0f2b4c8)
- 요약: 모든 OIDC 엔드포인트는 같은 조회 — 경로에 적힌 Realm — 로 시작하는데, 그 중 다섯이 “조회를 완료하지 못한 것”을 “Realm이 없는 것”과 같이 답했다. 그리고 그 답들은 전부 호출자가 보여주는 것이 아니라 실행하는 것이다. 404
realm_not_found는 RP에게 “설정된 issuer가 존재하지 않는다”는 말이라 라이브러리는 장애가 아니라 설정 오류로 읽고 캐시하기도 하는데, discovery와 JWKS는 RP가 무엇을 하기도 전에 먼저 가져오는 두 문서다. revoke는 더 나빴다 — RFC 7009이 “일치하는 Token이 없다”에 쓰라고 정해둔 200을 답해서, 유출된 Token을 폐기하러 온 사람에게 “그건 이미 죽었다”고 말하면서 Token은 그대로 살아 있었다. 근거는 같은 핸들러 안에 이미 있었다: 그 아래 모든 폐기 실패는 503 +TOKEN_REVOKEDFAILURE로 답하는데(docs/operations.md가 그렇게 적고 있다), 이 조회가 그 전부보다 앞에서 돌아 장애가 먼저 닿았고 뒤쪽 배려는 실행되지 않았다 — 게다가 감사 기록은 Realm을 해결한 뒤에 쓰이므로 트레일에는 아무것도 남지 않아 로그인 안 한 브라우저의 호출과 구별되지 않았다. 이제store.ErrNotFound만 옛 답을 유지하고(없거나 꺼진 Realm은 여전히 404, revoke는 200), 나머지는 500internal_error(revoke만 503 + FAILURE 기록) + 어느 엔드포인트가 만났는지 로그다. revoke 기록은 Client가 아니라 경로의 Realm 이름을 대상으로 남긴다 — 그 시점엔 Realm이 없어 Client를 인증할 수 없기 때문이고, 인증되지 않은 폼 값을 행위자로 적으면 트레일이 오염된다.token엔드포인트만 손대지 않았다: 같은 뭉개기(400invalid_grant)를 하지만 병합 대기 중인 브랜치 7753752가 이미 고쳤고 여기서 또 고치면 충돌만 만든다. 검증: 새 연동 테스트가realms를 RENAME으로 숨기고 다섯 엔드포인트를 모두 호출해, 수정 전 코드에서 여섯 건 모두 실제로 실패함을 확인했다(404 넷, revoke 200, FAILURE 감사 기록 0건). 정상 상태의 답 다섯 개와 테이블 복구 후 회복도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 84s / store 80s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test,npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의TOKEN_REVOKED항목에resolve the realm:상세의 읽는 법을,docs/compatibility.md의 Discovery 행에 404/500/503 구분을 적었다. -
보류 아이디어:
authorization의ClientByIdentifier실패가 400invalid_request“unknown client_id”로 나가 RP에 설정 오류로 보인다 — 이번에 고친 바로 윗줄이다(가치 4/위험 2/M) /authorization이id_token_hint·max_age를 저장하지 않아 로그인 폼을 거치면 hint가 지목한 계정과 다른 계정으로도 코드가 나간다(가치 3/위험 2/M) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) /oidcCORS가realmFromPath실패에 조용히 CORS 헤더를 빼고 지나가 브라우저 RP에는 원인 없는 CORS 오류로 보인다(가치 2/위험 1/S) - 릴리즈: v0.9.71 (2026-09-08, run 2026-09-08-123054-ReSSO-improve)
2026-09-08
- 선택: 인가 Endpoint가 이쪽 장애를 “그런 client_id는 등록되어 있지 않다”로 답하던 문제 수정 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 baef5f3)
- 요약: 인가 Endpoint는 무엇을 하기 전에 먼저 쿼리에 적힌 Client를 조회하는데, 그 조회를 완료하지 못한 것을 등록되지 않은 것과 같이 400
invalid_request“unknown client_id”로 답했다. 그 답은 RP의 배포 상태에 관한 단언이라 RP에게는 재시도할 것이 없다 — 라이브러리는 “네가 설정한 식별자는 여기 등록되어 있지 않다”는 말을 받은 것이고, 그건 사람이 가서 고쳐야 하는 실수이지 기다리면 끝나는 장애가 아니다. 그래서clients테이블 장애가 이 Endpoint를 저하시킨 게 아니라 Realm의 모든 연동에 한꺼번에 “너희는 등록이 해지되었다”고 통지했다. 근거는 같은 핸들러 안에 이미 있었다 — 이 아래의 store 호출은 전부(SSO 세션 조회·SessionAuthenticatedRecently·CreateAuthorizationCode·CreateAuthorizationRequest) 돌지 못하면server_error를 답하는데, 이 조회가 그 전부보다 앞에서 돌아 장애가 먼저 닿았고 뒤쪽 배려는 실행되지 않았다. store 에러는 그 자리에서 버려져 로그도 없었고, 여기서 400은 오래된 설정이 하루 종일 만드는 평범한 답이라 접근 로그에서도 구별되지 않았다. 이제store.ErrNotFound만 옛 답을 유지하고 꺼진 Client도 그대로 400이며(둘 다 호출자에 관한 사실이다), 나머지는 500server_error+ 로그다. 리다이렉트하지 않고 본문으로 답한다 —redirect_uri검증이 바로 이 레코드로 이루어지므로 그 시점에는 호출자의 것임이 확인된 목적지가 없다. RP-Initiated Logout은 같은 조회를 Client를 지목하는 두 방식마다 한 번씩 하는데 둘 다 장애를 같은 조용한 nil로 흘려보냈다. 그쪽은 차이를 답으로 바꿀 수 없어서(요청된 목적지가 등록 목록과 대조되지 않았으므로 오류를 보낼 안전한 곳이 없고, 로그아웃 자체는 수행해야 한다) 두 호출을 헬퍼 하나로 묶어 장애를 적어두게 했다 — 적지 않으면clients장애가 “RP가 자기 것 아닌 목적지를 보냈다”와 글자 그대로 같아 보이고, 잘 끝난 로그아웃 끝에 브라우저만 이 서비스 페이지에 남는다.handleRefreshGrant의 같은 뭉개기는 손대지 않았다(병합 대기 중인 7753752가 이미 고쳤고 여기서 또 고치면 충돌만 만든다). 검증: 새 연동 테스트가clients를 RENAME으로 숨긴 뒤 인가 요청과 로그아웃을 부르고, 수정 전 코드에서 두 건 모두 실제로 실패함을 확인했다(400invalid_request“unknown client_id”,error != server_error). 등록된 적 없는 client_id의 400, 꺼진 Client의 400, 장애 중에도 등록되지 않은 목적지로 리다이렉트하지 않는 것, 테이블 복구 후 회복도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 88s / store 84s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/compatibility.md에 인가 Endpointclient_id행과 로그아웃 행의 400/500 구분을,docs/operations.md의 로그 절에 새 로그 두 줄을 읽는 표를 적었다. -
보류 아이디어:
authorization이id_token_hint·max_age를AuthorizationRequest에 저장하지 않아 로그인 폼을 거치면 hint가 지목한 계정과 다른 계정으로도 코드가 나간다(가치 3/위험 2/M) / 인가 Endpoint가redirectOAuthError로 302에 실어 보내는server_error넷은 HTTP 상태가 302라 성공한 인가와 시계열에서 구별되지 않는다 — stage 라벨 카운터 하나가 드러낸다(가치 3/위험 2/M) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) /oidcCORS가realmFromPath실패에 조용히 CORS 헤더를 빼고 지나가 브라우저 RP에는 원인 없는 CORS 오류로 보인다(가치 2/위험 1/S) - 릴리즈: v0.9.72 (2026-09-08, run 2026-09-08-183056-ReSSO-improve)
2026-09-08
- 선택: id_token_hint를 로그인 화면 너머까지 지키게 하기 (가치 3 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 cc1b642)
- 요약:
id_token_hint는 RP가 “이 계정을 갱신하는 중”이라고 지목하는 파라미터인데, 인가 Endpoint는 그것을 브라우저에 이미 있는 Session과만 대조했다. 그 대조가 실패한 요청(=hint가 지목한 계정이 아닌 사람이 로그인해 있거나 아무도 로그인해 있지 않은 경우)은 로그인 화면으로 보내지는데, 보관되는authorization_requests행에는 hint의 흔적이 없어 화면에서 로그인한 사람이 누구든 코드가 나갔다. 즉 검사가 가장 필요한 경로에서만 검사가 없었다: 사양(OIDC Core 3.1.2.1)은 hint가 지목한 사람이 로그인하지 않으면 코드가 아니라 오류를 답하라고 하고,docs/compatibility.md는 “지정한 계정과 다르면 재인증을 요구한다”고 적고 있었지만, 그 재인증이 실제로 일어난 뒤에는 아무도 결과를 보지 않았다. 마이그레이션 015로authorization_requests.id_token_hint_subject를 더해 hint가 요청과 함께 보관되게 하고,login이 인가 코드를 만들기 직전에 방금 인증된 사용자와 대조한다. 다르면 403account_mismatch이고, 인가 요청은 일부러 소진하지 않는다 — 같은 화면에서 지정된 계정으로 다시 로그인하면 RP로 돌아가 흐름을 다시 시작할 필요 없이 이어진다. 응답은 어느 계정인지 밝히지 않는다(키보드 앞의 사람은 방금 자신이 다른 사람임을 증명했다). 로그인 자체는 성공했으므로 세션과 쿠키는 그대로 두고resso_login_attempts_total{result="success"}를 세며, 코드를 발급하지 않은 사실은LOGIN_SUCCESSresult=PARTIAL+reason=id_token_hint_mismatch로 트레일에만 남는다.max_age는 함께 보관하지 않았다 — 로그인 화면을 거친 요청은 항상 새 Session을 만들어auth_time이 그 시점이므로 정의상 충족되며, 그 판단을docs/compatibility.md에 적었다. 검증: 새 연동 테스트가 세션 없는 브라우저로 hint 붙은 인가 요청을 보내 로그인 화면으로 park된 뒤 다른 계정(admin)으로 로그인하고, 수정 전 코드에서 네 건 모두 실제로 실패함을 확인했다(200 +code=가 실린redirect_to,error != account_mismatch, PARTIAL 감사 0건). 지정된 계정으로 같은 request token을 써서 다시 로그인하면 등록된 redirect_uri로 코드가 나가는 것과, 거절 문구가 hint 계정 이름을 노출하지 않는 것도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 108s / store 100s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의result=PARTIAL항목에 이 기록만은 장애가 아니라 정책이라는 예외를 적었다. -
보류 아이디어: 인가 Endpoint가 302에 실어 보내는 server_error 넷은 HTTP 상태가 302라 성공한 인가와 시계열에서 구별되지 않는다 — stage 라벨 카운터 하나가 드러낸다(가치 3/위험 2/M) /
oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않는다(가치 2/위험 2/M) /id_token_hint의aud를 요청한 Client와 대조하지 않아 같은 Realm의 다른 Client에 발급된 ID Token도 hint로 통과한다(가치 2/위험 2/M) / 로그인 화면이account_mismatch를 일반 오류로만 보여줘 “지정된 계정으로 다시 로그인하면 이어진다”는 사실이 드러나지 않는다(가치 2/위험 1/S) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) - 릴리즈: v0.9.73 (2026-09-09, run 2026-09-09-040023-ReSSO-release)
2026-09-09
- 선택: 인가 Endpoint가 처리하지 못한 요청을 시계열에 남기게 하기 (가치 3 / 위험 2 / 작업량 M)
- 결과: 성공 (커밋 62332db)
- 요약: 인가 Endpoint가 만드는 자기 쪽 실패 여섯 중 넷은 RP의
redirect_uri로 302에error=server_error를 실어 보낸다 — 사양이 리다이렉트 가능한 오류를 거기 두라고 하므로 그 자체는 옳다. 문제는 302가 인가에 성공했을 때 나가는 상태와 같다는 것이다.resso_http_requests_total은 어느 쪽이든 정상 Redirect로 세고 접근 로그도status=302라, Realm의 모든 인가를 가져간 장애가 이 서비스가 publish하는 모든 신호에서 바쁘게 잘 도는 Endpoint와 구별되지 않았다: 사람이 로그인 화면에 도달하지 못할 뿐이고, RP들은 여기서 아무도 볼 수 없는 오류를 받았다. 게다가 넷 중 셋은 store 에러를 그 자리에서 버려 로그 한 줄도 없었다(SessionAuthenticatedRecently·CreateAuthorizationCode·CreateAuthorizationRequest). 같은 모양의 Endpoint 둘에 이미 있는 방식 그대로resso_authorization_errors_total{stage}를 더했다 — Token은resso_token_errors_total, Introspection은resso_introspection_errors_total이 정확히 이 이유로 존재한다.stage는 여섯 값(realm·client·sso_session·auth_time·authorization_code·authorization_request)으로 고정 카디널리티이고, 리다이렉트되지 않고 여기서 500으로 답하는 앞의 둘도 같은 계열에 세어 “처리하지 못한 인가”를 다른 계열과 조인하지 않고 한 번에 볼 수 있게 했다(그 둘은 이미 각자의 로그 문구가 docs에 적혀 있으므로 로그는 그대로 두고 카운터만 더했다). 검증: 새 연동 테스트가 테이블 넷(realms·clients·sso_sessions·authorization_codes·authorization_requests)을 차례로 RENAME으로 숨기며 여섯 단계를 모두 확인하고, 수정 전 코드에서 여섯 건 모두 실제로 실패함을 확인했다(어느 stage도 계열이 없었다).auth_time만은 숨길 테이블이 없어서(그 쿼리가 읽는 테이블은 모두 앞의 세션 조회가 먼저 읽는다) 데이터베이스가 interval로 만들 수 없는max_age로 그 쿼리 안에서만 실패하게 했다. 정상 인가 둘(로그인 화면으로 park, 세션 재사용으로 코드 발급), 없는 Realm의 404, 등록되지 않은client_id의 400이 계열을 만들지 않는 것과 테이블 복구 후 회복도 같은 테스트가 고정한다.make test전체 통과(exit 0) —go test -race ./...11개 패키지 ok, FAIL 0(httpserver 95s / store 88s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다. README 지표 표에 한 줄,docs/operations.md경보 절에 stage 값 목록과 “302라 성공한 인가와 상태가 같다”는 읽는 법을 적었다. -
보류 아이디어:
oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않아 다른 사람의 ID Token으로도 지금 로그인한 사람이 로그아웃된다(가치 2/위험 2/M) /id_token_hint의aud를 요청한 Client와 대조하지 않아 같은 Realm의 다른 Client에 발급된 ID Token도 hint로 통과한다(가치 2/위험 2/M) / 인가 Endpoint가max_age의 상한을 두지 않아 데이터베이스가 interval로 만들 수 없는 값이server_error가 되고 이제stage="auth_time"까지 올린다 — 호출자가 만든 값이 장애로 보인다(가치 2/위험 1/S, 새 아이디어) / 로그인 화면이account_mismatch를 일반 오류로만 보여줘 “지정된 계정으로 다시 로그인하면 이어진다”는 사실이 드러나지 않는다(가치 2/위험 1/S) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) - 릴리즈: v0.9.74 (2026-09-09, run 2026-09-09-060059-ReSSO-improve)
2026-09-09
- 선택: 호출자가 보낸
max_age가 이쪽 장애로 보고되던 문제 수정 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공 (커밋 d8026c1)
- 요약:
max_age는 호출자가 보내는 숫자인데, 인가 Endpoint는 그것을 그대로 최근 인증 시각 쿼리로 넘겼고 그 쿼리는 데이터베이스에게 그 숫자로 interval을 만들라고 한다. interval이 담을 수 있는 한계(약 9.2e12초)를 넘는 값은 거기서 실패하며, 그 실패는 저장소가 멈춘 것과 구별되지 않는다 — 요청은 302error=server_error로 나가고 어제 더한resso_authorization_errors_total{stage="auth_time"}을 올린다. 운영자가 장애로 읽으라고 문서에 적혀 있는 바로 그 계열이다. 즉 인증 없는 누구든 쿼리 파라미터 하나로 멀쩡한 데이터베이스에 대한 경보를 울릴 수 있었고, 그걸 받은 사람은sso_sessions를 들여다보러 갔다. clamp는 아무것도 잃지 않는다 — SSO Session의 수명은session_ttl_seconds가 상한이고 그 값은 30일로 제한되므로, 그보다 큰max_age는 존재하는 어떤 Session이든 충족한다. 답은 처음부터 정해져 있었고 계산만 되지 않았을 뿐이다. 상한은 MaxInt32(약 68년)로, 두 값이 같아지는 지점보다 60년 더 뒤다. int64에도 담기지 않는 값은 거절하지 않고 같은 상한에 도달하게 했다 — 그저 거대한 값은 clamp하면서 더 거대한 값만 거절하면 사양에 없는 선을 긋는 것이기 때문이다. 숫자가 아닌 값과 음수는 그대로invalid_request다.auth_time단계는 핸들러에 남지만(두 조회 사이에 세션이 사라지는 경우와 저장소 자체의 실패) 연동 테스트의 행은 잃었다 — 그 쿼리가 읽는 테이블은 모두 앞의 세션 조회가 먼저 읽으므로max_age가 그 단계에 닿는 유일한 길이었고, 이번 수정이 지운 것이 정확히 그 길이다. 대신 그 거대한max_age를 테스트가 올바로 답하는 요청 쪽으로 옮겨, 코드가 나가는 것과 실패 계열을 건드리지 않는 것을 함께 고정했고,TestIntegrationMaxAgeForcesReauthentication이 clamp되는 두 형태와 여전히 거절되는 음수를 지킨다. 검증: 새 단언 세 개가 수정 전 코드에서 모두 실제로 실패함을 확인했다(거대한 값 →error=server_error“authentication time is unavailable”, int64 초과 →invalid_request, 인가 카운터 테스트 → 코드 없이server_error).make test전체 통과(exit 0) —go test -race ./...11개 패키지 ok, FAIL 0(httpserver 86s / store 83s), 연동 테스트 SKIP 0건,go vet,golangci-lint(0 issues),govulncheck(0),npm run lint,npm run test(22파일/104테스트, 디스크 파일 수와 일치),npm run build. 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/compatibility.md의max_age행에 clamp를,docs/operations.md의resso_authorization_errors_total항목에 “이제 여섯 값 모두 이쪽 장애만 센다”를 적었다. -
보류 아이디어:
oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않아 다른 사람의 ID Token으로도 지금 로그인한 사람이 로그아웃된다(가치 2/위험 2/M) /id_token_hint의aud를 요청한 Client와 대조하지 않아 같은 Realm의 다른 Client에 발급된 ID Token도 hint로 통과한다(가치 2/위험 2/M) / 로그인 화면이account_mismatch를 일반 오류로만 보여줘 “지정된 계정으로 다시 로그인하면 이어진다”는 사실이 드러나지 않는다(가치 2/위험 1/S) /authChallenge가 없는 요청에 404를 답해 같은 원인에 login의 400expired_request와 화면 문구가 갈린다(가치 2/위험 1/S) /queryInt가 음수 offset만 걸러내고 상한을 두지 않아 콘솔 목록의offset이 그대로 SQL로 간다 — limit은 store가 clamp하지만 offset은 아무도 보지 않는다(가치 1/위험 1/S, 새 아이디어) - 릴리즈: v0.9.75 (2026-09-09, run 2026-09-09-141108-ReSSO-improve)
2026-09-10
- 선택: 로그인 화면이 이쪽 장애를 “로그인 요청이 만료됐다”로 답하던 문제 수정 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 9529746)
- 요약: RP에서 넘어온 로그인 화면은 폼을 그리기 전에
GET /api/v1/auth/challenge/{token}으로 park된 인가 요청을 먼저 읽는데, 그 읽기의 모든 실패에 한 문장으로 답했다 — “로그인 요청이 만료되었습니다. 연결한 서비스에서 다시 시작하세요.” 그 답이 맞는 경우는 하나뿐이다. 백엔드는 소진·만료·발급된 적 없는 request token에 404를 답하고(writeStoreError), 그것만이 토큰에 관한 사실이며 다시 시작하는 것이 유일한 출구다. 저장소가 답하지 않으면 500이고, 페이지가 재시작 중에 fetch하면api.ts가 status 0으로 만드는데, 그 둘은 이쪽 문제이고 사람이 들고 있는 요청은 소진되지 않은 채 그대로 남아 있다. 그래서 화면이 가진 그 한 문장은 설명이 아니라 지시였고, 되는 쪽에서 사람을 떼어놓았다: RP로 돌아가 새 request token을 받고 여기 도착해 같은 장애를 다시 만난다. 게다가 그 Alert이 이 화면의 마지막 말이었다 — 누를 것이 없고challenge.isError가 폼을 계속 막으므로, 장애가 걷힌 뒤에도 화면은 글자 그대로 같아 보였다. 근거는 한 디렉터리 옆에 이미 있었다: 콘솔의ErrorAlert(components/Feedback.tsx)는 status 0·429·5xx에만 재시도를 내주고 나머지에는 내주지 않으며, 각 상태에 무엇을 할지까지 적어 준다. 로그인 안 한 사람이 보는 유일한 화면만 그 구분을 버리고 있었다. 이제 404만 옛 문구를 유지하고, 나머지는 “요청은 그대로 남아 있으니 연결한 서비스에서 다시 시작하지 말고 잠시 후 다시 시도하세요” + 같은 challenge를 다시 부르는다시 시도버튼 + Trace ID다. 백엔드는 손대지 않았다 — 이 구분은writeStoreError가 이미 하고 있었고,login의 400expired_request가 저장소 장애를 함께 뭉개는 건은 미병합 브랜치와 겹치므로 그대로 두었다. 검증: 새 테스트 둘 — 소진된 토큰(404)은 여전히 RP로 돌려보내고 재시도 버튼을 내주지 않는 것, 500은 다른 문구와 Trace ID를 보이고 만료 문구를 보이지 않으며 재시도 뒤 Client 안내가 뜨고 폼이 다시 쓸 수 있게 되는 것. 두 번째가 수정 전 페이지에서 실제로 실패함을 확인했다(findByText(/로그인 요청을 확인하지 못했습니다/)타임아웃).make test전체 통과(exit 0) —go test -race ./...전 패키지 ok(httpserver 89s / store 82s), 연동 테스트 SKIP 0건,go vet,npm run test(22파일/106테스트, 디스크 파일 수와 일치),npm run build.make lint도 통과(golangci-lint 0 issues,govulncheck0,eslint --max-warnings 0). 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md에 두 문구를 원인·사용자가 할 일로 나눠 읽는 표와 “두 번째 문구가 보고되면 사용자를 RP로 돌려보내지 마세요”를 적었다. -
보류 아이디어:
oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않아 다른 사람의 ID Token으로도 지금 로그인한 사람이 로그아웃된다(가치 2/위험 2/M) /id_token_hint의aud를 요청한 Client와 대조하지 않아 같은 Realm의 다른 Client에 발급된 ID Token도 hint로 통과한다(가치 2/위험 2/M) / 로그인 화면이account_mismatch(403)를 실패 카운터에 세어, 로그인은 성공했는데 “반복 실패하면 계정이 잠긴다”는 안내를 띄운다 — 서버는 그 직전에 실패 횟수를 초기화했다(가치 2/위험 1/S, 새 아이디어) / 로그인 화면이account_mismatch를 일반 오류로만 보여줘 “지정된 계정으로 다시 로그인하면 이어진다”는 사실이 드러나지 않는다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) - 릴리즈: v0.9.76 (2026-09-10, run 2026-09-10-094110-ReSSO-improve)
2026-09-10
- 선택: 로그인 화면이 일어날 수 없는 계정 잠금을 경고하던 문제 수정 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공 (커밋 f286d21)
- 요약: 로그인 화면은 세 번 거절되면 “여러 번 실패했습니다. 반복 실패하면 계정이 일정 시간 잠기며, 잠긴 동안에는 올바른 비밀번호로도 로그인할 수 없습니다. 비밀번호가 확실하지 않으면 잠기기 전에 서비스 관리자에게 문의하세요.”를 띄우는데, 이것은 설명이 아니라 사람이 행동으로 옮기는 지시다 — 비밀번호를 의심하고 관리자에게 전화하라는 것이다. 카운터를 브라우저에 두는 것은 의도다(서버가 계정 존재 여부·잠금 여부를 밝히지 않게 하려고). 그런데
onError가 거절의 종류를 보지 않고 올려서, 실패한 시도가 아닌 답까지 그 지시에 닿았다. 403account_mismatch는 로그인이 성공한 경우다 — 비밀번호는 통과했고 세션은 만들어져 쿠키까지 브라우저에 들어갔으며, 서버는 그 직전에ResetLoginRateLimit으로 실패 횟수를 0으로 만든 뒤 RP가 지목하지 않은 계정에 인가 코드만 내주지 않았다(cc1b642). 그래서 지정된 계정이 아닌 계정으로 세 번 로그인하면, 맞았던 비밀번호를 의심하라는 안내가 뜬다 — 그 안내가 설명하는 카운터를 방금의 시도들이 매번 초기화했으므로 잠길 일은 영구히 없다.500과 status 0은 이쪽이 시도를 끝내지 못한 것이고 계정에는 아무것도 기록되지 않는데, 저장소 장애에도 같은 안내가 따라붙었다. 자격증명을 실제로 대조해서 나온 답은 401 셋(틀린 비밀번호·이미 잠긴 계정·비활성 계정)뿐이고 서버는 그 셋을 답하기 전에 계정에 실패로 기록하므로, 이제 그 셋만 센다.account_mismatch는 붉은 줄 하나를 틀린 비밀번호와 공유하던 것도 고쳤다 — 그렇게 보이면 “안 됐다”로 읽히고, 눈앞의 폼이 아직 출구라는 말이 아무데도 없었다. 인가 요청은 일부러 소진되지 않으므로 같은 화면에서 지정된 계정으로 다시 로그인하면 흐름이 이어지고, RP로 돌아가는 것은 같은 대조에 걸리는 request token을 새로 받는 일일 뿐이다. 이제 그렇게 안내하며 계정 이름은 여전히 밝히지 않는다. 백엔드는 손대지 않았다(login의 400expired_request가 저장소 장애를 뭉개는 건은 미병합 브랜치 9ba95ab와 겹친다). 검증: 새 테스트 둘이 더는 세지 않는 두 답으로 각각 세 번 제출해 잠금 안내가 뜨지 않는 것, mismatch가 “이 화면에서 다시 로그인하면 이어진다”고 말하는 것, 폼이 계속 쓸 수 있는 것을 확인하고, 수정 전 페이지에서 둘 다 실제로 실패함을 확인했다(잠금 안내 Alert이 그대로 떴다). 틀린 비밀번호에 안내가 유지되는 것은 기존 테스트가 지킨다.make test전체 통과(exit 0) —go test -race ./...전 패키지 ok, FAIL 0(httpserver 75s), 연동 테스트 SKIP 0건,go vet,npm run test(22파일/108테스트, 디스크 파일 수와 일치),npm run build.make lint도 통과(golangci-lint 0 issues,govulncheck0,eslint --max-warnings 0). 빌드가 만든webui/dist/index.html변경은 되돌렸다.docs/operations.md의 계정 잠금 절에 “잠금 안내를 봤다는 문의가 곧 잠김을 뜻하지는 않는다”를 이제는 뜻한다로 바꿔 적고 안내를 만들지 않는 두 답을 나열했으며,docs/compatibility.md의id_token_hint행에 화면이 그렇게 안내한다는 것을 적었다. -
보류 아이디어: 로그인의 두 가지 500이 같은
internal_errorcode로 반대의 지시를 준다 — 하나는 “이 화면에서 다시 시도”, 다른 하나는 “애플리케이션에서 다시 시도”인데 화면은 구분할 수 없어 재시도 안내를 붙일 수 없었다(가치 3/위험 1/S, 새 아이디어) /oidcLogout이 hint의sub를 쿠키 세션과 대조하지 않아 다른 사람의 ID Token으로도 지금 로그인한 사람이 로그아웃된다(가치 2/위험 2/M) /id_token_hint의aud를 요청한 Client와 대조하지 않아 같은 Realm의 다른 Client에 발급된 ID Token도 hint로 통과한다(가치 2/위험 2/M) / 로그인 화면이 409request_already_used에 “연결한 서비스에서 다시 시작하세요”를 말하지 않아, 소진된 요청으로 같은 폼에서 계속 시도하게 된다(가치 2/위험 1/S, 새 아이디어) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) - 릴리즈: v0.9.77 (2026-09-10, run 2026-09-10-182111-ReSSO-improve)
2026-09-10
- 선택: 사용자 가이드·관리자 가이드를 화면 캡처가 들어간 완성본으로 만들기 (가치 5 / 위험 1 / 작업량 L)
- 결과: 성공
- 요약: 이 저장소에는
docs/operations.md·compatibility.md·user-federation.md가 있었지만 화면을 쓰는 사람을 위한 문서도, 캡처도 한 장도 없었다 — 운영 문서 셋은 전부 이미 아는 사람이 읽는 참고서다. GUIDE-STANDARD.md에 맞춰docs/USER_GUIDE.md(11쪽)·docs/ADMIN_GUIDE.md(24쪽)와 두 PDF를 만들고, 실제로 띄운 화면 19장을docs/assets/guide/에 실었다. 캡처는 목업이 아니라 v0.9.77 바이너리(make build VERSION=v0.9.77)를 빈 PostgreSQL 위에 띄우고 새 스크립트scripts/guide-screenshots.mjs가 데모 데이터를 심은 뒤 headless Chrome을 CDP로 몰아 1440x900에서 찍은 것이다(puppeteer 없이 Node 22의 내장 WebSocket으로 붙는다). 문서 내용은 코드에서 읽어 만들었다: 환경 변수 표 여덟 개는internal/config/config.go가 실제로 읽는 전부이고, 안내한 API는 메서드까지 라우트 등록 자리(server.go·admin.go)에서 확인했으며(/metrics는 GET이지만 관리 권한이 필요하고/mcp는 POST 전용 — GET은 405임을 실제 호출로 확인), 「막혔을 때」 표의 문구는LoginPage.tsx와auth.go에 있는 그대로다. 권한 모델도 코드에서 읽었다 — 서비스 관리자는 API·화면으로 부여할 수 없고(users.platform_admin은BOOTSTRAP_ADMIN최초 생성과admin recover만 세운다), Realm 관리자는realm-adminRole이다. 캡처 스크립트는 GUIDE-STANDARD가 2026-09-10 AgentHub 사고에서 뽑은 규칙을 지킨다: 대상은 다른 스크립트와 공유하지 않는 전용 변수RESSO_GUIDE_URL로만 받고 기본값이 없으며, loopback이 아니면 그 자리에서 멈춘다(이 스크립트는 Realm·사용자·Client·API 키·승인 요청을 만들어 넣으므로 남의 배포에 닿으면 감사 트레일에 그대로 남는다). 전역 설정을 PUT으로 덮어쓰지 않고 새 객체만 만들며, 유일하게 건드리는 기존 레코드는 방금 만든 Realm의 승인 절차 스위치 하나다(그래서 되돌릴 이전 상태가 없다). 화면에는 실명·실제 주소·실제 비밀값이 없다 —홍길동/hong.gildong@example.com,데모 회사,sso.example.com,ldaps://ad.example.com:636이고, API 키 Prefix는 버릴 인스턴스에서 방금 발급된 값이다. 세션 화면의기기칸이node로 찍히던 것은 시드 로그인에 브라우저 User-Agent를 붙여Chrome · Windows/Edge · Windows로 바로잡았고, User Federation 화면이 빈 상태로 찍히던 것은 디렉터리 공급자 하나를 심어 채웠다(빈 목록을 찍지 않는다는 규칙). 정본은 하나다 — 기존 가이드가 없었으므로 README 맨 위에 두 문서(+PDF) 표를 더해 README가 그 요약이 아니라 개발·연동 참고용임을 밝혔고, 두 가이드는operations.md·compatibility.md·user-federation.md를 가리키기만 하고 겹쳐 쓰지 않는다. 검증: 캡처 스크립트를 빈 데이터베이스에서 처음부터 돌려 19장이 모두 나오는 것을 확인했고(중간에 두 번 실패했다 — Realm 생성 응답에는 DB가 기본값을 넣는 비밀번호 정책 필드가 비어 있어 그대로 PUT하면password_min_length 값은 8에서 128 사이여야 합니다로 거절되므로 읽어 오게 고쳤고, Chrome 프로필 삭제가 종료를 앞질러 ENOTEMPTY로 끝나던 것은 종료를 기다린 뒤 재시도하게 했다), 재실행해도 같은 결과가 나오도록 모든 생성을 멱등하게 두었다. PDF는 공용 도구(aidev/tools/guide/md2pdf.mjs)로 굽고 구조를 확인했다(사용자 11쪽/그림 6, 관리자 24쪽/그림 13 — 문서가 싣는 장수와 일치). 표지·표·코드 블록·그림이 깨지지 않았는지는--keep-html로 뽑은 같은 HTML을 Chrome으로 렌더해 눈으로 확인했다(이 환경에 poppler가 없어 PDF를 직접 이미지로 펼칠 수 없었다).make test전체 통과 — 연동 테스트 서비스 네 변수를 모두 세운 상태로 돌렸다. 빌드가 만든webui/dist/index.html변경과build/는 커밋 전에 되돌렸다. -
보류 아이디어: 로그인의 두 가지 500이 같은
internal_errorcode로 반대의 지시를 준다 — 하나는 “이 화면에서 재시도”, 다른 하나는 “RP에서 재시도”인데 화면은 구분할 수 없다(가치 3/위험 1/S) /oidcLogout이id_token_hint의sub를 쿠키 세션과 대조하지 않아 공용 브라우저에서 다른 사람 세션이 끊긴다(가치 2/위험 2/M) / 가이드 화면 캡처를 릴리즈 절차나 make 타깃으로 묶어 두지 않으면 화면이 바뀌어도 그림만 낡는다 —scripts/guide-screenshots.mjs는 있지만 아무도 부르지 않는다(가치 2/위험 2/M, 새 아이디어) / 로그인 화면이 409request_already_used에 “연결한 서비스에서 다시 시작하세요”를 말하지 않아 소진된 요청으로 같은 폼에서 계속 시도하게 된다(가치 2/위험 1/S) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S) - 릴리즈: v0.9.78 (2026-09-10, run 2026-09-10-230045-ReSSO-improve)
2026-09-12
- 선택: 방문 추적 스크립트 체계 (캠페인 tracking-2026-09) — 화면 설정·요청별 nonce·정책 출처 자동 추출·차단 신고 기록·Momento 같은 오리진 프록시 (가치 5 / 위험 2 / 작업량 L)
- 결과: 성공 (커밋 1d9b41d)
- 요약: TRACKING-STANDARD.md와 kanpic 참조 구현을 따라
internal/tracking(설정·스니펫·정책 출처·차단 기록),platform_settings테이블(마이그레이션 016),/api/admin/v1/tracking*(서비스 관리자 전용),/api/v1/tracking/csp-report(인증 없는 신고 수신),/momento/*리버스 프록시(세션 쿠키·Authorization 제거, 상류 Set-Cookie·CSP 제거), 콘솔방문 추적화면을 더했다. 기본값은 꺼짐이고 꺼진 동안 문서와 정책은 이전과 글자 그대로 같으며(basePolicy상수 하나로 묶었다), 켜면 비관리 경로의 문서에만 nonce 붙은 스니펫과script-src 'self' 'nonce-…'+ 출처 +report-uri가 실린다 —'unsafe-inline'은 어떤 경우에도 넣지 않는다. 검증: 새 연동 테스트가 끝까지 한 번에 돈다(오버사이즈 거절 → Momento 프록시로 켬 → 로그인 문서의 nonce가 헤더와 일치·관리 문서는 미포함·API 경로는 basePolicy → 가짜 수집기로 프록시되며 쿠키 미전달·Set-Cookie 미반환 → 신고 → 화면 목록 → 한 번 클릭 허용 → 정책 반영 → Realm 관리자 403 → 끄면 원상 복구·프록시 404), 단위 테스트(tracking 패키지 9개, httpserver 4개), 콘솔 테스트 4개(TrackingPage.test.tsx).make test는 처음에 CSRF·API 키 라우트 워커 두 테스트가 신고 엔드포인트를 변경 API로 잡아 실패했고 — 브라우저가 자격증명 없이 보내는 신고라 토큰을 실을 수 없고 메모리의 유계 목록만 바꾸므로 login POST와 같은 예외 목록에 넣었다 — 그 뒤 해당 테스트와 전체 패키지가 통과했다(store 90s·httpserver 96s, 연동 SKIP 0).golangci-lint0 issues(.golangci.yml에 misspell ignoremomento추가),npm run lint통과,npm run test·npm run build는 make test 첫 실행에서 통과.docs/ADMIN_GUIDE.md에 5-12 절(설정 표·CSP 설명·로그인 화면 주의)과 리버스 프록시/momento전달 한 줄을 더하고 PDF를 다시 구웠다. 캡처는 예산상 찍지 않았다(보류 아이디어로 남김). 실제 Momento 수집기로 수집이 들어오는 것은 이 환경에 수집기가 없어 가짜 수집기(httptest)로만 확인했다. - 보류 아이디어: 방문 추적 화면 캡처를 guide-screenshots.mjs에 더해 가이드에 싣기(가치 2/위험 1/S, 새 아이디어) / 비화면 경로의 CSP를
default-src 'none'으로 더 좁히기 — 표준 5절(가치 2/위험 2/S, 새 아이디어) / 로그인의 두 가지 500이 같은internal_errorcode로 반대의 지시를 준다(가치 3/위험 1/S) /oidcLogout이id_token_hint의sub를 쿠키 세션과 대조하지 않는다(가치 2/위험 2/M) / UserInfo POST에서 form-encodedaccess_token수용, RFC 6750 §2.2(가치 2/위험 1/S)