UNPKG

solid-curl

Version:

Command line application mimicking the behaviour of cURL but authenticating using Solid-OIDC

318 lines (281 loc) 9.65 kB
import { Session } from '@inrupt/solid-client-authn-node'; import process from 'process'; import { program } from 'commander'; import { createReadStream } from 'fs'; import logger from 'loglevel'; import { Writable } from 'stream'; import { deletePassword, findCredentials, getPassword, setPassword } from 'keytar'; import { question } from 'readline-sync';; import { Parser, Quad, Store, DataFactory } from 'n3'; import { printTable } from 'console-table-printer'; import { lookup } from 'mime-types'; const { namedNode } = DataFactory; const version = '0.1.11'; // Remove draft warning from oidc-client lib process.emitWarning = (warning: any, ...args: any) => { if (args[0] === 'DraftWarning') { return; } return process.emitWarning(warning, ...args); }; // Command line arguments program .version(version, '-V, --version', 'Show version number and quit') .argument('<uri>', 'Target URI') .option('-d, --data <data>', 'HTTP POST data') //.option('-f, --fail', 'Fail silently (no output at all) on HTTP errors') .option('-H, --header <header...>', 'Add header to request') .option('-i, --include', 'Include HTTP response headers in output') .option('-L, --location', 'Follow redirects') //.option('-o, --output <file>', 'Write to file instead of stdout') //.option('-O, --remote-name', 'Write output to a file named as the remote file') .option('-s, --silent', 'Silent mode') //.option('-T, --transfer-file <file>', 'Transfer local FILE to destination') .option('-u, --user <identity>', 'Use stored identity') .option('-u, -- <identity>', 'Use stored identity') //.option('-A, --user-agent <name>', 'Send User-Agent <name> to server') .option('-v, --verbose', 'Make the operation more talkative') .option('-X, --request <method>', 'Specify custom request method') .action(run); program .command('register-user') .argument('<uri>', 'WebID') .action(registerUser); program .command('delete-user') .argument('<identity>', 'Identity name') .action(deleteUser); program .command('list-users') .action(listUsers); program.parseAsync(); async function run(uri: string, options: any) { if(options.verbose) { logger.setLevel('info'); } if(options.silent) { logger.setLevel('silent'); } const session = new Session(); let headers: Record<string,string> = { 'accept': 'text/turtle, */*;q=0.8', 'user-agent': 'solid-curl/' + version, 'accept-encoding': 'gzip,deflate', 'connection': 'close', 'host': uri.split('/').slice(2,3).join() }; let fetchInit: RequestInit = { redirect: options.location ? 'follow' : 'manual' }; // Loading data from file if necessary let data = options.data?.startsWith('@') ? createReadStream(options.data.substring(1)) : options.data; if(data) { fetchInit['body'] = data; (fetchInit as RequestInit & { duplex: string })['duplex'] = 'half'; // Default method with data is POST fetchInit['method'] = 'POST'; // Determine MIME type (can be overwritten by the user), default text/turtle headers['content-type'] = lookup(options.data.substring(1)) || 'text/turtle'; } // Setting method if(options.request) { fetchInit['method'] = options.request; } // Transforming headers into format needed by fetch for(let h of options?.header || []) { let split = h.split(':') headers[split[0].toLowerCase()] = split.slice(1).join().trim(); } fetchInit['headers'] = headers; const user = options.user; // Do unauthenticated request when no user is provided if(!user) { logger.info('* No user identity given, doing unauthenticated request'); await doFetch(uri, fetchInit, headers, session, process.stdout, options?.include); process.exit(); } // Get credentials from storage let credentials = await findCredentials('solid-curl'); if(!credentials.some(c => c.account === user)) { logger.error('No credentials with name \'' + user + '\' found!'); process.exit(1); } let creds = JSON.parse(credentials.find(c => c.account === user)!.password); // Log in let oidcIssuer = creds['oidcIssuer']; logger.info(`* Initiating OIDC login at ${oidcIssuer}`); await session.login({ oidcIssuer: oidcIssuer, clientId: creds['id'], clientSecret: creds['secret'] }); await doFetch(uri, fetchInit, headers, session, process.stdout, options?.include); process.exit(); } async function listUsers() { let credentials = await findCredentials('solid-curl'); let prettyCredentials = credentials.map(c => { let creds = JSON.parse(c.password); return { Identity: c.account, WebID: creds['webId'], 'OIDC Issuer': creds['oidcIssuer'], ClientID: creds['id'] }; }); printTable(prettyCredentials); } async function registerUser(webId: string) { let oidcIssuer = await getOIDCIssuer(webId); let credentials = await registerApp(oidcIssuer); console.log('Successfully created credentials!') let identity = question('Identity name: ') setPassword('solid-curl', identity, JSON.stringify({ webId: webId, oidcIssuer: oidcIssuer, id: credentials.id, secret: credentials.secret })); } async function deleteUser(identity: string) { let creds = await getPassword('solid-curl', identity); if(creds == null) { throw Error('Identity "' + identity + '" not found!'); } let credsParsed = JSON.parse(creds); let oidcIssuer = credsParsed['oidcIssuer']; let clientId = credsParsed['id']; await deregisterApp(oidcIssuer, clientId); deletePassword('solid-curl', identity); } interface ClientCredentials { id: string, secret: string } async function registerApp(oidcIssuer: string): Promise<ClientCredentials> { if(!oidcIssuer.endsWith('/')) { oidcIssuer += '/'; } // Try Community Solid Server let response = await fetch(oidcIssuer + 'idp/credentials/') if(response.status == 405) { console.log('Authenticating with ' + oidcIssuer + ' (Community Solid Server):'); let email = question('E-Mail: '); let password = question('Password: ', { hideEchoBack: true }); response = await fetch(oidcIssuer + 'idp/credentials/', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: email, password: password, name: 'solid-curl' }) }); if(response.status == 200) { return await response.json(); } else { throw Error(await response.text()); } } throw Error('No client registration could be found for the OIDC issuer!'); } async function deregisterApp(oidcIssuer: string, clientId: string) { // Try Community Solid Server let response = await fetch(oidcIssuer + 'idp/credentials/') if(response.status == 405) { console.log('Authenticating with ' + oidcIssuer + ' (Community Solid Server):'); let email = question('E-Mail: '); let password = question('Password: ', { hideEchoBack: true }); response = await fetch(oidcIssuer + 'idp/credentials/', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: email, password: password, delete: clientId }) }); if(response.status == 200) { return await response.json(); } else { throw Error(await response.text()); } } throw Error('No client registration could be found for the OIDC issuer!'); } async function getOIDCIssuer(webId: string): Promise<string> { let response = await fetch(webId); let quads = await parseResponse(response); let store = new Store(); store.addQuads(quads); let issuers = store.getObjects(namedNode(webId), namedNode('http://www.w3.org/ns/solid/terms#oidcIssuer'), null); if(issuers.length > 0) { return issuers[0].value; } else { throw Error('No OIDC issuer in Profile Document found!'); } } async function parseResponse(response: Response): Promise<Quad[]> { return new Promise(async (resolve, reject) => { let quads: Quad[] = []; let parser = new Parser({ baseIRI: response.url }); let text: string = await response.text(); parser.parse(text, (error, quad) => { if(error) { reject(error); } if(quad) { quads.push(quad); } else { resolve(quads); } }); }); } async function doFetch(uri: string, fetchInit: RequestInit, headers: Record<string,string>, session: Session, outStream: Writable, include: boolean) { // Do actual request logger.info(`> ${fetchInit.method} /${uri.split('/').slice(3).join('/')} HTTP/1.1`); for(let h in headers) { // Make header names upercase for logging logger.info(`> ${h.split('-').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}: ${headers[h]}`); } // If request was authenticated, add placeholder for DPoP if(session.info.isLoggedIn) { logger.info(`> Authorization: DPoP [omitted]`); } logger.info('>'); try { let res = await session.fetch(uri, fetchInit); logger.info(`< HTTP/1.1 ${res.status} ${res.statusText}`); if(include) { outStream.write(`HTTP/1.1 ${res.status} ${res.statusText}\n`); } for(let h of res.headers) { logger.info(`< ${h[0].split('-').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}: ${h[1]}`); if(include) { outStream.write(`${'\x1b[1m'}${h[0].split('-').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}${'\x1b[0m'}: ${h[1]}\n`); } } if(include) { outStream.write(`\n`); } logger.info('<'); let buffer = Buffer.from(await res.arrayBuffer()) outStream.write(buffer); } catch(error: any) { if(error['errno'] === 'ENOTFOUND') { logger.error(`Could not resolve host: ${uri.split('/').slice(2,3).join()}`) } else if(error['errno'] === 'ECONNREFUSED') { logger.error(`Connection refused: ${uri.split('/').slice(2,3).join()}`) } else { logger.error(error); } } }