웹 연결을 고칠 때 가장 쉬운 우회는 허용 주소를 크게 늘리는 것이다. 나는 그 대신 무엇이 정확히 같아야 하는지 작은 코드로 분리했다. 이 모듈은 독립 합성 자료로 만든 비교 실습이며, 계정을 연결하거나 인증 코드를 발급하지 않는다. 전체 인증 서버를 줄여 놓은 예제라고 생각하면 적용 범위를 잘못 잡게 된다.

주소를 비교하기 전에 설정의 출처를 정한다

WebReturnPolicy는 신뢰할 설정의 클라이언트 표시값, 리소스 표시값, 반환 주소 목록을 받는다. 현재 요청에서 받은 주소를 그대로 목록에 넣고 다시 비교하면 어떤 주소든 통과시킬 수 있다. 그래서 요청을 받은 뒤 정책을 자동으로 넓히는 기능을 넣지 않았다. 먼저 검토한 설정을 만들고 그 설정에 요청을 대조하는 순서가 이 코드의 출발점이다.

설정 객체는 값을 튜플로 복사하고 고정한다. 호출자가 원래 리스트에 항목을 추가해도 이미 만들어진 정책은 늘어나지 않는다. 이것은 설정 변경 실수를 줄이기 위한 장치다. 같은 프로세스의 악성 코드까지 막는 보안 경계라고 주장하지 않는다. 설정 파일의 접근 제어와 변경 승인도 실제 서비스에서 별도로 다뤄야 한다.

같은 주소여도 다른 클라이언트라면 멈춘다

compare는 클라이언트, 리소스, 반환 주소를 따로 계산하고 세 조건이 모두 맞을 때만 contract_match를 참으로 만든다. 반환 주소가 같다는 이유로 다른 클라이언트의 요청까지 허용하면 등록의 구분이 사라진다. 같은 방식으로 리소스 표시값이 다른 경우도 전체 비교는 실패한다. 이 표시값들은 합성 식별자이며 비밀번호나 인증 증거가 아니다.

결과에는 각각의 일치 여부가 들어 있어 실습에서 어느 조건이 달랐는지 읽을 수 있다. 그러나 요청의 원문 주소나 토큰은 결과에 복사하지 않는다. 실제 서비스에서 이런 상세 실패 이유를 누구에게 보여줄지는 별도 판단이 필요하다. 이 모듈의 결과 사전은 로컬 설명을 위한 자료이며 외부 오류 응답 형식까지 정하지 않는다.

입력 주소를 고쳐서 통과시키지 않는다

요청의 앞뒤 공백을 지우거나 주소를 디코딩해서 비교하지 않는다. 설정과 다른 경로, 끝의 슬래시, 명시한 기본 포트, 쿼리와 조각이 붙은 값은 서로 다른 문자열로 남는다. 등록한 값과 요청한 값이 다르면 설정을 다시 검토해야지, 검사 함수가 알아서 같은 주소로 만들어 주면 안 된다는 선택이다.

예제의 설정 범위는 의도적으로 좁다. HTTPS 웹 주소 중 포트·사용자 정보·유니코드·쿼리·조각·퍼센트 인코딩·점 경로가 없는 형태만 받는다. 이것들이 모든 OAuth 구현에서 금지된다는 뜻은 아니다. 네이티브 앱의 가변 루프백 포트도 여기서는 구현하지 않는다. 정확 비교를 설명하려고 고른 부분집합과 실제 제품 전체의 지원 범위를 구별해야 한다.

코드와 구현 살펴보기

독립 합성 전체 구현

"""Pure, deliberately narrow comparison contract; not an OAuth server."""
from dataclasses import dataclass
from urllib.parse import urlsplit


def _plain(value, limit=2048):
    return (isinstance(value, str) and 0 < len(value) <= limit
            and value.isascii() and all(32 < ord(c) < 127 for c in value))


def _configured_web_uri(value):
    if not _plain(value):
        return False
    try:
        p = urlsplit(value)
        return (p.scheme == 'https' and p.hostname is not None
                and p.netloc == p.hostname and p.path.startswith('/')
                and not p.query and not p.fragment
                and '?' not in value and '#' not in value
                and '%' not in value and '\\' not in value
                and all(part not in ('.', '..') for part in p.path.split('/')))
    except ValueError:
        return False


