Skip to content

Commit b1fccf7

Browse files
author
Lee Richmond
committed
Merge pull request #6 from lrichmon/jwt
Integrate JWT authentication
2 parents 27e1279 + dfb7d0e commit b1fccf7

File tree

9 files changed

+238
-25
lines changed

9 files changed

+238
-25
lines changed

README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const Person = Model.extend({
1818
baseUrl: 'http://localhost:3000',
1919
jsonapiType: 'people'
2020
},
21-
21+
2222
firstName: attr(),
2323
lastName: attr(),
2424
fullName() {
@@ -32,12 +32,50 @@ Person.where({ name: 'Joe' }).page(2).per(10).sort('name').then((people) => {
3232
});
3333
```
3434

35+
### JSON Web Tokens
36+
37+
jsorm supports setting a JWT and using it for all requests. Set it
38+
during `Config.setup` and all subsequents will pass it using the
39+
`Authorization` header:
40+
41+
```es6
42+
const ApplicationRecord = Model.extend({
43+
// code
44+
});
45+
46+
const Person = ApplicationRecord.extend({
47+
// code
48+
});
49+
50+
const Author = ApplicationRecord.extend({
51+
// code
52+
});
53+
54+
Config.setup({ jwtOwners: [ApplicationRecord] });
55+
56+
ApplicationRecord.jwt = 's0m3t0k3n';
57+
Author.all(); // sends JWT in Authorization header
58+
Author.getJWT(); // grabs from ApplicationRecord
59+
Author.setJWT('t0k3n'); // sets on ApplicationRecord
60+
```
61+
62+
This means you could define `OtherApplicationRecord`, whose
63+
subclasses could use an alternate JWT for an alternate website.
64+
65+
The token is sent in the following format:
66+
67+
```
68+
Authorization: Token token="s0m3t0k3n"
69+
```
70+
71+
If your application responds with `X-JWT` in the headers, jsorm will
72+
use this JWT for all subsequent requests (helpful when
73+
implementing token expiry).
74+
3575
### Roadmap
3676

3777
* Find to throw error when record not found
38-
* Authentication from Node
3978
* Attribute transforms (type coercion)
4079
* Writes
4180
* Error / Validation handling
4281
* Nested Writes
43-
* Statistics

src/configuration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ export default class Config {
99
static typeMapping: Object = {};
1010
static logger: Logger = new Logger();
1111

12-
static setup() : void {
12+
static setup(options : Object) : void {
1313
for (let model of this.models) {
1414
this.typeMapping[model.jsonapiType] = model;
15+
16+
if (options['jwtOwners'] && options['jwtOwners'].indexOf(model) !== -1) {
17+
model.isJWTOwner = true;
18+
}
1519
}
1620

1721
for (let model of this.models) {

src/model.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ export default class Model {
1313
static apiNamespace = '/';
1414
static jsonapiType = 'define-in-subclass';
1515
static endpoint: string;
16+
static isJWTOwner: boolean = false;
17+
static jwt: string = null;
18+
static parentClass: typeof Model;
1619

1720
id: string;
1821
_attributes: Object = {};
1922
relationships: Object = {};
2023
__meta__: Object | void = null;
21-
parentClass: typeof Model;
2224
klass: typeof Model;
2325

2426
static attributeList = [];
@@ -38,8 +40,20 @@ export default class Model {
3840
return this._scope || new Scope(this);
3941
}
4042

41-
constructor(attributes?: Object) {
42-
this.attributes = attributes;
43+
static setJWT(token: string) : void {
44+
this.getJWTOwner().jwt = token;
45+
}
46+
47+
static getJWT() : string {
48+
return this.getJWTOwner().jwt;
49+
}
50+
51+
static getJWTOwner() : typeof Model {
52+
if (this.isJWTOwner) {
53+
return this;
54+
} else {
55+
return this.parentClass.getJWTOwner();
56+
}
4357
}
4458

4559
static all() : Promise<CollectionProxy<Model>> {
@@ -105,6 +119,10 @@ export default class Model {
105119
return deserialize(resource, payload);
106120
}
107121

122+
constructor(attributes?: Object) {
123+
this.attributes = attributes;
124+
}
125+
108126
get attributes() : Object {
109127
return this._attributes;
110128
}

src/request.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,28 @@ import Config from './configuration';
22
import colorize from './util/colorize';
33

44
export default class Request {
5-
get(url : string) : Promise<japiDoc> {
5+
get(url : string, options: Object) : Promise<any> {
66
Config.logger.info(colorize('cyan', 'GET: ') + colorize('magenta', url));
77

88
return new Promise((resolve, reject) => {
9-
fetch(url).then((response) => {
9+
let headers = this.buildHeaders(options);
10+
11+
fetch(url, { headers }).then((response) => {
1012
response.json().then((json) => {
1113
Config.logger.debug(colorize('bold', JSON.stringify(json, null, 4)));
12-
resolve(json);
14+
resolve({ json, headers: response.headers });
1315
});
1416
});
1517
});
1618
}
19+
20+
private buildHeaders(options: Object) : any {
21+
let headers = {};
22+
23+
if (options['jwt']) {
24+
headers['Authorization'] = `Token token="${options['jwt']}"`
25+
}
26+
27+
return headers;
28+
}
1729
}

src/scope.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ export default class Scope {
2525
all() : Promise<CollectionProxy<Model>> {
2626
return this._fetch(this.model.url()).then((json : japiDoc) => {
2727
let collection = new CollectionProxy(json);
28-
29-
return collection;
28+
return collection;
3029
});
3130
}
3231

@@ -217,6 +216,14 @@ export default class Scope {
217216
url = `${url}?${qp}`;
218217
}
219218
let request = new Request();
220-
return request.get(url);
219+
let jwt = this.model.getJWT();
220+
221+
return request.get(url, { jwt }).then((response) => {
222+
let jwtHeader = response.headers.get('X-JWT');
223+
if (jwtHeader) {
224+
this.model.setJWT(jwtHeader);
225+
}
226+
return response.json;
227+
});
221228
}
222229
}

test/fixtures.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { Model, Config, attr, hasMany, belongsTo, hasOne } from '../src/main';
22

3-
// typescript class
4-
class Person extends Model {
3+
class ApplicationRecord extends Model {
54
static baseUrl = 'http://example.com';
65
static apiNamespace = '/api';
7-
static endpoint = '/v1/people';
6+
}
87

8+
// typescript class
9+
class Person extends ApplicationRecord {
10+
static endpoint = '/v1/people';
911
static jsonapiType = 'people';
1012

1113
firstName: string = attr();
@@ -27,36 +29,39 @@ let Author = Person.extend({
2729
bio: hasOne('bios')
2830
});
2931

30-
class Book extends Model {
32+
class Book extends ApplicationRecord {
3133
static jsonapiType = 'books';
3234

3335
title: string = attr();
3436
}
3537

36-
class Genre extends Model {
38+
class Genre extends ApplicationRecord {
3739
static jsonapiType = 'genres';
3840

3941
authors: any = hasMany('authors')
4042

4143
name: string = attr();
4244
}
4345

44-
class Bio extends Model {
46+
class Bio extends ApplicationRecord {
4547
static jsonapiType = 'bios';
4648

4749
description: string = attr()
4850
}
4951

50-
class Tag extends Model {
52+
class Tag extends ApplicationRecord {
5153
static jsonapiType = 'tags';
5254

5355
name: string = attr()
5456
}
5557

56-
class MultiWord extends Model {
58+
class MultiWord extends ApplicationRecord {
5759
static jsonapiType = 'multi_words';
5860
}
5961

60-
Config.setup();
62+
const TestJWTSubclass = ApplicationRecord.extend({
63+
});
64+
65+
Config.setup({ jwtOwners: [ApplicationRecord, TestJWTSubclass] });
6166

62-
export { Author, Person, Book, Genre, Bio, Tag };
67+
export { ApplicationRecord, TestJWTSubclass, Author, Person, Book, Genre, Bio, Tag };
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import '../../test/test-helper';
2+
import { ApplicationRecord, Author } from '../fixtures';
3+
4+
let fetchMock = require('fetch-mock');
5+
6+
after(function () {
7+
fetchMock.restore();
8+
});
9+
10+
describe('authorization headers', function() {
11+
describe('when header is set on model class', function() {
12+
beforeEach(function() {
13+
ApplicationRecord.jwt = 'myt0k3n';
14+
});
15+
16+
afterEach(function() {
17+
fetchMock.restore();
18+
ApplicationRecord.jwt = null;
19+
});
20+
21+
it('is sent in request', function(done) {
22+
fetchMock.mock((url, opts) => {
23+
expect(opts.headers.Authorization).to.eq('Token token="myt0k3n"')
24+
done();
25+
return true;
26+
}, 200);
27+
Author.find(1);
28+
});
29+
});
30+
31+
describe('when header is NOT returned in response', function() {
32+
beforeEach(function() {
33+
fetchMock.get('http://example.com/api/v1/people', {
34+
data: [
35+
{
36+
id: '1',
37+
type: 'people',
38+
attributes: {
39+
name: 'John'
40+
}
41+
}
42+
]
43+
});
44+
45+
ApplicationRecord.jwt = 'dont change me';
46+
});
47+
48+
afterEach(function() {
49+
fetchMock.restore();
50+
ApplicationRecord.jwt = null;
51+
});
52+
53+
it('does not override the JWT', function(done) {
54+
Author.all().then((response) => {
55+
expect(ApplicationRecord.jwt).to.eq('dont change me');
56+
done();
57+
});
58+
});
59+
});
60+
61+
describe('when header is returned in response', function() {
62+
beforeEach(function() {
63+
fetchMock.mock({
64+
name: 'route',
65+
matcher: '*',
66+
response: {
67+
status: 200,
68+
body: { data: [] },
69+
headers: {
70+
'X-JWT': 'somet0k3n'
71+
}
72+
}
73+
});
74+
});
75+
76+
afterEach(function() {
77+
fetchMock.restore();
78+
ApplicationRecord.jwt = null;
79+
});
80+
81+
it('is used in subsequent requests', function(done) {
82+
Author.all().then((response) => {
83+
fetchMock.restore();
84+
85+
fetchMock.mock((url, opts) => {
86+
expect(opts.headers.Authorization).to.eq('Token token="somet0k3n"')
87+
done();
88+
return true;
89+
}, 200);
90+
expect(Author.getJWT()).to.eq('somet0k3n');
91+
expect(ApplicationRecord.jwt).to.eq('somet0k3n');
92+
Author.all();
93+
});
94+
});
95+
});
96+
});

test/unit/model-test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,45 @@
22

33
import { sinon } from '../../test/test-helper';
44
import { Model } from '../../src/main';
5-
import { Person, Author, Book, Genre, Bio } from '../fixtures';
5+
import { ApplicationRecord, TestJWTSubclass, Person, Author, Book, Genre, Bio } from '../fixtures';
66

77
let instance;
88

99
describe('Model', function() {
10+
describe('.getJWTOwner', function() {
11+
it('it finds the furthest ancestor where isJWTOwner', function() {
12+
expect(Author.getJWTOwner()).to.eq(ApplicationRecord);
13+
expect(Person.getJWTOwner()).to.eq(ApplicationRecord);
14+
expect(ApplicationRecord.getJWTOwner()).to.eq(ApplicationRecord);
15+
expect(TestJWTSubclass.getJWTOwner()).to.eq(TestJWTSubclass);
16+
});
17+
});
18+
19+
describe('#getJWT', function() {
20+
beforeEach(function() {
21+
ApplicationRecord.jwt = 'g3tm3';
22+
});
23+
24+
afterEach(function() {
25+
ApplicationRecord.jwt = null;
26+
});
27+
28+
it('it grabs jwt from top-most parent', function() {
29+
expect(Author.getJWT()).to.eq('g3tm3');
30+
});
31+
});
32+
33+
describe('#setJWT', function() {
34+
afterEach(function() {
35+
ApplicationRecord.jwt = null;
36+
});
37+
38+
it('it sets jwt on the top-most parent', function() {
39+
Author.setJWT('n3wt0k3n');
40+
expect(ApplicationRecord.jwt).to.eq('n3wt0k3n');
41+
});
42+
});
43+
1044
describe('#isType', function() {
1145
it('checks the jsonapiType of class', function() {
1246
instance = new Author()

tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,3 @@
2020
"test"
2121
]
2222
}
23-

0 commit comments

Comments
 (0)