From 813f82f7857e82d64c139f4fb11d568b2f2e4b14 Mon Sep 17 00:00:00 2001
From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com>
Date: Tue, 7 Jul 2026 22:12:44 +0530
Subject: [PATCH 1/2] feat(java): dashboard OAuth/OIDC (Google, GitHub,
generic)
Session-mode OAuth login: env-config parser, KV state store (single-use), PKCE S256, provider layer (Google/generic OIDC via nimbus id_token validation, GitHub OAuth2), open-redirect + discovery-SSRF guards, and the providers/start/callback endpoints. Degrades to password-only when the config is invalid or the optional nimbus-jose-jwt jar is absent. Organized into layered oauth subpackages (error/model/config/provider + root).
---
sdks/java/build.gradle.kts | 6 +
.../taskito/dashboard/DashboardServer.java | 77 +++-
.../dashboard/auth/oauth/OAuthFlow.java | 135 +++++++
.../dashboard/auth/oauth/OAuthHandlers.java | 118 ++++++
.../dashboard/auth/oauth/OAuthStateStore.java | 132 +++++++
.../dashboard/auth/oauth/UrlSafety.java | 38 ++
.../auth/oauth/config/OAuthConfig.java | 361 ++++++++++++++++++
.../auth/oauth/config/package-info.java | 2 +
.../auth/oauth/error/AllowlistDenied.java | 10 +
.../auth/oauth/error/IdentityFetchError.java | 14 +
.../auth/oauth/error/OAuthConfigError.java | 10 +
.../auth/oauth/error/OAuthException.java | 14 +
.../oauth/error/ProviderNotConfigured.java | 10 +
.../oauth/error/StateValidationError.java | 10 +
.../auth/oauth/error/package-info.java | 2 +
.../auth/oauth/model/OAuthState.java | 15 +
.../auth/oauth/model/ProviderIdentity.java | 13 +
.../auth/oauth/model/package-info.java | 2 +
.../dashboard/auth/oauth/package-info.java | 26 ++
.../oauth/provider/AbstractOidcProvider.java | 204 ++++++++++
.../oauth/provider/GenericOidcProvider.java | 50 +++
.../auth/oauth/provider/GitHubProvider.java | 173 +++++++++
.../auth/oauth/provider/GoogleProvider.java | 66 ++++
.../auth/oauth/provider/IdTokenValidator.java | 145 +++++++
.../auth/oauth/provider/OAuthHttp.java | 71 ++++
.../auth/oauth/provider/OAuthProvider.java | 34 ++
.../dashboard/auth/oauth/provider/Pkce.java | 44 +++
.../auth/oauth/provider/Providers.java | 42 ++
.../auth/oauth/provider/package-info.java | 2 +
.../taskito/dashboard/support/Json.java | 23 ++
.../taskito/dashboard/DashboardOAuthTest.java | 82 ++++
.../dashboard/oauth/FakeOAuthProvider.java | 81 ++++
.../dashboard/oauth/OAuthConfigTest.java | 110 ++++++
.../dashboard/oauth/OAuthFlowTest.java | 95 +++++
.../dashboard/oauth/OAuthStateStoreTest.java | 79 ++++
.../dashboard/oauth/OidcRoundTripTest.java | 187 +++++++++
.../taskito/dashboard/oauth/PkceTest.java | 32 ++
.../dashboard/oauth/UrlSafetyTest.java | 33 ++
38 files changed, 2537 insertions(+), 11 deletions(-)
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthHandlers.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthStateStore.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/UrlSafety.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/OAuthConfig.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/config/package-info.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/AllowlistDenied.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/IdentityFetchError.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthConfigError.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/OAuthException.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/ProviderNotConfigured.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/StateValidationError.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/error/package-info.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/OAuthState.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/ProviderIdentity.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/model/package-info.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/package-info.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/AbstractOidcProvider.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GenericOidcProvider.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GitHubProvider.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/GoogleProvider.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/IdTokenValidator.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthHttp.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/OAuthProvider.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Pkce.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/Providers.java
create mode 100644 sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/provider/package-info.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardOAuthTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/FakeOAuthProvider.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthConfigTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthFlowTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OAuthStateStoreTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/OidcRoundTripTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/PkceTest.java
create mode 100644 sdks/java/src/test/java/org/byteveda/taskito/dashboard/oauth/UrlSafetyTest.java
diff --git a/sdks/java/build.gradle.kts b/sdks/java/build.gradle.kts
index 6d5d418f..aaf46026 100644
--- a/sdks/java/build.gradle.kts
+++ b/sdks/java/build.gradle.kts
@@ -99,6 +99,12 @@ dependencies {
compileOnly("io.sentry:sentry:7.14.0")
testImplementation("io.sentry:sentry:7.14.0")
+ // Optional: OIDC id_token validation for dashboard OAuth (Google / generic
+ // OIDC). Zero transitive deps. The dashboard degrades to password-only auth
+ // when it is absent, so consumers who enable OAuth add it themselves.
+ compileOnly("com.nimbusds:nimbus-jose-jwt:10.9.1")
+ testImplementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
+
// Run the @TaskHandler processor over the tests so the generated companions
// are exercised end-to-end. Consumers wire it the same way.
testAnnotationProcessor(project(":processor"))
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
index 74c221d9..1e9d3a33 100644
--- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java
@@ -5,13 +5,14 @@
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
+import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
-import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.Executors;
import org.byteveda.taskito.Taskito;
import org.byteveda.taskito.dashboard.api.CoreHandlers;
@@ -26,6 +27,12 @@
import org.byteveda.taskito.dashboard.auth.Policy;
import org.byteveda.taskito.dashboard.auth.RequestContext;
import org.byteveda.taskito.dashboard.auth.TokenAuth;
+import org.byteveda.taskito.dashboard.auth.oauth.OAuthFlow;
+import org.byteveda.taskito.dashboard.auth.oauth.OAuthHandlers;
+import org.byteveda.taskito.dashboard.auth.oauth.OAuthStateStore;
+import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig;
+import org.byteveda.taskito.dashboard.auth.oauth.error.OAuthConfigError;
+import org.byteveda.taskito.dashboard.auth.oauth.provider.OAuthProvider;
import org.byteveda.taskito.dashboard.routing.Req;
import org.byteveda.taskito.dashboard.routing.Router;
import org.byteveda.taskito.dashboard.store.OverridesStore;
@@ -50,21 +57,23 @@
*/
public final class DashboardServer implements AutoCloseable {
private static final TaskitoLogger LOG = TaskitoLogger.create("dashboard");
+ private static final String METRICS_TOKEN_ENV = "TASKITO_DASHBOARD_METRICS_TOKEN";
private final HttpServer server;
private final Taskito queue;
private final Path staticDir;
private final boolean secureCookies;
- private static final String METRICS_TOKEN_ENV = "TASKITO_DASHBOARD_METRICS_TOKEN";
private final AuthStore authStore;
private final TokenAuth tokenAuth;
private final AuthHandlers authHandlers;
+ private final OAuthHandlers oauthHandlers;
private final OpsHandlers ops;
private final String metricsToken;
private final Router router;
- private DashboardServer(HttpServer server, Taskito queue, Path staticDir, boolean secureCookies, String token) {
+ private DashboardServer(
+ HttpServer server, Taskito queue, Path staticDir, boolean secureCookies, String token, OAuthFlow oauth) {
this.server = server;
this.queue = queue;
this.staticDir = staticDir;
@@ -72,6 +81,7 @@ private DashboardServer(HttpServer server, Taskito queue, Path staticDir, boolea
this.authStore = new AuthStore(SettingsAccess.of(queue));
this.tokenAuth = token != null ? new TokenAuth(token) : null;
this.authHandlers = new AuthHandlers(authStore);
+ this.oauthHandlers = new OAuthHandlers(oauth, secureCookies);
this.ops = new OpsHandlers(queue);
this.metricsToken = System.getenv(METRICS_TOKEN_ENV);
this.router = buildRouter();
@@ -100,10 +110,24 @@ public static DashboardServer start(Taskito queue, int port, String token, Strin
*/
public static DashboardServer start(Taskito queue, int port, String token, String staticDir, boolean secureCookies)
throws IOException {
+ // OAuth is session-mode only; the legacy shared-token mode has no login UI.
+ OAuthFlow oauth = token == null ? buildOAuthFlow(queue, System.getenv()) : null;
+ return startInternal(queue, port, token, staticDir, secureCookies, oauth);
+ }
+
+ /** Test seam: start in session mode with an explicitly built OAuth flow. */
+ static DashboardServer startWithOAuth(Taskito queue, int port, boolean secureCookies, OAuthFlow oauth)
+ throws IOException {
+ return startInternal(queue, port, null, null, secureCookies, oauth);
+ }
+
+ private static DashboardServer startInternal(
+ Taskito queue, int port, String token, String staticDir, boolean secureCookies, OAuthFlow oauth)
+ throws IOException {
// Resolve assets before binding so a discovery failure can't leak a bound port.
Path dir = staticDir != null ? Paths.get(staticDir).normalize() : DashboardAssets.resolveOrNull();
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
- DashboardServer dashboard = new DashboardServer(server, queue, dir, secureCookies, token);
+ DashboardServer dashboard = new DashboardServer(server, queue, dir, secureCookies, token, oauth);
// Seed an env admin before serving so no request races the open setup endpoint.
if (token == null) {
dashboard.authStore.bootstrapAdminFromEnv();
@@ -114,6 +138,37 @@ public static DashboardServer start(Taskito queue, int port, String token, Strin
return dashboard;
}
+ /**
+ * Build the OAuth flow from env, or return {@code null} to run password-only.
+ *
+ *
Degrades gracefully: an invalid config logs a warning and disables OAuth
+ * rather than failing startup; a missing nimbus-jose-jwt jar (needed only by
+ * the OIDC providers) is caught as a {@link LinkageError} and likewise
+ * disables OAuth, leaving password login intact.
+ */
+ private static OAuthFlow buildOAuthFlow(Taskito queue, Map env) {
+ Optional configured;
+ try {
+ configured = OAuthConfig.fromEnv(env);
+ } catch (OAuthConfigError e) {
+ LOG.warn("Dashboard OAuth disabled: " + e.getMessage());
+ return null;
+ }
+ if (configured.isEmpty()) {
+ return null;
+ }
+ OAuthConfig config = configured.get();
+ try {
+ SettingsAccess settings = SettingsAccess.of(queue);
+ Map providers = OAuthFlow.buildProviders(config, HttpClient.newHttpClient());
+ return new OAuthFlow(new AuthStore(settings), config, new OAuthStateStore(settings), providers);
+ } catch (LinkageError e) {
+ // NoClassDefFoundError (a LinkageError) when nimbus-jose-jwt is absent.
+ LOG.warn("Dashboard OAuth disabled: OIDC login requires the nimbus-jose-jwt jar on the classpath");
+ return null;
+ }
+ }
+
public int port() {
return server.getAddress().getPort();
}
@@ -158,6 +213,11 @@ private void handleApi(HttpExchange exchange, String path) throws IOException {
handleTokenMode(exchange, path, method, query);
return;
}
+ // OAuth routes emit redirects (not JSON), so they bypass the router; they
+ // are public, so this runs before the auth gate.
+ if (oauthHandlers.serve(exchange, path, method, query)) {
+ return;
+ }
RequestContext ctx = RequestContext.build(exchange, authStore);
Policy.authorize(path, method, ctx, authStore);
if (!router.dispatch(exchange, method, path, query, ctx)) {
@@ -214,13 +274,8 @@ private Router buildRouter() {
r.post("/api/auth/logout", this::logout);
r.get("/api/auth/whoami", req -> authHandlers.whoami(req.ctx()));
r.post("/api/auth/change-password", req -> authHandlers.changePassword(req.ctx(), req.jsonBody()));
- r.get("/api/auth/providers", req -> Map.of("password_enabled", true, "providers", List.of()));
- r.get("/api/auth/oauth/start/(.+)", req -> {
- throw DashboardError.notFound("oauth_not_configured");
- });
- r.get("/api/auth/oauth/callback/(.+)", req -> {
- throw DashboardError.notFound("oauth_not_configured");
- });
+ // /api/auth/providers and /api/auth/oauth/* are served by OAuthHandlers
+ // (they emit JSON or 302 redirects) before the router runs.
// Read
r.get("/api/stats", req -> core.stats());
diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java
new file mode 100644
index 00000000..92a297ea
--- /dev/null
+++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/auth/oauth/OAuthFlow.java
@@ -0,0 +1,135 @@
+package org.byteveda.taskito.dashboard.auth.oauth;
+
+import java.net.http.HttpClient;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.byteveda.taskito.dashboard.auth.AuthStore;
+import org.byteveda.taskito.dashboard.auth.Session;
+import org.byteveda.taskito.dashboard.auth.User;
+import org.byteveda.taskito.dashboard.auth.oauth.config.OAuthConfig;
+import org.byteveda.taskito.dashboard.auth.oauth.error.IdentityFetchError;
+import org.byteveda.taskito.dashboard.auth.oauth.error.ProviderNotConfigured;
+import org.byteveda.taskito.dashboard.auth.oauth.error.StateValidationError;
+import org.byteveda.taskito.dashboard.auth.oauth.model.OAuthState;
+import org.byteveda.taskito.dashboard.auth.oauth.model.ProviderIdentity;
+import org.byteveda.taskito.dashboard.auth.oauth.provider.OAuthProvider;
+import org.byteveda.taskito.dashboard.auth.oauth.provider.Providers;
+
+/**
+ * The seam between the HTTP handler layer and the provider implementations. It
+ * owns the provider registry, the state store, and the {@link AuthStore}
+ * integration: handlers call {@link #start} to mint a redirect URL and
+ * {@link #handleCallback} to land a session.
+ */
+public final class OAuthFlow {
+ private final AuthStore authStore;
+ private final OAuthConfig config;
+ private final OAuthStateStore stateStore;
+ private final Map providers;
+
+ public OAuthFlow(
+ AuthStore authStore, OAuthConfig config, OAuthStateStore stateStore, Map providers) {
+ this.authStore = authStore;
+ this.config = config;
+ this.stateStore = stateStore;
+ this.providers = new LinkedHashMap<>(providers);
+ }
+
+ /** The landed session plus the sanitised post-login redirect target. */
+ public record CallbackResult(Session session, String nextUrl) {}
+
+ /** Instantiate one provider per configured slot, keyed by slot, in display order. */
+ public static Map buildProviders(OAuthConfig config, HttpClient http) {
+ return Providers.build(config, http);
+ }
+
+ // ---- introspection -----------------------------------------------------
+
+ public boolean passwordAuthEnabled() {
+ return config.passwordAuthEnabled();
+ }
+
+ public boolean hasProvider(String slot) {
+ return providers.containsKey(slot);
+ }
+
+ /** Compact provider summary for the login UI (no secrets), in display order. */
+ public List