You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ma...@apache.org on 2026/02/22 22:47:16 UTC

(solr) branch main updated: SOLR-17845: Implement OAuth support in new UI (#3754)

This is an automated email from the ASF dual-hosted git repository.

malliaridis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new d06531384de SOLR-17845: Implement OAuth support in new UI (#3754)
d06531384de is described below

commit d06531384de2e3b02328284430893aebbc39779b
Author: Christos Malliaridis <ma...@apache.org>
AuthorDate: Mon Feb 23 00:47:10 2026 +0200

    SOLR-17845: Implement OAuth support in new UI (#3754)
    
    This merge adds support for OAuth in the new UI by using a Ktor server for handling callbacks to the app on desktop, and new tab and event listeners on Web.
    
    The solution also adds support for multiple authentication options (MultiAuthPlugin), but with solr-server side limitations.
---
 .../unreleased/SOLR-17845-oauth-in-new-ui.yml      |   9 +
 gradle/libs.versions.toml                          |   5 +
 solr/licenses/okio-jvm-3.16.0.jar.sha1             |   1 +
 solr/licenses/okio-jvm-3.6.0.jar.sha1              |   1 -
 solr/modules/jwt-auth/gradle.lockfile              |   6 +-
 solr/modules/language-models/gradle.lockfile       |   5 +-
 solr/server/etc/jetty-new-ui-dev.xml               |  31 +--
 solr/server/etc/jetty-new-ui-prod.xml              |  31 +--
 solr/ui/build.gradle.kts                           |   9 +
 solr/ui/gradle.lockfile                            |  14 ++
 solr/ui/proguard.pro                               |  17 ++
 .../commonMain/composeResources/values/strings.xml |   9 +-
 .../apache/solr/ui/components/auth/AuthUtils.kt    |  91 +++++++++
 .../ui/components/auth/AuthenticationComponent.kt  |  14 +-
 .../solr/ui/components/auth/OAuthComponent.kt      |  82 ++++++++
 .../integration/DefaultAuthenticationComponent.kt  |  56 +++++-
 .../auth/integration/DefaultOAuthComponent.kt      |  91 +++++++++
 .../solr/ui/components/auth/integration/Mappers.kt |   9 +
 .../auth/integration/PlatformOAuthStoreClient.kt}  |  32 ++-
 .../auth/store/AuthenticationStoreProvider.kt      |   4 +-
 .../solr/ui/components/auth/store/OAuthStore.kt    |  80 ++++++++
 .../ui/components/auth/store/OAuthStoreProvider.kt | 160 +++++++++++++++
 .../root/integration/SimpleRootComponent.kt        |   3 +-
 .../start/integration/HttpStartStoreClient.kt      |  34 +++-
 .../ui/components/start/integration/Mappers.kt     |  23 +++
 .../AuthorizationResponse.kt}                      |  41 ++--
 .../AuthorizationTokenRequest.kt}                  |  34 ++--
 .../{domain/AuthMethod.kt => data/SolrAuthData.kt} |  33 ++--
 .../kotlin/org/apache/solr/ui/domain/AuthMethod.kt |   3 +
 .../kotlin/org/apache/solr/ui/domain/AuthOption.kt |   8 +
 .../solr/ui/domain/{AuthMethod.kt => OAuthData.kt} |  31 ++-
 .../InvalidResponseException.kt}                   |  25 +--
 .../org/apache/solr/ui/utils/HttpClientUtils.kt    |  28 +++
 .../org/apache/solr/ui/views/auth/OAuthContent.kt  | 110 +++++++++++
 .../ui/views/auth/UserAuthenticationContent.kt     |  34 +++-
 .../solr/ui/components/auth/AuthUtilsTest.kt       | 120 ++++++++++++
 .../ui/components/auth/store/FakeOAuthClient.kt}   |  37 ++--
 .../auth/store/OAuthStoreProviderTest.kt           | 208 ++++++++++++++++++++
 .../ui/views/auth/TestAuthenticationComponent.kt   |  19 ++
 .../solr/ui/views/auth/TestOAuthComponent.kt}      |  37 ++--
 .../apache/solr/ui/views/auth/TestOAuthData.kt}    |  34 ++--
 .../solr/ui/components/auth/AuthUtils.desktop.kt}  |  28 +--
 .../auth/integration/PlatformOAuthStoreClient.kt   | 216 +++++++++++++++++++++
 .../auth/PreviewUserAuthenticationContent.kt       | 114 +++++++++--
 .../wasmJsMain/kotlin/org/apache/solr/ui/Main.kt   |  13 ++
 .../solr/ui/components/auth/AuthUtils.wasmJs.kt}   |  31 +--
 .../auth/integration/PlatformOAuthStoreClient.kt   | 160 +++++++++++++++
 .../solr/ui/components/auth/AuthUtilsTestJs.kt     |  80 ++++++++
 48 files changed, 1986 insertions(+), 275 deletions(-)

diff --git a/changelog/unreleased/SOLR-17845-oauth-in-new-ui.yml b/changelog/unreleased/SOLR-17845-oauth-in-new-ui.yml
new file mode 100644
index 00000000000..8882d29b82f
--- /dev/null
+++ b/changelog/unreleased/SOLR-17845-oauth-in-new-ui.yml
@@ -0,0 +1,9 @@
+title: SOLR 17845 - Implement OAuth support in new UI
+type: added
+authors:
+  - name: Christos Malliaridis
+    nick: malliaridis
+    url: https://home.apache.org/phonebook.html?uid=malliaridis
+links:
+  - name: SOLR-17845
+    url: https://issues.apache.org/jira/browse/SOLR-17845
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 950e88d2f3b..8c854fb41b0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -197,6 +197,7 @@ spatial4j = "0.8"
 spotbugs = "4.9.6"
 squareup-okhttp3-mockwebserver = "4.12.0"
 squareup-okhttp3-okhttp = "4.12.0"
+squareup-okio = "3.16.0"
 stephenc-jcip = "1.0-1"
 swagger3 = "2.2.22"
 tdunning-tdigest = "3.3"
@@ -446,6 +447,9 @@ ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotia
 ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
 ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
 ktor-client-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
+ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
+ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
+ktor-server-htmlBuilder = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
 langchain4j-bom = { module = "dev.langchain4j:langchain4j-bom", version.ref = "langchain4j-bom" }
 langchain4j-cohere = { module = "dev.langchain4j:langchain4j-cohere" }
 langchain4j-core = { module = "dev.langchain4j:langchain4j-core" }
@@ -509,6 +513,7 @@ slf4j-jultoslf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
 spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" }
 squareup-okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "squareup-okhttp3-mockwebserver" }
 squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "squareup-okhttp3-okhttp" }
+squareup-okio = { module = "com.squareup.okio:okio", version.ref = "squareup-okio" }
 stephenc-jcip-annotations = { module = "com.github.stephenc.jcip:jcip-annotations", version.ref = "stephenc-jcip" }
 swagger3-annotations-jakarta = { module = "io.swagger.core.v3:swagger-annotations-jakarta", version.ref = "swagger3" }
 swagger3-jaxrs2-jakarta = { module = "io.swagger.core.v3:swagger-jaxrs2-jakarta", version.ref = "swagger3" }
diff --git a/solr/licenses/okio-jvm-3.16.0.jar.sha1 b/solr/licenses/okio-jvm-3.16.0.jar.sha1
new file mode 100644
index 00000000000..38844b24131
--- /dev/null
+++ b/solr/licenses/okio-jvm-3.16.0.jar.sha1
@@ -0,0 +1 @@
+60375cdf2fd0ed2a1dcd6db787095f732a31ff10
diff --git a/solr/licenses/okio-jvm-3.6.0.jar.sha1 b/solr/licenses/okio-jvm-3.6.0.jar.sha1
deleted file mode 100644
index 9e2ea33d247..00000000000
--- a/solr/licenses/okio-jvm-3.6.0.jar.sha1
+++ /dev/null
@@ -1 +0,0 @@
-5600569133b7bdefe1daf9ec7f4abeb6d13e1786
diff --git a/solr/modules/jwt-auth/gradle.lockfile b/solr/modules/jwt-auth/gradle.lockfile
index f985bc8b48f..8a9d1765fd5 100644
--- a/solr/modules/jwt-auth/gradle.lockfile
+++ b/solr/modules/jwt-auth/gradle.lockfile
@@ -37,8 +37,8 @@ com.nimbusds:nimbus-jose-jwt:10.5=jarValidation,testCompileClasspath,testRuntime
 com.nimbusds:oauth2-oidc-sdk:10.10.1=jarValidation,testCompileClasspath,testRuntimeClasspath
 com.squareup.okhttp3:mockwebserver:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 com.squareup.okhttp3:okhttp:4.12.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-com.squareup.okio:okio-jvm:3.6.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-com.squareup.okio:okio:3.6.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+com.squareup.okio:okio-jvm:3.16.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+com.squareup.okio:okio:3.16.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-cli:commons-cli:1.10.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.19.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
@@ -176,7 +176,7 @@ org.glassfish.jersey.media:jersey-media-json-jackson:3.1.11=jarValidation,runtim
 org.hamcrest:hamcrest:3.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.javassist:javassist:3.30.2-GA=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-reflect:2.0.21=jarValidation,testRuntimeClasspath
-org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=jarValidation,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib:2.2.0=jarValidation,testCompileClasspath,testRuntimeClasspath
diff --git a/solr/modules/language-models/gradle.lockfile b/solr/modules/language-models/gradle.lockfile
index 8785d2343a8..27221b30a3e 100644
--- a/solr/modules/language-models/gradle.lockfile
+++ b/solr/modules/language-models/gradle.lockfile
@@ -32,8 +32,8 @@ com.jayway.jsonpath:json-path:2.9.0=jarValidation,runtimeClasspath,runtimeLibs,s
 com.knuddels:jtokkit:1.1.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 com.lmax:disruptor:4.0.0=solrPlatformLibs
 com.squareup.okhttp3:okhttp:4.12.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-com.squareup.okio:okio-jvm:3.6.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-com.squareup.okio:okio:3.6.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+com.squareup.okio:okio-jvm:3.16.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+com.squareup.okio:okio:3.16.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 com.squareup.retrofit2:converter-jackson:2.9.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 com.squareup.retrofit2:retrofit:2.9.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
@@ -164,7 +164,6 @@ org.glassfish.jersey.inject:jersey-hk2:3.1.11=jarValidation,runtimeClasspath,run
 org.glassfish.jersey.media:jersey-media-json-jackson:3.1.11=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 org.hamcrest:hamcrest:3.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.javassist:javassist:3.30.2-GA=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
-org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.jetbrains.kotlin:kotlin-stdlib:2.2.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
diff --git a/solr/server/etc/jetty-new-ui-dev.xml b/solr/server/etc/jetty-new-ui-dev.xml
index 28501dab377..01e49afe0da 100644
--- a/solr/server/etc/jetty-new-ui-dev.xml
+++ b/solr/server/etc/jetty-new-ui-dev.xml
@@ -26,15 +26,24 @@
 <!-- =============================================================== -->
 
 <Configure id="Server" class="org.eclipse.jetty.server.Server">
