Keycloak to IndieAuth translator (mirror of https://git.jbc.lol/jbcrn/KcIA)
at main 258 lines 7.9 kB view raw
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 })