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
24 changes: 24 additions & 0 deletions ironnode.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ export interface GroupUserEditResponse {
}>;
}

/**
* User status as returned by the IronCore Identity server. Typed as `number`
* instead of `0 | 1` because the wire format is a `u8` and future server releases
* may introduce new status values; older clients still need to deserialize
* those responses without a type-system lie. Use the `UserStatus` constants
* (`UserStatus.Disabled`, `UserStatus.Enabled`) for the values you write,
* and treat unknown values as opaque on read.
*/
export type UserStatus = number;
export interface UserUpdateResult {
accountID: string;
segmentID: number;
status: UserStatus;
userMasterPublicKey: PublicKey<Base64String>;
needsRotation: boolean;
}

export interface UserPublicKeyGetResponse {
[userID: string]: PublicKey<string> | null;
}
Expand Down Expand Up @@ -153,6 +170,7 @@ export interface User {
deleteDevice(id?: number): Promise<{id: number}>;
rotateMasterKey(password: string): Promise<{needsRotation: boolean}>;
changePassword(currentPassword: string, newPassword: string): Promise<void>;
disableSelf(): Promise<UserUpdateResult>;
}

export interface SDK {
Expand Down Expand Up @@ -192,4 +210,10 @@ export namespace User {
export function verify(jwt: string): Promise<ApiUserResponse | undefined>;
export function create(jwt: string, password: string, options?: UserCreateOptions): Promise<ApiUserResponse>;
export function generateDeviceKeys(jwt: string, password: string, options?: DeviceCreateOptions): Promise<DeviceDetails>;
export function updateStatus(jwt: string, status: (typeof UserStatus)[keyof typeof UserStatus]): Promise<UserUpdateResult>;
}

export const UserStatus: {
Disabled: 0;
Enabled: 1;
};
12 changes: 12 additions & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,18 @@ export const UserAndGroupTypes = {
GROUP: "group",
};

export const UserStatus = {
Disabled: 0,
Enabled: 1,
} as const;

// Narrow write-side type derived from the `UserStatus` constants. Distinct from the public
// `UserStatus` type in ironnode.d.ts, which is intentionally `number` for forward-compat on reads.
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];