-     <Ref refid="RewriteHandler">
-      <Call name="addRule">
-        <Arg>
-          <New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
-            <Set name="pattern">/solr/ui/*</Set>
-            <Set name="headerName">Content-Security-Policy</Set>
-            <Set name="headerValue">default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; worker-src 'self';</Set>
-          </New>
-        </Arg>
-      </Call>
-     </Ref>
+  <Ref refid="RewriteHandler">
+    <!-- SPA fallback: redirect all /solr/ui/* routes to index.html -->
+    <Call name="addRule">
+      <Arg>
+        <New class="org.eclipse.jetty.rewrite.handler.RewriteRegexRule">
+          <Set name="regex">^(/solr/ui/[^.?]*)(\?.*)?$</Set>
+          <Set name="replacement">/solr/ui/index.html</Set>
+        </New>
+      </Arg>
+    </Call>
+    <Call name="addRule">
+      <Arg>
+        <New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
+          <Set name="pattern">/solr/ui/*</Set>
+          <Set name="headerName">Content-Security-Policy</Set>
+          <Set name="headerValue">default-src 'none'; base-uri 'none'; connect-src *; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; worker-src 'self';</Set>
+        </New>
+      </Arg>
+    </Call>
+  </Ref>
 </Configure>
diff --git a/solr/server/etc/jetty-new-ui-prod.xml b/solr/server/etc/jetty-new-ui-prod.xml
index d158245dd08..51b717331d6 100644
--- a/solr/server/etc/jetty-new-ui-prod.xml
+++ b/solr/server/etc/jetty-new-ui-prod.xml
@@ -13,15 +13,24 @@
 <!-- =============================================================== -->
 
 <Configure id="Server" class="org.eclipse.jetty.server.Server">
-     <Ref refid="RewriteHandler">
-      <Call name="addRule">
-        <Arg>
-          <New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
-            <Set name="pattern">/solr/ui/*</Set>
-            <Set name="headerName">Content-Security-Policy</Set>
-            <Set name="headerValue">default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self';</Set>
-          </New>
-        </Arg>
-      </Call>
-     </Ref>
+  <Ref refid="RewriteHandler">
+    <!-- SPA fallback: redirect all /solr/ui/* routes to index.html -->
+    <Call name="addRule">
+      <Arg>
+        <New class="org.eclipse.jetty.rewrite.handler.RewriteRegexRule">
+          <Set name="regex">^(/solr/ui/[^.?]*)(\?.*)?$</Set>
+          <Set name="replacement">/solr/ui/index.html</Set>
+        </New>
+      </Arg>
+    </Call>
+    <Call name="addRule">
+      <Arg>
+        <New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
+          <Set name="pattern">/solr/ui/*</Set>
+          <Set name="headerName">Content-Security-Policy</Set>
+          <Set name="headerValue">default-src 'none'; base-uri 'none'; connect-src *; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self';</Set>
+        </New>
+      </Arg>
+    </Call>
+  </Ref>
 </Configure>
diff --git a/solr/ui/build.gradle.kts b/solr/ui/build.gradle.kts
index f0c97343898..79e58bc0314 100644
--- a/solr/ui/build.gradle.kts
+++ b/solr/ui/build.gradle.kts
@@ -96,6 +96,7 @@ kotlin {
                 implementation(libs.ktor.client.cio)
                 implementation(libs.ktor.client.contentNegotiation)
                 implementation(libs.ktor.client.serialization.json)
+                implementation(libs.squareup.okio)
 
                 implementation(libs.oshai.logging)
                 implementation(libs.slf4j.api)
@@ -114,6 +115,9 @@ kotlin {
 
         val desktopMain by getting {
             dependencies {
+                implementation(libs.ktor.server.core)
+                implementation(libs.ktor.server.cio)
+                implementation(libs.ktor.server.htmlBuilder)
                 implementation(compose.desktop.currentOs)
                 implementation(libs.kotlinx.coroutines.swing)
             }
@@ -132,6 +136,11 @@ compose.desktop {
     application {
         mainClass = "org.apache.solr.ui.MainKt"
 
+        buildTypes.release.proguard {
+            version.set("7.6.0")
+            configurationFiles.from("proguard.pro")
+        }
+
         nativeDistributions {
             targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
 
diff --git a/solr/ui/gradle.lockfile b/solr/ui/gradle.lockfile
index ee7cab2af40..2196fbb365b 100644
--- a/solr/ui/gradle.lockfile
+++ b/solr/ui/gradle.lockfile
@@ -57,6 +57,10 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=desk
 com.google.j2objc:j2objc-annotations:3.1=desktopTestRuntimeClasspath
 com.google.truth:truth:1.0.1=desktopTestRuntimeClasspath
 com.googlecode.java-diff-utils:diffutils:1.3.0=desktopTestRuntimeClasspath
+com.squareup.okio:okio-jvm:3.16.0=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
+com.squareup.okio:okio-wasm-js:3.16.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
+com.squareup.okio:okio:3.16.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMe [...]
+com.typesafe:config:1.4.3=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
 io.github.java-diff-utils:java-diff-utils:4.12=kotlinInternalAbiValidation
 io.github.oshai:kotlin-logging-jvm:7.0.13=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
 io.github.oshai:kotlin-logging-wasm-js:7.0.13=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
@@ -103,6 +107,12 @@ io.ktor:ktor-serialization-kotlinx-wasm-js:3.2.2=wasmJsCompileClasspath,wasmJsNp
 io.ktor:ktor-serialization-kotlinx:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainApiDep [...]
 io.ktor:ktor-serialization-wasm-js:3.2.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
 io.ktor:ktor-serialization:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependencie [...]
+io.ktor:ktor-server-cio-jvm:3.2.2=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
+io.ktor:ktor-server-cio:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata,desktopMainCompileClasspath,desktopMainCompileOnlyDependenciesMetadata,desktopMainImplementationDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestApiDependenciesMetadata,desktopTestCompileClasspath,desktopTestCompileOnlyDependenciesMetadata, [...]
+io.ktor:ktor-server-core-jvm:3.2.2=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
+io.ktor:ktor-server-core:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata,desktopMainCompileClasspath,desktopMainCompileOnlyDependenciesMetadata,desktopMainImplementationDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestApiDependenciesMetadata,desktopTestCompileClasspath,desktopTestCompileOnlyDependenciesMetadata [...]
+io.ktor:ktor-server-html-builder-jvm:3.2.2=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
+io.ktor:ktor-server-html-builder:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata,desktopMainCompileClasspath,desktopMainCompileOnlyDependenciesMetadata,desktopMainImplementationDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestApiDependenciesMetadata,desktopTestCompileClasspath,desktopTestCompileOnlyDependencies [...]
 io.ktor:ktor-sse-jvm:3.2.2=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
 io.ktor:ktor-sse-wasm-js:3.2.2=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
 io.ktor:ktor-sse:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata, [...]
@@ -117,6 +127,7 @@ io.ktor:ktor-websockets-wasm-js:3.2.2=wasmJsCompileClasspath,wasmJsNpmAggregated
 io.ktor:ktor-websockets:3.2.2=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMe [...]
 junit:junit:4.13.2=desktopTestCompileClasspath,desktopTestRuntimeClasspath
 org.checkerframework:checker-compat-qual:2.5.5=desktopTestRuntimeClasspath
+org.fusesource.jansi:jansi:2.4.2=desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath
 org.hamcrest:hamcrest-core:1.3=desktopTestCompileClasspath,desktopTestRuntimeClasspath
 org.jetbrains.androidx.lifecycle:lifecycle-common-wasm-js:2.8.4=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
 org.jetbrains.androidx.lifecycle:lifecycle-common:2.8.4=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,de [...]
@@ -210,6 +221,7 @@ org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.0=kotlinBuildToolsApiClasspath
 org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.2.0=kotlinKlibCommonizerClasspath
 org.jetbrains.kotlin:kotlin-metadata-jvm:2.2.0=kotlinInternalAbiValidation
 org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:2.1.21=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata,desktopMainCompileClasspath,desktopMainCompileOnlyDependenciesMetadata,desktopMainImplementationDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestApiDependenciesMetadata,desktopTestCompileClasspath,desktopTestCompileOnlyDependen [...]
 org.jetbrains.kotlin:kotlin-script-runtime:2.2.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDesktopMain,kotlinCompilerPluginClasspathDesktopTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest,kotlinInternalAbiValidation,kotlinKlibCommonizerClasspath
 org.jetbrains.kotlin:kotlin-scripting-common:2.2.0=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathDesktopMain,kotlinCompilerPluginClasspathDesktopTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest
 org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.0=kotlinBuildToolsApiClasspath,kotlinCompilerPluginClasspathDesktopMain,kotlinCompilerPluginClasspathDesktopTest,kotlinCompilerPluginClasspathMetadataCommonMain,kotlinCompilerPluginClasspathMetadataMain,kotlinCompilerPluginClasspathWasmJsMain,kotlinCompilerPluginClasspathWasmJsTest
@@ -246,6 +258,8 @@ org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2=desktopTestCompileClasspath
 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
 org.jetbrains.kotlinx:kotlinx-datetime-wasm-js:0.7.1=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
 org.jetbrains.kotlinx:kotlinx-datetime:0.7.1=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopMainAp [...]
+org.jetbrains.kotlinx:kotlinx-html-jvm:0.12.0=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-html:0.12.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,desktopCompileClasspath,desktopMainApiDependenciesMetadata,desktopMainCompileClasspath,desktopMainCompileOnlyDependenciesMetadata,desktopMainImplementationDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestApiDependenciesMetadata,desktopTestCompileClasspath,desktopTestCompileOnlyDependenc [...]
 org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.7.0=desktopCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath
 org.jetbrains.kotlinx:kotlinx-io-bytestring-wasm-js:0.7.0=wasmJsCompileClasspath,wasmJsNpmAggregated,wasmJsRuntimeClasspath,wasmJsTestCompileClasspath,wasmJsTestNpmAggregated,wasmJsTestRuntimeClasspath
 org.jetbrains.kotlinx:kotlinx-io-bytestring:0.7.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,commonMainApiDependenciesMetadata,commonMainCompileOnlyDependenciesMetadata,commonMainImplementationDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopCompileClasspath,desktopM [...]
diff --git a/solr/ui/proguard.pro b/solr/ui/proguard.pro
new file mode 100644
index 00000000000..c3a4b01f3f0
--- /dev/null
+++ b/solr/ui/proguard.pro
@@ -0,0 +1,17 @@
+-keep class org.apache.solr.ui.domain.** { *; }
+-keep class org.apache.solr.ui.*.data.** { *; }
+-keep class org.apache.solr.ui.*.domain.** { *; }
+
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.collection.** { *; }
+-keep class androidx.lifecycle.** { *; }
+
+# Kotlinx coroutines rules seems to be outdated with the latest version of Kotlin and Proguard
+-keep class kotlinx.coroutines.** { *; }
+
+# Don't warn about logback, as it is needed only during compile-time
+-dontwarn ch.qos.logback.**
+-dontwarn com.oracle.svm.core.annotate.**
+-dontwarn io.github.oshai.kotlinlogging.logback.internal.**
+# We're excluding Material 2 from the project as we're using Material 3
+-dontwarn androidx.compose.material.**
diff --git a/solr/ui/src/commonMain/composeResources/values/strings.xml b/solr/ui/src/commonMain/composeResources/values/strings.xml
index 42becfb8734..ab005ca9c2e 100644
--- a/solr/ui/src/commonMain/composeResources/values/strings.xml
+++ b/solr/ui/src/commonMain/composeResources/values/strings.xml
@@ -22,6 +22,8 @@
   <string name="action_go_back">Go Back</string>
   <string name="action_logout">Logout</string>
   <string name="action_sign_in_with_credentials">Sign In with Credentials</string>
+  <string name="action_sign_in_with_realm">Sign In with %1$s</string>
+  <string name="action_sign_in_with_identity_provider">Sign In with Identity Provider</string>
 
   <!-- Content Descriptions (cd) -->
   <string name="cd_back_navigation">Back Navigation</string>
@@ -30,8 +32,10 @@
   <!-- Descriptions (desc) -->
   <string name="desc_to_get_started">To get started, please provide a Solr host URL:</string>
   <string name="desc_solr_instance_with_auth">The Solr instance %1$s has enabled authentication.</string>
-  <string name="desc_sign_in_with_credentials">To authenticate you may use your credentials for Basic authentication.</string>
-  <string name="desc_sign_in_with_credentials_to_realm">To authenticate you may use your credentials for the realm %1$s.</string>
+  <string name="desc_sign_in_with_credentials">Authenticate with credentials</string>
+  <string name="desc_sign_in_with_credentials_to_realm">Authenticate with credentials for the realm %1$s.</string>
+  <string name="desc_sign_in_with_oauth">Authenticate with Identity Provider.</string>
+  <string name="desc_sign_in_with_oauth_to_realm">Authenticate with Identity Provider for the realm %1$s.</string>
 
   <!-- Errors (error) -->
   <string name="error_invalid_url">The provided URL is invalid.</string>
@@ -79,6 +83,7 @@
   <string name="support">Support</string>
   <string name="connecting">Connecting...</string>
   <string name="overview">Overview</string>
+  <string name="seperator_or">or</string>
   <string name="schema">Schema</string>
   <string name="zookeeper">Zookeeper</string>
 
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.kt
new file mode 100644
index 00000000000..e06c9f918f5
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.kt
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth
+
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.Base64.PaddingOption
+import kotlin.random.Random
+import okio.ByteString
+import okio.ByteString.Companion.encodeUtf8
+
+/**
+ * Characters allowed by RFC 7636 for the code verifier
+ */
+internal const val CODE_VERIFIER_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
+
+internal const val CODE_VERIFIER_LENGTH = 128
+
+/**
+ * Characters to use for the state generation during OAuth flows.
+ */
+internal const val STATE_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+
+/**
+ * Length of the state value during OAuth flows.
+ */
+internal const val STATE_LENGTH = 32
+
+/**
+ * Generates a code verifier that is used during OAuth flows.
+ *
+ * @return Code verifier that adheres to the allowed characters defined in [CODE_VERIFIER_CHARSET]
+ * and the required length as defined by [CODE_VERIFIER_LENGTH].
+ */
+internal fun generateCodeVerifier(): String = buildString {
+    repeat(CODE_VERIFIER_LENGTH) {
+        append(CODE_VERIFIER_CHARSET[Random.nextInt(CODE_VERIFIER_CHARSET.length)])
+    }
+}
+
+/**
+ * Generates a code challenge that is used during OAuth flows.
+ *
+ * @param verifier Code verifier as generated by [generateCodeVerifier].
+ * @return The URL-safe, Base64-encoded code challenge.
+ */
+internal fun generateCodeChallenge(verifier: String): String {
+    val sha256Bytes: ByteString = verifier.encodeUtf8().sha256()
+    return Base64.UrlSafe.withPadding(PaddingOption.ABSENT).encode(sha256Bytes.toByteArray())
+}
+
+/**
+ * Generates an OAuth state that is used during OAuth flows.
+ *
+ * @return State value with characters from [STATE_CHARSET] and length [STATE_LENGTH].
+ */
+internal fun generateOAuthState(): String = buildString {
+    repeat(STATE_LENGTH) {
+        append(STATE_CHARSET[Random.nextInt(STATE_CHARSET.length)])
+    }
+}
+
+/**
+ * Retrieve the redirect URI for the OAuth flow.
+ *
+ * This URI is platform-specific, as it generates different redirect URIs based on the current
+ * target / origin.
+ *
+ * Note that in future scenarios we will have to support a changed location path, as the user is
+ * allowed to customize the base path that is right now /solr/ui/.
+ *
+ * TODO Implement the function so that it also allows overriding the callback path. This will
+ *  allow us to customize the base path of Solr without affecting the new UI.
+ *
+ * @return The redirect URI to use in OAuth.
+ */
+internal expect fun getRedirectUri(): String
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthenticationComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthenticationComponent.kt
index c8072a30456..5006ccc929c 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthenticationComponent.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/AuthenticationComponent.kt
@@ -19,7 +19,6 @@ package org.apache.solr.ui.components.auth
 
 import com.arkivanov.decompose.router.slot.ChildSlot
 import com.arkivanov.decompose.value.Value
