@guardian/pan-domain-node 0.5.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,117 @@
1
1
  # @guardian/pan-domain-node
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - f91598a: # Changes
8
+ Adds a 24-hour grace period after cookie expiry, during which requests will still be considered authenticated.
9
+
10
+ This is modelled by changing `AuthenticatedStatus` into a discriminated union with the following properties (among others):
11
+
12
+ ### `success`
13
+
14
+ Whether to treat request as authenticated or not. **This will remain true after cookie expiry for the length of the grace period.**
15
+
16
+ [Almost all consumers](https://docs.google.com/spreadsheets/d/19XABaP9ua935TYJARkL8tstnizL69gIJ9av8C3UQBYw/edit?gid=0#gid=0) of this library check **only** the `AUTHORISED` status right now. These should all be able to switch to checking just this single boolean, implicitly getting grace period functionality in the process.
17
+
18
+ ### `shouldRefreshCredentials`
19
+
20
+ Whether to try and get fresh credentials.
21
+
22
+ This allows page endpoints to redirect to auth, and API endpoints to tell the frontend to show a warning message to the user.
23
+
24
+ ### `mustRefreshByEpochTimeMillis`
25
+
26
+ The time at which the grace period ends and the request will be treated as unauthenticated. This allows library consumers to warn the user in the app UI when they are near the end of the grace period, as Composer does: https://github.com/guardian/flexible-content/pull/5210
27
+
28
+ ```
29
+ Panda cookie: issued expires `mustRefreshByEpochTimeMillis`
30
+ | | |
31
+ |--1 hour--| |
32
+ Grace period: [------------- 24 hours ------]
33
+
34
+ `success`: --false-][-true-----------------------------------][-false-------->
35
+ `shouldRefreshCredentials` [-false---][-true------------------------]
36
+ ```
37
+
38
+ # Why have we made this change?
39
+
40
+ The Panda authentication cookie expires after 1 hour, and top-level navigation requests (page loads) trigger automatic re-authentication after this point.
41
+
42
+ Unfortunately API requests cannot trigger re-authentication on their own, and background refresh mechanisms (e.g. iframe-based method used by [Pandular](https://github.com/guardian/pandular)) are increasingly blocked by browsers due to third-party cookie restrictions.
43
+
44
+ We would like to enforce a 24-hour grace period
45
+
46
+ # How to update consuming code
47
+
48
+ At a minimum, switch from
49
+
50
+ ```typescript
51
+ const authResult = await panda.verify(cookieHeader);
52
+ if (
53
+ authResult.status === AuthenticationStatus.AUTHORISED &&
54
+ authResult.user
55
+ ) {
56
+ return authResult.user.email;
57
+ }
58
+ ```
59
+
60
+ to
61
+
62
+ ```typescript
63
+ const authResult = await panda.verify(cookieHeader);
64
+ if (authResult.success) {
65
+ return authResult.user.email;
66
+ }
67
+ ```
68
+
69
+ This will implicitly give you grace period functionality, because `success` will remain true during the grace period.
70
+
71
+ However, we **strongly** recommend all consumers to take account of `shouldRefreshCredentials`. What you do with the result should depend on whether your endpoint can trigger re-auth.
72
+
73
+ ## Endpoints that can refresh credentials
74
+
75
+ Endpoints that **can** refresh credentials, e.g. page endpoints that can redirect to an auth flow, should send the user to re-auth if `shouldRefreshCredentials` is `true`:
76
+
77
+ ```typescript
78
+ const authResult = await panda.verify(headers.cookie);
79
+ if (authResult.success) {
80
+ if (authResult.shouldRefreshCredentials) {
81
+ // Send for auth
82
+ } else {
83
+ // Can perform action with user
84
+ return authResult.user;
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## Endpoints that cannot refresh credentials
90
+
91
+ Endpoints that **cannot** refresh credentials, e.g. API endpoints, should log appropriately and return something to the client that can be used to warn the user that they need to refresh their session.
92
+
93
+ ```typescript
94
+ const authResult = await panda.verify(headers.cookie);
95
+ if (authResult.success) {
96
+ const user = authResult.user;
97
+ // Handle request
98
+ // When returning response:
99
+ if (authResult.shouldRefreshCredentials) {
100
+ const mustRefreshByEpochTimeMillis =
101
+ authResult.mustRefreshByEpochTimeMillis;
102
+ const remainingTime = mustRefreshByEpochTimeMillis - Date.now();
103
+ console.warn(
104
+ `Stale Panda auth, will expire in ${remainingTime} milliseconds`
105
+ );
106
+ // Can still return 200, but depending on the type of API,
107
+ // we may want to return some extra information so the client
108
+ // can warn the user they need to refresh their session.
109
+ } else {
110
+ // It's a fresh session. Nothing to worry about!
111
+ }
112
+ }
113
+ ```
114
+
3
115
  ## 0.5.1
4
116
 
5
117
  ### Patch Changes
package/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @guardian/digital-cms
1
+ * @guardian/workflow-and-collaboration
package/README.md CHANGED
@@ -14,14 +14,33 @@ functionality for signing and verifying login cookies in Scala.
14
14
 
15
15
  The `pan-domain-node` library provides an implementation of *verification only* for node apps.
16
16
 
17
- ## To verify login in NodeJS
17
+ ## Grace period
18
+ We continue to consider the request authenticated for a period of time after the cookie expiry.
18
19
 
19
- [![npm version](https://badge.fury.io/js/%40guardian%2Fpan-domain-node.svg)](https://badge.fury.io/js/%40guardian%2Fpan-domain-node)
20
+ This is to allow API requests which cannot directly send the user for re-auth to indicate to the user that they must take some action to refresh their credentials (usually, refreshing the page).
21
+
22
+ When the cookie is expired but we're still within this grace period, `shouldRefreshCredentials` will be `true`, which means:
23
+ - Endpoints that can refresh credentials (e.g. page endpoints that can redirect) should do so
24
+ - Endpoints that cannot refresh credentials (e.g. API endpoints) should tell the user to take some action to refresh credentials
25
+
26
+ ```
27
+ Panda cookie: issued expires `mustRefreshByEpochTimeMillis`
28
+ | | |
29
+ |--1 hour--| |
30
+ Grace period: [------------- 24 hours ------]
20
31
 
32
+ `success`: --false-][-true-----------------------------------][-false-------->
33
+ `shouldRefreshCredentials` [-false---][-true------------------------]
34
+ ```
35
+
36
+ ## Example usage
37
+ ### Installation
38
+ [![npm version](https://badge.fury.io/js/%40guardian%2Fpan-domain-node.svg)](https://badge.fury.io/js/%40guardian%2Fpan-domain-node)
21
39
  ```
22
40
  npm install --save-dev @guardian/pan-domain-node
23
41
  ```
24
42
 
43
+ ### Initialisation
25
44
  ```typescript
26
45
  import { PanDomainAuthentication, AuthenticationStatus, User, guardianValidation } from '@guardian/pan-domain-node';
27
46
 
@@ -38,18 +57,39 @@ function customValidation(user: User): boolean {
38
57
  const isInCorrectDomain = user.email.indexOf('test.com') !== -1;
39
58
  return isInCorrectDomain && user.multifactor;
40
59
  }
60
+ ```
41
61
 
42
- // when handling a request
43
- function(request) {
44
- // Pass the raw unparsed cookies
45
- return panda.verify(request.headers['Cookie']).then(( { status, user }) => {
46
- switch(status) {
47
- case AuthenticationStatus.Authorised:
48
- // Good user, handle the request!
49
-
50
- default:
51
- // Bad user. Return 4XX
62
+ ### Verification: page endpoints
63
+ This is for endpoints that **can** refresh credentials, e.g. a page endpoint that can redirect to an auth flow:
64
+ ```typescript
65
+ const authenticationResult = await panda.verify(headers.cookie);
66
+ if (authenticationResult.success) {
67
+ if (authenticationResult.shouldRefreshCredentials) {
68
+ // Send for auth
69
+ } else {
70
+ // Can perform action with user
71
+ return authenticationResult.user;
52
72
  }
53
- });
54
73
  }
55
74
  ```
75
+
76
+ ### Verification: API endpoints
77
+ This is for endpoints that **cannot** refresh credentials, e.g. API endpoints:
78
+ ```typescript
79
+ const authenticationResult = await panda.verify(headers.cookie);
80
+ if (authenticationResult.success) {
81
+ const user = authenticationResult.user;
82
+ // Handle request
83
+ // When returning response:
84
+ if (authenticationResult.shouldRefreshCredentials) {
85
+ const mustRefreshByEpochTimeMillis = authenticationResult.mustRefreshByEpochTimeMillis;
86
+ const remainingTime = mustRefreshByEpochTimeMillis - Date.now();
87
+ console.warn(`Stale Panda auth, will expire in ${remainingTime} milliseconds`);
88
+ // Can still return 200, but depending on the type of API,
89
+ // we may want to return some extra information so the client
90
+ // can warn the user they need to refresh their session.
91
+ } else {
92
+ // It's a fresh session. Nothing to worry about!
93
+ }
94
+ }
95
+ ```
package/dist/src/api.d.ts CHANGED
@@ -1,10 +1,35 @@
1
1
  export { PanDomainAuthentication } from './panda';
2
- export declare enum AuthenticationStatus {
3
- INVALID_COOKIE = "Invalid Cookie",
4
- EXPIRED = "Expired",
5
- NOT_AUTHORISED = "Not Authorised",
6
- AUTHORISED = "Authorised"
2
+ export declare const gracePeriodInMillis: number;
3
+ interface Result {
4
+ success: boolean;
7
5
  }
6
+ interface Success extends Result {
7
+ success: true;
8
+ shouldRefreshCredentials: boolean;
9
+ user: User;
10
+ }
11
+ interface Failure extends Result {
12
+ success: false;
13
+ reason: string;
14
+ }
15
+ export interface FreshSuccess extends Success {
16
+ shouldRefreshCredentials: false;
17
+ }
18
+ export interface StaleSuccess extends Success {
19
+ shouldRefreshCredentials: true;
20
+ mustRefreshByEpochTimeMillis: number;
21
+ }
22
+ export interface UserValidationFailure extends Failure {
23
+ reason: 'invalid-user';
24
+ user: User;
25
+ }
26
+ export interface CookieFailure extends Failure {
27
+ reason: 'no-cookie' | 'invalid-cookie' | 'expired-cookie';
28
+ }
29
+ export interface UnknownFailure extends Failure {
30
+ reason: 'unknown';
31
+ }
32
+ export declare type AuthenticationResult = FreshSuccess | StaleSuccess | CookieFailure | UserValidationFailure | UnknownFailure;
8
33
  export interface User {
9
34
  firstName: string;
10
35
  lastName: string;
@@ -15,9 +40,5 @@ export interface User {
15
40
  expires: number;
16
41
  multifactor: boolean;
17
42
  }
18
- export interface AuthenticationResult {
19
- status: AuthenticationStatus;
20
- user?: User;
21
- }
22
43
  export declare type ValidateUserFn = (user: User) => boolean;
23
44
  export declare function guardianValidation(user: User): boolean;
package/dist/src/api.js CHANGED
@@ -1,15 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.guardianValidation = exports.AuthenticationStatus = void 0;
3
+ exports.guardianValidation = exports.gracePeriodInMillis = void 0;
4
4
  var panda_1 = require("./panda");
5
5
  Object.defineProperty(exports, "PanDomainAuthentication", { enumerable: true, get: function () { return panda_1.PanDomainAuthentication; } });
6
- var AuthenticationStatus;
7
- (function (AuthenticationStatus) {
8
- AuthenticationStatus["INVALID_COOKIE"] = "Invalid Cookie";
9
- AuthenticationStatus["EXPIRED"] = "Expired";
10
- AuthenticationStatus["NOT_AUTHORISED"] = "Not Authorised";
11
- AuthenticationStatus["AUTHORISED"] = "Authorised";
12
- })(AuthenticationStatus = exports.AuthenticationStatus || (exports.AuthenticationStatus = {}));
6
+ // We continue to consider the request authenticated for
7
+ // a period of time after the cookie expiry. This is to allow
8
+ // API requests which cannot directly send the user for re-auth to
9
+ // indicate to the user that they must take some action to refresh their
10
+ // credentials (usually, refreshing the page).
11
+ // Panda cookie: issued expires
12
+ // | |
13
+ // |--1 hour--|
14
+ // Grace period: [------------- 24 hours ------]
15
+ // `success`: --false-][-true-----------------------------------][-false-------->
16
+ // `shouldRefreshCredentials` [-false---][-true------------------------]
17
+ exports.gracePeriodInMillis = 24 * 60 * 60 * 1000;
13
18
  function guardianValidation(user) {
14
19
  const isGuardianUser = user.email.indexOf('guardian.co.uk') !== -1;
15
20
  return isGuardianUser && user.multifactor;
@@ -10,7 +10,7 @@ export declare class PanDomainAuthentication {
10
10
  keyFile: string;
11
11
  validateUser: ValidateUserFn;
12
12
  publicKey: Promise<PublicKeyHolder>;
13
- keyCacheTime: number;
13
+ keyCacheTimeInMillis: number;
14
14
  keyUpdateTimer?: NodeJS.Timeout;
15
15
  constructor(cookieName: string, region: string, bucket: string, keyFile: string, validateUser: ValidateUserFn);
16
16
  stop(): void;
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 { status: api_1.AuthenticationStatus.INVALID_COOKIE };
45
+ return {
46
+ success: false,
47
+ reason: 'no-cookie'
48
+ };
46
49
  }
47
- const { data, signature } = utils_1.parseCookie(pandaCookie);
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 { status: api_1.AuthenticationStatus.INVALID_COOKIE };
59
+ return {
60
+ success: false,
61
+ reason: 'invalid-cookie'
62
+ };
50
63
  }
51
- const currentTimestampInMilliseconds = currentTime.getTime();
64
+ const currentTimestampInMillis = currentTime.getTime();
52
65
  try {
53
66
  const user = utils_1.parseUser(data);
54
- const isExpired = user.expires < currentTimestampInMilliseconds;
67
+ const isExpired = user.expires < currentTimestampInMillis;
55
68
  if (isExpired) {
56
- return { status: api_1.AuthenticationStatus.EXPIRED, user };
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 { status: api_1.AuthenticationStatus.NOT_AUTHORISED, user };
86
+ return {
87
+ success: false,
88
+ reason: 'invalid-user',
89
+ user
90
+ };
60
91
  }
61
- return { status: api_1.AuthenticationStatus.AUTHORISED, user };
92
+ return {
93
+ success: true,
94
+ shouldRefreshCredentials: false,
95
+ user
96
+ };
62
97
  }
63
98
  catch (error) {
64
99
  console.error(error);
65
- return { status: api_1.AuthenticationStatus.INVALID_COOKIE };
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.keyCacheTime = 60 * 1000; // 1 minute
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.keyCacheTime);
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.keyCacheTime) {
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
  }
@@ -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 splitCookie = cookie.split('\.');
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(splitCookie[0]),
37
- signature: splitCookie[1]
56
+ data: decodeBase64(data),
57
+ signature: signature
38
58
  };
39
59
  }
40
60
  exports.parseCookie = parseCookie;