export const ErrorCodes = {
INITIALIZE_INVALID_ACCOUNT_ID: 100,
SDK_NOT_INITIALIZED: 101,
USER_VERIFY_API_REQUEST_FAILURE: 200,
USER_CREATE_REQUEST_FAILURE: 201,
USER_PASSCODE_INCORRECT: 203,
Expand All @@ -43,6 +53,8 @@ export const ErrorCodes = {
USER_PRIVATE_KEY_ROTATION_FAILURE: 211,
USER_UPDATE_REQUEST_FAILURE: 212,
USER_PASSCODE_CHANGE_FAILURE: 213,
USER_UPDATE_STATUS_REQUEST_FAILURE: 214,
JWT_FORMAT_FAILURE: 215,
DOCUMENT_LIST_REQUEST_FAILURE: 300,
DOCUMENT_GET_REQUEST_FAILURE: 301,
DOCUMENT_CREATE_REQUEST_FAILURE: 302,
Expand Down
55 changes: 54 additions & 1 deletion src/api/UserApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {TransformKey} from "@ironcorelabs/recrypt-node-binding";
import Future from "futurejs";
import {DeviceCreateOptions, UserDeviceListResponse} from "../../ironnode";
import {AugmentationFactor, Base64String, MessageSignature, PrivateKey, PublicKey} from "../commonTypes";
import {ErrorCodes} from "../Constants";
import {ErrorCodes, UserStatus} from "../Constants";
import {computeEd25519PublicKey} from "../crypto/Recrypt";
import ApiState from "../lib/ApiState";
import SDKError from "../lib/SDKError";
Expand All @@ -15,6 +15,13 @@ interface UserUpdateApiResponse {
userPrivateKey: PrivateKey<Base64String>;
userMasterPublicKey: PublicKey<Base64String>;
}
export interface UserUpdateStatusApiResponse {
id: string;
status: number;
segmentId: number;
userMasterPublicKey: PublicKey<Base64String>;
needsRotation: boolean;
}
interface ApiUserResponse extends UserUpdateApiResponse {
segmentId: number;
needsRotation: boolean;
Expand Down Expand Up @@ -221,6 +228,34 @@ const userUpdateEncryptedPrivateKey = (sign: MessageSignature, accountId: string
errorCode: ErrorCodes.USER_UPDATE_REQUEST_FAILURE,
});

const userUpdateInternal = (auth: MessageSignature | string, accountId: string, status: number) => {
const authHeader = typeof auth === "string" ? `jwt ${auth}` : ApiRequest.getAuthHeader(auth);
return {
url: `users/${encodeURIComponent(accountId)}`,
options: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
body: JSON.stringify({status}),
},
errorCode: ErrorCodes.USER_UPDATE_STATUS_REQUEST_FAILURE,
};
};

/**
* Update the status of the current user using the SDK's device auth. Can only be used
* to disable a user. Re-enabling a user must be done with userUpdateStatusByJwt.
*/
const userDisableSelf = (sign: MessageSignature, accountId: string) => userUpdateInternal(sign, accountId, UserStatus.Disabled);

/**
* Update a user's status using JWT auth. Used to enable or disable a user by
* an admin holding a valid JWT for that user.
*/
const userUpdateStatusByJwt = (jwt: string, accountId: string, status: number) => userUpdateInternal(jwt, accountId, status);

export default {
/**
* API method to get the master public key for the user who the SDK is acting as.
Expand Down Expand Up @@ -325,4 +360,22 @@ export default {
const {url, options, errorCode} = userDeviceDelete(getSignatureHeader(), accountID, deviceID);
return ApiRequest.fetchJSON<{id: number}>(url, errorCode, options);
},

/**
* Update the status of the current SDK user
*/
callUserDisableSelfApi() {
const {accountID} = ApiState.accountAndSegmentIDs();
const {url, options, errorCode} = userDisableSelf(getSignatureHeader(), accountID);
return ApiRequest.fetchJSON<UserUpdateStatusApiResponse>(url, errorCode, options);
},

/**
* Update a user's status using JWT auth. The account ID is derived from
* the JWT's `sub` claim and used in the request URL.
*/
callUserUpdateStatusByJwtApi(jwt: string, accountID: string, status: number) {
const {url, options, errorCode} = userUpdateStatusByJwt(jwt, accountID, status);
return ApiRequest.fetchJSON<UserUpdateStatusApiResponse>(url, errorCode, options);
},
};
46 changes: 46 additions & 0 deletions src/api/tests/UserApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,52 @@ describe("UserApi", () => {
});
});

describe("callUserDisableSelfApi", () => {
test("PUTs to users/{accountId} with status body and no private key", (done) => {
UserApi.callUserDisableSelfApi().engage(
(e) => done.fail(e),
(res: any) => {
expect(res).toEqual({foo: "bar"});
expect(ApiRequest.fetchJSON).toHaveBeenCalledWith(`users/${TestUtils.testAccountID}`, jasmine.any(Number), jasmine.any(Object));
const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2];
expect(request.method).toEqual("PUT");
expect(request.headers.Authorization).toMatch(/IronCore\s{1}\d{1}[.][a-zA-Z0-9=\/+]+[.][a-zA-Z0-9=\/+]+/);
const body = JSON.parse(request.body);
expect(body).toEqual({status: 0});
expect(body).not.toHaveProperty("userPrivateKey");
done();
}
);
});
});

