Repository: sebadob/rauthy (language: rust)

Issue:
[ADFS] rfc6749 The client MUST NOT use more than one authentication method in each request.
Hi,
I had my first days with using rauthy and wanted to add Microsoft ADFS (Windows Server 2019) as OIDC Provider and got it nearly working after some problems.


At the very beginning I got the following error:
PeerIncompatible ServerTlsVersionIsDisabledByOurConfig
Windows 2019 has no support for TLS 1.3.
But I got it somehow working in the end with "Allow insecure TLS certificates" for now.
Will maybe try with Server 2025 later again.

Reverse-Proxies seem to be problematic for ADFS.
(https://peter.upfold.org.uk/blog/2016/05/31/reverse-proxying-adfs-with-nginx/)

But the final error now seems to be, that ADFS doesn't like to get "Multiple client authentication methods" in a request.

I found the same problem and hints here:
https://github.com/coreos/go-oidc/issues/181


> Keycloak was sending auth-code to exchange for access token at ADFS server's token endpoint, with both client-authentication methods: basic and post, which violates


and here:
https://github.com/FusionAuth/fusionauth-issues/issues/445


> The problem that is described by MSIS9631 is caused because we are sending the client_secret in the request body and as part of a Basic Authorization header.
> 
> We did this to support a few IdPs such as Discord that only accept the client_secret in the request body, even though the OIDC spec says that at a minimum you have to support HTTP Basic Authorization.
> 
> This is the first time I've seen an IdP puke because we send it both ways. To solve this and support other IdPs that require it to be one or the other, we'll have to expose some additional configuration to allow the IdP configuration to specify which mode to utilize, client_secret_basic or client_secret_post. [there seemed to be a typo in the original message]



From:
https://datatracker.ietf.org/doc/html/rfc6749#section-2.3

The client MUST NOT use more than one authentication method in each request.


Could that option (client_secret_basic or client_secret_post or both) be added here,too?



Discussion / hints:
> Windows 2019 has no support for TLS 1.3.

Even though I will add a limitation for TLS 1.2+ soon, at the moment, Rauthy does not limit the versions and all of them are allowed by default. Do you maybe use a reverse proxy for TLS termination which restircts the versions?

> The client MUST NOT use more than one authentication method in each request.

Rauthy does not do this. What it does, it checks for `Basic` auth first when you use a confidential client on `/token` and falls back the `client_id` and `client_secret` in the body, if the `Authorization` header does not exist. This was needed for compatibility in some situations back then. But it does not mix confidential and public clients.
At which point exactly do you get this error? On `/authorize` already, or when ADFS tries to fetch a token set from a received `code` ? The only place where Rauthy advertises these 2 methods is in the `openid-configuration` of course to let client's know it can do both.

Edit:

Ahh, sorry, my bad. Now I get what you mean. You are using ADFS as upstream and Rauthy sends client credentials in both places. Yes that's true. I can have a look at this, probably add an option to the UI config which method should be used.
I don't really get why this would be a problem, but this can be limited.

Now I get the TLS issue as well.
Yesterday I tested with Auth0 and that was working OK (just as feedback).

Did another test with JumpCloud today and failed to get it working.
I expect that if there is an option to configure one explicitly like for ADFS it might also fix this provider.
The last error looks like a bug.


I have to select only one of the three options, cannot select multiple:
Client Secret Post
Client Secret Basic
Public (None PKCE)

I tried with all 3 and got the following errors:

**Client Secret Post**

`HTTP 500: {"timestamp":1734510840,"error":"Internal","message":"HTTP 401 during POST https://oauth.id.jumpcloud.com/oauth2/token for upstream auth provider 'XXX'\n{\"error\":\"invalid_client\",\"error_description\":\"Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The OAuth 2.0 Client supports client authentication method 'client_secret_post', but method 'client_secret_basic' was requested. You must configure the OAuth 2.0 client's 'token_endpoint_auth_method' value to accept 'client_secret_basic'.\"}"}`

**Client Secret Basic**

`HTTP 500: {"timestamp":1734510848,"error":"Internal","message":"HTTP 401 during POST https://oauth.id.jumpcloud.com/oauth2/token for upstream auth provider 'XXX'\n{\"error\":\"invalid_client\",\"error_description\":\"Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The OAuth 2.0 Client supports client authentication method 'client_secret_basic', but method 'client_secret_post' was requested. You must configure the OAuth 2.0 client's 'token_endpoint_auth_method' value to accept 'client_secret_post'.\"}"}`


**Public (None PKCE)**

`HTTP 400: {"timestamp":1734510551,"error":"BadRequest","message":"Payload deserialization error: Error(\"invalid type: string \\\"true\\\", expected a boolean\", line: 1, column: 156)"}`


Oh yes the jumpcloud logs look buggy tbh.

If ADFS supports public client + S256 PKCE, that should work without any issues, because it does with Rauthy for many other clients.

That it complains about the client creds being present in the `Authorization` header as `Basic` and in the body does not make any sense from a security standpoint on though. What the RFC says about the MUST NOT use multiple methods is do not mix and match public and confidential clients, which makes a lot of sense.

The thing with Basic Auth + Body, as long as they contain the same information, is that any way is as secure as the others. These secrets could leak only if an attacker would be able to sniff the traffic in situations like unencrypted HTTP after a reverse proxy, or on an infected Firewall with DPI and such things. But then it does not matter at all, if they are in the header, in the body, or both. No other upstream IdP had any issues with that so far and I tested quite a few already.

Anyway, I will add an option to select them in the configuration, but don't know how fast this will come, because I just started a huge UI migration to Svelte 5. Maybe I can slip that in beforehand, need to have a look at this.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52e207f43..4da119e6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
 # Changelog
 
+## UNRELEASED
+
+### Changes
+
+#### Upstream Identity Providers
+
+To provide additional compatibility for some upstream providers like Active Directory Federation Severices,
+some changes have been applied to Rauthy's behavior.
+
+The first thing is that the HTTP client used for upstream Logins does not force TLS v1.3 anymore, but also allows
+TLS v1.2. Both v1.2 and v1.3 are considered being secure by current standards. This is necessary, because some OS'es
+like Windows Server 2019 do not support TLS 1.3.
+
+The second change is for the way upstream providers are configured. The behavior until now was, that Rauthy added the
+client credentials as both Basic Authentication in headers, and in the body for maximum compatibility. However, some
+IdP'S (like ADFS for instance) complain about this and only expect it in one place.
+To make this happen, there are 2 new fields for the upstream IdP configuration:
+
+- `client_secret_basic: bool`
+- `client_secret_post: bool`
+
+These are available as switches in the Admin UI for each upstream provider. To not introduce breaking changes, all
+possibly existing configurations will have both options enabled like it has been up until now.
+
+[#659](https://github.com/sebadob/rauthy/pull/659)
+
+#### Note
+
+Even though this changes the request and response objects on the API, this change is **NOT** being handled as
+a breaking change. API clients are forbidden to modify upstream IdPs for security reasons, which means this change
+should only affect the Rauthy Admin UI.
+
 ## v0.27.2
 
 ### Changes
diff --git a/Cargo.lock b/Cargo.lock
index d8b5ebe9b..bc9c1fdd4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4272,7 +4272,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-service",
  "actix-web",
@@ -4313,7 +4313,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-api-types"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-web",
  "chrono",
@@ -4334,7 +4334,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-common"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-web",
  "argon2",
@@ -4360,7 +4360,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-error"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-multipart",
  "actix-web",
@@ -4396,7 +4396,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-handlers"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-multipart",
  "actix-web",
@@ -4426,7 +4426,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-middlewares"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-web",
  "chrono",
@@ -4445,7 +4445,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-models"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "accept-language",
  "actix",
@@ -4521,7 +4521,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-notify"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "async-trait",
  "chrono",
@@ -4541,7 +4541,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-schedulers"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-web",
  "chrono",
@@ -4559,7 +4559,7 @@ dependencies = [
 
 [[package]]
 name = "rauthy-service"
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 dependencies = [
  "actix-web",
  "argon2",
diff --git a/Cargo.toml b/Cargo.toml
index cca776ce8..ee5609d49 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["src/*"]
 exclude = ["rauthy-client"]
 
 [workspace.package]
-version = "0.27.2"
+version = "0.27.3-20241218-1"
 edition = "2021"
 authors = ["Sebastian Dobe <sebastiandobe@mailbox.org>"]
 license = "Apache-2.0"
diff --git a/frontend/src/components/admin/providers/JsonPathDesc.svelte b/frontend/src/components/admin/providers/JsonPathDesc.svelte
index dbb375904..615600493 100644
--- a/frontend/src/components/admin/providers/JsonPathDesc.svelte
+++ b/frontend/src/components/admin/providers/JsonPathDesc.svelte
@@ -1,7 +1,7 @@
 <script></script>
 
 <div class="desc">
-    <p>ID token claim mappings</p>
+    <h4>ID token claim mappings</h4>
     <p>
         Values from the ID token after a successful upstream login can be mapped automatically.
     </p>
@@ -25,6 +25,10 @@
 </div>
 
 <style>
+    h4 {
+        margin-bottom: .5rem;
+    }
+
     .desc {
         display: flex;
         flex-direction: column;
diff --git a/frontend/src/components/admin/providers/ProviderConfig.svelte b/frontend/src/components/admin/providers/ProviderConfig.svelte
index 571faa64f..4ab508d6a 100644
--- a/frontend/src/components/admin/providers/ProviderConfig.svelte
+++ b/frontend/src/components/admin/providers/ProviderConfig.svelte
@@ -62,7 +62,6 @@
         client_id: yup.string().trim().matches(REGEX_URI, "Can only contain URI safe characters, length max: 128"),
         client_secret: yup.string().trim().max(256, "Max 256 characters"),
         scope: yup.string().trim().matches(REGEX_PROVIDER_SCOPE, "Can only contain: 'a-zA-Z0-9-_/ ', length max: 128"),
-        root_pem: yup.string().trim().nullable().matches(REGEX_PEM, "Invalid PEM certificate"),
 
         admin_claim_path: yup.string().trim().nullable().matches(REGEX_URI, "Can only contain URI safe characters, length max: 128"),
         admin_claim_value: yup.string().trim().nullable().matches(REGEX_URI, "Can only contain URI safe characters, length max: 128"),
@@ -77,9 +76,12 @@
     }
 
     async function onSubmit() {
+        if (!showRootPem) {
+            provider.root_pem = undefined;
+        }
+
         const valid = await validateForm();
         if (!valid) {
-            err = 'Invalid input';
             return;
         }
 
@@ -95,6 +97,9 @@
             // make sure we reset to false, which is what a user would expect
             provider.danger_allow_insecure = false;
             provider.root_pem = provider.root_pem.trim();
+        } else {
+            // make sure to not submit am empty string
+            provider.root_pem = undefined;
         }
 
         let res = await putProvider(provider.id, provider);
@@ -125,9 +130,21 @@
     }
 
     async function validateForm() {
+        formErrors = {};
         try {
             await schema.validate(provider, {abortEarly: false});
-            formErrors = {};
+
+            if (provider.client_secret && !(provider.client_secret_basic || provider.client_secret_post)) {
+                err = 'You have given a client secret, but no client auth method is active';
+                return false;
+            } else if (provider.root_pem && provider.root_pem.length > 0) {
+                if (!REGEX_PEM.test(provider.root_pem.trim())) {
+                    formErrors.root_pem = 'Invalid PEM certificate';
+                }
+            } else {
+                err = 'Invalid input';
+            }
+
             return true;
         } catch (err) {
             formErrors = extractFormErrors(err);
@@ -293,6 +310,30 @@
         CLIENT SECRET
     </PasswordInput>
 
+    <div class="desc">
+        <p>
+            The authentication method to use on the <code>/token</code> endpoint.<br>
+            Most providers should work with <code>basic</code>, some only with <code>post</code>.
+            In rare situations, you need both, while it can lead to errors with others.
+        </p>
+    </div>
+    <div class="switchRow">
+        <div>
+            client_secret_basic
+        </div>
+        <Switch
+                bind:selected={provider.client_secret_basic}
+        />
+    </div>
+    <div class="switchRow">
+        <div>
+            client_secret_post
+        </div>
+        <Switch
+                bind:selected={provider.client_secret_post}
+        />
+    </div>
+
     <JsonPathDesc/>
     <div class="desc">
         <p>
@@ -420,6 +461,13 @@
         color: var(--col-ok);
     }
 
+    .switchRow {
+        margin-bottom: .25rem;
+        padding-left: .5rem;
+        display: grid;
+        grid-template-columns: 9rem 1fr;
+    }
+
     .unit {
         margin: 7px 0;
     }
diff --git a/frontend/src/components/admin/providers/ProviderTileAddNew.svelte b/frontend/src/components/admin/providers/ProviderTileAddNew.svelte
index 078770394..71afbb59d 100644
--- a/frontend/src/components/admin/providers/ProviderTileAddNew.svelte
+++ b/frontend/src/components/admin/providers/ProviderTileAddNew.svelte
@@ -50,6 +50,8 @@
         token_auth_method_basic: false,
         userinfo_endpoint: '',
         use_pkce: true,
+        client_secret_basic: true,
+        client_secret_post: false,
 
         // user defined values
         name: '',
@@ -122,6 +124,8 @@
                     token_auth_method_basic: false,
                     userinfo_endpoint: 'https://api.github.com/user',
                     use_pkce: false,
+                    client_secret_basic: true,
+                    client_secret_post: true,
 
                     // user defined values
                     name: 'Github',
@@ -160,6 +164,8 @@
                     token_auth_method_basic: false,
                     userinfo_endpoint: '',
                     use_pkce: true,
+                    client_secret_basic: true,
+                    client_secret_post: true,
 
                     // user defined values
                     name: '',
@@ -196,7 +202,6 @@
     async function onSubmitConfig() {
         const valid = await validateFormConfig();
         if (!valid) {
-            err = 'Invalid input';
             return;
         }
 
@@ -256,6 +261,9 @@
             config.userinfo_endpoint = body.userinfo_endpoint;
             config.token_auth_method_basic = body.token_auth_method_basic;
             config.use_pkce = body.use_pkce;
+            config.client_secret_basic = body.client_secret_basic;
+            // we want to enable basic only if it is supported for better compatibility out of the box
+            config.client_secret_post = !body.client_secret_basic && body.client_secret_post;
             config.scope = body.scope;
             config.root_pem = body.root_pem;
 
@@ -287,6 +295,8 @@
             token_endpoint: '',
             userinfo_endpoint: '',
             use_pkce: true,
+            client_secret_basic: true,
+            client_secret_post: false,
             scope: '',
             root_pem: null,
             admin_claim_path: null,
@@ -299,12 +309,21 @@
     }
 
     async function validateFormConfig() {
+        formErrors = {};
         try {
             await schemaConfig.validate(config, {abortEarly: false});
-            formErrors = {};
+
+            if (config.client_secret && !(config.client_secret_basic || config.client_secret_post)) {
+                err = 'You have given a client secret, but no client auth method is active';
+                return false;
+            } else {
+                err = 'Invalid input';
+            }
+
             return true;
         } catch (err) {
             formErrors = extractFormErrors(err);
+            err = 'Invalid input';
             return false;
         }
     }
@@ -553,6 +572,30 @@
                 CLIENT SECRET
             </PasswordInput>
 
+            <div class="desc">
+                <p>
+                    The authentication method to use on the <code>/token</code> endpoint.<br>
+                    Most providers should work with <code>basic</code>, some only with <code>post</code>.
+                    In rare situations, you need both, while it can lead to errors with others.
+                </p>
+            </div>
+            <div class="switchRow">
+                <div>
+                    client_secret_basic
+                </div>
+                <Switch
+                        bind:selected={config.client_secret_basic}
+                />
+            </div>
+            <div class="switchRow">
+                <div>
+                    client_secret_post
+                </div>
+                <Switch
+                        bind:selected={config.client_secret_post}
+                />
+            </div>
+
             <JsonPathDesc/>
             <div class="desc">
                 <p>
@@ -670,4 +713,11 @@
     .success {
         color: var(--col-ok);
     }
+
+    .switchRow {
+        margin-bottom: .25rem;
+        padding-left: .5rem;
+        display: grid;
+        grid-template-columns: 9rem 1fr;
+    }
 </style>
diff --git a/migrations/hiqlite/3_client_auth_methods.sql b/migrations/hiqlite/3_client_auth_methods.sql
new file mode 100644
index 000000000..37988ccad
--- /dev/null
+++ b/migrations/hiqlite/3_client_auth_methods.sql
@@ -0,0 +1,4 @@
+ALTER TABLE auth_providers
+    ADD client_secret_basic INTEGER DEFAULT 1 NOT NULL;
+ALTER TABLE auth_providers
+    ADD client_secret_post INTEGER DEFAULT 1 NOT NULL;
\ No newline at end of file
diff --git a/migrations/postgres/29_client_auth_methods.sql b/migrations/postgres/29_client_auth_methods.sql
new file mode 100644
index 000000000..419daaabe
--- /dev/null
+++ b/migrations/postgres/29_client_auth_methods.sql
@@ -0,0 +1,4 @@
+ALTER TABLE auth_providers
+    ADD client_secret_basic BOOLEAN DEFAULT true NOT NULL;
+ALTER TABLE auth_providers
+    ADD client_secret_post BOOLEAN DEFAULT true NOT NULL;
\ No newline at end of file
diff --git a/src/api/src/auth_providers.rs b/src/api/src/auth_providers.rs
index aa1f257e3..6b89deb7c 100644
--- a/src/api/src/auth_providers.rs
+++ b/src/api/src/auth_providers.rs
@@ -264,7 +264,15 @@ pub async fn put_provider(
     if !payload.use_pkce && payload.client_secret.is_none() {
         return Err(ErrorResponse::new(
             ErrorResponseType::BadRequest,
-            "Must at least be a confidential client or use PKCE".to_string(),
+            "Must at least be a confidential client or use PKCE",
+        ));
+    }
+    if payload.client_secret.is_some()
+        && !(payload.client_secret_basic || payload.client_secret_post)
+    {
+        return Err(ErrorResponse::new(
+            ErrorResponseType::BadRequest,
+            "A confidential client must have a least one of 'client_secret_basic | client_secret_post'",
         ));
     }
 
diff --git a/src/api_types/src/auth_providers.rs b/src/api_types/src/auth_providers.rs
index 698b4c4db..a703f5a06 100644
--- a/src/api_types/src/auth_providers.rs
+++ b/src/api_types/src/auth_providers.rs
@@ -39,6 +39,8 @@ pub struct ProviderRequest {
 
     pub danger_allow_insecure: Option<bool>,
     pub use_pkce: bool,
+    pub client_secret_basic: bool,
+    pub client_secret_post: bool,
 
     // This validation is pretty loose, but if we make it too strict,
     // we will most probably get into compatibility issues.
@@ -164,6 +166,8 @@ pub struct ProviderResponse {
 
     pub danger_allow_insecure: bool,
     pub use_pkce: bool,
+    pub client_secret_basic: bool,
+    pub client_secret_post: bool,
 
     pub root_pem: Option<String>,
 }
@@ -183,5 +187,7 @@ pub struct ProviderLookupResponse<'a> {
     pub scope: String,
     pub root_pem: &'a Option<String>,
     pub use_pkce: bool,
+    pub client_secret_basic: bool,
+    pub client_secret_post: bool,
     pub danger_allow_insecure: bool,
 }
diff --git a/src/models/src/entity/auth_providers.rs b/src/models/src/entity/auth_providers.rs
index 636296c00..1aa6ba698 100644
--- a/src/models/src/entity/auth_providers.rs
+++ b/src/models/src/entity/auth_providers.rs
@@ -133,6 +133,7 @@ pub struct WellKnownLookup {
     pub userinfo_endpoint: String,
     pub jwks_uri: String,
     pub scopes_supported: Vec<String>,
+    pub token_endpoint_auth_methods_supported: Vec<String>,
     #[serde(default)]
     pub code_challenge_methods_supported: Vec<String>,
 }
@@ -190,8 +191,9 @@ pub struct AuthProvider {
 
     pub allow_insecure_requests: bool,
     pub use_pkce: bool,
-
     pub root_pem: Option<String>,
+    pub client_secret_basic: bool,
+    pub client_secret_post: bool,
 }
 
 impl<'r> From<hiqlite::Row<'r>> for AuthProvider {
@@ -218,6 +220,8 @@ impl<'r> From<hiqlite::Row<'r>> for AuthProvider {
             allow_insecure_requests: row.get("allow_insecure_requests"),
             use_pkce: row.get("use_pkce"),
             root_pem: row.get("root_pem"),
+            client_secret_basic: row.get("client_secret_basic"),
+            client_secret_post: row.get("client_secret_post"),
         }
     }
 }
@@ -234,9 +238,10 @@ impl AuthProvider {
 INSERT INTO
 auth_providers (id, name, enabled, typ, issuer, authorization_endpoint, token_endpoint,
 userinfo_endpoint, client_id, secret, scope, admin_claim_path, admin_claim_value,
-mfa_claim_path, mfa_claim_value, allow_insecure_requests, use_pkce, root_pem)
+mfa_claim_path, mfa_claim_value, allow_insecure_requests, use_pkce, root_pem, client_secret_basic,
+client_secret_post)
 VALUES
-($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
+($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
 RETURNING *"#,
                     params!(
                         slf.id,
@@ -256,7 +261,9 @@ RETURNING *"#,
                         slf.mfa_claim_value,
                         slf.allow_insecure_requests,
                         slf.use_pkce,
-                        slf.root_pem
+                        slf.root_pem,
+                        slf.client_secret_basic,
+                        slf.client_secret_post
                     ),
                 )
                 .await?
@@ -266,9 +273,10 @@ RETURNING *"#,
 INSERT INTO
 auth_providers (id, name, enabled, typ, issuer, authorization_endpoint, token_endpoint,
 userinfo_endpoint, client_id, secret, scope, admin_claim_path, admin_claim_value,
-mfa_claim_path, mfa_claim_value, allow_insecure_requests, use_pkce, root_pem)
+mfa_claim_path, mfa_claim_value, allow_insecure_requests, use_pkce, root_pem, client_secret_basic,
+client_secret_post)
 VALUES
-($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)"#,
+($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)"#,
                 slf.id,
                 slf.name,
                 slf.enabled,
@@ -287,6 +295,8 @@ VALUES
                 slf.allow_insecure_requests,
                 slf.use_pkce,
                 slf.root_pem,
+                slf.client_secret_basic,
+                slf.client_secret_post
             )
             .execute(DB::conn())
             .await?;
@@ -405,8 +415,9 @@ UPDATE auth_providers
 SET name = $1, enabled = $2, issuer = $3, typ = $4, authorization_endpoint = $5,
 token_endpoint = $6, userinfo_endpoint = $7, client_id = $8, secret = $9, scope = $10,
 admin_claim_path = $11, admin_claim_value = $12, mfa_claim_path = $13, mfa_claim_value = $14,
-allow_insecure_requests = $15, use_pkce = $16, root_pem = $17
-WHERE id = $18"#,
+allow_insecure_requests = $15, use_pkce = $16, root_pem = $17, client_secret_basic = $18,
+client_secret_post = $19
+WHERE id = $20"#,
                     params!(
                         self.name.clone(),
                         self.enabled,
@@ -425,6 +436,8 @@ WHERE id = $18"#,
                         self.allow_insecure_requests,
                         self.use_pkce,
                         self.root_pem.clone(),
+                        self.client_secret_basic,
+                        self.client_secret_post,
                         self.id.clone()
                     ),
                 )
@@ -436,8 +449,9 @@ UPDATE auth_providers
 SET name = $1, enabled = $2, issuer = $3, typ = $4, authorization_endpoint = $5,
 token_endpoint = $6, userinfo_endpoint = $7, client_id = $8, secret = $9, scope = $10,
 admin_claim_path = $11, admin_claim_value = $12, mfa_claim_path = $13, mfa_claim_value = $14,
-allow_insecure_requests = $15, use_pkce = $16, root_pem = $17
-WHERE id = $18"#,
+allow_insecure_requests = $15, use_pkce = $16, root_pem = $17, client_secret_basic = $18,
+client_secret_post = $19
+WHERE id = $20"#,
                 self.name,
                 self.enabled,
                 self.issuer,
@@ -455,6 +469,8 @@ WHERE id = $18"#,
                 self.allow_insecure_requests,
                 self.use_pkce,
                 self.root_pem,
+                self.client_secret_basic,
+                self.client_secret_post,
                 self.id,
             )
             .execute(DB::conn())
@@ -503,7 +519,7 @@ impl AuthProvider {
                 .timeout(Duration::from_secs(10))
                 .connect_timeout(Duration::from_secs(10))
                 .tcp_keepalive(Duration::from_secs(30))
-                .min_tls_version(tls::Version::TLS_1_3)
+                .min_tls_version(tls::Version::TLS_1_2)
                 .tls_built_in_root_certs(true)
                 .user_agent(format!("Rauthy Auth Provider Client v{}", RAUTHY_VERSION))
                 .https_only(true);
@@ -543,6 +559,8 @@ impl AuthProvider {
             allow_insecure_requests: req.danger_allow_insecure.unwrap_or(false),
             use_pkce: req.use_pkce,
             root_pem: req.root_pem,
+            client_secret_basic: req.client_secret_basic,
+            client_secret_post: req.client_secret_post,
         })
     }
 