-import io.ktor.http.Url
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.serialization.Serializable
 import org.apache.solr.ui.domain.AuthMethod
@@ -29,6 +28,10 @@ import org.jetbrains.compose.resources.StringResource
 /**
  * The authentication component takes care of the authentication processes. This typically includes
  * user authentication with credentials, tokens or certificates.
+ *
+ * Note that Solr does not support multiple authentication methods of the same type with the
+ * MultiAuthPlugin, so there will be always up to one instance per method, hence the component-based
+ * child slots for each method.
  */
 interface AuthenticationComponent {
 
@@ -43,9 +46,18 @@ interface AuthenticationComponent {
      */
     val basicAuthSlot: Value<ChildSlot<BasicAuthConfiguration, BasicAuthComponent>>
 
+    /**
+     * A child slot that holds the [OAuthComponent] for user authentication with OAuth
+     * (bearer token) if it is supported.
+     */
+    val oAuthSlot: Value<ChildSlot<OAuthConfiguration, OAuthComponent>>
+
     @Serializable
     data class BasicAuthConfiguration(val method: AuthMethod.BasicAuthMethod = AuthMethod.BasicAuthMethod())
 
+    @Serializable
+    data class OAuthConfiguration(val method: AuthMethod.OAuthMethod)
+
     /**
      * Aborts the authentication attempt.
      */
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/OAuthComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/OAuthComponent.kt
new file mode 100644
index 00000000000..722d5156c2d
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/OAuthComponent.kt
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import org.apache.solr.ui.components.auth.store.OAuthStore.Label
+import org.apache.solr.ui.domain.AuthMethod
+
+/**
+ * Component interface that is handling basic authentication with username and password.
+ */
+interface OAuthComponent {
+
+    val model: StateFlow<Model>
+
+    val labels: Flow<Label>
+
+    /**
+     * Tries to authenticate the user with the given authorization URL.
+     */
+    fun onAuthenticate()
+
+    /**
+     * Data class model that represents the [OAuthComponent] state.
+     *
+     * @property realm The OAuth realm the user is supposed to authenticate against.
+     * @property hasError Whether the current input has an error.
+     */
+    data class Model(
+        val realm: String? = null,
+        val hasError: Boolean = false,
+    )
+
+    sealed interface Output {
+
+        /**
+         * Output that is emitted when a connection process is started.
+         */
+        data object Authenticating : Output
+
+        /**
+         * Output that is emitted when the user successfully connected to the server
+         * with the oauth method.
+         *
+         * @property method The method that was used for authentication.
+         */
+        data class Authenticated(
+            val accessToken: String,
+            val method: AuthMethod.OAuthMethod,
+            val refreshToken: String? = null,
+        ) : Output
+
+        /**
+         * Output that is emitted when an authentication error occurs, for example, in case
+         * of timeout or invalid configuration.
+         *
+         * @property error The error that was thrown during a connection establishment.
+         */
+        data class AuthenticationFailed(val error: Throwable) : Output
+
+        /**
+         * Output that allows resetting any error reported via [AuthenticationFailed].
+         */
+        data object ErrorReset : Output
+    }
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultAuthenticationComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultAuthenticationComponent.kt
index 0aae2090309..81968a737bb 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultAuthenticationComponent.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultAuthenticationComponent.kt
@@ -22,7 +22,6 @@ import com.arkivanov.decompose.router.slot.SlotNavigation
 import com.arkivanov.decompose.router.slot.activate
 import com.arkivanov.decompose.router.slot.childSlot
 import com.arkivanov.decompose.value.Value
-import com.arkivanov.essenty.lifecycle.doOnCreate
 import com.arkivanov.mvikotlin.core.instancekeeper.getStore
 import com.arkivanov.mvikotlin.core.store.StoreFactory
 import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow
@@ -32,12 +31,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.SupervisorJob
 import org.apache.solr.ui.components.auth.AuthenticationComponent
 import org.apache.solr.ui.components.auth.AuthenticationComponent.BasicAuthConfiguration
+import org.apache.solr.ui.components.auth.AuthenticationComponent.OAuthConfiguration
 import org.apache.solr.ui.components.auth.AuthenticationComponent.Output
 import org.apache.solr.ui.components.auth.AuthenticationComponent.Output.OnAuthenticated
 import org.apache.solr.ui.components.auth.BasicAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent
 import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent
-import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent.FailAuthentication
-import org.apache.solr.ui.components.auth.store.AuthenticationStore.Intent.ResetError
 import org.apache.solr.ui.components.auth.store.AuthenticationStoreProvider
 import org.apache.solr.ui.domain.AuthMethod
 import org.apache.solr.ui.domain.AuthOption
@@ -80,6 +79,7 @@ class DefaultAuthenticationComponent(
     }
 
     private val basicAuthNavigation = SlotNavigation<BasicAuthConfiguration>()
+    private val oauthNavigation = SlotNavigation<OAuthConfiguration>()
 
     @OptIn(ExperimentalCoroutinesApi::class)
     override val model = store.stateFlow.map(mainScope, authStateToModel)
@@ -87,6 +87,7 @@ class DefaultAuthenticationComponent(
     override val basicAuthSlot: Value<ChildSlot<BasicAuthConfiguration, BasicAuthComponent>> =
         childSlot(
             source = basicAuthNavigation,
+            key = "BasicAuthSlot",
             serializer = BasicAuthConfiguration.serializer(),
             handleBackButton = true,
         ) { config, childComponentContext ->
@@ -99,11 +100,32 @@ class DefaultAuthenticationComponent(
             )
         }
 
+    override val oAuthSlot: Value<ChildSlot<OAuthConfiguration, OAuthComponent>> =
+        childSlot(
+            source = oauthNavigation,
+            key = "OAuthChildSlot",
+            serializer = OAuthConfiguration.serializer(),
+            handleBackButton = true,
+        ) { config, childComponentContext ->
+            DefaultOAuthComponent(
+                componentContext = childComponentContext,
+                storeFactory = storeFactory,
+                httpClient = httpClient,
+                method = config.method,
+                output = ::oAuthOutput,
+            )
+        }
+
     init {
         methods.forEach { method ->
             when (method) {
                 is AuthMethod.BasicAuthMethod ->
                     basicAuthNavigation.activate(configuration = BasicAuthConfiguration(method))
+
+                is AuthMethod.OAuthMethod -> oauthNavigation.activate(
+                    configuration = OAuthConfiguration(method),
+                )
+
                 is AuthMethod.Unknown -> {} // TODO Handle unknown auth methods
             }
         }
@@ -127,11 +149,31 @@ class DefaultAuthenticationComponent(
         )
 
         is BasicAuthComponent.Output.Authenticating -> store.accept(Intent.StartAuthenticating)
-        is BasicAuthComponent.Output.AuthenticationFailed ->
-            store.accept(FailAuthentication(output.error))
+        is BasicAuthComponent.Output.AuthenticationFailed -> store.accept(
+            intent = Intent.FailAuthentication(output.error),
+        )
+
+        is BasicAuthComponent.Output.ErrorReset -> store.accept(intent = Intent.ResetError)
+    }
+
+    private fun oAuthOutput(output: OAuthComponent.Output): Unit = when (output) {
+        is OAuthComponent.Output.Authenticating -> store.accept(Intent.StartAuthenticating)
+        is OAuthComponent.Output.Authenticated -> output(
+            OnAuthenticated(
+                option = AuthOption.OAuthOption(
+                    url = url,
+                    accessToken = output.accessToken,
+                    refreshToken = output.refreshToken,
+                    realm = output.method.realm,
+                ),
+            ),
+        )
+
+        is OAuthComponent.Output.AuthenticationFailed -> store.accept(
+            intent = Intent.FailAuthentication(output.error),
+        )
 
-        BasicAuthComponent.Output.ErrorReset ->
-            store.accept(ResetError)
+        is OAuthComponent.Output.ErrorReset -> store.accept(intent = Intent.ResetError)
     }
 
     override fun onAbort() = output(Output.OnAbort)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultOAuthComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultOAuthComponent.kt
new file mode 100644
index 00000000000..e0be9093f16
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/DefaultOAuthComponent.kt
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.integration
+
+import com.arkivanov.mvikotlin.core.instancekeeper.getStore
+import com.arkivanov.mvikotlin.core.store.StoreFactory
+import com.arkivanov.mvikotlin.extensions.coroutines.labels
+import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.apache.solr.ui.components.auth.OAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent.Output
+import org.apache.solr.ui.components.auth.store.OAuthStore
+import org.apache.solr.ui.components.auth.store.OAuthStore.Intent
+import org.apache.solr.ui.components.auth.store.OAuthStoreProvider
+import org.apache.solr.ui.domain.AuthMethod
+import org.apache.solr.ui.utils.AppComponentContext
+import org.apache.solr.ui.utils.coroutineScope
+import org.apache.solr.ui.utils.map
+
+class DefaultOAuthComponent(
+    componentContext: AppComponentContext,
+    storeFactory: StoreFactory,
+    httpClient: HttpClient,
+    method: AuthMethod.OAuthMethod,
+    private val output: (Output) -> Unit,
+) : OAuthComponent,
+    AppComponentContext by componentContext {
+
+    private val mainScope = coroutineScope(SupervisorJob() + mainContext)
+    private val ioScope = coroutineScope(SupervisorJob() + ioContext)
+
+    private val store = instanceKeeper.getStore {
+        OAuthStoreProvider(
+            storeFactory = storeFactory,
+            client = PlatformOAuthStoreClient(httpClient),
+            mainContext = mainScope.coroutineContext,
+            ioContext = ioScope.coroutineContext,
+            method = method,
+        ).provide()
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    override val model = store.stateFlow.map(mainScope, stateToOAuthModel)
+
+    override val labels: Flow<OAuthStore.Label> = store.labels
+
+    init {
+        store.labels.onEach { label ->
+            when (label) {
+                is OAuthStore.Label.Authenticated -> output(
+                    Output.Authenticated(
+                        method = label.method,
+                        accessToken = label.accessToken,
+                        refreshToken = label.refreshToken,
+                    ),
+                )
+
+                is OAuthStore.Label.AuthenticationFailed -> output(
+                    Output.AuthenticationFailed(error = label.error),
+                )
+
+                is OAuthStore.Label.AuthenticationStarted -> {
+                    output(Output.Authenticating)
+                }
+                is OAuthStore.Label.ErrorReset -> output(Output.ErrorReset)
+            }
+        }.launchIn(mainScope)
+    }
+
+    override fun onAuthenticate() = store.accept(Intent.Authenticate)
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/Mappers.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/Mappers.kt
index c46b27515ad..51cd7df8e8f 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/Mappers.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/Mappers.kt
@@ -19,8 +19,10 @@ package org.apache.solr.ui.components.auth.integration
 
 import org.apache.solr.ui.components.auth.AuthenticationComponent
 import org.apache.solr.ui.components.auth.BasicAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent
 import org.apache.solr.ui.components.auth.store.AuthenticationStore
 import org.apache.solr.ui.components.auth.store.BasicAuthStore
+import org.apache.solr.ui.components.auth.store.OAuthStore
 import org.apache.solr.ui.errors.InvalidCredentialsException
 import org.apache.solr.ui.generated.resources.Res
 import org.apache.solr.ui.generated.resources.error_invalid_credentials
@@ -48,3 +50,10 @@ internal val stateToBasicAuthModel: (BasicAuthStore.State) -> BasicAuthComponent
         hasError = it.error != null,
     )
 }
+
+internal val stateToOAuthModel: (OAuthStore.State) -> OAuthComponent.Model = {
+    OAuthComponent.Model(
+        realm = it.method.realm,
+        hasError = it.error != null,
+    )
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
similarity index 54%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
index e50d0429290..3ad4b909293 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
@@ -15,27 +15,23 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.components.auth.integration
 
-import kotlinx.serialization.Serializable
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import org.apache.solr.ui.components.auth.store.OAuthStoreProvider
+import org.apache.solr.ui.domain.OAuthData
 
 /**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
+ * OAuth store implementation that uses a server instance for handling callbacks.
  *
- * @see AuthOption
+ * @property httpClient A preconfigured HTTP client that has the base URL of a Solr instance
+ * already set.
  */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
+expect class PlatformOAuthStoreClient(httpClient: HttpClient) : OAuthStoreProvider.Client {
+    override suspend fun authenticate(
+        state: String,
+        verifier: String,
+        data: OAuthData,
+    ): Result<BearerTokens>
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/AuthenticationStoreProvider.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/AuthenticationStoreProvider.kt
index 0899697f519..7fb9c43cc61 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/AuthenticationStoreProvider.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/AuthenticationStoreProvider.kt
@@ -54,7 +54,7 @@ class AuthenticationStoreProvider(
     fun provide(): AuthenticationStore = object :
         AuthenticationStore,
         Store<Intent, State, Label> by storeFactory.create(
-            name = "BasicAuthStore",
+            name = "AuthStore",
             initialState = State(url = url, methods = methods),
             bootstrapper = SimpleBootstrapper(),
             executorFactory = ::ExecutorImpl,
@@ -93,7 +93,7 @@ class AuthenticationStoreProvider(
         override fun State.reduce(msg: Message): State = when (msg) {
             is Message.AuthenticationStarted -> copy(isAuthenticating = true)
             is Message.AuthenticationFailed -> copy(error = msg.error, isAuthenticating = false)
-            Message.ErrorReset -> copy(error = null)
+            is Message.ErrorReset -> copy(error = null)
         }
     }
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStore.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStore.kt
new file mode 100644
index 00000000000..0e596ab1180
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStore.kt
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.store
+
+import com.arkivanov.mvikotlin.core.store.Store
+import io.ktor.http.Url
+import org.apache.solr.ui.components.auth.store.OAuthStore.Intent
+import org.apache.solr.ui.components.auth.store.OAuthStore.Label
+import org.apache.solr.ui.components.auth.store.OAuthStore.State
+import org.apache.solr.ui.domain.AuthMethod.OAuthMethod
+
+interface OAuthStore : Store<Intent, State, Label> {
+
+    /**
+     * Intent for interacting with the OAuth store.
+     */
+    sealed interface Intent {
+
+        /**
+         * Intent for initiating an authentication attempt with the current OAuth configuration.
+         */
+        data object Authenticate : Intent
+    }
+
+    sealed interface Label {
+
+        /**
+         * Label that is published when a new authentication process starts.
+         *
+         * @property url The URL of the identity provider to open for authentication.
+         */
+        data class AuthenticationStarted(val url: Url) : Label
+
+        /**
+         * Label that is published when the user successfully authenticated to the Solr instance.
+         *
+         * @property method The method that was used for authentication.
+         */
+        data class Authenticated(
+            val method: OAuthMethod,
+            val accessToken: String,
+            val refreshToken: String? = null,
+        ) : Label
+
+        /**
+         * Label that is published when the authentication process failed with an error.
+         *
+         * @property error The error that occurred during the authentication process.
+         */
+        data class AuthenticationFailed(val error: Throwable) : Label
+
+        /**
+         * Label that is published whenever the error state resets.
+         */
+        data object ErrorReset : Label
+    }
+
+    /**
+     * State class that holds the data of the [OAuthStore].
+     */
+    data class State(
+        val method: OAuthMethod,
+        val error: Throwable? = null,
+    )
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProvider.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProvider.kt
new file mode 100644
index 00000000000..50cf1d58f8d
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProvider.kt
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.store
+
+import com.arkivanov.mvikotlin.core.store.Reducer
+import com.arkivanov.mvikotlin.core.store.SimpleBootstrapper
+import com.arkivanov.mvikotlin.core.store.Store
+import com.arkivanov.mvikotlin.core.store.StoreFactory
+import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.http.ParametersBuilder
+import io.ktor.http.URLBuilder
+import io.ktor.utils.io.CancellationException
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.apache.solr.ui.components.auth.generateCodeChallenge
+import org.apache.solr.ui.components.auth.generateCodeVerifier
+import org.apache.solr.ui.components.auth.generateOAuthState
+import org.apache.solr.ui.components.auth.getRedirectUri
+import org.apache.solr.ui.components.auth.store.OAuthStore.Intent
+import org.apache.solr.ui.components.auth.store.OAuthStore.Label
+import org.apache.solr.ui.components.auth.store.OAuthStore.State
+import org.apache.solr.ui.domain.AuthMethod
+import org.apache.solr.ui.domain.OAuthData
+import org.apache.solr.ui.errors.InvalidCredentialsException
+import org.apache.solr.ui.errors.UnauthorizedException
+import org.apache.solr.ui.utils.parseError
+
+class OAuthStoreProvider(
+    private val storeFactory: StoreFactory,
+    private val client: Client,
+    private val mainContext: CoroutineContext,
+    private val ioContext: CoroutineContext,
+    private val method: AuthMethod.OAuthMethod,
+) {
+
+    fun provide(): OAuthStore = object :
+        OAuthStore,
+        Store<Intent, State, Label> by storeFactory.create(
+            name = "OAuthStore",
+            initialState = State(method = method),
+            bootstrapper = SimpleBootstrapper(),
+            executorFactory = ::ExecutorImpl,
+            reducer = ReducerImpl,
+        ) {}
+
+    private sealed interface Message {
+        /**
+         * Message that is dispatched when an authentication error occurred.
+         */
+        data class AuthenticationFailed(val error: Throwable) : Message
+    }
+
+    private inner class ExecutorImpl : CoroutineExecutor<Intent, Unit, State, Message, Label>(mainContext) {
+
+        private var authJob: Job? = null
+
+        override fun executeIntent(intent: Intent) {
+            when (intent) {
+                is Intent.Authenticate -> authenticate()
+            }
+        }
+
+        private fun authenticate(): Unit = with(state()) {
+            val verifier = generateCodeVerifier()
+            val challenge = generateCodeChallenge(verifier)
+            val oAuthState = generateOAuthState()
+            val authorizeUrl = URLBuilder(method.data.authorizationEndpoint)
+                .apply {
+                    encodedParameters = ParametersBuilder().apply {
+                        set("client_id", method.data.clientId)
+                        set("scope", method.data.scope)
+                        set("redirect_uri", getRedirectUri())
+                        set("response_type", "code")
+                        set("code_challenge_method", "S256")
+                        set("code_challenge", challenge)
+                        set("state", oAuthState)
+                    }
+                }
+                .build()
+
+            authJob?.cancel()
+            authJob = scope.launch {
+                publish(Label.AuthenticationStarted(url = authorizeUrl))
+                try {
+                    withContext(ioContext) {
+                        client.authenticate(oAuthState, verifier, method.data)
+                    }.onSuccess {
+                        // Authentication succeeded with the given credentials
+                        publish(
+                            Label.Authenticated(
+                                method = method,
+                                accessToken = it.accessToken,
+                                refreshToken = it.refreshToken,
+                            ),
+                        )
+                    }.onFailure { error ->
+                        handleConnectionError(error)
+                    }
+                } catch (error: CancellationException) {
+                    throw error
+                } catch (throwable: Throwable) {
+                    val parsed = parseError(throwable)
+                    publish(Label.AuthenticationFailed(error = parsed))
+                    dispatch(Message.AuthenticationFailed(error = parsed))
+                }
+            }
+        }
+
+        private fun handleConnectionError(error: Throwable) {
+            val mappedError: Throwable = when (error) {
+                // Unauthorized response means that the credentials are invalid
+                is UnauthorizedException -> InvalidCredentialsException()
+                else -> error
+            }
+
+            dispatch(Message.AuthenticationFailed(mappedError))
+            publish(Label.AuthenticationFailed(mappedError))
+        }
+    }
+
+    /**
+     * Reducer implementation that consumes [Message]s and updates the store's [State].
+     */
+    private object ReducerImpl : Reducer<State, Message> {
+        override fun State.reduce(msg: Message): State = when (msg) {
+            is Message.AuthenticationFailed -> copy(error = msg.error)
+        }
+    }
+
+    interface Client {
+
+        /**
+         * Authenticates the user with the current Solr instance.
+         *
+         * @param state The state value used in the authorization flow with PKCE.
+         * @param verifier Code verifier used in the authorization flow with PKCE.
+         * @param data The OAuth data to use for the auth flow.
+         * @return Returns success results iff the user has successfully authenticated.
+         */
+        suspend fun authenticate(state: String, verifier: String, data: OAuthData): Result<BearerTokens>
+    }
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/root/integration/SimpleRootComponent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/root/integration/SimpleRootComponent.kt
index 7a9deb560a9..88a5e9bb6d9 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/root/integration/SimpleRootComponent.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/root/integration/SimpleRootComponent.kt
@@ -120,7 +120,8 @@ class SimpleRootComponent(
                 componentContext,
                 configuration.url,
                 configuration.methods,
-            ) { output -> authenticationOutput(output) },
+                ::authenticationOutput,
+            ),
         )
     }
 
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/HttpStartStoreClient.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/HttpStartStoreClient.kt
index 885fccefe2e..fe9b53efcc7 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/HttpStartStoreClient.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/HttpStartStoreClient.kt
@@ -17,6 +17,7 @@
 
 package org.apache.solr.ui.components.start.integration
 
+import io.github.oshai.kotlinlogging.KotlinLogging
 import io.ktor.client.HttpClient
 import io.ktor.client.plugins.timeout
 import io.ktor.client.request.get
@@ -25,11 +26,16 @@ import io.ktor.http.HttpStatusCode
 import io.ktor.http.URLBuilder
 import io.ktor.http.Url
 import io.ktor.http.path
+import kotlin.io.encoding.Base64
+import kotlinx.serialization.json.Json
 import org.apache.solr.ui.components.start.store.StartStoreProvider
+import org.apache.solr.ui.data.SolrAuthData
 import org.apache.solr.ui.domain.AuthMethod
 import org.apache.solr.ui.errors.UnauthorizedException
 import org.apache.solr.ui.errors.UnknownResponseException
 
+private val logger = KotlinLogging.logger {}
+
 /**
  * Client implementation of the [StartStoreProvider.Client] that makes use
  * of a preconfigured HTTP client for accessing the Solr API.
@@ -47,6 +53,7 @@ class HttpStartStoreClient(
         val url = URLBuilder(url).apply {
             // Try getting system data for checking if Solr host exists,
             // since there is no explicit endpoint for the connection establishment.
+            // TODO Consider /api/admin/authentication
             path("api/node/system")
         }.build()
 
@@ -91,11 +98,36 @@ class HttpStartStoreClient(
             // Split by comma, as there is the chance that headers will be merged and separated by
             // comma (e.g. on web target)
             .flatMap { header -> header.split(",") }
-            .map { authHeader ->
+            .map(String::trim)
+            .mapIndexed { index, authHeader ->
                 val (scheme, params) = parseWwwAuthenticate(authHeader)
 
                 when (scheme.lowercase()) {
                     "basic", "xbasic" -> AuthMethod.BasicAuthMethod(realm = params["realm"])
+                    "bearer" -> {
+                        // There should be one X-Solr-Authdata header for each scheme
+                        val base64AuthData = headers.getAll("X-Solr-Authdata")
+                            // Note that there is a chance the header values are merged again
+                            // and separated by comma, so we split again
+                            ?.flatMap { headerValue -> headerValue.split(",") }
+                            ?.getOrNull(index)
+
+                        base64AuthData?.let {
+                            val rawAuthData = Base64.decode(source = it)
+                            val jsonAuthData = Json.decodeFromString<SolrAuthData>(rawAuthData.decodeToString())
+
+                            AuthMethod.OAuthMethod(
+                                data = jsonAuthData.toOAuthData(),
+                                realm = params["realm"],
+                            )
+                        } ?: run {
+                            // Without X-Solr-Authdata value we cannot authenticate with OAuth
+                            logger.error {
+                                "Missing X-Solr-Authdata for authentication scheme \"${scheme}\"."
+                            }
+                            AuthMethod.Unknown
+                        }
+                    }
                     else -> AuthMethod.Unknown
                 }
             }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/Mappers.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/Mappers.kt
index fb2cfcc4589..2c9df684fa6 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/Mappers.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/components/start/integration/Mappers.kt
@@ -19,8 +19,12 @@ package org.apache.solr.ui.components.start.integration
 
 import io.ktor.client.network.sockets.ConnectTimeoutException
 import io.ktor.http.URLParserException
+import io.ktor.http.Url
 import org.apache.solr.ui.components.start.StartComponent
 import org.apache.solr.ui.components.start.store.StartStore
+import org.apache.solr.ui.data.SolrAuthData
+import org.apache.solr.ui.domain.AuthorizationFlow
+import org.apache.solr.ui.domain.OAuthData
 import org.apache.solr.ui.errors.HostNotFoundException
 import org.apache.solr.ui.generated.resources.Res
 import org.apache.solr.ui.generated.resources.error_invalid_url
@@ -40,3 +44,22 @@ internal val startStateToModel: (StartStore.State) -> StartComponent.Model = {
         },
     )
 }
+
+internal fun SolrAuthData.toOAuthData() = OAuthData(
+    clientId = clientId,
+    authorizationFlow = authorizationFlow.toAuthorizationFlow(),
+    scope = scope,
+    redirectUris = redirectUris.map { Url(urlString = it) },
+    authorizationEndpoint = Url(urlString = authorizationEndpoint),
+    tokenEndpoint = Url(urlString = tokenEndpoint),
+)
+
+/**
+ * Maps a string to the corresponding AuthorizationFlow enum value.
+ *
+ * Note that only Code flow with Proof Key for Code Exchange (PKCE) is supported right now.
+ */
+private fun String.toAuthorizationFlow(): AuthorizationFlow = when (this) {
+    "code_pkce" -> AuthorizationFlow.CodePKCE
+    else -> AuthorizationFlow.Unknown
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationResponse.kt
similarity index 56%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationResponse.kt
index e50d0429290..10f55359c1b 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationResponse.kt
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.data
 
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
 @Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
-}
+data class AuthorizationResponse(
+    @SerialName("access_token")
+    val accessToken: String = "",
+    @SerialName("expires_in")
+    val expiresIn: Long = 0,
+    @SerialName("refresh_expires_in")
+    val refreshExpiresIn: Long = 0,
+    @SerialName("refresh_token")
+    val refreshToken: String = "",
+    @SerialName("token_type")
+    val tokenType: String = "",
+    @SerialName("id_token")
+    val idToken: String? = null,
+    @SerialName("not-before-policy")
+    val notBeforePolicy: Int = -1,
+    @SerialName("session_state")
+    val sessionState: String = "",
+    val scope: String = "",
+)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationTokenRequest.kt
similarity index 60%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationTokenRequest.kt
index e50d0429290..6176bfe7393 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/AuthorizationTokenRequest.kt
@@ -15,27 +15,21 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.data
 
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
 @Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
-}
+data class AuthorizationTokenRequest(
+    @SerialName("grant_type")
+    val grantType: String,
+    val code: String,
+    @SerialName("redirect_uri")
+    val redirectUri: String,
+    val scope: String,
+    @SerialName("code_verifier")
+    val codeVerifier: String,
+    @SerialName("client_id")
+    val clientId: String,
+)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/SolrAuthData.kt
similarity index 60%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/SolrAuthData.kt
index e50d0429290..454690a1140 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/data/SolrAuthData.kt
@@ -15,27 +15,24 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.data
 
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
 /**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
+ * The Solr authentication data provided via the `X-Solr-Authdata` header during authentication
+ * with OAuth.
  */
 @Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
-}
+data class SolrAuthData(
+    @SerialName("client_id")
+    val clientId: String,
+    val tokenEndpoint: String,
+    @SerialName("authorization_flow")
+    val authorizationFlow: String,
+    val scope: String,
+    @SerialName("redirect_uris")
+    val redirectUris: List<String>,
+    val authorizationEndpoint: String,
+)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
index e50d0429290..72830d381a9 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
@@ -37,5 +37,8 @@ sealed interface AuthMethod {
     @Serializable
     data class BasicAuthMethod(val realm: String? = null) : AuthMethod
 
+    @Serializable
+    data class OAuthMethod(val data: OAuthData, val realm: String? = null) : AuthMethod
+
     data object Unknown : AuthMethod
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthOption.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthOption.kt
index 9665d7aa19c..8a280ba1772 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthOption.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthOption.kt
@@ -51,4 +51,12 @@ sealed interface AuthOption {
         val password: String,
         val realm: String? = null,
     ) : AuthOption
+
+    @Serializable
+    data class OAuthOption(
+        val url: Url,
+        val accessToken: String,
+        val refreshToken: String? = null,
+        val realm: String? = null,
+    ) : AuthOption
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/OAuthData.kt
similarity index 62%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/OAuthData.kt
index e50d0429290..9852be7768a 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/OAuthData.kt
@@ -17,25 +17,22 @@
 
 package org.apache.solr.ui.domain
 
+import io.ktor.http.Url
+import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
 @Serializable
-sealed interface AuthMethod {
+data class OAuthData(
+    val clientId: String,
+    val authorizationFlow: AuthorizationFlow,
+    val scope: String,
+    val redirectUris: List<Url>,
+    val authorizationEndpoint: Url,
+    val tokenEndpoint: Url,
+)
 
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
+@Serializable
+enum class AuthorizationFlow {
+    Unknown,
+    CodePKCE,
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/errors/InvalidResponseException.kt
similarity index 56%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonMain/kotlin/org/apache/solr/ui/errors/InvalidResponseException.kt
index e50d0429290..ea101820445 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/errors/InvalidResponseException.kt
@@ -15,27 +15,6 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.errors
 
-import kotlinx.serialization.Serializable
-
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
-}
+class InvalidResponseException(message: String? = null) : Throwable(message)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt
index 98e6d146ed3..928e9e7bdec 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/utils/HttpClientUtils.kt
@@ -21,7 +21,9 @@ import io.ktor.client.HttpClient
 import io.ktor.client.HttpClientConfig
 import io.ktor.client.plugins.auth.Auth
 import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
+import io.ktor.client.plugins.auth.providers.BearerTokens
 import io.ktor.client.plugins.auth.providers.basic
+import io.ktor.client.plugins.auth.providers.bearer
 import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
 import io.ktor.client.plugins.defaultRequest
 import io.ktor.http.Url
@@ -61,6 +63,12 @@ fun getHttpClientWithAuthOption(option: AuthOption) = when (option) {
         username = option.username,
         password = option.password,
     )
+    is AuthOption.OAuthOption -> getHttpClientWithBearerTokens(
+        url = option.url,
+        realm = option.realm,
+        accessToken = option.accessToken,
+        refreshToken = option.refreshToken,
+    )
 }
 
 fun getHttpClientWithCredentials(
@@ -79,3 +87,23 @@ fun getHttpClientWithCredentials(
         }
     }
 }
+
+fun getHttpClientWithBearerTokens(
+    accessToken: String,
+    refreshToken: String? = null,
+    url: Url = Url("http://127.0.0.1:8983/"),
+    realm: String? = null,
+) = getDefaultClient(url) {
+    install(Auth) {
+        bearer {
+            loadTokens {
+                BearerTokens(accessToken, refreshToken)
+            }
+            refreshTokens {
+                TODO("Not implemented yet")
+            }
+            this.realm = realm
+            sendWithoutRequest { true }
+        }
+    }
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/OAuthContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/OAuthContent.kt
new file mode 100644
index 00000000000..a56aab87ff1
--- /dev/null
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/OAuthContent.kt
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.views.auth
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import org.apache.solr.ui.components.auth.OAuthComponent
+import org.apache.solr.ui.components.auth.store.OAuthStore
+import org.apache.solr.ui.generated.resources.Res
+import org.apache.solr.ui.generated.resources.action_sign_in_with_identity_provider
+import org.apache.solr.ui.generated.resources.action_sign_in_with_realm
+import org.apache.solr.ui.generated.resources.authenticating
+import org.apache.solr.ui.generated.resources.desc_sign_in_with_oauth
+import org.apache.solr.ui.generated.resources.desc_sign_in_with_oauth_to_realm
+import org.apache.solr.ui.views.components.SolrButton
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * The Oauth content is an input button where the user can click to authenticate and sign in to a
+ * Solr instance via an identity provider.
+ *
+ * @param component The [OAuthComponent] that is handling the interactions with this composable.
+ * @param modifier Modifier that is applied to the root of this composable.
+ * @param isAuthenticating Whether the user is currently being authenticated. This disables the inputs
+ * and updates the text shown in the button.
+ * @param showSupportingText Whether to show supporting text above the sign-in button. Settings this
+ * value to `false` allows a more compact view when multiple authentication options are available.
+ */
+@Composable
+fun OAuthContent(
+    component: OAuthComponent,
+    modifier: Modifier = Modifier,
+    isAuthenticating: Boolean = true,
+    showSupportingText: Boolean = true,
+) = Column(
+    modifier = modifier,
+    verticalArrangement = Arrangement.spacedBy(16.dp),
+) {
+    val model by component.model.collectAsState()
+
+    if (showSupportingText) {
+        Text(
+            text = model.realm?.let {
+                stringResource(Res.string.desc_sign_in_with_oauth_to_realm, it)
+            } ?: stringResource(Res.string.desc_sign_in_with_oauth),
+            style = MaterialTheme.typography.bodyMedium,
+        )
+    }
+
+    Column {
+        SolrButton(
+            modifier = Modifier.fillMaxWidth().testTag(tag = "oauth_sign_in_button"),
+            onClick = component::onAuthenticate,
+            enabled = !isAuthenticating,
+        ) {
+            Text(
+                text = if (isAuthenticating) {
+                    stringResource(Res.string.authenticating)
+                } else {
+                    model.realm?.let {
+                        stringResource(Res.string.action_sign_in_with_realm, it)
+                    } ?: stringResource(Res.string.action_sign_in_with_identity_provider)
+                },
+            )
+        }
+        if (isAuthenticating) {
+            LinearProgressIndicator(
+                modifier = Modifier.fillMaxWidth().testTag(tag = "loading_indicator"),
+            )
+        }
+    }
+
+    val uriHandler = LocalUriHandler.current
+    // Listen for labels once per composition
+    LaunchedEffect(component) {
+        component.labels.collect { label ->
+            when (label) {
+                is OAuthStore.Label.AuthenticationStarted -> uriHandler.openUri(uri = label.url.toString())
+                else -> Unit
+            }
+        }
+    }
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/UserAuthenticationContent.kt b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/UserAuthenticationContent.kt
index dbf1a9b5b1b..67bdbc789a6 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/UserAuthenticationContent.kt
+++ b/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/views/auth/UserAuthenticationContent.kt
@@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.ChevronLeft
 import androidx.compose.material3.ButtonDefaults
@@ -46,6 +48,7 @@ import org.apache.solr.ui.generated.resources.action_go_back
 import org.apache.solr.ui.generated.resources.cd_back_navigation
 import org.apache.solr.ui.generated.resources.cd_solr_logo
 import org.apache.solr.ui.generated.resources.desc_solr_instance_with_auth
+import org.apache.solr.ui.generated.resources.seperator_or
 import org.apache.solr.ui.generated.resources.solr_sun
 import org.apache.solr.ui.generated.resources.title_sign_in_to_solr
 import org.apache.solr.ui.views.components.SolrCard
@@ -56,6 +59,9 @@ import org.jetbrains.compose.resources.stringResource
 /**
  * The user authentication content is the composable that will check and display
  * the available authentication options to the user.
+ *
+ * @param component The authentication component that manages the state of this composable.
+ * @param modifier Modifier to apply to the root composable.
  */
 @Composable
 fun UserAuthenticationContent(
@@ -79,7 +85,9 @@ fun UserAuthenticationContent(
         contentDescription = stringResource(Res.string.cd_solr_logo),
     )
 
-    Column(modifier = Modifier.weight(1f).padding(16.dp)) {
+    Column(
+        modifier = Modifier.weight(1f).padding(16.dp),
+    ) {
         SolrTextButton(
             onClick = component::onAbort,
             contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
@@ -92,8 +100,11 @@ fun UserAuthenticationContent(
         }
 
         SolrCard(
-            modifier = Modifier.widthIn(min = 512.dp, max = 640.dp),
+            modifier = Modifier
+                .widthIn(min = 512.dp, max = 640.dp)
+                .verticalScroll(rememberScrollState()),
             verticalArrangement = Arrangement.spacedBy(16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally,
         ) {
             Text(
                 text = stringResource(Res.string.title_sign_in_to_solr),
@@ -115,6 +126,25 @@ fun UserAuthenticationContent(
                 )
             }
 
+            val oauthState by component.oAuthSlot.subscribeAsState()
+
+            val hasMultiAuth = basicAuthState.child != null && oauthState.child != null
+            if (hasMultiAuth) {
+                Text(
+                    modifier = Modifier.testTag("separator_text"),
+                    text = stringResource(Res.string.seperator_or),
+                )
+            }
+
+            oauthState.child?.let { oAuth ->
+                OAuthContent(
+                    modifier = Modifier.testTag("basic_auth_content"),
+                    component = oAuth.instance,
+                    isAuthenticating = model.isAuthenticating,
+                    showSupportingText = !hasMultiAuth,
+                )
+            }
+
             model.error?.let { error ->
                 Text(
                     modifier = Modifier.testTag("error_text"),
diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTest.kt
new file mode 100644
index 00000000000..1495695724d
--- /dev/null
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AuthUtilsTest {
+
+    @Test
+    fun `WHEN generate code verifier THEN length is CODE_VERIFIER_LENGTH`() {
+        val codeVerifier = generateCodeVerifier()
+        assertEquals(
+            expected = CODE_VERIFIER_LENGTH,
+            actual = codeVerifier.length,
+            message = "code verifier length should be $CODE_VERIFIER_LENGTH characters",
+        )
+    }
+
+    @Test
+    fun `WHEN generate code verifier THEN used characters in CODE_VERIFIER_CHARSET`() {
+        val codeVerifier = generateCodeVerifier()
+        codeVerifier.all {
+            assertTrue(
+                actual = it in CODE_VERIFIER_CHARSET,
+                message = """Code verifier character "$it" should be only allowed characters from RFC 7636""",
+            )
+            true
+        }
+    }
+
+    @Test
+    fun `WHEN generate OAuth state THEN length is STATE_LENGTH`() {
+        val state = generateOAuthState()
+        assertEquals(
+            expected = STATE_LENGTH,
+            actual = state.length,
+            message = "state length should be $STATE_LENGTH characters",
+        )
+    }
+
+    @Test
+    fun `WHEN generate OAuth state THEN used characters in STATE_CHARSET`() {
+        val state = generateOAuthState()
+        state.all {
+            assertTrue(
+                actual = it in STATE_CHARSET,
+                message = """OAuth state character "$it" should be one of $STATE_CHARSET""",
+            )
+            true
+        }
+    }
+
+    @Test
+    fun `GIVEN valid code verifier WHEN generate code challenge THEN all output characters are URL safe`() {
+        // Use a fix code verifier to include all characters, instead of randomly generating one
+        val codeVerifier =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+        val actualCodeChallenge = generateCodeChallenge(codeVerifier)
+        val urlSafeCharacters = CODE_VERIFIER_CHARSET
+
+        actualCodeChallenge.all {
+            assertTrue(
+                actual = it in urlSafeCharacters,
+                message = """Code challenge character "$it" is not URL-safe""",
+            )
+            true
+        }
+    }
+
+    @Test
+    fun `GIVEN valid code verifier WHEN generate hashed code challenge THEN all characters are URL safe`() {
+        // Use a fix code verifier to include all characters, instead of randomly generating one
+        val codeVerifier =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+        val expectedCodeChallenge = "Gn88msbRKQ0wmy6Kms0RzrR4ZXFo3OGDewwvI9C7qZg"
+        val actualCodeChallenge = generateCodeChallenge(codeVerifier)
+        assertEquals(
+            expected = expectedCodeChallenge,
+            actual = actualCodeChallenge,
+            message = "code challenge hash ",
+        )
+        assertFalse(
+            actual = actualCodeChallenge.contains("="),
+            message = "Code challenge should not contain padding characters",
+        )
+    }
+
+    @Test
+    fun `GIVEN valid fixed code verifier WHEN generate hashed code challenge THEN no paddings included`() {
+        // Use a fix code verifier to include all characters, instead of randomly generating one
+        val codeVerifier =
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+        val actualCodeChallenge = generateCodeChallenge(codeVerifier)
+
+        // With padding the code verifier would generate Gn88msbRKQ0wmy6Kms0RzrR4ZXFo3OGDewwvI9C7qZg=
+        // with a padding character at the end.
+        assertFalse(
+            actual = actualCodeChallenge.contains("="),
+            message = "Code challenge should not contain padding characters",
+        )
+    }
+}
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/FakeOAuthClient.kt
similarity index 54%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/FakeOAuthClient.kt
index e50d0429290..8756d9c0429 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/FakeOAuthClient.kt
@@ -15,27 +15,30 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.components.auth.store
 
-import kotlinx.serialization.Serializable
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import org.apache.solr.ui.domain.OAuthData
 
 /**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
+ * OAuth client for mocking the OAuth server responses.
  */
-@Serializable
-sealed interface AuthMethod {
+internal class FakeOAuthClient(
+    private val result: suspend () -> Result<BearerTokens>,
+) : OAuthStoreProvider.Client {
 
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
+    var lastState: String? = null
+    var lastVerifier: String? = null
+    var lastData: OAuthData? = null
 
-    data object Unknown : AuthMethod
+    override suspend fun authenticate(
+        state: String,
+        verifier: String,
+        data: OAuthData,
+    ): Result<BearerTokens> {
+        lastState = state
+        lastVerifier = verifier
+        lastData = data
+        return result()
+    }
 }
diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProviderTest.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProviderTest.kt
new file mode 100644
index 00000000000..dd61ccb96e9
--- /dev/null
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/components/auth/store/OAuthStoreProviderTest.kt
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.store
+
+import com.arkivanov.mvikotlin.extensions.coroutines.labels
+import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.http.Url
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.apache.solr.ui.components.auth.store.OAuthStore.Intent
+import org.apache.solr.ui.components.auth.store.OAuthStore.Label
+import org.apache.solr.ui.domain.AuthMethod
+import org.apache.solr.ui.domain.AuthorizationFlow
+import org.apache.solr.ui.domain.OAuthData
+import org.apache.solr.ui.errors.InvalidCredentialsException
+import org.apache.solr.ui.errors.UnauthorizedException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class OAuthStoreProviderTest {
+
+    private lateinit var client: FakeOAuthClient
+    private lateinit var store: OAuthStore
+
+    private val method = AuthMethod.OAuthMethod(
+        data = OAuthData(
+            authorizationEndpoint = Url("https://auth.example.com/authorize"),
+            clientId = "client-id",
+            scope = "openid profile",
+            authorizationFlow = AuthorizationFlow.CodePKCE,
+            redirectUris = listOf(Url("http://127.0.0.1:8983/callback")),
+            tokenEndpoint = Url("https://auth.example.com/token"),
+        ),
+    )
+
+    private fun createStore(
+        testScheduler: TestCoroutineScheduler,
+        result: suspend () -> Result<BearerTokens>,
+    ) {
+        client = FakeOAuthClient(result)
+
+        store = OAuthStoreProvider(
+            storeFactory = DefaultStoreFactory(),
+            client = client,
+            mainContext = UnconfinedTestDispatcher(testScheduler),
+            ioContext = StandardTestDispatcher(testScheduler),
+            method = method,
+        ).provide()
+        store.init()
+    }
+
+    @Test
+    fun `WHEN authenticate THEN AuthenticationStarted is published`() = runTest {
+        createStore(testScheduler) {
+            Result.failure(RuntimeException("noop"))
+        }
+
+        val labels = mutableListOf<Label>()
+        store.labels.onEach(labels::add).launchIn(backgroundScope)
+        runCurrent()
+
+        store.accept(Intent.Authenticate)
+        runCurrent()
+        advanceUntilIdle()
+
+        val label = assertIs<Label.AuthenticationStarted>(
+            value = labels.firstOrNull(),
+            message = "first label emitted should be AuthenticationStarted",
+        )
+        val url = label.url.toString()
+
+        assertTrue(url.contains("client_id=client-id"))
+        assertTrue(url.contains("response_type=code"))
+        assertTrue(url.contains("code_challenge"))
+        assertTrue(url.contains("state="))
+    }
+
+    @Test
+    fun `WHEN unexpected error during authenticate THEN error state is set and published`() = runTest {
+        createStore(testScheduler) {
+            // Fail immediately
+            throw RuntimeException("oops")
+        }
+
+        val labels = mutableListOf<Label>()
+        store.labels.onEach(labels::add).launchIn(backgroundScope)
+        runCurrent()
+
+        store.accept(Intent.Authenticate)
+        runCurrent()
+        advanceUntilIdle()
+
+        val errorLabel = assertIs<Label.AuthenticationFailed>(
+            value = labels.filterIsInstance<Label.AuthenticationFailed>().single(),
+            message = "first label emitted should be AuthenticationStarted",
+        )
+        assertIs<RuntimeException>(
+            value = errorLabel.error,
+            message = "error should be RuntimeException",
+        )
+        assertNotNull(
+            actual = store.state.error,
+            message = "state should have error",
+        )
+    }
+
+    @Test
+    fun `WHEN authentication succeeds THEN Authenticated is published`() = runTest {
+        val tokens = BearerTokens(
+            accessToken = "access",
+            refreshToken = "refresh",
+        )
+
+        createStore(testScheduler) {
+            Result.success(tokens)
+        }
+
+        val labels = mutableListOf<Label>()
+        store.labels.onEach(labels::add).launchIn(backgroundScope)
+        runCurrent()
+
+        store.accept(Intent.Authenticate)
+        runCurrent()
+        advanceUntilIdle()
+
+        val authenticated = labels.filterIsInstance<Label.Authenticated>().single()
+
+        assertEquals("access", authenticated.accessToken)
+        assertEquals("refresh", authenticated.refreshToken)
+        assertEquals(method, authenticated.method)
+    }
+
+    @Test
+    fun `WHEN authentication fails THEN AuthenticationFailed is published`() = runTest {
+        val error = RuntimeException("boom")
+
+        createStore(testScheduler) {
+            Result.failure(error)
+        }
+
+        val labels = mutableListOf<Label>()
+        store.labels.onEach(labels::add).launchIn(backgroundScope)
+        runCurrent()
+
+        store.accept(Intent.Authenticate)
+        runCurrent()
+        advanceUntilIdle()
+
+        val failedLabel = labels.filterIsInstance<Label.AuthenticationFailed>().single()
+        assertEquals(error, failedLabel.error)
+
+        assertEquals(error, store.state.error)
+    }
+
+    @Test
+    fun `WHEN unauthorized THEN error is mapped to InvalidCredentialsException`() = runTest {
+        createStore(testScheduler) {
+            Result.failure(exception = UnauthorizedException())
+        }
+
+        val labels = mutableListOf<Label>()
+        store.labels.onEach(labels::add).launchIn(backgroundScope)
+        runCurrent()
+
+        store.accept(Intent.Authenticate)
+        runCurrent()
+        advanceUntilIdle()
+
+        assertTrue(actual = labels.size > 1, message = "multiple labels should exist")
+        val failed = labels.filterIsInstance<Label.AuthenticationFailed>().single()
+
+        assertIs<InvalidCredentialsException>(
+            value = failed.error,
+            message = "error should be InvalidCredentialsException",
+        )
+        assertIs<InvalidCredentialsException>(
+            value = store.state.error,
+            message = "state error should be InvalidCredentialsException",
+        )
+    }
+}
diff --git a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestAuthenticationComponent.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestAuthenticationComponent.kt
index 12968c93dff..337c3d9f147 100644
--- a/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestAuthenticationComponent.kt
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestAuthenticationComponent.kt
@@ -21,17 +21,27 @@ import com.arkivanov.decompose.Child.Created
 import com.arkivanov.decompose.router.slot.ChildSlot
 import com.arkivanov.decompose.value.MutableValue
 import com.arkivanov.decompose.value.Value
+import io.ktor.http.Url
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import org.apache.solr.ui.components.auth.AuthenticationComponent
 import org.apache.solr.ui.components.auth.AuthenticationComponent.BasicAuthConfiguration
 import org.apache.solr.ui.components.auth.AuthenticationComponent.Model
+import org.apache.solr.ui.components.auth.AuthenticationComponent.OAuthConfiguration
 import org.apache.solr.ui.components.auth.BasicAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent
+import org.apache.solr.ui.domain.AuthMethod
+import org.apache.solr.ui.domain.AuthorizationFlow
+import org.apache.solr.ui.domain.OAuthData
 
 internal class TestAuthenticationComponent(
     model: Model = Model(),
     basicAuthConfig: BasicAuthConfiguration = BasicAuthConfiguration(),
     basicAuthComponent: BasicAuthComponent = TestBasicAuthComponent(),
+    oAuthConfig: OAuthConfiguration = OAuthConfiguration(
+        method = AuthMethod.OAuthMethod(data = TestOAuthData),
+    ),
+    oAuthComponent: OAuthComponent = TestOAuthComponent(),
 ) : AuthenticationComponent {
     var onAbortClicked = false
     override val model: StateFlow<Model> = MutableStateFlow(model)
@@ -44,6 +54,15 @@ internal class TestAuthenticationComponent(
                 ),
             ),
         )
+    override val oAuthSlot: Value<ChildSlot<OAuthConfiguration, OAuthComponent>> =
+        MutableValue(
+            ChildSlot(
+                child = Created(
+                    configuration = oAuthConfig,
+                    instance = oAuthComponent,
+                ),
+            ),
+        )
 
     override fun onAbort() {
         onAbortClicked = true
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthComponent.kt
similarity index 52%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthComponent.kt
index e50d0429290..ec9bf437e0c 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthComponent.kt
@@ -15,27 +15,24 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.views.auth
 
-import kotlinx.serialization.Serializable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.apache.solr.ui.components.auth.OAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent.Model
+import org.apache.solr.ui.components.auth.store.OAuthStore
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
+internal class TestOAuthComponent(
+    model: Model = Model(),
+    override val labels: Flow<OAuthStore.Label> = MutableSharedFlow(),
+) : OAuthComponent {
+    var onAuthenticateClicked = false
 
-    data object Unknown : AuthMethod
+    override val model: StateFlow<Model> = MutableStateFlow(model)
+    override fun onAuthenticate() {
+        onAuthenticateClicked = true
+    }
 }
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthData.kt
similarity index 56%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthData.kt
index e50d0429290..de32ba074e1 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/commonTest/kotlin/org/apache/solr/ui/views/auth/TestOAuthData.kt
@@ -15,27 +15,17 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.views.auth
 
-import kotlinx.serialization.Serializable
+import io.ktor.http.Url
+import org.apache.solr.ui.domain.AuthorizationFlow
+import org.apache.solr.ui.domain.OAuthData
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
-}
+internal val TestOAuthData = OAuthData(
+    clientId = "client-id",
+    authorizationFlow = AuthorizationFlow.CodePKCE,
+    scope = "openid solr",
+    redirectUris = listOf(Url("http://127.0.0.1:8088")),
+    authorizationEndpoint = Url("http://127.0.0.1:3000/auth"),
+    tokenEndpoint = Url("http://127.0.0.1:3000/token"),
+)
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.desktop.kt
similarity index 56%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.desktop.kt
index e50d0429290..cb6d7c41f93 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.desktop.kt
@@ -15,27 +15,11 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.components.auth
 
-import kotlinx.serialization.Serializable
-
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
+internal actual fun getRedirectUri(): String {
+    // For desktop we return the URL the custom server instance is listening to
+    // TODO The hostname and port may be overwritten in future,
+    //  update accordingly to use dynamic values
+    return "http://127.0.0.1:8088/callback"
 }
diff --git a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
new file mode 100644
index 00000000000..8db947ec33b
--- /dev/null
+++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.integration
+
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.timeout
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.forms.submitForm
+import io.ktor.client.request.get
+import io.ktor.http.ContentType
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.parameters
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.request.receiveParameters
+import io.ktor.server.response.respondText
+import io.ktor.server.routing.get
+import io.ktor.server.routing.post
+import io.ktor.server.routing.routing
+import kotlinx.coroutines.CompletableDeferred
+import org.apache.solr.ui.components.auth.getRedirectUri
+import org.apache.solr.ui.components.auth.store.OAuthStoreProvider
+import org.apache.solr.ui.data.AuthorizationResponse
+import org.apache.solr.ui.domain.OAuthData
+import org.apache.solr.ui.errors.InvalidResponseException
+import org.apache.solr.ui.errors.UnauthorizedException
+import org.apache.solr.ui.errors.UnknownResponseException
+
+/**
+ * OAuth store implementation that uses a server instance for handling callbacks.
+ *
+ * The flow on desktop looks as follows:
+ * 1. The application launches a server that listens on 127.0.0.1:8088
+ * 2. The application opens the identity provider page in the browser
+ * 3. After the user authenticates and authorizes the app, the identity provider redirects the user
+ *    to the server that was launched by the app
+ * 4. The server evaluates the query parameters passed with the redirect and generates a new token.
+ *
+ * There is a special handling for redirects that use the hash fragment instead of query parameters.
+ * In that case, the server responds with a script that reloads the page with the hash fragment as
+ * query parameters.
+ *
+ * @property httpClient A preconfigured HTTP client that has the base URL of a Solr instance
+ * already set.
+ */
+actual class PlatformOAuthStoreClient actual constructor(private val httpClient: HttpClient) : OAuthStoreProvider.Client {
+
+    actual override suspend fun authenticate(
+        state: String,
+        verifier: String,
+        data: OAuthData,
+    ): Result<BearerTokens> {
+        val queryParams = listenForOAuthCallback()
+
+        val code =
+            queryParams["code"] ?: throw InvalidResponseException("code not retrieved but required")
+        val responseState = queryParams["state"]
+            ?: throw InvalidResponseException("state not retrieved but required")
+        if (state != responseState) {
+            return Result.failure(
+                exception = InvalidResponseException(message = "Invalid state value received"),
+            )
+        }
+
+        // TODO Add check for valid redirect URIs to make sure the current is fine
+        val authResponse = httpClient.submitForm(
+            url = data.tokenEndpoint.toString(),
+            formParameters = parameters {
+                append("grant_type", "authorization_code")
+                append("code", code)
+                append("redirect_uri", getRedirectUri())
+                append("scope", data.scope)
+                append("code_verifier", verifier)
+                append("client_id", data.clientId)
+            },
+        ).body<AuthorizationResponse>()
+
+        val accessToken = authResponse.accessToken
+        val refreshToken = authResponse.refreshToken
+
+        val response = httpClient.get("api/node/system") {
+            timeout { connectTimeoutMillis = 5000 }
+            bearerAuth(token = accessToken)
+        }
+
+        return when (response.status) {
+            HttpStatusCode.OK -> Result.success(BearerTokens(accessToken, refreshToken))
+            HttpStatusCode.Unauthorized -> {
+                Result.failure(UnauthorizedException(message = "Invalid Credentials"))
+            }
+
+            else -> Result.failure(UnknownResponseException(response))
+        }
+    }
+
+    /**
+     * Starts an embedded Ktor server on 127.0.0.1:3000 and waits for a single OAuth callback.
+     *
+     * Returns a map of captured parameters (e.g. "code", "state", or "access_token").
+     *
+     * The function suspends until the callback is received, then the server is stopped and the captured
+     * parameters are returned.
+     */
+    private suspend fun listenForOAuthCallback(
+        port: Int = 8088,
+        host: String = "127.0.0.1",
+        path: String = "/callback",
+        timeoutMillis: Long = 120_000L,
+    ): Map<String, String> {
+        val result = CompletableDeferred<Map<String, String>>()
+
+        val server = embeddedServer(CIO, host = host, port = port) {
+            routing {
+                get(path) {
+                    val queryParams = call.request.queryParameters
+                    val code = queryParams["code"]
+                    val state = queryParams["state"]
+                    // If query contains code or access_token, capture and finish
+                    // val hasQueryToken = queryParams["code"] != null || queryParams["access_token"] != null || queryParams.names().isNotEmpty()
+                    val hasQueryToken = code != null && state != null
+
+                    if (hasQueryToken) {
+                        // collect params into a map
+                        val map = queryParams.names().associateWith { queryParams[it].orEmpty() }
+                        // respond user-friendly page
+                        call.respondText(
+                            """
+                        <!doctype html>
+                        <html>
+                          <head>
+                            <meta charset="utf-8"/>
+                            <title>Authentication Complete</title>
+                            <meta name="viewport" content="width=device-width,initial-scale=1" />
+                          </head>
+                          <body>
+                            <h2>Authentication complete</h2>
+                            <p>You can now return to the application. You may close this window.</p>
+                          </body>
+                        </html>
+                            """.trimIndent(),
+                            ContentType.Text.Html,
+                        )
+                        // complete and stop the server
+                        result.complete(map)
+                    } else {
+                        // No query parameters — maybe the provider returned a fragment (#access_token=...)
+                        // Serve JS that converts the fragment into a query string and reloads the page.
+                        call.respondText(
+                            """
+                        <!doctype html>
+                        <html>
+                          <head>
+                            <meta charset="utf-8"/>
+                            <title>Completing authentication...</title>
+                            <meta name="viewport" content="width=device-width,initial-scale=1" />
+                          </head>
+                          <body>
+                            <p>Completing authentication — please wait...</p>
+                            <script>
+                              // If there's a hash fragment, transform it into a query and reload:
+                              (function() {
+                                if (window.location.hash && window.location.hash.length > 1) {
+                                  // Replace leading '#' with '?'
+                                  const q = window.location.hash.replace(/^#/, '?');
+                                  // Replace the current URL with the same path plus query (this sends it to the server)
+                                  window.location.replace(window.location.pathname + q);
+                                } else {
+                                  document.body.innerHTML = '<p>No token found in URL. Please return to the app and try again.</p>';
+                                }
+                              })();
+                            </script>
+                          </body>
+                        </html>
+                            """.trimIndent(),
+                            ContentType.Text.Html,
+                        )
+                    }
+                }
+
+                // Optional: provide a POST endpoint if you prefer the JS to post fragment via fetch
+                post("/__callback_post") {
+                    val params = call.receiveParameters()
+                    val map = params.names().associateWith { params[it].orEmpty() }
+                    call.respondText("OK", ContentType.Text.Plain)
+                    if (!result.isCompleted) result.complete(map)
+                }
+            }
+        }
+
+        server.start(wait = false)
+
+        // Wait until callback is received
+        val captured = result.await()
+
+        // shutdown server gracefully
+        server.stop(gracePeriodMillis = 1000, timeoutMillis = 2000)
+        return captured
+    }
+}
diff --git a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/auth/PreviewUserAuthenticationContent.kt b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/auth/PreviewUserAuthenticationContent.kt
index b1fcba41cdf..4d9843d9eab 100644
--- a/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/auth/PreviewUserAuthenticationContent.kt
+++ b/solr/ui/src/desktopMain/kotlin/org/apache/solr/ui/preview/auth/PreviewUserAuthenticationContent.kt
@@ -22,37 +22,119 @@ import com.arkivanov.decompose.Child
 import com.arkivanov.decompose.router.slot.ChildSlot
 import com.arkivanov.decompose.value.MutableValue
 import com.arkivanov.decompose.value.Value
+import io.ktor.http.Url
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import org.apache.solr.ui.components.auth.AuthenticationComponent
 import org.apache.solr.ui.components.auth.AuthenticationComponent.BasicAuthConfiguration
+import org.apache.solr.ui.components.auth.AuthenticationComponent.OAuthConfiguration
 import org.apache.solr.ui.components.auth.BasicAuthComponent
+import org.apache.solr.ui.components.auth.OAuthComponent
+import org.apache.solr.ui.components.auth.store.OAuthStore
 import org.apache.solr.ui.domain.AuthMethod
+import org.apache.solr.ui.domain.AuthorizationFlow
+import org.apache.solr.ui.domain.OAuthData
 import org.apache.solr.ui.preview.PreviewContainer
 import org.apache.solr.ui.views.auth.UserAuthenticationContent
 import org.jetbrains.compose.ui.tooling.preview.Preview
 
 @Composable
 @Preview
-internal fun PreviewUserAuthenticationContent() = PreviewContainer {
-    UserAuthenticationContent(component = PreviewAuthenticationComponentWithBasicAuth)
+internal fun PreviewBasicUserAuthenticationContent() = PreviewContainer {
+    UserAuthenticationContent(
+        component = PreviewAuthenticationComponentWithBasicAuth(
+            withBasicAuth = true,
+            withOAuth = false,
+        ),
+    )
+}
+
+@Composable
+@Preview
+internal fun PreviewOAuthUserAuthenticationContent() = PreviewContainer {
+    UserAuthenticationContent(
+        component = PreviewAuthenticationComponentWithBasicAuth(
+            withBasicAuth = false,
+            withOAuth = true,
+        ),
+    )
+}
+
+@Composable
+@Preview
+internal fun PreviewMultiUserAuthenticationContent() = PreviewContainer {
+    UserAuthenticationContent(
+        component = PreviewAuthenticationComponentWithBasicAuth(
+            withBasicAuth = true,
+            withOAuth = true,
+        ),
+    )
 }
 
-private data object PreviewAuthenticationComponentWithBasicAuth : AuthenticationComponent {
-    override val model: StateFlow<AuthenticationComponent.Model> = MutableStateFlow(
-        AuthenticationComponent.Model(
-            methods = listOf(AuthMethod.BasicAuthMethod()),
+private class PreviewAuthenticationComponentWithBasicAuth(
+    private val withBasicAuth: Boolean = false,
+    private val withOAuth: Boolean = false,
+    model: AuthenticationComponent.Model = AuthenticationComponent.Model(),
+) : AuthenticationComponent {
+    constructor(
+        withBasicAuth: Boolean = false,
+        withOAuth: Boolean = false,
+    ) : this(
+        withBasicAuth = withBasicAuth,
+        withOAuth = withOAuth,
+        model = AuthenticationComponent.Model(
+            methods = listOfNotNull(
+                if (withBasicAuth) AuthMethod.BasicAuthMethod() else null,
+                if (withOAuth) {
+                    AuthMethod.OAuthMethod(
+                        data = OAuthData(
+                            clientId = "",
+                            authorizationFlow = AuthorizationFlow.CodePKCE,
+                            scope = "",
+                            redirectUris = listOf(Url("http://localhost")),
+                            authorizationEndpoint = Url("http://localhost"),
+                            tokenEndpoint = Url("http://localhost"),
+                        ),
+                    )
+                } else {
+                    null
+                },
+            ),
         ),
     )
 
+    override val model: StateFlow<AuthenticationComponent.Model> = MutableStateFlow(model)
+
     override val basicAuthSlot: Value<ChildSlot<BasicAuthConfiguration, BasicAuthComponent>> =
         MutableValue(
-            ChildSlot(
-                child = Child.Created(
-                    configuration = BasicAuthConfiguration(),
-                    instance = PreviewBasicAuthComponent,
-                ),
-            ),
+            if (withBasicAuth) {
+                ChildSlot(
+                    Child.Created(
+                        configuration = BasicAuthConfiguration(),
+                        instance = PreviewBasicAuthComponent,
+                    ),
+                )
+            } else {
+                ChildSlot()
+            },
+        )
+
+    override val oAuthSlot: Value<ChildSlot<OAuthConfiguration, OAuthComponent>> =
+        MutableValue(
+            if (withOAuth) {
+                ChildSlot(
+                    Child.Created(
+                        configuration = OAuthConfiguration(
+                            method = model.methods.filterIsInstance<AuthMethod.OAuthMethod>().single(),
+                        ),
+                        instance = PreviewOAuthComponent,
+                    ),
+                )
+            } else {
+                ChildSlot()
+            },
         )
 
     override fun onAbort() = Unit
@@ -66,3 +148,11 @@ private object PreviewBasicAuthComponent : BasicAuthComponent {
     override fun onChangePassword(password: String) = Unit
     override fun onAuthenticate() = Unit
 }
+
+private object PreviewOAuthComponent : OAuthComponent {
+    override val model: StateFlow<OAuthComponent.Model> =
+        MutableStateFlow(OAuthComponent.Model())
+    override val labels: Flow<OAuthStore.Label> = MutableSharedFlow()
+
+    override fun onAuthenticate() = Unit
+}
diff --git a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt
index c3bd336105f..a224111b4e6 100644
--- a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt
+++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/Main.kt
@@ -27,6 +27,7 @@ import com.arkivanov.decompose.DefaultComponentContext
 import com.arkivanov.essenty.lifecycle.LifecycleRegistry
 import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
 import io.ktor.http.Url
+import kotlin.js.unsafeCast
 import kotlinx.browser.document
 import kotlinx.browser.window
 import kotlinx.coroutines.Dispatchers
@@ -36,6 +37,7 @@ import org.apache.solr.ui.utils.DefaultAppComponentContext
 import org.apache.solr.ui.utils.getDefaultClient
 import org.apache.solr.ui.views.root.RootContent
 import org.apache.solr.ui.views.theme.SolrTheme
+import org.w3c.dom.Window
 
 /**
  * Entry point of the Compose application for all wasmJs (browser) targets.
@@ -55,6 +57,17 @@ fun main() {
     val url = Url(window.location.href)
     val destination = url.parameters["dest"]
 
+    // TODO Extend the path and query params analysis for deep links and page reloads
+    if (url.rawSegments.last() == "callback") {
+        // This is a callback request from an authentication attempt,
+        // notify other tab of same origin and close window.
+        // Note that this is a simple way to handle deep links for OAuth callbacks
+        window.opener?.unsafeCast<Window>()
+            ?.postMessage(window.location.search.toJsString(), window.location.origin)
+        window.close()
+        return
+    }
+
     // TODO Set default request url to values from window location
     val httpClient = getDefaultClient()
 
diff --git a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.wasmJs.kt
similarity index 56%
copy from solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
copy to solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.wasmJs.kt
index e50d0429290..269953817f9 100644
--- a/solr/ui/src/commonMain/kotlin/org/apache/solr/ui/domain/AuthMethod.kt
+++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/AuthUtils.wasmJs.kt
@@ -15,27 +15,16 @@
  * limitations under the License.
  */
 
-package org.apache.solr.ui.domain
+package org.apache.solr.ui.components.auth
 
-import kotlinx.serialization.Serializable
+import io.ktor.http.URLBuilder
+import io.ktor.http.appendPathSegments
+import kotlinx.browser.window
 
-/**
- * The authentication method is used for determining what methods of authenticating are available.
- * These methods hold information for displaying and trying to authenticate.
- *
- * @see AuthOption
- */
-@Serializable
-sealed interface AuthMethod {
-
-    /**
-     * Basic authentication method that uses username and password for
-     * authenticating.
-     *
-     * @property realm The realm of the basic auth.
-     */
-    @Serializable
-    data class BasicAuthMethod(val realm: String? = null) : AuthMethod
-
-    data object Unknown : AuthMethod
+internal actual fun getRedirectUri(): String {
+    // For wasmJs we return the current origin with the full path
+    // TODO window.location.href may not be reliable, update if necessary
+    return URLBuilder(window.location.href).apply {
+        appendPathSegments("callback")
+    }.buildString()
 }
diff --git a/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
new file mode 100644
index 00000000000..31c13b240eb
--- /dev/null
+++ b/solr/ui/src/wasmJsMain/kotlin/org/apache/solr/ui/components/auth/integration/PlatformOAuthStoreClient.kt
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth.integration
+
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.auth.providers.BearerTokens
+import io.ktor.client.plugins.timeout
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.forms.submitForm
+import io.ktor.client.request.get
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.Url
+import io.ktor.http.parameters
+import kotlin.js.unsafeCast
+import kotlinx.browser.window
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.apache.solr.ui.components.auth.getRedirectUri
+import org.apache.solr.ui.components.auth.store.OAuthStoreProvider
+import org.apache.solr.ui.data.AuthorizationResponse
+import org.apache.solr.ui.domain.OAuthData
+import org.apache.solr.ui.errors.InvalidResponseException
+import org.apache.solr.ui.errors.UnauthorizedException
+import org.apache.solr.ui.errors.UnknownResponseException
+import org.w3c.dom.MessageEvent
+
+private val logger = KotlinLogging.logger {}
+
+/**
+ * OAuth store implementation that uses event listener and postMessage for handling callbacks
+ * from the identity provider after authentication / authorization.
+ *
+ * The basic flow on web looks as follows:
+ * 1. User selects authentication with OAuth
+ * 2. The current tab starts listening for messages
+ * 2. A new tab is opened that opens the identity provider authentication / authorization page.
+ * 3. The identity provide redirects in the new tab back to the app
+ * 4. The new app instance checks the URL data and publishes the redirect data via postMessage
+ * 5. The new tab is closed once the data is posted
+ * 6. The initial app that was listening retrieves the data and proceeds with the authentication
+ *
+ * @property httpClient A preconfigured HTTP client that has the base URL of a Solr instance
+ * already set.
+ */
+actual class PlatformOAuthStoreClient actual constructor(private val httpClient: HttpClient) : OAuthStoreProvider.Client {
+
+    actual override suspend fun authenticate(
+        state: String,
+        verifier: String,
+        data: OAuthData,
+    ): Result<BearerTokens> {
+        val queryParams = getQueryParams()
+
+        val code =
+            queryParams["code"] ?: throw InvalidResponseException("code not retrieved but required")
+        val responseState = queryParams["state"]
+            ?: throw InvalidResponseException("state not retrieved but required")
+        if (state != responseState) {
+            return Result.failure(
+                exception = InvalidResponseException(message = "Invalid state value received"),
+            )
+        }
+
+        val authResponse = httpClient.submitForm(
+            url = data.tokenEndpoint.toString(),
+            formParameters = parameters {
+                append("grant_type", "authorization_code")
+                append("code", code)
+                append("redirect_uri", pickRedirectUri(data.redirectUris))
+                append("scope", data.scope)
+                append("code_verifier", verifier)
+                append("client_id", data.clientId)
+            },
+        ).body<AuthorizationResponse>()
+
+        val accessToken = authResponse.accessToken
+        val refreshToken = authResponse.refreshToken
+
+        val response = httpClient.get("api/node/system") {
+            timeout { connectTimeoutMillis = 5000 }
+            bearerAuth(token = accessToken)
+        }
+
+        return when (response.status) {
+            HttpStatusCode.OK -> Result.success(BearerTokens(accessToken, refreshToken))
+            HttpStatusCode.Unauthorized -> {
+                Result.failure(UnauthorizedException(message = "Invalid Credentials"))
+            }
+
+            else -> Result.failure(UnknownResponseException(response))
+        }
+    }
+
+    /**
+     * Retrieves the query params from the current page.
+     */
+    private suspend fun getQueryParams(): Map<String, String> {
+        return suspendCancellableCoroutine { continuation ->
+            window.addEventListener("message", { event ->
+                event as MessageEvent
+                if (event.origin != window.origin) return@addEventListener // security check
+
+                val params = event.data?.unsafeCast<JsString>().toString()
+                    .substring(1) // remove leading ?
+                    .split("&") // split params
+                    .mapNotNull { param ->
+                        // Map params key-value pairs
+                        val parts = param.split("=")
+                        if (parts.size == 2) {
+                            parts[0] to parts[1]
+                        } else {
+                            null
+                        }
+                    }.toMap()
+
+                continuation.resumeWith(Result.success(params))
+            })
+        }
+    }
+
+    /**
+     * Picks a redirect URI based on provided redirect URIs and some criteria.
+     *
+     * The current criteria requires the redirect URI to end with "callback" (last path segment),
+     * and to be of the same origin. If not all criteria are met for any of the provided
+     * [redirectUris], a fallback URI is used.
+     *
+     * @param redirectUris A list of redirect URIs to try to use.
+     * @return One of the [redirectUris] that match the criteria, or a fallback URI.
+     */
+    private fun pickRedirectUri(redirectUris: List<Url>): String = redirectUris.find {
+        // We have in the Main.kt for web a handler that looks up for the last path segment
+        // to send a postMessage, so that it can be captured here
+        it.rawSegments.last() == "callback" &&
+            // Add also current window's origin as filter
+            it.toString().contains(window.location.origin)
+    }?.toString() ?: run {
+        // fallback to default URI and log non-matching redirect URIs
+        logger.warn {
+            "No matching redirect URI found that is of the same origin and ends with callback"
+        }
+        getRedirectUri()
+    }
+}
diff --git a/solr/ui/src/wasmJsTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTestJs.kt b/solr/ui/src/wasmJsTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTestJs.kt
new file mode 100644
index 00000000000..326b6a37aa2
--- /dev/null
+++ b/solr/ui/src/wasmJsTest/kotlin/org/apache/solr/ui/components/auth/AuthUtilsTestJs.kt
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.ui.components.auth
+
+import kotlin.test.Ignore
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.browser.window
+
+class AuthUtilsTestJs {
+
+    @Test
+    fun `WHEN generate redirect URI with defaults THEN redirect URI appends callback to the end`() {
+        val redirectUri = getRedirectUri()
+
+        assertContains(
+            charSequence = redirectUri,
+            other = "callback",
+            message = """The path "callback" should be in the generated redirect URI""",
+        )
+
+        assertTrue(
+            actual = redirectUri.endsWith("/callback"),
+            message = """The generated redirect URI should end with "/callback".""",
+        )
+    }
+
+    @Test
+    fun `WHEN generate redirect URI with defaults THEN no double slash exists`() {
+        val redirectUri = getRedirectUri()
+
+        assertFalse(
+            actual = redirectUri.contains("//"),
+            message = """The generated URI "$redirectUri" should contain double slashes.""",
+        )
+    }
+
+    @Test
+    fun `WHEN generate redirect URI THEN redirect URI contains current origin`() {
+        val redirectUri = getRedirectUri()
+
+        assertContains(
+            charSequence = redirectUri,
+            other = window.location.origin,
+            message = "Origin should be part of the generated redirect URI",
+        )
+    }
+
+    /**
+     * Future scenario for allowing overriding base path.
+     */
+    @Test
+    @Ignore
+    fun `WHEN generate redirect URI with custom path THEN only custom path included`() {
+        // val redirectUri = getRedirectUri("/custom/path")
+
+        // assertContains(
+        //     charSequence = redirectUri,
+        //     other = "callback",
+        //     message = """The path "callback" should be in the generated redirect URI""",
+        // )
+    }
+}