#48: add support for prompt and login_hint oauth params

This commit is contained in:
SleepWalker 2016-11-19 16:41:15 +02:00
parent 2beab5b6bc
commit 6498858d33
14 changed files with 173 additions and 36 deletions

View File

@ -450,17 +450,23 @@ class PanelTransition extends Component {
export default connect((state) => {
const {login} = state.auth;
const user = {
...state.user,
isGuest: true,
email: '',
username: ''
let user = {
...state.user
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
if (login) {
user = {
...user,
isGuest: true,
email: '',
username: ''
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
}
}
return {

View File

@ -145,6 +145,7 @@ export function clearErrors() {
}
export { logout, updateUser } from 'components/user/actions';
export { authenticate } from 'components/accounts/actions';
/**
* @param {object} oauthData
@ -153,6 +154,13 @@ export { logout, updateUser } from 'components/user/actions';
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
* Posible values:
* * none - default behaviour
* * consent - forcibly prompt user for rules acceptance
* * select_account - force account choosage, even if user has only one
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
* The possible values: account id, email, username
* @param {string} oauthData.state
*
* @return {Promise}
@ -163,8 +171,17 @@ export function oAuthValidate(oauthData) {
return wrapInLoader((dispatch) =>
oauth.validate(oauthData)
.then((resp) => {
let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim);
if (prompt.includes('none')) {
prompt = ['none'];
}
dispatch(setClient(resp.client));
dispatch(setOAuthRequest(resp.oAuth));
dispatch(setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
loginHint: oauthData.loginHint
}));
dispatch(setScopes(resp.session.scopes));
localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow
timestamp: Date.now(),
@ -246,6 +263,8 @@ export function setOAuthRequest(oauth) {
redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type,
scope: oauth.scope,
prompt: oauth.prompt,
loginHint: oauth.loginHint,
state: oauth.state
}
};

View File

@ -114,6 +114,8 @@ function oauth(
redirectUrl: payload.redirectUrl,
responseType: payload.responseType,
scope: payload.scope,
prompt: payload.prompt,
loginHint: payload.loginHint,
state: payload.state
};

View File

@ -90,7 +90,10 @@ function restoreScroll() {
/* global process: false */
if (process.env.NODE_ENV !== 'production') {
// some shortcuts for testing on localhost
window.testOAuth = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email';
window.testOAuth = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&login_hint=${loginHint}`;
window.testOAuthPromptAccount = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account';
window.testOAuthPromptPermissions = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=consent&login_hint=${loginHint}`;
window.testOAuthPromptAll = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account,consent';
window.testOAuthStatic = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page_with_code&response_type=code&scope=account_info%2Caccount_email';
window.testOAuthStaticCode = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page&response_type=code&scope=account_info%2Caccount_email';

View File

@ -57,6 +57,8 @@ function getOAuthRequest(oauthData) {
response_type: oauthData.responseType,
description: oauthData.description,
scope: oauthData.scope,
prompt: oauthData.prompt,
login_hint: oauthData.loginHint,
state: oauthData.state
};
}

View File

@ -192,8 +192,8 @@ export default class AuthFlow {
* @return {bool} - whether oauth state is being restored
*/
restoreOAuthState() {
if (this.getRequest().path.indexOf('/register') === 0) {
// allow register
if (/^\/(register|oauth2)/.test(this.getRequest().path)) {
// allow register or the new oauth requests
return;
}

View File

@ -8,6 +8,7 @@ export default class ChooseAccountState extends AbstractState {
}
resolve(context, payload) {
// do not ask again after user adds account, or chooses an existed one
context.run('setAccountSwitcher', false);
if (payload.id) {

View File

@ -6,6 +6,9 @@ import ActivationState from './ActivationState';
import AcceptRulesState from './AcceptRulesState';
import FinishState from './FinishState';
const PROMPT_ACCOUNT_CHOOSE = 'select_account';
const PROMPT_PERMISSIONS = 'consent';
export default class CompleteState extends AbstractState {
constructor(options = {}) {
super(options);
@ -23,7 +26,33 @@ export default class CompleteState extends AbstractState {
} else if (user.shouldAcceptRules) {
context.setState(new AcceptRulesState());
} else if (auth.oauth && auth.oauth.clientId) {
if (auth.isSwitcherEnabled && accounts.available.length > 1) {
let isSwitcherEnabled = auth.isSwitcherEnabled;
if (auth.oauth.loginHint) {
const account = accounts.available.filter((account) =>
account.id === auth.oauth.loginHint * 1
|| account.email === auth.oauth.loginHint
|| account.username === auth.oauth.loginHint
)[0];
if (account) {
// disable switching, because we are know the account, user must be authorized with
context.run('setAccountSwitcher', false);
isSwitcherEnabled = false;
if (account.id !== accounts.active.id) {
// lets switch user to an account, that is needed for auth
return context.run('authenticate', account)
.then(() => context.setState(new CompleteState()));
}
}
}
if (isSwitcherEnabled
&& (accounts.available.length > 1
|| auth.oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE)
)
) {
context.setState(new ChooseAccountState());
} else if (auth.oauth.code) {
context.setState(new FinishState());
@ -31,7 +60,7 @@ export default class CompleteState extends AbstractState {
const data = {};
if (typeof this.isPermissionsAccepted !== 'undefined') {
data.accept = this.isPermissionsAccepted;
} else if (auth.oauth.acceptRequired) {
} else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) {
context.setState(new PermissionsState());
return;
}

View File

@ -11,6 +11,8 @@ export default class OAuthState extends AbstractState {
responseType: query.response_type,
description: query.description,
scope: query.scope,
prompt: query.prompt,
loginHint: query.login_hint,
state: query.state
}).then(() => context.setState(new CompleteState()));
}

View File

@ -79,7 +79,11 @@ describe('components/auth/actions', () => {
callThunk(oAuthValidate, oauthData).then(() => {
expectDispatchCalls([
[setClient(resp.client)],
[setOAuthRequest(resp.oAuth)],
[setOAuthRequest({
...resp.oAuth,
prompt: 'none',
loginHint: undefined
})],
[setScopes(resp.session.scopes)]
]);
})
@ -102,7 +106,7 @@ describe('components/auth/actions', () => {
return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&state=',
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=',
{}
]);
});

View File

@ -84,7 +84,8 @@ describe('AuthFlow.functional', () => {
auth: {
oauth: {
clientId: 123
clientId: 123,
prompt: []
}
}
});

View File

@ -0,0 +1,56 @@
import ChooseAccountState from 'services/authFlow/ChooseAccountState';
import CompleteState from 'services/authFlow/CompleteState';
import LoginState from 'services/authFlow/LoginState';
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
describe('ChooseAccountState', () => {
let state;
let context;
let mock;
beforeEach(() => {
state = new ChooseAccountState();
const data = bootstrap();
context = data.context;
mock = data.mock;
});
afterEach(() => {
mock.verify();
});
describe('#enter', () => {
it('should navigate to /oauth/choose-account', () => {
expectNavigate(mock, '/oauth/choose-account');
state.enter(context);
});
});
describe('#resolve', () => {
it('should transition to complete if existed account was choosen', () => {
expectRun(mock, 'setAccountSwitcher', false);
expectState(mock, CompleteState);
state.resolve(context, {id: 123});
});
it('should transition to login if user wants to add new account', () => {
expectRun(mock, 'setAccountSwitcher', false);
expectNavigate(mock, '/login');
expectState(mock, LoginState);
state.resolve(context, {});
});
});
describe('#reject', () => {
it('should logout', () => {
expectRun(mock, 'logout');
state.reject(context);
});
});
});

View File

@ -144,7 +144,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -166,7 +167,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -194,7 +196,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -225,7 +228,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -242,21 +246,21 @@ describe('CompleteState', () => {
return promise.catch(mock.verify.bind(mock));
};
it('should transition to finish state if rejected with static_page', () => {
return testOAuth('resolve', {redirectUri: 'static_page'}, FinishState);
});
it('should transition to finish state if rejected with static_page', () =>
testOAuth('resolve', {redirectUri: 'static_page'}, FinishState)
);
it('should transition to finish state if rejected with static_page_with_code', () => {
return testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState);
});
it('should transition to finish state if rejected with static_page_with_code', () =>
testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState)
);
it('should transition to login state if rejected with unauthorized', () => {
return testOAuth('reject', {unauthorized: true}, LoginState);
});
it('should transition to login state if rejected with unauthorized', () =>
testOAuth('reject', {unauthorized: true}, LoginState)
);
it('should transition to permissions state if rejected with acceptRequired', () => {
return testOAuth('reject', {acceptRequired: true}, PermissionsState);
});
it('should transition to permissions state if rejected with acceptRequired', () =>
testOAuth('reject', {acceptRequired: true}, PermissionsState)
);
});
describe('permissions accept', () => {
@ -285,7 +289,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -309,7 +314,8 @@ describe('CompleteState', () => {
},
auth: {
oauth: {
clientId: 'ely.by'
clientId: 'ely.by',
prompt: []
}
}
});
@ -337,6 +343,7 @@ describe('CompleteState', () => {
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
acceptRequired: true
}
}
@ -365,6 +372,7 @@ describe('CompleteState', () => {
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
acceptRequired: true
}
}

View File

@ -28,6 +28,8 @@ describe('OAuthState', () => {
response_type: 'response_type',
description: 'description',
scope: 'scope',
prompt: 'none',
login_hint: 1,
state: 'state'
};
@ -42,6 +44,8 @@ describe('OAuthState', () => {
responseType: query.response_type,
description: query.description,
scope: query.scope,
prompt: query.prompt,
loginHint: query.login_hint,
state: query.state
})
).returns({then() {}});