This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-casbin.js.git

commit e21e9ed915dcb4db762a0b6f5ba4631fd758a4fd
Author: Yang Luo <[email protected]>
AuthorDate: Sun Nov 1 02:42:05 2026 +0800

    feat: add cookies mode and improve permission loading
---
 README.md                       | 269 ++++++++++++++++++++----------------
 src/Authorizer.ts               | 298 ++++++++++++++++++++++++++++------------
 src/Cache.ts                    |  86 ++++++++----
 src/Permission.ts               |  75 +++++-----
 src/__test__/authorizer.test.ts | 138 +++++++++++++++++++
 src/__test__/cache.test.ts      |  50 +++++--
 src/__test__/cookie.test.ts     |  74 ++++++++++
 src/__test__/index.test.ts      |  51 +++----
 src/__test__/permission.test.ts |  38 +++--
 src/__test__/server.ts          |  76 +++++-----
 src/index.ts                    |   2 +
 src/types.ts                    |  19 ++-
 src/utils.ts                    |   3 -
 13 files changed, 827 insertions(+), 352 deletions(-)

diff --git a/README.md b/README.md
index c3350d0..a03a681 100644
--- a/README.md
+++ b/README.md
@@ -1,116 +1,153 @@
-# Casbin.js
-
-[![GitHub 
Actions](https://github.com/apache/casbin-casbin.js/workflows/build/badge.svg)](https://github.com/apache/casbin-casbin.js/actions)
-[![Release](https://img.shields.io/github/release/apache/casbin-casbin.js.svg)](https://github.com/apache/casbin-casbin.js/releases/latest)
-[![NPM version][npm-image]][npm-url]
-[![NPM download][download-image]][download-url]
-[![install 
size](https://packagephobia.now.sh/badge?p=casbin.js)](https://packagephobia.now.sh/result?p=casbin.js)
-[![Discord](https://img.shields.io/discord/1022748306096537660?logo=discord&label=discord&color=5865F2)](https://discord.gg/S5UjpzGZjN)
-
-[npm-image]: https://img.shields.io/npm/v/casbin.js.svg?style=flat-square
-[npm-url]: https://npmjs.org/package/casbin.js
-[download-image]: https://img.shields.io/npm/dm/casbin.js.svg?style=flat-square
-[download-url]: https://npmjs.org/package/casbin.js
-
-Casbin.js is the frontend library for [Casbin](https://casbin.apache.org), 
which facilitates the manipulation, management and storage of the user 
permission in a frontend application.
-
-## Example
-
-We demonstrate the usage of Casbin.js with [a React 
app](https://github.com/casbin-js/examples/tree/master/src). View the code to 
see more details.
-
-
-You can use `manual` mode in Casbin.js, and set the permission whenever you 
wish.
-```javascript
-const casbinjs = require('casbin.js');
-
-// Set the user's permission:
-// He/She can read 2 objects: data1 and data2
-// Can write 1 objects: data1
-const permission = {
-    "read": ['data1', 'data2'],
-    "write": ['data1']
-}
-
-// Run casbin.js in manual mode, which requires you to set the permission 
manually.
-const authorizer = new casbinjs.Authorizer("manual");
-
-authorizer.setPermission(permission);
-
-authorizer.can("read", "data1").then(result => {
-  console.log(result)
-})
-authorizer.cannot("write", "data2").then(result => {
-  console.log(result)
-});
-```
-
-In `manual` mode you can also pass the model and policies produced by the 
backend's
-`CasbinJsGetPermissionForUser` (a JSON object with the keys `m`, `p` and `g`) 
to
-`setPermission`. Casbin.js then builds a real enforcer from that data, so 
roles (`g`
-rules) are taken into account. Remember to set the user, since the request is 
evaluated
-as `(user, object, action)`.
-```javascript
-const authorizer = new casbinjs.Authorizer("manual");
-
-// The JSON string or object returned by CasbinJsGetPermissionForUser
-authorizer.setPermission(responseFromApi);
-await authorizer.setUser("alice");
-
-authorizer.can("read", "data1").then(result => {
-  console.log(result)
-});
-```
-
-### Users with several roles
-
-A user may hold any number of roles at the same time, and a role may inherit 
from
-another role. Casbin.js evaluates the `g` rules with the same RBAC engine as 
the
-backend, so the user gets the permissions of every one of those roles. This 
needs
-the model and policies described above: the plain `{action: [objects]}` map 
holds no
-roles at all, only the flattened result.
-```javascript
-// g, alice, admin
-// g, alice, editor
-// g, admin, root
-const authorizer = new casbinjs.Authorizer("manual");
-authorizer.setPermission(responseFromApi);
-await authorizer.setUser("alice");
-
-// Granted by "admin", by "editor", and by the "root" role that "admin" 
inherits.
-await authorizer.can("read", "data1");
-
-// ["admin", "editor", "root"]
-await authorizer.getRoles();
-
-// [["admin", "data1", "read"], ["editor", "data2", "write"], ...]
-await authorizer.getImplicitPermissions();
-```
-
-For a model with domains, pass the domain to each of them: 
`authorizer.can("read", "data1", "domain1")`,
-`authorizer.getRoles("domain1")`.
-
-You can also use the `auto` mode. In details, specify a casbin backend service 
endpoint when initializing the Casbin.js authorizer, and set the subject when 
the frontend user identity changes. Casbin.js will automatically fetch the 
permission from the endpoint. (A pre-configurated casbin service API is 
required at the backend.)
-```javascript
-const casbinjs = require('casbin.js');
-
-// Set your backend casbin service url
-const authorizer = new casbinjs.Authorizer('auto', {endpoint: 
'http://Domain_name/casbin/api'});
-
-// When the identity shifts, reset the user. Casbin.js will automatically 
fetch the permission from the endpoint.
-await authorizer.setUser("Tom");
-
-// Evaluate the permission
-authorizer.can("read", "data1").then();
-```
-
-More functionalities of Casbin.js are still under development. Feel free to 
raise issues to share your features suggestions!
-
-## TODO MAP
-- [x] Permission cache.
-- [ ] Cookie mode.
-- [ ] Lightweight enforcer (avoid the abuse of async functions).
-- [ ] Integration with other modern frontend frameworks.
-
-
-
-
+# Casbin.js
+
+[![GitHub 
Actions](https://github.com/apache/casbin-casbin.js/workflows/build/badge.svg)](https://github.com/apache/casbin-casbin.js/actions)
+[![Release](https://img.shields.io/github/release/apache/casbin-casbin.js.svg)](https://github.com/apache/casbin-casbin.js/releases/latest)
+[![NPM version][npm-image]][npm-url]
+[![NPM download][download-image]][download-url]
+[![install 
size](https://packagephobia.now.sh/badge?p=casbin.js)](https://packagephobia.now.sh/result?p=casbin.js)
+[![Discord](https://img.shields.io/discord/1022748306096537660?logo=discord&label=discord&color=5865F2)](https://discord.com/invite/S5UjpzGZjN)
+
+[npm-image]: https://img.shields.io/npm/v/casbin.js.svg?style=flat-square
+[npm-url]: https://npmjs.org/package/casbin.js
+[download-image]: https://img.shields.io/npm/dm/casbin.js.svg?style=flat-square
+[download-url]: https://npmjs.org/package/casbin.js
+
+Casbin.js is the frontend authorization library of 
[Casbin](https://casbin.apache.org). It evaluates the permission of the current 
user in a browser with the same model and policies as the backend, for access 
control models like ACL, RBAC and ABAC.
+
+## Installation
+
+```bash
+npm install casbin.js
+```
+
+or
+
+```bash
+yarn add casbin.js
+```
+
+## Modes
+
+Casbin.js can load the permission in three ways:
+
+| Mode | How the permission arrives |
+| --- | --- |
+| `manual` | The application passes it to `setPermission` |
+| `auto` | Casbin.js fetches it from a backend endpoint when the user changes |
+| `cookies` | Casbin.js reads it from a cookie set by the backend |
+
+### Manual mode
+
+`setPermission` accepts either a map of actions to objects, or the model and 
policies produced by the backend's `CasbinJsGetPermissionForUser`.
+
+```javascript
+const casbin = require('casbin.js');
+
+// A map of actions to objects: the user can read data1 and data2, and write 
data1.
+const authorizer = new casbin.Authorizer('manual');
+authorizer.setPermission({
+    read: ['data1', 'data2'],
+    write: ['data1'],
+});
+
+await authorizer.can('read', 'data1'); // true
+await authorizer.cannot('write', 'data2'); // true
+```
+
+The map form is a flattened result. Use the model and policies when the 
backend returns roles, so that Casbin.js evaluates the same RBAC model itself:
+
+```javascript
+// The JSON string or object returned by CasbinJsGetPermissionForUser:
+// { m: "...", p: [["p", "admin", "data1", "read"], ...], g: [["g", "alice", 
"admin"], ...] }
+const authorizer = new casbin.Authorizer('manual');
+authorizer.setPermission(responseFromApi);
+await authorizer.setUser('alice');
+
+await authorizer.can('read', 'data1');
+await authorizer.getRoles(); // ["admin", ...]
+await authorizer.getImplicitPermissions(); // [["admin", "data1", "read"], ...]
+```
+
+A user may hold several roles at the same time, and a role may inherit from 
another role. Casbin.js evaluates the `g` rules with the same RBAC engine as 
the backend, so the user gets the permissions of every one of those roles.
+
+For a model with domains, pass the domain to each call: 
`authorizer.can('read', 'data1', 'domain1')`, `authorizer.getRoles('domain1')`.
+
+### Auto mode
+
+Point Casbin.js at a backend endpoint that serves the permission of a subject. 
The endpoint receives the user as the `subject` query parameter and returns `{ 
"message": "ok", "data": "<the CasbinJsGetPermissionForUser result>" }`.
+
+```javascript
+const authorizer = new casbin.Authorizer('auto', {
+    endpoint: 'https://example.com/casbin/api',
+    requestHeaders: { Authorization: 'Bearer ...' },
+    cacheExpiredTime: 60, // seconds, defaults to 60
+});
+
+// When the identity changes, Casbin.js fetches and caches the new permission.
+await authorizer.setUser('alice');
+await authorizer.can('read', 'data1');
+
+// The permission is cached in localStorage per endpoint and user.
+authorizer.clearCache(); // drop the cached permission of the current user
+await authorizer.refresh(); // drop it and load it again from the endpoint
+```
+
+`can` waits for a permission that `setUser` is still loading, so both of these 
work:
+
+```javascript
+authorizer.setUser('alice');
+await authorizer.can('read', 'data1'); // waits for the request above
+```
+
+### Cookies mode
+
+If the backend stores the permission in a cookie, Casbin.js reads it at 
construction time. The default cookie key is `access_perm`; pass `cookieKey` to 
use another one.
+
+```javascript
+// Reads the cookie "access_perm" and loads its JSON value.
+const authorizer = new casbin.Authorizer('cookies');
+
+// Or another cookie key.
+const authorizer2 = new casbin.Authorizer('cookies', { cookieKey: 'my_perm' });
+
+// Re-read the cookie after the backend refreshed it.
+authorizer.loadPermissionFromCookie();
+```
+
+The cookie value can be either form accepted by `setPermission`. With the 
model-and-policies form, call `setUser` before evaluating the permission.
+
+## API
+
+| Method | Description |
+| --- | --- |
+| `new Authorizer(mode, options)` | `mode` is `"manual"`, `"auto"` or 
`"cookies"`. Options: `endpoint`, `cacheExpiredTime`, `requestHeaders`, 
`cookieKey`. |
+| `setPermission(permission)` | Load the permission, as a JSON string or an 
object. |
+| `getPermission()` | Get the action-to-objects permission. Only available for 
the map form. |
+| `setUser(user)` | Set the subject. In `auto` mode this loads the permission. 
|
+| `refresh()` | Reload the permission from the endpoint, ignoring the cache. 
Only in `auto` mode. |
+| `clearCache()` | Remove the cached permission of the current user. |
+| `loadPermissionFromCookie()` | Re-read the permission cookie. Only in 
`cookies` mode. |
+| `can(action, object[, domain])` | Whether the current user can perform the 
action on the object. |
+| `cannot(action, object[, domain])` | The negation of `can`. |
+| `canAll(action, objects[, domain])` | Whether `can` is true for every 
object. |
+| `canAny(action, objects[, domain])` | Whether `can` is true for at least one 
object. |
+| `getRoles([domain])` | Every role of the user, the inherited ones included. 
Requires the model and policies. |
+| `getImplicitPermissions([domain])` | Every permission of the user, the ones 
from its roles included. Requires the model and policies. |
+
+## Development
+
+```bash
+yarn install
+yarn lint
+yarn test
+yarn build
+```
+
+## Example
+
+See the [React example](https://github.com/casbin-js/examples/tree/master/src) 
for a complete application.
+
+## TODO
+
+- [ ] Lightweight enforcer (avoid the abuse of async functions).
+- [ ] Integration with other modern frontend frameworks.
diff --git a/src/Authorizer.ts b/src/Authorizer.ts
index c03ac3e..09e9b02 100644
--- a/src/Authorizer.ts
+++ b/src/Authorizer.ts
@@ -1,70 +1,80 @@
 import * as casbin from 'casbin-core';
+import Cookies from 'js-cookie';
 import Permission from './Permission';
-import { StringKV } from './types';
+import { EnforcerData, HeaderMap, PermissionSource, StringKV } from './types';
 import * as Cache from './Cache';
 
 interface BaseResponse {
     message: string;
-    data: any;
+    data: PermissionSource;
 }
 
-type Mode = "auto" | "cookies" | "manual"
+export type Mode = 'auto' | 'cookies' | 'manual';
+
+export interface AuthorizerOptions {
+    /** Casbin service endpoint, required when mode == "auto" */
+    endpoint?: string;
+    /** The expired time of the local cache, in seconds. Default: 60s, 
activated when mode == "auto" */
+    cacheExpiredTime?: number;
+    /** Extra headers sent with every request to the endpoint */
+    requestHeaders?: HeaderMap;
+    /** The cookie key when loading the permission, activated when mode == 
"cookies". Default: "access_perm" */
+    cookieKey?: string;
+}
+
+const DEFAULT_COOKIE_KEY = 'access_perm';
 
 /**
  * Whether the object is the model + policies produced by casbin's
  * "CasbinJsGetPermissionForUser", rather than an action-to-objects map.
  */
-function isEnforcerData(obj: unknown): obj is Record<string, unknown> {
+function isEnforcerData(obj: unknown): obj is EnforcerData {
     return typeof obj === 'object' && obj !== null && typeof (obj as 
Record<string, unknown>)['m'] === 'string';
 }
 
 export class Authorizer {
     public mode!: Mode;
-    public endpoint: string | undefined = undefined;
-    public requestHeaders: StringKV | undefined = undefined;
-    public permission: Permission | undefined;
-    public cookieKey : string | undefined = undefined;
-    public cacheExpiredTime  = 60; // Seconds
-    public user : string | undefined;
-    public enforcer:casbin.Enforcer | undefined;
-    private enforcerPromise: Promise<void> | undefined;
+    public endpoint?: string;
+    public requestHeaders?: HeaderMap;
+    public permission?: Permission;
+    public cookieKey?: string;
+    public cacheExpiredTime = 60; // Seconds
+    public user?: string;
+    public enforcer?: casbin.Enforcer;
+
+    private enforcerPromise?: Promise<void>;
+    private userLoadPromise?: Promise<void>;
+    private userLoadTarget?: string;
+    /** Incremented whenever the permission is replaced, so a slow load cannot 
overwrite a newer one. */
+    private stateVersion = 0;
 
     /**
      *
      * @param mode "auto", "cookies" or "manual"
      * "auto": Specify the casbin server endpoint, and Casbin.js will load 
permission from it when the identity changes
-     * "cookies": Casbin.js load the permission data from the cookie 
"access_perm" or the specified cookie key.
-     * "manual": Load the permission mannually with "setPermission"
+     * "cookies": Casbin.js loads the permission data from the cookie 
"access_perm" or the specified cookie key.
+     * "manual": Load the permission manually with "setPermission"
      * @param args
      * @param args.endpoint Casbin service endpoint, REQUIRED when mode == 
"auto"
      * @param args.cacheExpiredTime The expired time of local cache, Unit: 
seconds, Default: 60s, activated when mode == "auto"
+     * @param args.requestHeaders Extra headers sent with every request to the 
endpoint, activated when mode == "auto"
      * @param args.cookieKey The cookie key when loading permission, activated 
when mode == "cookies"
      */
-    constructor(mode: Mode = 'manual', args: { endpoint?: string; 
cacheExpiredTime?: number; requestHeaders?: StringKV } = {}) {
+    constructor(mode: Mode = 'manual', args: AuthorizerOptions = {}) {
         if (mode == 'auto') {
             if (!args.endpoint) {
                 throw new Error("Specify the endpoint when initializing 
casbin.js with mode == 'auto'");
-            } else {
-                this.mode = mode;
-                this.endpoint = args.endpoint;
-                if (args.requestHeaders) {
-                    this.requestHeaders = args.requestHeaders;
-                }
-                if (args.cacheExpiredTime !== null && args.cacheExpiredTime 
!== undefined) {
-                    this.cacheExpiredTime = args.cacheExpiredTime;
-                }
             }
-        } else if (mode == 'cookies') {
-            throw Error("Cookie mode not implemented.");
-            /*
             this.mode = mode;
-            const permission = Cookies.get(args.cookieKey ? args.cookieKey : 
"access_perm");
-            if (permission) {
-                this.setPermission(permission);
-            } else {
-                console.log("WARNING: No specified cookies");
+            this.endpoint = args.endpoint;
+            this.requestHeaders = args.requestHeaders;
+            if (args.cacheExpiredTime !== null && args.cacheExpiredTime !== 
undefined) {
+                this.cacheExpiredTime = args.cacheExpiredTime;
             }
-            */
+        } else if (mode == 'cookies') {
+            this.mode = mode;
+            this.cookieKey = args.cookieKey ?? DEFAULT_COOKIE_KEY;
+            this.loadPermissionFromCookie();
         } else if (mode == 'manual') {
             this.mode = mode;
         } else {
@@ -73,15 +83,17 @@ export class Authorizer {
     }
 
     /**
-     * Get the permission.
+     * Get the action-to-objects permission. Only available when the permission
+     * was loaded as such a map; the model and policies of
+     * "CasbinJsGetPermissionForUser" are evaluated by "can" instead.
      */
-    public getPermission() : StringKV {
-        if (this.permission !== undefined) {
-            return this.permission?.getPermissionJsonObject();
-        } else {
-            throw Error("Permission is not defined. Are you using manual mode 
and have set the permission?");
-            return {} as StringKV;
+    public getPermission(): StringKV {
+        if (this.permission === undefined) {
+            throw Error(
+                'No action-to-objects permission is loaded. "getPermission" 
only works with the map form passed to "setPermission".',
+            );
         }
+        return this.permission.getPermissionJsonObject();
     }
 
     /**
@@ -93,7 +105,7 @@ export class Authorizer {
      *   (a JSON object with the keys "m", "p" and "g"). In this case an 
enforcer is
      *   built from that data, and "can" evaluates the model instead of a 
static map.
      */
-    public setPermission(permission : Record<string, unknown> | string) : void{
+    public setPermission(permission: Record<string, unknown> | string): void {
         const obj = typeof permission === 'string' ? JSON.parse(permission) : 
permission;
         if (isEnforcerData(obj)) {
             this.enforcerPromise = this.initEnforcer(obj);
@@ -102,8 +114,9 @@ export class Authorizer {
             this.enforcerPromise.catch(() => undefined);
             return;
         }
-        // A previously built enforcer takes precedence in "can", so it has to 
go
-        // when the permission is replaced by a plain action-to-objects map.
+        // A previously built enforcer, or one that is still being built, must 
not
+        // take precedence in "can" once a plain action-to-objects map is set.
+        this.stateVersion++;
         this.enforcer = undefined;
         this.enforcerPromise = undefined;
         if (this.permission === undefined) {
@@ -112,26 +125,34 @@ export class Authorizer {
         this.permission.load(obj);
     }
 
-    public async initEnforcer(s: string | Record<string, unknown>): 
Promise<void> {
-        const obj = typeof s === 'string' ? JSON.parse(s) : s;
-        if (!('m' in obj)) {
-            throw Error("No model when init enforcer.");
+    /**
+     * Build an enforcer from the model and policies of 
"CasbinJsGetPermissionForUser".
+     */
+    public async initEnforcer(permission: PermissionSource): Promise<void> {
+        const obj = typeof permission === 'string' ? JSON.parse(permission) : 
permission;
+        if (!isEnforcerData(obj)) {
+            throw Error('No model when init enforcer.');
         }
+        const version = ++this.stateVersion;
         // Drop the previous state first, so a failed or half-finished load 
never
         // leaves the permissions of the former identity in place.
         this.enforcer = undefined;
         this.permission = undefined;
-        const m = new casbin.Model(obj['m']);
+        const m = new casbin.Model(obj.m);
         const enforcer = await casbin.newEnforcer(m);
         // "CasbinJsGetPermissionForUser" puts the policies under "p" and the 
grouping
         // policies under "g", each rule prefixed with its own ptype. The 
grouping
         // policies carry the role assignments, including the several roles a 
single
         // user may have, and any role that inherits from another role.
-        for (const section of ['p', 'g']) {
+        for (const section of ['p', 'g'] as const) {
             if (section in obj) {
                 await this.loadRules(enforcer, obj[section]);
             }
         }
+        // A newer permission replaced this one while the enforcer was being 
built.
+        if (version !== this.stateVersion) {
+            return;
+        }
         this.enforcer = enforcer;
     }
 
@@ -140,10 +161,10 @@ export class Authorizer {
             return;
         }
         for (const rule of rules) {
-            if (!Array.isArray(rule)) {
+            if (!Array.isArray(rule) || rule.length === 0) {
                 continue;
             }
-            const arr = (rule as string[]).map(v => v.trim());
+            const arr = rule.map((v) => String(v).trim());
             const pType = arr.shift();
             if (!pType) {
                 continue;
@@ -157,39 +178,139 @@ export class Authorizer {
     }
 
     /**
-     * Initialize the enforcer
+     * Fetch the permission data from the configured endpoint.
      */
-    public async getEnforcerDataFromSvr(): Promise<string>{
+    public async getEnforcerDataFromSvr(): Promise<PermissionSource> {
         if (this.endpoint === undefined || this.endpoint === null) {
-            throw Error("Endpoint is null or not specified.");
+            throw Error('Endpoint is null or not specified.');
         }
-        const resp = await fetch(`${this.endpoint}?subject=${this.user}`, {
-            headers: this.requestHeaders as unknown as HeadersInit,
+        const subject = this.requireUser();
+        const separator = this.endpoint.includes('?') ? '&' : '?';
+        const resp = await 
fetch(`${this.endpoint}${separator}subject=${encodeURIComponent(subject)}`, {
+            headers: this.requestHeaders,
         });
         if (!resp.ok) {
-            throw new Error(`HTTP error: ${resp.status}`);
+            throw new Error(`Failed to load the permission from 
${this.endpoint}: HTTP ${resp.status}`);
+        }
+        let body: BaseResponse;
+        try {
+            body = await resp.json();
+        } catch (e) {
+            throw new Error(`The endpoint ${this.endpoint} did not return 
valid JSON.`);
+        }
+        if (body === null || typeof body !== 'object' || body.data === 
undefined) {
+            throw new Error(`The endpoint ${this.endpoint} did not return a 
"data" field.`);
         }
-        const body: BaseResponse = await resp.json();
         return body.data;
     }
 
     /**
-     * Set the user subject for the authroizer
+     * Set the user subject for the authorizer.
+     * In "auto" mode the permission of the user is loaded from the endpoint,
+     * or from the local cache when it has not expired yet.
      * @param user The current user
      */
-    public async setUser(user : string) : Promise<void> {
-        if (user === this.user) {
+    public async setUser(user: string): Promise<void> {
+        if (this.mode != 'auto') {
+            this.user = user;
+            return;
+        }
+        if (this.userLoadPromise !== undefined && this.userLoadTarget === 
user) {
+            // The same identity is already being loaded, share its result.
+            return this.userLoadPromise;
+        }
+        if (user === this.user && this.userLoadPromise === undefined && 
(this.enforcer !== undefined || this.permission !== undefined)) {
+            return;
+        }
+        await this.loadUser(user);
+    }
+
+    /**
+     * Load the permission of the current user again from the endpoint, 
ignoring
+     * the local cache. Only available in "auto" mode.
+     */
+    public async refresh(): Promise<void> {
+        if (this.mode != 'auto') {
+            throw Error('"refresh" is only available in auto mode.');
+        }
+        const user = this.requireUser();
+        Cache.removeLocalStorage(this.cacheKey(user));
+        await this.loadUser(user);
+    }
+
+    /**
+     * Remove the cached permission of the current user.
+     */
+    public clearCache(): void {
+        if (this.user === undefined) {
             return;
         }
+        Cache.removeLocalStorage(this.cacheKey(this.user));
+    }
+
+    private cacheKey(user: string): string {
+        // Two authorizers on the same origin may point at different endpoints,
+        // so the same user name must not share a cached permission between 
them.
+        return this.endpoint === undefined ? user : 
`${this.endpoint}::${user}`;
+    }
+
+    private async loadUser(user: string): Promise<void> {
         this.user = user;
-        if (this.mode == 'auto') {
-            let config = Cache.loadFromLocalStorage(user);
-            if (config === null) {
+        // The permission of the former identity must not stay in use while the
+        // new one is loading, otherwise a slow request would keep the old 
rights.
+        // Bumping the version also invalidates an enforcer that is still 
being built.
+        this.stateVersion++;
+        this.enforcer = undefined;
+        this.permission = undefined;
+        this.enforcerPromise = undefined;
+        const load = (async () => {
+            const key = this.cacheKey(user);
+            const cached = Cache.loadFromLocalStorage(key);
+            let config: PermissionSource;
+            if (cached === null) {
                 config = await this.getEnforcerDataFromSvr();
-                Cache.saveToLocalStorage(user, config, this.cacheExpiredTime);
+                Cache.saveToLocalStorage(key, typeof config === 'string' ? 
config : JSON.stringify(config), this.cacheExpiredTime);
+            } else {
+                config = cached;
+            }
+            // Another identity was requested while this one was loading.
+            if (this.user !== user) {
+                return;
             }
             await this.initEnforcer(config);
+        })();
+        this.userLoadTarget = user;
+        this.userLoadPromise = load;
+        try {
+            await load;
+        } finally {
+            if (this.userLoadPromise === load) {
+                this.userLoadPromise = undefined;
+                this.userLoadTarget = undefined;
+            }
+        }
+    }
+
+    /**
+     * Read the permission from the cookie and load it. Only available in 
"cookies" mode.
+     * @returns Whether a permission cookie was found.
+     */
+    public loadPermissionFromCookie(): boolean {
+        if (this.mode != 'cookies') {
+            throw Error('"loadPermissionFromCookie" is only available in 
cookies mode.');
         }
+        const key = this.cookieKey ?? DEFAULT_COOKIE_KEY;
+        const permission = Cookies.get(key);
+        if (permission === undefined) {
+            console.warn(`WARNING: No cookie named "${key}" was found.`);
+            return false;
+        }
+        try {
+            this.setPermission(permission);
+        } catch (e) {
+            throw new Error(`The cookie "${key}" does not contain a valid 
permission: ${(e as Error).message}`);
+        }
+        return true;
     }
 
     /**
@@ -200,11 +321,10 @@ export class Authorizer {
      * @param domain The domain, for a model with domains
      */
     public async getRoles(domain?: string): Promise<string[]> {
-        const enforcer = await this.getEnforcer();
+        await this.settle();
+        const enforcer = this.getEnforcerOrThrow();
         const user = this.requireUser();
-        return domain === undefined
-            ? await enforcer.getImplicitRolesForUser(user)
-            : await enforcer.getImplicitRolesForUser(user, domain);
+        return domain === undefined ? await 
enforcer.getImplicitRolesForUser(user) : await 
enforcer.getImplicitRolesForUser(user, domain);
     }
 
     /**
@@ -213,48 +333,55 @@ export class Authorizer {
      * @param domain The domain, for a model with domains
      */
     public async getImplicitPermissions(domain?: string): Promise<string[][]> {
-        const enforcer = await this.getEnforcer();
+        await this.settle();
+        const enforcer = this.getEnforcerOrThrow();
         const user = this.requireUser();
         return domain === undefined
             ? await enforcer.getImplicitPermissionsForUser(user)
             : await enforcer.getImplicitPermissionsForUser(user, domain);
     }
 
-    private async getEnforcer(): Promise<casbin.Enforcer> {
+    private async settle(): Promise<void> {
+        if (this.userLoadPromise !== undefined) {
+            await this.userLoadPromise;
+        }
         if (this.enforcerPromise !== undefined) {
             await this.enforcerPromise;
         }
+    }
+
+    private getEnforcerOrThrow(): casbin.Enforcer {
         if (this.enforcer === undefined) {
-            throw Error("Enforcer not initialized. Roles are only available 
when the permission " +
-                "is the model and policies of 
\"CasbinJsGetPermissionForUser\".");
+            throw Error(
+                'Enforcer not initialized. Roles are only available when the 
permission ' +
+                    'is the model and policies of 
"CasbinJsGetPermissionForUser".',
+            );
         }
         return this.enforcer;
     }
 
     private requireUser(): string {
         if (this.user === undefined) {
-            throw Error("User is not defined. Call \"setUser\" before 
evaluating the permission.");
+            throw Error('User is not defined. Call "setUser" before evaluating 
the permission.');
         }
         return this.user;
     }
 
     public async can(action: string, object: string, domain?: string): 
Promise<boolean> {
-        if (this.enforcerPromise !== undefined) {
-            await this.enforcerPromise;
-        }
+        await this.settle();
         if (this.enforcer !== undefined) {
             // Without a subject every request would silently be denied.
-            this.requireUser();
+            const user = this.requireUser();
             if (domain == undefined) {
-                return await this.enforcer.enforce(this.user, object, action);
+                return await this.enforcer.enforce(user, object, action);
             } else {
-                return await this.enforcer.enforce(this.user, domain, object, 
action);
+                return await this.enforcer.enforce(user, domain, object, 
action);
             }
         }
-        if (this.mode == "manual") {
+        if (this.mode == 'manual' || this.mode == 'cookies') {
             return this.permission !== undefined && 
this.permission.check(action, object);
-        } else if (this.mode == "auto") {
-            throw Error("Enforcer not initialized");
+        } else if (this.mode == 'auto') {
+            throw Error('Permission is not loaded. Set the user with "setUser" 
first.');
         } else {
             throw Error(`Mode ${this.mode} not recognized.`);
         }
@@ -264,7 +391,7 @@ export class Authorizer {
         return !(await this.can(action, object, domain));
     }
 
-    public async canAll(action: string, objects: string[], domain?: string) : 
Promise<boolean> {
+    public async canAll(action: string, objects: string[], domain?: string): 
Promise<boolean> {
         for (let i = 0; i < objects.length; ++i) {
             if (await this.cannot(action, objects[i], domain)) {
                 return false;
@@ -273,7 +400,7 @@ export class Authorizer {
         return true;
     }
 
-    public async canAny(action: string, objects: string[], domain?: string) : 
Promise<boolean> {
+    public async canAny(action: string, objects: string[], domain?: string): 
Promise<boolean> {
         for (let i = 0; i < objects.length; ++i) {
             if (await this.can(action, objects[i], domain)) {
                 return true;
@@ -281,5 +408,4 @@ export class Authorizer {
         }
         return false;
     }
-
 }
diff --git a/src/Cache.ts b/src/Cache.ts
index 954bfee..8e4608a 100644
--- a/src/Cache.ts
+++ b/src/Cache.ts
@@ -1,59 +1,95 @@
+const STORAGE_PREFIX = 'casbinjs_';
+
 const isLocalStorageAvailable: boolean = (() => {
     if (typeof window === 'undefined' || !window.localStorage) {
-        return false
+        return false;
     }
     try {
-        const key = "fUjXn2r59"; // A random key
-        const value = "test";
-        localStorage.setItem(key, value);
-        localStorage.getItem(key);
-        localStorage.removeItem(key);
+        const key = 'fUjXn2r59'; // A random key
+        window.localStorage.setItem(key, 'test');
+        window.localStorage.getItem(key);
+        window.localStorage.removeItem(key);
         return true;
     } catch (e) {
         return false;
     }
 })();
 
-export function saveToLocalStorage(key: string, value: string, expired: 
number): number{
+function storageKey(key: string): string {
+    return `${STORAGE_PREFIX}${key}`;
+}
+
+/**
+ * Save a permission to localStorage.
+ * @returns 0 on success, -1 when the cache is unavailable. Failing to cache
+ * the permission is not fatal, the caller can still use the in-memory copy.
+ */
+export function saveToLocalStorage(key: string, value: string, expired: 
number): number {
     if (!isLocalStorageAvailable) {
         return -1;
     }
     const savedItem = {
         value: value,
-        expired: Date.now() + 1000 * expired
+        expired: Date.now() + 1000 * expired,
     };
     try {
-        localStorage.setItem(`casbinjs_${key}`, JSON.stringify(savedItem));
+        window.localStorage.setItem(storageKey(key), 
JSON.stringify(savedItem));
+        return 0;
     } catch (e) {
-        throw(e)
-        // TODO: Process the quotaExceededError
+        // Typically QuotaExceededError. The cache is only an optimization.
+        return -1;
     }
-    return 0;
 }
 
 /***
- * return: a string.
- * If ret == null, it means there is no such user permission.
+ * Load a permission from localStorage.
+ * @returns The saved value, or null when there is no such entry or it expired.
  */
-export function loadFromLocalStorage(key: string): string | null{
+export function loadFromLocalStorage(key: string): string | null {
     if (!isLocalStorageAvailable) {
         return null;
     }
-    const itemStr = localStorage.getItem(`casbinjs_${key}`);
-    // No cache
+    let itemStr: string | null;
+    try {
+        itemStr = window.localStorage.getItem(storageKey(key));
+    } catch (e) {
+        // Storage can become unavailable at runtime (for example in private 
mode).
+        return null;
+    }
     if (itemStr === null) {
         return null;
     }
-    const item = JSON.parse(itemStr);
-
-    if (Date.now() > item["expired"]){
-        localStorage.removeItem(`casbinjs_${key}`);
+    let item: unknown;
+    try {
+        item = JSON.parse(itemStr);
+    } catch (e) {
+        // A corrupted entry (another script, a manual edit) must not break 
the caller.
+        removeLocalStorage(key);
+        return null;
+    }
+    if (item === null || typeof item !== 'object') {
+        removeLocalStorage(key);
+        return null;
+    }
+    const { value, expired } = item as { value?: unknown; expired?: unknown };
+    if (typeof value !== 'string' || typeof expired !== 'number') {
+        removeLocalStorage(key);
+        return null;
+    }
+    if (Date.now() > expired) {
+        removeLocalStorage(key);
         return null;
-    } else {
-        return item['value'];
     }
+    return value;
 }
 
-export function removeLocalStorage(key: string) {
-    localStorage.removeItem(`casbinjs_${key}`);
+export function removeLocalStorage(key: string): void {
+    if (!isLocalStorageAvailable) {
+        return;
+    }
+    try {
+        window.localStorage.removeItem(storageKey(key));
+    } catch (e) {
+        // Nothing to do, the entry is gone from the cache one way or another.
+    }
 }
diff --git a/src/Permission.ts b/src/Permission.ts
index f31a9dc..d188b75 100644
--- a/src/Permission.ts
+++ b/src/Permission.ts
@@ -1,20 +1,29 @@
-import {StringKV} from './types';
+import { StringKV } from './types';
 
 export default class Permission {
-    private actObjData : Map<string, Array<string>>;
-    private objActData : Map<string, Array<string>>;
+    private actObjData: Map<string, Array<string>>;
+    private objActData: Map<string, Array<string>>;
 
     public constructor() {
         this.actObjData = new Map<string, Array<string>>();
         this.objActData = new Map<string, Array<string>>();
     }
 
-    public load(permission : string | Record<string, unknown>) : void {
-        let p : StringKV;
-        if (typeof(permission) == 'string') {
-            p = JSON.parse(permission) as StringKV;
-        } else {
-            p = permission as StringKV;
+    public load(permission: string | Record<string, unknown>): void {
+        const p = typeof permission === 'string' ? JSON.parse(permission) : 
permission;
+        if (p === null || typeof p !== 'object' || Array.isArray(p)) {
+            throw new Error('The permission must be a JSON object that maps 
actions to arrays of objects.');
+        }
+
+        // Validate before touching the current permission, a malformed input
+        // must not leave a half-loaded one behind.
+        const entries: Array<[string, Array<string>]> = [];
+        for (const act of Object.keys(p)) {
+            const objects = (p as Record<string, unknown>)[act];
+            if (!Array.isArray(objects)) {
+                throw new Error(`The permission of action "${act}" must be an 
array of objects.`);
+            }
+            entries.push([act, objects as Array<string>]);
         }
 
         // A load replaces the permission, it does not merge into the previous 
one.
@@ -23,41 +32,43 @@ export default class Permission {
         this.actObjData.clear();
         this.objActData.clear();
 
-        // Generate data: {key:Actions, value: Array of objects}
-        for (const act in p) {
-            this.actObjData.set(act, p[act] as Array<string>);
-        }
+        const objActData = new Map<string, Array<string>>();
+        for (const [act, objects] of entries) {
+            this.actObjData.set(act, objects);
 
-        // Generate data: {key:Objects, value: Array of actions}
-        const tmp : StringKV = {};
-        for (const act in p) {
-            for (const obj of p[act]) {
-                if (!(obj in tmp)) {
-                    tmp[obj] = [];
+            // Generate data: {key:Objects, value: Array of actions}
+            // A Map is used instead of an object so that an object name such
+            // as "__proto__" is stored as plain data.
+            for (const obj of objects) {
+                const actions = objActData.get(obj);
+                if (actions === undefined) {
+                    objActData.set(obj, [act]);
+                } else {
+                    actions.push(act);
                 }
-                tmp[obj].push(act);
             }
         }
-        for (const obj in tmp) {
-            this.objActData.set(obj, tmp[obj] as Array<string>);
-        }
-        
+        objActData.forEach((actions, obj) => this.objActData.set(obj, 
actions));
     }
 
-    public getPermissionJsonObject() : StringKV {
-        const obj : StringKV = {};
-        this.actObjData.forEach((value, key) => (obj[key] = value));
+    public getPermissionJsonObject(): StringKV {
+        const obj: StringKV = {};
+        this.actObjData.forEach((value, key) => {
+            // defineProperty keeps an action named "__proto__" an ordinary own
+            // property instead of mutating the prototype of the result.
+            Object.defineProperty(obj, key, { value: value.slice(), 
enumerable: true, writable: true, configurable: true });
+        });
         return obj;
     }
 
     /*
         Parse the permission into JSON string
     */
-    public getPermissionString() : string {
+    public getPermissionString(): string {
         return JSON.stringify(this.getPermissionJsonObject());
     }
 
-    public getTargetsFromAction(action: string) : Array<string> {
+    public getTargetsFromAction(action: string): Array<string> {
         const result = this.actObjData.get(action);
         if (result === undefined) {
             return new Array<string>();
@@ -66,15 +77,15 @@ export default class Permission {
         }
     }
 
-    public getActionsObjects() : Map<string, Array<string>> {
+    public getActionsObjects(): Map<string, Array<string>> {
         return this.actObjData;
     }
 
-    public getObjectsActions() : Map<string, Array<string>> {
+    public getObjectsActions(): Map<string, Array<string>> {
         return this.objActData;
     }
 
-    public check(action : string, object : string) : boolean {
+    public check(action: string, object: string): boolean {
         const objects = this.actObjData.get(action);
         if (objects == undefined) {
             return false;
diff --git a/src/__test__/authorizer.test.ts b/src/__test__/authorizer.test.ts
new file mode 100644
index 0000000..2a3b693
--- /dev/null
+++ b/src/__test__/authorizer.test.ts
@@ -0,0 +1,138 @@
+import { Authorizer } from '../Authorizer';
+import { basicModelStr } from './models';
+
+function permissionOf(user: string, object: string): string {
+    return JSON.stringify({
+        m: basicModelStr,
+        p: [['p', user, object, 'read']],
+    });
+}
+
+test('can waits for the permission that setUser is still loading', async () => 
{
+    const authorizer = new Authorizer('auto', { endpoint: 
'http://casbin.test/in-flight' });
+    authorizer.getEnforcerDataFromSvr = () => 
Promise.resolve(permissionOf('alice', 'data1'));
+
+    const loading = authorizer.setUser('alice');
+    // No await on "loading" here, "can" has to wait for it by itself.
+    expect(await authorizer.can('read', 'data1')).toBe(true);
+    await loading;
+});
+
+test('a slow load for a former user does not overwrite the current one', async 
() => {
+    const authorizer = new Authorizer('auto', { endpoint: 
'http://casbin.test/switch' });
+    let releaseAlice: () => void = () => undefined;
+    const aliceLoading = new Promise<void>((resolve) => (releaseAlice = 
resolve));
+    authorizer.getEnforcerDataFromSvr = async () => {
+        if (authorizer.user === 'alice') {
+            await aliceLoading;
+            return permissionOf('alice', 'alice-data');
+        }
+        return permissionOf('bob', 'bob-data');
+    };
+
+    const alice = authorizer.setUser('alice');
+    const bob = authorizer.setUser('bob');
+    await bob;
+    releaseAlice();
+    await alice;
+
+    expect(authorizer.user).toBe('bob');
+    expect(await authorizer.can('read', 'bob-data')).toBe(true);
+    expect(await authorizer.can('read', 'alice-data')).toBe(false);
+});
+
+test('the cache of an endpoint is not shared with another endpoint', async () 
=> {
+    const first = new Authorizer('auto', { endpoint: 
'http://casbin.test/first' });
+    first.getEnforcerDataFromSvr = () => Promise.resolve(permissionOf('alice', 
'data1'));
+    await first.setUser('alice');
+    expect(await first.can('read', 'data1')).toBe(true);
+
+    const second = new Authorizer('auto', { endpoint: 
'http://casbin.test/second' });
+    second.getEnforcerDataFromSvr = () => 
Promise.resolve(permissionOf('alice', 'data2'));
+    await second.setUser('alice');
+
+    expect(await second.can('read', 'data2')).toBe(true);
+    expect(await second.can('read', 'data1')).toBe(false);
+});
+
+test('refresh ignores and updates the cached permission', async () => {
+    const authorizer = new Authorizer('auto', { endpoint: 
'http://casbin.test/refresh', cacheExpiredTime: 3600 });
+    let calls = 0;
+    authorizer.getEnforcerDataFromSvr = () => {
+        calls++;
+        return Promise.resolve(permissionOf('alice', calls === 1 ? 'data1' : 
'data2'));
+    };
+
+    await authorizer.setUser('alice');
+    expect(calls).toBe(1);
+    expect(await authorizer.can('read', 'data1')).toBe(true);
+
+    await authorizer.refresh();
+    expect(calls).toBe(2);
+    expect(await authorizer.can('read', 'data2')).toBe(true);
+    expect(await authorizer.can('read', 'data1')).toBe(false);
+});
+
+test('a second authorizer of the same endpoint and user uses the cache', async 
() => {
+    const endpoint = 'http://casbin.test/cached';
+    const first = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 });
+    let calls = 0;
+    first.getEnforcerDataFromSvr = () => {
+        calls++;
+        return Promise.resolve(permissionOf('alice', 'data1'));
+    };
+    await first.setUser('alice');
+
+    const second = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 
});
+    second.getEnforcerDataFromSvr = () => {
+        calls++;
+        return Promise.resolve(permissionOf('alice', 'data2'));
+    };
+    await second.setUser('alice');
+
+    expect(calls).toBe(1);
+    expect(await second.can('read', 'data1')).toBe(true);
+});
+
+test('clearCache removes the entry of the current user', async () => {
+    const endpoint = 'http://casbin.test/clear';
+    let calls = 0;
+    const load = () => {
+        calls++;
+        return Promise.resolve(permissionOf('alice', 'data1'));
+    };
+
+    const authorizer = new Authorizer('auto', { endpoint, cacheExpiredTime: 
3600 });
+    authorizer.getEnforcerDataFromSvr = load;
+    await authorizer.setUser('alice');
+    authorizer.clearCache();
+
+    const second = new Authorizer('auto', { endpoint, cacheExpiredTime: 3600 
});
+    second.getEnforcerDataFromSvr = load;
+    await second.setUser('alice');
+
+    expect(calls).toBe(2);
+});
+
+test('refresh is rejected outside auto mode', async () => {
+    const authorizer = new Authorizer('manual');
+    await expect(authorizer.refresh()).rejects.toThrow(/only available in auto 
mode/);
+});
+
+test('setUser retries after a failed load', async () => {
+    const authorizer = new Authorizer('auto', { endpoint: 
'http://casbin.test/retry' });
+    let attempts = 0;
+    authorizer.getEnforcerDataFromSvr = () => {
+        attempts++;
+        if (attempts === 1) {
+            return Promise.reject(new Error('network down'));
+        }
+        return Promise.resolve(permissionOf('alice', 'data1'));
+    };
+
+    await expect(authorizer.setUser('alice')).rejects.toThrow('network down');
+    await authorizer.setUser('alice');
+
+    expect(attempts).toBe(2);
+    expect(await authorizer.can('read', 'data1')).toBe(true);
+});
diff --git a/src/__test__/cache.test.ts b/src/__test__/cache.test.ts
index 8adcf30..23d1e59 100644
--- a/src/__test__/cache.test.ts
+++ b/src/__test__/cache.test.ts
@@ -1,11 +1,41 @@
 import * as Cache from '../Cache';
-import * as utils from '../utils';
-
-test("Cache unit test", async () => {
-    Cache.saveToLocalStorage("test", "test", 1);
-    expect(Cache.loadFromLocalStorage("test")).toBe("test");
-    expect(localStorage.getItem("casbinjs_test")).not.toBeNull();
-    await utils.sleep(2000);
-    expect(Cache.loadFromLocalStorage("test")).toBeNull();
-    expect(localStorage.getItem("casbinjs_test")).toBeNull();
-})
+
+describe('permission cache', () => {
+    beforeEach(() => {
+        localStorage.clear();
+        jest.useFakeTimers();
+    });
+
+    afterEach(() => {
+        jest.useRealTimers();
+    });
+
+    test('saves and expires an entry', () => {
+        expect(Cache.saveToLocalStorage('test', 'value', 1)).toBe(0);
+        expect(Cache.loadFromLocalStorage('test')).toBe('value');
+        expect(localStorage.getItem('casbinjs_test')).not.toBeNull();
+
+        jest.advanceTimersByTime(1001);
+
+        expect(Cache.loadFromLocalStorage('test')).toBeNull();
+        expect(localStorage.getItem('casbinjs_test')).toBeNull();
+    });
+
+    test('ignores and removes a corrupted entry', () => {
+        localStorage.setItem('casbinjs_bad', '{not json');
+        expect(Cache.loadFromLocalStorage('bad')).toBeNull();
+        expect(localStorage.getItem('casbinjs_bad')).toBeNull();
+    });
+
+    test('ignores and removes an entry with an unexpected shape', () => {
+        localStorage.setItem('casbinjs_bad', JSON.stringify({ value: 42 }));
+        expect(Cache.loadFromLocalStorage('bad')).toBeNull();
+        expect(localStorage.getItem('casbinjs_bad')).toBeNull();
+    });
+
+    test('removes an entry', () => {
+        Cache.saveToLocalStorage('test', 'value', 60);
+        Cache.removeLocalStorage('test');
+        expect(localStorage.getItem('casbinjs_test')).toBeNull();
+    });
+});
diff --git a/src/__test__/cookie.test.ts b/src/__test__/cookie.test.ts
new file mode 100644
index 0000000..fe8b93a
--- /dev/null
+++ b/src/__test__/cookie.test.ts
@@ -0,0 +1,74 @@
+import Cookies from 'js-cookie';
+import { Authorizer } from '../Authorizer';
+import { rbacModelStr } from './models';
+
+describe('cookies mode', () => {
+    afterEach(() => {
+        Cookies.remove('access_perm');
+        Cookies.remove('custom_perm');
+    });
+
+    test('loads the permission from the default cookie', async () => {
+        Cookies.set('access_perm', JSON.stringify({ read: ['data1'] }));
+
+        const authorizer = new Authorizer('cookies');
+
+        expect(authorizer.cookieKey).toBe('access_perm');
+        expect(await authorizer.can('read', 'data1')).toBe(true);
+        expect(await authorizer.can('read', 'data2')).toBe(false);
+        expect(authorizer.getPermission()).toEqual({ read: ['data1'] });
+    });
+
+    test('loads the permission from a custom cookie key', async () => {
+        Cookies.set('custom_perm', JSON.stringify({ write: ['data2'] }));
+
+        const authorizer = new Authorizer('cookies', { cookieKey: 
'custom_perm' });
+
+        expect(await authorizer.can('write', 'data2')).toBe(true);
+        expect(await authorizer.can('write', 'data1')).toBe(false);
+    });
+
+    test('reloads the permission when the cookie changes', async () => {
+        Cookies.set('access_perm', JSON.stringify({ read: ['data1'] }));
+        const authorizer = new Authorizer('cookies');
+
+        Cookies.set('access_perm', JSON.stringify({ read: ['data2'] }));
+        expect(authorizer.loadPermissionFromCookie()).toBe(true);
+
+        expect(await authorizer.can('read', 'data2')).toBe(true);
+        expect(await authorizer.can('read', 'data1')).toBe(false);
+    });
+
+    test('supports the model and policies of CasbinJsGetPermissionForUser', 
async () => {
+        Cookies.set(
+            'access_perm',
+            JSON.stringify({
+                m: rbacModelStr,
+                p: [['p', 'admin', 'data1', 'read']],
+                g: [['g', 'alice', 'admin']],
+            }),
+        );
+
+        const authorizer = new Authorizer('cookies');
+        await authorizer.setUser('alice');
+
+        expect(await authorizer.can('read', 'data1')).toBe(true);
+        expect(await authorizer.can('write', 'data1')).toBe(false);
+        expect(await authorizer.getRoles()).toEqual(['admin']);
+    });
+
+    test('reports a missing cookie', async () => {
+        const warn = jest.spyOn(console, 'warn').mockImplementation(() => 
undefined);
+
+        const authorizer = new Authorizer('cookies');
+
+        expect(warn).toHaveBeenCalled();
+        expect(await authorizer.can('read', 'data1')).toBe(false);
+        warn.mockRestore();
+    });
+
+    test('rejects a malformed cookie', () => {
+        Cookies.set('access_perm', '{not json');
+        expect(() => new Authorizer('cookies')).toThrow(/does not contain a 
valid permission/);
+    });
+});
diff --git a/src/__test__/index.test.ts b/src/__test__/index.test.ts
index ce8dbe0..6da7201 100644
--- a/src/__test__/index.test.ts
+++ b/src/__test__/index.test.ts
@@ -1,15 +1,7 @@
 import { Authorizer } from '../index';
 import { basicModelStr } from './models';
 import { basicPolicies } from './policies';
-import { removeLocalStorage } from '../Cache';
-import TestServer  from './server';
-
-test('Mock functions', () => {
-    // TODO: Use mock function to get response object
-    // const authorizer = new Authorizer('http://localhost:4000');
-    // authorizer.setUser('alice');
-    // expect(authorizer.getPermission()).toMatchObject(respObj);
-});
+import TestServer from './server';
 
 async function check(authorizer: Authorizer) {
     // can
@@ -32,52 +24,43 @@ async function check(authorizer: Authorizer) {
 
 const permissionObj = {
     read: ['data1', 'data2'],
-    write: ['data2']
-}
-
-// test('Cookies mode', () => {
-//     const permissionObj = {
-//         read: ['data1', 'data2', 'data3'],
-//         write: ['data1']
-//     }
-//     const cookieKey = 'test_perm'
-//     Cookies.set('test_perm', JSON.stringify(permissionObj));
-//     const authorizer = new Authorizer('cookies', {cookieKey: cookieKey});
-//     check(authorizer);
-// })
+    write: ['data2'],
+};
 
-test('Manual mode', () => {
+test('Manual mode', async () => {
     const authorizer = new Authorizer('manual');
     authorizer.setPermission(permissionObj);
-    check(authorizer);
-})
-
+    await check(authorizer);
+    expect(authorizer.getPermission()).toEqual(permissionObj);
+});
 
 test('Auto mode', async () => {
     const respData = JSON.stringify({
         m: basicModelStr,
         p: basicPolicies,
     });
-    const authorizer = new Authorizer("auto", {endpoint: "whatever"});
-    removeLocalStorage('alice');
+    const authorizer = new Authorizer('auto', { endpoint: 'whatever' });
     await authorizer.initEnforcer(respData);
-    authorizer.user = "alice";
+    authorizer.user = 'alice';
     await check(authorizer);
-})
+});
 
 describe('Auto mode with server', () => {
     let server: TestServer;
+
     beforeAll(async () => {
         server = new TestServer();
         await server.start();
     });
 
+    afterAll(() => server.terminate());
+
     test('Request for /api/permissions', async () => {
-        removeLocalStorage('alice');
-        const authorizer = new Authorizer('auto', { endpoint: 
'http://localhost:4000/api/permissions'});
+        const endpoint = `http://localhost:${server.port}/api/permissions`;
+        const authorizer = new Authorizer('auto', { endpoint });
         await authorizer.setUser('alice');
         await check(authorizer);
+        // The permission is cached for the next authorizer of the same 
endpoint and user.
+        
expect(localStorage.getItem(`casbinjs_${endpoint}::alice`)).not.toBeNull();
     });
-
-    afterAll(() => server.terminate());
 });
diff --git a/src/__test__/permission.test.ts b/src/__test__/permission.test.ts
index b5cd33e..dfe14f1 100644
--- a/src/__test__/permission.test.ts
+++ b/src/__test__/permission.test.ts
@@ -2,24 +2,23 @@ import Permission from '../Permission';
 
 describe('Permission unit test', () => {
     const permExample = {
-        'read': ['data1', 'data2'],
-        'write': ['data2']
-    }
+        read: ['data1', 'data2'],
+        write: ['data2'],
+    };
     const permission = new Permission();
 
     test('Load permission from JSON object', () => {
-        const s = JSON.stringify(permExample);
         permission.load(permExample);
         const t = permission.getPermissionJsonObject();
         expect(t).toMatchObject(permExample);
-    })
+    });
 
     test('Load permission from JSON string', () => {
         const s = JSON.stringify(permExample);
         permission.load(s);
         const t = permission.getPermissionString();
         expect(JSON.parse(t)).toMatchObject(permExample);
-    })
+    });
 
     test('Get targets from an action', () => {
         permission.load(permExample);
@@ -31,8 +30,7 @@ describe('Permission unit test', () => {
         expected = permExample['write'];
         actual = permission.getTargetsFromAction('write');
         expect(actual.sort()).toEqual(expected.sort());
-
-    })
+    });
 
     test('Get action-object mapping', () => {
         permission.load(permExample);
@@ -46,6 +44,26 @@ describe('Permission unit test', () => {
         const actual = permission.getObjectsActions();
         expect(actual.get('data1')).toEqual(['read']);
         expect(actual.get('data2')).toEqual(['read', 'write']);
-    })
+    });
+
+    test('Reject a permission that does not map actions to arrays', () => {
+        expect(() => permission.load({ read: 'data1' } as any)).toThrow(/must 
be an array of objects/);
+        expect(() => permission.load('[]')).toThrow(/must be a JSON object/);
+    });
+
+    test('An action named "__proto__" stays an own property', () => {
+        permission.load(JSON.parse('{"__proto__": ["data1"]}'));
+
+        const actual = permission.getPermissionJsonObject();
+        expect(Object.prototype.hasOwnProperty.call(actual, 
'__proto__')).toBe(true);
+        expect(actual['__proto__']).toEqual(['data1']);
+        expect(({} as any).data1).toBeUndefined();
+    });
 
-})
+    test('The returned permission does not share its arrays with the internal 
state', () => {
+        permission.load(permExample);
+        permission.getPermissionJsonObject()['read'].push('data3');
+
+        expect(permission.check('read', 'data3')).toBe(false);
+    });
+});
diff --git a/src/__test__/server.ts b/src/__test__/server.ts
index 090e559..49cccc0 100644
--- a/src/__test__/server.ts
+++ b/src/__test__/server.ts
@@ -1,92 +1,98 @@
-import * as http from "http";
+import * as http from 'http';
+import { AddressInfo } from 'net';
 import express from 'express';
 import { newEnforcer, Enforcer, Model } from 'casbin-core';
-import { StringKV } from '../types';
 import { basicModelStr } from './models';
 import { basicPolicies } from './policies';
 
 class CasbinService {
-    private enforcer! : Enforcer;
-    
+    private enforcer!: Enforcer;
+
     public async run() {
         // RBAC API doesn't support RBAC w/ domain.
         // this.enforcer = await 
newEnforcer('./src/__test__/example/rbac_with_domains_model.conf', 
'./src/__test__/example/rbac_with_domains_policy.csv');
         this.enforcer = await newEnforcer(new Model(basicModelStr));
         for (const policy of basicPolicies) {
-            const arr = policy.map(v => v.trim());
+            const arr = policy.map((v) => v.trim());
             const pType = arr.shift();
             if (pType === 'p') {
                 await this.enforcer.addPolicy(...arr);
             }
         }
     }
-    
-    public async getEnforcerConfig(sub: string): Promise<string> {
 
+    public async getEnforcerConfig(sub: string): Promise<string> {
         const obj: any = {};
 
         const m = this.enforcer.getModel().model;
-          let s = "";
-          s += "[request_definition]\n";
-          s += `r = ${m.get('r')?.get('r')?.value.replace(/_/g, ".")}\n`;
-          s += "[policy_definition]\n";
-          s += `p = ${m.get('p')?.get('p')?.value.replace(/_/g, ".")}\n`;
-          if (m.get('g')?.get('g') !== undefined) {
-            s += "[role_definition]\n";
-            s += `g = ${m.get('g')?.get('g')?.value}\n`
-          }
-          s += "[policy_effect]\n"
-          s += `e = ${m.get('e')?.get('e')?.value.replace(/_/g, ".")}\n`;
-          s += "[matchers]\n";
-          s += `m = ${m.get('m')?.get('m')?.value.replace(/_/g, ".")}`;
+        let s = '';
+        s += '[request_definition]\n';
+        s += `r = ${m.get('r')?.get('r')?.value.replace(/_/g, '.')}\n`;
+        s += '[policy_definition]\n';
+        s += `p = ${m.get('p')?.get('p')?.value.replace(/_/g, '.')}\n`;
+        if (m.get('g')?.get('g') !== undefined) {
+            s += '[role_definition]\n';
+            s += `g = ${m.get('g')?.get('g')?.value}\n`;
+        }
+        s += '[policy_effect]\n';
+        s += `e = ${m.get('e')?.get('e')?.value.replace(/_/g, '.')}\n`;
+        s += '[matchers]\n';
+        s += `m = ${m.get('m')?.get('m')?.value.replace(/_/g, '.')}`;
         obj['m'] = s;
         obj['p'] = await this.enforcer.getPolicy();
         for (const arr of obj['p']) {
             arr.splice(0, 0, 'p');
         }
-        
+
         return JSON.stringify(obj);
     }
 }
 
 class TestServer {
     private app: express.Application;
-    private port = 4000;
     private listener!: http.Server;
     private casbinServ: CasbinService;
+    private requestedPort: number;
+    /** The port the server is actually listening on, 0 picks a free one. */
+    public port: number;
+
     public constructor(port?: number) {
-        if (port) {
-            this.port = port;
-        }
+        this.requestedPort = port ?? 0;
+        this.port = this.requestedPort;
         this.app = express();
         this.casbinServ = new CasbinService();
-        
     }
 
     private setRouter(): void {
         this.app.get('/', (req: express.Request, res: express.Response) => {
             res.status(200).json({
                 message: 'ok',
-                data: 'this is the data'
-            })
+                data: 'this is the data',
+            });
         });
         this.app.get('/api/permissions', async (req: express.Request, res: 
express.Response) => {
-            const sub = String(req.query["subject"]);
+            const sub = String(req.query['subject']);
             const config = await this.casbinServ.getEnforcerConfig(sub);
             res.status(200).json({
                 message: 'ok',
-                data: config
-            })
-        })
+                data: config,
+            });
+        });
     }
 
-    public async start() : Promise<void> {
+    public async start(): Promise<void> {
         await this.casbinServ.run();
         this.setRouter();
-        this.listener = this.app.listen(this.port, () => console.log(`Express 
server is listening at http://localhost:${this.port}`));
+        await new Promise<void>((resolve) => {
+            this.listener = this.app.listen(this.requestedPort, () => {
+                this.port = (this.listener.address() as AddressInfo).port;
+                console.log(`Express server is listening at 
http://localhost:${this.port}`);
+                resolve();
+            });
+        });
     }
 
-    public terminate() : void {
+    public terminate(): void {
         this.listener.close();
         console.log('Express server is terminated');
     }
diff --git a/src/index.ts b/src/index.ts
index 21cef9f..3a68ad9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,3 @@
 export * from './Authorizer';
+export * from './types';
+export { default as Permission } from './Permission';
diff --git a/src/types.ts b/src/types.ts
index 95d7f5f..793b1b6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,3 +1,20 @@
 export interface StringKV {
-    [key: string] : string[]
+    [key: string]: string[];
 }
+
+export interface HeaderMap {
+    [key: string]: string;
+}
+
+/**
+ * The model and policies produced by casbin's "CasbinJsGetPermissionForUser",
+ * or the same data after it has been parsed from JSON.
+ */
+export interface EnforcerData {
+    m: string;
+    p?: string[][];
+    g?: string[][];
+    [key: string]: unknown;
+}
+
+export type PermissionSource = string | Record<string, unknown>;
diff --git a/src/utils.ts b/src/utils.ts
deleted file mode 100644
index 9f1dbdf..0000000
--- a/src/utils.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function sleep(ms: number) {
-    return new Promise(resolve => setTimeout(resolve, ms));
-}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to