auth-node: add PassportHelpers

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-07-26 14:19:16 +02:00
parent 14fd4fb7c8
commit feefbd3da6
6 changed files with 296 additions and 0 deletions
+1
View File
@@ -40,6 +40,7 @@
"jose": "^4.6.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"passport": "^0.6.0",
"winston": "^3.2.1"
},
"devDependencies": {
+1
View File
@@ -21,6 +21,7 @@
*/
export * from './identity';
export * from './passport';
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
export { DefaultIdentityClient } from './DefaultIdentityClient';
export { IdentityClient } from './IdentityClient';
@@ -0,0 +1,246 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Request } from 'express';
import { Strategy } from 'passport';
import { PassportProfile } from './types';
import { ProfileInfo } from '../types';
// Re-declared here to avoid direct dependency on passport-oauth2
/** @internal */
interface InternalOAuthError extends Error {
oauthError?: {
data?: string;
};
}
/** @internal */
function decodeJwtPayload(token: string): Record<string, string> {
const payloadStr = token.split('.')[1];
if (!payloadStr) {
throw new Error('Invalid JWT token');
}
let payload: unknown;
try {
payload = JSON.parse(
atob(payloadStr.replace(/-/g, '+').replace(/_/g, '/')),
);
} catch (e) {
throw new Error('Invalid JWT token');
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid JWT token');
}
return payload as Record<string, string>;
}
/** @public */
export class PassportHelpers {
private constructor() {}
static transformProfile = (
profile: PassportProfile,
idToken?: string,
): ProfileInfo => {
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
const [firstEmail] = profile.emails;
email = firstEmail.value;
}
let picture: string | undefined = undefined;
if (profile.avatarUrl) {
picture = profile.avatarUrl;
} else if (profile.photos && profile.photos.length > 0) {
const [firstPhoto] = profile.photos;
picture = firstPhoto.value;
}
let displayName: string | undefined =
profile.displayName ?? profile.username ?? profile.id;
if ((!email || !picture || !displayName) && idToken) {
try {
const decoded: Record<string, string> = decodeJwtPayload(idToken);
if (!email && decoded.email) {
email = decoded.email;
}
if (!picture && decoded.picture) {
picture = decoded.picture;
}
if (!displayName && decoded.name) {
displayName = decoded.name;
}
} catch (e) {
throw new Error(`Failed to parse id token and get profile info, ${e}`);
}
}
return {
email,
picture,
displayName,
};
};
static async executeRedirectStrategy(
req: Request,
providerStrategy: Strategy,
options: Record<string, string>,
): Promise<{
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
}> {
return new Promise(resolve => {
const strategy = Object.create(providerStrategy);
strategy.redirect = (url: string, status?: number) => {
resolve({ url, status: status ?? undefined });
};
strategy.authenticate(req, { ...options });
});
}
static async executeFrameHandlerStrategy<TResult, TPrivateInfo = never>(
req: Request,
providerStrategy: Strategy,
options?: Record<string, string>,
): Promise<{ result: TResult; privateInfo: TPrivateInfo }> {
return new Promise((resolve, reject) => {
const strategy = Object.create(providerStrategy);
strategy.success = (result: any, privateInfo: any) => {
resolve({ result, privateInfo });
};
strategy.fail = (
info: { type: 'success' | 'error'; message?: string },
// _status: number,
) => {
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
};
strategy.error = (error: InternalOAuthError) => {
let message = `Authentication failed, ${error.message}`;
if (error.oauthError?.data) {
try {
const errorData = JSON.parse(error.oauthError.data);
if (errorData.message) {
message += ` - ${errorData.message}`;
}
} catch (parseError) {
message += ` - ${error.oauthError}`;
}
}
reject(new Error(message));
};
strategy.redirect = () => {
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req, { ...(options ?? {}) });
});
}
static async executeRefreshTokenStrategy(
providerStrategy: Strategy,
refreshToken: string,
scope: string,
): Promise<{
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
}> {
return new Promise((resolve, reject) => {
const anyStrategy = providerStrategy as any;
const OAuth2 = anyStrategy._oauth2.constructor;
const oauth2 = new OAuth2(
anyStrategy._oauth2._clientId,
anyStrategy._oauth2._clientSecret,
anyStrategy._oauth2._baseSite,
anyStrategy._oauth2._authorizeUrl,
anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
anyStrategy._oauth2._customHeaders,
);
oauth2.getOAuthAccessToken(
refreshToken,
{
scope,
grant_type: 'refresh_token',
},
(
err: Error | null,
accessToken: string,
newRefreshToken: string,
params: any,
) => {
if (err) {
reject(
new Error(`Failed to refresh access token ${err.toString()}`),
);
}
if (!accessToken) {
reject(
new Error(
`Failed to refresh access token, no access token received`,
),
);
}
resolve({
accessToken,
refreshToken: newRefreshToken,
params,
});
},
);
});
}
static async executeFetchUserProfileStrategy(
providerStrategy: Strategy,
accessToken: string,
): Promise<PassportProfile> {
return new Promise((resolve, reject) => {
const anyStrategy = providerStrategy as unknown as {
userProfile(accessToken: string, callback: Function): void;
};
anyStrategy.userProfile(
accessToken,
(error: Error, rawProfile: PassportProfile) => {
if (error) {
reject(error);
} else {
resolve(rawProfile);
}
},
);
});
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { PassportHelpers } from './PassportHelpers';
export type { PassportDoneCallback, PassportProfile } from './types';
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Profile } from 'passport';
/** @public */
export type PassportProfile = Profile & {
avatarUrl?: string;
};
/** @public */
export type PassportDoneCallback<TResult, TPrivateInfo = never> = (
err?: Error,
result?: TResult,
privateInfo?: TPrivateInfo,
) => void;
+1
View File
@@ -4661,6 +4661,7 @@ __metadata:
lodash: ^4.17.21
msw: ^1.0.0
node-fetch: ^2.6.7
passport: ^0.6.0
uuid: ^8.0.0
winston: ^3.2.1
languageName: unknown