diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 726e1e5c150b4..c6cd3442b3887 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -138,6 +138,7 @@ "@rocket.chat/omni-core-ee": "workspace:^", "@rocket.chat/omnichannel-services": "workspace:^", "@rocket.chat/onboarding-ui": "^0.36.2", + "@rocket.chat/passport-x": "workspace:~", "@rocket.chat/password-policies": "workspace:^", "@rocket.chat/patch-injection": "workspace:^", "@rocket.chat/pdf-worker": "workspace:^", @@ -268,8 +269,8 @@ "passport-facebook": "^3.0.0", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-oauth1": "~1.3.0", "passport-oauth2": "^1.8.0", - "passport-twitter": "^1.0.4", "path": "^0.12.7", "path-to-regexp": "^6.3.0", "pino": "10.3.1", @@ -402,7 +403,6 @@ "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2", "@types/passport-oauth2": "^1", - "@types/passport-twitter": "^1", "@types/prometheus-gc-stats": "^0.6.4", "@types/proxy-from-env": "^1.0.4", "@types/proxyquire": "^1.3.31", diff --git a/apps/meteor/server/lib/oauth/oauthConfigs.ts b/apps/meteor/server/lib/oauth/oauthConfigs.ts index b4c8fe6604fa5..44fc2d16358db 100644 --- a/apps/meteor/server/lib/oauth/oauthConfigs.ts +++ b/apps/meteor/server/lib/oauth/oauthConfigs.ts @@ -1,8 +1,8 @@ +import { Strategy as XStrategy } from '@rocket.chat/passport-x'; import type { Strategy } from 'passport'; import { Strategy as FacebookStrategy } from 'passport-facebook'; import { Strategy as GitHubStrategy } from 'passport-github2'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { Strategy as TwitterStrategy } from 'passport-twitter'; export type OAuthConfig = { strategy: new (...args: any[]) => Strategy; @@ -24,7 +24,7 @@ export const OAuthConfigs: Record = { scope: ['email', 'profile'], }, twitter: { - strategy: TwitterStrategy, + strategy: XStrategy, includeEmail: true, }, github_enterprise: { diff --git a/packages/passport-x/jest.config.ts b/packages/passport-x/jest.config.ts new file mode 100644 index 0000000000000..90df717c76945 --- /dev/null +++ b/packages/passport-x/jest.config.ts @@ -0,0 +1,12 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + projects: [ + { + displayName: 'server', + preset: server.preset, + testMatch: ['/src/**/*.spec.[jt]s?(x)'], + }, + ], +} satisfies Config; diff --git a/packages/passport-x/package.json b/packages/passport-x/package.json new file mode 100644 index 0000000000000..5c4715edfdd7a --- /dev/null +++ b/packages/passport-x/package.json @@ -0,0 +1,31 @@ +{ + "name": "@rocket.chat/passport-x", + "version": "0.0.1", + "private": true, + "description": "Fork of passport-twitter for X (Twitter) OAuth", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "testunit": "jest" + }, + "dependencies": { + "passport-oauth1": "~1.3.0" + }, + "devDependencies": { + "@types/express": "^4.17.25", + "@types/passport": "~1.0.17", + "eslint": "~9.39.4", + "jest": "~30.2.0", + "typescript": "~5.9.3" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/passport-x/src/APIError.ts b/packages/passport-x/src/APIError.ts new file mode 100644 index 0000000000000..cf1583a647a26 --- /dev/null +++ b/packages/passport-x/src/APIError.ts @@ -0,0 +1,15 @@ +/** + * `APIError` error. + */ +export class APIError extends Error { + public override readonly name: string = 'APIError'; + + public status: number = 500; + + constructor( + message: string, + public readonly code: number, + ) { + super(message); + } +} diff --git a/packages/passport-x/src/Strategy.spec.ts b/packages/passport-x/src/Strategy.spec.ts new file mode 100644 index 0000000000000..63f28979148c8 --- /dev/null +++ b/packages/passport-x/src/Strategy.spec.ts @@ -0,0 +1,99 @@ +import type { Request } from 'express'; + +import { Strategy } from './Strategy'; + +const defaultOptions = { + consumerKey: 'ABC123', + consumerSecret: 'secret', + callbackURL: 'http://www.example.test/callback', +}; + +function noop() { + // verify callback placeholder +} + +describe('Strategy', () => { + describe('constructed', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should be named twitter', () => { + expect(strategy.name).toBe('twitter'); + }); + }); + + describe('constructed with undefined options', () => { + it('should throw', () => { + expect(() => { + // @ts-expect-error testing invalid input + new Strategy(undefined, noop); + }).toThrow(); + }); + }); + + describe('failure caused by user denying request', () => { + it('should call fail()', () => { + const strategy = new Strategy(defaultOptions, noop); + strategy.fail = jest.fn(); + + const req = { query: { denied: '8L74Y149' } } as unknown as Request; + strategy.authenticate(req); + + expect(strategy.fail).toHaveBeenCalled(); + }); + }); + + describe('userAuthorizationParams', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should return empty object when no options', () => { + expect(strategy.userAuthorizationParams({})).toEqual({}); + }); + + it('should map forceLogin to force_login', () => { + const params = strategy.userAuthorizationParams({ forceLogin: true }); + expect(params).toEqual({ force_login: true }); + }); + + it('should map screenName to screen_name', () => { + const params = strategy.userAuthorizationParams({ screenName: 'bob' }); + expect(params).toEqual({ screen_name: 'bob' }); + }); + + it('should map both options', () => { + const params = strategy.userAuthorizationParams({ forceLogin: true, screenName: 'bob' }); + expect(params).toEqual({ force_login: true, screen_name: 'bob' }); + }); + }); + + describe('parseErrorResponse', () => { + const strategy = new Strategy(defaultOptions, noop); + + it('should parse JSON error response', () => { + const body = '{"errors":[{"code":32,"message":"Could not authenticate you."}]}'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Could not authenticate you.'); + }); + + it('should parse XML error response', () => { + const body = + '\n\n This client application\'s callback url has been locked\n /oauth/request_token\n\n'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe("This client application's callback url has been locked"); + }); + + it('should return plain text as error when body is not JSON or XML', () => { + const body = 'Invalid request token.'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Invalid request token.'); + }); + + it('should return undefined for JSON without errors array', () => { + const body = '{"foo":"bar"}'; + const err = strategy.parseErrorResponse(body, 401); + expect(err).toBeUndefined(); + }); + }); +}); diff --git a/packages/passport-x/src/Strategy.ts b/packages/passport-x/src/Strategy.ts new file mode 100644 index 0000000000000..3c59aa39446aa --- /dev/null +++ b/packages/passport-x/src/Strategy.ts @@ -0,0 +1,228 @@ +import { format, parse as urlParse } from 'url'; + +import type { Request } from 'express'; +import OAuthStrategy, { InternalOAuthError, type OAuthClient } from 'passport-oauth1'; + +import { APIError } from './APIError'; +import { parse as parseProfile } from './profile'; +import type { Profile } from './profile'; + +// --------------------------------------------------------------------------- +// Strategy options +// --------------------------------------------------------------------------- + +export interface IStrategyOptions { + consumerKey: string; + consumerSecret: string; + callbackURL: string; + requestTokenURL?: string; + accessTokenURL?: string; + userAuthorizationURL?: string; + sessionKey?: string; + userProfileURL?: string; + skipExtendedUserProfile?: boolean; + includeEmail?: boolean; + includeStatus?: boolean; + includeEntities?: boolean; + forceLogin?: boolean; + screenName?: string; +} + +// --------------------------------------------------------------------------- +// Strategy +// --------------------------------------------------------------------------- + +type DoneCallback = (err: Error | null, profile?: Profile | { provider: string; id: string; username: string }) => void; + +/** + * `Strategy` constructor. + * + * The Twitter/X authentication strategy authenticates requests by delegating to + * Twitter using the OAuth protocol. + * + * Applications must supply a `verify` callback which accepts a `token`, + * `tokenSecret` and service-specific `profile`, and then calls the `cb` + * callback supplying a `user`, which should be set to `false` if the + * credentials are not valid. If an exception occurred, `err` should be set. + * + * Options: + * - `consumerKey` identifies client to Twitter + * - `consumerSecret` secret used to establish ownership of the consumer key + * - `callbackURL` URL to which Twitter will redirect the user after obtaining authorization + * + * Examples: + * + * passport.use(new Strategy({ + * consumerKey: '123-456-789', + * consumerSecret: 'shhh-its-a-secret', + * callbackURL: 'https://www.example.net/auth/twitter/callback' + * }, + * function(token, tokenSecret, profile, cb) { + * User.findOrCreate(..., function (err, user) { + * cb(err, user); + * }); + * } + * )); + */ +export class Strategy { + public name: string; + + private _userProfileURL: string; + + private _skipExtendedUserProfile: boolean; + + private _includeEmail: boolean; + + private _includeStatus: boolean; + + private _includeEntities: boolean; + + // Provided by OAuthStrategy base + declare _oauth: OAuthClient; + + declare fail: (challenge?: unknown, status?: number) => void; + + declare error: (err: Error) => void; + + declare success: (user: unknown, info?: unknown) => void; + + declare redirect: (url: string, status?: number) => void; + + constructor(options: IStrategyOptions, verify: (...args: unknown[]) => void) { + const opts: Record = { + ...options, + requestTokenURL: options.requestTokenURL || 'https://api.twitter.com/oauth/request_token', + accessTokenURL: options.accessTokenURL || 'https://api.twitter.com/oauth/access_token', + userAuthorizationURL: options.userAuthorizationURL || 'https://api.twitter.com/oauth/authenticate', + sessionKey: options.sessionKey || 'oauth:twitter', + }; + + OAuthStrategy.call(this as unknown as OAuthStrategy, opts, verify); + this.name = 'twitter'; + this._userProfileURL = options.userProfileURL || 'https://api.twitter.com/1.1/account/verify_credentials.json'; + this._skipExtendedUserProfile = options.skipExtendedUserProfile !== undefined ? options.skipExtendedUserProfile : false; + this._includeEmail = options.includeEmail !== undefined ? options.includeEmail : false; + this._includeStatus = options.includeStatus !== undefined ? options.includeStatus : true; + this._includeEntities = options.includeEntities !== undefined ? options.includeEntities : true; + } + + /** + * Authenticate request by delegating to Twitter using OAuth. + */ + authenticate(req: Request, options?: Record): void { + if (req.query && (req.query as Record).denied) { + this.fail(); + return; + } + + OAuthStrategy.prototype.authenticate.call(this as unknown as OAuthStrategy, req, options); + } + + /** + * Retrieve user profile from Twitter. + */ + userProfile(token: string, tokenSecret: string, params: Record, done: DoneCallback): void { + if (!this._skipExtendedUserProfile) { + const url = urlParse(this._userProfileURL, true); + + if (url.pathname?.endsWith('/users/show.json')) { + url.query.user_id = params.user_id; + } + if (this._includeEmail) { + url.query.include_email = 'true'; + } + if (!this._includeStatus) { + url.query.skip_status = 'true'; + } + if (!this._includeEntities) { + url.query.include_entities = 'false'; + } + url.search = null; + + this._oauth.get(format(url), token, tokenSecret, (err, body, res) => { + if (err) { + let json: { errors?: { message: string; code: number }[] } | undefined; + const oauthErr = err; + if (oauthErr.data) { + try { + json = JSON.parse(oauthErr.data); + } catch { + // ignore parse error + } + } + + if (json?.errors?.length) { + const e = json.errors[0]; + return done(new APIError(e.message, e.code)); + } + return done(new InternalOAuthError('Failed to fetch user profile', err)); + } + + let json: Record; + try { + json = JSON.parse(body!); + } catch { + return done(new Error('Failed to parse user profile')); + } + + const profile: Profile = { + ...parseProfile(json), + provider: 'twitter', + _raw: body!, + _json: json, + _accessLevel: res?.headers['x-access-level'], + }; + + return done(null, profile); + }); + } else { + done(null, { + provider: 'twitter', + id: params.user_id, + username: params.screen_name, + }); + } + } + + /** + * Return extra Twitter-specific parameters to be included in the user + * authorization request. + */ + userAuthorizationParams(options: Record): Record { + const params: Record = {}; + if (options.forceLogin) { + params.force_login = options.forceLogin; + } + if (options.screenName) { + params.screen_name = options.screenName; + } + return params; + } + + /** + * Parse error response from Twitter OAuth endpoint. + */ + parseErrorResponse(body: string, _status: number): Error | undefined { + try { + const json: unknown = JSON.parse(body); + if (typeof json === 'object' && json !== null && 'errors' in json && Array.isArray(json.errors) && json.errors.length > 0) { + const first: unknown = json.errors[0]; + if (typeof first === 'object' && first !== null && 'message' in first && typeof first.message === 'string') { + return new Error(first.message); + } + } + } catch { + // Not JSON — try XML + const match = /(.*?)<\/error>/.exec(body); + if (match) { + return new Error(match[1]); + } + return new Error(body); + } + + return undefined; + } +} + +// Wire up prototype chain: Strategy extends OAuthStrategy +Object.setPrototypeOf(Strategy.prototype, OAuthStrategy.prototype); diff --git a/packages/passport-x/src/Strategy.userProfile.spec.ts b/packages/passport-x/src/Strategy.userProfile.spec.ts new file mode 100644 index 0000000000000..86d50a91de959 --- /dev/null +++ b/packages/passport-x/src/Strategy.userProfile.spec.ts @@ -0,0 +1,197 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { APIError } from './APIError'; +import { Strategy } from './Strategy'; +import type { Profile } from './profile'; + +function loadFixture(relativePath: string): string { + return readFileSync(join(__dirname, '__fixtures__', relativePath), 'utf8'); +} + +const defaultOptions = { + consumerKey: 'ABC123', + consumerSecret: 'secret', + callbackURL: 'http://www.example.test/callback', +}; + +function noop() { + // verify callback placeholder +} + +function getUserProfile( + strategy: Strategy, + token: string, + tokenSecret: string, + params: Record, +): Promise<{ err: Error | null; profile?: Profile | { provider: string; id: string; username: string } }> { + return new Promise((resolve) => { + strategy.userProfile(token, tokenSecret, params, (err, profile) => { + resolve({ err, profile }); + }); + }); +} + +describe('Strategy#userProfile', () => { + describe('fetched from default endpoint', () => { + const strategy = new Strategy(defaultOptions, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should parse profile', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + const p = profile as Profile; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('38895958'); + expect(p.username).toBe('theSeanCook'); + expect(p.displayName).toBe('Sean Cook'); + expect(typeof p._raw).toBe('string'); + expect(typeof p._json).toBe('object'); + expect(p._accessLevel).toBe('read'); + }); + }); + + describe('fetched from default endpoint, with email included', () => { + const strategy = new Strategy({ ...defaultOptions, includeEmail: true }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('include_email=true'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include email param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from default endpoint, with status excluded', () => { + const strategy = new Strategy({ ...defaultOptions, includeStatus: false }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('skip_status=true'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include skip_status param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from default endpoint, with entities excluded', () => { + const strategy = new Strategy({ ...defaultOptions, includeEntities: false }, noop); + const fixtureBody = loadFixture('account/theSeanCook.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('include_entities=false'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should include include_entities param in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + }); + }); + + describe('fetched from legacy users/show endpoint', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + const fixtureBody = loadFixture('users/rsarver.json'); + + strategy._oauth.get = (url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + expect(url).toContain('user_id=6253282'); + callback(null, fixtureBody, { headers: { 'x-access-level': 'read' } }); + }; + + it('should parse profile and include user_id in URL', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '6253282' }); + expect(err).toBeNull(); + const p = profile as Profile; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('795649'); + expect(p.username).toBe('rsarver'); + expect(p.displayName).toBe('Ryan Sarver'); + expect(p._accessLevel).toBe('read'); + }); + }); + + describe('skipping extended profile', () => { + const strategy = new Strategy({ ...defaultOptions, skipExtendedUserProfile: true }, noop); + + strategy._oauth.get = () => { + throw new Error('should not fetch profile'); + }; + + it('should return minimal profile from params', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { + user_id: '1705', + screen_name: 'jaredhanson', + }); + expect(err).toBeNull(); + expect(profile).toBeDefined(); + const p = profile as { provider: string; id: string; username: string }; + expect(p.provider).toBe('twitter'); + expect(p.id).toBe('1705'); + expect(p.username).toBe('jaredhanson'); + }); + }); + + describe('error caused by invalid token', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + const body = '{"errors":[{"message":"Invalid or expired token","code":89}]}'; + callback({ statusCode: 401, data: body }); + }; + + it('should return APIError', async () => { + const { err, profile } = await getUserProfile(strategy, 'x-token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(APIError); + expect(err?.message).toBe('Invalid or expired token'); + expect((err as APIError).code).toBe(89); + expect((err as APIError).status).toBe(500); + expect(profile).toBeUndefined(); + }); + }); + + describe('error caused by malformed response', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(null, 'Hello, world.', undefined); + }; + + it('should return parse error', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toBe('Failed to parse user profile'); + expect(profile).toBeUndefined(); + }); + }); + + describe('internal error', () => { + const strategy = new Strategy({ ...defaultOptions, userProfileURL: 'https://api.twitter.com/1.1/users/show.json' }, noop); + + strategy._oauth.get = (_url: string, _token: string, _tokenSecret: string, callback: (...args: unknown[]) => void) => { + callback(new Error('something went wrong')); + }; + + it('should return InternalOAuthError', async () => { + const { err, profile } = await getUserProfile(strategy, 'token', 'token-secret', { user_id: '123' }); + expect(err).toBeInstanceOf(Error); + expect(err?.constructor.name).toBe('InternalOAuthError'); + expect(err?.message).toBe('Failed to fetch user profile'); + expect(profile).toBeUndefined(); + }); + }); +}); diff --git a/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json b/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json new file mode 100644 index 0000000000000..66ec3b3e85d92 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/account/theSeanCook-include_email.json @@ -0,0 +1,8 @@ +{ + "id": 38895958, + "id_str": "38895958", + "name": "Sean Cook", + "screen_name": "theSeanCook", + "email": "theSeanCook@example.test", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG" +} diff --git a/packages/passport-x/src/__fixtures__/account/theSeanCook.json b/packages/passport-x/src/__fixtures__/account/theSeanCook.json new file mode 100644 index 0000000000000..f78c91bdfdbb3 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/account/theSeanCook.json @@ -0,0 +1,7 @@ +{ + "id": 38895958, + "id_str": "38895958", + "name": "Sean Cook", + "screen_name": "theSeanCook", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG" +} diff --git a/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json b/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json new file mode 100644 index 0000000000000..ae79194a0f0f0 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/users/rsarver-without-id_str.json @@ -0,0 +1,6 @@ +{ + "id": 795649, + "name": "Ryan Sarver", + "screen_name": "rsarver", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png" +} diff --git a/packages/passport-x/src/__fixtures__/users/rsarver.json b/packages/passport-x/src/__fixtures__/users/rsarver.json new file mode 100644 index 0000000000000..7fede20e276d7 --- /dev/null +++ b/packages/passport-x/src/__fixtures__/users/rsarver.json @@ -0,0 +1,7 @@ +{ + "id": 795649, + "id_str": "795649", + "name": "Ryan Sarver", + "screen_name": "rsarver", + "profile_image_url_https": "https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png" +} diff --git a/packages/passport-x/src/index.ts b/packages/passport-x/src/index.ts new file mode 100644 index 0000000000000..6d2e6af0ab60d --- /dev/null +++ b/packages/passport-x/src/index.ts @@ -0,0 +1,3 @@ +export { Strategy } from './Strategy'; +export type { IStrategyOptions as StrategyOptions } from './Strategy'; +export type { Profile } from './profile'; diff --git a/packages/passport-x/src/passport-oauth1.d.ts b/packages/passport-x/src/passport-oauth1.d.ts new file mode 100644 index 0000000000000..9953d434db26e --- /dev/null +++ b/packages/passport-x/src/passport-oauth1.d.ts @@ -0,0 +1,41 @@ +declare module 'passport-oauth1' { + import type { Request } from 'express'; + + interface OAuthGetCallback { + (err: OAuthError | null, body?: string, res?: { headers: Record }): void; + } + + interface OAuthError { + statusCode?: number; + data?: string; + } + + interface OAuthClient { + get(url: string, token: string, tokenSecret: string, callback: OAuthGetCallback): void; + } + + class OAuthStrategy { + name: string; + + _oauth: OAuthClient; + + constructor(options: Record, verify: (...args: unknown[]) => void); + + authenticate(req: Request, options?: Record): void; + + fail(challenge?: unknown, status?: number): void; + + error(err: Error): void; + + success(user: unknown, info?: unknown): void; + + redirect(url: string, status?: number): void; + } + + class InternalOAuthError extends Error { + constructor(message: string, err: unknown); + } + + export = OAuthStrategy; + export { InternalOAuthError, OAuthClient, OAuthError, OAuthGetCallback }; +} diff --git a/packages/passport-x/src/profile.spec.ts b/packages/passport-x/src/profile.spec.ts new file mode 100644 index 0000000000000..81808d0b1c8d7 --- /dev/null +++ b/packages/passport-x/src/profile.spec.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { parse } from './profile'; + +function loadFixture(relativePath: string): string { + return readFileSync(join(__dirname, '__fixtures__', relativePath), 'utf8'); +} + +describe('Profile.parse', () => { + describe('theSeanCook profile', () => { + const profile = parse(loadFixture('account/theSeanCook.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('38895958'); + expect(profile.username).toBe('theSeanCook'); + expect(profile.displayName).toBe('Sean Cook'); + expect(profile.emails).toBeUndefined(); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG'); + }); + }); + + describe('theSeanCook profile with email', () => { + const profile = parse(loadFixture('account/theSeanCook-include_email.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('38895958'); + expect(profile.username).toBe('theSeanCook'); + expect(profile.displayName).toBe('Sean Cook'); + expect(profile.emails).toHaveLength(1); + expect(profile.emails![0].value).toBe('theSeanCook@example.test'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1751506047/dead_sexy_normal.JPG'); + }); + }); + + describe('rsarver profile', () => { + const profile = parse(loadFixture('users/rsarver.json')); + + it('should parse profile', () => { + expect(profile.id).toBe('795649'); + expect(profile.username).toBe('rsarver'); + expect(profile.displayName).toBe('Ryan Sarver'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png'); + }); + }); + + describe('rsarver profile without id_str', () => { + const profile = parse(loadFixture('users/rsarver-without-id_str.json')); + + it('should parse profile using numeric id', () => { + expect(profile.id).toBe('795649'); + expect(profile.username).toBe('rsarver'); + expect(profile.displayName).toBe('Ryan Sarver'); + expect(profile.photos[0].value).toBe('https://si0.twimg.com/profile_images/1777569006/image1327396628_normal.png'); + }); + }); +}); diff --git a/packages/passport-x/src/profile.ts b/packages/passport-x/src/profile.ts new file mode 100644 index 0000000000000..1b9d1dc7509ac --- /dev/null +++ b/packages/passport-x/src/profile.ts @@ -0,0 +1,28 @@ +export type Profile = { + id: string; + username: string; + displayName: string; + emails?: { value: string }[]; + photos: { value: string }[]; + provider?: string; + _raw?: string; + _json?: Record; + _accessLevel?: string; +}; + +export function parse(json: string | Record): Profile { + const data: Record = typeof json === 'string' ? JSON.parse(json) : json; + + const profile: Profile = { + id: data.id_str ? String(data.id_str) : String(data.id), + username: data.screen_name as string, + displayName: data.name as string, + photos: [{ value: data.profile_image_url_https as string }], + }; + + if (data.email) { + profile.emails = [{ value: data.email as string }]; + } + + return profile; +} diff --git a/packages/passport-x/tsconfig.json b/packages/passport-x/tsconfig.json new file mode 100644 index 0000000000000..cb95b38fb6568 --- /dev/null +++ b/packages/passport-x/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 1365fc5408011..0f4255d277332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9950,6 +9950,7 @@ __metadata: "@rocket.chat/omni-core-ee": "workspace:^" "@rocket.chat/omnichannel-services": "workspace:^" "@rocket.chat/onboarding-ui": "npm:^0.36.2" + "@rocket.chat/passport-x": "workspace:~" "@rocket.chat/password-policies": "workspace:^" "@rocket.chat/patch-injection": "workspace:^" "@rocket.chat/pdf-worker": "workspace:^" @@ -10044,7 +10045,6 @@ __metadata: "@types/passport-github2": "npm:^1.2.9" "@types/passport-google-oauth20": "npm:^2" "@types/passport-oauth2": "npm:^1" - "@types/passport-twitter": "npm:^1" "@types/prometheus-gc-stats": "npm:^0.6.4" "@types/proxy-from-env": "npm:^1.0.4" "@types/proxyquire": "npm:^1.3.31" @@ -10190,8 +10190,8 @@ __metadata: passport-facebook: "npm:^3.0.0" passport-github2: "npm:^0.1.12" passport-google-oauth20: "npm:^2.0.0" + passport-oauth1: "npm:~1.3.0" passport-oauth2: "npm:^1.8.0" - passport-twitter: "npm:^1.0.4" path: "npm:^0.12.7" path-to-regexp: "npm:^6.3.0" pino: "npm:10.3.1" @@ -10524,6 +10524,19 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/passport-x@workspace:packages/passport-x, @rocket.chat/passport-x@workspace:~": + version: 0.0.0-use.local + resolution: "@rocket.chat/passport-x@workspace:packages/passport-x" + dependencies: + "@types/express": "npm:^4.17.25" + "@types/passport": "npm:~1.0.17" + eslint: "npm:~9.39.4" + jest: "npm:~30.2.0" + passport-oauth1: "npm:~1.3.0" + typescript: "npm:~5.9.3" + languageName: unknown + linkType: soft + "@rocket.chat/password-policies@workspace:^, @rocket.chat/password-policies@workspace:packages/password-policies": version: 0.0.0-use.local resolution: "@rocket.chat/password-policies@workspace:packages/password-policies" @@ -14985,17 +14998,7 @@ __metadata: languageName: node linkType: hard -"@types/passport-twitter@npm:^1": - version: 1.0.40 - resolution: "@types/passport-twitter@npm:1.0.40" - dependencies: - "@types/express": "npm:*" - "@types/passport": "npm:*" - checksum: 10/cf97db9469acff4dffff11ecf4e12ac016fc55d478e2fbd881c7961fe36f26ba232a55f74fdd0bd0216ea49d3a9dcd4e18afac852b6bb10450f43413e4a464b7 - languageName: node - linkType: hard - -"@types/passport@npm:*, @types/passport@npm:^1.0.17": +"@types/passport@npm:*, @types/passport@npm:^1.0.17, @types/passport@npm:~1.0.17": version: 1.0.17 resolution: "@types/passport@npm:1.0.17" dependencies: @@ -30349,7 +30352,7 @@ __metadata: languageName: node linkType: hard -"passport-oauth1@npm:1.x.x": +"passport-oauth1@npm:~1.3.0": version: 1.3.0 resolution: "passport-oauth1@npm:1.3.0" dependencies: @@ -30380,16 +30383,6 @@ __metadata: languageName: node linkType: hard -"passport-twitter@npm:^1.0.4": - version: 1.0.4 - resolution: "passport-twitter@npm:1.0.4" - dependencies: - passport-oauth1: "npm:1.x.x" - xtraverse: "npm:0.1.x" - checksum: 10/46c2efcbd8893c2de329770f16703b027531e2becfc8865dd288290635dcc9a678bbcec82a66ad52e49742bccd77ff4fc49f576fef2d711f49363e1b75ac1e80 - languageName: node - linkType: hard - "passport@npm:^0.7.0": version: 0.7.0 resolution: "passport@npm:0.7.0" @@ -38230,13 +38223,6 @@ __metadata: languageName: node linkType: hard -"xmldom@npm:0.1.x": - version: 0.1.31 - resolution: "xmldom@npm:0.1.31" - checksum: 10/eddc09391c928be61c504a355d5b7c42cbec526e94cc1e2d092d006ed5bc1525b869fb922cbcf3f6f7aac946a6b25ec4243dc2630afb5cbec61b723829211792 - languageName: node - linkType: hard - "xorshift@npm:^1.1.1": version: 1.2.0 resolution: "xorshift@npm:1.2.0" @@ -38272,15 +38258,6 @@ __metadata: languageName: node linkType: hard -"xtraverse@npm:0.1.x": - version: 0.1.0 - resolution: "xtraverse@npm:0.1.0" - dependencies: - xmldom: "npm:0.1.x" - checksum: 10/86e837a626ef363c01efa55a3e20b1d85b49d87f3de504955af119b451cb1f2c63e887bff6061335314c1d49005d851f4bc1d0cf2603dce18271aceb3e673d8a - languageName: node - linkType: hard - "xxhashjs@npm:~0.2.2": version: 0.2.2 resolution: "xxhashjs@npm:0.2.2"