Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement oauth for public apis #1048

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
twilio-node changelog
=====================

[2024-11-15] Version 5.3.6
--------------------------
**Library - Chore**
- [PR #1040](https://github.com/twilio/twilio-node/pull/1040): pass http agent options to client. Thanks to [@tiwarishubham635](https://github.com/tiwarishubham635)!
- [PR #1041](https://github.com/twilio/twilio-node/pull/1041): remove preview sync api. Thanks to [@sbansla](https://github.com/sbansla)!

**Api**
- Added `ivr-virtual-agent-custom-voices` and `ivr-virtual-agent-genai` to `usage_record` API.
- Add open-api file tag to realtime_transcriptions

**Taskrouter**
- Add `api-tag` property to workers reservation
- Add `api-tag` property to task reservation


[2024-10-24] Version 5.3.5
--------------------------
**Conversations**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "twilio",
"description": "A Twilio helper library",
"version": "5.3.5",
"version": "5.3.6",
"author": "API Team <[email protected]>",
"contributors": [
{
Expand Down
89 changes: 89 additions & 0 deletions spec/cluster/orgs_api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
jest.setTimeout(15000);

import twilio from "twilio";

const clientId = process.env.TWILIO_ORGS_CLIENT_ID;
const clientSecret = process.env.TWILIO_ORGS_CLIENT_SECRET;
const organizationSid = process.env.TWILIO_ORG_SID;
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const userId = process.env.TWILIO_ORGS_USER_ID;

const client = twilio();
const clientCredentialProvider = new twilio.ClientCredentialProviderBuilder()
.setClientId(clientId)
.setClientSecret(clientSecret)
.build();
client.setCredentialProvider(clientCredentialProvider);

test("Should generate access token", () => {
const noAuthClient = twilio();
noAuthClient.setCredentialProvider(new twilio.NoAuthCredentialProvider());
return noAuthClient.previewIam.v1.token
.create({
grantType: "client_credentials",
clientId: clientId,
clientSecret: clientSecret,
})
.then((token) => {
expect(token).not.toBeNull();
expect(token.accessToken).not.toBeUndefined();
expect(token.tokenType).toEqual("Bearer");
expect(token.expiresIn).toEqual(86400);
});
});

test("Should list accounts under an organization", () => {
return client.previewIam
.organization(organizationSid)
.accounts.list()
.then((accounts) => {
expect(accounts).not.toBeNull();
expect(accounts).not.toBeUndefined();
expect(accounts.length).toBeGreaterThanOrEqual(0);
});
});

test("Should fetch given account", () => {
return client.previewIam
.organization(organizationSid)
.accounts(accountSid)
.fetch()
.then((account) => {
expect(account).not.toBeNull();
expect(account).not.toBeUndefined();
expect(account.accountSid).toEqual(accountSid);
});
});

test("Should list users", () => {
return client.previewIam
.organization(organizationSid)
.users.list()
.then((users) => {
expect(users).not.toBeNull();
expect(users).not.toBeUndefined();
expect(users.length).toBeGreaterThanOrEqual(0);
});
});

test("Should fetch given user", () => {
return client.previewIam
.organization(organizationSid)
.users(userId)
.fetch()
.then((user) => {
expect(user).not.toBeNull();
expect(user).not.toBeUndefined();
expect(user.id).toEqual(userId);
});
});

test("Should list role assignments", () => {
client.previewIam
.organization(organizationSid)
.roleAssignments.list({ scope: accountSid })
.then((roles) => {
expect(roles).not.toBeNull();
expect(roles.length).toBeGreaterThanOrEqual(0);
});
});
8 changes: 8 additions & 0 deletions src/auth_strategy/AuthStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default abstract class AuthStrategy {
private authType: string;
protected constructor(authType: string) {
this.authType = authType;
}
abstract getAuthString(): Promise<string>;
abstract requiresAuthentication(): boolean;
}
23 changes: 23 additions & 0 deletions src/auth_strategy/BasicAuthStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import AuthStrategy from "./AuthStrategy";

export default class BasicAuthStrategy extends AuthStrategy {
private username: string;
private password: string;

constructor(username: string, password: string) {
super("basic");
this.username = username;
this.password = password;
}

getAuthString(): Promise<string> {
const auth = Buffer.from(this.username + ":" + this.password).toString(
"base64"
);
return Promise.resolve(`Basic ${auth}`);
}

requiresAuthentication(): boolean {
return true;
}
}
15 changes: 15 additions & 0 deletions src/auth_strategy/NoAuthStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import AuthStrategy from "./AuthStrategy";

export default class NoAuthStrategy extends AuthStrategy {
constructor() {
super("noauth");
}

getAuthString(): Promise<string> {
return Promise.resolve("");
}

requiresAuthentication(): boolean {
return false;
}
}
67 changes: 67 additions & 0 deletions src/auth_strategy/TokenAuthStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import AuthStrategy from "./AuthStrategy";
import TokenManager from "../http/bearer_token/TokenManager";
import jwt, { JwtPayload } from "jsonwebtoken";

export default class TokenAuthStrategy extends AuthStrategy {
private token: string;
private tokenManager: TokenManager;

constructor(tokenManager: TokenManager) {
super("token");
this.token = "";
this.tokenManager = tokenManager;
}

async getAuthString(): Promise<string> {
return this.fetchToken()
.then((token) => {
this.token = token;
return `Bearer ${this.token}`;
})
.catch((error) => {
throw new Error(`Failed to fetch access token: ${error}`);
});
}

requiresAuthentication(): boolean {
return true;
}

async fetchToken(): Promise<string> {
if (
this.token == null ||
this.token.length === 0 ||
this.isTokenExpired(this.token)
) {
return this.tokenManager.fetchToken();
}
return Promise.resolve(this.token);
}

/**
* Function to check if the token is expired with a buffer of 30 seconds.
* @param token - The JWT token as a string.
* @returns Boolean indicating if the token is expired.
*/
isTokenExpired(token: string): boolean {
try {
// Decode the token without verifying the signature, as we only want to read the expiration for this check
const decoded = jwt.decode(token) as JwtPayload;

if (!decoded || !decoded.exp) {
// If the token doesn't have an expiration, consider it expired
return true;
}

const expiresAt = decoded.exp * 1000;
const bufferMilliseconds = 30 * 1000;
const bufferExpiresAt = expiresAt - bufferMilliseconds;

// Return true if the current time is after the expiration time with buffer
return Date.now() > bufferExpiresAt;
} catch (error) {
// If there's an error decoding the token, consider it expired
return true;
}
}
}
76 changes: 58 additions & 18 deletions src/base/BaseTwilio.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import RequestClient from "./RequestClient"; /* jshint ignore:line */
import { HttpMethod } from "../interfaces"; /* jshint ignore:line */
import { Headers } from "../http/request"; /* jshint ignore:line */
import AuthStrategy from "../auth_strategy/AuthStrategy"; /* jshint ignore:line */
import CredentialProvider from "../credential_provider/CredentialProvider"; /* jshint ignore:line */

const os = require("os"); /* jshint ignore:line */
const url = require("url"); /* jshint ignore:line */
Expand Down Expand Up @@ -40,6 +42,7 @@ namespace Twilio {
uri?: string;
username?: string;
password?: string;
authStrategy?: AuthStrategy;
headers?: Headers;
params?: object;
data?: object;
Expand All @@ -56,9 +59,10 @@ namespace Twilio {
/* jshint ignore:end */

export class Client {
username: string;
password: string;
username?: string;
password?: string;
accountSid: string;
credentialProvider?: CredentialProvider;
opts?: ClientOpts;
env?: NodeJS.ProcessEnv;
edge?: string;
Expand Down Expand Up @@ -101,23 +105,23 @@ namespace Twilio {
/* jshint ignore:end */

constructor(username?: string, password?: string, opts?: ClientOpts) {
this.opts = opts || {};
this.env = this.opts.env || {};
this.setOpts(opts);
this.username =
username ??
this.env.TWILIO_ACCOUNT_SID ??
process.env.TWILIO_ACCOUNT_SID ??
(() => {
throw new Error("username is required");
})();
this.env?.TWILIO_ACCOUNT_SID ??
process.env.TWILIO_ACCOUNT_SID;
this.password =
password ??
this.env.TWILIO_AUTH_TOKEN ??
process.env.TWILIO_AUTH_TOKEN ??
(() => {
throw new Error("password is required");
})();
this.accountSid = this.opts.accountSid || this.username;
this.env?.TWILIO_AUTH_TOKEN ??
process.env.TWILIO_AUTH_TOKEN;
this.accountSid = "";
this.setAccountSid(this.opts?.accountSid || this.username);
this.invalidateOAuth();
}

setOpts(opts?: ClientOpts) {
this.opts = opts || {};
this.env = this.opts.env || {};
this.edge =
this.opts.edge ?? this.env.TWILIO_EDGE ?? process.env.TWILIO_EDGE;
this.region =
Expand All @@ -144,16 +148,35 @@ namespace Twilio {
if (this.opts.lazyLoading === false) {
this._httpClient = this.httpClient;
}
}

if (!this.accountSid.startsWith("AC")) {
const apiKeyMsg = this.accountSid.startsWith("SK")
setAccountSid(accountSid?: string) {
this.accountSid = accountSid || "";

if (this.accountSid && !this.accountSid?.startsWith("AC")) {
const apiKeyMsg = this.accountSid?.startsWith("SK")
? ". The given SID indicates an API Key which requires the accountSid to be passed as an additional option"
: "";

throw new Error("accountSid must start with AC" + apiKeyMsg);
}
}

setCredentialProvider(credentialProvider: CredentialProvider) {
this.credentialProvider = credentialProvider;
this.accountSid = "";
this.invalidateBasicAuth();
}

invalidateBasicAuth() {
this.username = undefined;
this.password = undefined;
}

invalidateOAuth() {
this.credentialProvider = undefined;
}

get httpClient() {
if (!this._httpClient) {
this._httpClient = new RequestClient({
Expand Down Expand Up @@ -196,6 +219,22 @@ namespace Twilio {

const username = opts.username || this.username;
const password = opts.password || this.password;
const authStrategy =
opts.authStrategy || this.credentialProvider?.toAuthStrategy();

if (!authStrategy) {
if (!username) {
(() => {
throw new Error("username is required");
})();
}

if (!password) {
(() => {
throw new Error("password is required");
})();
}
}

const headers = opts.headers || {};

Expand Down Expand Up @@ -223,7 +262,7 @@ namespace Twilio {
headers["Content-Type"] = "application/x-www-form-urlencoded";
}

if (!headers["Accept"]) {
if (opts.method !== "delete" && !headers["Accept"]) {
headers["Accept"] = "application/json";
}

Expand All @@ -235,6 +274,7 @@ namespace Twilio {
uri: uri.href,
username: username,
password: password,
authStrategy: authStrategy,
headers: headers,
params: opts.params,
data: opts.data,
Expand Down
2 changes: 2 additions & 0 deletions src/base/Page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ export default class Page<
if (keys.length === 1) {
return payload[keys[0]];
}
for (const key of keys)
if (Array.isArray(payload[key])) return payload[key];

throw new Error("Page Records cannot be deserialized");
}
Expand Down
Loading
Loading