auth-backend: migrate GitHub handler to sign-in resolver + add refresh support
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -15,9 +15,13 @@
|
||||
*/
|
||||
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
import { GithubAuthProvider } from './provider';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TokenIssuer } from '../../identity/types';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { GithubAuthProvider, githubDefaultSignInResolver } from './provider';
|
||||
import * as helpers from '../../lib/passport/PassportStrategyHelper';
|
||||
import { OAuthResult } from '../../lib/oauth';
|
||||
import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper';
|
||||
|
||||
const mockFrameHandler = jest.spyOn(
|
||||
helpers,
|
||||
@@ -25,11 +29,28 @@ const mockFrameHandler = jest.spyOn(
|
||||
) as unknown as jest.MockedFunction<
|
||||
() => Promise<{
|
||||
result: Omit<OAuthResult, 'params'> & { params: { scope: string } };
|
||||
privateInfo: { refreshToken?: string };
|
||||
}>
|
||||
>;
|
||||
|
||||
describe('GithubAuthProvider', () => {
|
||||
const tokenIssuer = {
|
||||
issueToken: jest.fn(),
|
||||
listPublicKeys: jest.fn(),
|
||||
};
|
||||
const catalogIdentityClient = {
|
||||
findUser: jest.fn(),
|
||||
};
|
||||
|
||||
const provider = new GithubAuthProvider({
|
||||
logger: getVoidLogger(),
|
||||
catalogIdentityClient:
|
||||
catalogIdentityClient as unknown as CatalogIdentityClient,
|
||||
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
|
||||
signInResolver: githubDefaultSignInResolver,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}),
|
||||
callbackUrl: 'mock',
|
||||
clientId: 'mock',
|
||||
clientSecret: 'mock',
|
||||
@@ -80,6 +101,7 @@ describe('GithubAuthProvider', () => {
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
@@ -124,6 +146,7 @@ describe('GithubAuthProvider', () => {
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
@@ -167,6 +190,7 @@ describe('GithubAuthProvider', () => {
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
@@ -210,6 +234,7 @@ describe('GithubAuthProvider', () => {
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: { fullProfile, accessToken, params },
|
||||
privateInfo: {},
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual(expected);
|
||||
|
||||
@@ -15,14 +15,22 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
import { Strategy as GithubStrategy } from 'passport-github2';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import { RedirectInfo, AuthProviderFactory } from '../types';
|
||||
import {
|
||||
RedirectInfo,
|
||||
AuthProviderFactory,
|
||||
AuthHandler,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import {
|
||||
OAuthAdapter,
|
||||
OAuthProviderOptions,
|
||||
@@ -31,18 +39,41 @@ import {
|
||||
OAuthStartRequest,
|
||||
encodeState,
|
||||
OAuthResult,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
} from '../../lib/oauth';
|
||||
import { CatalogIdentityClient } from '../../lib/catalog';
|
||||
import { TokenIssuer } from '../../identity';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type GithubAuthProviderOptions = OAuthProviderOptions & {
|
||||
tokenUrl?: string;
|
||||
userProfileUrl?: string;
|
||||
authorizationUrl?: string;
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
tokenIssuer: TokenIssuer;
|
||||
catalogIdentityClient: CatalogIdentityClient;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class GithubAuthProvider implements OAuthHandlers {
|
||||
private readonly _strategy: GithubStrategy;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly tokenIssuer: TokenIssuer;
|
||||
private readonly catalogIdentityClient: CatalogIdentityClient;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: GithubAuthProviderOptions) {
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.authHandler = options.authHandler;
|
||||
this.tokenIssuer = options.tokenIssuer;
|
||||
this.catalogIdentityClient = options.catalogIdentityClient;
|
||||
this.logger = options.logger;
|
||||
this._strategy = new GithubStrategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
@@ -54,12 +85,12 @@ export class GithubAuthProvider implements OAuthHandlers {
|
||||
},
|
||||
(
|
||||
accessToken: any,
|
||||
_refreshToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
fullProfile: any,
|
||||
done: PassportDoneCallback<OAuthResult>,
|
||||
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
|
||||
) => {
|
||||
done(undefined, { fullProfile, params, accessToken });
|
||||
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -72,42 +103,112 @@ export class GithubAuthProvider implements OAuthHandlers {
|
||||
}
|
||||
|
||||
async handler(req: express.Request) {
|
||||
const {
|
||||
result: { fullProfile, accessToken, params },
|
||||
} = await executeFrameHandlerStrategy<OAuthResult>(req, this._strategy);
|
||||
|
||||
const profile = makeProfileInfo(
|
||||
{
|
||||
...fullProfile,
|
||||
id: fullProfile.username || fullProfile.id,
|
||||
displayName:
|
||||
fullProfile.displayName || fullProfile.username || fullProfile.id,
|
||||
},
|
||||
params.id_token,
|
||||
);
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this._strategy);
|
||||
|
||||
return {
|
||||
response: {
|
||||
profile,
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
},
|
||||
backstageIdentity: {
|
||||
id: fullProfile.username || fullProfile.id,
|
||||
},
|
||||
},
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
);
|
||||
return this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken: req.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
const { profile } = await this.authHandler(result);
|
||||
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
{
|
||||
tokenIssuer: this.tokenIssuer,
|
||||
catalogIdentityClient: this.catalogIdentityClient,
|
||||
logger: this.logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type GithubProviderOptions = {};
|
||||
export const githubDefaultSignInResolver: SignInResolver<OAuthResult> = async (
|
||||
info,
|
||||
ctx,
|
||||
) => {
|
||||
const { fullProfile } = info.result;
|
||||
|
||||
const userId = fullProfile.username || fullProfile.id;
|
||||
|
||||
const token = await ctx.tokenIssuer.issueToken({
|
||||
claims: { sub: userId, ent: [`user:default/${userId}`] },
|
||||
});
|
||||
|
||||
return { id: userId, token };
|
||||
};
|
||||
|
||||
export type GithubProviderOptions = {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<OAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*
|
||||
* Set to `'email'` to use the default email-based sign in resolver, which will search
|
||||
* the catalog for a single user entity that has a matching `google.com/email` annotation.
|
||||
*/
|
||||
signIn?: {
|
||||
resolver?: SignInResolver<OAuthResult>;
|
||||
};
|
||||
};
|
||||
|
||||
export const createGithubProvider = (
|
||||
_options?: GithubProviderOptions,
|
||||
options?: GithubProviderOptions,
|
||||
): AuthProviderFactory => {
|
||||
return ({ providerId, globalConfig, config, tokenIssuer }) =>
|
||||
return ({
|
||||
providerId,
|
||||
globalConfig,
|
||||
config,
|
||||
tokenIssuer,
|
||||
catalogApi,
|
||||
logger,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
@@ -125,6 +226,27 @@ export const createGithubProvider = (
|
||||
: undefined;
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const catalogIdentityClient = new CatalogIdentityClient({
|
||||
catalogApi,
|
||||
tokenIssuer,
|
||||
});
|
||||
|
||||
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
});
|
||||
|
||||
const signInResolverFn =
|
||||
options?.signIn?.resolver ?? githubDefaultSignInResolver;
|
||||
|
||||
const signInResolver: SignInResolver<OAuthResult> = info =>
|
||||
signInResolverFn(info, {
|
||||
catalogIdentityClient,
|
||||
tokenIssuer,
|
||||
logger,
|
||||
});
|
||||
|
||||
const provider = new GithubAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -132,10 +254,14 @@ export const createGithubProvider = (
|
||||
tokenUrl,
|
||||
userProfileUrl,
|
||||
authorizationUrl,
|
||||
signInResolver,
|
||||
authHandler,
|
||||
tokenIssuer,
|
||||
catalogIdentityClient,
|
||||
logger,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
disableRefresh: true,
|
||||
persistScopes: true,
|
||||
providerId,
|
||||
tokenIssuer,
|
||||
|
||||
Reference in New Issue
Block a user