diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 68549d1574e90..8b9a47c224549 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8907,6 +8907,9 @@ const docTemplate = `{ "codersdk.ExternalAuthLink": { "type": "object", "properties": { + "authenticated": { + "type": "boolean" + }, "created_at": { "type": "string", "format": "date-time" @@ -8924,6 +8927,9 @@ const docTemplate = `{ "updated_at": { "type": "string", "format": "date-time" + }, + "validate_error": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 609e544550387..85fe382918ec4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7993,6 +7993,9 @@ "codersdk.ExternalAuthLink": { "type": "object", "properties": { + "authenticated": { + "type": "boolean" + }, "created_at": { "type": "string", "format": "date-time" @@ -8010,6 +8013,9 @@ "updated_at": { "type": "string", "format": "date-time" + }, + "validate_error": { + "type": "string" } } }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index f1104f7b1213a..e17d5e32d8476 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -16,21 +16,28 @@ import ( "github.com/coder/coder/v2/provisionersdk/proto" ) -func ExternalAuths(auths []database.ExternalAuthLink) []codersdk.ExternalAuthLink { +type ExternalAuthMeta struct { + Authenticated bool + ValidateError string +} + +func ExternalAuths(auths []database.ExternalAuthLink, meta map[string]ExternalAuthMeta) []codersdk.ExternalAuthLink { out := make([]codersdk.ExternalAuthLink, 0, len(auths)) for _, auth := range auths { - out = append(out, ExternalAuth(auth)) + out = append(out, ExternalAuth(auth, meta[auth.ProviderID])) } return out } -func ExternalAuth(auth database.ExternalAuthLink) codersdk.ExternalAuthLink { +func ExternalAuth(auth database.ExternalAuthLink, meta ExternalAuthMeta) codersdk.ExternalAuthLink { return codersdk.ExternalAuthLink{ ProviderID: auth.ProviderID, CreatedAt: auth.CreatedAt, UpdatedAt: auth.UpdatedAt, HasRefreshToken: auth.OAuthRefreshToken != "", Expires: auth.OAuthExpiry, + Authenticated: meta.Authenticated, + ValidateError: meta.ValidateError, } } diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 32d182a249c1e..b9d7e665b1637 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -337,6 +337,36 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { return } + // This process of authenticating each external link increases the + // response time. However, it is necessary to more correctly debug + // authentication issues. + // We can do this in parallel if we want to speed it up. + configs := make(map[string]*externalauth.Config) + for _, cfg := range api.ExternalAuthConfigs { + configs[cfg.ID] = cfg + } + // Check if the links are authenticated. + linkMeta := make(map[string]db2sdk.ExternalAuthMeta) + for i, link := range links { + if link.OAuthAccessToken != "" { + cfg, ok := configs[link.ProviderID] + if ok { + newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link) + meta := db2sdk.ExternalAuthMeta{ + Authenticated: valid, + } + if err != nil { + meta.ValidateError = err.Error() + } + // Update the link if it was potentially refreshed. + if err == nil && valid { + links[i] = newLink + } + break + } + } + } + // Note: It would be really nice if we could cfg.Validate() the links and // return their authenticated status. To do this, we would also have to // refresh expired tokens too. For now, I do not want to cause the excess @@ -344,7 +374,7 @@ func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) { // call. httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{ Providers: ExternalAuthConfigs(api.ExternalAuthConfigs), - Links: db2sdk.ExternalAuths(links), + Links: db2sdk.ExternalAuths(links, linkMeta), }) } diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index ad2988edb7d74..d0b48c1a058fe 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -76,6 +76,8 @@ type ExternalAuthLink struct { UpdatedAt time.Time `json:"updated_at" format:"date-time"` HasRefreshToken bool `json:"has_refresh_token"` Expires time.Time `json:"expires" format:"date-time"` + Authenticated bool `json:"authenticated"` + ValidateError string `json:"validate_error"` } // ExternalAuthLinkProvider are the static details of a provider. diff --git a/docs/api/git.md b/docs/api/git.md index 07678b0b8f2c5..71a0d2921f5fa 100644 --- a/docs/api/git.md +++ b/docs/api/git.md @@ -19,11 +19,13 @@ curl -X GET http://coder-server:8080/api/v2/external-auth \ ```json { + "authenticated": true, "created_at": "2019-08-24T14:15:22Z", "expires": "2019-08-24T14:15:22Z", "has_refresh_token": true, "provider_id": "string", - "updated_at": "2019-08-24T14:15:22Z" + "updated_at": "2019-08-24T14:15:22Z", + "validate_error": "string" } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d7b3fba4a117d..bcf54626d4952 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3005,11 +3005,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "authenticated": true, "created_at": "2019-08-24T14:15:22Z", "expires": "2019-08-24T14:15:22Z", "has_refresh_token": true, "provider_id": "string", - "updated_at": "2019-08-24T14:15:22Z" + "updated_at": "2019-08-24T14:15:22Z", + "validate_error": "string" } ``` @@ -3017,11 +3019,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ------------------- | ------- | -------- | ------------ | ----------- | +| `authenticated` | boolean | false | | | | `created_at` | string | false | | | | `expires` | string | false | | | | `has_refresh_token` | boolean | false | | | | `provider_id` | string | false | | | | `updated_at` | string | false | | | +| `validate_error` | string | false | | | ## codersdk.ExternalAuthUser diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 94ef2749f8959..850e6c6feeb42 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -132,6 +132,10 @@ const ObservabilitySettingsPage = lazy( const ExternalAuthPage = lazy( () => import("./pages/ExternalAuthPage/ExternalAuthPage"), ); +const UserExternalAuthSettingsPage = lazy( + () => + import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"), +); const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ); @@ -265,6 +269,10 @@ export const AppRouter: FC = () => { } /> + } + /> @@ -320,6 +328,10 @@ export const AppRouter: FC = () => { } /> } /> } /> + } + /> } /> } /> @@ -366,17 +378,13 @@ export const AppRouter: FC = () => { } /> - {/* Pages that don't have the dashboard layout */} + {/* Terminal and CLI auth pages don't have the dashboard layout */} } /> } /> } /> - } - /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9513ce023e449..d1a7bcfe3dbfc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -939,6 +939,19 @@ export const exchangeExternalAuthDevice = async ( return resp.data; }; +export const getUserExternalAuthProviders = + async (): Promise => { + const resp = await axios.get(`/api/v2/external-auth`); + return resp.data; + }; + +export const unlinkExternalAuthProvider = async ( + provider: string, +): Promise => { + const resp = await axios.delete(`/api/v2/external-auth/${provider}`); + return resp.data; +}; + export const getAuditLogs = async ( options: TypesGen.AuditLogsRequest, ): Promise => { diff --git a/site/src/api/queries/externalauth.ts b/site/src/api/queries/externalauth.ts new file mode 100644 index 0000000000000..684135db75d13 --- /dev/null +++ b/site/src/api/queries/externalauth.ts @@ -0,0 +1,40 @@ +import * as API from "api/api"; +import { QueryClient } from "react-query"; + +const getUserExternalAuthsKey = () => ["list", "external-auth"]; + +// listUserExternalAuths returns all configured external auths for a given user. +export const listUserExternalAuths = () => { + return { + queryKey: getUserExternalAuthsKey(), + queryFn: () => API.getUserExternalAuthProviders(), + }; +}; + +const getUserExternalAuthKey = (providerID: string) => [ + providerID, + "get", + "external-auth", +]; + +export const userExternalAuth = (providerID: string) => { + return { + queryKey: getUserExternalAuthKey(providerID), + queryFn: () => API.getExternalAuthProvider(providerID), + }; +}; + +export const validateExternalAuth = (_: QueryClient) => { + return { + mutationFn: API.getExternalAuthProvider, + }; +}; + +export const unlinkExternalAuths = (queryClient: QueryClient) => { + return { + mutationFn: API.unlinkExternalAuthProvider, + onSuccess: async () => { + await queryClient.invalidateQueries(["external-auth"]); + }, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8b4c80ced3673..af34c7d602e4e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -502,6 +502,8 @@ export interface ExternalAuthLink { readonly updated_at: string; readonly has_refresh_token: boolean; readonly expires: string; + readonly authenticated: boolean; + readonly validate_error: string; } // From codersdk/externalauth.go diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx index 9593c0f969af9..149488b557c10 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx @@ -18,6 +18,10 @@ export interface DeleteDialogProps { name: string; info?: string; confirmLoading?: boolean; + verb?: string; + title?: string; + label?: string; + confirmText?: string; } export const DeleteDialog: FC> = ({ @@ -28,6 +32,11 @@ export const DeleteDialog: FC> = ({ info, name, confirmLoading, + // All optional to change the verbiage. For example, "unlinking" vs "deleting" + verb, + title, + label, + confirmText, }) => { const hookId = useId(); const theme = useTheme(); @@ -52,14 +61,17 @@ export const DeleteDialog: FC> = ({ type="delete" hideCancel={false} open={isOpen} - title={`Delete ${entity}`} + title={title ?? `Delete ${entity}`} onConfirm={onConfirm} onClose={onCancel} confirmLoading={confirmLoading} disabled={!deletionConfirmed} + confirmText={confirmText} description={ <> -

Deleting this {entity} is irreversible!

+

+ {verb ?? "Deleting"} this {entity} is irreversible! +

{Boolean(info) && (

{info}

@@ -84,7 +96,7 @@ export const DeleteDialog: FC> = ({ onChange={(event) => setUserConfirmationText(event.target.value)} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} - label={`Name of the ${entity} to delete`} + label={label ?? `Name of the ${entity} to delete`} color={inputColor} error={displayErrorMessage} helperText={ diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index 6657aab80ab58..a6fc879171c49 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -11,6 +11,7 @@ import { SidebarHeader, SidebarNavItem, } from "components/Sidebar/Sidebar"; +import { GitIcon } from "components/Icons/GitIcon"; export const Sidebar: React.FC<{ user: User }> = ({ user }) => { const { entitlements } = useDashboard(); @@ -40,6 +41,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => { SSH Keys + + External Authentication + Tokens diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx index c1e61ac74832b..691b6681afddb 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx @@ -15,6 +15,7 @@ export interface ExternalAuthProps { externalAuthPollingState: ExternalAuthPollingState; startPollingExternalAuth: () => void; error?: string; + message?: string; } export const ExternalAuth: FC = (props) => { @@ -26,8 +27,14 @@ export const ExternalAuth: FC = (props) => { externalAuthPollingState, startPollingExternalAuth, error, + message, } = props; + const messageContent = + message ?? + (authenticated + ? `Authenticated with ${displayName}` + : `Login with ${displayName}`); return ( = (props) => { variant="contained" size="large" startIcon={ - {`${displayName} + displayIcon && ( + {`${displayName} + ) } disabled={authenticated} css={{ height: 52 }} @@ -61,9 +70,7 @@ export const ExternalAuth: FC = (props) => { startPollingExternalAuth(); }} > - {authenticated - ? `Authenticated with ${displayName}` - : `Login with ${displayName}`} + {messageContent} {externalAuthPollingState === "abandoned" && ( diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx new file mode 100644 index 0000000000000..ced75156bd0c5 --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage.tsx @@ -0,0 +1,99 @@ +import { FC, useState } from "react"; +import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView"; +import { + listUserExternalAuths, + unlinkExternalAuths, + validateExternalAuth, +} from "api/queries/externalauth"; +import { Section } from "components/SettingsLayout/Section"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { getErrorMessage } from "api/errors"; + +const UserExternalAuthSettingsPage: FC = () => { + const queryClient = useQueryClient(); + // This is used to tell the child components something was unlinked and things + // need to be refetched + const [unlinked, setUnlinked] = useState(0); + + const { + data: externalAuths, + error, + isLoading, + refetch, + } = useQuery(listUserExternalAuths()); + + const [appToUnlink, setAppToUnlink] = useState(); + const mutateParams = unlinkExternalAuths(queryClient); + const unlinkAppMutation = useMutation({ + ...mutateParams, + onSuccess: async () => { + await mutateParams.onSuccess(); + }, + }); + + const validateAppMutation = useMutation(validateExternalAuth(queryClient)); + + return ( +
+ { + setAppToUnlink(providerID); + }} + onValidateExternalAuth={async (providerID: string) => { + try { + const data = await validateAppMutation.mutateAsync(providerID); + if (data.authenticated) { + displaySuccess("Application link is valid."); + } else { + displayError( + "Application link is not valid. Please unlink the application and reauthenticate.", + ); + } + } catch (e) { + displayError( + getErrorMessage(e, "Error validating application link."), + ); + } + }} + /> + setAppToUnlink(undefined)} + onConfirm={async () => { + try { + await unlinkAppMutation.mutateAsync(appToUnlink!); + // setAppToUnlink closes the modal + setAppToUnlink(undefined); + // refetch repopulates the external auth data + await refetch(); + // this tells our child components to refetch their data + // as at least 1 provider was unlinked. + setUnlinked(unlinked + 1); + + displaySuccess("Successfully unlinked the oauth2 application."); + } catch (e) { + displayError(getErrorMessage(e, "Error unlinking application.")); + } + }} + /> +
+ ); +}; + +export default UserExternalAuthSettingsPage; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx new file mode 100644 index 0000000000000..3419f0f49a69c --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.stories.tsx @@ -0,0 +1,52 @@ +import { + MockGithubAuthLink, + MockGithubExternalProvider, +} from "testHelpers/entities"; +import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta: Meta = { + title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView", + component: UserExternalAuthSettingsPageView, + args: { + isLoading: false, + getAuthsError: undefined, + unlinked: 0, + auths: { + providers: [], + links: [], + }, + onUnlinkExternalAuth: () => {}, + onValidateExternalAuth: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const NoProviders: Story = {}; + +export const Authenticated: Story = { + args: { + ...meta.args, + auths: { + providers: [MockGithubExternalProvider], + links: [MockGithubAuthLink], + }, + }, +}; + +export const UnAuthenticated: Story = { + args: { + ...meta.args, + auths: { + providers: [MockGithubExternalProvider], + links: [ + { + ...MockGithubAuthLink, + authenticated: false, + }, + ], + }, + }, +}; diff --git a/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx new file mode 100644 index 0000000000000..96ef1066f43b7 --- /dev/null +++ b/site/src/pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView.tsx @@ -0,0 +1,240 @@ +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import type { + ListUserExternalAuthResponse, + ExternalAuthLinkProvider, + ExternalAuthLink, +} from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { Avatar } from "components/Avatar/Avatar"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth"; +import Divider from "@mui/material/Divider"; +import { + MoreMenu, + MoreMenuContent, + MoreMenuItem, + MoreMenuTrigger, + ThreeDotsButton, +} from "components/MoreMenu/MoreMenu"; +import { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage"; +import { useState, useCallback, useEffect } from "react"; +import { useQuery } from "react-query"; +import { userExternalAuth } from "api/queries/externalauth"; +import { FullScreenLoader } from "components/Loader/FullScreenLoader"; + +export type UserExternalAuthSettingsPageViewProps = { + isLoading: boolean; + getAuthsError?: unknown; + unlinked: number; + auths?: ListUserExternalAuthResponse; + onUnlinkExternalAuth: (provider: string) => void; + onValidateExternalAuth: (provider: string) => void; +}; + +export const UserExternalAuthSettingsPageView = ({ + isLoading, + getAuthsError, + auths, + unlinked, + onUnlinkExternalAuth, + onValidateExternalAuth, +}: UserExternalAuthSettingsPageViewProps): JSX.Element => { + if (getAuthsError) { + // Nothing to show if there is an error + return ; + } + + if (isLoading || !auths) { + return ; + } + + return ( + <> + + + + + Application + Link + + + + + {((auths.providers === null || auths.providers?.length === 0) && ( + + +
+ No providers have been configured! +
+
+
+ )) || + auths.providers?.map((app: ExternalAuthLinkProvider) => { + return ( + l.provider_id === app.id)} + onUnlinkExternalAuth={() => { + onUnlinkExternalAuth(app.id); + }} + onValidateExternalAuth={() => { + onValidateExternalAuth(app.id); + }} + /> + ); + })} +
+
+
+ + ); +}; + +interface ExternalAuthRowProps { + app: ExternalAuthLinkProvider; + link?: ExternalAuthLink; + unlinked: number; + onUnlinkExternalAuth: () => void; + onValidateExternalAuth: () => void; +} + +const ExternalAuthRow = ({ + app, + unlinked, + link, + onUnlinkExternalAuth, + onValidateExternalAuth, +}: ExternalAuthRowProps): JSX.Element => { + const name = app.id || app.type; + const authURL = "/external-auth/" + app.id; + + const { + externalAuth, + externalAuthPollingState, + refetch, + startPollingExternalAuth, + } = useExternalAuth(app.id, unlinked); + + const authenticated = externalAuth + ? externalAuth.authenticated + : link?.authenticated ?? false; + + return ( + + + + ) + } + /> + + + + + + {(link || externalAuth?.authenticated) && ( + + + + + + { + onValidateExternalAuth(); + // This is kinda jank. It does a refetch of the thing + // it just validated... But we need to refetch to update the + // login button. And the 'onValidateExternalAuth' does the + // message display. + await refetch(); + }} + > + Test Validate… + + + { + onUnlinkExternalAuth(); + await refetch(); + }} + > + Unlink… + + + + )} + + + ); +}; + +// useExternalAuth handles the polling of the auth to update the button. +const useExternalAuth = (providerID: string, unlinked: number) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { data: externalAuth, refetch } = useQuery({ + ...userExternalAuth(providerID), + refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + }); + + const signedIn = externalAuth?.authenticated; + + useEffect(() => { + if (unlinked > 0) { + void refetch(); + } + }, [refetch, unlinked]); + + useEffect(() => { + if (signedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, signedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + refetch, + }; +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2b422cefe5d68..555b21058e382 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3148,3 +3148,23 @@ export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { }, }, }; + +export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = { + id: "github", + type: "github", + device: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", + allow_refresh: true, + allow_validate: true, +}; + +export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { + provider_id: "github", + created_at: "", + updated_at: "", + has_refresh_token: true, + expires: "", + authenticated: true, + validate_error: "", +};