OAuth 1.0과 2.0, 그리고 인가 코드 흐름을 단계별로 따라가기
참고자료
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - Bearer Token Usage
- RFC 7636 - Proof Key for Code Exchange (PKCE)
- RFC 9700 - Best Current Practice for OAuth 2.0 Security
- oauth.net - OAuth 2.0
배경
소셜 로그인을 붙이면서 OAuth를 처음 봤다. 그런데 흐름도를 봐도 계속 헷갈렸다.
- 등장 인물이 넷인데 누가 누구인지 자꾸 섞인다. 우리 서비스는 어디에 해당하는가?
- 왜 액세스 토큰을 한 번에 주지 않고 인가 코드라는 것을 먼저 주는가? 단계가 하나 더 있는 이유가 있는가?
- OAuth는 인증인가 인가인가? 이름은 Authorization인데 로그인에 쓰고 있다.
- 1.0과 2.0은 무엇이 달라졌는가?
명세를 읽으면서 하나씩 확인했다.
1. OAuth가 무엇인가
1.1 풀어쓰면
Open Authorization의 줄임말이다. 내 정보를 갖고 있는 서비스에게, 다른 서비스가 그 정보에 접근할 수 있도록 권한을 위임하는 방식이다.
구체적인 상황으로 보면 이렇다. 어떤 앱이 내 구글 캘린더를 읽고 싶어 한다. 예전 방식은 그 앱에 내 구글 아이디와 비밀번호를 주는 것이었다.
이 방식의 문제가 넷이다.
- 그 앱이 내 비밀번호를 알게 된다
- 그 앱이 캘린더뿐 아니라 내 계정 전체를 할 수 있게 된다
- 권한을 회수하려면 비밀번호를 바꿔야 하고, 그러면 다른 앱들도 함께 끊긴다
- 그 앱이 뚫리면 내 비밀번호가 유출된다
OAuth는 비밀번호를 넘기지 않고, 필요한 범위만, 나중에 취소할 수 있게 권한을 주는 방법이다.
1.2 인증인가 인가인가
세 번째 질문을 먼저 처리한다. 이름이 Authorization(인가)인 이유가 있다.
OAuth는 인가 프로토콜이지 인증 프로토콜이 아니다. “이 앱이 이 자원에 접근해도 되는가”를 다루지, “이 사람이 누구인가”를 다루지 않는다.
그런데 실제로는 소셜 로그인에 쓰인다. 왜 그런가.
액세스 토큰으로 사용자 정보 API를 호출하면 그 사람이 누구인지 알 수 있기 때문이다. 그래서 인가 결과를 인증에 전용해서 쓰는 것이다.
이 전용에는 문제가 있다. 액세스 토큰은 “이 토큰을 가진 자에게 권한을 준다”는 의미일 뿐, “이 토큰이 지금 로그인하려는 그 사람의 것”이라는 보장이 없다. 다른 앱에서 발급된 토큰을 가져와도 사용자 정보 API는 응답한다.
그래서 인증까지 제대로 하려면 OpenID Connect(OIDC) 를 쓴다. OAuth 2.0 위에 인증 계층을 얹은 표준이고, ID 토큰이라는 별도 토큰으로 “누구인지”를 검증 가능한 형태로 전달한다.
정리하면 이렇다.
| 다루는 것 | 결과물 | |
|---|---|---|
| OAuth 2.0 | 인가 (무엇을 할 수 있는가) | 액세스 토큰 |
| OpenID Connect | 인증 (누구인가) | ID 토큰 (+ 액세스 토큰) |
2. 등장 인물 넷
첫 번째 질문이다. 명세가 정의하는 역할이 넷인데, 구체적인 예로 놓으면 안 헷갈린다.
우리 서비스에 “깃허브로 로그인” 버튼을 붙인다고 하자.
| 역할 | 명세상 정의 | 이 예에서는 |
|---|---|---|
| Resource Owner | 보호된 자원에 대한 접근을 허가하는 주체 | 사용자 본인 |
| Resource Server | 보호된 자원을 갖고 있고 토큰을 검증해 내주는 서버 | 깃허브 API 서버 |
| Authorization Server | 소유자의 동의를 확인하고 토큰을 발급하는 서버 | 깃허브 인가 서버 |
| Client | 자원에 접근하려는 애플리케이션 | 우리 서비스 |
가장 헷갈리는 것이 Client가 사용자의 브라우저가 아니라 우리 서비스라는 점이다. 여기서 “클라이언트”는 자원 서버 입장에서 본 클라이언트다.
flowchart LR
RO["Resource Owner<br/>사용자"]
C["Client<br/>우리 서비스"]
AS["Authorization Server<br/>깃허브 인가 서버"]
RS["Resource Server<br/>깃허브 API"]
RO -->|"동의"| AS
C -->|"토큰 요청"| AS
AS -->|"액세스 토큰"| C
C -->|"토큰으로 요청"| RS
Resource Server와 Authorization Server가 같은 회사여도 역할이 다르다. 하나는 토큰을 발급하고 하나는 토큰을 검증한다. 규모가 큰 서비스에서는 실제로 분리되어 있다.
3. 인가 코드 흐름
3.1 전체 흐름
가장 많이 쓰이는 방식이다. 명세의 4.1절에 정의되어 있다.
sequenceDiagram
participant U as 사용자 브라우저
participant C as 우리 서비스 (Client)
participant AS as 깃허브 인가 서버
participant RS as 깃허브 API
U->>C: 1. "깃허브로 로그인" 클릭
C-->>U: 2. 깃허브 인가 페이지로 리다이렉트<br/>(client_id, redirect_uri, scope, state)
U->>AS: 3. 인가 페이지 접속
AS-->>U: 4. 로그인 + 동의 화면
U->>AS: 5. 동의
AS-->>U: 6. redirect_uri로 리다이렉트<br/>(code, state)
U->>C: 7. 인가 코드 전달
Note over C,AS: 여기부터는 브라우저를 거치지 않는다
C->>AS: 8. 코드 + client_secret으로 토큰 요청
AS-->>C: 9. 액세스 토큰 (+ 리프레시 토큰)
C->>RS: 10. 액세스 토큰으로 사용자 정보 요청
RS-->>C: 11. 사용자 정보
C-->>U: 12. 우리 서비스 세션 발급
3.2 왜 코드를 먼저 주는가
두 번째 질문의 답이다. 6번에서 액세스 토큰을 바로 주면 안 되는가.
리다이렉트는 브라우저를 통해 일어나기 때문이다.
6번의 리다이렉트는 URL에 값을 담아 사용자 브라우저를 거쳐 전달된다. 그 URL은 브라우저 히스토리에 남고, 리퍼러 헤더로 새어 나갈 수 있고, 서버 접근 로그에 기록된다. 여기에 액세스 토큰을 담으면 그 토큰이 여러 곳에 흔적을 남긴다.
그래서 한 번만 쓸 수 있고 수명이 짧은 인가 코드를 대신 보낸다. 그리고 진짜 토큰은 8번에서 받는데, 이 요청은 브라우저를 거치지 않는 서버 대 서버 통신이다.
8번에 client_secret이 들어가는 것도 요점이다. 인가 코드가 중간에 새어 나가도, 그 코드를 토큰으로 바꾸려면 우리 서비스만 아는 비밀값이 필요하다. 코드 유출만으로는 토큰을 얻을 수 없다.
flowchart LR
subgraph BR["브라우저를 거침 (노출 위험)"]
A["인가 코드<br/>1회용, 수명 짧음"]
end
subgraph SV["서버 대 서버 (노출 없음)"]
B["액세스 토큰<br/>실제 권한"]
end
A -->|"client_secret과 함께 교환"| B
3.3 state 파라미터
2번 요청에 state를 넣고 6번 응답에서 같은 값이 오는지 확인한다.
CSRF를 막는 장치다. 공격자가 자기 계정의 인가 코드를 만들어놓고 피해자에게 그 콜백 URL을 열게 하면, 피해자의 우리 서비스 계정에 공격자의 깃허브 계정이 연결된다. state를 세션에 저장해두고 대조하면 이 공격이 막힌다.
state를 검증하지 않으면 이 공격이 그대로 성립한다. 넣기만 하고 확인을 안 하는 경우가 실제로 있다.
3.4 PKCE
client_secret을 안전하게 보관할 수 없는 클라이언트가 있다. 모바일 앱과 브라우저에서 도는 SPA다. 앱에 비밀값을 넣어두면 디컴파일로 꺼낼 수 있다.
이런 경우를 위한 것이 PKCE(Proof Key for Code Exchange) 다. RFC 7636에 정의되어 있고, 발음은 “픽시”다.
동작이 간단하다.
sequenceDiagram
participant C as 클라이언트
participant AS as 인가 서버
Note over C: 1. 무작위 문자열 code_verifier 생성
Note over C: 2. code_challenge = SHA256(code_verifier)
C->>AS: 3. 인가 요청 + code_challenge
Note over AS: code_challenge를 저장해둔다
AS-->>C: 4. 인가 코드
C->>AS: 5. 토큰 요청 + code_verifier (원본)
Note over AS: SHA256(code_verifier)가<br/>저장해둔 code_challenge와 같은가
AS-->>C: 6. 액세스 토큰
인가 코드를 가로챈 공격자는 code_verifier를 모른다. 3번에 실린 것은 해시값이라 원본을 되돌릴 수 없기 때문이다. client_secret이 하던 역할을 매 요청마다 새로 만드는 값이 대신한다.
지금은 PKCE를 공개 클라이언트뿐 아니라 모든 클라이언트에 적용하도록 권고한다. RFC 9700이 그 최신 권고를 담고 있다.
3.5 다른 방식들
명세에는 인가 코드 방식 말고도 몇 가지가 더 있다.
| 방식 | 설명 | 현재 권고 |
|---|---|---|
| Authorization Code | 위에서 본 것 | 기본 선택 |
| Client Credentials | 사용자 없이 서비스 간 인증 | 서버 간 통신에 적합 |
| Implicit | 리다이렉트로 토큰을 직접 받음 | 쓰지 않는다 |
| Resource Owner Password | 아이디와 비밀번호를 클라이언트에 직접 입력 | 쓰지 않는다 |
| Refresh Token | 만료된 액세스 토큰 갱신 | 함께 사용 |
Implicit 방식이 폐기된 이유가 3.2절 그대로다. 액세스 토큰이 리다이렉트 URL에 실려 브라우저를 거친다. PKCE가 나오면서 이 방식을 쓸 이유가 없어졌다.
Password 방식이 폐기된 이유는 더 명확하다. 사용자가 자기 비밀번호를 제3자 앱에 입력하게 만드는데, 그건 OAuth가 애초에 없애려던 것이다.
4. 토큰 다루기
4.1 Bearer 토큰
액세스 토큰은 보통 이렇게 실어 보낸다.
1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Bearer는 “소지자”라는 뜻이다. 이 토큰을 가진 사람이면 누구든 권한을 갖는다는 의미다. 소지자 자신을 확인하는 절차가 없다.
그래서 따라오는 요구사항이 있다. RFC 6750이 명시하는 것들이다.
- 반드시 TLS로 전송한다. 평문으로 나가면 그대로 탈취된다.
- URL 쿼리 파라미터에 담지 않는다. 로그와 히스토리에 남는다.
- 수명을 짧게 잡는다.
4.2 범위
scope 파라미터로 필요한 권한만 요청한다.
1
scope=read:user user:email
전체 권한을 요청하면 사용자가 동의 화면에서 놀라고 거부율이 올라간다. 그리고 토큰이 유출됐을 때 피해 범위가 그만큼 넓어진다.
필요한 것만 요청하고, 나중에 더 필요해지면 그때 추가로 요청하는 것이 원칙이다.
4.3 리프레시 토큰
액세스 토큰의 수명을 짧게 잡으면 사용자가 자주 다시 로그인해야 한다. 그걸 피하려고 리프레시 토큰을 함께 발급한다.
리프레시 토큰은 액세스 토큰보다 수명이 길고, 오직 새 액세스 토큰을 받는 데만 쓸 수 있다. 자원 접근에는 못 쓴다.
수명이 길다는 것은 유출됐을 때 위험도 크다는 뜻이다. 그래서 회전(rotation) 을 쓴다. 리프레시 토큰을 쓸 때마다 새 것을 발급하고 이전 것을 무효화한다. 무효화된 것이 다시 쓰이면 유출을 의심하고 그 계열의 토큰을 전부 끊는다.
5. OAuth 1.0과 2.0
네 번째 질문이다.
5.1 1.0이 하던 것
2007년에 나왔고, 트위터를 비롯한 여러 서비스가 도입했다.
특징은 보안을 프로토콜 자체가 책임졌다는 점이다.
- HTTPS에 의존하지 않고 자체 서명 방식을 썼다
- 요청마다 서명을 만들어 무결성을 증명했다
- 서명이 맞지 않으면 그 트랜잭션이 무효가 됐다
문제는 구현이 어려웠다는 것이다. 서명 대상 문자열을 만드는 규칙이 까다로웠고, 파라미터 정렬이나 인코딩을 조금만 틀려도 서명이 안 맞았다. 클라이언트 개발자에게 부담이 컸다.
5.2 2.0이 바꾼 것
전송 계층 보안을 TLS에 위임했다. 프로토콜 자체는 서명을 요구하지 않고, HTTPS를 쓰는 것을 전제로 삼는다.
그 결과 구현이 훨씬 단순해졌다. 대신 TLS 구성이 잘못되면 전체가 무너진다. 인증서 검증을 안 하거나 약한 프로토콜을 허용하면 중간자 공격에 그대로 노출된다.
두 방식의 맞바꿈을 정리하면 이렇다.
| OAuth 1.0 | OAuth 2.0 | |
|---|---|---|
| 전송 보안 | 프로토콜 자체 서명 | TLS에 위임 |
| 구현 난이도 | 높다 | 낮다 |
| 토큰 형태 | 서명된 요청 | Bearer 토큰 |
| 대상 | 웹 위주 | 웹, 모바일, SPA, 서버 간 |
| 역할 분리 | 없음 | 인가 서버와 자원 서버 분리 |
| 취약 지점 | 서명 구현 실수 | TLS 구성 실수, 토큰 유출 |
“2.0이 더 안전하다”고 말하기는 어렵다. 쓰기 쉬워진 대신 안전하게 만드는 책임이 구현하는 쪽으로 넘어왔다. 그래서 RFC 9700 같은 보안 모범 사례 문서가 계속 갱신된다.
6. 붙이면서 걸렸던 것들
6.1 리다이렉트 URI 정확 일치
인가 서버에 등록한 redirect_uri와 요청에 담은 값이 정확히 같아야 한다. 끝의 슬래시 하나, 포트 번호, 프로토콜이 다르면 거부된다.
이게 왜 엄격한가. 이 값을 느슨하게 검증하면 공격자가 자기 서버로 인가 코드를 받아갈 수 있기 때문이다. 부분 일치나 와일드카드를 허용하면 우회 경로가 생긴다.
환경마다 URI가 다르므로 개발, 스테이징, 운영을 전부 등록해야 한다. 이걸 빠뜨려서 배포 직후 로그인이 안 되는 경우가 흔하다.
6.2 인가 코드는 한 번만 쓸 수 있다
명세가 재사용을 금지한다. 같은 코드로 두 번 토큰을 요청하면 인가 서버는 그 코드로 발급된 토큰까지 함께 무효화하도록 권고한다.
개발 중에 이걸로 헤맬 수 있다. 콜백 URL을 브라우저에서 새로고침하면 같은 코드로 다시 요청이 가고, 두 번째부터 실패한다. 코드가 잘못된 것이 아니라 이미 쓴 코드를 다시 쓴 것이다.
6.3 상태를 서버에 둘 것인가
액세스 토큰을 받은 뒤 우리 서비스는 자체 세션이나 토큰을 발급한다. 여기서 결정할 것이 있다.
받아온 액세스 토큰을 저장할 것인가. 사용자 정보를 한 번만 읽고 끝이면 저장할 필요가 없다. 계속 그 서비스의 API를 부를 것이면 저장해야 하고, 그러면 그 토큰을 안전하게 보관하고 갱신하는 책임이 생긴다.
우리 서비스는 로그인 시점에 한 번만 사용자 정보를 읽었으므로 저장하지 않았다. 저장할 것이 줄면 지킬 것도 준다.
정리하며
처음 던진 질문들에 대한 답이다.
등장 인물 넷 중 우리 서비스는 어디인가. Client다. 사용자의 브라우저가 아니다. 여기서 클라이언트는 자원 서버 입장에서 본 클라이언트를 뜻한다.
왜 인가 코드를 먼저 주는가. 리다이렉트는 브라우저를 거치므로 URL에 담긴 값이 히스토리, 리퍼러, 로그에 남는다. 그래서 1회용에 수명이 짧은 코드만 그 경로로 보내고, 실제 토큰은 서버 대 서버로 client_secret과 함께 교환한다. 비밀값을 보관할 수 없는 클라이언트는 PKCE로 같은 효과를 낸다.
OAuth는 인증인가 인가인가. 인가다. 소셜 로그인은 인가 결과를 인증에 전용해서 쓰는 것이고, 인증까지 제대로 하려면 OpenID Connect를 쓴다.
1.0과 2.0의 차이. 1.0은 프로토콜 자체가 서명으로 보안을 책임졌고 그래서 구현이 어려웠다. 2.0은 그것을 TLS에 위임해서 쉬워졌지만, 안전하게 만드는 책임이 구현하는 쪽으로 넘어왔다.
정리하고 나서 남은 것은 “이 값이 브라우저를 거치는가 아닌가” 라는 질문이었다. 인가 코드와 액세스 토큰을 나눈 이유, state가 필요한 이유, PKCE가 하는 일이 전부 이 하나에서 나온다.