feat: failed update refresh should redirect to login (#9442)

* chore: update refresh oauth token message
* chore: unauthorized -> forbidden for non authentication failures
* redirect to login on all 401 responses
* add unit test to verify 401 on expired refresh
This commit is contained in:
Steven Masley
2023-08-30 16:14:24 -05:00
committed by GitHub
parent b9fbc541c6
commit e827278db7
9 changed files with 58 additions and 19 deletions

View File

@ -296,7 +296,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
}).Token() }).Token()
if err != nil { if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{ return write(http.StatusUnauthorized, codersdk.Response{
Message: "Could not refresh expired Oauth token.", Message: "Could not refresh expired Oauth token. Try re-authenticating to resolve this issue.",
Detail: err.Error(), Detail: err.Error(),
}) })
} }

View File

@ -125,7 +125,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil) resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
require.NoError(t, err) require.NoError(t, err)
defer resp.Body.Close() defer resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode) require.Equal(t, http.StatusForbidden, resp.StatusCode)
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
require.Contains(t, string(body), "Path-based applications are disabled") require.Contains(t, string(body), "Path-based applications are disabled")

View File

@ -256,8 +256,8 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) { func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
if s.DisablePathApps { if s.DisablePathApps {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusUnauthorized, Status: http.StatusForbidden,
Title: "Unauthorized", Title: "Forbidden",
Description: "Path-based applications are disabled on this Coder deployment by the administrator.", Description: "Path-based applications are disabled on this Coder deployment by the administrator.",
RetryEnabled: false, RetryEnabled: false,
DashboardURL: s.DashboardURL.String(), DashboardURL: s.DashboardURL.String(),

View File

@ -359,7 +359,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
} }
if organization.ID != template.OrganizationID { if organization.ID != template.OrganizationID {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: fmt.Sprintf("Template is not in organization %q.", organization.Name), Message: fmt.Sprintf("Template is not in organization %q.", organization.Name),
}) })
return return

View File

@ -447,7 +447,7 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
require.Error(t, err) require.Error(t, err)
var apiErr *codersdk.Error var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr) require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
}) })
t.Run("AlreadyExists", func(t *testing.T) { t.Run("AlreadyExists", func(t *testing.T) {

View File

@ -1,12 +1,14 @@
package coderd_test package coderd_test
import ( import (
"context"
"net/http" "net/http"
"regexp" "regexp"
"testing" "testing"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
@ -357,6 +359,40 @@ func TestUserOIDC(t *testing.T) {
runner.ForceRefresh(t, client, claims) runner.ForceRefresh(t, client, claims)
} }
}) })
t.Run("FailedRefresh", func(t *testing.T) {
t.Parallel()
runner := setupOIDCTest(t, oidcTestConfig{
FakeOpts: []oidctest.FakeIDPOpt{
oidctest.WithRefreshHook(func(_ string) error {
// Always "expired" refresh token.
return xerrors.New("refresh token is expired")
}),
},
Config: func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
},
})
claims := jwt.MapClaims{
"email": "alice@coder.com",
}
// Login a new client that signs up
client, resp := runner.Login(t, claims)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Expire the token, cause a refresh
runner.ExpireOauthToken(t, client)
// This should fail because the oauth token refresh should fail.
_, err := client.User(context.Background(), codersdk.Me)
require.Error(t, err)
var apiError *codersdk.Error
require.ErrorAs(t, err, &apiError)
require.Equal(t, http.StatusUnauthorized, apiError.StatusCode())
require.ErrorContains(t, apiError, "refresh")
})
}) })
} }
@ -577,6 +613,7 @@ type oidcTestRunner struct {
// OIDC token to be expired and require a refresh. The refresh will use the claims provided. // OIDC token to be expired and require a refresh. The refresh will use the claims provided.
// It just calls the /users/me endpoint to trigger the refresh. // It just calls the /users/me endpoint to trigger the refresh.
ForceRefresh func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) ForceRefresh func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims)
ExpireOauthToken func(t *testing.T, client *codersdk.Client)
} }
type oidcTestConfig struct { type oidcTestConfig struct {
@ -584,6 +621,7 @@ type oidcTestConfig struct {
// Config allows modifying the Coderd OIDC configuration. // Config allows modifying the Coderd OIDC configuration.
Config func(cfg *coderd.OIDCConfig) Config func(cfg *coderd.OIDCConfig)
FakeOpts []oidctest.FakeIDPOpt
} }
func (r *oidcTestRunner) AssertRoles(t *testing.T, userIdent string, roles []string) { func (r *oidcTestRunner) AssertRoles(t *testing.T, userIdent string, roles []string) {
@ -633,10 +671,12 @@ func setupOIDCTest(t *testing.T, settings oidcTestConfig) *oidcTestRunner {
t.Helper() t.Helper()
fake := oidctest.NewFakeIDP(t, fake := oidctest.NewFakeIDP(t,
append([]oidctest.FakeIDPOpt{
oidctest.WithStaticUserInfo(settings.Userinfo), oidctest.WithStaticUserInfo(settings.Userinfo),
oidctest.WithLogging(t, nil), oidctest.WithLogging(t, nil),
// Run fake IDP on a real webserver // Run fake IDP on a real webserver
oidctest.WithServing(), oidctest.WithServing(),
}, settings.FakeOpts...)...,
) )
ctx := testutil.Context(t, testutil.WaitMedium) ctx := testutil.Context(t, testutil.WaitMedium)
@ -665,5 +705,8 @@ func setupOIDCTest(t *testing.T, settings oidcTestConfig) *oidcTestRunner {
ForceRefresh: func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) { ForceRefresh: func(t *testing.T, client *codersdk.Client, idToken jwt.MapClaims) {
helper.ForceRefresh(t, api.Database, client, idToken) helper.ForceRefresh(t, api.Database, client, idToken)
}, },
ExpireOauthToken: func(t *testing.T, client *codersdk.Client) {
helper.ExpireOauthToken(t, api.Database, client)
},
} }
} }

