Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdks/java/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -50,28 +57,31 @@
*/
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;
this.secureCookies = secureCookies;
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();
Expand Down Expand Up @@ -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();
Expand All @@ -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.
*
* <p>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<String, String> env) {
Optional<OAuthConfig> 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<String, OAuthProvider> 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();
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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<String, OAuthProvider> providers;

public OAuthFlow(
AuthStore authStore, OAuthConfig config, OAuthStateStore stateStore, Map<String, OAuthProvider> 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<String, OAuthProvider> 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<Map<String, Object>> providersListing() {
List<Map<String, Object>> out = new ArrayList<>();
for (OAuthProvider provider : providers.values()) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("slot", provider.slot());
entry.put("label", provider.label());
entry.put("type", provider.type());
out.add(entry);
}
return out;
}

// ---- flow --------------------------------------------------------------

/**
* Mint a state row and return the provider's authorize URL. {@code nextUrl}
* is sanitised against {@link UrlSafety#isSafeRedirect}, falling back to
* {@code "/"}.
*/
public String start(String slot, String nextUrl) {
OAuthProvider provider = requireProvider(slot);
String safeNext = nextUrl != null && UrlSafety.isSafeRedirect(nextUrl) ? nextUrl : "/";
OAuthState state = stateStore.create(slot, safeNext);
return provider.authorizationUrl(state, config.callbackUrl(slot));
}

/**
* Exchange {@code code} for an identity and create a session.
*
* @throws StateValidationError missing/expired/replayed state or a slot mismatch
* @throws IdentityFetchError any token / userinfo / claim failure
* @throws org.byteveda.taskito.dashboard.auth.oauth.error.AllowlistDenied the
* identity is outside a configured allowlist
* @throws ProviderNotConfigured the slot has no registered provider
*/
public CallbackResult handleCallback(String slot, String code, String stateToken, String error) {
if (error != null && !error.isEmpty()) {
throw new IdentityFetchError("provider returned error: " + error);
}
if (code == null || code.isEmpty() || stateToken == null || stateToken.isEmpty()) {
throw new StateValidationError("missing code or state parameter");
}
Optional<OAuthState> row = stateStore.consume(stateToken);
if (row.isEmpty()) {
throw new StateValidationError("state is invalid, expired, or already used");
}
OAuthState state = row.get();
// slot is the non-null callback route param; compare from it so a null
// slot on a malformed-but-parsed state row is rejected, not an NPE.
if (!slot.equals(state.slot())) {
throw new StateValidationError("state slot does not match callback slot");
}
Comment thread
pratyush618 marked this conversation as resolved.

OAuthProvider provider = requireProvider(slot);
ProviderIdentity identity =
provider.exchangeCode(code, state.codeVerifier(), config.callbackUrl(slot), state.nonce());
provider.checkAllowlist(identity);

User user = authStore.getOrCreateOauthUser(
identity.slot(),
identity.subject(),
identity.email(),
identity.name(),
identity.emailVerified(),
config.adminEmails());
Session session = authStore.createSession(user.username(), user.role());
stateStore.pruneExpiredIfDue();
return new CallbackResult(session, state.nextUrl());
}
Comment thread
pratyush618 marked this conversation as resolved.

private OAuthProvider requireProvider(String slot) {
OAuthProvider provider = providers.get(slot);
if (provider == null) {
throw new ProviderNotConfigured("OAuth provider '" + slot + "' is not configured");
}
return provider;
}
}
Loading