From 77fe78370b931636c6a13cd02e84616e69c04df9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 2 Jul 2020 09:19:02 +0200 Subject: [PATCH] feat(auth): implement generic oauth2 provider --- packages/app/src/apis.ts | 13 +- packages/backend/README.md | 8 +- .../core-api/src/apis/definitions/auth.ts | 10 + .../src/apis/implementations/auth/index.ts | 3 +- .../implementations/auth/oauth2/OAuth2.ts | 164 +++++++++++++++ .../apis/implementations/auth/oauth2/index.ts | 18 ++ .../apis/implementations/auth/oauth2/types.ts | 28 +++ .../core/src/layout/Sidebar/UserSettings.tsx | 20 +- packages/storybook/.storybook/apis.js | 35 ++-- .../auth-backend/src/providers/factories.ts | 14 +- .../src/providers/oauth2/index.ts | 17 ++ .../src/providers/oauth2/provider.ts | 198 ++++++++++++++++++ plugins/auth-backend/src/providers/types.ts | 10 + plugins/auth-backend/src/service/router.ts | 10 + 14 files changed, 520 insertions(+), 28 deletions(-) create mode 100644 packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts create mode 100644 packages/core-api/src/apis/implementations/auth/oauth2/index.ts create mode 100644 packages/core-api/src/apis/implementations/auth/oauth2/types.ts create mode 100644 plugins/auth-backend/src/providers/oauth2/index.ts create mode 100644 plugins/auth-backend/src/providers/oauth2/provider.ts diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 4ac527ef45..e13046c33d 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,12 +26,14 @@ import { FeatureFlags, GoogleAuth, GithubAuth, + OAuth2, OktaAuth, GitlabAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oauth2ApiRef, oktaAuthApiRef, gitlabAuthApiRef, storageApiRef, @@ -109,7 +111,16 @@ export const apis = (config: ConfigApi) => { builder.add( gitlabAuthApiRef, GitlabAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + + builder.add( + oauth2ApiRef, + OAuth2.create({ + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), diff --git a/packages/backend/README.md b/packages/backend/README.md index cd71b50fb0..47574e12af 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -25,7 +25,13 @@ You should only need to do this once. After that, go to the `packages/backend` directory and run ```bash -AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start +AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \ +AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \ +AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \ +AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \ +SENTRY_TOKEN=x \ +LOG_LEVEL=debug \ +yarn start ``` Substitute `x` for actual values, or leave them as diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index c9a17f0153..ec7aba81c4 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -263,3 +263,13 @@ export const gitlabAuthApiRef = createApiRef< id: 'core.auth.gitlab', description: 'Provides authentication towards Gitlab APIs', }); + +/** + * Provides authentication for custom identity providers. + */ +export const oauth2ApiRef = createApiRef< + OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi +>({ + id: 'core.auth.oauth2', + description: 'Example of how to use oauth2 custom provider', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 9d89ba5b04..786e9fa771 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -export * from './google'; export * from './github'; export * from './gitlab'; +export * from './google'; +export * from './oauth2'; export * from './okta'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts new file mode 100644 index 0000000000..d2a3531d05 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -0,0 +1,164 @@ +/* + * 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 OAuth2Icon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { Observable } from '../../../../types'; +import { AuthProvider, OAuthRequestApi } from '../../../definitions'; +import { + AuthRequestOptions, + BackstageIdentity, + OAuthApi, + OpenIdConnectApi, + ProfileInfo, + ProfileInfoApi, + SessionState, + SessionStateApi, +} from '../../../definitions/auth'; +import { OAuth2Session } from './types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'oauth2', + title: 'Your Identity Provider', + icon: OAuth2Icon, +}; + +const SCOPE_PREFIX = ''; + +class OAuth2 + implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OAuth2Response): OAuth2Session { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + `${SCOPE_PREFIX}userinfo.email`, + `${SCOPE_PREFIX}userinfo.profile`, + ]), + sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes, + sessionShouldRefresh: (session: OAuth2Session) => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OAuth2(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken( + scope?: string | string[], + options?: AuthRequestOptions, + ) { + const normalizedScopes = OAuth2.normalizeScopes(scope); + const session = await this.sessionManager.getSession({ + ...options, + scopes: normalizedScopes, + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s]/).filter(Boolean); + + return new Set(scopeList); + } +} + +export default OAuth2; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/index.ts b/packages/core-api/src/apis/implementations/auth/oauth2/index.ts new file mode 100644 index 0000000000..52bcb1df2c --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/index.ts @@ -0,0 +1,18 @@ +/* + * 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 OAuth2 } from './OAuth2'; +export * from './types'; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-api/src/apis/implementations/auth/oauth2/types.ts new file mode 100644 index 0000000000..0e9c6f124d --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/types.ts @@ -0,0 +1,28 @@ +/* + * 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 OAuth2Session = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 937169c8bf..5f66fc6558 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -14,25 +14,26 @@ * limitations under the License. */ -import React, { useContext, useEffect } from 'react'; -import Collapse from '@material-ui/core/Collapse'; -import Star from '@material-ui/icons/Star'; -import SignOutIcon from '@material-ui/icons/MeetingRoom'; -import { SidebarContext } from './config'; import { - googleAuthApiRef, githubAuthApiRef, gitlabAuthApiRef, + googleAuthApiRef, identityApiRef, + oauth2ApiRef, oktaAuthApiRef, useApi, } from '@backstage/core-api'; +import Collapse from '@material-ui/core/Collapse'; +import SignOutIcon from '@material-ui/icons/MeetingRoom'; +import Star from '@material-ui/icons/Star'; +import React, { useContext, useEffect } from 'react'; +import { SidebarContext } from './config'; +import { SidebarItem } from './Items'; import { OAuthProviderSettings, OIDCProviderSettings, UserProfile as SidebarUserProfile, } from './Settings'; -import { SidebarItem } from './Items'; export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); @@ -68,6 +69,11 @@ export function SidebarUserSettings() { apiRef={oktaAuthApiRef} icon={Star} /> + , + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + const providerOptions = { + ...options, + accessType: 'offline', + prompt: 'consent', + }; + return await executeRedirectStrategy(req, this._strategy, providerOptions); + } + + async handler( + req: express.Request, + ): 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 { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + } + + // Use this function to grab the user profile info from the token + // Then populate the profile with it + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Profile does not contain a profile'); + } + + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createOAuth2Provider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const config = (envConfig as unknown) as GenericOAuth2ProviderConfig; + const { secure, appOrigin } = config; + const callbackURLParam = `?env=${env}`; + const opts = { + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`, + authorizationURL: config.authorizationURL, + tokenURL: config.tokenURL, + }; + + if ( + !opts.clientID || + !opts.clientSecret || + !opts.authorizationURL || + !opts.tokenURL + ) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', + ); + } + + logger.warn( + 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), { + disableRefresh: false, + providerId: 'oauth2', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index aec4f9dfd0..de4e076919 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -33,6 +33,11 @@ export type OAuthProviderOptions = { callbackURL: string; }; +export type GenericOAuth2ProviderOptions = OAuthProviderOptions & { + authorizationURL: string; + tokenURL: string; +}; + export type OAuthProviderConfig = { /** * Cookies can be marked with a secure flag to send cookies only when the request @@ -61,6 +66,11 @@ export type OAuthProviderConfig = { audience?: string; }; +export type GenericOAuth2ProviderConfig = OAuthProviderConfig & { + authorizationURL: string; + tokenURL: string; +}; + export type EnvironmentProviderConfig = { /** * key, values are environment names and OAuthProviderConfigs diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index fb533a4bdd..973b78858f 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -100,6 +100,16 @@ export async function createRouter( audience: process.env.AUTH_OKTA_AUDIENCE, }, }, + oauth2: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_OAUTH2_CLIENT_ID!, + clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!, + authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!, + tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!, + }, + }, }, }, };