diff --git a/.changeset/beige-pandas-thank.md b/.changeset/beige-pandas-thank.md new file mode 100644 index 0000000000..b2c52842fc --- /dev/null +++ b/.changeset/beige-pandas-thank.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add OneLogin Identity Provider to Auth Backend diff --git a/app-config.yaml b/app-config.yaml index 4746e822da..a495df0263 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -232,6 +232,14 @@ auth: $env: AUTH_MICROSOFT_CLIENT_SECRET tenantId: $env: AUTH_MICROSOFT_TENANT_ID + onelogin: + development: + clientId: + $env: AUTH_ONELOGIN_CLIENT_ID + clientSecret: + $env: AUTH_ONELOGIN_CLIENT_SECRET + issuer: + $env: AUTH_ONELOGIN_ISSUER costInsights: engineerCost: 200000 products: diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 388bc8d8b6..06ff6b07d3 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -21,6 +21,7 @@ import { githubAuthApiRef, samlAuthApiRef, microsoftAuthApiRef, + oneloginAuthApiRef, } from '@backstage/core'; export const providers = [ @@ -60,4 +61,10 @@ export const providers = [ message: 'Sign In using SAML', apiRef: samlAuthApiRef, }, + { + id: 'onelogin-auth-provider', + title: 'OneLogin', + message: 'Sign In using OneLogin', + apiRef: oneloginAuthApiRef, + }, ]; diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 946baeabd9..c538065f3a 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -320,3 +320,14 @@ export const samlAuthApiRef: ApiRef< id: 'core.auth.saml', description: 'Example of how to use SAML custom provider', }); + +export const oneloginAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.onelogin', + description: 'Provides authentication towards OneLogin APIs and identities', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 39223e8e15..7ef78d19cf 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -22,3 +22,4 @@ export * from './okta'; export * from './saml'; export * from './auth0'; export * from './microsoft'; +export * from './onelogin'; diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts new file mode 100644 index 0000000000..ff5c7c1990 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/onelogin/OneLoginAuth.ts @@ -0,0 +1,82 @@ +/* + * 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 OneLoginIcon from '@material-ui/icons/AcUnit'; +import { oneloginAuthApiRef } from '../../../definitions/auth'; +import { + OAuthRequestApi, + AuthProvider, + DiscoveryApi, +} from '../../../definitions'; +import { OAuth2 } from '../oauth2'; + +type CreateOptions = { + discoveryApi: DiscoveryApi; + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +const DEFAULT_PROVIDER = { + id: 'onelogin', + title: 'onelogin', + icon: OneLoginIcon, +}; + +const OIDC_SCOPES: Set = new Set([ + 'openid', + 'profile', + 'email', + 'phone', + 'address', + 'groups', + 'offline_access', +]); + +const SCOPE_PREFIX: string = 'onelogin.'; + +class OneLoginAuth { + static create({ + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions): typeof oneloginAuthApiRef.T { + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); + }, + }); + } +} + +export default OneLoginAuth; diff --git a/packages/core-api/src/apis/implementations/auth/onelogin/index.ts b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts new file mode 100644 index 0000000000..1d163207db --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/onelogin/index.ts @@ -0,0 +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. + */ + +export { default as OneLoginAuth } from './OneLoginAuth'; diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts index d0a777dc94..61d4f3a7e1 100644 --- a/packages/core/src/api-wrappers/defaultApis.ts +++ b/packages/core/src/api-wrappers/defaultApis.ts @@ -44,6 +44,8 @@ import { UrlPatternDiscovery, samlAuthApiRef, SamlAuth, + oneloginAuthApiRef, + OneLoginAuth, } from '@backstage/core-api'; export const defaultApis = [ @@ -142,4 +144,13 @@ export const defaultApis = [ }, factory: ({ discoveryApi }) => SamlAuth.create({ discoveryApi }), }), + createApiFactory({ + api: oneloginAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + OneLoginAuth.create({ discoveryApi, oauthRequestApi }), + }), ]; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index caa20e361b..5a9364bf6c 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,6 +45,7 @@ "passport-microsoft": "^0.1.0", "passport-oauth2": "^1.5.0", "passport-okta-oauth": "^0.0.1", + "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^1.3.3", "uuid": "^8.0.0", "winston": "^3.2.1", diff --git a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 3264fd8faa..28f15ee9e8 100644 --- a/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -116,7 +116,6 @@ export const executeFrameHandlerStrategy = async ( strategy.redirect = () => { reject(new Error('Unexpected redirect')); }; - strategy.authenticate(req, {}); }, ); diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 37761c2305..a764d0b50e 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -22,6 +22,7 @@ import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; +import { createOneLoginProvider } from './onelogin'; import { AuthProviderFactory, AuthProviderFactoryOptions } from './types'; const factories: { [providerId: string]: AuthProviderFactory } = { @@ -33,6 +34,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { auth0: createAuth0Provider, microsoft: createMicrosoftProvider, oauth2: createOAuth2Provider, + onelogin: createOneLoginProvider, }; export function createAuthProvider( diff --git a/plugins/auth-backend/src/providers/onelogin/index.ts b/plugins/auth-backend/src/providers/onelogin/index.ts new file mode 100644 index 0000000000..79af226cd1 --- /dev/null +++ b/plugins/auth-backend/src/providers/onelogin/index.ts @@ -0,0 +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. + */ + +export { createOneLoginProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/onelogin/provider.ts b/plugins/auth-backend/src/providers/onelogin/provider.ts new file mode 100644 index 0000000000..97cf182e5a --- /dev/null +++ b/plugins/auth-backend/src/providers/onelogin/provider.ts @@ -0,0 +1,173 @@ +/* + * 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 { Strategy as OneLoginStrategy } from 'passport-onelogin-oauth'; +import express from 'express'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, + OAuthStartRequest, + encodeState, + OAuthRefreshRequest, +} from '../../lib/oauth'; +import passport from 'passport'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; + +type PrivateInfo = { + refreshToken: string; +}; + +export type OneLoginProviderOptions = OAuthProviderOptions & { + issuer: string; +}; + +export class OneLoginProvider implements OAuthHandlers { + private readonly _strategy: any; + + constructor(options: OneLoginProviderOptions) { + this._strategy = new OneLoginStrategy( + { + issuer: options.issuer, + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + passReqToCallback: false as true, + }, + ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + 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: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this._strategy, { + accessType: 'offline', + prompt: 'consent', + scope: 'openid', + state: encodeState(req.state), + }); + } + + 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(req: OAuthRefreshRequest): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + req.refreshToken, + req.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, + }); + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('OIDC profile contained no email'); + } + + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export const createOneLoginProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'onelogin'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const issuer = envConfig.getString('issuer'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + + const provider = new OneLoginProvider({ + clientId, + clientSecret, + callbackUrl, + issuer, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); diff --git a/plugins/auth-backend/src/providers/onelogin/types.d.ts b/plugins/auth-backend/src/providers/onelogin/types.d.ts new file mode 100644 index 0000000000..d418f1bcd4 --- /dev/null +++ b/plugins/auth-backend/src/providers/onelogin/types.d.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ +declare module 'passport-onelogin-oauth' { + export class Strategy { + constructor(options: any, verify: any); + } +} diff --git a/yarn.lock b/yarn.lock index 06cab2d9ed..38a37b8ab3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18065,6 +18065,15 @@ passport-okta-oauth@^0.0.1: pkginfo "0.2.x" uid2 "0.0.3" +passport-onelogin-oauth@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/passport-onelogin-oauth/-/passport-onelogin-oauth-0.0.1.tgz#6e991ac6720783fdd80d4caa08c36cc71ef19962" + integrity sha512-EXFBqlJdHf5AX4QaiZsLfhgQUOR6z3zGA5479SUJF4I4rnAt7yasZEbs27pg8MRiQh/uLZEWLGMoVXr6LHV9mQ== + dependencies: + passport-oauth "1.0.0" + pkginfo "0.2.x" + uid2 "0.0.3" + passport-saml@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935"