Skip to content

Commit

Permalink
feat!: removes deprecated code from Passage class (#209)
Browse files Browse the repository at this point in the history
* feat!: removes deprecated code from Passage class

* feat!: removes deprecated code from User class

* feat!: removes deprecated models

* feat!: removes unused or deprecated types and utils

* feat!: removes public PassageError constructor and swaps some PassageErrors to std Error

* feat: adds readonly modifiers to User class fields and moves TokensApi object initialization to constructor

* feat: removes auth origin comparison from jwt audience validation (#214)

feat: changes jwt validation to only check app id in audience using the jwt libs validation options

* feat: removes redundant error message prefixes (#213)

* feat!: return void instead of boolean (#212)

* refactor: set configs access modifiers in base class instead of inheritied classes

* chore: remove exporting generated types
  • Loading branch information
ctran88 authored Dec 31, 2024
1 parent 7f0dfeb commit ba42c26
Show file tree
Hide file tree
Showing 21 changed files with 118 additions and 714 deletions.
40 changes: 14 additions & 26 deletions src/classes/Auth/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
KeyLike,
} from 'jose';
import { PassageBase, PassageInstanceConfig } from '../PassageBase';
import { PassageError } from '../PassageError';
import { MagicLink, MagicLinkChannel, MagicLinksApi } from '../../generated';
import { CreateMagicLinkArgs, MagicLinkOptions } from './types';

Expand All @@ -17,11 +16,12 @@ import { CreateMagicLinkArgs, MagicLinkOptions } from './types';
export class Auth extends PassageBase {
private readonly jwks: (protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput) => Promise<KeyLike>;
private readonly magicLinksApi: MagicLinksApi;

/**
* Auth class constructor.
* @param {PassageInstanceConfig} config config properties for Passage instance
*/
public constructor(protected config: PassageInstanceConfig) {
public constructor(config: PassageInstanceConfig) {
super(config);
this.jwks = createRemoteJWKSet(
new URL(`https://auth.passage.id/v1/apps/${this.config.appId}/.well-known/jwks.json`),
Expand All @@ -44,32 +44,20 @@ export class Auth extends PassageBase {
throw new Error('jwt is required.');
}

try {
const { kid } = decodeProtectedHeader(jwt);
if (!kid) {
throw new PassageError('Could not find valid cookie for authentication. You must catch this error.');
}
const { kid } = decodeProtectedHeader(jwt);
if (!kid) {
throw new Error('kid missing in jwt header.');
}

const {
payload: { sub: userId, aud },
} = await jwtVerify(jwt, this.jwks);
const {
payload: { sub: userId },
} = await jwtVerify(jwt, this.jwks, { audience: [this.config.appId] });

if (!userId) {
throw new PassageError('Could not validate auth token. You must catch this error.');
}
if (Array.isArray(aud)) {
if (!aud.includes(this.config.appId)) {
throw new Error('Incorrect app ID claim in token. You must catch this error.');
}
}
return userId;
} catch (e) {
if (e instanceof Error) {
throw new PassageError(`Could not verify token: ${e.toString()}. You must catch this error.`);
}

throw new PassageError(`Could not verify token. You must catch this error.`);
if (!userId) {
throw new Error('sub missing in jwt claims.');
}

return userId;
}

/**
Expand Down Expand Up @@ -106,7 +94,7 @@ export class Auth extends PassageBase {

return response.magicLink;
} catch (err) {
throw await this.parseError(err, 'Could not create a magic link for this app');
throw await this.parseError(err);
}
}
}
10 changes: 5 additions & 5 deletions src/classes/Passage/Passage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Passage } from './Passage';
import { PassageConfig } from '../../types/PassageConfig';
import { PassageError } from '../PassageError';
import { PassageConfig } from './types';

describe('Passage Class Constructor', () => {
it('should throw an error if appID is not provided', () => {
Expand All @@ -9,7 +8,7 @@ describe('Passage Class Constructor', () => {
apiKey: 'test_api_key',
};

expect(() => new Passage(config)).toThrow(PassageError);
expect(() => new Passage(config)).toThrow(Error);
expect(() => new Passage(config)).toThrow(
'A Passage appID is required. Please include {appID: YOUR_APP_ID, apiKey: YOUR_API_KEY}.',
);
Expand All @@ -21,7 +20,7 @@ describe('Passage Class Constructor', () => {
apiKey: '',
};

expect(() => new Passage(config)).toThrow(PassageError);
expect(() => new Passage(config)).toThrow(Error);
expect(() => new Passage(config)).toThrow(
'A Passage API Key is required. Please include {appID: YOUR_APP_ID, apiKey: YOUR_API_KEY}.',
);
Expand Down Expand Up @@ -53,6 +52,7 @@ describe('Passage Class Constructor', () => {
expect(passage).toBeInstanceOf(Passage);
expect(passage.user).toBeDefined();
expect(passage.auth).toBeDefined();
expect(passage['_apiConfiguration'].fetchApi).toBe(mockFetchApi);
expect(passage.user['config'].apiConfiguration.fetchApi).toBe(mockFetchApi);
expect(passage.auth['config'].apiConfiguration.fetchApi).toBe(mockFetchApi);
});
});
212 changes: 24 additions & 188 deletions src/classes/Passage/Passage.ts
Original file line number Diff line number Diff line change
@@ -1,224 +1,60 @@
import { AuthStrategy } from '../../types/AuthStrategy';
import { PassageConfig } from '../../types/PassageConfig';
import { PassageError } from '../PassageError';
import {
AppInfo,
AppsApi,
Configuration,
CreateMagicLinkRequest,
MagicLink,
MagicLinksApi,
ResponseError,
} from '../../generated';
import apiConfiguration from '../../utils/apiConfiguration';
import { IncomingMessage } from 'http';
import { getHeaderFromRequest } from '../../utils/getHeader';
import { PassageInstanceConfig } from '../PassageBase';
import { Auth } from '../Auth';
import { User } from '../User';
import { PassageConfig } from './types';
import { Configuration, ConfigurationParameters, FetchAPI } from '../../generated';

/**
* Passage Class
*/
export class Passage {
private readonly appId: string;
private _apiKey: string | undefined;
private readonly authStrategy: AuthStrategy;
public readonly user: User;
public readonly auth: Auth;

private readonly _apiConfiguration: Configuration;

/**
* Initialize a new Passage instance.
* @param {PassageConfig} config The default config for Passage initialization
*/
public constructor(config: PassageConfig) {
if (!config.appID) {
throw new PassageError(
'A Passage appID is required. Please include {appID: YOUR_APP_ID, apiKey: YOUR_API_KEY}.',
);
throw new Error('A Passage appID is required. Please include {appID: YOUR_APP_ID, apiKey: YOUR_API_KEY}.');
}
if (!config.apiKey) {
throw new PassageError(
throw new Error(
'A Passage API Key is required. Please include {appID: YOUR_APP_ID, apiKey: YOUR_API_KEY}.',
);
}
this._apiConfiguration = apiConfiguration({
accessToken: config.apiKey,
fetchApi: config.fetchApi,
});

const instanceConfig: PassageInstanceConfig = {
appId: config.appID,
apiConfiguration: this._apiConfiguration,
apiConfiguration: this.configureApi({
accessToken: config.apiKey,
fetchApi: config.fetchApi,
}),
};

this.user = new User(instanceConfig);
this.auth = new Auth(instanceConfig);

// To be removed on next major release
this.appId = config.appID;
this._apiKey = config.apiKey;

this.authStrategy = config?.authStrategy ? config.authStrategy : 'COOKIE';
}

/**
* @deprecated Use Passage.auth.validateJwt instead.
* Authenticate request with a cookie, or header. If no authentication
* strategy is given, authenticate the request via cookie (default
* authentication strategy).
*
* @param {IncomingMessage | Request} req Node http request or fetch request
* @return {string} UserID of the Passage user
*/
async authenticateRequest(req: IncomingMessage | Request): Promise<string> {
if (this.authStrategy == 'HEADER') {
return this.authenticateRequestWithHeader(req);
} else {
return this.authenticateRequestWithCookie(req);
}
}

/**
* @deprecated Set the API key in the constructor of the Passage object. Do not change API key at runtime.
* Set API key for this Passage instance
* @param {string} _apiKey
*/
set apiKey(_apiKey) {
this._apiKey = _apiKey;
}

/**
* @deprecated Getting the API key will be removed in the next major release.
* Get API key for this Passage instance
* @return {string | undefined} Passage API Key
*/
get apiKey(): string | undefined {
return this._apiKey;
}

/**
* @deprecated Use Passage.auth.validateJwt instead.
* Authenticate a request via the http header.
*
* @param {IncomingMessage | Request} req Node http request or fetch request
* @return {string} User ID for Passage User
*/
async authenticateRequestWithHeader(req: IncomingMessage | Request): Promise<string> {
const authorization = getHeaderFromRequest(req, 'authorization');

if (!authorization || typeof authorization !== 'string') {
throw new PassageError('Header authorization not found. You must catch this error.');
} else {
const authToken = (authorization as string).split(' ')[1];
const userID = await this.validAuthToken(authToken);
if (userID) {
return userID;
} else {
throw new Error('Auth token is invalid');
}
}
}

/**
* @deprecated Use Passage.auth.validateJwt instead.
* Authenticate request via cookie.
*
* @param {IncomingMessage | Request} req Node http request or fetch request
* @return {string} UserID for Passage User
* Configure the API with the provided configuration parameters.
* @param {ConfigurationParameters} config The configuration parameters
* @return {Configuration} The configured API
*/
async authenticateRequestWithCookie(req: IncomingMessage | Request): Promise<string> {
const cookiesStr = getHeaderFromRequest(req, 'cookie');
if (!cookiesStr || typeof cookiesStr !== 'string') {
throw new PassageError('Could not find valid cookie for authentication. You must catch this error.');
}

const cookies = cookiesStr.split(';');
let passageAuthToken;
for (const cookie of cookies) {
const sepIdx = cookie.indexOf('=');
if (sepIdx === -1) {
continue;
}
const key = cookie.slice(0, sepIdx).trim();
if (key !== 'psg_auth_token') {
continue;
}
passageAuthToken = cookie.slice(sepIdx + 1).trim();
break;
}

if (passageAuthToken) {
const userID = await this.validAuthToken(passageAuthToken);
if (userID) return userID;
else {
throw new PassageError('Could not validate auth token. You must catch this error.');
}
} else {
throw new PassageError(
"Could not find authentication cookie 'psg_auth_token' token. You must catch this error.",
);
}
}

/**
* @deprecated Use Passage.auth.validateJwt instead.
* Determine if the provided token is valid when compared with its
* respective public key.
*
* @param {string} token Authentication token
* @return {string} sub claim if the jwt can be verified, or Error
*/
async validAuthToken(token: string): Promise<string | undefined> {
return this.auth.validateJwt(token);
}

/**
* @deprecated Use Passage.auth.createMagicLink instead.
* Create a Magic Link for your app.
*
* @param {CreateMagicLinkRequest} args options for creating a MagicLink.
* @return {Promise<MagicLink>} Passage MagicLink object
*/
async createMagicLink(args: CreateMagicLinkRequest): Promise<MagicLink> {
try {
const magicLinksApi = new MagicLinksApi(this._apiConfiguration);
const response = await magicLinksApi.createMagicLink({
appId: this.appId,
createMagicLinkRequest: args,
});

return response.magicLink;
} catch (err) {
if (err instanceof ResponseError) {
throw await PassageError.fromResponseError(err, 'Could not create a magic link for this app');
}
throw err;
}
}

/**
* @deprecated Passage.auth.validateJwt will validate the JWT audience automatically.
* Get App Info about an app
*
* @return {Promise<AppInfo>} Passage App object
*/
async getApp(): Promise<AppInfo> {
try {
const client = new AppsApi(this._apiConfiguration);
const response = await client.getApp({
appId: this.appId,
});

return response.app;
} catch (err) {
if (err instanceof ResponseError) {
throw await PassageError.fromResponseError(err, 'Could not fetch app');
}
private configureApi(config?: ConfigurationParameters): Configuration {
const fetchApi = config?.fetchApi ?? (fetch as unknown as FetchAPI);
const configuration = new Configuration({
accessToken: config?.accessToken,
fetchApi,
headers: {
...config?.headers,
'Authorization': `Bearer ${config?.accessToken}`,
'Passage-Version': process.env.npm_package_version || '',
},
middleware: [],
});

throw err;
}
return configuration;
}
}
10 changes: 10 additions & 0 deletions src/classes/Passage/types.ts
Original file line number Diff line number Diff line change
@@ -1 +1,11 @@
import { FetchAPI } from '../../generated';

export { AppInfo, Layouts, LayoutConfig, UserMetadataFieldType, UserMetadataField } from '../../generated';
export type PassageConfig = {
/** The App ID for your Passage Application. */
appID: string;
/** The API Key for your Passage Application. */
apiKey: string;
/** Optional fetch API to use. Will use node-fetch by default if not provided. */
fetchApi?: FetchAPI;
};
7 changes: 3 additions & 4 deletions src/classes/PassageBase/PassageBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,16 @@ export class PassageBase {
* PassageBase class constructor.
* @param {PassageInstanceConfig} config config properties for Passage instance
*/
public constructor(protected config: PassageInstanceConfig) {}
public constructor(protected readonly config: PassageInstanceConfig) {}

/**
* Handle errors from PassageFlex API
* @param {unknown} err error from node-fetch request
* @param {string} message optional message to include in the error
* @return {Promise<void>}
*/
protected async parseError(err: unknown, message?: string): Promise<Error> {
protected async parseError(err: unknown): Promise<Error> {
if (err instanceof ResponseError) {
throw await PassageError.fromResponseError(err, message);
throw await PassageError.fromResponseError(err);
}
return err as Error;
}
Expand Down
Loading

0 comments on commit ba42c26

Please sign in to comment.