TL;DR
- **OAuth 2.0**은 **인가(authorization)** 프로토콜이지 인증(authentication)이 아니다. "이 앱이 내 Gmail을 읽게 허락한다"는 위임이지 "내가 누구인지 증명한다"가 아니다.
- **OpenID Connect (OIDC)**는 OAuth 2.0 위에 **인증 레이어**를 추가. ID Token(JWT)을 발급해 "사용자가 누구인지"를 표현.
- **4대 flow**: Authorization Code (+ PKCE), Client Credentials, Device Code, Refresh Token. **Implicit와 Password는 OAuth 2.1에서 폐기**.
- **PKCE**: 초기에는 모바일 앱의 공개 client를 위한 확장이었지만, **OAuth 2.1에서 모든 client에 필수**. 인증 코드 가로채기 공격 방어의 핵심.
- **JWT (JSON Web Token)**: Base64URL 인코딩된 `header.payload.signature`. 자체 포함(self-contained) 토큰. 빠르지만 취소 어려움.
- **OIDC Discovery**: `/.well-known/openid-configuration` 엔드포인트가 모든 URL/키/알고리즘을 광고. 클라이언트가 자동 설정.
- **JWKS (JSON Web Key Set)**: 서명 공개 키를 JSON으로 배포. 키 롤오버 가능.
- **Token binding**: 토큰을 특정 client/TLS 연결에 묶기. **DPoP**(proof-of-possession), **mTLS binding**.
- **FAPI**: 금융급 보안 프로파일. Read/Write 두 단계, PAR, JARM 등 추가 보호.
- **실전 제공자**: Auth0, Okta, Keycloak(오픈소스), AWS Cognito, Azure AD, Google, GitHub, Apple Sign-In.
1. OAuth의 탄생
1.1 암흑 시대 (2000년대 초)
웹 앱이 Gmail을 읽고 싶다면? 2006년 방식:
앱: "당신의 Gmail 비밀번호를 알려주세요."
사용자: "..."
앱: Gmail에 IMAP 로그인 → 받은 편지함 읽음.
**말도 안 된다**. 문제:
- 앱이 **비밀번호를 영구 저장**. 해킹되면 모든 것이 털림.
- **권한 분리 없음**: 앱이 Gmail의 모든 것(삭제 포함)을 할 수 있음.
- **취소 어려움**: 권한을 돌려주려면 비밀번호 변경.
- **2FA 불가**: 2차 인증이 앱 자동화를 방해.
1.2 OAuth 1.0 (2007)
Twitter, Ma.gnolia, Flickr 등이 개발한 **권한 위임 프로토콜**. 핵심 아이디어:
- **Resource Owner**: 사용자.
- **Client**: 앱.
- **Resource Server**: API (예: Twitter API).
- **Authorization Server**: 권한 승인 서버.
흐름:
1. 사용자가 앱에서 "Twitter 연결" 클릭.
2. Twitter가 로그인 화면 표시.
3. 사용자가 승인.
4. Twitter가 앱에게 **토큰** 발급.
5. 앱은 비밀번호 대신 **토큰으로** API 호출.
토큰은 취소 가능, 스코프 제한 가능, 앱마다 별도. 비밀번호 공유보다 훨씬 안전.
그러나 OAuth 1.0은 **복잡했다**. 요청마다 HMAC 서명 필요, 타임스탬프, nonce, 매우 많은 파라미터. 개발자들이 "너무 어렵다"고 불평.
1.3 OAuth 2.0 (2012)
RFC 6749로 표준화. 주요 변경:
- **단순화**: 서명 대신 TLS 사용. 토큰은 bearer token (소지자가 사용).
- **프레임워크**: 구체적인 흐름이 여러 개, 용도별로 다름.
- **확장성**: extension point 많음 → 후에 여러 RFC가 추가.
단순화의 대가: **보안 책임이 구현자에게 전가**. OAuth 2.0 자체는 안전하게 "사용하면" 안전하지만 **잘못 사용하기 쉽다**. 초기에 보안 사고 많았다.
1.4 OIDC (2014)
OAuth 2.0은 **인가**(authorization) 프로토콜이지만 사람들이 **인증**(authentication)에 썼다. "Facebook으로 로그인" 같은 기능.
문제: OAuth로 access token을 받으면 "이 사용자가 진짜 Facebook에 로그인했다"는 **증명이 아니다**. 토큰은 그냥 권한을 의미할 뿐.
**OpenID Connect**가 이 간극을 메꿨다. OAuth 2.0 위에 **ID Token**을 추가:
- ID Token = 사용자 신원 정보 + 서명된 JWT.
- "user@example.com이 시각 T에 Auth Server에 로그인했다"를 증명.
- 클라이언트가 서명 검증 → 신원 확인.
1.5 OAuth 2.1 (초안, 2020+)
OAuth 2.0의 10년 운영 경험을 반영한 업데이트:
- **Implicit flow 제거**: 보안상 문제.
- **Password grant 제거**: 비밀번호를 client가 만진다는 자체가 반OAuth.
- **PKCE 필수**: 모든 flow에서 required.
- **Redirect URI exact match**: wildcard 금지.
- **Refresh token rotation**: 매 사용마다 교체.
아직 RFC로 확정되진 않았지만 많은 공급자가 이미 적용.
2. OAuth 2.0의 기본 개념
2.1 4대 역할
**1. Resource Owner (RO)**: 자원을 소유한 사용자. 예: Gmail 사용자.
**2. Client**: 자원에 접근하려는 앱. 예: "Todo 앱이 내 Google Calendar 확인".
**3. Resource Server (RS)**: API 서버. 예: `calendar.google.com/api`.
**4. Authorization Server (AS)**: 토큰을 발급하는 서버. 예: `accounts.google.com`.
참고: RS와 AS는 같은 조직일 수도, 다를 수도 있다. Google은 같이 운영, Auth0는 AS만 제공.
2.2 Client 종류
**Confidential Client**: 비밀을 안전하게 보관 가능. 예: 서버 사이드 웹 앱. Client Secret 사용 가능.
**Public Client**: 비밀을 보관 못함. 예: 모바일 앱, SPA, CLI 도구. Secret 없음 → PKCE 등으로 보완.
과거에는 confidential/public 구분이 중요했지만, **OAuth 2.1부터는 거의 모든 경우 PKCE 사용**.
2.3 토큰 종류
**Access Token**: 리소스 서버에 접근할 때 사용. 보통 짧은 수명(5-60분).
**Refresh Token**: Access Token 재발급에 사용. 긴 수명(수일 ~ 수개월). AS에만 제출.
**ID Token** (OIDC만): 사용자 신원 정보. JWT. 클라이언트가 직접 파싱/검증.
2.4 Scope
권한 범위. AS에게 "이 토큰으로 뭘 할 수 있는가"를 선언.
scope=read:email write:calendar profile openid
- `read:email`: 이메일 읽기.
- `write:calendar`: 캘린더 쓰기.
- `profile`: 사용자 프로필.
- `openid`: **OIDC 사용**을 의미. 이 scope 있으면 ID Token도 발급.
Scope는 AS가 정의. 표준은 없다(OIDC의 `openid`, `profile`, `email` 정도만 표준).
3. Authorization Code Flow
가장 중요한 flow. 웹 앱의 표준 로그인 흐름.
3.1 개요
User ←→ Client ←→ Authorization Server ←→ Resource Server
1. User: "앱으로 로그인해줘"
2. Client: User를 AS로 리다이렉트
3. AS: 로그인 화면 표시
4. User: 로그인 + 권한 승인
5. AS: Client에게 authorization code 전달 (redirect)
6. Client: code를 token으로 교환 (서버 간 직접 통신)
7. Client: access token으로 RS 호출
3.2 상세 흐름
**Step 1: Authorization Request**
Client가 user를 AS로 리다이렉트:
GET /authorize?
response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=xyz789 ← CSRF 방어
&code_challenge=<PKCE>
&code_challenge_method=S256
**Step 2: User 승인**
AS가 로그인 화면 + 권한 요청 화면 표시. 사용자가 "허용" 클릭.
**Step 3: Authorization Response**
AS가 사용자를 redirect_uri로 보냄:
https://app.example.com/callback?
code=AUTH_CODE_XYZ
&state=xyz789
Client는 **state를 검증**한다. 보낸 값과 받은 값이 일치하지 않으면 CSRF 공격 의심.
**Step 4: Token Request**
Client(서버 사이드)가 AS에 직접 요청:
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE_XYZ
&redirect_uri=https://app.example.com/callback
&client_id=abc123
&client_secret=secret456 ← confidential client만
&code_verifier=<PKCE> ← PKCE의 원본 값
**Step 5: Token Response**
AS가 토큰 발급:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def456...",
"id_token": "eyJhbGciOiJSUzI1NiIs...", ← OIDC만
"scope": "openid profile email"
}
**Step 6: API 호출**
GET /api/userinfo
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
3.3 왜 이렇게 복잡한가
두 가지 주요 이유:
**1. Front-channel / Back-channel 분리**:
- Front-channel: 브라우저를 통함. 사용자 개입 가능.
- Back-channel: Client ↔ AS 직접. 사용자 안 봄.
Authorization code는 front-channel로 전달(리다이렉트). Token은 back-channel로만 전달(직접 HTTP). Token이 브라우저에 노출되지 않음.
**2. User 상호작용 분리**:
- 로그인과 동의는 **사용자가 보는 화면** (AS).
- Token 사용은 **서버가 하는 일** (Client).
이 분리로 Client는 사용자의 비밀번호를 **영원히** 알 필요 없다.
4. PKCE — 현대 OAuth의 필수
4.1 문제
모바일 앱이나 SPA는 **client secret을 안전하게 저장할 수 없다**. 앱 바이너리를 디컴파일하면 secret이 노출됨.
그래서 과거에는 "public client는 secret 없이 authorization code를 사용"했다. 하지만 취약점이 있다:
**공격 시나리오**:
1. 사용자가 합법적인 앱에서 로그인 시작.
2. 악성 앱이 같은 **custom URL scheme** (예: `myapp://`)을 등록.
3. AS가 authorization code를 redirect로 반환 → 악성 앱이 가로챔.
4. 악성 앱이 code를 token으로 교환 → 사용자 계정 탈취.
문제: code를 token으로 교환할 때 **client가 누구인지 증명할 방법이 없다** (secret 없으므로).
4.2 PKCE의 해결
**PKCE** (Proof Key for Code Exchange, RFC 7636). 발음: "픽시(pixy)".
아이디어: client가 **임의의 비밀 값**을 만들어, (1) 해시를 미리 AS에 전달하고, (2) 토큰 요청 시 원본을 증명.
4.3 작동 방식
**Step 1**: Client가 `code_verifier` 생성 (43-128자 랜덤 문자열).
code_verifier = "M25iVXpKU3puUjFaYWg3T1NDTDQtcW1rOUY5ZXhFUnVqQkN4b2YwNGp..."
**Step 2**: `code_challenge` 계산.
code_challenge = BASE64URL(SHA256(code_verifier))
**Step 3**: Authorization request에 `code_challenge` 포함:
GET /authorize?
...
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
**Step 4**: AS가 code를 발급하면서 `code_challenge`를 **기억**.
**Step 5**: Token request에 원본 `code_verifier` 포함:
POST /token
...
&code_verifier=M25iVXpKU3puUjFaYWg3T1NDTDQtcW1rOUY5ZXhFUnVqQkN4b2YwNGp...
**Step 6**: AS가 검증:
SHA256(code_verifier) == code_challenge?
일치하면 token 발급, 아니면 거부.
4.4 공격 방어
악성 앱이 code를 가로챘다 해도 `code_verifier`를 모른다 (원본은 합법 앱의 메모리에만). Token 요청이 실패한다.
4.5 Plain vs S256
PKCE는 두 가지 challenge method 지원:
- **plain**: `code_challenge = code_verifier` (해시 없음).
- **S256**: `code_challenge = SHA256(code_verifier)`.
**plain은 사용하지 말 것**. 해시의 일방향성이 없으면 중간에서 `code_challenge`를 역으로 구해낼 수 있다.
4.6 PKCE는 모든 client에 필수
**OAuth 2.1**: 모든 client(confidential 포함)가 PKCE 사용. 이유:
- Defense in depth. Secret이 누출돼도 PKCE가 추가 방어선.
- 단순성: "client 종류에 따라 다른 보안"보다 "모두 같은 방식".
5. JWT — JSON Web Token
5.1 구조
JWT는 3부분으로 구성:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└─── Header ────────────────────────┘└──── Payload ────────────────────────────────────────┘└── Signature ──┘
각각 Base64URL 인코딩된 JSON.
**Header**:
{
"alg": "HS256",
"typ": "JWT",
"kid": "my-key-id"
}
- `alg`: 서명 알고리즘.
- `typ`: 타입.
- `kid`: Key ID (키 롤오버 시 어떤 키로 서명됐는지).
**Payload** (Claims):
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1700000000,
"exp": 1700003600,
"iss": "https://example.com",
"aud": "my-api"
}
표준 claim:
- `sub`: Subject (사용자 ID).
- `iss`: Issuer (토큰 발급자).
- `aud`: Audience (의도된 수신자).
- `exp`: Expiration.
- `iat`: Issued At.
- `nbf`: Not Before.
- `jti`: JWT ID (고유).
**Signature**:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
5.2 서명 알고리즘
- **HS256**: HMAC with SHA-256. 대칭 키 (공유 비밀).
- **RS256**: RSA-SHA256. 비대칭 (공개 키/개인 키).
- **ES256**: ECDSA P-256. 비대칭, 더 작은 서명.
- **EdDSA**: Ed25519. 가장 빠르고 안전.
- **none**: 서명 없음. **절대 프로덕션에서 사용 금지**.
HS256은 서명자와 검증자가 같은 비밀을 가져야 한다. 보통 OIDC에서는 **비대칭(RS256/ES256)**을 쓴다 — AS가 서명, client/RS가 공개 키로 검증.
5.3 "alg: none" 취약점
초기 JWT 라이브러리들이 다음 공격에 취약했다:
{"alg":"none"}.{"admin":true}.
서명 없는 JWT. 라이브러리가 `alg` 검증 없이 받아들이면 누구나 위조 가능. **절대 `alg: none` 허용하지 말 것**.
5.4 Key Confusion
다른 취약점: HS256과 RS256을 혼동.
Server가 RS256으로 검증하리라 예상하고 공개 키를 가짐. 공격자가 `alg: HS256`으로 바꾸고 **공개 키를 비밀로** 써서 HMAC 서명.
라이브러리가 `alg`를 신뢰하면 공개 키로 HMAC 검증을 시도 → 성공 → 위조 토큰 통과.
방어: 검증 시 **예상하는 알고리즘을 하드코딩**. `alg` 값을 신뢰하지 말 것.
5.5 JWT의 장단점
**장점**:
- Self-contained. DB 조회 없이 검증.
- Stateless. 서버가 세션 저장 불필요.
- 표준화.
**단점**:
- **취소 어려움**. 만료 전까진 유효. 긴급 취소는 blacklist 필요 (stateful).
- **크기**: 보통 수백~수 KB. 매 요청 전송.
- **민감 정보 노출**: 서명만 되지 암호화 아님. Payload는 누구나 볼 수 있다.
실무 권장: **짧은 수명 access token (5-15분)** + refresh token. 이러면 취소 지연을 허용 가능하게 제한.
6. OIDC — 인증 레이어
6.1 OAuth와의 차이
OAuth는 **인가** (authorization). OIDC는 **인증** (authentication).
OAuth만 썼을 때 문제:
- Client가 access token을 받았어도 "누가 로그인했는지" 모른다.
- `/userinfo` 같은 엔드포인트를 별도 호출해야 함.
- 토큰 수명 관리 복잡.
OIDC의 해결: **ID Token**.
6.2 ID Token vs Access Token
| 항목 | ID Token | Access Token |
|------|---------|-------------|
| 목적 | 사용자 신원 증명 | API 접근 |
| 수신자 | Client | Resource Server |
| 형식 | JWT (필수) | JWT 또는 opaque |
| 누가 검증 | Client | Resource Server |
| 어디 전달 | Client 메모리 (HTTP 요청에 전달 안 함) | Authorization 헤더 |
**중요**: ID Token을 API 호출에 사용하지 말 것. Access token을 ID 확인에 사용하지 말 것.
6.3 ID Token 예제
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"azp": "1234.apps.googleusercontent.com",
"aud": "1234.apps.googleusercontent.com",
"iat": 1700000000,
"exp": 1700003600,
"email": "user@example.com",
"email_verified": true,
"name": "John Doe",
"picture": "https://...",
"locale": "en"
}
주요 claim:
- `iss`: Issuer URL.
- `sub`: Subject (사용자의 고유 ID. 이 값은 issuer 내에서 유일).
- `aud`: Audience. Client ID.
- `azp`: Authorized Party (Multi-audience일 때).
- `nonce`: CSRF 방어 (authorization request에서 전달).
6.4 Nonce
ID token 재생 공격을 방지:
**Step 1**: Client가 authorization request에 nonce 추가.
?nonce=random_string_123
**Step 2**: AS가 ID Token의 `nonce` claim에 같은 값 포함.
**Step 3**: Client가 ID Token 검증 시 nonce 일치 확인.
공격자가 옛 ID token을 재사용해도 nonce가 다르면 거부.
6.5 UserInfo Endpoint
OIDC 표준 엔드포인트. Access token을 제시하면 사용자 프로필을 반환:
GET /userinfo
Authorization: Bearer <access_token>
Response:
{
"sub": "110169484474386276334",
"name": "John Doe",
"email": "user@example.com",
...
}
ID Token에 없는 최신 정보가 필요할 때 사용.
7. OIDC Discovery와 JWKS
7.1 OIDC Discovery
Client가 AS의 모든 설정을 **자동으로** 알 수 있는 방법:
GET https://accounts.google.com/.well-known/openid-configuration
응답:
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": ["code", "token", "id_token", "..."],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
...
}
클라이언트가 이걸 읽으면 **수동 설정 필요 없음**. 단 `issuer` 하나만 알면 모든 것 자동.
7.2 JWKS (JSON Web Key Set)
서명 공개 키를 공개하는 표준 형식:
{
"keys": [
{
"kid": "key-id-1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "modulus base64url",
"e": "AQAB"
},
{
"kid": "key-id-2",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "another modulus",
"e": "AQAB"
}
]
}
검증자:
1. JWT 헤더에서 `kid` 읽기.
2. JWKS에서 해당 `kid`의 키 찾기.
3. 그 키로 서명 검증.
7.3 Key Rotation
JWKS의 중요한 기능. AS가 키를 주기적으로 교체:
Day 1: keys = [A]
Day 30: keys = [A, B] # B 추가, A로 서명된 기존 토큰도 검증 가능
Day 60: keys = [B] # A 제거, 모든 새 토큰은 B
Client는 JWKS를 **주기적으로 갱신**해야 한다. `Cache-Control: max-age=3600` 같은 힌트 참고.
7.4 JWKS 캐시 전략
매 JWT 검증마다 JWKS를 가져오면 느리고 AS에 부하. 캐시:
1. 초기 JWKS를 로드하고 메모리에 캐시.
2. 만료(예: 1시간) 후 재로드.
3. JWT의 kid가 캐시에 없으면 즉시 재로드 (key rotation 대응).
`node-jwks-rsa`, `Okta JWT Verifier` 같은 라이브러리가 이 로직을 제공.
8. Client Credentials Flow
서버 간 통신용. 사용자가 없는 경우.
8.1 용도
- 마이크로서비스 간 호출.
- 배치 작업.
- API 키 대체.
사용자 개입 없음. Client가 **자기 자신을 증명**하고 토큰 받음.
8.2 흐름
POST /token
Authorization: Basic <client_id:client_secret base64>
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=read:users
응답:
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:users"
}
ID token 없음 (사용자가 없으므로).
8.3 주의사항
- **Client secret 관리 필수**. HashiCorp Vault, AWS Secrets Manager 등.
- **mTLS** 사용 권장 (secret 대신).
- **짧은 수명** (1시간 이하).
9. Device Code Flow
TV, 게임 콘솔, CLI 도구 등 **키보드 입력이 어려운 기기**용.
9.1 문제
스마트 TV에서 Netflix 로그인을 어떻게 할까? 비밀번호 입력이 불편. 스마트폰에서 로그인하고 TV에 권한을 넘겨주는 게 좋다.
9.2 흐름
**Step 1**: 기기(TV)가 AS에 요청:
POST /device/code
client_id=abc123
scope=openid profile
응답:
{
"device_code": "SOME_LONG_CODE",
"user_code": "WDJB-MJHT",
"verification_uri": "https://example.com/device",
"expires_in": 1800,
"interval": 5
}
**Step 2**: TV가 사용자에게 표시:
Go to https://example.com/device
Enter code: WDJB-MJHT
**Step 3**: 사용자가 스마트폰에서 URL 방문, 코드 입력, 로그인.
**Step 4**: TV가 동시에 **polling**:
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=SOME_LONG_CODE
client_id=abc123
아직 사용자가 완료 안 했으면:
{"error": "authorization_pending"}
완료되면:
{
"access_token": "...",
"refresh_token": "..."
}
9.3 Security
- **User code**는 **짧고 기억 쉬운** (`WDJB-MJHT`). 대소문자 없음.
- **짧은 수명** (수 분).
- **rate limiting**: 너무 자주 polling하면 `slow_down` 에러.
Google TV, Xbox, PlayStation, GitHub CLI, `gcloud auth login`이 이 방식.
10. Refresh Token Flow
Access token 만료 시 재발급.
10.1 흐름
POST /token
grant_type=refresh_token
&refresh_token=OLD_REFRESH
&client_id=abc123
&client_secret=secret456 (confidential only)
응답:
{
"access_token": "NEW_ACCESS",
"refresh_token": "NEW_REFRESH", ← rotation 시
"expires_in": 3600
}
10.2 Rotation
**OAuth 2.1 권장**: refresh token을 **매번 교체**한다.
- 장점: 탈취된 refresh token이 한 번만 사용됨. 이후 사용 시 AS가 "이미 사용됨"을 감지 → 세션 무효화.
- 단점: client가 race condition 처리 필요 (동시에 refresh 요청 시).
10.3 Refresh Token 보관
- **웹 앱**: HTTPOnly, Secure 쿠키.
- **모바일 앱**: OS 키체인 (iOS Keychain, Android Keystore).
- **SPA**: 보관 불가. PKCE + 짧은 access token만 권장.
- **서버 간**: 보통 refresh token 없이 매번 client_credentials.
11. 웹 앱의 실전 보안
11.1 Redirect URI 검증
AS는 **등록된 redirect_uri와 정확히 일치**하는 URL로만 리다이렉트해야 한다.
잘못된 설정:
Registered: https://app.example.com/callback
Received: https://app.example.com/callback/?a=b ← 거부해야
Registered: https://*.example.com/callback ← wildcard 금지 (OAuth 2.1)
11.2 State Parameter
CSRF 방어. 랜덤 값을 인증 요청에 포함, 응답에서 일치 확인:
// 요청 전
const state = generateRandomString(32);
sessionStorage.setItem('oauth_state', state);
window.location = `${auth_url}&state=${state}`;
// 응답 받은 후
const received_state = url.searchParams.get('state');
const expected_state = sessionStorage.getItem('oauth_state');
if (received_state !== expected_state) {
throw new Error('CSRF attempt!');
}
State 없으면 공격자가 자기 authorization code를 희생자에게 주입할 수 있다.
11.3 Token Storage
**SPA에서 access token 저장**:
- **LocalStorage**: XSS 취약.
- **Memory only**: 페이지 새로고침 시 사라짐.
- **HTTPOnly Cookie**: JavaScript 접근 불가, CSRF 방어는 별도.
현실: **완벽한 방법 없음**. 최선은 **짧은 수명의 access token + 메모리 저장** 또는 **Backend-for-Frontend** 패턴 (서버가 토큰 보관, 브라우저는 세션 쿠키만).
11.4 Implicit Flow는 왜 죽었나
OAuth 2.0 원래 "public client용"으로 **Implicit flow**를 정의했다. 토큰을 URL fragment로 직접 반환:
https://app.example.com/callback#access_token=XYZ&expires_in=3600
문제:
- Access token이 **브라우저 히스토리**에 남음.
- Fragment가 **referrer** 통해 유출 가능.
- Refresh token 없음.
OAuth 2.1에서 **폐기**. Authorization Code + PKCE가 모든 client의 표준.
11.5 Open Redirect
`redirect_uri`가 엄격히 검증되지 않으면:
?redirect_uri=https://evil.com
사용자가 AS에 로그인 후 악성 사이트로 리다이렉트. Phishing의 발판.
방어: 허용된 redirect_uri 목록을 미리 등록, exact match.
12. Token Binding: DPoP와 mTLS
Bearer token의 문제: **토큰을 훔치면 아무 client가 사용 가능**. "이 토큰은 이 client만 쓸 수 있다"는 보장이 없다.
12.1 mTLS Client Certificate
RFC 8705. Client가 TLS 연결에서 client certificate를 사용. AS는 토큰에 **certificate thumbprint**를 바인딩:
{
"cnf": {
"x5t#S256": "SHA256(client_cert)"
}
}
RS는 요청 시:
1. TLS 연결의 client cert 확인.
2. JWT의 `cnf.x5t#S256`이 현재 cert thumbprint와 일치?
3. 일치해야 허용.
토큰을 훔쳐도 client cert 없으면 사용 불가.
12.2 DPoP (Demonstrating Proof-of-Possession)
RFC 9449. mTLS 대안. client가 **별도 공개 키**로 각 요청을 서명.
**Step 1**: Client가 키 쌍 생성 (세션마다).
**Step 2**: Token request에 `DPoP` 헤더 추가:
POST /token
DPoP: <JWS signed with private key, contains public key in header>
...
**Step 3**: AS가 토큰에 **DPoP thumbprint** 바인딩:
{
"cnf": {
"jkt": "SHA256(DPoP public key)"
}
}
**Step 4**: API 호출 시 매번 DPoP JWS 생성:
GET /api/users
Authorization: DPoP <access_token>
DPoP: <new JWS for this request>
RS가 검증:
1. DPoP JWS 서명이 유효한가?
2. JWT의 `cnf.jkt`와 DPoP 공개 키 thumbprint 일치?
3. 요청의 `htm`/`htu` claim이 실제 요청과 일치?
12.3 Bearer vs DPoP
| 항목 | Bearer | DPoP |
|------|--------|------|
| 토큰 탈취 시 | 공격자가 사용 가능 | 사용 불가 (private key 없음) |
| Client 복잡도 | 낮음 | 높음 (매 요청 서명) |
| RS 복잡도 | 낮음 | 중간 (DPoP 검증) |
| 채택률 | 매우 높음 | 느림 |
DPoP는 중요도 높은 사용 사례(금융, 헬스케어)에서 채택 중. 일반 소비자 앱은 여전히 Bearer가 지배적.
13. FAPI — Financial-grade API
금융 기관의 "open banking"용 보안 프로파일.
13.1 목표
일반 OAuth로는 **충분히 안전하지 않다**. 금융 데이터 접근은 더 엄격한 보호 필요:
- 토큰 탈취 완전 차단.
- 요청 무결성 보장.
- 비가역적 거래 요청 보호.
13.2 FAPI 1.0 Read/Write
**FAPI 1.0 Baseline (Read)**: 읽기 전용. PKCE + state/nonce 필수.
**FAPI 1.0 Advanced (Read/Write)**: 전체. 추가:
- **PAR** (Pushed Authorization Request): authorization request를 미리 back-channel로 push.
- **JARM** (JWT Response Authorization Mode): authorization response도 JWT로 서명.
- **mTLS** 또는 **DPoP**: token binding 필수.
- **Request Object**: authorization request 파라미터를 JWT로 감쌈.
13.3 PAR
일반 OAuth:
GET /authorize?response_type=code&...&scope=...&state=...
파라미터가 URL에 노출. 큰 요청은 지저분. 중간자가 쉽게 조작.
PAR:
POST /par
{request object}
Response: { "request_uri": "urn:request:xyz" }
이후 authorization request는:
GET /authorize?client_id=...&request_uri=urn:request:xyz
**훨씬 짧고 안전**. 파라미터가 URL에 없음. 서명된 request object.
13.4 FAPI 2.0
2024년+ 진행 중. 더 단순하고 강력:
- PAR + DPoP 기본.
- mTLS 옵션.
- Sender-constrained tokens 강제.
영국, 호주, 브라질의 open banking이 FAPI 기반.
14. 실전 공급자 비교
14.1 Auth0
- **장점**: 풍부한 기능, 훌륭한 문서, Rules/Actions로 커스터마이즈.
- **단점**: 비쌈, vendor lock-in.
- **용도**: B2C 앱, SaaS.
14.2 Okta
- **장점**: 엔터프라이즈 최강, SSO, 거버넌스.
- **단점**: 비쌈, 복잡.
- **용도**: B2B, 대기업.
14.3 Keycloak
- **장점**: 오픈소스, 자체 호스팅, 풍부한 기능.
- **단점**: 운영 부담, 성능 튜닝 필요.
- **용도**: 데이터 주권이 중요한 경우, 자체 호스팅 선호.
14.4 AWS Cognito
- **장점**: AWS 통합, 저렴, 스케일.
- **단점**: 문서 부족, 커스터마이즈 제한.
- **용도**: AWS 기반 서비스.
14.5 Google / Apple / GitHub
**Social login providers**. 자체 AS 운영 않고 third-party에게 위임.
- Google Sign-In.
- Apple Sign-In (iOS 앱스토어 필수).
- GitHub OAuth (개발자 도구).
대부분 OIDC 지원. Discovery URL로 자동 설정.
14.6 Ory
오픈소스 identity 플랫폼:
- **Hydra**: OAuth/OIDC 서버.
- **Kratos**: 로그인/등록 시스템.
- **Keto**: 권한 관리.
Keycloak 대안. 각 컴포넌트가 독립, 더 클라우드 네이티브.
15. 흔한 보안 실수
15.1 Open Redirect
// BAD
const redirect_uri = req.query.redirect_uri;
res.redirect(oauth_url + '?redirect_uri=' + redirect_uri);
// 공격자: ?redirect_uri=https://evil.com
해결: whitelist.
15.2 Access Token으로 인증
// BAD
const token = verifyJWT(accessToken);
const user = db.find(token.sub); // Access token은 인증용이 아님
해결: OIDC ID token 또는 UserInfo endpoint 사용.
15.3 Refresh Token을 공개적으로
// BAD
localStorage.setItem('refresh_token', rt);
LocalStorage는 XSS로 털림. 해결: HTTPOnly cookie 또는 BFF 패턴.
15.4 Scope 과도 요청
scope=read write admin delete everything ← BAD
필요한 최소 scope만 요청. 사용자 신뢰와 권한 최소화.
15.5 JWT 검증 생략
// BAD
const payload = JSON.parse(atob(token.split('.')[1]));
if (payload.admin) { /* ... */ }
서명 검증 없이 payload를 신뢰 → 누구나 위조 가능.
해결: `jsonwebtoken`, `jose` 같은 검증 라이브러리 사용.
15.6 exp 검증 누락
만료 시간 검사 안 함 → 오래된 토큰도 작동. 반드시 `exp` 검증.
16. 디버깅 도구
16.1 jwt.io
JWT 디코더/검증기. 붙여넣으면 header/payload/signature 분석. **프로덕션 토큰 붙이지 말 것** (payload가 public).
16.2 Postman / Insomnia
OAuth flow 테스트 지원. "Get New Access Token" 버튼으로 authorization code flow 자동화.
16.3 OIDC Debugger
https://oidcdebugger.com — authorization request를 만들고 response를 검사.
16.4 MITM Proxy / Charles
HTTP 트래픽 가로채기. OAuth 흐름의 redirect, 토큰 교환 시각화.
16.5 OpenID Connect Conformance Test Suite
OIDC 구현체가 표준을 준수하는지 테스트. 공식 도구.
17. 학습 리소스
**RFC**:
- RFC 6749: OAuth 2.0 Framework.
- RFC 7636: PKCE.
- RFC 8705: mTLS client authentication.
- RFC 9449: DPoP.
- OpenID Connect Core 1.0.
**책**:
- "OAuth 2 in Action" — Justin Richer & Antonio Sanso (고전).
- "OAuth 2.0 Simplified" — Aaron Parecki (온라인 무료).
**블로그/사이트**:
- https://oauth.net — 모든 공식 문서와 확장.
- Justin Richer의 블로그 (OAuth WG 공저자).
- Aaron Parecki의 블로그 (IndieAuth 저자).
**영상**:
- OktaDev YouTube 채널.
- "OAuth & OIDC for Normal People" 강의.
18. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ OAuth 2.0 / OIDC Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 역할: │
│ Resource Owner (user) │
│ Client (app) │
│ Resource Server (API) │
│ Authorization Server (OAuth/OIDC 서버) │
│ │
│ 4대 Flow (2.1): │
│ 1. Authorization Code + PKCE (웹앱) │
│ 2. Client Credentials (서버간) │
│ 3. Device Code (TV/CLI) │
│ 4. Refresh Token │
│ │
│ 폐기된 Flow: │
│ - Implicit │
│ - Password │
│ │
│ 토큰: │
│ Access Token: API 접근 (짧은 수명) │
│ Refresh Token: 재발급 (긴 수명, 회전) │
│ ID Token: 사용자 신원 (JWT, OIDC만) │
│ │
│ JWT 구조: │
│ header.payload.signature │
│ Base64URL 인코딩 │
│ alg=HS256/RS256/ES256/EdDSA (never none) │
│ Claims: sub/iss/aud/exp/iat/nonce/jti │
│ │
│ PKCE: │
│ code_verifier (random) │
│ code_challenge = SHA256(verifier) │
│ Authorization req: challenge │
│ Token req: verifier │
│ S256만 사용 (plain 금지) │
│ │
│ OIDC Discovery: │
│ /.well-known/openid-configuration │
│ JWKS /.well-known/jwks.json │
│ Key rotation 지원 │
│ │
│ 보안: │
│ state (CSRF) │
│ nonce (replay) │
│ exact redirect_uri match │
│ HTTPS only │
│ Short-lived access tokens │
│ PKCE always │
│ │
│ Token Binding: │
│ Bearer: 기본 (취약) │
│ mTLS: client cert │
│ DPoP: 매 요청 서명 │
│ │
│ FAPI (금융급): │
│ PAR: pushed authorization request │
│ JARM: JWT authorization response │
│ Request Object │
│ mTLS 또는 DPoP 필수 │
│ │
│ 공급자: │
│ Auth0, Okta, Keycloak, AWS Cognito │
│ Google, Apple, GitHub (social) │
│ Ory (오픈소스) │
└─────────────────────────────────────────────────────┘
19. 퀴즈
**A.** **OAuth 2.0은 인가(authorization) 프로토콜**이고 **OIDC는 인증(authentication) 프로토콜**. OAuth는 "이 앱이 내 Gmail을 읽도록 허용한다"는 위임. OIDC는 "이 사용자가 지금 로그인했다"는 신원 확인. 기술적으로 OIDC는 OAuth 2.0 위에 **ID Token**(JWT)을 추가한 레이어. OAuth만 쓰면 access token이 있어도 "누가 로그인했는지" 공식적으로 모른다 — OIDC의 ID Token이 이 간극을 메꾼다. OAuth를 인증에 쓰는 것은 흔한 실수로, 90%의 OAuth 버그가 여기서 나온다.
**A.** **Authorization Code Interception Attack**. 모바일 앱이나 SPA는 client secret을 안전하게 저장할 수 없는데, 이 경우 authorization code를 가로챈 악성 앱이 token으로 교환할 수 있었다(예: 악성 앱이 같은 custom URL scheme 등록). PKCE는 client가 랜덤 `code_verifier`를 만들어 `code_challenge = SHA256(verifier)`를 authorization request에 보내고, token request 시 원본 `verifier`를 증명. 중간에 code를 훔쳐도 verifier를 모르면 token 교환 불가. OAuth 2.1에서 모든 client(confidential 포함)에 필수가 됐다 — defense in depth.
**A.** JWT 헤더의 `alg` 값이 `none`이면 서명이 없다는 뜻. 초기 JWT 라이브러리들은 이를 받아들였다. 공격자는 `{"alg":"none"}.{"admin":true}.`처럼 payload를 조작한 JWT를 만들면 서명 없이 통과 → 누구나 위조 가능. 방어: 검증 시 **예상하는 알고리즘을 하드코딩**해서 `alg`를 신뢰하지 말 것. `jsonwebtoken` 같은 라이브러리는 `none`을 기본 거부. 관련 취약점: HS/RS **Key Confusion** — 서버가 RS256 공개 키를 가지고 있을 때 공격자가 `alg:HS256`으로 바꾸고 공개 키를 HMAC 비밀로 사용해 위조.
**A.** **설정 자동화와 키 회전**. Discovery(`/.well-known/openid-configuration`)는 AS의 모든 엔드포인트, 지원 알고리즘, scope를 하나의 JSON으로 노출 → client는 issuer URL 하나만 알면 모든 설정 자동. JWKS(`/.well-known/jwks.json`)는 서명 공개 키를 배포하는데, 각 키에 `kid`(key ID)가 있어서 AS가 여러 키를 동시에 노출할 수 있다. AS가 키를 교체할 때 `[A, B]` 상태로 두었다가 `[B]`로 전환하면 기존 토큰도 검증 가능 + 새 토큰은 B로. Client는 JWKS를 주기적으로 갱신해 key rotation에 자동 대응.
**A.** **Access token이 브라우저에 노출되는 보안 문제**. Implicit flow는 토큰을 URL fragment(`#access_token=...`)로 직접 반환. 문제: (1) 브라우저 히스토리에 저장됨, (2) referrer 헤더로 유출 가능, (3) JavaScript로 접근 가능한 위치에 존재, (4) refresh token 없음. Authorization Code + PKCE가 훨씬 안전하고 refresh 가능. OAuth 2.0은 "SPA는 secret 저장 못 하니 Implicit를 쓰라"고 했지만, PKCE가 등장한 후 이 제약이 사라졌다. OAuth 2.1에서 공식 폐기. 현재 모든 주요 공급자가 Authorization Code + PKCE를 권장한다.
**A.** **Bearer token의 탈취 위험**. 일반 bearer token은 누구든 토큰 값을 가지면 사용 가능. 네트워크 중간이나 XSS로 탈취되면 끝. **DPoP(Demonstrating Proof-of-Possession)**는 client가 세션마다 키 쌍을 만들고, 매 API 요청마다 DPoP JWS를 **private key로 서명**해 전달. AS는 token 발급 시 DPoP 공개 키 thumbprint를 token에 바인딩(`cnf.jkt`). RS는 요청 시 (1) token의 `jkt`, (2) 현재 DPoP의 공개 키 해시, (3) 요청 URL/method까지 일치 확인. 탈취된 토큰도 private key 없으면 사용 불가. mTLS와 유사하지만 certificate 인프라 없이 구현 가능. FAPI, Open Banking에서 채택 중.
**A.** Refresh token을 사용할 때마다 **새 값으로 교체**하고 이전 값을 무효화하는 것. 왜 필요한가: Refresh token은 오래 유효(수일~수개월)하므로 탈취 위험이 크다. Rotation 없이는 탈취된 refresh token을 공격자가 무제한 사용 가능. Rotation이 있으면 공격자가 한 번 사용하면 합법 사용자의 refresh token이 무효화되고, 합법 사용자의 다음 refresh 시도는 실패 → **세션 탈취 감지**. AS는 "이미 사용된 refresh token이 다시 제출됐다"를 감지하면 **전체 토큰 패밀리를 무효화**해 공격자와 희생자 모두의 세션 종료(확실한 차단). 단점: race condition(동시 refresh 요청), 네트워크 실패 시 토큰 상실 가능. OAuth 2.1에서 강력 권장.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "TLS/SSL Deep Dive" — HTTPS와 mTLS client certificate 배경.
- "DNS Deep Dive" — OIDC Discovery가 DNS 기반 구조를 어떻게 활용하는지.
- "API Versioning & Evolution" — OAuth scope 설계 원칙.
- "Distributed Tracing OpenTelemetry" — OAuth 토큰 전파와 관측성.
현재 단락 (1/634)
- **OAuth 2.0**은 **인가(authorization)** 프로토콜이지 인증(authentication)이 아니다. "이 앱이 내 Gmail을 읽게 허락한다"는 위임이지...