Skip to content

Commit

Permalink
feat(Auth): Authentication utilities for new Identity system
Browse files Browse the repository at this point in the history
  • Loading branch information
medikoo committed May 6, 2022
1 parent 76c6719 commit d699e52
Show file tree
Hide file tree
Showing 10 changed files with 570 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -12,6 +12,7 @@ npm install @serverless/utils

- [`account`](docs/account.md)
- [`analyticsAndNotificationsUrl`](docs/analytics-and-notifications-url.md)
- [`auth`](docs/auth.md)
- [`cloudformationSchema`](docs/cloudformation-schema.md)
- [`config`](docs/config.md)
- [`download`](docs/download.md)
Expand Down
148 changes: 148 additions & 0 deletions auth/login.js
@@ -0,0 +1,148 @@
'use strict';

const _ = require('lodash');
const fetch = require('node-fetch');
const open = require('open');
const wait = require('timers-ext/promise/sleep');
const ServerlessError = require('../serverless-error');
const log = require('../log').log.get('auth');
const { style } = require('../log');
const configUtils = require('../config');
const urls = require('../lib/auth/urls');

const createLoginSession = async () => {
const response = await (async () => {
try {
return await fetch(`${urls.backend}/api/identity/auth/login-sessions`, { method: 'POST' });
} catch (error) {
log.debug('Server unavailable', error);
throw new ServerlessError(
'Serverless Console is not available, please try again later',
'CONSOLE_SERVER_UNAVAILABLE'
);
}
})();
if (!response.ok) {
log.debug('Cannot create login session', response.status);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
throw new ServerlessError(
'Cannot initialize login session at this point, please try again later',
'CONSOLE_LOGIN_SESSION_INITIALIZATION_REJECTED'
);
}
const responseObject = await (async () => {
try {
return await response.json();
} catch (error) {
log.debug('Cannot resolve response JSON', error);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
throw new ServerlessError(
'Cannot initialize login session due to unexpected server response, please try again later',
'CONSOLE_LOGIN_SESSION_UNEXPECTED_RESPONSE_TYPE'
);
}
})();
if (!_.get(responseObject, 'sessionId')) {
log.debug('Unepxected response value', responseObject);
throw new ServerlessError(
'Cannot initialize login session due to unexpected server response, please try again later',
'CONSOLE_LOGIN_SESSION_INVALID_RESPONSE'
);
}
return responseObject.sessionId;
};

const getRefreshToken = async (sessionId) => {
const response = await (async () => {
try {
return await fetch(`${urls.backend}/api/identity/auth/login-sessions/${sessionId}`);
} catch (error) {
log.debug('Server unavailable', error);
throw new ServerlessError(
'Serverless Console is not available, please try again later',
'CONSOLE_SERVER_UNAVAILABLE'
);
}
})();
if (!response.ok) {
log.debug('Canot retrieve refresh token', response.status);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
throw new ServerlessError(
'Cannot login at this point, please try again later',
'CONSOLE_LOGIN_REJECTED'
);
}
const responseObject = await (async () => {
try {
return await response.json();
} catch (error) {
log.debug('Canot resolve response JSON', error);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
throw new ServerlessError(
'Cannot login due to unexpected server response, please try again later',
'CONSOLE_LOGIN_UNEXPECTED_RESPONSE_TYPE'
);
}
})();
if (!_.get(responseObject, 'status')) {
log.debug('Unexpected response value', responseObject);
throw new ServerlessError(
'Cannot initialize login session due to unexpected server response, please try again later',
'CONSOLE_LOGIN_INVALID_RESPONSE'
);
}
if (responseObject.status === 'FAILED') {
log.debug('Login rejected', responseObject);
throw new ServerlessError(
'Login was rejected, please try again later',
'CONSOLE_LOGIN_REJECTED'
);
}
if (responseObject.refreshToken) return responseObject.refreshToken;
const expiresAt = Date.parse(responseObject.expiresAt);
if (expiresAt < Date.now() + 1500) {
throw new ServerlessError(
'Login session timed out, please try again later',
'CONSOLE_LOGIN_TIMEOUT'
);
}

await wait(1000);
return getRefreshToken(sessionId);
};

module.exports = async () => {
log.notice('Logging into the Serverless Console via the browser');

const sessionId = await createLoginSession();
const loginUrl = `${urls.frontend}?client=cli&transactionId=${sessionId}`;
open(loginUrl);
log.notice(
style.aside('If your browser does not open automatically, please open this URL:', loginUrl)
);

const refreshToken = await getRefreshToken(sessionId);

configUtils.set('auth', { refreshToken });

log.notice();
log.notice.success("You are now logged into the Serverless Console'");
log.notice();
log.notice('Learn more at https://www.serverless.com/console/docs');
};
15 changes: 15 additions & 0 deletions auth/logout.js
@@ -0,0 +1,15 @@
'use strict';

const log = require('../log').log.get('auth');
const configUtils = require('../config');

module.exports = () => {
if (!configUtils.get('auth.refreshToken')) {
log.notice.skip('You are already logged out');
return;
}
configUtils.delete('auth');
log.notice('Logging into the Serverless Console via the browser');

log.notice.success('You are now logged out of the Serverless Console');
};
108 changes: 108 additions & 0 deletions auth/resolve-id-token.js
@@ -0,0 +1,108 @@
'use strict';

const _ = require('lodash');
const jwtDecode = require('jwt-decode');
const fetch = require('node-fetch');
const configUtils = require('../config');
const log = require('../log').log.get('auth');
const ServerlessError = require('../serverless-error');
const backendUrl = require('../lib/auth/urls').backend;
const logout = require('./logout');

