SSO login for generic clients and CLI (#140)
* sso * move break into try block * make client id dynamic * clientId is a string, DOH! * reject if port not available * lint fixes
This commit is contained in:
parent
101c5688c4
commit
7d49902eea
|
@ -4132,6 +4132,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"is-docker": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ=="
|
||||||
|
},
|
||||||
"is-extendable": {
|
"is-extendable": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||||
|
@ -4265,6 +4270,14 @@
|
||||||
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
|
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"is-wsl": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||||
|
"requires": {
|
||||||
|
"is-docker": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"isarray": {
|
"isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
@ -6272,6 +6285,15 @@
|
||||||
"mimic-fn": "^1.0.0"
|
"mimic-fn": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"open": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/open/-/open-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-lLPI5KgOwEYCDKXf4np7y1PBEkj7HYIyP2DY8mVDRnx0VIIu6bNrRB0R66TuO7Mack6EnTNLm4uvcl1UoklTpA==",
|
||||||
|
"requires": {
|
||||||
|
"is-docker": "^2.0.0",
|
||||||
|
"is-wsl": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"opencollective": {
|
"opencollective": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/opencollective/-/opencollective-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/opencollective/-/opencollective-1.0.3.tgz",
|
||||||
|
|
|
@ -93,6 +93,7 @@
|
||||||
"ngx-infinite-scroll": "7.0.1",
|
"ngx-infinite-scroll": "7.0.1",
|
||||||
"node-fetch": "2.2.0",
|
"node-fetch": "2.2.0",
|
||||||
"node-forge": "0.7.6",
|
"node-forge": "0.7.6",
|
||||||
|
"open": "7.1.0",
|
||||||
"papaparse": "4.6.0",
|
"papaparse": "4.6.0",
|
||||||
"rxjs": "6.3.3",
|
"rxjs": "6.3.3",
|
||||||
"tldjs": "2.3.1",
|
"tldjs": "2.3.1",
|
||||||
|
|
|
@ -31,7 +31,10 @@ export class SsoComponent {
|
||||||
protected twoFactorRoute = '2fa';
|
protected twoFactorRoute = '2fa';
|
||||||
protected successRoute = 'lock';
|
protected successRoute = 'lock';
|
||||||
protected changePasswordRoute = 'change-password';
|
protected changePasswordRoute = 'change-password';
|
||||||
|
protected clientId: string;
|
||||||
protected redirectUri: string;
|
protected redirectUri: string;
|
||||||
|
protected state: string;
|
||||||
|
protected codeChallenge: string;
|
||||||
|
|
||||||
constructor(protected authService: AuthService, protected router: Router,
|
constructor(protected authService: AuthService, protected router: Router,
|
||||||
protected i18nService: I18nService, protected route: ActivatedRoute,
|
protected i18nService: I18nService, protected route: ActivatedRoute,
|
||||||
|
@ -50,6 +53,12 @@ export class SsoComponent {
|
||||||
if (qParams.code != null && codeVerifier != null && state != null && state === qParams.state) {
|
if (qParams.code != null && codeVerifier != null && state != null && state === qParams.state) {
|
||||||
await this.logIn(qParams.code, codeVerifier);
|
await this.logIn(qParams.code, codeVerifier);
|
||||||
}
|
}
|
||||||
|
} else if (qParams.clientId != null && qParams.redirectUri != null && qParams.state != null &&
|
||||||
|
qParams.codeChallenge != null) {
|
||||||
|
this.redirectUri = qParams.redirectUri;
|
||||||
|
this.state = qParams.state;
|
||||||
|
this.codeChallenge = qParams.codeChallenge;
|
||||||
|
this.clientId = qParams.clientId;
|
||||||
}
|
}
|
||||||
if (queryParamsSub != null) {
|
if (queryParamsSub != null) {
|
||||||
queryParamsSub.unsubscribe();
|
queryParamsSub.unsubscribe();
|
||||||
|
@ -66,16 +75,21 @@ export class SsoComponent {
|
||||||
numbers: true,
|
numbers: true,
|
||||||
special: false,
|
special: false,
|
||||||
};
|
};
|
||||||
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
let codeChallenge = this.codeChallenge;
|
||||||
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
let state = this.state;
|
||||||
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, 'sha256');
|
if (codeChallenge == null) {
|
||||||
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, 'sha256');
|
||||||
await this.storageService.save(ConstantsService.ssoCodeVerifierKey, codeVerifier);
|
codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||||
await this.storageService.save(ConstantsService.ssoStateKey, state);
|
await this.storageService.save(ConstantsService.ssoCodeVerifierKey, codeVerifier);
|
||||||
|
await this.storageService.save(ConstantsService.ssoStateKey, state);
|
||||||
|
}
|
||||||
|
if (state == null) {
|
||||||
|
state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
}
|
||||||
|
|
||||||
const authorizeUrl = this.apiService.identityBaseUrl + '/connect/authorize?' +
|
const authorizeUrl = this.apiService.identityBaseUrl + '/connect/authorize?' +
|
||||||
'client_id=web&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' +
|
'client_id=' + this.clientId + '&redirect_uri=' + encodeURIComponent(this.redirectUri) + '&' +
|
||||||
'response_type=code&scope=api offline_access&' +
|
'response_type=code&scope=api offline_access&' +
|
||||||
'state=' + state + '&code_challenge=' + codeChallenge + '&' +
|
'state=' + state + '&code_challenge=' + codeChallenge + '&' +
|
||||||
'code_challenge_method=S256&response_mode=query&' +
|
'code_challenge_method=S256&response_mode=query&' +
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import * as program from 'commander';
|
import * as program from 'commander';
|
||||||
|
import * as http from 'http';
|
||||||
import * as inquirer from 'inquirer';
|
import * as inquirer from 'inquirer';
|
||||||
|
|
||||||
import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
|
import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
|
||||||
|
@ -8,55 +9,91 @@ import { TwoFactorEmailRequest } from '../../models/request/twoFactorEmailReques
|
||||||
|
|
||||||
import { ApiService } from '../../abstractions/api.service';
|
import { ApiService } from '../../abstractions/api.service';
|
||||||
import { AuthService } from '../../abstractions/auth.service';
|
import { AuthService } from '../../abstractions/auth.service';
|
||||||
|
import { CryptoFunctionService } from '../../abstractions/cryptoFunction.service';
|
||||||
|
import { EnvironmentService } from '../../abstractions/environment.service';
|
||||||
import { I18nService } from '../../abstractions/i18n.service';
|
import { I18nService } from '../../abstractions/i18n.service';
|
||||||
|
import { PasswordGenerationService } from '../../abstractions/passwordGeneration.service';
|
||||||
|
|
||||||
import { Response } from '../models/response';
|
import { Response } from '../models/response';
|
||||||
|
|
||||||
import { MessageResponse } from '../models/response/messageResponse';
|
import { MessageResponse } from '../models/response/messageResponse';
|
||||||
|
|
||||||
import { NodeUtils } from '../../misc/nodeUtils';
|
import { NodeUtils } from '../../misc/nodeUtils';
|
||||||
|
import { Utils } from '../../misc/utils';
|
||||||
|
|
||||||
|
// tslint:disable-next-line
|
||||||
|
const open = require('open');
|
||||||
|
|
||||||
export class LoginCommand {
|
export class LoginCommand {
|
||||||
protected validatedParams: () => Promise<any>;
|
protected validatedParams: () => Promise<any>;
|
||||||
protected success: () => Promise<MessageResponse>;
|
protected success: () => Promise<MessageResponse>;
|
||||||
|
protected canInteract: boolean;
|
||||||
|
protected clientId: string;
|
||||||
|
|
||||||
|
private ssoRedirectUri: string = null;
|
||||||
|
|
||||||
constructor(protected authService: AuthService, protected apiService: ApiService,
|
constructor(protected authService: AuthService, protected apiService: ApiService,
|
||||||
protected i18nService: I18nService) { }
|
protected i18nService: I18nService, protected environmentService: EnvironmentService,
|
||||||
|
protected passwordGenerationService: PasswordGenerationService,
|
||||||
|
protected cryptoFunctionService: CryptoFunctionService) { }
|
||||||
|
|
||||||
async run(email: string, password: string, cmd: program.Command) {
|
async run(email: string, password: string, cmd: program.Command) {
|
||||||
const canInteract = process.env.BW_NOINTERACTION !== 'true';
|
this.canInteract = process.env.BW_NOINTERACTION !== 'true';
|
||||||
if ((email == null || email === '') && canInteract) {
|
|
||||||
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
|
|
||||||
type: 'input',
|
|
||||||
name: 'email',
|
|
||||||
message: 'Email address:',
|
|
||||||
});
|
|
||||||
email = answer.email;
|
|
||||||
}
|
|
||||||
if (email == null || email.trim() === '') {
|
|
||||||
return Response.badRequest('Email address is required.');
|
|
||||||
}
|
|
||||||
if (email.indexOf('@') === -1) {
|
|
||||||
return Response.badRequest('Email address is invalid.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password == null || password === '') {
|
let ssoCodeVerifier: string = null;
|
||||||
if (cmd.passwordfile) {
|
let ssoCode: string = null;
|
||||||
password = await NodeUtils.readFirstLine(cmd.passwordfile);
|
if (cmd.sso != null && this.canInteract) {
|
||||||
} else if (cmd.passwordenv && process.env[cmd.passwordenv]) {
|
const passwordOptions: any = {
|
||||||
password = process.env[cmd.passwordenv];
|
type: 'password',
|
||||||
} else if (canInteract) {
|
length: 64,
|
||||||
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
|
uppercase: true,
|
||||||
type: 'password',
|
lowercase: true,
|
||||||
name: 'password',
|
numbers: true,
|
||||||
message: 'Master password:',
|
special: false,
|
||||||
});
|
};
|
||||||
password = answer.password;
|
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
|
||||||
|
const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, 'sha256');
|
||||||
|
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
|
||||||
|
try {
|
||||||
|
ssoCode = await this.getSsoCode(codeChallenge, state);
|
||||||
|
} catch {
|
||||||
|
return Response.badRequest('Something went wrong. Try again.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ((email == null || email === '') && this.canInteract) {
|
||||||
|
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
|
||||||
|
type: 'input',
|
||||||
|
name: 'email',
|
||||||
|
message: 'Email address:',
|
||||||
|
});
|
||||||
|
email = answer.email;
|
||||||
|
}
|
||||||
|
if (email == null || email.trim() === '') {
|
||||||
|
return Response.badRequest('Email address is required.');
|
||||||
|
}
|
||||||
|
if (email.indexOf('@') === -1) {
|
||||||
|
return Response.badRequest('Email address is invalid.');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (password == null || password === '') {
|
if (password == null || password === '') {
|
||||||
return Response.badRequest('Master password is required.');
|
if (cmd.passwordfile) {
|
||||||
|
password = await NodeUtils.readFirstLine(cmd.passwordfile);
|
||||||
|
} else if (cmd.passwordenv && process.env[cmd.passwordenv]) {
|
||||||
|
password = process.env[cmd.passwordenv];
|
||||||
|
} else if (this.canInteract) {
|
||||||
|
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
|
||||||
|
type: 'password',
|
||||||
|
name: 'password',
|
||||||
|
message: 'Master password:',
|
||||||
|
});
|
||||||
|
password = answer.password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password == null || password === '') {
|
||||||
|
return Response.badRequest('Master password is required.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let twoFactorToken: string = cmd.code;
|
let twoFactorToken: string = cmd.code;
|
||||||
|
@ -76,10 +113,20 @@ export class LoginCommand {
|
||||||
|
|
||||||
let response: AuthResult = null;
|
let response: AuthResult = null;
|
||||||
if (twoFactorToken != null && twoFactorMethod != null) {
|
if (twoFactorToken != null && twoFactorMethod != null) {
|
||||||
response = await this.authService.logInComplete(email, password, twoFactorMethod,
|
if (ssoCode != null && ssoCodeVerifier != null) {
|
||||||
twoFactorToken, false);
|
response = await this.authService.logInSsoComplete(ssoCode, ssoCodeVerifier, this.ssoRedirectUri,
|
||||||
|
twoFactorMethod, twoFactorToken, false);
|
||||||
|
} else {
|
||||||
|
response = await this.authService.logInComplete(email, password, twoFactorMethod,
|
||||||
|
twoFactorToken, false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
response = await this.authService.logIn(email, password);
|
if (ssoCode != null && ssoCodeVerifier != null) {
|
||||||
|
response = await this.authService.logInSso(ssoCode, ssoCodeVerifier, this.ssoRedirectUri);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
response = await this.authService.logIn(email, password);
|
||||||
|
}
|
||||||
if (response.twoFactor) {
|
if (response.twoFactor) {
|
||||||
let selectedProvider: any = null;
|
let selectedProvider: any = null;
|
||||||
const twoFactorProviders = this.authService.getSupportedTwoFactorProviders(null);
|
const twoFactorProviders = this.authService.getSupportedTwoFactorProviders(null);
|
||||||
|
@ -98,7 +145,7 @@ export class LoginCommand {
|
||||||
if (selectedProvider == null) {
|
if (selectedProvider == null) {
|
||||||
if (twoFactorProviders.length === 1) {
|
if (twoFactorProviders.length === 1) {
|
||||||
selectedProvider = twoFactorProviders[0];
|
selectedProvider = twoFactorProviders[0];
|
||||||
} else if (canInteract) {
|
} else if (this.canInteract) {
|
||||||
const options = twoFactorProviders.map((p) => p.name);
|
const options = twoFactorProviders.map((p) => p.name);
|
||||||
options.push(new inquirer.Separator());
|
options.push(new inquirer.Separator());
|
||||||
options.push('Cancel');
|
options.push('Cancel');
|
||||||
|
@ -128,7 +175,7 @@ export class LoginCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (twoFactorToken == null) {
|
if (twoFactorToken == null) {
|
||||||
if (canInteract) {
|
if (this.canInteract) {
|
||||||
const answer: inquirer.Answers =
|
const answer: inquirer.Answers =
|
||||||
await inquirer.createPromptModule({ output: process.stderr })({
|
await inquirer.createPromptModule({ output: process.stderr })({
|
||||||
type: 'input',
|
type: 'input',
|
||||||
|
@ -162,4 +209,49 @@ export class LoginCommand {
|
||||||
return Response.error(e);
|
return Response.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getSsoCode(codeChallenge: string, state: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const callbackServer = http.createServer((req, res) => {
|
||||||
|
const urlString = 'http://localhost' + req.url;
|
||||||
|
const url = new URL(urlString);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const receivedState = url.searchParams.get('state');
|
||||||
|
res.setHeader('Content-Type', 'text/html');
|
||||||
|
if (code != null && receivedState != null && receivedState === state) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('<html><head><title>Success | Bitwarden CLI</title></head><body>' +
|
||||||
|
'<h1>Successfully authenticated with the Bitwarden CLI</h1>' +
|
||||||
|
'<p>You may now close this tab and return to the terminal.</p>' +
|
||||||
|
'</body></html>');
|
||||||
|
callbackServer.close(() => resolve(code));
|
||||||
|
} else {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('<html><head><title>Failed | Bitwarden CLI</title></head><body>' +
|
||||||
|
'<h1>Something went wrong logging into the Bitwarden CLI</h1>' +
|
||||||
|
'<p>You may now close this tab and return to the terminal.</p>' +
|
||||||
|
'</body></html>');
|
||||||
|
callbackServer.close(() => reject());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let foundPort = false;
|
||||||
|
const webUrl = this.environmentService.webVaultUrl == null ? 'https://vault.bitwarden.com' :
|
||||||
|
this.environmentService.webVaultUrl;
|
||||||
|
for (let port = 8065; port <= 8070; port++) {
|
||||||
|
try {
|
||||||
|
this.ssoRedirectUri = 'http://localhost:' + port;
|
||||||
|
callbackServer.listen(port, async () => {
|
||||||
|
await open(webUrl + '/#/sso?clientId=' + this.clientId +
|
||||||
|
'&redirectUri=' + encodeURIComponent(this.ssoRedirectUri) +
|
||||||
|
'&state=' + state + '&codeChallenge=' + codeChallenge);
|
||||||
|
});
|
||||||
|
foundPort = true;
|
||||||
|
break;
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
if (!foundPort) {
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -261,7 +261,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
|
async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
|
||||||
const policies: Policy[] = await this.policyService.getAll(PolicyType.PasswordGenerator);
|
const policies: Policy[] = this.policyService == null ? null :
|
||||||
|
await this.policyService.getAll(PolicyType.PasswordGenerator);
|
||||||
let enforcedOptions: PasswordGeneratorPolicyOptions = null;
|
let enforcedOptions: PasswordGeneratorPolicyOptions = null;
|
||||||
|
|
||||||
if (policies == null || policies.length === 0) {
|
if (policies == null || policies.length === 0) {
|
||||||
|
|
Loading…
Reference in New Issue