Merge pull request #6912 from backstage/rugvip/gha

auth-backend,core-app-api: add support for auth via GitHub Apps + handler options
This commit is contained in:
Patrik Oldsberg
2021-08-23 14:58:27 +02:00
committed by GitHub
17 changed files with 579 additions and 90 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/core-app-api': patch
'@backstage/plugin-auth-backend': patch
---
Added support for using authenticating via GitHub Apps in addition to GitHub OAuth Apps. It used to be possible to use GitHub Apps, but they did not handle session refresh correctly.
Note that GitHub Apps handle OAuth scope at the app installation level, meaning that the `scope` parameter for `getAccessToken` has no effect. When calling `getAccessToken` in open source plugins, one should still include the appropriate scope, but also document in the plugin README what scopes are required in the case of GitHub Apps.
In addition, the `authHandler` and `signInResolver` options have been implemented for the GitHub provider in the auth backend.
+14 -1
View File
@@ -10,11 +10,16 @@ that can authenticate users using GitHub or GitHub Enterprise OAuth.
## Create an OAuth App on GitHub
To add GitHub authentication, you must create an OAuth App from the GitHub
To add GitHub authentication, you must create either a GitHub App, or an OAuth
App from the GitHub
[developer settings](https://github.com/settings/developers). The `Homepage URL`
should point to Backstage's frontend, while the `Authorization callback URL`
will point to the auth backend.
Note that if you're using a GitHub App, the allowed scopes are configured as
part of that app. This means you need to verify what scopes the plugins you use
require, so be sure to check the plugin READMEs for that information.
Settings for local development:
- Application name: Backstage (or your custom app name)
@@ -51,3 +56,11 @@ The GitHub provider is a structure with three configuration keys:
To add the provider to the frontend, add the `githubAuthApi` reference and
`SignInPage` component as shown in
[Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page).
## Difference between GitHub Apps and GitHub OAuth Apps
GitHub Apps handle OAuth scope at the app installation level, meaning that the
`scope` parameter for the call to `getAccessToken` in the frontend has no
effect. When calling `getAccessToken` in open source plugins, one should still
include the appropriate scope, but also document in the plugin README what
scopes are required for GitHub Apps.
+1 -1
View File
@@ -394,7 +394,7 @@ export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
expiresAt?: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
@@ -29,15 +29,17 @@ import {
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import {
AuthSessionStore,
RefreshingAuthSessionManager,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
import { OAuthApiCreateOptions } from '../types';
import { OptionalRefreshSessionManagerMux } from '../../../../lib/AuthSessionManager/OptionalRefreshSessionManagerMux';
export type GithubAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
expiresInSeconds?: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
@@ -68,27 +70,46 @@ class GithubAuth implements OAuthApi, SessionApi {
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
expiresAt: res.providerInfo.expiresInSeconds
? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000)
: undefined,
},
};
},
});
const sessionManager = new StaticAuthSessionManager({
const refreshingSessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: GithubSession) => {
const { expiresAt } = session.providerInfo;
if (!expiresAt) {
return false;
}
const expiresInSec = (expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
const authSessionStore = new AuthSessionStore<GithubSession>({
manager: sessionManager,
const staticSessionManager = new AuthSessionStore<GithubSession>({
manager: new StaticAuthSessionManager({
connector,
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
}),
storageKey: `${provider.id}Session`,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(authSessionStore);
const sessionManagerMux = new OptionalRefreshSessionManagerMux({
refreshingSessionManager,
staticSessionManager,
sessionCanRefresh: session =>
session.providerInfo.expiresAt !== undefined,
});
return new GithubAuth(sessionManagerMux);
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
@@ -20,7 +20,7 @@ export type GithubSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
expiresAt?: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
@@ -128,6 +128,19 @@ describe('GheAuth AuthSessionStore', () => {
expect(manager.removeSession).toHaveBeenCalled();
});
it('should set session', async () => {
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
await expect(store.getSession({ optional: true })).resolves.toBe(undefined);
expect(localStorage.getItem('my-key')).toBe(null);
expect(manager.setSession).not.toHaveBeenCalled();
store.setSession('123');
expect(manager.setSession).toHaveBeenCalled();
expect(localStorage.getItem('my-key')).toBe('"123"');
await expect(store.getSession({ optional: true })).resolves.toBe('123');
});
it('should forward sessionState calls', () => {
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
@@ -15,7 +15,6 @@
*/
import {
SessionManager,
MutableSessionManager,
SessionScopesFunc,
SessionShouldRefreshFunc,
@@ -40,7 +39,7 @@ type Options<T> = {
*
* Session is serialized to JSON with special support for following types: Set.
*/
export class AuthSessionStore<T> implements SessionManager<T> {
export class AuthSessionStore<T> implements MutableSessionManager<T> {
private readonly manager: MutableSessionManager<T>;
private readonly storageKey: string;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
@@ -63,6 +62,11 @@ export class AuthSessionStore<T> implements SessionManager<T> {
});
}
setSession(session: T | undefined): void {
this.manager.setSession(session);
this.saveSession(session);
}
async getSession(options: GetSessionOptions): Promise<T | undefined> {
const { scopes } = options;
const session = this.loadSession();
@@ -0,0 +1,138 @@
/*
* Copyright 2020 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 { Observable, SessionState } from '@backstage/core-plugin-api';
import { OptionalRefreshSessionManagerMux } from './OptionalRefreshSessionManagerMux';
import { MutableSessionManager, SessionManager } from './types';
class MockManager implements MutableSessionManager<string> {
constructor(public session?: string) {}
setSession(session: string | undefined): void {
this.session = session;
}
async getSession(): Promise<string | undefined> {
return this.session;
}
async removeSession(): Promise<void> {
delete this.session;
}
sessionState$(): Observable<SessionState> {
throw new Error('Method not implemented.');
}
}
function trackState(manager: SessionManager<any>) {
const states = new Array<boolean>();
manager
.sessionState$()
.subscribe(state => states.push(state === SessionState.SignedIn));
return states;
}
describe('OptionalRefreshSessionManagerMux', () => {
it('finds no session', async () => {
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager: new MockManager(),
refreshingSessionManager: new MockManager(),
sessionCanRefresh: () => false,
});
const states = trackState(mux);
await expect(mux.getSession({})).resolves.toBe(undefined);
expect(states).toEqual([false]);
});
it('prioritizes a static session', async () => {
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager: new MockManager('static'),
refreshingSessionManager: new MockManager('refreshing'),
sessionCanRefresh: () => false,
});
const states = trackState(mux);
await expect(mux.getSession({})).resolves.toBe('static');
expect(states).toEqual([false, true]);
});
it('transfers a refreshing session to the static manager', async () => {
const staticSessionManager = new MockManager();
const refreshingSessionManager = new MockManager('refreshing');
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager,
refreshingSessionManager,
sessionCanRefresh: () => false,
});
const states = trackState(mux);
expect(staticSessionManager.session).toBeUndefined();
await expect(mux.getSession({})).resolves.toBe('refreshing');
expect(staticSessionManager.session).toBe('refreshing');
expect(states).toEqual([false, true]);
});
it('relies on the refreshing manager if refresh is available', async () => {
const staticSessionManager = new MockManager();
const refreshingSessionManager = new MockManager('refreshing');
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager,
refreshingSessionManager,
sessionCanRefresh: () => true,
});
const states = trackState(mux);
await expect(mux.getSession({})).resolves.toBe('refreshing');
expect(staticSessionManager.session).toBeUndefined();
expect(states).toEqual([false, true]);
});
it('can switch between refreshing and static sessions', async () => {
let canRefresh = true;
const staticSessionManager = new MockManager();
const refreshingSessionManager = new MockManager('refreshing');
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager,
refreshingSessionManager,
sessionCanRefresh: () => canRefresh,
});
const states = trackState(mux);
await expect(mux.getSession({})).resolves.toBe('refreshing');
expect(staticSessionManager.session).toBeUndefined();
canRefresh = false;
await expect(mux.getSession({})).resolves.toBe('refreshing');
expect(staticSessionManager.session).toBe('refreshing');
expect(states).toEqual([false, true]);
});
it('removes sessions from both managers', async () => {
const staticSessionManager = new MockManager('static');
const refreshingSessionManager = new MockManager('refreshing');
const mux = new OptionalRefreshSessionManagerMux({
staticSessionManager,
refreshingSessionManager,
sessionCanRefresh: () => true,
});
const states = trackState(mux);
await expect(mux.getSession({})).resolves.toBe('static');
await mux.removeSession();
expect(staticSessionManager.session).toBeUndefined();
expect(refreshingSessionManager.session).toBeUndefined();
expect(states).toEqual([false, true, false]);
});
});
@@ -0,0 +1,105 @@
/*
* Copyright 2020 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 { Observable, SessionState } from '@backstage/core-plugin-api';
import {
SessionManager,
MutableSessionManager,
GetSessionOptions,
} from './types';
import { SessionStateTracker } from './SessionStateTracker';
type Options<T> = {
/**
* A callback that is called to determine whether a given session supports refresh
*/
sessionCanRefresh: (session: T) => boolean;
/**
* The session manager that is used if the a session does not support refresh.
*/
staticSessionManager: MutableSessionManager<T>;
/**
* The session manager that is used if the a session supports refresh.
*/
refreshingSessionManager: SessionManager<T>;
};
/**
* OptionalRefreshSessionManagerMux wraps two different session managers, one for
* static session storage and another one that supports refresh. For each session
* that is retrieved is checked for whether it supports refresh. If it does, the
* refreshing session manager is used, otherwise the static session manager is used.
*/
export class OptionalRefreshSessionManagerMux<T> implements SessionManager<T> {
private readonly stateTracker = new SessionStateTracker();
private readonly sessionCanRefresh: (session: T) => boolean;
private readonly staticSessionManager: MutableSessionManager<T>;
private readonly refreshingSessionManager: SessionManager<T>;
constructor(options: Options<T>) {
this.sessionCanRefresh = options.sessionCanRefresh;
this.staticSessionManager = options.staticSessionManager;
this.refreshingSessionManager = options.refreshingSessionManager;
}
async getSession(options: GetSessionOptions): Promise<T | undefined> {
// First we check if there is an existing static session, using an optional request
const staticSession = await this.staticSessionManager.getSession({
...options,
optional: true,
});
if (staticSession) {
this.stateTracker.setIsSignedIn(true);
return staticSession;
}
// If there is no static session available, we ask the refresh manager to get a session
const session = await this.refreshingSessionManager.getSession(options);
// Handling the case where the session request is optional
if (!session) {
this.stateTracker.setIsSignedIn(false);
return undefined;
}
// Next we check if the session we received from the refreshing manager can actually
// be refreshed. If it can, we use this session without storing it in the static manager.
if (this.sessionCanRefresh(session)) {
this.stateTracker.setIsSignedIn(true);
return session;
}
// If the session can't be refreshed, we store it in the static manager
this.staticSessionManager.setSession(session);
this.stateTracker.setIsSignedIn(true);
return session;
}
async removeSession(): Promise<void> {
await Promise.all([
this.refreshingSessionManager.removeSession(),
this.staticSessionManager.removeSession(),
]);
this.stateTracker.setIsSignedIn(false);
}
sessionState$(): Observable<SessionState> {
return this.stateTracker.sessionState$();
}
}
@@ -140,11 +140,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
response.providerInfo.scope = grantedScopes;
}
if (!this.options.disableRefresh) {
if (!refreshToken) {
throw new InputError('Missing refresh token');
}
if (refreshToken && !this.options.disableRefresh) {
// set new refresh token
this.setRefreshTokenCookie(res, refreshToken);
}
@@ -174,10 +170,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return;
}
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
this.removeRefreshTokenCookie(res);
}
// remove refresh token cookie if it is set
this.removeRefreshTokenCookie(res);
res.status(200).send('logout!');
}
@@ -30,8 +30,6 @@ export const makeProfileInfo = (
profile: passport.Profile,
idToken?: string,
): ProfileInfo => {
let { displayName } = profile;
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
const [firstEmail] = profile.emails;
@@ -44,6 +42,9 @@ export const makeProfileInfo = (
picture = firstPhoto.value;
}
let displayName: string | undefined =
profile.displayName ?? profile.username ?? profile.id;
if ((!email || !picture || !displayName) && idToken) {
try {
const decoded: Record<string, string> = jwtDecoder(idToken);
@@ -15,4 +15,4 @@
*/
export { createGithubProvider } from './provider';
export type { GithubProviderOptions } from './provider';
export type { GithubOAuthResult, GithubProviderOptions } from './provider';
@@ -15,21 +15,47 @@
*/
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,
GithubOAuthResult,
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,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{
result: Omit<OAuthResult, 'params'> & { params: { scope: string } };
result: GithubOAuthResult;
privateInfo: { refreshToken?: string };
}>
>;
describe('GithubAuthProvider', () => {
const tokenIssuer: TokenIssuer = {
listPublicKeys: jest.fn(),
async issueToken(params) {
return `token-for-${params.claims.sub}`;
},
};
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',
@@ -63,11 +89,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -80,6 +105,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -108,11 +134,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -124,6 +149,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -151,11 +177,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -167,6 +192,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -178,11 +204,11 @@ describe('GithubAuthProvider', () => {
const fullProfile = {
id: 'ipd12039',
username: 'daveboyle',
provider: 'gitlab',
provider: 'github',
displayName: 'Dave Boyle',
emails: [
{
value: 'daveboyle@gitlab.org',
value: 'daveboyle@github.org',
},
],
};
@@ -194,25 +220,63 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'daveboyle',
token: 'token-for-daveboyle',
},
providerInfo: {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
scope: 'read:user',
expiresInSeconds: undefined,
idToken: undefined,
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
email: 'daveboyle@github.org',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should forward a refresh token', async () => {
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
id: 'ipd12039',
provider: 'github',
displayName: 'Dave Boyle',
},
accessToken: 'a.b.c',
params: {
scope: 'read:user',
expires_in: '123',
},
},
privateInfo: { refreshToken: 'refresh-me' },
});
const response = await provider.handler({} as any);
expect(response).toEqual({
response: {
backstageIdentity: {
id: 'ipd12039',
token: 'token-for-ipd12039',
},
providerInfo: {
accessToken: 'a.b.c',
scope: 'read:user',
expiresInSeconds: 123,
},
profile: {
displayName: 'Dave Boyle',
},
},
refreshToken: 'refresh-me',
});
});
});
});
@@ -15,14 +15,23 @@
*/
import express from 'express';
import { Logger } from 'winston';
import { Profile as PassportProfile } from 'passport';
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,
@@ -30,19 +39,52 @@ import {
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthResult,
OAuthRefreshRequest,
OAuthResponse,
} from '../../lib/oauth';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken?: string;
};
export type GithubOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
expires_in?: string;
refresh_token_expires_in?: string;
};
accessToken: string;
refreshToken?: string;
};
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
userProfileUrl?: string;
authorizationUrl?: string;
signInResolver?: SignInResolver<GithubOAuthResult>;
authHandler: AuthHandler<GithubOAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
private readonly signInResolver?: SignInResolver<GithubOAuthResult>;
private readonly authHandler: AuthHandler<GithubOAuthResult>;
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 +96,12 @@ export class GithubAuthProvider implements OAuthHandlers {
},
(
accessToken: any,
_refreshToken: any,
refreshToken: any,
params: any,
fullProfile: any,
done: PassportDoneCallback<OAuthResult>,
done: PassportDoneCallback<GithubOAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, params, accessToken });
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
}
@@ -72,42 +114,109 @@ 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<
GithubOAuthResult,
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: GithubOAuthResult) {
const { profile } = await this.authHandler(result);
const expiresInStr = result.params.expires_in;
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
},
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<GithubOAuthResult> =
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<GithubOAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<GithubOAuthResult>;
};
};
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 +234,27 @@ export const createGithubProvider = (
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<GithubOAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const signInResolverFn =
options?.signIn?.resolver ?? githubDefaultSignInResolver;
const signInResolver: SignInResolver<GithubOAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GithubAuthProvider({
clientId,
clientSecret,
@@ -132,10 +262,14 @@ export const createGithubProvider = (
tokenUrl,
userProfileUrl,
authorizationUrl,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
@@ -240,13 +240,10 @@ export type GoogleProviderOptions = {
/**
* 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?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -247,13 +247,10 @@ export type MicrosoftProviderOptions = {
/**
* 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 `microsoft.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -250,13 +250,10 @@ export type OktaProviderOptions = {
/**
* 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 `okta.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};