// Assume single user session per process
const data = {};
let idTokenExpiresAt;

if (process.env.SLS_ORG_TOKEN) {
data.idToken = process.env.SLS_ORG_TOKEN;
idTokenExpiresAt = Infinity;
}

module.exports = async () => {
if (!data.idToken) {
Object.assign(data, configUtils.get('auth'));
if (data.idToken) {
const idTokenData = jwtDecode(data.idToken);
log.debug('id token data: %o', idTokenData);
idTokenExpiresAt = idTokenData.exp * 1000;
}
}
if (data.idToken) {
if (idTokenExpiresAt > Date.now() + 500) return data.idToken;
configUtils.delete('auth.idToken');
idTokenExpiresAt = null;
}
if (!data.refreshToken) Object.assign(data, configUtils.get('auth'));
if (!data.refreshToken) {
throw new ServerlessError(
'You are not currently logged in. To log in, use: $ serverless login',
'CONSOLE_LOGGED_OUT'
);
}

const response = await (async () => {
try {
return await fetch(`${backendUrl}/api/identity/auth/tokens/refresh`, {
method: 'POST',
body: JSON.stringify({ refreshToken: data.refreshToken }),
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
log.debug('Server unavailable', error);
throw new ServerlessError(
'Console server is not avaiable, please try again later',
'CONSOLE_SERVER_UNAVAILABLE'
);
}
})();
if (!response.ok) {
log.debug('Canot resolve idToken', response.status);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
if (response.status < 500) {
logout();
delete data.refreshToken;
throw new ServerlessError(
'Cannot resolve authentication token, please login again',
'CONSOLE_ID_TOKEN_RETRIEVAL_REJECTED'
);
}
throw new ServerlessError(
'Cannot resolve Console authentication token, please try again later',
'CONSOLE_ID_TOKEN_RETRIEVAL_FAILED'
);
}
const responseObject = await (async () => {
try {
return await response.json();
} catch (error) {
log.debug('Canot resolve response JSON', error);
try {
log.debug('Server response text', await response.text());
} catch {
/* ignore */
}
throw new ServerlessError(
'Cannot resolve Console authentication token, please try again later',
'CONSOLE_ID_TOKEN_RETRIEVAL_UNEXPECTED_RESPONSE_TYPE'
);
}
})();
const idToken = _.get(responseObject, 'idToken');
if (!idToken) {
log.debug('Unexpected response value', responseObject);
throw new ServerlessError(
'Cannot initialize login session due to unexpected server response, please try again later',
'CONSOLE_LOGIN_SESSION_INVALID_RESPONSE'
);
}
data.idToken = idToken;
const idTokenData = jwtDecode(data.idToken);
log.debug('id token data: %o', idTokenData);
idTokenExpiresAt = idTokenData.exp * 1000;
configUtils.set('auth.idToken', idToken);
return idToken;
};
40 changes: 40 additions & 0 deletions docs/auth.md
@@ -0,0 +1,40 @@
## Serverless Inc. authentication utilities

### `login`

Initialize login session (which opens login window in a browser). After user logs in, CLI receives refresh token, which in furtger turn can be used to [retrieve short living id tokens (for further interaction with Serverless Inc. API)](#resolveidtoken)

```javascript
const login = require('@serverless/utils/auth/login');
...
await login();
// User logged in successfully
```

### `logout`

Logout (clear stored refresh token).

```javascript
const logout = require('@serverless/utils/auth/logout');
...
logout();
// No logged in user
```

### `resolveIdToken`

Resolve valid `idToken` required for any exchange with Serverless Inc. API.
Once resolved, tokens are cached for instant resolution until they expire.

_Note: Resolved token should never be stored in outer logic. In all cases all calls to API should hit this utility directly to retrieve the id token._

Token is resolved either via _refresh token_ stored for logged-in CLI users or is assumed from `SLS_ORG_TOKEN` environment variable where in CI/CD cases non expiring token is expected to be provided

```javascript
const resolveIdToken = require('@serverless/utils/auth/resolve-id-token');
...
const responseData = await someServerlessIncApiCall.request({
idToken: await resolveIdToken()
});
```
24 changes: 24 additions & 0 deletions lib/auth/urls.js
@@ -0,0 +1,24 @@
'use strict';

const config = new Map([
[
'dev',
{
backend: 'https://core.serverless-dev.com',
frontend: 'https://console.serverless-dev.com',
},
],
[
'prod',
{
backend: 'https://core.serverless.com',
frontend: 'https://console.serverless.com',
},
],
]);

const stage = config.has(process.env.SERVERLESS_PLATFORM_STAGE)
? process.env.SERVERLESS_PLATFORM_STAGE
: 'prod';

module.exports = config.get(stage);
6 changes: 4 additions & 2 deletions package.json
Expand Up @@ -29,8 +29,11 @@
"make-dir": "^3.1.0",
"memoizee": "^0.4.15",
"ncjsm": "^4.3.0",
"node-fetch": "^2.6.7",
"open": "^7.4.2",
"p-event": "^4.2.0",
"supports-color": "^8.1.1",
"timers-ext": "^0.1.7",
"type": "^2.6.0",
"uni-global": "^1.0.0",
"uuid": "^8.3.2",
Expand Down Expand Up @@ -58,8 +61,7 @@
"random-buffer": "^0.1.0",
"sinon": "^13.0.1",
"sinon-chai": "^3.7.0",
"standard-version": "^9.3.2",
"timers-ext": "^0.1.7"
"standard-version": "^9.3.2"
},
"eslintConfig": {
"extends": "@serverless/eslint-config/node",
Expand Down

0 comments on commit d699e52

Please sign in to comment.