176 lines
5.9 KiB
Markdown
176 lines
5.9 KiB
Markdown
---
|
|
id: security-oauth-flows
|
|
title: OAuth 2.1 — Authorization Code + PKCE
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [security, oauth, oidc, vibe-coding]
|
|
tech_stack: { language: "TS", applicable_to: ["Backend", "Frontend"] }
|
|
applied_in: []
|
|
aliases: [OAuth 2.0, OAuth 2.1, OIDC, PKCE, refresh token, Authorization Code]
|
|
---
|
|
|
|
# OAuth 2.1 + OIDC
|
|
|
|
> Authorization Code + PKCE 가 표준. **Implicit / Password grant deprecated**. Refresh token rotation. 사용자 인증 = OIDC (OAuth 위 layer).
|
|
|
|
## 📖 핵심 개념
|
|
- Authorization Code: 표준 web 흐름. PKCE 필수.
|
|
- Client Credentials: 서버 간 (사용자 X).
|
|
- Refresh Token: 짧은 access token + 긴 refresh.
|
|
- OIDC: id_token (JWT, 사용자 정보).
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### Authorization Code + PKCE 흐름
|
|
```
|
|
1. App 이 code_verifier 생성 (random) → code_challenge = SHA256(verifier)
|
|
2. /authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&state=...
|
|
3. 사용자 로그인 → IdP 가 redirect_uri 로 code 반환
|
|
4. App 이 code + verifier → /token 으로 교환
|
|
5. access_token + refresh_token 받음
|
|
```
|
|
|
|
### Client (browser)
|
|
```ts
|
|
function base64url(buf: ArrayBuffer): string {
|
|
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
async function startLogin() {
|
|
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
|
|
const state = base64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
|
|
|
sessionStorage.setItem('pkce_verifier', verifier);
|
|
sessionStorage.setItem('oauth_state', state);
|
|
|
|
const url = new URL('https://idp.example.com/authorize');
|
|
url.searchParams.set('client_id', CLIENT_ID);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
url.searchParams.set('code_challenge', challenge);
|
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
url.searchParams.set('state', state);
|
|
url.searchParams.set('scope', 'openid email profile');
|
|
location.href = url.toString();
|
|
}
|
|
|
|
// callback 페이지
|
|
async function handleCallback() {
|
|
const params = new URLSearchParams(location.search);
|
|
const state = params.get('state');
|
|
if (state !== sessionStorage.getItem('oauth_state')) throw new Error('CSRF');
|
|
|
|
const code = params.get('code');
|
|
const verifier = sessionStorage.getItem('pkce_verifier');
|
|
const r = await fetch('https://idp.example.com/token', {
|
|
method: 'POST', body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: code!, redirect_uri: REDIRECT_URI,
|
|
client_id: CLIENT_ID, code_verifier: verifier!,
|
|
}),
|
|
});
|
|
const tokens = await r.json();
|
|
// tokens.access_token / id_token / refresh_token
|
|
}
|
|
```
|
|
|
|
### Server-side (BFF — backend for frontend)
|
|
```ts
|
|
// Cookie session 만 client 에 — token 은 server 에 보관
|
|
app.get('/auth/login', (req, res) => {
|
|
const { url, state, verifier } = buildAuthUrl();
|
|
res.cookie('pkce', JSON.stringify({ state, verifier }), { httpOnly: true, secure: true, sameSite: 'lax' });
|
|
res.redirect(url);
|
|
});
|
|
|
|
app.get('/auth/callback', async (req, res) => {
|
|
const { state, verifier } = JSON.parse(req.cookies.pkce);
|
|
if (req.query.state !== state) return res.status(400).end();
|
|
const tokens = await exchangeCode(req.query.code, verifier);
|
|
|
|
// 사용자 식별
|
|
const idToken = jwt.decode(tokens.id_token);
|
|
await db.userSessions.create({ userId: idToken.sub, ...tokens });
|
|
|
|
res.cookie('session', sign({ userId: idToken.sub }), { httpOnly: true, secure: true, sameSite: 'strict' });
|
|
res.redirect('/');
|
|
});
|
|
```
|
|
|
|
### Refresh token rotation
|
|
```ts
|
|
async function refresh(oldRefreshToken: string) {
|
|
const session = await db.sessions.find({ refreshToken: oldRefreshToken });
|
|
if (!session) throw new Error('reuse detected — revoke all sessions');
|
|
|
|
const newTokens = await idp.refresh(oldRefreshToken);
|
|
await db.sessions.update(session.id, {
|
|
refreshToken: newTokens.refresh_token,
|
|
accessToken: newTokens.access_token,
|
|
rotatedAt: new Date(),
|
|
});
|
|
// old refresh 사용 시 reuse 검출 → 모든 세션 revoke
|
|
return newTokens;
|
|
}
|
|
```
|
|
|
|
### Client Credentials (M2M)
|
|
```ts
|
|
const r = await fetch('https://idp.example.com/token', {
|
|
method: 'POST',
|
|
headers: { authorization: 'Basic ' + base64(`${id}:${secret}`) },
|
|
body: 'grant_type=client_credentials&scope=read:orders',
|
|
});
|
|
```
|
|
|
|
### OIDC discovery
|
|
```
|
|
GET https://idp.example.com/.well-known/openid-configuration
|
|
→ authorization_endpoint, token_endpoint, jwks_uri ...
|
|
```
|
|
|
|
```ts
|
|
const cfg = await fetch('https://idp.example.com/.well-known/openid-configuration').then(r => r.json());
|
|
const jwks = await fetch(cfg.jwks_uri).then(r => r.json());
|
|
// id_token 검증 = JWKS 의 공개키
|
|
```
|
|
|
|
### NextAuth / Auth.js / Clerk / Auth0
|
|
대부분 SaaS 추천 — 직접 구현 위험.
|
|
|
|
## 🤔 의사결정 기준
|
|
| 환경 | 추천 |
|
|
|---|---|
|
|
| Web SPA | Authorization Code + PKCE + BFF |
|
|
| Mobile native | Authorization Code + PKCE (system browser) |
|
|
| 서버 ↔ 서버 | Client Credentials |
|
|
| Single sign-on | OIDC + 기존 IdP (Okta/Auth0/Keycloak) |
|
|
| 빠른 구현 | NextAuth / Auth.js / Clerk |
|
|
| Custom IdP | Keycloak / Hydra (self-host) |
|
|
|
|
## ❌ 안티패턴
|
|
- **Implicit grant**: deprecated.
|
|
- **Password grant**: deprecated.
|
|
- **PKCE 없음**: code interception 가능.
|
|
- **State 검증 없음**: CSRF 가능.
|
|
- **Token localStorage**: XSS 가 token 훔침. HttpOnly cookie + BFF.
|
|
- **Refresh token rotate 안 함**: leak 시 영원 사용.
|
|
- **id_token 서명 검증 안 함**: 위조 가능.
|
|
- **Custom OAuth 직접 구현**: 표준 라이브러리.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Authorization Code + PKCE + state + nonce.
|
|
- BFF 패턴 (server 가 token 보관).
|
|
- NextAuth / Auth.js 권장 — 직접 X.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Security_2FA_TOTP_WebAuthn]]
|
|
- [[Security_OWASP_Top_10_Practical]]
|
|
- [[Web_JWT_Patterns]]
|