View File

@ -6,7 +6,6 @@ import { embedRedirect } from "../../utils/redirect"
import { FullScreenLoader } from "../Loader/FullScreenLoader" import { FullScreenLoader } from "../Loader/FullScreenLoader"
import { DashboardProvider } from "components/Dashboard/DashboardProvider" import { DashboardProvider } from "components/Dashboard/DashboardProvider"
import { ProxyProvider } from "contexts/ProxyContext" import { ProxyProvider } from "contexts/ProxyContext"
import { getErrorDetail } from "api/errors"
export const RequireAuth: FC = () => { export const RequireAuth: FC = () => {
const [authState, authSend] = useAuth() const [authState, authSend] = useAuth()
@ -23,10 +22,7 @@ export const RequireAuth: FC = () => {
// 401 Unauthorized // 401 Unauthorized
// If we encountered an authentication error, then our token is probably // If we encountered an authentication error, then our token is probably
// invalid and we should update the auth state to reflect that. // invalid and we should update the auth state to reflect that.
if ( if (error.response.status === 401) {
error.response.status === 401 &&
getErrorDetail(error)?.startsWith("API key expired")
) {
authSend("SIGN_OUT") authSend("SIGN_OUT")
} }

View File

@ -447,7 +447,7 @@ describe("UsersPage", () => {
server.use( server.use(
rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => { rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => {
return res( return res(
ctx.status(401), ctx.status(400),
ctx.json({ message: "message from the backend" }), ctx.json({ message: "message from the backend" }),
) )
}), }),

View File

@ -192,7 +192,7 @@ export type AuthEvent =
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest } | { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
export const authMachine = export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdADYD2yEAlgHZQCS1l6lyxAghpgCL7IDEEUtSI0AbqQDWRNFlz4iZCjXqNmrDlh54EY0gGN8lIQG0ADAF0z5xKAAOpWEyPUbIAB6IAjAFZThACwATAAcAGwAzOGewZ7hoYH+ADQgAJ6Igaam3oSeGf7BcbnBAJyBAL5lyTI4eAQk5FS0DE7qnFr8gsKEulKE1XJ1io0qLextvDrU4gbMJhbGntZIIPaOsy7LHgg+fkFhkdGx8Ump6bEA7ITn56HnkaFFpRVVnAMKDcrNamOavAJCIimkmkr1q7yUTVULB+3AmuhmzisxkCSzsDicQlcWx2ARCESiMTiCWSaQQgUC5124UCxQKDxCT0qIH6YPqEJG3w0sLwfDAACc+aQ+YRbMR8AAzIUAWz6oPkbOGX2hXPak2mhjmlgsrlWGI2oGxvlx+wJR2JpzJ-lM4UIphKgXuj3KTJZ8scUGEEAA8hg+Ng6ABxAByAH06EGrDr0essV5TOdslloqZPKZ-Od-NESV4Hjb-OESqFvAyEudgs9mXK6u7GJD-l0ekQawxI8tdTHNl5ybtPKnPKFin3ivns2TU3mi1FrmFSuEK67q5QPZ9qLyBUKRWL0JK+TLm9RW2i1s5Y9tu4Q8ZksiFgmnR93PLbad5h6FMgm5y6q02l56GH7A1DL0AFUABVDxWaMT07BAogHQhgmCfwBxvbwiwKe87WyYIM1ibxPHzG5PxeWRWRrSAGBFQVxUoYgRAgOi+GAgAFLg2FAgBRENmIAJS9AAxOgABkOIg9toINdIi0fcJzn7dMshfXtR2ibx-EIUJkOKbwiVw4p52-QhyIgSjbGo2iiFQWwIEMWhmPMxjOkBcRegXH8PQo6gqNIGi6MIKybOYOyHLANV9A1A95m1NsoMxGDPGfHIiPCfxvDSwcCJUwsci0nT4j0gzSLdX9PO83zLOs2yoHsnyLLXQVhVFCVpVlIrFw8kyvLM2q-ICqqavKsKEU1MTYv1dwvESzxktS9LexOUlonyHKBzyilM30r82vc2soB9dB62c4EjN-fbRuPOLJNghK-HJckX2KFLimHFTSkCQhZIKUwByQotnRImpiuXWh9sO7ogV6GszsWKMLvGrY4n8dSbn8bTfFyXx01e8JsLU580unG5CsB9rdtB-kGs3ZrdxOj0zuio89VPWTTHenHTEegpwm+nDwkwzSPuKIjEPOckkOJt5CD0IQaKgVA+WUUDMDAfjKD5WB0GA2B+QA4MwwjBnILh09b1CHIOdTN8Uoe+9pve0WhZKHHNJRiomWoUgIDgVw3NhpmYIAWm8YIAhxule1zfNilCFTi2yTno8pfICwpCXWSGFdRhVXg-Y7K6A9TUPg8KftYijmOLUOdTQhiDJShuVMEjToHPX23OJIm2CExDuT7QiLJEKy76L3wpDrmteJEOb0mV3by7O+jvwUodEI7hw-MK9JQIjgvGlSkQyI01Caeds8uf4a8fDiiuSk7VmpTMsr24bQIlDAluc5HruE-ab-LqQvPqeHwtwb6ZCQmlB+C0vA3D8C7FMCYdJxG8D-YypkQrdAYmAQBMFt7pg+nJOBc1PBZTUhpBS000r3GQVtEmp9OplQshgvyHsOLrj5Ngq629tL4PkpSIhr0ggrW0rpTMOEUElXod1cqTCiAUyFBwzuXDsiyV4YpDKmFC6v2EflUR5xxEdTQT1CqgVlADQsgo7EDxsjjzvhAjKUDtjZS0WtAqNDJY1mUG3GKxsYLLxtHcGkxwCIpnyPzNmelpoDnyHotxrJpbUFlvLRWytVbq01trdh3j-ZXTSn4BKyE7Q3hrsEbwttRZXBpGWR6aF0yaTdmUIAA */ /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdADYD2yEAlgHZQCS1l6lyxAghpgCL7IDEEUtSI0AbqQDWRNFlz4iZCjXqNmrDlh54EY0gGN8lIQG0ADAF0z5xKAAOpWEyPUbIAB6IAjAFZThACwATAAcAGwAzOGewZ7hoYH+ADQgAJ6Igaam3oSeGf7BcbnBAJyBAL5lyTI4eAQk5FS0DE7qnFr8gsKEulKE1XJ1io0qLextvDrU4gbMJhbGntZIIPaOsy7LHgg+fkFhkdGx8Ump6bEA7ITn56HnkaFFpRVVnAMKDcrNamOavAJCIimkmkr1q7yUTVULB+3AmuhmzisxkCSzsDicQlcWx2ARCESiMTiCWSaQQgUC5124UCxQKDxCT0qIH6YPqEJG3w0sLwfDAACc+aQ+YRbMR8AAzIUAWz6oPkbOGX2hXPak2mhjmlgsrlWGI2oGxvlx+wJR2JpzJ-lM4UIphKgXuj3KTJZ8scUGEEAA8hg+Ng6ABxAByAH06EGrDr0essV5TOdslloqZPKZ-Od-NESV4Hjb-OESqFvAyEudgs9mXK6u7GJD-l0ekQawxI8tdTHNl5ybtPKnPKFin3ivns2TU3mi1FrmFSuEK67q5QPZ9qLyBUKRWL0JK+TLm9RW2i1s5Y9tu4Q8ZksiFgmnR93PLbad5h6FMgm5y6q02l56GH7A1DL0AFUABVDxWaMT07BAogHQhgmCfwBxvbwiwKe87WyYIM1ibxPHzG5PxeWRWRrSAGBFQVxUoYgRAgOi+GAgAFLg2FAgBRENmIAJS9AAxOgABkOIg9toINdIi0fcJzn7dMshfXtR2ibx-EIUJkOKbwiVw4p52-QhyIgSjbGo2iiFQWwIEMWhmPMxjOkBcRegXH8PQo6gqNIGi6MIKybOYOyHLANV9A1A95m1NsoMxGDPGfHIiPCfxvDSwcCJUwsci0nT4j0gzSLdX9PO83zLOs2yoHsnyLLXQVhVFCVpVlIrFw8kyvLM2q-ICqqavKsKEU1MTYv1dwvESzxktS9LexOUlonyHKBzyilM30r82vc2soB9dB62c4EjN-fbRuPOLJNghK-HJckX2KFLimHFTSkCQhZIKUwByQotnRImpiuXWh9sO7ogV6GszsWKMLvGrY4n8dSbn8bTfFyXx01e8JsLU580unG5CsB9rdtB-kGs3ZrdxOj0zuio89VPWTTHenHTEegpwm+nDwkwzSPuKIjEPOckkOJt5CD0IQaKgVA+WUUDMDAfjKD5WB0GA2B+QA4MwwjBnILh09b1CHIOdTN8Uoe+9pve0WhZKHHNJRiomWoUgIDgVw3NhpmYIAWlCFS3w04cCwyDmKWpYjK22hUV1GFVeD9jsroD1MAhxule1zfNimDi1DnU0IYgyUoblTBIJbIkrvQwVOJIm2CE2CK5olKCIskQrLvovfCkOua14kQmugd2hhG8u5uC78FKHRCO4cPzQvSUCI4LxpUpEMiNNQjH0nPKn+GvDiM3KW52dcO+vmLQSNuEvzRC0uQtDZIPnbSu68rj9PAiCKuNaKOslMw31HJEbIA5874SvOEbSH9aZ-i6iFboDEwC-3igXM28RfB2kCH9I4o4gg2kflaeSalyShH3ltEmn9OplQsqgvyHsOLrj5Bgq669tIfTki7RSGVXpBBWtpXSmYcIIOMqZFBlA0GEApkKDhzcuHZFkvJSkc1PCYUzgRVaojojnAkXXKRPUKqBWUANCyijsQPGyEPO0s0lKZSLtlHRIj8obUMcDPaDcYrGxgvPG0dwaTHAIimfI-M2Z6WmlA8RNDJbS2oLLeWitlaq3VprbW7DfH+yumlPwj83zBBvKXYI3hbaiyuDSMsj00Lpk0m7MoQA */
createMachine( createMachine(
{ {
id: "authState", id: "authState",