feat: add user/settings page for managing external auth (#10945)

Also add support for unlinking on the coder side to allow reflow.
This commit is contained in:
Steven Masley 2023-12-06 08:41:45 -06:00 committed by GitHub
parent f6891bc465
commit b376b2cd13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 577 additions and 23 deletions

6
coderd/apidoc/docs.go generated
View File

@ -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"
}
}
},

View File

@ -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"
}
}
},

View File

@ -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,
}
}

View File

@ -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),
})
}

View File

@ -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.

4
docs/api/git.md generated
View File

@ -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"
}
```

6
docs/api/schemas.md generated
View File

@ -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

View File

@ -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 = () => {
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
<Route
path="edit"
element={<TemplateVersionEditorPage />}
/>
</Route>
</Route>
</Route>
@ -320,6 +328,10 @@ export const AppRouter: FC = () => {
<Route path="schedule" element={<SchedulePage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route
path="external-auth"
element={<UserExternalAuthSettingsPage />}
/>
<Route path="tokens">
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
@ -366,17 +378,13 @@ export const AppRouter: FC = () => {
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Pages that don't have the dashboard layout */}
{/* Terminal and CLI auth pages don't have the dashboard layout */}
<Route
path="/:username/:workspace/terminal"
element={<TerminalPage />}
/>
<Route path="/cli-auth" element={<CliAuthenticationPage />} />
<Route path="/icons" element={<IconsPage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
/>
</Route>
</Routes>
</Router>

View File

@ -939,6 +939,19 @@ export const exchangeExternalAuthDevice = async (
return resp.data;
};
export const getUserExternalAuthProviders =
async (): Promise<TypesGen.ListUserExternalAuthResponse> => {
const resp = await axios.get(`/api/v2/external-auth`);
return resp.data;
};
export const unlinkExternalAuthProvider = async (
provider: string,
): Promise<string> => {
const resp = await axios.delete(`/api/v2/external-auth/${provider}`);
return resp.data;
};
export const getAuditLogs = async (
options: TypesGen.AuditLogsRequest,
): Promise<TypesGen.AuditLogResponse> => {

View File

@ -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"]);
},
};
};

View File

@ -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

View File

@ -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<PropsWithChildren<DeleteDialogProps>> = ({
@ -28,6 +32,11 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
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<PropsWithChildren<DeleteDialogProps>> = ({
type="delete"
hideCancel={false}
open={isOpen}
title={`Delete ${entity}`}
title={title ?? `Delete ${entity}`}
onConfirm={onConfirm}
onClose={onCancel}
confirmLoading={confirmLoading}
disabled={!deletionConfirmed}
confirmText={confirmText}
description={
<>
<p>Deleting this {entity} is irreversible!</p>
<p>
{verb ?? "Deleting"} this {entity} is irreversible!
</p>
{Boolean(info) && (
<p css={{ color: theme.palette.warning.light }}>{info}</p>
@ -84,7 +96,7 @@ export const DeleteDialog: FC<PropsWithChildren<DeleteDialogProps>> = ({
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={

View File

@ -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 }) => {
<SidebarNavItem href="ssh-keys" icon={FingerprintOutlinedIcon}>
SSH Keys
</SidebarNavItem>
<SidebarNavItem href="external-auth" icon={GitIcon}>
External Authentication
</SidebarNavItem>
<SidebarNavItem href="tokens" icon={VpnKeyOutlined}>
Tokens
</SidebarNavItem>

View File

@ -15,6 +15,7 @@ export interface ExternalAuthProps {
externalAuthPollingState: ExternalAuthPollingState;
startPollingExternalAuth: () => void;
error?: string;
message?: string;
}
export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
@ -26,8 +27,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
externalAuthPollingState,
startPollingExternalAuth,
error,
message,
} = props;
const messageContent =
message ??
(authenticated
? `Authenticated with ${displayName}`
: `Login with ${displayName}`);
return (
<Tooltip
title={authenticated && `${displayName} has already been connected.`}
@ -40,12 +47,14 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
variant="contained"
size="large"
startIcon={
<img
src={displayIcon}
alt={`${displayName} Icon`}
width={16}
height={16}
/>
displayIcon && (
<img
src={displayIcon}
alt={`${displayName} Icon`}
width={16}
height={16}
/>
)
}
disabled={authenticated}
css={{ height: 52 }}
@ -61,9 +70,7 @@ export const ExternalAuth: FC<ExternalAuthProps> = (props) => {
startPollingExternalAuth();
}}
>
{authenticated
? `Authenticated with ${displayName}`
: `Login with ${displayName}`}
{messageContent}
</LoadingButton>
{externalAuthPollingState === "abandoned" && (

View File

@ -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<string>();
const mutateParams = unlinkExternalAuths(queryClient);
const unlinkAppMutation = useMutation({
...mutateParams,
onSuccess: async () => {
await mutateParams.onSuccess();
},
});
const validateAppMutation = useMutation(validateExternalAuth(queryClient));
return (
<Section title="External Authentication">
<UserExternalAuthSettingsPageView
isLoading={isLoading}
getAuthsError={error}
auths={externalAuths}
unlinked={unlinked}
onUnlinkExternalAuth={(providerID: string) => {
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."),
);
}
}}
/>
<DeleteDialog
key={appToUnlink}
title="Unlink Application"
verb="Unlinking"
info="This does not revoke the access token from the oauth2 provider.
It only removes the link on this side. To fully revoke access, you must
do so on the oauth2 provider's side."
label="Name of the application to unlink"
isOpen={appToUnlink !== undefined}
confirmLoading={unlinkAppMutation.isLoading}
name={appToUnlink ?? ""}
entity="application"
onCancel={() => 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."));
}
}}
/>
</Section>
);
};
export default UserExternalAuthSettingsPage;

View File

@ -0,0 +1,52 @@
import {
MockGithubAuthLink,
MockGithubExternalProvider,
} from "testHelpers/entities";
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView",
component: UserExternalAuthSettingsPageView,
args: {
isLoading: false,
getAuthsError: undefined,
unlinked: 0,
auths: {
providers: [],
links: [],
},
onUnlinkExternalAuth: () => {},
onValidateExternalAuth: () => {},
},
};
export default meta;
type Story = StoryObj<typeof UserExternalAuthSettingsPageView>;
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,
},
],
},
},
};

View File

@ -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 <ErrorAlert error={getAuthsError} />;
}
if (isLoading || !auths) {
return <FullScreenLoader />;
}
return (
<>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Application</TableCell>
<TableCell>Link</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{((auths.providers === null || auths.providers?.length === 0) && (
<TableRow>
<TableCell colSpan={999}>
<div css={{ textAlign: "center" }}>
No providers have been configured!
</div>
</TableCell>
</TableRow>
)) ||
auths.providers?.map((app: ExternalAuthLinkProvider) => {
return (
<ExternalAuthRow
key={app.id}
app={app}
unlinked={unlinked}
link={auths.links.find((l) => l.provider_id === app.id)}
onUnlinkExternalAuth={() => {
onUnlinkExternalAuth(app.id);
}}
onValidateExternalAuth={() => {
onValidateExternalAuth(app.id);
}}
/>
);
})}
</TableBody>
</Table>
</TableContainer>
</>
);
};
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 (
<TableRow key={name}>
<TableCell>
<AvatarData
title={app.display_name || app.id}
// subtitle={template.description}
avatar={
app.display_icon !== "" && (
<Avatar src={app.display_icon} variant="square" fitImage />
)
}
/>
</TableCell>
<TableCell>
<ExternalAuth
displayName={name}
// We could specify the user is linked, but the link is invalid.
// This could indicate it expired, or was revoked on the other end.
authenticated={authenticated}
authenticateURL={authURL}
displayIcon=""
message={authenticated ? "Authenticated" : "Click to Login"}
externalAuthPollingState={externalAuthPollingState}
startPollingExternalAuth={startPollingExternalAuth}
></ExternalAuth>
</TableCell>
<TableCell>
{(link || externalAuth?.authenticated) && (
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={async () => {
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&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem
danger
onClick={async () => {
onUnlinkExternalAuth();
await refetch();
}}
>
Unlink&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
);
};
// useExternalAuth handles the polling of the auth to update the button.
const useExternalAuth = (providerID: string, unlinked: number) => {
const [externalAuthPollingState, setExternalAuthPollingState] =
useState<ExternalAuthPollingState>("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,
};
};

View File

@ -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: "",
};