@dataclass(frozen=True)
class WebReturnPolicy:
    """Load only from trusted configuration, never from the current request.

    This example's configuration subset excludes query strings, fragments,
    ports, user information, Unicode and encoded path characters. This is
    narrower than the full set of valid OAuth redirect URI configurations.
    """
    client_id: str
    resource_id: str
    redirects: tuple[str, ...]

    def __post_init__(self):
        if not _plain(self.client_id, 128) or not _plain(self.resource_id, 128):
            raise ValueError('Invalid trusted binding label')
        if not isinstance(self.redirects, (tuple, list)):
            raise ValueError('Expected a configured sequence')
        values = tuple(self.redirects)
        if not 1 <= len(values) <= 4:
            raise ValueError('Expected one to four distinct configured URIs')
        if not all(_configured_web_uri(v) for v in values):
            raise ValueError('Configured URI outside this example subset')
        if len(set(values)) != len(values):
            raise ValueError('Expected distinct configured URIs')
        object.__setattr__(self, 'redirects', values)

    def compare(self, *, client_id, resource_id, redirect_uri):
        # No parsing, stripping, decoding or canonicalizing request values.
        client = isinstance(client_id, str) and client_id == self.client_id
        resource = isinstance(resource_id, str) and resource_id == self.resource_id
        callback = _plain(redirect_uri) and redirect_uri in self.redirects
        return {
            'contract_match': bool(client and resource and callback),
            'client_match': client,
            'resource_match': resource,
            'callback_match': callback,
            'consent_verified': False,
            'authenticated': False,
            'token_issued': False,
            'scope': 'Synthetic configured web-return comparison only',
        }

통과 시험보다 경계를 바꾸는 시험이 더 중요했다

정확한 값이 통과하는 시험 하나만으로는 이 선택을 확인할 수 없다. 같은 호스트의 다른 경로, 호스트 이름 뒤에 다른 도메인을 붙인 값, 사용자 정보를 끼운 값, 인코딩한 경로와 점 경로를 각각 거절하는지 확인했다. 빈 값과 문자열이 아닌 입력도 실패해야 한다. 잘못된 설정은 요청 처리 전에 생성 단계에서 거절한다.

또한 리스트를 복사한 뒤 바깥에서 수정하는 경우, 두 번째 주소를 별도로 등록한 경우, 같은 주소를 다른 클라이언트나 리소스로 요청하는 경우를 확인했다. 시험은 14개 메서드와 그 안의 변형 입력으로 구성했다. 성공 사례 수를 늘리기보다 허용 범위가 실수로 넓어지는 지점을 찾는 데 목적을 뒀다.

기록 선택기는 계산기가 아니라 결과를 읽는 창이다

아래 선택기는 Python으로 실제 만든 다섯 합성 실행 기록을 읽는다. 정확 일치, 같은 호스트의 다른 경로, 명시한 기본 포트, 다른 클라이언트, 다른 리소스를 차례로 비교할 수 있다. 선택할 때 웹에서 인증 요청을 보내거나 방문자의 파일을 읽지 않는다. 바뀌는 것은 저장된 결과의 표시다.

인라인 JavaScript는 초기 표시와 다섯 선택 결과를 DOM 대역에서 확인한다. 이 검사는 기록이 올바르게 연결되는지 보는 범위다. 실제 렌더링, 모바일 배치, 키보드 조작이나 계정 연결을 검수했다는 뜻으로 확대하지 않는다. 실행 기록의 성격을 화면 가까이에 적어 둔 이유도 독자가 이를 실제 로그인 체험으로 오해하지 않게 하기 위해서다.

합성 로컬 기록의 읽기 뷰어

일치 다음에 남아 있는 책임

결과의 consent_verified, authenticated, token_issued는 일치 여부와 관계없이 거짓이다. 사람의 동의, 세션과 state의 연결, PKCE, 발급자 확인, 만료, 범위, 코드 일회성, 재사용 차단, 토큰 보관은 이 함수가 하지 않는다. 실제 서비스는 검증된 인증 구현의 해당 절차를 유지해야 한다. 이 모듈로 기존 인증 서버를 대체하면 안 된다.

ZIP에는 전체 코드, 시험, 기록 생성기, 결과, 사용법과 MIT 라이선스를 넣었다. 압축을 푼 별도 폴더에서도 같은 시험을 다시 실행한다. 먼저 합성 값으로 다른 경계가 어디인지 익힌 다음, 실제 제품에 필요한 등록 방식과 책임을 따로 설계하는 것이 내가 권하는 사용 순서다.

코드·시험·실행 기록 ZIP 받기
Python 표준 라이브러리 · 합성 자료 · 수정과 재사용 가능