Skip to content

Commit

Permalink
Handling 403 status codes with "anonymous" (#2163)
Browse files Browse the repository at this point in the history
* Handling 403 status codes with "anonymous"

 k8s api server nowadays defaults to allowing
 anonymous requests, so that rather than returning a 401, a 403 is returned.

* Add test case. Remove early rejections

* Add minor changes after PR review
  • Loading branch information
antgamdia committed Nov 16, 2020
1 parent b8524e8 commit 9a7b52c
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 11 deletions.
43 changes: 43 additions & 0 deletions dashboard/src/shared/Auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,46 @@ describe("is403FromAuthProxy", () => {
).toBe(false);
});
});

describe("isAnonymous", () => {
it("returns true if the message includes 'system:anonymous' in response.data", () => {
expect(
Auth.isAnonymous({
status: 403,
data:
'{"metadata":{},"status":"Failure","message":"selfsubjectaccessreviews.authorization.k8s.io is forbidden: User "system:anonymous" cannot create resource "selfsubjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope","reason":"Forbidden","details":{"group":"authorization.k8s.io","kind":"selfsubjectaccessreviews"},"code":403} {"namespaces":null}',
} as AxiosResponse<any>),
).toBe(true);
});
it("returns true if the message includes 'system:anonymous' in response.data.message", () => {
expect(
Auth.isAnonymous({
status: 403,
data: {
message:
'{"metadata":{},"status":"Failure","message":"selfsubjectaccessreviews.authorization.k8s.io is forbidden: User "system:anonymous" cannot create resource "selfsubjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope","reason":"Forbidden","details":{"group":"authorization.k8s.io","kind":"selfsubjectaccessreviews"},"code":403} {"namespaces":null}',
},
} as AxiosResponse<any>),
).toBe(true);
});
it("returns false if the message does not include 'system:anonymous' in response.data", () => {
expect(
Auth.isAnonymous({
status: 403,
data:
'namespaces is forbidden: User "system:serviceaccount:kubeapps:kubeapps-internal-kubeops" cannot list resource "namespaces" in API group "" at the cluster scope',
} as AxiosResponse<any>),
).toBe(false);
});
it("returns false if the message does not include 'system:anonymous' in response.data.message", () => {
expect(
Auth.isAnonymous({
status: 403,
data: {
message:
'namespaces is forbidden: User "system:serviceaccount:kubeapps:kubeapps-internal-kubeops" cannot list resource "namespaces" in API group "" at the cluster scope',
},
} as AxiosResponse<any>),
).toBe(false);
});
});
18 changes: 12 additions & 6 deletions dashboard/src/shared/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Axios, { AxiosResponse } from "axios";
import * as jwt from "jsonwebtoken";
import { get } from "lodash";
import * as url from "shared/url";
import { IConfig } from "./Config";

const AuthTokenKey = "kubeapps_auth_token";
const AuthTokenOIDCKey = "kubeapps_auth_token_oidc";

Expand Down Expand Up @@ -94,6 +94,16 @@ export class Auth {
return r.status === 403 && (!r.data || !r.data.message);
}

// isAnonymous returns true if the message includes "system:anonymous"
// in response.data or response.data.message
// the k8s api server nowadays defaults to allowing anonymous
// requests, so that rather than returning a 401, a 403 is returned if
// RBAC does not allow the anonymous user access.
public static isAnonymous(response: AxiosResponse): boolean {
const msg = get(response, "data.message") || get(response, "data");
return typeof msg === "string" && msg.includes("system:anonymous");
}

// isAuthenticatedWithCookie() does an anonymous GET request to determine if
// the request is authenticated with an http-only cookie (there is, by design,
// no way to determine via client JS whether an http-only cookie is present).
Expand All @@ -120,11 +130,7 @@ export class Auth {
// requests, so that rather than returning a 401, a 403 is returned if
// RBAC does not allow the anonymous user access. An http-only cookie
// will not result in an anonymous request, so...
const isAnon =
response.data &&
response.data.message &&
response.data.message.includes("system:anonymous");
return !isAnon;
return !this.isAnonymous(response);
}
return true;
}
Expand Down
46 changes: 44 additions & 2 deletions dashboard/src/shared/AxiosInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ describe("createAxiosInterceptorWithAuth", () => {
try {
await axios.get(testPath);
} catch (error) {
expect(store.getActions()).toEqual(expectedActions);
expect(error.message).toBe("Boom!");
}
expect(store.getActions()).toEqual(expectedActions);
expect(Auth.unsetAuthCookie).toHaveBeenCalled();
});

