Merge pull request #2613 from spotify/rugvip/idlookup

auth-backend: initial implementation of catalog user lookup
This commit is contained in:
Patrik Oldsberg
2020-09-29 01:28:02 +02:00
committed by GitHub
6 changed files with 132 additions and 14 deletions
+3
View File
@@ -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",
@@ -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<string, string>;
};
/**
* 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<UserEntity[]> {
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();
}
}
@@ -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';
@@ -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, {
@@ -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 = (
+11 -10
View File
@@ -34,20 +34,20 @@ export interface RouterOptions {
discovery: PluginEndpointDiscovery;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
export async function createRouter({
logger,
config,
discovery,
database,
}: RouterOptions): Promise<express.Router> {
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();