Skip to content

필사 모드: OAuth 2.0 & OIDC Deep Dive — Authorization Code, PKCE, JWT, DPoP, FAPI 완전 정복 (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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을 읽게 허락한다"는 위임이지...

작성 글자: 0원문 글자: 21,831작성 단락: 0/634