@@ -630,6 +648,15 @@ impl AuthProvider {
             scope.push_str("email ");
         }
 
+        let client_secret_basic = well_known
+            .token_endpoint_auth_methods_supported
+            .iter()
+            .any(|m| m.as_str() == "client_secret_basic");
+        let client_secret_post = well_known
+            .token_endpoint_auth_methods_supported
+            .iter()
+            .any(|m| m.as_str() == "client_secret_post");
+
         Ok(ProviderLookupResponse {
             issuer: well_known.issuer,
             // TODO optimization (and possibly security enhancement): strip issuer url from all of these?
@@ -642,6 +669,8 @@ impl AuthProvider {
                 .code_challenge_methods_supported
                 .iter()
                 .any(|c| c == "S256"),
+            client_secret_basic,
+            client_secret_post,
             danger_allow_insecure: payload.danger_allow_insecure.unwrap_or(false),
             scope,
             // TODO add `scopes_supported` Vec and make them selectable with checkboxes in the UI
@@ -693,6 +722,8 @@ impl TryFrom<AuthProvider> for ProviderResponse {
             mfa_claim_value: value.mfa_claim_value,
             danger_allow_insecure: value.allow_insecure_requests,
             use_pkce: value.use_pkce,
+            client_secret_basic: value.client_secret_basic,
+            client_secret_post: value.client_secret_post,
             root_pem: value.root_pem,
         })
     }
@@ -881,24 +912,36 @@ impl AuthProviderCallback {
             provider.allow_insecure_requests,
             provider.root_pem.as_deref(),
         )?;
-        let payload = OidcCodeRequestParams {
+        let mut payload = OidcCodeRequestParams {
+            // a client MAY add the `client_id`, but it MUST add it when it's public
             client_id: &provider.client_id,
-            client_secret: AuthProvider::get_secret_cleartext(&provider.secret)?,
+            client_secret: None,
             code: &payload.code,
             code_verifier: provider.use_pkce.then_some(&payload.pkce_verifier),
             grant_type: "authorization_code",
             redirect_uri: &PROVIDER_CALLBACK_URI,
         };
-        let res = client
-            .post(&provider.token_endpoint)
-            .header(ACCEPT, APPLICATION_JSON)
-            .basic_auth(
-                &provider.client_id,
-                AuthProvider::get_secret_cleartext(&provider.secret)?,
-            )
-            .form(&payload)
-            .send()
-            .await?;
+        if provider.client_secret_post {
+            payload.client_secret = AuthProvider::get_secret_cleartext(&provider.secret)?;
+        }
+
+        let res = {
+            let mut builder = client
+                .post(&provider.token_endpoint)
+                .header(ACCEPT, APPLICATION_JSON);
+
+            if provider.client_secret_basic {
+                builder = builder.basic_auth(
+                    &provider.client_id,
+                    AuthProvider::get_secret_cleartext(&provider.secret)?,
+                )
+            }
+
+            builder
+        }
+        .form(&payload)
+        .send()
+        .await?;
 
         let status = res.status().as_u16();
         debug!("POST /token auth provider status: {}", status);


Test patch: