[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user