diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8a9bfadca1..d8fe00ff31 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.23", + "@backstage/catalog-model": "^0.1.1-alpha.23", "@backstage/config": "^0.1.1-alpha.23", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -36,6 +37,7 @@ "knex": "^0.21.1", "moment": "^2.26.0", "morgan": "^1.10.0", + "node-fetch": "^2.6.1", "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-gitlab2": "^5.0.0", @@ -53,6 +55,7 @@ "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/jwt-decode": "2.2.1", + "@types/node-fetch": "^2.5.7", "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts new file mode 100644 index 0000000000..b09dfaa268 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -0,0 +1,55 @@ +/* + * 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 fetch from 'node-fetch'; +import { UserEntity } from '@backstage/catalog-model'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +type UserQuery = { + annotations: Record; +}; + +/** + * A catalog client tailored for reading out identity data from the catalog. + */ +export class CatalogIdentityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + async findUser(query: UserQuery): Promise { + const params = new URLSearchParams(); + params.append('kind', 'User'); + + for (const [key, value] of Object.entries(query.annotations)) { + params.append(`metadata.annotations.${key}`, value); + } + + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const response = await fetch(`${baseUrl}/entities?${params}`); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${text}`, + ); + } + + return response.json(); + } +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts new file mode 100644 index 0000000000..f15469668f --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/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 { CatalogIdentityClient } from './CatalogIdentityClient'; diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 3cc585605c..a93c964dbc 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -15,6 +15,7 @@ */ import express from 'express'; +import { Logger } from 'winston'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { executeFrameHandlerStrategy, @@ -36,15 +37,25 @@ import { OAuthRefreshRequest, } from '../../lib/oauth'; import passport from 'passport'; +import { CatalogIdentityClient } from '../../lib/catalog'; type PrivateInfo = { refreshToken: string; }; +export type GoogleAuthProviderOptions = OAuthProviderOptions & { + logger: Logger; + identityClient: CatalogIdentityClient; +}; + export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; + private readonly logger: Logger; + private readonly identityClient: CatalogIdentityClient; - constructor(options: OAuthProviderOptions) { + constructor(options: GoogleAuthProviderOptions) { + this.logger = options.logger; + this.identityClient = options.identityClient; // TODO: throw error if env variables not set? this._strategy = new GoogleStrategy( { @@ -138,17 +149,44 @@ export class GoogleAuthProvider implements OAuthHandlers { 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]; + const users = await this.identityClient.findUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + if (users.length !== 1) { + if (users.length > 1) { + this.logger.info( + `Multiple identities found for Google user ${profile.email}`, + ); + } else { + this.logger.info(`No identity found for Google user ${profile.email}`); + } - return { ...response, backstageIdentity: { id } }; + this.logger.warn( + `Falling back to allowing login based on email pattern, this will probably break in the future`, + ); + return { + ...response, + backstageIdentity: { id: profile.email.split('@')[0] }, + }; + } + + return { + ...response, + backstageIdentity: { + id: users[0].metadata.name, + }, + }; } } export const createGoogleProvider: AuthProviderFactory = ({ globalConfig, config, + logger, tokenIssuer, + discovery, }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const providerId = 'google'; @@ -160,6 +198,8 @@ export const createGoogleProvider: AuthProviderFactory = ({ clientId, clientSecret, callbackUrl, + logger, + identityClient: new CatalogIdentityClient({ discovery }), }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 5c05b02739..6776095f99 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,6 +18,7 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; import { Config } from '@backstage/config'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; export type AuthProviderConfig = { /** @@ -115,6 +116,7 @@ export type AuthProviderFactoryOptions = { config: Config; logger: Logger; tokenIssuer: TokenIssuer; + discovery: PluginEndpointDiscovery; }; export type AuthProviderFactory = ( diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 99b5ee800b..bcd1fc1794 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -34,20 +34,20 @@ export interface RouterOptions { discovery: PluginEndpointDiscovery; } -export async function createRouter( - options: RouterOptions, -): Promise { +export async function createRouter({ + logger, + config, + discovery, + database, +}: RouterOptions): Promise { const router = Router(); - const logger = options.logger.child({ plugin: 'auth' }); - const appUrl = options.config.getString('app.baseUrl'); - const authUrl = await options.discovery.getExternalBaseUrl('auth'); + const appUrl = config.getString('app.baseUrl'); + const authUrl = await discovery.getExternalBaseUrl('auth'); const keyDurationSeconds = 3600; - const keyStore = await DatabaseKeyStore.create({ - database: options.database, - }); + const keyStore = await DatabaseKeyStore.create({ database }); const tokenIssuer = new TokenFactory({ issuer: authUrl, keyStore, @@ -59,7 +59,7 @@ export async function createRouter( router.use(express.urlencoded({ extended: false })); router.use(express.json()); - const providersConfig = options.config.getConfig('auth.providers'); + const providersConfig = config.getConfig('auth.providers'); const providers = providersConfig.keys(); for (const providerId of providers) { @@ -70,6 +70,7 @@ export async function createRouter( config: providersConfig.getConfig(providerId), logger, tokenIssuer, + discovery, }); const r = Router();