Skip to content

Commit

Permalink
feat: Relax expectations around redirect URL and port to better suppo…
Browse files Browse the repository at this point in the history
…rt installed app client IDs and use ephemeral ports when possible (#141)
  • Loading branch information
sqrrrl committed Jun 20, 2022
1 parent 6bf952a commit 5948e33
Show file tree
Hide file tree
Showing 2 changed files with 62 additions and 48 deletions.
96 changes: 62 additions & 34 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {URL} from 'url';
import * as opn from 'open';
import arrify = require('arrify');
import destroyer = require('server-destroy');
import {AddressInfo} from 'net';

const invalidRedirectUri = `The provided keyfile does not define a valid
redirect URI. There must be at least one redirect URI defined, and this sample
Expand All @@ -32,6 +33,10 @@ your keyfile, and add a 'redirect_uris' section. For example:
]
`;

function isAddressInfo(addr: string | AddressInfo | null): addr is AddressInfo {
return (addr as AddressInfo).port !== undefined;
}

export interface LocalAuthOptions {
keyfilePath: string;
scopes: string[] | string;
Expand All @@ -53,58 +58,81 @@ export async function authenticate(
);
}

options.scopes = arrify(options.scopes || []);

// eslint-disable-next-line @typescript-eslint/no-var-requires
const keyFile = require(options.keyfilePath);
const keys = keyFile.installed || keyFile.web;
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
throw new Error(invalidRedirectUri);
}
const redirectUri = keys.redirect_uris[keys.redirect_uris.length - 1];
const parts = new URL(redirectUri);
if (
redirectUri.length === 0 ||
parts.port !== '3000' ||
parts.hostname !== 'localhost' ||
parts.pathname !== '/oauth2callback'
) {
const redirectUri = new URL(keys.redirect_uris[0] ?? 'http://localhost');
if (redirectUri.hostname !== 'localhost') {
throw new Error(invalidRedirectUri);
}

// create an oAuth client to authorize the API call
const client = new OAuth2Client({
clientId: keys.client_id,
clientSecret: keys.client_secret,
redirectUri,
});
// grab the url that will be used for authorization
const authorizeUrl = client.generateAuthUrl({
access_type: 'offline',
scope: options.scopes.join(' '),
});

return new Promise((resolve, reject) => {
const server = http
.createServer(async (req, res) => {
try {
if (req.url!.indexOf('/oauth2callback') > -1) {
const qs = new URL(req.url!, 'http://localhost:3000').searchParams;
res.end('Authentication successful! Please return to the console.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(server as any).destroy();
const {tokens} = await client.getToken(qs.get('code')!);
client.credentials = tokens;
resolve(client);
}
} catch (e) {
reject(e);
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url!, 'http://localhost:3000');
if (url.pathname !== redirectUri.pathname) {
res.end('Invalid callback URL');
return;
}
})
.listen(3000, () => {
// open the browser to the authorize url to start the workflow
opn(authorizeUrl, {wait: false}).then(cp => cp.unref());
const searchParams = url.searchParams;
if (searchParams.has('error')) {
res.end('Authorization rejected.');
reject(new Error(searchParams.get('error')!));
return;
}
if (!searchParams.has('code')) {
res.end('No authentication code provided.');
reject(new Error('Cannot read authentication code.'));
return;
}

const code = searchParams.get('code');
const {tokens} = await client.getToken({
code: code!,
redirect_uri: redirectUri.toString(),
});
client.credentials = tokens;
resolve(client);
res.end('Authentication successful! Please return to the console.');
} catch (e) {
reject(e);
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(server as any).destroy();
}
});

let listenPort = 3000;
if (keyFile.installed) {
// Use emphemeral port if not a web client
listenPort = 0;
} else if (redirectUri.port !== '') {
listenPort = Number(redirectUri.port);
}

server.listen(listenPort, () => {
const address = server.address();
if (isAddressInfo(address)) {
redirectUri.port = String(address.port);
}
const scopes = arrify(options.scopes || []);
// open the browser to the authorize url to start the workflow
const authorizeUrl = client.generateAuthUrl({
redirect_uri: redirectUri.toString(),
access_type: 'offline',
scope: scopes.join(' '),
});
opn(authorizeUrl, {wait: false}).then(cp => cp.unref());
});
destroyer(server);
});
}
14 changes: 0 additions & 14 deletions test/test.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,6 @@ describe('🔑 authenticate', () => {
);
});

it('should throw if the keyfile has an invalid redirectUrl', async () => {
const keyfilePath = path.join(
__dirname,
'../../test/fixtures/keys-invalid-redirect.json'
);
await assert.rejects(
nlaTypes.authenticate({
keyfilePath: keyfilePath,
scopes: [],
}),
/The provided keyfile does not define/
);
});

it('should surface errors if the server returns an error', async () => {
callbackUrl = 'http://localhost:3000/oauth2callback';
await assert.rejects(
Expand Down

0 comments on commit 5948e33

Please sign in to comment.