Merge pull request #11328 from dbravovmw/kubernetes-oidc-auth

Add generic OIDC as a Kubernetes auth provider
This commit is contained in:
Fredrik Adelöw
2022-05-10 11:12:56 +02:00
committed by GitHub
20 changed files with 392 additions and 16 deletions
+1
View File
@@ -41,6 +41,7 @@ export interface ClusterDetails {
dashboardParameters?: JsonObject;
dashboardUrl?: string;
name: string;
oidcTokenProvider?: string | undefined;
// (undocumented)
serviceAccountToken?: string | undefined;
skipMetricsLookup?: boolean;
+8 -1
View File
@@ -52,7 +52,14 @@ export interface Config {
/** @visibility secret */
serviceAccountToken?: string;
/** @visibility frontend */
authProvider: 'aws' | 'google' | 'serviceAccount' | 'azure';
authProvider:
| 'aws'
| 'google'
| 'serviceAccount'
| 'azure'
| 'oidc';
/** @visibility frontend */
oidcTokenProvider?: string;
/** @visibility frontend */
skipTLSVerify?: boolean;
}>;
@@ -64,6 +64,11 @@ export class ConfigClusterLocator implements KubernetesClustersSupplier {
case 'azure': {
return clusterDetails;
}
case 'oidc': {
const oidcTokenProvider = c.getString('oidcTokenProvider');
return { oidcTokenProvider, ...clusterDetails };
}
case 'serviceAccount': {
return clusterDetails;
}
@@ -19,6 +19,7 @@ import { GoogleKubernetesAuthTranslator } from './GoogleKubernetesAuthTranslator
import { KubernetesAuthTranslatorGenerator } from './KubernetesAuthTranslatorGenerator';
import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernetesAuthTranslator';
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
describe('getKubernetesAuthTranslatorInstance', () => {
const sut = KubernetesAuthTranslatorGenerator;
@@ -43,6 +44,12 @@ describe('getKubernetesAuthTranslatorInstance', () => {
).toBe(true);
});
it('can return an auth translator for oidc auth', () => {
const authTranslator: KubernetesAuthTranslator =
sut.getKubernetesAuthTranslatorInstance('oidc');
expect(authTranslator instanceof OidcKubernetesAuthTranslator).toBe(true);
});
it('throws an error when asked for an auth translator for an unsupported auth type', () => {
expect(() => sut.getKubernetesAuthTranslatorInstance('linode')).toThrow(
'authProvider "linode" has no KubernetesAuthTranslator associated with it',
@@ -20,6 +20,7 @@ import { ServiceAccountKubernetesAuthTranslator } from './ServiceAccountKubernet
import { AwsIamKubernetesAuthTranslator } from './AwsIamKubernetesAuthTranslator';
import { GoogleServiceAccountAuthTranslator } from './GoogleServiceAccountAuthProvider';
import { AzureIdentityKubernetesAuthTranslator } from './AzureIdentityKubernetesAuthTranslator';
import { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
export class KubernetesAuthTranslatorGenerator {
static getKubernetesAuthTranslatorInstance(
@@ -41,6 +42,9 @@ export class KubernetesAuthTranslatorGenerator {
case 'googleServiceAccount': {
return new GoogleServiceAccountAuthTranslator();
}
case 'oidc': {
return new OidcKubernetesAuthTranslator();
}
default: {
throw new Error(
`authProvider "${authProvider}" has no KubernetesAuthTranslator associated with it`,
@@ -0,0 +1,67 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { OidcKubernetesAuthTranslator } from './OidcKubernetesAuthTranslator';
import { ClusterDetails } from '../types/types';
import { Entity } from '@backstage/catalog-model';
describe('OidcKubernetesAuthTranslator tests', () => {
const at = new OidcKubernetesAuthTranslator();
const entity: Entity = {
apiVersion: 'v1',
kind: 'service',
metadata: { name: 'test' },
};
const baseClusterDetails: ClusterDetails = {
name: 'test',
authProvider: 'oidc',
url: '',
};
it('returns cluster details with auth token', async () => {
const details = await at.decorateClusterDetailsWithAuth(
{
oidcTokenProvider: 'okta',
...baseClusterDetails,
},
{
auth: {
oidc: { okta: 'fakeToken' },
},
entity,
},
);
expect(details.serviceAccountToken).toBe('fakeToken');
});
it('returns error when oidcTokenProvider is not configured', async () => {
await expect(
at.decorateClusterDetailsWithAuth(baseClusterDetails, { entity }),
).rejects.toThrow(
'oidc authProvider requires a configured oidcTokenProvider',
);
});
it('returns error when token is not included in request body', async () => {
await expect(
at.decorateClusterDetailsWithAuth(
{ oidcTokenProvider: 'okta', ...baseClusterDetails },
{ entity },
),
).rejects.toThrow('Auth token not found under oidc.okta in request body');
});
});
@@ -0,0 +1,51 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { KubernetesAuthTranslator } from './types';
import { ClusterDetails } from '../types/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
export class OidcKubernetesAuthTranslator implements KubernetesAuthTranslator {
async decorateClusterDetailsWithAuth(
clusterDetails: ClusterDetails,
requestBody: KubernetesRequestBody,
): Promise<ClusterDetails> {
const clusterDetailsWithAuthToken: ClusterDetails = Object.assign(
{},
clusterDetails,
);
const { oidcTokenProvider } = clusterDetails;
if (!oidcTokenProvider || oidcTokenProvider === '') {
throw new Error(
`oidc authProvider requires a configured oidcTokenProvider`,
);
}
const authToken: string | undefined =
requestBody.auth?.oidc?.[oidcTokenProvider];
if (authToken) {
clusterDetailsWithAuthToken.serviceAccountToken = authToken;
} else {
throw new Error(
`Auth token not found under oidc.${oidcTokenProvider} in request body`,
);
}
return clusterDetailsWithAuthToken;
}
}
@@ -246,6 +246,7 @@ export class KubernetesBuilder {
name: cd.name,
dashboardUrl: cd.dashboardUrl,
authProvider: cd.authProvider,
oidcTokenProvider: cd.oidcTokenProvider,
})),
});
});
@@ -105,6 +105,10 @@ export interface ClusterDetails {
url: string;
authProvider: string;
serviceAccountToken?: string | undefined;
/**
* oidc provider used to get id tokens to authenticate against kubernetes
*/
oidcTokenProvider?: string | undefined;
skipTLSVerify?: boolean;
/**
* Whether to skip the lookup to the metrics server to retrieve pod resource usage.
+3
View File
@@ -195,6 +195,9 @@ export interface KubernetesRequestBody {
// (undocumented)
auth?: {
google?: string;
oidc?: {
[key: string]: string;
};
};
// (undocumented)
entity: Entity;
+3
View File
@@ -31,6 +31,9 @@ import { Entity } from '@backstage/catalog-model';
export interface KubernetesRequestBody {
auth?: {
google?: string;
oidc?: {
[key: string]: string;
};
};
entity: Entity;
}
+8 -1
View File
@@ -17,6 +17,7 @@ import type { JsonObject } from '@backstage/types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { OAuthApi } from '@backstage/core-plugin-api';
import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { V1ConfigMap } from '@kubernetes/client-node';
@@ -225,6 +226,7 @@ export interface KubernetesApi {
{
name: string;
authProvider: string;
oidcTokenProvider?: string | undefined;
}[]
>;
// (undocumented)
@@ -242,7 +244,12 @@ export const kubernetesApiRef: ApiRef<KubernetesApi>;
//
// @public (undocumented)
export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
constructor(options: { googleAuthApi: OAuthApi });
constructor(options: {
googleAuthApi: OAuthApi;
oidcProviders?: {
[key: string]: OpenIdConnectApi;
};
});
// (undocumented)
decorateRequestBodyForAuth(
authProvider: string,
+7 -1
View File
@@ -28,5 +28,11 @@ export interface KubernetesApi {
getObjectsByEntity(
requestBody: KubernetesRequestBody,
): Promise<ObjectsByEntityResponse>;
getClusters(): Promise<{ name: string; authProvider: string }[]>;
getClusters(): Promise<
{
name: string;
authProvider: string;
oidcTokenProvider?: string | undefined;
}[]
>;
}
@@ -53,8 +53,16 @@ export const useKubernetesObjects = (
}
const authProviders: string[] = [
...new Set(clusters.map(c => c.authProvider)),
...new Set(
clusters.map(
c =>
`${c.authProvider}${
c.oidcTokenProvider ? `.${c.oidcTokenProvider}` : ''
}`,
),
),
];
// For each auth type, invoke decorateRequestBodyForAuth on corresponding KubernetesAuthProvider
let requestBody: KubernetesRequestBody = {
entity,
@@ -0,0 +1,79 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { OAuthApi, OpenIdConnectApi } from '@backstage/core-plugin-api';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { KubernetesAuthProviders } from './KubernetesAuthProviders';
class MockAuthApi implements OAuthApi, OpenIdConnectApi {
constructor(private readonly token: string) {}
getAccessToken = jest.fn(async () => {
return this.token;
});
getIdToken = jest.fn(async () => {
return this.token;
});
}
const requestBody: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'service',
metadata: { name: 'test' },
},
};
describe('KubernetesAuthProviders tests', () => {
const kap = new KubernetesAuthProviders({
googleAuthApi: new MockAuthApi('googleToken'),
oidcProviders: {
okta: new MockAuthApi('oktaToken'),
},
});
it('adds token to request body for google authProvider', async () => {
const details = await kap.decorateRequestBodyForAuth('google', requestBody);
expect(details.auth?.google).toBe('googleToken');
});
it('adds token to request body for oidc authProvider', async () => {
const details = await kap.decorateRequestBodyForAuth(
'oidc.okta',
requestBody,
);
expect(details.auth?.oidc?.okta).toBe('oktaToken');
});
it('returns error for unknown authProvider', async () => {
await expect(
kap.decorateRequestBodyForAuth('unknown', requestBody),
).rejects.toThrow(
'authProvider "unknown" has no KubernetesAuthProvider defined for it',
);
});
it('returns error for missconfigured oidc authProvider', async () => {
await expect(
kap.decorateRequestBodyForAuth('oidc.random', requestBody),
).rejects.toThrow(
'KubernetesAuthProviders has no oidcProvider configured for oidc.random',
);
});
});
@@ -19,9 +19,10 @@ import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types';
import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider';
import { ServiceAccountKubernetesAuthProvider } from './ServiceAccountKubernetesAuthProvider';
import { AwsKubernetesAuthProvider } from './AwsKubernetesAuthProvider';
import { OAuthApi } from '@backstage/core-plugin-api';
import { OAuthApi, OpenIdConnectApi } from '@backstage/core-plugin-api';
import { GoogleServiceAccountAuthProvider } from './GoogleServiceAccountAuthProvider';
import { AzureKubernetesAuthProvider } from './AzureKubernetesAuthProvider';
import { OidcKubernetesAuthProvider } from './OidcKubernetesAuthProvider';
export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
private readonly kubernetesAuthProviderMap: Map<
@@ -29,7 +30,10 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
KubernetesAuthProvider
>;
constructor(options: { googleAuthApi: OAuthApi }) {
constructor(options: {
googleAuthApi: OAuthApi;
oidcProviders?: { [key: string]: OpenIdConnectApi };
}) {
this.kubernetesAuthProviderMap = new Map<string, KubernetesAuthProvider>();
this.kubernetesAuthProviderMap.set(
'google',
@@ -48,6 +52,18 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
'azure',
new AzureKubernetesAuthProvider(),
);
if (options.oidcProviders) {
Object.keys(options.oidcProviders).forEach(provider => {
this.kubernetesAuthProviderMap.set(
`oidc.${provider}`,
new OidcKubernetesAuthProvider(
provider,
options.oidcProviders![provider],
),
);
});
}
}
async decorateRequestBodyForAuth(
@@ -61,6 +77,12 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi {
requestBody,
);
}
if (authProvider.startsWith('oidc.')) {
throw new Error(
`KubernetesAuthProviders has no oidcProvider configured for ${authProvider}`,
);
}
throw new Error(
`authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`,
);
@@ -0,0 +1,43 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { KubernetesAuthProvider } from './types';
import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
export class OidcKubernetesAuthProvider implements KubernetesAuthProvider {
providerName: string;
authProvider: OpenIdConnectApi;
constructor(providerName: string, authProvider: OpenIdConnectApi) {
this.providerName = providerName;
this.authProvider = authProvider;
}
async decorateRequestBodyForAuth(
requestBody: KubernetesRequestBody,
): Promise<KubernetesRequestBody> {
const authToken: string = await this.authProvider.getIdToken();
const auth = { ...requestBody.auth };
if (auth.oidc) {
auth.oidc[this.providerName] = authToken;
} else {
auth.oidc = { [this.providerName]: authToken };
}
requestBody.auth = auth;
return requestBody;
}
}
+23 -3
View File
@@ -24,6 +24,9 @@ import {
discoveryApiRef,
identityApiRef,
googleAuthApiRef,
microsoftAuthApiRef,
oktaAuthApiRef,
oneloginAuthApiRef,
createRoutableExtension,
} from '@backstage/core-plugin-api';
@@ -45,9 +48,26 @@ export const kubernetesPlugin = createPlugin({
}),
createApiFactory({
api: kubernetesAuthProvidersApiRef,
deps: { googleAuthApi: googleAuthApiRef },
factory: ({ googleAuthApi }) => {
return new KubernetesAuthProviders({ googleAuthApi });
deps: {
googleAuthApi: googleAuthApiRef,
microsoftAuthApi: microsoftAuthApiRef,
oktaAuthApi: oktaAuthApiRef,
oneloginAuthApi: oneloginAuthApiRef,
},
factory: ({
googleAuthApi,
microsoftAuthApi,
oktaAuthApi,
oneloginAuthApi,
}) => {
const oidcProviders = {
google: googleAuthApi,
microsoft: microsoftAuthApi,
okta: oktaAuthApi,
onelogin: oneloginAuthApi,
};
return new KubernetesAuthProviders({ googleAuthApi, oidcProviders });
},
}),
],