Skip to content

Commit 0720d8e

Browse files
d-gubertclaude
andcommitted
refactor(apps-engine): promote cross-boundary types to definition layer
OAuth2Client, IExternalComponentRoomInfo/UserInfo, and the room query option types (GetMessagesOptions, GetRoomsFilters, GetRoomsOptions) were defined in server/ or client/ but were imported by definition/ files. Move them into the definition layer so the public API is self-contained. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2118bf6 commit 0720d8e

9 files changed

Lines changed: 418 additions & 3 deletions

File tree

packages/apps-engine/src/definition/accessors/IRoomRead.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '../../server/bridges/RoomBridge';
1+
import type { GetMessagesOptions, GetRoomsFilters, GetRoomsOptions } from '../rooms/IGetMessagesOptions';
22
import type { IMessageRaw } from '../messages/index';
33
import type { IRoom, IRoomRaw } from '../rooms/index';
44
import type { IUser } from '../users/index';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { IRoom } from '../rooms';
2+
import type { IExternalComponentUserInfo } from './IExternalComponentUserInfo';
3+
4+
type ClientRoomInfo = Pick<IRoom, 'id' | 'slugifiedName'>;
5+
6+
/**
7+
* Represents the room's information returned to the
8+
* external component.
9+
*/
10+
export interface IExternalComponentRoomInfo extends ClientRoomInfo {
11+
/**
12+
* the list that contains all the users belonging
13+
* to this room.
14+
*/
15+
members: Array<IExternalComponentUserInfo>;
16+
}

packages/apps-engine/src/definition/externalComponent/IExternalComponentState.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { IExternalComponentRoomInfo, IExternalComponentUserInfo } from '../../client/definition';
1+
import type { IExternalComponentRoomInfo } from './IExternalComponentRoomInfo';
2+
import type { IExternalComponentUserInfo } from './IExternalComponentUserInfo';
23

34
/**
45
* The state of an external component, which contains the
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { IUser } from '../users';
2+
3+
type ClientUserInfo = Pick<IUser, 'id' | 'username'>;
4+
5+
/**
6+
* Represents the user's information returned to
7+
* the external component.
8+
*/
9+
export interface IExternalComponentUserInfo extends ClientUserInfo {
10+
/**
11+
* the avatar URL of the Rocket.Chat user
12+
*/
13+
avatarUrl: string;
14+
}

packages/apps-engine/src/definition/externalComponent/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ import { IPostExternalComponentClosed } from './IPostExternalComponentClosed';
33
import { IPostExternalComponentOpened } from './IPostExternalComponentOpened';
44

55
export { IExternalComponent, IPostExternalComponentClosed, IPostExternalComponentOpened };
6+
export * from './IExternalComponentState';
7+
export * from './IExternalComponentRoomInfo';
8+
export * from './IExternalComponentUserInfo';

