Konfigurasi Keycloak di Self Host Supabase
Di sini saya akan memaparkan cara konfigurasi Keycloak di Self Hosting Supabase. Saya mencoba clientnya menggunakan Flutter.
Pengertian
Keycloak adalah platform open source yang dikembangkan oleh Red Hat untuk manajemen identitas dan akses. Dirancang untuk memenuhi kebutuhan aplikasi modern, Keycloak menawarkan berbagai fitur termasuk otentikasi multi-faktor, manajemen akses berbasis peran, dan dukungan SSO. Dengan Keycloak, organisasi dapat dengan mudah dan efisien mengelola otentikasi pengguna, otorisasi, dan administrasi identitas.
Supabase adalah alternatif dari Supabase yang bersifat open-source, di bangun di atas Postgres. Supabase menyediakan hampir semua service backend untuk membangun produk. Di antara produk yang disediakan:
Hanya perlu waktu kurang dari 2 menit, untuk membuat service backend dengan Supabase.
Menjalankan Keycloak
Kamu bisa mengikuti dokumentasi Keycloak atau dokumentasi Supabase untuk menjalankan Keycloak dengan docker.
Akses Keycloak console admin
Masuk dengan cara kunjungi http://localhost:8080
dan klik di "Administration Console".
Buat Keycloak realm
Buat Keycloak client
- http://192.168.1.20:8000 merupakan url supabase self host
Ambil client secret
Konfigurasi docker-compose.yml Supabase
Di supabase/docker/docker-compose.yml tambahkan
# ...
auth:
# ...
environment:
# ...
# Keycloak OAuth config
GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: ${ENABLE_KEYCLOAK}
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID}
GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ${GOTRUE_EXTERNAL_KEYCLOAK_SECRET}
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI}
GOTRUE_EXTERNAL_KEYCLOAK_URL: ${GOTRUE_EXTERNAL_KEYCLOAK_URL}
# ...
Di file supabase/docker/.env tambahkan
# ...
## General
# Ini url aplikasi anda, disini saya pakai deep link dari firebase hosting
SITE_URL=https://firebase-hosting81ae1.web.app
ADDITIONAL_REDIRECT_URLS=["**"]
JWT_EXPIRY=3600
DISABLE_SIGNUP=false
API_EXTERNAL_URL=http://localhost:8000
# Keycloak OAuth config
ENABLE_KEYCLOAK=true
GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID="client id dari keycloak"
GOTRUE_EXTERNAL_KEYCLOAK_SECRET= "secret dari keycloak"
GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI="http://192.168.1.20:8000/auth/v1/callback"
GOTRUE_EXTERNAL_KEYCLOAK_URL="http://192.168.1.20:8080/realms/myrealm"
# ...
http://192.168.1.20:8080 — merupakan url mengarah ke Keycloak
Setelah mengubah 2 file di atas silahkan restart docker compose supabase
docker compose down && docker compose up -d
Simple app di Flutter app
Disini saya menggunakan go_router untuk menghandle deep link callback dari Keycloak
// main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, __) => const LoginPage(),
routes: [
GoRoute(
path: 'login',
builder: (_, __) => const LoginPage(),
),
GoRoute(
path: 'home',
builder: (_, __) => const MyHomePage(title: 'Home'),
),
],
),
],
);
Future<void> main() async {
await Supabase.initialize(
url: 'http://192.168.1.20:8000',
anonKey: 'anonKey',
);
runApp(const MyApp());
}
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
bool isLoading = false;
List<Map<String, dynamic>> todos = [];
void fetchTodos() async {
final response = await supabase.from('todos').select();
setState(() {
todos = response;
});
}
@override
void initState() {
super.initState();
fetchTodos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
actions: [
ElevatedButton(
onPressed: () async {
try {
setState(() {
isLoading = true;
});
await supabase.auth.signOut();
GoRouter.of(context).pushReplacement('/login');
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Error: $e"),
),
);
} finally {
setState(() {
isLoading = false;
});
}
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Logout'),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
// show todos
if (todos.isNotEmpty)
Expanded(
child: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo['task']),
// delete button
leading: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
try {
setState(() {
isLoading = true;
});
await supabase
.from('todos')
.delete()
.eq('id', todo['id']);
setState(() {
todos.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('${todo['task'].toString()} deleted'),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Error: $e"),
),
);
} finally {
setState(() {
isLoading = false;
});
}
},
),
trailing: Checkbox(
value: todo['is_completed'],
onChanged: (value) async {
try {
setState(() {
isLoading = true;
});
await supabase.from('todos').update(
{'is_completed': value},
).eq('id', todo['id']);
setState(() {
todos[index]['is_completed'] = value;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('${todo['task'].toString()} updated'),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Error: $e"),
),
);
} finally {
setState(() {
isLoading = false;
});
}
},
),
);
},
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final time = DateTime.now().millisecondsSinceEpoch;
try {
setState(() {
isLoading = true;
});
await supabase.from('todos').insert(
{'task': 'Buy milk $time', 'is_completed': false},
);
fetchTodos();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Todo inserted"),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Error: $e"),
),
);
} finally {
setState(() {
isLoading = false;
});
}
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
// state variables
bool isLoading = false;
String email = 'example@mail.com';
String password = '12345678';
Future<void> signInWithKeycloak() async {
await supabase.auth.signInWithOAuth(
OAuthProvider.keycloak,
scopes: 'openid',
);
}
@override
void initState() {
super.initState();
supabase.auth.onAuthStateChange.listen((data) {
final event = data.event;
print('Auth Session: ${data.session.toString()}');
print('Auth Event: $event');
if (event == AuthChangeEvent.signedIn) {
GoRouter.of(context).pushReplacement('/home');
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Login'),
),
body: Center(
child: Column(
children: [
ElevatedButton(
onPressed: () async {
try {
setState(() {
isLoading = true;
});
final signIn = await supabase.auth.signInWithPassword(
email: email,
password: password,
);
if (signIn.user != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Signed in as ${signIn.user!.email}"),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Error: $e"),
),
);
} finally {
setState(() {
isLoading = false;
});
}
},
child: isLoading
? const CircularProgressIndicator()
: const Text('Login with Email'),
),
ElevatedButton(
onPressed: () {
signInWithKeycloak();
},
child: const Text('Login with Keycloak'),
),
],
),
),
);
}
}