Merge branch 'master' of github.com:spotify/backstage into techdocs_md_extensions

This commit is contained in:
Sebastian Qvarfordt
2020-06-24 13:42:56 +02:00
56 changed files with 825 additions and 488 deletions
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: experimental
owner: tools@example.com
owner: artists@example.com
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: experimental
owner: tools@example.com
owner: players@example.com
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
import { ProfileInfo } from './auth';
/**
* The Identity API used to identify and get information about the signed in user.
@@ -29,6 +30,11 @@ export type IdentityApi = {
*/
getUserId(): string;
/**
* The profile of the signed in user.
*/
getProfile(): ProfileInfo;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
+58 -48
View File
@@ -37,13 +37,13 @@ import { Observable } from '../..';
*/
export type OAuthScope = string | string[];
export type AccessTokenOptions = {
export type AuthRequestOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty access token will be returned if there is no existing session.
* and an empty response will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in with a set of scopes,
* or if you don't want to force a user to be logged in, but provide functionality if they already are.
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
@@ -88,7 +88,7 @@ export type OAuthApi = {
*/
getAccessToken(
scope?: OAuthScope,
options?: AccessTokenOptions,
options?: AuthRequestOptions,
): Promise<string>;
/**
@@ -97,29 +97,6 @@ export type OAuthApi = {
logout(): Promise<void>;
};
export type IdTokenOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty id token will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
/**
* If this is set to true, the request will bypass the regular oauth login modal
* and open the login popup directly.
*
* The method must be called synchronously from a user action for this to work in all browsers.
*
* @default false
*/
instantPopup?: boolean;
};
/**
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
* which can be passed to backend services to prove the user's identity.
@@ -136,7 +113,7 @@ export type OpenIdConnectApi = {
* to log in. The returned promise will not resolve until the user has successfully logged in.
* The returned promise can be rejected, but only if the user rejects the login request.
*/
getIdToken(options?: IdTokenOptions): Promise<string>;
getIdToken(options?: AuthRequestOptions): Promise<string>;
/**
* Log out the user's session. This will reload the page.
@@ -144,38 +121,65 @@ export type OpenIdConnectApi = {
logout(): Promise<void>;
};
export type ProfileInfoOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty profile will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
/**
* Get profile information for the user as supplied by this auth provider.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
};
/**
* Profile information of the user from an auth provider.
* This API provides access to the user's identity within Backstage.
*
* An auth provider that implements this interface can be used to sign-in to backstage. It is
* not intended to be used directly from a plugin, but instead serves as a connection between
* this authentication method and the app's @IdentityApi
*/
export type BackstageIdentityApi = {
/**
* Get the user's identity within Backstage. This should normally not be called directly,
* use the @IdentityApi instead.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
};
export type BackstageIdentity = {
/**
* The backstage user ID.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
*/
idToken: string;
};
/**
* Profile information of the user.
*/
export type ProfileInfo = {
/**
* Email ID.
*/
email: string;
email?: string;
/**
* Display name that can be presented to the user.
*/
name?: string;
displayName?: string;
/**
* URL to an avatar image of the user.
*/
@@ -207,7 +211,11 @@ export type SessionStateApi = {
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionStateApi
>({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
@@ -219,7 +227,9 @@ export const googleAuthApiRef = createApiRef<
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
export const githubAuthApiRef = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
>({
id: 'core.auth.github',
description: 'Provides authentication towards Github APIs',
});
@@ -20,7 +20,7 @@ describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token' });
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
@@ -19,15 +19,16 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
OAuthApi,
AccessTokenOptions,
SessionStateApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
@@ -41,10 +42,13 @@ type CreateOptions = {
};
export type GithubAuthResponse = {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
@@ -69,9 +73,14 @@ class GithubAuth implements OAuthApi, SessionStateApi {
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
accessToken: res.accessToken,
scopes: GithubAuth.normalizeScope(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
@@ -79,36 +88,40 @@ class GithubAuth implements OAuthApi, SessionStateApi {
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['user']),
sessionScopes: session => session.scopes,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
const normalizedScopes = GithubAuth.normalizeScope(scope);
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
scopes: GithubAuth.normalizeScope(scope),
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
return '';
return session?.providerInfo.accessToken ?? '';
}
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;
}
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
static normalizeScope(scope?: string): Set<string> {
@@ -14,8 +14,15 @@
* limitations under the License.
*/
import { ProfileInfo } from '../../..';
import { BackstageIdentity } from '../../../definitions';
export type GithubSession = {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -23,9 +23,9 @@ const PREFIX = 'https://www.googleapis.com/auth/';
describe('GoogleAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getAccessToken()).toBe('access-token');
@@ -33,9 +33,9 @@ describe('GoogleAuth', () => {
});
it('should get refreshed id token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken()).toBe('id-token');
@@ -43,9 +43,9 @@ describe('GoogleAuth', () => {
});
it('should get optional id token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
@@ -58,9 +58,11 @@ describe('GoogleAuth', () => {
const getSession = jest
.fn()
.mockResolvedValueOnce({
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const googleAuth = new GoogleAuth({ getSession } as any);
@@ -77,17 +79,21 @@ describe('GoogleAuth', () => {
it('should wait for all session refreshes', async () => {
const initialSession = {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const googleAuth = new GoogleAuth({ getSession } as any);
@@ -95,7 +101,7 @@ describe('GoogleAuth', () => {
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.expiresAt = thePast;
initialSession.providerInfo.expiresAt = thePast;
const promise1 = googleAuth.getIdToken();
const promise2 = googleAuth.getIdToken();
@@ -20,19 +20,18 @@ import { GoogleSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
IdTokenOptions,
AccessTokenOptions,
ProfileInfoApi,
ProfileInfoOptions,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth
@@ -46,11 +45,14 @@ type CreateOptions = {
};
export type GoogleAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
@@ -62,7 +64,12 @@ const DEFAULT_PROVIDER = {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
static create({
apiOrigin,
basePath,
@@ -78,11 +85,15 @@ class GoogleAuth
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
profile: res.profile,
idToken: res.idToken,
accessToken: res.accessToken,
scopes: GoogleAuth.normalizeScopes(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
@@ -94,9 +105,10 @@ class GoogleAuth
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: session => session.scopes,
sessionShouldRefresh: session => {
const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000;
sessionScopes: (session: GoogleSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: GoogleSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
@@ -104,51 +116,42 @@ class GoogleAuth
return new GoogleAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
scope?: string | string[],
options?: AccessTokenOptions,
options?: AuthRequestOptions,
) {
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
scopes: GoogleAuth.normalizeScopes(scope),
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
return '';
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: IdTokenOptions = {}) {
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.idToken;
}
return '';
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
async getProfile(options: ProfileInfoOptions = {}) {
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (!session) {
return undefined;
}
return session.profile;
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
@@ -14,12 +14,15 @@
* limitations under the License.
*/
import { ProfileInfo } from '../../../definitions';
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GoogleSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
backstageIdentity: BackstageIdentity;
};
+8 -15
View File
@@ -17,7 +17,6 @@ import React, {
ComponentType,
FC,
useMemo,
useCallback,
useState,
ReactElement,
} from 'react';
@@ -258,24 +257,14 @@ export class PrivateAppImpl implements BackstageApp {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}> = ({ component: Component, children }) => {
const [done, setDone] = useState(false);
const [result, setResult] = useState<SignInResult>();
const onResult = useCallback(
(result: SignInResult) => {
if (done) {
throw new Error('Identity result callback was called twice');
}
this.identityApi.setSignInResult(result);
setDone(true);
},
[done],
);
if (done) {
if (result) {
this.identityApi.setSignInResult(result);
return children;
}
return <Component onResult={onResult} />;
return <Component onResult={setResult} />;
};
const AppRouter: FC<{}> = ({ children }) => {
@@ -293,6 +282,10 @@ export class PrivateAppImpl implements BackstageApp {
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
return (
+16 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { IdentityApi } from '../apis';
import { IdentityApi, ProfileInfo } from '../apis';
import { SignInResult } from './types';
/**
@@ -24,6 +24,7 @@ import { SignInResult } from './types';
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private logoutFunc?: () => Promise<void>;
@@ -36,6 +37,15 @@ export class AppIdentity implements IdentityApi {
return this.userId!;
}
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
@@ -55,6 +65,7 @@ export class AppIdentity implements IdentityApi {
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
@@ -62,8 +73,12 @@ export class AppIdentity implements IdentityApi {
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.logoutFunc = result.logout;
}
+4 -1
View File
@@ -18,7 +18,7 @@ import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme, ConfigApi } from '../apis/definitions';
import { AppTheme, ConfigApi, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
export type BootErrorPageProps = {
@@ -31,6 +31,9 @@ export type SignInResult = {
* User ID that will be returned by the IdentityApi
*/
userId: string;
profile: ProfileInfo;
/**
* Function used to retrieve an ID token for the signed in user.
*/
@@ -38,6 +38,7 @@ class LocalStorage {
class MockManager implements SessionManager<string> {
getSession = jest.fn();
removeSession = jest.fn();
sessionState$ = jest.fn();
}
describe('GheAuth AuthSessionStore', () => {
@@ -119,4 +120,11 @@ describe('GheAuth AuthSessionStore', () => {
expect(localStorage.getItem('my-key')).toBe(null);
});
it('should forward sessionState calls', () => {
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
store.sessionState$();
expect(manager.sessionState$).toHaveBeenCalled();
});
});
@@ -82,6 +82,10 @@ export class AuthSessionStore<T> implements SessionManager<T> {
await this.manager.removeSession();
}
sessionState$() {
return this.manager.sessionState$();
}
private loadSession(): T | undefined {
try {
const sessionJson = localStorage.getItem(this.storageKey);
@@ -15,6 +15,7 @@
*/
import { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
import { SessionState } from '../../apis';
const defaultOptions = {
sessionScopes: (session: { scopes: Set<string> }) => session.scopes,
@@ -22,21 +23,44 @@ const defaultOptions = {
};
describe('RefreshingAuthSessionManager', () => {
it('should save result form createSession', async () => {
it('should save result from createSession', async () => {
const createSession = jest.fn().mockResolvedValue({ expired: false });
const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE'));
const removeSession = jest.fn();
const manager = new RefreshingAuthSessionManager({
connector: { createSession, refreshSession },
connector: { createSession, refreshSession, removeSession },
...defaultOptions,
} as any);
const stateSubscriber = jest.fn();
manager.sessionState$().subscribe(stateSubscriber);
await Promise.resolve(); // Wait a tick for observer to post a value
expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]);
await manager.getSession({});
expect(createSession).toBeCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
]);
await manager.getSession({});
expect(createSession).toBeCalledTimes(1);
expect(refreshSession).toBeCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
]);
expect(removeSession).toHaveBeenCalledTimes(0);
await manager.removeSession();
expect(removeSession).toHaveBeenCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
[SessionState.SignedOut],
]);
});
it('should ask consent only if scopes have changed', async () => {
@@ -130,7 +154,7 @@ describe('RefreshingAuthSessionManager', () => {
expect(refreshSession).toBeCalledTimes(1);
});
it('should remove session and reload', async () => {
it('should remove session straight away', async () => {
const removeSession = jest.fn();
const manager = new RefreshingAuthSessionManager({
connector: { removeSession },
@@ -22,6 +22,7 @@ import {
} from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper, hasScopes } from './common';
import { SessionStateTracker } from './SessionStateTracker';
type Options<T> = {
/** The connector used for acting on the auth session */
@@ -43,6 +44,7 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
private readonly helper: SessionScopeHelper<T>;
private readonly sessionScopesFunc: SessionScopesFunc<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly stateTracker = new SessionStateTracker();
private refreshPromise?: Promise<T>;
private currentSession: T | undefined;
@@ -109,16 +111,18 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
this.stateTracker.setIsSignedIn(true);
return this.currentSession;
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
this.stateTracker.setIsSignedIn(false);
}
async getCurrentSession() {
return this.currentSession;
sessionState$() {
return this.stateTracker.sessionState$();
}
private async collapsedSessionRefresh(): Promise<T> {
@@ -129,7 +133,9 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
this.refreshPromise = this.connector.refreshSession();
try {
return await this.refreshPromise;
const session = await this.refreshPromise;
this.stateTracker.setIsSignedIn(true);
return session;
} finally {
delete this.refreshPromise;
}
@@ -16,17 +16,25 @@
import { BehaviorSubject } from '..';
import { SessionState } from '../../apis';
import { Observable } from '../../types';
export class SessionStateTracker {
private signedIn: boolean = false;
observable = new BehaviorSubject<SessionState>(SessionState.SignedOut);
private readonly subject = new BehaviorSubject<SessionState>(
SessionState.SignedOut,
);
setIsSignedId(isSignedIn: boolean) {
private signedIn: boolean = false;
setIsSignedIn(isSignedIn: boolean) {
if (this.signedIn !== isSignedIn) {
this.signedIn = isSignedIn;
this.observable.next(
this.subject.next(
this.signedIn ? SessionState.SignedIn : SessionState.SignedOut,
);
}
}
sessionState$(): Observable<SessionState> {
return this.subject;
}
}
@@ -17,6 +17,7 @@
import { SessionManager, GetSessionOptions } from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
import { SessionStateTracker } from './SessionStateTracker';
type Options<T> = {
/** The connector used for acting on the auth session */
@@ -33,6 +34,7 @@ type Options<T> = {
export class StaticAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private readonly stateTracker = new SessionStateTracker();
private currentSession: T | undefined;
@@ -60,11 +62,17 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
this.stateTracker.setIsSignedIn(true);
return this.currentSession;
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
this.stateTracker.setIsSignedIn(false);
}
sessionState$() {
return this.stateTracker.sessionState$();
}
}
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import { Observable } from '../../types';
import { SessionState } from '../../apis';
export type GetSessionOptions = {
optional?: boolean;
instantPopup?: boolean;
@@ -29,6 +32,8 @@ export type SessionManager<T> = {
getSession(options: GetSessionOptions): Promise<T | undefined>;
removeSession(): Promise<void>;
sessionState$(): Observable<SessionState>;
};
/**
+7 -7
View File
@@ -59,7 +59,7 @@ describe('showLoginPopup', () => {
// None of these should be accepted
listener({ source: popupMock } as MessageEvent);
listener({ origin: 'my-origin' } as MessageEvent);
listener({ data: { type: 'auth-result' } } as MessageEvent);
listener({ data: { type: 'authorization_response' } } as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
@@ -68,26 +68,26 @@ describe('showLoginPopup', () => {
listener({
source: popupMock,
origin: 'my-origin',
data: { type: 'not-auth-result', payload: {} },
data: { type: 'not-auth-result', response: {} },
} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
const myPayload = {};
const myResponse = {};
// This should be accepted as a valid sessions response
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
payload: myPayload,
type: 'authorization_response',
response: myResponse,
},
} as MessageEvent);
await expect(payloadPromise).resolves.toBe(myPayload);
await expect(payloadPromise).resolves.toBe(myResponse);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
@@ -118,7 +118,7 @@ describe('showLoginPopup', () => {
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
type: 'authorization_response',
error: {
message: 'NOPE',
name: 'NopeError',
+8 -7
View File
@@ -46,11 +46,11 @@ export type LoginPopupOptions = {
type AuthResult =
| {
type: 'auth-result';
payload: any;
type: 'authorization_response';
response: unknown;
}
| {
type: 'auth-result';
type: 'authorization_response';
error: {
name: string;
message: string;
@@ -58,12 +58,13 @@ type AuthResult =
};
/**
* Show a popup pointing to a URL that starts an auth flow.
* Show a popup pointing to a URL that starts an auth flow. Implementing the receiving
* end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00
*
* The redirect handler of the flow should use postMessage to communicate back
* to the app window. The message posted to the app must match the AuthResult type.
*
* The returned promise resolves to the contents of the message that was posted from the auth popup.
* The returned promise resolves to the response of the message that was posted from the auth popup.
*/
export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
@@ -91,7 +92,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
return;
}
const { data } = event;
if (data.type !== 'auth-result') {
if (data.type !== 'authorization_response') {
return;
}
const authResult = data as AuthResult;
@@ -103,7 +104,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
// error.extra = authResult.error.extra;
reject(error);
} else {
resolve(authResult.payload);
resolve(authResult.response);
}
done();
};
@@ -41,22 +41,29 @@ export const OAuthProviderSettings: FC<OAuthProviderSidebarProps> = ({
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
let didCancel = false;
const checkSession = async () => {
const session = await api.getAccessToken('', { optional: true });
setSignedIn(!!session);
if (!didCancel) {
setSignedIn(!!session);
}
};
let subscription: Subscription;
const observeSession = () => {
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
if (!didCancel) {
setSignedIn(sessionState === SessionState.SignedIn);
}
});
};
checkSession();
observeSession();
return () => {
didCancel = true;
subscription.unsubscribe();
};
}, [api]);
@@ -41,9 +41,13 @@ export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
let didCancel = false;
const checkSession = async () => {
const session = await api.getIdToken({ optional: true });
setSignedIn(!!session);
if (!didCancel) {
setSignedIn(!!session);
}
};
let subscription: Subscription;
@@ -51,13 +55,16 @@ export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
if (!didCancel) {
setSignedIn(sessionState === SessionState.SignedIn);
}
});
};
checkSession();
observeSession();
return () => {
didCancel = true;
subscription.unsubscribe();
};
}, [api]);
@@ -14,19 +14,12 @@
* limitations under the License.
*/
import React, { FC, useState, useRef, useEffect } from 'react';
import React, { FC, useRef } from 'react';
import { makeStyles, Avatar, Divider } from '@material-ui/core';
import {
ProfileInfo,
useApi,
googleAuthApiRef,
Subscription,
SessionState,
} from '@backstage/core-api';
import { useApi, identityApiRef } from '@backstage/core-api';
import { SidebarItem } from '../Items';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
const useStyles = makeStyles({
avatar: {
@@ -39,71 +32,28 @@ export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
open,
setOpen,
}) => {
const [profile, setProfile] = useState<ProfileInfo>();
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const googleAuth = useApi(googleAuthApiRef);
const classes = useStyles();
const identityApi = useApi(identityApiRef);
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
useEffect(() => {
const fetchProfile = async () => {
await googleAuth
.getProfile({ optional: true })
.then((userProfile?: ProfileInfo) => {
setProfile(userProfile);
});
};
let subscription: Subscription;
const observeSession = () => {
subscription = googleAuth
.sessionState$()
.subscribe(async (sessionState: SessionState) => {
if (sessionState === SessionState.SignedIn) {
await fetchProfile();
} else {
setProfile(undefined);
}
});
};
fetchProfile();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [googleAuth]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName = 'Guest';
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => (
<Avatar alt={displayName} src={imageUrl} className={classes.avatar} />
)
: () => <Avatar alt={displayName} className={classes.avatar} />;
}
const userId = identityApi.getUserId();
const profile = identityApi.getProfile();
const displayName = profile.displayName ?? userId;
const SignInAvatar = () => (
<Avatar src={profile.picture} className={classes.avatar}>
{displayName[0]}
</Avatar>
);
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
>
<SidebarItem text={displayName} onClick={handleClick} icon={SignInAvatar}>
{open ? <ExpandMore /> : <ExpandLess />}
</SidebarItem>
</>
@@ -56,6 +56,9 @@ const Component: ProviderComponent = ({ onResult }) => {
const handleResult = ({ userId, idToken }: Data) => {
onResult({
userId,
profile: {
email: `${userId}@example.com`,
},
getIdToken: idToken ? async () => idToken : undefined,
});
};
@@ -18,16 +18,7 @@ import React from 'react';
import { Grid, Typography, Button } from '@material-ui/core';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
import {
useApi,
googleAuthApiRef,
errorApiRef,
ProfileInfo,
} from '@backstage/core-api';
function parseUserId(profile: ProfileInfo) {
return profile!.email.replace(/@.*/, '');
}
import { useApi, googleAuthApiRef, errorApiRef } from '@backstage/core-api';
const Component: ProviderComponent = ({ onResult }) => {
const googleAuthApi = useApi(googleAuthApiRef);
@@ -35,12 +26,17 @@ const Component: ProviderComponent = ({ onResult }) => {
const handleLogin = async () => {
try {
await googleAuthApi.getIdToken({ instantPopup: true });
const identity = await googleAuthApi.getBackstageIdentity({
instantPopup: true,
});
const profile = await googleAuthApi.getProfile();
onResult({
userId: parseUserId(profile!),
getIdToken: () => googleAuthApi.getIdToken(),
userId: identity!.id,
profile: profile!,
getIdToken: () =>
googleAuthApi.getBackstageIdentity().then(i => i!.idToken),
logout: async () => {
await googleAuthApi.logout();
},
@@ -69,11 +65,21 @@ const Component: ProviderComponent = ({ onResult }) => {
const loader: ProviderLoader = async apis => {
const googleAuthApi = apis.get(googleAuthApiRef)!;
const profile = await googleAuthApi.getProfile({ optional: true });
const identity = await googleAuthApi.getBackstageIdentity({
optional: true,
});
if (!identity) {
return undefined;
}
const profile = await googleAuthApi.getProfile();
return {
userId: parseUserId(profile!),
getIdToken: () => googleAuthApi.getIdToken(),
userId: identity.id,
profile: profile!,
getIdToken: () =>
googleAuthApi.getBackstageIdentity().then(i => i!.idToken),
logout: async () => {
await googleAuthApi.logout();
},
@@ -19,6 +19,14 @@ import { Grid, Typography, Button } from '@material-ui/core';
import { InfoCard } from '../InfoCard/InfoCard';
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
const result = {
userId: 'guest',
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
};
const Component: ProviderComponent = ({ onResult }) => (
<Grid item>
<InfoCard
@@ -27,7 +35,7 @@ const Component: ProviderComponent = ({ onResult }) => (
<Button
color="primary"
variant="outlined"
onClick={() => onResult({ userId: 'guest' })}
onClick={() => onResult(result)}
>
Enter
</Button>
@@ -45,7 +53,7 @@ const Component: ProviderComponent = ({ onResult }) => (
);
const loader: ProviderLoader = async () => {
return { userId: 'guest' };
return result;
};
export const guestProvider: SignInProvider = { Component, loader };
@@ -93,8 +93,9 @@ export const useSignInProviders = (
}
if (result) {
handleWrappedResult(result);
} else {
setLoading(false);
}
setLoading(false);
})
.catch(error => {
if (didCancel) {
+1
View File
@@ -22,6 +22,7 @@ builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
builder.add(identityApiRef, {
getUserId: () => 'guest',
getProfile: () => ({ email: 'guest@example.com' }),
getIdToken: () => undefined,
logout: async () => {},
});
@@ -23,7 +23,22 @@ import {
verifyNonce,
OAuthProvider,
} from './OAuthProvider';
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types';
const mockResponseData = {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'foo',
},
};
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
@@ -38,6 +53,7 @@ describe('OAuthProvider Utils', () => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Missing nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
@@ -49,6 +65,7 @@ describe('OAuthProvider Utils', () => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Missing nonce');
});
it('should throw error if nonce mismatch', () => {
const mockRequest = ({
cookies: {
@@ -62,6 +79,7 @@ describe('OAuthProvider Utils', () => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const mockRequest = ({
cookies: {
@@ -85,13 +103,22 @@ 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',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
@@ -111,8 +138,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 +188,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 +346,13 @@ 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,
backstageIdentity: {
id: mockResponseData.backstageIdentity.id,
idToken: 'my-id-token',
},
});
});
it('handles refresh without capabilities', async () => {
+47 -32
View File
@@ -18,9 +18,10 @@ import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
AuthResponse,
AuthProviderRouteHandlers,
OAuthProviderHandlers,
WebMessageResponse,
BackstageIdentity,
} 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,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, refreshToken);
}
user.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: user.profile.email },
});
await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
payload: user,
type: 'authorization_response',
response,
});
} 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 +166,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 +204,29 @@ 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 },
});
await this.populateIdentity(response.backstageIdentity);
return res.send(refreshInfo);
res.send(response);
} catch (error) {
return res.status(401).send(`${error.message}`);
res.status(401).send(`${error.message}`);
}
}
/**
* If the response from the OAuth provider includes a Backstage identity, we
* make sure it's populated with all the information we can derive from the user ID.
*/
private async populateIdentity(identity?: BackstageIdentity) {
if (!identity) {
return;
}
if (!identity.idToken) {
identity.idToken = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
});
}
}
@@ -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,48 @@ import {
export const makeProfileInfo = (
profile: passport.Profile,
params: any,
idToken?: string,
): ProfileInfo => {
const { displayName: name } = profile;
const { displayName } = profile;
let email = '';
if (profile.emails) {
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
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}`);
}
}
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 +79,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 +159,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 +170,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,40 @@ 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) {
const { response } = await executeFrameHandlerStrategy<OAuthResponse>(
req,
this._strategy,
);
return { 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 { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: 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,16 +118,33 @@ 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,
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
};
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
@@ -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,28 @@ 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 id = userId;
const idToken = await this.tokenIssuer.issueToken({
claims: { sub: id },
});
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
payload: user,
type: 'authorization_response',
response: {
providerInfo: {},
profile,
backstageIdentity: { id, idToken },
},
});
} catch (error) {
return postMessageResponse(res, 'http://localhost:3000', {
type: 'auth-result',
type: 'authorization_response',
error: {
name: error.name,
message: error.message,
@@ -82,7 +108,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 +117,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 +134,7 @@ export function createSamlProvider(
entryPoint: config.entryPoint,
issuer: config.issuer,
path: '/auth/saml/handler/frame',
tokenIssuer,
};
if (!opts.entryPoint || !opts.issuer) {
+60 -24
View File
@@ -89,25 +89,36 @@ 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: AuthResponse<OAuthProviderInfo>;
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<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<any>;
logout?(): Promise<void>;
}
/**
@@ -134,7 +145,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 +160,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 +174,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 +185,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 +195,27 @@ export type AuthProviderFactory = (
issuer: TokenIssuer,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
profile: ProfileInfo;
backstageIdentity?: BackstageIdentity;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type BackstageIdentity = {
/**
* The backstage user ID.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
*/
idToken?: string;
};
export type OAuthProviderInfo = {
/**
* An access token issued for the signed in user.
*/
@@ -203,14 +234,7 @@ 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.
*/
@@ -221,16 +245,22 @@ export type AuthInfoPrivate = {
* 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,20 +272,26 @@ export type RedirectInfo = {
status?: number;
};
/**
* Used to display login information to user, i.e. sidebar popup.
*
* It is also temporarily used as the profile of the signed-in user's Backstage
* identity, but we want to replace that with data from identity and/org catalog service
*/
export type ProfileInfo = {
/**
* Email ID of the signed in user.
*/
email: string;
email?: string;
/**
* 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 = {
@@ -67,12 +67,6 @@ export async function createRouter(
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
},
production: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: '',
clientSecret: '',
},
},
github: {
development: {
+1 -1
View File
@@ -14,5 +14,5 @@ for URL in \
--location \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/${URL}\"}"
--data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}"
done
+5 -3
View File
@@ -6,8 +6,10 @@ Welcome to MkDocs. This is the TechDocs implementation of MkDocs.
## Getting started
```
docker build ./container -t mkdocs-container
```bash
docker build ./container -t mkdocs-container
docker run -w /content -v $(pwd)/mock-docs:/content -p 8000:8000 -it mkdocs-container serve -a 0.0.0.0:8000
docker run -w /content -v $(pwd)/mock-docs:/content -p 8000:8000 -it mkdocs-container serve -a 0.0.0.0:8000
```
Then open up `http://localhost:8000` on your local machine.
+11 -12
View File
@@ -1,22 +1,21 @@
# Copyright 2020 Spotify AB
# 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
# 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
# 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.
# 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.
FROM python:3.7.7-alpine3.12
RUN apk update && apk --no-cache add gcc musl-dev
RUN pip install --upgrade pip && pip install mkdocs==1.1.2 mkdocs-material==5.3.2 pymdown-extensions==7.1
RUN pip install --upgrade pip && pip install mkdocs==1.1.2 mkdocs-material==5.3.2 mkdocs-monorepo-plugin==0.4.5 pymdown-extensions==7.1
ADD ./techdocs-core /techdocs-core
RUN pip install --no-index /techdocs-core
@@ -0,0 +1,2 @@
.tox
*.egg-info
@@ -0,0 +1,45 @@
# techdocs-core
This is the base [Mkdocs](https://mkdocs.org) plugin used when using Mkdocs with Spotify's TechDocs. It is written in Python and packages all of our Mkdocs defaults, such as theming, plugins, etc in a single plugin.
## Usage
**Installation instructions TBD.** We haven't published it to a Python registry yet.
Once you have installed the `mkdocs-techdocs-core` plugin, you'll need to add it to your `mkdocs.yml`.
```yaml
site_name: Backstage Docs
nav:
- Home: index.md
- Developing a Plugin: developing-a-plugin.md
plugins:
- techdocs-core
```
## Running Locally
You can install this package locally using `pip` and the `--editable` flag used for making developing Python packages.
```bash
pip install --editable .
```
You'll then have the `techdocs-core` package available to use in Mkdocs and `pip` will point the dependency to this folder.
## Running with Docker
In the parent `Dockerfile` we add this folder to the build and install the package locally in the container. In the future, we'll probably move away from this approach and have it download directly from a Python registry (and this folder will publish to one).
See the `README.md` located in the `mkdocs/` folder for more details on how to build and run the Docker container.
## Linting
```bash
pip install -r requirements.txt
python -m black src/
```
**Note:** This will write to all Python files in `src/` with the formatted code. If you would like to only check to see if it passes, simply append the `--check` flag.
@@ -1 +1,9 @@
# The "base" version of the Mkdocs project.
# Note: if you update this, also update `install_requires` in setup.py
# https://github.com/mkdocs/mkdocs
mkdocs==1.1.2
# The linter using for Python
# Note: This requires Python 3.6+ to run, but can format Python 2 code too.
# https://github.com/psf/black
black==19.10b0
@@ -1,17 +1,17 @@
"""
* 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.
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.
"""
from setuptools import setup, find_packages
@@ -19,17 +19,29 @@ from mkdocs.theme import Theme
from mkdocs.contrib.search import SearchPlugin
from mkdocs_monorepo_plugin.plugin import MonorepoPlugin
class TechDocsCore(BasePlugin):
def on_config(self, config):
# Theme
config['theme'] = Theme(name="material")
config["theme"] = Theme(name="material")
# Plugins
del config['plugins']['techdocs-core']
del config["plugins"]["techdocs-core"]
search_plugin = SearchPlugin()
search_plugin.load_config({})
config['plugins']['search'] = search_plugin
monorepo_plugin = MonorepoPlugin()
monorepo_plugin.load_config({})
config["plugins"]["search"] = search_plugin
config["plugins"]["monorepo"] = monorepo_plugin
search_plugin = SearchPlugin()
search_plugin.load_config({})
config["plugins"]["search"] = search_plugin
# Markdown Extensions
config['markdown_extensions'].append('admonition')
@@ -71,4 +83,3 @@ class TechDocsCore(BasePlugin):
config['markdown_extensions'].append('pymdownx.tilde')
return config
@@ -2,6 +2,7 @@ site_name: 'mock-docs'
nav:
- Home: index.md
- SubDocs: '!include ./sub-docs/mkdocs.yml'
plugins:
- techdocs-core
@@ -0,0 +1 @@
### This is an md file in another docs folder using the [MkDocs Monorepo Plugin](https://github.com/spotify/mkdocs-monorepo-plugin)
@@ -0,0 +1,4 @@
site_name: subdocs
nav:
- Home 2: "index.md"