Keycloak to IndieAuth translator (mirror of https://git.jbc.lol/jbcrn/KcIA)
1 import * as cheerio from 'cheerio';
2 import 'dotenv/config';
3 import express from 'express';
4 import KcAdminClient from '@keycloak/keycloak-admin-client';
5 import { mf2 } from 'microformats-parser';
6
7 const app = express();
8 app.use(express.urlencoded({ extended: true }));
9 app.use(express.json());
10
11 const kcAdminClient = new KcAdminClient({
12 baseUrl: process.env.KC_URL,
13 realmName: process.env.KC_REALM
14 });
15
16 const credentials = {
17 grantType: 'client_credentials',
18 clientId: process.env.KC_ADMIN_CLIENT_ID,
19 clientSecret: process.env.KC_ADMIN_CLIENT_SECRET,
20 };
21 await kcAdminClient.auth(credentials);
22
23 setInterval(() => kcAdminClient.auth(credentials), 58 * 1000);
24
25 app.get('/', (req, res) => {
26 res.send('KcIA (alpha)')
27 })
28
29 const callbacks = new Map();
30
31 app.get('/callback', async (req, res) => {
32 let { state } = req.query;
33 let callbackUrl = callbacks.get(state);
34 if (!callbackUrl) {
35 return res.status(400).json({
36 error: 'invalid_state',
37 error_description: 'State does not exist, try to reauthenticate'
38 });
39 }
40 if (!req.query.error) {
41 let params = new URLSearchParams({ ...req.query, iss: `https://${process.env.KCIA_HOST}/auth` })
42 res.redirect(`${callbackUrl}?${params}`);
43 } else {
44 res.redirect(`${callbackUrl}?${new URLSearchParams({...req.query})}`);
45 }
46 })
47
48 app.get('/auth', async (req, res) => {
49 let { me, scope, client_id, redirect_uri, state, code_challenge, code_challenge_method } = req.query;
50
51 if (!client_id || !redirect_uri) {
52 return res.status(400).json({
53 error: 'invalid_request',
54 error_description: 'Missing or invalid required parameters'
55 });
56 }
57
58 if (!client_id.startsWith("http")) {
59 client_id = "https://" + client_id
60 }
61
62 if (!redirect_uri.startsWith("http")) {
63 redirect_uri = "https://" + redirect_uri
64 }
65
66 callbacks.set(state, redirect_uri);
67
68 const clientUri = new URL(client_id).hostname.replaceAll('.', '-');
69
70 let clientName = clientUri.hostname, clientIcon, clientDesc = "";
71
72 let url = new URL(client_id);
73 url.pathname = '/'
74
75 try {
76 let clientRes = await fetch(client_id);
77 if (!clientRes.ok) throw new Error();
78 if (clientRes.headers.get('Content-Type').includes('json')) {
79 const json = await clientRes.json();
80 clientName = json.client_name;
81 clientIcon = json.client_logo ?? json.logo_uri;
82
83 try {
84 let homepageRes = await fetch(url);
85 const html = await homepageRes.text();
86 const cheerioParsed = cheerio.load(html);
87 if (!clientIcon) {
88 clientIcon = cheerioParsed('link[rel*="icon"]').attr('href');
89 }
90 if (!clientName) {
91 clientName = cheerioParsed('title').text();
92 }
93 if (!clientDesc) {
94 clientDesc = cheerioParsed('meta[property="og:description"]').attr('content');
95 }
96 if (!clientDesc) {
97 clientDesc = cheerioParsed('meta[name="description"]').attr('content');
98 }
99 } catch {}
100 } else {
101 const html = await clientRes.text();
102 const parsed = mf2(html, { baseUrl: client_id });
103 const cheerioParsed = cheerio.load(html);
104 const hApp = parsed.items.find(x => x.type.includes('h-app') || x.type.includes('h-x-app'))?.properties;
105 if (hApp) {
106 clientIcon = hApp.logo?.[0];
107 clientName = hApp.name?.[0];
108 clientDesc = hApp.summary?.[0];
109 }
110
111 // can't parse h-app? use meta tags instead
112 if (!clientIcon) {
113 clientIcon = cheerioParsed('link[rel*="icon"]').attr('href');
114 }
115 if (!clientName) {
116 clientName = cheerioParsed('title').text();
117 }
118 if (!clientDesc) {
119 clientDesc = cheerioParsed('meta[property="og:description"]').attr('content');
120 }
121 if (!clientDesc) {
122 clientDesc = cheerioParsed('meta[name="description"]').attr('content');
123 }
124
125 clientIcon = (clientIcon.startsWith('/')) ? new URL(clientIcon, url).href : clientIcon;
126 console.log(clientIcon, url);
127 }
128 } catch {
129 clientIcon = undefined;
130 clientName = new URL(client_id).hostname;
131 }
132
133 console.log(clientIcon, clientName)
134
135 try {
136 await kcAdminClient.clients.create({
137 clientId: clientUri,
138 baseUrl: url.href,
139 name: clientName,
140 redirectUris: [
141 redirect_uri,
142 `https://${process.env.KCIA_HOST}/callback`
143 ],
144 clientAuthenticatorType: "Client Id and Secret",
145 attributes: {
146 logoUri: clientIcon ?? ""
147 },
148 alwaysDisplayInConsole: true,
149 consentRequired: true,
150 publicClient: true,
151 description: clientDesc
152 });
153 } catch (error) {
154 // do not log: assume client already created
155 }
156
157 const params = new URLSearchParams({
158 client_id: clientUri,
159 redirect_uri: `https://${process.env.KCIA_HOST}/callback`,
160 scope: `openid website${scope ? ' ' + scope : ''}`,
161 state: state,
162 response_type: 'code',
163 website: me,
164 prompt: 'consent'
165 });
166
167 // Add PKCE params only if present
168 if (code_challenge) {
169 params.append('code_challenge', code_challenge);
170 params.append('code_challenge_method', code_challenge_method || 'S256');
171 }
172
173 res.redirect(`${process.env.KC_URL}/realms/${process.env.KC_REALM}/protocol/openid-connect/auth?${params}`)
174 })
175
176 app.post('/auth', async (req, res) => {
177 let { code, client_id, redirect_uri, code_verifier } = req.body;
178
179 if (!code || !client_id || !redirect_uri) {
180 return res.status(400).json({
181 error: 'invalid_request',
182 error_description: 'Missing required parameters'
183 });
184 }
185
186 if (!client_id.startsWith("http")) {
187 client_id = "https://" + client_id
188 }
189
190 try {
191 const clientUri = new URL(client_id).hostname.replaceAll('.', '-');
192
193 const tokenResponse = await fetch(
194 `${process.env.KC_URL}/realms/${process.env.KC_REALM}/protocol/openid-connect/token`,
195 {
196 method: 'POST',
197 headers: {
198 'Content-Type': 'application/x-www-form-urlencoded',
199 },
200 body: new URLSearchParams({
201 grant_type: 'authorization_code',
202 client_id: clientUri,
203 code: code,
204 redirect_uri: `https://${process.env.KCIA_HOST}/callback`,
205 code_verifier: code_verifier
206 }),
207 }
208 );
209
210 if (!tokenResponse.ok) {
211 console.error(await tokenResponse.json());
212 return res.status(400).json({
213 error: 'invalid_grant',
214 error_description: 'Failed to exchange authorization code'
215 });
216 }
217
218 const tokenData = await tokenResponse.json();
219
220 const userInfoResponse = await fetch(
221 `${process.env.KC_URL}/realms/${process.env.KC_REALM}/protocol/openid-connect/userinfo`,
222 {
223 headers: {
224 'Authorization': `Bearer ${tokenData.access_token}`,
225 },
226 }
227 );
228
229 if (!userInfoResponse.ok) {
230 return res.status(400).json({
231 error: 'server_error',
232 error_description: 'Failed to fetch user info'
233 });
234 }
235
236 const userInfo = await userInfoResponse.json();
237
238 const meUrl = userInfo.website || `${process.env.BASE_URL}/users/${userInfo.sub}`;
239
240 return res.json({
241 ...tokenData,
242 me: meUrl,
243 profile: userInfo.profile,
244 email: userInfo.email,
245 });
246
247 } catch (error) {
248 console.error('Error in POST /auth:', error);
249 return res.status(500).json({
250 error: 'server_error',
251 error_description: 'Internal server error'
252 });
253 }
254 })
255
256 app.listen(process.env.KCIA_PORT, () => {
257 console.log('KcIA ready at port', process.env.KCIA_PORT)
258 })