5.9 KiB
5.9 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| security-oauth-flows | OAuth 2.1 — Authorization Code + PKCE | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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)
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)
// 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
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)
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 ...
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.