Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions coderd/database/db2sdk/db2sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
32 changes: 31 additions & 1 deletion coderd/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,14 +337,44 @@ 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
// traffic on this request, so the user will have to do this with a separate
// call.
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{
Providers: ExternalAuthConfigs(api.ExternalAuthConfigs),
Links: db2sdk.ExternalAuths(links),
Links: db2sdk.ExternalAuths(links, linkMeta),
})
}

Expand Down
2 changes: 2 additions & 0 deletions codersdk/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/api/git.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion docs/api/schemas.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 13 additions & 5 deletions site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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 />} />
Expand Down Expand Up @@ -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>
Expand Down
13 changes: 13 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> => {
Expand Down
40 changes: 40 additions & 0 deletions site/src/api/queries/externalauth.ts
Original file line number Diff line number Diff line change
@@ -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"]);
},
};
};
2 changes: 2 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 15 additions & 3 deletions site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>> = ({
Expand All @@ -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();
Expand All @@ -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>
Expand All @@ -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={
Expand Down
4 changes: 4 additions & 0 deletions site/src/components/SettingsLayout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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>
Expand Down
Loading