backend - fix types + refactoring
This commit is contained in:
@@ -23,7 +23,23 @@ import {
|
||||
verifyNonce,
|
||||
OAuthProvider,
|
||||
} from './OAuthProvider';
|
||||
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
|
||||
import {
|
||||
WebMessageResponse,
|
||||
OAuthProviderHandlers,
|
||||
OAuthResponse,
|
||||
} from '../providers/types';
|
||||
|
||||
const mockResponseData: OAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
};
|
||||
|
||||
describe('OAuthProvider Utils', () => {
|
||||
describe('verifyNonce', () => {
|
||||
@@ -38,6 +54,7 @@ describe('OAuthProvider Utils', () => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Missing nonce');
|
||||
});
|
||||
|
||||
it('should throw error if state nonce missing', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
@@ -49,6 +66,7 @@ describe('OAuthProvider Utils', () => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Missing nonce');
|
||||
});
|
||||
|
||||
it('should throw error if nonce mismatch', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
@@ -62,6 +80,7 @@ describe('OAuthProvider Utils', () => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Invalid nonce');
|
||||
});
|
||||
|
||||
it('should not throw any error if nonce matches', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
@@ -85,13 +104,19 @@ describe('OAuthProvider Utils', () => {
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: AuthResponse = {
|
||||
type: 'auth-result',
|
||||
payload: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
userIdToken: 'a.b.c',
|
||||
},
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
@@ -111,8 +136,8 @@ describe('OAuthProvider Utils', () => {
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: AuthResponse = {
|
||||
type: 'auth-result',
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
error: new Error('Unknown error occured'),
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
@@ -161,16 +186,12 @@ describe('OAuthProvider', () => {
|
||||
}
|
||||
async handler() {
|
||||
return {
|
||||
user: {},
|
||||
info: {
|
||||
refreshToken: 'token',
|
||||
},
|
||||
response: mockResponseData,
|
||||
refreshToken: 'token',
|
||||
};
|
||||
}
|
||||
async refresh() {
|
||||
return {
|
||||
accessToken: 'token',
|
||||
};
|
||||
return mockResponseData;
|
||||
}
|
||||
}
|
||||
const providerInstance = new MyAuthProvider();
|
||||
@@ -323,11 +344,10 @@ describe('OAuthProvider', () => {
|
||||
|
||||
await oauthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: 'token',
|
||||
}),
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
...mockResponseData,
|
||||
userIdToken: 'my-id-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles refresh without capabilities', async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AuthResponse,
|
||||
AuthProviderRouteHandlers,
|
||||
OAuthProviderHandlers,
|
||||
WebMessageResponse,
|
||||
} from '../providers/types';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { TokenIssuer } from '../identity';
|
||||
@@ -53,9 +54,9 @@ export const verifyNonce = (req: express.Request, providerId: string) => {
|
||||
export const postMessageResponse = (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
data: AuthResponse,
|
||||
response: WebMessageResponse,
|
||||
) => {
|
||||
const jsonData = JSON.stringify(data);
|
||||
const jsonData = JSON.stringify(response);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
@@ -96,7 +97,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
this.basePath = url.pathname;
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<any> {
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
// retrieve scopes from request
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
|
||||
@@ -108,14 +109,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
// set a nonce cookie before redirecting to oauth provider
|
||||
this.setNonceCookie(res, nonce);
|
||||
|
||||
const options = {
|
||||
const queryParameters = {
|
||||
scope,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: nonce,
|
||||
};
|
||||
|
||||
const { url, status } = await this.providerHandlers.start(req, options);
|
||||
const { url, status } = await this.providerHandlers.start(
|
||||
req,
|
||||
queryParameters,
|
||||
);
|
||||
|
||||
res.statusCode = status || 302;
|
||||
res.setHeader('Location', url);
|
||||
@@ -126,16 +128,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<any> {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// verify nonce cookie and state cookie on callback
|
||||
verifyNonce(req, this.options.providerId);
|
||||
|
||||
const { user, info } = await this.providerHandlers.handler(req);
|
||||
const { response, refreshToken } = await this.providerHandlers.handler(
|
||||
req,
|
||||
);
|
||||
|
||||
if (!this.options.disableRefresh) {
|
||||
// throw error if missing refresh token
|
||||
const { refreshToken } = info;
|
||||
if (!refreshToken) {
|
||||
throw new Error('Missing refresh token');
|
||||
}
|
||||
@@ -144,19 +147,23 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
}
|
||||
|
||||
user.userIdToken = await this.options.tokenIssuer.issueToken({
|
||||
claims: { sub: user.profile.email },
|
||||
const userIdToken = await this.options.tokenIssuer.issueToken({
|
||||
claims: { sub: response.profile.email },
|
||||
});
|
||||
const fullResponse: AuthResponse<unknown> = {
|
||||
...response,
|
||||
userIdToken,
|
||||
};
|
||||
|
||||
// post message back to popup if successful
|
||||
return postMessageResponse(res, this.options.appOrigin, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
type: 'authorization_response',
|
||||
response: fullResponse,
|
||||
});
|
||||
} catch (error) {
|
||||
// post error message back to popup if failure
|
||||
return postMessageResponse(res, this.options.appOrigin, {
|
||||
type: 'auth-result',
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
@@ -165,27 +172,30 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response): Promise<any> {
|
||||
async logout(req: express.Request, res: express.Response): Promise<void> {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
res.status(401).send('Invalid X-Requested-With header');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.options.disableRefresh) {
|
||||
// remove refresh token cookie before logout
|
||||
this.removeRefreshTokenCookie(res);
|
||||
}
|
||||
return res.send('logout!');
|
||||
res.send('logout!');
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<any> {
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
res.status(401).send('Invalid X-Requested-With header');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
|
||||
return res.send(
|
||||
res.send(
|
||||
`Refresh token not supported for provider: ${this.options.providerId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -200,18 +210,19 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
|
||||
// get new access_token
|
||||
const refreshInfo = await this.providerHandlers.refresh(
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
const response = await this.providerHandlers.refresh(refreshToken, scope);
|
||||
|
||||
refreshInfo.userIdToken = await this.options.tokenIssuer.issueToken({
|
||||
claims: { sub: refreshInfo.profile?.email },
|
||||
const userIdToken = await this.options.tokenIssuer.issueToken({
|
||||
claims: { sub: response.profile.email },
|
||||
});
|
||||
const fullResponse: AuthResponse<unknown> = {
|
||||
...response,
|
||||
userIdToken,
|
||||
};
|
||||
|
||||
return res.send(refreshInfo);
|
||||
res.send(fullResponse);
|
||||
} catch (error) {
|
||||
return res.status(401).send(`${error.message}`);
|
||||
res.status(401).send(`${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ describe('PassportStrategyHelper', () => {
|
||||
expect(spyAuthenticate).toBeCalledTimes(1);
|
||||
await expect(frameHandlerStrategyPromise).resolves.toStrictEqual(
|
||||
expect.objectContaining({
|
||||
user: { accessToken: 'ACCESS_TOKEN' },
|
||||
info: { refreshToken: 'REFRESH_TOKEN' },
|
||||
response: { accessToken: 'ACCESS_TOKEN' },
|
||||
privateInfo: { refreshToken: 'REFRESH_TOKEN' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,42 +26,52 @@ import {
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
params: any,
|
||||
idToken?: string,
|
||||
): ProfileInfo => {
|
||||
const { displayName: name } = profile;
|
||||
const { displayName } = profile;
|
||||
|
||||
let email = '';
|
||||
let email: string | undefined = undefined;
|
||||
if (profile.emails) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
if (!email && params.id_token) {
|
||||
try {
|
||||
const decoded: { email: string } = jwtDecoder(params.id_token);
|
||||
email = decoded.email;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse id token and get profile info');
|
||||
}
|
||||
}
|
||||
|
||||
let picture = '';
|
||||
let picture: string | undefined = undefined;
|
||||
if (profile.photos) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
if ((!email || !picture) && idToken) {
|
||||
try {
|
||||
const decoded: Record<string, string> = jwtDecoder(idToken);
|
||||
|
||||
if (!email && decoded.email) {
|
||||
email = decoded.email;
|
||||
}
|
||||
if (!picture && decoded.picture) {
|
||||
picture = decoded.picture;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse id token and get profile info, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error('No email received in profile info');
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
options: any,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo> => {
|
||||
return new Promise(resolve => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
@@ -73,30 +83,32 @@ export const executeRedirectStrategy = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFrameHandlerStrategy = async (
|
||||
export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
) => {
|
||||
return new Promise<{ user: any; info: any }>((resolve, reject) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (user: any, info: any) => {
|
||||
resolve({ user, info });
|
||||
};
|
||||
strategy.fail = (
|
||||
info: { type: 'success' | 'error'; message?: string },
|
||||
// _status: number,
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: Error) => {
|
||||
reject(new Error(`Authentication failed, ${error}`));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
return new Promise<{ response: T; privateInfo: PrivateInfo }>(
|
||||
(resolve, reject) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (response: any, privateInfo: any) => {
|
||||
resolve({ response, privateInfo });
|
||||
};
|
||||
strategy.fail = (
|
||||
info: { type: 'success' | 'error'; message?: string },
|
||||
// _status: number,
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: Error) => {
|
||||
reject(new Error(`Authentication failed, ${error}`));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
|
||||
strategy.authenticate(req, {});
|
||||
});
|
||||
strategy.authenticate(req, {});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const executeRefreshTokenStrategy = async (
|
||||
@@ -151,7 +163,7 @@ export const executeRefreshTokenStrategy = async (
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerStrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
params: any,
|
||||
idToken?: string,
|
||||
): Promise<ProfileInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
|
||||
@@ -162,7 +174,7 @@ export const executeFetchUserProfileStrategy = async (
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params);
|
||||
const profile = makeProfileInfo(passportProfile, idToken);
|
||||
resolve(profile);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,16 +19,17 @@ import { Strategy as GithubStrategy } from 'passport-github2';
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
makeProfileInfo,
|
||||
} from '../../lib/PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
AuthProviderConfig,
|
||||
RedirectInfo,
|
||||
AuthInfoBase,
|
||||
AuthInfoPrivate,
|
||||
EnvironmentProviderConfig,
|
||||
OAuthProviderOptions,
|
||||
OAuthProviderConfig,
|
||||
OAuthResponse,
|
||||
PassportDoneCallback,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../../lib/OAuthProvider';
|
||||
import {
|
||||
@@ -44,25 +45,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
|
||||
constructor(options: OAuthProviderOptions) {
|
||||
this._strategy = new GithubStrategy(
|
||||
{ ...options },
|
||||
(accessToken: any, _: any, params: any, profile: any, done: any) => {
|
||||
(
|
||||
accessToken: any,
|
||||
_: any,
|
||||
params: any,
|
||||
rawProfile: any,
|
||||
done: PassportDoneCallback<OAuthResponse>,
|
||||
) => {
|
||||
const profile = makeProfileInfo(rawProfile);
|
||||
done(undefined, {
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
},
|
||||
profile,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: express.Request, options: any): Promise<RedirectInfo> {
|
||||
async start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, options);
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
async handler(req: express.Request): Promise<{ response: OAuthResponse }> {
|
||||
const result = await executeFrameHandlerStrategy<OAuthResponse>(
|
||||
req,
|
||||
this._strategy,
|
||||
);
|
||||
|
||||
return {
|
||||
response: result.response,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,14 +25,13 @@ import {
|
||||
} from '../../lib/PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
AuthInfoBase,
|
||||
AuthInfoPrivate,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
AuthInfoWithProfile,
|
||||
EnvironmentProviderConfig,
|
||||
OAuthProviderOptions,
|
||||
OAuthProviderConfig,
|
||||
OAuthResponse,
|
||||
PassportDoneCallback,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../../lib/OAuthProvider';
|
||||
import passport from 'passport';
|
||||
@@ -43,6 +42,10 @@ import {
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly _strategy: GoogleStrategy;
|
||||
|
||||
@@ -56,18 +59,20 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
profile: passport.Profile,
|
||||
done: any,
|
||||
rawProfile: passport.Profile,
|
||||
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
|
||||
) => {
|
||||
const profileInfo = makeProfileInfo(profile, params);
|
||||
const profile = makeProfileInfo(rawProfile, params.id_token);
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
profile: profileInfo,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
providerInfo: {
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
},
|
||||
profile,
|
||||
},
|
||||
{
|
||||
refreshToken,
|
||||
@@ -77,20 +82,33 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: express.Request, options: any): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, options);
|
||||
async start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo> {
|
||||
const providerOptions = {
|
||||
...options,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
};
|
||||
return await executeRedirectStrategy(req, this._strategy, providerOptions);
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
const result = await executeFrameHandlerStrategy<
|
||||
OAuthResponse,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
return {
|
||||
response: result.response,
|
||||
refreshToken: result.privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthInfoWithProfile> {
|
||||
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
@@ -100,14 +118,16 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
params,
|
||||
params.id_token,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { Strategy as SamlStrategy } from 'passport-saml';
|
||||
import {
|
||||
Strategy as SamlStrategy,
|
||||
Profile as SamlProfile,
|
||||
VerifyWithoutRequest,
|
||||
} from 'passport-saml';
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
@@ -25,6 +29,8 @@ import {
|
||||
AuthProviderRouteHandlers,
|
||||
EnvironmentProviderConfig,
|
||||
SAMLProviderConfig,
|
||||
PassportDoneCallback,
|
||||
ProfileInfo,
|
||||
} from '../types';
|
||||
import { postMessageResponse } from '../../lib/OAuthProvider';
|
||||
import {
|
||||
@@ -32,30 +38,39 @@ import {
|
||||
EnvironmentHandler,
|
||||
} from '../../lib/EnvironmentHandler';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
|
||||
type SamlInfo = {
|
||||
userId: string;
|
||||
profile: ProfileInfo;
|
||||
};
|
||||
|
||||
export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly strategy: SamlStrategy;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
|
||||
constructor(options: SAMLProviderOptions) {
|
||||
this.strategy = new SamlStrategy(
|
||||
{ ...options },
|
||||
(profile: any, done: any) => {
|
||||
// TODO: There's plenty more validation and profile handling to do here,
|
||||
// this provider is currently only intended to validate the provider pattern
|
||||
// for non-oauth auth flows.
|
||||
// TODO: This flow doesn't issue an identity token that can be used to validate
|
||||
// the identity of the user in other backends, which we need in some form.
|
||||
done(undefined, {
|
||||
email: profile.email,
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
displayName: profile.displayName,
|
||||
});
|
||||
},
|
||||
);
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.strategy = new SamlStrategy({ ...options }, ((
|
||||
profile: SamlProfile,
|
||||
done: PassportDoneCallback<SamlInfo>,
|
||||
) => {
|
||||
// TODO: There's plenty more validation and profile handling to do here,
|
||||
// this provider is currently only intended to validate the provider pattern
|
||||
// for non-oauth auth flows.
|
||||
// TODO: This flow doesn't issue an identity token that can be used to validate
|
||||
// the identity of the user in other backends, which we need in some form.
|
||||
done(undefined, {
|
||||
userId: profile.ID!,
|
||||
profile: {
|
||||
email: profile.email!,
|
||||
displayName: profile.displayName as string,
|
||||
},
|
||||
});
|
||||
}) as VerifyWithoutRequest);
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<any> {
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
const { url } = await executeRedirectStrategy(req, this.strategy, {});
|
||||
res.redirect(url);
|
||||
}
|
||||
@@ -63,17 +78,27 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<any> {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
|
||||
const {
|
||||
response: { userId, profile },
|
||||
} = await executeFrameHandlerStrategy<SamlInfo>(req, this.strategy);
|
||||
|
||||
const userIdToken = await this.tokenIssuer.issueToken({
|
||||
claims: { sub: userId },
|
||||
});
|
||||
|
||||
return postMessageResponse(res, 'http://localhost:3000', {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {},
|
||||
profile,
|
||||
userIdToken,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return postMessageResponse(res, 'http://localhost:3000', {
|
||||
type: 'auth-result',
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
@@ -82,7 +107,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
}
|
||||
}
|
||||
|
||||
async logout(_req: express.Request, res: express.Response): Promise<any> {
|
||||
async logout(_req: express.Request, res: express.Response): Promise<void> {
|
||||
res.send('noop');
|
||||
}
|
||||
}
|
||||
@@ -91,12 +116,14 @@ type SAMLProviderOptions = {
|
||||
entryPoint: string;
|
||||
issuer: string;
|
||||
path: string;
|
||||
tokenIssuer: TokenIssuer;
|
||||
};
|
||||
|
||||
export function createSamlProvider(
|
||||
_authProviderConfig: AuthProviderConfig,
|
||||
providerConfig: EnvironmentProviderConfig,
|
||||
logger: Logger,
|
||||
tokenIssuer: TokenIssuer,
|
||||
) {
|
||||
const envProviders: EnvironmentHandlers = {};
|
||||
|
||||
@@ -106,6 +133,7 @@ export function createSamlProvider(
|
||||
entryPoint: config.entryPoint,
|
||||
issuer: config.issuer,
|
||||
path: '/auth/saml/handler/frame',
|
||||
tokenIssuer,
|
||||
};
|
||||
|
||||
if (!opts.entryPoint || !opts.issuer) {
|
||||
|
||||
@@ -89,25 +89,30 @@ export interface OAuthProviderHandlers {
|
||||
* @param {express.Request} req
|
||||
* @param options
|
||||
*/
|
||||
start(req: express.Request, options: any): Promise<any>;
|
||||
start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo>;
|
||||
|
||||
/**
|
||||
* Handles the redirect from the auth provider when the user has signed in.
|
||||
* @param {express.Request} req
|
||||
*/
|
||||
handler(req: express.Request): Promise<any>;
|
||||
handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken?: string }>;
|
||||
|
||||
/**
|
||||
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
|
||||
* @param {string} refreshToken
|
||||
* @param {string} scope
|
||||
*/
|
||||
refresh?(refreshToken: string, scope: string): Promise<any>;
|
||||
refresh?(refreshToken: string, scope: string): Promise<OAuthResponse>;
|
||||
|
||||
/**
|
||||
* (Optional) Sign out of the auth provider.
|
||||
*/
|
||||
logout?(): Promise<any>;
|
||||
logout?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +139,7 @@ export interface AuthProviderRouteHandlers {
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
start(req: express.Request, res: express.Response): Promise<any>;
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
|
||||
@@ -149,7 +154,7 @@ export interface AuthProviderRouteHandlers {
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<any>;
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* (Optional) If the auth provider supports refresh tokens then this method handles
|
||||
@@ -163,7 +168,7 @@ export interface AuthProviderRouteHandlers {
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
refresh?(req: express.Request, res: express.Response): Promise<any>;
|
||||
refresh?(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* (Optional) Handles sign out requests
|
||||
@@ -174,7 +179,7 @@ export interface AuthProviderRouteHandlers {
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
logout?(req: express.Request, res: express.Response): Promise<any>;
|
||||
logout?(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
|
||||
export type AuthProviderFactory = (
|
||||
@@ -184,7 +189,18 @@ export type AuthProviderFactory = (
|
||||
issuer: TokenIssuer,
|
||||
) => AuthProviderRouteHandlers;
|
||||
|
||||
export type AuthInfoBase = {
|
||||
export type AuthResponse<ProviderInfo> = {
|
||||
providerInfo: ProviderInfo;
|
||||
profile: ProfileInfo;
|
||||
userIdToken: string;
|
||||
};
|
||||
|
||||
export type OAuthResponse = Omit<
|
||||
AuthResponse<OAuthProviderInfo>,
|
||||
'userIdToken'
|
||||
>;
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
@@ -203,34 +219,35 @@ export type AuthInfoBase = {
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
/**
|
||||
* Profile information of the signed in user.
|
||||
*/
|
||||
profile: ProfileInfo | undefined;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = {
|
||||
export type OAuthPrivateInfo = {
|
||||
/**
|
||||
* A refresh token issued for the signed in user.
|
||||
*/
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
// {type: 'authorization_response', response: {...}}
|
||||
|
||||
/**
|
||||
* Payload sent as a post message after the auth request is complete.
|
||||
* If successful then has a valid payload with Auth information else contains an error.
|
||||
*/
|
||||
export type AuthResponse =
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'auth-result';
|
||||
payload: AuthInfoBase | AuthInfoWithProfile;
|
||||
type: 'authorization_response';
|
||||
response: AuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'auth-result';
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type PassportDoneCallback<Res, Private = never> = (
|
||||
err?: Error,
|
||||
response?: Res,
|
||||
privateInfo?: Private,
|
||||
) => void;
|
||||
|
||||
export type RedirectInfo = {
|
||||
/**
|
||||
* URL to redirect to
|
||||
@@ -242,6 +259,22 @@ export type RedirectInfo = {
|
||||
status?: number;
|
||||
};
|
||||
|
||||
/*
|
||||
metadata:
|
||||
name: john
|
||||
spec:
|
||||
profile:
|
||||
email: john.doe@example.com
|
||||
displayName: John Doe
|
||||
picture: https://example.com/avatars/john.doe
|
||||
|
||||
identities:
|
||||
- type: google
|
||||
email: john.doe@gmail.com
|
||||
*/
|
||||
|
||||
// 1. Link to identity in catalog / within backstage
|
||||
// 2. Display login information to user, i.e. sidebar popup
|
||||
export type ProfileInfo = {
|
||||
/**
|
||||
* Email ID of the signed in user.
|
||||
@@ -250,12 +283,12 @@ export type ProfileInfo = {
|
||||
/**
|
||||
* Display name that can be presented to the signed in user.
|
||||
*/
|
||||
name: string;
|
||||
displayName?: string;
|
||||
/**
|
||||
* URL to an image that can be used as the display image or avatar of the
|
||||
* signed in user.
|
||||
*/
|
||||
picture: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
|
||||
Reference in New Issue
Block a user