solid-curl
Version:
Command line application mimicking the behaviour of cURL but authenticating using Solid-OIDC
318 lines (281 loc) • 9.65 kB
text/typescript
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);
}
}
}