Skip to main content

클라이언트 앱 인증(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 Extensionchrome.storage.session내장 API세션 토큰 (메모리, 브라우저 종료 시 삭제)
Browser Extensionchrome.storage.local (암호화)내장 APIRefresh 토큰 (영구, 확장 내부만 접근)
Tauri (macOS)Keychaintauri-plugin-store + keytarAccess/Refresh 토큰
Tauri (Windows)Credential Managertauri-plugin-store + keytarAccess/Refresh 토큰
Tauri (Linux)libsecret (GNOME) / KWallettauri-plugin-store + keytarAccess/Refresh 토큰
Flutter (iOS)Keychainflutter_secure_storageAccess/Refresh 토큰
Flutter (Android)EncryptedSharedPreferencesflutter_secure_storageAccess/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 수신

8. Laravel(SaaS 백엔드) 연동 패턴

Laravel Passport/Sanctum 연동

기능Passport (OAuth)Sanctum (토큰)
OAuth 2.0 PKCEO-
API 토큰OO
SPA 세션-O
모바일 앱OO (토큰)
서드파티 연동O-

권장: Passport (OAuth PKCE) - 클라이언트 앱은 서드파티와 동일하게 취급

Laravel API 엔드포인트

POST /oauth/authorize          # 인증 코드 요청
POST /oauth/token # 토큰 교환
POST /oauth/token/refresh # 토큰 갱신
POST /oauth/revoke # 토큰 무효화
GET /api/user # 현재 사용자 프로필
GET /api/user/tenants # 사용자 테넌트 목록
POST /api/user/tenant/switch # 테넌트 전환

테넌트 전환 (SaaS)

// 멀티테넌트 SaaS에서 테넌트 전환
async function switchTenant(tenantId: string): Promise<void> {
// 1. 서버에 테넌트 전환 요청
const response = await api.post('/api/user/tenant/switch', { tenant_id: tenantId });

// 2. 새 토큰 저장 (테넌트 스코프 토큰)
await storeAuth({
...response.data.tokens,
tenantId,
});

// 3. 앱 상태 초기화
await resetAppState();

// 4. 테넌트별 데이터 로드
await loadTenantData(tenantId);
}

9. 오프라인 인증 처리

오프라인 시 인증 전략

상황처리
토큰 유효 + 오프라인로컬 토큰으로 계속 사용, API 요청 큐잉
토큰 만료 + 오프라인마지막 인증 상태 유지, 온라인 복귀 시 갱신
토큰 없음 + 오프라인로그인 불가 안내, 오프라인 모드 제한적 접근
생체 인증 (모바일)오프라인에서도 앱 잠금 해제 가능

오프라인 인증 유효 기간

const OFFLINE_AUTH_VALIDITY = {
maxAge: 7 * 24 * 60 * 60 * 1000, // 최대 7일
requireBiometric: true, // 생체 인증 필수 (모바일)
requirePin: true, // PIN 필수 (데스크톱)
readOnly: true, // 오프라인 시 읽기 전용
};

10. 구현 체크리스트

필수 구현

  • OAuth 2.0 PKCE 흐름 구현
  • 보안 저장소에 토큰 저장
  • 토큰 자동 갱신 (사전 + 401 대응)
  • 로그인 흐름 (초기화 포함)
  • 로그아웃 흐름 (정리 체크리스트 전체)
  • CSRF/State 파라미터 검증

보안 검증

  • 토큰이 안전한 저장소에만 저장되는지 확인
  • code_verifier가 충분한 엔트로피를 가지는지 확인 (43-128자)
  • redirect_uri가 정확히 일치하는지 검증
  • state 파라미터로 CSRF 방지
  • 에러 응답에 토큰 정보 노출 없는지 확인
  • 토큰 만료 시간 서버 응답 기준 (클라이언트 시계 의존 금지)

선택 구현

  • 멀티 계정 지원
  • Device Authorization Flow
  • 생체 인증 (Flutter)
  • 오프라인 인증 처리
  • 테넌트 전환 (SaaS)
  • 로그인 이력/디바이스 관리 UI