scim errors

This commit is contained in:
Hugo Renard 2022-02-14 14:03:16 +01:00
parent 9cc58730dd
commit 835c2aa53c
Signed by: hougo
GPG key ID: 3A285FD470209C59
11 changed files with 398 additions and 220 deletions

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"trailingComma": "all"
}

76
RcHttp.ts Normal file
View file

@ -0,0 +1,76 @@
import {
IHttp,
IHttpRequest,
IHttpResponse,
IRead,
} from "@rocket.chat/apps-engine/definition/accessors";
export class RcHttp implements IHttp {
private readonly baseUrl = "http://localhost:3000/api/v1";
private readonly http: IHttp;
private readonly read: IRead;
constructor(http: IHttp, read: IRead) {
this.http = http;
this.read = read;
}
public async get(url: string, content?: any): Promise<IHttpResponse> {
return this.http.get(
this.buildUrl(url),
await this.buildOptions(content)
);
}
public async post(url: string, content?: any): Promise<IHttpResponse> {
return this.http.post(
this.buildUrl(url),
await this.buildOptions(content)
);
}
public async put(url: string, content?: any): Promise<IHttpResponse> {
return this.http.put(
this.buildUrl(url),
await this.buildOptions(content)
);
}
public async del(url: string, content?: any): Promise<IHttpResponse> {
return this.http.del(
this.buildUrl(url),
await this.buildOptions(content)
);
}
public async patch(url: string, content?: any): Promise<IHttpResponse> {
return this.http.patch(
this.buildUrl(url),
await this.buildOptions(content)
);
}
private buildUrl(url: string): string {
return `${this.baseUrl}/${url}`;
}
private async buildOptions(content?: any): Promise<IHttpRequest> {
const options: IHttpRequest = {
headers: {
"X-User-Id": await this.read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-user-id"),
"X-Auth-Token": await this.read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-token"),
"Content-Type": "application/json",
},
};
if (content !== undefined) {
options.content = JSON.stringify(content);
}
return options;
}
}

171
ScimEndpoint.ts Normal file
View file

@ -0,0 +1,171 @@
import {
HttpStatusCode,
IHttp,
IHttpResponse,
IModify,
IPersistence,
IRead,
} from "@rocket.chat/apps-engine/definition/accessors";
import {
ApiEndpoint,
IApiEndpointInfo,
IApiRequest,
IApiResponse,
} from "@rocket.chat/apps-engine/definition/api";
import { IApp } from "@rocket.chat/apps-engine/definition/IApp";
import { ConflictError } from "./errors/ConflictError";
import { EmptyRequestError } from "./errors/EmptyRequestError";
import { EmptyResponseError } from "./errors/EmptyResponseError";
import { JsonParseError } from "./errors/JsonParseError";
import { SCIMError, SCIMErrorType } from "./scim/Error";
type ApiEndpointMethod = (
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
) => Promise<IApiResponse>;
export interface IScimEndpoint {
_get?(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
): Promise<IApiResponse>;
_post?(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
): Promise<IApiResponse>;
_put?(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
): Promise<IApiResponse>;
_delete?(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
): Promise<IApiResponse>;
}
export abstract class ScimEndpoint extends ApiEndpoint {
public get: ApiEndpointMethod | undefined;
public post: ApiEndpointMethod | undefined;
public put: ApiEndpointMethod | undefined;
public delete: ApiEndpointMethod | undefined;
constructor(app: IApp) {
super(app);
this.get = this.wrapMethod("get");
this.post = this.wrapMethod("post");
this.put = this.wrapMethod("put");
this.delete = this.wrapMethod("delete");
}
protected success(content?: any): IApiResponse {
return this.response({
status: HttpStatusCode.OK,
content,
});
}
protected response(response: IApiResponse): IApiResponse {
if (response.headers === undefined) {
response.headers = {};
}
response.headers["Content-Type"] = "application/scim+json";
return response;
}
protected error(error: SCIMError): IApiResponse {
return this.response({
status: parseInt(error.status, 10),
content: error,
});
}
protected parseResponse(response: IHttpResponse): any {
if (!response.content) {
throw new EmptyResponseError();
}
let content: any;
try {
content = JSON.parse(response.content);
} catch (e) {
throw new JsonParseError();
}
return content;
}
protected hasContent(request: IApiRequest) {
if (!request.content || Object.keys(request.content).length === 0) {
throw new EmptyRequestError();
}
}
protected handleError(o: any) {
if (!o.success) {
if (o.error.includes("already in use")) {
throw new ConflictError(
o.error.includes("@") ? "email" : "username",
);
}
if (o.error.includes("not found")) {
}
throw new Error(o.error);
}
}
private wrapMethod(name: string): ApiEndpointMethod | undefined {
const method = this[`_${name}`];
if (method === undefined || typeof method !== "function") {
return undefined;
}
return async (
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence,
): Promise<IApiResponse> => {
try {
return await method.bind(this)(
request,
endpoint,
read,
modify,
http,
persis,
);
} catch (e) {
let err: SCIMError;
if (e.toSCIMError && typeof e.toSCIMError === "function") {
err = e.toSCIMError();
} else {
err = new SCIMError()
.setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR)
.setScimType(SCIMErrorType.INVALID_VALUE)
.setDetail(e.message);
}
return this.error(err);
}
};
}
}