describe("callUserUpdateStatusByJwtApi", () => {
test("PUTs to users/{accountId} with status body and JWT auth", (done) => {
UserApi.callUserUpdateStatusByJwtApi("the.jwt.token", "target-user", 1).engage(
(e) => done.fail(e),
(res: any) => {
expect(res).toEqual({foo: "bar"});
expect(ApiRequest.fetchJSON).toHaveBeenCalledWith("users/target-user", jasmine.any(Number), jasmine.any(Object));
const request = (ApiRequest.fetchJSON as jest.Mock).mock.calls[0][2];
expect(request.method).toEqual("PUT");
expect(request.headers.Authorization).toEqual("jwt the.jwt.token");
expect(JSON.parse(request.body)).toEqual({status: 1});
done();
}
);
});

test("URL-encodes the account ID", (done) => {
UserApi.callUserUpdateStatusByJwtApi("jwt", "user/with:special#chars", 0).engage(
(e) => done.fail(e),
() => {
expect(ApiRequest.fetchJSON).toHaveBeenCalledWith("users/user%2Fwith%3Aspecial%23chars", jasmine.any(Number), jasmine.any(Object));
done();
}
);
});
});

describe("callUserDeviceDeleteApi", () => {
test("calls delete API and returns response", (done) => {
const deviceDeleteResult = {id: 35352};
Expand Down
23 changes: 23 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {DeviceCreateOptions, UserCreateOptions} from "../ironnode";
import {Base64String} from "./commonTypes";
import {UserStatus} from "./Constants";
import * as Utils from "./lib/Utils";
import * as Initialization from "./sdk/Initialization";
import * as UserOperations from "./operations/UserOperations";

/**
* Initialize the Node SDK by providing account information necessary to run operations from a server side context. Returns a Promise which
Expand Down Expand Up @@ -54,8 +56,29 @@ export const User = {
generateDeviceKeys(jwt: string, password: string, deviceOptions: DeviceCreateOptions = {deviceName: ""}) {
return Initialization.generateDevice(jwt, password, deviceOptions).toPromise();
},
/**
* Enable or disable a user identified by the provided signed JWT. The user ID is read from the JWT's `sub` claim. The server validates
* the JWT signature; clients holding a valid JWT for a user may flip that user's status between `UserStatus.Disabled` and `UserStatus.Enabled`.
* @param {string} jwt Signed JWT for the user whose status is being updated
* @param {UserStatus} status Desired status (`UserStatus.Disabled` or `UserStatus.Enabled`)
*/
updateStatus(jwt: string, status: UserStatus) {
if (typeof jwt !== "string" || !jwt.length) {
throw new Error("Expected a non-empty JWT string.");
}
// Runtime guard for callers using `as any` to bypass the literal-union parameter type.
if (status !== UserStatus.Disabled && status !== UserStatus.Enabled) {
throw new Error(`Invalid user status '${status}'. Expected UserStatus.Disabled (0) or UserStatus.Enabled (1).`);
}
return UserOperations.updateUserStatus(jwt, status).toPromise();
},
};

/**
* User status constants. Used with `User.updateStatus` to enable or disable a user.
*/
export {UserStatus};

/**
* List of SDK Error Codes
*/
Expand Down
17 changes: 17 additions & 0 deletions src/lib/ApiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ class ApiState {
this.accountEncryptedPublicKeyBytes = key;
}

/**
* Wipe the in-memory account context. Used after operations that revoke the current
* device (`disableSelf`, current-device delete). Subsequent SDK calls fail locally
* rather than signing requests with revoked keys.
*/
clearCurrentUser() {
const replacement = new ApiState();
this.accountID = replacement.accountID;
this.segmentID = replacement.segmentID;
this.currentKeyId = replacement.currentKeyId;
this.accountPublicKeyBytes = replacement.accountPublicKeyBytes;
this.accountEncryptedPublicKeyBytes = replacement.accountEncryptedPublicKeyBytes;
this.publicSigningKey = replacement.publicSigningKey;
this.privateDeviceKey = replacement.privateDeviceKey;
this.privateSigningKey = replacement.privateSigningKey;
}

/**
* Return the public key of the account being used.
*/
Expand Down
35 changes: 35 additions & 0 deletions src/lib/SDKState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {ErrorCodes} from "../Constants";
import SDKError from "./SDKError";

/**
* Public-surface initialization flag. Kept separate from `ApiState` (which stores the
* cryptographic context with definite-assignment types) so the lifecycle gate at the
* SDK boundary doesn't require nullable accessors on every keyed field. Every method
* on the `SDK` object returned from `initialize()` calls `checkSDKInitialized()` at
* the top so post-disable / post-current-device-delete calls fail cleanly here
* instead of signing requests with revoked keys deeper in the stack.
*/
let hasInitializedSDK = false;

export function setSDKInitialized() {
hasInitializedSDK = true;
}

export function clearSDKInitialized() {
hasInitializedSDK = false;
}

export function isSDKInitialized(): boolean {
return hasInitializedSDK;
}

export function checkSDKInitialized(): void {
if (!hasInitializedSDK) {
throw new SDKError(
new Error(
"SDK is not initialized. Either `initialize()` has not been called, or this session was terminated by `disableSelf()` or by deleting the current device."
),
ErrorCodes.SDK_NOT_INITIALIZED
);
}
}
40 changes: 38 additions & 2 deletions src/lib/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {TransformKey} from "@ironcorelabs/recrypt-node-binding";
import Future from "futurejs";
import {PublicKey, Base64String, DocumentHeader} from "../commonTypes";
import {
AES_IV_LENGTH,
Expand All @@ -7,8 +8,10 @@ import {
HEADER_META_LENGTH_LENGTH,
VERSION_HEADER_LENGTH,
ALLOWED_ID_CHAR_REGEX,
ErrorCodes,
} from "../Constants";
import {DocumentAccessList} from "../../ironnode";
import SDKError from "./SDKError";

export const Codec = {
/**
Expand Down Expand Up @@ -74,6 +77,33 @@ export function dedupeArray(list: string[], clearEmptyValues: boolean = false) {
});
}

/**
* Extract the user/account ID (the `sub` claim) from a signed JWT. Signature
* verification is left to the IronCore backend; this only parses the payload.
* Resolves with the parsed user ID on success, or rejects with an `SDKError`
* (code `JWT_FORMAT_FAILURE`) on failure.
*/
export function getUserIdFromJwt(jwtToken: string): Future<SDKError, string> {
const fail = (message: string): Future<SDKError, string> => Future.reject(new SDKError(new Error(message), ErrorCodes.JWT_FORMAT_FAILURE));
if (typeof jwtToken !== "string" || !jwtToken.length) {
return fail("Invalid JWT provided.");
}
const parts = jwtToken.split(".");
if (parts.length !== 3) {
return fail("Invalid JWT provided.");
}
let payload: {sub?: unknown};
try {
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
} catch {
return fail("Invalid JWT provided.");
}
if (typeof payload.sub !== "string" || !payload.sub.length) {
return fail("Invalid JWT provided. Missing 'sub' claim.");
}
return Future.of(payload.sub);
}

/**
* Validate that the provided document ID is a string and has a length
*/
Expand Down Expand Up @@ -137,10 +167,16 @@ export function dedupeAccessLists(accessList: DocumentAccessList) {
let userAccess: string[] = [];
let groupAccess: string[] = [];
if (accessList.users && accessList.users.length) {
userAccess = dedupeArray(accessList.users.map(({id}) => id), true);
userAccess = dedupeArray(
accessList.users.map(({id}) => id),
true
);
}
if (accessList.groups && accessList.groups.length) {
groupAccess = dedupeArray(accessList.groups.map(({id}) => id), true);
groupAccess = dedupeArray(
accessList.groups.map(({id}) => id),
true
);
}
return [userAccess, groupAccess];
}
Expand Down
Loading