snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# @rocket.chat/passport-x
## 0.1.0
### Minor Changes
- ([#40721](https://github.com/RocketChat/Rocket.Chat/pull/40721)) ## Phishing-Resistant Multi-Factor Authentication
Introduces a more secure and reliable server-side OAuth authentication flow.
### What’s New
- **Improved OAuth login security**
OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception.
- **Built-in CSRF, state validation, and PKCE protection**
OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support.
- **Improved two-step verification with OAuth logins**
Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others.
- **Improved mobile & desktop app login**
Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow.
- **A new setting to enable/disable new OAuth Flow**
Enable this new setting `Accounts_OAuth_Use_Modern_Flow` to use all of the above mentioned features.
## 0.1.0-rc.0
### Minor Changes
- ([#40721](https://github.com/RocketChat/Rocket.Chat/pull/40721)) ## Phishing-Resistant Multi-Factor Authentication
Introduces a more secure and reliable server-side OAuth authentication flow.
### What’s New
- **Improved OAuth login security**
OAuth authentication now happens fully on the server, reducing the risk of token theft, phishing attacks, and client-side credential interception.
- **Built-in CSRF, state validation, and PKCE protection**
OAuth logins now include stronger protection against CSRF attacks, request tampering, and authorization code interception through secure state validation and PKCE support.
- **Improved two-step verification with OAuth logins**
Users with email or TOTP two-factor authentication enabled will now be asked to complete 2FA even when signing in with providers like Google, GitHub, GitLab, and others.
- **Improved mobile & desktop app login**
Mobile and desktop apps now support a smoother and more secure deep-link OAuth login flow.
- **A new setting to enable/disable new OAuth Flow**
Enable this new setting `Accounts_OAuth_Use_Modern_Flow` to use all of the above mentioned features.

View File

@@ -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: ['<rootDir>/src/**/*.spec.[jt]s?(x)'],
},
],
} satisfies Config;

View File

@@ -0,0 +1,31 @@
{
"name": "@rocket.chat/passport-x",
"version": "0.1.0",
"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.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -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);
}
}

View File

@@ -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 =
'<?xml version="1.0" encoding="UTF-8"?>\n<hash>\n <error>This client application\'s callback url has been locked</error>\n <request>/oauth/request_token</request>\n</hash>\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();
});
});
});

View File

@@ -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<string, unknown> = {
...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<string, unknown>): void {
if (req.query && (req.query as Record<string, unknown>).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<string, string>, 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<string, unknown>;
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<string, unknown>): Record<string, unknown> {
const params: Record<string, unknown> = {};
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>(.*?)<\/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);

View File

@@ -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<string, string>,
): 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();
});
});
});

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,3 @@
export { Strategy } from './Strategy';
export type { IStrategyOptions as StrategyOptions } from './Strategy';
export type { Profile } from './profile';

View File

@@ -0,0 +1,45 @@
declare module 'passport-oauth1' {
import type { Request } from 'express';
class OAuthStrategy {
name: string;
_oauth: OAuthStrategy.OAuthClient;
constructor(options: Record<string, unknown>, verify: (...args: unknown[]) => void);
authenticate(req: Request, options?: Record<string, unknown>): void;
fail(challenge?: unknown, status?: number): void;
error(err: Error): void;
success(user: unknown, info?: unknown): void;
redirect(url: string, status?: number): void;
}
// Merged namespace so the CommonJS `module.exports = Strategy` default and the
// `exports.InternalOAuthError` named export coexist under a single `export =`.
// TS7 rejects mixing `export =` with `export { ... }`.
namespace OAuthStrategy {
interface OAuthGetCallback {
(err: OAuthError | null, body?: string, res?: { headers: Record<string, string> }): void;
}
interface OAuthError {
statusCode?: number;
data?: string;
}
interface OAuthClient {
get(url: string, token: string, tokenSecret: string, callback: OAuthGetCallback): void;
}
class InternalOAuthError extends Error {
constructor(message: string, err: unknown);
}
}
export = OAuthStrategy;
}

View File

@@ -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');
});
});
});

View File

@@ -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<string, unknown>;
_accessLevel?: string;
};
export function parse(json: string | Record<string, unknown>): Profile {
const data: Record<string, unknown> = 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;
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"]
}