OpenIddict / ClickUp integration quirks
Notes on non-obvious configuration required to get ClickUp's OAuth2 API working through OpenIddict's generic client, found while building and live-testing the connection type. Kept here instead of as inline comments so the reasoning isn't lost, without cluttering the registration code itself.
ClickUp has no discovery document (no .well-known/openid-configuration), so
OpenIddict has nothing to infer server capabilities from — every one of the
following had to be declared explicitly on the hand-built registration in
Configuration/ClickUpWebIntegrationBuilderExtensions.cs.
GrantTypesSupported / ResponseTypesSupported / ResponseModesSupported
Without these set on the registration's OpenIddictConfiguration, the first
attempt to connect fails at the authorization redirect with:
InvalidOperationException: No supported response type could be found in the server configuration...
OpenIddict's AttachGrantTypeAndResponseType handler negotiates the OAuth flow
by intersecting what the client registration declares (GrantTypes/ResponseTypes)
against what the server configuration declares supported. An empty
GrantTypesSupported is leniently treated as "assume supported" — but there's no
equivalent fallback for ResponseTypesSupported, so an empty list can never
match anything. ResponseModesSupported isn't strictly required for the
authorization-code + query flow (a global default fills in), but is set
explicitly for the same reason rather than relying on that default.
ClientAuthenticationMethods / TokenEndpointAuthMethodsSupported
Without these, the token exchange reaches ClickUp but is rejected with:
{"err":"Client secret does not match","ECODE":"OAUTH_011"}
When a server's supported-methods list is empty, OpenIddict's negotiation falls
back to client_secret_basic ("as it MUST be implemented by all OAuth 2.0
servers"). ClickUp's token endpoint only reads client_id/client_secret as
POST body parameters — it doesn't parse the Authorization: Basic header — so
the secret silently never arrives. Forcing client_secret_post on both the
registration and the configuration's supported-methods list fixes this.
ClientType
Without this, the token exchange still reaches ClickUp with a client_id but no
client_secret at all — same downstream error as above, even with the two
fixes above in place. This one is subtler: it's a race between two separate
IPostConfigureOptions<OpenIddictClientOptions> steps that both run once, in
registration order, whenever the options are first resolved:
- OpenIddict's own
OpenIddictClientConfigurationinfersregistration.ClientType— but only if it isn't already set — asConfidentialifClientSecretis non-empty, elsePublic. - Umbraco.Automate's
OpenIddictClientCredentialsConfiguratoris the thing that patches the realClientSecretin fromUmbraco:Automate:Providers:ClickUpconfig. Our own registration always starts withClientSecret = null.
If OpenIddict's inference runs before Umbraco's patching, it permanently infers
ClientType = Public from the still-empty secret. AttachTokenEndpointClientAuthenticationMethod
short-circuits to none for public clients — before it ever looks at
ClientAuthenticationMethods/TokenEndpointAuthMethodsSupported — so the secret
is dropped from every request regardless of the fixes above.
Setting ClientType = Confidential explicitly at registration-creation time
skips OpenIddict's inference entirely, so the outcome no longer depends on
which post-configure step happens to run first.
CopyLocalLockFileAssemblies on Automate.ClickUp.csproj
Without this, dotnet build fails during GenerateAppsettingsSchema with:
Could not load file or assembly 'Umbraco.Automate.OpenIddict.Core'...
The JsonSchemaGenerate MSBuild task (from Umbraco.JsonSchema.Extensions)
reflects over the freshly-built $(TargetPath) to read UmbracoAutomateClickUpSchema,
loading it into a plugin AssemblyLoadContext that resolves dependencies via an
AssemblyDependencyResolver scoped to files sitting next to that dll. Class
library builds don't otherwise copy transitive NuGet dependencies into their own
output folder (only apps do) — CopyLocalLockFileAssemblies forces that copy so
the resolver can find them during our own build.