Expand All @@ -176,11 +177,52 @@ describe("createAxiosInterceptorWithAuth", () => {
try {
await axios.get(testPath);
} catch (error) {
expect(store.getActions()).toEqual(expectedActions);
expect(error.message).toBe("not ajson paylod");
}
expect(store.getActions()).toEqual(expectedActions);
expect(Auth.unsetAuthCookie).toHaveBeenCalled();
});

it("dispatches auth error and logout if 403 anonymous user and no auth proxy", async () => {
Auth.usingOIDCToken = jest.fn().mockReturnValue(false);
Auth.unsetAuthToken = jest.fn();
const expectedActions = [
{
type: "AUTHENTICATION_ERROR",
payload:
'{"metadata":{},"status":"Failure","message":"selfsubjectaccessreviews.authorization.k8s.io is forbidden: User "system:anonymous" cannot create resource "selfsubjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope","reason":"Forbidden","details":{"group":"authorization.k8s.io","kind":"selfsubjectaccessreviews"},"code":403} {"namespaces":null}',
},
{
type: "SET_AUTHENTICATED",
payload: {
authenticated: false,
oidc: false,
},
},
{
type: "CLEAR_CLUSTERS",
},
];

moxios.stubRequest(testPath, {
response: {
message:
'{"metadata":{},"status":"Failure","message":"selfsubjectaccessreviews.authorization.k8s.io is forbidden: User "system:anonymous" cannot create resource "selfsubjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope","reason":"Forbidden","details":{"group":"authorization.k8s.io","kind":"selfsubjectaccessreviews"},"code":403} {"namespaces":null}',
},
status: 403,
});

try {
await axios.get(testPath);
} catch (error) {
expect(error.message).toBe(
'{"metadata":{},"status":"Failure","message":"selfsubjectaccessreviews.authorization.k8s.io is forbidden: User "system:anonymous" cannot create resource "selfsubjectaccessreviews" in API group "authorization.k8s.io" at the cluster scope","reason":"Forbidden","details":{"group":"authorization.k8s.io","kind":"selfsubjectaccessreviews"},"code":403} {"namespaces":null}',
);
}
expect(store.getActions()).toEqual(expectedActions);
expect(Auth.unsetAuthToken).toHaveBeenCalled();
});

it("parses a forbidden response", async () => {
moxios.stubRequest(testPath, {
response: {
Expand Down
19 changes: 16 additions & 3 deletions dashboard/src/shared/AxiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,21 @@ export function addErrorHandling(axiosInstance: AxiosInstance, store: Store<ISto
dispatchErrorAndLogout(message);
return Promise.reject(new UnauthorizedError(message));
case 403:
// A 403 directly from the auth proxy requires reauthentication.
if (Auth.usingOIDCToken() && Auth.is403FromAuthProxy(response)) {
// Subcase 1:
// if usingOIDCToken: a 403 directly from the auth proxy
// always requires reauthentication.
// Subcase 2:
// if !usingOIDCToken, an anonymous response is sent when
// a serviceaccount token expires (eg., delete secret xxx)
// or the token is managed externally (eg., vsphere kubectl login)
// In this case, force reauthentication
if (Auth.is403FromAuthProxy(response) || Auth.isAnonymous(response)) {
dispatchErrorAndLogout(message);
}
// Subcase 3:
// The most likely case is just a 403 due to a lack of
// permissions in a certain namespace.
// In this case, just return the error message back to the user
try {
const jsonMessage = JSON.parse(message) as IRBACRole[];
return Promise.reject(
Expand All @@ -82,7 +93,9 @@ export function addErrorHandling(axiosInstance: AxiosInstance, store: Store<ISto
),
);
} catch (e) {
// Not a json error
// Subcase 4:
// A non-parseable 403 error.
// Do not require reauthentication and display error (ie. edge cases of proxy auth)
}
return Promise.reject(new ForbiddenError(message));
case 404:
Expand Down

0 comments on commit 9a7b52c

Please sign in to comment.