diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 6634363e4ce9e..9ababe6cd45ef 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -51,6 +51,11 @@ type OAuth2Configs struct { Github OAuth2Config } +const ( + signedOutErrorMessage string = "You are signed out or your session has expired. Please sign in again to continue." + internalErrorMessage string = "An internal error occurred. Please try again or contact the system administrator." +) + // ExtractAPIKey requires authentication using a valid API key. // It handles extending an API key if it comes close to expiry, // updating the last used time in the database. @@ -83,7 +88,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool } if cookieValue == "" { write(http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenKey), + Message: signedOutErrorMessage, + Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenKey), }) return } @@ -91,7 +97,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool // APIKeys are formatted: ID-SECRET if len(parts) != 2 { write(http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("Invalid %q cookie API key format.", codersdk.SessionTokenKey), + Message: signedOutErrorMessage, + Detail: fmt.Sprintf("Invalid %q cookie API key format.", codersdk.SessionTokenKey), }) return } @@ -100,13 +107,15 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool // Ensuring key lengths are valid. if len(keyID) != 10 { write(http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("Invalid %q cookie API key id.", codersdk.SessionTokenKey), + Message: signedOutErrorMessage, + Detail: fmt.Sprintf("Invalid %q cookie API key id.", codersdk.SessionTokenKey), }) return } if len(keySecret) != 22 { write(http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("Invalid %q cookie API key secret.", codersdk.SessionTokenKey), + Message: signedOutErrorMessage, + Detail: fmt.Sprintf("Invalid %q cookie API key secret.", codersdk.SessionTokenKey), }) return } @@ -114,13 +123,14 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool if err != nil { if errors.Is(err, sql.ErrNoRows) { write(http.StatusUnauthorized, codersdk.Response{ - Message: "API key is invalid.", + Message: signedOutErrorMessage, + Detail: "API key is invalid.", }) return } write(http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching API key by id.", - Detail: err.Error(), + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()), }) return } @@ -129,7 +139,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool // Checking to see if the secret is valid. if subtle.ConstantTimeCompare(key.HashedSecret, hashed[:]) != 1 { write(http.StatusUnauthorized, codersdk.Response{ - Message: "API key secret is invalid.", + Message: signedOutErrorMessage, + Detail: "API key secret is invalid.", }) return } @@ -146,7 +157,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool oauthConfig = oauth.Github default: write(http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Unexpected authentication type %q.", key.LoginType), + Message: internalErrorMessage, + Detail: fmt.Sprintf("Unexpected authentication type %q.", key.LoginType), }) return } @@ -174,7 +186,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool // Checking if the key is expired. if key.ExpiresAt.Before(now) { write(http.StatusUnauthorized, codersdk.Response{ - Message: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), + Message: signedOutErrorMessage, + Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), }) return } @@ -216,7 +229,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool }) if err != nil { write(http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("API key couldn't update: %s.", err.Error()), + Message: internalErrorMessage, + Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()), }) return } @@ -228,8 +242,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool roles, err := db.GetAuthorizationUserRoles(r.Context(), key.UserID) if err != nil { write(http.StatusUnauthorized, codersdk.Response{ - Message: "Internal error fetching user's roles.", - Detail: err.Error(), + Message: internalErrorMessage, + Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()), }) return } diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 1776720d90a7a..9a51ab658d2db 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -13,9 +13,10 @@ export const RequireAuth: React.FC = ({ children }) => { const xServices = useContext(XServiceContext) const [authState] = useActor(xServices.authXService) const location = useLocation() - const navigateTo = location.pathname === "/" ? "/login" : embedRedirect(location.pathname) + const isHomePage = location.pathname === "/" + const navigateTo = isHomePage ? "/login" : embedRedirect(location.pathname) if (authState.matches("signedOut")) { - return + return } else if (authState.hasTag("loading")) { return } else { diff --git a/site/src/components/SignInForm/SignInForm.stories.tsx b/site/src/components/SignInForm/SignInForm.stories.tsx index 1937dfd066a5b..88378c4acd23a 100644 --- a/site/src/components/SignInForm/SignInForm.stories.tsx +++ b/site/src/components/SignInForm/SignInForm.stories.tsx @@ -51,6 +51,17 @@ WithLoginError.args = { }, } +export const WithGetUserError = Template.bind({}) +WithGetUserError.args = { + ...SignedOut.args, + loginErrors: { + [LoginErrors.GET_USER_ERROR]: makeMockApiError({ + message: "You are logged out. Please log in to continue.", + detail: "API Key is invalid.", + }), + }, +} + export const WithCheckPermissionsError = Template.bind({}) WithCheckPermissionsError.args = { ...SignedOut.args, @@ -70,6 +81,18 @@ WithAuthMethodsError.args = { }, } +export const WithGetUserAndAuthMethodsError = Template.bind({}) +WithGetUserAndAuthMethodsError.args = { + ...SignedOut.args, + loginErrors: { + [LoginErrors.GET_USER_ERROR]: makeMockApiError({ + message: "You are logged out. Please log in to continue.", + detail: "API Key is invalid.", + }), + [LoginErrors.GET_METHODS_ERROR]: new Error("Failed to fetch auth methods"), + }, +} + export const WithGithub = Template.bind({}) WithGithub.args = { ...SignedOut.args, diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index a4d75dad63dd7..2860f6f3cedd2 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -25,6 +25,7 @@ interface BuiltInAuthFormValues { export enum LoginErrors { AUTH_ERROR = "authError", + GET_USER_ERROR = "getUserError", CHECK_PERMISSIONS_ERROR = "checkPermissionsError", GET_METHODS_ERROR = "getMethodsError", } @@ -36,6 +37,7 @@ export const Language = { emailRequired: "Please enter an email address.", errorMessages: { [LoginErrors.AUTH_ERROR]: "Incorrect email or password.", + [LoginErrors.GET_USER_ERROR]: "Failed to fetch user details.", [LoginErrors.CHECK_PERMISSIONS_ERROR]: "Unable to fetch user permissions.", [LoginErrors.GET_METHODS_ERROR]: "Unable to fetch auth methods.", }, diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 305012425130d..b84dac9c87106 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -4,7 +4,7 @@ import React, { useContext } from "react" import { Helmet } from "react-helmet" import { Navigate, useLocation } from "react-router-dom" import { Footer } from "../../components/Footer/Footer" -import { SignInForm } from "../../components/SignInForm/SignInForm" +import { LoginErrors, SignInForm } from "../../components/SignInForm/SignInForm" import { pageTitle } from "../../util/page" import { retrieveRedirect } from "../../util/redirect" import { XServiceContext } from "../../xServices/StateContext" @@ -28,6 +28,10 @@ export const useStyles = makeStyles((theme) => ({ }, })) +interface LocationState { + isRedirect: boolean +} + export const LoginPage: React.FC = () => { const styles = useStyles() const location = useLocation() @@ -35,12 +39,14 @@ export const LoginPage: React.FC = () => { const [authState, authSend] = useActor(xServices.authXService) const isLoading = authState.hasTag("loading") const redirectTo = retrieveRedirect(location.search) + const locationState = location.state ? (location.state as LocationState) : null + const isRedirected = locationState ? locationState.isRedirect : false const onSubmit = async ({ email, password }: { email: string; password: string }) => { authSend({ type: "SIGN_IN", email, password }) } - const { authError, checkPermissionsError, getMethodsError } = authState.context + const { authError, getUserError, checkPermissionsError, getMethodsError } = authState.context if (authState.matches("signedIn")) { return @@ -57,9 +63,10 @@ export const LoginPage: React.FC = () => { redirectTo={redirectTo} isLoading={isLoading} loginErrors={{ - authError, - checkPermissionsError, - getMethodsError, + [LoginErrors.AUTH_ERROR]: authError, + [LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null, + [LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError, + [LoginErrors.GET_METHODS_ERROR]: getMethodsError, }} onSubmit={onSubmit} />