View file

@ -6,161 +6,74 @@ import {
IRead,
} from "@rocket.chat/apps-engine/definition/accessors";
import {
ApiEndpoint,
IApiEndpointInfo,
IApiRequest,
IApiResponse,
} from "@rocket.chat/apps-engine/definition/api";
import { RcHttp } from "./RcHttp";
import { SCIMUser } from "./scim/User";
import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint";
export class UserEndpoint extends ApiEndpoint {
export class UserEndpoint extends ScimEndpoint implements IScimEndpoint {
public path = "Users/:id";
public async get(
public async _get(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
persis: IPersistence,
): Promise<IApiResponse> {
let user: SCIMUser;
try {
const response = await http.get(
`http://localhost:3000/api/v1/users.info?userId=` +
request.params.id,
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
}
const response = await new RcHttp(http, read).get(
`users.info?userId=${request.params.id}`,
);
if (!response.content) throw new Error("Empty response");
const o = JSON.parse(response.content);
if (!o.success) throw new Error(o.error);
user = SCIMUser.fromRC(o.user);
} catch (e) {
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.BAD_REQUEST,
content: { message: e.message },
};
}
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.OK,
content: user,
};
const o = this.parseResponse(response);
this.handleError(o);
const user = SCIMUser.fromRC(o.user);
return this.success(user);
}
public async put(
public async _put(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
persis: IPersistence,
): Promise<IApiResponse> {
let user: SCIMUser;
try {
const response = await http.post(
"http://localhost:3000/api/v1/users.update",
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
content: JSON.stringify(
this.hasContent(request);
const response = await new RcHttp(http, read).post(
"users.update",
this.scimToUserUpdate(
request.params.id,
SCIMUser.fromPlain(request.content)
)
SCIMUser.fromPlain(request.content),
),
}
);
if (!response.content) throw new Error("Empty response");
const o = JSON.parse(response.content);
if (!o.success) throw new Error(o.error);
user = SCIMUser.fromRC(o.user);
} catch (e) {
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.BAD_REQUEST,
content: { message: e.message },
};
}
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.OK,
content: user,
};
const o = this.parseResponse(response);
this.handleError(o);
const user = SCIMUser.fromRC(o.user);
return this.success(user);
}
public async delete(
public async _delete(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
persis: IPersistence,
): Promise<IApiResponse> {
let d: IUserDelete = {
const d: IUserDelete = {
userId: request.params.id,
confirmRelinquish: true,
};
try {
const response = await http.post(
"http://localhost:3000/api/v1/users.delete",
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
content: JSON.stringify(d),
}
);
if (!response.content) throw new Error("Empty response");
const o = JSON.parse(response.content);
if (!o.success) throw new Error(o.error);
} catch (e) {
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.BAD_REQUEST,
content: { message: e.message },
};
}
return {
headers: {
"Content-Type": "application/scim+json",
},
const response = await new RcHttp(http, read).post("users.delete", d);
const o = this.parseResponse(response);
this.handleError(o);
return this.response({
status: HttpStatusCode.NO_CONTENT,
};
}
private async getAuthHeaders(
read: IRead
): Promise<{ [key: string]: string }> {
return {
"X-User-Id": await read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-user-id"),
"X-Auth-Token": await read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-token"),
};
});
}
private scimToUserUpdate(userId: string, user: SCIMUser): IUserUpdate {

View file

@ -6,128 +6,67 @@ import {
IRead,
} from "@rocket.chat/apps-engine/definition/accessors";
import {
ApiEndpoint,
IApiEndpointInfo,
IApiRequest,
IApiResponse,
} from "@rocket.chat/apps-engine/definition/api";
import crypto = require("crypto");
import { SCIMError, SCIMErrorType } from "./scim/Error";
import { RcHttp } from "./RcHttp";
import { SCIMListResponse } from "./scim/ListResponse";
import { SCIMUser } from "./scim/User";
import { IScimEndpoint, ScimEndpoint } from "./ScimEndpoint";
export class UsersEndpoint extends ApiEndpoint {
export class UsersEndpoint extends ScimEndpoint implements IScimEndpoint {
public path = "Users";
public async get(
public async _get(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
persis: IPersistence,
): Promise<IApiResponse> {
const list = new SCIMListResponse();
try {
const response = await http.get(
`http://localhost:3000/api/v1/users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`,
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
}
const response = await new RcHttp(http, read).get(
`users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`,
);
if (!response.content) {
throw new Error("Empty response");
}
const o = JSON.parse(response.content);
if (!o.success) {
throw new Error(o.error);
}
const o = this.parseResponse(response);
this.handleError(o);
const list = new SCIMListResponse();
list.Resources = o.users.map(SCIMUser.fromRC);
list.totalResults = o.total;
} catch (e) {
const err = new SCIMError();
err.scimType = SCIMErrorType.INVALID_VALUE;
err.detail = e.message;
err.status = "400";
return err.toApiResponse();
}
return {
headers: {
"Content-Type": "application/scim+json",
},
status: HttpStatusCode.OK,
content: list,
};
return this.success(list);
}
public async post(
public async _post(
request: IApiRequest,
endpoint: IApiEndpointInfo,
read: IRead,
modify: IModify,
http: IHttp,
persis: IPersistence
persis: IPersistence,
): Promise<IApiResponse> {
let user = request.content;
try {
const response = await http.post(
`http://localhost:3000/api/v1/users.create`,
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
content: JSON.stringify(
this.scimToUserCreate(SCIMUser.fromPlain(user))
),
}
this.hasContent(request);
const response = await new RcHttp(http, read).post(
`users.create`,
this.scimToUserCreate(SCIMUser.fromPlain(request.content)),
);
if (!response.content) {
throw new Error("Empty response");
}
const o = JSON.parse(response.content);
if (!o.success) {
throw new Error(o.error);
}
user = SCIMUser.fromRC(o.user);
} catch (e) {
const err = new SCIMError();
err.scimType = SCIMErrorType.INVALID_VALUE;
err.detail = e.message;
err.status = "400";
return err.toApiResponse();
}
return {
headers: {
"Content-Type": "application/scim+json",
},
const o = this.parseResponse(response);
this.handleError(o);
const user = SCIMUser.fromRC(o.user);
return this.response({
status: HttpStatusCode.CREATED,
content: user,
};
}
private async getAuthHeaders(
read: IRead
): Promise<{ [key: string]: string }> {
return {
"X-User-Id": await read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-user-id"),
"X-Auth-Token": await read
.getEnvironmentReader()
.getSettings()
.getValueById("rc-token"),
};
});
}
private scimToUserCreate(user: SCIMUser): IUserCreate {
return {
email: user.emails[0].value,
name: user.displayName,
email: user.getEmail(),
name:
user.displayName ||
`${user.name.givenName} ${user.name.familyName}` ||
user.userName,
username: user.userName,
password: crypto.randomBytes(64).toString("base64").slice(0, 64),
verified: true,

6
errors/BaseError.ts Normal file
View file

@ -0,0 +1,6 @@
import { SCIMError } from "../scim/Error";
export abstract class BaseError extends Error {
public readonly isBaseError = true;
public abstract toSCIMError(): SCIMError;
}

22
errors/ConflictError.ts Normal file
View file

@ -0,0 +1,22 @@
import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors";
import { SCIMError, SCIMErrorType } from "../scim/Error";
import { BaseError } from "./BaseError";
export class ConflictError extends BaseError {
public get message() {
return `This ${this.type} already exists`;
}
private type = "";
constructor(type: string) {
super();
this.type = type;
}
public toSCIMError(): SCIMError {
return new SCIMError()
.setStatus(HttpStatusCode.CONFLICT)
.setScimType(SCIMErrorType.INVALID_VALUE)
.setDetail(this.message);
}
}

View file

@ -0,0 +1,13 @@
import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors";
import { SCIMError, SCIMErrorType } from "../scim/Error";
import { BaseError } from "./BaseError";
export class EmptyRequestError extends BaseError {
public message = "Request body is empty or content type is unsupported";
public toSCIMError(): SCIMError {
return new SCIMError()
.setStatus(HttpStatusCode.BAD_REQUEST)
.setScimType(SCIMErrorType.INVALID_VALUE)
.setDetail(this.message);
}
}

View file

@ -0,0 +1,13 @@
import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors";
import { SCIMError, SCIMErrorType } from "../scim/Error";
import { BaseError } from "./BaseError";
export class EmptyResponseError extends BaseError {
public message = "Rocket.Chat API returned an empty response";
public toSCIMError(): SCIMError {
return new SCIMError()
.setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR)
.setScimType(SCIMErrorType.INVALID_VALUE)
.setDetail(this.message);
}
}

13
errors/JsonParseError.ts Normal file
View file

@ -0,0 +1,13 @@
import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors";
import { SCIMError, SCIMErrorType } from "../scim/Error";
import { BaseError } from "./BaseError";
export class JsonParseError extends BaseError {
public message = "Failed to parse Rocket.Chat API response content";
public toSCIMError(): SCIMError {
return new SCIMError()
.setStatus(HttpStatusCode.INTERNAL_SERVER_ERROR)
.setScimType(SCIMErrorType.INVALID_VALUE)
.setDetail(this.message);
}
}

View file

@ -1,4 +1,4 @@
import { IApiResponse } from "@rocket.chat/apps-engine/definition/api";
import { HttpStatusCode } from "@rocket.chat/apps-engine/definition/accessors";
export enum SCIMErrorType {
INVALID_FILTER = "invalidFilter",
@ -32,10 +32,19 @@ export class SCIMError {
public scimType: SCIMErrorType;
public detail: SCIMErrorDetail | string;
public status: string;
public toApiResponse(): IApiResponse {
return {
status: parseInt(this.status, 10),
content: this,
};
public setScimType(scimType: SCIMErrorType): SCIMError {
this.scimType = scimType;
return this;
}
public setStatus(status: HttpStatusCode): SCIMError {
this.status = `${status}`;
return this;
}
public setDetail(detail: SCIMErrorDetail | string): SCIMError {
this.detail = detail;
return this;
}
}