Skip to content

Commit b0cb0f4

Browse files
authored
Add functions to update user status (#211)
1 parent aa052cf commit b0cb0f4

17 files changed

Lines changed: 649 additions & 111 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ The IronWeb SDK NPM releases follow standard [Semantic Versioning](https://semve
44

55
**Note:** The patch versions of the IronWeb SDK will not be sequential and might jump by multiple numbers between sequential releases.
66

7+
## v4.4.0
8+
9+
- add `IronWeb.user.disableSelf()` which the currently authenticated user can call to disable their own account. Disabled users can still be members of groups but will be unable to call SDK functions.
10+
- add `IronWeb.updateUserStatus(jwtCallback, status)` which uses a JWT to enable or disable a user without an initialized SDK. Use this to re-enable a user that has previously been disabled.
11+
712
## v4.3.6
813

914
- fix a streaming bug, encrypt could get blocked with no reader yet on writing the header+IV.

integration/nightwatch/tests/user-sync/userPasscodeChange.test.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ module.exports = {
4545

4646
initializeUser.clickInitializeAppButton().enterUserPasscode(firstPasscode).submitPasscode();
4747

48-
initializeUser.expect.element("@submitPasscode").to.not.have.value;
48+
// The old passcode must be rejected by SDK init, so the user should remain on the
49+
// initialize screen and never reach the document list page. Pause briefly to let the
50+
// async init promise reject before we assert.
51+
browser.pause(2000);
52+
demoApp.expect.element("@browserListPage").to.not.be.present;
53+
initializeUser.expect.element("@submitPasscode").to.be.visible;
4954

5055
browser.end();
5156
},
@@ -65,9 +70,12 @@ module.exports = {
6570
demoApp
6671
.enterPasscodeFields(secondPasscode, secondPasscode)
6772
.submitChangePasscode()
68-
.waitForElementPresent("@currentPasscodeInput")
69-
.expect.element("@currentPasscodeInput").to.not.have.value;
73+
.waitForElementPresent("@passwordChangeDialog");
74+
// On a wrong current passcode, the SDK rejects the change and the component renders
75+
// an "Incorrect passcode" error inside the dialog. That message is what proves this
76+
// specific failure path ran.
77+
demoApp.expect.element("@passwordChangeDialog").text.to.contain("Incorrect passcode");
7078

7179
browser.end();
7280
},
73-
};
81+
};

