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
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes-common': patch
---
Add support for 'oidc' as authProvider for kubernetes authentication
and adds optional 'oidcTokenProvider' config value. This will allow
users to authenticate to kubernetes cluster using id tokens obtained
from the configured auth provider in their backstage instance.
+35 -7
View File
@@ -85,13 +85,14 @@ array. Users will see this value in the Software Catalog Kubernetes plugin.
This determines how the Kubernetes client authenticates with the Kubernetes
cluster. Valid values are:
| Value | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
| `aws` | This will use AWS credentials to access resources in EKS clusters |
| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters |
| `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters |
| Value | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. |
| `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. |
| `aws` | This will use AWS credentials to access resources in EKS clusters |
| `googleServiceAccount` | This will use the Google Cloud service account credentials to access resources in clusters |
| `azure` | This will use [Azure Identity](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview) to access resources in clusters |
| `oidc` | This will use [Oidc Tokens](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens) to authenticate to the Kubernetes API. When this is used the `oidcTokenProvider` field should also be set. |
##### `clusters.\*.skipTLSVerify`
@@ -115,6 +116,33 @@ kubectl -n <NAMESPACE> get secret $(kubectl -n <NAMESPACE> get sa <SERVICE_ACCOU
| base64 --decode
```
##### `clusters.\*.oidcTokenProvider` (optional)
This field is to be used when using the `oidc` auth provider. It will use the id tokens
from a configured [backstage auth provider](https://backstage.io/docs/auth/) to
authenticate to the cluster. The selected `oidcTokenProvider` needs to be properly
configured under `auth` for this to work.
```yaml
kubernetes:
clusterLocatorMethods:
- type: 'config'
clusters:
- name: test-cluster
url: http://localhost:8080
authProvider: oidc
oidcTokenProvider: okta # This value needs to match a config under auth.providers
auth:
providers:
okta:
development:
clientId: ${AUTH_OKTA_CLIENT_ID}
clientSecret: ${AUTH_OKTA_CLIENT_SECRET}
audience: ${AUTH_OKTA_AUDIENCE}
```
The following values are supported out-of-the-box by the frontend: `google`, `microsoft`, `okta`, `onelogin`.
##### `clusters.\*.dashboardUrl` (optional)
Specifies the link to the Kubernetes dashboard managing this cluster.
+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 });
},
}),
],