Merge pull request #2284 from jibone/add-saml-login

Add saml login to backstage
This commit is contained in:
Ben Lambert
2020-09-25 11:37:17 +02:00
committed by GitHub
14 changed files with 275 additions and 5 deletions
+7
View File
@@ -19,6 +19,7 @@ import {
gitlabAuthApiRef,
oktaAuthApiRef,
githubAuthApiRef,
samlAuthApiRef,
microsoftAuthApiRef,
} from '@backstage/core';
@@ -53,4 +54,10 @@ export const providers = [
message: 'Sign In using Okta',
apiRef: oktaAuthApiRef,
},
{
id: 'saml-auth-provider',
title: 'SAML',
message: 'Sign In using SAML',
apiRef: samlAuthApiRef,
},
];
@@ -310,3 +310,13 @@ export const oauth2ApiRef = createApiRef<
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
/**
* Provides authentication for saml based identity providers
*/
export const samlAuthApiRef = createApiRef<
ProfileInfoApi & BackstageIdentityApi & SessionApi
>({
id: 'core.auth.saml',
description: 'Example of how to use SAML custom provider',
});
@@ -19,5 +19,6 @@ export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
export * from './saml';
export * from './auth0';
export * from './microsoft';
@@ -0,0 +1,104 @@
/*
* Copyright 2020 Spotify AB
*
* 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 SamlIcon from '@material-ui/icons/AcUnit';
import { DirectAuthConnector } from '../../../../lib/AuthConnector';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import {
ProfileInfo,
BackstageIdentity,
SessionState,
AuthRequestOptions,
ProfileInfoApi,
BackstageIdentityApi,
SessionApi,
} from '../../../definitions/auth';
import { AuthProvider, DiscoveryApi } from '../../../definitions';
import { SamlSession } from './types';
import {
AuthSessionStore,
StaticAuthSessionManager,
} from '../../../../lib/AuthSessionManager';
type CreateOptions = {
discoveryApi: DiscoveryApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type SamlAuthResponse = {
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'saml',
title: 'SAML',
icon: SamlIcon,
};
class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
}: CreateOptions) {
const connector = new DirectAuthConnector<SamlSession>({
discoveryApi,
environment,
provider,
});
const sessionManager = new StaticAuthSessionManager<SamlSession>({
connector,
});
const authSessionStore = new AuthSessionStore<SamlSession>({
manager: sessionManager,
storageKey: 'samlSession',
});
return new SamlAuth(authSessionStore);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<SamlSession>) {}
async signIn() {
await this.getBackstageIdentity({});
}
async signOut() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions,
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
}
export default SamlAuth;
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { default as SamlAuth } from './SamlAuth';
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type SamlSession = {
userId: string;
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,89 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
AuthProvider,
ProfileInfo,
BackstageIdentity,
DiscoveryApi,
} from '../../apis/definitions';
import { showLoginPopup } from '../loginPopup';
type Options = {
discoveryApi: DiscoveryApi;
environment?: string;
provider: AuthProvider & { id: string };
};
export type DirectAuthResponse = {
userId: string;
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
export class DirectAuthConnector<DirectAuthResponse> {
private readonly discoveryApi: DiscoveryApi;
private readonly environment: string | undefined;
private readonly provider: AuthProvider & { id: string };
constructor(options: Options) {
const { discoveryApi, environment, provider } = options;
this.discoveryApi = discoveryApi;
this.environment = environment;
this.provider = provider;
}
async createSession(): Promise<DirectAuthResponse> {
const popupUrl = await this.buildUrl('/start');
const payload = await showLoginPopup({
url: popupUrl,
name: `${this.provider.title} Login`,
origin: new URL(popupUrl).origin,
width: 450,
height: 730,
});
return {
...payload,
id: payload.profile.email,
};
}
async refreshSession(): Promise<any> {}
async removeSession(): Promise<void> {
const res = await fetch(await this.buildUrl('/logout'), {
method: 'POST',
headers: {
'x-requested-with': 'XMLHttpRequest',
},
credentials: 'include',
}).catch(error => {
throw new Error(`Logout request failed, ${error}`);
});
if (!res.ok) {
const error: any = new Error(`Logout request failed, ${res.statusText}`);
error.status = res.status;
throw error;
}
}
private async buildUrl(path: string): Promise<string> {
const baseUrl = await this.discoveryApi.getBaseUrl('auth');
return `${baseUrl}/${this.provider.id}${path}?env=${this.environment}`;
}
}
@@ -15,4 +15,5 @@
*/
export { DefaultAuthConnector } from './DefaultAuthConnector';
export { DirectAuthConnector } from './DirectAuthConnector';
export * from './types';
@@ -28,7 +28,7 @@ type Options<T> = {
/** Storage key to use to store sessions */
storageKey: string;
/** Used to get the scope of the session */
sessionScopes: SessionScopesFunc<T>;
sessionScopes?: SessionScopesFunc<T>;
/** Used to check if the session needs to be refreshed, defaults to never refresh */
sessionShouldRefresh?: SessionShouldRefreshFunc<T>;
};
@@ -23,7 +23,7 @@ type Options<T> = {
/** The connector used for acting on the auth session */
connector: AuthConnector<T>;
/** Used to get the scope of the session */
sessionScopes: (session: T) => Set<string>;
sessionScopes?: (session: T) => Set<string>;
/** The default scopes that should always be present in a session, defaults to none. */
defaultScopes?: Set<string>;
};
@@ -29,7 +29,7 @@ export function hasScopes(
}
type ScopeHelperOptions<T> = {
sessionScopes: SessionScopesFunc<T>;
sessionScopes: SessionScopesFunc<T> | undefined;
defaultScopes?: Set<string>;
};
@@ -46,13 +46,16 @@ export class SessionScopeHelper<T> {
if (!scopes) {
return true;
}
if (this.options.sessionScopes === undefined) {
return true;
}
const sessionScopes = this.options.sessionScopes(session);
return hasScopes(sessionScopes, scopes);
}
getExtendedScope(session: T | undefined, scopes?: Set<string>) {
const newScope = new Set(this.options.defaultScopes);
if (session) {
if (session && this.options.sessionScopes !== undefined) {
const sessionScopes = this.options.sessionScopes(session);
for (const scope of sessionScopes) {
newScope.add(scope);
@@ -44,6 +44,8 @@ import {
createApiFactory,
configApiRef,
UrlPatternDiscovery,
samlAuthApiRef,
SamlAuth,
} from '@backstage/core-api';
export const defaultApis = [
@@ -132,4 +134,11 @@ export const defaultApis = [
factory: ({ discoveryApi, oauthRequestApi }) =>
OAuth2.create({ discoveryApi, oauthRequestApi }),
}),
createApiFactory({
api: samlAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
},
factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }),
}),
];
@@ -21,6 +21,7 @@ import {
oauth2ApiRef,
oktaAuthApiRef,
microsoftAuthApiRef,
samlAuthApiRef,
useApi,
} from '@backstage/core-api';
import Star from '@material-ui/icons/Star';
@@ -69,6 +70,13 @@ export const DefaultProviderSettings = () => {
icon={Star}
/>
)}
{providers.includes('saml') && (
<ProviderSettingsItem
title="SAML"
apiRef={samlAuthApiRef}
icon={Star}
/>
)}
{providers.includes('oauth2') && (
<ProviderSettingsItem
title="YourOrg"
@@ -54,7 +54,7 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
// 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!,
userId: profile.nameID!,
profile: {
email: profile.email!,
displayName: profile.displayName as string,