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

chore: add support for oauth in public apis #1050

Merged
merged 15 commits into from
Dec 9, 2024
Merged
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
11 changes: 11 additions & 0 deletions src/auth_strategy/AuthStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default abstract class AuthStrategy {
private authType: string;
protected constructor(authType: string) {
this.authType = authType;
}
getAuthType(): string {
return this.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.message}`);
});
}

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
12 changes: 10 additions & 2 deletions src/base/RequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import qs from "qs";
import * as https from "https";
import Response from "../http/response";
import Request, {
RequestOptions as LastRequestOptions,
Headers,
RequestOptions as LastRequestOptions,
} from "../http/request";
import AuthStrategy from "../auth_strategy/AuthStrategy";

const DEFAULT_CONTENT_TYPE = "application/x-www-form-urlencoded";
const DEFAULT_TIMEOUT = 30000;
Expand Down Expand Up @@ -149,6 +150,7 @@ class RequestClient {
* @param opts.uri - The request uri
* @param opts.username - The username used for auth
* @param opts.password - The password used for auth
* @param opts.authStrategy - The authStrategy for API call
* @param opts.headers - The request headers
* @param opts.params - The request params
* @param opts.data - The request data
Expand All @@ -157,7 +159,7 @@ class RequestClient {
* @param opts.forever - Set to true to use the forever-agent
* @param opts.logLevel - Show debug logs
*/
request<TData>(
async request<TData>(
opts: RequestClient.RequestOptions<TData>
): Promise<Response<TData>> {
if (!opts.method) {
Expand All @@ -180,6 +182,8 @@ class RequestClient {
"base64"
);
headers.Authorization = "Basic " + auth;
} else if (opts.authStrategy) {
headers.Authorization = await opts.authStrategy.getAuthString();
}

const options: AxiosRequestConfig = {
Expand Down Expand Up @@ -296,6 +300,10 @@ namespace RequestClient {
* The password used for auth
*/
password?: string;
/**
* The AuthStrategy for API Call
*/
authStrategy?: AuthStrategy;
/**
* The request headers
*/
Expand Down
66 changes: 66 additions & 0 deletions src/credential_provider/ClientCredentialProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import CredentialProvider from "./CredentialProvider";
import TokenManager from "../http/bearer_token/TokenManager";
import AuthStrategy from "../auth_strategy/AuthStrategy";
import ApiTokenManager from "../http/bearer_token/ApiTokenManager";
import TokenAuthStrategy from "../auth_strategy/TokenAuthStrategy";

class ClientCredentialProvider extends CredentialProvider {
grantType: string;
clientId: string;
clientSecret: string;
tokenManager: TokenManager | null;

constructor() {
super("client-credentials");
this.grantType = "client_credentials";
this.clientId = "";
this.clientSecret = "";
this.tokenManager = null;
}

public toAuthStrategy(): AuthStrategy {
if (this.tokenManager == null) {
this.tokenManager = new ApiTokenManager({
grantType: this.grantType,
clientId: this.clientId,
clientSecret: this.clientSecret,
});
}
return new TokenAuthStrategy(this.tokenManager);
}
}

namespace ClientCredentialProvider {
export class ClientCredentialProviderBuilder {
private readonly instance: ClientCredentialProvider;

constructor() {
this.instance = new ClientCredentialProvider();
}

public setClientId(clientId: string): ClientCredentialProviderBuilder {
this.instance.clientId = clientId;
return this;
}

public setClientSecret(
clientSecret: string
): ClientCredentialProviderBuilder {
this.instance.clientSecret = clientSecret;
return this;
}

public setTokenManager(
tokenManager: TokenManager
): ClientCredentialProviderBuilder {
this.instance.tokenManager = tokenManager;
return this;
}

public build(): ClientCredentialProvider {
return this.instance;
}
}
}

export = ClientCredentialProvider;
Loading
Loading