Files
2nd/10_Wiki/Topics/Coding/Security_OAuth_Flows.md
T
2026-05-09 21:08:02 +09:00

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
security
oauth
oidc
vibe-coding
language applicable_to
TS
Backend
Frontend
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)

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.

🔗 관련 문서