--- 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]]