2. OAuth2 & OIDC

문정준's avatar
Jun 25, 2025
2. OAuth2 & OIDC

Kakao API를 사용한 OAuth2 & OIDC 구현

 

1. Kakao Developers 접속

 

2. 내 애플리케이션 클릭

notion image
 

3. 애플리케이션 추가

notion image

애플리케이션 설정

  • 앱 아이콘, 이름, 회사명, 카테고리 설정
  • 사용자의 정보 호출 시 권한을 받기 위한 심사용으로 사용됨
notion image
 

4. 튜토리얼 따라하기

  • 문서의 튜토리얼 및 각종 환경에서 API를 호출하는 방법 확인
notion image

5. OIDC

✅ OIDC란?

  • *OIDC (OpenID Connect)**는 OAuth 2.0 위에서 동작하는 신원 인증 프로토콜입니다.
즉,
🔑 "누구인지 증명하는 인증 계층"을 OAuth 위에 추가한 것

🔁 OIDC vs OAuth 2.0 비교

항목
OAuth 2.0
OIDC
목적
권한 위임 (Authorization)
사용자 인증 (Authentication)
핵심 토큰
Access Token
ID Token (JWT 형식)
사용자 정보 제공
없음 (또는 별도 API 필요)
기본적으로 제공 (표준화된 방식)
용도
API 호출 권한 위임
로그인 시스템 (소셜 로그인 등)
확장
OIDC는 OAuth 2.0을 확장

🧾 OIDC의 주요 구성 요소

구성 요소
설명
ID Token
사용자의 인증 정보가 담긴 토큰 (JWT 형식)
UserInfo Endpoint
인증된 사용자 정보(JSON)를 제공하는 API
Discovery Endpoint
OIDC 서버의 설정 정보를 자동으로 가져오는 URL (.well-known/openid-configuration)
Scopes
openid, email, profile 등 인증을 위한 스코프를 지정

🧭 OIDC 인증 흐름 (Authorization Code Flow 기준)

  1. 클라이언트가 로그인 요청
  1. 사용자가 로그인하고 동의
  1. 클라이언트는 인가 코드(auth code)를 받음
  1. 클라이언트가 auth code로 토큰 요청
  1. OIDC 서버는 Access Token + ID Token 반환
  1. 클라이언트는 ID Token을 디코딩하여 사용자의 신원을 확인
 
notion image
 

6. 앱 키 확인

  • 앱 키는 ID Token 및 Access Token을 받아올 수 있는 비밀 키이므로 노출되지 않도록 주의 필요
notion image
 

7. 구현 방식 설정

  • Flutter의 경우 리다이렉션을 사용할 수 없기 때문에 네이티브 앱 서비스 사용
  • 카카오톡 앱을 사용하지 못하는 환경(Windows, Web)은 웹 서비스 사용
notion image
 

8. 카카오 API 활성화

  • OpenID Connect (OIDC)도 함께 활성화 : ID Token으로 서버에서 인증을 진행할 것임
notion image
 

9. Tutorial : blogv4 (flutter)

Code
AndroidManifest.xml
  • 문서 내부의 AndroidManifest.xml activity 항목을 추가하여 반영
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <application android:label="mykakao" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> <!-- Specifies an Android theme to apply to this Activity as soon as the Android process has started. This theme is visible to the user while the Flutter UI initializes. After that, this theme continues to determine the Window background behind the Flutter UI. --> <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" /> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <!-- 카카오 로그인 커스텀 URL 스킴 설정 --> <activity android:name="com.kakao.sdk.flutter.AuthCodeCustomTabsActivity" android:exported="true"> <intent-filter android:label="flutter_web_auth"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <!-- "kakao${YOUR_NATIVE_APP_KEY}://oauth" 형식의 앱 실행 스킴 설정 --> <!-- 카카오 로그인 Redirect URI --> <data android:scheme="kakao${YOUR_NATIVE_APP_KEY}" android:host="oauth" /> </intent-filter> </activity> <!-- Don't delete the meta-data below. This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> <meta-data android:name="flutterEmbedding" android:value="2" /> </application> <!-- Required to query activities that can process text, see: https://developer.android.com/training/package-visibility and https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> <queries> <intent> <action android:name="android.intent.action.PROCESS_TEXT" /> <data android:mimeType="text/plain" /> </intent> </queries> </manifest>
build.gradle.kts
  • main.dart에서 설정한 Native App Key를 읽어서 설정
  • 설정이 불가능할 경우 직접 대입 : 실제 앱 키를 작성해주어야 함
  • 대입된 kakaoAppKey 값을 YOUR_NATIVE_APP_KEY 환경 변수로 설정
plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { namespace = "com.example.mykakao" compileSdk = flutter.compileSdkVersion ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.mykakao" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName // 카카오 네이티브 앱 키 설정 // Flutter에서 --dart-define=KAKAO_NATIVE_APP_KEY=your_key로 전달 가능 val kakaoAppKey = project.findProperty("KAKAO_NATIVE_APP_KEY") as String? ?: "YOUR_NATIVE_APP_KEY" manifestPlaceholders["YOUR_NATIVE_APP_KEY"] = kakaoAppKey } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } } flutter { source = "../.." }
main.dart
  • 최초 main 실행 전 상수로 Native App Key 저장
  • 카카오 SDK에 Native App Key 저장
  • 이후 main 실행
    • 화면이 출력되고 카카오로 시작하기 버튼 클릭 시 웹에서 카카오 계정으로 로그인
      • 카카오톡 앱이 설치되어 있을 경우 loginWithKakaoTalk() 메서드 사용해도 됨
import 'package:flutter/material.dart'; import 'package:kakao_flutter_sdk/kakao_flutter_sdk.dart'; // 카카오 네이티브 앱 키 (빌드 시점에 --dart-define로 전달 가능) const String KAKAO_NATIVE_APP_KEY = String.fromEnvironment( 'KAKAO_NATIVE_APP_KEY', defaultValue: 'YOUR_NATIVE_APP_KEY', ); void main() { WidgetsFlutterBinding.ensureInitialized(); // runApp() 호출 전 Flutter SDK 초기화 KakaoSdk.init(nativeAppKey: KAKAO_NATIVE_APP_KEY); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'My Kakao', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFFFEE500), // 카카오 브랜드 컬러 brightness: Brightness.light, ), useMaterial3: true, fontFamily: 'Pretendard', ), home: const KakaoLoginPage(), ); } } class KakaoLoginPage extends StatefulWidget { const KakaoLoginPage({super.key}); @override State<KakaoLoginPage> createState() => _KakaoLoginPageState(); } class _KakaoLoginPageState extends State<KakaoLoginPage> { bool _isLoggedIn = false; void _handleKakaoLogin() async { try { OAuthToken token = await UserApi.instance.loginWithKakaoAccount(); print('카카오톡으로 로그인 성공 ${token.idToken}'); setState(() { _isLoggedIn = true; }); } catch (error) { print('카카오톡으로 로그인 실패 $error'); } // 로그인 성공 메시지 표시 ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('카카오 로그인 성공! 🎉'), backgroundColor: const Color(0xFFFEE500), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: ListView( padding: const EdgeInsets.all(24.0), children: [ // 상단 여백 const SizedBox(height: 60), // 카카오 로고 및 타이틀 Column( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: const Color(0xFFFEE500), borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: const Color(0xFFFEE500).withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 10), ), ], ), child: const Icon( Icons.chat_bubble_outline, size: 40, color: Color(0xFF3C1E1E), ), ), const SizedBox(height: 24), const Text( 'My Kakao', style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF3C1E1E), ), ), const SizedBox(height: 8), Text( '카카오와 함께 시작하세요', style: TextStyle(fontSize: 16, color: Colors.grey[600]), ), ], ), const SizedBox(height: 80), // 카카오 로그인 버튼 Container( width: double.infinity, height: 56, decoration: BoxDecoration( color: const Color(0xFFFEE500), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: const Color(0xFFFEE500).withOpacity(0.4), blurRadius: 15, offset: const Offset(0, 8), ), ], ), child: Material( color: Colors.transparent, child: InkWell( onTap: _handleKakaoLogin, borderRadius: BorderRadius.circular(16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 24, height: 24, decoration: BoxDecoration( color: const Color(0xFF3C1E1E), borderRadius: BorderRadius.circular(4), ), child: const Icon( Icons.chat_bubble, size: 16, color: Color(0xFFFEE500), ), ), const SizedBox(width: 12), const Text( '카카오로 시작하기', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF3C1E1E), ), ), ], ), ), ), ), const SizedBox(height: 24), // 또는 구분선 Row( children: [ Expanded(child: Container(height: 1, color: Colors.grey[300])), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( '또는', style: TextStyle(color: Colors.grey[500], fontSize: 14), ), ), Expanded(child: Container(height: 1, color: Colors.grey[300])), ], ), const SizedBox(height: 24), // 빈 박스 카드들 _buildEmptyCard(), const SizedBox(height: 16), _buildEmptyCard(), const SizedBox(height: 16), _buildEmptyCard(), const SizedBox(height: 60), // 로그인 상태 표시 if (_isLoggedIn) Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFFEE500).withOpacity(0.1), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFFEE500), width: 1), ), child: Row( children: [ Icon(Icons.check_circle, color: Colors.green[600], size: 20), const SizedBox(width: 8), Text( '로그인 완료! 환영합니다 👋', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Colors.green[700], ), ), ], ), ), const SizedBox(height: 40), ], ), ); } Widget _buildEmptyCard() { return Container( height: 80, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!, width: 1), boxShadow: [ BoxShadow( color: Colors.grey[200]!, blurRadius: 8, offset: const Offset(0, 2), ), ], ), ); } Widget _buildLoginOption({ required IconData icon, required String title, required String subtitle, required Color color, }) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!, width: 1), boxShadow: [ BoxShadow( color: Colors.grey[200]!, blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: color, size: 20), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF3C1E1E), ), ), Text( subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), ], ), ), Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey[400]), ], ), ); } }
Result
  • 정상 로그인 시 로그인 완료 알림 출력
notion image
 
 

구성

OAuthToken

  • access_token : 로그인을 위한 인증 용 access_token
    • 해시로 저장되어 있어 해독 불가 : kakao에서 대조해야 함
  • expires_at : access_token 만료 시간
  • refresh_token : access_token 재 발급 용 refresh_token
  • refresh_token_expires_at : refresh_token 만료 시간
  • scopes : 데이터 제공 허용 범위
  • id_token : 사용자 신원 검증 용 JWT
    • Spring 서버에서도 인증 가능하므로 id_token을 사용할 예정
 

id_token

  • aud : 앱의 Native App Key
  • sub : 로그인 한 사용자의 고유 ID
  • auth_time : 사용자가 로그인 한 시간
  • iss : Issuer (토큰 공급자)
  • exp : Expired At (토큰 만료 시간)
  • iat : Issued At (토큰 발급 시각)
  • nickname : (확장) 사용자 이름
  • picture : (확장) 사용자 프로필 주소
 

Code vs Credential

Code

  • 사용자가 kakao에 로그인
  • kakao에서는 사용자가 로그인했으므로 Access Token 및 기타 정보를 제공
  • 사용자는 Spring 서버에게 Access Token 전달
  • Spring 서버는 Access Token을 검증하기 위해 kakao에게 검증 요청
  • kakao는 Access Token을 검증하면 유저 정보를 전송
  • Spring 서버에서는 Access Token이 검증되었으므로 로그인 승인
 

Credential

  • 사용자가 kakao에 로그인
  • kakao에서는 사용자가 로그인했으므로 Access Token, ID Token 및 기타 정보를 제공
  • 사용자는 Spring 서버에게 ID Token 및 개인 정보 전달
  • Spring 서버는 kakao의 공개 키 다운로드 후 ID Token 복호화
  • ID Token이 복호화되고 개인 정보가 토큰 안의 개인 정보와 일치하면 로그인 승인
 
🔑
  • Kakao : Resource Server
  • Spring 서버 : Client
  • 사용자 : Resource Owner
 

10. Tutorial 2 : blogv3 (spring)

Code
  • IndexController.java
package shop.mtcoding.blog; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import shop.mtcoding.blog._core.util.Resp; import shop.mtcoding.blog.user.UserResponse; import shop.mtcoding.blog.user.UserService; @RequiredArgsConstructor @RestController public class IndexController { private final UserService userService; // redirect url : /kakao @GetMapping("/kakao") public String kakao(String code) { return "카카오로부터 받은 임시 코드 : " + code; } @PostMapping("/oauth/login") public ResponseEntity<?> login(@RequestBody String idToken) { // 타입 추론 var : 카카오 로그인 함수에서 받을 데이터타입을 결정 var respDTO = userService.카카오로그인(idToken); return Resp.ok(respDTO); } }
 
  • UserService.java
@Transactional public UserResponse.IdTokenDTO 카카오로그인(String idToken) { // application context에 담을 수 있음 : session // 1. 공개키 존재 확인 없으면 다운로드 // 2. id Token 검증 (base64 디코딩, 서명검증) OAuthProfile oAuthProfile = MyRSAUtil.verify(idToken); User user = null; // 3. 회원가입 유무 확인 Optional<User> userOP = userRepository.findByUsername(ProviderType.KAKAO+"_"+oAuthProfile.getSub()); if(userOP.isEmpty()) { // 4. 안되있으면 강제 회원가입 user = User.builder() .username(ProviderType.KAKAO+"_"+oAuthProfile.getSub()) .password(UUID.randomUUID().toString()) .email(null) .provider(ProviderType.KAKAO) .build(); userRepository.save(user); } else user = userOP.get(); // 5. 되어있다면 아무것도 안해도 됨 return new UserResponse.IdTokenDTO(user, idToken); }
 
  • MyRSAUtil.java
package shop.mtcoding.blog.user; import com.fasterxml.jackson.databind.ObjectMapper; import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.util.Base64URL; import com.nimbusds.jwt.SignedJWT; import org.springframework.web.client.RestTemplate; import java.math.BigInteger; import java.util.Base64; public class MyRSAUtil { public static JwtKeySet downloadRSAKey() { String jwtUrl = "https://kauth.kakao.com/.well-known/jwks.json"; // ← 실제 주소로 교체 RestTemplate restTemplate = new RestTemplate(); JwtKeySet keySet = restTemplate.getForObject(jwtUrl, JwtKeySet.class); return keySet; } public static OAuthProfile verify(String idToken) { JwtKeySet keySet = downloadRSAKey(); String n = keySet.getKeys().get(1).getN(); String e = keySet.getKeys().get(1).getE(); System.out.println("n : " + n); System.out.println("e : " + e); BigInteger bin = new BigInteger(1, Base64.getUrlDecoder().decode(n)); BigInteger bie = new BigInteger(1, Base64.getUrlDecoder().decode(e)); RSAKey rsaKey = new RSAKey.Builder(Base64URL.encode(bin), Base64URL.encode(bie)).build(); try { // 1. 파싱 SignedJWT signedJWT = SignedJWT.parse(idToken); // 2. 검증 RSASSAVerifier verifier = new RSASSAVerifier(rsaKey.toRSAPublicKey()); if (signedJWT.verify(verifier)) { System.out.println("ID Token을 검증하였습니다"); String payload = signedJWT.getPayload().toString(); System.out.println("페이로드"); System.out.println(payload); ObjectMapper objectMapper = new ObjectMapper(); OAuthProfile profile = objectMapper.readValue(payload, OAuthProfile.class); return profile; } else { throw new RuntimeException("id토큰 검증 실패"); } } catch (Exception ex) { throw new RuntimeException(ex.getMessage()); } } }
 
  • OAuthProfile.java
package shop.mtcoding.blog.user; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data public class OAuthProfile { private String aud; // 앱 키 private String sub; // 고유 사용자 ID @JsonProperty("auth_time") private Long authTime; // 인증 시간 (Epoch 초) private String iss; // 발급자 (issuer) private String nickname; // 사용자 닉네임 private Long exp; // 만료 시간 (Epoch 초) private Long iat; // 발급 시간 (Epoch 초) private String picture; // 프로필 사진 URL }
 
  • JwtKeySet.java
package shop.mtcoding.blog.user; import lombok.Data; import java.util.List; @Data public class JwtKeySet { private List<JwtKey> keys; @Data public static class JwtKey { private String kid; private String kty; private String alg; private String use; private String n; private String e; } }
 
  • ProviderType.java
package shop.mtcoding.blog.user; public enum ProviderType { KAKAO, GOOGLE, NAVER; }
 
Result
  • 정상 로그인 시 로그인 완료 알림 출력
notion image
 
  • 최초 로그인 시 : 신규 가입
notion image
  • 로그인 성공
notion image
 
  • 이후 로그인 시 : 가입된 유저 확인 후 반환
notion image
notion image
 
 
Share article

sxias