클라이언트 앱 인증(Auth) 가이드
작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Browser Extension, Tauri Desktop App, Flutter Mobile App
1. 인증 흐름 개요
지원 인증 방식
| 방식 | 용도 | 대상 플랫폼 |
|---|---|---|
| OAuth 2.0 PKCE | 메인 인증 (소셜/SSO) | 전체 |
| API 토큰 | 장기 인증 (CLI/자동화) | Tauri, Browser Ext |
| 세션 기반 | 웹 연동 (쿠키 공유) | Browser Ext |
| Device Flow | 입력 제한 디바이스 | 선택적 |
| Biometric | 로컬 잠금 해제 | Flutter (iOS/Android) |
인증 흐름 결정 매트릭스
| 조건 | 권장 방식 |
|---|---|
| 웹 브라우저 내 동작 (Extension) | 세션 공유 또는 OAuth PKCE |
| 데스크톱 앱 (Tauri) | OAuth PKCE + 시스템 브라우저 |
| 모바일 앱 (Flutter) | OAuth PKCE + 인앱 브라우저(ASWebAuthSession/CustomTabs) |
| 서버 간 통신 | API 토큰 (Client Credentials) |
| IoT/TV 등 입력 제한 | Device Authorization Flow |
2. OAuth 2.0 PKCE (Authorization Code + PKCE)
PKCE란
Public Client(시크릿을 안전하게 보관할 수 없는 클라이언트)를 위한 OAuth 2.0 확장. Authorization Code 가로채기 공격 방지.
흐름
1. 클라이언트: code_verifier (랜덤 43-128자) 생성
2. 클라이언트: code_challenge = BASE64URL(SHA256(code_verifier)) 계산
3. 클라이언트 -> 인증 서버: /authorize 요청
- response_type=code
- client_id
- redirect_uri
- code_challenge
- code_challenge_method=S256
- scope
- state (CSRF 방지)
4. 사용자: 브라우저에서 로그인/동의
5. 인증 서버 -> 클라이언트: redirect_uri?code=xxx&state=xxx
6. 클라이언트 -> 인증 서버: /token 요청
- grant_type=authorization_code
- code
- code_verifier (원본)
- redirect_uri
- client_id
7. 인증 서버 -> 클라이언트: access_token + refresh_token
플랫폼별 OAuth PKCE 구현
Browser Extension
// manifest.json - 리다이렉트 URL
// Chrome: https://<extension-id>.chromiumapp.org/callback
// Firefox: moz-extension://<uuid>/callback.html
// chrome.identity API 활용 (권장)
async function loginWithOAuth(): Promise<AuthTokens> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
const authUrl = new URL(`${AUTH_SERVER}/oauth/authorize`);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', chrome.identity.getRedirectURL('callback'));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'read write');
authUrl.searchParams.set('state', state);
// chrome.identity.launchWebAuthFlow로 인증 팝업
const redirectUrl = await chrome.identity.launchWebAuthFlow({
url: authUrl.toString(),
interactive: true,
});
const params = new URL(redirectUrl).searchParams;
if (params.get('state') !== state) throw new Error('State mismatch');
const code = params.get('code')!;
return exchangeCodeForTokens(code, codeVerifier);
}
Tauri Desktop App
// 시스템 기본 브라우저 + 로컬 서버 또는 커스텀 URL 스킴
// 방법 1: 로컬 HTTP 서버 (localhost 리다이렉트)
// 방법 2: 커스텀 URL 스킴 (myapp://callback)
// 방법 3: tauri-plugin-oauth (권장)
import { start, cancel } from '@anthropic-ai/tauri-plugin-oauth';
async function loginWithOAuth(): Promise<AuthTokens> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateState();
// 로컬 서버 시작 (리다이렉트 수신)
const port = await start(); // 예: 8765
const redirectUri = `http://localhost:${port}/callback`;
const authUrl = buildAuthUrl({
redirectUri,
codeChallenge,
state,
});
// 시스템 브라우저에서 인증 페이지 열기
await open(authUrl);
// 리다이렉트 콜백 대기 (tauri-plugin-oauth가 처리)
const callbackUrl = await waitForCallback(port);
cancel(port);
const params = new URL(callbackUrl).searchParams;
if (params.get('state') !== state) throw new Error('State mismatch');
return exchangeCodeForTokens(params.get('code')!, codeVerifier);
}
Flutter Mobile App
// flutter_appauth 패키지 (ASWebAuthenticationSession / Chrome Custom Tabs)
import 'package:flutter_appauth/flutter_appauth.dart';
final FlutterAppAuth appAuth = const FlutterAppAuth();
Future<AuthTokens> loginWithOAuth() async {
final result = await appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
clientId,
redirectUri, // com.example.app://callback
issuer: authServerUrl,
scopes: ['read', 'write'],
// PKCE는 flutter_appauth가 자동 처리
),
);
return AuthTokens(
accessToken: result!.accessToken!,
refreshToken: result.refreshToken!,
expiresAt: result.accessTokenExpirationDateTime!,
);
}
3. 토큰 저장
보안 저장소 매핑
| 플랫폼 | 보안 저장소 | 라이브러리 | 용도 |
|---|---|---|---|
| Browser Extension | chrome.storage.session | 내장 API | 세션 토큰 (메모리, 브라우저 종료 시 삭제) |
| Browser Extension | chrome.storage.local (암호화) | 내장 API | Refresh 토큰 (영구, 확장 내부만 접근) |
| Tauri (macOS) | Keychain | tauri-plugin-store + keytar | Access/Refresh 토큰 |
| Tauri (Windows) | Credential Manager | tauri-plugin-store + keytar | Access/Refresh 토큰 |
| Tauri (Linux) | libsecret (GNOME) / KWallet | tauri-plugin-store + keytar | Access/Refresh 토큰 |
| Flutter (iOS) | Keychain | flutter_secure_storage | Access/Refresh 토큰 |
| Flutter (Android) | EncryptedSharedPreferences | flutter_secure_storage | Access/Refresh 토큰 |
저장 금지 위치
| 위치 | 이유 |
|---|---|
localStorage (웹) | XSS 공격에 노출 |
cookie (HttpOnly 아닌) | XSS로 탈취 가능 |
| 평문 파일 | 다른 앱/프로세스 접근 가능 |
| 소스 코드 / 하드코딩 | 리버스 엔지니어링으로 노출 |
| 쿼리 파라미터 | URL 로그에 기록됨 |
토큰 저장 구조
interface StoredAuth {
accessToken: string; // 짧은 수명 (15분~1시간)
refreshToken: string; // 긴 수명 (7~30일)
expiresAt: number; // access_token 만료 시각 (Unix timestamp)
tokenType: 'Bearer';
scope: string;
userId: string; // 현재 사용자 ID
tenantId?: string; // 현재 테넌트 ID (SaaS)
}
4. 토큰 자동 갱신
갱신 전략
| 전략 | 구현 | 장점 | 단점 |
|---|---|---|---|
| 사전 갱신 (권장) | 만료 5분 전 자동 갱신 | UX 끊김 없음 | 타이머 관리 필요 |
| 401 대응 갱신 | 401 응답 시 갱신 후 재시도 | 구현 단순 | 첫 요청 지연 |
| 혼합 (최적) | 사전 + 401 대응 | 안정적 | 약간 복잡 |
구현 (TypeScript - Axios 인터셉터)
// API 클라이언트 인터셉터
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
api.interceptors.request.use(async (config) => {
const auth = await getStoredAuth();
if (!auth) return config;
// 사전 갱신: 만료 5분 전
const fiveMinutes = 5 * 60 * 1000;
if (auth.expiresAt - Date.now() < fiveMinutes) {
try {
const newAuth = await refreshTokens(auth.refreshToken);
await storeAuth(newAuth);
config.headers.Authorization = `Bearer ${newAuth.accessToken}`;
} catch {
// 갱신 실패 시 기존 토큰 사용 (401 대응에서 처리)
config.headers.Authorization = `Bearer ${auth.accessToken}`;
}
} else {
config.headers.Authorization = `Bearer ${auth.accessToken}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// 이미 갱신 중이면 대기
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const auth = await getStoredAuth();
const newAuth = await refreshTokens(auth!.refreshToken);
await storeAuth(newAuth);
// 대기 중인 요청 처리
failedQueue.forEach(({ resolve }) => resolve(newAuth.accessToken));
failedQueue = [];
originalRequest.headers.Authorization = `Bearer ${newAuth.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh 토큰도 만료 -> 로그아웃
failedQueue.forEach(({ reject }) => reject(refreshError as Error));
failedQueue = [];
await logout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
Flutter (Dio 인터셉터)
class AuthInterceptor extends Interceptor {
final Dio _dio;
final AuthService _authService;
bool _isRefreshing = false;
final _pendingRequests = <Completer<Response>>[];
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final auth = await _authService.getStoredAuth();
if (auth != null) {
// 사전 갱신
if (auth.isExpiringSoon) {
try {
final newAuth = await _authService.refreshTokens();
options.headers['Authorization'] = 'Bearer ${newAuth.accessToken}';
} catch (_) {
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
}
} else {
options.headers['Authorization'] = 'Bearer ${auth.accessToken}';
}
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
return handler.next(err);
}
// 401 대응 갱신 (위 TypeScript와 동일한 큐 패턴)
// ...
}
}
5. 로그인/로그아웃 흐름
로그인 흐름
[시작]
|
v
사용자가 로그인 버튼 클릭
|
v
저장된 토큰 확인 ──(유효)──> 홈 화면
|
(없음/만료)
v
OAuth PKCE 흐름 시작
|
v
인증 서버에서 로그인
|
v
토큰 수신 + 보안 저장소에 저장
|
v
사용자 프로필 API 호출
|
v
앱 상태 초기화 (테넌트, 설정 등)
|
v
[홈 화면]
로그아웃 흐름
[로그아웃 요청]
|
v
서버에 토큰 무효화 요청 (POST /oauth/revoke)
|
(성공/실패 무관하게 계속)
v
보안 저장소에서 토큰 삭제
|
v
로컬 캐시/상태 초기화
|
v
WebSocket/SSE 연결 종료
|
v
쿠키 삭제 (해당 시)
|
v
[로그인 화면]
로그아웃 시 정리 체크리스트
| 항목 | 설명 |
|---|---|
| Access Token 삭제 | 보안 저장소에서 제거 |
| Refresh Token 삭제 | 보안 저장소에서 제거 |
| 서버 토큰 무효화 | /oauth/revoke API 호출 |
| 로컬 캐시 삭제 | API 응답 캐시, 이미지 캐시 |
| 앱 상태 초기화 | 상태 관리 스토어 리셋 |
| 실시간 연결 종료 | WebSocket, SSE 연결 해제 |
| 쿠키 삭제 | 세션 쿠키 제거 (Browser Ext) |
| 푸시 토큰 해제 | FCM/APNs 토큰 서버에서 해제 |
| 생체 인증 키 삭제 | 로컬 인증 데이터 정리 |
6. 멀티 계정 지원
아키텍처
AccountManager
|
+-- accounts: Account[]
| +-- account1: { userId, tokens, profile, isActive }
| +-- account2: { userId, tokens, profile, isActive: false }
|
+-- activeAccount: Account (현재 활성 계정)
|
+-- switchAccount(userId): void
+-- addAccount(): void
+-- removeAccount(userId): void
저장 구조
interface AccountStore {
activeAccountId: string;
accounts: {
[userId: string]: {
userId: string;
email: string;
displayName: string;
avatarUrl: string;
tenantId: string;
tokens: StoredAuth; // 각 계정별 독립 토큰
settings: UserSettings; // 계정별 설정
};
};
}
멀티 계정 구현 시 주의사항
| 항목 | 주의점 |
|---|---|
| 토큰 분리 | 각 계정 토큰 독립 저장 (혼용 금지) |
| API 요청 | 현재 활성 계정 토큰만 사용 |
| 푸시 알림 | 모든 계정의 알림 수신 (비활성 포함) |
| 캐시 분리 | 계정별 캐시 키 프리픽스 사용 |
| 전환 시 | 상태 초기화 + 새 계정 데이터 로드 |
| 로그아웃 | 해당 계정만 제거 (다른 계정 유지) |
7. Device Authorization Flow
용도
입력이 제한된 디바이스 (TV, IoT, CLI)에서 사용. 사용자가 다른 기기(폰/PC)에서 인증 코드를 입력하여 인증.
흐름
1. 디바이스 -> 서버: POST /oauth/device/code
- client_id
- scope
2. 서버 -> 디바이스: device_code, user_code, verification_uri, interval
3. 디바이스: "https://auth.example.com/device 에서 코드 ABCD-1234 입력" 표시
4. 사용자: 폰/PC에서 verification_uri 접속 -> user_code 입력 -> 로그인
5. 디바이스: POST /oauth/token 폴링 (interval 간격)
- grant_type=urn:ietf:params:oauth:grant-type:device_code
- device_code
- client_id
6. 인증 완료 시: access_token + refresh_token 수신