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

Feature/authenticator for devise token auth #1

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Expand Up @@ -35,7 +35,7 @@ install:
script:
# Usually, it's ok to finish the test scenario without reverting
# to the addon's original dependency state, skipping "cleanup".
- ember try:one $EMBER_TRY_SCENARIO test --skip-cleanup
- ember try:one $EMBER_TRY_SCENARIO test && npm run nodetest && npm run fastboot-nodetest

notifications:
email: false
Expand Down
17 changes: 16 additions & 1 deletion README.md
Expand Up @@ -540,7 +540,9 @@ store if `localStorage` is available.

[The Cookie store](http://ember-simple-auth.com/api/classes/CookieStore.html)
stores its data in a cookie. This is used by the adaptive store if
`localStorage` is not available.
`localStorage` is not available. __This store must be used when the
application uses
[FastBoot](https://github.com/ember-fastboot/ember-cli-fastboot).__

#### Ephemeral Store

Expand Down Expand Up @@ -590,6 +592,19 @@ export default Base.extend({
});
```

## FastBoot

Ember Simple Auth works with FastBoot out of the box as long as the Cookie
session store is being used. In order to enable the cookie store, define it as
the application store:

```js
// app/session-stores/application.js
import CookieStore from 'ember-simple-auth/session-stores/cookie';

export default CookieStore.extend();
```

## Testing

Ember Simple Auth comes with a __set of test helpers that can be used in
Expand Down
99 changes: 99 additions & 0 deletions addon/authenticators/devise-token-auth.js
@@ -0,0 +1,99 @@
import Ember from 'ember';
import DeviseAuthenticator from './devise';

const { RSVP: { Promise }, isEmpty, run } = Ember;

/**
Authenticator that works with the Ruby gem
[devise_token_auth](https://github.com/lynndylanhurley/devise_token_auth)
which does token-based authentication. Read more about the [Token Header format](https://github.com/lynndylanhurley/devise_token_auth#token-header-format).

@class DeviseTokenAuthAuthenticator
@module ember-simple-auth/authenticators/devise-token-auth
@extends DeviseAuthenticator
@public
*/
export default DeviseAuthenticator.extend({
/**
The [devise_token_auth](https://github.com/lynndylanhurley/devise_token_auth)-mounted
endpoint on the server that the authentication request is sent to.

@property serverTokenEndpoint
@type String
@default 'auth/sign_in'
@public
*/
serverTokenEndpoint: 'auth/sign_in',

/**
The attribute in the session data that represents the authentication
token. __This will be used in the request and also be expected in the
server's response.__

@property tokenAttributeName
@type String
@default 'accessToken'
@public
*/
tokenAttributeName: 'accessToken',

/**
Authenticates the session with the specified `identification` and
`password`; the credentials are `POST`ed to the
{{#crossLink "DeviseAuthenticator/serverTokenEndpoint:property"}}server{{/crossLink}}.
If the credentials are valid the server will respond with a
{{#crossLink "DeviseAuthenticator/tokenAttributeName:property"}}token{{/crossLink}}
and
{{#crossLink "DeviseAuthenticator/identificationAttributeName:property"}}identification{{/crossLink}}.
__If the credentials are valid and authentication succeeds, a promise that
resolves with the server's response is returned__, otherwise a promise that
rejects with the server error is returned.

@method authenticate
@param {String} identification The user's identification
@param {String} password The user's password
@return {Ember.RSVP.Promise} A promise that when it resolves results in the session becoming authenticated
@public
*/
authenticate(identification, password) {
return new Promise((resolve, reject) => {
const useResponse = this.get('rejectWithResponse');
const { resourceName, identificationAttributeName, tokenAttributeName } = this.getProperties('resourceName', 'identificationAttributeName', 'tokenAttributeName');
const data = {};
data[resourceName] = { password };
data[resourceName][identificationAttributeName] = identification;

this.makeRequest(data).then((response) => {
if (response.ok) {
response.json().then((json) => {
if (this._validate(json)) {
const resourceName = this.get('resourceName');
const _json = json[resourceName] ? json[resourceName] : json;

// DeviseTokenAuth: now add accessToken, client
_json.accessToken = response.headers.get('access-token');
_json.client = response.headers.get('client');

run(null, resolve, _json);
} else {
run(null, reject, `Check that server response includes ${tokenAttributeName} and ${identificationAttributeName}`);
}
});
} else {
if (useResponse) {
run(null, reject, response);
} else {
response.json().then((json) => run(null, reject, json));
}
}
}).catch((error) => run(null, reject, error));
});
},

// Validate that 'client' is also in the given data
_validate(data) {
const resourceName = this.get('resourceName');
const _data = data[resourceName] ? data[resourceName] : data;
return !isEmpty(_data.client) && this._super(...arguments);
}
});
73 changes: 49 additions & 24 deletions addon/authenticators/devise.js
@@ -1,9 +1,12 @@
import Ember from 'ember';
import BaseAuthenticator from './base';
import fetch from 'ember-network/fetch';

const { RSVP: { Promise }, isEmpty, run, $: jQuery, assign: emberAssign, merge } = Ember;
const { RSVP: { Promise }, isEmpty, run, assign: emberAssign, merge, computed } = Ember;
const assign = emberAssign || merge;

const JSON_CONTENT_TYPE = 'application/json';

/**
Authenticator that works with the Ruby gem
[devise](https://github.com/plataformatec/devise).
Expand Down Expand Up @@ -71,9 +74,24 @@ export default BaseAuthenticator.extend({
@property rejectWithXhr
@type Boolean
@default false
@deprecated DeviseAuthenticator/rejectWithResponse:property
@public
*/
rejectWithXhr: computed.deprecatingAlias('rejectWithResponse'),

/**
When authentication fails, the rejection callback is provided with the whole
fetch response object instead of it's response JSON or text.

This is useful for cases when the backend provides additional context not
available in the response body.

@property rejectWithResponse
@type Boolean
@default false
@public
*/
rejectWithXhr: false,
rejectWithResponse: false,

/**
Restores the session from a session data object; __returns a resolving
Expand Down Expand Up @@ -112,24 +130,31 @@ export default BaseAuthenticator.extend({
*/
authenticate(identification, password) {
return new Promise((resolve, reject) => {
const useXhr = this.get('rejectWithXhr');
const useResponse = this.get('rejectWithResponse');
const { resourceName, identificationAttributeName, tokenAttributeName } = this.getProperties('resourceName', 'identificationAttributeName', 'tokenAttributeName');
const data = {};
data[resourceName] = { password };
data[resourceName][identificationAttributeName] = identification;

return this.makeRequest(data).then(
(response) => {
if (this._validate(response)) {
const resourceName = this.get('resourceName');
const _response = response[resourceName] ? response[resourceName] : response;
run(null, resolve, _response);
this.makeRequest(data).then((response) => {
if (response.ok) {
response.json().then((json) => {
if (this._validate(json)) {
const resourceName = this.get('resourceName');
const _json = json[resourceName] ? json[resourceName] : json;
run(null, resolve, _json);
} else {
run(null, reject, `Check that server response includes ${tokenAttributeName} and ${identificationAttributeName}`);
}
});
} else {
if (useResponse) {
run(null, reject, response);
} else {
run(null, reject, `Check that server response includes ${tokenAttributeName} and ${identificationAttributeName}`);
response.json().then((json) => run(null, reject, json));
}
},
(xhr) => run(null, reject, useXhr ? xhr : (xhr.responseJSON || xhr.responseText))
);
}
}).catch((error) => run(null, reject, error));
});
},

Expand All @@ -149,25 +174,25 @@ export default BaseAuthenticator.extend({

@method makeRequest
@param {Object} data The request data
@param {Object} options Ajax configuration object merged into argument of `$.ajax`
@return {jQuery.Deferred} A promise like jQuery.Deferred as returned by `$.ajax`
@param {Object} options request options that are passed to `fetch`
@return {Promise} The promise returned by `fetch`
@protected
*/
makeRequest(data, options) {
const serverTokenEndpoint = this.get('serverTokenEndpoint');
makeRequest(data, options = {}) {
let url = options.url || this.get('serverTokenEndpoint');
let requestOptions = {};
let body = JSON.stringify(data);
assign(requestOptions, {
url: serverTokenEndpoint,
type: 'POST',
dataType: 'json',
data,
beforeSend(xhr, settings) {
xhr.setRequestHeader('Accept', settings.accepts.json);
body,
method: 'POST',
headers: {
'accept': JSON_CONTENT_TYPE,
'content-type': JSON_CONTENT_TYPE
}
});
assign(requestOptions, options || {});

return jQuery.ajax(requestOptions);
return fetch(url, requestOptions);
},

_validate(data) {
Expand Down
65 changes: 45 additions & 20 deletions addon/authenticators/oauth2-password-grant.js
@@ -1,6 +1,7 @@
/* jscs:disable requireDotNotation */
import Ember from 'ember';
import BaseAuthenticator from './base';
import fetch from 'ember-network/fetch';

const {
RSVP,
Expand All @@ -11,7 +12,6 @@ const {
assign: emberAssign,
merge,
A,
$: jQuery,
testing,
warn,
keys: emberKeys
Expand Down Expand Up @@ -137,9 +137,24 @@ export default BaseAuthenticator.extend({
@property rejectWithXhr
@type Boolean
@default false
@deprecated OAuth2PasswordGrantAuthenticator/rejectWithResponse:property
@public
*/
rejectWithXhr: false,
rejectWithXhr: computed.deprecatingAlias('rejectWithResponse'),

/**
When authentication fails, the rejection callback is provided with the whole
fetch response object instead of it's response JSON or text.

This is useful for cases when the backend provides additional context not
available in the response body.

@property rejectWithResponse
@type Boolean
@default false
@public
*/
rejectWithResponse: false,

/**
Restores the session from a session data object; __will return a resolving
Expand Down Expand Up @@ -209,7 +224,7 @@ export default BaseAuthenticator.extend({
return new RSVP.Promise((resolve, reject) => {
const data = { 'grant_type': 'password', username: identification, password };
const serverTokenEndpoint = this.get('serverTokenEndpoint');
const useXhr = this.get('rejectWithXhr');
const useResponse = this.get('rejectWithResponse');
const scopesString = makeArray(scope).join(' ');
if (!isEmpty(scopesString)) {
data.scope = scopesString;
Expand All @@ -228,8 +243,8 @@ export default BaseAuthenticator.extend({

resolve(response);
});
}, (xhr) => {
run(null, reject, useXhr ? xhr : (xhr.responseJSON || xhr.responseText));
}, (response) => {
run(null, reject, useResponse ? response : response.responseJSON);
});
});
},
Expand Down Expand Up @@ -283,29 +298,39 @@ export default BaseAuthenticator.extend({
@param {String} url The request URL
@param {Object} data The request data
@param {Object} headers Additional headers to send in request
@return {jQuery.Deferred} A promise like jQuery.Deferred as returned by `$.ajax`
@return {Promise} A promise that resolves with the response object`
@protected
*/
makeRequest(url, data, headers = {}) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';

const body = keys(data).map((key) => {
return `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`;
}).join('&');

const options = {
url,
data,
type: 'POST',
dataType: 'json',
contentType: 'application/x-www-form-urlencoded',
headers
body,
headers,
method: 'POST'
};

const clientIdHeader = this.get('_clientIdHeader');
if (!isEmpty(clientIdHeader)) {
merge(options.headers, clientIdHeader);
}

if (isEmpty(keys(options.headers))) {
delete options.headers;
}

return jQuery.ajax(options);
return new RSVP.Promise((resolve, reject) => {
fetch(url, options).then((response) => {
response.text().then((text) => {
let json = text ? JSON.parse(text) : {};
if (!response.ok) {
response.responseJSON = json;
reject(response);
} else {
resolve(json);
}
});
}).catch(reject);
});
},

_scheduleAccessTokenRefresh(expiresIn, expiresAt, refreshToken) {
Expand Down Expand Up @@ -340,8 +365,8 @@ export default BaseAuthenticator.extend({
this.trigger('sessionDataUpdated', data);
resolve(data);
});
}, (xhr, status, error) => {
warn(`Access token could not be refreshed - server responded with ${error}.`);
}, (response) => {
warn(`Access token could not be refreshed - server responded with ${response.responseJSON}.`);
reject();
});
});
Expand Down