Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PoC] Auth with CLEVER_API_TOKEN #901

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion bin/clever.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Application from '../src/models/application.js';
import { AVAILABLE_ZONES } from '../src/models/application.js';
import { EXPERIMENTAL_FEATURES } from '../src/experimental-features.js';
import { getExitOnOption, getOutputFormatOption, getSameCommitPolicyOption } from '../src/command-options.js';
import { getHostAndTokens } from '../src/models/send-to-api.js';
import { getFeatures, conf } from '../src/models/configuration.js';

import * as Addon from '../src/models/addon.js';
Expand Down Expand Up @@ -1005,12 +1006,22 @@ async function run () {
description: 'Revoke an API token',
args: [args.apiTokenId],
}, tokens.revoke);
const tokensCommands = cliparse.command('tokens', {
let tokensCommands = cliparse.command('tokens', {
description: `Manage API tokens to query Clever Cloud API from ${conf.AUTH_BRIDGE_HOST}`,
commands: [apiTokenCreateCommand, apiTokenRevokeCommand],
privateOptions: [opts.humanJsonOutputFormat],
}, tokens.list);

// If the user is logged in through the API bridge, we remove list/revoke tokens commands
const data = await getHostAndTokens();
if (data.tokens.apiToken != null) {
tokensCommands = cliparse.command('tokens', {
description: `Manage API tokens to query Clever Cloud API from ${conf.AUTH_BRIDGE_HOST}`,
commands: [apiTokenCreateCommand],
privateOptions: [opts.humanJsonOutputFormat],
}, tokens.showApiTokenUser);
}

// UNLINK COMMAND
const appUnlinkCommand = cliparse.command('unlink', {
description: 'Unlink this repo from an existing application',
Expand Down
10 changes: 10 additions & 0 deletions src/clever-client/auth-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ export function deleteApiToken (apiTokenId) {
});
}

export function addApiToken (token) {
return (requestParams) => {
requestParams.headers = {
...requestParams.headers,
Authorization: `Bearer ${token}`,
};
return requestParams;
};
}

export function addOauthHeaderPlaintext (tokens) {

return async function (requestParams) {
Expand Down
90 changes: 32 additions & 58 deletions src/commands/login.js
Original file line number Diff line number Diff line change
@@ -1,66 +1,40 @@
import crypto from 'node:crypto';
import util from 'node:util';

import colors from 'colors/safe.js';
import open from 'open';
import superagent from 'superagent';

import { Logger } from '../logger.js';
import * as User from '../models/user.js';
import { conf, writeOAuthConf } from '../models/configuration.js';

import { getPackageJson } from '../load-package-json.cjs';

const delay = util.promisify(setTimeout);
const pkg = getPackageJson();

// 20 random bytes as Base64URL
function randomToken () {
return crypto.randomBytes(20).toString('base64').replace(/\//g, '-').replace(/\+/g, '_').replace(/=/g, '');
}

const POLLING_INTERVAL = 2000;
const POLLING_MAX_TRY_COUNT = 60;

function pollOauthData (url, tryCount = 0) {

if (tryCount >= POLLING_MAX_TRY_COUNT) {
throw new Error('Something went wrong while trying to log you in.');
}
if (tryCount > 1 && tryCount % 10 === 0) {
Logger.println("We're still waiting for the login process (in your browser) to be completed…");
}

return superagent
.get(url)
.send()
.then(({ body }) => body)
.catch(async (e) => {
if (e.status === 404) {
await delay(POLLING_INTERVAL);
return pollOauthData(url, tryCount + 1);
}
throw new Error('Something went wrong while trying to log you in.');
});
}
import { Logger } from '../logger.js';
import { promptEmail, promptPassword } from '../prompt.js';
import { sendToAuthBridge } from '../models/send-to-api.js';
import { writeOAuthConf } from '../models/configuration.js';
import { createApiToken } from '../clever-client/auth-bridge.js';

async function loginViaConsole () {

const cliToken = randomToken();

const consoleUrl = new URL(conf.CONSOLE_TOKEN_URL);
consoleUrl.searchParams.set('cli_version', pkg.version);
consoleUrl.searchParams.set('cli_token', cliToken);

const cliPollUrl = new URL(conf.API_HOST);
cliPollUrl.pathname = '/v2/self/cli_tokens';
cliPollUrl.searchParams.set('cli_token', cliToken);

Logger.debug('Try to login to Clever Cloud…');
Logger.println(`Opening ${colors.green(consoleUrl.toString())} in your browser to log you in…`);
await open(consoleUrl.toString(), { wait: false });

return pollOauthData(cliPollUrl.toString());
const dateObject = new Date();
dateObject.setFullYear(dateObject.getFullYear() + 1);
const expirationDate = dateObject;

const name = `Clever Tools - ${dateObject.getTime()}`;
const email = await promptEmail('Enter your email:');
const password = await promptPassword('Enter your password:');
const mfaCode = await promptPassword('Enter your 2FA code (press Enter if none):');

const tokenData = {
email,
password,
mfaCode,
name,
expirationDate: expirationDate.toISOString(),
};

return createApiToken(tokenData).then(sendToAuthBridge).catch((error) => {
const errorCode = error?.cause?.responseBody?.code;
if (errorCode === 'invalid-credential') {
throw new Error('Invalid credentials, check your password');
}
if (errorCode === 'invalid-mfa-code') {
throw new Error('Invalid credentials, check your 2FA code');
}
throw error;
});
}

export async function login (params) {
Expand Down
10 changes: 7 additions & 3 deletions src/commands/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { sendToAuthBridge } from '../models/send-to-api.js';
import { getCurrent as getCurrentUser } from '../models/user.js';
import { conf } from '../models/configuration.js';
import dedent from 'dedent';
import { promptPassword } from '../prompt-password.js';
import { promptPassword } from '../prompt.js';

/**
* Create a new API token
Expand Down Expand Up @@ -76,8 +76,8 @@ export async function create (params) {

Export this token and use it to make authenticated requests to the Clever Cloud API through the Auth Bridge:

export CC_API_TOKEN=${createdToken.apiToken}
curl -H "Authorization: Bearer $CC_API_TOKEN" ${conf.AUTH_BRIDGE_HOST}/v2/self
export CLEVER_API_TOKEN=${createdToken.apiToken}
curl -H "Authorization: Bearer $CLEVER_API_TOKEN" ${conf.AUTH_BRIDGE_HOST}/v2/self

Then, to revoke this token, run:
clever tokens revoke ${createdToken.apiTokenId}
Expand Down Expand Up @@ -135,3 +135,7 @@ export async function revoke (params) {
function formatDate (dateInput) {
return new Date(dateInput).toISOString().substring(0, 16).replace('T', ' ');
}

export async function showApiTokenUser () {
Logger.println(`You're logged in with an API Token, you can just create a new one with ${colors.blue('clever tokens create')} command`);
}
3 changes: 2 additions & 1 deletion src/models/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ export async function loadOAuthConf () {
Logger.debug('Load configuration from ' + conf.CONFIGURATION_FILE);
try {
const rawFile = await fs.readFile(conf.CONFIGURATION_FILE);
const { token, secret } = JSON.parse(rawFile);
const { apiToken, token, secret } = JSON.parse(rawFile);
return {
source: 'configuration file',
apiToken,
token,
secret,
};
Expand Down
29 changes: 25 additions & 4 deletions src/models/send-to-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { conf, loadOAuthConf } from './configuration.js';
import { prefixUrl } from '@clevercloud/client/esm/prefix-url.js';
import { request } from '@clevercloud/client/esm/request.fetch.js';
import { subtle as cryptoSuble } from 'node:crypto';
import { addOauthHeaderPlaintext } from '../clever-client/auth-bridge.js';
import { addApiToken, addOauthHeaderPlaintext } from '../clever-client/auth-bridge.js';
import colors from 'colors/safe.js';

// Required for @clevercloud/client with "old" Node.js
Expand All @@ -16,6 +16,13 @@ if (globalThis.crypto == null) {

async function loadTokens () {
const tokens = await loadOAuthConf();

if (tokens.apiToken != null) {
return {
apiToken: tokens.apiToken,
};
}

return {
OAUTH_CONSUMER_KEY: conf.OAUTH_CONSUMER_KEY,
OAUTH_CONSUMER_SECRET: conf.OAUTH_CONSUMER_SECRET,
Expand All @@ -26,9 +33,17 @@ async function loadTokens () {

export async function sendToApi (requestParams) {
const tokens = await loadTokens();
let apiHost = conf.API_HOST;
let addAuthPromise = addOauthHeader(tokens);

if (process.env.CLEVER_API_TOKEN != null || tokens.apiToken != null) {
apiHost = conf.AUTH_BRIDGE_HOST;
addAuthPromise = addApiToken(process.env.CLEVER_API_TOKEN || tokens.apiToken);
}

return Promise.resolve(requestParams)
.then(prefixUrl(conf.API_HOST))
.then(addOauthHeader(tokens))
.then(prefixUrl(apiHost))
.then(addAuthPromise)
.then((requestParams) => {
Logger.debug(`${requestParams.method.toUpperCase()} ${requestParams.url} ? ${JSON.stringify(requestParams.queryParams)}`);
return requestParams;
Expand Down Expand Up @@ -66,8 +81,14 @@ export function processError (error) {

export async function getHostAndTokens () {
const tokens = await loadTokens();
let host = conf.API_HOST;

if (tokens.apiToken != null) {
host = conf.AUTH_BRIDGE_HOST;
}

return {
apiHost: conf.API_HOST,
apiHost: host,
tokens,
};
}
10 changes: 0 additions & 10 deletions src/prompt-password.js

This file was deleted.

27 changes: 27 additions & 0 deletions src/prompt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { input, password } from '@inquirer/prompts';

export function promptEmail (message) {
return input({
message,
validate: (value) => {
if (value.match(/^.+@.+\..+$/)) {
return true;
}
return 'Please enter a valid email address';
},
}).catch((error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
process.exit(1);
}
throw error;
});
}

export function promptPassword (message) {
return password({ message, mask: true }).catch((error) => {
if (error instanceof Error && error.name === 'ExitPromptError') {
process.exit(1);
}
throw error;
});
}