- Supabase에서 프로젝트 생성
- Authentication → Sign In / Providers
- Auth Providers에서 원하는 소셜 플랫폼 활성화 (필자는 카카오,애플 선택)
여기서 코드 생성 후 복,붙하기
Client에서 로그인 로그아웃 처리 (카카오, 애플 동일)
import Supabase
enum SocialType: String {
case kakao = "kakao"
case apple = "apple"
}
...
let client = SupabaseClient(supabseURL: _ , supabaseKey: _)
// 해당 코드를 로그인할 버튼에 이어주면 끝
// MARK: 로그인
func signInWithSocial(type: SocialType) async throws {
let authSession = try await client.auth.signInWithOAuth(provider: Provider(rawValue: type.rawValue)!, redirectTo: redirectURL) { session in
// 아래 값은 true/false로 바뀌는 동작 보고 결정
session.prefersEphemeralWebBrowserSession = false
}
// 키체인으로 accessToken, refreshToken 저장
self.saveSupabaseTokens(authSession: authSession)
print("\(type.rawValue) 로그인")
// 화면 이동
self.gotoMainView()
}
// MARK: 로그아웃
func signOutSupabase() async throws {
do {
try await client.auth.signOut()
} catch {
print("Supabase signOut 실패:", error.localizedDescription)
}
print("로그아웃")
// 키체인으로 저장된 토큰들 삭제
self.deleteSupabaseTokens()
self.gotoLoginView()
}
애플 설정은 너무나도 잘 정리되어 있는 공식문서를 첨부하겠다.
https://docs-pgth9qjfy-supabase.vercel.app/docs/guides/auth/social-login/auth-apple
회원탈퇴는 카카오, 애플에서 제공하는 RestAPI로 직접 호출해야 한다.
이유는 카카오,애플의 accessToken (또는 RefreshToken이 필요하기 때문)
import Alamofire
private func unlinkKakaoAccout(_ token: String) {
print("읽어온 액세스 토큰 (provider : 카카오 ): \(token)")
let url = "https://kapi.kakao.com/v1/user/unlink"
let headers: HTTPHeaders = [
"Authorization": "Bearer \(token)"
]
Task {
AF.request(url, method: .post, headers: headers)
.validate()
.responseData { response in
switch response.result {
case .success:
print("리프레시 토큰 리보크 성공. 상태 코드:", response.response?.statusCode ?? -1)
let bodyString = response.data.flatMap { String(data: $0, encoding: .utf8) } ?? "응답 바디 없음"
print(bodyString)
case .failure(let error):
let statusCode = response.response?.statusCode ?? -1
let bodyString = response.data.flatMap { String(data: $0, encoding: .utf8) } ?? "응답 바디 없음"
print("리프레시 토큰 리보크 실패. HTTP \(statusCode), 응답: \(bodyString), 오류: \(error.localizedDescription)")
}
}
}
}
애플은 하단에 Supabase에서 Edge Functions을 사용한 글을 읽어야 이해할 수 있다.
import Alamofire
private func revokeToken(_ token: String) {
let url = "https://appleid.apple.com/auth/revoke"
let clientID = "사용자의 서비스 아이디 등록(애플 개발자 홈페이지에서 만들어야함)"
Task {
let clientSecret: String
do {
clientSecret = try await requestSecret() // Edge Functions으로 가져오기
} catch {
print("Client Secret 획득 실패:", error.localizedDescription)
return
}
// 요청 파라미터
let params: [String: String] = [
"client_id": clientID,
"client_secret": clientSecret,
"token": token,
"token_type_hint": "access_token"
]
// Alamofire 요청
AF.request(
url,
method: .post,
parameters: params,
encoder: URLEncodedFormParameterEncoder.default,
headers: ["Content-Type": "application/x-www-form-urlencoded"]
)
.validate()
.response { response in
switch response.result {
case .success:
print("토큰 리보크 성공. 상태 코드:", response.response?.statusCode ?? -1)
case .failure(let error):
print("토큰 리보크 실패. HTTP \(response.response?.statusCode ?? -1) 오류: \(error.localizedDescription)")
}
}
}
}
// MARK: 회원탈퇴
/// 1. 로그인 다시 해서 provieder.access / refresh 토큰 수령
/// 2. 소셜 서비스에서 unlink / revoke 수행
/// 3. 슈파베이스 row 삭제
func deleteUserInSupabase() async throws {
// 1) 현재 세션을 가져와서, 어떤 provider(kakao or apple)인지 판별
let session = try await client.auth.session
guard
let rawProvider = session.user.appMetadata["provider"]?.rawValue,
let socialProvider = Provider(rawValue: rawProvider)
else {
print("지원하지 않는 로그인 방식이거나 provider 정보가 없습니다.")
return
}
// 2) provider에 따라 호출할 함수 결정
let performUnlinkOrRevoke: (String) async throws -> Void
switch socialProvider {
case .kakao:
print("unlinkKakao")
performUnlinkOrRevoke = unlinkKakaoAccout(_: )
case .apple:
print("Revoke Apple")
performUnlinkOrRevoke = revokeToken(_: )
default:
// 만약 다른 소셜이 추가되면 여기서 분기 처리 가능
return
}
// 3) 재로그인하여 providerToken을 새로 받아오기
// (기존 토큰이 만료됐을 수 있기 때문)
let authSession: Supabase.Session
do {
authSession = try await client.auth.signInWithOAuth(
provider: socialProvider,
redirectTo: redirectURL
) { session in
session.prefersEphemeralWebBrowserSession = false
}
} catch {
print("재로그인 실패:", error.localizedDescription)
return
}
guard let providerToken = authSession.providerToken else {
print("새로 받은 providerToken이 없습니다.")
return
}
// 4) providerToken을 이용해 unlink 또는 revoke 호출
do {
try await performUnlinkOrRevoke(providerToken)
} catch {
print("Provider 연결 해제 실패:", error.localizedDescription)
return
}
// 5) Supabase 측 사용자 삭제 RPC 호출 → 로그아웃
do {
_ = try await client
.rpc("delete_current_user")
.execute()
try await signOutSupabase()
} catch {
print("supabase User 삭제 실패 :", error.localizedDescription)
}
}
Delete Current User Function
client에서 회원탈퇴한 정보를 삭제하기 위해 사용
create or replace function delete_current_user()
returns void
language plpgsql
security definer
set search_path = public, auth, pg_catalog
as $$
declare
uid uuid := auth.uid(); -- 현재 로그인된 사용자의 ID
begin
-- (1) public.user 테이블에서 현재 유저의 프로필(추가 정보) 삭제
delete from public."user"
where id = uid;
-- (2) auth.users 테이블에서 현재 유저의 인증 정보 삭제
delete from auth.users
where id = uid;
end;
$$;
client에서는 다음과 같이 사용
try await client
.rpc("delete_current_user")
.execute()
User Management Starter
auth.users는 client에서 수정할 수 없기 때문에 client에서 수정할 수 있도록 public.user 테이블을 생성
이때 auth.users.uid 와 public.user.id는 외래키 관계
-- public.user에 function 추가
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.user (id, email, name)
values (new.id, new.email, new.raw_user_meta_data ->> 'name');
return new;
end;
$$;
-- auth.users에 trigger 추가
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- 1) SELECT 정책: 인증된 사용자는 자신의 행(id)이 일치할 때만 SELECT 허용
CREATE POLICY "select_self_user"
ON public."user"
FOR SELECT
USING ( auth.uid() = id );
-- 2) UPDATE 정책: 인증된 사용자는 자신의 행(id)이 일치할 때만 UPDATE 허용
CREATE POLICY "update_self_user"
ON public."user"
FOR UPDATE
USING ( auth.uid() = id );
애플 로그인 한정
애플 로그인 정보를 삭제하기 위해선 apple의 revoke api call을 해야한다.
이때 필요한 client_secret의 값을 client에서 만들기 위해서는 보안키를 앱 번들에 저장해야하는데 보안상 위험하다. 그래서 원래는 Back-End 서버에서 처리를 해야하지만, 필자는 따로 서버가 없고, Supabase에서 해결하고자 찾은 방법중 하나이다.
Edge Functions의 Secrets에 애플 로그인을 하기 위해 만들었던 client_secret 값을 저장하고 필요할때 호출하기 위해 사용
참고 : 해당 값은 6개월마다 교체를 해야한다. 필자는 보안키를 잃어버리지 않기 위해 Supabase의 Storage에 저장했다.
// index.ts
// 1) CORS 헤더 설정
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS, PUT, DELETE"
};
// 2) 환경 변수에서 CLIENT_SECRET 값 읽어오기
// 만약 없으면 빈 문자열을 반환하게 처리
const CLIENT_SECRET = Deno.env.get("CLIENT_SECRET") ?? "";
// 3) 모든 요청을 처리하는 Deno.serve
Deno.serve(async (req)=>{
const url = new URL(req.url);
const pathname = url.pathname;
// GET /generate-client-secret 요청 처리
// 필자의 Edge Functions 함수명이 swift-api이다.
// /generate-client-secret은 endpoint는 api를 구분하기 쉽게 작성함(작성안해도 됨)
if ((pathname === "/swift-api/generate-client-secret" || pathname === "/generate-client-secret") && req.method === "GET") {
return new Response(JSON.stringify({
client_secret: CLIENT_SECRET
}), {
status: 200,
headers: {
...corsHeaders,
"Content-Type": "application/json"
}
});
}
// 그 외 요청은 404 반환
return new Response(JSON.stringify({
error: "Not Found",
path: pathname
}), {
status: 404,
headers: {
...corsHeaders,
"Content-Type": "application/json"
}
});
});
여기다가 Key값으로 CLIENT_SECRET로 저장했다.
client에서 호출하는 방법
struct SecretResponse: Codable {
let client_secret: String
}
...
private func requestSecret() async throws -> String {
// 호출할 Edge Function 이름
let functionName = "swift-api/generate-client-secret"
let response: SecretResponse = try await client.functions.invoke(
functionName,
options: FunctionInvokeOptions(
method: .get,
headers: [
"Authorization": "Bearer \(token)"
// 보안 연결에 사용되는 헤더
// 이 토큰은 supabase에서 OAUTH 성공할 때 제공하는 AccessToken 입력
// 필자는 로그인(OAUTH 인증)성공시 해당값을 키체인으로 저장함.
]
)
)
return response.client_secret
}
//
// KeycahinHelper.swift
// KakaoLoginTest
//
// Created by yimkeul on 6/3/25.
//
import Foundation
import Security
enum KeychainService {
static let supabaseAccess = "SupabaseAccessToken"
static let supabaseRefresh = "SupabaseRefreshToken"
}
final class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
/// Data를 주어진 service/account 키로 저장
func save(_ data: Data, service: String, account: String = "current_user") {
// 1. 동일한 service/account가 이미 있으면 삭제
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
SecItemDelete(query as CFDictionary)
// 2. 새로 저장
let attributes: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: data
]
SecItemAdd(attributes as CFDictionary, nil)
print("키체인 \(service) - \(String(describing: String(data: data, encoding: .utf8)!.prefix(10))) 저장 성공")
}
/// 주어진 service/account로 저장된 Data를 가져옴
func read(service: String, account: String = "current_user") -> Data? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
print("키체인 \(service) 불러오기")
return result as? Data
}
/// 주어진 service/account에 저장된 아이템 삭제
func delete(service: String, account: String = "current_user") {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
SecItemDelete(query as CFDictionary)
print("키체인 \(service) 삭제")
}
}
// MARK: Supabase Key 관리
private func deleteSupabaseTokens() {
KeychainHelper.shared.delete(service: "SupabaseAccessToken", account: "current_user")
KeychainHelper.shared.delete(service: "SupabaseRefreshToken", account: "current_user")
print("키체인에서 supabase에 연결되어있는 토큰(access,refresh) 삭제")
}
private func saveSupabaseTokens(authSession: Supabase.Session) {
let accessToken = authSession.accessToken
let refreshToken = authSession.refreshToken
KeychainHelper.shared.save(
Data(accessToken.utf8),
service: KeychainService.supabaseAccess
)
KeychainHelper.shared.save(
Data(refreshToken.utf8),
service: KeychainService.supabaseRefresh
)
}