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, IRead,
} from "@rocket.chat/apps-engine/definition/accessors"; } from "@rocket.chat/apps-engine/definition/accessors";
import { import {
ApiEndpoint,
IApiEndpointInfo, IApiEndpointInfo,
IApiRequest, IApiRequest,
IApiResponse, IApiResponse,
} from "@rocket.chat/apps-engine/definition/api"; } from "@rocket.chat/apps-engine/definition/api";
import { RcHttp } from "./RcHttp";
import { SCIMUser } from "./scim/User"; 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 path = "Users/:id";
public async get( public async _get(
request: IApiRequest, request: IApiRequest,
endpoint: IApiEndpointInfo, endpoint: IApiEndpointInfo,
read: IRead, read: IRead,
modify: IModify, modify: IModify,
http: IHttp, http: IHttp,
persis: IPersistence persis: IPersistence,
): Promise<IApiResponse> { ): Promise<IApiResponse> {
let user: SCIMUser; const response = await new RcHttp(http, read).get(
try { `users.info?userId=${request.params.id}`,
const response = await http.get( );
`http://localhost:3000/api/v1/users.info?userId=` + const o = this.parseResponse(response);
request.params.id, this.handleError(o);
{ const user = SCIMUser.fromRC(o.user);
headers: { return this.success(user);
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
}
);
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,
};
} }
public async put( public async _put(
request: IApiRequest, request: IApiRequest,
endpoint: IApiEndpointInfo, endpoint: IApiEndpointInfo,
read: IRead, read: IRead,
modify: IModify, modify: IModify,
http: IHttp, http: IHttp,
persis: IPersistence persis: IPersistence,
): Promise<IApiResponse> { ): Promise<IApiResponse> {
let user: SCIMUser; this.hasContent(request);
try { const response = await new RcHttp(http, read).post(
const response = await http.post( "users.update",
"http://localhost:3000/api/v1/users.update", this.scimToUserUpdate(
{ request.params.id,
headers: { SCIMUser.fromPlain(request.content),
...(await this.getAuthHeaders(read)), ),
"Content-Type": "application/json", );
}, const o = this.parseResponse(response);
content: JSON.stringify( this.handleError(o);
this.scimToUserUpdate( const user = SCIMUser.fromRC(o.user);
request.params.id, return this.success(user);
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,
};
} }
public async delete( public async _delete(
request: IApiRequest, request: IApiRequest,
endpoint: IApiEndpointInfo, endpoint: IApiEndpointInfo,
read: IRead, read: IRead,
modify: IModify, modify: IModify,
http: IHttp, http: IHttp,
persis: IPersistence persis: IPersistence,
): Promise<IApiResponse> { ): Promise<IApiResponse> {
let d: IUserDelete = { const d: IUserDelete = {
userId: request.params.id, userId: request.params.id,
confirmRelinquish: true, confirmRelinquish: true,
}; };
try { const response = await new RcHttp(http, read).post("users.delete", d);
const response = await http.post( const o = this.parseResponse(response);
"http://localhost:3000/api/v1/users.delete", this.handleError(o);
{ return this.response({
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",
},
status: HttpStatusCode.NO_CONTENT, 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 { private scimToUserUpdate(userId: string, user: SCIMUser): IUserUpdate {

View file

@ -6,128 +6,67 @@ import {
IRead, IRead,
} from "@rocket.chat/apps-engine/definition/accessors"; } from "@rocket.chat/apps-engine/definition/accessors";
import { import {
ApiEndpoint,
IApiEndpointInfo, IApiEndpointInfo,
IApiRequest, IApiRequest,
IApiResponse, IApiResponse,
} from "@rocket.chat/apps-engine/definition/api"; } from "@rocket.chat/apps-engine/definition/api";
import crypto = require("crypto"); import crypto = require("crypto");
import { SCIMError, SCIMErrorType } from "./scim/Error"; import { RcHttp } from "./RcHttp";
import { SCIMListResponse } from "./scim/ListResponse"; import { SCIMListResponse } from "./scim/ListResponse";
import { SCIMUser } from "./scim/User"; 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 path = "Users";
public async get( public async _get(
request: IApiRequest, request: IApiRequest,
endpoint: IApiEndpointInfo, endpoint: IApiEndpointInfo,
read: IRead, read: IRead,
modify: IModify, modify: IModify,
http: IHttp, http: IHttp,
persis: IPersistence persis: IPersistence,
): Promise<IApiResponse> { ): Promise<IApiResponse> {
const response = await new RcHttp(http, read).get(
`users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`,
);
const o = this.parseResponse(response);
this.handleError(o);
const list = new SCIMListResponse(); const list = new SCIMListResponse();
try { list.Resources = o.users.map(SCIMUser.fromRC);
const response = await http.get( list.totalResults = o.total;
`http://localhost:3000/api/v1/users.list?query={"type":{"$eq":"user"}}&fields={"createdAt":1}`, return this.success(list);
{
headers: {
...(await this.getAuthHeaders(read)),
"Content-Type": "application/json",
},
}
);
if (!response.content) {
throw new Error("Empty response");
}
const o = JSON.parse(response.content);
if (!o.success) {
throw new Error(o.error);
}
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,
};
} }
public async post( public async _post(
request: IApiRequest, request: IApiRequest,
endpoint: IApiEndpointInfo, endpoint: IApiEndpointInfo,
read: IRead, read: IRead,
modify: IModify, modify: IModify,
http: IHttp, http: IHttp,
persis: IPersistence persis: IPersistence,
): Promise<IApiResponse> { ): Promise<IApiResponse> {
let user = request.content; this.hasContent(request);
try { const response = await new RcHttp(http, read).post(
const response = await http.post( `users.create`,
`http://localhost:3000/api/v1/users.create`, this.scimToUserCreate(SCIMUser.fromPlain(request.content)),
{ );
headers: { const o = this.parseResponse(response);
...(await this.getAuthHeaders(read)), this.handleError(o);
"Content-Type": "application/json", const user = SCIMUser.fromRC(o.user);
}, return this.response({
content: JSON.stringify(
this.scimToUserCreate(SCIMUser.fromPlain(user))
),
}
);
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",
},
status: HttpStatusCode.CREATED, status: HttpStatusCode.CREATED,
content: user, 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 { private scimToUserCreate(user: SCIMUser): IUserCreate {
return { return {
email: user.emails[0].value, email: user.getEmail(),
name: user.displayName, name:
user.displayName ||
`${user.name.givenName} ${user.name.familyName}` ||
user.userName,
username: user.userName, username: user.userName,
password: crypto.randomBytes(64).toString("base64").slice(0, 64), password: crypto.randomBytes(64).toString("base64").slice(0, 64),
verified: true, 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 { export enum SCIMErrorType {
INVALID_FILTER = "invalidFilter", INVALID_FILTER = "invalidFilter",
@ -27,15 +27,24 @@ export enum SCIMErrorDetail {
NOT_IMPLEMENTED = "Service provider does not support the request operation.", NOT_IMPLEMENTED = "Service provider does not support the request operation.",
} }
export class SCIMError { export class SCIMError {
public readonly schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"]; public readonly schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"];
public scimType: SCIMErrorType; public scimType: SCIMErrorType;
public detail: SCIMErrorDetail | string; public detail: SCIMErrorDetail | string;
public status: string; public status: string;
public toApiResponse(): IApiResponse {
return { public setScimType(scimType: SCIMErrorType): SCIMError {
status: parseInt(this.status, 10), this.scimType = scimType;
content: this, return this;
};
} }
}
public setStatus(status: HttpStatusCode): SCIMError {
this.status = `${status}`;
return this;
}
public setDetail(detail: SCIMErrorDetail | string): SCIMError {
this.detail = detail;
return this;
}
}