ironweb.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ export interface UserCreateResponse {
155155
userMasterPublicKey: PublicKey<Base64String>;
156156
needsRotation: boolean;
157157
}
158+
export interface UserUpdateResponse {
159+
accountID: string;
160+
segmentID: number;
161+
status: UserStatus;
162+
userMasterPublicKey: PublicKey<Base64String>;
163+
needsRotation: boolean;
164+
}
165+
export const UserStatus: {
166+
DISABLED: 0;
167+
ENABLED: 1;
168+
};
169+
export type UserStatus = 0 | 1;
158170
export interface DeviceKeys {
159171
accountId: string;
160172
segmentId: number;
@@ -200,6 +212,7 @@ export interface User {
200212
listDevices(): Promise<UserDeviceListResponse>;
201213
changePasscode(currentPasscode: string, newPasscode: string): Promise<void>;
202214
rotateMasterKey(passcode: string): Promise<void>;
215+
disableSelf(): Promise<UserUpdateResponse>;
203216
}
204217

205218
export interface Document {
@@ -306,6 +319,7 @@ export interface ErrorCodes {
306319
USER_DEVICE_DELETE_REQUEST_FAILURE: 210;
307320
USER_UPDATE_KEY_REQUEST_FAILURE: 211;
308321
USER_PRIVATE_KEY_ROTATION_FAILURE: 212;
322+
USER_UPDATE_STATUS_REQUEST_FAILURE: 214;
309323
DOCUMENT_LIST_REQUEST_FAILURE: 300;
310324
DOCUMENT_GET_REQUEST_FAILURE: 301;
311325
DOCUMENT_CREATE_REQUEST_FAILURE: 302;
@@ -360,3 +374,4 @@ export function createNewUser(jwtCallback: JWTCallback, passcode: string, option
360374
export function createNewDeviceKeys(jwtCallback: JWTCallback, passcode: string): Promise<DeviceKeys>;
361375
export function isInitialized(): boolean;
362376
export function deleteDeviceByPublicSigningKey(jwtCallback: JWTCallback, publicSigningKey: Base64String): Promise<number>;
377+
export function updateUserStatus(jwtCallback: JWTCallback, status: UserStatus): Promise<UserUpdateResponse>;

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"license": "AGPL-3.0-only",
3-
"version": "4.3.8",
3+
"version": "4.4.0",
44
"scripts": {
55
"cleanTest": "find dist -type d -name tests -prune -exec rm -rf {} \\;",
66
"lint": "eslint . --ext .ts,.tsx",
@@ -73,4 +73,4 @@
7373
"jsxBracketSameLine": true,
7474
"arrowParens": "always"
7575
}
76-
}
76+
}

src/Constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export enum ErrorCodes {
4949
USER_UPDATE_KEY_REQUEST_FAILURE = 211,
5050
USER_PRIVATE_KEY_ROTATION_FAILURE = 212,
5151
USER_DEVICE_LIST_REQUEST_FAILURE = 213,
52+
USER_UPDATE_STATUS_REQUEST_FAILURE = 214,
5253
DOCUMENT_LIST_REQUEST_FAILURE = 300,
5354
DOCUMENT_GET_REQUEST_FAILURE = 301,
5455
DOCUMENT_CREATE_REQUEST_FAILURE = 302,
@@ -119,6 +120,16 @@ export const UserAndGroupTypes = {
119120
GROUP: "group",
120121
};
121122

123+
/**
124+
* Status values for a user. A disabled user cannot call SDK functions but
125+
* remains a member of any groups they were added to.
126+
*/
127+
export const UserStatus = {
128+
DISABLED: 0,
129+
ENABLED: 1,
130+
} as const;
131+
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
132+
122133
export const Versions = {
123134
//This define is replaced at runtime during development, and at build time in the build script with the proper version
124135
SDK_VERSION: SDK_NPM_VERSION_PLACEHOLDER,

src/FrameMessageTypes.d.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,21 @@ export interface ListDevicesResponse {
452452
type: "LIST_DEVICES_RESPONSE";
453453
message: UserDeviceListResponse;
454454
}
455+
export interface DisableUserSelf {
456+
type: "DISABLE_USER_SELF";
457+
message: null;
458+
}
459+
export interface UpdateUserStatusJwt {
460+
type: "UPDATE_USER_STATUS_JWT";
461+
message: {
462+
jwtToken: string;
463+
status: number;
464+
};
465+
}
466+
export interface UpdateUserStatusResponse {
467+
type: "UPDATE_USER_STATUS_RESPONSE";
468+
message: ApiUserResponse;
469+
}
455470

456471
// Blind index search methods
457472
export interface BlindSearchIndexCreate {
@@ -628,6 +643,8 @@ export type RequestMessage =
628643
| DeleteDevice
629644
| DeleteDeviceBySigningKey
630645
| DeleteDeviceBySigningKeyJwt
646+
| DisableUserSelf
647+
| UpdateUserStatusJwt
631648
| DocumentUnmanagedDecryptRequest
632649
| DocumentUnmanagedEncryptRequest
633650
| BlindSearchIndexCreate
@@ -662,6 +679,7 @@ export type ResponseMessage =
662679
| CreateDetachedUserDeviceResponse
663680
| ListDevicesResponse
664681
| DeleteDeviceResponse
682+
| UpdateUserStatusResponse
665683
| GroupListResponse
666684
| GroupGetResponse
667685
| GroupCreateResponse

src/frame/endpoints/UserApiEndpoints.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,23 @@ const userUpdate = (userID: string, userPrivateKey?: PrivateKey<Uint8Array>, sta
240240
errorCode: ErrorCodes.USER_UPDATE_REQUEST_FAILURE,
241241
});
242242

243+
/**
244+
* Update a users status using JWT authorization.
245+
* @param {string} userID ID of user to update
246+
* @param {number} status Updated status of user
247+
*/
248+
const userUpdateStatusWithJwt = (userID: string, status: number): RequestMeta => ({
249+
url: `users/${encodeURIComponent(userID)}`,
250+
options: {
251+
method: "PUT",
252+
headers: {
253+
"Content-Type": "application/json",
254+
},
255+
body: JSON.stringify({status}),
256+
},
257+
errorCode: ErrorCodes.USER_UPDATE_STATUS_REQUEST_FAILURE,
258+
});
259+
243260
/**
244261
* Generate an API request to rotate the users private key passing the augmentation factor that the key is rotated by and
245262
* the users encrypted private key that has been augmented by that same factor.
@@ -336,6 +353,17 @@ export default {
336353
return makeAuthorizedApiRequest(url, errorCode, options);
337354
},
338355

356+
/**
357+
* Invoke user update API to change a user's status using JWT authorization.
358+
* @param {string} jwtToken Authorized JWT for the user
359+
* @param {string} userId ID of the user (must match the JWT subject)
360+
* @param {number} status Status to set for the user
361+
*/
362+
callUserUpdateStatusWithJwt(jwtToken: string, userId: string, status: number): Future<SDKError, UserUpdateResponseType> {
363+
const {url, options, errorCode} = userUpdateStatusWithJwt(userId, status);
364+
return makeJwtApiRequest<UserUpdateResponseType>(url, errorCode, options, jwtToken);
365+
},
366+
339367
/**
340368
* Invoke the user device add API with the provided device/signing/transform keys
341369
* @param {string} jwtToken Users authorized JWT token

src/frame/endpoints/tests/UserApiEndpoints.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,35 @@ describe("UserApiEndpoints", () => {
304304
});
305305
});
306306

307+
describe("callUserUpdateStatusWithJwt", () => {
308+
it("calls API and updates status using JWT auth", () => {
309+
(ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mockReturnValue(
310+
Future.of<any>({
311+
id: "user-10",
312+
foo: "bar",
313+
})
314+
);
315+
316+
UserApiEndpoints.callUserUpdateStatusWithJwt("jwtToken", "user-special~!@#$", 0).engage(
317+
(e) => {
318+
throw new Error(e.message);
319+
},
320+
(response: any) => {
321+
expect(response).toEqual({id: "user-10", foo: "bar"});
322+
expect(ApiRequest.makeJwtApiRequest).toHaveBeenCalledWith(
323+
"users/user-special~!%40%23%24",
324+
expect.any(Number),
325+
expect.any(Object),
326+
"jwtToken"
327+
);
328+
const request = (ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mock.calls[0][2];
329+
expect(request.method).toEqual("PUT");
330+
expect(JSON.parse(request.body)).toEqual({status: 0});
331+
}
332+
);
333+
});
334+
});
335+
307336
describe("callUserDeviceAdd", () => {
308337
it("calls API and returns data as expected", () => {
309338
(ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mockReturnValue(

src/frame/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ function onParentPortMessage(data: RequestMessage, callback: (message: ResponseM
8585
);
8686
case "LIST_DEVICES":
8787
return UserApi.listDevices().engage(errorHandler, (result) => callback({type: "LIST_DEVICES_RESPONSE", message: result}));
88+
case "DISABLE_USER_SELF":
89+
return UserApi.disableSelf().engage(errorHandler, (result) => callback({type: "UPDATE_USER_STATUS_RESPONSE", message: result}));
90+
case "UPDATE_USER_STATUS_JWT":
91+
return UserApi.updateUserStatusWithJwt(data.message.jwtToken, data.message.status).engage(errorHandler, (result) =>
92+
callback({type: "UPDATE_USER_STATUS_RESPONSE", message: result})
93+
);
8894
case "DOCUMENT_LIST":
8995
return DocumentApi.list().engage(errorHandler, (documents) => callback({type: "DOCUMENT_LIST_RESPONSE", message: documents}));
9096
case "DOCUMENT_META_GET":

src/frame/sdk/UserApi.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1+
import {decode as utf8Decode} from "@stablelib/utf8";
12
import Future from "futurejs";
2-
import SDKError from "src/lib/SDKError";
3+
import SDKError from "../../lib/SDKError";
4+
import {ErrorCodes, UserStatus} from "../../Constants";
35
import * as WMT from "../../WorkerMessageTypes";
46
import ApiState from "../ApiState";
57
import UserApiEndpoints from "../endpoints/UserApiEndpoints";
68
import {clearDeviceAndSigningKeys} from "../FrameUtils";
79
import * as WorkerMediator from "../WorkerMediator";
810

11+
/**
12+
* Extract the `sub` claim from a JWT token to identify the user the JWT is for. The JWT is
13+
* not verified here; the server is the authority. UTF-8 safe for non-ASCII user IDs.
14+
*/
15+
export const userIdFromJwt = (jwt: string): Future<SDKError, string> => {
16+
try {
17+
const payload = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
18+
const bytes = Uint8Array.from(window.atob(payload), (c) => c.charCodeAt(0));
19+
const claims = JSON.parse(utf8Decode(bytes));
20+
if (typeof claims.sub !== "string" || claims.sub.length === 0) {
21+
throw new Error("JWT is missing required 'sub' claim.");
22+
}
23+
return Future.of(claims.sub);
24+
} catch (e) {
25+
// Wrap in a fresh Error so SDKError applies our error code instead of inheriting one from
26+
// the underlying exception (e.g. DOMException from atob exposes a numeric `code` property).
27+
const message = e instanceof Error ? e.message : String(e);
28+
return Future.reject(new SDKError(new Error(message), ErrorCodes.JWT_FORMAT_FAILURE));
29+
}
30+
};
31+
932
/**
1033
* Rotate users current private key by taking their current passcode and using it to derive a key to decrypt their user private key.
1134
* Then generates and augmentation factor and subtracts that augmentation factor from the users private key. The new private key is then
@@ -69,11 +92,7 @@ export const deleteDevice = (deviceId?: number) => {
6992
//their device private key from local storage. So mock out a fake ID here that we can use to decision off of.
7093
.handleWith(() => Future.of({id: -1}))
7194
.map((deleteResponse) => {
72-
const user = ApiState.user();
73-
74-
const {id, segmentId} = user;
75-
clearDeviceAndSigningKeys(id, segmentId);
76-
ApiState.clearCurrentUser();
95+
deleteLocalDeviceAndClearUser();
7796
return deleteResponse.id;
7897
})
7998
);
@@ -98,3 +117,29 @@ export const deleteDeviceBySigningKeyWithJwt = (jwtToken: string, publicSigningK
98117
* Makes a request to list the devices for the currently logged in user.
99118
*/
100119
export const listDevices = () => UserApiEndpoints.callUserListDevices();
120+
121+
const deleteLocalDeviceAndClearUser = () => {
122+
const user = ApiState.user();
123+
const {id, segmentId} = user;
124+
clearDeviceAndSigningKeys(id, segmentId);
125+
ApiState.clearCurrentUser();
126+
};
127+
128+
/**
129+
* Disables the currently authenticated user. After this call succeeds the user
130+
* will not be able to invoke any other authorized SDK functions. Disabled users
131+
* remain members of any groups they belonged to but cannot use them.
132+
*/
133+
export const disableSelf = () =>
134+
UserApiEndpoints.callUserUpdateApi(undefined, UserStatus.DISABLED).map((r) => {
135+
deleteLocalDeviceAndClearUser();
136+
return r;
137+
});
138+
139+
/**
140+
* Update the status of the user identified by the provided JWT.
141+
* Allows changing a user's status using JWT auth, without an initialized SDK.
142+
* The user id is taken from the `sub` claim of the JWT.
143+
*/
144+
export const updateUserStatusWithJwt = (jwtToken: string, status: number) =>
145+
userIdFromJwt(jwtToken).flatMap((userId) => UserApiEndpoints.callUserUpdateStatusWithJwt(jwtToken, userId, status));

0 commit comments

Comments
 (0)