clients.js (3489B)
1 import crypto from 'node:crypto'; 2 3 const VIRTUAL_CLIENTS = { 4 admin: { 5 id: 'virtual:admin', 6 client_id: 'admin', 7 name: 'Admin', 8 access_mode: 'admin', 9 enabled: 1, 10 require_pkce: 1, 11 token_endpoint_auth_method: 'none', 12 redirect_uris: [], 13 post_logout_redirect_uris: [], 14 branding: {}, 15 required_roles: [], 16 virtual: true, 17 }, 18 account: { 19 id: 'virtual:account', 20 client_id: 'account', 21 name: 'Account', 22 access_mode: 'public', 23 enabled: 1, 24 require_pkce: 1, 25 token_endpoint_auth_method: 'none', 26 redirect_uris: [], 27 post_logout_redirect_uris: [], 28 branding: {}, 29 required_roles: [], 30 virtual: true, 31 }, 32 }; 33 34 function parseJson(raw, fallback) { 35 try { 36 return JSON.parse(raw || 'null') ?? fallback; 37 } catch { 38 return fallback; 39 } 40 } 41 42 export function hashClientSecret(secret) { 43 return crypto.createHash('sha256').update(secret).digest('hex'); 44 } 45 46 export function isVirtualClient(clientId) { 47 return Object.hasOwn(VIRTUAL_CLIENTS, clientId); 48 } 49 50 export async function getClient(db, clientId) { 51 const virtual = VIRTUAL_CLIENTS[clientId]; 52 if (virtual) return { ...virtual }; 53 54 const row = await db.get( 55 `SELECT id, client_id, name, redirect_uris, scopes, access_mode, enabled, 56 client_secret_hash, token_endpoint_auth_method, require_pkce, 57 post_logout_redirect_uris, branding 58 FROM oidc_clients 59 WHERE client_id = {clientId}`, 60 { clientId } 61 ); 62 if (!row) return null; 63 64 const roles = await db.all( 65 `SELECT r.slug 66 FROM oidc_client_roles cr 67 JOIN roles r ON r.id = cr.role_id 68 WHERE cr.client_id = {id}`, 69 { id: row.id } 70 ); 71 72 return { 73 ...row, 74 enabled: Boolean(row.enabled), 75 require_pkce: Boolean(row.require_pkce), 76 redirect_uris: parseJson(row.redirect_uris, []), 77 post_logout_redirect_uris: parseJson(row.post_logout_redirect_uris, []), 78 branding: parseJson(row.branding, {}), 79 required_roles: roles.map((role) => role.slug), 80 virtual: false, 81 }; 82 } 83 84 export function redirectUriAllowed(client, redirectUri, virtualCallbacks = []) { 85 if (client.virtual) return virtualCallbacks.includes(redirectUri); 86 return client.redirect_uris.includes(redirectUri); 87 } 88 89 export function postLogoutRedirectAllowed(client, redirectUri, virtualCallbacks = []) { 90 if (!redirectUri) return true; 91 if (client.virtual) return virtualCallbacks.includes(redirectUri); 92 return client.post_logout_redirect_uris.includes(redirectUri); 93 } 94 95 export function authenticateClient(req, client, body = {}) { 96 const method = client.token_endpoint_auth_method || 'none'; 97 98 if (method === 'none') { 99 return !body.client_id || body.client_id === client.client_id; 100 } 101 102 if (method !== 'client_secret_basic' || !client.client_secret_hash) { 103 return false; 104 } 105 106 const header = String(req.headers.authorization || ''); 107 if (!header.startsWith('Basic ')) return false; 108 109 let decoded; 110 try { 111 decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); 112 } catch { 113 return false; 114 } 115 116 const colon = decoded.indexOf(':'); 117 if (colon === -1) return false; 118 const clientId = decoded.slice(0, colon); 119 const secret = decoded.slice(colon + 1); 120 if (clientId !== client.client_id) return false; 121 122 const expected = Buffer.from(client.client_secret_hash, 'hex'); 123 const actual = Buffer.from(hashClientSecret(secret), 'hex'); 124 return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); 125 }