Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ The IronWeb SDK NPM releases follow standard [Semantic Versioning](https://semve

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

## v4.4.0
Comment thread
giarc3 marked this conversation as resolved.

- 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.
- 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.

## v4.3.6

- fix a streaming bug, encrypt could get blocked with no reader yet on writing the header+IV.
Expand Down
16 changes: 12 additions & 4 deletions integration/nightwatch/tests/user-sync/userPasscodeChange.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ module.exports = {

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

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

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

browser.end();
},
};
};
15 changes: 15 additions & 0 deletions ironweb.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export interface UserCreateResponse {
userMasterPublicKey: PublicKey<Base64String>;
needsRotation: boolean;
}
export interface UserUpdateResponse {
accountID: string;
segmentID: number;
status: UserStatus;
userMasterPublicKey: PublicKey<Base64String>;
needsRotation: boolean;
}
export const UserStatus: {
DISABLED: 0;
ENABLED: 1;
};
export type UserStatus = 0 | 1;
export interface DeviceKeys {
accountId: string;
segmentId: number;
Expand Down Expand Up @@ -200,6 +212,7 @@ export interface User {
listDevices(): Promise<UserDeviceListResponse>;
changePasscode(currentPasscode: string, newPasscode: string): Promise<void>;
rotateMasterKey(passcode: string): Promise<void>;
disableSelf(): Promise<UserUpdateResponse>;
}

export interface Document {
Expand Down Expand Up @@ -306,6 +319,7 @@ export interface ErrorCodes {
USER_DEVICE_DELETE_REQUEST_FAILURE: 210;
USER_UPDATE_KEY_REQUEST_FAILURE: 211;
USER_PRIVATE_KEY_ROTATION_FAILURE: 212;
USER_UPDATE_STATUS_REQUEST_FAILURE: 214;
DOCUMENT_LIST_REQUEST_FAILURE: 300;
DOCUMENT_GET_REQUEST_FAILURE: 301;
DOCUMENT_CREATE_REQUEST_FAILURE: 302;
Expand Down Expand Up @@ -360,3 +374,4 @@ export function createNewUser(jwtCallback: JWTCallback, passcode: string, option
export function createNewDeviceKeys(jwtCallback: JWTCallback, passcode: string): Promise<DeviceKeys>;
export function isInitialized(): boolean;
export function deleteDeviceByPublicSigningKey(jwtCallback: JWTCallback, publicSigningKey: Base64String): Promise<number>;
export function updateUserStatus(jwtCallback: JWTCallback, status: UserStatus): Promise<UserUpdateResponse>;
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"license": "AGPL-3.0-only",
"version": "4.3.8",
"version": "4.4.0",
"scripts": {
"cleanTest": "find dist -type d -name tests -prune -exec rm -rf {} \\;",
"lint": "eslint . --ext .ts,.tsx",
Expand Down Expand Up @@ -73,4 +73,4 @@
"jsxBracketSameLine": true,
"arrowParens": "always"
}
}
}
11 changes: 11 additions & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export enum ErrorCodes {
USER_UPDATE_KEY_REQUEST_FAILURE = 211,
USER_PRIVATE_KEY_ROTATION_FAILURE = 212,
USER_DEVICE_LIST_REQUEST_FAILURE = 213,
USER_UPDATE_STATUS_REQUEST_FAILURE = 214,
DOCUMENT_LIST_REQUEST_FAILURE = 300,
DOCUMENT_GET_REQUEST_FAILURE = 301,
DOCUMENT_CREATE_REQUEST_FAILURE = 302,
Expand Down Expand Up @@ -119,6 +120,16 @@ export const UserAndGroupTypes = {
GROUP: "group",
};

/**
* Status values for a user. A disabled user cannot call SDK functions but
* remains a member of any groups they were added to.
*/
export const UserStatus = {
DISABLED: 0,
ENABLED: 1,
} as const;
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];

export const Versions = {
//This define is replaced at runtime during development, and at build time in the build script with the proper version
SDK_VERSION: SDK_NPM_VERSION_PLACEHOLDER,
Expand Down
18 changes: 18 additions & 0 deletions src/FrameMessageTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,21 @@ export interface ListDevicesResponse {
type: "LIST_DEVICES_RESPONSE";
message: UserDeviceListResponse;
}
export interface DisableUserSelf {
type: "DISABLE_USER_SELF";
message: null;
}
export interface UpdateUserStatusJwt {
type: "UPDATE_USER_STATUS_JWT";
message: {
jwtToken: string;
status: number;
};
}
export interface UpdateUserStatusResponse {
type: "UPDATE_USER_STATUS_RESPONSE";
message: ApiUserResponse;
}

// Blind index search methods
export interface BlindSearchIndexCreate {
Expand Down Expand Up @@ -628,6 +643,8 @@ export type RequestMessage =
| DeleteDevice
| DeleteDeviceBySigningKey
| DeleteDeviceBySigningKeyJwt
| DisableUserSelf
| UpdateUserStatusJwt
| DocumentUnmanagedDecryptRequest
| DocumentUnmanagedEncryptRequest
| BlindSearchIndexCreate
Expand Down Expand Up @@ -662,6 +679,7 @@ export type ResponseMessage =
| CreateDetachedUserDeviceResponse
| ListDevicesResponse
| DeleteDeviceResponse
| UpdateUserStatusResponse
| GroupListResponse
| GroupGetResponse
| GroupCreateResponse
Expand Down
28 changes: 28 additions & 0 deletions src/frame/endpoints/UserApiEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,23 @@ const userUpdate = (userID: string, userPrivateKey?: PrivateKey<Uint8Array>, sta
errorCode: ErrorCodes.USER_UPDATE_REQUEST_FAILURE,
});

/**
* Update a users status using JWT authorization.
* @param {string} userID ID of user to update
* @param {number} status Updated status of user
*/
const userUpdateStatusWithJwt = (userID: string, status: number): RequestMeta => ({
url: `users/${encodeURIComponent(userID)}`,
options: {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({status}),
},
errorCode: ErrorCodes.USER_UPDATE_STATUS_REQUEST_FAILURE,
});

/**
* Generate an API request to rotate the users private key passing the augmentation factor that the key is rotated by and
* the users encrypted private key that has been augmented by that same factor.
Expand Down Expand Up @@ -336,6 +353,17 @@ export default {
return makeAuthorizedApiRequest(url, errorCode, options);
},

/**
* Invoke user update API to change a user's status using JWT authorization.
* @param {string} jwtToken Authorized JWT for the user
* @param {string} userId ID of the user (must match the JWT subject)
* @param {number} status Status to set for the user
*/
callUserUpdateStatusWithJwt(jwtToken: string, userId: string, status: number): Future<SDKError, UserUpdateResponseType> {
const {url, options, errorCode} = userUpdateStatusWithJwt(userId, status);
return makeJwtApiRequest<UserUpdateResponseType>(url, errorCode, options, jwtToken);
},

/**
* Invoke the user device add API with the provided device/signing/transform keys
* @param {string} jwtToken Users authorized JWT token
Expand Down
29 changes: 29 additions & 0 deletions src/frame/endpoints/tests/UserApiEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,35 @@ describe("UserApiEndpoints", () => {
});
});

describe("callUserUpdateStatusWithJwt", () => {
it("calls API and updates status using JWT auth", () => {
(ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mockReturnValue(
Future.of<any>({
id: "user-10",
foo: "bar",
})
);

UserApiEndpoints.callUserUpdateStatusWithJwt("jwtToken", "user-special~!@#$", 0).engage(
(e) => {
throw new Error(e.message);
},
(response: any) => {
expect(response).toEqual({id: "user-10", foo: "bar"});
expect(ApiRequest.makeJwtApiRequest).toHaveBeenCalledWith(
"users/user-special~!%40%23%24",
expect.any(Number),
expect.any(Object),
"jwtToken"
);
const request = (ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mock.calls[0][2];
expect(request.method).toEqual("PUT");
expect(JSON.parse(request.body)).toEqual({status: 0});
}
);
});
});

describe("callUserDeviceAdd", () => {
it("calls API and returns data as expected", () => {
(ApiRequest.makeJwtApiRequest as unknown as jest.SpyInstance).mockReturnValue(
Expand Down
6 changes: 6 additions & 0 deletions src/frame/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ function onParentPortMessage(data: RequestMessage, callback: (message: ResponseM
);
case "LIST_DEVICES":
return UserApi.listDevices().engage(errorHandler, (result) => callback({type: "LIST_DEVICES_RESPONSE", message: result}));
case "DISABLE_USER_SELF":
return UserApi.disableSelf().engage(errorHandler, (result) => callback({type: "UPDATE_USER_STATUS_RESPONSE", message: result}));
case "UPDATE_USER_STATUS_JWT":
return UserApi.updateUserStatusWithJwt(data.message.jwtToken, data.message.status).engage(errorHandler, (result) =>
callback({type: "UPDATE_USER_STATUS_RESPONSE", message: result})
);
case "DOCUMENT_LIST":
return DocumentApi.list().engage(errorHandler, (documents) => callback({type: "DOCUMENT_LIST_RESPONSE", message: documents}));
case "DOCUMENT_META_GET":
Expand Down
57 changes: 51 additions & 6 deletions src/frame/sdk/UserApi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import {decode as utf8Decode} from "@stablelib/utf8";
import Future from "futurejs";
import SDKError from "src/lib/SDKError";
import SDKError from "../../lib/SDKError";
import {ErrorCodes, UserStatus} from "../../Constants";
import * as WMT from "../../WorkerMessageTypes";
import ApiState from "../ApiState";
import UserApiEndpoints from "../endpoints/UserApiEndpoints";
import {clearDeviceAndSigningKeys} from "../FrameUtils";
import * as WorkerMediator from "../WorkerMediator";

/**
* Extract the `sub` claim from a JWT token to identify the user the JWT is for. The JWT is
* not verified here; the server is the authority. UTF-8 safe for non-ASCII user IDs.
*/
export const userIdFromJwt = (jwt: string): Future<SDKError, string> => {
try {
const payload = jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(window.atob(payload), (c) => c.charCodeAt(0));
const claims = JSON.parse(utf8Decode(bytes));
if (typeof claims.sub !== "string" || claims.sub.length === 0) {
throw new Error("JWT is missing required 'sub' claim.");
}
return Future.of(claims.sub);
} catch (e) {
// Wrap in a fresh Error so SDKError applies our error code instead of inheriting one from
// the underlying exception (e.g. DOMException from atob exposes a numeric `code` property).
const message = e instanceof Error ? e.message : String(e);
return Future.reject(new SDKError(new Error(message), ErrorCodes.JWT_FORMAT_FAILURE));
}
};

/**
* Rotate users current private key by taking their current passcode and using it to derive a key to decrypt their user private key.
* Then generates and augmentation factor and subtracts that augmentation factor from the users private key. The new private key is then
Expand Down Expand Up @@ -69,11 +92,7 @@ export const deleteDevice = (deviceId?: number) => {
//their device private key from local storage. So mock out a fake ID here that we can use to decision off of.
.handleWith(() => Future.of({id: -1}))
.map((deleteResponse) => {
const user = ApiState.user();

const {id, segmentId} = user;
clearDeviceAndSigningKeys(id, segmentId);
ApiState.clearCurrentUser();
deleteLocalDeviceAndClearUser();
return deleteResponse.id;
})
);
Expand All @@ -98,3 +117,29 @@ export const deleteDeviceBySigningKeyWithJwt = (jwtToken: string, publicSigningK
* Makes a request to list the devices for the currently logged in user.
*/
export const listDevices = () => UserApiEndpoints.callUserListDevices();

const deleteLocalDeviceAndClearUser = () => {
const user = ApiState.user();
const {id, segmentId} = user;
clearDeviceAndSigningKeys(id, segmentId);
ApiState.clearCurrentUser();
};

/**
* Disables the currently authenticated user. After this call succeeds the user
* will not be able to invoke any other authorized SDK functions. Disabled users
* remain members of any groups they belonged to but cannot use them.
*/
export const disableSelf = () =>
UserApiEndpoints.callUserUpdateApi(undefined, UserStatus.DISABLED).map((r) => {
deleteLocalDeviceAndClearUser();
return r;
});

/**
* Update the status of the user identified by the provided JWT.
* Allows changing a user's status using JWT auth, without an initialized SDK.
* The user id is taken from the `sub` claim of the JWT.
*/
export const updateUserStatusWithJwt = (jwtToken: string, status: number) =>
userIdFromJwt(jwtToken).flatMap((userId) => UserApiEndpoints.callUserUpdateStatusWithJwt(jwtToken, userId, status));
Loading