feat: add client credentials OAuth2 applications for API access#18846
Conversation
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
c654948 to
eac2681
Compare
8c29819 to
168176b
Compare
eac2681 to
ac0d1f6
Compare
4fcf5b1 to
65b1054
Compare
|
I'm doing the review, but I'm really missing a video demoing the feature, or at least screenshots. |
I’ll add some screenshots tomorrow |
There was a problem hiding this comment.
Thanks for working on the FE 🙏 👏
Since there are a good amount of changes, and I want to be sure I'm going to review them properly, and not overwhelming you, I think it would make sense to run a few review rounds.
For the first round, I pointed one blocker, and a few major improvements we can do. After we work on that, I will run another review and see if there is any minor left.
Sounds good?
| @@ -1726,11 +1726,18 @@ class ApiMethods { | |||
| getOAuth2ProviderApps = async ( | |||
| filter?: TypesGen.OAuth2ProviderAppFilter, | |||
There was a problem hiding this comment.
I think we can simplify this function by just making the filter not optional and adding a default value as {} so we could use it directly in the URLSearchParams without having to do many ifs.
getOAuth2ProviderApps = async (
filter: TypesGen.OAuth2ProviderAppFilter = {},
): Promise<TypesGen.OAuth2ProviderApp[]> => {
const params = new URLSearchParams(filter);
const resp = await this.axios.get(
"/api/v2/oauth2-provider/apps", { params },
);
return resp.data;
};| }; | ||
|
|
||
| export const getApps = (userId?: string) => { | ||
| export const getApps = (filter?: { user_id?: string; owner_id?: string }) => { |
There was a problem hiding this comment.
Another function we can improve.
export const getApps = (filter: OAuth2ProviderAppFilter = {}) => {
return {
queryKey: [...appsKey, filter],
queryFn: () => API.getOAuth2ProviderApps(filter),
};
};| readonly icon: string; | ||
| readonly created_at: string; | ||
| readonly grant_types: readonly string[]; | ||
| readonly user_id?: string; |
There was a problem hiding this comment.
Is it possible to have an OAuth2ProviderApp without an user?
There was a problem hiding this comment.
What is the use case?
There was a problem hiding this comment.
Different flows/grant types. Client credential flows have to be owned/tied to a user, while authorization code flow must not be tied to a user.
There was a problem hiding this comment.
Do you think would make sense to left a comment in this attribute, in the codersdk type? I think this is something I would easily forget in the future.
There was a problem hiding this comment.
My overall feeling is this component is doing way more than a form should do. I think we can improve by:
- Remove props that are not going to change and use the value directly
- Execute the mutation in the form, and only call the
onSubmitoronSubmitSuccessif the parent needs that
By applying these, I think we can significantly reduce the complexity of this component. This may require a few changes, so probably will need a second review round.
| event.preventDefault(); | ||
| const formData = new FormData(event.target as HTMLFormElement); | ||
|
|
||
| onSubmit({ |
| const [showCodeExample, setShowCodeExample] = useState(false); | ||
|
|
||
| // Fetch users and find the owner by user_id | ||
| const usersQuery = useQuery({ |
There was a problem hiding this comment.
The BE supports filtering a user by username or email, if that is not available in the app, I think we can do.
- Add the username or user email to the app response
- Add an endpoint into the backend to fetch user data by ID
I don't think we should move forward with the current solution.
| hideCancel | ||
| {fullNewSecret && app && ( | ||
| <Dialog | ||
| css={{ |
There was a problem hiding this comment.
We should avoid adding a new dialog style only for this case. Did you take a look in the other dialogs? Wondering what you want to achieve here that is not achievable by using the existent dialogs.
There was a problem hiding this comment.
I can refactor that into a component if you'd like, but none of the other dialogs allow resizing since the max width is always hardcoded.
When opening the code example in the dialog, I want it to expand both horizontally and vertically so that the word wrap on the curl command in the code example doesn't apply, and users can see the command they are about to copy and paste without having first to copy it and then inspect it in another editor.
There was a problem hiding this comment.
Ok, lets keep it as it is - in terms of design. I will give it a try and see what we can do to better support this use case in our design system.
| OAuth2 client secret | ||
| </h3> | ||
| <div | ||
| css={{ |
There was a problem hiding this comment.
We are not using inline CSS styles anymore. Give a try to TailwindCSS class names. You can see many of them in the code base.
| } | ||
| /> | ||
| <div css={{ marginTop: 24 }}> | ||
| <Link |
There was a problem hiding this comment.
Looks like it should be a button instead of a link. Links should be used for navigation.
| SSH Keys | ||
| </SidebarNavItem> | ||
| <SidebarNavItem href="tokens" icon={KeyIcon}> | ||
| <SidebarNavItem href="tokens" icon={KeyIcon} end={false}> |
There was a problem hiding this comment.
Wondering why only these two have end set to false 🤔
There was a problem hiding this comment.
None of the other items has nested menus. Only the tokens and OAuth2 app section can navigate to another route, thus making the nav item "unselected".
There was a problem hiding this comment.
I will give this a try and see how it behaves.
| Valid: true, | ||
| }) | ||
| if err != nil { | ||
| httpapi.InternalServerError(rw, err) |
There was a problem hiding this comment.
Let's also add a log here; we probably don't want to leak the error details back in the HTTP response, so let's just say something generic; admins can refer to the logs for details.
There was a problem hiding this comment.
You're still returning the error directly; we probably don't want to do that; ditto for the other cases.
65b1054 to
f044533
Compare
ac0d1f6 to
ef3c66e
Compare
5f71ebd to
c84c4be
Compare
|
@BrunoQuaresma, sorry it took me a while, but here's a video showing off the client_credentials UI, including the expanding dialog: coder_client_credentials_ui.mp4 |
ef3c66e to
3f1495c
Compare
c84c4be to
40d7fd1
Compare
BrunoQuaresma
left a comment
There was a problem hiding this comment.
Thanks for working in all my review comments. I think it is good to go 👍
PS: I think the UX is ok and there are a few improvements we can do. Ideally, we would have a design phase when adding or changing features.
cc78865 to
4ce585e
Compare
|
|
||
| test: | ||
| $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="./..." -- -v -short -count=1 $(if $(RUN),-run $(RUN)) | ||
| $(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(if $(PACKAGE),$(PACKAGE),./...)" -- -v -short -count=1 $(if $(RUN),-run $(RUN)) |
4ce585e to
fd42ee1
Compare
487fa65 to
c5bbc6e
Compare
fd42ee1 to
65adff3
Compare
- Add ClientCredentialsAppForm and ClientCredentialsAppRow components - Update API schemas to include created_at, grant_types, and user_id fields - Add dedicated pages for creating and managing client credentials apps - Update sidebar navigation and routing for OAuth2 client credentials - Enhance OAuth2AppPageView with user ownership information display Change-Id: I3271c7fb995d7225dd6cc830066fa2c8cb29720a Signed-off-by: Thomas Kosiewski <tk@coder.com>
c5bbc6e to
c659bbc
Compare
65adff3 to
7d8adca
Compare
buenos-nachos
left a comment
There was a problem hiding this comment.
Did a quick scan since I figured that this PR has had a decent amount of review already. My main concern was around some of the conditional rendering logic
| : ""; | ||
|
|
||
| const resp = await this.axios.get(`/api/v2/oauth2-provider/apps?${params}`); | ||
| const params = new URLSearchParams(filter as Record<string, string>); |
There was a problem hiding this comment.
Nit: while it shouldn't be an issue now, I'd prefer it if we avoid the type assertion, since this will silence any type mismatches in the future. How confident are we that we won't need to support non-strings values in the future?
I feel like the Go way of doing things makes sense here: copy the values from the filter into a new object that's passed directly to the constructor:
new URLSearchParams({
user_id: filter.user_id ?? "",
owner_id: filter.owner_id ?? ""
});| </Button> | ||
| </Stack> | ||
|
|
||
| <form className="mt-2.5" onSubmit={form.handleSubmit}> |
There was a problem hiding this comment.
Nit: mt-2.5 can be swapped for pt-2.5, and should be less likely to cause layout side effects from margin collapse
| <div className="font-medium mb-1"> | ||
| About Client Credentials Applications | ||
| </div> | ||
| <div className="text-sm"> |
There was a problem hiding this comment.
This long-form text should be a p tag
| if (!appId) { | ||
| navigate("/settings/oauth2-provider"); | ||
| return null; | ||
| } |
There was a problem hiding this comment.
Two big problems here:
navigateis a side effect function, and shouldn't be called from inside a render like this.- The conditional rendering breaks React's rules of hooks. In practice, I don't see this code breaking, because the app ID shouldn't be able to change without a full page change, but breaking this rule in general can cause a React component to completely blow up and trigger our global error boundary. I don't feel comfortable with having code that the React team actively pushes you away from, because the justification here feels like "just trust me bro"
Two changes we can do:
- Add a
useEffectcall to handle the navigationuseEffect(() => { if (!appId) { navigate("/settings/oauth2-provider"); } }, [navigate, appId]);
- Anywhere after all the hooks mount, do conditional rendering that instead returns out the
Navigatecomponent (which is basically a wrapper overuseNavigate+useEffectunder the hood)
There was a problem hiding this comment.
React Router has a weird policy of not stabilizing their function references from their hooks, so if throwing navigate in the dependency array causes too many renders (or worse, infinite renders), you can do this instead:
const stableNavigate = useEffectEvent(navigate);
useEffect(() => {
if (!appId) {
stableNavigate("/settings/oauth2-provider");
}
}, [stableNavigate, appId]);| const handleUpdateApp = async (data: { | ||
| name: string; | ||
| icon: string; | ||
| grant_types: TypesGen.OAuth2ProviderGrantType[]; | ||
| redirect_uris: string[]; | ||
| }) => { |
There was a problem hiding this comment.
This type definition is used in a few different places, and it's complicated enough that I feel like it'd be better to extract it into a custom type alias
| const appToDelete = ownedApps.find((app) => app.id === appIdToDelete); | ||
|
|
||
| const handleManageOwnedApp = (app: TypesGen.OAuth2ProviderApp) => { | ||
| navigate(`${app.id}`); |
There was a problem hiding this comment.
Is there a reason for the template literal?

Add OAuth2 Client Credentials Grant Type Support
This PR adds support for OAuth2 client credentials grant type, enabling server-to-server API access. Key changes include:
created_at,grant_types, anduser_idcreated_atfield to OAuth2ProviderAppSecret modelThis implementation provides a more secure alternative to long-lived API tokens by allowing users to create OAuth2 applications with client credentials grant type for server-to-server authentication.