@guardian/pan-domain-node 0.5.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -0
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/node-release.yml +57 -0
- package/CHANGELOG.md +118 -0
- package/CODEOWNERS +1 -0
- package/LICENSE +201 -0
- package/README.md +95 -0
- package/dist/src/api.d.ts +30 -9
- package/dist/src/api.js +13 -8
- package/dist/src/panda.d.ts +1 -1
- package/dist/src/panda.js +50 -12
- package/dist/src/utils.d.ts +6 -4
- package/dist/src/utils.js +23 -3
- package/dist/test/panda.test.js +196 -23
- package/dist/test/utils.test.js +10 -5
- package/package.json +1 -1
- package/src/api.ts +63 -10
- package/src/panda.ts +56 -19
- package/src/utils.ts +30 -4
- package/test/panda.test.ts +226 -25
- package/test/utils.test.ts +12 -6
package/dist/src/panda.js
CHANGED
|
@@ -42,40 +42,78 @@ function createCookie(user, privateKey) {
|
|
|
42
42
|
exports.createCookie = createCookie;
|
|
43
43
|
function verifyUser(pandaCookie, publicKey, currentTime, validateUser) {
|
|
44
44
|
if (!pandaCookie) {
|
|
45
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
reason: 'no-cookie'
|
|
48
|
+
};
|
|
46
49
|
}
|
|
47
|
-
const
|
|
50
|
+
const parsedCookie = utils_1.parseCookie(pandaCookie);
|
|
51
|
+
if (!parsedCookie) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
reason: 'invalid-cookie'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const { data, signature } = parsedCookie;
|
|
48
58
|
if (!utils_1.verifySignature(data, signature, publicKey)) {
|
|
49
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
reason: 'invalid-cookie'
|
|
62
|
+
};
|
|
50
63
|
}
|
|
51
|
-
const
|
|
64
|
+
const currentTimestampInMillis = currentTime.getTime();
|
|
52
65
|
try {
|
|
53
66
|
const user = utils_1.parseUser(data);
|
|
54
|
-
const isExpired = user.expires <
|
|
67
|
+
const isExpired = user.expires < currentTimestampInMillis;
|
|
55
68
|
if (isExpired) {
|
|
56
|
-
|
|
69
|
+
const gracePeriodEndsAtEpochTimeMillis = user.expires + api_1.gracePeriodInMillis;
|
|
70
|
+
if (gracePeriodEndsAtEpochTimeMillis < currentTimestampInMillis) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
reason: 'expired-cookie'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
shouldRefreshCredentials: true,
|
|
80
|
+
mustRefreshByEpochTimeMillis: gracePeriodEndsAtEpochTimeMillis,
|
|
81
|
+
user
|
|
82
|
+
};
|
|
83
|
+
}
|
|
57
84
|
}
|
|
58
85
|
if (!validateUser(user)) {
|
|
59
|
-
return {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
reason: 'invalid-user',
|
|
89
|
+
user
|
|
90
|
+
};
|
|
60
91
|
}
|
|
61
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
success: true,
|
|
94
|
+
shouldRefreshCredentials: false,
|
|
95
|
+
user
|
|
96
|
+
};
|
|
62
97
|
}
|
|
63
98
|
catch (error) {
|
|
64
99
|
console.error(error);
|
|
65
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
reason: 'unknown'
|
|
103
|
+
};
|
|
66
104
|
}
|
|
67
105
|
}
|
|
68
106
|
exports.verifyUser = verifyUser;
|
|
69
107
|
class PanDomainAuthentication {
|
|
70
108
|
constructor(cookieName, region, bucket, keyFile, validateUser) {
|
|
71
|
-
this.
|
|
109
|
+
this.keyCacheTimeInMillis = 60 * 1000; // 1 minute
|
|
72
110
|
this.cookieName = cookieName;
|
|
73
111
|
this.region = region;
|
|
74
112
|
this.bucket = bucket;
|
|
75
113
|
this.keyFile = keyFile;
|
|
76
114
|
this.validateUser = validateUser;
|
|
77
115
|
this.publicKey = fetch_public_key_1.fetchPublicKey(region, bucket, keyFile);
|
|
78
|
-
this.keyUpdateTimer = setInterval(() => this.getPublicKey(), this.
|
|
116
|
+
this.keyUpdateTimer = setInterval(() => this.getPublicKey(), this.keyCacheTimeInMillis);
|
|
79
117
|
}
|
|
80
118
|
stop() {
|
|
81
119
|
if (this.keyUpdateTimer) {
|
|
@@ -87,7 +125,7 @@ class PanDomainAuthentication {
|
|
|
87
125
|
return this.publicKey.then(({ key, lastUpdated }) => {
|
|
88
126
|
const now = new Date();
|
|
89
127
|
const diff = now.getTime() - lastUpdated.getTime();
|
|
90
|
-
if (diff > this.
|
|
128
|
+
if (diff > this.keyCacheTimeInMillis) {
|
|
91
129
|
this.publicKey = fetch_public_key_1.fetchPublicKey(this.region, this.bucket, this.keyFile);
|
|
92
130
|
return this.publicKey.then(({ key }) => key);
|
|
93
131
|
}
|
package/dist/src/utils.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { User } from './api';
|
|
2
2
|
export declare function decodeBase64(data: string): string;
|
|
3
|
-
|
|
4
|
-
* Parse a pan-domain user cookie in to data and signature
|
|
5
|
-
*/
|
|
6
|
-
export declare function parseCookie(cookie: string): {
|
|
3
|
+
export declare type ParsedCookie = {
|
|
7
4
|
data: string;
|
|
8
5
|
signature: string;
|
|
9
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* Parse a pan-domain user cookie in to data and signature
|
|
9
|
+
* Validates that the cookie is properly formatted (two base64 strings separated by '.')
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseCookie(cookie: string): ParsedCookie | undefined;
|
|
10
12
|
/**
|
|
11
13
|
* Verify signed data using nodeJs crypto library
|
|
12
14
|
*/
|
package/dist/src/utils.js
CHANGED
|
@@ -27,14 +27,34 @@ function decodeBase64(data) {
|
|
|
27
27
|
return Buffer.from(data, 'base64').toString('utf8');
|
|
28
28
|
}
|
|
29
29
|
exports.decodeBase64 = decodeBase64;
|
|
30
|
+
/**
|
|
31
|
+
* Check if a string is valid base64
|
|
32
|
+
*/
|
|
33
|
+
function isBase64(str) {
|
|
34
|
+
try {
|
|
35
|
+
return Buffer.from(str, 'base64').toString('base64') === str;
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
30
41
|
/**
|
|
31
42
|
* Parse a pan-domain user cookie in to data and signature
|
|
43
|
+
* Validates that the cookie is properly formatted (two base64 strings separated by '.')
|
|
32
44
|
*/
|
|
33
45
|
function parseCookie(cookie) {
|
|
34
|
-
const
|
|
46
|
+
const cookieRegex = /^([\w\W]*)\.([\w\W]*)$/;
|
|
47
|
+
const match = cookie.match(cookieRegex);
|
|
48
|
+
if (!match) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const [, data, signature] = match;
|
|
52
|
+
if (!isBase64(data) || !isBase64(signature)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
35
55
|
return {
|
|
36
|
-
data: decodeBase64(
|
|
37
|
-
signature:
|
|
56
|
+
data: decodeBase64(data),
|
|
57
|
+
signature: signature
|
|
38
58
|
};
|
|
39
59
|
}
|
|
40
60
|
exports.parseCookie = parseCookie;
|
package/dist/test/panda.test.js
CHANGED
|
@@ -16,26 +16,114 @@ const fixtures_1 = require("./fixtures");
|
|
|
16
16
|
const utils_1 = require("../src/utils");
|
|
17
17
|
jest.mock('../src/fetch-public-key');
|
|
18
18
|
jest.useFakeTimers('modern');
|
|
19
|
+
function userFromCookie(cookie) {
|
|
20
|
+
// This function is only used to generate a `User` object from
|
|
21
|
+
// a well-formed text fixture cookie, in order to check that successful
|
|
22
|
+
// `AuthenticationResult`s have the right shape. As such we don't want
|
|
23
|
+
// to have to deal with the case of a bad cookie so we just cast to `ParsedCookie`.
|
|
24
|
+
const parsedCookie = utils_1.parseCookie(cookie);
|
|
25
|
+
return utils_1.parseUser(parsedCookie.data);
|
|
26
|
+
}
|
|
19
27
|
describe('verifyUser', function () {
|
|
20
|
-
test("
|
|
21
|
-
|
|
28
|
+
test("fail to authenticate if cookie is missing", () => {
|
|
29
|
+
const expected = {
|
|
30
|
+
success: false,
|
|
31
|
+
reason: 'no-cookie'
|
|
32
|
+
};
|
|
33
|
+
expect(panda_1.verifyUser(undefined, "", new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
22
34
|
});
|
|
23
|
-
test("
|
|
35
|
+
test("fail to authenticate if signature is malformed", () => {
|
|
24
36
|
const [data, signature] = fixtures_1.sampleCookie.split(".");
|
|
25
37
|
const testCookie = data + ".1234";
|
|
26
|
-
|
|
38
|
+
const expected = {
|
|
39
|
+
success: false,
|
|
40
|
+
reason: 'invalid-cookie'
|
|
41
|
+
};
|
|
42
|
+
expect(panda_1.verifyUser(testCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
27
43
|
});
|
|
28
|
-
test("
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
test("fail to authenticate if cookie expired and we're outside the grace period", () => {
|
|
45
|
+
// Cookie expires at epoch time 1234
|
|
46
|
+
const afterEndOfGracePeriod = new Date(1234 + api_1.gracePeriodInMillis + 1);
|
|
47
|
+
const expected = {
|
|
48
|
+
success: false,
|
|
49
|
+
reason: 'expired-cookie'
|
|
50
|
+
};
|
|
51
|
+
expect(panda_1.verifyUser(fixtures_1.sampleCookie, fixtures_1.publicKey, afterEndOfGracePeriod, api_1.guardianValidation)).toStrictEqual(expected);
|
|
32
52
|
});
|
|
33
|
-
test("
|
|
34
|
-
expect(panda_1.verifyUser(fixtures_1.sampleCookieWithoutMultifactor, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)
|
|
35
|
-
|
|
53
|
+
test("fail to authenticate if user fails validation function", () => {
|
|
54
|
+
expect(panda_1.verifyUser(fixtures_1.sampleCookieWithoutMultifactor, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual({
|
|
55
|
+
success: false,
|
|
56
|
+
reason: 'invalid-user',
|
|
57
|
+
user: userFromCookie(fixtures_1.sampleCookieWithoutMultifactor)
|
|
58
|
+
});
|
|
59
|
+
expect(panda_1.verifyUser(fixtures_1.sampleNonGuardianCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual({
|
|
60
|
+
success: false,
|
|
61
|
+
reason: 'invalid-user',
|
|
62
|
+
user: userFromCookie(fixtures_1.sampleNonGuardianCookie)
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test("fail to authenticate with invalid-cookie reason if signature is not valid", () => {
|
|
66
|
+
const expected = {
|
|
67
|
+
success: false,
|
|
68
|
+
reason: 'invalid-cookie'
|
|
69
|
+
};
|
|
70
|
+
const slightlyBadCookie = fixtures_1.sampleCookie.slice(0, -2);
|
|
71
|
+
expect(panda_1.verifyUser(slightlyBadCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
72
|
+
});
|
|
73
|
+
test("fail to authenticate with invalid-cookie reason if data part is not base64", () => {
|
|
74
|
+
const expected = {
|
|
75
|
+
success: false,
|
|
76
|
+
reason: 'invalid-cookie'
|
|
77
|
+
};
|
|
78
|
+
const [_, signature] = fixtures_1.sampleCookie.split(".");
|
|
79
|
+
const nonBase64Data = "not-base64-data";
|
|
80
|
+
const testCookie = `${nonBase64Data}.${signature}`;
|
|
81
|
+
expect(panda_1.verifyUser(testCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
82
|
+
});
|
|
83
|
+
test("fail to authenticate with invalid-cookie reason if signature part is not base64", () => {
|
|
84
|
+
const expected = {
|
|
85
|
+
success: false,
|
|
86
|
+
reason: 'invalid-cookie'
|
|
87
|
+
};
|
|
88
|
+
const [data, _] = fixtures_1.sampleCookie.split(".");
|
|
89
|
+
const nonBase64Signature = "not-base64-signature";
|
|
90
|
+
const testCookie = `${data}.${nonBase64Signature}`;
|
|
91
|
+
expect(panda_1.verifyUser(testCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
92
|
+
});
|
|
93
|
+
test("fail to authenticate with invalid-cookie reason if cookie has no dot separator", () => {
|
|
94
|
+
const expected = {
|
|
95
|
+
success: false,
|
|
96
|
+
reason: 'invalid-cookie'
|
|
97
|
+
};
|
|
98
|
+
const noDotCookie = fixtures_1.sampleCookie.replace(".", "");
|
|
99
|
+
expect(panda_1.verifyUser(noDotCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
100
|
+
});
|
|
101
|
+
test("fail to authenticate with invalid-cookie reason if cookie has multiple dot separators", () => {
|
|
102
|
+
const expected = {
|
|
103
|
+
success: false,
|
|
104
|
+
reason: 'invalid-cookie'
|
|
105
|
+
};
|
|
106
|
+
const multipleDotsCookie = fixtures_1.sampleCookie.replace(".", "..");
|
|
107
|
+
expect(panda_1.verifyUser(multipleDotsCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
108
|
+
});
|
|
109
|
+
test("authenticate if the cookie and user are valid", () => {
|
|
110
|
+
const expected = {
|
|
111
|
+
success: true,
|
|
112
|
+
// Cookie is not expired so no need to refresh credentials
|
|
113
|
+
shouldRefreshCredentials: false,
|
|
114
|
+
user: userFromCookie(fixtures_1.sampleCookie)
|
|
115
|
+
};
|
|
116
|
+
expect(panda_1.verifyUser(fixtures_1.sampleCookie, fixtures_1.publicKey, new Date(0), api_1.guardianValidation)).toStrictEqual(expected);
|
|
36
117
|
});
|
|
37
|
-
test("
|
|
38
|
-
|
|
118
|
+
test("authenticate with shouldRefreshCredentials if cookie expired but we're within the grace period", () => {
|
|
119
|
+
const beforeEndOfGracePeriod = new Date(1234 + api_1.gracePeriodInMillis - 1);
|
|
120
|
+
const expected = {
|
|
121
|
+
success: true,
|
|
122
|
+
user: userFromCookie(fixtures_1.sampleCookie),
|
|
123
|
+
shouldRefreshCredentials: true,
|
|
124
|
+
mustRefreshByEpochTimeMillis: 1234 + api_1.gracePeriodInMillis
|
|
125
|
+
};
|
|
126
|
+
expect(panda_1.verifyUser(fixtures_1.sampleCookie, fixtures_1.publicKey, beforeEndOfGracePeriod, api_1.guardianValidation)).toStrictEqual(expected);
|
|
39
127
|
});
|
|
40
128
|
});
|
|
41
129
|
describe('createCookie', function () {
|
|
@@ -92,23 +180,108 @@ describe('panda class', function () {
|
|
|
92
180
|
beforeEach(() => {
|
|
93
181
|
fetch_public_key_1.fetchPublicKey.mockResolvedValue({ key: fixtures_1.publicKey, lastUpdated: new Date() });
|
|
94
182
|
});
|
|
95
|
-
it('should
|
|
183
|
+
it('should authenticate if cookie and user are valid', () => __awaiter(this, void 0, void 0, function* () {
|
|
96
184
|
jest.setSystemTime(100);
|
|
97
185
|
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u) => true);
|
|
98
|
-
const
|
|
99
|
-
|
|
186
|
+
const authenticationResult = yield panda.verify(`cookiename=${fixtures_1.sampleCookie}`);
|
|
187
|
+
const expected = {
|
|
188
|
+
success: true,
|
|
189
|
+
// Cookie is not expired
|
|
190
|
+
shouldRefreshCredentials: false,
|
|
191
|
+
user: userFromCookie(fixtures_1.sampleCookie)
|
|
192
|
+
};
|
|
193
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
100
194
|
}));
|
|
101
|
-
it('should
|
|
102
|
-
jest.setSystemTime(
|
|
195
|
+
it('should authenticate if cookie and user are valid when multiple cookies are passed', () => __awaiter(this, void 0, void 0, function* () {
|
|
196
|
+
jest.setSystemTime(100);
|
|
103
197
|
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u) => true);
|
|
104
|
-
const
|
|
105
|
-
|
|
198
|
+
const authenticationResult = yield panda.verify(`a=blah; b=stuff; cookiename=${fixtures_1.sampleCookie}; c=4958345`);
|
|
199
|
+
const expected = {
|
|
200
|
+
success: true,
|
|
201
|
+
// Cookie is not expired
|
|
202
|
+
shouldRefreshCredentials: false,
|
|
203
|
+
user: userFromCookie(fixtures_1.sampleCookie)
|
|
204
|
+
};
|
|
205
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
206
|
+
}));
|
|
207
|
+
it('should fail to authenticate if cookie expired and we\'re outside the grace period', () => __awaiter(this, void 0, void 0, function* () {
|
|
208
|
+
// Cookie expiry is 1234
|
|
209
|
+
const afterEndOfGracePeriodEpochMillis = 1234 + api_1.gracePeriodInMillis + 1;
|
|
210
|
+
jest.setSystemTime(afterEndOfGracePeriodEpochMillis);
|
|
211
|
+
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u) => true);
|
|
212
|
+
const authenticationResult = yield panda.verify(`cookiename=${fixtures_1.sampleCookie}`);
|
|
213
|
+
const expected = {
|
|
214
|
+
success: false,
|
|
215
|
+
reason: 'expired-cookie'
|
|
216
|
+
};
|
|
217
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
218
|
+
}));
|
|
219
|
+
it('authenticate with shouldRefreshCredentials if cookie expired but we\'re within the grace period', () => __awaiter(this, void 0, void 0, function* () {
|
|
220
|
+
// Cookie expiry is 1234
|
|
221
|
+
const beforeEndOfGracePeriodEpochMillis = 1234 + api_1.gracePeriodInMillis - 1;
|
|
222
|
+
jest.setSystemTime(beforeEndOfGracePeriodEpochMillis);
|
|
223
|
+
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', (u) => true);
|
|
224
|
+
const authenticationResult = yield panda.verify(`cookiename=${fixtures_1.sampleCookie}`);
|
|
225
|
+
const expected = {
|
|
226
|
+
success: true,
|
|
227
|
+
shouldRefreshCredentials: true,
|
|
228
|
+
mustRefreshByEpochTimeMillis: 1234 + api_1.gracePeriodInMillis,
|
|
229
|
+
user: userFromCookie(fixtures_1.sampleCookie)
|
|
230
|
+
};
|
|
231
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
232
|
+
}));
|
|
233
|
+
it('should fail to authenticate if user is not valid', () => __awaiter(this, void 0, void 0, function* () {
|
|
234
|
+
jest.setSystemTime(100);
|
|
235
|
+
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', api_1.guardianValidation);
|
|
236
|
+
const authenticationResult = yield panda.verify(`cookiename=${fixtures_1.sampleNonGuardianCookie}`);
|
|
237
|
+
const expected = {
|
|
238
|
+
success: false,
|
|
239
|
+
reason: 'invalid-user',
|
|
240
|
+
user: userFromCookie(fixtures_1.sampleNonGuardianCookie)
|
|
241
|
+
};
|
|
242
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
106
243
|
}));
|
|
107
|
-
it('should
|
|
244
|
+
it('should fail to authenticate if there is no cookie with the correct name', () => __awaiter(this, void 0, void 0, function* () {
|
|
108
245
|
jest.setSystemTime(100);
|
|
109
246
|
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', api_1.guardianValidation);
|
|
110
|
-
const
|
|
111
|
-
|
|
247
|
+
const authenticationResult = yield panda.verify(`wrongcookiename=${fixtures_1.sampleNonGuardianCookie}`);
|
|
248
|
+
const expected = {
|
|
249
|
+
success: false,
|
|
250
|
+
reason: "no-cookie"
|
|
251
|
+
};
|
|
252
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
253
|
+
}));
|
|
254
|
+
it('should fail to authenticate if the cookie request header is malformed', () => __awaiter(this, void 0, void 0, function* () {
|
|
255
|
+
jest.setSystemTime(100);
|
|
256
|
+
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', api_1.guardianValidation);
|
|
257
|
+
// The cookie headers should be semicolon-separated name=valueg
|
|
258
|
+
const authenticationResult = yield panda.verify(fixtures_1.sampleNonGuardianCookie);
|
|
259
|
+
const expected = {
|
|
260
|
+
success: false,
|
|
261
|
+
reason: "no-cookie"
|
|
262
|
+
};
|
|
263
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
264
|
+
}));
|
|
265
|
+
it('should fail to authenticate if there is no cookie with the correct name out of multiple cookies', () => __awaiter(this, void 0, void 0, function* () {
|
|
266
|
+
jest.setSystemTime(100);
|
|
267
|
+
const panda = new panda_1.PanDomainAuthentication('cookiename', 'region', 'bucket', 'keyfile', api_1.guardianValidation);
|
|
268
|
+
const authenticationResult = yield panda.verify(`wrongcookiename=${fixtures_1.sampleNonGuardianCookie}; anotherwrongcookiename=${fixtures_1.sampleNonGuardianCookie}`);
|
|
269
|
+
const expected = {
|
|
270
|
+
success: false,
|
|
271
|
+
reason: "no-cookie"
|
|
272
|
+
};
|
|
273
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
274
|
+
}));
|
|
275
|
+
it('should fail to authenticate with invalid-cookie reason if cookie is malformed', () => __awaiter(this, void 0, void 0, function* () {
|
|
276
|
+
jest.setSystemTime(100);
|
|
277
|
+
const panda = new panda_1.PanDomainAuthentication('rightcookiename', 'region', 'bucket', 'keyfile', api_1.guardianValidation);
|
|
278
|
+
// There is a valid Panda cookie in here, but it's under the wrong name
|
|
279
|
+
const authenticationResult = yield panda.verify(`wrongcookiename=${fixtures_1.sampleNonGuardianCookie}; rightcookiename=not-valid-panda-cookie`);
|
|
280
|
+
const expected = {
|
|
281
|
+
success: false,
|
|
282
|
+
reason: "invalid-cookie"
|
|
283
|
+
};
|
|
284
|
+
expect(authenticationResult).toStrictEqual(expected);
|
|
112
285
|
}));
|
|
113
286
|
});
|
|
114
287
|
});
|
package/dist/test/utils.test.js
CHANGED
|
@@ -4,9 +4,14 @@ const utils_1 = require("../src/utils");
|
|
|
4
4
|
const fixtures_1 = require("./fixtures");
|
|
5
5
|
const url_1 = require("url");
|
|
6
6
|
test("decode a cookie", () => {
|
|
7
|
-
const
|
|
8
|
-
expect(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
const parsedCookie = utils_1.parseCookie(fixtures_1.sampleCookie);
|
|
8
|
+
expect(parsedCookie).toBeDefined();
|
|
9
|
+
// Unfortunately the above expect() doesn't narrow the type
|
|
10
|
+
if (parsedCookie) {
|
|
11
|
+
const { data, signature } = parsedCookie;
|
|
12
|
+
expect(signature.length).toBe(684);
|
|
13
|
+
const params = new url_1.URLSearchParams(data);
|
|
14
|
+
expect(params.get("firstName")).toBe("Test");
|
|
15
|
+
expect(params.get("lastName")).toBe("User");
|
|
16
|
+
}
|
|
12
17
|
});
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
1
|
export { PanDomainAuthentication } from './panda';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// We continue to consider the request authenticated for
|
|
4
|
+
// a period of time after the cookie expiry. This is to allow
|
|
5
|
+
// API requests which cannot directly send the user for re-auth to
|
|
6
|
+
// indicate to the user that they must take some action to refresh their
|
|
7
|
+
// credentials (usually, refreshing the page).
|
|
8
|
+
|
|
9
|
+
// Panda cookie: issued expires
|
|
10
|
+
// | |
|
|
11
|
+
// |--1 hour--|
|
|
12
|
+
// Grace period: [------------- 24 hours ------]
|
|
13
|
+
// `success`: --false-][-true-----------------------------------][-false-------->
|
|
14
|
+
// `shouldRefreshCredentials` [-false---][-true------------------------]
|
|
15
|
+
export const gracePeriodInMillis = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
// These are used to enforce the structure of the
|
|
18
|
+
// `AuthenticationResult` union types,
|
|
19
|
+
// but are not exported because they are too general.
|
|
20
|
+
interface Result {
|
|
21
|
+
success: boolean
|
|
22
|
+
}
|
|
23
|
+
interface Success extends Result {
|
|
24
|
+
// `success` is true when both these are true:
|
|
25
|
+
// 1. we've verified that the cookie is signed by the correct private key
|
|
26
|
+
// and decoded a `User` from it
|
|
27
|
+
// 2. we've validated the `User` using `ValidateUserFn`
|
|
28
|
+
success: true,
|
|
29
|
+
shouldRefreshCredentials: boolean,
|
|
30
|
+
user: User
|
|
31
|
+
}
|
|
32
|
+
interface Failure extends Result {
|
|
33
|
+
success: false,
|
|
34
|
+
reason: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// These are members of the `AuthenticationResult` union,
|
|
38
|
+
// so they are exported for use by library consumers.
|
|
39
|
+
export interface FreshSuccess extends Success {
|
|
40
|
+
// Cookie has not expired yet, so no need to refresh credentials.
|
|
41
|
+
shouldRefreshCredentials: false
|
|
42
|
+
}
|
|
43
|
+
export interface StaleSuccess extends Success {
|
|
44
|
+
// Cookie has expired: we're in the grace period.
|
|
45
|
+
// Endpoints that can refresh credentials should do so,
|
|
46
|
+
// and those that cannot should tell the user to do so.
|
|
47
|
+
shouldRefreshCredentials: true,
|
|
48
|
+
mustRefreshByEpochTimeMillis: number
|
|
8
49
|
}
|
|
50
|
+
export interface UserValidationFailure extends Failure {
|
|
51
|
+
reason: 'invalid-user',
|
|
52
|
+
user: User
|
|
53
|
+
}
|
|
54
|
+
export interface CookieFailure extends Failure {
|
|
55
|
+
reason: 'no-cookie' | 'invalid-cookie' | 'expired-cookie'
|
|
56
|
+
}
|
|
57
|
+
export interface UnknownFailure extends Failure {
|
|
58
|
+
reason: 'unknown'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type AuthenticationResult = FreshSuccess
|
|
62
|
+
| StaleSuccess
|
|
63
|
+
| CookieFailure
|
|
64
|
+
| UserValidationFailure
|
|
65
|
+
| UnknownFailure
|
|
66
|
+
|
|
9
67
|
|
|
10
68
|
export interface User {
|
|
11
69
|
firstName: string,
|
|
@@ -18,11 +76,6 @@ export interface User {
|
|
|
18
76
|
multifactor: boolean
|
|
19
77
|
}
|
|
20
78
|
|
|
21
|
-
export interface AuthenticationResult {
|
|
22
|
-
status: AuthenticationStatus,
|
|
23
|
-
user?: User
|
|
24
|
-
}
|
|
25
|
-
|
|
26
79
|
export type ValidateUserFn = (user: User) => boolean;
|
|
27
80
|
|
|
28
81
|
export function guardianValidation(user: User): boolean {
|
package/src/panda.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as cookie from 'cookie';
|
|
2
2
|
|
|
3
3
|
import {parseCookie, parseUser, sign, verifySignature} from './utils';
|
|
4
|
-
import {
|
|
4
|
+
import {User, AuthenticationResult, ValidateUserFn, gracePeriodInMillis} from './api';
|
|
5
5
|
import { fetchPublicKey, PublicKeyHolder } from './fetch-public-key';
|
|
6
6
|
|
|
7
7
|
export function createCookie(user: User, privateKey: string): string {
|
|
@@ -25,34 +25,71 @@ export function createCookie(user: User, privateKey: string): string {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function verifyUser(pandaCookie: string | undefined, publicKey: string, currentTime: Date, validateUser: ValidateUserFn): AuthenticationResult {
|
|
28
|
-
if(!pandaCookie) {
|
|
29
|
-
return {
|
|
28
|
+
if (!pandaCookie) {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
reason: 'no-cookie'
|
|
32
|
+
};
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
const
|
|
35
|
+
const parsedCookie = parseCookie(pandaCookie);
|
|
36
|
+
if (!parsedCookie) {
|
|
37
|
+
return {
|
|
38
|
+
success: false,
|
|
39
|
+
reason: 'invalid-cookie'
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const { data, signature } = parsedCookie;
|
|
33
43
|
|
|
34
|
-
if(!verifySignature(data, signature, publicKey)) {
|
|
35
|
-
return {
|
|
44
|
+
if (!verifySignature(data, signature, publicKey)) {
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
reason: 'invalid-cookie'
|
|
48
|
+
};
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
const
|
|
51
|
+
const currentTimestampInMillis = currentTime.getTime();
|
|
39
52
|
|
|
40
53
|
try {
|
|
41
54
|
const user: User = parseUser(data);
|
|
42
|
-
const isExpired = user.expires <
|
|
43
|
-
|
|
44
|
-
if(isExpired) {
|
|
45
|
-
|
|
55
|
+
const isExpired = user.expires < currentTimestampInMillis;
|
|
56
|
+
|
|
57
|
+
if (isExpired) {
|
|
58
|
+
const gracePeriodEndsAtEpochTimeMillis = user.expires + gracePeriodInMillis;
|
|
59
|
+
if (gracePeriodEndsAtEpochTimeMillis < currentTimestampInMillis) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
reason: 'expired-cookie'
|
|
63
|
+
};
|
|
64
|
+
} else {
|
|
65
|
+
return {
|
|
66
|
+
success: true,
|
|
67
|
+
shouldRefreshCredentials: true,
|
|
68
|
+
mustRefreshByEpochTimeMillis: gracePeriodEndsAtEpochTimeMillis,
|
|
69
|
+
user
|
|
70
|
+
}
|
|
71
|
+
}
|
|
46
72
|
}
|
|
47
73
|
|
|
48
|
-
if(!validateUser(user)) {
|
|
49
|
-
return {
|
|
74
|
+
if (!validateUser(user)) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
reason: 'invalid-user',
|
|
78
|
+
user
|
|
79
|
+
};
|
|
50
80
|
}
|
|
51
81
|
|
|
52
|
-
return {
|
|
53
|
-
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
shouldRefreshCredentials: false,
|
|
85
|
+
user
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
54
88
|
console.error(error);
|
|
55
|
-
return {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
reason: 'unknown'
|
|
92
|
+
};
|
|
56
93
|
}
|
|
57
94
|
}
|
|
58
95
|
|
|
@@ -64,7 +101,7 @@ export class PanDomainAuthentication {
|
|
|
64
101
|
validateUser: ValidateUserFn;
|
|
65
102
|
|
|
66
103
|
publicKey: Promise<PublicKeyHolder>;
|
|
67
|
-
|
|
104
|
+
keyCacheTimeInMillis: number = 60 * 1000; // 1 minute
|
|
68
105
|
keyUpdateTimer?: NodeJS.Timeout;
|
|
69
106
|
|
|
70
107
|
constructor(cookieName: string, region: string, bucket: string, keyFile: string, validateUser: ValidateUserFn) {
|
|
@@ -76,7 +113,7 @@ export class PanDomainAuthentication {
|
|
|
76
113
|
|
|
77
114
|
this.publicKey = fetchPublicKey(region, bucket, keyFile);
|
|
78
115
|
|
|
79
|
-
this.keyUpdateTimer = setInterval(() => this.getPublicKey(), this.
|
|
116
|
+
this.keyUpdateTimer = setInterval(() => this.getPublicKey(), this.keyCacheTimeInMillis);
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
stop(): void {
|
|
@@ -91,7 +128,7 @@ export class PanDomainAuthentication {
|
|
|
91
128
|
const now = new Date();
|
|
92
129
|
const diff = now.getTime() - lastUpdated.getTime();
|
|
93
130
|
|
|
94
|
-
if(diff > this.
|
|
131
|
+
if(diff > this.keyCacheTimeInMillis) {
|
|
95
132
|
this.publicKey = fetchPublicKey(this.region, this.bucket, this.keyFile);
|
|
96
133
|
return this.publicKey.then(({ key }) => key);
|
|
97
134
|
} else {
|