Merge pull request #2284 from jibone/add-saml-login
Add saml login to backstage
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user