Skip to content
This repository was archived by the owner on Jan 13, 2026. It is now read-only.

Commit e0f8d17

Browse files
authored
Update dashboard's validateToken not to use k8s api server. (#4043)
* Update dashboard's validateToken not to use k8s api server. Signed-off-by: Michael Nelson <minelson@vmware.com> * Lint Signed-off-by: Michael Nelson <minelson@vmware.com> * Removed debug code. Signed-off-by: Michael Nelson <minelson@vmware.com> * Use `.toThrow` now that it works with async. Signed-off-by: Michael Nelson <minelson@vmware.com>
1 parent 032b9c9 commit e0f8d17

7 files changed

Lines changed: 166 additions & 65 deletions

File tree

cmd/kubeapps-apis/plugins/resources/v1alpha1/server.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,14 +415,18 @@ func resourceRefsEqual(r1, r2 *pkgsGRPCv1alpha1.ResourceRef) bool {
415415
}
416416

417417
// errorByStatus generates a meaningful error message
418+
// TODO(minelson): Move to a shared helper for plugins interacting
419+
// with the k8s cluster.
418420
func errorByStatus(verb, resource, identifier string, err error) error {
419421
if identifier == "" {
420422
identifier = "all"
421423
}
422424
if errors.IsNotFound(err) {
423425
return status.Errorf(codes.NotFound, "unable to %s the %s '%s' due to '%v'", verb, resource, identifier, err)
424-
} else if errors.IsForbidden(err) || errors.IsUnauthorized(err) {
425-
return status.Errorf(codes.PermissionDenied, "Unauthorized to %s the %s '%s' due to '%v'", verb, resource, identifier, err)
426+
} else if errors.IsForbidden(err) {
427+
return status.Errorf(codes.PermissionDenied, "Forbidden to %s the %s '%s' due to '%v'", verb, resource, identifier, err)
428+
} else if errors.IsUnauthorized(err) {
429+
return status.Errorf(codes.Unauthenticated, "Authorization required to %s the %s '%s' due to '%v'", verb, resource, identifier, err)
426430
} else if errors.IsAlreadyExists(err) {
427431
return status.Errorf(codes.AlreadyExists, "Cannot %s the %s '%s' due to '%v' as it already exists", verb, resource, identifier, err)
428432
}

dashboard/src/components/LoginForm/LoginForm.test.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import LoadingWrapper from "components/LoadingWrapper";
22
import { Location } from "history";
33
import { act } from "react-dom/test-utils";
44
import { Redirect } from "react-router";
5-
import { defaultStore, mountWrapper } from "shared/specs/mountWrapper";
5+
import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper";
66
import LoginForm from "./LoginForm";
77
import OAuthLogin from "./OauthLogin";
88
import TokenLogin from "./TokenLogin";
@@ -145,21 +145,33 @@ describe("oauth login form", () => {
145145
expect(wrapper.find("input#token").exists()).toBe(false);
146146
});
147147

148-
it("displays the oauth login if oauthLoginURI provided", () => {
149-
const wrapper = mountWrapper(defaultStore, <LoginForm {...props} />);
148+
it("displays the oauth login if authProxyEnabled", () => {
149+
const state = {
150+
...defaultStore,
151+
config: {
152+
authProxyEnabled: true,
153+
},
154+
};
155+
const wrapper = mountWrapper(getStore({ ...state }), <LoginForm {...props} />);
150156
expect(props.checkCookieAuthentication).toHaveBeenCalled();
151157
expect(wrapper.find(OAuthLogin)).toExist();
152158
expect(wrapper.find("a").findWhere(a => a.prop("href") === props.oauthLoginURI)).toExist();
153159
});
154160

155161
it("doesn't render the login form if the cookie has not been checked yet", () => {
162+
const state = {
163+
...defaultStore,
164+
config: {
165+
authProxyEnabled: true,
166+
},
167+
};
156168
const props2 = {
157169
...props,
158170
checkCookieAuthentication: jest.fn().mockReturnValue({
159171
then: jest.fn(() => false),
160172
}),
161173
};
162-
const wrapper = mountWrapper(defaultStore, <LoginForm {...props2} />);
174+
const wrapper = mountWrapper(getStore({ ...state }), <LoginForm {...props2} />);
163175
expect(wrapper.find(LoadingWrapper)).toExist();
164176
expect(wrapper.find(OAuthLogin)).not.toExist();
165177
});

dashboard/src/components/LoginForm/LoginForm.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import LoadingWrapper from "../../components/LoadingWrapper";
77
import "./LoginForm.css";
88
import OAuthLogin from "./OauthLogin";
99
import TokenLogin from "./TokenLogin";
10+
import { useSelector } from "react-redux";
11+
import { IStoreState } from "shared/types";
1012

1113
export interface ILoginFormProps {
1214
cluster: string;
@@ -25,14 +27,19 @@ function LoginForm(props: ILoginFormProps) {
2527
const intl = useIntl();
2628
const [token, setToken] = useState("");
2729
const [cookieChecked, setCookieChecked] = useState(false);
28-
const { oauthLoginURI, checkCookieAuthentication } = props;
30+
const { checkCookieAuthentication } = props;
31+
32+
const {
33+
config: { authProxyEnabled },
34+
} = useSelector((state: IStoreState) => state);
35+
2936
useEffect(() => {
30-
if (oauthLoginURI) {
37+
if (authProxyEnabled) {
3138
checkCookieAuthentication(props.cluster).then(() => setCookieChecked(true));
3239
} else {
3340
setCookieChecked(true);
3441
}
35-
}, [oauthLoginURI, checkCookieAuthentication, props.cluster]);
42+
}, [authProxyEnabled, checkCookieAuthentication, props.cluster]);
3643

3744
if (props.authenticating || !cookieChecked) {
3845
return (

dashboard/src/shared/Auth.test.ts

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,84 @@
1+
import { grpc } from "@improbable-eng/grpc-web";
12
import Axios, { AxiosResponse } from "axios";
3+
import { CheckNamespaceExistsRequest } from "gen/kubeappsapis/plugins/resources/v1alpha1/resources";
24
import * as jwt from "jsonwebtoken";
35
import { Auth } from "./Auth";
46
import { SupportedThemes } from "./Config";
7+
import { KubeappsGrpcClient } from "./KubeappsGrpcClient";
58

69
describe("Auth", () => {
10+
// Create a real client, but we'll stub out the function we're interested in.
11+
const client = new KubeappsGrpcClient().getResourcesServiceClientImpl();
12+
let mockClientCheckNamespaceExists: jest.MockedFunction<typeof client.CheckNamespaceExists>;
13+
714
beforeEach(() => {
815
Axios.get = jest.fn();
16+
mockClientCheckNamespaceExists = jest
17+
.fn()
18+
.mockImplementation(() => Promise.resolve({ exists: true } as CheckNamespaceExistsRequest));
19+
jest.spyOn(client, "CheckNamespaceExists").mockImplementation(mockClientCheckNamespaceExists);
20+
jest.spyOn(Auth, "resourcesClient").mockImplementation(() => client);
921
});
1022
afterEach(() => {
1123
jest.resetAllMocks();
1224
});
13-
it("should get an URL with the given token", async () => {
14-
const mock = jest.fn();
15-
Axios.get = mock;
25+
26+
it("should return without error when the endpoint succeeds with the given token", async () => {
1627
await Auth.validateToken("othercluster", "foo");
17-
expect(mock.mock.calls[0]).toEqual([
18-
"api/clusters/othercluster/",
19-
{ headers: { Authorization: "Bearer foo" } },
20-
]);
28+
29+
expect(Auth.resourcesClient).toHaveBeenCalledWith("foo");
30+
expect(mockClientCheckNamespaceExists).toHaveBeenCalledWith({
31+
context: {
32+
cluster: "othercluster",
33+
namespace: "default",
34+
},
35+
});
36+
});
37+
38+
it("should return without error when the endpoint returns PermissionDenied with the given token", async () => {
39+
mockClientCheckNamespaceExists = jest
40+
.fn()
41+
.mockImplementation(() => Promise.reject({ code: grpc.Code.PermissionDenied }));
42+
jest.spyOn(client, "CheckNamespaceExists").mockImplementation(mockClientCheckNamespaceExists);
43+
await Auth.validateToken("othercluster", "foo");
44+
45+
expect(Auth.resourcesClient).toHaveBeenCalledWith("foo");
46+
expect(mockClientCheckNamespaceExists).toHaveBeenCalledWith({
47+
context: {
48+
cluster: "othercluster",
49+
namespace: "default",
50+
},
51+
});
2152
});
2253

2354
describe("when there is an error", () => {
2455
[
2556
{
2657
name: "should throw an invalid token error for 401 responses",
2758
response: { status: 401, data: "ignored anyway" },
59+
grpcCode: grpc.Code.Unauthenticated,
2860
expectedError: new Error("invalid token"),
2961
},
3062
{
3163
name: "should throw a standard error for a 404 response",
32-
response: { status: 404, data: "Not found" },
33-
expectedError: new Error("404: Not found"),
64+
grpcCode: grpc.Code.NotFound,
65+
expectedError: new Error("not found"),
3466
},
3567
{
3668
name: "should throw a standard error for a 500 response",
37-
response: { status: 500, data: "Server exception" },
38-
expectedError: new Error("500: Server exception"),
39-
},
40-
{
41-
name: "should succeed for a 403 response",
42-
response: { status: 403, data: "Not Allowed" },
43-
expectedError: null,
69+
grpcCode: grpc.Code.Internal,
70+
expectedError: new Error("internal error"),
4471
},
4572
].forEach(testCase => {
4673
it(testCase.name, async () => {
47-
const mock = jest.fn(() => {
48-
return Promise.reject({ response: testCase.response });
49-
});
50-
Axios.get = mock;
51-
// TODO(absoludity): tried using `expect(fn()).rejects.toThrow()` but it seems we need
52-
// to upgrade jest for `toThrow()` to work with async.
53-
let err = null;
54-
try {
55-
await Auth.validateToken("default", "foo");
56-
} catch (e: any) {
57-
err = e;
58-
} finally {
59-
expect(err).toEqual(testCase.expectedError);
60-
}
74+
mockClientCheckNamespaceExists = jest
75+
.fn()
76+
.mockImplementation(() => Promise.reject({ code: testCase.grpcCode }));
77+
jest
78+
.spyOn(client, "CheckNamespaceExists")
79+
.mockImplementation(mockClientCheckNamespaceExists);
80+
81+
await expect(Auth.validateToken("default", "foo")).rejects.toThrow(testCase.expectedError);
6182
});
6283
});
6384
});

dashboard/src/shared/Auth.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ import * as jwt from "jsonwebtoken";
33
import { get } from "lodash";
44
import * as url from "shared/url";
55
import { IConfig } from "./Config";
6+
import { KubeappsGrpcClient } from "./KubeappsGrpcClient";
7+
import { grpc } from "@improbable-eng/grpc-web";
68
const AuthTokenKey = "kubeapps_auth_token";
79
const AuthTokenOIDCKey = "kubeapps_auth_token_oidc";
810

911
export class Auth {
12+
public static resourcesClient = (token?: string) =>
13+
new KubeappsGrpcClient().getResourcesServiceClientImpl(token);
14+
1015
public static getAuthToken() {
1116
return localStorage.getItem(AuthTokenKey);
1217
}
@@ -60,21 +65,27 @@ export class Auth {
6065
// Throws an error if the token is invalid
6166
public static async validateToken(cluster: string, token: string) {
6267
try {
63-
await Axios.get(url.api.k8s.base(cluster) + "/", {
64-
headers: { Authorization: `Bearer ${token}` },
68+
await this.resourcesClient(token).CheckNamespaceExists({
69+
context: { cluster, namespace: "default" },
6570
});
6671
} catch (e: any) {
67-
const res = e.response as AxiosResponse<any>;
68-
if (res.status === 401) {
72+
if (e.code === grpc.Code.Unauthenticated) {
6973
throw new Error("invalid token");
7074
}
71-
// A 403 authorization error only occurs if the token resulted in
72-
// successful authentication. We don't make any assumptions over RBAC
73-
// for the root "/" nonResourceURL or other required authz permissions
74-
// until operations on those resources are attempted (though we may
75-
// want to revisit this in the future).
76-
if (res.status !== 403) {
77-
throw new Error(`${res.status}: ${res.data}`);
75+
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#anonymous-requests
76+
// Since we are always passing a token here, A 403 authorization error
77+
// only occurs if the token resulted in successful authentication. We
78+
// don't make any assumptions over RBAC for the requested namespace or
79+
// other required authz permissions until operations on those resources
80+
// are attempted (though we may want to revisit this in the future).
81+
if (e.code !== grpc.Code.PermissionDenied) {
82+
if (e.code === grpc.Code.NotFound) {
83+
throw new Error("not found");
84+
}
85+
if (e.code === grpc.Code.Internal) {
86+
throw new Error("internal error");
87+
}
88+
throw new Error(`${e.code}: ${e.message}`);
7889
}
7990
}
8091
}
@@ -117,6 +128,7 @@ export class Auth {
117128
// The only error response which can possibly mean we did authenticate is
118129
// a 403 from the k8s api server (ie. we got through to k8s api server
119130
// but RBAC doesn't authorize us).
131+
// See note below about anonymous requests.
120132
if (response.status !== 403) {
121133
return false;
122134
}

dashboard/src/shared/KubeappsGrpcClient.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,45 @@ describe("kubeapps grpc core plugin service", () => {
8989
// TODO(agamez): try to also mock the messages ussing the new FakeTransportBuilder().withMessages([])
9090
// More details: https://github.com/kubeapps/kubeapps/issues/3165#issuecomment-882944035
9191
});
92+
93+
describe("kubeapps grpc resources plugin service", () => {
94+
afterEach(() => {
95+
jest.restoreAllMocks();
96+
});
97+
98+
const fakeAuthTransport = new FakeTransportBuilder()
99+
.withHeaders(new grpc.Metadata({ authorization: "Bearer topsecret" }))
100+
.build();
101+
102+
it("it set the metadata if using token auth", async () => {
103+
const kubeappsGrpcClient = new KubeappsGrpcClient(fakeAuthTransport);
104+
jest.spyOn(window.localStorage.__proto__, "getItem").mockReturnValue("topsecret");
105+
106+
const getClientMetadataMock = jest.spyOn(KubeappsGrpcClient.prototype, "getClientMetadata");
107+
kubeappsGrpcClient.getResourcesServiceClientImpl();
108+
109+
const expectedMetadata = new grpc.Metadata({ authorization: "Bearer topsecret" });
110+
expect(getClientMetadataMock.mock.results[0].value).toEqual(expectedMetadata);
111+
});
112+
113+
it("it doesn't set the metadata if not using token auth", async () => {
114+
const kubeappsGrpcClient = new KubeappsGrpcClient(fakeAuthTransport);
115+
jest.spyOn(window.localStorage.__proto__, "getItem").mockReturnValue(null);
116+
const getClientMetadataMock = jest.spyOn(KubeappsGrpcClient.prototype, "getClientMetadata");
117+
118+
kubeappsGrpcClient.getResourcesServiceClientImpl();
119+
120+
expect(getClientMetadataMock.mock.results[0].value).toBeUndefined();
121+
});
122+
123+
it("it sets the metadata if passed an explicit token", async () => {
124+
const kubeappsGrpcClient = new KubeappsGrpcClient(fakeAuthTransport);
125+
jest.spyOn(window.localStorage.__proto__, "getItem").mockReturnValue(null);
126+
const getClientMetadataMock = jest.spyOn(KubeappsGrpcClient.prototype, "getClientMetadata");
127+
128+
kubeappsGrpcClient.getResourcesServiceClientImpl("topsecret");
129+
130+
const expectedMetadata = new grpc.Metadata({ authorization: "Bearer topsecret" });
131+
expect(getClientMetadataMock.mock.results[0].value).toEqual(expectedMetadata);
132+
});
133+
});

dashboard/src/shared/KubeappsGrpcClient.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { Auth } from "./Auth";
1212
import * as URL from "./url";
1313

1414
export class KubeappsGrpcClient {
15-
private grpcWebImpl!: GrpcWebImpl;
1615
private transport: grpc.TransportFactory;
1716

1817
constructor(transport?: grpc.TransportFactory) {
@@ -21,22 +20,22 @@ export class KubeappsGrpcClient {
2120

2221
// getClientMetadata, if using token authentication, creates grpc metadata
2322
// and the token in the 'authorization' field
24-
public getClientMetadata() {
23+
public getClientMetadata(token?: string) {
24+
if (token) {
25+
return new grpc.Metadata({ authorization: `Bearer ${token}` });
26+
}
27+
2528
return Auth.getAuthToken()
2629
? new grpc.Metadata({ authorization: `Bearer ${Auth.getAuthToken()}` })
2730
: undefined;
2831
}
2932

30-
// getGrpcClient returns the already configured grpcWebImpl
31-
// if uncreated, it is created and returned
32-
public getGrpcClient = () => {
33-
return (
34-
this.grpcWebImpl ||
35-
new GrpcWebImpl(URL.api.kubeappsapis, {
36-
transport: this.transport,
37-
metadata: this.getClientMetadata(),
38-
})
39-
);
33+
// getGrpcClient returns a grpc client
34+
public getGrpcClient = (token?: string) => {
35+
return new GrpcWebImpl(URL.api.kubeappsapis, {
36+
transport: this.transport,
37+
metadata: this.getClientMetadata(token),
38+
});
4039
};
4140

4241
// Core APIs
@@ -49,8 +48,12 @@ export class KubeappsGrpcClient {
4948
}
5049

5150
// Resources API
52-
public getResourcesServiceClientImpl() {
53-
return new ResourcesServiceClientImpl(this.getGrpcClient());
51+
//
52+
// The resources API client implementation takes an optional token
53+
// only because it is used to validate token authentication before
54+
// the token is stored.
55+
public getResourcesServiceClientImpl(token?: string) {
56+
return new ResourcesServiceClientImpl(this.getGrpcClient(token));
5457
}
5558

5659
// Plugins (packages) APIs
@@ -68,4 +71,4 @@ export class KubeappsGrpcClient {
6871
}
6972
}
7073

71-
export default new KubeappsGrpcClient();
74+
export default KubeappsGrpcClient;

0 commit comments

Comments
 (0)