packages/apps-engine/src/definition/oauth2/OAuth2.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OAuth2Client } from '../../server/oauth2/OAuth2Client';
1+
import { OAuth2Client } from './OAuth2Client';
22
import type { App } from '../App';
33
import type { IOAuth2ClientOptions } from './IOAuth2';
44

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import { URL } from 'url';
2+
3+
import type { App } from '../App';
4+
import type { IConfigurationExtend, IHttp, IModify, IPersistence, IRead } from '../accessors';
5+
import { HttpStatusCode } from '../accessors';
6+
import type { IApiEndpointInfo, IApiRequest, IApiResponse } from '../api';
7+
import { ApiSecurity, ApiVisibility } from '../api';
8+
import { RocketChatAssociationModel, RocketChatAssociationRecord } from '../metadata';
9+
import type { IAuthData, IOAuth2Client, IOAuth2ClientOptions } from './IOAuth2';
10+
import { SettingType } from '../settings';
11+
import type { IUser } from '../users';
12+
13+
export enum GrantType {
14+
RefreshToken = 'refresh_token',
15+
AuthorizationCode = 'authorization_code',
16+
}
17+
18+
export class OAuth2Client implements IOAuth2Client {
19+
private defaultContents = {
20+
success: `<div style="display: flex;align-items: center;justify-content: center; height: 100%;">\
21+
<h1 style="text-align: center; font-family: Helvetica Neue;">\
22+
Authorization went successfully<br>\
23+
You can close this tab now<br>\
24+
</h1>\
25+
</div>`,
26+
failed: `<div style="display: flex;align-items: center;justify-content: center; height: 100%;">\
27+
<h1 style="text-align: center; font-family: Helvetica Neue;">\
28+
Oops, something went wrong, please try again or in case it still does not work, contact the administrator.\
29+
</h1>\
30+
</div>`,
31+
};
32+
33+
constructor(
34+
private readonly app: App,
35+
private readonly config: IOAuth2ClientOptions,
36+
) {}
37+
38+
public async setup(configuration: IConfigurationExtend): Promise<void> {
39+
configuration.api.provideApi({
40+
security: ApiSecurity.UNSECURE,
41+
visibility: ApiVisibility.PUBLIC,
42+
endpoints: [
43+
{
44+
path: `${this.config.alias}-callback`,
45+
get: this.handleOAuthCallback.bind(this),
46+
},
47+
],
48+
});
49+
50+
await Promise.all([
51+
configuration.settings.provideSetting({
52+
id: `${this.config.alias}-oauth-client-id`,
53+
type: SettingType.STRING,
54+
public: true,
55+
required: true,
56+
packageValue: '',
57+
i18nLabel: `${this.config.alias}-oauth-client-id`,
58+
}),
59+
60+
configuration.settings.provideSetting({
61+
id: `${this.config.alias}-oauth-clientsecret`,
62+
type: SettingType.STRING,
63+
public: true,
64+
required: true,
65+
packageValue: '',
66+
i18nLabel: `${this.config.alias}-oauth-client-secret`,
67+
}),
68+
]);
69+
}
70+
71+
public async getUserAuthorizationUrl(user: IUser, scopes?: Array<string>): Promise<URL> {
72+
const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1);
73+
74+
const siteUrl = await this.getBaseURLWithoutTrailingSlash();
75+
76+
const finalScopes = ([] as Array<string>).concat(this.config.defaultScopes || [], scopes || []);
77+
78+
const { authUri } = this.config;
79+
80+
const clientId = await this.app
81+
.getAccessors()
82+
.reader.getEnvironmentReader()
83+
.getSettings()
84+
.getValueById(`${this.config.alias}-oauth-client-id`);
85+
86+
const url = new URL(authUri, siteUrl);
87+
88+
url.searchParams.set('response_type', 'code');
89+
url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`);
90+
url.searchParams.set('state', user.id);
91+
url.searchParams.set('client_id', clientId);
92+
url.searchParams.set('access_type', 'offline');
93+
94+
if (finalScopes.length > 0) {
95+
url.searchParams.set('scope', finalScopes.join(' '));
96+
}
97+
98+
return url;
99+
}
100+
101+
public async getAccessTokenForUser(user: IUser): Promise<IAuthData | undefined> {
102+
const associations = [
103+
new RocketChatAssociationRecord(RocketChatAssociationModel.USER, user.id),
104+
new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`),
105+
];
106+
107+
const [result] = (await this.app.getAccessors().reader.getPersistenceReader().readByAssociations(associations)) as unknown as Array<
108+
IAuthData | undefined
109+
>;
110+
111+
return result;
112+
}
113+
114+
public async refreshUserAccessToken(user: IUser, persis: IPersistence): Promise<IAuthData | undefined> {
115+
try {
116+
const tokenInfo = await this.getAccessTokenForUser(user);
117+
118+
if (!tokenInfo) {
119+
throw new Error('User has no access token information');
120+
}
121+
122+
if (!tokenInfo.refreshToken) {
123+
throw new Error('User token information has no refresh token available');
124+
}
125+
126+
const {
127+
config: { refreshTokenUri },
128+
} = this;
129+
130+
const clientId = await this.app
131+
.getAccessors()
132+
.reader.getEnvironmentReader()
133+
.getSettings()
134+
.getValueById(`${this.config.alias}-oauth-client-id`);
135+
136+
const clientSecret = await this.app
137+
.getAccessors()
138+
.reader.getEnvironmentReader()
139+
.getSettings()
140+
.getValueById(`${this.config.alias}-oauth-clientsecret`);
141+
142+
const siteUrl = await this.getBaseURLWithoutTrailingSlash();
143+
144+
const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1);
145+
146+
const url = new URL(refreshTokenUri);
147+
148+
url.searchParams.set('client_id', clientId);
149+
url.searchParams.set('client_secret', clientSecret);
150+
url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`);
151+
url.searchParams.set('refresh_token', tokenInfo.refreshToken);
152+
url.searchParams.set('grant_type', GrantType.RefreshToken);
153+
154+
const { content, statusCode } = await this.app.getAccessors().http.post(url.href);
155+
156+
if (statusCode !== 200) {
157+
throw new Error('Request to provider was unsuccessful. Check logs for more information');
158+
}
159+
160+
const { access_token, expires_in, refresh_token, scope } = JSON.parse(content as string);
161+
162+
if (!access_token) {
163+
throw new Error('No access token returned by the provider');
164+
}
165+
166+
const authData: IAuthData = {
167+
scope,
168+
token: access_token,
169+
expiresAt: expires_in,
170+
refreshToken: refresh_token || tokenInfo.refreshToken,
171+
};
172+
173+
await this.saveToken(authData, user.id, persis);
174+
175+
return authData;
176+
} catch (error) {
177+
this.app.getLogger().error(error);
178+
throw error;
179+
}
180+
}
181+
182+
public async revokeUserAccessToken(user: IUser, persis: IPersistence): Promise<boolean> {
183+
try {
184+
const tokenInfo = await this.getAccessTokenForUser(user);
185+
186+
if (!tokenInfo?.token) {
187+
throw new Error('No access token available for this user.');
188+
}
189+
190+
const url = new URL(this.config.revokeTokenUri);
191+
192+
url.searchParams.set('token', tokenInfo?.token);
193+
194+
const result = await this.app.getAccessors().http.post(url.href);
195+
196+
if (result.statusCode !== 200) {
197+
throw new Error('Provider did not allow token to be revoked');
198+
}
199+
200+
await this.removeToken({ userId: user.id, persis });
201+
202+
return true;
203+
} catch (error) {
204+
this.app.getLogger().error(error);
205+
return false;
206+
}
207+
}
208+
209+
private async getBaseURLWithoutTrailingSlash(): Promise<string> {
210+
const SITE_URL = 'Site_Url';
211+
const url = await this.app.getAccessors().environmentReader.getServerSettings().getValueById(SITE_URL);
212+
213+
if (url.endsWith('/')) {
214+
return url.substr(0, url.length - 1);
215+
}
216+
return url;
217+
}
218+
219+
private async handleOAuthCallback(
220+
request: IApiRequest,
221+
endpoint: IApiEndpointInfo,
222+
read: IRead,
223+
modify: IModify,
224+
http: IHttp,
225+
persis: IPersistence,
226+
): Promise<IApiResponse> {
227+
try {
228+
const {
229+
query: { code, state },
230+
} = request;
231+
232+
const user = await this.app.getAccessors().reader.getUserReader().getById(state);
233+
234+
if (!user) {
235+
throw new Error('User could not be determined.');
236+
}
237+
238+
// User chose not to authorize the access
239+
if (!code) {
240+
const failedResult = await this.config.authorizationCallback?.(undefined, user, read, modify, http, persis);
241+
242+
return {
243+
status: HttpStatusCode.UNAUTHORIZED,
244+
content: failedResult?.responseContent || this.defaultContents.failed,
245+
};
246+
}
247+
248+
const siteUrl = await this.getBaseURLWithoutTrailingSlash();
249+
250+
const accessTokenUrl = this.config.accessTokenUri;
251+
252+
const redirectUri = this.app.getAccessors().providedApiEndpoints[0].computedPath.substring(1);
253+
254+
const clientId = await this.app
255+
.getAccessors()
256+
.reader.getEnvironmentReader()
257+
.getSettings()
258+
.getValueById(`${this.config.alias}-oauth-client-id`);
259+
260+
const clientSecret = await this.app
261+
.getAccessors()
262+
.reader.getEnvironmentReader()
263+
.getSettings()
264+
.getValueById(`${this.config.alias}-oauth-clientsecret`);
265+
266+
const url = new URL(accessTokenUrl, siteUrl);
267+
268+
url.searchParams.set('client_id', clientId);
269+
url.searchParams.set('redirect_uri', `${siteUrl}/${redirectUri}`);
270+
url.searchParams.set('code', code);
271+
url.searchParams.set('client_secret', clientSecret);
272+
url.searchParams.set('access_type', 'offline');
273+
url.searchParams.set('grant_type', GrantType.AuthorizationCode);
274+
275+
const { content, statusCode } = await http.post(url.href, {
276+
headers: { Accept: 'application/json' },
277+
});
278+
279+
// If provider had a server error, nothing we can do
280+
if (statusCode >= 500) {
281+
throw new Error('Request for access token failed. Check logs for more information');
282+
}
283+
284+
const response = JSON.parse(content as string);
285+
const { access_token, expires_in, refresh_token, scope } = response;
286+
287+
const authData: IAuthData = {
288+
scope,
289+
token: access_token,
290+
expiresAt: expires_in,
291+
refreshToken: refresh_token,
292+
};
293+
294+
const result = await this.config.authorizationCallback?.(authData, user, read, modify, http, persis);
295+
296+
await this.saveToken(authData, user.id, persis);
297+
298+
return {
299+
status: statusCode,
300+
content: result?.responseContent || this.defaultContents.success,
301+
};
302+
} catch (error) {
303+
this.app.getLogger().error(error);
304+
return {
305+
status: HttpStatusCode.INTERNAL_SERVER_ERROR,
306+
content: this.defaultContents.failed,
307+
};
308+
}
309+
}
310+
311+
private async saveToken(authData: IAuthData, userId: string, persis: IPersistence): Promise<string> {
312+
const { scope, token, expiresAt, refreshToken } = authData;
313+
314+
return persis.updateByAssociations(
315+
[
316+
new RocketChatAssociationRecord(RocketChatAssociationModel.USER, userId),
317+
new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`),
318+
],
319+
{
320+
scope,
321+
token,
322+
expiresAt: expiresAt || '',
323+
refreshToken: refreshToken || '',
324+
},
325+
true, // we want to create the record if it doesn't exist
326+
);
327+
}
328+
329+
private async removeToken({ userId, persis }: { userId: string; persis: IPersistence }): Promise<IAuthData> {
330+
const [result] = (await persis.removeByAssociations([
331+
new RocketChatAssociationRecord(RocketChatAssociationModel.USER, userId),
332+
new RocketChatAssociationRecord(RocketChatAssociationModel.MISC, `${this.config.alias}-oauth-connection`),
333+
])) as unknown as Array<IAuthData>;
334+
335+
return result;
336+
}
337+
}

0 commit comments

Comments
 (0)