Files
document-haness/.playwright-mcp/_p3.mjs
T

54 lines
42 KiB
JavaScript

async (page) => {
const DOCS = [{"id": "39fdf472-82c4-43ed-abec-73de672f08ae", "name": "reference-authorization-code-endpoints", "jobs": [["요약", "Authorization Endpoint에서 Redirect, Token Endpoint, JWK 검증, Resource API까지 각 지점에서 무엇이 이동하고 무엇이 이동하지 않는지 확인한다. 먼저 client_secret이 가는 곳과 가지 않는 곳을 나눠 보자."], ["목적", "Authorization Endpoint와 Token Endpoint는 역할과 호출 방식이 다르다.\n이 구분을 해야 SPA에서 client_secret이 어디로 갔는지, PKCE가 어느 구간을 지키는지 이해하기 쉽다.\n\n하나는 브라우저의 full-page navigation이고 하나는 server-to-server 호출이 될 수도 있고 browser-to-server 호출이 될 수도 있다.\n노출되는 것도, 인증하는 방법도 다르다.\n\nAuthorization Endpoint\n경로 : 브라우저 주소창 남는 곳 : 히스토리·서버 로그·referrer client 인증 : x\n\nToken Endpoint\n경로 : body와 Authorization 헤더 보내는 쪽 : client 종류에 따라 server 또는 브라우저 client 인증 : o"], ["규칙 1 제목", "Authorization Endpoint에는 client_secret을 보내지 않는다"], ["규칙 1 본문", "이 요청은 브라우저 주소창을 통해 나간다. 그래서 URL이 주소창에도, 브라우저 히스토리에도, 서버 접근 로그에도, 그리고 링크를 타고 온 경우 referrer에도 남는다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. secret이 필요한 인증은 아직 하지 않는다.\n\n반대로 말하면 이 목록에 없는 값을 여기 넣으면 그 값도 같은 곳에 다 남는다."], ["규칙 2 제목", "Token Endpoint에서 비로소 client를 인증한다"], ["규칙 2 본문", "code를 access token으로 바꾸는 요청은 credential을 URL query가 아니라 body와 Authorization 헤더에 싣는다. 그래서 client 인증을 여기서 한다. confidential client는 client_secret_basic처럼 secret을 함께 보낸다.\n\n주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 debug 로그, reverse proxy 로그, tracing과 APM, packet capture에 남을 수 있어서 credential masking을 따로 둔다.\n\n이 요청을 누가 보내는지는 client 종류에 따라 갈린다. server가 보내면 server-to-server이고, secret이 없는 SPA가 보내면 브라우저가 직접 보낸다. token endpoint를 server 안에서만 부르게 하려면 client 종류부터 confidential로 정해야 한다."], ["규칙 3 제목", "PKCE는 두 요청을 같은 주체에 묶는다"], ["규칙 3 본문", "처음 요청에 code_challenge를 담아서 보내고, 교환할 때 원본인 code_verifier를 보내서 이 두개가 일치하는지 확인 한다.\n이 2개가 일치해야 토큰 교환이 되게 된다.\n\ncode를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다."], ["규칙 4 제목", "issuer 검증값과 JWK 조회 주소를 같은 값으로 맞추려 하지 않는다"], ["규칙 4 본문", "issuer는 요청을 보내는 주소가 아니라 token의 canonical issuer identifier다. 검증은 발급된 token의 iss claim이 그 값과 같은지를 본다.\n\nJWK 조회 주소는 실제로 공개키를 가져오는 network 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니므로 service 이름을 써야 하고, 브라우저는 그 이름에 닿지 못한다.\n\nissuer 검증값과 endpoint 연결 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 redirect가 깨지거나 서버가 키를 못 가져온다."], ["규칙 5 제목", "Resource API는 서명만 보고 끝내지 않는다"], ["규칙 5 본문", "서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다.\n\n그래서 issuer와 유효 시간, 그리고 이 API를 위해 발급됐다는 audience를 함께 본다. audience 검증이 빠지면 옆 서비스의 token으로 우리 API가 열린다."], ["규칙 6 제목", "redirect_uri는 exact match로 좁힌다"], ["규칙 6 본문", "wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.\n\n실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.\n\n등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다."], ["규칙 7 제목", "로그인 구간과 API 호출 구간을 한 줄로 그리지 않는다"], ["규칙 7 본문", "로그인 구간은 authorization request에서 시작해 callback과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답이다.\n\n두 구간을 한 줄로 이어 그리면 누가 code를 바꾸고 누가 API를 부르는지가 겹쳐 보인다. 구조가 갈리는 자리가 바로 여기라서 나눠서 그려야 한다."], ["적용 조건 1", "Authorization Code Flow를 쓰는 client를 설정하거나 문서로 설명할 때"], ["적용 조건 2", "브라우저 요청과 server-to-server 요청이 한 흐름에 섞여 있을 때"], ["적용 조건 3", "endpoint별로 무엇이 노출되는지 나눠야 할 때"], ["적용 조건 4", "PKCE와 client 인증의 자리를 정할 때"], ["예외 1", "Client Credentials처럼 사용자 없이 token을 받는 흐름은 Authorization Endpoint를 지나지 않는다."], ["예외 2", "Device Authorization Grant는 브라우저 redirect 대신 별도의 사용자 code 단계를 쓴다. redirect_uri 항목이 그대로 적용되지 않는다."], ["예시 1", "authorization request에는 code_challenge_method=S256이 있고 client secret은 없다"], ["예시 2", "token request에는 code_verifier가 있다. secret을 가진 client는 이 요청에서 자기를 인증한다"], ["예시 3", "expected issuer는 http://localhost:8080/realms/keycloak-patterns 이고 JWK 조회는 컨테이너 network 주소를 쓴다"], ["예시 4", "audience에 keycloak-pattern-api 가 없으면 invalid_token 결과가 되어 401이 된다"], ["예시 5", "redirect allowlist에 wildcard가 있으면 등록한 host의 다른 경로로도 code가 갈 수 있다"], ["관계 1 이유", "브라우저가 code를 직접 교환하는 흐름에서 endpoint별 이동을 관측했다."], ["관계 2 이유", "confidential client가 token endpoint에서 자기 client를 인증하는 실례다."], ["관계 3 이유", "client 종류가 정해져야 PKCE와 client 인증의 자리가 정해진다."]], "groups": {"규칙": 7, "적용 조건": 4, "예외": 2, "예시": 5}}, {"id": "97eddd97-1096-426a-a2c6-a6c5bf1cd09f", "name": "reference-bff-auth-design", "jobs": [["요약", "브라우저에 HttpOnly session만 남기고 BFF가 access token으로 Resource Server를 부르는 구조에서, BFF를 넣기로 정한 다음에 반드시 같이 정해야 하는 항목을 모았다. CSRF 검증, authorized client 저장소, logout, downstream 오류 변환이다."], ["목적", "브라우저에서 OAuth token을 없애면 code 교환과 API 호출을 BFF가 대신하게 된다. 그 시점에 BFF는 로그인 상태와 token을 가진 보안 구성요소가 된다.\n\ncookie가 credential이 되면 브라우저가 요청마다 자동으로 붙인다. 값을 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. 그리고 재시작과 replica 이동을 견딜 저장소도 함께 필요해진다.\n\n여기 있는 것은 「BFF를 쓴다」로 답이 되지 않는 항목들이다."], ["규칙 1 제목", "브라우저에는 session cookie만 남긴다"], ["규칙 1 본문", "access token과 refresh token은 server-side authorized client에 둔다. 응답 본문으로 token을 한 번이라도 내보내면 원문이 응답과 지역 변수, 헤더를 차례로 지나게 되어서 이 구조를 고른 이유가 사라진다.\n\nsession cookie는 downstream으로 전달하지 않는다. BFF가 session을 애플리케이션 credential로 소비하고, Resource Server가 아는 Bearer 요청을 새로 만든다. 두 credential은 같은 요청 처리 안에 있지만 검증하는 주체가 다르다."], ["규칙 2 제목", "cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다"], ["규칙 2 본문", "GET만 보면 문제가 보이지 않는다. cookie는 브라우저가 알아서 붙이기 때문에 다른 사이트가 만든 요청에도 그대로 실린다. 그래서 값을 바꾸는 endpoint에는 사용자가 실제로 그 화면에서 눌렀다는 신호가 하나 더 필요하고, 그 신호는 JavaScript가 읽을 수 있어야 한다.\n\n노출 값과 제출 값이 다를 수 있다. 응답 본문의 token이 가려진 값이면 헤더에 넣는 값은 cookie에서 읽어야 한다. 두 값을 같다고 가정하고 구현하면 클라이언트가 그대로 403을 받는다.\n\nSameSite로 대신하지 않는다. SameSite는 cookie를 아예 안 실어 보내는 브라우저 정책이고 CSRF token은 실려 온 요청의 의도를 서버가 확인하는 규약이다. port가 달라도 site 계산상 같은 경우가 있어서, 그때는 cookie가 실리고 SameSite만으로는 막지 못한다."], ["규칙 3 제목", "session과 authorized client의 수명주기를 따로 설계한다"], ["규칙 3 본문", "session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾는다. 열쇠가 달라서 하나를 옮긴다고 다른 하나가 따라오지 않는다.\n\n같은 사용자가 두 브라우저에서 로그인하면 같은 token 항목을 공유하거나 덮어쓴다. session ID마다 token을 따로 보관해야 하면 그렇게 설계해야 한다.\n\n저장소는 재시작과 replica 이동을 견뎌야 한다. 공유 durable store와 session affinity, 저장 token 암호화 중 무엇을 쓸지 정하고 암호화 key 교체 방법도 같이 정한다.\n\nlogout은 두 상태를 모두 지운다. 열쇠가 달라서 한 번의 삭제로 함께 지워지지 않고, 하나만 지우면 다음 로그인에서 남은 쪽으로 상태가 복구될 수 있다."], ["규칙 4 제목", "downstream 오류를 화면 오류로 바꾸는 규칙을 둔다"], ["규칙 4 본문", "Resource Server의 401을 그대로 내려보내면 사용자는 로그인이 끊긴 것인지 권한이 없는 것인지 알 수 없다. timeout과 retry, circuit breaker, 재로그인 전환도 함께 정한다. 모든 UI 요청이 BFF를 지나기 때문에 여기서 정하지 않으면 화면마다 다르게 처리된다."], ["규칙 5 제목", "자기 보고 값을 증거로 쓰지 않는다"], ["규칙 5 본문", "「브라우저에 token이 없다」고 서버가 응답에 적는 값은 서버가 넣은 상수다. 브라우저를 들여다본 결과가 아니다.\n\n밖에서 관측한 것을 따로 남긴다. 브라우저 개발자 도구의 요청 목록과 Web Storage를 직접 확인하고, 자기 보고와 외부 관측을 같은 증거로 묶지 않는다."], ["규칙 6 제목", "BFF를 넣어도 XSS는 남는다"], ["규칙 6 본문", "same-origin 악성 script는 피해자 session으로 BFF endpoint를 그대로 부를 수 있고 읽을 수 있는 CSRF cookie도 읽는다. 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용될 범위다. CSP와 output encoding, 의존성 무결성, 애플리케이션 인가는 그대로 필요하다."], ["적용 조건 1", "브라우저가 OAuth token을 받아서는 안 될 때"], ["적용 조건 2", "backend가 화면에 맞춰 여러 API를 조합해야 할 때"], ["적용 조건 3", "로그인 상태를 애플리케이션이 소유해야 할 때"], ["적용 조건 4", "downstream API가 늘어나도 브라우저는 하나만 알게 하고 싶을 때"], ["예외 1", "stateless 직접 API 호출과 독립 client가 핵심이면 BFF를 넣지 않는다. server state와 단일 장애 지점만 늘어난다."], ["예외 2", "브라우저의 직접 API 호출을 남겨야 하면 refresh credential만 서버로 분리하는 구조가 맞다."], ["예외 3", "server state를 둘 수 없는 환경이면 브라우저가 token을 직접 다루는 구조가 더 단순하다."], ["예시 1", "브라우저 요청에는 Authorization 헤더가 없고 session cookie만 있다"], ["예시 2", "BFF가 authorized client에서 access token을 읽어 downstream Bearer 요청을 새로 만든다"], ["예시 3", "CSRF 헤더가 없는 POST는 403이 되고 cookie의 raw 값을 헤더에 넣은 POST는 200이 된다"], ["예시 4", "응답 본문의 token은 가려진 값이고 헤더에 넣는 값은 cookie의 raw 값이다"], ["예시 5", "진단 endpoint의 browserTokenCount는 controller literal이라서 token 비노출의 근거가 아니다"], ["관계 1 이유", "이 기준의 항목 중 실제로 구현된 것과 비어 있는 것을 센 기록이다."], ["관계 2 이유", "저장소 항목이 아직 답이 없는 질문으로 남아 있다."], ["관계 3 이유", "어느 저장소에 둘지가 이 기준의 미결 항목이다."], ["관계 4 이유", "이 결정이 PROPOSED인 동안 실제 적용 기준은 이 문서다."]], "groups": {"규칙": 6, "적용 조건": 4, "예외": 3, "예시": 5}}, {"id": "004dd0a2-5fb3-4f25-80c9-576f709de331", "name": "reference-forward-auth-header-trust", "jobs": [["요약", "upstream이 사용자를 판단하는 근거가 헤더 하나뿐인 구조에서, 그 헤더를 믿을 수 있게 만드는 조건을 모았다. 외부 경로 차단, 동명 헤더 덮어쓰기, internal credential 검증이 서로 다른 곳에 함께 있어야 한다."], ["목적", "외부 요청이 edge를 지나 인증되고 upstream으로 가는 구조에서, upstream이 사용자를 판단하는 근거는 헤더 하나다.\n\n같은 이름의 헤더를 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다. upstream이 받는 요청에서 이 둘은 구분되지 않는다.\n\n그래서 헤더를 어떻게 붙이느냐보다 받은 헤더를 어떻게 믿을 수 있느냐를 먼저 정한다."], ["규칙 1 제목", "외부에서 upstream과 auth proxy에 직접 닿지 못하게 한다"], ["규칙 1 본문", "edge만 공개하고 나머지는 내부 network에 두면서 host port로 노출하지 않는다.\n\n이걸 안 하면 공격자가 edge를 건너뛰고 upstream을 직접 부른다. 그때는 헤더를 아무리 검사해도 공격자가 그 헤더를 마음대로 쓸 수 있어서 의미가 없다."], ["규칙 2 제목", "client가 보낸 동명 헤더를 항상 덮어쓴다"], ["규칙 2 본문", "merge가 아니라 덮어쓰기로 채우고, 인증 결과에서 복사한 값만 upstream으로 보낸다. merge로 두면 client가 보낸 값이 앞이나 뒤에 함께 붙고, 어느 쪽을 읽을지는 upstream 구현에 달려 있다.\n\ntrusted proxy 범위도 같이 좁힌다. 넓게 잡으면 같은 network 안의 다른 workload가 edge인 척할 수 있고, forwarded 계열 헤더를 믿는 설정에서는 그 범위가 곧 신뢰 경계다."], ["규칙 3 제목", "auth endpoint는 subrequest 전용으로 둔다"], ["규칙 3 본문", "이 endpoint는 외부 client가 쓰라고 만든 것이 아니다. proxy가 만드는 subrequest만 들어가게 하고 외부 호출에는 응답하지 않게 둔다. Nginx라면 `internal` location이 그 역할을 한다."], ["규칙 4 제목", "upstream이 헤더 존재만 보지 않는다"], ["규칙 4 본문", "배포로 주입한 internal credential과 일치하는지까지 확인한다. 비교는 값이 어디까지 맞았는지 시간으로 새지 않는 방식으로 한다. 앞자리부터 순서대로 끊는 비교를 쓰면 응답 시간 차이로 값을 한 글자씩 좁혀 갈 수 있다.\n\n그 검사를 controller 한 곳에 적어 두면 다음 사람이 새 endpoint를 만들 때 따라오지 않는다. filter나 interceptor, security chain처럼 대상 endpoint 전체에 자동으로 걸리는 자리로 옮긴다."], ["규칙 5 제목", "격리와 헤더 검증은 서로 대신하지 않는다"], ["규칙 5 본문", "격리는 밖에서 들어오는 직접 접근을 막고 헤더 검증은 안에서 만들어진 위조를 막는다. 막는 대상이 달라서 하나로 다른 하나를 대체했다고 쓸 수 없다."], ["규칙 6 제목", "전달할 헤더를 allowlist로 고정한다"], ["규칙 6 본문", "복사할 응답 헤더 목록을 정해 두고 그 밖은 버린다. 늘릴 때마다 claim 출처와 다중 값 구분자, escaping, 최대 크기, upstream 검증 계약을 다시 정해야 한다.\n\nuser와 email만 전달하는 구조는 누가 왔는지만 말하고 무엇을 해도 되는지는 말하지 않는다. role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지도 따로 정한다."], ["규칙 7 제목", "검사 지점은 요청 실패가 아니라 응답의 사용자다"], ["규칙 7 본문", "위조 헤더를 얹은 정상 session 요청은 정상 session이니 200이 되는 것이 맞다. 확인할 값은 그 응답의 사용자가 위조 값인지 실제 인증된 사용자인지다. 요청이 실패하는지만 보면 덮어쓰기가 동작하는지 알 수 없다."], ["규칙 8 제목", "지금 확인한 것과 운영에서 더 필요한 것을 나눠 적는다"], ["규칙 8 본문", "이 기준에서 실제 fixture로 확인한 것은 외부 경로 차단, 헤더 덮어쓰기, auth endpoint 내부 전용 지정, upstream의 internal credential 확인이다.\n\n운영에서는 여기에 더 필요하다. 공유 secret을 secret manager에서 주입하고 교체 절차를 두는 것, network policy로 경로를 강제하는 것, 그리고 더 강하게 묶으려면 mTLS나 workload identity를 쓰는 것이다. 두 묶음을 같은 문단에 섞어 적지 않는다."], ["적용 조건 1", "upstream에 OAuth client나 JWT 검증 코드를 넣기 어려울 때"], ["적용 조건 2", "여러 legacy service 앞에 같은 로그인 정책을 둘 때"], ["적용 조건 3", "edge에서 정책을 강제할 수 있을 때"], ["적용 조건 4", "이미 forward-auth를 쓰고 있는 구조를 점검할 때"], ["예외 1", "backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다."], ["예외 2", "애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF 구조가 더 자연스럽다."], ["예외 3", "임의 경로와 body, streaming을 그대로 넘기는 범용 reverse proxy가 필요하면 URI rewrite와 timeout, 응답 헤더 처리를 따로 설계해야 한다."], ["예시 1", "외부에는 edge만 공개하고 app과 auth proxy의 port는 host에 publish하지 않는다"], ["예시 2", "정상 session에 위조 헤더를 얹은 요청은 200을 받지만 응답의 사용자는 실제 사용자다"], ["예시 3", "외부에서 auth endpoint를 직접 부르면 404가 된다"], ["예시 4", "upstream은 user 헤더와 internal token을 함께 확인하고 하나라도 어긋나면 401을 돌려준다"], ["예시 5", "내부 검사가 controller 하나에만 있으면 새 endpoint에는 보호가 따라오지 않는다"], ["관계 1 이유", "이 기준의 다섯 조건을 실제 설정에서 확인한 기록이다."], ["관계 2 이유", "헤더를 어디까지 늘릴지가 이 기준의 미결 항목이다."], ["관계 3 이유", "identity 헤더를 JWT나 session과 같은 이름으로 부르지 않는다."]], "groups": {"규칙": 8, "적용 조건": 4, "예외": 3, "예시": 5}}, {"id": "1a00a640-8987-4075-a9e4-7ec023cdffbb", "name": "reference-idp-federation-boundary", "jobs": [["요약", "Google 로그인은 다섯 번째 인증 구조가 아니다. Google에서 브로커의 identity brokering과 local session, authorization code를 지나면 애플리케이션이 고르는 것은 여전히 앞의 네 경계 중 하나다."], ["목적", "외부 IdP를 붙이면서 그것을 애플리케이션 인증 구조로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다.\n\nGoogle은 브로커 앞의 upstream identity provider다. 사용자가 브로커 로그인 화면에서 Google을 고르면 브라우저가 upstream authorization을 하게 되고, 브로커가 그 응답을 검증해 local identity와 연결한 뒤 다시 자기가 만든 authorization code를 애플리케이션으로 보내게 된다.\n\n그래서 소셜 로그인을 붙여도 브라우저가 token을 받는지, 어느 계층이 API를 부르는지는 하나도 바뀌지 않는다."], ["규칙 1 제목", "외부 IdP는 브로커 앞단이고 애플리케이션 경계는 그 뒤다"], ["규칙 1 본문", "외부 IdP는 브로커 앞의 provider다. 애플리케이션이 고르는 것은 브로커 뒤의 경계이고, 구조 수를 셀 때 외부 IdP를 목록에 넣으면 성격이 다른 것이 섞인다.\n\nupstream identity assertion은 브로커에서 끝난다. 애플리케이션이 받는 것은 브로커가 발급한 authorization code이고, Resource Server가 검증하는 issuer도 브로커다. 그래서 code 교환부터 뒤의 흐름은 외부 IdP가 없을 때와 똑같아진다.\n\nUI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. 다만 Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.\n\n외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다."], ["규칙 2 제목", "stable identity key는 provider와 upstream subject의 조합이다"], ["규칙 2 본문", "email은 바뀔 수 있고 다른 계정과 겹칠 수도 있어서 계정을 잇는 열쇠로 맞지 않는다. 어느 provider의 어느 subject인지를 열쇠로 쓴다. email을 열쇠로 쓰면 사용자가 주소를 바꾼 순간 다른 사람이 된다."], ["규칙 3 제목", "email 충돌은 별도의 계정 연결 문제로 다룬다"], ["규칙 3 본문", "upstream email이 기존 계정과 같다는 이유로 자동 병합하지 않는다. 같은 주소를 쓰는 다른 사람일 수도 있고 주소를 선점한 공격일 수도 있어서, 기존 계정의 소유권을 증명하는 절차를 따로 둔다."], ["규칙 4 제목", "mock provider로 확인한 범위와 실제 IdP를 구분한다"], ["규칙 4 본문", "브로커와 claim mapping 계약까지만 확인했다. 실제 계정과 공개 HTTPS callback, consent 화면, 도메인 정책은 아직 통과해 보지 않았다. 두 범위를 같은 증거로 쓰면 운영에서 처음 보는 실패를 만난다."], ["적용 조건 1", "외부 IdP를 붙이며 구조 수를 세려 할 때"], ["적용 조건 2", "계정 연결 규칙을 정할 때"], ["적용 조건 3", "검증 범위를 문서로 적을 때"], ["적용 조건 4", "브로커를 거치는 흐름과 직접 OIDC 흐름을 비교할 때"], ["예외 1", "애플리케이션이 브로커를 거치지 않고 외부 IdP와 직접 OIDC를 하는 구조라면 그 IdP가 애플리케이션의 issuer가 된다. 그때는 client 종류와 endpoint 기준을 그대로 적용한다."], ["예외 2", "조직 계정만 쓰고 외부 IdP가 하나뿐이면 브로커를 두지 않는 선택도 있다. 그때는 계정 연결 규칙이 필요하지 않다."], ["예시 1", "Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 네 경계 중 하나다"], ["예시 2", "브로커가 provider alias와 upstream subject로 account identity를 정한다"], ["예시 3", "애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다"], ["예시 4", "mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다"], ["관계 1 이유", "이 기준을 프로젝트 결정으로 굳힌 기록이다."], ["관계 2 이유", "브로커가 만든 authorization code를 애플리케이션이 받는 흐름이다."], ["관계 3 이유", "외부 IdP가 있어도 애플리케이션 쪽 endpoint 이동은 그대로다."]], "groups": {"규칙": 4, "적용 조건": 4, "예외": 2, "예시": 4}}, {"id": "3f886154-1b85-407b-bda4-57d28370e745", "name": "reference-pattern-selection", "jobs": [["요약", "SPA와 Mediator, BFF, OAuth2-Proxy는 브라우저에 token이 덜 보이는 순서로 늘어놓을 수 있다. 그 순서는 보안 등급이 아니다. 구조를 고를 때는 code 교환·token 보관·API 호출·요청 인증이 각각 어디에 있는지를 본다."], ["목적", "브라우저에 token이 덜 보이는 순서는 있다. 그 순서를 보안 등급으로 쓰면 판단이 틀린다.\n\nBFF는 브라우저 token을 없애지만 server session과 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 새로 생긴 쪽을 감당할 수 없는 환경이면 앞 구조가 더 안전하다.\n\n번호가 아니라 배치를 본다."], ["규칙 1 제목", "네 축으로 배치를 적는다"], ["규칙 1 본문", "구조 이름을 나란히 놓으면 실제로 누가 무엇을 하는지가 보이지 않는다. 네 축을 먼저 채운다.\n\n브라우저가 access token을 받나\nSPA : o Mediator : o BFF : x Forward-Auth : x\n\n브라우저가 보호 자원을 직접 부르나\nSPA : o Mediator : o BFF : x Forward-Auth : x\n\nserver-side token 상태가 있나\nSPA : x Mediator : o BFF : o Forward-Auth : proxy session\n\n보호 자원이 무엇을 검증하나\nSPA : 서명된 JWT Mediator : 서명된 JWT BFF : 서명된 JWT Forward-Auth : edge가 붙인 헤더\n\ncookie가 credential이면 CSRF 검증이 어디에 붙나\nSPA : 해당 없음 Mediator : session endpoint BFF : 상태 변경 endpoint Forward-Auth : proxy cookie 기준\n\n이 축이 채워지면 필요한 방어와 저장소도 따라서 정해진다. 남는 질문은 401과 403, 갱신 실패와 logout을 어느 계층이 최종 응답으로 번역하느냐다."], ["규칙 2 제목", "피해야 할 조건을 먼저 확인한다"], ["규칙 2 본문", "정책상 브라우저에 token을 둘 수 없으면 memory에만 두는 보관은 답이 아니다. backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없으면 edge에 인증을 맡기지 않는다. 이 조건에 걸리면 다른 항목은 볼 필요가 없다."], ["규칙 3 제목", "없앤 것과 새로 맡은 것을 같이 적는다"], ["규칙 3 본문", "없앤 것만 적어 두면 다음 사람이 같은 판단을 다시 하지 못한다. 잘 맞는 조건과 피해야 할 조건도 같이 남긴다."], ["규칙 4 제목", "이름으로 운영 속성을 추정하지 않는다"], ["규칙 4 본문", "BFF나 forward-auth라는 이름은 배치를 말할 뿐이다. 공유 저장소와 장애 복구, session failover, secret 교체가 갖춰져 있는지는 매번 따로 확인한다."], ["규칙 5 제목", "옮기는 것은 업그레이드가 아니다"], ["규칙 5 본문", "한 구조에서 다른 구조로 가는 것은 credential 계약의 변경이다. 되돌아가는 선택도 후퇴가 아니다. 헤더 종류를 계속 늘리는 것보다 API 조합 책임을 애플리케이션에 돌려주는 편이 단순해질 때가 있다."], ["적용 조건 1", "인증 구조를 처음 고를 때"], ["적용 조건 2", "한 구조에서 다른 구조로 옮기려 할 때"], ["적용 조건 3", "구조를 문서로 비교할 때"], ["적용 조건 4", "이름만 보고 고른 구조를 다시 검토할 때"], ["예외 1", "요구가 하나로 좁혀지면 비교가 필요 없다. 브라우저에 token을 둘 수 없고 backend가 API를 조합해야 하면 선택지는 하나다."], ["예외 2", "학습이나 시연이 목적이면 운영 속성 비교를 하지 않아도 된다. 그때는 학습 환경이라고 문서에 적어 둔다."], ["예시 1", "SPA : 브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다"], ["예시 2", "Mediator : refresh token은 server에 있고 access token은 응답 본문으로 브라우저에 간다"], ["예시 3", "BFF : server가 셋을 다 맡고 브라우저에는 session cookie만 남는다"], ["예시 4", "Forward-Auth : edge가 인증하고 upstream은 edge가 붙인 헤더를 본다"], ["관계 1 이유", "브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다."], ["관계 2 이유", "셋 중 refresh 보관만 server로 갔을 때 무엇이 남는지 관측했다."], ["관계 3 이유", "server가 셋을 다 맡을 때 새로 생기는 상태를 실행해 봤다."], ["관계 4 이유", "인증이 edge로 가면 보호 자원이 검증하는 것이 JWT에서 헤더로 바뀐다."], ["관계 5 이유", "이 기준의 첫 항목을 프로젝트 결정으로 굳힌 기록이다."]], "groups": {"규칙": 5, "적용 조건": 4, "예외": 2, "예시": 4}}, {"id": "ede6b9ce-eeed-40c8-9175-9e8116029395", "name": "reference-public-confidential-client", "jobs": [["요약", "client 종류는 secret을 안전하게 보관할 수 있는지로 정한다. SPA는 보관할 곳이 없어 public client로 등록한다. 종류는 secret이 어디 있는지를 말할 뿐이고, 브라우저에 token이 가는지는 따로 정해진다."], ["목적", "client 종류를 무엇으로 정하는지부터 맞춰야 PKCE와 client 인증을 어디에 둘지 정할 수 있게 된다.\n\n기준은 프레임워크나 언어가 아니라 값이 도달하는 범위다. 브라우저에서 실행되는 코드에 넣은 값은 개발자 도구를 열면 그대로 보이기 때문에 SPA는 secret을 가질 수 없고, server와 BFF는 그 값을 process 밖으로 내보내지 않을 수 있어서 secret을 들고 있게 된다.\n\n여기서 자주 섞이는 것이 하나 있는데, 종류가 confidential이어도 브라우저에 token이 갈 수 있다. 서로 다른 결정이라서 따로 답해야 한다."], ["규칙 1 제목", "secret을 숨길 수 있는지로 종류를 정한다"], ["규칙 1 본문", "배포물이나 실행 중 memory에서 사용자가 값을 꺼낼 수 있으면 public client가 되고, server 안에만 두고 응답으로 나가지 않게 할 수 있으면 confidential client다.\n\nnative app은 브라우저가 아니지만 배포물을 뜯으면 값이 나오기 때문에 여기서도 public client로 다루게 된다. 실행 환경의 이름이 아니라 값이 어디까지 가는지로 정한다."], ["규칙 2 제목", "public client에서도 Authorization Code Flow에 PKCE를 함께 쓴다"], ["규칙 2 본문", "PKCE는 client secret의 대체 인증 수단이 아니다. authorization request를 시작한 주체와 code를 교환하는 주체를 잇는 장치이고, 막는 것은 code interception이다. client 인증이 없는 자리를 메우는 것이 아니라 다른 구간을 막는다.\n\n여기서 S256을 쓴다. plain은 challenge가 verifier 그대로라서 중간에서 본 사람이 그대로 쓸 수 있다."], ["규칙 3 제목", "confidential client에도 PKCE를 함께 쓸 수 있다"], ["규칙 3 본문", "client 인증이 있어도 PKCE는 여전히 쓸모가 있다. 두 장치가 막는 구간이 서로 달라서 함께 두면 그만큼 좁아지게 된다.\n\n다만 「Authorization Code를 쓴다」와 「PKCE S256까지 설정으로 고정했다」는 서로 다른 주장이다. 설정과 테스트에서 확인한 범위까지만 말할 수 있다."], ["규칙 4 제목", "public client에서는 implicit flow와 direct access grant를 끈다"], ["규칙 4 본문", "implicit flow는 token을 redirect fragment로 받게 되어서 주소창과 히스토리에 token이 남고, direct access grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받게 되어서 IdP만 알면 되는 값을 애플리케이션이 만지게 된다.\n\n이 두 flow는 standard flow로 대신할 수 있어서 꺼 둔다."], ["규칙 5 제목", "종류가 곧 브라우저 token 유무는 아니다"], ["규칙 5 본문", "confidential client가 code를 교환해도 그 결과인 access token을 응답 본문으로 브라우저에 건넬 수 있고, 실제로 그렇게 도는 구조가 있다.\n\n종류는 secret을 어디에 두는지를 말하고, token 노출은 어느 계층이 API를 부르는지에 따라 갈린다."], ["적용 조건 1", "새 OAuth client를 등록할 때"], ["적용 조건 2", "SPA와 server 중 어디가 code를 교환할지 정할 때"], ["적용 조건 3", "PKCE와 client 인증을 어디에 둘지 정할 때"], ["적용 조건 4", "기존 client의 종류가 맞는지 다시 볼 때"], ["예외 1", "같은 서비스가 브라우저용 public client와 server용 confidential client를 따로 등록할 수 있다. 하나로 합치려고 secret을 브라우저로 내보내지는 않는다."], ["예외 2", "backend가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 client다."], ["예시 1", "SPA용 client : public, standard flow만 켜고 implicit flow와 direct grant는 끈다"], ["예시 2", "Mediator용 client : confidential, client_secret_basic으로 token endpoint에서 인증한다"], ["예시 3", "BFF용 client : confidential, PKCE S256을 함께 쓴다"], ["예시 4", "Proxy용 client : confidential, oauth2-proxy가 secret과 verifier로 code를 교환한다"], ["예시 5", "confidential client인 Mediator를 써도 access token은 브라우저 응답에 실릴 수 있다"], ["관계 1 이유", "secret을 숨길 수 없는 SPA를 public client로 둔 실례다."], ["관계 2 이유", "confidential client를 쓰면서도 access token이 브라우저로 나간 실례다."], ["관계 3 이유", "종류가 정해지면 어느 endpoint에서 무엇을 인증할지가 따라온다."]], "groups": {"규칙": 5, "적용 조건": 4, "예외": 2, "예시": 5}}, {"id": "66c18e42-116c-459f-86bd-b7e4bf394866", "name": "reference-token-vs-session", "jobs": [["요약", "IdP의 SSO session, access token, refresh token, 애플리케이션 session cookie, proxy session cookie는 만든 주체도 소비자도 수명도 다르다. 다섯을 로그인 상태 하나로 부르면 무엇이 만료됐고 무엇을 지워야 하는지 말할 수 없게 된다."], ["목적", "네 구조를 다 실행해 보면 응답에는 모두 같은 사용자 이름이 나오게 되어서 같은 인증 정보라고 묶기 쉽다.\n\n그런데 값이 들어온 곳을 따라가 보면 어떤 때는 JWT 안의 claim이고 어떤 때는 proxy가 만든 헤더다. 둘을 다 로그인 상태라고 부르게 되면 서명을 검증한 것인지 헤더를 확인한 것인지 문장만 봐서는 구분할 수 없게 된다.\n\n로그아웃과 만료를 설계할 때 이 구분이 바로 걸리게 되는데, 무엇을 지우면 무엇이 남는지를 답하려면 이름부터 나뉘어 있어야 하기 때문이다."], ["규칙 1 제목", "다섯 상태에 각각 다른 이름을 쓴다"], ["규칙 1 본문", "IdP SSO session, OAuth access token, OAuth refresh token, 애플리케이션 session cookie, proxy session cookie는 서로 다른 것이라서 문서와 코드, 로그에서 같은 이름을 돌려 쓰지 않는다.\n\n로그에 로그인 상태라는 말만 남아 있으면 나중에 어느 것이 끊겼는지 찾을 수 없게 된다."], ["규칙 2 제목", "만든 주체와 주된 소비자로 구분한다"], ["규칙 2 본문", "access token은 IdP가 만들고 Resource Server가 소비하게 되고, 애플리케이션 session cookie는 애플리케이션이 만들어 자기 로그인 상태를 찾는 데 쓰게 되며, proxy session cookie는 proxy의 auth endpoint에만 제시된다.\n\n그래서 화면에 같은 사용자 이름이 보여도 만든 쪽과 쓰는 쪽이 다르면 다른 credential로 다룬다."], ["규칙 3 제목", "cookie가 token을 담고 있다고 쓰지 않는다"], ["규칙 3 본문", "애플리케이션 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 별도 store에 있어서 cookie 안에는 없다.\n\nproxy session cookie는 같은 모델이 아니다. 서버에 상태를 두지 않고 최소 정보를 cookie 자체에 담아 proxy가 검증하는 구성일 수 있다. 두 cookie를 같은 문장으로 설명하지 않는다.\n\ncookie를 token map의 직렬화라고 설명하게 되면 구현 설명이 틀리게 되고, 그 store를 어디에 둘지가 별도 문제라는 것도 함께 가려지게 된다."], ["규칙 4 제목", "브라우저에 없다는 말의 대상을 밝힌다"], ["규칙 4 본문", "애플리케이션이 쓰는 OAuth token이 없다는 뜻과 브라우저에 인증 상태가 없다는 뜻은 다르다. HttpOnly session cookie는 남아서 요청마다 붙게 되고 IdP 도메인의 SSO cookie도 따로 있을 수 있다.\n\n무엇이 없는지를 적지 않으면 브라우저에 인증 상태가 아예 없다는 뜻으로 읽힌다."], ["규칙 5 제목", "영구 저장소에 없는 것과 실행 중에 없는 것을 나눈다"], ["규칙 5 본문", "memory에만 두는 보관은 새로고침 뒤 남는 복사본을 없애 주지만 실행 중 script가 응답이나 지역 변수를 읽는 것까지 막지는 못한다.\n\n두 문장을 같은 증거로 쓰게 되면 XSS 위험이 줄었다는 잘못된 결론이 나오게 된다."], ["규칙 6 제목", "로그아웃 범위를 상태별로 적는다"], ["규칙 6 본문", "애플리케이션 상태를 지우는 것과 IdP session을 끝내는 것은 다르고, 이미 발급된 self-contained JWT는 만료 전까지 API에서 계속 통하게 된다.\n\n서버에 지울 session이 없는 구조라면 로그아웃이 그 token을 무효로 만들지 못한다. denylist나 introspection, revocation을 아는 구조를 두지 않았다면 남는 수단은 노출 시간을 줄이는 것, 즉 짧은 TTL이다. 어느 쪽을 골랐는지 문서에 적는다."], ["규칙 7 제목", "하나를 지웠다고 다른 하나가 사라졌다고 쓰지 않는다"], ["규칙 7 본문", "새로고침으로 memory의 token이 사라져도 IdP SSO는 남아 있어서 다시 로그인 버튼을 누르면 아이디 입력 없이 돌아오게 된다.\n\n애플리케이션 session을 지워도 authorized client가 남으면 로그인 상태가 복구될 수 있으니 지울 목록을 빠짐없이 적어 둔다."], ["적용 조건 1", "인증 상태를 표나 문서로 정리할 때"], ["적용 조건 2", "로그아웃과 만료 동작을 설계할 때"], ["적용 조건 3", "브라우저에 무엇이 남는지 설명할 때"], ["적용 조건 4", "여러 구조를 같은 항목으로 비교할 때"], ["예외 1", "한 요청 안에서 어느 상태를 말하는지 문맥으로 이미 분명하면 짧은 이름을 쓸 수 있다. 그때도 문서에서 처음 나올 때는 전체 이름을 적어 둔다."], ["예외 2", "IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다."], ["예시 1", "IdP SSO session : IdP 도메인의 cookie이고 애플리케이션 memory와 별개다"], ["예시 2", "access token : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다"], ["예시 3", "refresh token : 새 access token을 받는 장기 credential이다"], ["예시 4", "애플리케이션 session cookie : server-side 로그인 상태를 찾는 열쇠다"], ["예시 5", "proxy session cookie : proxy의 auth endpoint에 제시하는 최소 상태다"], ["예시 6", "CSRF token : cookie가 자동으로 붙는 상태 변경 요청의 의도를 확인한다"], ["예시 7", "identity header : edge가 확인한 사용자 정보의 투영이고 JWT가 아니다"], ["관계 1 이유", "memory-only 보관과 IdP SSO를 구분한 실례다."], ["관계 2 이유", "같은 요청 안에서 session cookie와 access token이 함께 움직인다."], ["관계 3 이유", "session cookie와 readable CSRF token, server-side token이 함께 있는 실례다."], ["관계 4 이유", "proxy session cookie와 identity 헤더가 JWT를 대신하는 실례다."]], "groups": {"규칙": 7, "적용 조건": 4, "예외": 2, "예시": 7}}];
const RAIL = 'aside[class*="studio-document-status"]';
const out = [];
for (const d of DOCS) {
const rec = { name: d.name, filled: [], skipped: 0, miss: [], rows: [] };
await page.goto('https://hyeonworks.com/studio/documents/' + d.id + '/edit');
try { await page.waitForSelector(RAIL, { timeout: 25000 }); }
catch { rec.error = 'AUTH? ' + page.url(); out.push(rec); return out; }
await page.waitForTimeout(400);
rec.version = await page.locator(RAIL + ' dd').first().innerText();
for (const [legend, want] of Object.entries(d.groups)) {
const fs = page.locator('xpath=//fieldset[./legend[normalize-space(.)="' + legend + '"]]').first();
let cur = await fs.locator('.studio-ordered-item').count();
const from = cur;
while (cur > want) {
await fs.locator('.studio-ordered-item').last().getByRole('button', { name: '삭제' }).click();
await page.waitForTimeout(60); cur--;
}
while (cur < want) {
await fs.getByRole('button', { name: legend + ' 추가' }).click();
await page.waitForTimeout(60); cur++;
}
if (from !== want) rec.rows.push(legend + ' ' + from + '→' + want);
}
for (const [label, wantv] of d.jobs) {
const loc = page.locator('xpath=//label[./span[normalize-space(.)="' + label + '"]]')
.locator('textarea, input').first();
const n = await loc.count().catch(() => 0);
if (n !== 1) { rec.miss.push(label); continue; }
if ((await loc.inputValue()) === wantv) { rec.skipped++; continue; }
await loc.fill(wantv);
rec.filled.push(label);
}
if (rec.filled.length === 0 && rec.rows.length === 0) { rec.saved = 'CLEAN'; out.push(rec); continue; }
await page.locator(RAIL).getByRole('button', { name: '저장' }).first().click();
try {
await page.waitForFunction(() => {
const el = document.querySelector('aside[class*="studio-document-status"]');
return el && /저장됨/.test(el.innerText);
}, null, { timeout: 30000 });
rec.saved = 'OK v' + (await page.locator(RAIL + ' dd').first().innerText());
} catch {
rec.saved = 'FAIL ' + (await page.locator(RAIL).innerText()).replace(/\s+/g, ' ').slice(0, 140);
}
out.push({ name: rec.name, version: rec.version, saved: rec.saved,
filled: rec.filled.length, rows: rec.rows, miss: rec.miss });
}
return out;
}