Skip to content

Commit

Permalink
fixup! feat(ng-dev): create auth subcommand for ng-dev
Browse files Browse the repository at this point in the history
  • Loading branch information
josephperrott committed Jul 20, 2022
1 parent 64b1fb2 commit 1993089
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 62 deletions.
40 changes: 25 additions & 15 deletions ng-dev/auth/shared/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
linkWithCredential,
GithubAuthProvider,
} from 'firebase/auth';
import {Log} from '../../utils/logging.js';
import {
deviceCodeOAuthDance,
authorizationCodeOAuthDance,
Expand All @@ -15,22 +16,31 @@ import {
export async function loginToFirebase() {
/** The type of OAuth dance to do based on whether a session display is available. */
const oAuthDance = process.env.DISPLAY ? authorizationCodeOAuthDance : deviceCodeOAuthDance;
/** The id and access tokens for Google from the oauth login. */
const {idToken, accessToken} = await oAuthDance(GoogleOAuthDanceConfig);
/** The credential generated by the GoogleAuthProvider from the OAuth tokens. */
const googleCredential = GoogleAuthProvider.credential(idToken, accessToken);
/** The newly signed in user. */
const {user} = await signInWithCredential(getAuth(), googleCredential);
try {
/** The id and access tokens for Google from the oauth login. */
const {idToken, accessToken} = await oAuthDance(GoogleOAuthDanceConfig);
/** The credential generated by the GoogleAuthProvider from the OAuth tokens. */
const googleCredential = GoogleAuthProvider.credential(idToken, accessToken);
/** The newly signed in user. */
const {user} = await signInWithCredential(getAuth(), googleCredential);

// If the user already has a github account linked to their account, the login is complete.
if (user.providerData.find((provider) => provider.providerId === 'github.com')) {
return true;
}
// If the user already has a github account linked to their account, the login is complete.
if (user.providerData.find((provider) => provider.providerId === 'github.com')) {
return true;
}

/** The access token for Github from the oauth login. */
const {accessToken: githubAccessToken} = await oAuthDance(GithubOAuthDanceConfig);
/** The access token for Github from the oauth login. */
const {accessToken: githubAccessToken} = await oAuthDance(GithubOAuthDanceConfig);

// Link the Github account to the account for the currently logged in user.
await linkWithCredential(user, GithubAuthProvider.credential(githubAccessToken));
return true;
// Link the Github account to the account for the currently logged in user.
await linkWithCredential(user, GithubAuthProvider.credential(githubAccessToken));
return true;
} catch (e) {
if (e instanceof Error) {
Log.error(`${e.name}: ${e.message}`);
} else {
Log.error(e);
}
return false;
}
}
30 changes: 27 additions & 3 deletions ng-dev/auth/shared/ng-dev-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {mkdir, readFile, stat, writeFile} from 'fs/promises';
import {homedir} from 'os';
import {join} from 'path';
import {Log} from '../../utils/logging.js';
import {randomBytes, createCipheriv, createDecipheriv} from 'crypto';

/** Algorithm to use for encryption. */
const algorithm = 'aes-256-ctr';

/** Data for an ng-dev token. */
interface NgDevToken {
Expand Down Expand Up @@ -89,7 +93,7 @@ export async function getCurrentUser() {
/** Save the token to the file system as a base64 encoded string. */
async function saveTokenToFileSystem(data: NgDevToken) {
await mkdir(tokenDir, {recursive: true});
await writeFile(tokenPath, Buffer.from(JSON.stringify(data), 'utf8').toString('base64'));
await writeFile(tokenPath, encrypt(JSON.stringify(data)));
}

/** Retrieve the token from the file system. */
Expand All @@ -98,6 +102,26 @@ async function retrieveTokenFromFileSystem(): Promise<NgDevToken | null> {
return null;
}

const rawToken = Buffer.from(await readFile(tokenPath, 'utf8'), 'base64').toString('utf8');
return JSON.parse(rawToken) as NgDevToken;
const rawToken = Buffer.from(await readFile(tokenPath)).toString();
return JSON.parse(decrypt(rawToken)) as NgDevToken;
}

/** Encrypt the provided string. */
function encrypt(text: string) {
const iv = randomBytes(16);
const key = randomBytes(32);
let cipher = createCipheriv(algorithm, key, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}

/** Decrypt the provided string. */
function decrypt(text: string) {
let textParts = text.split(':');
let key = Buffer.from(textParts.shift()!, 'hex');
let iv = Buffer.from(textParts.shift()!, 'hex');
let encryptedText = Buffer.from(textParts.join(':'), 'hex');
let decipher = createDecipheriv(algorithm, key, iv);
const decrypted = Buffer.concat([decipher.update(encryptedText), decipher.final()]);
return decrypted.toString();
}
90 changes: 46 additions & 44 deletions ng-dev/auth/shared/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import {Log} from '../../utils/logging.js';
import fetch from 'node-fetch';

import {
AuthorizationError,
AuthorizationErrorJson,
AuthorizationNotifier,
AuthorizationRequest,
AuthorizationServiceConfiguration,
BaseTokenRequestHandler,
GRANT_TYPE_AUTHORIZATION_CODE,
TokenRequest,
TokenResponse,
TokenResponseJson,
} from '@openid/appauth';
import {NodeRequestor} from '@openid/appauth/built/node_support/node_requestor.js';
import {NodeBasedHandler} from '@openid/appauth/built/node_support/node_request_handler.js';
Expand All @@ -32,16 +35,16 @@ export async function authorizationCodeOAuthDance({
authConfig,
scope,
}: OAuthDanceConfig): Promise<TokenResponse> {
if (client_id === undefined) {
throw Error();
}
/** Requestor instance for NodeJS usage. */
const requestor = new NodeRequestor();

/** Notifier to watch for authorization completion. */
const notifier = new AuthorizationNotifier();
/** Handler for node based requests. */
const authorizationHandler = new NodeBasedHandler();

authorizationHandler.setAuthorizationNotifier(notifier);

/** The authorization request. */
let request = new AuthorizationRequest({
client_id,
scope,
Expand All @@ -51,6 +54,7 @@ export async function authorizationCodeOAuthDance({
'access_type': 'offline',
},
});

authorizationHandler.performAuthorizationRequest(authConfig, request);
await authorizationHandler.completeAuthorizationRequestIfPossible();
const authorization = await authorizationHandler.authorizationPromise;
Expand Down Expand Up @@ -84,10 +88,6 @@ export async function deviceCodeOAuthDance({
deviceAuthEndpoint,
scope,
}: OAuthDanceConfig): Promise<TokenResponse> {
if (client_id === undefined) {
throw Error();
}

// Set up and configure the authentication url to initiate the OAuth dance.
const url = new URL(deviceAuthEndpoint);
url.searchParams.append('scope', scope);
Expand All @@ -101,16 +101,26 @@ export async function deviceCodeOAuthDance({
headers: {'Accept': 'application/json'},
}).then(
(resp) =>
resp.json() as unknown as {
resp.json() as Promise<{
verification_uri: string;
verification_url: string;
interval: number;
user_code: string;
expires_in: number;
device_code: string;
},
}>,
);

if (
isAuthorizationError(response) &&
(response.error === 'invalid_client' ||
response.error === 'unsupported_grant_type' ||
response.error === 'invalid_grant' ||
response.error === 'invalid_request')
) {
throw new OAuthDanceError(new AuthorizationError(response).errorDescription || 'Unknown Error');
}

Log.info(`Please visit: ${response.verification_uri || response.verification_url}`);
Log.info(`Enter your one time ID code: ${response.user_code}`);

Expand All @@ -124,59 +134,36 @@ export async function deviceCodeOAuthDance({

while (true) {
if (Date.now() > oauthDanceTimeout) {
throw {
authenticated: false,
message: 'Failed to completed OAuth authentication before the user code expired.',
};
throw new OAuthDanceError(
'Failed to completed OAuth authentication before the user code expired.',
);
}
// Wait for the requested interval before polling, this is done before the request as it is unnecessary to
//immediately poll while the user has to perform the auth out of this flow.
await new Promise((resolve) => setTimeout(resolve, response.interval * 1000 + pollingBackoff));

const result = await pollAuthServer(
const result = await checkStatusOfAuthServer(
authConfig.tokenEndpoint,
response.device_code,
client_id,
client_secret,
);
if (!result.error) {
return {
...result,
idToken: result.id_token,
accessToken: result.access_token,
};

if (!isAuthorizationError(result)) {
return new TokenResponse(result);
}
if (result.error === 'access_denied') {
throw {
authenticated: false,
message: 'Unable to authorize, as access was denied during the OAuth flow.',
};
}

if (result.error === 'authorization_pending') {
// Update messaging.
throw new OAuthDanceError('Unable to authorize, as access was denied during the OAuth flow.');
}

if (result.error === 'slow_down') {
// Update messaging.
Log.debug('"slow_down" response from server, backing off polling interval by 5 seconds');
pollingBackoff += 5000;
}

if (
result.error === 'invalid_client' ||
result.error === 'unsupported_grant_type' ||
result.error === 'invalid_grant' ||
result.error === 'invalid_request'
) {
throw {
authenticated: false,
message: result.errorDescription,
};
}
}
}

async function pollAuthServer(
async function checkStatusOfAuthServer(
serverUrl: string,
deviceCode: string,
clientId: string,
Expand All @@ -193,7 +180,7 @@ async function pollAuthServer(
return await fetch(url.toString(), {
method: 'POST',
headers: {'Accept': 'application/json'},
}).then((x) => x.json() as Promise<any>);
}).then((x) => x.json() as Promise<TokenResponseJson | AuthorizationErrorJson>);
}

// NOTE: the `client_secret`s are okay to be included in this code as these values are sent
Expand Down Expand Up @@ -242,3 +229,18 @@ export const GithubOAuthDanceConfig: OAuthDanceConfig = {
}),
deviceAuthEndpoint: 'https://github.com/login/device/code',
};

class OAuthDanceError extends Error {
constructor(message: string) {
super(message);
}
}

function isAuthorizationError<T>(
result: T | AuthorizationErrorJson,
): result is AuthorizationErrorJson {
if ((result as AuthorizationErrorJson).error !== undefined) {
return true;
}
return false;
}

0 comments on commit 1993089

Please sign in to comment.